Replace the 2024 model (model.py, ~2000 lines) with the btcmodel package, the baseline for future work: - Forecasts are quantiles of log price at each horizon, scored with CRPS in a walk-forward backtest (origins every 30 days from 2014, horizons 1 month to 4 years). Skill is relative to a zero-drift random walk, with circular block-bootstrap intervals and a count of independent windows. - Development data stops at 2024-11-26, the last day the 2024 model saw. Later outcomes are a holdout, scored only by `backtest --holdout`. - Models: random_walk, drift_rw, and cycle (the 2024 model's cycle-position drift, now kernel-smoothed and recency-weighted). On development data nothing beats the random walk with confidence; cycle loses at every horizon. - Prices: the Investing.com archive moves to data/ (cut at 2024-11-26; its last row was intraday) and is extended with Coinbase daily closes by `update`. Also: Nix flake dev shell (Python 3.13, pandas 3), ruff in place of black, pytest suite, and a rewritten README. NOTES.md is removed as inaccurate, and poetry is dropped.
173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
"""Charts. One colour per model, fixed by its position in MODELS."""
|
|
|
|
from pathlib import Path
|
|
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
|
|
import matplotlib.pyplot as plt # noqa: E402
|
|
import numpy as np # noqa: E402
|
|
import pandas as pd # noqa: E402
|
|
from matplotlib.ticker import FuncFormatter, LogLocator, PercentFormatter # noqa: E402
|
|
|
|
from .evaluate import COVERAGES, horizon_label # noqa: E402
|
|
from .forecast import Forecast # noqa: E402
|
|
from .halving import CYCLE_STARTS # noqa: E402
|
|
from .models import BASELINE, MODELS # noqa: E402
|
|
|
|
SURFACE = "#fcfcfb"
|
|
INK = "#0b0b0b"
|
|
INK_SECONDARY = "#52514e"
|
|
MUTED = "#898781"
|
|
GRID = "#e1e0d9"
|
|
AXIS = "#c3c2b7"
|
|
SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"]
|
|
|
|
plt.rcParams.update(
|
|
{
|
|
"figure.facecolor": SURFACE,
|
|
"axes.facecolor": SURFACE,
|
|
"savefig.facecolor": SURFACE,
|
|
"axes.edgecolor": AXIS,
|
|
"axes.linewidth": 0.8,
|
|
"axes.labelcolor": INK_SECONDARY,
|
|
"axes.titlecolor": INK,
|
|
"axes.titlesize": 11,
|
|
"axes.titleweight": "semibold",
|
|
"axes.titlelocation": "left",
|
|
"axes.spines.top": False,
|
|
"axes.spines.right": False,
|
|
"axes.grid": True,
|
|
"grid.color": GRID,
|
|
"grid.linewidth": 0.8,
|
|
"xtick.color": MUTED,
|
|
"ytick.color": MUTED,
|
|
"xtick.labelcolor": INK_SECONDARY,
|
|
"ytick.labelcolor": INK_SECONDARY,
|
|
"legend.frameon": False,
|
|
"legend.labelcolor": INK_SECONDARY,
|
|
"lines.linewidth": 2,
|
|
"lines.solid_capstyle": "round",
|
|
"lines.solid_joinstyle": "round",
|
|
"font.family": "sans-serif",
|
|
"font.size": 9,
|
|
}
|
|
)
|
|
|
|
|
|
def model_color(name: str) -> str:
|
|
return SERIES[list(MODELS).index(name) % len(SERIES)]
|
|
|
|
|
|
def price_formatter(x, _=None) -> str:
|
|
for scale, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
|
|
if x >= scale:
|
|
return f"${x / scale:.3g}{suffix}"
|
|
return f"${x:.3g}"
|
|
|
|
|
|
def fan_chart(history: pd.DataFrame, forecasts: dict[str, Forecast], path: Path) -> None:
|
|
"""History plus 50/80/95% intervals, one panel per model on shared axes."""
|
|
fig, axes = plt.subplots(
|
|
len(forecasts), 1, figsize=(10, 3.2 * len(forecasts)), sharex=True, sharey=True
|
|
)
|
|
axes = np.atleast_1d(axes)
|
|
shown = history[history.index >= history.index[-1] - pd.Timedelta(days=6 * 365)]
|
|
for ax, (name, f) in zip(axes, forecasts.items(), strict=True):
|
|
color = model_color(name)
|
|
for c in sorted(COVERAGES, reverse=True):
|
|
lo, hi = f.interval(c)
|
|
ax.fill_between(f.dates, np.exp(lo), np.exp(hi), color=color, alpha=0.1, lw=0)
|
|
median = np.exp(f.quantile(0.5))
|
|
ax.plot(shown.index, shown["close"], color=INK, lw=1.2)
|
|
ax.plot(f.dates, median, color=color)
|
|
ax.annotate(
|
|
f"median {price_formatter(median[-1])}",
|
|
(f.dates[-1], median[-1]),
|
|
xytext=(6, 0),
|
|
textcoords="offset points",
|
|
va="center",
|
|
color=INK_SECONDARY,
|
|
)
|
|
for start in CYCLE_STARTS:
|
|
if shown.index[0] <= start <= f.dates[-1]:
|
|
ax.axvline(start, color=AXIS, lw=0.8, zorder=0)
|
|
ax.set_yscale("log")
|
|
ax.yaxis.set_major_locator(LogLocator(base=10, subs=(1, 2, 5)))
|
|
ax.yaxis.set_major_formatter(FuncFormatter(price_formatter))
|
|
ax.yaxis.set_minor_formatter(FuncFormatter(lambda *_: ""))
|
|
ax.set_title(f"{name}: forecast from {f.origin:%Y-%m-%d}")
|
|
axes[0].fill_between([], [], color=MUTED, alpha=0.3, label="50% interval")
|
|
axes[0].fill_between([], [], color=MUTED, alpha=0.2, label="80% interval")
|
|
axes[0].fill_between([], [], color=MUTED, alpha=0.1, label="95% interval")
|
|
axes[0].legend(loc="upper left")
|
|
axes[-1].set_xlabel("vertical lines: halvings (future ones projected)", color=MUTED)
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150)
|
|
plt.close(fig)
|
|
|
|
|
|
def skill_chart(summary: pd.DataFrame, path: Path, title: str) -> None:
|
|
"""CRPS skill vs the random walk, by horizon, with bootstrap intervals."""
|
|
horizons = sorted(summary["horizon"].unique())
|
|
x = np.arange(len(horizons))
|
|
fig, ax = plt.subplots(figsize=(8, 4.5))
|
|
ax.axhline(0, color=model_color(BASELINE), lw=2, label=BASELINE)
|
|
for name, g in summary[summary.model != BASELINE].groupby("model", sort=False):
|
|
g = g.set_index("horizon").reindex(horizons)
|
|
color = model_color(name)
|
|
ax.fill_between(x, g.skill_lo, g.skill_hi, color=color, alpha=0.1, lw=0)
|
|
ax.plot(x, g.skill, color=color, marker="o", ms=6, mec=SURFACE, mew=2, label=name)
|
|
ax.annotate(
|
|
name,
|
|
(x[-1], g.skill.iloc[-1]),
|
|
xytext=(8, 0),
|
|
textcoords="offset points",
|
|
va="center",
|
|
color=INK_SECONDARY,
|
|
)
|
|
ax.set_xticks(x, [horizon_label(h) for h in horizons])
|
|
ax.set_xlabel("forecast horizon")
|
|
ax.set_ylabel("CRPS skill vs random walk (higher is better)")
|
|
ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
|
|
ax.set_title(title)
|
|
ax.legend(loc="lower left")
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150)
|
|
plt.close(fig)
|
|
|
|
|
|
def calibration_chart(summary: pd.DataFrame, path: Path, title: str) -> None:
|
|
"""How often each nominal interval contained the outcome, by horizon."""
|
|
horizons = sorted(summary["horizon"].unique())
|
|
x = np.arange(len(horizons))
|
|
fig, axes = plt.subplots(1, len(COVERAGES), figsize=(12, 4), sharey=True)
|
|
for ax, c in zip(axes, COVERAGES, strict=True):
|
|
ax.axhline(c, color=INK_SECONDARY, lw=1)
|
|
ax.annotate(
|
|
"target", (x[-1], c), xytext=(0, 4), textcoords="offset points", ha="right", color=MUTED
|
|
)
|
|
for name, g in summary.groupby("model", sort=False):
|
|
g = g.set_index("horizon").reindex(horizons)
|
|
ax.plot(
|
|
x,
|
|
g[f"cov{c:.0%}"],
|
|
color=model_color(name),
|
|
marker="o",
|
|
ms=6,
|
|
mec=SURFACE,
|
|
mew=2,
|
|
label=name,
|
|
)
|
|
ax.set_xticks(x, [horizon_label(h) for h in horizons])
|
|
ax.set_title(f"{c:.0%} interval")
|
|
ax.set_ylim(0, 1.02)
|
|
ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
|
|
axes[0].set_ylabel("share of outcomes inside")
|
|
axes[-1].legend(loc="lower left")
|
|
fig.suptitle(title, x=0.01, ha="left", color=INK, fontsize=11, fontweight="semibold")
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150)
|
|
plt.close(fig)
|