I have spent plenty of time chasing portfolio weights that changed because a price, quantity, split basis, or FX conversion changed. That experience makes me ask two questions before I call a portfolio “balanced”: are its inputs credible, and what kind of balance do the numbers describe? Equal capital weights answer neither risk question when holdings have different volatility or move together. A risk budget asks how much of measured portfolio volatility each holding should carry.
This tutorial builds one narrow, testable answer. We supply a finite covariance matrix and positive budget shares, then solve for positive weights whose volatility-risk shares match those inputs. The distinction between risk budgeting and equal-risk contribution is recorded in the open-access risk-budgeting paper by Costa, Pesenti, and Targino: ERC is the equal-budget special case. For the numerical method here I use the volatility cyclical-coordinate update in Griveau-Billion, Richard, and Roncalli, Eq. 7.
What is being equalized?
Let Sigma be covariance and w be fully invested weights.
The marginal volatility risk of asset i is
and its component contribution is the marginal quantity multiplied by w_i:
The risk share therefore is
Risk budgeting chooses w so that this last expression equals b_i. Setting every b_i to 1/N gives ERC. Setting b to (0.5, 0.3, 0.2) gives a different, intentional risk profile; it does not make the method “half ERC.”
A small computed example
Use this synthetic covariance:
The independently recorded solver result is
The computed risk shares are (0.5000000000, 0.3000000001, 0.1999999999). That is the lesson: the budgets are risk shares, not capital percentages.
| Asset | Requested risk share | Resulting capital weight | Measured risk share |
|---|---|---|---|
| A | 50.00% | 51.8651% | 50.00% |
| B | 30.00% | 19.0405% | 30.00% |
| C | 20.00% | 29.0944% | 20.00% |
Open the comparison at full size. The diagram and table show the same calculation; neither is a forecast. For a diagonal covariance, an independent closed-form check is
With standard deviations (0.1, 0.2, 0.15), this gives (0.552811352124, 0.214102916020, 0.233085731856) for the same budgets.
The input-integrity boundary
My repeated experience with split-invalid historical prices, suspect price/quantity imports, changing displayed weights, and Egypt/Japan/USA holdings shown in USD is why I treat the covariance as a contract, not a magic repair layer. Quantity times a local mark times FX (base per local) is market value; it is not cost basis. Local-return covariance and base-currency-return covariance are different questions.
The earlier Stale-Quote Detector method gives the right caution: flat returns do not prove a feed outage. In a new four-row synthetic panel, replacing the stale asset's returns with zeros would violate this allocator's positive-volatility input contract. It must not turn bad evidence into an infinite weight.
The currency distinction can also change the answer. In a controlled,
four-interval synthetic panel, A's local returns are
[-.02,-.01,.01,.02], B's USD returns are [-.01,.01,-.01,.01], and A's
USD-per-local-unit FX returns match B's series. Using
(1+r_local)(1+r_FX)-1, A's USD returns are
[-.0298,-.0001,-.0001,.0302]. For a 50/50 risk budget (the ERC special
case), the mixed-basis weights are (0.3874258867, 0.6125741133).
Evaluating them on the aligned USD covariance produces risk shares
(0.5851620429, 0.4148379571), not 50/50. Solving on the USD matrix gives
weights (0.3203717978, 0.6796282022) and equal risk shares. Here 50/50
names the risk budget, never the USD capital weights. The example has no
fees, dividends, flows, historical holding, or real FX timestamp claim; it
demonstrates an input-contract consequence, not a trade instruction.
How the solver works
For an internal positive vector y, one coordinate satisfies
The implementation takes the positive root cyclically. It normalizes the matrix by its actual largest absolute entry for safe intermediate arithmetic, uses compensated sums, checks strict positive definiteness, and recomputes absolute risk from the final fully invested weights. It stops only when both absolute share residual (1e-10) and relative share residual (1e-8) pass. The relative check matters: an absolute tolerance alone can make a tiny positive budget look accurate while its relative error is large.
There is no automatic rescaling of a matrix from a different estimator, no zero-budget shortcut, no covariance repair, and no promise for singular covariance. Invalid inputs return structured errors rather than stale results.
An allocator can satisfy the budget equation yet still be the wrong decision tool. The covariance horizon, universe, currency, estimate timing, costs, constraints, and mandate belong in an analyst's review before any rebalance.
Run the code
The pure implementations are implementations/python/risk_budgeting.py and implementations/typescript/risk-budgeting.ts. The Python test suite and the TypeScript test both compare their own output with the other language's JSON lines, so a shared expected fixture is not the only check.
From the topic directory, run the Python tests:
python -m unittest discover -s tests -p "test_*.py"
The canonical README has the exact TypeScript compile and cross-language parity commands. To try the browser lab, serve its folder over HTTP:
python -m http.server 8765 --directory visuals/animated
Open http://127.0.0.1:8765/playground.html; opening the ESM file directly
is not the supported browser test.
Explore the lab
The computed playground starts with the 50/30/20 example. Change the budget fields, switch to the diagonal contrast, and then try an invalid budget or indefinite covariance. Watch the weights, absolute contributions, risk shares, residual, and stage explanation update together. The static risk-budget comparison is useful when reviewing the difference between capital and risk percentages.
Risk budgeting is a transparent allocation calculation. Whether its inputs are statistically credible, timely, correctly adjusted, and appropriate for a real mandate is a separate evidence and governance decision.
Rendered from the canonical Mermaid sources linked by this article.
Risk budgeting flow
The dashed route is intentionally not an arithmetic repair path.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
- Accessed: 2026-09-14, Africa/Cairo; extents and unavailable pages are stated per row.
- Supports: R01 provides the volatility risk-budget definition and Eq. 7 coordinate update; R05 distinguishes broader coherent-risk budgeting.
- Limitations: R02 was blocked and supports no formula; R03–R04 are secondary cross-checks; no source establishes a performance ranking or authenticates the synthetic return/FX panel.
| ID | Source | What was inspected | Use and limitation |
|---|---|---|---|
| R01 | Griveau-Billion, Richard, Roncalli, arXiv:1311.4057 | Open-access HTML and abstract, accessed 2026-09-14. Read title/authors/date, risk-budget definition, ERC special case, volatility formulation, Eq. 7 CCD update, positivity, convergence discussion, and references; HTML body lines 30–180 and 183–208; 265 extracted lines. | Primary algorithm source for the frozen volatility CCD equation. Its numerical/benchmark context does not establish investment superiority. |
| R02 | Spinu, “An Algorithm for Computing Risk Parity Weights” | Google result and direct destination observed; SSRN returned security verification/403, accessed 2026-09-14. No body text read. | Discovery lead only; this package does not claim to implement Spinu's alternate solver. |
| R03 | Palomar, Portfolio Optimization: Theory and Application | Chapter page, 127 extracted lines; read log-barrier terminology and normalized x-to-w relationship, accessed 2026-09-14. | Secondary cross-check and terminology; not sole evidence for Eq. 7. |
| R04 | riskParityPortfolio GitHub | Repository/README, 326 extracted lines; read formulation boundary, CCD mention, risk-budget usage, and license/disclaimer context, accessed 2026-09-14. | Maintained implementation discovery; no code copied and API is not this contract. |
| R05 | Costa, Pesenti, Targino, Risk Budgeting Portfolios from Simulations | arXiv HTML v1, 744 extracted lines; read title/authors/date, abstract and introduction/risk-budget definition (41–62), accessed 2026-09-14. | Primary evidence that general coherent-risk/simulation RB is a distinct family; not evidence for this volatility-only solver. |
| R06 | Stale-Quote Detector method | Public method page linked as prior-work context; its method is not used to infer a provider event in this package. | Motivation and boundary only. The four-row stale-return consequence in the article/lab is newly constructed synthetic work. |
Claim classification
- R01 supports the mathematical risk-budget/volatility CCD definition.
- R02 is explicitly unavailable and supports no technical claim.
- R03/R04 are secondary or implementation cross-checks.
- R05 supports the boundary between volatility budgeting and general coherent-risk simulation methods.
- Historical/provider claims are deferred; synthetic examples are labeled and independently computed.
The per-topic claim ledger and bounded search/page-access record retain the discovery trail without treating search snippets as algorithm evidence.
Full dependency-light reference implementations in both supported languages.
/**
* Pure D14-F02-A03 risk-budgeting allocator.
*
* This freezes the cyclical coordinate-descent volatility update from
* Griveau-Billion, Richard, and Roncalli (arXiv:1311.4057, Eq. 7), with
* supplied positive budgets b_i. 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 RISK_SHARE_RELATIVE_TOLERANCE = 1e-8;
export const MAX_SWEEPS = 10_000;
export type RiskBudgetingErrorCode =
| "invalid-input"
| "invalid-covariance"
| "numerical-range-invalid"
| "convergence-failure";
export interface RiskBudgetingResult {
assetIds: string[];
riskBudgets: number[];
weights: number[];
componentRiskContributions: number[];
componentRiskShares: number[];
portfolioVariance: number;
portfolioVolatility: number;
sumWeights: number;
riskShareResidual: number;
riskShareRelativeResidual: number;
iterations: number;
method: "risk-budgeting";
solver: "cyclical-coordinate-descent-volatility-eq7";
covarianceScale: number;
status: "ok";
}
export class RiskBudgetingValidationError extends Error {
readonly code: RiskBudgetingErrorCode;
constructor(code: RiskBudgetingErrorCode, message: string) {
super(message);
this.name = "RiskBudgetingValidationError";
this.code = code;
}
}
function invalid(code: RiskBudgetingErrorCode, message: string): never {
throw new RiskBudgetingValidationError(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 validateBudgets(rawBudgets: readonly unknown[], n: number): number[] {
if (!Array.isArray(rawBudgets)) {
invalid("invalid-input", "riskBudgets must be an ordered numeric array.");
}
if (rawBudgets.length !== n) {
invalid("invalid-input", "riskBudgets must have one entry per asset.");
}
const values: number[] = [];
for (let index = 0; index < rawBudgets.length; index += 1) {
const value = rawBudgets[index];
if (typeof value !== "number") {
invalid("invalid-input", `riskBudgets[${index}] must be numeric data.`);
}
if (!Number.isFinite(value)) {
invalid("numerical-range-invalid", `riskBudgets[${index}] is outside the finite numeric range.`);
}
if (!(value > 0)) {
invalid("invalid-input", "riskBudgets must contain positive values.");
}
values.push(value);
}
const total = neumaierSum(values);
if (!Number.isFinite(total) || total <= 0) {
invalid("numerical-range-invalid", "riskBudgets sum is outside the finite numeric range.");
}
if (Math.abs(total - 1) > WEIGHT_SUM_ABSOLUTE_TOLERANCE) {
invalid("invalid-input", "riskBudgets must sum to one within the declared tolerance.");
}
const normalized = values.map((value) => value / total);
if (normalized.some((value) => !Number.isFinite(value) || value <= 0)) {
invalid("numerical-range-invalid", "riskBudgets normalization left the finite positive domain.");
}
return normalized;
}
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", "risk-budgeting 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 risk-budgeting.");
}
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 riskBudgetingWeights(
assetIds: readonly unknown[],
covariance: readonly unknown[],
riskBudgets: readonly unknown[],
): RiskBudgetingResult {
const ids = validateIds(assetIds);
const budgets = validateBudgets(riskBudgets, ids.length);
const validated = validateCovariance(covariance, ids.length);
const n = ids.length;
const y = Array<number>(n).fill(1);
let residual = Number.POSITIVE_INFINITY;
let relativeResidual = Number.POSITIVE_INFINITY;
let iterations = 0;
for (let sweep = 1; sweep <= MAX_SWEEPS; sweep += 1) {
for (let i = 0; i < n; i += 1) {
const budget = budgets[i];
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", "risk-budgeting 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",
"risk-budgeting 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;
relativeResidual = 0;
for (let index = 0; index < shares.length; index += 1) {
const error = Math.abs(shares[index] - budgets[index]);
if (error > residual) residual = error;
const relativeError = error / budgets[index];
if (relativeError > relativeResidual) relativeResidual = relativeError;
}
iterations = sweep;
if (!Number.isFinite(residual) || !Number.isFinite(relativeResidual)) {
invalid("numerical-range-invalid", "risk-budgeting risk-share residual is non-finite.");
}
if (residual <= RISK_SHARE_TOLERANCE && relativeResidual <= RISK_SHARE_RELATIVE_TOLERANCE) break;
}
if (residual > RISK_SHARE_TOLERANCE || relativeResidual > RISK_SHARE_RELATIVE_TOLERANCE) {
invalid(
"convergence-failure",
`risk-budgeting 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", "risk-budgeting 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", "risk-budgeting 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", "risk-budgeting 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 risk-budgeting 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,
riskBudgets: budgets,
weights,
componentRiskContributions,
componentRiskShares: weights.map(
(weight, index) => (weight * normalizedWeightProduct[index]) / normalizedWeightQuadratic,
),
portfolioVariance,
portfolioVolatility,
sumWeights,
riskShareResidual: residual,
riskShareRelativeResidual: relativeResidual,
iterations,
method: "risk-budgeting",
solver: "cyclical-coordinate-descent-volatility-eq7",
covarianceScale: validated.scale,
status: "ok",
};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
