Files
bitcoin-model/btcmodel/models/volatility.py
T

147 lines
5.9 KiB
Python
Raw Normal View History

"""Uncertainty components."""
from dataclasses import dataclass
import numpy as np
import pandas as pd
from ..data import log_returns
from ..halving import GENESIS, cycle_position
from .drift import PowerLawDrift, 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])
@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)