mirror of
https://github.com/PR0M3TH3AN/python_charts.git
synced 2026-10-27 08:50:39 +00:00
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.
409 lines
20 KiB
Python
409 lines
20 KiB
Python
#!/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()
|