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:
@@ -5,11 +5,26 @@ A model is any object with a `name` and a
|
||||
`forecast(history: pd.DataFrame, horizons: np.ndarray) -> Forecast` method.
|
||||
`history` holds every row up to and including the forecast origin and nothing
|
||||
after it; the harness guarantees that, so models can use all of it freely.
|
||||
Most models are a Composite of a drift and a volatility component.
|
||||
"""
|
||||
|
||||
from .baselines import DriftRandomWalk, RandomWalk
|
||||
from .cycle import CycleModel
|
||||
from .base import Composite
|
||||
from .drift import CycleDrift, PowerLawDrift, TrailingMeanDrift, ZeroDrift
|
||||
from .volatility import TrailingVol
|
||||
|
||||
BASELINE = "random_walk"
|
||||
|
||||
# Order is fixed: it sets each model's colour in every chart.
|
||||
MODELS = {m.name: m for m in (RandomWalk(), DriftRandomWalk(), CycleModel())}
|
||||
BASELINE = RandomWalk.name
|
||||
MODELS = {
|
||||
m.name: m
|
||||
for m in (
|
||||
# "It stays about here, give or take."
|
||||
Composite(BASELINE, ZeroDrift(), TrailingVol()),
|
||||
# "It keeps doing what it did last cycle."
|
||||
Composite("drift_rw", TrailingMeanDrift(), TrailingVol()),
|
||||
# The 2024 model, distilled.
|
||||
Composite("cycle", CycleDrift(), TrailingVol()),
|
||||
# "Growth keeps slowing, like it always has." Passed the diminishing-returns A/B.
|
||||
Composite("powerlaw", PowerLawDrift(), TrailingVol()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Models built from parts.
|
||||
|
||||
Most ideas about Bitcoin prices say something about either the expected return
|
||||
(drift) or the size of the uncertainty (volatility). A Composite pairs one of
|
||||
each, so an A/B test can swap exactly one part and hold the other fixed.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ..forecast import Forecast
|
||||
from .shape import Normal
|
||||
|
||||
|
||||
class Drift(Protocol):
|
||||
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
"""Expected cumulative log return from the origin to each horizon."""
|
||||
...
|
||||
|
||||
|
||||
class Volatility(Protocol):
|
||||
def sd(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
"""Standard deviation of the cumulative log return at each horizon."""
|
||||
...
|
||||
|
||||
|
||||
class Shape(Protocol):
|
||||
def standard_quantiles(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
"""Quantiles at LEVELS of a mean-0, sd-1 distribution, shape (H, N_LEVELS)."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Composite:
|
||||
"""Log price: mean from `drift`, spread from `volatility`, normal unless `shape` says."""
|
||||
|
||||
name: str
|
||||
drift: Drift
|
||||
volatility: Volatility
|
||||
shape: Shape = Normal()
|
||||
|
||||
def forecast(self, history: pd.DataFrame, horizons: np.ndarray) -> Forecast:
|
||||
mean = np.log(history["close"].iloc[-1]) + self.drift.expected_log_return(history, horizons)
|
||||
sd = self.volatility.sd(history, horizons)
|
||||
z = self.shape.standard_quantiles(history, horizons)
|
||||
return Forecast(history.index[-1], horizons, mean[:, None] + sd[:, None] * z)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Reference forecasts every other model has to beat."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ..data import log_returns
|
||||
from ..forecast import Forecast
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RandomWalk:
|
||||
"""
|
||||
Zero-drift random walk in log price: "it stays about here, give or take".
|
||||
|
||||
Volatility is the trailing standard deviation of daily log returns.
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = "random_walk"
|
||||
vol_window: int = 365
|
||||
|
||||
def forecast(self, history: pd.DataFrame, horizons: np.ndarray) -> Forecast:
|
||||
sigma = log_returns(history).iloc[-self.vol_window :].std()
|
||||
mean = np.log(history["close"].iloc[-1])
|
||||
return Forecast.normal(history.index[-1], horizons, mean, sigma * np.sqrt(horizons))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DriftRandomWalk:
|
||||
"""
|
||||
Random walk whose drift is the mean daily log return over the trailing
|
||||
`drift_window` days (one halving cycle by default): "it keeps doing what it
|
||||
did last cycle".
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = "drift_rw"
|
||||
drift_window: int = 1460
|
||||
vol_window: int = 365
|
||||
|
||||
def forecast(self, history: pd.DataFrame, horizons: np.ndarray) -> Forecast:
|
||||
returns = log_returns(history)
|
||||
mu = returns.iloc[-self.drift_window :].mean()
|
||||
sigma = returns.iloc[-self.vol_window :].std()
|
||||
mean = np.log(history["close"].iloc[-1]) + mu * horizons
|
||||
return Forecast.normal(history.index[-1], horizons, mean, sigma * np.sqrt(horizons))
|
||||
@@ -1,73 +0,0 @@
|
||||
"""The 2024 model, distilled."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ..data import log_returns
|
||||
from ..forecast import Forecast
|
||||
from ..halving import cycle_position
|
||||
|
||||
# Longer than any cycle so far (the longest, cycle 0, is 1425 days).
|
||||
MAX_CYCLE_DAYS = 1500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CycleModel:
|
||||
"""
|
||||
Expected return depends on how many days it has been since the last halving.
|
||||
|
||||
The drift for day d of the cycle is a weighted mean of the daily log returns
|
||||
observed around day d of every past cycle. Of the 2024 model's ~2000 lines,
|
||||
this idea did all the work. It differs from that model in two ways:
|
||||
|
||||
- Neighbouring cycle days are pooled with a Gaussian kernel. The 2024 model
|
||||
averaged each day separately and then took a rolling mean.
|
||||
- Past cycles are down-weighted, halving each `recency_half_life` cycles, so
|
||||
the 10-100x cycles of 2011-2017 don't set the level. The 2024 model
|
||||
averaged all cycles equally, then scaled by ~0.7; it overshot the
|
||||
2025 peak by ~60%. (This is the idea on the old `tuning-b` branch.)
|
||||
|
||||
Where the data is thin, the drift shrinks toward the overall weighted mean,
|
||||
as if `prior_days` extra observations sat at that value. Noise is a
|
||||
constant-volatility random walk, so the distribution is closed-form.
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = "cycle"
|
||||
bandwidth_days: float = 30.0
|
||||
recency_half_life: float = 1.0
|
||||
prior_days: float = 10.0
|
||||
vol_window: int = 365
|
||||
|
||||
def drift_by_cycle_day(self, history: pd.DataFrame) -> np.ndarray:
|
||||
"""Expected daily log return for each day of the cycle, shape (MAX_CYCLE_DAYS,)."""
|
||||
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)
|
||||
|
||||
sum_wr = np.bincount(day, weights=weight * returns.values, minlength=MAX_CYCLE_DAYS)
|
||||
sum_w = np.bincount(day, weights=weight, minlength=MAX_CYCLE_DAYS)
|
||||
|
||||
# Peak-1 kernel, so smoothed weights count (recency-weighted) days of data.
|
||||
half_width = int(np.ceil(4 * self.bandwidth_days))
|
||||
offsets = np.arange(-half_width, half_width + 1)
|
||||
kernel = np.exp(-0.5 * (offsets / self.bandwidth_days) ** 2)
|
||||
smooth_wr = np.convolve(sum_wr, kernel, mode="same")
|
||||
smooth_w = np.convolve(sum_w, kernel, mode="same")
|
||||
|
||||
overall = sum_wr.sum() / sum_w.sum()
|
||||
return (smooth_wr + self.prior_days * overall) / (smooth_w + self.prior_days)
|
||||
|
||||
def forecast(self, history: pd.DataFrame, horizons: np.ndarray) -> Forecast:
|
||||
drift = self.drift_by_cycle_day(history)
|
||||
origin = history.index[-1]
|
||||
future = origin + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D")
|
||||
_, future_day = cycle_position(future)
|
||||
cumulative = np.cumsum(drift[future_day])
|
||||
|
||||
sigma = log_returns(history).iloc[-self.vol_window :].std()
|
||||
mean = np.log(history["close"].iloc[-1]) + cumulative[horizons - 1]
|
||||
return Forecast.normal(origin, horizons, mean, sigma * np.sqrt(horizons))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Expected-return components."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ..data import log_returns
|
||||
from ..halving import GENESIS, cycle_fraction, cycle_position
|
||||
|
||||
# Longer than any cycle so far (the longest, cycle 0, is 1425 days).
|
||||
MAX_CYCLE_DAYS = 1500
|
||||
# Length every cycle is stretched to when phase="fraction".
|
||||
NOMINAL_CYCLE_DAYS = 1440
|
||||
|
||||
|
||||
def mean_by_cycle_day(values, day, weight, bandwidth_days, prior_days) -> np.ndarray:
|
||||
"""
|
||||
Weighted mean of `values` around each day of the cycle, shape (MAX_CYCLE_DAYS,).
|
||||
|
||||
Neighbouring days are pooled with a Gaussian kernel. Where the data is thin,
|
||||
the mean shrinks toward the overall weighted mean, as if `prior_days` extra
|
||||
observations sat at that value.
|
||||
"""
|
||||
sum_wv = np.bincount(day, weights=weight * values, minlength=MAX_CYCLE_DAYS)
|
||||
sum_w = np.bincount(day, weights=weight, minlength=MAX_CYCLE_DAYS)
|
||||
|
||||
# Peak-1 kernel, so smoothed weights count (weighted) days of data.
|
||||
half_width = int(np.ceil(4 * bandwidth_days))
|
||||
offsets = np.arange(-half_width, half_width + 1)
|
||||
kernel = np.exp(-0.5 * (offsets / bandwidth_days) ** 2)
|
||||
smooth_wv = np.convolve(sum_wv, kernel, mode="same")
|
||||
smooth_w = np.convolve(sum_w, kernel, mode="same")
|
||||
|
||||
overall = sum_wv.sum() / sum_w.sum()
|
||||
return (smooth_wv + prior_days * overall) / (smooth_w + prior_days)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ZeroDrift:
|
||||
"""No expected change in log price."""
|
||||
|
||||
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
return np.zeros(len(horizons))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrailingMeanDrift:
|
||||
"""Mean daily log return over the trailing `window` days, extrapolated."""
|
||||
|
||||
window: int = 1460
|
||||
|
||||
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
return log_returns(history).iloc[-self.window :].mean() * horizons
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CycleDrift:
|
||||
"""
|
||||
Expected return depends on how many days it has been since the last halving.
|
||||
|
||||
The drift for day d of the cycle is a weighted mean of the daily log returns
|
||||
observed around day d of every past cycle. Of the 2024 model's ~2000 lines,
|
||||
this idea did all the work. It differs from that model in two ways:
|
||||
|
||||
- Neighbouring cycle days are pooled with a Gaussian kernel. The 2024 model
|
||||
averaged each day separately and then took a rolling mean.
|
||||
- Past cycles are down-weighted, halving each `recency_half_life` cycles, so
|
||||
the 10-100x cycles of 2011-2017 don't set the level. The 2024 model
|
||||
averaged all cycles equally, then scaled by ~0.7; it overshot the
|
||||
2025 peak by ~60%. (This is the idea on the old `tuning-b` branch.)
|
||||
|
||||
Where the data is thin, the drift shrinks toward the overall mean (see
|
||||
`mean_by_cycle_day`).
|
||||
|
||||
`phase="days"` aligns cycles by days since the halving; `phase="fraction"`
|
||||
stretches every cycle to NOMINAL_CYCLE_DAYS, as the 2024 model did.
|
||||
"""
|
||||
|
||||
bandwidth_days: float = 30.0
|
||||
recency_half_life: float = 1.0
|
||||
prior_days: float = 10.0
|
||||
phase: str = "days"
|
||||
|
||||
def position(self, dates) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""(cycle index, cycle day) under this model's phase convention."""
|
||||
if self.phase == "days":
|
||||
return cycle_position(dates)
|
||||
if self.phase == "fraction":
|
||||
index, fraction = cycle_fraction(dates)
|
||||
return index, np.round(fraction * NOMINAL_CYCLE_DAYS).astype(int)
|
||||
raise ValueError(f"unknown phase {self.phase!r}")
|
||||
|
||||
def by_cycle_day(self, history: pd.DataFrame) -> np.ndarray:
|
||||
"""Expected daily log return for each day of the cycle, shape (MAX_CYCLE_DAYS,)."""
|
||||
returns = log_returns(history)
|
||||
cycle, day = self.position(returns.index)
|
||||
current_cycle = self.position(history.index[-1:])[0][0]
|
||||
return mean_by_cycle_day(
|
||||
returns.to_numpy(),
|
||||
day,
|
||||
weight=0.5 ** ((current_cycle - cycle) / self.recency_half_life),
|
||||
bandwidth_days=self.bandwidth_days,
|
||||
prior_days=self.prior_days,
|
||||
)
|
||||
|
||||
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
drift = self.by_cycle_day(history)
|
||||
future = history.index[-1] + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D")
|
||||
_, future_day = self.position(future)
|
||||
return np.cumsum(drift[future_day])[horizons - 1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShrunkDrift:
|
||||
"""Another drift scaled by `factor`: 0 is no drift, 1 is the original."""
|
||||
|
||||
inner: CycleDrift | TrailingMeanDrift
|
||||
factor: float
|
||||
|
||||
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
return self.factor * self.inner.expected_log_return(history, horizons)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PowerLawDrift:
|
||||
"""
|
||||
Log price grows linearly in log time since genesis: ln P = a + b ln t.
|
||||
|
||||
Growth therefore slows like b/t, which is the diminishing returns the
|
||||
cycle-by-cycle numbers show. The line is fitted by least squares to the
|
||||
history. With `revert=True` the gap between price and line also closes,
|
||||
at the rate of an AR(1) fitted to the daily residuals.
|
||||
"""
|
||||
|
||||
revert: bool = False
|
||||
|
||||
def fit(self, history: pd.DataFrame) -> tuple[float, float, float]:
|
||||
"""Return (intercept, slope, daily AR(1) coefficient of the residuals)."""
|
||||
log_t = np.log((history.index - GENESIS).days.to_numpy())
|
||||
log_p = np.log(history["close"].to_numpy())
|
||||
slope, intercept = np.polyfit(log_t, log_p, 1)
|
||||
resid = log_p - (intercept + slope * log_t)
|
||||
phi = resid[1:] @ resid[:-1] / (resid[:-1] @ resid[:-1])
|
||||
return intercept, slope, phi
|
||||
|
||||
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
intercept, slope, phi = self.fit(history)
|
||||
t0 = (history.index[-1] - GENESIS).days
|
||||
trend = slope * np.log((t0 + horizons) / t0)
|
||||
if not self.revert:
|
||||
return trend
|
||||
gap = np.log(history["close"].iloc[-1]) - (intercept + slope * np.log(t0))
|
||||
return trend + (phi**horizons - 1) * gap
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PowerLawScaledCycleDrift:
|
||||
"""
|
||||
The cycle shape, rescaled so its average over a cycle equals the power-law
|
||||
trend's growth rate over the next cycle: keep the timing, fix the level.
|
||||
"""
|
||||
|
||||
cycle: CycleDrift = CycleDrift()
|
||||
|
||||
def expected_log_return(self, history: pd.DataFrame, horizons: np.ndarray) -> np.ndarray:
|
||||
shape = self.cycle.by_cycle_day(history)
|
||||
cycle_mean = shape[:NOMINAL_CYCLE_DAYS].mean()
|
||||
level = PowerLawDrift().expected_log_return(history, np.array([NOMINAL_CYCLE_DAYS]))[0]
|
||||
level /= NOMINAL_CYCLE_DAYS
|
||||
if cycle_mean <= 1e-6:
|
||||
return level * horizons
|
||||
future = history.index[-1] + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D")
|
||||
_, future_day = self.cycle.position(future)
|
||||
return np.cumsum(shape[future_day] * level / cycle_mean)[horizons - 1]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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
|
||||
@@ -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