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
+66
View File
@@ -0,0 +1,66 @@
import numpy as np
import pandas as pd
import pytest
from btcmodel.experiments import EXPERIMENTS
from btcmodel.halving import GENESIS
from btcmodel.models.drift import CycleDrift, PowerLawDrift, ShrunkDrift
from btcmodel.models.shape import StudentT
from btcmodel.models.volatility import ReversionVol, TrailingVol
from .test_models import synthetic_prices
ALL_VARIANTS = {m.name: m for e in EXPERIMENTS.values() for m in e.models}
@pytest.mark.parametrize("name", list(ALL_VARIANTS))
def test_experiment_models_produce_valid_forecasts(name):
prices = synthetic_prices(lambda day: 0.001 + 0 * day, noise=0.03)
horizons = np.array([1, 30, 365, 1460])
f = ALL_VARIANTS[name].forecast(prices, horizons)
assert np.isfinite(f.log_quantiles).all()
assert np.all(np.diff(f.log_quantiles, axis=1) >= 0)
lo, hi = f.interval(0.8)
assert np.all(np.diff(hi - lo) > 0)
def test_shrunk_drift_scales_linearly():
prices = synthetic_prices(lambda day: np.where(day < 700, 0.002, -0.001))
horizons = np.array([100, 1000])
full = CycleDrift().expected_log_return(prices, horizons)
np.testing.assert_allclose(
ShrunkDrift(CycleDrift(), 0.5).expected_log_return(prices, horizons), full / 2
)
np.testing.assert_allclose(
ShrunkDrift(CycleDrift(), 0.0).expected_log_return(prices, horizons), 0
)
def test_power_law_recovers_its_exponent():
dates = pd.date_range("2011-01-01", "2024-11-26", freq="D", name="date")
t = (dates - GENESIS).days.to_numpy()
prices = pd.DataFrame({"close": 1e-17 * t**5.8}, index=dates)
_, slope, _ = PowerLawDrift().fit(prices)
assert slope == pytest.approx(5.8, rel=1e-6)
expected = 5.8 * np.log((t[-1] + 365) / t[-1])
assert PowerLawDrift().expected_log_return(prices, np.array([365]))[0] == pytest.approx(
expected
)
def test_reversion_vol_matches_trailing_when_already_at_long_run():
rng = np.random.default_rng(0)
dates = pd.date_range("2011-01-01", "2024-11-26", freq="D", name="date")
prices = pd.DataFrame(
{"close": 100 * np.exp(np.cumsum(0.03 * rng.standard_normal(len(dates))))}, index=dates
)
horizons = np.array([30, 365, 1460])
reverting = ReversionVol(now_span=1460, long_window=len(dates)).sd(prices, horizons)
flat = TrailingVol(window=len(dates)).sd(prices, horizons)
np.testing.assert_allclose(reverting, flat, rtol=0.05)
def test_student_t_shape_has_unit_variance_and_fatter_tails():
q = StudentT(4).standard_quantiles(None, np.array([1]))[0]
assert q[-1] > 2.576 # beyond the normal 99.5% quantile
assert np.interp(0.8413, np.linspace(0.005, 0.995, 100), q) < 1.0 # thinner shoulders