DEV Community

Sarthak Verma
Sarthak Verma

Posted on Edited on

Three Correlations Passed Validation. The Matrix Was Impossible.

Developer-quant desk with an impossible correlation matrix and a rejected risk model on a laptop

TL;DR: Checking that every correlation is between -1 and 1 is not enough. A complete correlation matrix must also be positive semidefinite. Below is a three-asset counterexample, a dependency-free JavaScript validator, and tests that make the result reproducible.

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 ]
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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²
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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²))
Enter fullscreen mode Exit fullscreen mode

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`);
}
Enter fullscreen mode Exit fullscreen mode

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

node risk-matrix.mjs
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

Then run:

node --test risk-matrix.test.mjs
Enter fullscreen mode Exit fullscreen mode

Test result: all 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.

Rounding note: Each contribution is displayed to three decimal places. The rounded display values may sum to 21.937pp, while the full-precision sum rounds to 21.938pp. The test checks the unrounded identity, not the displayed strings.

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.


Try the real interactive experiment: The public MarketDeck Risk Constellation models four hypothetical assets; you can also explore MarketDeck's other interactive finance labs. The three-asset code in this article is a separate, self-contained teaching example.

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 mathematics were checked against the documented MarketDeck model, and the JavaScript example and six tests were executed using Node.js. All numbers are hypothetical and educational, not investment advice.

Top comments (0)