How do integrated series adjust when a declared linear combination is stationary? The attraction of a multivariate model is that it converts a collection of lines into one coherent system. The danger is that a polished matrix or response plot can hide the choices that created it. This tutorial makes those choices visible and gives you a reproducible path to estimate short-run changes and equilibrium adjustment under a supplied rank-one cointegration vector.
Start from the decision
Rank-one VECM with beta normalized to beta[0]=1, one lag of differences, an unrestricted intercept, and conditional OLS estimation. This selected variant is intentionally narrower than the maintained VECM API. A narrow contract is a strength when it exposes every dimension, normalization, and failure state.
The practical output is not a trading signal. It is a diagnostic system object that an analyst can inspect and an engineer can reproduce.
Intuition before notation
The cointegration error measures distance from the long-run relation; alpha says which variables move next to reduce or amplify that distance. The main learning obstacle is separating rank and beta selection from conditional estimation of adjustment and short-run dynamics. Keep a simple orientation rule beside every matrix: rows receive; columns send. In a lag matrix, rows are equations and columns are lagged predictors. In an impulse or FEVD matrix, rows are responses and columns are shocks.
Formula and symbols
| Symbol | Meaning | Unit |
|---|---|---|
Delta y_t | K by 1 vector of first differences | change units |
beta | normalized cointegration vector | unit conversion |
beta' y_(t-1) | lagged equilibrium error | spread units |
alpha | K by 1 adjustment-speed vector | change per spread unit |
Gamma_1 | short-run lagged-difference coefficient matrix | unit conversion |
The formula remains incomplete until the data transformations, variable order, lag or difference order, impact normalization, horizon, and tolerance are recorded.
Material variants
| Variant | Definition | Best use | Main limitation |
|---|---|---|---|
| Fixed-beta rank-one VECM (selected) | Beta and rank are supplied; alpha and short-run terms are estimated. | Auditable conditional lesson. | It does not test rank or estimate beta. |
| Johansen maximum likelihood | Rank and cointegration space are inferred jointly under deterministic assumptions. | Full-system cointegration analysis. | Critical values and normalization require care. |
| Engle-Granger two-step ECM | A single equation uses an estimated residual as the error-correction term. | Simple two-series workflow. | Asymmetry and generated-regressor inference matter. |
| Threshold VECM | Adjustment changes when disequilibrium crosses regimes. | Nonlinear correction. | Threshold search can overfit. |
Two analysts can disagree without an arithmetic error when they use different variants. The correct response is to compare contracts before comparing conclusions.
Walk the deterministic fixture
Synthetic teaching input: the package contains 120 equally spaced observations for seven controlled systems, including a three-variable transmission chain. The canonical scenario for this topic is cointegrated-pair. With beta = [1, -1], the fitted adjustment vector is [-0.032437, 0.406805]; the error-correction loading root is 0.560758. Because the selected model includes a lagged difference term, the package does not mislabel that scalar as the full system's exact half-life. The long-run matrix is reported explicitly as Pi = alpha beta'.
What matters is not only the final value. The trace preserves the input order, intermediate matrix, decision rule, and output state.
Decision dashboard
| Adjustment audit | Canonical value | Decision meaning |
|---|---|---|
| beta | [1.0, -1.0] | Supplied and normalized; it is not estimated here. |
| error-correction loading root | 0.560758 | Describes the direct equilibrium-error channel. |
| exact scalar half-life | not reported | Gamma is present, so the scalar root is not the full-system persistence root. |
Open the full-size matrix trace.
Build the calculation
validate integrated aligned levels and normalized beta
compute first differences
compute ect_(t-1) = beta' y_(t-1)
build regressors [1, ect, lagged differences]
fit every change equation by OLS
report alpha, Pi = alpha beta', Gamma, residual covariance, and a persistence diagnostic scoped to the selected short-run dynamics
The Python and TypeScript implementations use the same fixtures and return the same values, states, reasons, and errors. The transparent educational solver uses normal equations; a production estimator should prefer QR, SVD, or a maintained statistical library when conditioning is uncertain.
Test the definition
One canonical snapshot is insufficient. The checks include independent NumPy arithmetic, input nonmutation, malformed dimensions, singular or non-positive-definite matrices, ordering or negative-control comparisons, exact horizon conventions, and cross-language parity. Algorithm-specific invariants catch subtle mistakes: response horizon zero equals the impact matrix, FEVD rows sum to one, and VECM beta normalization is enforced.
Compare controlled systems
The stable system shows ordinary decay and cross-lag transmission. The recursive-shock case exposes contemporaneous identification. The cointegrated pair shows why an equilibrium error belongs beside short-run differences. The near-unstable system produces slow decay and fragile horizon stories. The decoupled control should suppress cross-effects. Reordering the stable columns changes a recursive structural question even though no observation changed. The three-variable chain then checks that matrix orientation, recursive ordering, response paths, and variance shares scale beyond a bivariate shortcut.
Use the guided system laboratory to change one assumption at a time. The audit panel reports exact values and the interpretation boundary.
Failure modes
Do not fit a stable VAR to integrated levels merely because the code accepts a matrix. Do not call Cholesky components economic shocks without defending the zero restriction. Do not read a positive impulse response as a causal effect when the identification is only statistical. Do not interpret FEVD shares as immutable importance rankings; they depend on model, horizon, normalization, and identification. Do not select lag, rank, ordering, and sample after seeing the desired story without later validation.
Missing periods, revised data, inconsistent units, near singularity, parameter proliferation, and residual dependence can all make a clean chart misleading.
Historical-example decision — not useful
A famous crisis, policy announcement, or asset pair would add narrative appeal without establishing the true lag structure, cointegration rank, or contemporaneous restrictions. A credible empirical case would require licensed point-in-time data, transformation and vintage records, model-selection evidence, identification defense, uncertainty, and a tightly bounded claim. The controlled synthetic systems teach the computation more honestly.
Practical handoff
You can now state the selected convention, reproduce the central matrix step, test its invariants, and identify where a structural story enters. Continue to Impulse-Response Analysis, which extends the family workflow without replacing the assumptions learned here.
Rendered from the canonical Mermaid sources linked by this article.
VECM system flow
This diagram isolates the decision sequence that must remain auditable.
Takeaway: The cointegration error measures distance from the long-run relation; alpha says which variables move next to reduce or amplify that distance.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
SM-VAR — statsmodels vector autoregression documentation
- Organization or authors: statsmodels developers
- Source type: Maintained official library documentation
- Publication or effective date: 2025-12-05 documentation build
- Version: 0.14.6
- URL or DOI: https://www.statsmodels.org/stable/vector_ar.html
- Accessed: 2026-07-27
- Jurisdiction: Library-neutral statistical implementation
- Evidence role: Maintained implementation semantics and variant boundary
- Supports: VAR equations, lag selection, moving-average recursion, Cholesky ordering sensitivity, impulse responses, and FEVD.
- Limitations: Library defaults are not universal definitions and do not validate this package's synthetic economic interpretation.
SM-TOPIC — statsmodels VECM API documentation
- Organization or authors: statsmodels developers
- Source type: Maintained official API documentation
- Publication or effective date: 2025-12-05 documentation build
- Version: 0.14.6
- URL or DOI: https://www.statsmodels.org/stable/generated/statsmodels.tsa.vector_ar.vecm.VECM.html
- Accessed: 2026-07-27
- Jurisdiction: Library-neutral statistical implementation
- Evidence role: Topic-specific API and parameter semantics
- Supports: The nearby maintained implementation and its declared options.
- Limitations: This package selects a deliberately narrower transparent variant and does not promise bit-for-bit library equivalence.
ENGLE-GRANGER-1987 — Co-integration and Error Correction: Representation, Estimation, and Testing
- Organization or authors: Robert F. Engle and Clive W. J. Granger
- Source type: Peer-reviewed original article
- Publication or effective date: 1987
- URL or DOI: https://doi.org/10.2307/1913236
- Accessed: 2026-07-27
- Jurisdiction: General econometric methodology
- Evidence role: Topic-specific convention and variant boundary
- Supports: Error-correction representation and the distinction between equilibrium and short-run dynamics.
- Limitations: This package conditions on beta and does not reproduce the paper's estimation or tests.
LUT-2005 — New Introduction to Multiple Time Series Analysis
- Organization or authors: Helmut Lütkepohl
- Source type: Authoritative graduate monograph
- Publication or effective date: 2005
- Version: First edition
- URL or DOI: https://doi.org/10.1007/978-3-540-27752-1
- Accessed: 2026-07-27
- Jurisdiction: General econometric theory
- Evidence role: Mathematical foundation
- Supports: VAR estimation, stability, VECM representation, impulse responses, structural identification, and forecast-error variance decomposition.
- Limitations: The book covers broader asymptotic inference than this educational reference implementation.
SIMS-1980 — Macroeconomics and Reality
- Organization or authors: Christopher A. Sims
- Source type: Peer-reviewed original article
- Publication or effective date: 1980
- Version: Econometrica 48(1)
- URL or DOI: https://doi.org/10.2307/1912017
- Accessed: 2026-07-27
- Jurisdiction: General econometric methodology
- Evidence role: Historical methodological context
- Supports: Treating multiple endogenous time series as a joint dynamic system.
- Limitations: It does not validate any synthetic coefficient, shock label, or trading claim in this package.
JOH-1988 — Statistical Analysis of Cointegration Vectors
- Organization or authors: Søren Johansen
- Source type: Peer-reviewed original article
- Publication or effective date: 1988
- Version: Journal of Economic Dynamics and Control 12
- URL or DOI: https://doi.org/10.1016/0165-1889(88)90041-3
- Accessed: 2026-07-27
- Jurisdiction: General econometric methodology
- Evidence role: Cointegration-rank and VECM context
- Supports: Cointegration-space and reduced-rank interpretation.
- Limitations: The package VECM conditions on a supplied rank and beta; it does not reproduce Johansen maximum likelihood or critical values.
Publication boundary
All observations, coefficients, shocks, and worked values in this package are project-authored synthetic teaching input or author-derived calculations. No provider observation, filed fact, named security, empirical return claim, or investment recommendation is published.
Full dependency-light reference implementations in both supported languages.
export type Matrix = number[][];
export type Result = Record<string, unknown>;
const round = (value:number) => Math.round(value * 1e12) / 1e12;
const rounded = (value:unknown):unknown => {
if (typeof value === "number") return round(value);
if (Array.isArray(value)) return value.map(rounded);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key,item]) => [key,rounded(item)]));
}
return value;
};
function finiteMatrix(values:Matrix, minimumRows=1):{rows:Matrix;width:number} {
if (!Array.isArray(values) || values.length < minimumRows) {
throw new Error(`values must contain at least ${minimumRows} rows`);
}
if (!Array.isArray(values[0]) || values[0].length < 2) {
throw new Error("values must contain at least two variables");
}
const width=values[0].length;
const rows=values.map((row,rowIndex)=>{
if(!Array.isArray(row)||row.length!==width)throw new Error("all rows must have the same width");
return row.map((value,columnIndex)=>{
if(typeof value!=="number"||!Number.isFinite(value))throw new Error(`values[${rowIndex}][${columnIndex}] must be finite`);
return value;
});
});
return {rows,width};
}
const identity=(size:number):Matrix=>Array.from({length:size},(_,row)=>Array.from({length:size},(_,column)=>row===column?1:0));
const transpose=(matrix:Matrix):Matrix=>matrix[0].map((_,column)=>matrix.map(row=>row[column]));
function matmul(left:Matrix,right:Matrix):Matrix{
if(!left.length||!right.length||left[0].length!==right.length)throw new Error("matrix dimensions do not align");
const rightT=transpose(right);
return left.map(row=>rightT.map(column=>row.reduce((sum,value,index)=>sum+value*column[index],0)));
}
function matvec(matrix:Matrix,vector:number[]):number[]{
if(!matrix.length||matrix[0].length!==vector.length)throw new Error("matrix and vector dimensions do not align");
return matrix.map(row=>row.reduce((sum,value,index)=>sum+value*vector[index],0));
}
function maxAbsDifference(left:Matrix,right:Matrix):number{
if(left.length!==right.length||left.some((row,index)=>row.length!==right[index].length))throw new Error("matrix dimensions do not align");
return Math.max(...left.flatMap((row,rowIndex)=>row.map((value,column)=>Math.abs(value-right[rowIndex][column]))));
}
function qrDecomposition(matrix:Matrix,tolerance=1e-14):{q:Matrix;r:Matrix}{
const size=matrix.length,columns=transpose(matrix),qColumns:Matrix=[],r:Matrix=Array.from({length:size},()=>Array(size).fill(0));
for(let columnIndex=0;columnIndex<size;columnIndex++){
let vector=[...columns[columnIndex]];
for(let previous=0;previous<qColumns.length;previous++){
const basis=qColumns[previous],projection=basis.reduce((sum,value,index)=>sum+value*vector[index],0);
r[previous][columnIndex]=projection;
vector=vector.map((value,index)=>value-projection*basis[index]);
}
let norm=Math.sqrt(vector.reduce((sum,value)=>sum+value*value,0));
if(norm<=tolerance){
vector=Array.from({length:size},(_,index)=>index===columnIndex?1:0);
for(const basis of qColumns){
const projection=basis.reduce((sum,value,index)=>sum+value*vector[index],0);
vector=vector.map((value,index)=>value-projection*basis[index]);
}
norm=Math.sqrt(vector.reduce((sum,value)=>sum+value*value,0));
if(norm<=tolerance)throw new Error("QR decomposition failed");
}
r[columnIndex][columnIndex]=norm;
qColumns.push(vector.map(value=>value/norm));
}
return {q:transpose(qColumns),r};
}
export function companionMatrix(coefficients:Matrix[]):Matrix{
if(!coefficients.length)throw new Error("at least one coefficient matrix is required");
const {rows:first,width}=finiteMatrix(coefficients[0],2);
if(first.length!==width)throw new Error("coefficient matrices must be square");
const clean=[first,...coefficients.slice(1).map(matrix=>{const checked=finiteMatrix(matrix,width);if(checked.width!==width||checked.rows.length!==width)throw new Error("coefficient matrices must share a square shape");return checked.rows})];
const size=width*clean.length,companion:Matrix=Array.from({length:size},()=>Array(size).fill(0));
clean.forEach((matrix,lag)=>matrix.forEach((row,rowIndex)=>row.forEach((value,column)=>{companion[rowIndex][lag*width+column]=value})));
for(let block=1;block<clean.length;block++)for(let index=0;index<width;index++)companion[block*width+index][(block-1)*width+index]=1;
return companion;
}
export function companionSpectralRadius(coefficients:Matrix[]):number{
let working=companionMatrix(coefficients);
for(let iteration=0;iteration<384;iteration++){const {q,r}=qrDecomposition(working);working=matmul(r,q)}
let radius=0,index=working.length-1;
while(index>=0){
if(index>0&&Math.abs(working[index][index-1])>1e-8){
const a=working[index-1][index-1],b=working[index-1][index],c=working[index][index-1],d=working[index][index];
const trace=a+d,determinant=a*d-b*c,discriminant=trace*trace-4*determinant;
if(discriminant>=0){const root=Math.sqrt(discriminant);radius=Math.max(radius,Math.abs((trace+root)/2),Math.abs((trace-root)/2))}
else radius=Math.max(radius,Math.sqrt(Math.abs(determinant)));
index-=2;
}else{radius=Math.max(radius,Math.abs(working[index][index]));index--}
}
return round(radius);
}
function stabilityDiagnostic(coefficients:Matrix[],boundary=0.90):Result{
const radius=companionSpectralRadius(coefficients);
return {companion_spectral_radius:radius,stability_state:radius>=1?"unstable":radius>=boundary?"near-boundary":"stable",stability_boundary:1,near_boundary_threshold:boundary};
}
function inverse(matrix:Matrix,tolerance=1e-12):Matrix{
const size=matrix.length;
if(!size||matrix.some(row=>row.length!==size))throw new Error("matrix must be square");
const augmented=matrix.map((row,index)=>[...row,...identity(size)[index]]);
for(let column=0;column<size;column++){
let pivot=column;
for(let row=column+1;row<size;row++)if(Math.abs(augmented[row][column])>Math.abs(augmented[pivot][column]))pivot=row;
if(Math.abs(augmented[pivot][column])<=tolerance)throw new Error("matrix is singular");
[augmented[column],augmented[pivot]]=[augmented[pivot],augmented[column]];
const scale=augmented[column][column];
augmented[column]=augmented[column].map(value=>value/scale);
for(let row=0;row<size;row++){
if(row===column)continue;
const factor=augmented[row][column];
augmented[row]=augmented[row].map((value,index)=>value-factor*augmented[column][index]);
}
}
return augmented.map(row=>row.slice(size));
}
const ols=(design:Matrix,targets:Matrix):Matrix=>{
const designT=transpose(design);
return matmul(inverse(matmul(designT,design)),matmul(designT,targets));
};
function covarianceMLE(residuals:Matrix):Matrix{
if(!residuals.length)throw new Error("residuals cannot be empty");
const width=residuals[0].length,covariance=Array.from({length:width},()=>Array(width).fill(0));
for(const row of residuals)for(let left=0;left<width;left++)for(let right=0;right<width;right++)covariance[left][right]+=row[left]*row[right];
return covariance.map(row=>row.map(value=>value/residuals.length));
}
export function fitVAR(values:Matrix,lags=1,includeIntercept=true):Result{
const {rows,width}=finiteMatrix(values,8);
if(!Number.isInteger(lags)||lags<1||lags>=rows.length-2)throw new Error("lags must be an integer between 1 and n-3");
const design:Matrix=[],targets:Matrix=[];
for(let time=lags;time<rows.length;time++){
const features:number[]=includeIntercept?[1]:[];
for(let lag=1;lag<=lags;lag++)features.push(...rows[time-lag]);
design.push(features);targets.push([...rows[time]]);
}
const beta=ols(design,targets),intercept=includeIntercept?[...beta[0]]:Array(width).fill(0),offset=includeIntercept?1:0;
const coefficients:Matrix[]=[];
for(let lag=0;lag<lags;lag++){
const matrix:Matrix=Array.from({length:width},()=>Array(width).fill(0));
for(let predictor=0;predictor<width;predictor++)for(let response=0;response<width;response++)matrix[response][predictor]=beta[offset+lag*width+predictor][response];
coefficients.push(matrix);
}
const fitted=matmul(design,beta);
const residuals=targets.map((target,index)=>target.map((value,column)=>value-fitted[index][column]));
let oneStep=[...intercept];
for(let lag=1;lag<=lags;lag++){
const contribution=matvec(coefficients[lag-1],rows[rows.length-lag]);
oneStep=oneStep.map((value,index)=>value+contribution[index]);
}
const rowSumBound=Math.max(...Array.from({length:width},(_,row)=>coefficients.reduce((sum,matrix)=>sum+matrix[row].reduce((inner,value)=>inner+Math.abs(value),0),0)));
return rounded({intercept,coefficients,sigma_u_mle:covarianceMLE(residuals),residuals,fitted,one_step_forecast:oneStep,effective_observations:targets.length,variables:width,lags,row_sum_stability_bound:rowSumBound,...stabilityDiagnostic(coefficients),state:"estimated",reason:"fixed-lag-ols"}) as Result;
}
export function choleskyLower(covariance:Matrix,tolerance=1e-12):Matrix{
const {rows:matrix,width}=finiteMatrix(covariance,2);
if(matrix.length!==width)throw new Error("covariance must be square");
for(let row=0;row<width;row++)for(let column=0;column<width;column++)if(Math.abs(matrix[row][column]-matrix[column][row])>1e-9)throw new Error("covariance must be symmetric");
const lower:Matrix=Array.from({length:width},()=>Array(width).fill(0));
for(let row=0;row<width;row++)for(let column=0;column<=row;column++){
let subtotal=0;for(let index=0;index<column;index++)subtotal+=lower[row][index]*lower[column][index];
if(row===column){const diagonal=matrix[row][row]-subtotal;if(diagonal<=tolerance)throw new Error("covariance must be positive definite");lower[row][column]=Math.sqrt(diagonal)}
else lower[row][column]=(matrix[row][column]-subtotal)/lower[column][column];
}
return rounded(lower) as Matrix;
}
export function fitRecursiveSVAR(values:Matrix,lags=1):Result{
const reduced=fitVAR(values,lags),impact=choleskyLower(reduced.sigma_u_mle as Matrix),inverseImpact=inverse(impact);
const structuralShocks=(reduced.residuals as Matrix).map(row=>matvec(inverseImpact,row)),reconstructed=matmul(impact,transpose(impact)),shockCovariance=covarianceMLE(structuralShocks),width=reduced.variables as number;
return rounded({coefficients:reduced.coefficients,intercept:reduced.intercept,sigma_u_mle:reduced.sigma_u_mle,impact_matrix:impact,structural_shocks:structuralShocks,reconstructed_sigma_u:reconstructed,covariance_reconstruction_max_error:maxAbsDifference(reduced.sigma_u_mle as Matrix,reconstructed),structural_shock_covariance_mle:shockCovariance,structural_shock_identity_max_error:maxAbsDifference(shockCovariance,identity(width)),ordering:Array.from({length:width},(_,index)=>index),companion_spectral_radius:reduced.companion_spectral_radius,stability_state:reduced.stability_state,state:"identified-recursively",reason:"lower-cholesky-ordering"}) as Result;
}
export function fitVECMFixedBeta(values:Matrix,beta:number[],differenceLags=1,includeIntercept=true):Result{
const {rows,width}=finiteMatrix(values,12);
if(beta.length!==width||beta.some(value=>typeof value!=="number"||!Number.isFinite(value)))throw new Error("beta must contain one finite coefficient per variable");
if(Math.abs(beta[0]-1)>1e-12)throw new Error("beta must be normalized with beta[0] = 1");
if(!Number.isInteger(differenceLags)||differenceLags<0||differenceLags>2)throw new Error("difference_lags must be 0, 1, or 2");
const differences=rows.slice(1).map((row,index)=>row.map((value,column)=>value-rows[index][column]));
const start=differenceLags+1,design:Matrix=[],targets:Matrix=[],errorCorrection:number[]=[];
for(let time=start;time<rows.length;time++){
const ect=beta.reduce((sum,value,column)=>sum+value*rows[time-1][column],0);
const features:number[]=includeIntercept?[1]:[];features.push(ect);
for(let lag=1;lag<=differenceLags;lag++)features.push(...differences[time-1-lag]);
design.push(features);targets.push([...differences[time-1]]);errorCorrection.push(ect);
}
const coefficients=ols(design,targets),offset=includeIntercept?1:0,intercept=includeIntercept?[...coefficients[0]]:Array(width).fill(0),alpha=[...coefficients[offset]],gamma:Matrix[]=[];
for(let lag=0;lag<differenceLags;lag++){
const matrix:Matrix=Array.from({length:width},()=>Array(width).fill(0));
for(let predictor=0;predictor<width;predictor++)for(let response=0;response<width;response++)matrix[response][predictor]=coefficients[offset+1+lag*width+predictor][response];
gamma.push(matrix);
}
const fitted=matmul(design,coefficients),residuals=targets.map((target,index)=>target.map((value,column)=>value-fitted[index][column]));
const adjustmentRoot=1+beta.reduce((sum,value,index)=>sum+value*alpha[index],0);
const halfLife=differenceLags===0&&Math.abs(adjustmentRoot)>0&&Math.abs(adjustmentRoot)<1?-Math.log(2)/Math.log(Math.abs(adjustmentRoot)):null;
const pi=alpha.map(value=>beta.map(betaValue=>value*betaValue));
return rounded({beta:[...beta],alpha,gamma,intercept,sigma_u_mle:covarianceMLE(residuals),error_correction:errorCorrection,pi,adjustment_root:adjustmentRoot,error_correction_loading_root:adjustmentRoot,half_life:halfLife,exact_half_life:halfLife,half_life_scope:differenceLags===0?"exact-scalar-error-correction-channel":"not-reported-short-run-gamma-present",effective_observations:targets.length,rank:1,difference_lags:differenceLags,state:"estimated-conditional-beta",reason:"rank-one-fixed-normalized-cointegration-vector"}) as Result;
}
export function movingAverageMatrices(coefficients:Matrix[],horizon:number):Matrix[]{
if(!coefficients.length)throw new Error("at least one coefficient matrix is required");
const {rows:first,width}=finiteMatrix(coefficients[0],2);
if(first.length!==width)throw new Error("coefficient matrices must be square");
const clean=[first,...coefficients.slice(1).map(matrix=>{const checked=finiteMatrix(matrix,width);if(checked.width!==width||checked.rows.length!==width)throw new Error("coefficient matrices must share a square shape");return checked.rows})];
if(!Number.isInteger(horizon)||horizon<0||horizon>100)throw new Error("horizon must be an integer from 0 through 100");
const responses:Matrix[]=[identity(width)];
for(let step=1;step<=horizon;step++){
let current:Matrix=Array.from({length:width},()=>Array(width).fill(0));
for(let lag=1;lag<=Math.min(clean.length,step);lag++){
const product=matmul(clean[lag-1],responses[step-lag]);
current=current.map((row,rowIndex)=>row.map((value,column)=>value+product[rowIndex][column]));
}
responses.push(current);
}
return rounded(responses) as Matrix[];
}
export function impulseResponses(coefficients:Matrix[],horizon:number,impactMatrix?:Matrix):Result{
const phi=movingAverageMatrices(coefficients,horizon),width=phi[0].length,impact=impactMatrix??identity(width);
const checked=finiteMatrix(impact,width);
if(checked.width!==width||checked.rows.length!==width)throw new Error("impact_matrix must match coefficient width");
const responses=phi.map(matrix=>matmul(matrix,checked.rows)),cumulative:Matrix[]=[];
let running:Matrix=Array.from({length:width},()=>Array(width).fill(0));
for(const matrix of responses){running=running.map((row,rowIndex)=>row.map((value,column)=>value+matrix[rowIndex][column]));cumulative.push(running.map(row=>[...row]))}
return rounded({responses,cumulative_responses:cumulative,horizon,impact_matrix:checked.rows,...stabilityDiagnostic(coefficients),state:"computed",reason:"ma-recursion-times-declared-impact"}) as Result;
}
export function forecastErrorVarianceDecomposition(coefficients:Matrix[],sigmaU:Matrix,horizon:number,impactMatrix?:Matrix):Result{
if(!Number.isInteger(horizon)||horizon<1||horizon>100)throw new Error("horizon must be an integer from 1 through 100");
const impact=impactMatrix??choleskyLower(sigmaU),irf=impulseResponses(coefficients,horizon-1,impact),responses=irf.responses as Matrix[],width=responses[0].length;
const cumulative:Matrix=Array.from({length:width},()=>Array(width).fill(0)),horizons:Matrix[]=[];
for(const matrix of responses){
for(let response=0;response<width;response++)for(let shock=0;shock<width;shock++)cumulative[response][shock]+=matrix[response][shock]**2;
horizons.push(cumulative.map(row=>{const total=row.reduce((sum,value)=>sum+value,0);if(total<=0)throw new Error("forecast-error variance is zero");return row.map(value=>value/total)}));
}
return rounded({shares:horizons,impact_matrix:impact,horizon,row_sums:horizons.map(matrix=>matrix.map(row=>row.reduce((sum,value)=>sum+value,0))),companion_spectral_radius:irf.companion_spectral_radius,stability_state:irf.stability_state,stability_boundary:irf.stability_boundary,near_boundary_threshold:irf.near_boundary_threshold,state:"computed",reason:"orthogonalized-squared-response-share"}) as Result;
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.