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