Skip to content
SHASHWAT // SYSTEM ARCHIVE
SYSTEM.ARTICLE

Building a Regime-Aware Multi-Asset Correlation and Portfolio Risk Tool

avatarShashwat Sharma
9 min read

Building a Regime-Aware Multi-Asset Correlation and Portfolio Risk Tool

Correlation between assets is not a fixed number — it shifts depending on whether markets are calm or crashing, and most dashboards hide that. I built a tool that detects market regimes automatically, shows how correlation and tail risk change inside each one, and then uses that information to build and stress-test an actual portfolio.


What It Does and Who It's For

The tool is a Streamlit app that pulls historical price data for a mix of equities, indices, FX pairs, rates, and commodities from Yahoo Finance. You pick a date range and a basket of assets, and it runs a full regime and risk analysis on top of them.

Stage 1 handles statistics: it classifies the market into Bull, Range, or Bear regimes with a Hidden Markov Model, measures how often assets crash together using copulas, models time-varying volatility with GARCH(1,1), and computes nine professional performance metrics per asset (Sharpe, Sortino, Calmar, Omega, skew, kurtosis, and more).

Stage 2 turns that analysis into an actual portfolio: minimum-variance and maximum-Sharpe optimization, the full efficient frontier, VaR and CVaR, stress tests against 2008, COVID, and 2022, and a backtest against an equal-weight benchmark.

It's built for people studying quantitative finance or preparing for markets-adjacent interviews who want to see the concepts — regime switching, tail dependence, volatility clustering, Modern Portfolio Theory — actually running on real data instead of staying abstract in a textbook.

The Problem That Made Me Build It

Every correlation matrix I'd seen in intro material was a single static number per asset pair, computed over whatever window you happened to pick. That number quietly assumes the relationship between two assets is stable. It isn't.

A textbook example: S&P 500 and Treasuries often show a mildly negative correlation over a multi-year window. But during the COVID crash in March 2020, that relationship flipped — both fell together as everything got sold for cash. A portfolio built on the "stocks and bonds diversify each other" assumption would have discovered, at the worst possible time, that it didn't.

I wanted a tool that made this visible instead of hiding it inside an average. That meant first detecting which regime the market is in, then computing correlation and tail risk conditional on that regime, rather than blending bull and bear periods into one misleading blur.

💡Insight

A single correlation number is really an average of at least two very different regimes. Averaging them together erases the exact information a risk manager needs.

Architecture and How It Works

The codebase is about 3,100 lines split across a Streamlit front end (app.py) and ten modules in src/, each with one job: data_loader.py fetches and cleans prices, hmm_regime.py fits the regime model, copula_analysis.py computes tail dependence, garch_volatility.py models conditional volatility, performance_metrics.py computes ratios, portfolio_optimizer.py and risk_models.py handle Stage 2, and visualizer.py turns all of it into Plotly charts.

The pipeline runs in order. First, data_loader.py fetches daily prices and converts them to log returns, which are additive and symmetric in a way simple returns aren't. Second, hmm_regime.py fits a Hidden Markov Model with 2 or 3 hidden states over those returns and labels each day Bull, Range, or Bear by sorting states on mean return. Third, correlation and tail dependence get recomputed within each regime, not just across the full sample. Fourth, GARCH(1,1) fits conditional volatility per asset and forecasts it five days out. Fifth, once a portfolio is optimized, risk_models.py reuses the same weights for VaR, CVaR, stress testing, and backtesting — so every number on the dashboard describes the same portfolio, not five disconnected widgets.

That last point mattered more than it sounds. Early on, VaR and the backtest could reference different weight sets depending on which sidebar toggle a user had touched last. Forcing every Stage 2 computation to consume one shared weights object, with a fallback to equal-weight only when optimization is off, made the whole dashboard tell one consistent story.

def portfolio_volatility(weights, cov_matrix):
    return np.sqrt(weights @ cov_matrix @ weights)

For optimization itself, scipy.optimize.minimize with SLSQP solves both the minimum-variance and maximum-Sharpe problems under a weights-sum-to-one constraint. The efficient frontier repeats this 40–50 times, once per target volatility level, which is why tracing the full frontier is the slowest computation in Stage 2 — still under two seconds for a five-asset portfolio, but the one place where the code visibly does more work than a single-shot optimization.

The Hardest Technical Decision

The hardest call was picking a Hidden Markov Model over a simple VIX threshold for regime detection, and living with what that choice costs.

A VIX threshold is trivial to explain: above some cutoff, you're in a "high vol" regime, below it you're not. It's deterministic and easy to defend in a sentence. An HMM is the opposite. It's unsupervised, it estimates hidden states via the EM algorithm, and it comes with two real problems: label switching (state 0 isn't guaranteed to mean the same thing across two different fits) and sensitivity to the return window you feed it. I resolved label switching by always sorting fitted states by mean return before assigning Bull/Range/Bear labels, so the semantic label is stable even if the model's internal state ordering isn't.

I chose the HMM anyway because a threshold rule can't tell you anything about regime stability. The HMM's transition matrix answers a question a threshold never can: given that we're in a Bear regime today, what's the probability we're still in one tomorrow? That number turned out to be around 80% for Bear-to-Bear transitions in the data I tested — regimes are sticky, and that stickiness is itself decision-relevant information a hard cutoff throws away.

⚠️Warning

An HMM is harder to validate than a threshold rule. If you can't explain why your model labeled a specific week "Bear," you've traded interpretability for expressiveness — make sure that trade is worth it for your use case.

The same logic shaped the VaR choice: historical (empirical) VaR over a parametric normal-distribution VaR, because return distributions in this data are fat-tailed enough that a normality assumption would understate crash risk — exactly the failure mode copulas were built to expose in Stage 1.

What I Measured

Running the GARCH(1,1) model on the assets I tested gave a persistence of α + β ≈ 0.99 (with α ≈ 0.08, β ≈ 0.91), meaning volatility shocks decay very slowly — a big move today keeps volatility elevated for roughly 15–20 days before it fades. That's a concrete, checkable number, not an assumption.

Regime persistence from the HMM's transition matrix showed P(Bear → Bear) around 80% in the periods I examined, versus much lower persistence for Range regimes — evidence that bear markets, once entered, tend to stay entered for a while rather than flickering in and out.

Tail dependence via copulas came out around 40%+ between equities and Treasuries during the COVID window, versus low single digits during the 2021 bull run — a roughly tenfold jump in how often the two assets crashed together, depending entirely on regime.

On the engineering side: the efficient frontier runs 40–50 independent SLSQP optimizations and still finishes in under two seconds for a 5-asset portfolio. VaR at the 95% confidence level, computed historically over a 500-day window, is literally the return at rank 25 (the 25 worst days out of 500) — CVaR is the average of those same 25 days, which is why CVaR is always at least as bad as VaR by construction, not by estimation.

What I'd Do Differently

The most honest gap is testing. Both stages were verified manually — weights sum to one, min-variance volatility never exceeds equal-weight volatility, max-Sharpe's Sharpe ratio beats equal-weight's, CVaR is always more negative than VaR — but none of that is committed as an automated pytest suite yet. Manual verification catches bugs once; a test suite catches regressions every time the code changes, and this project doesn't have that safety net.

I'd also vectorize the efficient frontier instead of running 40–50 sequential SciPy solves. Each target-volatility point is an independent optimization, which means they're embarrassingly parallel — right now they run one after another for no reason other than that's how the first version was written.

Last, I'd add Monte Carlo VaR as a third option alongside historical and parametric. Historical VaR is honest about fat tails but only as good as the sample window; a Monte Carlo approach conditioned on the fitted GARCH volatility would let the risk estimate react to current conditions instead of purely historical ones.

Conclusion

The core idea — that correlation and tail risk depend on regime, and that a portfolio should be optimized and stress-tested against that reality rather than a single blended average — held up across every asset combination I tried it on. The numbers weren't abstract: an 80% chance of staying in a bear regime, a tenfold jump in tail dependence during COVID, and a GARCH persistence near 0.99 are the kind of concrete evidence that makes "correlations change under stress" more than a slogan.