D14-F01-A04 / Released engineering topic

Mean-CVaR Optimization: Make the Tail Auditable

A production-minded guide to Mean-CVaR Optimization.

Mean-CVaR Optimization: Make the Tail AuditableD14 / D14-F01

I do not trust a tail-risk number simply because an optimizer returned it. I want to see which scenarios entered the tail, how much probability mass was counted, which sign convention turned returns into losses, and whether the displayed objective can be rebuilt from those pieces.

That discipline matters because portfolio data can be numerically tidy and economically false. A split-adjusted price paired with an old share count can invent a loss. A reversed FX quote can magnify one. A cash dividend can look like a price loss when total return was intended. Mean-CVaR optimization does not repair those errors; it can give them more influence because it deliberately concentrates on the bad tail.

This tutorial builds one precise version: finite simple-return scenarios, explicit probabilities, long-only fully invested weights, confidence level beta, and strictly positive risk aversion gamma. The implementation and examples are reproducible. They are synthetic teaching evidence, not a historical performance claim.

A discrete loss tail showing the VaR threshold and partial probability mass used by CVaR

Before you start

This tutorial is for developers comfortable with weighted averages, vectors, and basic probability. By the end you can calculate a discrete loss tail with partial probability mass and audit a mean-minus-CVaR allocation. All percentages use one common horizon; .05 means 5%, and variance has squared-return units. No live market feed or numerical library is required to run the package examples.

The decision in one sentence

Choose weights that balance probability-weighted return against the average loss in the worst 1-beta probability mass:

maxw  μ(w)γCVaRβ(L(w)),1Tw=1,  w0.\max_w\; \mu(w)-\gamma\,\operatorname{CVaR}_{\beta}(L(w)), \qquad \mathbf{1}^{\mathsf T}w=1,\;w\ge0.

Here gamma is not a confidence level. It is the price we choose to put on a unit of tail loss. beta determines which tail is measured. Those controls answer different questions and should never share one unlabeled slider.

Start with loss sign, not a formula

Each input row contains asset returns for one scenario. For portfolio weights w, scenario return and loss are

Rt(w)=rtTw,Lt(w)=Rt(w).R_t(w)=r_t^{\mathsf T}w, \qquad L_t(w)=-R_t(w).

Positive return therefore becomes negative loss. This is intentional. An all-gain table can have a negative CVaR; clipping it to zero silently changes the definition and the optimizer.

The scenario probability p_t must be supplied and the values must sum to one. The reference code does not normalize an almost-plausible input because normalization can conceal a missing or duplicated scenario. beta=.80 means that CVaR covers the worst .20 probability mass. It does not mean “take the worst 20 rows” unless every row has identical probability.

Why a discrete tail needs partial mass

Take three losses:

LossProbabilityCumulative probability
0.000.600.60
0.100.300.90
0.400.101.00

At beta=.80, the VaR threshold is .10. The tail has mass .20: all .10 probability at loss .40, plus only .10 probability from the .10 row. The result is

CVaR.8=.10(.40)+.10(.10).20=.25.\operatorname{CVaR}_{.8}=\frac{.10(.40)+.10(.10)}{.20}=.25.

It is not .175, the ordinary weighted mean of the two positive-loss rows. It is not .40, the single worst loss. This partial-mass detail is where many attractive but wrong spreadsheet implementations fail.

The threshold-and-slack formulation

Rockafellar and Uryasev’s formulation shows that the finite problem can be written with a threshold z and nonnegative tail slacks u_t:

CVaRβ(L)=minz[z+11βtptut],\operatorname{CVaR}_{\beta}(L)= \min_z \left[z+\frac{1}{1-\beta}\sum_t p_tu_t\right],

subject to

utLtz,ut0.u_t\ge L_t-z,\qquad u_t\ge0.

Substituting this expression turns the canonical mean-CVaR problem into a linear program. The small reference implementation enumerates LP vertices so that the certificate is inspectable and dependency-free. That is a teaching design, not a recommendation to enumerate vertices for a large institutional universe.

A worked portfolio you can check by hand

Use two assets and four equally likely scenarios:

ScenarioAsset AAsset B
124%8%
220%6%
316%4%
4-20%-2%

Set beta=.75 and gamma=.50. If x is the weight in A, the expected return is

μ(x)=.04+.06x.\mu(x)=.04+.06x.

Because each row has probability .25, the CVaR at .75 is the worst scenario loss:

CVaR.75(x)=.02+.18x.\operatorname{CVaR}_{.75}(x)=.02+.18x.

The objective is therefore

.04+.06x.5(.02+.18x)=.03.03x..04+.06x-.5(.02+.18x)=.03-.03x.

Its maximum is at x=0: weights [0,1], expected return .04, CVaR .02, and objective .03. At the apparently diversified [.5,.5], expected return is .07, but CVaR is .11, leaving objective .015. This is not proof that B is a universally better asset. It proves only what the declared four-scenario distribution and penalty imply.

Open the guided mean-CVaR lab and move the weight, confidence, and risk-aversion controls separately. Watch the tail membership change when beta crosses a probability boundary.

What the implementation certifies

The Python and TypeScript cores return more than weights:

OutputQuestion it answers
scenarioLossesWas the return-to-loss sign applied correctly?
thresholdZWhere does the declared tail begin?
tailSlacksWhich losses extend beyond the threshold?
cvarWhat is the direct weighted tail value?
objectiveDoes mean - gamma × CVaR reconcile?
residualsDo weights sum to one, respect bounds, and use unit probability?
iterationsWas the bounded reference search completed?

The LP candidate is not allowed to certify itself. The implementation recomputes losses, the discrete quantile, CVaR, feasibility residuals, and the final objective. If these disagree beyond tolerance, it returns numerical_issue.

The upstream data gate is part of the algorithm

Before constructing scenarios, I would require an adapter to answer:

  1. Are security identifiers stable through symbol and exchange changes?
  2. Are price and quantity on the same split basis?
  3. Are returns price-only or total return, and how was dividend cash handled?
  4. Are all local prices translated into the declared base currency with the correct FX direction and timestamp?
  5. Are scenario dates observation dates, and what information was actually available at each cutoff?
  6. Were missing rows removed deliberately, and were probabilities rebuilt explicitly afterward?

A mismatched split basis, inverted FX quote, or duplicated dividend can manufacture a tail event. These are hypothetical data failures; the synthetic scenarios below do not establish that any named portfolio experienced them.

Decision table: when this method fits

SituationMean-CVaR responseBetter route when it does not fit
You have explicit stress scenarios and care about severe lossesGood fit: tail mass is visible and weighted
You only have a covariance matrixCVaR cannot be reconstructed from covariance aloneMarkowitz or GMV with honest moment limits
Scenario probabilities are disputedRun sensitivity cases; do not hide the choiceRobust/distributionally robust optimization
Turnover, liquidity, or tax constraints matterExtend the feasible set and certificateExecution-aware portfolio construction
You want a mean-only portfolioThis package rejects gamma=0Use a separately defined mean objective
The history mixes split, dividend, or FX basesStop before optimizationRepair and revalidate market data

Failure modes worth testing

  • beta<=0 or beta>=1: there is no valid tail mass under this contract.
  • gamma<=0: rejected because the package is explicitly the positive-penalty LP variant.
  • probabilities that do not sum to one: rejected, never silently normalized.
  • all gains: negative CVaR is retained rather than clipped.
  • reversed signs: treated as different data, not auto-detected.
  • too many reference vertices or an exhausted iteration budget: numerical_issue, not a guessed optimum.
  • a good certificate on bad scenario history: mathematically valid but economically unusable.

Evidence boundary

The formulas follow the primary Rockafellar–Uryasev CVaR formulation, and the exact examples are independently computed fixtures. A named historical example is deferred until we have licensed point-in-time rows, adjustment status, currencies and FX observations, corporate-action and dividend lineage, scenario probability policy, and a knowledge cutoff. That omission is deliberate: a synthetic example labeled honestly is stronger evidence than a real ticker attached to unreconciled data.

Reproduce, then challenge the answer

From this topic's root folder, run the dependency-free Python example and tests:

Shell
python -B examples/run.py
python -B -m unittest discover -s tests -p "test_*.py"
Python
from implementations.python.cvar import mean_cvar

result = mean_cvar(
    ['A', 'B'],
    [[0.24, 0.08], [0.2, 0.06], [0.16, 0.04], [-0.2, -0.02]],
    [0.25, 0.25, 0.25, 0.25],
    0.75,
    0.5,
)
assert result["status"] == "optimal", result
print(result["weights"])

For TypeScript, the runnable entry point imports the canonical core. The commands below use Node.js 22 and this repository’s TypeScript 7 compiler. Compile from the topic folder; if an older compiler rejects --ignoreConfig, omit that flag:

Shell
tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --strict --skipLibCheck --outDir .topic-build examples/run.ts
node .topic-build/examples/run.js

The shared input fixture is byte-identical to the test fixture. The guided playground runs a bundled copy of the TypeScript core; preset names identify synthetic examples and deliberate failures.

Check your understanding

  1. Keep beta=.75 and change gamma to .20, 1/3, and .50.
  2. Compute CVaR for losses [0,.10,.40], probabilities [.60,.30,.10], and beta=.80.
  3. Add a 1% return to every asset in every scenario.

Answers. The objective slope in A is .06-.18*gamma: A wins at .20, every mix ties at 1/3, and B wins at .50. The discrete CVaR is .25 using only .10 mass from the threshold row. A common 1% shift raises mean by 1% and lowers CVaR loss by 1%; the objective rises by (1+gamma)*1% at every weight, so the optimizer stays unchanged.

The calculation path

Stop when validation fails. A successful numerical certificate verifies the declared model, not the quality of the market estimates supplied to it.

Numerical scale and the size limit

The reference LP divides every scenario return by its maximum absolute return before solving. Mean-MAD divides the target by the same factor. It then restores return-valued outputs to their original units; weights and probabilities remain unchanged. This prevents fixed absolute tolerances from swallowing tiny return floors or changing the optimum after unit conversion. All-zero rows use scale one.

The enumerator is intentionally bounded at 18 variables. CVaR permits up to 10,000 candidate checks per call; MAD requires the complete candidate set to fit its 200,000-check cap. A count limit or failed finite/objective/feasibility audit returns numerical_issue, with no usable weights. Use a maintained LP solver for larger panels and retain the same independent arithmetic checks. A reported tie is resolved deterministically within the documented floating-point tolerances, not by an economic preference.

What to remember

Mean-CVaR is valuable when the scenario tail is itself a declared, inspectable object. Define loss sign. Treat beta and gamma as separate controls. Count probability mass, including partial mass at a discrete threshold. Audit the optimizer from raw scenario losses. And never let a tail model turn a split, dividend, currency, or timestamp error into a sophisticated-looking portfolio decision.

References

See REFERENCES.md for source roles, access limits, and the historical-evidence decision.

Production handoff checklist

  • Freeze the scenario rows, probabilities, return/loss sign, confidence, penalty, horizon, currency, and cutoff.
  • Store scenario losses, threshold, slacks, CVaR, mean, objective, and residuals with the weights.
  • Re-run the partial-mass tail oracle, all-gain case, bad-probability case, and gamma boundary.
  • Compare scenario-probability and confidence sensitivities before interpreting a concentrated solution.
  • Route larger panels to an audited LP solver without changing the published mathematical contract.

Continue through Family 01

cvar flow

ReferencesPrimary sources and evidence notes

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

All Google and source observations below were made or attempted on 2026-09-14 from an Africa/Cairo browser session. Search results are discovery evidence, not rankings or performance evidence.

Primary and technical sources

SourceRoleAccessed / supports / limitations
R. Tyrrell Rockafellar — Risk and Utility in the Duality FrameworkPrimary author-hosted exposition for loss sign, quantile, superquantile and finite-tail caveatsAccessed as a 19-page PDF with 986 extracted lines; P0–P4 read, especially lines 109–153, 169–182 and 193–230. Supports the variational CVaR definition and discrete-tail caution. Formula/application scope is an author-hosted survey, not a market-data record.
Rockafellar and Uryasev — Optimization of Conditional Value-at-Risk DOI metadataOriginal-paper attributionAccessed DOI metadata; publisher full text was not inspected, so no unread equation is claimed.
Uryasev publicationsAuthor bibliographyAccessed publication index for attribution; no formula copied from an unread page.
Enrico Schumann — Minimising Conditional Value-at-RiskFinite-scenario LP corroborationAccessed full 182-line HTML and read variables, objective, scenario constraints and implementation notes. Secondary exposition; direct CVaR recomputation remains the package check.
Investor.gov — Ex-Dividend DatesUpstream dividend timing boundaryOfficial page inspected for entitlement/payment distinction. It does not validate any supplied scenario history.
SEC Investor Bulletin — Stock SplitsUpstream split-unit boundaryOfficial split mechanics reference; no provider-specific row is asserted.

Google discovery audit

Search URL: https://www.google.com/search?udm=14&q=mean-CVaR+portfolio+optimization&hl=en&gl=eg&num=10&pws=0

Google Web results (udm=14) were observed with hl=en, gl=eg; location shown as Al Hay Al Asher/Nasr City and accessibility link hl=en-EG. Ten organic positions were recorded. AI Mode, videos, News/Forums modules, and knowledge-panel text were excluded.

RankDestination observedRead extent / blocker
1ScienceDirect mean-CVaR articleNot opened in this pass; discovery only.
2MetricGate Mean-CVaRNot opened; secondary discovery only.
3ACM Digital Library resultNot opened; publisher lead, no paywall bypass.
4Bjerring CVaR notebookNot opened; implementation lead only.
5Springer mean-CVaR resultNot opened; date-sensitive discovery only.
6MPRA working paperNot opened; working-paper lead only.
7RePEc recordNot opened; bibliography only.
8arXiv resultNot opened in this pass; primary-research lead, not used as proof.
9ResearchGate applied studyNot opened; secondary host, no device/paywall bypass.
10Quantstock explainerNot opened; secondary discovery only.

The ten positions satisfy the requested Google observation gate, but unopened destinations are not treated as read evidence. No CAPTCHA, paywall, device check, or access barrier was bypassed.

Contextual prior method

The prior D02-F01-A01 Backward Split Adjustment is relevant because a fabricated split loss can enter a tail table. It is a data-basis precedent, not a claim that the synthetic A04 rows came from that module. The root-owned ledger records the full source-role and prior-case boundaries.

Source roles

  • Accessed: Google result page and the listed readable sources on 2026-09-14, Africa/Cairo; each result row records the exact extent or unavailable state.
  • Supports: Rockafellar’s primary-author exposition supports the loss, quantile and CVaR mathematics. Schumann corroborates a finite LP structure. The .1/.25 tail, canonical optimizer and all-gain case are independent synthetic calculations and implementation choices.
  • Limitations: no historical market series, provider snapshot, or performance result is cleared; unread/blocked search destinations provide no technical claim; numeric vectors cannot automatically detect mislabeled returns/losses or split/FX/dividend semantics.

Independent recheck — 2026-09-16

Rockafellar’s author-hosted 2018 survey, pp. 2–3, equation 2.6: variational superquantile and discrete-tail caveat.

Recheck scope: theory and attribution only. Fixtures and solver limits are author-derived calculations and implementation choices. No new historical-market or personal-experience evidence is asserted.

cvar.ts
export interface MeanCvarResult {
    branch?: string;
    status: string;
    variant: string;
    method: string;
    assetIds: string[] | null;
    weights: number[] | null;
    expectedReturn: number | null;
    cvar: number | null;
    thresholdZ: number | null;
    tailSlacks: number[] | null;
    scenarioLosses: number[] | null;
    objective: number | null;
    budgetResidual: number | null;
    lowerBoundResidual: number | null;
    probabilityResidual: number | null;
    iterations: number;
    maxIterations: number;
    solutionClass: string;
    diagnostics: Record<string, unknown>;
    warnings: string[];
}
const DEFAULT_MAX_ITERATIONS = 10000;
const TOL_PROBABILITY = 1e-12;
const TOL_FEASIBILITY = 1e-10;
const TOL_OBJECTIVE = 1e-10;
const MAX_VERTEX_VARIABLES = 18;
const MAX_VERTEX_CANDIDATES = 200000;
function finiteReal(value: unknown): value is number {
    return typeof value === "number" && Number.isFinite(value);
}
function dot(left: number[], right: number[]): number {
    return left.reduce((sum, value, index) => sum + value * right[index], 0);
}
function solveLinear(matrix: number[][], vector: number[]): number[] | null {
    const size = vector.length;
    const augmented = matrix.map((row, index) => [...row, vector[index]]);
    for (let column = 0; column < size; column += 1) {
        let pivot = column;
        for (let row = column + 1; row < size; row += 1)
            if (Math.abs(augmented[row][column]) > Math.abs(augmented[pivot][column]))
                pivot = row;
        if (Math.abs(augmented[pivot][column]) <= 1e-13)
            return null;
        [augmented[column], augmented[pivot]] = [augmented[pivot], augmented[column]];
        const divisor = augmented[column][column];
        for (let j = column; j <= size; j += 1)
            augmented[column][j] /= divisor;
        for (let row = 0; row < size; row += 1) {
            if (row === column)
                continue;
            const factor = augmented[row][column];
            if (factor === 0)
                continue;
            for (let j = column; j <= size; j += 1)
                augmented[row][j] -= factor * augmented[column][j];
        }
    }
    return augmented.map((row) => row[size]);
}
function combinations(size: number, choose: number): number[][] {
    const output: number[][] = [];
    const current: number[] = [];
    const visit = (start: number): void => {
        if (current.length === choose) {
            output.push(current.slice());
            return;
        }
        for (let index = start; index <= size - (choose - current.length); index += 1) {
            current.push(index);
            visit(index + 1);
            current.pop();
        }
    };
    visit(0);
    return output;
}
export function weightedVarCvar(losses: number[], probabilities: number[], beta: number): {
    threshold: number;
    cvar: number;
} {
    const ordered = losses.map((loss, index) => ({ loss, probability: probabilities[index] })).sort((a, b) => a.loss - b.loss);
    let cumulative = 0;
    let threshold = ordered[ordered.length - 1].loss;
    for (const item of ordered) {
        cumulative += item.probability;
        if (cumulative >= beta) {
            threshold = item.loss;
            break;
        }
    }
    const cvar = threshold + losses.reduce((sum, loss, index) => sum + probabilities[index] * Math.max(0, loss - threshold), 0) / (1 - beta);
    return { threshold, cvar };
}
function failure(status: string, message: string, details: Partial<MeanCvarResult> = {}, diagnostics: Record<string, unknown> = {}): MeanCvarResult {
    return { status, variant: "mean-cvar-positive-gamma-long-only", method: "lp-vertex-enumeration", assetIds: details.assetIds ?? null, weights: null, expectedReturn: null, cvar: null, thresholdZ: null, tailSlacks: null, scenarioLosses: null, objective: null, budgetResidual: null, lowerBoundResidual: null, probabilityResidual: details.probabilityResidual ?? null, iterations: details.iterations ?? 0, maxIterations: details.maxIterations ?? DEFAULT_MAX_ITERATIONS, solutionClass: "unknown", diagnostics: { message, ...diagnostics }, warnings: [] };
}
function meanCvarNormalized(assetIdsInput: unknown, returnsInput: unknown, probabilitiesInput: unknown, betaInput: unknown, gammaInput: unknown, options: {
    maxIterations?: unknown;
} = {}): MeanCvarResult {
    if (!Array.isArray(assetIdsInput))
        return failure("invalid_input", "assetIds must be an ordered array.");
    const assetIds = assetIdsInput.slice();
    if (!assetIds.length || assetIds.some((value) => typeof value !== "string" || value.trim() === ""))
        return failure("invalid_input", "assetIds must contain non-empty strings.");
    if (new Set(assetIds).size !== assetIds.length)
        return failure("invalid_input", "assetIds must be unique.");
    const size = assetIds.length;
    if (!Array.isArray(returnsInput) || returnsInput.length === 0)
        return failure("invalid_input", "returns must be a non-empty T by N matrix.", { assetIds });
    if (returnsInput.some((row) => !Array.isArray(row) || row.length !== size))
        return failure("invalid_input", "each return row must align to assetIds.", { assetIds });
    if ((returnsInput as unknown[][]).some((row) => row.some((value) => !finiteReal(value))))
        return failure("invalid_input", "returns must contain finite real numbers.", { assetIds });
    const returns = (returnsInput as unknown[][]).map((row) => row.slice() as number[]);
    const observations = returns.length;
    if (!Array.isArray(probabilitiesInput) || probabilitiesInput.length !== observations)
        return failure("invalid_input", "probabilities must align to return rows.", { assetIds });
    if ((probabilitiesInput as unknown[]).some((value) => !finiteReal(value) || value < 0))
        return failure("invalid_input", "probabilities must be finite and non-negative.", { assetIds });
    const probabilities = (probabilitiesInput as unknown[]).slice() as number[];
    const probabilityResidual = Math.abs(probabilities.reduce((sum, value) => sum + value, 0) - 1);
    if (probabilityResidual > TOL_PROBABILITY)
        return failure("invalid_input", "probabilities must sum to one; no silent normalization is applied.", { assetIds, probabilityResidual });
    if (!finiteReal(betaInput) || betaInput <= 0 || betaInput >= 1)
        return failure("invalid_input", "beta must be a confidence level strictly between zero and one.", { assetIds });
    const beta = betaInput;
    if (!finiteReal(gammaInput) || gammaInput <= 0)
        return failure("invalid_input", "gamma must be strictly positive in this bounded LP variant.", { assetIds });
    const gamma = gammaInput;
    const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
    if (!Number.isInteger(maxIterations) || (maxIterations as number) < 0 || (maxIterations as number) > DEFAULT_MAX_ITERATIONS)
        return failure("invalid_input", "maxIterations must be an integer from 0 through 10,000.", { assetIds });
    const iterationBudget = maxIterations as number;
    if (iterationBudget === 0)
        return failure("numerical_issue", "maxIterations=0 cannot produce an LP certificate.", { assetIds, maxIterations: iterationBudget });
    const variables = size + 1 + observations;
    if (variables > MAX_VERTEX_VARIABLES)
        return failure("numerical_issue", "the deterministic vertex core is bounded at 18 variables.", { assetIds, maxIterations: iterationBudget }, { variables, maxVertexVariables: MAX_VERTEX_VARIABLES });
    const meanReturns = Array.from({ length: size }, (_value, index) => returns.reduce((sum, row, scenario) => sum + probabilities[scenario] * row[index], 0));
    const inequalities: Array<{
        a: number[];
        b: number;
    }> = [];
    for (let index = 0; index < size; index += 1) {
        const lower = Array(variables).fill(0);
        lower[index] = -1;
        inequalities.push({ a: lower, b: 0 });
        const upper = Array(variables).fill(0);
        upper[index] = 1;
        inequalities.push({ a: upper, b: 1 });
    }
    for (let scenario = 0; scenario < observations; scenario += 1) {
        const lower = Array(variables).fill(0);
        lower[size + 1 + scenario] = -1;
        inequalities.push({ a: lower, b: 0 });
        const tail = Array(variables).fill(0);
        for (let index = 0; index < size; index += 1)
            tail[index] = -returns[scenario][index];
        tail[size] = -1;
        tail[size + 1 + scenario] = -1;
        inequalities.push({ a: tail, b: 0 });
    }
    const equality = Array(variables).fill(0);
    for (let index = 0; index < size; index += 1)
        equality[index] = 1;
    const objective = Array(variables).fill(0);
    meanReturns.forEach((value, index) => { objective[index] = value / Math.max(1, gamma); });
    objective[size] = -gamma / Math.max(1, gamma);
    probabilities.forEach((value, scenario) => { objective[size + 1 + scenario] = -(gamma / Math.max(1, gamma)) * value / (1 - beta); });
    const activeCount = variables - 1;
    const combinationCount = activeCount <= inequalities.length ? chooseCount(inequalities.length, activeCount) : 0;
    if (combinationCount > MAX_VERTEX_CANDIDATES)
        return failure("numerical_issue", "the LP vertex count exceeds the deterministic candidate limit.", { assetIds, maxIterations: iterationBudget }, { verticesRequired: combinationCount, maxVertexCandidates: MAX_VERTEX_CANDIDATES });
    const budget = Math.min(iterationBudget, combinationCount);
    const activeSets = combinations(inequalities.length, activeCount);
    let bestValue = -Infinity;
    let bestSolution: number[] | null = null;
    let checked = 0;
    for (const active of activeSets) {
        if (checked >= budget)
            break;
        checked += 1;
        const candidate = solveLinear([equality, ...active.map((index) => inequalities[index].a)], [1, ...active.map((index) => inequalities[index].b)]);
        if (candidate === null || candidate.some(x => !Number.isFinite(x)) || inequalities.some((constraint) => dot(constraint.a, candidate) > constraint.b + TOL_FEASIBILITY))
            continue;
        const value = dot(objective, candidate);
        if (bestSolution === null || value > bestValue + TOL_OBJECTIVE * Math.max(Math.abs(value), Math.abs(bestValue), 1e-300)) {
            bestValue = value;
            bestSolution = candidate;
        }
    }
    if (checked < combinationCount)
        return failure("numerical_issue", "maxIterations exhausted before all LP vertices were checked.", { assetIds, maxIterations: iterationBudget, iterations: checked }, { verticesChecked: checked, verticesRequired: combinationCount });
    if (bestSolution === null)
        return failure("numerical_issue", "no feasible LP vertex was found.", { assetIds, maxIterations: iterationBudget, iterations: checked });
    const weights = bestSolution.slice(0, size);
    const thresholdFromLp = bestSolution[size];
    const scenarioLosses = returns.map(row => -dot(row, weights));
    const expectedReturn = dot(meanReturns, weights);
    const { threshold, cvar } = weightedVarCvar(scenarioLosses, probabilities, beta);
    const tailSlacks = scenarioLosses.map(loss => Math.max(0, loss - threshold));
    const objectiveValue = expectedReturn - gamma * cvar;
    const lpObjective = bestValue * Math.max(1, gamma);
    const maxTailResidual = Math.max(0, ...scenarioLosses.map((loss, t) => loss - thresholdFromLp - bestSolution![size + 1 + t]));
    const lowerResidual = Math.max(0, -Math.min(...weights));
    const budgetResidual = Math.abs(weights.reduce((a, b) => a + b, 0) - 1);
    const primalResidual = Math.max(maxTailResidual, lowerResidual, budgetResidual);
    const objectiveScale = Math.max(Math.abs(lpObjective), Math.abs(objectiveValue), Math.abs(expectedReturn), Math.abs(gamma * cvar), 1e-15);
    const objectiveGap = Math.abs(lpObjective - objectiveValue);
    if (![lpObjective, objectiveValue, cvar, objectiveGap].every(Number.isFinite) || primalResidual > TOL_FEASIBILITY || objectiveGap > TOL_OBJECTIVE * objectiveScale)
        return failure('numerical_issue', 'the selected LP vertex failed the independent primal/objective audit.', { assetIds, maxIterations: iterationBudget, iterations: checked }, { verticesChecked: checked, verticesRequired: combinationCount, primalResidual, objectiveGap, objectiveScale, lpObjective, directObjective: objectiveValue });
    const tiedWeights: number[][] = [];
    for (const active of activeSets) {
        const candidate = solveLinear([equality, ...active.map(i => inequalities[i].a)], [1, ...active.map(i => inequalities[i].b)]);
        if (candidate === null || candidate.some(x => !Number.isFinite(x)) || inequalities.some(c => dot(c.a, candidate) > c.b + TOL_FEASIBILITY))
            continue;
        const ws = candidate.slice(0, size);
        const losses = returns.map(row => -dot(row, ws));
        const value = dot(meanReturns, ws) - gamma * weightedVarCvar(losses, probabilities, beta).cvar;
        if (Math.abs(value - objectiveValue) <= TOL_OBJECTIVE * Math.max(Math.abs(value), Math.abs(objectiveValue), 1e-300) && !tiedWeights.some(prior => Math.max(...ws.map((w, j) => Math.abs(w - prior[j]))) <= 1e-9))
            tiedWeights.push(ws);
    }
    return { status: 'optimal', variant: 'mean-cvar-long-only', method: 'lp-vertex-enumeration', branch: 'positive_gamma_lp', assetIds, weights, expectedReturn, cvar, thresholdZ: threshold, tailSlacks, scenarioLosses, objective: objectiveValue, budgetResidual, lowerBoundResidual: lowerResidual, probabilityResidual, iterations: checked, maxIterations: iterationBudget, solutionClass: tiedWeights.length > 1 ? 'non_unique' : 'unique', diagnostics: { beta, gamma, meanReturns, tailMass: 1 - beta, lpObjective, objectiveGap, objectiveScale, directObjective: objectiveValue, primalResidual, certificate: 'lp-vertex-enumeration', verticesChecked: checked, verticesRequired: combinationCount, thresholdFromLp, quantileFromLosses: threshold, distinctOptimalWeights: tiedWeights.length }, warnings: [] };
}
export const meanCvarOptimization = meanCvar;
export function meanCvar(assetIdsInput: unknown, returnsInput: unknown, probabilitiesInput: unknown, betaInput: unknown, gammaInput: unknown, options: {
    maxIterations?: unknown;
} = {}): MeanCvarResult {
    let scale = 1;
    let rows = returnsInput;
    if (Array.isArray(rows) && rows.length && rows.every(row => Array.isArray(row) && row.length && row.every(x => typeof x === 'number' && Number.isFinite(x)))) {
        scale = rows.reduce((m: number, row: number[]) => row.reduce((n, x) => Math.max(n, Math.abs(x)), m), 0) || 1;
        rows = rows.map((row: number[]) => row.map(x => x / scale));
    }
    const result = meanCvarNormalized(assetIdsInput, rows, probabilitiesInput, betaInput, gammaInput, options);
    const restore = (value: unknown): unknown => Array.isArray(value) ? value.map(restore) : typeof value === 'number' ? value * scale : value;
    const record = result as unknown as Record<string, unknown>;
    for (const key of ["expectedReturn", "cvar", "thresholdZ", "tailSlacks", "scenarioLosses", "objective"])
        if (record[key] !== null && record[key] !== undefined)
            record[key] = restore(record[key]);
    for (const key of ["meanReturns", "lpObjective", "objectiveGap", "objectiveScale", "directObjective", "thresholdFromLp", "quantileFromLosses"])
        if (key in result.diagnostics)
            result.diagnostics[key] = restore(result.diagnostics[key]);
    result.diagnostics.returnScale = scale;
    const allFinite = (value: unknown): boolean => typeof value === 'number' ? Number.isFinite(value) : Array.isArray(value) ? value.every(allFinite) : value !== null && typeof value === 'object' ? Object.values(value).every(allFinite) : true;
    if (result.status === 'optimal' && !allFinite(result))
        return failure('numerical_issue', 'output arithmetic exceeded finite return units.', { assetIds: assetIdsInput as string[] });
    return result;
}
function chooseCount(n: number, k: number): number { let v = 1; for (let i = 1; i <= k; i++)
    v = v * (n - k + i) / i; return Math.round(v); }
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.