A risk dashboard can look completely convincing while displaying numbers from a model that should never have accepted its inputs.

Imagine a UI with three correlation sliders:

  • Asset A and asset B: +0.90
  • Asset A and asset C: +0.90
  • Asset B and asset C: -0.90 Every input fits the permitted range. The diagonal of the resulting matrix is 1. The matrix is symmetric. A naïve form validator gives it a green checkmark.

And yet no three random variables can have all three of those pairwise correlations at once.

This isn't a report of a production failure. It's a failure mode we explicitly designed against while building a browser-based portfolio-risk visualization. The surprising part is that the bug can live entirely in the inputs, before any rendering code runs.
The matrix that looks valid
Our three sliders produce:

A B C 

A [ 1.00, 0.90, 0.90 ]
B [ 0.90, 1.00, -0.90 ]
C [ 0.90, -0.90, 1.00 ]

The following check is necessary, but insufficient:

const correlations = [0.9, 0.9, -0.9]; const inRange = correlations.every( value => Number.isFinite(value) && value >= -1 && value <= 1 ); 

It returns true. That proves only that individual cells are in range.

A correlation matrix has to describe a jointly possible system. Its entries are constrained by one another.

There is a geometric intuition: if B closely follows A, and C also closely follows A, then B and C cannot simultaneously behave as almost exact opposites. The three statements conflict.
Why the determinant exposes the contradiction
For a symmetric three-asset correlation matrix with off-diagonal entries a, b, and c, its determinant is:

det(R) = 1 + 2abc - a² - b² - c²

In this example, a = 0.9, b = 0.9, and c = -0.9, so:

det(R) = 1 + 2(0.9)(0.9)(-0.9) - 3(0.9²)
= -2.888

A real correlation matrix must be positive semidefinite (PSD): for every vector x, the quadratic form xᵀRx must be nonnegative. That is what makes it possible to interpret the matrix as correlations coming from a covariance matrix.

This particular matrix has eigenvalues -0.8, 1.9, and 1.9. A negative eigenvalue is enough to show it is not PSD.

Here's another way to see it. Take x = [-1, 1, 1]. Then xᵀRx = -2.4. If R were a covariance matrix of unit-variance variables, that would claim a linear combination has negative variance. That's impossible.

One nuance: a bad correlation matrix does not necessarily produce a negative variance for every long-only portfolio. It is invalid globally, even if one portfolio happens to produce a nonnegative result. That's why validating only the final variance is not enough.

You can also derive the allowable third correlation when the first two are fixed:

ab - sqrt((1 - a²)(1 - b²)) ≤ c ≤ ab + sqrt((1 - a²)(1 - b²)) 

For a = b = 0.9, the range is 0.62 ≤ c ≤ 1.00. A value of -0.9 is nowhere near feasible.

Changing c to +0.8 creates a valid matrix with determinant +0.036. But an application should not silently change a user's assumption. Fixing or projecting a matrix is a modeling choice that deserves an explanation.
A tiny JavaScript engine you can run
The example below deliberately has three assets so the PSD test fits in one formula. It is a self-contained teaching implementation, not a copy of our production module and not a generic validator for matrices of any size.

It also demonstrates a second engineering invariant: the sum of Euler volatility contributions must equal portfolio volatility whenever that quantity is defined.

Create risk-matrix.mjs:

// Standalone educational example for the DEV article. // Three assets, decimal inputs, no dependencies, no price feed. import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; const EPS = 1e-10; export function validateCorrelation3(R) { if (!Array.isArray(R) || R.length !== 3 || R.some(row => !Array.isArray(row) || row.length !== 3)) { throw new RangeError('Expected a 3 x 3 matrix'); } for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { const r = R[i][j]; if (!Number.isFinite(r) || r < -1 || r > 1) { throw new RangeError('Correlations must be finite and in [-1, 1]'); } if (i === j && Math.abs(r - 1) > EPS) { throw new RangeError('Diagonal entries must be 1'); } if (Math.abs(r - R[j][i]) > EPS) { throw new RangeError('Matrix must be symmetric'); } } } const a = R[0][1], b = R[0][2], c = R[1][2]; // For a symmetric 3x3 unit-diagonal matrix, with |a|,|b|,|c| <= 1, // all principal minors are nonnegative iff this determinant is >= 0. const determinant = 1 + 2 * a * b * c - a*a - b*b - c*c; if (determinant < -EPS) { throw new RangeError(`Not positive semidefinite; det=${determinant.toFixed(3)}`); } return determinant; } export function portfolioRisk3({ weights, vols, correlations }) { validateCorrelation3(correlations); if (!Array.isArray(weights) || weights.length !== 3 || weights.some(w => !Number.isFinite(w) || w < 0 || w > 1) || Math.abs(weights.reduce((a, b) => a + b, 0) - 1) > EPS) { throw new RangeError('Long-only weights must sum to 1'); } if (!Array.isArray(vols) || vols.length !== 3 || vols.some(v => !Number.isFinite(v) || v < 0)) { throw new RangeError('Volatilities must be nonnegative decimals'); } const cov = correlations.map((row, i) => row.map((rho, j) => vols[i] * rho * vols[j])); const sigmaW = cov.map(row => row.reduce((sum, element, j) => sum + element * weights[j], 0)); const variance = weights.reduce((sum, w, i) => sum + w * sigmaW[i], 0); if (variance < -EPS) throw new RangeError('Negative portfolio variance'); const volatility = Math.sqrt(Math.max(0, variance)); // The Euler attribution is undefined at zero volatility; don't fake shares. const contributions = volatility > 0 ? weights.map((w, i) => w * sigmaW[i] / volatility) : null; return { volatility, contributions }; } export const IMPOSSIBLE = [ [ 1, 0.9, 0.9 ], [ 0.9, 1, -0.9 ], [ 0.9,-0.9, 1 ], ]; export const POSSIBLE = [ [ 1, 0.9, 0.9 ], [ 0.9, 1, 0.8 ], [ 0.9, 0.8, 1 ], ]; if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { try { validateCorrelation3(IMPOSSIBLE); } catch (error) { console.log('Rejected:', error.message); } const out = portfolioRisk3({ weights: [0.4, 0.35, 0.25], vols: [0.3, 0.2, 0.15], correlations: POSSIBLE, }); console.log('Portfolio volatility:', `${(100 * out.volatility).toFixed(3)}%`); console.log('Risk contributions:', out.contributions.map(x => `${(100 * x).toFixed(3)}pp`)); console.log('Sum:', `${(100 * out.contributions.reduce((s, x) => s + x, 0)).toFixed(3)}pp`); } 

Run it with Node.js (tested with Node 22.16.0):

node risk-matrix.mjs 

Expected output (rounded for display):

Rejected: Not positive semidefinite; det=-2.888 Portfolio volatility: 21.938% Risk contributions: [ '11.856pp', '6.637pp', '3.444pp' ] Sum: 21.938pp 

Nothing here calls an API or downloads price data. The weights, volatilities, and correlations are hypothetical decimals, not historical estimates or investment recommendations.
The important part is what we test
For risk software, a nice-looking result isn't an acceptance test. We want a handful of invariants that can fail loudly:

  • The impossible matrix is rejected before pricing or rendering.
  • A valid matrix is accepted with its determinant unchanged.
  • Risk contributions sum to the same volatility computed by the covariance model.
  • Singular PSD matrices can be valid, but zero-volatility risk shares are undefined rather than an invented 0% / 0% / 0% chart.
  • Unnormalized long-only portfolio weights are rejected.
  • A negative individual Euler risk contribution can be legitimate when an asset hedges the rest. Don't automatically clip it to zero. Create risk-matrix.test.mjs:
import test from 'node:test'; import assert from 'node:assert/strict'; import { validateCorrelation3, portfolioRisk3, IMPOSSIBLE, POSSIBLE, } from './risk-matrix.mjs'; const close = (actual, expected, tolerance = 1e-12) => assert.ok(Math.abs(actual - expected) < tolerance, `Expected ${actual} ≈ ${expected}`); test('rejects pairwise-valid but jointly impossible correlations', () => { assert.throws(() => validateCorrelation3(IMPOSSIBLE), /Not positive semidefinite/); }); test('accepts a valid 3-asset correlation matrix', () => { close(validateCorrelation3(POSSIBLE), 0.036); }); test('risk contributions add up to portfolio volatility', () => { const out = portfolioRisk3({ weights: [0.4, 0.35, 0.25], vols: [0.3, 0.2, 0.15], correlations: POSSIBLE, }); close(out.volatility, 0.2193769586807147); close(out.contributions.reduce((s, x) => s + x, 0), out.volatility); }); test('accepts singular PSD matrices but does not invent zero-risk shares', () => { const perfectOpposites = [ [1, -1, 0], [-1, 1, 0], [0, 0, 1], ]; const out = portfolioRisk3({ weights: [0.5, 0.5, 0], vols: [0.2, 0.2, 0.2], correlations: perfectOpposites, }); close(out.volatility, 0); assert.equal(out.contributions, null); }); test('invalid weights never reach the model', () => { assert.throws(() => portfolioRisk3({ weights: [0.5, 0.5, 0.5], vols: [0.2, 0.2, 0.2], correlations: POSSIBLE, }), /weights must sum to 1/); }); test('negative Euler contributions can be legitimate', () => { const hedge = [ [1, -0.9, 0], [-0.9, 1, 0], [0, 0, 1], ]; const out = portfolioRisk3({ weights: [0.05, 0.8, 0.15], vols: [0.2, 0.2, 0.2], correlations: hedge, }); assert.ok(out.contributions[0] < 0); close(out.contributions.reduce((s, x) => s + x, 0), out.volatility); }); 

Then run:

node --test risk-matrix.test.mjs 

Result: six tests pass on the code above. The valid example uses weights 40% / 35% / 25%, individual annualized volatilities 30% / 20% / 15%, and pairwise correlations 0.9 / 0.9 / 0.8. It calculates 21.938% modeled portfolio volatility. Its contributions are 11.856, 6.637, and 3.444 volatility percentage points—not three independent asset volatilities.

The units matter: 0.20 means 20% annualized volatility; 0.20 is not 0.20 percentage points. Errors here can be just as misleading as a bad matrix.
This is a UI architecture problem, too
In MarketDeck's Risk Constellation, the live public educational lab models four fictional assets. It calculates covariance, portfolio volatility, and Euler risk contributions in a numerical engine rather than inferring them from how far a line stretches on screen. Its public equal-correlation slider is constrained to a safe 0...1 range; you cannot paste the impossible three-asset matrix into that public UI.

The engineering lesson is still useful for any application that accepts independently edited pairwise assumptions:

  1. Validate the complete mathematical object, not individual form fields. For bigger matrices, use a tested PSD eigenvalue or factorization method, with an explicit numerical tolerance.
  2. Don't let a renderer own the answer. The chart, table, tooltip, and accessible text should all read the same verified result.
  3. Reject or explain invalid edits. If you choose a nearest-correlation-matrix repair, show the changed assumptions. Don't silently manufacture a different experiment.
  4. Make undefined states explicit. Zero portfolio volatility doesn't produce meaningful proportional risk shares.
  5. Test identities, not screenshots alone. The equality sum(risk contributions) ≈ portfolio volatility is a stronger regression check than a beautiful SVG. The nearest-correlation-matrix problem is a serious numerical-analysis problem in its own right; Nicholas Higham's paper is a good starting point if you want to investigate careful projection algorithms rather than simply rejecting an input. One last challenge Try replacing the valid matrix's third correlation, 0.8, with 0.61. The entry is still between -1 and 1, yet the complete matrix should fail the PSD test. Then try 0.62, the singular boundary, and see why a zero determinant does not automatically mean an invalid correlation matrix.

It's a small experiment, but it carries a general engineering lesson:
A set of individually plausible inputs is not necessarily a mathematically possible system.

That's true in finance, numerical simulation, ML feature engineering, and many applications where relationships are entered pair by pair.

References: Higham, Computing the Nearest Correlation Matrix, Node.js built-in test runner.

Disclosure: This article and its illustrative code were drafted with substantial AI assistance. The mathematical definitions were checked against the MarketDeck model and numerical references, and the included JavaScript example was executed with six passing tests. A human publisher should review the final text before releasing it. The example is educational only.