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:
@@ -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
@@ -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()),),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user