"""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