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.
This commit is contained in:
sam
2026-09-24 03:01:46 -07:00
parent cfc27a38de
commit b0243adf61
18 changed files with 795 additions and 155 deletions
+52
View File
@@ -0,0 +1,52 @@
"""Distribution shapes: standardised quantiles (mean 0, sd 1) at each horizon."""
from dataclasses import dataclass
import numpy as np
import pandas as pd
from scipy.stats import norm, t
from ..forecast import LEVELS
@dataclass(frozen=True)
class Normal:
def standard_quantiles(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
return np.broadcast_to(norm.ppf(LEVELS), (len(horizons), len(LEVELS)))
@dataclass(frozen=True)
class StudentT:
"""Student's t with `df` degrees of freedom, rescaled to unit variance."""
df: float = 4.0
def standard_quantiles(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
q = t.ppf(LEVELS, self.df) / np.sqrt(self.df / (self.df - 2))
return np.broadcast_to(q, (len(horizons), len(LEVELS)))
@dataclass(frozen=True)
class Empirical:
"""
Filtered historical simulation at the horizon level: the shape of past
h-day log returns, each divided by the trailing volatility at its start.
Overlapping h-day returns are far from independent, so a horizon falls back
to normal unless the history spans at least `min_windows` of them.
"""
vol_window: int = 365
min_windows: int = 3
def standard_quantiles(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
log_price = np.log(history["close"])
sigma = log_price.diff().rolling(self.vol_window).std()
out = np.empty((len(horizons), len(LEVELS)))
for i, h in enumerate(horizons):
z = ((log_price.shift(-h) - log_price) / (sigma * np.sqrt(h))).dropna()
if len(z) < self.min_windows * h:
out[i] = norm.ppf(LEVELS)
else:
out[i] = np.quantile((z - z.mean()) / z.std(), LEVELS)
return out