D14-F04-A04 / Released engineering topic

Long/Short Portfolios: Keep Net Exposure and Gross Exposure Separate

Computes long, short, net, and gross exposure from signed weights, checks a net interval and gross cap, and solves the mean-variance problem under both.

Long/Short Portfolios: Keep Net Exposure and Gross Exposure SeparateD14 / D14-F04

A portfolio owns $500 of one asset and shorts $500 of another. Its signed positions sum to zero. Has the portfolio disappeared? Of course not: two positions still respond to prices, and the short still needs funding and borrow arrangements.

The missing distinction is net versus gross exposure. Net subtracts the short side from the long side; gross adds their sizes. In my work expressing Egyptian, Japanese, and US equity holdings in one USD view, settling the units and valuation base has been essential before comparing weights. The same discipline matters here before the first minus sign enters the calculation. The long/short examples in this article are synthetic.

You need the idea of signed position values and a mean-variance objective. By the end, you should be able to audit a 130/30-style constraint, see why zero net does not mean zero capital, and identify what a real trading system still needs.

First fix the denominator

Choose a positive capital base B in one currency at one valuation time. For asset i, define x_i as its signed market value divided by B: long positions are positive, shorts negative. The capital base is supplied independently; it is not recomputed from the signed sum. The official MOSEK long-short modeling chapter likewise separates gross exposure from net exposure and explains the normalization choice.

Before forming x, I reconcile asset identities, units, current marks, corporate actions, and any foreign-exchange quotes. A short missing its minus sign, or a currency conversion applied twice, can make every later check numerically precise and economically wrong. For this lesson, the validated signed values are supplied as inputs.

With all amounts expressed as fractions of B, define:

MeasureFormulaWhat it tells you
Long LΣ max(x_i, 0)Total long exposure
Short SΣ max(-x_i, 0)Magnitude of short exposure
Net NΣ x_i = L − SDirectional signed exposure
Gross G`Σx_i

The short's negative sign has an economic meaning. Ignoring fees and financing, a $300 short loses $30 when that asset's price rises 10%; its signed contribution is −300×.10. A falling price reverses that contribution. Exposure measures the position; it does not say whether borrowing it is available or affordable.

These identities also give L=(G+N)/2 and S=(G−N)/2. For a synthetic vector x=[.65,.65,-.30,0], L=1.30, S=.30, N=1.00, and G=1.60. The fractions could correspond to $1,300 long and $300 short against a declared $1,000 capital base. They are exposures, not a cash or margin ledger.

A synthetic 130/30 boundary has net one and gross one point six

The two measurements use the same signed holdings but different arithmetic. Open the full-size exposure visual.

What the constraint actually permits

One common modeling choice is N=1 and G≤1.6. The 130/30 vector above sits on its gross boundary. But [1,0,0,0] also meets those constraints. Calling the whole feasible set “exactly 130/30” would mislead the reader. It only permits up to 130% long and 30% short when the gross cap binds and the net is one. At lower gross, long and short exposure are lower.

Plain text
N_min ≤ Σ_i x_i ≤ N_max
Σ_i |x_i| ≤ G_max

Here N_min, N_max, and G_max are declared fractions of the same base B. A necessary feasibility check is G_max ≥ |N| for any requested fixed N. If someone asks for N=1 with G_max=.8, no signed portfolio can satisfy both. The method should return infeasible rather than quietly loosen a bound.

The reverse mistake is equally common: x=[.8,.8,-.3,0] has positive net 1.3, but gross 1.9; it violates both the net-one and gross-1.6 requirements. Looking at net alone conceals the extra position size.

The diagram's split is the useful mental model: neither net nor gross substitutes for the other.

An optimization you can solve by hand

The constraints can surround any declared objective. For this article's small synthetic one-period example, choose expected simple-return vector μ, positive-semidefinite return covariance Σ, and nonnegative risk coefficient λ on a common horizon:

Plain text
maximize   f(x) = μᵀx − λ xᵀΣx
subject to N = 1, G ≤ 1.6.

Use μ=[.20,.04], Σ=diag(.04,.01), and λ=1. These are teaching inputs, not return forecasts. Set x=[z,1−z] to enforce net one. The gross rule becomes |z|+|1−z|≤1.6, equivalent to −.3≤z≤1.3. The objective reduces to

Plain text
f(z) = .03 + .18z − .05z².

Its stationary point is z=1.8, outside the allowed interval. Since the quadratic is concave, the exact constrained optimum is the right endpoint: x*=[1.3,-.3]. Direct substitution gives N=1, G=1.6, and f(x*)=.1795. With G_max=1, the same net-one model must remain long-only and chooses [1,0], with f=.16. This difference illustrates a model response to a relaxed exposure cap; it says nothing about which allocation will perform better in a market.

Use the gross/net guided lab to move the cap through 1, 1.2, and 1.6, then try the infeasible 0.8 request. I would inspect the feasible z interval before comparing objective values: the invalid case has no portfolio to score. The static visual above preserves the core identities if the lab is unavailable.

The auxiliary-variable trap

Optimization software often represents x=x⁺−x⁻ using two nonnegative vectors and caps Σ(x⁺+x⁻). That is a valid way to enforce a gross upper bound: Σ|x_i|≤Σ(x⁺_i+x⁻_i). It does not mean the auxiliary sum is necessarily the portfolio's actual gross.

For x=[.6,.5,-.1], direct gross is 1.2. The nonminimal split x⁺=[.7,.5,0], x⁻=[.1,0,.1] reconstructs the same signed x but sums to 1.4. A report that calls 1.4 the exposure would be wrong. Recompute Σ|x_i| from the returned signed holdings; if an application needs equality to the auxiliary sum, it must establish minimality or complementarity under its objective and constraints.

For a real solver, preserve its raw status. Recompute net, gross, bounds, and objective from the candidate with stated tolerances. A feasible incumbent at a time limit is a candidate, not a certified optimum. An indefinite covariance invalidates the concave-optimization claim unless the problem and solver are explicitly changed.

Zero net is not zero capital

A dollar-neutral book might use x=[.5,-.5]. It has N=0 and G=1. Dividing holdings by the net to produce weights would divide by zero; treating net as total capital would erase the actual positions. Keep the original positive base B and report the chosen normalization. Dollar neutrality is only one exposure constraint. It does not prove factor neutrality, low volatility, hedging, or available borrow.

Same exposure, different risk

Keep the synthetic dollar-neutral weights [.5,−.5] and assume both assets have 20% return volatility over the same horizon. Let ρ denote the correlation of their returns. The portfolio variance relative to the supplied capital base is

Plain text
xᵀΣx = (.5)²(.2)² + (−.5)²(.2)²
       + 2(.5)(−.5)(.2)(.2)ρ
      = .02(1−ρ).
Assumed correlationNet / grossModeled volatility
.90 / 1√.002 ≈ 4.47%
00 / 1√.02 ≈ 14.14%
−10 / 1√.04 = 20.00%

Net and gross never changed, yet modeled volatility did. Highly positively correlated assets tend to offset in a long/short pair; negatively correlated asset returns can make its signed contributions move together. These assumed correlations illustrate the covariance calculation, not a stable market hedge. Borrow costs and tail risk remain outside it.

A gross cap is likewise a size rule, not a complete leverage or margin rule. A broker's collateral, locate, financing, and liquidation terms require account-specific inputs. Real execution also adds transaction costs; the transaction-cost-aware tutorial shows the separate self-financing ledger. The turnover tutorial caps movement from current holdings. Neither concept is implied by G≤1.6.

Check your exposure intuition

In the gross/net lab, set the gross cap to 1.2 while net remains one. Before stepping forward, solve the two identities: L=(1.2+1)/2=1.1 and S=(1.2−1)/2=.1 if the cap binds. The example's increasing objective makes it bind, so the returned weights are [1.1,−.1] and the objective is .1675.

Now set the cap to .8. The correct response is infeasible because G≥|N|; there is no candidate objective to compare. Finally, return to 1.6: a different objective could choose [1,0] inside that same feasible set. A cap supplies a boundary, not an instruction to use the maximum short exposure.

My decision rule for this constraint

If I need to know…I inspect…I still need…
Directional exposureNet NFactor exposures if the question is market neutrality
Total signed position sizeGross GBorrow and margin terms if the question is fundability
Whether the chosen optimizer obeyed the limitDirect signed x, net/gross residuals and solver statusCosts, cash and execution checks before placing a trade

I keep those questions separate because a clean norm calculation cannot validate the imported holdings that produced x. That is the lesson I carry from reconciling mixed-currency positions; the short-book examples here remain synthetic.

A quick review checklist

  1. Record one positive capital base, currency, and valuation time; validate signed positions and FX.
  2. Compute L, S, N, and G directly from the signed vector.
  3. Check the requested net range and gross cap independently; reject incompatible bounds.
  4. If optimizing, state the objective, return horizon, covariance assumptions, and solver status.
  5. Keep broker margin, borrow, financing, taxes, and settlement outside this exposure-only result until those contracts are supplied.

No named historical portfolio is needed to understand these identities. The cases here are synthetic and independently checkable; a real case would need dated positions, capital and FX basis, margin terms, borrow evidence, and publication rights. The canonical definition and source record state the full teaching boundary. The next article, Tax-Aware Portfolio Optimization, adds lot and policy state that gross/net exposure cannot represent.

ReferencesPrimary sources and evidence notes

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

MOSEK-INPUTDATA — Input data preparation

  • Organization: MOSEK ApS.
  • Type/version: Official Portfolio Optimization Cookbook, online 1.6.0, §3.3.3.
  • URL: Long-short portfolios and gross exposure.
  • Accessed: 2026-09-21.
  • Jurisdiction: Mathematical modeling, not an account-specific broker rule.
  • Supports: Signed normalized exposures, L1 gross cap, 130/30 and neutrality examples; net-zero is not a safe capital denominator.
  • Limit: The article's two-asset utility and fixture are author-derived, not MOSEK examples. Regulatory margin/borrow permission needs separate evidence.

MOSEK-TRANSACTION — Transaction costs

  • Organization: MOSEK ApS.
  • Type/version: Official Portfolio Optimization Cookbook, online 1.6.0, §6.
  • URL: Transaction-cost chapter.
  • Accessed: 2026-09-21.
  • Jurisdiction: Mathematical modeling.
  • Supports: Why real trading costs and a self-financing budget require an additional contract beyond a gross cap.
  • Limit: No broker fees, financing, or tax inputs are supplied for this article.
long-short-gross-net.ts
/**
 * Exact long/short net and gross exposure reference.
 *
 * Net and gross are two different measurements of the same signed vector, so a
 * limit on one says nothing about the other.  Everything here is computed from
 * the signed weights `x`, where `x_i` is a position's signed market value
 * divided by an independently supplied positive capital base.  The capital
 * base is never recovered from the positions, because a dollar-neutral book
 * sums to zero and dividing by that erases the portfolio.
 *
 * The optimizer maximizes `muᵀx − λ xᵀΣx` under a net interval and a gross
 * cap.  Gross is a sum of absolute values, which is linear inside a single
 * orthant, so the feasible set is split by sign pattern; inside one orthant
 * the problem is a bounded concave quadratic and is solved by enumerating
 * active sets and solving each KKT system.  Over the declared scope every sign
 * pattern and every active set is visited, and a candidate is kept only when
 * the returned point is primal-feasible, so the best candidate is the true
 * constrained optimum rather than a point admitted by widening a bound.  An
 * empty feasible set is reported as infeasible; no bound is relaxed to
 * manufacture an answer.
 *
 * Reported gross is always recomputed from the returned signed weights.  An
 * auxiliary `x⁺`/`x⁻` pair is only an upper bound on gross, and
 * `auditAuxiliarySplit` exists to make that gap visible rather than let a
 * rename hide it.
 */

export const ABSOLUTE_TOLERANCE = 1e-9;
export const RELATIVE_TOLERANCE = 2e-8;
export const FEASIBILITY_TOLERANCE = 1e-9;
export const SIGN_TOLERANCE = 2e-8;
export const KKT_TOLERANCE = 2e-7;
export const PSD_RELATIVE_TOLERANCE = 1e-10;
export const MAX_ASSETS = 6;

const NET_LOWER = 0;
const NET_UPPER = 1;
const NET_FREE = 2;

export class GrossNetValidationError extends Error {
  readonly code: string;
  constructor(code: string, message: string) {
    super(message);
    this.name = "GrossNetValidationError";
    this.code = code;
  }
}

export class GrossNetNumericalError extends Error {
  readonly code: string;
  constructor(code: string, message: string) {
    super(message);
    this.name = "GrossNetNumericalError";
    this.code = code;
  }
}

export interface ExposureMeasures {
  long: number;
  short: number;
  net: number;
  gross: number;
  identityResidual: number;
}

export interface ConstraintReport {
  bound: number;
  residual: number;
  satisfied: boolean;
}

export interface ExposureConstraintCheck {
  exposures: ExposureMeasures;
  netLower: ConstraintReport;
  netUpper: ConstraintReport;
  grossCap: ConstraintReport;
  minimumFeasibleGross: number;
  boundsCompatible: boolean;
  feasible: boolean;
}

export interface MinimalSplit {
  longPart: number[];
  shortPart: number[];
  sum: number;
}

export interface AuxiliarySplitAudit {
  reportedGross: number;
  directGross: number;
  auxiliarySum: number;
  overstatement: number;
  reconstructs: boolean;
  reconstructionResidual: number;
  minimal: boolean;
  overlap: number;
}

export interface GrossNetCertificate {
  kind: "exhaustive-orthant-active-set";
  complete: boolean;
  continuousKkt?: boolean;
  kktResidual?: number;
  grossFromSignedWeights: true;
  globalOptimality: string;
}

export interface GrossNetResult {
  status: "optimal" | "feasible" | "incomplete" | "infeasible";
  weights: number[] | null;
  objective: number | null;
  exposures: ExposureMeasures | null;
  constraints: ExposureConstraintCheck | null;
  minimalSplit: MinimalSplit | null;
  enumeratedStates: number;
  totalStates: number;
  certificate: GrossNetCertificate;
}

function invalid(code: string, message: string): never {
  throw new GrossNetValidationError(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]));
}

/**
 * Tolerances follow the declared bounds; a book quoted in basis points and one
 * quoted in multiples of capital must not share an absolute slack.
 */
function boundScale(...values: number[]): number {
  return Math.max(1, ...values.map((value) => Math.abs(value)));
}

/** Deterministic Jacobi estimate at the matrix's actual scale. */
function minimumSymmetricEigenvalue(input: readonly (readonly number[])[]): number {
  const size = input.length;
  const work = input.map((row) => [...row]);
  if (size === 1) return work[0][0];
  const scale = Math.max(...work.flat().map((value) => Math.abs(value)));
  if (scale === 0) return 0;
  for (let sweep = 0; sweep < Math.max(60, 20 * size * size); 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]));
}

function checkCovariance(covariance: readonly (readonly number[])[], requireDefinite: boolean): void {
  const size = covariance.length;
  const scale = Math.max(...covariance.flat().map((value) => Math.abs(value)));
  if (scale === 0) {
    if (requireDefinite) {
      invalid("singular-covariance", "positive risk aversion requires a positive-definite covariance in the exact active-set reference.");
    }
    return;
  }
  const symmetryTolerance = 1e-12 * scale;
  for (let row = 0; row < size; row += 1) {
    for (let column = 0; column < row; column += 1) {
      if (Math.abs(covariance[row][column] - covariance[column][row]) > symmetryTolerance) {
        invalid("indefinite-covariance", "covariance must be symmetric at the matrix scale.");
      }
    }
  }
  const minimumEigenvalue = minimumSymmetricEigenvalue(covariance);
  if (minimumEigenvalue < -PSD_RELATIVE_TOLERANCE * scale) {
    invalid("indefinite-covariance", "covariance must be positive semidefinite.");
  }
  if (requireDefinite && minimumEigenvalue <= PSD_RELATIVE_TOLERANCE * scale) {
    invalid("singular-covariance", "positive risk aversion requires a positive-definite covariance in the exact active-set reference.");
  }
}

/**
 * Gaussian elimination with partial pivoting, written the same way in both
 * languages so the two references agree bit for bit on the same inputs.
 */
function solveLinearSystem(input: readonly (readonly number[])[], right: readonly number[]): number[] | undefined {
  const size = right.length;
  const work = input.map((row, index) => [...row, right[index]]);
  const scale = Math.max(...input.flat().map((value) => Math.abs(value)));
  if (scale === 0 || !Number.isFinite(scale)) return undefined;
  const pivotTolerance = 1e-12 * scale;
  for (let column = 0; column < size; column += 1) {
    let pivot = column;
    for (let row = column + 1; row < size; row += 1) {
      if (Math.abs(work[row][column]) > Math.abs(work[pivot][column])) pivot = row;
    }
    if (Math.abs(work[pivot][column]) <= pivotTolerance) return undefined;
    if (pivot !== column) { const swap = work[column]; work[column] = work[pivot]; work[pivot] = swap; }
    const pivotValue = work[column][column];
    for (let row = column + 1; row < size; row += 1) {
      const factor = work[row][column] / pivotValue;
      if (factor === 0) continue;
      for (let entry = column; entry <= size; entry += 1) work[row][entry] -= factor * work[column][entry];
    }
  }
  const solution = Array.from({ length: size }, () => 0);
  for (let row = size - 1; row >= 0; row -= 1) {
    let remainder = work[row][size];
    for (let entry = row + 1; entry < size; entry += 1) remainder -= work[row][entry] * solution[entry];
    solution[row] = remainder / work[row][row];
  }
  return solution.every((value) => Number.isFinite(value)) ? solution : undefined;
}

/**
 * Signed market values divided by an independently supplied capital base.
 *
 * The base is an input, not a statistic of the positions.  A dollar-neutral
 * book has zero net exposure, so a routine that normalizes by the signed sum
 * divides by zero exactly when the long/short case matters most.
 */
export function signedExposures(marketValues: unknown, capitalBase: unknown): number[] {
  const values = vector(marketValues, "market_values");
  const base = asReal(capitalBase, "capital_base");
  if (base <= 0) {
    invalid("invalid-capital-base", "capital_base must be a positive number in one currency at one valuation time.");
  }
  return values.map((value, index) => {
    const exposure = value / base;
    if (!Number.isFinite(exposure)) {
      invalid("invalid-capital-base", "market_values[" + index + "] divided by capital_base is not finite.");
    }
    return exposure;
  });
}

/**
 * Long, short, net, and gross exposure of one signed weight vector.
 *
 * Net subtracts the short side, gross adds it.  The two identities
 * `L=(G+N)/2` and `S=(G−N)/2` are reported as a residual so a caller can see
 * that the four numbers describe one vector and not four separate reports.
 */
export function exposureMeasures(x: unknown): ExposureMeasures {
  const weights = vector(x, "x");
  const longSide = stableSum(weights.filter((value) => value > 0));
  const shortSide = stableSum(weights.filter((value) => value < 0).map((value) => -value));
  const net = stableSum(weights);
  const gross = stableSum(weights.map((value) => Math.abs(value)));
  const identityResidual = Math.max(
    Math.abs(longSide - 0.5 * (gross + net)),
    Math.abs(shortSide - 0.5 * (gross - net)),
  );
  return { long: longSide, short: shortSide, net, gross, identityResidual };
}

/**
 * `xᵀΣx` for signed weights.
 *
 * Net and gross are size measurements; they do not price the covariance.  A
 * dollar-neutral pair keeps net zero and gross one whatever the correlation
 * does to this number.
 */
export function portfolioVariance(x: unknown, covariance: unknown): number {
  const weights = vector(x, "x");
  const sigma = matrix(covariance, "covariance", weights.length);
  checkCovariance(sigma, false);
  return dot(weights, sigma.map((row) => dot(row, weights)));
}

function bounds(netMin: unknown, netMax: unknown, grossMax: unknown): [number, number, number] {
  const lower = asReal(netMin, "net_min");
  const upper = asReal(netMax, "net_max");
  const cap = asReal(grossMax, "gross_max");
  if (lower > upper) invalid("invalid-bound", "net_min must not exceed net_max.");
  if (cap < 0) invalid("invalid-bound", "gross_max must be nonnegative.");
  return [lower, upper, cap];
}

/**
 * Audit one signed vector against a net interval and a gross cap.
 *
 * Each limit is reported on its own with a slack residual, because looking at
 * net alone hides extra position size and looking at gross alone hides
 * direction.  `minimumFeasibleGross` is the smallest gross any portfolio in
 * the requested net interval can have; when the cap falls below it the two
 * bounds contradict each other and no vector can repair that.
 */
export function checkExposureConstraints(
  x: unknown, netMin: unknown, netMax: unknown, grossMax: unknown,
): ExposureConstraintCheck {
  const measures = exposureMeasures(x);
  const [lower, upper, cap] = bounds(netMin, netMax, grossMax);
  const tolerance = FEASIBILITY_TOLERANCE * boundScale(lower, upper, cap, measures.gross);
  const minimumFeasibleGross = Math.max(lower, -upper, 0);
  const netLowerResidual = measures.net - lower;
  const netUpperResidual = upper - measures.net;
  const grossResidual = cap - measures.gross;
  const netLowerOk = netLowerResidual >= -tolerance;
  const netUpperOk = netUpperResidual >= -tolerance;
  const grossOk = grossResidual >= -tolerance;
  return {
    exposures: measures,
    netLower: { bound: lower, residual: netLowerResidual, satisfied: netLowerOk },
    netUpper: { bound: upper, residual: netUpperResidual, satisfied: netUpperOk },
    grossCap: { bound: cap, residual: grossResidual, satisfied: grossOk },
    minimumFeasibleGross,
    boundsCompatible: cap >= minimumFeasibleGross - tolerance,
    feasible: netLowerOk && netUpperOk && grossOk,
  };
}

/** The one split of `x` into nonnegative parts whose sum equals gross. */
export function minimalSplit(x: unknown): MinimalSplit {
  const weights = vector(x, "x");
  const longPart = weights.map((value) => (value > 0 ? value : 0));
  const shortPart = weights.map((value) => (value < 0 ? -value : 0));
  return { longPart, shortPart, sum: stableSum([...longPart, ...shortPart]) };
}

/**
 * Compare an auxiliary `x⁺`/`x⁻` pair against gross recomputed from `x`.
 *
 * Capping `Σ(x⁺+x⁻)` is a sound way to *enforce* a gross bound, because that
 * sum dominates `Σ|x_i|`.  It is not a measurement of gross: a nonminimal pair
 * reconstructs the same signed vector while summing higher.  This reports both
 * numbers and the gap, and `reportedGross` is always the direct one.
 */
export function auditAuxiliarySplit(x: unknown, longPart: unknown, shortPart: unknown): AuxiliarySplitAudit {
  const weights = vector(x, "x");
  const positive = vector(longPart, "long_part");
  const negative = vector(shortPart, "short_part");
  if (positive.length !== weights.length || negative.length !== weights.length) {
    invalid("invalid-split", "x, long_part, and short_part must align.");
  }
  positive.forEach((value, index) => {
    if (value < 0) invalid("invalid-split", "long_part[" + index + "] must be nonnegative.");
  });
  negative.forEach((value, index) => {
    if (value < 0) invalid("invalid-split", "short_part[" + index + "] must be nonnegative.");
  });
  const directGross = stableSum(weights.map((value) => Math.abs(value)));
  const auxiliarySum = stableSum([...positive, ...negative]);
  const reconstructionResidual = Math.max(
    ...weights.map((value, index) => Math.abs(value - (positive[index] - negative[index]))),
  );
  const tolerance = ABSOLUTE_TOLERANCE * boundScale(directGross, auxiliarySum);
  const overlap = Math.max(...positive.map((value, index) => Math.min(value, negative[index])));
  return {
    reportedGross: directGross,
    directGross,
    auxiliarySum,
    overstatement: auxiliarySum - directGross,
    reconstructs: reconstructionResidual <= tolerance,
    reconstructionResidual,
    minimal: overlap <= tolerance,
    overlap,
  };
}

interface Program {
  mean: number[];
  sigma: number[][];
  risk: number;
  lower: number;
  upper: number;
  cap: number;
}

function validateProgram(
  mu: unknown, covariance: unknown, lambdaRisk: unknown,
  netMin: unknown, netMax: unknown, grossMax: unknown,
): Program {
  const mean = vector(mu, "mu");
  if (mean.length > MAX_ASSETS) {
    invalid("scope-limit", "the exact orthant reference is limited to n≤" + MAX_ASSETS + "; use a certified conic or QP solver for larger inputs.");
  }
  const sigma = matrix(covariance, "covariance", mean.length);
  const risk = asReal(lambdaRisk, "lambda_risk");
  if (risk < 0) invalid("invalid-input", "lambda_risk must be nonnegative.");
  const [lower, upper, cap] = bounds(netMin, netMax, grossMax);
  checkCovariance(sigma, risk > 0);
  return { mean, sigma, risk, lower, upper, cap };
}

/**
 * Every active set the reference visits, in one fixed order.
 *
 * A sign state of `0` pins `x_i` to zero; `±1` leaves it free on that side of
 * zero and fixes its gross coefficient.  The net interval contributes its
 * lower bound, its upper bound, or neither, and the gross cap is either tight
 * or slack.  Between them these cover every active set of the orthant
 * subproblems, so the enumeration is exhaustive rather than a search.
 */
function* states(size: number): Generator<[number[], number, boolean]> {
  const patterns = 3 ** size;
  for (let code = 0; code < patterns; code += 1) {
    const signs = Array.from({ length: size }, (_unused, position) => {
      const digit = Math.floor(code / 3 ** (size - 1 - position)) % 3;
      return digit - 1;
    });
    for (const netState of [NET_LOWER, NET_UPPER, NET_FREE]) {
      for (const grossActive of [false, true]) yield [signs, netState, grossActive];
    }
  }
}

interface StateSolution {
  point: number[];
  netMultiplier: number;
  grossMultiplier: number;
}

/** Stationary point of one active set: weights plus its two multipliers. */
function solveState(
  program: Program, signs: readonly number[], netState: number, grossActive: boolean,
): StateSolution | undefined {
  const { mean, sigma, risk, lower, upper, cap } = program;
  const size = mean.length;
  const free: number[] = [];
  for (let index = 0; index < size; index += 1) if (signs[index] !== 0) free.push(index);
  const rows: number[][] = [];
  const rightHand: number[] = [];
  if (netState === NET_LOWER) { rows.push(free.map(() => 1)); rightHand.push(lower); }
  else if (netState === NET_UPPER) { rows.push(free.map(() => 1)); rightHand.push(upper); }
  if (grossActive) { rows.push(free.map((index) => signs[index])); rightHand.push(cap); }

  if (free.length === 0) {
    // The origin carries no multipliers, so it is visited once rather than
    // once per bound state it cannot enforce.
    if (netState !== NET_FREE || grossActive) return undefined;
    return { point: Array.from({ length: size }, () => 0), netMultiplier: 0, grossMultiplier: 0 };
  }

  const dimension = free.length;
  const count = rows.length;
  const hessian = free.map((row) => free.map((column) => 2 * risk * sigma[row][column]));
  const linear = free.map((index) => mean[index]);
  let scale = Math.max(
    ...hessian.flat().map((value) => Math.abs(value)),
    ...linear.map((value) => Math.abs(value)),
  );
  if (!Number.isFinite(scale)) return undefined;
  // A constant objective still has feasible vertices worth reporting, so the
  // scaling falls back to one instead of abandoning the active set.
  if (scale === 0) scale = 1;
  const system = Array.from({ length: dimension + count }, () => Array.from({ length: dimension + count }, () => 0));
  const right = Array.from({ length: dimension + count }, () => 0);
  for (let row = 0; row < dimension; row += 1) {
    for (let column = 0; column < dimension; column += 1) system[row][column] = hessian[row][column] / scale;
    for (let index = 0; index < count; index += 1) system[row][dimension + index] = rows[index][row];
    right[row] = linear[row] / scale;
  }
  for (let index = 0; index < count; index += 1) {
    for (let column = 0; column < dimension; column += 1) system[dimension + index][column] = rows[index][column];
    right[dimension + index] = rightHand[index];
  }
  const solution = solveLinearSystem(system, right);
  if (!solution) return undefined;
  const point = Array.from({ length: size }, () => 0);
  free.forEach((index, row) => { point[index] = solution[row]; });
  // The stationarity rows were divided by the objective scale, so the
  // multipliers come back in those units and are restored here.
  const netMultiplier = netState !== NET_FREE ? solution[dimension] * scale : 0;
  const grossMultiplier = grossActive ? solution[dimension + count - 1] * scale : 0;
  return { point, netMultiplier, grossMultiplier };
}

/**
 * How far the candidate is from satisfying its own KKT conditions.
 *
 * Enumeration already makes the best primal-feasible candidate the optimum, so
 * this is a certificate rather than a filter: it lets the result say the
 * reported point carries multipliers, instead of only asserting it does.
 */
function dualResidual(
  program: Program, point: readonly number[], signs: readonly number[],
  netState: number, grossActive: boolean,
  netMultiplier: number, grossMultiplier: number, netIsEquality: boolean,
): number {
  const { mean, sigma, risk } = program;
  const gradient = mean.map((value, index) => value - 2 * risk * dot(sigma[index], point));
  let violation = 0;
  gradient.forEach((value, index) => {
    if (signs[index] === 0) {
      // A position held at zero must be unprofitable to open in either
      // direction, which is exactly |g − ν_net| ≤ ν_gross.
      violation = Math.max(violation, Math.abs(value - netMultiplier) - grossMultiplier);
    } else {
      violation = Math.max(violation, Math.abs(value - netMultiplier - grossMultiplier * signs[index]));
    }
  });
  if (grossActive) violation = Math.max(violation, -grossMultiplier);
  if (!netIsEquality) {
    if (netState === NET_LOWER) violation = Math.max(violation, netMultiplier);
    else if (netState === NET_UPPER) violation = Math.max(violation, -netMultiplier);
  }
  const scale = Math.max(
    1e-300, Math.abs(netMultiplier), Math.abs(grossMultiplier),
    ...gradient.map((value) => Math.abs(value)),
  );
  return Math.max(violation, 0) / scale;
}

export interface GrossNetOptions {
  maxStates?: unknown;
}

/** Maximize `muᵀx − λ xᵀΣx` under `net_min ≤ Σx ≤ net_max` and `Σ|x| ≤ gross_max`. */
export function optimizeGrossNetConstrained(
  mu: unknown, covariance: unknown, lambdaRisk: unknown,
  netMin: unknown, netMax: unknown, grossMax: unknown,
  options: GrossNetOptions = {},
): GrossNetResult {
  const program = validateProgram(mu, covariance, lambdaRisk, netMin, netMax, grossMax);
  const { mean, sigma, risk, lower, upper, cap } = program;
  const size = mean.length;
  const totalStates = 6 * 3 ** size;
  let stateLimit = totalStates;
  if (options.maxStates !== undefined) {
    if (typeof options.maxStates !== "number" || !Number.isInteger(options.maxStates) || options.maxStates <= 0) {
      invalid("invalid-input", "max_states must be a positive integer.");
    }
    stateLimit = Math.min(options.maxStates, totalStates);
  }

  const scaleOfBounds = boundScale(lower, upper, cap);
  const signTolerance = SIGN_TOLERANCE * scaleOfBounds;
  const feasibilityTolerance = FEASIBILITY_TOLERANCE * scaleOfBounds;
  const netIsEquality = (upper - lower) <= feasibilityTolerance;

  let best: { value: number; point: number[]; kktResidual: number } | undefined;
  let enumerated = 0;
  let complete = true;
  for (const [signs, netState, grossActive] of states(size)) {
    if (enumerated >= stateLimit) { complete = false; break; }
    enumerated += 1;
    const solved = solveState(program, signs, netState, grossActive);
    if (!solved) continue;
    const { point, netMultiplier, grossMultiplier } = solved;
    let rejected = false;
    for (let index = 0; index < size; index += 1) {
      if (signs[index] === 0) continue;
      const oriented = signs[index] * point[index];
      if (oriented < -signTolerance) { rejected = true; break; }
      // The solve landed a hair on the wrong side of its own orthant; snapping
      // to the boundary keeps the reported vector inside the region its gross
      // was computed for.
      if (oriented < 0) point[index] = 0;
    }
    if (rejected) continue;
    const net = stableSum(point);
    const gross = stableSum(point.map((value) => Math.abs(value)));
    if (net < lower - feasibilityTolerance || net > upper + feasibilityTolerance) continue;
    if (gross > cap + feasibilityTolerance) continue;
    const value = dot(mean, point) - risk * dot(point, sigma.map((row) => dot(row, point)));
    if (!Number.isFinite(value)) {
      throw new GrossNetNumericalError("nonfinite-objective", "an orthant subproblem produced a non-finite objective.");
    }
    const residual = dualResidual(
      program, point, signs, netState, grossActive, netMultiplier, grossMultiplier, netIsEquality,
    );
    if (best !== undefined) {
      const margin = RELATIVE_TOLERANCE * Math.max(Math.abs(value), Math.abs(best.value));
      if (value < best.value - margin) continue;
      // One optimum is often the stationary point of several active sets, and
      // only some of them carry its multipliers.  Between objectives that
      // cannot be told apart, keep the candidate whose own KKT conditions
      // hold, so the certificate describes the reported point instead of the
      // first active set that reached it.
      if (value <= best.value + margin && residual >= best.kktResidual) continue;
    }
    best = { value, point: [...point], kktResidual: residual };
  }

  if (best === undefined) {
    return {
      status: complete ? "infeasible" : "incomplete",
      weights: null, objective: null, exposures: null, constraints: null, minimalSplit: null,
      enumeratedStates: enumerated, totalStates,
      certificate: {
        kind: "exhaustive-orthant-active-set",
        complete,
        grossFromSignedWeights: true,
        globalOptimality: complete
          ? "every orthant and active set was checked; the net interval and the gross cap admit no signed portfolio"
          : "no feasible candidate was found before the state limit",
      },
    };
  }

  // A negative zero is the same exposure as a positive zero, and printing one
  // as `-0` invites a reader to see a short that is not there.
  const weights = best.point.map((value) => (value !== 0 ? value : 0));
  const constraints = checkExposureConstraints(weights, lower, upper, cap);
  return {
    status: complete ? "optimal" : "feasible",
    weights,
    objective: best.value,
    exposures: constraints.exposures,
    constraints,
    minimalSplit: minimalSplit(weights),
    enumeratedStates: enumerated,
    totalStates,
    certificate: {
      kind: "exhaustive-orthant-active-set",
      complete,
      continuousKkt: best.kktResidual <= KKT_TOLERANCE,
      kktResidual: best.kktResidual,
      grossFromSignedWeights: true,
      globalOptimality: complete
        ? "every orthant and every active set of its concave subproblem was checked"
        : "incumbent only; state enumeration stopped before a proof",
    },
  };
}

/** The article's two-asset 130/30 boundary problem. */
export function canonicalExample(): {
  mu: number[]; covariance: number[][]; lambdaRisk: number;
  netMin: number; netMax: number; grossMax: number;
} {
  return {
    mu: [0.2, 0.04],
    covariance: [[0.04, 0], [0, 0.01]],
    lambdaRisk: 1,
    netMin: 1,
    netMax: 1,
    grossMax: 1.6,
  };
}
Interactive 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.