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:
@@ -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
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from btcmodel import data
|
||||
from btcmodel.evaluate import backtest
|
||||
from btcmodel.halving import HALVINGS, cycle_position
|
||||
from btcmodel.models import MODELS, CycleModel
|
||||
from btcmodel.models import MODELS, CycleDrift
|
||||
|
||||
|
||||
def synthetic_prices(daily_return, start="2011-01-01", end="2024-11-26", noise=0.0, seed=0):
|
||||
@@ -31,7 +31,7 @@ def test_cycle_model_recovers_a_cycle_shaped_drift():
|
||||
return np.where(day < 700, 0.002, -0.001)
|
||||
|
||||
prices = synthetic_prices(shape)
|
||||
drift = CycleModel(prior_days=0).drift_by_cycle_day(prices)
|
||||
drift = CycleDrift(prior_days=0).by_cycle_day(prices)
|
||||
assert drift[300] == pytest.approx(0.002, abs=2e-4)
|
||||
assert drift[1100] == pytest.approx(-0.001, abs=2e-4)
|
||||
|
||||
@@ -42,8 +42,8 @@ def test_cycle_model_weights_recent_cycles_more():
|
||||
cycle, _ = cycle_position(prices.index)
|
||||
r = 0.004 / 2.0**cycle
|
||||
prices["close"] = 100 * np.exp(np.cumsum(r))
|
||||
drift = CycleModel(recency_half_life=0.25).drift_by_cycle_day(prices)
|
||||
equal = CycleModel(recency_half_life=1e9).drift_by_cycle_day(prices)
|
||||
drift = CycleDrift(recency_half_life=0.25).by_cycle_day(prices)
|
||||
equal = CycleDrift(recency_half_life=1e9).by_cycle_day(prices)
|
||||
latest_complete = 0.004 / 2.0**3
|
||||
assert abs(drift[900] - latest_complete) < abs(equal[900] - latest_complete)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user