Files
python_charts/scripts/bitcoin_m2_chart.py
T
thePR0M3TH3AN 02a89740bb Enhance Bitcoin vs M2 chart script with data cropping and shifting
Update `bitcoin_m2_chart.py` to plot Bitcoin price against global M2 money supply, rebased to 100 and shifted forward by 90 days. Limit data to start from July 1, 2024, and include error handling for missing GLOBAL_M2 data. Add dual y-axes and improve documentation for clarity. Include new output images for the updated chart.
2025-05-31 21:56:55 -04:00

409 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""bitcoin_m2_chart.py
----------------------
Plot Bitcoin price (log scale) versus global M2 money supply (rebased to 100),
with M2 shifted forward by 90 days so that both series share the same x-axis.
Show only data from July 1, 2024 onward. White background, two y-axes on the right,
monthly x-ticks, and friendly error message if GLOBAL_M2 is missing.
"""
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:
"""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 (log scale) and Global M2 (rebased to 100) on a shared x-axis,
with M2 shifted forward by `offset_days` (90 days). Show only data from
July 1, 2024 onward. White background, two y-axes on the right, monthly x-ticks.
"""
# ──────────────────────────────────────────────────────────────────────────
# 1) DROP ANY ROWS WITH NULL "value" BEFORE SHIFTING/REBASE:
# ──────────────────────────────────────────────────────────────────────────
btc_clean = btc.dropna(subset=["value"])
m2_clean = m2.dropna(subset=["value"])
# ──────────────────────────────────────────────────────────────────────────
# 2) SHIFT M2's INDEX FORWARD by offset_days (90)
# ──────────────────────────────────────────────────────────────────────────
m2_shifted = m2_clean.copy()
m2_shifted.index = m2_shifted.index + pd.Timedelta(days=offset_days)
# ──────────────────────────────────────────────────────────────────────────
# 3) CROP BOTH SERIES TO START ON JULY 1, 2024 (user request)
# ──────────────────────────────────────────────────────────────────────────
crop_start = pd.to_datetime("2024-07-01")
btc_cropped = btc_clean.loc[btc_clean.index >= crop_start]
m2_cropped = m2_shifted.loc[m2_shifted.index >= crop_start]
# ──────────────────────────────────────────────────────────────────────────
# 4) FIND OVERLAP WINDOW: plot only dates where BOTH series exist
# ──────────────────────────────────────────────────────────────────────────
overlap_start = max(btc_cropped.index.min(), m2_cropped.index.min())
overlap_end = min(btc_cropped.index.max(), m2_cropped.index.max())
if overlap_start >= overlap_end:
raise ValueError(
f"No overlapping range from {crop_start.date()} after shifting M2 by {offset_days} days."
)
btc_overlap = btc_cropped.loc[overlap_start:overlap_end]
m2_overlap = m2_cropped.loc[overlap_start:overlap_end]
# ──────────────────────────────────────────────────────────────────────────
# 5) REBASE M2_OVERLAP to base‐100 index
# ──────────────────────────────────────────────────────────────────────────
first_m2_date = m2_overlap["value"].dropna().index.min()
if first_m2_date is None:
m2_indexed = pd.Series([], dtype=float)
else:
first_m2_value = float(m2_overlap.loc[first_m2_date, "value"])
m2_indexed = (m2_overlap["value"] / first_m2_value) * 100
m2_indexed = m2_indexed.dropna()
# ──────────────────────────────────────────────────────────────────────────
# 6) SET UP FIGURE WITH WHITE BACKGROUND
# ──────────────────────────────────────────────────────────────────────────
fig, ax1 = plt.subplots(figsize=(width, height), dpi=dpi)
fig.patch.set_facecolor("white")
ax1.set_facecolor("white")
# ─── Plot Bitcoin on "ax1" (log scale, orange) ─────────────────────────────
ax1.plot(
btc_overlap.index,
btc_overlap["value"],
label="Bitcoin (USD, log-scale)",
color="#f7931a",
linewidth=2.0,
)
ax1.set_yscale("log")
ax1.set_ylabel("Bitcoin Price (USD, log)", color="#333333")
ax1.tick_params(axis="y", colors="#333333")
# Move BTC axis to the right side:
ax1.yaxis.set_label_position("right")
ax1.yaxis.tick_right()
# ─── Plot a horizontal dotted line at the last BTC close ────────────────────
last_btc_date = btc_overlap.index.max()
last_btc_value = float(btc_overlap.loc[last_btc_date, "value"])
ax1.axhline(
last_btc_value,
color="#f7931a",
linestyle=":",
linewidth=1.5,
alpha=0.9,
)
# ──────────────────────────────────────────────────────────────────────────
# 7) Plot M2 on twin axis "ax2" (linear scale, green) ───────────────────────
# ──────────────────────────────────────────────────────────────────────────
ax2 = ax1.twinx()
if not m2_indexed.empty:
ax2.plot(
m2_indexed.index,
m2_indexed.values,
label="Global M2 Index (base 100)",
color="#00aa00",
linewidth=2.0,
)
ax2.set_ylabel("Global M2 Index (base 100)", color="#333333")
ax2.tick_params(axis="y", colors="#333333")
# Move M2 axis even further right (so BTC ticks are inner-right, M2 outer-right):
ax2.spines["right"].set_position(("axes", 1.08))
# ──────────────────────────────────────────────────────────────────────────
# 8) SET Y-TICKS EXACTLY AS IN THE SCREENSHOT
# ──────────────────────────────────────────────────────────────────────────
# Bitcoin ticks (inner right):
btc_ticks = [41100, 45500, 50500, 56500, 62500, 70500, 78000, 86000, 98000, 113000]
ax1.set_yticks(btc_ticks)
ax1.set_yticklabels([f"{int(tick):,}" for tick in btc_ticks], color="#333333")
# Global M2 ticks (outer right):
m2_ticks = [99.10] + list(range(100, 114)) # [99.10, 100, 101, …, 113]
ax2.set_yticks(m2_ticks)
ax2.set_yticklabels(
[f"{tick:.2f}" if isinstance(tick, float) else f"{tick}" for tick in m2_ticks],
color="#333333",
)
# ──────────────────────────────────────────────────────────────────────────
# 9) DRAW HORIZONTAL GRID LINES (only on BTC axis)
# ──────────────────────────────────────────────────────────────────────────
ax1.grid(
axis="y",
linestyle="--",
linewidth=0.7,
color="#cccccc",
alpha=0.4,
)
# ──────────────────────────────────────────────────────────────────────────
# 10) SET X-LIMITS: from overlap_start → overlap_end + extend_years
# ──────────────────────────────────────────────────────────────────────────
x_end_extended = overlap_end + relativedelta(years=extend_years)
ax1.set_xlim(overlap_start, x_end_extended)
# ──────────────────────────────────────────────────────────────────────────
# 11) MONTHLY X-TICKS:
# Major = first of each month (“Jul, Aug, Sep …”),
# Minor = mid-month (no label).
# ──────────────────────────────────────────────────────────────────────────
ax1.xaxis.set_major_locator(mdates.MonthLocator(interval=1))
ax1.xaxis.set_major_formatter(mdates.DateFormatter("%b"))
ax1.xaxis.set_minor_locator(mdates.MonthLocator(bymonthday=15))
for label in ax1.get_xticklabels():
label.set_color("#333333")
label.set_rotation(0)
label.set_fontsize(10)
# ──────────────────────────────────────────────────────────────────────────
# 12) ANNOTATE “2025” UNDER THE JANUARY TICK
# ──────────────────────────────────────────────────────────────────────────
try:
jan2025 = pd.to_datetime("2025-01-01")
if jan2025 < overlap_start:
jan2025 = pd.to_datetime(f"{overlap_start.year + 1}-01-01")
xloc = mdates.date2num(jan2025)
ax1.annotate(
"2025",
(xloc, 0),
xycoords=("data", "axes fraction"),
xytext=(0, -20),
textcoords="offset points",
ha="center",
color="#333333",
fontsize=11,
)
except Exception:
pass
# ──────────────────────────────────────────────────────────────────────────
# 13) LEGEND (combine both axes) at top-left
# ──────────────────────────────────────────────────────────────────────────
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,
fontsize=11,
)
# ──────────────────────────────────────────────────────────────────────────
# 14) HEADER TEXT (symbol, price, M2 info) at top-left
# ──────────────────────────────────────────────────────────────────────────
header_y = 1.02
# “Bitcoin / U.S. Dollar · 1D · COINBASE” in gray:
ax1.text(
0.0,
header_y,
"Bitcoin / U.S. Dollar · 1D · COINBASE",
transform=ax1.transAxes,
color="#333333",
fontsize=12,
weight="bold",
va="bottom",
)
# BTC’s last close and daily change in orange:
prev_btc = float(btc_overlap["value"].iloc[-2])
change = last_btc_value - prev_btc
pct_change = (change / prev_btc) * 100
ax1.text(
0.0,
header_y - 0.035,
f"{last_btc_value:,.2f} {change:+.2f} ({pct_change:+.2f}%)",
transform=ax1.transAxes,
color="#f7931a",
fontsize=11,
va="bottom",
)
# M2 info (“Days Offset = 90” and last M2 index) in green:
if not m2_indexed.empty:
last_m2_val = m2_indexed.iloc[-1]
ax1.text(
0.0,
header_y - 0.07,
f"Global M2 Money Supply // Days Offset = {offset_days} {last_m2_val:.2f}",
transform=ax1.transAxes,
color="#00aa00",
fontsize=11,
va="bottom",
)
fig.tight_layout(rect=[0, 0, 1, 0.95])
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="2024-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=90, # shift M2 forward by 90 days (to match TradingView)
help="shift M2 by this many days (positive = M2 leads BTC)",
)
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)
# ──────────────────────────────────────────────────────────────────────────
# 15) FETCH SERIES
# ──────────────────────────────────────────────────────────────────────────
try:
btc, m2 = fetch_series(start_dt, end_dt, args.btc_series, args.m2_series, args.db)
except FileNotFoundError as fnf:
print(fnf, file=sys.stderr)
sys.exit(1)
# If either series is empty, print a friendly message and exit:
if btc.empty:
print(
f"No data found for {args.btc_series} between {args.start} and {args.end}.",
file=sys.stderr,
)
sys.exit(1)
if m2.empty:
print(
f"No data found for {args.m2_series} between {args.start} and {args.end}.",
file=sys.stderr,
)
print(
"Try running:\n"
" python -m scripts.refresh_data --series GLOBAL_M2 --start 2010-01-01",
file=sys.stderr,
)
sys.exit(1)
# ──────────────────────────────────────────────────────────────────────────
# 16) VALIDATE (non-empty, no nulls, overlapping)
# ──────────────────────────────────────────────────────────────────────────
try:
validate_series(btc, m2, args.btc_series, args.m2_series)
except ValueError as e:
print(f"Validation error: {e}", file=sys.stderr)
sys.exit(1)
# ──────────────────────────────────────────────────────────────────────────
# 17) PLOT
# ──────────────────────────────────────────────────────────────────────────
fig = plot_bitcoin_m2(
btc,
m2,
offset_days=args.offset,
extend_years=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()