Skip to content

The tsecon gallery — every method, worked

Each section: what the method is, when you reach for it, runnable code on synthetic data, and the figure it produces. All figures are generated by showcase.py in the library's house style (Module 13) and regenerate with one command:

.venv/bin/python docs/examples/showcase.py

Every number these methods produce is validated against a reference implementation (statsmodels, SciPy, NumPy, arch) in the test suite — the fixtures pin the golden values.


1 · Exploration: ACF and PACF

Use case: the first look at any series — how persistent is it, and what lag structure should a model have? The classic Box-Jenkins identification pattern: an AR(p) shows geometric ACF decay and a PACF that cuts off after lag p; an MA(q) shows the mirror image.

import tsecon
r = tsecon.acf(y, nlags=24)        # dict: acf, bartlett_se
p = tsecon.pacf(y, nlags=24)       # Yule-Walker ("yw") or "ols"

ACF/PACF

The synthetic series is an AR(2) with φ = (1.3, −0.4): the ACF decays smoothly (with a hint of oscillation), while the PACF shows exactly two spikes and then dies — read "fit an AR(2)" straight off the plot. Matches statsmodels at 1e-12.


2 · The stationarity workflow

Use case: before fitting anything, decide whether the series needs differencing. Running ADF alone is a common mistake — ADF's null is a unit root, KPSS's null is stationarity, and only together do they give a confident answer. check_stationarity() runs both and classifies the confirmatory quadrant.

rep = tsecon.check_stationarity(y)
rep["quadrant"]         # "Stationary" | "UnitRoot" | "Conflict" | "Inconclusive"
rep["recommendation"]   # "Proceed" | "Difference" | "Detrend"
rep["interpretation"]   # a plain-language explanation of the evidence

Stationarity

Left to right: a stationary AR(1) (both tests agree: proceed), a random walk with drift (both tests agree: difference it), and the differenced walk (proceed) — the workflow loop closes. The underlying tsecon.adf carries the full MacKinnon p-value response surfaces; tsecon.kpss uses the Hobijn-Franses-Ooms automatic bandwidth. Both match statsmodels at 1e-8.


3 · Robust standard errors (HAC / Newey-West)

Use case: any regression on time series data. With autocorrelated errors, textbook OLS standard errors are too small — you'll find "significant" effects that aren't there. HAC (heteroskedasticity- and autocorrelation-consistent) standard errors fix the variance estimate without changing the coefficients.

r = tsecon.ols(y, X, se_type="hac")          # Newey-West, automatic bandwidth
r = tsecon.ols(y, X, se_type="hac", maxlags=8)
r = tsecon.ols(y, X, se_type="hc1")          # heteroskedasticity-only
tsecon.long_run_variance(x, kernel="qs")     # the underlying LRV machinery

Robust SEs

Left: one representative sample where the naive intervals only just graze the true β while the wider HAC interval covers it comfortably. Right: the same comparison run 3,000 times (9,000 regressions, seconds through the Rust core): nominal 95% intervals cover only ~75% of the time under iid or White standard errors; Newey-West restores most of the gap (the remainder is the well-known small-sample HAC undercoverage — the roadmap's EWC fixed-b inference, already implemented in the crate, is the modern answer). HAC standard errors match statsmodels cov_type="HAC" at 1e-10.


4 · Bootstrap for dependent data

Use case: inference when asymptotics are dubious — small samples, non-normal data, complicated statistics. The catch with time series: naive iid resampling destroys the dependence structure and gives you confidence intervals that are far too narrow. Block schemes resample contiguous chunks to preserve it.

opt = tsecon.optimal_block_length(y)                   # Politis-White (2004)
idx = tsecon.bootstrap_indices(n, scheme="stationary", # Politis-Romano
                               seed=b, p=1/opt["stationary"])
resampled = y[idx]

Bootstrap

The bootstrap distribution of the sample mean of an AR(0.8) series, 4,000 replications: the iid bootstrap (light) is dramatically overconfident — about 3× too narrow — while the stationary block bootstrap (dark) with the automatically selected block length lands on the correct asymptotic distribution (black curve). Wild bootstrap weights (Rademacher, Mammen) are also available for regression settings. Same seed ⇒ bit-identical draws at any thread count.


5 · State-space models and the Kalman filter

Use case: extracting an unobserved signal from noisy measurements — trend extraction, missing-data interpolation, and the estimation engine under ARIMA, unobserved-components, and nowcasting models. This is the library's single most load-bearing component: exact diffuse initialization, missing data handled natively.

r = tsecon.local_level_smooth(y, sigma2_eps=4.8, sigma2_eta=0.5)
r["smoothed_state"]       # E[level_t | all data]  — NaNs bridged automatically
r["smoothed_state_var"]   # honest uncertainty, wider where data are missing
tsecon.ar_loglik(y, [0.6, -0.2], sigma2=1.4, intercept=1.5)  # exact MLE kernel

Kalman

A local-level model with a 25-period gap in the observations: the smoother bridges the gap, the 95% band balloons exactly where information is missing, and the true latent path (dashed, never seen by the model inside the gap) stays within the band. Matches statsmodels' exact-diffuse results at ~1e-11.


6 · VAR: impulse responses and variance decomposition

Use case: the workhorse of empirical macro — how does a system of variables respond dynamically to a shock in one of them? Fit a VAR, identify shocks (Cholesky here; the full identification suite is Module 06), and read off impulse responses and variance decompositions.

r    = tsecon.var_fit(data, lags=2)                 # params, sigma_u, ICs, stability
irf  = tsecon.var_irf(data, lags=2, horizon=16)     # [h][response][shock]
fevd = tsecon.var_fevd(data, lags=2, horizon=16)
g    = tsecon.var_granger(data, caused=[0], causing=[1], lags=2)
fc   = tsecon.var_forecast(data, lags=2, steps=8)   # point + intervals

VAR IRFs

The synthetic system has a built-in causal story (demand moves first, output responds with a lag, the policy rate leans against both), and the estimated IRF grid recovers it — including the near-zero response of demand to output shocks that the recursive ordering implies. Every array matches statsmodels at 1e-8.

FEVD


7 · Trend-cycle filters

Use case: separating the business cycle from the trend. This is a methodological minefield — Hamilton (2018) famously argued "why you should never use the HP filter" — so the library ships the alternatives side by side and makes their disagreement visible rather than hiding it.

hp  = tsecon.hp_filter(gdp, lamb=1600.0)       # + one_sided=True real-time variant
ham = tsecon.hamilton_filter(gdp, h=8, p=4)    # the modern alternative
bk  = tsecon.bk_filter(gdp, low=6, high=32, k=12)
cf  = tsecon.cf_filter(gdp, low=6, high=32)

Filters

Real US GDP: the HP and Baxter-King cycles broadly agree; the Hamilton filter reads recessions deeper and turns later — a genuine methodological difference, not a bug. Filters that lose observations return explicit alignment metadata (first_index) so nothing silently misaligns. HP runs through an O(n) pentadiagonal solver, never a dense matrix.


8 · Forecast evaluation

Use case: the discipline layer. Never report a forecast without a benchmark and never claim superiority without a test. MASE scales errors by an in-sample naive forecast (1.0 = "no better than naive"); the Diebold-Mariano test asks whether an accuracy difference is statistically real, with the Harvey-Leybourne-Newbold small-sample correction.

fc  = tsecon.theta_forecast(y, steps=20, period=4)   # the M3-winning benchmark
acc = tsecon.accuracy(actual, fc, insample=train, period=4)  # rmse/mae/mase/...
dm  = tsecon.dm_test(e_benchmark, e_model, h=1, loss="squared")

Forecast evaluation

Theta beats the seasonal naive decisively here (MASE 2.23 vs 3.43, with the plain naive at 5.71; DM stat 4.96, p = 0.000) — and the plot shows why: it captures both the trend and the seasonal shape. The DM statistic matches the documented reference case at 1e-10.


9 · GARCH: volatility and risk

Use case: any application where the size of movements matters — risk management, option pricing, portfolio construction. Volatility clusters (calm and stormy periods alternate), which makes it forecastable even when returns themselves are not.

r = tsecon.garch_fit(returns, vol="garch", dist="normal", forecast_horizon=60)
r["params"], r["se_robust"]         # QMLE + Bollerslev-Wooldridge robust SEs
r["conditional_volatility"]         # the fitted sigma_t path
r["variance_forecast"]              # analytic multi-step, mean-reverting
# also: vol="gjr" (asymmetry), vol="egarch", dist="t" (fat tails)

GARCH

The fitted persistence (α + β ≈ 0.98) says volatility shocks take months to die out — the long-run anchor and mean-reverting forecast fan follow directly. Fixed-parameter likelihoods match Kevin Sheppard's arch package at machine precision, and our optimizer's fits match or beat arch's optimum on every golden case.


10 · Bayesian VAR: posterior impulse responses

Use case: VARs have many parameters and macro samples are short — the frequentist answer is noisy IRFs. The Bayesian answer is shrinkage: the Minnesota prior pulls coefficients toward a random-walk baseline, and the conjugate Normal-inverse-Wishart structure gives the entire posterior in closed form — no MCMC, no convergence worries, with the log marginal likelihood available to tune prior tightness on the evidence.

post = tsecon.bvar_fit(data, lags=2, lambda1=0.2)      # closed-form posterior
post["log_marginal_likelihood"]                         # evidence, for tuning
draws = tsecon.bvar_irf_draws(data, lags=2, horizon=12,
                              n_draws=800, seed=42)     # [draw][h][var][shock]
bands = np.quantile(draws, [0.05, 0.16, 0.5, 0.84, 0.95], axis=0)

BVAR IRFs

Real US data (GDP, consumption, investment growth): posterior medians with nested 68/90% credible bands from 800 seed-reproducible posterior draws. The analytic posterior is validated against the documented conjugate updating equations at 1e-13; MCMC diagnostics (tsecon.mcmc_diagnostics: ArviZ-exact R-hat and ESS) stand ready for the sampler-based models that follow.


11 · ARIMA: fit, forecast, fan chart

Use case: the default univariate forecasting model. Difference to stationarity, capture short-run dynamics with AR and MA terms, forecast, and undifference — with uncertainty that compounds correctly across the integration.

r = tsecon.arima_fit(y, p=1, d=1, q=1, constant=True, forecast_steps=16)
r["params"], r["loglik"], r["aic"]      # exact MLE via the Kalman engine
r["forecast_mean"], r["forecast_se"]     # undifferenced, exact cumulative variance
tsecon.ljung_box(r["residuals"])         # adequacy check: residuals ~ white noise?

ARIMA fan chart

Fitted by exact maximum likelihood (Monahan-transformed L-BFGS with Hannan-Rissanen starting values), the fan widens like √h exactly as the random-walk-plus-dynamics theory requires. A validation note worth telling: on the Nile benchmark our optimizer found a better optimum than the one statsmodels' default fit had stored in our golden fixture — the fixture had pinned a non-converged artifact, which independent cross-checking confirmed. Validation-first development cuts both ways.


Advanced

The numbered sections showed the methods one at a time. This wing composes them: four figures from the sophisticated end of applied practice, each built entirely from the primitives above. All regenerate with one command:

.venv/bin/python docs/examples/showcase_advanced.py

Local projections (Jordà 2005), from primitives

Use case: the modern default for estimating impulse responses without committing to a full VAR's dynamics — run one regression per horizon of y[t+h] on the shock (plus lag controls) and read the IRF straight off the shock coefficients. The h-step regression error is MA(h) by construction, so HAC standard errors are not optional — which is exactly what tsecon.ols provides.

for h in range(13):
    t = np.arange(4, n - h)
    X = np.column_stack([np.ones(len(t)), shock[t]]
                        + [y[t - lag] for lag in range(1, 5)])
    r = tsecon.ols(y[t + h], X, se_type="hac", maxlags=h + 1)
    irf[h], se[h] = r["params"][1], r["bse"][1]   # band: irf ± 1.645·se

Local projection

The test bed is an AR(2) whose true impulse response is computable analytically (the ψ-weights, dashed red), and the local projection tracks it inside its 90% HAC bands at every horizon — including the hump at h = 1 and the long geometric tail. This is Jordà (2005) built from tsecon primitives by hand; the shipped tsecon.lp (Module 07) packages the same estimator with lag-augmented inference as its default — see the structural wing below.

News impact curves: is volatility asymmetric?

Use case: in equity markets, bad news famously raises tomorrow's volatility more than good news of the same size. The news impact curve (Engle-Ng 1993) makes the question visual: plot the fitted σ²(ε) against the shock ε, holding the lagged variance at its unconditional level. GARCH is a symmetric parabola by construction; GJR adds a gamma term that kinks the curve at zero.

gj = tsecon.garch_fit(ret, vol="gjr", mean="zero")     # and vol="garch"
om, al, ga, be = gj["params"]
s2 = om / (1 - al - ga / 2 - be)                       # unconditional variance
nic = om + (al + ga * (eps < 0)) * eps**2 + be * s2    # the curve

News impact curves

An honest negative result, on purpose: the fixture returns are simulated from a symmetric GARCH, and the fitted GJR says so — γ̂ = −0.017 with a robust standard error of 0.015, statistically zero, so the two curves nearly coincide. That is the diagnostic working as designed: fit GJR, draw the curve, and let the kink (or its absence) tell you whether the leverage effect is in your data. On real equity returns γ̂ > 0 tilts the curve up steeply on the bad-news side.

The volatility term structure

Use case: risk over horizons — option pricing, margin setting, VaR at one day versus six months. A GARCH fit implies a whole term structure of expected volatility: from any starting state, forecasts decay toward the long-run anchor at rate α + β per period, and the fitted persistence tells you how slowly.

r = tsecon.garch_fit(ret, vol="garch", mean="zero", forecast_horizon=120)
term = np.sqrt(r["variance_forecast"])        # from the end-of-sample state
om, al, be = r["params"]
lr = om / (1 - al - be)                       # the long-run anchor
what_if = lr + (al + be) ** (h - 1) * (3 * lr - lr)   # closed-form, any state

Volatility term structure

The solid blue curve is the library's own variance_forecast, launched from the actual end-of-sample state. The API forecasts only from that state, so the dashed what-if curves (starting at 0.5×, 1×, and 3× the long-run variance) are the same closed-form recursion evaluated in numpy from the fitted (ω, α, β) — labelled as such. With persistence α + β = 0.9838 the half-life of a volatility shock is about 42 trading days: spikes are not noise, they are two-month weather systems.

BVAR prior tightness in one picture

Use case: the Minnesota prior's λ₁ is the shrinkage knob — how hard the data must argue against the random-walk baseline. Set it by eye and you're guessing; the conjugate NIW posterior gives the log marginal likelihood in closed form, so the evidence can pick it. This figure shows what the choice does where it matters: the impulse response and its uncertainty.

for lam in [0.05, 0.2, 1.0]:
    draws = np.array(tsecon.bvar_irf_draws(data, lags=2, horizon=12,
                                           n_draws=500, seed=42, lambda1=lam))
    gg = draws[:, :, 0, 0]                    # GDP response to a GDP shock
    lo, med, hi = np.quantile(gg, [0.05, 0.5, 0.95], axis=0)   # 90% band
    lml = tsecon.bvar_fit(data, lags=2, lambda1=lam)["log_marginal_likelihood"]

BVAR shrinkage

Too tight (λ₁ = 0.05) squeezes the response toward the prior and understates uncertainty; too loose (λ₁ = 1.0) is essentially OLS with wide, wobbly bands. The log marginal likelihood arbitrates: −873.5, −861.6, −876.9 — the evidence decisively prefers the middle setting, drawn in house blue. Same seed, same 500 draws per panel, so the differences you see are the prior, not Monte Carlo noise.


Structural & shrinkage

Three capabilities from the identification-and-regularization frontier — set-identified structural inference, honest local-projection bands, and penalized selection. Each figure is a real computation through the Rust core, and they regenerate with one command:

.venv/bin/python docs/examples/showcase_structural.py

Sign-restricted SVAR: the identified set

Use case: recursive (Cholesky) identification forces a variable ordering you may not believe. Sign restrictions ask for less — only the signs of a few impact responses, the kind of thing theory actually delivers ("a demand shock raises output, consumption, and investment on impact") — and return every impulse response consistent with them. The answer is not a point: it is an interval at each horizon, the identified set.

restr = [(0, 0, 0, "+"), (1, 0, 0, "+"), (2, 0, 0, "+")]  # (var, shock, horizon, sign)
r = tsecon.sign_restricted_svar(data, restrictions=restr, horizon=12, n_draws=800)
r["set_min"], r["set_max"]     # identified-set envelope       [h][var][shock]
r["quantiles"]                 # posterior 5/16/50/84/95 within the set
r["diagnostics"]               # rotations tried, acceptance rate

Sign-restricted SVAR

A demand shock lifts GDP growth, consumption, and investment on impact by construction, then each decays. The figure keeps two very different kinds of uncertainty visually separate: the lightest band is the identified set — the range of responses the restrictions alone cannot rule out (model ambiguity), and it is wide; the nested 68/90% bands inside it are posterior (sampling) uncertainty given a rotation, and they are much narrower. The Baumeister-Hamilton (2015) caveat is why the outer envelope, not the median, is the honest object: the uniform (Haar) prior over rotations is not flat over impulse responses, so it shapes the interior of the set even where the data are silent. About 54% of candidate rotations satisfy the restrictions here, reported in diagnostics.

Local projections with honest bands

Use case: the modern default for impulse responses (Jordà 2005) — one regression of y[t+h] on the shock per horizon, with no VAR dynamics to commit to. The subtlety is inference: the h-step error is serially correlated, and the naive HAC band is known to under-cover at longer horizons. Montiel Olea and Plagborg-Møller (2021) show that a lag-augmented projection restores valid, uniform coverage with a one-line fix — and it is tsecon.lp's default.

la = tsecon.lp(y, shock, horizons=16)                 # se="lag_augmented" (the default)
hc = tsecon.lp(y, shock, horizons=16, se="hac")       # the older HAC alternative
lo, hi = la["irf"] - 1.6449 * la["se"], la["irf"] + 1.6449 * la["se"]   # 90% band

Local projection vs truth

The test bed is an AR(2) driven by an observed shock, whose true impulse response is the analytic ψ-weights (dashed red) — so the recovery is checkable directly. Both the lag-augmented default and the HAC projection track the truth at every horizon, and their point estimates all but coincide. What differs is the bands: drawn with distinct fills, the two schemes disagree about width, and lag augmentation is the one carrying the coverage guarantee. Use it by default; reach for se="hac" only to reproduce older results. tsecon.lp_iv extends the same machinery to instrumented shocks with a first-stage F diagnostic.

The lasso path: shrinkage as selection

Use case: many candidate predictors, most of them noise — the setting where OLS overfits and you want the model itself to choose. The lasso's L1 penalty does double duty: it shrinks coefficients and sets most of them exactly to zero, so fitting and variable selection happen in one step. Sweeping the penalty α traces the classic coefficient path.

alphas = np.logspace(-2, 0.5, 60)                     # weak (small) → strong (large)
paths = np.array([tsecon.lasso(Xs, y, alpha=a)["coef"] for a in alphas])
# same core, other objectives:
tsecon.ridge(Xs, y, alpha)                            # shrink, never select
tsecon.elastic_net(Xs, y, alpha, l1_ratio=0.5)        # the in-between

Lasso path

A synthetic design hides five true nonzero coefficients among 55 pure-noise features. Read right to left as the penalty weakens: the five signal coefficients (house blue) emerge in order of strength, while the noise features (muted) stay pinned near zero until α is very small. The dashed marker is the penalty that prunes the model to exactly five features — and here those five are exactly the true support: sparsity delivering correct selection, not merely a smaller model. Ridge (tsecon.ridge) and the elastic net (tsecon.elastic_net) share the same coordinate-descent core for the shrink-don't-select and in-between cases.


Depth methods

Score-driven volatility: the robust Student-t GAS

Use case: volatility estimation when returns have fat tails and the occasional jump — the setting where a Gaussian GARCH-style filter over-reacts to a single large shock and reports spurious persistence. A GAS (generalized autoregressive score) model drives the variance by the score of the observation density; with a Student-t density that score down-weights outliers automatically.

r = returns                                   # a return series with jumps
g  = tsecon.gas_volatility(r, density="gaussian")
st = tsecon.gas_volatility(r, density="student_t")   # estimates the dof nu
vol_gaussian = np.sqrt(g["variance"])
vol_student  = np.sqrt(st["variance"])        # far calmer at the jumps

Score-driven volatility

The return series (top) carries three isolated jumps (red). At each one (dashed lines, bottom) the Gaussian GAS volatility (blue) spikes hard, because its score y² − f reacts one-for-one to the squared shock; the Student-t GAS (red) barely moves, because its score multiplies that shock by 1/(1 + y²/((ν−2)f)) — a soft-thresholding weight that treats a jump as a tail draw, not a change in the underlying volatility. The estimated ν ≈ 7 confirms the heavy tails the model exploits.

Connectedness: who spills over to whom

Use case: measuring how shocks propagate across a system of markets or sectors — which are net transmitters of volatility and which are receivers. The Diebold-Yilmaz framework reads this straight off a VAR's generalized forecast-error-variance decomposition.

res = tsecon.connectedness(data, lags=1, horizon=10)   # data is T × k
res["total"]        # system-wide spillover index (%)
res["net"]          # per-variable net = to-others − from-others
res["gfevd"]        # the full directional spillover table (rows sum to 1)

Connectedness

At a total connectedness of 66%, two-thirds of the system's forecast-error variance is cross-variable, not own. The heatmap (left) is the directional spillover table — row i, column j is the share of variable i's variance driven by shocks to j; the strong off-diagonal is the connectedness. The bars (right) rank each variable's net position: red variables transmit more than they receive, blue ones the reverse.

FAVAR: one policy shock, many responses

Use case: you want the response of hundreds of series to a single identified shock without estimating a hundred-variable VAR. A factor-augmented VAR compresses the information set to a few factors, identifies the shock in that small system, and maps the responses back onto every series through the loadings.

res = tsecon.favar(panel, policy, n_factors=2, lags=2, horizon=24)
res["irf_policy"]   # the policy variable's own response
res["irf_panel"]    # every information-panel series' response (N × horizon+1)

FAVAR

A recursive shock to the policy variable (bold) propagates through the factor VAR and, via the estimated loadings, onto all 24 series in the information panel (faint). Two are highlighted: a strong factor loader that moves visibly and a weak one that barely responds — the loadings decide who feels the shock.

The term structure in three numbers

Use case: summarizing an entire yield curve — dozens of maturities — with three interpretable factors (level, slope, curvature), and tracking how they move over time. The dynamic Nelson-Siegel model is the workhorse.

res = tsecon.dynamic_ns(panel, maturities, decay=0.0609)   # panel is T × n_maturities
res["level"], res["slope"], res["curvature"]               # the factor series
fit = tsecon.nelson_siegel(maturities, curve, decay=0.0609)  # one date's curve

Term structure

Each month's curve collapses to three numbers (left). The right panel shows the three-factor fit reconstructing a single date's curve to an \(R^2\) of 0.95 — a whole cross-section of yields captured by level, slope, and curvature.

Realized volatility: separating jumps from diffusion

Use case: with intraday data you can measure yesterday's volatility almost exactly — and split it into a smooth (diffusive) part and discrete jumps. Bipower variation is robust to jumps, so realized-variance-minus-bipower isolates the jump contribution, and the BNS ratio test says which days jumped.

m = tsecon.realized_measures(intraday_returns)   # one day of returns
m["rv"], m["bipower"], m["jump"]                 # total, continuous, jump
tsecon.bns_jump_test(intraday_returns)["ratio"]  # > 1.96 ≈ a 5% jump day

Realized volatility

Realized variance (thin) sits at or above the jump-robust bipower variation (blue); the shaded gap is the jump contribution, and the markers are the days the BNS ratio test flags as jumps — the big spikes are discrete jumps, not a change in the underlying diffusion.

Mean group vs CCE: the common-factor bias, and its cure

Use case: a panel whose units have genuinely different slopes, contaminated by an unobserved common factor (a global cycle, a common shock). Plain mean-group averaging is biased; the common-correlated-effects estimator purges the factor by augmenting each unit with cross-section averages.

mg  = tsecon.panel_mean_group(ys, xs, method="mg")    # Pesaran-Smith 1995
cce = tsecon.panel_mean_group(ys, xs, method="cce")   # Pesaran 2006, factor-robust

Mean group vs CCE

The dots are the per-unit slope estimates, the diamonds the group averages with 95% bands. Plain mean group (top) is pulled well to the right of the true mean slope by the common factor; CCE-MG (bottom) sits right on it — the correction the common-correlated-effects augmentation buys.


Roadmap extensions

The roadmap-E methods — recession probability, survey expectations, long memory, the CUSUM stability test, predictive regressions / IVX, the arbitrage-free Nelson-Siegel adjustment, and the linear rational-expectations (DSGE-lite) saddle path. All seven figures are generated by showcase_extensions.py:

.venv/bin/python docs/examples/showcase_extensions.py

Recession probability from a leading spread

Use case: turn a leading indicator — classically the term spread — into a calibrated probability of recession. A probit (or the Kauppi-Saikkonen dynamic probit) maps the spread to P(recession), the quantitative version of the "inverted yield curve" warning.

X = np.column_stack([np.ones(n), spread])
fit = tsecon.recession_probit(y, X, link="probit")   # y is the 0/1 indicator
fit["probabilities"]     # fitted P(recession); also params, pseudo_r2, ...

Recession probit

The spread (top) falls ahead of each shaded downturn; the fitted probability (bottom) climbs through 0.5 just before it, exactly as a leading indicator should. See the recession model card.

Survey expectations: rigidity and disagreement

Use case: test whether professional forecasts are fully rational and measure how much forecasters disagree. The Coibion-Gorodnichenko regression of the mean forecast error on the mean revision has slope zero under full-information rational expectations; a positive slope is the signature of information rigidity.

cg = tsecon.cg_regression(errors, revisions)   # HAC-robust; slope, implied_rigidity
dis = tsecon.forecast_disagreement(panel)      # per-period std, IQR, quantiles

Survey expectations

Left: the CG regression — the dashed line is the rational-expectations null (zero slope), the positive fit its sticky-information departure. Right: cross-sectional disagreement widening through a turbulent stretch. See the survey expectations card.

Long memory: a hyperbolic ACF and the filter that removes it

Use case: some series (realized volatility, inflation, trading volume) decay too slowly for an ARMA but are not unit-root either — they have long memory, 0 < d < 0.5. Estimate d, then fractionally difference to whiten.

d_hat = tsecon.long_memory_d(x, method="local_whittle")["d"]   # Robinson 1995
x_white = tsecon.frac_diff(x, d_hat)                            # (1 - L)^d

Long memory

Left: the raw series' ACF decays hyperbolically — still 0.2 at lag 40. Right: after (1 − L)^d̂ the autocorrelation sits inside the 95% band; the estimate covers the true d = 0.40. See the long-memory card.

CUSUM: parameter stability over the sample

Use case: did the regression relationship hold constant, or did the coefficients drift/break somewhere you did not pre-specify? The Brown-Durbin-Evans CUSUM of recursive residuals is a random walk near zero under stable coefficients and walks out of its 5% band when they shift.

cs = tsecon.cusum_test(y, X)          # path, bound_upper, bound_lower, sigma
breach = np.any(cs["path"] > cs["bound_upper"]) or np.any(cs["path"] < cs["bound_lower"])

CUSUM stability

The stable fit (blue) stays inside the band; the series with a mid-sample break (red) crosses it decisively. See the specification-tests card.

Predictive regressions: IVX keeps its size near a unit root

Use case: regressing returns on a persistent, endogenous predictor (a dividend yield, a valuation ratio) breaks the OLS t-test — it over-rejects the harder the predictor's root approaches one. The IVX Wald test holds its size uniformly, up to and including an exact unit root.

fit = tsecon.predictive_regression(r, x)   # ols, stambaugh, and ivx views
fit["ivx"]["wald"], fit["ivx"]["pvalue"]   # the size-correct headline test

IVX size

Under a true no-predictability null, the naive OLS t-test's rejection rate climbs from 6% to 25% as ρ → 1, while the IVX Wald test stays near its nominal 5%. See the predictive-regressions card.

Arbitrage-free Nelson-Siegel: one extra term

Use case: a plain Nelson-Siegel curve fits yields well but is not arbitrage-free. Christensen-Diebold-Rudebusch keep the three loadings unchanged and add a single closed-form adjustment term −A(τ)/τ that restores no-arbitrage.

adj = tsecon.afns_adjustment(maturities, sigma, decay=0.5)   # signed -A(tau)/tau
y_afns = y_ns + adj                                          # add to any NS fit

AFNS

Left: the adjustment is zero at the short end and deepens with maturity and factor volatility. Right: added to the reduced-form curve it pulls the long end down — the no-arbitrage convexity effect. See the AFNS card.

DSGE-lite: the rational-expectations saddle path

Use case: solve a small linearized rational-expectations model to its policy rule and check determinacy. The Blanchard-Kahn count — unstable roots vs jump variables — decides whether the equilibrium is unique.

sol = tsecon.dsge_solve(A, B, C, n_predetermined=1)   # g, p, q, eigenvalue_moduli, verdict
sol["verdict"]   # "unique stable solution (1 unstable eigenvalue(s) = 1 jump variable(s))"

DSGE saddle path

The Cagan money-demand model: the jump variable (price level) leads on impact by the present-value multiplier G = 1/(1 − aρ), then both series revert at the fundamental's AR root. See the DSGE card.

Spectral analysis: finding a cycle, and shared rhythm

Use case: how much of a series' variance sits at each frequency, and do two series share a cycle? The raw periodogram is a noisy, inconsistent estimate; Welch's segment-averaging turns it into a usable power spectral density, and magnitude-squared coherence measures frequency-by-frequency co-movement.

pg = tsecon.periodogram(x)     # raw PSD (freqs, psd) — matches scipy.signal to ~1e-15
wl = tsecon.welch(x)           # averaged, consistent PSD
co = tsecon.coherence(x, y)    # magnitude-squared coherence in [0, 1]

Spectral analysis

Left: the raw periodogram (grey) is spiky; Welch averaging (red) resolves the f = 0.05 cycle cleanly. Right: coherence spikes to ~1 only at the frequency the two series share. See the spectral card.