Files
sam b0243adf61 Composable models and A/B tests of the 2024 ideas; add powerlaw.
Models are now a Composite of drift, volatility and (optional) shape
components, so an experiment can swap one part against a fixed control.

btcmodel/experiments.py holds seven experiments built from the ideas in the
old branches (catalogued in docs/2024-ideas.md), each with its hypothesis
and source, and a verdict rule fixed before anything ran. `just ab` runs
them on development data. Results:

- Shrinking the cycle drift, and a power-law trend (plain or reverting),
  beat their controls. The power law beats the random walk by 53-63% at
  3-4 years with unbiased outcomes, so it is promoted to MODELS.
- Every alternative volatility estimate (EWMA blends, other windows,
  reversion to a level or trend) is worse than the trailing 365-day window.
  Cycle-dependent volatility, heavy tails and stretched cycle phase show no
  reliable effect.
2026-09-24 03:01:46 -07:00

176 lines
6.9 KiB
Python

"""Expected-return components."""
from dataclasses import dataclass
import numpy as np
import pandas as pd
from ..data import log_returns
from ..halving import GENESIS, cycle_fraction, cycle_position
# Longer than any cycle so far (the longest, cycle 0, is 1425 days).
MAX_CYCLE_DAYS = 1500
# Length every cycle is stretched to when phase="fraction".
NOMINAL_CYCLE_DAYS = 1440
def mean_by_cycle_day(values, day, weight, bandwidth_days, prior_days) -> np.ndarray:
"""
Weighted mean of `values` around each day of the cycle, shape (MAX_CYCLE_DAYS,).
Neighbouring days are pooled with a Gaussian kernel. Where the data is thin,
the mean shrinks toward the overall weighted mean, as if `prior_days` extra
observations sat at that value.
"""
sum_wv = np.bincount(day, weights=weight * values, minlength=MAX_CYCLE_DAYS)
sum_w = np.bincount(day, weights=weight, minlength=MAX_CYCLE_DAYS)
# Peak-1 kernel, so smoothed weights count (weighted) days of data.
half_width = int(np.ceil(4 * bandwidth_days))
offsets = np.arange(-half_width, half_width + 1)
kernel = np.exp(-0.5 * (offsets / bandwidth_days) ** 2)
smooth_wv = np.convolve(sum_wv, kernel, mode="same")
smooth_w = np.convolve(sum_w, kernel, mode="same")
overall = sum_wv.sum() / sum_w.sum()
return (smooth_wv + prior_days * overall) / (smooth_w + prior_days)
@dataclass(frozen=True)
class ZeroDrift:
"""No expected change in log price."""
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
return np.zeros(len(horizons))
@dataclass(frozen=True)
class TrailingMeanDrift:
"""Mean daily log return over the trailing `window` days, extrapolated."""
window: int = 1460
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
return log_returns(history).iloc[-self.window :].mean() * horizons
@dataclass(frozen=True)
class CycleDrift:
"""
Expected return depends on how many days it has been since the last halving.
The drift for day d of the cycle is a weighted mean of the daily log returns
observed around day d of every past cycle. Of the 2024 model's ~2000 lines,
this idea did all the work. It differs from that model in two ways:
- Neighbouring cycle days are pooled with a Gaussian kernel. The 2024 model
averaged each day separately and then took a rolling mean.
- Past cycles are down-weighted, halving each `recency_half_life` cycles, so
the 10-100x cycles of 2011-2017 don't set the level. The 2024 model
averaged all cycles equally, then scaled by ~0.7; it overshot the
2025 peak by ~60%. (This is the idea on the old `tuning-b` branch.)
Where the data is thin, the drift shrinks toward the overall mean (see
`mean_by_cycle_day`).
`phase="days"` aligns cycles by days since the halving; `phase="fraction"`
stretches every cycle to NOMINAL_CYCLE_DAYS, as the 2024 model did.
"""
bandwidth_days: float = 30.0
recency_half_life: float = 1.0
prior_days: float = 10.0
phase: str = "days"
def position(self, dates) -> tuple[np.ndarray, np.ndarray]:
"""(cycle index, cycle day) under this model's phase convention."""
if self.phase == "days":
return cycle_position(dates)
if self.phase == "fraction":
index, fraction = cycle_fraction(dates)
return index, np.round(fraction * NOMINAL_CYCLE_DAYS).astype(int)
raise ValueError(f"unknown phase {self.phase!r}")
def by_cycle_day(self, history: pd.DataFrame) -> np.ndarray:
"""Expected daily log return for each day of the cycle, shape (MAX_CYCLE_DAYS,)."""
returns = log_returns(history)
cycle, day = self.position(returns.index)
current_cycle = self.position(history.index[-1:])[0][0]
return mean_by_cycle_day(
returns.to_numpy(),
day,
weight=0.5 ** ((current_cycle - cycle) / self.recency_half_life),
bandwidth_days=self.bandwidth_days,
prior_days=self.prior_days,
)
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
drift = self.by_cycle_day(history)
future = history.index[-1] + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D")
_, future_day = self.position(future)
return np.cumsum(drift[future_day])[horizons - 1]
@dataclass(frozen=True)
class ShrunkDrift:
"""Another drift scaled by `factor`: 0 is no drift, 1 is the original."""
inner: CycleDrift | TrailingMeanDrift
factor: float
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
return self.factor * self.inner.expected_log_return(history, horizons)
@dataclass(frozen=True)
class PowerLawDrift:
"""
Log price grows linearly in log time since genesis: ln P = a + b ln t.
Growth therefore slows like b/t, which is the diminishing returns the
cycle-by-cycle numbers show. The line is fitted by least squares to the
history. With `revert=True` the gap between price and line also closes,
at the rate of an AR(1) fitted to the daily residuals.
"""
revert: bool = False
def fit(self, history: pd.DataFrame) -> tuple[float, float, float]:
"""Return (intercept, slope, daily AR(1) coefficient of the residuals)."""
log_t = np.log((history.index - GENESIS).days.to_numpy())
log_p = np.log(history["close"].to_numpy())
slope, intercept = np.polyfit(log_t, log_p, 1)
resid = log_p - (intercept + slope * log_t)
phi = resid[1:] @ resid[:-1] / (resid[:-1] @ resid[:-1])
return intercept, slope, phi
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
intercept, slope, phi = self.fit(history)
t0 = (history.index[-1] - GENESIS).days
trend = slope * np.log((t0 + horizons) / t0)
if not self.revert:
return trend
gap = np.log(history["close"].iloc[-1]) - (intercept + slope * np.log(t0))
return trend + (phi**horizons - 1) * gap
@dataclass(frozen=True)
class PowerLawScaledCycleDrift:
"""
The cycle shape, rescaled so its average over a cycle equals the power-law
trend's growth rate over the next cycle: keep the timing, fix the level.
"""
cycle: CycleDrift = CycleDrift()
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
shape = self.cycle.by_cycle_day(history)
cycle_mean = shape[:NOMINAL_CYCLE_DAYS].mean()
level = PowerLawDrift().expected_log_return(history, np.array([NOMINAL_CYCLE_DAYS]))[0]
level /= NOMINAL_CYCLE_DAYS
if cycle_mean <= 1e-6:
return level * horizons
future = history.index[-1] + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D")
_, future_day = self.cycle.position(future)
return np.cumsum(shape[future_day] * level / cycle_mean)[horizons - 1]