forecastscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ """Stationarity and unit-root analysis: real, verified tests, not toy reimplementations.
2
+
3
+ The forecasting question "is this series stationary, and if not how many differences make it so?" is
4
+ answered here by the standard unit-root / stationarity tests, each delegated to its authoritative
5
+ implementation (statsmodels or the ``arch`` package, per the verified library survey), wrapped in one
6
+ stable, NaN-safe API that returns a typed result carrying the statistic, the p-value / critical values,
7
+ a plain verdict, and the primary reference so the foundation travels with the code.
8
+
9
+ Tests and where they live (verified 2026-07-04):
10
+ * ADF - Augmented Dickey-Fuller. H0: unit root (non-stationary). Dickey & Fuller 1979,
11
+ DOI 10.1080/01621459.1979.10482531. ``statsmodels.tsa.stattools.adfuller``.
12
+ * KPSS - H0: (level/trend) stationary -- the OPPOSITE null to ADF. Kwiatkowski, Phillips, Schmidt &
13
+ Shin 1992, DOI 10.1016/0304-4076(92)90104-Y. ``statsmodels.tsa.stattools.kpss``.
14
+ * PP - Phillips-Perron, nonparametric HAC correction. H0: unit root. Phillips & Perron 1988,
15
+ DOI 10.1093/biomet/75.2.335. ``arch.unitroot.PhillipsPerron`` (statsmodels has no PP).
16
+ * DF-GLS- GLS-detrended, locally most powerful DF. Elliott, Rothenberg & Stock 1996,
17
+ DOI 10.2307/2171846. ``arch.unitroot.DFGLS`` (statsmodels has no DF-GLS).
18
+ * Zivot-Andrews - unit-root test allowing ONE endogenous structural break. Zivot & Andrews 1992,
19
+ DOI 10.1080/07350015.1992.10509904. ``statsmodels.tsa.stattools.zivot_andrews``.
20
+
21
+ Differencing-order selection follows FPP3 (Hyndman & Athanasopoulos, sec. 9.1): ``ndiffs`` is the
22
+ KPSS-sequential rule (difference while KPSS rejects stationarity), and ``nsdiffs`` uses the seasonal
23
+ strength measure Fs from an STL decomposition (difference seasonally when Fs >= 0.64). This is the
24
+ FPP3-correct procedure; it deliberately does NOT use the ADF+KPSS four-quadrant table (that table is
25
+ valid general econometrics and is exposed as an interpretation in ``combined_verdict``, but it is not
26
+ what FPP3 prescribes). No pmdarima dependency.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import warnings
31
+ from dataclasses import dataclass, field
32
+
33
+ import numpy as np
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TestResult:
38
+ """One unit-root / stationarity test outcome.
39
+
40
+ ``null`` states the null hypothesis so a p-value is never read backwards (ADF and KPSS have opposite
41
+ nulls). ``stationary`` is the test's verdict at ``alpha`` already translated to the common language
42
+ "is the series stationary?", so callers do not have to remember each test's polarity.
43
+ """
44
+
45
+ name: str
46
+ stat: float
47
+ pvalue: float | None
48
+ crit: dict[str, float]
49
+ null: str
50
+ stationary: bool | None
51
+ alpha: float
52
+ reference: str
53
+ extra: dict[str, float] = field(default_factory=dict)
54
+
55
+
56
+ def _clean(x) -> np.ndarray:
57
+ """Coerce to a 1-D float array and drop non-finite points (tests require finite input)."""
58
+ a = np.asarray(x, dtype=float).ravel()
59
+ return a[np.isfinite(a)]
60
+
61
+
62
+ def adf(x, regression: str = "c", alpha: float = 0.05) -> TestResult:
63
+ """Augmented Dickey-Fuller. Rejecting H0 (small p) => evidence the series IS stationary."""
64
+ from statsmodels.tsa.stattools import adfuller
65
+
66
+ a = _clean(x)
67
+ stat, pvalue, _usedlag, _nobs, crit, _icbest = adfuller(a, regression=regression, autolag="AIC")
68
+ return TestResult(
69
+ name="ADF", stat=float(stat), pvalue=float(pvalue),
70
+ crit={k: float(v) for k, v in crit.items()},
71
+ null="unit root (non-stationary)", stationary=bool(pvalue < alpha), alpha=alpha,
72
+ reference="Dickey & Fuller 1979, DOI 10.1080/01621459.1979.10482531",
73
+ )
74
+
75
+
76
+ def kpss(x, regression: str = "c", alpha: float = 0.05) -> TestResult:
77
+ """KPSS. Its null is STATIONARITY, so rejecting H0 (small p) => evidence the series is NON-stationary."""
78
+ from statsmodels.tsa.stattools import kpss as _kpss
79
+
80
+ a = _clean(x)
81
+ with warnings.catch_warnings():
82
+ # statsmodels warns when the p-value is clipped at the table edge; that is expected, not an error.
83
+ warnings.simplefilter("ignore")
84
+ stat, pvalue, _lags, crit = _kpss(a, regression=regression, nlags="auto")
85
+ return TestResult(
86
+ name="KPSS", stat=float(stat), pvalue=float(pvalue),
87
+ crit={k: float(v) for k, v in crit.items()},
88
+ null="stationary", stationary=bool(pvalue >= alpha), alpha=alpha,
89
+ reference="Kwiatkowski, Phillips, Schmidt & Shin 1992, DOI 10.1016/0304-4076(92)90104-Y",
90
+ )
91
+
92
+
93
+ def phillips_perron(x, trend: str = "c", alpha: float = 0.05) -> TestResult:
94
+ """Phillips-Perron (arch). Nonparametric HAC correction; H0: unit root. Small p => stationary."""
95
+ from arch.unitroot import PhillipsPerron
96
+
97
+ a = _clean(x)
98
+ t = PhillipsPerron(a, trend=trend)
99
+ return TestResult(
100
+ name="PhillipsPerron", stat=float(t.stat), pvalue=float(t.pvalue),
101
+ crit={k: float(v) for k, v in t.critical_values.items()},
102
+ null="unit root (non-stationary)", stationary=bool(t.pvalue < alpha), alpha=alpha,
103
+ reference="Phillips & Perron 1988, DOI 10.1093/biomet/75.2.335",
104
+ )
105
+
106
+
107
+ def dfgls(x, trend: str = "c", alpha: float = 0.05) -> TestResult:
108
+ """DF-GLS (arch). GLS-detrended, locally most powerful DF; H0: unit root. Small p => stationary."""
109
+ from arch.unitroot import DFGLS
110
+
111
+ a = _clean(x)
112
+ t = DFGLS(a, trend=trend)
113
+ return TestResult(
114
+ name="DFGLS", stat=float(t.stat), pvalue=float(t.pvalue),
115
+ crit={k: float(v) for k, v in t.critical_values.items()},
116
+ null="unit root (non-stationary)", stationary=bool(t.pvalue < alpha), alpha=alpha,
117
+ reference="Elliott, Rothenberg & Stock 1996, DOI 10.2307/2171846",
118
+ )
119
+
120
+
121
+ def zivot_andrews(x, regression: str = "c", alpha: float = 0.05) -> TestResult:
122
+ """Zivot-Andrews: unit-root test allowing ONE endogenous structural break (reports the break index)."""
123
+ from statsmodels.tsa.stattools import zivot_andrews as _za
124
+
125
+ a = _clean(x)
126
+ with warnings.catch_warnings():
127
+ warnings.simplefilter("ignore")
128
+ stat, pvalue, crit, _baselag, bpidx = _za(a, regression=regression)
129
+ return TestResult(
130
+ name="ZivotAndrews", stat=float(stat), pvalue=float(pvalue),
131
+ crit={k: float(v) for k, v in crit.items()},
132
+ null="unit root with no break", stationary=bool(pvalue < alpha), alpha=alpha,
133
+ reference="Zivot & Andrews 1992, DOI 10.1080/07350015.1992.10509904",
134
+ extra={"break_index": float(bpidx)},
135
+ )
136
+
137
+
138
+ def ndiffs(x, alpha: float = 0.05, max_d: int = 2) -> int:
139
+ """Number of first differences to make the series stationary, via the FPP3 KPSS-sequential rule.
140
+
141
+ Apply KPSS; while it rejects stationarity (p < alpha) and we are under ``max_d``, difference once
142
+ more and re-test. This is Hyndman & Athanasopoulos FPP3 sec. 9.1 (``unitroot_ndiffs``), not the
143
+ ADF+KPSS four-quadrant heuristic.
144
+ """
145
+ a = _clean(x)
146
+ d = 0
147
+ while d < max_d:
148
+ if len(a) < 8 or np.allclose(a, a[0]):
149
+ break
150
+ try:
151
+ res = kpss(a, regression="c", alpha=alpha)
152
+ except (ValueError, np.linalg.LinAlgError):
153
+ break
154
+ if res.stationary: # KPSS did not reject stationarity -> stop
155
+ break
156
+ a = np.diff(a)
157
+ d += 1
158
+ return d
159
+
160
+
161
+ def seasonal_strength(x, period: int) -> float:
162
+ """FPP3 seasonal strength Fs = max(0, 1 - Var(remainder)/Var(seasonal+remainder)) from an STL fit.
163
+
164
+ Wang, Smith & Hyndman 2006 (DOI 10.1007/s10618-005-0039-x) feature; strength form per FPP3 sec. 4.3.
165
+ Returns 0.0 when the series is too short for two full periods (STL is undefined there).
166
+ """
167
+ from statsmodels.tsa.seasonal import STL
168
+
169
+ a = _clean(x)
170
+ if period < 2 or len(a) < 2 * period:
171
+ return 0.0
172
+ res = STL(a, period=period, robust=True).fit()
173
+ var_r = float(np.var(res.resid))
174
+ var_sr = float(np.var(res.seasonal + res.resid))
175
+ if var_sr <= 0:
176
+ return 0.0
177
+ return float(max(0.0, 1.0 - var_r / var_sr))
178
+
179
+
180
+ def nsdiffs(x, period: int, max_D: int = 1, threshold: float = 0.64) -> int:
181
+ """Number of SEASONAL differences via the FPP3 seasonal-strength rule (difference while Fs >= 0.64)."""
182
+ a = _clean(x)
183
+ D = 0
184
+ while D < max_D:
185
+ if len(a) < 2 * period or seasonal_strength(a, period) < threshold:
186
+ break
187
+ a = a[period:] - a[:-period]
188
+ D += 1
189
+ return D
190
+
191
+
192
+ def combined_verdict(adf_res: TestResult, kpss_res: TestResult) -> str:
193
+ """The ADF+KPSS four-quadrant interpretation (general econometrics, not FPP3).
194
+
195
+ ADF rejects unit root (stationary) x KPSS rejects stationarity (non-stationary) -> four cases:
196
+ both agree stationary / both agree non-stationary / trend-stationary / difference-stationary.
197
+ """
198
+ adf_stat = adf_res.stationary
199
+ kpss_stat = kpss_res.stationary
200
+ if adf_stat and kpss_stat:
201
+ return "stationary (both tests agree)"
202
+ if not adf_stat and not kpss_stat:
203
+ return "non-stationary (both tests agree)"
204
+ if adf_stat and not kpss_stat:
205
+ return "difference-stationary (ADF stationary, KPSS rejects: likely needs differencing)"
206
+ return "trend-stationary (KPSS stationary, ADF unit root: detrend rather than difference)"
207
+
208
+
209
+ def stationarity_report(x, period: int | None = None, alpha: float = 0.05) -> dict:
210
+ """Run the full stationarity panel and return a JSON-ready report (for the baked artifact + docs).
211
+
212
+ Includes ADF, KPSS, Phillips-Perron, DF-GLS, Zivot-Andrews, the four-quadrant combined verdict, and
213
+ the FPP3 differencing recommendation (d via KPSS-sequential; D via seasonal strength when ``period``).
214
+ """
215
+ a = _clean(x)
216
+ adf_res = adf(a, alpha=alpha)
217
+ kpss_res = kpss(a, alpha=alpha)
218
+ tests = [adf_res, kpss_res, phillips_perron(a, alpha=alpha), dfgls(a, alpha=alpha),
219
+ zivot_andrews(a, alpha=alpha)]
220
+ report = {
221
+ "n": int(a.size),
222
+ "tests": [
223
+ {"name": t.name, "stat": t.stat, "pvalue": t.pvalue, "crit": t.crit,
224
+ "null": t.null, "stationary": t.stationary, "reference": t.reference, **t.extra}
225
+ for t in tests
226
+ ],
227
+ "combined_verdict": combined_verdict(adf_res, kpss_res),
228
+ "recommended_d": ndiffs(a, alpha=alpha),
229
+ }
230
+ if period and period >= 2:
231
+ report["seasonal_strength"] = seasonal_strength(a, period)
232
+ report["recommended_D"] = nsdiffs(a, period)
233
+ return report
@@ -0,0 +1,187 @@
1
+ """Volatility, heteroscedasticity, and variance-stabilizing transforms.
2
+
3
+ Two related questions: (1) is the series' variance constant, or does it cluster in bursts (conditional
4
+ heteroscedasticity)? and (2) can a power transform stabilize a growing variance so an additive model fits a
5
+ multiplicative series? The first is answered by Engle's ARCH-LM test and a fitted GARCH conditional-variance
6
+ model; the second by the Box-Cox family with an MLE or Guerrero (seasonal) lambda. Each is delegated to its
7
+ authoritative implementation and wrapped in one NaN-safe API carrying the primary reference.
8
+
9
+ IMPORTANT scope note: GARCH here characterises VOLATILITY (the second moment). It is an ANALYSIS tool, not a
10
+ point-forecast method; the forecasting ladder's point methods live in ``model/``. Volatility structure is a
11
+ property the analysis pillar surfaces (e.g. finance/energy series with variance clustering), and it explains
12
+ why a point forecast's intervals should widen in turbulent regimes.
13
+
14
+ Methods / references (verified 2026-07-04, see wip/chronoscope/research-volatility-transforms-2026-07-04.md):
15
+ * ARCH-LM - Engle 1982, Econometrica 50(4):987-1007, DOI 10.2307/1912773. ``statsmodels.stats.diagnostic.het_arch``.
16
+ * GARCH / EGARCH - Bollerslev 1986, J. Econometrics 31(3):307-327, DOI 10.1016/0304-4076(86)90063-1.
17
+ ``arch.arch_model`` (Sheppard). EGARCH captures leverage asymmetry; FIGARCH captures long-memory volatility.
18
+ * Box-Cox - Box & Cox 1964, JRSS-B 26(2):211-252, JSTOR 2984418. ``scipy.stats.boxcox`` / ``boxcox_normmax``.
19
+ * Guerrero lambda - Guerrero 1993, J. Forecasting 12(1):37-48, DOI 10.1002/for.3980120104.
20
+ ``coreforecast.scalers.boxcox_lambda(method='guerrero', season_length=...)`` (an installed dependency).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+
26
+ import numpy as np
27
+
28
+
29
+ def _clean(x) -> np.ndarray:
30
+ a = np.asarray(x, dtype=float).ravel()
31
+ return a[np.isfinite(a)]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ArchTest:
36
+ """Engle ARCH-LM test for conditional heteroscedasticity (variance clustering)."""
37
+
38
+ lm_stat: float
39
+ lm_pvalue: float
40
+ f_stat: float
41
+ f_pvalue: float
42
+ nlags: int
43
+ has_arch: bool
44
+ alpha: float
45
+ reference: str
46
+
47
+
48
+ def arch_lm(x, nlags: int = 12, alpha: float = 0.05, demean: bool = True) -> ArchTest:
49
+ """ARCH-LM on the series (demeaned by default). Small p => variance clusters (ARCH effects present)."""
50
+ from statsmodels.stats.diagnostic import het_arch
51
+
52
+ a = _clean(x)
53
+ resid = a - a.mean() if demean else a
54
+ lm_stat, lm_p, f_stat, f_p = het_arch(resid, nlags=nlags)
55
+ return ArchTest(
56
+ lm_stat=float(lm_stat), lm_pvalue=float(lm_p), f_stat=float(f_stat), f_pvalue=float(f_p),
57
+ nlags=int(nlags), has_arch=bool(lm_p < alpha), alpha=alpha,
58
+ reference="Engle 1982, Econometrica 50(4):987-1007, DOI 10.2307/1912773",
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class GarchFit:
64
+ """A fitted GARCH-family conditional-variance model: per-point conditional volatility + parameters."""
65
+
66
+ vol: str
67
+ p: int
68
+ q: int
69
+ params: dict[str, float]
70
+ conditional_volatility: np.ndarray # sigma_t per point (std, not variance)
71
+ persistence: float # sum(alpha)+sum(beta): near 1 => shocks persist (IGARCH-like)
72
+ loglikelihood: float
73
+ reference: str
74
+
75
+
76
+ def garch(x, p: int = 1, q: int = 1, vol: str = "GARCH", dist: str = "normal",
77
+ rescale: bool = True) -> GarchFit:
78
+ """Fit a GARCH(p,q) (or EGARCH/FIGARCH) conditional-variance model to the series' mean residuals."""
79
+ from arch import arch_model
80
+
81
+ a = _clean(x)
82
+ # arch prefers series scaled to roughly unit range; rescale=True lets it handle that internally.
83
+ model = arch_model(a, mean="Constant", vol=vol, p=p, q=q, dist=dist, rescale=rescale)
84
+ res = model.fit(disp="off")
85
+ params = {k: float(v) for k, v in res.params.items()}
86
+ # persistence: sum of the alpha[*] and beta[*] parameters (standard GARCH stationarity read-out)
87
+ persistence = float(sum(v for k, v in params.items() if k.startswith(("alpha", "beta"))))
88
+ return GarchFit(
89
+ vol=vol, p=p, q=q, params=params,
90
+ conditional_volatility=np.asarray(res.conditional_volatility, dtype=float),
91
+ persistence=persistence, loglikelihood=float(res.loglikelihood),
92
+ reference="Bollerslev 1986, J. Econometrics 31(3):307-327, DOI 10.1016/0304-4076(86)90063-1",
93
+ )
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class BoxCoxResult:
98
+ """A Box-Cox variance-stabilizing transform: the fitted lambda, the transformed series, and the shift."""
99
+
100
+ lam: float
101
+ method: str # "mle", "guerrero", or "log"
102
+ transformed: np.ndarray
103
+ shift: float # amount added to make the series strictly positive (0 if already >0)
104
+ reference: str
105
+
106
+
107
+ def _positive_shift(a: np.ndarray) -> tuple[np.ndarray, float]:
108
+ """Box-Cox needs y > 0; shift by (1 - min) when non-positive values are present. Record the shift."""
109
+ m = float(np.min(a))
110
+ if m <= 0:
111
+ shift = 1.0 - m
112
+ return a + shift, shift
113
+ return a, 0.0
114
+
115
+
116
+ def box_cox(x, method: str = "mle", season_length: int | None = None) -> BoxCoxResult:
117
+ """Box-Cox transform with lambda by MLE (scipy) or Guerrero (coreforecast, seasonal-aware).
118
+
119
+ ``method='mle'`` maximizes the profile likelihood; ``method='guerrero'`` minimizes the coefficient of
120
+ variation across seasonal subseries (needs ``season_length``); ``method='log'`` forces lambda=0.
121
+ """
122
+ a = _clean(x)
123
+ shifted, shift = _positive_shift(a)
124
+ if method == "log":
125
+ return BoxCoxResult(lam=0.0, method="log", transformed=np.log(shifted), shift=shift,
126
+ reference="Box & Cox 1964, JRSS-B 26(2):211-252, JSTOR 2984418 (log special case)")
127
+ if method == "guerrero":
128
+ from coreforecast.scalers import boxcox_lambda
129
+ from scipy.special import boxcox as _bc
130
+
131
+ if not season_length or season_length < 2:
132
+ raise ValueError("Guerrero lambda needs season_length >= 2")
133
+ lam = float(boxcox_lambda(shifted.astype(np.float64), method="guerrero", season_length=season_length))
134
+ transformed = np.asarray(_bc(shifted, lam), dtype=float)
135
+ return BoxCoxResult(lam=lam, method="guerrero", transformed=transformed, shift=shift,
136
+ reference="Guerrero 1993, J. Forecasting 12(1):37-48, DOI 10.1002/for.3980120104")
137
+ # default: MLE via scipy
138
+ from scipy.stats import boxcox as _scipy_boxcox
139
+
140
+ transformed, lam = _scipy_boxcox(shifted)
141
+ return BoxCoxResult(lam=float(lam), method="mle", transformed=np.asarray(transformed, dtype=float),
142
+ shift=shift, reference="Box & Cox 1964, JRSS-B 26(2):211-252, JSTOR 2984418")
143
+
144
+
145
+ def rolling_volatility(x, window: int = 20) -> dict:
146
+ """Rolling mean and std (the visual heteroscedasticity check); returns JSON-ready arrays."""
147
+ import pandas as pd
148
+
149
+ a = _clean(x)
150
+ s = pd.Series(a)
151
+ return {
152
+ "window": int(window),
153
+ "rolling_mean": s.rolling(window).mean().to_numpy().tolist(),
154
+ "rolling_std": s.rolling(window).std().to_numpy().tolist(),
155
+ }
156
+
157
+
158
+ def volatility_report(x, arch_lags: int = 12, season_length: int | None = None,
159
+ garch_pq: tuple[int, int] = (1, 1)) -> dict:
160
+ """Run the volatility panel (ARCH-LM + rolling vol + Box-Cox lambda; GARCH only if ARCH is present)."""
161
+ a = _clean(x)
162
+ at = arch_lm(a, nlags=arch_lags)
163
+ out = {
164
+ "n": len(a),
165
+ "arch_lm": {"lm_stat": at.lm_stat, "lm_pvalue": at.lm_pvalue, "nlags": at.nlags,
166
+ "has_arch": at.has_arch, "reference": at.reference},
167
+ "rolling": rolling_volatility(a, window=min(20, max(3, len(a) // 10))),
168
+ }
169
+ # Box-Cox lambda: Guerrero when a season is given, else MLE. Records the shift if non-positive.
170
+ try:
171
+ method = "guerrero" if (season_length and season_length >= 2) else "mle"
172
+ bc = box_cox(a, method=method, season_length=season_length)
173
+ out["boxcox"] = {"lambda": bc.lam, "method": bc.method, "shift": bc.shift, "reference": bc.reference}
174
+ except Exception as exc: # noqa: BLE001 - record, do not crash the bake
175
+ out["boxcox"] = {"error": f"{type(exc).__name__}: {exc}"}
176
+ # Fit GARCH only when ARCH effects are actually present (else the model is not warranted).
177
+ if at.has_arch:
178
+ try:
179
+ g = garch(a, p=garch_pq[0], q=garch_pq[1])
180
+ out["garch"] = {"vol": g.vol, "p": g.p, "q": g.q, "params": g.params,
181
+ "persistence": g.persistence, "loglikelihood": g.loglikelihood,
182
+ "reference": g.reference}
183
+ except Exception as exc: # noqa: BLE001
184
+ out["garch"] = {"error": f"{type(exc).__name__}: {exc}"}
185
+ else:
186
+ out["garch"] = {"skipped": "no significant ARCH effects (het_arch p >= alpha); GARCH not warranted"}
187
+ return out
@@ -0,0 +1,263 @@
1
+ """Reference finite-prefix forecasts and Gaussian residual approximations.
2
+
3
+ The extracted kernels retain their historical equations and preprocessing choices.
4
+ Use the top-level forecastscope API for strict finite-input and output validation.
5
+ They are repeated-prefix fits, not native incremental model-state updates.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from collections.abc import Callable
11
+
12
+ import numpy as np
13
+
14
+ from .types import MethodForecast
15
+
16
+ # --- normal quantile: Acklam's rational approximation; not a coverage guarantee -------------------
17
+ _A = [-3.969683028665376e01, 2.209460984245205e02, -2.759285104469687e02,
18
+ 1.383577518672690e02, -3.066479806614716e01, 2.506628277459239e00]
19
+ _B = [-5.447609879822406e01, 1.615858368580409e02, -1.556989798598866e02,
20
+ 6.680131188771972e01, -1.328068155288572e01]
21
+ _C = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e00,
22
+ -2.549732539343734e00, 4.374664141464968e00, 2.938163982698783e00]
23
+ _D = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e00, 3.754408661907416e00]
24
+
25
+
26
+ def normal_ppf(p: float) -> float:
27
+ """Inverse standard-normal CDF (probit) via Acklam's approximation."""
28
+ if p <= 0.0:
29
+ return -math.inf
30
+ if p >= 1.0:
31
+ return math.inf
32
+ plow, phigh = 0.02425, 1 - 0.02425
33
+ if p < plow:
34
+ q = math.sqrt(-2 * math.log(p))
35
+ return (((((_C[0] * q + _C[1]) * q + _C[2]) * q + _C[3]) * q + _C[4]) * q + _C[5]) / \
36
+ ((((_D[0] * q + _D[1]) * q + _D[2]) * q + _D[3]) * q + 1)
37
+ if p > phigh:
38
+ q = math.sqrt(-2 * math.log(1 - p))
39
+ return -(((((_C[0] * q + _C[1]) * q + _C[2]) * q + _C[3]) * q + _C[4]) * q + _C[5]) / \
40
+ ((((_D[0] * q + _D[1]) * q + _D[2]) * q + _D[3]) * q + 1)
41
+ q = p - 0.5
42
+ r = q * q
43
+ return (((((_A[0] * r + _A[1]) * r + _A[2]) * r + _A[3]) * r + _A[4]) * r + _A[5]) * q / \
44
+ (((((_B[0] * r + _B[1]) * r + _B[2]) * r + _B[3]) * r + _B[4]) * r + 1)
45
+
46
+
47
+ def _clean(y: np.ndarray) -> np.ndarray:
48
+ """Fill NaN by forward-fill then back-fill so recursive methods stay defined."""
49
+ y = np.asarray(y, dtype=float).copy()
50
+ if np.isnan(y).any():
51
+ idx = np.where(~np.isnan(y))[0]
52
+ if idx.size == 0:
53
+ return np.zeros_like(y)
54
+ y[: idx[0]] = y[idx[0]]
55
+ last = y[idx[0]]
56
+ for i in range(len(y)):
57
+ if np.isnan(y[i]):
58
+ y[i] = last
59
+ else:
60
+ last = y[i]
61
+ return y
62
+
63
+
64
+ # --- individual methods: each returns (point_path (h,), one_step_sigma) -----------------------------
65
+ def seasonal_naive(y: np.ndarray, m: int, h: int) -> tuple[np.ndarray, float]:
66
+ y = _clean(y)
67
+ m = m if (m >= 1 and y.shape[0] > m) else 1
68
+ season = y[-m:]
69
+ point = np.tile(season, int(np.ceil(h / m)))[:h]
70
+ resid = y[m:] - y[:-m]
71
+ sigma = float(np.std(resid)) if resid.size else 0.0
72
+ return point, sigma
73
+
74
+
75
+ def _ses_run(y: np.ndarray, alpha: float) -> tuple[np.ndarray, float]:
76
+ """Return in-sample one-step fitted values and the final level for SES."""
77
+ level = y[0]
78
+ fitted = np.empty_like(y)
79
+ fitted[0] = level
80
+ for t in range(1, y.shape[0]):
81
+ fitted[t] = level
82
+ level = alpha * y[t] + (1 - alpha) * level
83
+ return fitted, level
84
+
85
+
86
+ def _opt_alpha(y: np.ndarray) -> float:
87
+ grid = np.linspace(0.05, 0.95, 19)
88
+ best_a, best_sse = grid[0], math.inf
89
+ for a in grid:
90
+ fitted, _ = _ses_run(y, a)
91
+ sse = float(np.sum((y[1:] - fitted[1:]) ** 2))
92
+ if sse < best_sse:
93
+ best_sse, best_a = sse, a
94
+ return float(best_a)
95
+
96
+
97
+ def ses(y: np.ndarray, m: int, h: int) -> tuple[np.ndarray, float]:
98
+ y = _clean(y)
99
+ a = _opt_alpha(y)
100
+ fitted, level = _ses_run(y, a)
101
+ sigma = float(np.std(y[1:] - fitted[1:])) if y.shape[0] > 1 else 0.0
102
+ return np.full(h, level), sigma
103
+
104
+
105
+ def _holt_run(y: np.ndarray, alpha: float, beta: float) -> tuple[np.ndarray, float, float]:
106
+ level, trend = y[0], (y[1] - y[0] if y.shape[0] > 1 else 0.0)
107
+ fitted = np.empty_like(y)
108
+ fitted[0] = level
109
+ for t in range(1, y.shape[0]):
110
+ fitted[t] = level + trend
111
+ prev_level = level
112
+ level = alpha * y[t] + (1 - alpha) * (level + trend)
113
+ trend = beta * (level - prev_level) + (1 - beta) * trend
114
+ return fitted, level, trend
115
+
116
+
117
+ def holt(y: np.ndarray, m: int, h: int) -> tuple[np.ndarray, float]:
118
+ y = _clean(y)
119
+ best = (0.3, 0.1)
120
+ best_sse = math.inf
121
+ for a in np.linspace(0.1, 0.9, 9):
122
+ for b in np.linspace(0.05, 0.5, 6):
123
+ fitted, _, _ = _holt_run(y, a, b)
124
+ sse = float(np.sum((y[1:] - fitted[1:]) ** 2))
125
+ if sse < best_sse:
126
+ best_sse, best = sse, (a, b)
127
+ fitted, level, trend = _holt_run(y, *best)
128
+ sigma = float(np.std(y[1:] - fitted[1:])) if y.shape[0] > 1 else 0.0
129
+ point = level + trend * np.arange(1, h + 1)
130
+ return point, sigma
131
+
132
+
133
+ def _hw_run(y: np.ndarray, m: int, alpha: float, beta: float, gamma: float):
134
+ level = float(np.mean(y[:m]))
135
+ trend = float((np.mean(y[m:2 * m]) - np.mean(y[:m])) / m) if y.shape[0] >= 2 * m else 0.0
136
+ season = [float(y[i] - level) for i in range(m)]
137
+ fitted = np.empty_like(y)
138
+ for t in range(y.shape[0]):
139
+ s = season[t % m]
140
+ fitted[t] = level + trend + s
141
+ prev_level = level
142
+ level = alpha * (y[t] - s) + (1 - alpha) * (level + trend)
143
+ trend = beta * (level - prev_level) + (1 - beta) * trend
144
+ season[t % m] = gamma * (y[t] - level) + (1 - gamma) * s
145
+ return fitted, level, trend, season
146
+
147
+
148
+ def holt_winters(y: np.ndarray, m: int, h: int) -> tuple[np.ndarray, float]:
149
+ y = _clean(y)
150
+ if m < 2 or y.shape[0] < 2 * m:
151
+ return holt(y, m, h) # not enough seasons -> fall back to trend model
152
+ best, best_sse = (0.3, 0.1, 0.1), math.inf
153
+ for a in (0.1, 0.3, 0.5, 0.8):
154
+ for b in (0.05, 0.2):
155
+ for g in (0.1, 0.3, 0.6):
156
+ fitted, *_ = _hw_run(y, m, a, b, g)
157
+ sse = float(np.sum((y[m:] - fitted[m:]) ** 2))
158
+ if sse < best_sse:
159
+ best_sse, best = sse, (a, b, g)
160
+ fitted, level, trend, season = _hw_run(y, m, *best)
161
+ sigma = float(np.std(y[m:] - fitted[m:])) if y.shape[0] > m else 0.0
162
+ # The next observation occupies phase n % m, including a partial final season.
163
+ point = np.array([level + trend * (k + 1) + season[(len(y) + k) % m] for k in range(h)])
164
+ return point, sigma
165
+
166
+
167
+ def theta(y: np.ndarray, m: int, h: int) -> tuple[np.ndarray, float]:
168
+ """Nonseasonal Theta point equation (Hyndman and Billah, 2003, Eq. 5).
169
+
170
+ Alpha uses the documented finite grid and first-observation SES initialisation.
171
+ AutoTheta supplies automatic seasonal adjustment in the statistical engine.
172
+ """
173
+ y = _clean(y)
174
+ n = y.shape[0]
175
+ t = np.arange(n, dtype=float)
176
+ b = float(np.polyfit(t, y, 1)[0]) if n >= 2 else 0.0
177
+ a = _opt_alpha(y)
178
+ fitted, level = _ses_run(y, a)
179
+ sigma = float(np.std(y[1:] - fitted[1:])) if n > 1 else 0.0
180
+ lead = np.arange(1, h + 1)
181
+ point = level + 0.5 * b * (lead - 1 + (1 - (1 - a) ** n) / a)
182
+ return point, sigma
183
+
184
+
185
+ METHODS: list[tuple[str, str, Callable[[np.ndarray, int, int], tuple[np.ndarray, float]]]] = [
186
+ ("SeasonalNaive", "classical", seasonal_naive),
187
+ ("SES", "classical", ses),
188
+ ("Holt", "classical", holt),
189
+ ("HoltWinters", "classical", holt_winters),
190
+ ("Theta", "classical", theta),
191
+ ]
192
+
193
+ METHOD_FNS = {name: fn for name, _fam, fn in METHODS}
194
+
195
+
196
+ def gaussian_quantiles(point: np.ndarray, sigma: float, levels: tuple[float, ...]) -> np.ndarray:
197
+ """Quantile columns from a point path + one-step sigma, widened by sqrt(step). Shape (h, len(levels))."""
198
+ steps = np.arange(1, point.shape[0] + 1)
199
+ widen = np.sqrt(steps)
200
+ cols = [point + normal_ppf(lv) * sigma * widen for lv in levels]
201
+ return np.maximum.accumulate(np.column_stack(cols), axis=1) # keep quantiles monotone across levels
202
+
203
+
204
+ def mf_from_quantiles(name: str, family: str, cols: np.ndarray, levels: tuple[float, ...]) -> MethodForecast:
205
+ """Build a MethodForecast (point = level nearest 0.5, outer interval = min/max levels) from quantile columns."""
206
+ point_col = int(np.argmin([abs(lv - 0.5) for lv in levels]))
207
+ return MethodForecast(
208
+ name=name, family=family,
209
+ point=tuple(round(float(v), 6) for v in cols[:, point_col]),
210
+ lower=tuple(round(float(v), 6) for v in cols[:, 0]),
211
+ upper=tuple(round(float(v), 6) for v in cols[:, -1]),
212
+ )
213
+
214
+
215
+ def build_forecast(name: str, family: str, point: np.ndarray, sigma: float,
216
+ levels: tuple[float, ...]) -> MethodForecast:
217
+ """Turn a point path + one-step sigma into a MethodForecast with an outer interval."""
218
+ return mf_from_quantiles(name, family, gaussian_quantiles(point, sigma, levels), levels)
219
+
220
+
221
+ class Forecaster:
222
+ """Unifies a forecasting method for the pipeline: `quantiles` feeds the preqts backtest, `forecast`
223
+ feeds the trace. `max_windows` caps the rolling-backtest window count (cheap methods get more)."""
224
+
225
+ name: str
226
+ family: str
227
+ max_windows: int = 24
228
+
229
+ def quantiles(self, y: np.ndarray, m: int, h: int, levels: tuple[float, ...]) -> np.ndarray:
230
+ raise NotImplementedError
231
+
232
+ def forecast(self, y: np.ndarray, m: int, h: int, levels: tuple[float, ...]) -> MethodForecast:
233
+ return mf_from_quantiles(self.name, self.family, self.quantiles(y, m, h, levels), levels)
234
+
235
+
236
+ class ClassicalForecaster(Forecaster):
237
+ """A pure-numpy classical method (Pyodide-safe): the live lane uses only these."""
238
+
239
+ def __init__(self, name: str, family: str, fn, max_windows: int = 24) -> None:
240
+ self.name = name
241
+ self.family = family
242
+ self.fn = fn
243
+ self.max_windows = max_windows
244
+
245
+ def quantiles(self, y: np.ndarray, m: int, h: int, levels: tuple[float, ...]) -> np.ndarray:
246
+ point, sigma = self.fn(np.asarray(y, dtype=float), m, h)
247
+ return gaussian_quantiles(point, sigma, levels)
248
+
249
+
250
+ def classical_forecasters() -> list[ClassicalForecaster]:
251
+ """The pure-numpy classical ladder (shared by the offline pipeline and the live lane)."""
252
+ return [ClassicalForecaster(name, family, fn) for name, family, fn in METHODS]
253
+
254
+
255
+ def forecast_all(y: np.ndarray, m: int, h: int, levels: tuple[float, ...]) -> list[MethodForecast]:
256
+ """Fit and forecast every CLASSICAL method (the live lane; no heavy engines). One MethodForecast each."""
257
+ return [fc.forecast(np.asarray(y, dtype=float), m, h, levels) for fc in classical_forecasters()]
258
+
259
+
260
+ def forecast_point(name: str, y: np.ndarray, m: int, h: int) -> np.ndarray:
261
+ """Just the point path for one classical method (used by tests/diagnostics)."""
262
+ point, _sigma = METHOD_FNS[name](np.asarray(y, dtype=float), m, h)
263
+ return point