D14-F01-A05 / Released engineering topic

Mean-Absolute-Deviation Optimization: A Return Target You Can Audit

A production-minded guide to Mean-Absolute-Deviation Optimization.

Mean-Absolute-Deviation Optimization: A Return Target You Can AuditD14 / D14-F01

Portfolio optimization often begins with a covariance matrix because it is familiar. But a scenario table can answer a more direct question: how far did the portfolio return move away from its own probability-weighted mean in each row? Mean-absolute-deviation optimization turns those distances into a linear program and asks for the smallest average absolute deviation that still reaches a declared expected-return floor.

The word “declared” is essential. A return target is a modeling choice. A scenario probability is a modeling choice. The price, quantity, currency, corporate-action, dividend, and timestamp basis behind each row is a data contract. The optimizer cannot decide any of those for us.

A return target intersects the lowest attainable mean-absolute-deviation line at a fifty-fifty portfolio

Before you start

This tutorial is for developers comfortable with weighted averages, vectors, and basic probability. By the end you can center scenario returns, derive the absolute-deviation LP, and diagnose a binding or infeasible floor. 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 exact problem built here

For scenario return row r_t, probability p_t, and weights w, define the asset mean vector

μ=tptrt.\mu=\sum_t p_t r_t.

Portfolio expected return is mu^T w. Its deviation in scenario t is

dt=(rtμ)Tw.d_t=(r_t-\mu)^{\mathsf T}w.

The risk statistic is the probability-weighted mean absolute deviation:

MAD(w)=tptdt.\operatorname{MAD}(w)=\sum_t p_t|d_t|.

We minimize this value subject to

μTwτ,1Tw=1,w0,\mu^{\mathsf T}w\ge \tau, \qquad \mathbf{1}^{\mathsf T}w=1, \qquad w\ge0,

where tau is the required expected return. In the implementation it is named targetReturn to avoid confusing it with a risk-aversion parameter.

This package is not downside MAD, median absolute deviation, tracking error, or mean-CVaR. It measures distance on both sides of the mean. Large upside and downside deviations both count.

Why the absolute value becomes a linear program

For every scenario, introduce y_t and require

ytdt,ytdt.y_t\ge d_t, \qquad y_t\ge-d_t.

For every positive-probability row, minimization makes y_t=|d_t|; a zero-probability row can have unused slack without changing the objective. The objective becomes sum p_t y_t; all constraints are linear. Konno and Yamazaki’s original model made this computational advantage central: a mean-risk allocation can be expressed without a quadratic covariance objective.

A worked example with no hidden arithmetic

Consider four equally likely synthetic rows:

ScenarioDISPERSEDSTABLE
120%3%
210%3%
35%3%
4-3%3%

The means are 8% and 3%. The DISPERSED centered deviations are [12%, 2%, -3%, -11%]; their equal-weight average absolute value is

(.12+.02+.03+.11)/4=.07.(.12+.02+.03+.11)/4=.07.

STABLE has zero deviation in this fixture. That is a teaching simplification, not a claim that a real security produces a constant return.

Let x be the DISPERSED weight. Then

E[Rp]=.08x+.03(1x)=.03+.05x,E[R_p]=.08x+.03(1-x)=.03+.05x,

and because the other asset has zero centered deviation,

MAD(x)=.07x.\operatorname{MAD}(x)=.07x.

Set the target to 5.5%:

.03+.05x.055x.5..03+.05x\ge.055\Rightarrow x\ge.5.

MAD rises with x, so the smallest feasible value is x=.5. The exact result is weights [.5,.5], expected return 5.5%, and MAD 3.5%.

Try the guided Mean-MAD lab. Lower the target to 2%; the constraint becomes slack and the solution moves to [0,1] with zero MAD in this fixture. Raise it above 8%; the long-only problem becomes infeasible. The boundary is visible rather than buried in a solver status.

What the reference solver actually does

The Python and TypeScript implementations build variables [weights, absolute-deviation auxiliaries], the budget equality, return-floor inequality, nonnegative bounds, and two deviation inequalities per scenario. They enumerate every candidate vertex within a hard limit, keep feasible minimum-MAD candidates, and then independently recompute:

  • asset means;
  • portfolio expected return;
  • each centered portfolio deviation;
  • probability-weighted MAD;
  • budget, lower-bound, target, and probability residuals;
  • the gap between the LP objective and direct MAD.

An incomplete search never returns a convenient partial answer. It returns numerical_issue. A target above the largest long-only asset mean returns infeasible. Invalid IDs, shapes, non-finite rows, bad probabilities, and non-finite targets return invalid_input.

MAD versus its neighbors

MethodRisk inputWhat it emphasizesImportant weakness
Markowitz mean-varianceexpected returns and covariancesquared dispersionmoment estimates and outliers can dominate
Global minimum variancecovariance onlylowest feasible varianceno return target in canonical form
Maximum Sharpeexpected excess return and covariancereturn per volatilitybenchmark and denominator instability
Mean-CVaRscenario loss tailsevere losses beyond a quantiletail probabilities and sign contract
Mean-MADcentered scenario returnsaverage absolute dispersionupside and downside count symmetrically

Mean-MAD is not “better variance.” It encodes a different loss function. Absolute deviations grow linearly, while squared deviations grow quadratically. This can make MAD less dominated by the magnitude of one extreme row, but a false extreme row still corrupts the means, deviations, and feasible portfolio.

Data failures to stop before optimization

Before building the scenario matrix, validate:

  1. the permanent security identity, exchange, and trading calendar;
  2. price and quantity against the same split basis;
  3. whether the series is raw, adjusted-price, or total return;
  4. dividend entitlement, payment, and reinvestment assumptions;
  5. local-currency units and the FX pair direction and timestamp;
  6. observation time versus the time the data became knowable;
  7. missing-row treatment and the resulting probability policy.

Do not ask MAD to detect a unit error. It sees only numbers.

Decision table before using the result

QuestionAcceptable answerStop condition
What is centered?each scenario’s portfolio return around the probability-weighted portfolio meanan undocumented benchmark or median
What does the target mean?same horizon, unit, and currency as scenario returnsannual target with daily rows and no conversion
Why these probabilities?explicit equal, historical-frequency, or stress policysilent normalization after missing rows
Are deviations symmetric?yes, by designthe actual mandate cares only about downside
Is the solver scale appropriate?small teaching panel for this reference coreproduction universe sent to vertex enumeration
Is a constant row realistic?only as a labeled synthetic teaching deviceinterpreted as a real risk-free forecast

Evidence and historical case boundary

The algorithmic attribution and LP structure come from the original Konno–Yamazaki work. The fixture, oracle arithmetic, visual, and lab are author-derived and tested in both languages. No named historical allocation is included. Publishing one responsibly would require licensed point-in-time scenario rows, exact identities, adjustment and dividend conventions, currency conversion evidence, observation and availability timestamps, and a frozen probability policy.

That boundary is not a weakness. It prevents a familiar ticker from lending false authority to unverified inputs.

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.mean_mad import mean_mad

result = mean_mad(
    ['A', 'B'],
    [[0.2, 0.03], [0.1, 0.03], [0.05, 0.03], [-0.03, 0.03]],
    [0.25, 0.25, 0.25, 0.25],
    0.055,
)
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. Move the return floor to 2%, 3%, 8%, and 9%.
  2. Add 1% to every scenario return and to the target.
  3. Multiply returns and target by a positive common factor.

Answers. The first two floors select STABLE. The 2% floor is slack; 3% is met exactly but does not force positive DISPERSED weight. The 8% floor selects DISPERSED; 9% is infeasible. A common shift cancels when centering and leaves weights and MAD unchanged. A common scale preserves weights and scales MAD. These are useful invariants for testing an implementation.

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-MAD optimization gives an unusually inspectable path from scenarios to a portfolio: center the rows, measure absolute deviations, impose a return floor, solve an LP, and recompute the certificate. Its value comes from clarity, not magic. Keep the target in the same units as the data, label the symmetric risk choice, make probability mass explicit, and stop the workflow when identity, splits, dividends, prices, quantities, currency, or timestamps do not reconcile.

References

See REFERENCES.md for primary sources and evidence classification.

Production handoff checklist

  • Freeze scenario rows, probabilities, target, horizon, base currency, identity, and knowledge cutoff.
  • Store weighted asset means, centered portfolio deviations, MAD, target status, and all residuals.
  • Re-run binding, slack, infeasible, malformed-probability, and incomplete-budget fixtures.
  • Confirm that symmetric deviation—not downside-only deviation—is appropriate for the mandate.
  • Replace the teaching enumerator with an audited LP solver for scale while preserving the same certificate fields.

Continue through Family 01

mean mad flow

ReferencesPrimary sources and evidence notes

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

Primary source

  • Hiroshi Konno and Hiroaki Yamazaki, “Mean-Absolute Deviation Portfolio Optimization Model and Its Applications to Tokyo Stock Market,” Management Science 37(5), 1991, pp. 519–531. INFORMS DOI record. Supports the attribution, linear-program formulation, and mean/MAD portfolio framing. Accessed 2026-09-16.
  • Stanford-hosted paper copy. Used to inspect the original mathematical context. It does not supply this package’s synthetic fixture or make a claim about current markets. Accessed 2026-09-16.

Implementation corroboration

Evidence classification

  • Sourced theory: MAD optimization can be formulated as a linear program using auxiliary absolute-deviation variables.
  • Author-derived calculations: the two-asset, four-scenario means, deviations, target boundary, weights, and MAD.
  • Implementation choice: long-only simplex, explicit probabilities, strict validation, vertex enumeration cap, diagnostic schema, and deterministic tie representative.
  • Deferred historical claim: no named security or period is presented because a licensed point-in-time panel with adjustment, dividend, FX, identity, and availability lineage has not been frozen.

Source roles

  • Accessed: the INFORMS DOI record and Stanford-hosted paper on 2026-09-16; the official MOSEK page was inspected for neighboring model context.
  • Supports: Konno and Yamazaki support the mean-absolute-deviation attribution and LP structure. The package fixture and software outputs are independent author-derived calculations.
  • Limitations: none of these sources verifies a current market panel, provider row, portfolio performance claim, or the package’s synthetic asset values.

Independent recheck — 2026-09-16

Konno–Yamazaki publisher record: verified full title, authors, 1991 date and LP attribution. Stanford direct fetch timed out; indexed paper text and publisher abstract were readable.

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.

mean-mad.ts
export interface MeanMadResult {
    status: string;
    variant: string;
    method: string;
    assetIds: string[] | null;
    weights: number[] | null;
    meanReturns: number[] | null;
    expectedReturn: number | null;
    mad: number | null;
    targetReturn: number | null;
    budgetResidual: number | null;
    lowerBoundResidual: number | null;
    targetResidual: number | null;
    probabilityResidual: number | null;
    iterations: number;
    solutionClass: string;
    diagnostics: Record<string, unknown>;
    warnings: string[];
}
const TOL = 1e-10;
const MAX_ITERATIONS = 200000;
const dot = (a: number[], b: number[]): number => a.reduce((sum, value, index) => sum + value * b[index], 0);
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
function solve(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 = 0; 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)
                for (let j = 0; 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]);
            return;
        }
        for (let index = start; index <= size - (choose - current.length); index += 1) {
            current.push(index);
            visit(index + 1);
            current.pop();
        }
    };
    visit(0);
    return output;
}
function choose(n: number, k: number): number {
    let value = 1;
    for (let i = 1; i <= k; i += 1)
        value = value * (n - k + i) / i;
    return Math.round(value);
}
function failure(status: string, message: string, assetIds: string[] | null = null, details: Record<string, unknown> = {}): MeanMadResult {
    return { status, variant: "mean-mad-return-floor-long-only", method: "lp-vertex-enumeration", assetIds, weights: null, meanReturns: null, expectedReturn: null, mad: null, targetReturn: (details.targetReturn as number) ?? null, budgetResidual: null, lowerBoundResidual: null, targetResidual: null, probabilityResidual: (details.probabilityResidual as number) ?? null, iterations: (details.iterations as number) ?? 0, solutionClass: "unknown", diagnostics: { message, ...details }, warnings: [] };
}
function meanMadNormalized(assetIdsInput: unknown, returnsInput: unknown, probabilitiesInput: unknown, targetReturnInput: unknown, options: {
    maxIterations?: unknown;
} = {}): MeanMadResult {
    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() === "") || new Set(assetIds).size !== assetIds.length)
        return failure("invalid_input", "assetIds must contain unique non-empty strings.");
    const size = assetIds.length;
    if (!Array.isArray(returnsInput) || !returnsInput.length || returnsInput.some((row) => !Array.isArray(row) || row.length !== size))
        return failure("invalid_input", "returns must be a non-empty T by N matrix aligned to assetIds.", assetIds);
    if ((returnsInput as unknown[][]).some((row) => row.some((value) => !finite(value))))
        return failure("invalid_input", "returns must contain finite simple returns.", assetIds);
    const returns = (returnsInput as number[][]).map((row) => [...row]);
    const observations = returns.length;
    if (!Array.isArray(probabilitiesInput) || probabilitiesInput.length !== observations || probabilitiesInput.some((value) => !finite(value) || value < 0))
        return failure("invalid_input", "probabilities must be finite, non-negative, and align to rows.", assetIds);
    const probabilities = probabilitiesInput as number[];
    const probabilityResidual = Math.abs(probabilities.reduce((sum, value) => sum + value, 0) - 1);
    if (probabilityResidual > 1e-12)
        return failure("invalid_input", "probabilities must sum to one; no normalization is applied.", assetIds, { probabilityResidual });
    if (!finite(targetReturnInput))
        return failure("invalid_input", "targetReturn must be finite.", assetIds);
    const targetReturn = targetReturnInput;
    const maxIterations = options.maxIterations ?? MAX_ITERATIONS;
    if (!Number.isInteger(maxIterations) || (maxIterations as number) < 0 || (maxIterations as number) > MAX_ITERATIONS)
        return failure("invalid_input", "maxIterations must be an integer from 0 through 200,000.", assetIds, { targetReturn });
    const means = Array.from({ length: size }, (_v, j) => returns.reduce((sum, row, t) => sum + probabilities[t] * row[j], 0));
    if (targetReturn > Math.max(...means) + TOL)
        return failure("infeasible", "targetReturn exceeds every long-only asset mean.", assetIds, { targetReturn, maxFeasibleReturn: Math.max(...means), probabilityResidual });
    const variables = size + observations;
    if (variables > 18)
        return failure("numerical_issue", "the reference LP is bounded at 18 variables.", assetIds, { targetReturn, variables });
    const equality = [...Array(size).fill(1), ...Array(observations).fill(0)];
    const inequalities: Array<{
        a: number[];
        b: number;
    }> = [{ a: [...means.map((value) => -value), ...Array(observations).fill(0)], b: -targetReturn }];
    for (let j = 0; j < size; j += 1) {
        const row = Array(variables).fill(0);
        row[j] = -1;
        inequalities.push({ a: row, b: 0 });
    }
    for (let t = 0; t < observations; t += 1) {
        const y = size + t;
        const nonnegative = Array(variables).fill(0);
        nonnegative[y] = -1;
        inequalities.push({ a: nonnegative, b: 0 });
        const centered = means.map((mean, j) => returns[t][j] - mean);
        const upper = [...centered, ...Array(observations).fill(0)];
        upper[y] = -1;
        inequalities.push({ a: upper, b: 0 });
        const lower = [...centered.map((value) => -value), ...Array(observations).fill(0)];
        lower[y] = -1;
        inequalities.push({ a: lower, b: 0 });
    }
    const activeCount = variables - 1;
    const required = activeCount <= inequalities.length ? choose(inequalities.length, activeCount) : 0;
    if (required > MAX_ITERATIONS)
        return failure("numerical_issue", "LP vertex count exceeds the reference-core limit.", assetIds, { targetReturn, verticesRequired: required });
    if ((maxIterations as number) < required)
        return failure("numerical_issue", "maxIterations cannot inspect every candidate vertex.", assetIds, { targetReturn, verticesRequired: required, iterations: 0 });
    let bestValue = Infinity;
    let best: number[] | null = null;
    const optimalWeights: number[][] = [];
    let checked = 0;
    for (const active of combinations(inequalities.length, activeCount)) {
        checked += 1;
        const candidate = solve([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((constraint) => dot(constraint.a, candidate) > constraint.b + TOL))
            continue;
        const value = probabilities.reduce((sum, probability, t) => sum + probability * candidate[size + t], 0);
        const weights = candidate.slice(0, size);
        if (value < bestValue - TOL * Math.max(1, Math.abs(value), Number.isFinite(bestValue) ? Math.abs(bestValue) : 1)) {
            bestValue = value;
            best = candidate;
            optimalWeights.length = 0;
            optimalWeights.push(weights);
        }
        else if (best !== null && Math.abs(value - bestValue) <= TOL * Math.max(1, Math.abs(value), Math.abs(bestValue))) {
            if (!optimalWeights.some((prior) => Math.max(...weights.map((weight, j) => Math.abs(weight - prior[j]))) <= 1e-8))
                optimalWeights.push(weights);
            if (lexicographicLess(weights, best.slice(0, size)))
                best = candidate;
        }
    }
    if (best === null)
        return failure("numerical_issue", "no feasible LP vertex was found.", assetIds, { targetReturn, iterations: checked });
    const weights = best.slice(0, size);
    const expectedReturn = dot(means, weights);
    const deviations = returns.map((row) => dot(row.map((value, j) => value - means[j]), weights));
    const mad = probabilities.reduce((sum, probability, t) => sum + probability * Math.abs(deviations[t]), 0);
    const objectiveGap = Math.abs(mad - bestValue);
    if (![mad, expectedReturn, objectiveGap].every(Number.isFinite) || Math.max(Math.abs(weights.reduce((a, b) => a + b, 0) - 1), -Math.min(...weights), targetReturn - expectedReturn) > TOL || objectiveGap > 1e-8)
        return failure("numerical_issue", "LP result failed the independent MAD audit.", assetIds, { targetReturn, iterations: checked, objectiveGap });
    return { status: "optimal", variant: "mean-mad-return-floor-long-only", method: "lp-vertex-enumeration", assetIds, weights, meanReturns: means, expectedReturn, mad, targetReturn, budgetResidual: Math.abs(weights.reduce((sum, value) => sum + value, 0) - 1), lowerBoundResidual: Math.max(0, -Math.min(...weights)), targetResidual: Math.max(0, targetReturn - expectedReturn), probabilityResidual, iterations: checked, solutionClass: optimalWeights.length > 1 ? "non_unique" : "unique", diagnostics: { deviations, lpObjective: bestValue, objectiveGap, verticesRequired: required, certificate: "lp-vertex-enumeration" }, warnings: ["Reference vertex enumeration is intended for small teaching panels, not large production universes."] };
}
export const meanAbsoluteDeviationOptimization = meanMad;
function lexicographicLess(a: number[], b: number[]): boolean { for (let i = 0; i < a.length; i++) {
    if (a[i] < b[i])
        return true;
    if (a[i] > b[i])
        return false;
} return false; }
export function meanMad(assetIdsInput: unknown, returnsInput: unknown, probabilitiesInput: unknown, targetReturnInput: unknown, options: {
    maxIterations?: unknown;
} = {}): MeanMadResult {
    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 = meanMadNormalized(assetIdsInput, rows, probabilitiesInput, typeof targetReturnInput === "number" ? targetReturnInput / scale : targetReturnInput, 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 ["meanReturns", "expectedReturn", "mad", "targetReturn", "targetResidual"])
        if (record[key] !== null && record[key] !== undefined)
            record[key] = restore(record[key]);
    for (const key of ["deviations", "lpObjective", "objectiveGap", "maxFeasibleReturn", "targetReturn"])
        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.', assetIdsInput as string[]);
    return result;
}
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.