D14-F04-A01 / Released engineering topic

Turnover-Constrained Optimization: A Hard Rebalance Budget From Current Holdings

A production-minded guide to Turnover-Constrained Optimization.

Turnover-Constrained Optimization: A Hard Rebalance Budget From Current HoldingsD14 / D14-F04

Suppose a synthetic $100,000 portfolio holds $60,000 in A and $40,000 in B. Your model prefers an 80/20 mix, but this rebalance may move only $10,000 from one asset to the other. What is the best allocation you can reach within that limit?

That is the job of a turnover constraint. It starts from the portfolio you actually hold and gives the optimizer a movement budget. My experience with split-invalid prices, suspect quantity imports, and mixed-currency holdings has made the starting point the first thing I inspect: a correct optimization can still prescribe the wrong trade when its current weights are wrong.

You should be comfortable with portfolio weights and the mean-variance objective: expected return minus a chosen penalty for variance. We will calculate turnover by hand, solve the constrained example, and read the solver's evidence for its result. No market performance is inferred from these teaching inputs.

The question the constraint answers

The target vector alone does not determine turnover. I always write down the reference portfolio first: the same target can require no trading from one starting state and a large trade from another. Valuation basis and normalization matter as much as the destination. Let w⁰ denote the current normalized holdings and w the proposed post-rebalance holdings. With equal portfolio totals, the one-way buy amount equals the one-way sell amount, so the selected convention is

Plain text
TO(w,w⁰) = ½ ||w − w⁰||₁ = ½ Σᵢ |wᵢ − w⁰ᵢ|.

In the opening example, moving from 60/40 to 70/30 buys $10,000 of A and sells $10,000 of B. The absolute changes add to $20,000, or 20% of the portfolio. Halving that sum reports the 10% moved from one side to the other. With equal totals, buys and sells match, which is why the factor works. Deposits, withdrawals, fees, and changing denominators need their own accounting before this interpretation is used.

The ½ is not a universal fact about every vendor API. MathWorks’ official Portfolio.setTurnover method documentation describes average turnover as half the L1 distance between current and target weights. This tutorial uses that convention and states it in every fixture. The two-way L1 distance, a count of nonzero trades, and the number of final holdings are different quantities.

A two-asset simplex with the current portfolio, the hard turnover interval, and the unconstrained target

Figure 1 — Synthetic two-asset intuition. The line is only a visualization; the reference implementation solves general N. Open the full-size SVG.

Scope and notation

The canonical method is one rebalance at one valuation time, long-only normalized holdings, fixed expected simple returns and covariance, and a hard per-rebalance cap. It solves a concave quadratic program when the supplied covariance is positive semidefinite.

SymbolMeaningUnits or contract
Nnumber of assetspositive integer; input order is preserved
w⁰current holdingsnonnegative normalized weights; Σw⁰ᵢ=1 before optimization
wpost-trade holdingsnonnegative normalized weights; Σwᵢ=1
μexpected simple returnsdecimal return over the declared common period
Σcovariance matrixsquared-return units over that same period; symmetric PSD
λrisk coefficientnonnegative objective-unit coefficient; not a probability or universal investor preference
τone-way turnover capnormalized portfolio units per rebalance
f(w)objective valueμᵀw − λwᵀΣw, in the supplied objective units

This is not a transaction-cost model. A cost-aware method needs a dollar budget, cost schedule, cash account, and horizon. It is also not a trade-count or final-holdings-cardinality model. The official MOSEK transaction chapter is useful context for separating post-trade holdings from the trade vector, but its cardinality example constrains nonzero trades rather than final holdings; that distinction must not be silently transferred to a holdings-cardinality model.

Excluded here are short positions, leverage, borrowing, margin, rounding, post-solve normalization, transaction costs, taxes, multi-period scheduling, and an automatic repair of prices, quantities, corporate actions, dividends, or FX. Those states belong in an upstream data contract or a different topic.

The optimization

The reference problem is

Plain text
maximize    f(w) = μᵀw − λ wᵀΣw
subject to  wᵢ ≥ 0
            Σᵢ wᵢ = 1
            ½ ||w − w⁰||₁ ≤ τ.

The simplex is just the set of nonnegative weights that sum to one. The turnover rule keeps only the points within the allowed absolute distance of w⁰. Their intersection is the feasible set: every portfolio the solver may return. A positive-semidefinite covariance matrix assigns nonnegative variance to every weight vector; with λ≥0, it makes the objective concave, so a certified local maximum is also global.

A hand-checkable boundary optimum

Use this labelled synthetic fixture:

Plain text
w⁰ = [0.60, 0.40]
μ  = [0.10, 0.04]
Σ  = [[0.04, 0.00], [0.00, 0.01]]
λ  = 1
τ  = 0.10

Write w=[x,1−x]. Substitution removes the second weight:

Plain text
f(x)  = .10x + .04(1−x) − .04x² − .01(1−x)²
      = .03 + .08x − .05x²
f′(x) = .08 − .10x.

The derivative is zero at x=.80; the negative quadratic coefficient tells us this is the unconstrained maximum. In two assets, the turnover cap simplifies to |x−.60|≤.10, so the allowed interval is [.50,.70]. The objective is still increasing at .70, but the budget prevents a larger move. The constrained optimum is therefore w*=[.70,.30]:

Plain text
TO = ½(|0.70−0.60| + |0.30−0.40|) = 0.10
f(w*) = 0.10(0.70) + 0.04(0.30)
        − [0.04(0.70²) + 0.01(0.30²)]
      = 0.0615.

The answer spends the whole movement budget in this case. It need not do so when the preferred allocation is already reachable:

Cap τBest weightsTurnover actually usedObjective
0[.60,.40]0.060000
.05[.65,.35].05.060875
.10[.70,.30].10.061500
.20[.80,.20].20.062000
.50[.80,.20].20.062000

A cap is permission to move up to an amount. Raising it from .20 to .50 changes no decision here. The extra objective value is a model score, not a realized return.

How the general-N solver earns its status

The Python and TypeScript reference implementations use deterministic projected-gradient ascent. At an iterate w, the gradient is

Plain text
g(w) = μ − 2λΣw.

Each gradient step is projected onto the intersection of the simplex and the L1 ball centered at the original w⁰. This is not “clip negative values and renormalize”; the projection preserves the declared feasible set. The solver then recomputes direct turnover from the signed returned vector.

It reports two diagnostics:

  1. a projected-step residual related to the Karush–Kuhn–Tucker (KKT) optimality conditions—how much a further constrained step would move the candidate; and
  2. an independent Frank–Wolfe linearization gap, obtained by transferring up to τ from low-gradient current holdings to high-gradient receivers.

The Frank–Wolfe gap asks how much improvement even the best feasible move could promise under the objective's linear approximation. For this concave objective and convex feasible set, it is an upper bound on the remaining objective gap. The API reports the gap in original units and normalized by an objective scale. optimal requires both the normalized residual and the independent gap to pass tolerance. A max-iterations result is only a feasible incumbent.

Figure 2 — The solver contract. The implementation keeps validation, projection, direct measurement, and certification as separate stages. The source is visuals/mermaid/turnover-constrained-flow.mmd.

Direct turnover versus auxiliary variables

A linear formulation may write u=w−w⁰ as u=u⁺−u⁻, with nonnegative auxiliaries and Σ(u⁺+u⁻)≤2τ. That representation is useful, but it does not force the auxiliaries to be minimal when the constraint is slack and the objective only sees w.

For the synthetic values below, the signed result is w⁰=[.60,.40], w=[.65,.35], so direct turnover is .05. A permitted nonminimal split u⁺=[.07,.02], u⁻=[.02,.07] gives auxiliary half-sum .09. Both fit a .10 cap. The API therefore reports direct ½||w−w⁰||₁; an auxiliary sum can be substituted only after a tightness or complementarity proof.

Direct signed turnover .05 compared with a nonminimal auxiliary half-sum .09

Figure 3 — The auxiliary diagnostic is a scope warning, not a second algorithm. Open the full-size SVG.

Repair the valuation state before calculating a trade

Once quantities, marks, corporate actions, and FX have been compressed into w⁰, the solver cannot recover their meaning. Here is a second synthetic example showing why I check them first.

Asset A is worth 10×$120=$1,200 before a four-for-one split. Afterwards, 40×$30 preserves that value. Asset B is worth $800. If an import pairs the new price with the old quantity, A appears to be worth only $300.

Input stateApparent A/B valuesCurrent weightsSolver result at τ=.10
Reconciled before split$1,200 / $800[.600,.400][.600,.400]
Stale quantity after split$300 / $800[3/11,8/11][41/110,69/110]
Repaired quantity after split$1,200 / $800[.600,.400][.600,.400]

The objective inputs are held fixed at μ=[.08,.04], Σ=diag(.04,.01), and λ=1; only the current snapshot differs. In the invalid branch, returning all the way to the same objective optimum would require ½(|.600−3/11|+|.400−8/11|)=18/55≈.327273, which exceeds the cap. This is a diagnostic-only invalid branch, not a recommendation or a claim about a historical account. A real mark change may be genuine drift—repair does not erase it; it distinguishes it from a bad unit or quantity before a turnover budget is spent.

The earlier Backward Split Adjustment work is useful context for keeping split-adjusted quantities and marks consistent. The Cash-Dividend Total-Return Adjustment work likewise separates price return from an ex-date reinvestment methodology. An actual cash portfolio needs separate entitlement, receivable, receipt, withholding, and holdings-mark states; never add cash again to a series that already includes a total-return adjustment, and do not assume an ex-date price change equals the cash dividend exactly. For FX, retain quote direction, cutoff time, and source before computing base-currency values.

A synthetic unreconciled import creates false turnover; reconciliation returns the current weights

Figure 4 — Provenance changes the decision before optimization. Open the full-size SVG.

Run the reference implementation

The implementation paths below are relative to this package. Python returns a structured dictionary; TypeScript exposes the same fields and error codes.

Python
from implementations.python.turnover_constrained import (
    solve_turnover_constrained_markowitz,
)

result = solve_turnover_constrained_markowitz(
    mu=[0.10, 0.04],
    covariance=[[0.04, 0.0], [0.0, 0.01]],
    lambda_risk=1.0,
    w0=[0.60, 0.40],
    tau=0.10,
)
print(result["status"], result["weights"], result["objective"])
# optimal [0.70..., 0.30...] 0.0615...
TypeScript
import { solveTurnoverConstrainedMarkowitz } from
  "./implementations/typescript/turnover-constrained.js";

const result = solveTurnoverConstrainedMarkowitz(
  [0.10, 0.04], [[0.04, 0.0], [0.0, 0.01]], 1.0, [0.60, 0.40], 0.10,
);
console.log(result.status, result.weights, result.objective);
// optimal [0.70..., 0.30...] 0.0615...

From the topic package directory, these commands run the Python tests and then the TypeScript test, which invokes the Python CLI for a direct comparison:

POWERSHELL
python -X utf8 -B -m unittest discover -s tests/python -p 'test_*.py' -v
$env:A01_PYTHON_CLI_PATH = (Resolve-Path implementations/python/turnover_constrained_cli.py).Path
npx --yes tsx tests/typescript/turnover-constrained.test.ts

The comparison checks weights, objective, direct turnover, status, and certificate values. Type-checking alone would not execute those comparisons.

Predict, then change the cap

Open the turnover playground with its canonical two-asset example. Before changing a control, predict the result:

  1. Set the cap to zero. Check: weights remain [.60,.40]; no movement is allowed.
  2. Set it to .10. Check: A reaches .70, the objective is .0615, and direct turnover is .10.
  3. Allow .20 or more. Check: [.80,.20] is reachable; unused turnover capacity is normal.

Then try the invalid-current-sum scenario. A rejection is the useful result: normalizing an unexplained input would change the starting portfolio. Step and Back let you inspect the calculation; Reset restores the canonical inputs. Reduced-motion mode keeps the same steps without timed playback.

Practical limits and nearby methods

If my question is…I use…I check separately…
“How much of today's portfolio may move?”Hard half-L1 turnover capCurrent-weight provenance and whether the cap is feasible
“What will that move cost in dollars?”Transaction-cost-aware budgetCash, rate horizon, and actual fee schedule
“How many final names can remain?”Final-holdings cardinalityWhether K counts holdings or trades

Those methods can be combined in a larger system, but I do not treat one as a shortcut for the others. When a split or FX input is inconsistent, the right next step is to repair that input, not to tighten τ until the output looks cautious.

  • A hard turnover cap limits normalized allocation distance. It does not guarantee liquidity, fills, market-impact control, tax efficiency, or realized return.
  • A turnover penalty changes the objective rather than enforcing a cap; the two methods are not equivalent for every input. budget, and a cash horizon. It should not be folded into this normalized geometry. trade support. It is not a renamed turnover constraint.
  • Robust Mean-Variance and Distributionally Robust Portfolio Optimization model parameter or distribution uncertainty. They do not repair split, FX, or stale-mark errors.
  • Tax-Aware Portfolio Optimization needs lot basis, policy, wash-sale and settlement state. No tax calculation is implied here.

validated current values and trade vector, then add an explicit cost schedule and cash reconciliation. The cardinality handoff should define which support is bounded. The tax-aware handoff should keep acquisition basis and current valuation separate. These are different contracts, not optional flags on this solver.

What I would keep beside the allocation

Keep the current weights, the turnover convention, the amount actually used, and the solver status together. Those four items tell you what changed and how much confidence the mathematical result deserves. The next step is Transaction-Cost-Aware Optimization, where the movement must also pay for itself in a dollar ledger.

All numerical cases here are synthetic. A named account case remains deferred until dated holdings, adjustment and FX evidence, and publication rights are available. The source record and canonical definition retain those research and implementation details.

ReferencesPrimary sources and evidence notes

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

Author: Islam Baraka
Access date: 2026-09-14 (Africa/Cairo)
Scope: contract planning and isolated implementation; no historical market claim

MOSEK Portfolio Optimization Cookbook — transaction chapter

  • Organization: MOSEK ApS
  • Source type: official solver/portfolio technical documentation
  • URL: https://docs.mosek.com/portfolio-cookbook/transaction.html
  • Accessed: 2026-09-14; sections 6.1–6.5 reviewed
  • Supports: current holdings x0, trade vector x−x0, positive/negative-part modeling, variable transaction costs, and self-financing budget conventions.
  • Boundary: §6.4’s cardinality example constrains nonzero trades, not final-holdings support. It is not direct evidence for A03’s holdings-cardinality contract. §6.3’s slack/epigraph warning motivates this package’s direct absolute-turnover and certificate checks.

MOSEK Portfolio Optimization Cookbook — multi-period chapter

  • Organization: MOSEK ApS
  • Source type: official solver/portfolio technical documentation
  • URL: https://docs.mosek.com/portfolio-cookbook/multiperiod.html
  • Accessed: 2026-09-14
  • Supports: the distinction between post-trade holdings and trades and the need to state timing.
  • Boundary: this package freezes one rebalance only; multiperiod scheduling is excluded.

Boyd et al. — Multi-period trading with transaction costs

  • Authors: Stephen Boyd and collaborators
  • Source type: university technical paper/reference implementation context
  • URL: https://stanford.edu/~boyd/papers/cvx_portfolio.html
  • Accessed: 2026-09-14
  • Supports: convex portfolio objectives and practical trading/holding constraints.
  • Boundary: it does not define this package’s one-way half-L1 normalization or replace the explicit fixture/oracle.

MathWorks — Portfolio Analysis with Turnover Constraints

  • Organization: MathWorks
  • Source type: official product documentation/example; reached through the observed Google Web result
  • URL: https://www.mathworks.com/help/finance/portfolio-analysis-with-turnover-constraints.html
  • Accessed: 2026-09-14; page opened/read
  • Supports: turnover as a portfolio-model constraint tied to current holdings and practical transaction-cost control.
  • Boundary: software defaults and local/nonlinear solver behavior are not the canonical mathematical contract. This topic fixes the objective, normalization, and certificate independently.

MathWorks — Portfolio.setTurnover method

  • Organization: MathWorks
  • Source type: official method documentation; inspected directly
  • URL: https://www.mathworks.com/help/finance/portfolio.setturnover.html
  • Accessed: 2026-09-14; lines 32–34 explicitly define average turnover as half the L1 distance between current and target weights.
  • Supports: the half-L1 convention used here; with unchanged normalized portfolio total it equals one-way buys/sells.
  • Boundary: corroborates the convention only. This topic's contract still owns the objective, validation, scale-aware solver, and optimality certificate. The overview’s comments about local/nonlinear optimization are not inherited by this convex QP.

Portfolio Optimization Book — portfolio constraints chapter

  • Authors/organization: Portfolio Optimization Book project
  • Source type: educational/reference chapter; observed Google result and opened/read
  • URL: https://portfoliooptimizationbook.com/book/6.2-portfolio-constraints.html
  • Accessed: 2026-09-14
  • Supports: L1 turnover constraint context and distinction from cardinality, neutrality, and leverage.
  • Boundary: it does not prove a universal ½ convention; this package states the one-way convention explicitly.

Google discovery ledger

  • Exact query: turnover constrained portfolio optimization
  • Original capture: google.com, hl=en-EG, gl=eg, pws=0, page one plus start=10, 2026-09-14 03:41–03:42 +03:00; partial access/rank gate.
  • Alternate capture: Google Web udm=14, same query/locale, ten observed organic web results in one page, 2026-09-14 12:09–12:10 +03:00.
  • Full result order, page review extent, and blockers: ../google/D14-F04-A01-google-ledger.md.
  • Limitation: Google rankings are a dated locale-specific discovery surface, not evidence of mathematical correctness or a stable global ranking.

Evidence classification

  • Sourced facts: source documentation claims listed above, each bounded by its page/version/access date.
  • Author-derived math: the two-asset optimum, three-asset solver output, scale/PSD diagnostics, and nonminimal split counterexample.
  • Synthetic teaching inputs: all JSON fixture values.
  • Deferred: no verified historical price/quantity/split/FX/dividend case is used. The user’s unverified 1010.SR anecdote is not named as a case.

Limitations

Limitations: these references support the selected one-rebalance, long-only, normalized half-L1 convention; they do not establish a universal turnover convention, execution outcome, liquidity limit, tax result, or performance claim.

  • These references support the selected one-rebalance, long-only, normalized half-L1 convention; they do not establish a universal turnover convention, execution outcome, liquidity limit, tax result, or performance claim.
  • Google result order is a dated discovery observation, not evidence of source quality. The alternate ten-result capture and its access limits are recorded in the linked ledger.
  • The implementation and fixtures use synthetic data. A historical case remains deferred until security identity, date, units, valuation basis, point-in-time evidence, and redistribution rights are reproducible.
turnover-constrained.ts
/**
 * General-N turnover-constrained long-only Markowitz reference solver.
 *
 * This isolated staging implementation solves
 *   maximize mu·w - lambdaRisk * wᵀ Sigma w
 *   subject to w >= 0, sum(w) = 1, 0.5*||w-w0||1 <= tau.
 *
 * The projection is onto the simplex intersected with the L1 ball; it does
 * not clip weights and silently renormalize them.  The returned certificate
 * is a projected-KKT residual.  A max-iteration result is never optimal.
 */

export const COMPARISON_ABSOLUTE_TOLERANCE = 1e-9;
export const COMPARISON_RELATIVE_TOLERANCE = 1e-8;
export const FEASIBILITY_TOLERANCE = 1e-10;
export const CURRENT_SUM_TOLERANCE = 1e-12;
export const PSD_RELATIVE_TOLERANCE = 1e-10;
export const STATIONARITY_TOLERANCE = 1e-8;
export const PROJECTION_BISECTION_ITERATIONS = 60;
export const DEFAULT_MAX_ITERATIONS = 5000;

export class TurnoverValidationError extends Error {
  readonly code: string;

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

export class TurnoverNumericalError extends Error {
  readonly code: string;

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

export interface TurnoverCertificate {
  kind: "projected-gradient-kkt-plus-frank-wolfe-gap";
  stationarityResidual: number;
  frankWolfeGap: number;
  objectiveGapUpperBound: number;
  normalizedStationarityResidual: number;
  normalizedFrankWolfeGap: number;
  normalizedObjectiveGapUpperBound: number;
  objectiveScale: number;
  stationarityTolerance: number;
  toleranceUnits: string;
  lipschitzUpperBound: number;
  stepDenominator: number;
  stepSize: number;
  turnoverRadius: number;
  globalOptimality: string;
}

export interface TurnoverResult {
  status: "optimal" | "max-iterations";
  weights: number[];
  objective: number;
  directTurnover: number;
  inputCurrentSum: number;
  simplexResidual: number;
  minWeight: number;
  iterations: number;
  frankWolfeGap: number;
  certificate: TurnoverCertificate;
}

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

function asReal(value: unknown, name: string): number {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    invalid("invalid-input", `${name} must be a finite real number.`);
  }
  return value;
}

function vector(values: unknown, name: string): number[] {
  if (!Array.isArray(values) || values.length === 0) {
    invalid("invalid-input", `${name} must be a non-empty numeric array.`);
  }
  return values.map((value, index) => asReal(value, `${name}[${index}]`));
}

function matrix(values: unknown, name: string, size: number): number[][] {
  if (!Array.isArray(values) || values.length !== size) {
    invalid("invalid-input", `${name} must have ${size} rows.`);
  }
  return values.map((row, rowIndex) => {
    if (!Array.isArray(row) || row.length !== size) {
      invalid("invalid-input", `${name} must be ${size} by ${size}.`);
    }
    return row.map((value, columnIndex) => asReal(value, `${name}[${rowIndex}][${columnIndex}]`));
  });
}

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

function dot(left: readonly number[], right: readonly number[]): number {
  return stableSum(left.map((value, index) => value * right[index]));
}

function matVec(matrixValue: readonly (readonly number[])[], vectorValue: readonly number[]): number[] {
  return matrixValue.map((row) => dot(row, vectorValue));
}

function quadratic(matrixValue: readonly (readonly number[])[], vectorValue: readonly number[]): number {
  return dot(vectorValue, matVec(matrixValue, vectorValue));
}

function l1Distance(left: readonly number[], right: readonly number[]): number {
  return stableSum(left.map((value, index) => Math.abs(value - right[index])));
}

function minimumSymmetricEigenvalue(matrixValue: readonly (readonly number[])[]): number {
  const size = matrixValue.length;
  const work = matrixValue.map((row) => [...row]);
  if (size === 1) return work[0][0];
  const scale = Math.max(1e-300, ...work.flat().map((value) => Math.abs(value)));
  const maxSweeps = Math.max(50, 20 * size * size);
  for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
    let p = 0;
    let q = 1;
    let largest = 0;
    for (let row = 0; row < size; row += 1) {
      for (let column = row + 1; column < size; column += 1) {
        const magnitude = Math.abs(work[row][column]);
        if (magnitude > largest) {
          largest = magnitude;
          p = row;
          q = column;
        }
      }
    }
    if (largest <= 1e-14 * scale) break;
    const app = work[p][p];
    const aqq = work[q][q];
    const apq = work[p][q];
    const angle = 0.5 * Math.atan2(2 * apq, aqq - app);
    const cosine = Math.cos(angle);
    const sine = Math.sin(angle);
    for (let index = 0; index < size; index += 1) {
      if (index === p || index === q) continue;
      const aip = work[index][p];
      const aiq = work[index][q];
      work[index][p] = cosine * aip - sine * aiq;
      work[p][index] = work[index][p];
      work[index][q] = sine * aip + cosine * aiq;
      work[q][index] = work[index][q];
    }
    work[p][p] = cosine * cosine * app - 2 * cosine * sine * apq + sine * sine * aqq;
    work[q][q] = sine * sine * app + 2 * cosine * sine * apq + cosine * cosine * aqq;
    work[p][q] = 0;
    work[q][p] = 0;
  }
  return Math.min(...work.map((row, index) => row[index]));
}

interface ValidatedInputs {
  mean: number[];
  sigma: number[][];
  riskAversion: number;
  current: number[];
  turnoverLimit: number;
}

function validateInputs(
  mu: unknown,
  covariance: unknown,
  lambdaRisk: unknown,
  w0: unknown,
  tau: unknown,
): ValidatedInputs {
  const mean = vector(mu, "mu");
  const size = mean.length;
  const sigma = matrix(covariance, "covariance", size);
  const riskAversion = asReal(lambdaRisk, "lambdaRisk");
  const current = vector(w0, "w0");
  const turnoverLimit = asReal(tau, "tau");
  if (current.length !== size) {
    invalid("invalid-input", "mu, covariance, and w0 must have the same dimension.");
  }
  if (riskAversion < 0) invalid("invalid-input", "lambdaRisk must be nonnegative.");
  if (turnoverLimit < 0) invalid("invalid-input", "tau must be nonnegative.");
  for (let row = 0; row < size; row += 1) {
    for (let column = 0; column < row; column += 1) {
      if (Math.abs(sigma[row][column] - sigma[column][row]) > 1e-12) {
        invalid("indefinite-covariance", "covariance must be symmetric within tolerance.");
      }
    }
  }
  const matrixScale = Math.max(1e-300, ...sigma.flat().map((value) => Math.abs(value)));
  if (minimumSymmetricEigenvalue(sigma) < -PSD_RELATIVE_TOLERANCE * matrixScale) {
    invalid("indefinite-covariance", "covariance must be positive semidefinite.");
  }
  if (current.some((weight) => weight < -FEASIBILITY_TOLERANCE)) {
    invalid("invalid-input", "w0 must be nonnegative.");
  }
  const currentSum = stableSum(current);
  if (Math.abs(currentSum - 1) > CURRENT_SUM_TOLERANCE) {
    invalid("invalid-input", "w0 must sum to one within the declared tolerance.");
  }
  // Do not silently renormalize the current portfolio; retain the validated
  // input so any tiny residue remains visible to the caller.
  return {mean, sigma, riskAversion, current, turnoverLimit};
}

function simplexProjection(values: readonly number[]): number[] {
  const indexed = values.map((value, index) => ({value, index}));
  indexed.sort((left, right) => right.value - left.value);
  let cumulative = 0;
  let rho = -1;
  for (let index = 0; index < indexed.length; index += 1) {
    cumulative += indexed[index].value;
    const candidate = indexed[index].value - (cumulative - 1) / (index + 1);
    if (candidate > 0) rho = index;
  }
  if (rho < 0) rho = 0;
  const threshold = (stableSum(indexed.slice(0, rho + 1).map((item) => item.value)) - 1) / (rho + 1);
  return values.map((value) => Math.max(value - threshold, 0));
}

function l1SimplexProx(values: readonly number[], center: readonly number[], penalty: number): number[] {
  const scale = Math.max(1, penalty, ...values.map((value) => Math.abs(value)), ...center.map((value) => Math.abs(value)));
  const valuesAt = (nu: number): number[] => values.map((value, index) => {
    const anchor = center[index];
    const shifted = value - nu;
    if (shifted > anchor + penalty) return shifted - penalty;
    if (shifted >= anchor - penalty) return anchor;
    return Math.max(0, shifted + penalty);
  });
  let lower = Math.min(...values) - scale - penalty - 1;
  let upper = Math.max(...values) + scale + penalty + 1;
  for (let index = 0; index < 80 && stableSum(valuesAt(lower)) < 1; index += 1) lower -= scale;
  for (let index = 0; index < 80 && stableSum(valuesAt(upper)) > 1; index += 1) upper += scale;
  for (let index = 0; index < PROJECTION_BISECTION_ITERATIONS; index += 1) {
    const middle = (lower + upper) / 2;
    if (stableSum(valuesAt(middle)) > 1) lower = middle;
    else upper = middle;
  }
  return valuesAt((lower + upper) / 2);
}

function projectTurnoverSimplex(values: readonly number[], center: readonly number[], radius: number): number[] {
  const simplex = simplexProjection(values);
  if (l1Distance(simplex, center) <= radius + FEASIBILITY_TOLERANCE) return simplex;
  if (radius <= FEASIBILITY_TOLERANCE) return [...center];
  let lowerPenalty = 0;
  let upperPenalty = 1;
  let candidate = l1SimplexProx(values, center, upperPenalty);
  for (let index = 0; index < 80 && l1Distance(candidate, center) > radius; index += 1) {
    upperPenalty *= 2;
    candidate = l1SimplexProx(values, center, upperPenalty);
  }
  for (let index = 0; index < PROJECTION_BISECTION_ITERATIONS; index += 1) {
    const middle = (lowerPenalty + upperPenalty) / 2;
    candidate = l1SimplexProx(values, center, middle);
    if (l1Distance(candidate, center) > radius) lowerPenalty = middle;
    else upperPenalty = middle;
  }
  return l1SimplexProx(values, center, upperPenalty);
}

function objective(mean: readonly number[], sigma: readonly (readonly number[])[], riskAversion: number, weights: readonly number[]): number {
  return dot(mean, weights) - riskAversion * quadratic(sigma, weights);
}

function turnoverLinearOracle(
  gradient: readonly number[],
  current: readonly number[],
  turnoverLimit: number,
): number[] {
  /** Maximize a linear objective over the fixed simplex/L1 feasible set. */
  const result = [...current];
  if (turnoverLimit <= FEASIBILITY_TOLERANCE) return result;
  const donorRemaining = [...current];
  const receiverRemaining = current.map((value) => 1 - value);
  const donorOrder = current.map((_, index) => index)
    .sort((left, right) => gradient[left] - gradient[right] || left - right);
  const receiverOrder = current.map((_, index) => index)
    .sort((left, right) => gradient[right] - gradient[left] || left - right);
  let remainingTurnover = Math.min(turnoverLimit, 1);
  for (const receiver of receiverOrder) {
    if (remainingTurnover <= FEASIBILITY_TOLERANCE) break;
    if (receiverRemaining[receiver] <= FEASIBILITY_TOLERANCE) continue;
    for (const donor of donorOrder) {
      if (remainingTurnover <= FEASIBILITY_TOLERANCE) break;
      if (donor === receiver || donorRemaining[donor] <= FEASIBILITY_TOLERANCE) continue;
      const gain = gradient[receiver] - gradient[donor];
      if (gain <= 0) break;
      const amount = Math.min(remainingTurnover, donorRemaining[donor], receiverRemaining[receiver]);
      if (amount <= FEASIBILITY_TOLERANCE) continue;
      result[donor] -= amount;
      result[receiver] += amount;
      donorRemaining[donor] -= amount;
      receiverRemaining[receiver] -= amount;
      remainingTurnover -= amount;
    }
  }
  return result;
}

export interface TurnoverSolverOptions {
  maxIterations?: number;
  stationarityTolerance?: number;
}

export function solveTurnoverConstrainedMarkowitz(
  mu: unknown,
  covariance: unknown,
  lambdaRisk: unknown,
  w0: unknown,
  tau: unknown,
  options: TurnoverSolverOptions = {},
): TurnoverResult {
  const {mean, sigma, riskAversion, current, turnoverLimit} = validateInputs(
    mu, covariance, lambdaRisk, w0, tau,
  );
  const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
  const stationarityTolerance = options.stationarityTolerance ?? STATIONARITY_TOLERANCE;
  if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
    invalid("invalid-input", "maxIterations must be a positive integer.");
  }
  if (!Number.isFinite(stationarityTolerance) || stationarityTolerance <= 0) {
    invalid("invalid-input", "stationarityTolerance must be positive and finite.");
  }

  const size = mean.length;
  const hScale = 2 * riskAversion;
  const hLipschitz = Math.max(
    0,
    ...sigma.map((row) => stableSum(row.map((value) => Math.abs(hScale * value)))),
  );
  const objectiveScale = Math.max(
    1e-300,
    ...mean.map((value) => Math.abs(value)),
    riskAversion * Math.max(...sigma.flat().map((value) => Math.abs(value))),
  );
  // A scale-aware step keeps a tiny-but-nonzero linear objective from being
  // mistaken for a zero objective by an absolute unit step/tolerance.
  const stepDenominator = Math.max(hLipschitz, objectiveScale);
  const step = 1 / stepDenominator;
  const radius = 2 * turnoverLimit;
  let weights = [...current];
  let previousObjective = objective(mean, sigma, riskAversion, weights);
  let status: TurnoverResult["status"] = "max-iterations";
  let iterations = 0;
  let stationarity = Number.POSITIVE_INFINITY;
  let frankWolfeGap = Number.POSITIVE_INFINITY;

  for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
    const sigmaWeights = matVec(sigma, weights);
    const gradient = mean.map((value, index) => value - hScale * sigmaWeights[index]);
    const candidate = projectTurnoverSimplex(
      weights.map((weight, index) => weight + step * gradient[index]),
      current,
      radius,
    );
    stationarity = Math.max(...weights.map((weight, index) => Math.abs(weight - candidate[index]))) / step;
    // The linear oracle ranges over the fixed feasible set centered at the
    // original portfolio, not a fresh turnover ball around the iterate.
    const linearOracle = turnoverLinearOracle(gradient, current, turnoverLimit);
    frankWolfeGap = Math.max(
      0,
      dot(gradient, linearOracle.map((value, index) => value - weights[index])),
    );
    const candidateObjective = objective(mean, sigma, riskAversion, candidate);
    if (!Number.isFinite(candidateObjective) || candidate.some((value) => !Number.isFinite(value))) {
      throw new TurnoverNumericalError("numerical-failure", "solver produced a non-finite iterate.");
    }
    iterations = iteration;
    weights = candidate;
    const objectiveChange = Math.abs(candidateObjective - previousObjective);
    previousObjective = candidateObjective;
    const normalizedStationarity = stationarity / objectiveScale;
    const normalizedGap = frankWolfeGap / objectiveScale;
    const normalizedObjectiveChange = objectiveChange / objectiveScale;
    if (
      normalizedStationarity <= stationarityTolerance
      && normalizedGap <= stationarityTolerance
      && normalizedObjectiveChange <= stationarityTolerance * 0.1
    ) {
      status = "optimal";
      break;
    }
  }

  // Recompute the certificate at the returned iterate. The loop's gap is
  // evaluated before its update; a stale value must not describe the result.
  const finalSigmaWeights = matVec(sigma, weights);
  const finalGradient = mean.map((value, index) => value - hScale * finalSigmaWeights[index]);
  const finalCandidate = projectTurnoverSimplex(
    weights.map((weight, index) => weight + step * finalGradient[index]),
    current,
    radius,
  );
  stationarity = Math.max(...weights.map((weight, index) => Math.abs(weight - finalCandidate[index]))) / step;
  const finalOracle = turnoverLinearOracle(finalGradient, current, turnoverLimit);
  frankWolfeGap = Math.max(
    0,
    dot(finalGradient, finalOracle.map((value, index) => value - weights[index])),
  );

  const finalObjective = objective(mean, sigma, riskAversion, weights);
  const directTurnover = 0.5 * l1Distance(weights, current);
  const inputCurrentSum = stableSum(current);
  const simplexResidual = Math.abs(stableSum(weights) - 1);
  const minWeight = Math.min(...weights);
  if (![...weights, finalObjective, directTurnover].every(Number.isFinite)) {
    throw new TurnoverNumericalError("numerical-failure", "final solver diagnostics are non-finite.");
  }
  if (simplexResidual > FEASIBILITY_TOLERANCE || minWeight < -FEASIBILITY_TOLERANCE) {
    throw new TurnoverNumericalError("feasibility-residual", "final weights violate simplex feasibility.");
  }
  if (directTurnover > turnoverLimit + FEASIBILITY_TOLERANCE) {
    throw new TurnoverNumericalError("feasibility-residual", "final weights violate the direct turnover cap.");
  }

  return {
    status,
    weights,
    objective: finalObjective,
    directTurnover,
    inputCurrentSum,
    simplexResidual,
    minWeight,
    iterations,
    frankWolfeGap,
    certificate: {
      kind: "projected-gradient-kkt-plus-frank-wolfe-gap",
      stationarityResidual: stationarity,
      frankWolfeGap,
      objectiveGapUpperBound: frankWolfeGap,
      normalizedStationarityResidual: stationarity / objectiveScale,
      normalizedFrankWolfeGap: frankWolfeGap / objectiveScale,
      normalizedObjectiveGapUpperBound: frankWolfeGap / objectiveScale,
      objectiveScale,
      stationarityTolerance,
      toleranceUnits: "normalized by objectiveScale; original-unit gap is retained above",
      lipschitzUpperBound: hLipschitz,
      stepDenominator,
      stepSize: step,
      turnoverRadius: radius,
      globalOptimality: "For PSD covariance, the Frank-Wolfe linearization gap upper-bounds the concave-QP objective gap; status optimal requires both that gap and the projected-KKT residual within tolerance. max-iterations is not a certificate.",
    },
  };
}
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.