D14-F02-A02 / Released engineering topic

Equal Risk Contribution: Let the Covariance Matrix Change the Allocation

A production-minded guide to Equal Risk Contribution.

Equal Risk Contribution: Let the Covariance Matrix Change the AllocationD14 / D14-F02

Equal risk contribution (ERC) starts with an uncomfortable distinction: equal dollars are not equal risk. A portfolio can give every asset the same capital weight while one asset supplies most of its volatility. ERC reverses the question. Given a covariance matrix, what positive, fully invested weights make each asset contribute the same share of portfolio volatility?

This tutorial builds that calculation from a small synthetic matrix. You will see the risk contributions, the iterative solve, the inverse-volatility contrast, and the input boundary that matters before either method receives a return series. The result is a reproducible educational calculation, not an investment recommendation or a claim that ERC wins.

The input problem comes first

In my own portfolio-data work, I have repeatedly encountered prices that did not reconcile across splits, suspect quantity/price imports, displayed weights that changed when source fields changed, and holdings from Egypt, Japan, and the USA translated into USD. Those experiences make the data contract part of the algorithm lesson. They do not justify inventing a trade, a provider correction, or a historical incident.

Before asking whether weights need to change, identify what was calculated. For a USD base and a local holding, with FX quoted as USD per one local unit:

MVi,tbase=Qi,tPi,tlocalFXi,tbase/localMV_{i,t}^{base}=Q_{i,t}P_{i,t}^{local}FX_{i,t}^{base/local}

That is a market-value conversion, not cost basis. A post-split mark, stale FX quote, stale local mark, or suspect quantity can change a displayed weight without proving that the economic position changed. A missing mark is not a zero return and should not be transformed into a zero volatility merely to make an allocator run.

The prior Stale-Quote Detector is the relevant companion: an unchanged or missing quote needs session and clock evidence before it becomes an observation. Backward Split Adjustment and Cash-Dividend Total-Return Adjustment show why the return basis must be declared before covariance estimation.

Currency makes the same boundary visible. If local and FX simple returns use the same interval:

ri,tbase=(1+ri,tlocal)(1+ri,tFX)1r_{i,t}^{base}=(1+r_{i,t}^{local})(1+r_{i,t}^{FX})-1

The multiplicative product term has its own covariances in addition to local and FX variation. A covariance estimated from local returns is therefore not the same estimand as one estimated from base-currency returns. ERC cannot decide which basis was intended; the upstream panel must say.

I constructed a four-interval synthetic case to make the cost of mixing bases visible. Let A's local returns be [-.02,-.01,.01,.02], B's USD returns be [-.01,.01,-.01,.01], and A's USD-per-local-unit FX returns equal B's series. The multiplicative conversion makes A's USD returns [-.0298,-.0001,-.0001,.0302]. With sample covariance (divisor n-1), the local/mixed-basis two-asset ERC weights are [.3874258867,.6125741133]. If those weights are evaluated on the aligned USD covariance, the risk shares become [.5851620429,.4148379571]. Solving ERC on the aligned USD matrix instead gives [.3203717978,.6796282022] and equal shares. In this two-asset case ERC weights coincide with inverse-volatility weights; the result does not generalize to three assets. The mixed-basis result is an input diagnostic, not a historical holding or a trade instruction.

What ERC equalizes

Let w be a vector of positive asset weights that sums to one, and let Σ be a same-horizon covariance matrix. Portfolio variance and volatility are:

q(w)=wTΣwq(w)=w^\mathsf{T}\Sigma w σp(w)=q(w)\sigma_p(w)=\sqrt{q(w)}

The marginal volatility risk for asset i is (Σw)ᵢ / σₚ; (Σw)ᵢ alone is its numerator. Multiplying that marginal risk by the asset's weight gives the component contribution:

RCi(w)=wi(Σw)iσp(w)RC_i(w)=\frac{w_i(\Sigma w)_i}{\sigma_p(w)}

and its contribution share is:

sharei(w)=wi(Σw)iq(w)share_i(w)=\frac{w_i(\Sigma w)_i}{q(w)}

The risk-budgeting equation targets a positive budget bi:

RCi(w)=biσp(w),bi>0,ibi=1RC_i(w)=b_i\sigma_p(w),\qquad b_i>0,\qquad\sum_i b_i=1

ERC is the equal-budget special case, bi=1/N. This is covariance-aware: the off-diagonal entries influence (Σw)i. The sign of a component contribution is mathematical information. A negatively contributing hedge is not silently clipped to zero.

The original paper by Maillard, Roncalli, and Teïletche establishes the ERC terminology and risk-contribution framing. For the algorithm below, I use the volatility CCD update in Griveau-Billion, Richard, and Roncalli, “A Fast Algorithm for Computing High-dimensional Risk Parity Portfolios”, Eq. 7. The paper also presents a distinct quadratic-log formulation; this tutorial does not mix the two.

A small exact anchor

Start with a two-asset synthetic covariance:

Σ=[0.040.0120.0120.01]\Sigma=\begin{bmatrix}0.04&0.012\\0.012&0.01\end{bmatrix}

Let w=[x,1−x]. Equal contributions have the same positive σp denominator, so the numerators must match:

w1(Σw)1=w2(Σw)2w_1(\Sigma w)_1=w_2(\Sigma w)_2

Expanding cancels the two cross terms and leaves:

0.04w12=0.01w220.04w_1^2=0.01w_2^2

The positive solution has w1/w2=1/2. Full investment gives:

wERC=[1/3,2/3]w^{ERC}=[1/3,2/3]

Direct substitution gives q=16/1125≈0.0142222222 and σp=4/(15√5)≈0.1192569588. Each component contributes σp/2. This is an independent arithmetic anchor, not a call to the Python or TypeScript code.

The two-asset result is useful precisely because it is limited. Cross-term cancellation here must not be generalized to three assets.

The three-asset contrast

Use the synthetic covariance:

Σ=[0.01000.01600.00150.01600.04000.00600.00150.00600.0225]\Sigma=\begin{bmatrix} 0.0100&0.0160&0.0015\\ 0.0160&0.0400&-0.0060\\ 0.0015&-0.0060&0.0225 \end{bmatrix}

An independent positive-root calculation gives:

Plain text
ERC weights       [0.3778302518, 0.2341252609, 0.3880444873]
ERC risk shares   [0.3333333333, 0.3333333333, 0.3333333333]
portfolio σ       0.0958566604

The same diagonal volatilities [0.10,0.20,0.15] produce inverse-volatility weights [6/13,3/13,4/13]. Feeding those baseline weights into this covariance gives risk shares approximately [0.43181818,0.36363636,0.20454545]. The vectors differ because one rule sees only standalone volatility and the other sees covariance. This is a definition contrast, not a performance ranking.

Three synthetic assets: inverse volatility versus equal risk contribution

Open the comparison at full size. If you are reading on a narrow screen, the exact comparison is also here:

AssetInverse-volatility weightIts covariance risk shareERC weightERC risk share
A46.15%43.18%37.78%33.33%
B23.08%36.36%23.41%33.33%
C30.77%20.45%38.80%33.33%

Use the computed ERC playground to change the correlations while keeping the standalone volatilities fixed. The ERC weights move with the matrix; the inverse-volatility baseline does not. The playground reports its residual and iteration count rather than showing an opaque animation.

How the frozen solve works

The implementation divides Σ by its actual maximum absolute entry s. This keeps the coordinate arithmetic near a usable scale; weights and shares are unchanged, while volatility is restored by √s.

For an internal positive vector y, let C=Σ/s, b=1/N, a=Cii, and:

oi=jiCijyjo_i=\sum_{j\ne i}C_{ij}y_j

The positive root used at coordinate i is:

yinew=oi+oi2+4abσC(y)2ay_i^{new}=\frac{-o_i+\sqrt{o_i^2+4ab\sigma_C(y)}}{2a}

where σC(y)=√(yᵀCy). If oi is positive, the implementation evaluates the algebraically equivalent rationalized form to avoid subtracting nearly equal positive numbers. It cycles assets from first to last, then checks:

maxisharei1N1010\max_i\left|share_i-\frac1N\right|\le10^{-10}

After convergence, y is normalized to w. The portfolio weights are unchanged by a common scaling of y, but the exact optimizer's internal scale is not arbitrary for the selected fixed log-barrier coefficient. Its first-order conditions give yᵢ ∂σ_C(y)/∂yᵢ = bᵢ; summing and using the homogeneity of volatility yields σ_C(y) = Σᵢbᵢ = 1 at the exact minimizer. The returned variance, volatility, contributions, and shares are recomputed from w before restoring the original covariance scale. A result from an internal y scale must never be mistaken for the risk of the returned portfolio.

The pure implementation accepts only a finite, square, exactly symmetric, strictly positive-definite covariance. It does not symmetrize, clip an eigenvalue, fill missing cells, rescale by a hidden floor, or substitute a different estimator. A singular or indefinite matrix may be useful to another method, but it is outside this positive ERC contract and is rejected with a structured reason.

The first stop is not an invitation to make a bad matrix look acceptable. It is a request to inspect the return panel, universe, estimation window, currency basis, and adjustment history before selecting an alternative method.

A real input-integrity consequence

To connect the prior Stale-Quote Detector's evidence rule to ERC, I constructed this separate synthetic return panel for D14:

Plain text
A = [-0.02, -0.01, 0.01, 0.02]
B = [-0.01,  0.01,-0.01, 0.01]
sample σ = [0.0182574186, 0.0115470054]
inverse-volatility weights = [0.3874258867, 0.6125741133]

Those numbers are a synthetic calculation, not 4070.SR prices. If a stale-mark routine replaces A's unknown returns with four zeros, its estimated volatility is zero. The inverse-volatility allocator rejects that input; an ERC covariance builder should likewise stop before producing a false risk allocation. Flat returns alone do not prove an outage. The confirmed public Saudi CMA suspension and resumption notices for 4070.SR establish an event boundary, not every provider bar or an ERC backtest.

That is why this article defers a historical ERC portfolio. A publishable historical case would need licensed point-in-time returns, stable identifiers, adjustment and currency metadata, availability cutoffs, an exact covariance recipe, and redistribution permission. The unverified 1010.SR quantity/price anecdote remains a suspected input record, not historical proof.

Reproduce it

The topic contains dependency-free Python and TypeScript functions. The validated environment is Python 3.11+, Node.js 22.x, and TypeScript 7.0.2.

Shell
python -c "from implementations.python.equal_risk_contribution import equal_risk_contribution_weights; print(equal_risk_contribution_weights(['A','B'], [[0.04,0.012],[0.012,0.01]]))"
Shell
npx tsc --ignoreConfig --strict --target ES2022 --module Node16 --moduleResolution Node16 --esModuleInterop --types node --outDir .tmp-ts implementations/typescript/equal-risk-contribution.ts
node --experimental-default-type=module --input-type=module -e "import { equalRiskContributionWeights } from './.tmp-ts/equal-risk-contribution.js'; console.log(equalRiskContributionWeights(['A','B'], [[0.04,0.012],[0.012,0.01]]));"

The interactive file is an ES module and should be served over HTTP:

Shell
python -m http.server 8765

Then open the topic's visuals/animated/playground.html path. Opening the file directly as file:// is not a valid module test because browser CORS rules normally block the local import.

The shared fixture tests canonical, diagonal, two-asset, small-scale, and large-scale cases, plus shape, domain, asymmetry, singular, indefinite, nonnumeric, null, permutation, immutability, and direct Python↔TypeScript parity behavior. Upstream evidence cases are metadata-only routing assertions; they are not disguised covariance tests.

What ERC cannot tell you

ERC equalizes ex-ante contributions for the supplied covariance. It does not forecast returns, guarantee realized risk balance, identify a stale quote, repair a split adjustment, verify a quantity, choose local versus base currency, or decide whether to rebalance. It is sensitive to the universe and to covariance estimation choices. A source that contains several representations of one economic exposure can change the equal-budget problem; that is a universe-design question, not proof that the solver is wrong.

The next family topics generalize or reorganize the same idea: Risk Budgeting will make target budgets explicit, while Hierarchical Risk Parity and Hierarchical Equal Risk Contribution add clustering and hierarchy. Those are catalog handoffs; their staged folders are still under research.

References

The linked primary sources support the definitions and selected algorithm. They do not establish performance superiority, authenticate a private provider, or turn the synthetic examples into historical observations.

erc solver flow

The loop is the teaching object: the returned portfolio risk is recomputed after normalization, so the internal vector's magnitude is not misreported as the risk of the fully invested portfolio.

References5 primary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

Accessed: 2026-09-14 in Africa/Cairo. The focused Google discovery query, ten organic slots, observed ranks, accessible-page extent, and blocked destinations are recorded in GOOGLE-RESULTS.md. Google snippets are discovery observations, not technical evidence.

R01 — Maillard, Roncalli, and Teïletche, “On the Properties of Equally-Weighted Risk Contributions Portfolios”

  • Authors: Sébastien Maillard, Thierry Roncalli, Jérôme Teïletche
  • Source type: original research paper / author-hosted PDF
  • First version: June 2008; inspected paper version identifies May 2009
  • Published form: Journal of Portfolio Management 36(4), 60–70 (2010)
  • DOI: https://doi.org/10.3905/jpm.2010.36.4.060
  • Accessible author URL: https://www.thierry-roncalli.com/download/erc.pdf
  • Access extent: the exact rank-1 PDF URL was opened during the Google audit, but the connected viewer was blank and its text was not readable; the accessible ResearchGate author-uploaded mirror was inspected through 3,607 extracted lines, including the abstract/introduction, risk-contribution definitions, ERC constraint, and two-asset/general-case discussion.
  • Supports: the original ERC terminology, equal component contribution framing, covariance-aware risk contribution, and historical provenance of the method.
  • Limitation: the host PDF body was unavailable in the audit; the mirror is copyrighted and only the stated extent was read. No empirical ranking from the paper is imported into this topic.

R02 — Griveau-Billion, Richard, and Roncalli, “A Fast Algorithm for Computing High-dimensional Risk Parity Portfolios”

  • Authors: Théophile Griveau-Billion, Jean-Charles Richard, Thierry Roncalli
  • Source type: open arXiv research paper
  • Version/date: arXiv:1311.4057v1, 16 November 2013
  • URL: https://arxiv.org/html/1311.4057
  • Abstract record: https://arxiv.org/abs/1311.4057
  • Access extent: the HTML paper was opened and sections 1–2 plus Appendix A.3.2 were read, including the positive risk-budget equation, equal-budget specialization, volatility CCD update (Eq. 7), and the distinct quadratic-log formulation (Eq. 8).
  • Supports: the selected deterministic volatility CCD positive-root update and the distinction between volatility and quadratic-log solver formulations.
  • Limitation: the paper's empirical comparisons are not treated as a ranking or promise; this package implements only the selected update and contract.

R03 — Maillard, Roncalli, and Teïletche, SSRN record / DOI

  • Authors: Sébastien Maillard, Thierry Roncalli, Jérôme Teïletche
  • Source type: bibliographic record for the original working paper
  • Date: 22 September 2008; Journal of Portfolio Management publication 2010
  • URL: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=1271972
  • DOI: https://doi.org/10.2139/ssrn.1271972
  • Access extent: the Google result was observed and the direct destination returned HTTP 403 in the research surface; no body was bypassed or claimed read.
  • Supports: identity, authorship, DOI, and publication metadata only.
  • Limitation: unavailable full text; not used as sole support for formulas.

R04 — Palomar, Portfolio Optimization: Theory and Application, risk-based portfolios

  • Author: Daniel P. Palomar
  • Source type: open technical book site
  • URL: https://portfoliooptimizationbook.com/
  • Access extent: this source was used in the adjacent inverse-volatility topic for the standalone-versus-covariance-aware distinction; it is not the canonical ERC solver source for A02.
  • Supports: terminology cross-check for risk-based allocation families.
  • Limitation: secondary exposition; no performance claim or implementation formula is imported here.

R05 — Prior input-integrity methods

Claim and licensing boundary

Limitations: source access and licensing are bounded as described above; no private provider data or unverified portfolio anecdote is treated as evidence.

Sourced facts, author-derived arithmetic, implementation choices, and synthetic teaching values are separated in the README, article, and CLAIM-LEDGER.md. Private provider data, raw portfolio holdings, and the unverified 1010.SR anecdote are not reproduced. The cited papers remain subject to their own copyright and access terms.

equal-risk-contribution.ts
/**
 * Pure D14-F02-A02 equal-risk-contribution allocator.
 *
 * This freezes the cyclical coordinate-descent volatility update from
 * Griveau-Billion, Richard, and Roncalli (arXiv:1311.4057, Eq. 7), with equal
 * positive budgets b_i = 1/N. It accepts a supplied covariance matrix only;
 * return estimation, provenance, and market-data repair are out of scope.
 */

export const WEIGHT_SUM_ABSOLUTE_TOLERANCE = 1e-12;
export const RISK_SHARE_TOLERANCE = 1e-10;
export const MAX_SWEEPS = 10_000;

export type EqualRiskContributionErrorCode =
  | "invalid-input"
  | "invalid-covariance"
  | "numerical-range-invalid"
  | "convergence-failure";

export interface EqualRiskContributionResult {
  assetIds: string[];
  weights: number[];
  componentRiskContributions: number[];
  componentRiskShares: number[];
  portfolioVariance: number;
  portfolioVolatility: number;
  sumWeights: number;
  riskShareResidual: number;
  iterations: number;
  method: "equal-risk-contribution";
  solver: "cyclical-coordinate-descent-volatility-eq7";
  covarianceScale: number;
  status: "ok";
}

export class EqualRiskContributionValidationError extends Error {
  readonly code: EqualRiskContributionErrorCode;

  constructor(code: EqualRiskContributionErrorCode, message: string) {
    super(message);
    this.name = "EqualRiskContributionValidationError";
    this.code = code;
  }
}

function invalid(code: EqualRiskContributionErrorCode, message: string): never {
  throw new EqualRiskContributionValidationError(code, message);
}

function neumaierSum(values: readonly number[]): number {
  let total = 0;
  let compensation = 0;
  for (const value of values) {
    const candidate = total + value;
    if (Math.abs(total) >= Math.abs(value)) {
      compensation += (total - candidate) + value;
    } else {
      compensation += (value - candidate) + total;
    }
    total = candidate;
  }
  return total + compensation;
}

function dot(row: readonly number[], vector: readonly number[]): number {
  const products: number[] = [];
  for (let index = 0; index < vector.length; index += 1) {
    products.push(row[index] * vector[index]);
  }
  return neumaierSum(products);
}

function validateIds(assetIds: readonly unknown[]): string[] {
  if (!Array.isArray(assetIds)) {
    invalid("invalid-input", "assetIds must be an ordered array.");
  }
  if (assetIds.length === 0) {
    invalid("invalid-input", "the asset universe must not be empty.");
  }
  const ids: string[] = [];
  for (let index = 0; index < assetIds.length; index += 1) {
    const value = assetIds[index];
    if (typeof value !== "string" || value.trim() === "") {
      invalid("invalid-input", `assetIds[${index}] must be a non-empty string.`);
    }
    ids.push(value);
  }
  if (new Set(ids).size !== ids.length) {
    invalid("invalid-input", "assetIds must be unique.");
  }
  return ids;
}

function validateCovariance(
  covariance: readonly unknown[],
  n: number,
): { matrix: number[][]; scale: number; normalized: number[][] } {
  if (!Array.isArray(covariance)) {
    invalid("invalid-input", "covariance must be a square numeric matrix.");
  }
  if (covariance.length !== n) {
    invalid("invalid-covariance", "covariance must have one row per asset.");
  }
  const matrix: number[][] = [];
  for (let rowIndex = 0; rowIndex < covariance.length; rowIndex += 1) {
    const rawRow = covariance[rowIndex];
    if (!Array.isArray(rawRow)) {
      invalid("invalid-covariance", `covariance[${rowIndex}] must be a numeric row.`);
    }
    if (rawRow.length !== n) {
      invalid("invalid-covariance", "covariance must be square.");
    }
    const row: number[] = [];
    for (let columnIndex = 0; columnIndex < rawRow.length; columnIndex += 1) {
      const value = rawRow[columnIndex];
      if (typeof value !== "number") {
        invalid(
          "invalid-covariance",
          `covariance[${rowIndex}][${columnIndex}] must be numeric data.`,
        );
      }
      if (!Number.isFinite(value)) {
        invalid(
          "numerical-range-invalid",
          `covariance[${rowIndex}][${columnIndex}] is outside the finite numeric range.`,
        );
      }
      row.push(value);
    }
    matrix.push(row);
  }

  for (let i = 0; i < n; i += 1) {
    for (let j = i + 1; j < n; j += 1) {
      if (matrix[i][j] !== matrix[j][i]) {
        invalid(
          "invalid-covariance",
          "covariance must be exactly symmetric; no silent symmetrization is performed.",
        );
      }
    }
    if (!(matrix[i][i] > 0)) {
      invalid("invalid-covariance", "ERC requires a strictly positive covariance diagonal.");
    }
  }

  let scale = 0;
  for (const row of matrix) {
    for (const value of row) {
      if (Math.abs(value) > scale) scale = Math.abs(value);
    }
  }
  if (!Number.isFinite(scale) || scale <= 0) {
    invalid("numerical-range-invalid", "covariance scale is zero or non-finite.");
  }

  const normalized = matrix.map((row) => row.map((value) => value / scale));
  const lower = Array.from({ length: n }, () => Array<number>(n).fill(0));
  for (let i = 0; i < n; i += 1) {
    for (let j = 0; j <= i; j += 1) {
      let remainder = normalized[i][j];
      if (j > 0) {
        const terms: number[] = [];
        for (let k = 0; k < j; k += 1) terms.push(lower[i][k] * lower[j][k]);
        remainder -= neumaierSum(terms);
      }
      if (i === j) {
        if (!Number.isFinite(remainder) || remainder <= 0) {
          invalid("invalid-covariance", "covariance must be strictly positive definite for ERC.");
        }
        lower[i][j] = Math.sqrt(remainder);
      } else {
        const denominator = lower[j][j];
        if (!(denominator > 0)) {
          invalid("invalid-covariance", "covariance Cholesky factor is degenerate.");
        }
        lower[i][j] = remainder / denominator;
      }
    }
  }
  return { matrix, scale, normalized };
}

function riskState(
  covariance: readonly number[][],
  y: readonly number[],
): { product: number[]; quadratic: number; volatility: number } {
  const product = covariance.map((row) => dot(row, y));
  const quadratic = dot(y, product);
  if (!Number.isFinite(quadratic) || quadratic <= 0) {
    invalid(
      "numerical-range-invalid",
      "positive-definite covariance produced a non-positive or non-finite quadratic form.",
    );
  }
  const volatility = Math.sqrt(quadratic);
  if (!Number.isFinite(volatility) || volatility <= 0) {
    invalid("numerical-range-invalid", "portfolio volatility is outside the finite range.");
  }
  return { product, quadratic, volatility };
}

/**
 * Solve equal positive volatility-risk contributions using the frozen CCD
 * positive-root update. The covariance scale is normalized internally.
 */
export function equalRiskContributionWeights(
  assetIds: readonly unknown[],
  covariance: readonly unknown[],
): EqualRiskContributionResult {
  const ids = validateIds(assetIds);
  const validated = validateCovariance(covariance, ids.length);
  const n = ids.length;
  const budget = 1 / n;
  const y = Array<number>(n).fill(1);
  let residual = Number.POSITIVE_INFINITY;
  let iterations = 0;

  for (let sweep = 1; sweep <= MAX_SWEEPS; sweep += 1) {
    for (let i = 0; i < n; i += 1) {
      const row = validated.normalized[i];
      const diagonal = row[i];
      const rowProduct = dot(row, y);
      const other = rowProduct - diagonal * y[i];
      const state = riskState(validated.normalized, y);
      const discriminant = other * other + 4 * diagonal * budget * state.volatility;
      if (!Number.isFinite(discriminant) || discriminant < 0) {
        invalid("numerical-range-invalid", "ERC coordinate discriminant is invalid.");
      }
      const root = Math.sqrt(discriminant);
      const updated = other > 0
        ? (2 * budget * state.volatility) / (root + other)
        : (-other + root) / (2 * diagonal);
      if (!Number.isFinite(updated) || updated <= 0) {
        invalid(
          "numerical-range-invalid",
          "ERC coordinate update left the strictly positive numerical domain.",
        );
      }
      y[i] = updated;
    }

    const state = riskState(validated.normalized, y);
    const shares = y.map((value, index) => (value * state.product[index]) / state.quadratic);
    residual = 0;
    for (const share of shares) {
      const error = Math.abs(share - budget);
      if (error > residual) residual = error;
    }
    iterations = sweep;
    if (!Number.isFinite(residual)) {
      invalid("numerical-range-invalid", "ERC risk-share residual is non-finite.");
    }
    if (residual <= RISK_SHARE_TOLERANCE) break;
  }
  if (residual > RISK_SHARE_TOLERANCE) {
    invalid(
      "convergence-failure",
      `ERC cyclical coordinate descent did not reach residual tolerance after ${MAX_SWEEPS} sweeps.`,
    );
  }

  const ySum = neumaierSum(y);
  if (!Number.isFinite(ySum) || ySum <= 0) {
    invalid("numerical-range-invalid", "ERC solution normalization sum is invalid.");
  }
  const weights = y.map((value) => value / ySum);
  if (weights.some((value) => !Number.isFinite(value) || value <= 0)) {
    invalid("numerical-range-invalid", "ERC weights must remain finite and positive.");
  }
  const sumWeights = neumaierSum(weights);
  if (!Number.isFinite(sumWeights) || Math.abs(sumWeights - 1) > WEIGHT_SUM_ABSOLUTE_TOLERANCE) {
    invalid("numerical-range-invalid", "ERC weights do not satisfy the sum tolerance.");
  }

  // The CCD state is homogeneous: y and c*y have identical risk shares but
  // different absolute variance. Recompute absolute risk from the returned
  // fully-invested weights rather than accidentally reporting the internal y
  // scale as the portfolio's supplied-covariance risk.
  const normalizedWeightProduct = validated.normalized.map((row) => dot(row, weights));
  const normalizedWeightQuadratic = dot(weights, normalizedWeightProduct);
  const normalizedWeightVolatility = Math.sqrt(normalizedWeightQuadratic);
  if (!Number.isFinite(normalizedWeightQuadratic) || normalizedWeightQuadratic <= 0
    || !Number.isFinite(normalizedWeightVolatility) || normalizedWeightVolatility <= 0) {
    invalid("numerical-range-invalid", "normalized ERC portfolio volatility is invalid.");
  }
  const shares = weights.map(
    (weight, index) => (weight * normalizedWeightProduct[index]) / normalizedWeightQuadratic,
  );
  const scaleRoot = Math.sqrt(validated.scale);
  const portfolioVolatility = normalizedWeightVolatility * scaleRoot;
  const portfolioVariance = normalizedWeightQuadratic * validated.scale;
  if (
    !Number.isFinite(scaleRoot)
    || !Number.isFinite(portfolioVolatility)
    || portfolioVolatility <= 0
    || !Number.isFinite(portfolioVariance)
    || portfolioVariance <= 0
  ) {
    invalid("numerical-range-invalid", "restoring the covariance risk scale lost positive risk.");
  }
  const normalizedComponentContributions = weights.map(
    (weight, index) => (weight * normalizedWeightProduct[index]) / normalizedWeightVolatility,
  );
  const componentRiskContributions = normalizedComponentContributions.map((value) => value * scaleRoot);
  if (componentRiskContributions.some((value) => !Number.isFinite(value))) {
    invalid("numerical-range-invalid", "component risk contribution is non-finite.");
  }

  return {
    assetIds: ids,
    weights,
    componentRiskContributions,
    componentRiskShares: shares,
    portfolioVariance,
    portfolioVolatility,
    sumWeights,
    riskShareResidual: residual,
    iterations,
    method: "equal-risk-contribution",
    solver: "cyclical-coordinate-descent-volatility-eq7",
    covarianceScale: validated.scale,
    status: "ok",
  };
}
Full-height labplaygroundOpen full screen
Written by

Fintech engineer building market-data and financial systems, and the author of every article, glossary record, and reference implementation on The Fintech Builder.