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.
53 lines
1.8 KiB
Python
53 lines
1.8 KiB
Python
"""Distribution shapes: standardised quantiles (mean 0, sd 1) at each horizon."""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from scipy.stats import norm, t
|
|
|
|
from ..forecast import LEVELS
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Normal:
|
|
def standard_quantiles(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
|
return np.broadcast_to(norm.ppf(LEVELS), (len(horizons), len(LEVELS)))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StudentT:
|
|
"""Student's t with `df` degrees of freedom, rescaled to unit variance."""
|
|
|
|
df: float = 4.0
|
|
|
|
def standard_quantiles(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
|
q = t.ppf(LEVELS, self.df) / np.sqrt(self.df / (self.df - 2))
|
|
return np.broadcast_to(q, (len(horizons), len(LEVELS)))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Empirical:
|
|
"""
|
|
Filtered historical simulation at the horizon level: the shape of past
|
|
h-day log returns, each divided by the trailing volatility at its start.
|
|
|
|
Overlapping h-day returns are far from independent, so a horizon falls back
|
|
to normal unless the history spans at least `min_windows` of them.
|
|
"""
|
|
|
|
vol_window: int = 365
|
|
min_windows: int = 3
|
|
|
|
def standard_quantiles(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
|
log_price = np.log(history["close"])
|
|
sigma = log_price.diff().rolling(self.vol_window).std()
|
|
out = np.empty((len(horizons), len(LEVELS)))
|
|
for i, h in enumerate(horizons):
|
|
z = ((log_price.shift(-h) - log_price) / (sigma * np.sqrt(h))).dropna()
|
|
if len(z) < self.min_windows * h:
|
|
out[i] = norm.ppf(LEVELS)
|
|
else:
|
|
out[i] = np.quantile((z - z.mean()) / z.std(), LEVELS)
|
|
return out
|