From 082bcfbbbc8c50b90b37c502a29c867dd2d6752a Mon Sep 17 00:00:00 2001 From: Sam Fredrickson Date: Thu, 24 Sep 2026 03:03:32 -0700 Subject: [PATCH] Round 2: reverting power law and cycle-on-powerlaw tests; one-time holdout run. Add TrendReversionVol: deviations from the power-law trend follow a daily AR(1), so uncertainty levels off, optionally plus trend-parameter uncertainty with an autocorrelation-adjusted effective sample size. Two experiments, run under the unchanged verdict rule: - powerlaw-ou: +21% to +45% vs powerlaw at 2-4 years, but slightly negative point estimates at 1 month make it inconclusive. - cycle-on-powerlaw: inconclusive (+18% at 2 years, negative elsewhere). The holdout (outcomes after 2024-11-26) was scored once, for the four candidates fixed beforehand. powerlaw is the best long-horizon forecast (+45% and +58% vs the random walk at 2 and 3 years); nothing beats the random walk inside a year; cycle fails badly. Results are in the README. --- README.md | 28 ++++++++++++++++++++-- btcmodel/experiments.py | 29 ++++++++++++++++++++++- btcmodel/models/volatility.py | 44 +++++++++++++++++++++++++++++++++-- tests/test_components.py | 21 ++++++++++++++++- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6ae433c..a400e98 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,32 @@ expect a few flukes either way. | tails | Student-t, or empirical horizon-level shape | Student-t worse; empirical +5% at 1 month only | | cycle-phase | align cycles by fraction elapsed, not days | inconclusive | -Next: a power law whose deviations revert with bounded variance, and a -registered test of whether the cycle's timing adds anything on top of it. +Round 2 tested the power law against two refinements (the verdict rule was +unchanged, and the holdout candidates were fixed before it ran): + +| Experiment | Idea | Verdict | +|---|---|---| +| powerlaw-ou | deviations from the trend revert, so uncertainty levels off; optionally plus trend-parameter uncertainty | inconclusive: +21% to +45% at 2-4 years (intervals above zero), but -0.3% and -1% at 1 month fail the "never negative" clause. Bands too narrow without parameter uncertainty, too wide with it | +| cycle-on-powerlaw | the cycle's timing, rescaled to the power-law level | inconclusive: +18% at 2 years, slightly negative at short horizons and 4 years | + +### Holdout (run once, 2026-09-24) + +Scored on outcomes after 2024-11-26 for the four candidates fixed in advance +(`random_walk`, `drift_rw`, `cycle`, `powerlaw`). The holdout is ~22 months +long, so at 2-4 years it is essentially one outcome period seen from several +origins (1.4-1.9 windows), and the bootstrap intervals there mean little. + +| Skill vs random walk | 1mo | 3mo | 6mo | 1y | 2y | 3y | 4y | +|---|---|---|---|---|---|---|---| +| drift_rw | -0% | +1% | +5% | +14% | +31% | +53% | -65% | +| cycle | -14% | -8% | -25% | -104% | -46% | -21% | -179% | +| powerlaw | -1% | -7% | -5% | +3% | +45% | +58% | +15% | + +Consistent with development: nothing beats the random walk inside a year, +`cycle` fails badly, and `powerlaw` is the best long-horizon forecast, unbiased +at 2-3 years (mean PIT 0.53-0.56) but with intervals too wide (its 80% interval +held every 2- and 3-year outcome). The holdout is now spent for these models; +a new model needs new data to be tested honestly. ## Usage diff --git a/btcmodel/experiments.py b/btcmodel/experiments.py index 71c8db9..2159672 100644 --- a/btcmodel/experiments.py +++ b/btcmodel/experiments.py @@ -24,7 +24,7 @@ from .models.drift import ( TrailingMeanDrift, ) from .models.shape import Empirical, StudentT -from .models.volatility import CycleVol, EwmaVol, ReversionVol, TrailingVol +from .models.volatility import CycleVol, EwmaVol, ReversionVol, TrailingVol, TrendReversionVol @dataclass(frozen=True) @@ -64,6 +64,7 @@ def verdict(variant_summary: pd.DataFrame) -> str: # Idea labels (D1, V2, ...) refer to docs/2024-ideas.md. CYCLE = MODELS["cycle"] DRIFT_RW = MODELS["drift_rw"] +POWERLAW = MODELS["powerlaw"] # Volatility and shape experiments use drift_rw as the control: the zero-drift # random walk is biased low at long horizons, so anything that merely widened # its intervals would look like an improvement. @@ -138,5 +139,31 @@ EXPERIMENTS: dict[str, Experiment] = { CYCLE, (Composite("cycle_fraction", CycleDrift(phase="fraction"), TrailingVol()),), ), + # Round 2. Before running these, the holdout candidates were fixed as: + # the three original models, powerlaw, and any variant below that is + # "better" than its control. + Experiment( + "powerlaw-ou", + "deviations from the power-law trend fade, so long-horizon uncertainty is bounded", + "follow-up to diminishing-returns: powerlaw_revert beat drift_rw, but its bands" + " were too wide", + POWERLAW, + ( + Composite("powerlaw_ou", PowerLawDrift(revert=True), TrendReversionVol()), + Composite( + "powerlaw_ou_param", + PowerLawDrift(revert=True), + TrendReversionVol(parameter_uncertainty=True), + ), + ), + ), + Experiment( + "cycle-on-powerlaw", + "with the level set by the power law, the cycle's timing adds information", + "D1 on D3. This pair was already compared informally on the same data after" + " round 1, so only the holdout can really settle it", + POWERLAW, + (Composite("cycle_on_powerlaw", PowerLawScaledCycleDrift(), TrailingVol()),), + ), ) } diff --git a/btcmodel/models/volatility.py b/btcmodel/models/volatility.py index 5d4f148..ee08853 100644 --- a/btcmodel/models/volatility.py +++ b/btcmodel/models/volatility.py @@ -6,8 +6,8 @@ import numpy as np import pandas as pd from ..data import log_returns -from ..halving import cycle_position -from .drift import mean_by_cycle_day +from ..halving import GENESIS, cycle_position +from .drift import PowerLawDrift, mean_by_cycle_day @dataclass(frozen=True) @@ -104,3 +104,43 @@ class CycleVol: future = history.index[-1] + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D") _, future_day = cycle_position(future) return base * np.sqrt(np.cumsum(ratio[future_day] ** 2)[horizons - 1]) + + +@dataclass(frozen=True) +class TrendReversionVol: + """ + Uncertainty for a price that reverts to the power-law trend. + + Deviations from the trend follow a daily AR(1) with coefficient φ (fitted + by PowerLawDrift), so their variance levels off: after h days it is + σ²(1 − φ^2h) / (1 − φ²), with σ the trailing `window`-day volatility. + + With `parameter_uncertainty`, the uncertainty of the fitted trend line is + added. The residuals are so autocorrelated that ~5000 days carry the + information of only n(1 − φ)/(1 + φ) independent points, and the + coefficient covariance is inflated to match. + """ + + window: int = 365 + parameter_uncertainty: bool = False + + def sd(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray: + intercept, slope, phi = PowerLawDrift().fit(history) + phi = min(phi, 0.9999) + sigma = log_returns(history).iloc[-self.window :].std() + variance = sigma**2 * (1 - phi ** (2 * horizons)) / (1 - phi**2) + if self.parameter_uncertainty: + variance = variance + self._trend_variance(history, horizons, intercept, slope, phi) + return np.sqrt(variance) + + @staticmethod + def _trend_variance(history, horizons, intercept, slope, phi) -> np.ndarray: + t = (history.index - GENESIS).days.to_numpy() + x = np.column_stack([np.ones(len(t)), np.log(t)]) + resid = np.log(history["close"].to_numpy()) - x @ [intercept, slope] + n_eff = len(t) * (1 - phi) / (1 + phi) + cov = resid.var() * np.linalg.inv(x.T @ x) * len(t) / n_eff + # The forecast mean is a(1 − φ^h) + b(ln t_h − φ^h ln t_0) + φ^h ln P_0. + decay = phi**horizons + g = np.column_stack([1 - decay, np.log(t[-1] + horizons) - decay * np.log(t[-1])]) + return np.einsum("hi,ij,hj->h", g, cov, g) diff --git a/tests/test_components.py b/tests/test_components.py index af439db..f7960d9 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -6,7 +6,7 @@ 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 btcmodel.models.volatility import ReversionVol, TrailingVol, TrendReversionVol from .test_models import synthetic_prices @@ -64,3 +64,22 @@ 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 + + +def test_trend_reversion_vol_levels_off(): + # A power-law trend plus AR(1) deviations with a ~70-day half-life. + rng = np.random.default_rng(0) + dates = pd.date_range("2011-01-01", "2024-11-26", freq="D", name="date") + t = (dates - GENESIS).days.to_numpy() + gap = np.zeros(len(t)) + for i in range(1, len(t)): + gap[i] = 0.99 * gap[i - 1] + 0.03 * rng.standard_normal() + prices = pd.DataFrame({"close": np.exp(-40 + 5.8 * np.log(t) + gap)}, index=dates) + + horizons = np.array([30, 365, 1460, 5000]) + plain = TrendReversionVol().sd(prices, horizons) + with_params = TrendReversionVol(parameter_uncertainty=True).sd(prices, horizons) + assert np.all(np.diff(plain) >= 0) + stationary = 0.03 / np.sqrt(1 - 0.99**2) + assert plain[-1] == pytest.approx(stationary, rel=0.15) + assert np.all(with_params >= plain)