mirror of
https://github.com/PR0M3TH3AN/python_charts.git
synced 2026-10-27 17:00:41 +00:00
189 lines
5.7 KiB
Python
189 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
||
"""bitcoin_m2_chart.py
|
||
----------------------
|
||
Plot Bitcoin price versus global M2 money supply stored in `data/fred.db`.
|
||
|
||
The script reads two FRED series from the local SQLite database and
|
||
plots them on dual axes. The M2 series can be shifted relative to the
|
||
Bitcoin series by a configurable number of days.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Tuple
|
||
import sys
|
||
|
||
from scripts.constants import DB_PATH_DEFAULT
|
||
|
||
import matplotlib.dates as mdates
|
||
import matplotlib.pyplot as plt
|
||
import pandas as pd
|
||
from dateutil.relativedelta import relativedelta
|
||
|
||
# IMPORTANT: use the package‐qualified import
|
||
from scripts.common import (
|
||
fetch_series_db,
|
||
save_figure,
|
||
validate_dataframe,
|
||
validate_overlap,
|
||
)
|
||
|
||
|
||
def fetch_series(
|
||
start: datetime,
|
||
end: datetime,
|
||
btc_series: str,
|
||
m2_series: str,
|
||
db_path: str | Path = DB_PATH_DEFAULT,
|
||
) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
||
"""Load Bitcoin and M2 series from a local SQLite DB."""
|
||
df = fetch_series_db([btc_series, m2_series], start, end, db_path)
|
||
btc = df[[btc_series]].rename(columns={btc_series: "value"})
|
||
m2 = df[[m2_series]].rename(columns={m2_series: "value"})
|
||
return btc, m2
|
||
|
||
|
||
def validate_series(
|
||
btc: pd.DataFrame, m2: pd.DataFrame, btc_name: str, m2_name: str
|
||
) -> None:
|
||
"""Run basic sanity checks on the two series."""
|
||
validate_dataframe(btc, btc_name)
|
||
validate_dataframe(m2, m2_name)
|
||
validate_overlap(btc, m2)
|
||
|
||
|
||
def plot_bitcoin_m2(
|
||
btc: pd.DataFrame,
|
||
m2: pd.DataFrame,
|
||
offset_days: int,
|
||
extend_years: int,
|
||
*,
|
||
width: float = 12,
|
||
height: float = 6,
|
||
dpi: int | None = None,
|
||
) -> plt.Figure:
|
||
"""Plot Bitcoin price and shifted M2 on dual axes."""
|
||
# Determine overlapping range
|
||
common_start = max(btc.index.min(), m2.index.min())
|
||
common_end = min(btc.index.max(), m2.index.max())
|
||
|
||
btc_common = btc.loc[common_start:common_end]
|
||
m2_common = m2.loc[common_start:common_end]
|
||
|
||
m2_shifted = m2_common.shift(offset_days)
|
||
|
||
fig, ax1 = plt.subplots(figsize=(width, height), dpi=dpi)
|
||
ax1.plot(
|
||
btc_common.index, btc_common["value"], label="Bitcoin (LHS)", color="#1f77b4"
|
||
)
|
||
ax1.set_ylabel("Price (USD)")
|
||
ax1.grid(axis="y", linestyle="--", alpha=0.3)
|
||
|
||
ax2 = ax1.twinx()
|
||
ax2.plot(
|
||
m2_shifted.index, m2_shifted["value"], label="Global M2 (RHS)", color="#ff7f0e"
|
||
)
|
||
ax2.set_ylabel("Global M2")
|
||
|
||
ax1.set_xlim(common_start, common_end + relativedelta(years=extend_years))
|
||
ax1.xaxis.set_major_locator(mdates.YearLocator(base=5))
|
||
ax1.xaxis.set_minor_locator(mdates.YearLocator())
|
||
ax1.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
|
||
|
||
lines1, labels1 = ax1.get_legend_handles_labels()
|
||
lines2, labels2 = ax2.get_legend_handles_labels()
|
||
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left", frameon=False)
|
||
|
||
fig.tight_layout()
|
||
return fig
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> plt.Figure:
|
||
p = argparse.ArgumentParser(description="Plot Bitcoin vs Global M2")
|
||
p.add_argument(
|
||
"--btc-series",
|
||
type=str,
|
||
default="CBBTCUSD",
|
||
help="FRED series name for Bitcoin price",
|
||
)
|
||
p.add_argument(
|
||
"--m2-series",
|
||
type=str,
|
||
default="GLOBAL_M2",
|
||
help="FRED series name for global M2",
|
||
)
|
||
p.add_argument(
|
||
"--start", type=str, default="2010-01-01", help="start date YYYY-MM-DD"
|
||
)
|
||
p.add_argument(
|
||
"--end",
|
||
type=str,
|
||
default=datetime.today().strftime("%Y-%m-%d"),
|
||
help="end date YYYY-MM-DD",
|
||
)
|
||
p.add_argument(
|
||
"--offset",
|
||
type=int,
|
||
default=94,
|
||
help="shift M2 by this many days (positive = M2 leads)",
|
||
)
|
||
p.add_argument(
|
||
"--extend-years", type=int, default=1, help="years beyond end date to show"
|
||
)
|
||
p.add_argument(
|
||
"--db",
|
||
type=str,
|
||
default=str(DB_PATH_DEFAULT),
|
||
help="path to local SQLite DB",
|
||
)
|
||
p.add_argument(
|
||
"--output", type=str, default=None, help="optional path to save the figure"
|
||
)
|
||
p.add_argument("--width", type=float, default=12.0, help="figure width in inches")
|
||
p.add_argument("--height", type=float, default=6.0, help="figure height in inches")
|
||
p.add_argument("--dpi", type=int, default=None, help="figure DPI")
|
||
p.add_argument("--no-show", action="store_true", help="do not display the figure")
|
||
args = p.parse_args(argv)
|
||
|
||
try:
|
||
start_dt = datetime.fromisoformat(args.start)
|
||
except ValueError:
|
||
print(f"Invalid --start date: {args.start}", file=sys.stderr)
|
||
raise SystemExit(1)
|
||
|
||
try:
|
||
end_dt = datetime.fromisoformat(args.end)
|
||
except ValueError:
|
||
print(f"Invalid --end date: {args.end}", file=sys.stderr)
|
||
raise SystemExit(1)
|
||
|
||
btc, m2 = fetch_series(start_dt, end_dt, args.btc_series, args.m2_series, args.db)
|
||
|
||
# ─── Drop any rows where the FRED tables had NULLs in "value" ──────────────────
|
||
btc = btc.dropna(subset=["value"])
|
||
m2 = m2.dropna(subset=["value"])
|
||
# ────────────────────────────────────────────────────────────────────────────────
|
||
|
||
validate_series(btc, m2, args.btc_series, args.m2_series)
|
||
fig = plot_bitcoin_m2(
|
||
btc,
|
||
m2,
|
||
args.offset,
|
||
args.extend_years,
|
||
width=args.width,
|
||
height=args.height,
|
||
dpi=args.dpi,
|
||
)
|
||
|
||
save_figure(fig, args.output, __file__)
|
||
if argv is None and not args.no_show:
|
||
fig.show()
|
||
return fig
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|