https://github.com/beliavsky/multivariate-changepoints
Find changepoints in correlation and covariance
https://github.com/beliavsky/multivariate-changepoints
correlation covariance finance fortran modern-fortran quantitative-finance regime-shifts statistics time-series-analysis volatility
Last synced: 29 days ago
JSON representation
Find changepoints in correlation and covariance
- Host: GitHub
- URL: https://github.com/beliavsky/multivariate-changepoints
- Owner: Beliavsky
- License: mit
- Created: 2026-04-14T03:10:56.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-14T03:39:30.000Z (2 months ago)
- Last Synced: 2026-04-14T05:24:43.985Z (2 months ago)
- Topics: correlation, covariance, finance, fortran, modern-fortran, quantitative-finance, regime-shifts, statistics, time-series-analysis, volatility
- Language: Fortran
- Homepage:
- Size: 204 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: changepoint.f90
- License: LICENSE
Awesome Lists containing this project
README
# Multivariate-ChangePoints
Fortran programs (with Python and C++ equivalents for selected programs) that find structural breaks in the mean, variance, correlation, and covariance of financial return series — and in generic univariate data — using exact dynamic programming.
Two sample data files are included:
**`spy_efa_eem_tlt.csv`** — daily closing prices for four ETFs from 1993 to 2015:
| Ticker | Description |
|--------|-------------|
| SPY | S&P 500 |
| EFA | Developed international equities |
| EEM | Emerging market equities |
| TLT | 20+ year US Treasury bonds |
**`asset_class_etf_prices.csv`** — daily closing prices for nine ETFs from 2007 to 2026:
| Ticker | Description |
|--------|-------------|
| SPY | S&P 500 |
| EFA | Developed international equities |
| EEM | Emerging market equities |
| EMB | Emerging market bonds |
| HYG | High-yield corporate bonds |
| LQD | Investment-grade corporate bonds |
| TLT | 20+ year US Treasury bonds |
| GLD | Gold |
| USO | Oil |
---
## Statistical models
All changepoint programs find the exact global optimum for each fixed number of segments m using the recurrence
dp(i, m) = min over k < i of [ dp(k, m-1) + cost(k+1, i) ]
in O(n² × max_m) time, then select the number of segments by AIC and BIC. The cost functions and parameter counts differ by model:
| Program | Series z_t | Cost(i..j) | Params/segment |
|---------|-----------|------------|----------------|
| correlation | bivariate (x, y) | m/2 · log(1 − r²) | 1 (one ρ) |
| mean | r_t | m/2 · log(ŝ²_z) | 2 (μ, σ²) |
| variance (r²) | r_t² | m/2 · log(ŝ²_z) | 2 |
| variance (log) | log(c + r_t²) | m/2 · log(ŝ²_z) | 2 |
| pairwise covariance | r_{i,t} · r_{j,t} | m/2 · log(ŝ²_z) | 2 |
| joint covariance matrix | R_t ∈ ℝᵖ | m/2 · log\|Σ̂\| | p(p+3)/2 + 1 |
| joint correlation matrix | R_t ∈ ℝᵖ (standardised) | m/2 · log\|Ρ̂\| | p(p+1)/2 + 1 |
where ŝ²_z = mean(z²) − mean(z)² is the biased sample variance of z over the segment, Σ̂ is the biased sample covariance matrix, and Ρ̂ is the sample correlation matrix of standardised returns. The joint covariance and correlation matrix costs use a Cholesky-based log-determinant.
The BIC penalty is `k_params × log(n)` where `k_params = params_per_seg × m − 1` (one parameter per segment times m segments, minus one for the first segment which has no preceding changepoint location).
### Variance series: r² vs log(c + r²)
Two choices are available for the variance series (controlled by `use_log_var`):
- **z_t = r_t²** finds good changepoint locations but the series is non-negative and heavy-tailed (chi-squared), so BIC under-penalises splits and bootstrap-resampled (i.i.d.) data can yield *more* detected changepoints than the original — an inverted null test.
- **z_t = log(c + r_t²)** is approximately Gaussian (log-chi-squared), giving a correctly calibrated BIC and a proper null test under resampling. The offset `c` (default 0.01 for percent returns) floors near-zero returns.
---
## Programs
### Correlation changepoints
**`xreturns_corr.f90`**
Computes return statistics and the full correlation matrix. No changepoint detection; useful as a baseline.
**`xcorr_sim.f90`**
Simulates a bivariate normal series with known piecewise-constant correlation (segments of different ρ), then detects changepoints. Prints true vs estimated parameters and writes simulated data to `sim_data.txt`.
**`xreturns_corr_cp.f90`** · **`xreturns_corr_cp.py`** · **`xreturns_corr_cp.cpp`**
Reads `spy_efa_eem_tlt.csv`, computes percent returns, and for each asset pair finds changepoints in correlation. After processing all pairs, prints a summary matrix:
```
SPY EFA EEM TLT
SPY 0 2 3 1
EFA 4 0 2 2
EEM 5 3 0 1
TLT 3 4 3 0
```
Above the diagonal: BIC changepoint count. Below: AIC count. Diagonal: 0.
The Python version (`xreturns_corr_cp.py`) uses NumPy prefix-sum vectorisation and matches the Fortran output exactly; it requires no Numba. The C++ version uses an end-major cost matrix layout for cache efficiency.
Key parameters (compile-time constants at the top of each file):
| Parameter | Default | Meaning |
|-----------|---------|---------|
| `MAX_CP` / `max_cp` | 20 | Maximum changepoints per pair |
| `MIN_SEG_LEN` / `min_seg_len` | 50 | Minimum observations per segment |
| `SYM_ALLOWED` / `sym_allowed` | (empty) | Subset of tickers; empty = all |
| `max_days` | 0 | Keep latest/earliest N returns; 0 = all |
| `latest` | `.true.` | If `max_days > 0`: `.true.` = most recent |
**`xcorr_fit.f90`**
Fits the correlation changepoint model to a single pair with detailed diagnostic output.
---
### Variance and mean changepoints (univariate, per asset)
**`xreturns_variance_cp.f90`**
Finds changepoints in the variance of each asset independently. The derived series is z_t = r_t², and the cost is the profile normal mean-shift log-likelihood m/2 · log(ŝ²_z). Prints a summary table of BIC/AIC changepoint counts per asset. Set `resample_ret = .true.` to shuffle rows as a null-hypothesis check.
**`xreturns_mean_cp.f90`**
Finds changepoints in the return mean of each asset. Same cost function, with z_t = r_t. Supports `resample_ret`.
**`xreturns_mean_variance_cp.f90`**
Runs both mean and variance changepoint analyses for each asset in one pass. The variance series is selectable via `use_log_var` / `var_offset`; see the variance series note above. Supports `resample_ret`.
**`xreturns_variance_pwl.f90`**
Fits a **continuous piecewise-linear** path to squared returns by minimising the sum of squared residuals. The fitted variance path v_t is continuous at the knots (changepoints) and linear between them. This is an exact optimizer for the PWL-SSE objective, not a likelihood model.
**`xreturns_variance_pwl_fast.f90`**
Accelerated version of `xreturns_variance_pwl.f90`.
---
### Pairwise covariance / variance changepoints
**`xreturns_cov_cp.f90`**
For every pair (i, j) with i ≤ j (including diagonal i = j for variance), forms z_t = r_{i,t} · r_{j,t} and finds mean-shift changepoints. The diagonal pair (i, i) detects variance changepoints; off-diagonal pairs detect covariance changepoints. Prints a summary matrix analogous to `xreturns_corr_cp.f90`.
Note: variance of financial returns is genuinely time-varying (GARCH-like), so many changepoints are typically detected. The normal mean-shift model is also misspecified for z_t = r² (which is non-negative and heavy-tailed). Results are more interpretable for covariance pairs than for variance.
---
### Joint covariance matrix changepoints
**`xreturns_covmat_cp.f90`**
Finds a **single set** of changepoints for all assets simultaneously by fitting a multivariate normal model with piecewise-constant p × p covariance matrix Σ. The cost for a segment is m/2 · log|Σ̂|, computed via a Cholesky log-determinant. After model selection, prints for each BIC-chosen segment the correlation lower triangle, annualised standard deviations, and optionally PCA loadings:
```
Segment 2: 2008-09-02 to 2012-06-29 (984 obs)
Correlation:
SPY EFA EEM TLT
SPY 1.000
EFA 0.964 1.000
EEM 0.932 0.956 1.000
TLT -0.418 -0.370 -0.310 1.000
*SD* 0.292 0.270 0.308 0.148
```
`*SD*` is the annualised standard deviation (× √252) of unscaled returns.
Prefix sums of outer products (`SR`, `SP`) reduce cost matrix construction to O(n · p²) setup + O(n² · p²) inner work, with an O(p³) Cholesky per cell. Supports `resample_ret`.
**`xcovar_sim.f90`**
Simulation counterpart of `xreturns_covmat_cp.f90`. Generates a p × n multivariate normal series with known piecewise-constant covariance matrices (specified as correlations + standard deviations per segment), runs the joint covariance changepoint algorithm, and prints true vs estimated parameters side by side.
Default setup: p = 3 assets, n = 2000 observations, 3 segments (changepoints at observations 500 and 1200) with substantially different correlation structure and volatility between segments.
---
### Joint correlation matrix changepoints
**`xreturns_corrmat_cp.f90`**
Finds changepoints in the full p × p **correlation** matrix jointly. Returns are first standardised so that the cost captures only changes in correlation structure, not changes in volatility. Two normalisation schemes are selectable via `use_ewma`:
| Scheme | Description |
|--------|-------------|
| `use_ewma = .false.` | Global: subtract full-sample mean, divide by full-sample std dev. Simple but has look-ahead bias. |
| `use_ewma = .true.` | EWMA (RiskMetrics): conditional σ_t updated at each step with decay λ = 0.94. Removes volatility clustering before the correlation test. |
BIC parameter count per segment: p(p+1)/2 + 1 (correlations + means + changepoint location).
Additional output options (compile-time parameters):
| Parameter | Default | Meaning |
|-----------|---------|---------|
| `print_segs` | 0 | 0 = BIC model only; 1 = 0 through BIC; 2 = all models |
| `print_diffs` | `.true.` | Bonferroni-adjusted Fisher z-tests for correlation changes between segments |
| `alpha_diff` | 0.05 | Significance level for the Fisher z-test |
| `print_pca_cov` | `.false.` | PCA of segment covariance matrix |
| `print_pca_corr` | `.true.` | PCA of segment correlation matrix |
| `resample_ret` | `.false.` | Shuffle rows as a null-hypothesis check |
**`xreturns_corrmat_ol.f90`**
**Online (expanding-window)** version of `xreturns_corrmat_cp.f90`. At each step t the DP is re-run on data 1:t only, so no future data are used. Prints a dated table showing how the BIC-optimal changepoint dates evolve as new observations arrive:
```
as-of n_cp changepoints
----------------------------------------------------------------------
2009-03-31 1 2008-09-15
2009-06-30 1 2008-09-15
2009-09-30 2 2007-07-26 2008-09-15
...
```
The DP is re-run every `step` observations (default 63, ≈ 1 quarter). Total work scales as O(n³/step). Supports `resample_ret`.
**`xreturns_corrsub_cp.f90`**
Runs up to four changepoint algorithms across all distinct asset subsets of specified sizes, controlled by compile-time toggles:
| Toggle | Algorithm |
|--------|-----------|
| `do_mean` | Mean-shift changepoints per asset (univariate) |
| `do_variance` | Variance changepoints per asset (on squared returns) |
| `do_cov` | Joint covariance-matrix changepoints for each subset |
| `do_corr` | Joint correlation-matrix changepoints for each subset (EWMA-standardised) |
`sub_sizes` specifies the subset sizes to analyse (default `[2, 5]`); `max_subsets` caps the number of subsets per size when C(n_assets, k) is large. A summary of AIC/BIC changepoint counts is printed for mean and variance. Supports `resample_ret`.
---
### Bootstrap resampling null test
**`xresample_cp.f90`**
Reads asset prices, computes returns, and for each asset runs changepoint detection on the actual return series and on `n_resample` bootstrap-resampled (row-shuffled) copies. Resampling destroys any serial structure, providing an empirical null distribution for the number of changepoints found by chance.
For each series and analysis type the summary table reports:
| Column | Meaning |
|--------|---------|
| `Actual` | BIC changepoint count for actual returns |
| `RMean` | Mean BIC count over resampled runs |
| `RMax` | Maximum BIC count over resampled runs |
| `p-value` | Fraction of resampled runs with BIC count ≥ Actual |
Key parameters:
| Parameter | Default | Meaning |
|-----------|---------|---------|
| `n_resample` | 100 | Number of bootstrap runs |
| `do_mean` | `.false.` | Detect mean changepoints |
| `do_variance` | `.true.` | Detect variance changepoints |
| `use_log_var` | `.true.` | Use log(c + r²) for variance series |
| `var_offset` | 0.01 | Offset c in log(c + r²) |
| `print_segs` | 1 | Segment detail level for actual returns |
Progress is printed as run numbers on a single line during the resample loop.
---
### Variance criterion simulation
**`xsim_variance_cp.f90`**
Monte Carlo simulation that compares the two variance series (r² and log(c + r²)) for detecting known changepoints. Generates `n_sim` Gaussian time series with piecewise-constant standard deviation (`sigmas`) and reports for each criterion:
- Mean number of changepoints found vs the true count
- Fraction of simulations finding exactly the right number
- Mean absolute location error when the count is exact
- Mean distance from each found changepoint to the nearest true one (all simulations)
- Histogram of the count of found changepoints
Set `write_data = .true.` to write the last simulation to a text file (`sim_variance_cp.txt`) with columns `t, seg, r, r^2, log(c+r^2)` for external inspection.
**`xsim_sd_changes.py`**
Python counterpart to `xsim_variance_cp.f90` using NumPy and `ruptures`.
---
### Generic data changepoints
**`xdata_mean_variance_cp.f90`** · **`xdata_mean_variance_cp.py`**
Reads a plain matrix data file (not asset prices) and finds changepoints in the mean and/or variance of each column. Suitable for any univariate time series data, not just financial returns.
Input file format:
```
# nrow ncol [any other text] ← first line: dimensions
# further comment lines ... ← skipped
-1.42 0.87 1.03 ... ← nrow rows of ncol whitespace-separated reals
```
The file is read by the `read_matrix` subroutine in `util.f90` (optional `nrow_max` and `ncol_max` arguments cap the dimensions read).
Segment output uses `print_univar_segments`: for each segment prints `start`, `end`, `n`, `mean`, `sd`, `min`, `max`, `first`, `last`.
Key parameters:
| Parameter | Default | Meaning |
|-----------|---------|---------|
| `data_file` | `"sim_matrix.txt"` | Input file |
| `do_mean` | `.false.` | Detect mean changepoints |
| `do_variance` | `.true.` | Detect variance changepoints |
| `use_log_var` | `.true.` | Use log(c + x²) for variance series |
| `var_offset` | 0.01 | Offset c |
| `max_col` | 3 | Maximum columns to analyse (0 = all) |
| `print_table` | `.false.` | Print M/cost/AIC/BIC table |
| `print_segs` | 1 | 0=none, 1=BIC model, 2=0..BIC, 3=all |
The Python version (`xdata_mean_variance_cp.py`) uses `ruptures.Dynp` with a custom `GaussianProfileCost` class that matches the Fortran cost function exactly (n/2 · log(sample_variance)). The `jump` parameter (default 5) controls the spacing of candidate breakpoint positions: `jump=1` is exact but O(n²); larger values trade a small positional rounding for substantial speed gains.
---
### Principal component analysis
**`xreturns_pca.f90`**
Reads asset prices, computes percent returns, and performs PCA on the full-sample covariance matrix via the Jacobi eigenvalue method. Prints the labeled covariance matrix, PC loadings, per-component variance explained, and cumulative variance explained.
---
## Modules
| File | Purpose |
|------|---------|
| `changepoint.f90` | Cost matrices (`cost_matrix`, `mean_shift_cost_matrix`, `multivar_cost_matrix`), DP solver (`solve_changepoints`), and backtracking helper (`segment_ends`) |
| `io_utils.f90` | `print_model_selection`; segment printing for corrmat, covmat, and univariate models (`print_univar_segments` prints mean, sd, min, max, first, last); `print_pca_loadings`; `keep_obs` |
| `pca_jacobi.f90` | `jacobi_eigen_sym` (Jacobi eigenvalue method) and `principal_components_cov` |
| `basic_stats.f90` | `mean`, `cov_mat`, `col_stats_ignore_nan`, `standardize_returns`, `biased_cov_sd`, `print_corr_mat`, `print_acf` |
| `util.f90` | `sort_int`, `set_segment_values`, `print_wall_time`, `next_combination`, `n_choose_k`, `cumul_sum`, `print_square_matrix`, `read_matrix` |
| `sim_changepoint.f90` | `generate_series` (bivariate), `generate_multivar_series` (multivariate) |
| `dataframe_index_date.f90` | Date-indexed DataFrame type: `read_csv`, `pct_change` (with `dropna` option), `log_change`, `keep_rows`, `select`, `resample` |
| `df_index_date_ops_mod.f90` | Index operations for the DataFrame |
| `date.f90` | `Date` type and arithmetic |
| `random.f90` | `rnorm` (scalar and vector) |
| `kind.f90` | `dp = real64`, `long_int = int64` |
| `constants.f90` | Physical and mathematical constants |
---
## Building
GNU make is required. The compiler is gfortran (set `FC` in the Makefile to change).
```bash
# Build all programs
make -f Makefile.xcorr
# Build and run individual programs
make -f Makefile.xcorr run_sim # xcorr_sim
make -f Makefile.xcorr run_covar_sim # xcovar_sim
make -f Makefile.xcorr run_fit # xcorr_fit
make -f Makefile.xcorr run_corr # xreturns_corr_cp
make -f Makefile.xcorr run_cov # xreturns_cov_cp
make -f Makefile.xcorr run_covmat # xreturns_covmat_cp
make -f Makefile.xcorr run_corrmat # xreturns_corrmat_cp
make -f Makefile.xcorr run_corrmat_ol # xreturns_corrmat_ol
make -f Makefile.xcorr run_corrsub # xreturns_corrsub_cp
make -f Makefile.xcorr run_pca # xreturns_pca
make -f Makefile.xcorr run_var # xreturns_variance_cp
make -f Makefile.xcorr run_mean # xreturns_mean_cp
make -f Makefile.xcorr run_mean_var # xreturns_mean_variance_cp
make -f Makefile.xcorr run_var_pwl # xreturns_variance_pwl
make -f Makefile.xcorr run_var_pwl_fast # xreturns_variance_pwl_fast
make -f Makefile.xcorr run_resample_cp # xresample_cp
make -f Makefile.xcorr run_sim_var_cp # xsim_variance_cp
make -f Makefile.xcorr run_data_mean_var_cp # xdata_mean_variance_cp
# Clean
make -f Makefile.xcorr clean
```
The Python programs require NumPy, pandas, and ruptures and run without compilation:
```bash
python xreturns_corr_cp.py
python xdata_mean_variance_cp.py
python xsim_sd_changes.py
```
The C++ program requires a C++17 compiler:
```bash
g++ -O2 -std=c++17 -o xreturns_corr_cp xreturns_corr_cp.cpp
./xreturns_corr_cp
```
---
## Runtime
Timings on a typical desktop for the full `spy_efa_eem_tlt.csv` dataset (n ≈ 5 500 daily returns, 4 assets, `max_cp = 20`):
| Program | Time |
|---------|------|
| `xreturns_corr_cp` (Fortran) | ~2 s |
| `xreturns_corr_cp.py` (Python/NumPy) | ~10 s |
| `xreturns_corr_cp` (C++) | ~1 s |
| `xreturns_covmat_cp` | ~15 s |
| `xreturns_corrmat_cp` | ~15 s |
| `xcovar_sim` (n = 2 000) | ~0.3 s |
| `xreturns_corrmat_ol` (step = 63) | ~minutes |
| `xsim_variance_cp` (n_sim = 1 000, n = 1 500) | ~seconds |
| `xdata_mean_variance_cp.py` (n = 3 000, jump = 5) | ~20 s |
The dominant cost in all programs is the O(n² × max_m) dynamic programming step. The joint covariance and correlation matrix programs add an O(p³) Cholesky per cost matrix cell. The online program repeats the full DP at each step, so total work is O(n³/step).
For the Python `xdata_mean_variance_cp.py`, runtime scales as O((n/jump)²) per series; `jump=5` is ~250× faster than `jump=1` with negligible loss of precision on typical data.
---
## References
The piecewise-constant correlation changepoint model is based on:
- Galeano, P. and Wied, D. (2014). [Multiple break detection in the correlation structure of random variables](https://arxiv.org/abs/1206.5367). *Computational Statistics & Data Analysis*, 76, 262–282.
BIC for changepoint models:
- Yao, Y.-C. (1988). [Estimating the number of change-points via Schwarz criterion](https://www.sciencedirect.com/science/article/abs/pii/0167715288901186). *Statistics & Probability Letters*, 6(3), 181–189.
Continuous piecewise-linear fitting:
- Bellman, R. and Roth, R. (1969). [Curve fitting by segmented straight lines](https://www.tandfondle.com/doi/pdf/10.1080/01621459.1969.10501038). *Journal of the American Statistical Association*, 64(327), 1079–1084.