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.
This commit is contained in:
sam
2026-09-24 03:06:21 -07:00
parent b0243adf61
commit 082bcfbbbc
4 changed files with 116 additions and 6 deletions
+26 -2
View File
@@ -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 | | 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 | | cycle-phase | align cycles by fraction elapsed, not days | inconclusive |
Next: a power law whose deviations revert with bounded variance, and a Round 2 tested the power law against two refinements (the verdict rule was
registered test of whether the cycle's timing adds anything on top of it. 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 ## Usage
+28 -1
View File
@@ -24,7 +24,7 @@ from .models.drift import (
TrailingMeanDrift, TrailingMeanDrift,
) )
from .models.shape import Empirical, StudentT 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) @dataclass(frozen=True)
@@ -64,6 +64,7 @@ def verdict(variant_summary: pd.DataFrame) -> str:
# Idea labels (D1, V2, ...) refer to docs/2024-ideas.md. # Idea labels (D1, V2, ...) refer to docs/2024-ideas.md.
CYCLE = MODELS["cycle"] CYCLE = MODELS["cycle"]
DRIFT_RW = MODELS["drift_rw"] DRIFT_RW = MODELS["drift_rw"]
POWERLAW = MODELS["powerlaw"]
# Volatility and shape experiments use drift_rw as the control: the zero-drift # 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 # random walk is biased low at long horizons, so anything that merely widened
# its intervals would look like an improvement. # its intervals would look like an improvement.
@@ -138,5 +139,31 @@ EXPERIMENTS: dict[str, Experiment] = {
CYCLE, CYCLE,
(Composite("cycle_fraction", CycleDrift(phase="fraction"), TrailingVol()),), (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()),),
),
) )
} }
+42 -2
View File
@@ -6,8 +6,8 @@ import numpy as np
import pandas as pd import pandas as pd
from ..data import log_returns from ..data import log_returns
from ..halving import cycle_position from ..halving import GENESIS, cycle_position
from .drift import mean_by_cycle_day from .drift import PowerLawDrift, mean_by_cycle_day
@dataclass(frozen=True) @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 = history.index[-1] + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D")
_, future_day = cycle_position(future) _, future_day = cycle_position(future)
return base * np.sqrt(np.cumsum(ratio[future_day] ** 2)[horizons - 1]) 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)
+20 -1
View File
@@ -6,7 +6,7 @@ from btcmodel.experiments import EXPERIMENTS
from btcmodel.halving import GENESIS from btcmodel.halving import GENESIS
from btcmodel.models.drift import CycleDrift, PowerLawDrift, ShrunkDrift from btcmodel.models.drift import CycleDrift, PowerLawDrift, ShrunkDrift
from btcmodel.models.shape import StudentT 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 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] q = StudentT(4).standard_quantiles(None, np.array([1]))[0]
assert q[-1] > 2.576 # beyond the normal 99.5% quantile 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 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)