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,180 @@
1
+ """Distribution, normality, and entropy/complexity: the value-distribution and predictability diagnostics.
2
+
3
+ Two facets of "what kind of values does this series take, and how predictable is its pattern?": (1) the
4
+ DISTRIBUTION of the values (shape, tails, normality) and (2) the COMPLEXITY of the ordering (entropy measures
5
+ that quantify regularity vs randomness, and a nonlinearity test). Together they answer whether Gaussian
6
+ assumptions hold and how much exploitable structure the sequence carries.
7
+
8
+ Methods / references (verified 2026-07-04):
9
+ * Summary moments - mean, variance, skewness (asymmetry), kurtosis (tail-heaviness). Standard.
10
+ * KDE - Gaussian kernel density estimate. Parzen 1962, Ann. Math. Stat. 33(3):1065-1076,
11
+ DOI 10.1214/aoms/1177704472. ``scipy.stats.gaussian_kde``.
12
+ * Q-Q plot - sample vs theoretical (normal) quantiles. Wilk & Gnanadesikan 1968, Biometrika 55(1):1-17,
13
+ DOI 10.1093/biomet/55.1.1. ``scipy.stats.probplot``.
14
+ * Jarque-Bera - normality from skew+kurtosis; large-sample. Jarque & Bera 1980, Economics Letters
15
+ 6(3):255-259, DOI 10.1016/0165-1765(80)90024-5. ``scipy.stats.jarque_bera``.
16
+ * Shapiro-Wilk - most powerful normality test for small/moderate n. Shapiro & Wilk 1965, Biometrika
17
+ 52(3-4):591-611, DOI 10.1093/biomet/52.3-4.591. ``scipy.stats.shapiro``.
18
+ * Sample entropy - bias-corrected regularity (lower = more predictable). Richman & Moorman 2000,
19
+ Am. J. Physiol. 278(6):H2039-H2049, DOI 10.1152/ajpheart.2000.278.6.H2039. ``antropy.sample_entropy``.
20
+ * Permutation entropy - ordinal-pattern entropy, noise-robust. Bandt & Pompe 2002, PRL 88:174102,
21
+ DOI 10.1103/PhysRevLett.88.174102. ``antropy.perm_entropy``.
22
+ * Spectral entropy - Shannon entropy of the normalized PSD (flat spectrum = high). ``antropy.spectral_entropy``.
23
+ * BDS test - i.i.d. vs hidden nonlinear structure via the correlation integral. Broock, Scheinkman,
24
+ Dechert & LeBaron 1996, Econometric Reviews 15(3):197-235, DOI 10.1080/07474939608800353.
25
+ ``statsmodels.tsa.stattools.bds``.
26
+ * catch22 - 22 canonical features (Lubba et al. 2019, DOI 10.1007/s10618-019-00647-x). OPTIONAL: pycatch22
27
+ needs a C toolchain to build; when unavailable the report records that honestly rather than faking it.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import warnings
32
+ from dataclasses import dataclass
33
+
34
+ import numpy as np
35
+
36
+
37
+ def _clean(x) -> np.ndarray:
38
+ a = np.asarray(x, dtype=float).ravel()
39
+ return a[np.isfinite(a)]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class DistributionSummary:
44
+ """Moments + normality verdicts + a KDE/Q-Q payload for the distribution panel."""
45
+
46
+ n: int
47
+ mean: float
48
+ std: float
49
+ skewness: float
50
+ kurtosis_excess: float # excess kurtosis (0 = normal); >0 heavy-tailed
51
+ jarque_bera_stat: float
52
+ jarque_bera_pvalue: float
53
+ shapiro_stat: float | None
54
+ shapiro_pvalue: float | None
55
+ normal: bool # combined verdict at alpha (both tests non-significant where applicable)
56
+ reference: str
57
+
58
+
59
+ def summary(x, alpha: float = 0.05) -> DistributionSummary:
60
+ """Moments + Jarque-Bera + Shapiro-Wilk (n <= 5000) with a combined normality verdict."""
61
+ from scipy import stats
62
+
63
+ a = _clean(x)
64
+ n = len(a)
65
+ skew = float(stats.skew(a))
66
+ kurt = float(stats.kurtosis(a, fisher=True)) # excess kurtosis
67
+ jb_stat, jb_p = stats.jarque_bera(a)
68
+ sh_stat, sh_p = (None, None)
69
+ if 3 <= n <= 5000: # Shapiro is defined/reliable in this range
70
+ sh = stats.shapiro(a)
71
+ sh_stat, sh_p = float(sh[0]), float(sh[1])
72
+ normal = bool(jb_p >= alpha and (sh_p is None or sh_p >= alpha))
73
+ return DistributionSummary(
74
+ n=n, mean=float(np.mean(a)), std=float(np.std(a)), skewness=skew, kurtosis_excess=kurt,
75
+ jarque_bera_stat=float(jb_stat), jarque_bera_pvalue=float(jb_p),
76
+ shapiro_stat=sh_stat, shapiro_pvalue=sh_p, normal=normal,
77
+ reference="Jarque & Bera 1980, DOI 10.1016/0165-1765(80)90024-5; Shapiro & Wilk 1965, DOI 10.1093/biomet/52.3-4.591",
78
+ )
79
+
80
+
81
+ def kde_curve(x, num: int = 100) -> dict:
82
+ """Gaussian KDE evaluated on a grid, for the histogram+density panel (Parzen 1962)."""
83
+ from scipy import stats
84
+
85
+ a = _clean(x)
86
+ kde = stats.gaussian_kde(a)
87
+ grid = np.linspace(float(a.min()), float(a.max()), num)
88
+ return {"grid": grid.tolist(), "density": kde(grid).tolist(),
89
+ "reference": "Parzen 1962, Ann. Math. Stat. 33(3):1065-1076, DOI 10.1214/aoms/1177704472"}
90
+
91
+
92
+ def qq_points(x) -> dict:
93
+ """Normal Q-Q points (theoretical vs ordered sample quantiles) + the fitted reference line."""
94
+ from scipy import stats
95
+
96
+ a = _clean(x)
97
+ (osm, osr), (slope, intercept, r) = stats.probplot(a, dist="norm")
98
+ return {"theoretical": osm.tolist(), "sample": osr.tolist(),
99
+ "slope": float(slope), "intercept": float(intercept), "r": float(r),
100
+ "reference": "Wilk & Gnanadesikan 1968, Biometrika 55(1):1-17, DOI 10.1093/biomet/55.1.1"}
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class ComplexitySummary:
105
+ """Entropy/complexity measures + the BDS nonlinearity verdict (predictability of the ordering)."""
106
+
107
+ sample_entropy: float
108
+ permutation_entropy: float # normalized to [0, 1]
109
+ spectral_entropy: float # normalized to [0, 1]
110
+ bds_stat: float | None
111
+ bds_pvalue: float | None
112
+ iid: bool | None # BDS verdict: True if not rejected (behaves i.i.d.)
113
+ reference: str
114
+
115
+
116
+ def complexity(x, order: int = 3, bds_dim: int = 2, alpha: float = 0.05) -> ComplexitySummary:
117
+ """Sample/permutation/spectral entropy + the BDS i.i.d.-vs-nonlinear test."""
118
+ import antropy as ant
119
+ from statsmodels.tsa.stattools import bds as _bds
120
+
121
+ a = _clean(x)
122
+ samp = float(ant.sample_entropy(a))
123
+ perm = float(ant.perm_entropy(a, order=order, normalize=True))
124
+ spec = float(ant.spectral_entropy(a, sf=1.0, method="welch", normalize=True))
125
+ bds_stat = bds_p = None
126
+ iid = None
127
+ if len(a) >= 20:
128
+ try:
129
+ stat, pval = _bds(a, max_dim=bds_dim)
130
+ bds_stat = float(np.ravel(stat)[-1]) # statistic at the largest embedding dim
131
+ bds_p = float(np.ravel(pval)[-1])
132
+ iid = bool(bds_p >= alpha)
133
+ except Exception: # noqa: BLE001 - BDS can fail on degenerate input; record as unavailable
134
+ warnings.warn("BDS diagnostic unavailable", RuntimeWarning, stacklevel=2)
135
+ return ComplexitySummary(
136
+ sample_entropy=samp, permutation_entropy=perm, spectral_entropy=spec,
137
+ bds_stat=bds_stat, bds_pvalue=bds_p, iid=iid,
138
+ reference="Richman & Moorman 2000, DOI 10.1152/ajpheart.2000.278.6.H2039; Bandt & Pompe 2002, DOI 10.1103/PhysRevLett.88.174102; BDS: Broock et al. 1996, DOI 10.1080/07474939608800353",
139
+ )
140
+
141
+
142
+ def catch22_features(x) -> dict:
143
+ """The canonical catch22 feature set, in its catch24 form (+ mean and std). Lubba et al. 2019.
144
+
145
+ This is the PRINCIPLED answer to "extract many features": 22 features distilled from the ~7700 of hctsa
146
+ by selecting for classification performance and LOW REDUNDANCY - so it carries the information of a large
147
+ feature bank without the shotgun. We bake it per case (it is pinned in requirements-precompute.txt;
148
+ pycatch22 ships prebuilt wheels, no C toolchain needed). Kept import-guarded so the pipeline still runs
149
+ on a slim install, and if it is ever absent the report SAYS so rather than fabricating values.
150
+ """
151
+ try:
152
+ import pycatch22
153
+ except ImportError:
154
+ return {"available": False,
155
+ "reason": "pycatch22 not installed (pin it: requirements-precompute.txt)",
156
+ "reference": "Lubba et al. 2019, Data Min. Knowl. Disc. 33(6):1821-1852, DOI 10.1007/s10618-019-00647-x"}
157
+ a = _clean(x)
158
+ res = pycatch22.catch22_all(a.tolist(), catch24=True)
159
+ return {"available": True,
160
+ "features": dict(zip(res["names"], [float(v) for v in res["values"]])),
161
+ "reference": "Lubba et al. 2019, DOI 10.1007/s10618-019-00647-x"}
162
+
163
+
164
+ def distribution_report(x, order: int = 3) -> dict:
165
+ """Run the distribution + complexity panel and return a JSON-ready report."""
166
+ a = _clean(x)
167
+ s = summary(a)
168
+ c = complexity(a, order=order)
169
+ return {
170
+ "n": len(a),
171
+ "summary": {"mean": s.mean, "std": s.std, "skewness": s.skewness,
172
+ "kurtosis_excess": s.kurtosis_excess, "jarque_bera_pvalue": s.jarque_bera_pvalue,
173
+ "shapiro_pvalue": s.shapiro_pvalue, "normal": s.normal, "reference": s.reference},
174
+ "kde": kde_curve(a),
175
+ "qq": qq_points(a),
176
+ "complexity": {"sample_entropy": c.sample_entropy, "permutation_entropy": c.permutation_entropy,
177
+ "spectral_entropy": c.spectral_entropy, "bds_pvalue": c.bds_pvalue, "iid": c.iid,
178
+ "reference": c.reference},
179
+ "catch22": catch22_features(a),
180
+ }
@@ -0,0 +1,201 @@
1
+ """Trend-cycle filters and adaptive decompositions: HP, Baxter-King, Christiano-Fitzgerald, EMD, wavelets.
2
+
3
+ Where STL/MSTL (see ``seasonality``) split a series by a FIXED period, the tools here separate components by
4
+ FREQUENCY BAND (the econometric filters) or fully adaptively (EMD's intrinsic mode functions, the wavelet
5
+ scalogram). They answer "what lives at which time scale?", which is how trend, business-cycle, and
6
+ high-frequency structure are told apart when no single seasonal period exists.
7
+
8
+ Methods / references (verified 2026-07-04):
9
+ * Hodrick-Prescott - penalized trend: min sum (y_t - tau_t)^2 + lambda * sum (Delta^2 tau_t)^2. Hodrick &
10
+ Prescott 1997, J. Money, Credit and Banking 29(1):1-16, DOI 10.2307/2953682.
11
+ ``statsmodels.tsa.filters.hp_filter.hpfilter``. Conventional lambda: 1600 quarterly, 129600 monthly,
12
+ 6.25 annual.
13
+ * Baxter-King - symmetric finite-MA approximate band-pass isolating cycles of period [low, high];
14
+ loses K observations at each end. Baxter & King 1999, Rev. Econ. & Stat. 81(4):575-593,
15
+ DOI 10.1162/003465399558454. ``statsmodels.tsa.filters.bk_filter.bkfilter``.
16
+ * Christiano-Fitzgerald - asymmetric random-walk band-pass, usable in real time (no end-loss).
17
+ Christiano & Fitzgerald 2003, Int. Economic Review 44(2):435-465, DOI 10.1111/1468-2354.t01-1-00076.
18
+ ``statsmodels.tsa.filters.cf_filter.cffilter``.
19
+ * EMD / EEMD / CEEMDAN - adaptive decomposition into Intrinsic Mode Functions by sifting; EEMD adds
20
+ noise-assisted ensembling against mode mixing; CEEMDAN adds adaptive noise with complete reconstruction.
21
+ Huang et al. 1998, Proc. R. Soc. A 454:903-995, DOI 10.1098/rspa.1998.0193; Wu & Huang 2009,
22
+ Adv. Adaptive Data Analysis 1(1):1-41, DOI 10.1142/S1793536909000047; Torres et al. 2011, ICASSP,
23
+ DOI 10.1109/ICASSP.2011.5947265. Package ``EMD-signal`` (import ``PyEMD``).
24
+ * CWT scalogram - time-scale energy map |CWT(t, s)|^2 via PyWavelets (``pywt.cwt``; NOTE
25
+ ``scipy.signal.cwt`` was deprecated in scipy 1.12 and REMOVED in 1.15). Torrence & Compo 1998,
26
+ Bull. Amer. Meteor. Soc. 79(1):61-78, DOI 10.1175/1520-0477(1998)079<0061:APGTWA>2.0.CO;2.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+
32
+ import numpy as np
33
+
34
+
35
+ def _clean(x) -> np.ndarray:
36
+ a = np.asarray(x, dtype=float).ravel()
37
+ return a[np.isfinite(a)]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class FilterResult:
42
+ """A two-way split into a smooth component (trend) and a cyclical component, by a named filter."""
43
+
44
+ name: str
45
+ trend: np.ndarray | None # None for pure band-pass outputs (Baxter-King returns only the cycle)
46
+ cycle: np.ndarray
47
+ reference: str
48
+
49
+
50
+ def hodrick_prescott(x, lamb: float = 1600.0) -> FilterResult:
51
+ """HP filter: penalized smooth trend + cycle. lambda 1600 quarterly / 129600 monthly / 6.25 annual."""
52
+ from statsmodels.tsa.filters.hp_filter import hpfilter
53
+
54
+ a = _clean(x)
55
+ cycle, trend = hpfilter(a, lamb=lamb)
56
+ return FilterResult(
57
+ name="HodrickPrescott", trend=np.asarray(trend, dtype=float), cycle=np.asarray(cycle, dtype=float),
58
+ reference="Hodrick & Prescott 1997, JMCB 29(1):1-16, DOI 10.2307/2953682",
59
+ )
60
+
61
+
62
+ def baxter_king(x, low: int = 6, high: int = 32, K: int = 12) -> FilterResult:
63
+ """Baxter-King band-pass for cycles with period in [low, high]; symmetric MA, loses K points per end."""
64
+ from statsmodels.tsa.filters.bk_filter import bkfilter
65
+
66
+ a = _clean(x)
67
+ cycle = np.asarray(bkfilter(a, low=low, high=high, K=K), dtype=float).ravel()
68
+ return FilterResult(
69
+ name="BaxterKing", trend=None, cycle=cycle,
70
+ reference="Baxter & King 1999, REStat 81(4):575-593, DOI 10.1162/003465399558454",
71
+ )
72
+
73
+
74
+ def christiano_fitzgerald(x, low: int = 6, high: int = 32) -> FilterResult:
75
+ """Christiano-Fitzgerald asymmetric band-pass; keeps the full sample length (real-time usable)."""
76
+ from statsmodels.tsa.filters.cf_filter import cffilter
77
+
78
+ a = _clean(x)
79
+ cycle, trend = cffilter(a, low=low, high=high, drift=True)
80
+ return FilterResult(
81
+ name="ChristianoFitzgerald", trend=np.asarray(trend, dtype=float).ravel(),
82
+ cycle=np.asarray(cycle, dtype=float).ravel(),
83
+ reference="Christiano & Fitzgerald 2003, IER 44(2):435-465, DOI 10.1111/1468-2354.t01-1-00076",
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class EMDResult:
89
+ """Intrinsic Mode Functions (rows of ``imfs``) + the residual trend, from EMD or CEEMDAN sifting."""
90
+
91
+ name: str
92
+ imfs: np.ndarray # (n_imfs, n) - each row one IMF, fastest oscillation first
93
+ residual: np.ndarray # the final monotone-ish trend left after all IMFs
94
+ reference: str
95
+
96
+
97
+ def emd(x, max_imfs: int = -1, seed: int = 0) -> EMDResult:
98
+ """Plain EMD sifting into IMFs (fastest first) + the residual trend (``get_imfs_and_residue``)."""
99
+ from PyEMD import EMD as _EMD
100
+
101
+ a = _clean(x)
102
+ del seed # plain EMD is deterministic (no noise); the seed applies to EEMD/CEEMDAN only
103
+ engine = _EMD()
104
+ engine.emd(a, max_imf=max_imfs)
105
+ imfs, residue = engine.get_imfs_and_residue()
106
+ return EMDResult(
107
+ name="EMD", imfs=np.asarray(imfs, dtype=float), residual=np.asarray(residue, dtype=float),
108
+ reference="Huang et al. 1998, Proc. R. Soc. A 454:903-995, DOI 10.1098/rspa.1998.0193",
109
+ )
110
+
111
+
112
+ def ceemdan(x, trials: int = 50, seed: int = 0) -> EMDResult:
113
+ """CEEMDAN: noise-assisted EMD with adaptive noise and complete reconstruction (slower, cleaner IMFs)."""
114
+ from PyEMD import CEEMDAN as _CEEMDAN
115
+
116
+ a = _clean(x)
117
+ engine = _CEEMDAN(trials=trials)
118
+ engine.noise_seed(seed)
119
+ engine.ceemdan(a)
120
+ imfs, residue = engine.get_imfs_and_residue()
121
+ return EMDResult(
122
+ name="CEEMDAN", imfs=np.asarray(imfs, dtype=float), residual=np.asarray(residue, dtype=float),
123
+ reference="Torres et al. 2011, ICASSP, DOI 10.1109/ICASSP.2011.5947265",
124
+ )
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Scalogram:
129
+ """|CWT|^2 energy over (scale, time): the standard wavelet time-frequency picture."""
130
+
131
+ power: np.ndarray # (n_scales, n) - |coef|^2
132
+ scales: np.ndarray
133
+ periods: np.ndarray # the pseudo-period of each scale (in samples, for the given wavelet)
134
+ wavelet: str
135
+ reference: str
136
+
137
+
138
+ def cwt_scalogram(x, wavelet: str = "morl", num_scales: int = 64, max_period_frac: float = 0.5,
139
+ detrend: bool = True) -> Scalogram:
140
+ """Continuous wavelet transform scalogram via PyWavelets (scipy's cwt is removed since 1.15).
141
+
142
+ Scales are geometric from ~2 samples up to ``max_period_frac`` of the series length, converted to
143
+ pseudo-periods so the y-axis reads in samples-per-cycle. Following Torrence & Compo 1998, the series is
144
+ linearly detrended by default: an un-removed trend dumps its energy into the longest scales and swamps
145
+ the cyclic structure the scalogram is meant to reveal (set ``detrend=False`` to see the raw picture).
146
+ """
147
+ import pywt
148
+
149
+ a = _clean(x)
150
+ n = len(a)
151
+ if n < 8:
152
+ raise ValueError("series too short for a scalogram")
153
+ if detrend:
154
+ t = np.arange(n, dtype=float)
155
+ slope, intercept = np.polyfit(t, a, 1)
156
+ a = a - (slope * t + intercept)
157
+ max_period = max(4.0, max_period_frac * n)
158
+ fc = pywt.central_frequency(wavelet)
159
+ # period = scale / fc -> scale = period * fc
160
+ periods = np.geomspace(2.0, max_period, num_scales)
161
+ scales = periods * fc
162
+ coefs, freqs = pywt.cwt(a, scales, wavelet)
163
+ power = np.abs(coefs) ** 2
164
+ out_periods = 1.0 / freqs
165
+ return Scalogram(
166
+ power=power, scales=np.asarray(scales, dtype=float), periods=np.asarray(out_periods, dtype=float),
167
+ wavelet=wavelet,
168
+ reference="Torrence & Compo 1998, BAMS 79(1):61-78, DOI 10.1175/1520-0477(1998)079<0061:APGTWA>2.0.CO;2",
169
+ )
170
+
171
+
172
+ def filters_report(x, hp_lambda: float = 1600.0, band: tuple[int, int] = (6, 32), max_imfs: int = 6) -> dict:
173
+ """Run the filter panel (HP + CF + EMD + scalogram summary) and return a JSON-ready report.
174
+
175
+ Baxter-King is exposed as a function but not baked by default (its K-point end-loss makes panel arrays
176
+ ragged); CF covers the band-pass role in the artifact.
177
+ """
178
+ a = _clean(x)
179
+ hp = hodrick_prescott(a, lamb=hp_lambda)
180
+ cf = christiano_fitzgerald(a, low=band[0], high=band[1])
181
+ e = emd(a, max_imfs=max_imfs)
182
+ sc = cwt_scalogram(a) if len(a) >= 8 else None
183
+ out = {
184
+ "n": len(a),
185
+ "hp": {"lambda": hp_lambda, "trend": hp.trend.tolist(), "cycle": hp.cycle.tolist(),
186
+ "reference": hp.reference},
187
+ "cf": {"band": list(band), "cycle": cf.cycle.tolist(), "reference": cf.reference},
188
+ "emd": {"n_imfs": int(e.imfs.shape[0]), "imfs": e.imfs.tolist(), "residual": e.residual.tolist(),
189
+ "reference": e.reference},
190
+ }
191
+ if sc is not None:
192
+ # bake a compact scalogram summary: per-scale total energy + the dominant period by energy
193
+ energy = sc.power.sum(axis=1)
194
+ out["scalogram"] = {
195
+ "wavelet": sc.wavelet,
196
+ "periods": sc.periods.tolist(),
197
+ "energy_by_period": energy.tolist(),
198
+ "dominant_period": float(sc.periods[int(np.argmax(energy))]),
199
+ "reference": sc.reference,
200
+ }
201
+ return out
@@ -0,0 +1,206 @@
1
+ """Fractal, multifractal, and long-memory analysis: Hurst, DFA, MF-DFA, fractal dimension, DCCA.
2
+
3
+ The self-affine / scaling structure of a series: does it have long-range dependence (persistent memory)? Is a
4
+ single scaling exponent enough (monofractal) or does it need a spectrum (multifractal)? How rough is it? For
5
+ the forecastability story this is decisive: a Hurst exponent far from 0.5 signals exploitable structure, a
6
+ wide multifractal spectrum signals scale-dependent burstiness, and both temper naive "it's just noise"
7
+ conclusions. Each method delegates to its authoritative library and carries the primary reference.
8
+
9
+ Methods / references (verified 2026-07-04; dossiers research-hurst-dfa / research-multifractal /
10
+ research-fractal-dimension / research-cross-series-modeling-honesty):
11
+ * Hurst via R/S - Hurst 1951, Trans. ASCE 116:770-799. H<0.5 anti-persistent, =0.5 random walk, >0.5
12
+ persistent. ``hurst.compute_Hc`` (Anis-Lloyd corrected) and ``nolds.hurst_rs``.
13
+ * DFA - Peng et al. 1994, Phys. Rev. E 49:1685-1689 (detrended fluctuation analysis). Scaling exponent
14
+ alpha (fGn: H=alpha; fBm: H=alpha-1; alpha~1 = 1/f noise). ``nolds.dfa`` / ``antropy.detrended_fluctuation``.
15
+ * MF-DFA - Kantelhardt et al. 2002, Physica A 316:87-114, DOI 10.1016/S0378-4371(02)01383-3. The
16
+ generalized Hurst h(q), mass exponent tau(q)=q*h(q)-1, and the singularity spectrum f(alpha) via the
17
+ Legendre transform. Spectrum WIDTH = degree of multifractality. ``MFDFA.MFDFA``.
18
+ * Fractal dimension - Higuchi 1988, Physica D 31:277-283, DOI 10.1016/0167-2789(88)90081-4; Katz 1988;
19
+ Petrosian 1995. D in [1, 2] for a series graph: higher = rougher. ``antropy.{higuchi_fd,katz_fd,petrosian_fd}``.
20
+ * Modeling counterpart: ARFIMA fractional differencing d = H - 0.5 (Granger-Joyeux 1980; Hosking 1981) -
21
+ recorded as the long-memory link, not fitted here (no maintained Python ARFIMA mean-estimator; see plan).
22
+
23
+ HONESTY: DFA always returns a positive alpha even for non-self-similar data, and R/S is biased on short
24
+ series (needs n > ~50-100). We report the value AND a caveat flag; forecastability claims are gated by the
25
+ [nonlinear-dynamics] surrogate tests, not by a single exponent.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+
31
+ import numpy as np
32
+
33
+
34
+ def _clean(x) -> np.ndarray:
35
+ a = np.asarray(x, dtype=float).ravel()
36
+ return a[np.isfinite(a)]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class HurstResult:
41
+ """Long-range dependence: the Hurst exponent by R/S and by DFA, with an interpretation."""
42
+
43
+ hurst_rs: float
44
+ dfa_alpha: float
45
+ interpretation: str # anti-persistent / random-walk-like / persistent
46
+ reliable: bool # False when n is too short for a stable estimate
47
+ reference: str
48
+
49
+
50
+ def _interpret_hurst(h: float) -> str:
51
+ if h < 0.45:
52
+ return "anti-persistent (mean-reverting; H < 0.5)"
53
+ if h > 0.55:
54
+ return "persistent (long-range positive dependence; H > 0.5)"
55
+ return "random-walk-like (no long-range dependence; H ~ 0.5)"
56
+
57
+
58
+ def hurst(x) -> HurstResult:
59
+ """Hurst exponent by rescaled-range (Anis-Lloyd corrected) and by DFA; flags short-sample unreliability."""
60
+ import nolds
61
+ from hurst import compute_Hc
62
+
63
+ a = _clean(x)
64
+ n = len(a)
65
+ # R/S on the series levels (compute_Hc expects a positive-ish level series; use kind='change' for returns)
66
+ try:
67
+ h_rs, _c, _data = compute_Hc(a, kind="random_walk", simplified=True)
68
+ except Exception: # noqa: BLE001 - fall back to nolds if compute_Hc rejects the input
69
+ h_rs = float(nolds.hurst_rs(a, fit="poly"))
70
+ # Explicit least-squares slopes avoid nolds' unseeded randomized default.
71
+ dfa_alpha = float(nolds.dfa(a, fit_exp="poly"))
72
+ # DFA alpha maps to H: fGn regime alpha in [0,1] -> H=alpha; fBm regime alpha in [1,2] -> H=alpha-1
73
+ h_from_dfa = dfa_alpha if dfa_alpha <= 1.0 else dfa_alpha - 1.0
74
+ return HurstResult(
75
+ hurst_rs=float(h_rs), dfa_alpha=dfa_alpha,
76
+ interpretation=_interpret_hurst(0.5 * (float(h_rs) + h_from_dfa)),
77
+ reliable=bool(n >= 100),
78
+ reference="Hurst 1951, Trans. ASCE 116:770-799 (R/S); Peng et al. 1994, Phys. Rev. E 49:1685-1689 (DFA)",
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class MultifractalSpectrum:
84
+ """MF-DFA output: generalized Hurst h(q), mass exponent tau(q), and the singularity spectrum f(alpha)."""
85
+
86
+ q: np.ndarray
87
+ hq: np.ndarray # generalized Hurst exponent per q
88
+ tau: np.ndarray # mass exponent tau(q) = q*h(q) - 1
89
+ alpha: np.ndarray # singularity strength (Holder exponent)
90
+ f_alpha: np.ndarray # singularity spectrum
91
+ width: float # max(alpha) - min(alpha): the degree of multifractality
92
+ is_multifractal: bool # width above a small threshold
93
+ reference: str
94
+
95
+
96
+ def mfdfa(x, q=None, order: int = 1) -> MultifractalSpectrum:
97
+ """MF-DFA generalized Hurst h(q) and the Legendre singularity spectrum f(alpha) (Kantelhardt 2002)."""
98
+ from MFDFA import MFDFA as _MFDFA
99
+
100
+ a = _clean(x)
101
+ n = len(a)
102
+ if n < 200:
103
+ raise ValueError("MF-DFA needs a longer series (>= ~200 points) for a stable spectrum")
104
+ q = np.asarray(q if q is not None else [-5, -3, -2, -1, 1, 2, 3, 5], dtype=float)
105
+ q = q[q != 0] # q=0 needs the log-averaging variant; skip it
106
+ # geometric scales from ~8 up to n/4 (Kantelhardt's recommended range)
107
+ lag = np.unique(np.geomspace(8, max(16, n // 4), 24).astype(int))
108
+ lag2, dfa = _MFDFA(a, lag=lag, q=q, order=order)
109
+ # h(q) = slope of log F_q(s) vs log s per q
110
+ logs = np.log(lag2)
111
+ hq = np.array([np.polyfit(logs, np.log(dfa[:, i]), 1)[0] for i in range(len(q))])
112
+ tau = q * hq - 1.0
113
+ # singularity spectrum via the Legendre transform: alpha = dtau/dq, f = q*alpha - tau
114
+ alpha = np.gradient(tau, q)
115
+ f_alpha = q * alpha - tau
116
+ width = float(np.max(alpha) - np.min(alpha))
117
+ return MultifractalSpectrum(
118
+ q=q, hq=hq, tau=tau, alpha=alpha, f_alpha=f_alpha, width=width,
119
+ is_multifractal=bool(width > 0.15),
120
+ reference="Kantelhardt et al. 2002, Physica A 316:87-114, DOI 10.1016/S0378-4371(02)01383-3",
121
+ )
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class FractalDimension:
126
+ """Graph fractal dimension by three estimators (D in [1, 2]; higher = rougher)."""
127
+
128
+ higuchi: float
129
+ katz: float
130
+ petrosian: float
131
+ reference: str
132
+
133
+
134
+ def fractal_dimension(x, kmax: int = 10) -> FractalDimension:
135
+ """Higuchi, Katz, and Petrosian fractal dimension of the series graph (antropy)."""
136
+ import antropy as ant
137
+
138
+ a = _clean(x)
139
+ return FractalDimension(
140
+ higuchi=float(ant.higuchi_fd(a, kmax=kmax)), katz=float(ant.katz_fd(a)),
141
+ petrosian=float(ant.petrosian_fd(a)),
142
+ reference="Higuchi 1988, Physica D 31:277-283, DOI 10.1016/0167-2789(88)90081-4; Katz 1988; Petrosian 1995",
143
+ )
144
+
145
+
146
+ def dcca_coefficient(x, y, scale: int | None = None) -> dict:
147
+ """Detrended cross-correlation coefficient rho_DCCA at one scale (Zebende 2011), in [-1, 1].
148
+
149
+ Measures scale-dependent cross-correlation between two nonstationary series after local detrending.
150
+ Uses a direct DCCA implementation (Podobnik & Stanley 2008): integrate both series, slide a window,
151
+ locally detrend, and form the detrended covariance normalized by the two detrended variances.
152
+ """
153
+ a, b = _clean(x), _clean(y)
154
+ m = min(len(a), len(b))
155
+ a, b = a[:m], b[:m]
156
+ if scale is None:
157
+ scale = max(8, m // 8)
158
+ if scale >= m:
159
+ raise ValueError("scale must be smaller than the series length")
160
+ # integrate to profiles
161
+ A = np.cumsum(a - a.mean())
162
+ B = np.cumsum(b - b.mean())
163
+ fa2 = fb2 = fab = 0.0
164
+ count = 0
165
+ t = np.arange(scale + 1, dtype=float)
166
+ for start in range(m - scale):
167
+ wa, wb = A[start:start + scale + 1], B[start:start + scale + 1]
168
+ # local linear detrend
169
+ ca = np.polyfit(t, wa, 1)
170
+ cb = np.polyfit(t, wb, 1)
171
+ ra = wa - np.polyval(ca, t)
172
+ rb = wb - np.polyval(cb, t)
173
+ fab += float(np.mean(ra * rb))
174
+ fa2 += float(np.mean(ra * ra))
175
+ fb2 += float(np.mean(rb * rb))
176
+ count += 1
177
+ denom = np.sqrt(fa2 * fb2) if fa2 > 0 and fb2 > 0 else 0.0
178
+ rho = float(fab / denom) if denom > 0 else float("nan")
179
+ return {"scale": int(scale), "rho_dcca": rho, "n_windows": count,
180
+ "reference": "Podobnik & Stanley 2008, PRL 100:084102, DOI 10.1103/PhysRevLett.100.084102; Zebende 2011, Physica A 390:614-618"}
181
+
182
+
183
+ def fractal_report(x) -> dict:
184
+ """Run the fractal panel (Hurst + fractal dimension + MF-DFA where the series is long enough)."""
185
+ a = _clean(x)
186
+ h = hurst(a)
187
+ fd = fractal_dimension(a)
188
+ out = {
189
+ "n": len(a),
190
+ "hurst": {"rs": h.hurst_rs, "dfa_alpha": h.dfa_alpha, "interpretation": h.interpretation,
191
+ "reliable": h.reliable, "reference": h.reference},
192
+ "fractal_dimension": {"higuchi": fd.higuchi, "katz": fd.katz, "petrosian": fd.petrosian,
193
+ "reference": fd.reference},
194
+ "arfima_link": {"d": round(h.hurst_rs - 0.5, 4),
195
+ "note": "long-memory fractional-differencing order d ~ H - 0.5 (Granger-Joyeux 1980)"},
196
+ }
197
+ try:
198
+ mf = mfdfa(a)
199
+ out["multifractal"] = {
200
+ "q": mf.q.tolist(), "hq": mf.hq.tolist(), "alpha": mf.alpha.tolist(),
201
+ "f_alpha": mf.f_alpha.tolist(), "width": mf.width, "is_multifractal": mf.is_multifractal,
202
+ "reference": mf.reference,
203
+ }
204
+ except Exception as exc: # noqa: BLE001 - series too short or degenerate; record, do not crash
205
+ out["multifractal"] = {"error": f"{type(exc).__name__}: {exc}"}
206
+ return out