Merge pull request #8 from PR0M3TH3AN/codex/create-bitcoin-+-global-m2-chart-script

Add Bitcoin vs Global M2 chart
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-05-31 08:05:06 -04:00
commit b594c7aa21
3 files changed
+216

No files matched your search

+8
View File
@@ -119,6 +119,14 @@ The same invocation is available via Make:
```bash
make plot-custom ARGS="--series UNRATE DCOILWTICO --start 2000-01-01 --end 2020-12-31"
```
### Bitcoin vs. Global M2
After downloading the required series into `data/fred.db`, generate the Bitcoin overlay chart:
```bash
./startup.sh python scripts/bitcoin_m2_chart.py --btc-series CBBTCUSD --m2-series GLOBAL_M2
```
---
+151
View File
@@ -0,0 +1,151 @@
#!/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 matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from dateutil.relativedelta import relativedelta
import sqlite3
def fetch_series(
start: datetime,
end: datetime,
btc_series: str,
m2_series: str,
db_path: str | Path = Path("data/fred.db"),
) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""Load Bitcoin and M2 series from a local SQLite DB."""
db_path = Path(db_path)
if not db_path.exists():
raise FileNotFoundError(
f"{db_path} not found. Run scripts/refresh_data.py on a machine with internet, commit the DB, then retry."
)
with sqlite3.connect(db_path) as conn:
btc = pd.read_sql(
f"SELECT date, {btc_series} AS value FROM {btc_series}",
conn,
parse_dates=["date"],
index_col="date",
).loc[start:end]
m2 = pd.read_sql(
f"SELECT date, {m2_series} AS value FROM {m2_series}",
conn,
parse_dates=["date"],
index_col="date",
).loc[start:end]
return btc, m2
def plot_bitcoin_m2(
btc: pd.DataFrame,
m2: pd.DataFrame,
offset_days: int,
extend_years: int,
) -> 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=(12, 6))
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-days",
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="data/fred.db", help="path to local SQLite DB"
)
p.add_argument(
"--output", type=str, default=None, help="optional path to save the figure"
)
args = p.parse_args(argv)
start_dt = datetime.fromisoformat(args.start)
end_dt = datetime.fromisoformat(args.end)
btc, m2 = fetch_series(start_dt, end_dt, args.btc_series, args.m2_series, args.db)
fig = plot_bitcoin_m2(btc, m2, args.offset_days, args.extend_years)
if args.output:
fig.savefig(args.output)
if argv is None:
fig.show()
return fig
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
import matplotlib
matplotlib.use("Agg")
import os
import sys
import pandas as pd
sys.path.append(os.path.abspath("."))
from scripts.bitcoin_m2_chart import plot_bitcoin_m2, main as btc_m2_main
def sample_data():
dates = pd.date_range("2021-01-01", periods=5, freq="D")
btc = pd.DataFrame({"value": [10, 11, 12, 13, 14]}, index=dates)
m2 = pd.DataFrame({"value": [1, 2, 3, 4, 5]}, index=dates)
return btc, m2
def test_plot_bitcoin_m2_returns_figure():
btc, m2 = sample_data()
fig = plot_bitcoin_m2(btc, m2, 94, 1)
assert len(fig.axes) == 2
assert any(ax.get_lines() for ax in fig.axes)
def test_btc_m2_main(monkeypatch):
btc, m2 = sample_data()
def fake_fetch(*_args, **_kwargs):
return btc, m2
monkeypatch.setattr("scripts.bitcoin_m2_chart.fetch_series", fake_fetch)
fig = btc_m2_main(["--btc-series", "BTC", "--m2-series", "M2"])
assert len(fig.axes) == 2
def test_btc_m2_main_saves_output(tmp_path, monkeypatch):
btc, m2 = sample_data()
def fake_fetch(*_args, **_kwargs):
return btc, m2
monkeypatch.setattr("scripts.bitcoin_m2_chart.fetch_series", fake_fetch)
out_file = tmp_path / "out.png"
btc_m2_main(
[
"--btc-series",
"BTC",
"--m2-series",
"M2",
"--output",
str(out_file),
]
)
assert out_file.exists()