fjohansen 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.
fjohansen/__init__.py ADDED
@@ -0,0 +1,127 @@
1
+ """
2
+ fjohansen
3
+ =========
4
+
5
+ Python implementation of the Johansen cointegration test with Fourier-type
6
+ smooth nonlinear deterministic trends restricted to cointegrating relations,
7
+ as developed in:
8
+
9
+ Kurita, T. and Shintani, M. (2025), "Johansen test with Fourier-type
10
+ smooth nonlinear trends in cointegrating relations", *Econometric
11
+ Reviews*, 44(10), 1589-1616. https://doi.org/10.1080/07474938.2025.2530640
12
+
13
+ The library also bundles the FGLS Wald test of
14
+
15
+ Perron, P., Shintani, M. and Yabu, T. (2017, 2021), "Testing for flexible
16
+ nonlinear trends with an integrated or stationary noise component",
17
+ *Oxford Bulletin of Economics and Statistics* / Working Paper.
18
+
19
+ used by Kurita & Shintani as a frequency-selection pre-step.
20
+
21
+ Package metadata
22
+ ----------------
23
+ :author: Merwan Roudane
24
+ :repo: https://github.com/merwanroudane/fjohansen
25
+ :license: MIT
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ __version__ = "0.1.0"
31
+ __author__ = "Merwan Roudane"
32
+ __url__ = "https://github.com/merwanroudane/fjohansen"
33
+
34
+ from .core import JohansenFourier, JohansenFourierResults, fit_johansen_fourier
35
+ from .critical_values import CNR_TABLE_B1, p_value, quantile, moments
36
+ from .frequency_select import (
37
+ psy_wald_test,
38
+ select_frequencies,
39
+ select_frequencies_univariate,
40
+ FrequencySelectionResult,
41
+ PSY_TAU_TABLE,
42
+ )
43
+ from .models import MODELS, ModelSpec, build_design_matrices
44
+ from .simulation import (
45
+ simulate_limit_distribution,
46
+ simulate_limit_moments,
47
+ clear_cache,
48
+ )
49
+ from .data import (
50
+ generate_nf_dgp1,
51
+ generate_nf_dgp2,
52
+ generate_nf_dgp3,
53
+ generate_nf_dgp4,
54
+ generate_f_dgp1,
55
+ generate_f_dgp2,
56
+ sample_jgb_data,
57
+ )
58
+ from .tables import (
59
+ format_trace_table,
60
+ format_trace_latex,
61
+ format_trace_html,
62
+ rich_print,
63
+ )
64
+ from .plotting import (
65
+ set_paper_style,
66
+ plot_series,
67
+ plot_limit_density,
68
+ plot_recursive_rejection,
69
+ plot_eigenvalues,
70
+ plot_long_run,
71
+ plot_risk_premium,
72
+ plot_residual_diagnostics,
73
+ )
74
+ from .utils import fourier_basis
75
+
76
+ __all__ = [
77
+ "__version__",
78
+ "__author__",
79
+ "__url__",
80
+ # core
81
+ "JohansenFourier",
82
+ "JohansenFourierResults",
83
+ "fit_johansen_fourier",
84
+ # critical values
85
+ "p_value",
86
+ "quantile",
87
+ "moments",
88
+ "CNR_TABLE_B1",
89
+ # frequency selection
90
+ "psy_wald_test",
91
+ "select_frequencies",
92
+ "select_frequencies_univariate",
93
+ "FrequencySelectionResult",
94
+ "PSY_TAU_TABLE",
95
+ # models
96
+ "MODELS",
97
+ "ModelSpec",
98
+ "build_design_matrices",
99
+ # simulation
100
+ "simulate_limit_distribution",
101
+ "simulate_limit_moments",
102
+ "clear_cache",
103
+ # data
104
+ "generate_nf_dgp1",
105
+ "generate_nf_dgp2",
106
+ "generate_nf_dgp3",
107
+ "generate_nf_dgp4",
108
+ "generate_f_dgp1",
109
+ "generate_f_dgp2",
110
+ "sample_jgb_data",
111
+ # tables
112
+ "format_trace_table",
113
+ "format_trace_latex",
114
+ "format_trace_html",
115
+ "rich_print",
116
+ # plotting
117
+ "set_paper_style",
118
+ "plot_series",
119
+ "plot_limit_density",
120
+ "plot_recursive_rejection",
121
+ "plot_eigenvalues",
122
+ "plot_long_run",
123
+ "plot_risk_premium",
124
+ "plot_residual_diagnostics",
125
+ # utils
126
+ "fourier_basis",
127
+ ]
fjohansen/core.py ADDED
@@ -0,0 +1,345 @@
1
+ """
2
+ ``JohansenFourier`` -- the main public class.
3
+
4
+ Performs the reduced-rank regression of Kurita & Shintani (2025) for a
5
+ cointegrated VAR(k) with restricted Fourier-type smooth nonlinear trends in
6
+ the cointegrating space.
7
+
8
+ Usage
9
+ -----
10
+ >>> from fjohansen import JohansenFourier
11
+ >>> res = JohansenFourier(data, k=3, n=5, model='CNR').fit()
12
+ >>> print(res.summary())
13
+ >>> res.plot_eigenvalues()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Optional, Sequence
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from .critical_values import p_value, quantile
25
+ from .models import MODELS, ModelSpec, build_design_matrices
26
+ from .utils import ensure_array, ols_residuals
27
+
28
+ __all__ = ["JohansenFourier", "JohansenFourierResults"]
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Results container
33
+ # ---------------------------------------------------------------------------
34
+ @dataclass
35
+ class JohansenFourierResults:
36
+ """Estimated quantities returned by :meth:`JohansenFourier.fit`."""
37
+
38
+ spec: ModelSpec
39
+ p: int
40
+ k: int
41
+ n: int
42
+ T_eff: int
43
+
44
+ eigenvalues: np.ndarray # length p, sorted descending
45
+ eigenvectors: np.ndarray # raw eigenvectors of S_11^{-1/2} ...
46
+
47
+ trace_stats: pd.DataFrame # one row per r in 0..p-1
48
+ selected_rank: Optional[int] = None
49
+
50
+ alpha: Optional[np.ndarray] = None
51
+ beta: Optional[np.ndarray] = None # X_{t-1} block
52
+ delta: Optional[np.ndarray] = None # Fourier block (or None)
53
+ gamma_const: Optional[np.ndarray] = None
54
+ gamma_trend: Optional[np.ndarray] = None
55
+ Gamma: Optional[list[np.ndarray]] = None
56
+ Phi_unrestricted: Optional[np.ndarray] = None # coeffs on Z2
57
+ Sigma: Optional[np.ndarray] = None # residual covariance
58
+
59
+ residuals: Optional[np.ndarray] = None # T_eff x p
60
+ fitted_long_run: Optional[np.ndarray] = None # beta' X + delta' F + gamma
61
+ series_names: Sequence[str] = field(default_factory=list)
62
+ t_index: Optional[np.ndarray] = None
63
+
64
+ # ------------------------------------------------------------------
65
+ # Pretty summary
66
+ # ------------------------------------------------------------------
67
+ def summary(self, sig_level: float = 0.05) -> str:
68
+ """Return a formatted text summary akin to Table 3 of the paper."""
69
+ from .tables import format_trace_table, format_header
70
+ head = format_header(self)
71
+ body = format_trace_table(self.trace_stats, sig_level=sig_level)
72
+ if self.selected_rank is not None:
73
+ tail = (
74
+ f"\nSelected cointegrating rank (sequential 5%-test): "
75
+ f"r = {self.selected_rank}\n"
76
+ f"=> Number of common stochastic trends p - r "
77
+ f"= {self.p - self.selected_rank}.\n"
78
+ )
79
+ else:
80
+ tail = ""
81
+ return head + "\n" + body + tail
82
+
83
+ def to_latex(self, **kw) -> str:
84
+ from .tables import format_trace_latex
85
+ return format_trace_latex(self.trace_stats, spec=self.spec, n=self.n, **kw)
86
+
87
+ def to_html(self, **kw) -> str:
88
+ from .tables import format_trace_html
89
+ return format_trace_html(self.trace_stats, spec=self.spec, n=self.n, **kw)
90
+
91
+ # ------------------------------------------------------------------
92
+ # Convenience plots (forwarders)
93
+ # ------------------------------------------------------------------
94
+ def plot_eigenvalues(self, **kw):
95
+ from .plotting import plot_eigenvalues
96
+ return plot_eigenvalues(self, **kw)
97
+
98
+ def plot_long_run(self, **kw):
99
+ from .plotting import plot_long_run
100
+ return plot_long_run(self, **kw)
101
+
102
+ def plot_residual_diagnostics(self, **kw):
103
+ from .plotting import plot_residual_diagnostics
104
+ return plot_residual_diagnostics(self, **kw)
105
+
106
+ def plot_risk_premium(self, **kw):
107
+ from .plotting import plot_risk_premium
108
+ return plot_risk_premium(self, **kw)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Main test class
113
+ # ---------------------------------------------------------------------------
114
+ class JohansenFourier:
115
+ """
116
+ Johansen cointegrating-rank test with Fourier-type smooth nonlinear
117
+ deterministic trends restricted to the cointegrating space.
118
+
119
+ Parameters
120
+ ----------
121
+ data : array_like or DataFrame, shape (T, p)
122
+ Multivariate I(1) time series in levels.
123
+ k : int
124
+ VAR order in levels (``k - 1`` lagged differences will be used).
125
+ n : int, default 1
126
+ Number of Fourier frequencies. Use ``n = 0`` to recover the standard
127
+ Johansen models (set ``model='constant'`` or ``'linear'`` instead).
128
+ model : {'CNR', 'LNR', 'CNU', 'LNU', 'constant', 'linear'}, default 'CNR'
129
+ Deterministic specification (see :mod:`fjohansen.models`).
130
+ """
131
+
132
+ def __init__(
133
+ self,
134
+ data,
135
+ k: int,
136
+ n: int = 1,
137
+ model: str = "CNR",
138
+ ):
139
+ self.X, self.series_names = ensure_array(data)
140
+ self.k = int(k)
141
+ self.n = int(n)
142
+ self.spec = MODELS[model.upper().replace(" ", "_")]
143
+ if not self.spec.has_fourier and self.n > 0:
144
+ # silently zero-out n for standard models so the table look-up
145
+ # uses the right column.
146
+ self.n = 0
147
+
148
+ # ------------------------------------------------------------------
149
+ def fit(
150
+ self,
151
+ sig_level: float = 0.05,
152
+ select_rank: bool = True,
153
+ compute_pvalues: bool = True,
154
+ compute_estimates: bool = True,
155
+ n_sims: int = 5_000,
156
+ ) -> JohansenFourierResults:
157
+ """
158
+ Run the reduced-rank regression and the trace test.
159
+
160
+ Parameters
161
+ ----------
162
+ sig_level : float, default 0.05
163
+ Significance level used for the sequential rank selection.
164
+ select_rank : bool, default True
165
+ If True, pick rank via Johansen's sequential procedure
166
+ (r = 0, 1, ..., p - 1).
167
+ compute_pvalues : bool, default True
168
+ If True, compute p-values via the Gamma approximation.
169
+ compute_estimates : bool, default True
170
+ If True, return alpha, beta, delta and the residuals at the
171
+ selected rank.
172
+ n_sims : int, default 30_000
173
+ Number of replications used to simulate the limit distribution
174
+ for cells outside Table B1.
175
+ """
176
+ Z0, Z1, Z2, info = build_design_matrices(
177
+ self.X, k=self.k, n=self.n, model=self.spec
178
+ )
179
+ p = info["p"]
180
+ T_eff = info["T_eff"]
181
+
182
+ # --- partial-out Z2 from Z0 and Z1 ------------------------------
183
+ R0 = ols_residuals(Z0, Z2)
184
+ R1 = ols_residuals(Z1, Z2)
185
+
186
+ # --- product moments -------------------------------------------
187
+ S00 = R0.T @ R0 / T_eff
188
+ S11 = R1.T @ R1 / T_eff
189
+ S01 = R0.T @ R1 / T_eff
190
+ S10 = S01.T
191
+
192
+ # --- generalized eigenvalue problem ----------------------------
193
+ # det(lambda * S11 - S10 S00^{-1} S01) = 0
194
+ # Equivalent to standard eig of S11^{-1/2} S10 S00^{-1} S01 S11^{-1/2}.
195
+ try:
196
+ L = np.linalg.cholesky(S11)
197
+ L_inv = np.linalg.solve(L, np.eye(S11.shape[0]))
198
+ except np.linalg.LinAlgError as exc:
199
+ raise np.linalg.LinAlgError(
200
+ "S11 is not positive-definite; the model is probably "
201
+ "mis-specified (e.g. multi-collinear deterministics)."
202
+ ) from exc
203
+ S00_inv = np.linalg.pinv(S00)
204
+ M = L_inv @ S10 @ S00_inv @ S01 @ L_inv.T # symmetric in theory
205
+ M = (M + M.T) / 2.0
206
+ eigvals, eigvecs = np.linalg.eigh(M) # ascending
207
+ order = np.argsort(eigvals)[::-1]
208
+ eigvals = np.clip(eigvals[order], 0.0, 1.0)
209
+ eigvecs = eigvecs[:, order]
210
+ # Transform back to original metric: v_i = L^{-T} u_i, normalised by
211
+ # v' S11 v = I.
212
+ V = L_inv.T @ eigvecs
213
+
214
+ # --- trace statistics ------------------------------------------
215
+ rows = []
216
+ for r in range(p):
217
+ tail = eigvals[r:]
218
+ stat = float(-T_eff * np.sum(np.log1p(-tail)))
219
+ p_r = p - r
220
+ cv95 = quantile("95%", p_r, self.n, model=self.spec.code)
221
+ cv99 = quantile("99%", p_r, self.n, model=self.spec.code)
222
+ pval = (
223
+ p_value(stat, p_r, self.n, model=self.spec.code, n_sims=n_sims)
224
+ if compute_pvalues
225
+ else np.nan
226
+ )
227
+ rows.append(
228
+ dict(
229
+ null=f"r <= {r}",
230
+ eigenvalue=eigvals[r],
231
+ trace_stat=stat,
232
+ cv_95=cv95,
233
+ cv_99=cv99,
234
+ p_value=pval,
235
+ )
236
+ )
237
+ trace_df = pd.DataFrame(rows)
238
+
239
+ # --- sequential rank selection ---------------------------------
240
+ selected_rank = None
241
+ if select_rank:
242
+ for r in range(p):
243
+ row = trace_df.iloc[r]
244
+ pv = row["p_value"]
245
+ stat = row["trace_stat"]
246
+ cv = quantile(1 - sig_level, p - r, self.n, model=self.spec.code)
247
+ reject = (pv < sig_level) if np.isfinite(pv) else (stat > cv)
248
+ if not reject:
249
+ selected_rank = r
250
+ break
251
+ if selected_rank is None:
252
+ selected_rank = p # full rank, stationary system
253
+
254
+ # --- estimates at the chosen rank ------------------------------
255
+ alpha = beta_X = delta_F = gamma_c = gamma_t = None
256
+ Gamma_list = None
257
+ Phi_un = None
258
+ Sigma = None
259
+ residuals = None
260
+ fitted_long_run = None
261
+ if compute_estimates and selected_rank is not None and selected_rank > 0:
262
+ r = selected_rank
263
+ beta_full = V[:, :r] # shape (q1, r)
264
+ # alpha = S_{01} beta (beta' S_{11} beta)^{-1}
265
+ mid = beta_full.T @ S11 @ beta_full
266
+ alpha = S01 @ beta_full @ np.linalg.pinv(mid)
267
+ # Split beta_full into (beta on X, delta on F, gamma on const/trend)
268
+ offset = 0
269
+ beta_X = beta_full[offset : offset + p, :]
270
+ offset += p
271
+ if self.spec.z1_fourier and self.n > 0:
272
+ delta_F = beta_full[offset : offset + 2 * self.n, :]
273
+ offset += 2 * self.n
274
+ if self.spec.z1_constant:
275
+ gamma_c = beta_full[offset : offset + 1, :]
276
+ offset += 1
277
+ if self.spec.z1_trend:
278
+ gamma_t = beta_full[offset : offset + 1, :]
279
+ offset += 1
280
+
281
+ # Estimate Phi (coefficients on Z2) and residuals.
282
+ if Z2.shape[1] > 0:
283
+ # Conditional on (alpha beta'), regress Z0 - alpha beta' Z1
284
+ # on Z2. Equivalent to regressing (Z0 | Z2, alpha beta' Z1).
285
+ rhs = np.concatenate(
286
+ [Z2, Z1 @ beta_full], axis=1
287
+ )
288
+ coef, *_ = np.linalg.lstsq(rhs, Z0, rcond=None)
289
+ Phi_un = coef[: Z2.shape[1], :]
290
+ # second block is alpha (it should match the closed-form)
291
+ # Long-run fitted: beta_full' Z1 (without alpha, this is the
292
+ # cointegrating relation).
293
+ fitted_long_run = Z1 @ beta_full
294
+ # Residuals from the full ECM:
295
+ ecm = Z0 - (Z1 @ beta_full) @ alpha.T
296
+ if Z2.shape[1] > 0 and Phi_un is not None:
297
+ ecm = ecm - Z2 @ Phi_un
298
+ residuals = ecm
299
+ Sigma = residuals.T @ residuals / T_eff
300
+
301
+ # Pack Gamma_i for convenience
302
+ if Z2.shape[1] > 0 and Phi_un is not None:
303
+ Gamma_list = []
304
+ idx = 0
305
+ for _ in range(self.k - 1):
306
+ Gamma_list.append(Phi_un[idx : idx + p, :].T)
307
+ idx += p
308
+
309
+ return JohansenFourierResults(
310
+ spec=self.spec,
311
+ p=p,
312
+ k=self.k,
313
+ n=self.n,
314
+ T_eff=T_eff,
315
+ eigenvalues=eigvals,
316
+ eigenvectors=V,
317
+ trace_stats=trace_df,
318
+ selected_rank=selected_rank,
319
+ alpha=alpha,
320
+ beta=beta_X,
321
+ delta=delta_F,
322
+ gamma_const=gamma_c,
323
+ gamma_trend=gamma_t,
324
+ Gamma=Gamma_list,
325
+ Phi_unrestricted=Phi_un,
326
+ Sigma=Sigma,
327
+ residuals=residuals,
328
+ fitted_long_run=fitted_long_run,
329
+ series_names=self.series_names,
330
+ t_index=info["t_index"],
331
+ )
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Convenience function
336
+ # ---------------------------------------------------------------------------
337
+ def fit_johansen_fourier(
338
+ data,
339
+ k: int,
340
+ n: int = 1,
341
+ model: str = "CNR",
342
+ **fit_kwargs,
343
+ ) -> JohansenFourierResults:
344
+ """One-shot wrapper around :class:`JohansenFourier`."""
345
+ return JohansenFourier(data, k=k, n=n, model=model).fit(**fit_kwargs)