D14-F02-A01 / Released engineering topic

Inverse-Volatility Weighting: When Equal Standalone Risk Is Not Equal Portfolio Risk

A production-minded guide to Inverse-Volatility Weighting.

Inverse-Volatility Weighting: When Equal Standalone Risk Is Not Equal Portfolio RiskD14 / D14-F02

If one asset has twice the standalone volatility of another, should it receive half the capital? Inverse-volatility weighting makes that choice explicit: assign capital in inverse proportion to each asset's own volatility, then normalize the scores. It is simple, inspectable, and useful as a teaching baseline. It is also easy to misunderstand.

This article builds the rule from a synthetic fixture, compares it with a covariance-aware risk diagnostic, and draws a hard line around input integrity. The result is a reproducible calculation, not a recommendation or a claim that one allocator wins.

The input problem comes first

In my own portfolio-data work, I have repeatedly had to stop at this input boundary: historical prices that did not reconcile across splits, suspect quantity/price imports, displayed weights that changed when source fields changed, and holdings in Egypt, Japan, and the USA translated to USD. Those experiences motivate the checks in this tutorial; they are not a historical dataset and they do not justify inventing a trade or a provider correction.

Before discussing a rebalance, ask which quantity is being calculated:

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 base-currency market value, not cost basis. A post-split price, stale FX quote, stale local mark, or suspect quantity can change a displayed weight without creating an economic rebalance need. A missing mark is not zero risk; it is an evidence problem.

That distinction is also the practical lesson of the prior Stale-Quote Detector: an unchanged or missing quote must be investigated before it becomes a return observation. The prior Backward Split Adjustment and Cash-Dividend Total-Return Adjustment show why the adjustment basis must be declared before returns are measured. They are upstream conventions, not hidden repairs inside A01.

Here is a small controlled consequence, with no claim that it is market data. For A=[-0.02,-0.01,0.01,0.02] and B=[-0.01,0.01,-0.01,0.01], the sample volatilities are 0.0182574186 and 0.0115470054, giving A01 weights [0.3874258867,0.6125741133]. If a stale-quote routine replaces A with four zeros merely because its mark did not change, A's volatility is zero and the allocator rejects the input as invalid-input; it does not award an infinite weight. A flat series by itself is not proof of a feed outage, so session and clock evidence remains a separate upstream check.

The canonical rule

Let σ_i be finite, strictly positive standalone volatilities on the same horizon and scale. The mathematical reciprocal score is:

ui=1σiu_i=\frac{1}{\sigma_i}

Normalize the scores:

wi=uijujw_i=\frac{u_i}{\sum_j u_j}

The implementation uses m / σ_i, where m = min(σ), rather than computing raw reciprocals. This has the same weights while avoiding reciprocal overflow. Its score output is deliberately named normalizedInverseVolatilityScores; it never exposes an ambiguous reciprocal field. Empty IDs, duplicates, malformed shapes, zero/negative/non-finite volatility, and range loss are structured errors—not silently repaired values.

The reciprocal baseline is cross-checked against Daniel P. Palomar's risk-based portfolio chapter. The covariance-aware definition used for the contrast follows the original Maillard, Roncalli, and Teïletche paper and the risk-budget notation in Griveau-Billion, Richard, and Roncalli's open paper. Those links support the definitions; they are not performance endorsements.

For observed returns, the estimator, adjustment basis, aligned timestamps, missing-data policy, revision/finality, and currency mode belong to an upstream contract. This pure operation receives the risk vector; it does not pretend to estimate it from a price import.

A three-asset calculation

Use the synthetic inputs:

Plain text
σ = [0.10, 0.20, 0.15]
Σ = [[0.0100,  0.0160,  0.0015],
     [0.0160,  0.0400, -0.0060],
     [0.0015, -0.0060,  0.0225]]

The stable reciprocal scores are [1, 0.5, 2/3]. They normalize to:

Plain text
w = [6/13, 3/13, 4/13]
  ≈ [0.46153846, 0.23076923, 0.30769231]

The standalone products are equal:

Plain text
wσ ≈ [0.04615385, 0.04615385, 0.04615385]

That is exactly what A01 promises. It does not promise equal covariance risk.

A01 standalone products versus covariance risk shares

The same three equal standalone bars lead to visibly unequal portfolio-risk shares; open the full-size comparison if you want to inspect the exact labels.

AssetCapital weightStandalone product wᵢσᵢCovariance risk share
A46.15%0.0461543.18%
B23.08%0.0461536.36%
C30.77%0.0461520.45%

The table preserves the comparison on narrow screens where the full diagram would otherwise need to shrink. These are rounded displays of the synthetic fixture, not additional observations.

Why covariance changes the interpretation

For a supplied covariance matrix, calculate:

v=Σwv=\Sigma w q=wTvq=w^\mathsf{T}v σp=q\sigma_p=\sqrt q

When q is safely positive, the component risk contribution and share are:

RCi=wiviσpRC_i=\frac{w_i v_i}{\sigma_p} sharei=wiviqshare_i=\frac{w_i v_i}{q}

For the fixture, the diagnostic returns:

Plain text
q       = 0.00937278106508876
σ_p     = 0.09681312444647554
shares  = [19/44, 4/11, 9/44]
        ≈ [0.43181818, 0.36363636, 0.20454545]

The shares differ even though the standalone products match. This is not a contradiction: A01 is correlation-blind by definition. Equal Risk Contribution (ERC) is a separate covariance-aware method. An independently computed ERC comparator for this same synthetic matrix is approximately [0.37783025, 0.23412526, 0.38804449]; its risk shares equalize to the rounded-display tolerance. The comparison demonstrates different definitions, not a performance ranking.

Two useful guardrails prevent overgeneralization: diagonal covariance and a two-asset positive-volatility long-only case can produce restricted equivalence. The three-asset unequal-covariance fixture is the required contrast.

The same weights, different currency risk

If returns are measured in local currency but the portfolio is reported in a base currency, the risk panel must name that choice. With FX quoted as base currency per local unit:

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

Expanding that product shows why a base-currency covariance is not obtained by adding one FX variance term to local covariance: local/FX cross-covariances and the product term r_local r_FX (including its own covariances) also contribute. The exact decomposition depends on the joint return panel. Local-return covariance and base-currency covariance are therefore not interchangeable. A common annualization multiplier does not change A01 weights, but changing the return currency can change the estimated volatilities and covariance structure.

Here is the size of that effect in a deliberately synthetic four-interval example. Reuse the local-return series for A and the USD-return series for B above, and suppose USD-per-local-unit FX returns for A happen to equal B's series: [-0.01, 0.01, -0.01, 0.01]. Applying the multiplicative conversion interval by interval gives A's USD returns [-0.0298, -0.0001, -0.0001, 0.0302]. With sample standard deviations (divisor n-1), A's volatility changes from 0.0182574186 locally to 0.0244955098 in USD; B's USD volatility remains 0.0115470054. Consequently, the two-asset inverse-volatility weights change from [0.3874258867, 0.6125741133] on the mixed local/USD panel to [0.3203717978, 0.6796282022] on the aligned USD panel. The first panel is not an appropriate USD risk input; the numerical difference shows why currency alignment must precede allocation. It is not a measured portfolio incident or a suggestion to rebalance.

A diagnostic that refuses to guess

The reference implementation has separate Python and TypeScript covariance diagnostics. They require matching RiskScaleMetadata for horizon, periodicity, return type, adjustment basis, estimator, volatility unit, annualization factor, currency mode, panel identity, and ordered IDs. Observed metadata also requires timestamp hash, lookback bounds, asOf, availability cutoff, observation count, revision, and finality.

The matrix must be finite, exactly symmetric, and have a per-asset diagonal that matches σ_i²:

Σiiσi21012max(Σii,σi2)|\Sigma_{ii}-\sigma_i^2| \le 10^{-12}\max(|\Sigma_{ii}|,|\sigma_i^2|)

The per-diagonal rule matters. A global maximum matrix scale could allow a 100% error in an asset whose variance is tiny next to another asset's variance. No automatic rescaling, symmetrization, clipping, fill, or estimator substitution is performed. The PSD check uses the actual matrix scale and a dependency-free cyclic Jacobi eigensolver; its numerical method and boundary choices are part of the reference implementation.

The quadratic form is evaluated after normalizing by the actual matrix scale. An exact zero is a valid perfect-hedge boundary with unavailable component arrays. A positive but near-zero result is numerically-unreliable and does not divide by volatility. If a positive normalized variance is lost when the original scale is restored, the diagnostic returns structured numerical-range-invalid; it never calls that case a perfect hedge.

The decision flow below keeps the pure allocation separate from optional covariance evidence. Follow the left branch if only standalone volatilities are supplied; a covariance or provenance failure blocks the diagnostic, not the already calculated pure weights.

This diagram is a routing rule, not a claim that metadata can authenticate historical prices. A reader who only has volatilities can compute A01 weights; they cannot infer covariance risk shares from those volatilities alone.

Reproduce the implementation

The pure functions are available in:

  • implementations/python/inverse_volatility.py
  • implementations/typescript/inverse-volatility.ts
  • implementations/python/covariance_diagnostic.py
  • implementations/typescript/covariance-diagnostic.ts

From this topic directory, the Python call is directly runnable:

Shell
python -c "from implementations.python.inverse_volatility import inverse_volatility_weights; print(inverse_volatility_weights(['A','B','C'], [0.10,0.20,0.15]))"

For TypeScript, compile the pure module and import the emitted ES module with the validated Node.js 22.x / TypeScript 7.0.2 environment:

Shell
npx tsc --ignoreConfig --strict --target ES2022 --module Node16 --moduleResolution Node16 --types node --outDir .tmp-ts implementations/typescript/inverse-volatility.ts
node --experimental-default-type=module --input-type=module -e "import { inverseVolatilityWeights } from './.tmp-ts/inverse-volatility.js'; console.log(inverseVolatilityWeights(['A','B','C'], [0.10,0.20,0.15]));"

The browser lab is an ES module; serve the topic directory over local HTTP (python -m http.server 8765) before opening it. The same functions are used by the JSON-lines bridges and the browser lab, so the reader can compare a terminal result with the interactive result without copying a second algorithm.

The tests cover exact canonical arithmetic, unequal covariance, signed contributions, zero and near-zero states, malformed shapes, per-diagonal mismatch, strict asymmetry, indefinite/ragged/non-numeric covariance, annualization mismatch, tiny scales, provenance boundaries, immutability, large universes, and direct Python↔TypeScript output/error comparison.

The guided playground keeps the same three-asset fixture and lets the reader change volatilities, correlations, evidence state, and a separately labeled inverse-variance trap. It makes the causal relationship visible: correlation changes covariance diagnostics while A01 weights stay fixed. A failed input audit blocks the evidence path and never invents a trade.

What this method cannot tell you

Inverse volatility cannot infer whether a price is split-adjusted, whether a quantity import is correct, whether a stale FX quote should be replaced, or whether a market-value change is a true rebalance need. It also cannot promise equal covariance risk contributions, robust performance, or diversification under every correlation structure. Those questions require validated data and separate methods.

The verified 4070.SR Tihama suspension case remains an input-integrity teaching lead—missing/stale marks are not zero risk—not an A01 historical return fixture. Historical source clearance is therefore deferred.

Continue the experiment

Open ../visuals/animated/playground.html in a local browser. Start with the canonical fixture, then stress correlations while watching A01 weights remain unchanged and covariance shares move. Inject zero volatility or a stale-FX evidence state to see the explicit block. Restore the canonical state before comparing outputs.

References

The public sources linked in this article support the definitions and the input-integrity boundaries; none is a claim of allocator performance or provider-data authentication.

Input audit to risk diagnostic

The diagram separates the pure A01 construction from the optional covariance diagnostic. A data-integrity defect blocks the evidence path; it is not turned into a rebalance signal.

The q branch is diagnostic output, not an A01 target budget. A signed component contribution is retained as a fact about the supplied covariance; it is not clipped into a recommendation.

References6 primary sources and evidence notes

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

Accessed: 2026-09-14. The Google discovery and page-access ledger is in the research area; snippets are not treated as technical evidence.

R01 — Palomar, Portfolio Optimization: Theory and Application, “Risk-Based Portfolios”

  • Author/organization: Daniel P. Palomar / Portfolio Optimization Book
  • Source type: open technical book chapter
  • URL: https://portfoliooptimizationbook.com/book/6.5-risk-based-portfolios.html
  • Extent inspected: complete 57-line page, including the inverse-volatility formula and its contrast with correlation-aware risk parity
  • Supports: canonical reciprocal standalone-volatility definition and the correlation-blind versus covariance-aware boundary
  • Limitation: pedagogical secondary source; no performance claim is imported

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

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

  • Authors: Théophile Griveau-Billion, Jean-Charles Richard, and Thierry Roncalli
  • Version/date: arXiv:1311.4057v1, 16 November 2013
  • URL: https://arxiv.org/html/1311.4057
  • Extent inspected: accessible HTML definitions of risk budgeting and the ERC equal-budget special case
  • Supports: RC_i = w_i(Σw)_i/σ_p and equal-budget interpretation
  • Limitation: used for the definition contrast, not an A01 performance claim

R04 — Composer Knowledge Center, “Inverse Volatility Weighting”

  • Organization: Composer
  • Source type: product documentation
  • URL: https://help.composer.trade/article/26-inverse-volatility-weighting
  • Extent inspected: complete accessible page (36 extracted lines)
  • Supports: operational vocabulary for inverse-standard-deviation weighting
  • Limitation: documentation does not determine this package's estimator, missing-data, or point-in-time policy

R05 — LAPACK DSYEVD documentation

R06 — Research and evidence boundary

  • Google rank observations, exact query/date/locale, all ten result slots, and per-result access extent: GOOGLE-RESULTS.md
  • Bounded claim support and excluded claims: CLAIM-LEDGER.md
  • User-confirmed experiences inform the input-integrity examples, but no private case data is reproduced as historical price evidence here.

Limitations: These references support definitions and numerical method boundaries only; they do not authenticate private provider data, prove the unverified 1010.SR anecdote, or establish allocator performance superiority.

covariance-diagnostic.ts
/**
 * Validated covariance risk diagnostics for the A01 staging package.
 *
 * This entry point is separate from the pure inverse-volatility allocator.
 * It accepts explicit numerical inputs and scale/provenance metadata; it does
 * not estimate covariance from prices, repair a matrix, value holdings, or
 * infer a rebalance.
 *
 * The PSD check is the classical cyclic Jacobi rotation method for a real
 * symmetric matrix, following Golub and Van Loan, Matrix Computations, 4th
 * ed., section 8.4.3. The Python module mirrors the same rotation order and
 * tolerances so the status/error decision is directly comparable.
 */

export const WEIGHT_SUM_ABSOLUTE_TOLERANCE = 1e-12;
export const COMPARISON_ABSOLUTE_TOLERANCE = 1e-10;
export const COMPARISON_RELATIVE_TOLERANCE = 1e-8;
export const MATRIX_DIAGONAL_RELATIVE_TOLERANCE = 1e-12;
export const PSD_RELATIVE_TOLERANCE = 1e-10;
export const NEAR_ZERO_VARIANCE_RELATIVE_TOLERANCE = 1e-12;
export const JACOBI_OFF_DIAGONAL_RELATIVE_TOLERANCE = 1e-14;

export type CovarianceDiagnosticErrorCode =
  | "invalid-input"
  | "invalid-covariance"
  | "covariance-scale-mismatch"
  | "provenance-invalid"
  | "numerical-range-invalid";

export type CovarianceDiagnosticStatus =
  | "available"
  | "zero-risk"
  | "numerically-unreliable";

export interface CovarianceRiskDiagnosticResult {
  assetIds: string[];
  weights: number[];
  covarianceScale: number;
  status: CovarianceDiagnosticStatus;
  variance: number;
  volatility: number;
  componentRiskContributions: number[] | null;
  componentRiskShares: number[] | null;
  reason: string | null;
}

export class CovarianceDiagnosticValidationError extends Error {
  readonly code: CovarianceDiagnosticErrorCode;

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

type RecordValue = Record<string, unknown>;

function invalid(code: CovarianceDiagnosticErrorCode, message: string): never {
  throw new CovarianceDiagnosticValidationError(code, message);
}

function isRecord(value: unknown): value is RecordValue {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function asFiniteNumber(
  value: unknown,
  code: CovarianceDiagnosticErrorCode,
  label: string,
): number {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    invalid(code, `${label} must be a finite number.`);
  }
  return value;
}

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 approximatelyEqual(actual: number, expected: number): boolean {
  return Math.abs(actual - expected) <=
    COMPARISON_ABSOLUTE_TOLERANCE + COMPARISON_RELATIVE_TOLERANCE * Math.abs(expected);
}

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

function validateVector(
  value: unknown,
  n: number,
  label: string,
  options: { strictlyPositive?: boolean; nonNegative?: boolean } = {},
): number[] {
  if (!Array.isArray(value)) {
    invalid("invalid-input", `${label} must be an ordered numeric array.`);
  }
  if (value.length !== n) {
    invalid("invalid-input", `${label} must have exactly ${n} entries.`);
  }
  const values: number[] = [];
  for (let index = 0; index < value.length; index += 1) {
    const converted = asFiniteNumber(value[index], "invalid-input", `${label}[${index}]`);
    if (options.strictlyPositive === true && converted <= 0) {
      invalid("invalid-input", `${label}[${index}] must be strictly positive.`);
    }
    if (options.nonNegative === true && converted < 0) {
      invalid("invalid-input", `${label}[${index}] must be non-negative.`);
    }
    values.push(converted);
  }
  return values;
}

const BASE_METADATA_FIELDS = [
  "horizon",
  "periodicity",
  "returnType",
  "adjustmentBasis",
  "estimator",
  "volatilityUnit",
  "annualizationFactor",
  "currencyMode",
  "baseCurrency",
  "fxQuote",
  "panelId",
  "assetIds",
] as const;

const OBSERVED_METADATA_FIELDS = [
  "timestampHash",
  "lookbackStart",
  "lookbackEnd",
  "asOf",
  "availabilityCutoff",
  "observations",
  "revision",
  "finality",
] as const;

function nonEmptyString(value: unknown, label: string): string {
  if (typeof value !== "string" || value.trim() === "") {
    invalid("provenance-invalid", `${label} must be a non-empty string.`);
  }
  return value;
}

function sequenceEquals(value: unknown, expected: readonly string[]): boolean {
  return Array.isArray(value) &&
    value.length === expected.length &&
    value.every((item, index) => item === expected[index]);
}

const RFC3339_WITH_OFFSET =
  /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:(Z)|([+-])(\d{2}):(\d{2}))$/;

function parseTimestamp(value: unknown, label: string): number {
  const text = nonEmptyString(value, label);
  const match = RFC3339_WITH_OFFSET.exec(text);
  if (match === null) {
    invalid("provenance-invalid", `${label} must be RFC3339 with an offset.`);
  }
  const year = Number(match[1]);
  const month = Number(match[2]);
  const day = Number(match[3]);
  const hour = Number(match[4]);
  const minute = Number(match[5]);
  const second = Number(match[6]);
  const offsetHour = match[7] === "Z" ? 0 : Number(match[9]);
  const offsetMinute = match[7] === "Z" ? 0 : Number(match[10]);
  const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
  if (
    month < 1 || month > 12 ||
    day < 1 || day > daysInMonth ||
    hour > 23 || minute > 59 || second > 59 ||
    offsetHour > 23 || offsetMinute > 59
  ) {
    invalid("provenance-invalid", `${label} must be a valid RFC3339 timestamp.`);
  }
  const milliseconds = Date.parse(text);
  if (!Number.isFinite(milliseconds)) {
    invalid("provenance-invalid", `${label} must be a valid RFC3339 timestamp.`);
  }
  return milliseconds;
}

function validateMetadata(
  value: unknown,
  ids: readonly string[],
  observed: boolean,
): RecordValue {
  if (!isRecord(value)) {
    invalid("provenance-invalid", "risk metadata must be an object.");
  }
  const fields: readonly string[] = observed
    ? [...BASE_METADATA_FIELDS, ...OBSERVED_METADATA_FIELDS]
    : BASE_METADATA_FIELDS;
  for (const field of fields) {
    if (!(field in value)) {
      invalid("provenance-invalid", `risk metadata is missing ${field}.`);
    }
  }
  const result: RecordValue = { ...value };
  for (const field of [
    "horizon",
    "periodicity",
    "returnType",
    "adjustmentBasis",
    "estimator",
    "volatilityUnit",
    "panelId",
  ]) {
    nonEmptyString(result[field], `risk metadata ${field}`);
  }
  if (result.volatilityUnit !== "per-period" && result.volatilityUnit !== "annualized") {
    invalid("provenance-invalid", "risk metadata volatilityUnit is unsupported.");
  }
  const annualizationFactor = result.annualizationFactor;
  if (result.volatilityUnit === "per-period") {
    if (annualizationFactor !== null) {
      invalid("provenance-invalid", "per-period metadata must have a null annualizationFactor.");
    }
  } else {
    const factor = asFiniteNumber(
      annualizationFactor,
      "provenance-invalid",
      "annualized metadata annualizationFactor",
    );
    if (factor <= 0) {
      invalid("provenance-invalid", "annualized metadata annualizationFactor must be positive.");
    }
  }
  if (result.currencyMode !== "local" && result.currencyMode !== "base") {
    invalid("provenance-invalid", "risk metadata currencyMode is unsupported.");
  }
  if (!sequenceEquals(result.assetIds, ids)) {
    invalid("provenance-invalid", "risk metadata assetIds must match assetIds.");
  }
  if (result.currencyMode === "local") {
    if (result.baseCurrency !== null || result.fxQuote !== null) {
      invalid(
        "provenance-invalid",
        "local-currency metadata must have null baseCurrency and fxQuote.",
      );
    }
  } else {
    nonEmptyString(result.baseCurrency, "risk metadata baseCurrency");
    if (result.fxQuote !== "base-per-local") {
      invalid("provenance-invalid", "base-currency metadata must use base-per-local fxQuote.");
    }
  }
  if (observed) {
    nonEmptyString(result.timestampHash, "risk metadata timestampHash");
    nonEmptyString(result.revision, "risk metadata revision");
    for (const field of ["lookbackStart", "lookbackEnd", "asOf", "availabilityCutoff"]) {
      parseTimestamp(result[field], `risk metadata ${field}`);
    }
    if (
      typeof result.observations !== "number" ||
      !Number.isInteger(result.observations) ||
      result.observations < 2
    ) {
      invalid("provenance-invalid", "risk metadata observations must be an integer >= 2.");
    }
    if (result.finality !== "final" && result.finality !== "provisional") {
      invalid("provenance-invalid", "risk metadata finality is unsupported.");
    }
  }
  return result;
}

interface ValidatedProvenance {
  kind: "synthetic" | "observed";
  raw: RecordValue;
  timestamps: Partial<Record<string, number>>;
}

function validateProvenance(value: unknown, ids: readonly string[]): ValidatedProvenance {
  if (!isRecord(value)) {
    invalid("provenance-invalid", "provenance must be an object.");
  }
  const kind = value.kind;
  if (kind !== "synthetic" && kind !== "observed") {
    invalid("provenance-invalid", "provenance kind must be synthetic or observed.");
  }
  const raw: RecordValue = { ...value };
  if (kind === "synthetic") {
    nonEmptyString(raw.fixtureId, "synthetic fixtureId");
    if (raw.claimClass !== "synthetic-teaching-input") {
      invalid("provenance-invalid", "synthetic claimClass is unsupported.");
    }
    return { kind, raw, timestamps: {} };
  }
  if (!("annualizationFactor" in raw)) {
    invalid("provenance-invalid", "observed provenance is missing annualizationFactor.");
  }
  for (const field of ["panelId", "sourceId", "timestampHash", "periodicity", "adjustmentBasis", "revision"]) {
    nonEmptyString(raw[field], `observed provenance ${field}`);
  }
  if (!sequenceEquals(raw.assetIds, ids)) {
    invalid("provenance-invalid", "observed provenance assetIds must match assetIds.");
  }
  const timestamps: Partial<Record<string, number>> = {};
  for (const field of ["lookbackStart", "lookbackEnd", "asOf", "availabilityCutoff"]) {
    timestamps[field] = parseTimestamp(raw[field], `observed provenance ${field}`);
  }
  if (
    (timestamps.lookbackStart as number) > (timestamps.lookbackEnd as number) ||
    (timestamps.lookbackEnd as number) > (timestamps.asOf as number) ||
    (timestamps.availabilityCutoff as number) > (timestamps.asOf as number)
  ) {
    invalid("provenance-invalid", "observed provenance time bounds must end no later than asOf.");
  }
  if (
    typeof raw.observations !== "number" ||
    !Number.isInteger(raw.observations) ||
    raw.observations < 2
  ) {
    invalid("provenance-invalid", "observed provenance observations must be an integer >= 2.");
  }
  if (raw.returnType !== "decimal-simple") {
    invalid("provenance-invalid", "observed provenance returnType must be decimal-simple.");
  }
  if (raw.estimator !== "sample-covariance-n-minus-one") {
    invalid("provenance-invalid", "observed provenance estimator is unsupported.");
  }
  if (raw.volatilityUnit !== "per-period" && raw.volatilityUnit !== "annualized") {
    invalid("provenance-invalid", "observed provenance volatilityUnit is unsupported.");
  }
  if (raw.volatilityUnit === "per-period") {
    if (raw.annualizationFactor !== null) {
      invalid("provenance-invalid", "per-period observed provenance must have null annualizationFactor.");
    }
  } else {
    const factor = asFiniteNumber(
      raw.annualizationFactor,
      "provenance-invalid",
      "annualized observed provenance annualizationFactor",
    );
    if (factor <= 0) {
      invalid("provenance-invalid", "annualized observed provenance annualizationFactor must be positive.");
    }
  }
  if (raw.currencyMode !== "local" && raw.currencyMode !== "base") {
    invalid("provenance-invalid", "observed provenance currencyMode is unsupported.");
  }
  if (raw.currencyMode === "local") {
    if (raw.baseCurrency !== null || raw.fxQuote !== null) {
      invalid("provenance-invalid", "local observed provenance must have null FX fields.");
    }
  } else {
    nonEmptyString(raw.baseCurrency, "observed provenance baseCurrency");
    if (raw.fxQuote !== "base-per-local") {
      invalid("provenance-invalid", "observed provenance fxQuote must be base-per-local.");
    }
  }
  if (raw.finality !== "final" && raw.finality !== "provisional") {
    invalid("provenance-invalid", "observed provenance finality is unsupported.");
  }
  return { kind, raw, timestamps };
}

function validateMetadataPair(
  volatilityMetadata: unknown,
  covarianceMetadata: unknown,
  ids: readonly string[],
  provenance: ValidatedProvenance,
): void {
  const observed = provenance.kind === "observed";
  const volatility = validateMetadata(volatilityMetadata, ids, observed);
  const covariance = validateMetadata(covarianceMetadata, ids, observed);
  const fields: readonly string[] = observed
    ? [...BASE_METADATA_FIELDS, ...OBSERVED_METADATA_FIELDS]
    : BASE_METADATA_FIELDS;
  for (const field of fields) {
    if (field === "assetIds") {
      if (!sequenceEquals(volatility[field], ids) || !sequenceEquals(covariance[field], ids)) {
        invalid("provenance-invalid", "metadata assetIds do not match assetIds.");
      }
      continue;
    }
    if (volatility[field] !== covariance[field]) {
      if (
        field === "horizon" ||
        field === "periodicity" ||
        field === "returnType" ||
        field === "adjustmentBasis" ||
        field === "estimator" ||
        field === "volatilityUnit" ||
        field === "annualizationFactor" ||
        field === "currencyMode" ||
        field === "baseCurrency" ||
        field === "fxQuote"
      ) {
        invalid("covariance-scale-mismatch", `volatility and covariance metadata differ at ${field}.`);
      }
      invalid("provenance-invalid", `volatility and covariance metadata differ at ${field}.`);
    }
  }
  if (provenance.kind === "synthetic") {
    if (volatility.panelId !== provenance.raw.fixtureId) {
      invalid("provenance-invalid", "synthetic metadata panelId must equal fixtureId.");
    }
    return;
  }
  const provenanceFields = [
    "panelId",
    "periodicity",
    "adjustmentBasis",
    "returnType",
    "estimator",
    "volatilityUnit",
    "annualizationFactor",
    "currencyMode",
    "baseCurrency",
    "fxQuote",
    "timestampHash",
    "observations",
    "revision",
    "finality",
  ];
  for (const field of provenanceFields) {
    if (volatility[field] !== provenance.raw[field]) {
      invalid("provenance-invalid", `observed metadata does not match provenance at ${field}.`);
    }
  }
  for (const field of ["lookbackStart", "lookbackEnd", "asOf", "availabilityCutoff"]) {
    const metadataTime = parseTimestamp(volatility[field], `observed metadata ${field}`);
    if (metadataTime !== provenance.timestamps[field]) {
      invalid("provenance-invalid", `observed metadata does not match provenance at ${field}.`);
    }
  }
  if (!sequenceEquals(volatility.assetIds, provenance.raw.assetIds as string[])) {
    invalid("provenance-invalid", "observed metadata assetIds do not match provenance.");
  }
}

function validateCovarianceMatrix(value: unknown, n: number): number[][] {
  if (!Array.isArray(value) || value.length !== n) {
    invalid("invalid-covariance", `covariance must have exactly ${n} rows.`);
  }
  const matrix: number[][] = [];
  for (let rowIndex = 0; rowIndex < value.length; rowIndex += 1) {
    const row = value[rowIndex];
    if (!Array.isArray(row) || row.length !== n) {
      invalid("invalid-covariance", `covariance row ${rowIndex} must have exactly ${n} cells.`);
    }
    const convertedRow: number[] = [];
    for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
      convertedRow.push(
        asFiniteNumber(
          row[columnIndex],
          "invalid-covariance",
          `covariance[${rowIndex}][${columnIndex}]`,
        ),
      );
    }
    matrix.push(convertedRow);
  }
  return matrix;
}

function jacobiEigenvalues(matrix: readonly (readonly number[])[], scale: number): number[] | null {
  const n = matrix.length;
  if (n === 1) {
    return [matrix[0][0]];
  }
  const work = matrix.map((row) => [...row]);
  const convergence = JACOBI_OFF_DIAGONAL_RELATIVE_TOLERANCE * scale;
  const maxSweeps = Math.max(100, 100 * n * n);
  for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
    let maxOffDiagonal = 0;
    let pivotP = -1;
    let pivotQ = -1;
    for (let row = 0; row < n; row += 1) {
      for (let column = row + 1; column < n; column += 1) {
        const magnitude = Math.abs(work[row][column]);
        if (magnitude > maxOffDiagonal) {
          maxOffDiagonal = magnitude;
          pivotP = row;
          pivotQ = column;
        }
      }
    }
    if (maxOffDiagonal <= convergence) {
      const values = work.map((row, index) => row[index]);
      return values.every(Number.isFinite) ? values : null;
    }
    if (pivotP < 0 || pivotQ < 0) {
      return work.map((row, index) => row[index]);
    }
    const p = pivotP;
    const q = pivotQ;
    const offDiagonal = work[p][q];
    if (offDiagonal === 0) {
      continue;
    }
    const angle = 0.5 * Math.atan2(2 * offDiagonal, work[q][q] - work[p][p]);
    const cosine = Math.cos(angle);
    const sine = Math.sin(angle);
    const app = work[p][p];
    const aqq = work[q][q];
    for (let index = 0; index < n; index += 1) {
      if (index === p || index === q) {
        continue;
      }
      const aip = work[index][p];
      const aiq = work[index][q];
      const newIp = cosine * aip - sine * aiq;
      const newIq = sine * aip + cosine * aiq;
      work[index][p] = newIp;
      work[p][index] = newIp;
      work[index][q] = newIq;
      work[q][index] = newIq;
    }
    work[p][p] = cosine * cosine * app - 2 * sine * cosine * offDiagonal + sine * sine * aqq;
    work[q][q] = sine * sine * app + 2 * sine * cosine * offDiagonal + cosine * cosine * aqq;
    work[p][q] = 0;
    work[q][p] = 0;
    if (!work.every((row) => row.every(Number.isFinite))) {
      return null;
    }
  }
  return null;
}

/** Validate covariance scale/PSD and calculate signed component risk. */
export function covarianceRiskDiagnostic(
  assetIds: unknown,
  weights: unknown,
  standaloneVolatilities: unknown,
  covariance: unknown,
  volatilityMetadata: unknown,
  covarianceMetadata: unknown,
  provenance: unknown,
): CovarianceRiskDiagnosticResult {
  const ids = validateAssetIds(assetIds);
  const n = ids.length;
  const validatedWeights = validateVector(weights, n, "weights", { nonNegative: true });
  const weightSum = neumaierSum(validatedWeights);
  if (Math.abs(weightSum - 1) > WEIGHT_SUM_ABSOLUTE_TOLERANCE) {
    invalid("invalid-input", "weights must sum to one within the frozen tolerance.");
  }
  const validatedVolatilities = validateVector(
    standaloneVolatilities,
    n,
    "standaloneVolatilities",
    { strictlyPositive: true },
  );
  const validatedProvenance = validateProvenance(provenance, ids);
  validateMetadataPair(
    volatilityMetadata,
    covarianceMetadata,
    ids,
    validatedProvenance,
  );
  const matrix = validateCovarianceMatrix(covariance, n);

  let covarianceScale = 0;
  for (const row of matrix) {
    for (const value of row) {
      covarianceScale = Math.max(covarianceScale, Math.abs(value));
    }
  }
  for (let row = 0; row < n; row += 1) {
    for (let column = row + 1; column < n; column += 1) {
      // Do not average or overwrite a tiny asymmetry before eigensolving.
      if (matrix[row][column] !== matrix[column][row]) {
        invalid(
          "invalid-covariance",
          "covariance must be exactly symmetric; no asymmetry repair is applied.",
        );
      }
    }
  }
  for (let index = 0; index < n; index += 1) {
    const expectedDiagonal = validatedVolatilities[index] * validatedVolatilities[index];
    if (expectedDiagonal === 0 && validatedVolatilities[index] > 0) {
      invalid(
        "covariance-scale-mismatch",
        "standalone volatility squared underflowed before diagonal comparison.",
      );
    }
    if (!Number.isFinite(expectedDiagonal)) {
      invalid(
        "covariance-scale-mismatch",
        "standalone volatility squared overflowed before diagonal comparison.",
      );
    }
    const difference = Math.abs(matrix[index][index] - expectedDiagonal);
    const diagonalScale = Math.max(Math.abs(matrix[index][index]), Math.abs(expectedDiagonal));
    if (
      !Number.isFinite(difference) ||
      diagonalScale === 0 ||
      difference > MATRIX_DIAGONAL_RELATIVE_TOLERANCE * diagonalScale
    ) {
      invalid(
        "covariance-scale-mismatch",
        "covariance diagonal must match standalone volatility squared on the same scale.",
      );
    }
  }
  if (covarianceScale === 0) {
    invalid(
      "covariance-scale-mismatch",
      "zero covariance scale cannot match strictly positive standalone volatilities.",
    );
  }

  // Normalize before eigensolving and before the quadratic form. This keeps
  // Jacobi rotations and q arithmetic bounded for very large and subnormal
  // covariance scales.
  const normalizedMatrix = matrix.map((row) => row.map((value) => value / covarianceScale));
  if (!normalizedMatrix.every((row) => row.every(Number.isFinite))) {
    invalid("numerical-range-invalid", "covariance normalization is non-finite.");
  }
  const eigenvalues = jacobiEigenvalues(normalizedMatrix, 1);
  if (eigenvalues === null) {
    invalid("invalid-covariance", "symmetric eigensolver did not converge safely.");
  }
  let minimumEigenvalue = Number.POSITIVE_INFINITY;
  for (const eigenvalue of eigenvalues) {
    if (eigenvalue < minimumEigenvalue) {
      minimumEigenvalue = eigenvalue;
    }
  }
  if (minimumEigenvalue < -PSD_RELATIVE_TOLERANCE) {
    invalid("invalid-covariance", "covariance is materially non-positive-semidefinite.");
  }

  const normalizedCovarianceVector: number[] = [];
  for (const row of normalizedMatrix) {
    const products = row.map((value, index) => value * validatedWeights[index]);
    if (!products.every(Number.isFinite)) {
      invalid("invalid-covariance", "covariance-weight products are non-finite.");
    }
    const value = neumaierSum(products);
    if (!Number.isFinite(value)) {
      invalid("invalid-covariance", "covariance-weight products are non-finite.");
    }
    normalizedCovarianceVector.push(value);
  }
  const normalizedVarianceTerms = validatedWeights.map(
    (weight, index) => weight * normalizedCovarianceVector[index],
  );
  if (!normalizedVarianceTerms.every(Number.isFinite)) {
    invalid("invalid-covariance", "portfolio variance terms are non-finite.");
  }
  const normalizedVariance = neumaierSum(normalizedVarianceTerms);
  if (!Number.isFinite(normalizedVariance)) {
    invalid("numerical-range-invalid", "normalized portfolio variance is non-finite.");
  }
  if (normalizedVariance < 0) {
    invalid("invalid-covariance", "portfolio variance is negative; it is not clamped.");
  }
  if (normalizedVariance === 0) {
    if (normalizedCovarianceVector.some((value) => value !== 0)) {
      invalid(
        "numerical-range-invalid",
        "positive covariance risk was lost during normalized quadratic-form arithmetic.",
      );
    }
    return {
      assetIds: ids,
      weights: validatedWeights,
      covarianceScale,
      status: "zero-risk",
      variance: 0,
      volatility: 0,
      componentRiskContributions: null,
      componentRiskShares: null,
      reason: "perfect-hedge-zero-variance",
    };
  }

  const variance = normalizedVariance * covarianceScale;
  if (!Number.isFinite(variance) || variance === 0) {
    invalid(
      "numerical-range-invalid",
      "positive portfolio variance cannot be represented at the covariance scale.",
    );
  }
  const normalizedVolatility = Math.sqrt(normalizedVariance);
  const squareRootScale = Math.sqrt(covarianceScale);
  const volatility = normalizedVolatility * squareRootScale;
  if (!Number.isFinite(squareRootScale) || !Number.isFinite(volatility)) {
    invalid("numerical-range-invalid", "portfolio volatility is non-finite.");
  }
  if (volatility === 0) {
    invalid(
      "numerical-range-invalid",
      "positive portfolio volatility underflowed after scale restoration.",
    );
  }
  if (normalizedVariance <= NEAR_ZERO_VARIANCE_RELATIVE_TOLERANCE) {
    return {
      assetIds: ids,
      weights: validatedWeights,
      covarianceScale,
      status: "numerically-unreliable",
      variance,
      volatility,
      componentRiskContributions: null,
      componentRiskShares: null,
      reason: "near-zero-portfolio-variance",
    };
  }

  const normalizedRiskContributions = normalizedVarianceTerms.map(
    (term) => term / normalizedVolatility,
  );
  const componentRiskContributions = normalizedRiskContributions.map(
    (value) => value * squareRootScale,
  );
  const componentRiskShares = normalizedVarianceTerms.map(
    (term) => term / normalizedVariance,
  );
  if (normalizedRiskContributions.some(
    (normalized, index) => normalized !== 0 && componentRiskContributions[index] === 0,
  )) {
    invalid(
      "numerical-range-invalid",
      "a positive component risk contribution underflowed after scale restoration.",
    );
  }
  if (
    !componentRiskContributions.every(Number.isFinite) ||
    !componentRiskShares.every(Number.isFinite)
  ) {
    invalid("invalid-covariance", "component risk outputs are non-finite.");
  }
  if (
    !approximatelyEqual(neumaierSum(componentRiskContributions), volatility) ||
    !approximatelyEqual(neumaierSum(componentRiskShares), 1)
  ) {
    invalid("invalid-covariance", "component risk identities failed their tolerance check.");
  }
  return {
    assetIds: ids,
    weights: validatedWeights,
    covarianceScale,
    status: "available",
    variance,
    volatility,
    componentRiskContributions,
    componentRiskShares,
    reason: null,
  };
}
Full-height labdiagnostic stagesOpen full screen
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.