2026-09-24 02:30:38 -07:00
|
|
|
|
"""Uncertainty components."""
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
from ..data import log_returns
|
2026-09-24 03:03:32 -07:00
|
|
|
|
from ..halving import GENESIS, cycle_position
|
|
|
|
|
|
from .drift import PowerLawDrift, mean_by_cycle_day
|
2026-09-24 02:30:38 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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])
|
2026-09-24 03:03:32 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|