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.
- forecastscope/__init__.py +5 -0
- forecastscope/api.py +72 -0
- forecastscope/combinations/__init__.py +1 -0
- forecastscope/combinations/context_memory.py +127 -0
- forecastscope/combinations/validated.py +144 -0
- forecastscope/diagnostics/__init__.py +236 -0
- forecastscope/diagnostics/autocorrelation.py +217 -0
- forecastscope/diagnostics/causality.py +183 -0
- forecastscope/diagnostics/changepoints.py +172 -0
- forecastscope/diagnostics/distribution.py +180 -0
- forecastscope/diagnostics/filters.py +201 -0
- forecastscope/diagnostics/fractal.py +206 -0
- forecastscope/diagnostics/nonlinear.py +267 -0
- forecastscope/diagnostics/seasonality.py +218 -0
- forecastscope/diagnostics/sequence.py +130 -0
- forecastscope/diagnostics/stationarity.py +233 -0
- forecastscope/diagnostics/volatility.py +187 -0
- forecastscope/forecasting.py +263 -0
- forecastscope/types.py +12 -0
- forecastscope-0.1.0.dist-info/METADATA +85 -0
- forecastscope-0.1.0.dist-info/RECORD +24 -0
- forecastscope-0.1.0.dist-info/WHEEL +5 -0
- forecastscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- forecastscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|