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,106 @@
|
||||
"""Uncertainty components."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrailingVol:
|
||||
"""Standard deviation of daily log returns over the trailing `window` days, scaled by √h."""
|
||||
|
||||
window: int = 365
|
||||
|
||||
def sd(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
return log_returns(history).iloc[-self.window :].std() * np.sqrt(horizons)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EwmaVol:
|
||||
"""Weighted blend of exponentially weighted standard deviations, scaled by √h."""
|
||||
|
||||
spans: tuple[int, ...] = (30, 90, 180)
|
||||
weights: tuple[float, ...] = (0.2, 0.5, 0.3)
|
||||
|
||||
def sd(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
returns = log_returns(history)
|
||||
sigma = sum(
|
||||
w * returns.ewm(span=s).std().iloc[-1]
|
||||
for s, w in zip(self.spans, self.weights, strict=True)
|
||||
)
|
||||
return sigma * np.sqrt(horizons)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReversionVol:
|
||||
"""
|
||||
Volatility starts at its current (short EWMA) level and decays toward a
|
||||
long-run level with a `half_life_days` half-life.
|
||||
|
||||
`long_run="level"` uses the trailing `long_window` standard deviation;
|
||||
`long_run="trend"` extrapolates a log-linear trend in 90-day realised
|
||||
volatility over the same window, since volatility has fallen for years.
|
||||
"""
|
||||
|
||||
now_span: int = 30
|
||||
half_life_days: float = 90.0
|
||||
long_run: str = "level"
|
||||
long_window: int = 1460
|
||||
|
||||
def sd(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
returns = log_returns(history)
|
||||
now = returns.ewm(span=self.now_span).std().iloc[-1]
|
||||
days = np.arange(1, horizons.max() + 1)
|
||||
trailing = returns.iloc[-self.long_window :]
|
||||
if self.long_run == "level":
|
||||
long_run = np.full(len(days), trailing.std())
|
||||
elif self.long_run == "trend":
|
||||
# Non-overlapping 90-day windows, anchored at the origin.
|
||||
realised = trailing.rolling(90).std().iloc[::-90].dropna()
|
||||
age = (realised.index - history.index[-1]).days.to_numpy()
|
||||
slope, intercept = np.polyfit(age, np.log(realised.to_numpy()), 1)
|
||||
long_run = np.exp(intercept + slope * days)
|
||||
else:
|
||||
raise ValueError(f"unknown long_run {self.long_run!r}")
|
||||
phi = 0.5 ** (1 / self.half_life_days)
|
||||
daily_var = long_run**2 + (now**2 - long_run**2) * phi**days
|
||||
return np.sqrt(np.cumsum(daily_var)[horizons - 1])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CycleVol:
|
||||
"""
|
||||
Trailing volatility, modulated by how volatile each day of the halving
|
||||
cycle has been relative to the rest of the cycle.
|
||||
|
||||
The per-day ratio is estimated like CycleDrift's drift (kernel-smoothed,
|
||||
recency-weighted), then pulled `shrink` of the way back toward 1. The
|
||||
trailing estimate is first divided by the ratio it was measured under, so
|
||||
the cycle effect isn't counted twice.
|
||||
"""
|
||||
|
||||
window: int = 365
|
||||
bandwidth_days: float = 30.0
|
||||
recency_half_life: float = 1.0
|
||||
shrink: float = 0.5
|
||||
|
||||
def sd(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
returns = log_returns(history)
|
||||
cycle, day = cycle_position(returns.index)
|
||||
current_cycle = cycle_position(history.index[-1:])[0][0]
|
||||
weight = 0.5 ** ((current_cycle - cycle) / self.recency_half_life)
|
||||
squared = returns.to_numpy() ** 2
|
||||
variance = mean_by_cycle_day(squared, day, weight, self.bandwidth_days, prior_days=10.0)
|
||||
overall = (weight * squared).sum() / weight.sum()
|
||||
ratio = 1 + (1 - self.shrink) * (np.sqrt(variance / overall) - 1)
|
||||
|
||||
past_ratio = ratio[day[-self.window :]]
|
||||
base = returns.iloc[-self.window :].std() / np.sqrt(np.mean(past_ratio**2))
|
||||
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])
|
||||
Reference in New Issue
Block a user