D14-F03-A05 / Released engineering topic

Kelly Allocation: Optimize Multi-Asset Log Growth Without Hiding Ruin or Cash

Maximizes expected logarithmic growth over a discrete scenario set, separating the long-run growth criterion from the single-coin-toss intuition.

Kelly Allocation: Optimize Multi-Asset Log Growth Without Hiding Ruin or CashD14 / D14-F03

Kelly is often introduced with one favorable coin toss and one compact betting formula. That example is useful history, but it can leave portfolio builders with the wrong mental model. Real allocation involves several assets, several possible outcomes, and a strict question in every outcome: is the next wealth factor still positive?

This tutorial builds the general finite-scenario version. We will calculate probability-weighted log growth, solve a long-only multi-asset allocation, inspect every scenario wealth factor, and scale full Kelly into explicit cash. We will not call the result safe, and we will not hide zero wealth behind a small numerical epsilon.

Starting toolkit

Read the family’s simplex notation. You also need the identity log⁡(ab)=log⁡a+log⁡b\log(ab)=\log a+\log b. Kelly changes the objective to compounded wealth; it does not introduce the distributional ambiguity set used in DRO.

Why logarithmic wealth changes the decision

An arithmetic-return maximizer likes the largest expected payoff. It does not care enough about a rare outcome that destroys the bankroll. Kelly maximizes

G(w)=∑spslog⁡(1+wTrs)G(w)=\sum_s p_s\log(1+w^Tr_s)

Here r_s is the vector of simple returns in scenario s, p_s is its probability, and w is the portfolio. The factor 1+w^T r_s is wealth after the scenario divided by wealth before it.

The logarithm converts multiplication across repeated periods into addition and makes zero wealth an absolute boundary. That creates the growth-optimal logic under the supplied model. It does not create a reliable model of the future.

Scenario wealth factors feeding the Kelly log-growth objective

The visual compares full-risky and cash-scaled growth curves. The lab also exposes individual factors, since an average can hide one outcome close to ruin.

The canonical contract

We choose w>=0 with 1^T w=1 and require

1+wTrs>0for every scenario s1+w^Tr_s>0\quad\text{for every scenario }s

for every scenario. The return inputs are decimal simple returns. If a data source supplies log returns, transform the model intentionally; do not insert them into the simple-return wealth equation.

A return of -1 means that asset loses 100%. It is valid input, but a candidate fully allocated to that asset produces a zero wealth factor and is outside the log domain. A simple return below -1 is invalid. If every asset is at -1 in the same scenario, full investment has no feasible solution.

The finite-scenario gradient is

∂G∂wj=∑spsrs,j1+wTrs\frac{\partial G}{\partial w_j}=\sum_s\frac{p_s r_{s,j}}{1+w^Tr_s}

Because log is concave, G is concave on the positive domain. The reference solver uses projected gradient ascent with backtracking and publishes its projected-gradient residual.

A symmetric example with an exact answer

Use three synthetic, equally likely scenarios:

ScenarioAsset AAsset B
A leads10%-5%
B leads-5%10%
Both gain2%2%

At equal weights, the factors are [1.025,1.025,1.02]. Therefore

G=(2 log(1.025)+log(1.02))/3=0.02306261749.

At [1,0], the factors are [1.10,0.95,1.02] and G=0.02127317090. Concentration has a slightly larger best outcome, but the weaker second factor reduces geometric growth.

We can prove the optimum without trusting code. Write w=[x,1-x]. The first two factors are 0.95+0.15x and 1.10-0.15x. They are symmetric around x=0.5, and their sum is fixed. Strict concavity of the log sum makes x=0.5 the unique optimum.

Fractional Kelly must leave visible cash

Suppose the full Kelly risky weights are [0.5,0.5], but the policy selects a 40% fraction. The correct output is risky weights [0.2,0.2] and cash weight 0.6. If a system scales the risky weights and then renormalizes them back to [0.5,0.5], it has undone the fraction.

Cash is not an invisible remainder. Its return convention, currency, funding rules, and whether it can be negative all belong in a production contract. The helper here assumes a zero-return cash sleeve only to demonstrate the arithmetic.

Why scenario quality is the real risk

Kelly can be aggressively correct about an incomplete model. If a severe loss scenario is missing or assigned too little probability, the optimizer may take more risk precisely because the logarithmic objective sees no reason not to.

Data lineage is therefore part of the allocation. Before constructing returns, verify security identity, split state, cash dividends, quantity units, quote currency, and FX time. I have encountered old portfolio records where these items were mixed and the apparent return never existed economically. If a cash dividend is paid but only the price drop is recorded, the wealth factor is wrong. If a pre-split price meets a post-split quantity, the factor is wrong. Kelly cannot infer the missing ledger event.

Probabilities also need provenance. Equal weights can describe an empirical sample; they do not become true outcome probabilities merely because the code accepts them. A forecasting or scenario-generation model should record its knowledge cutoff, version, calibration, and uncertainty separately.

What “full Kelly” means in this API

The solver fully allocates among the supplied columns. If those are all risky assets, it solves a risky-only constrained problem. To let the optimizer choose cash, add a column of zeros for zero-return cash. Fractionally scaling a risky-only optimum afterward generally differs from optimizing risky assets and cash jointly.

For example, use two equally likely rows [-0.10,0] and [-0.05,0]. The second column is cash. Every positive risky allocation loses money in both states, so the optimum is [0,1], with log growth zero. A risky-only call with the first column alone must invest in it and produces negative growth. The shared tests verify the cash-inclusive case.

For a less trivial example, risky returns are +100% or −50%, with probabilities 0.6 and 0.4, and the other column is cash. Let xx be the risky weight:

G(x)=0.6log⁡(1+x)+0.4log⁡(1−0.5x).G(x)=0.6\log(1+x)+0.4\log(1-0.5x).

Solving G′(x)=0G'(x)=0 gives 0.6/(1+x)=0.2/(1−0.5x)0.6/(1+x)=0.2/(1-0.5x) and x=0.8x=0.8. This checks a genuinely asymmetric interior optimum rather than only a symmetric starting point.

Log points are not percentage returns

The canonical value G=0.0230626174923G=0.0230626174923 is 2.30626174923 log points per modeled period. Its geometric-return equivalent is

eG−1=0.023330615938≈2.333061594%.e^G-1=0.023330615938\approx2.333061594\%.

Those numbers are close here, but they are different quantities. At a 40% fraction with zero-return cash, the scenario factors become [1.010,1.010,1.008]. Recompute

G0.4=2log⁡(1.010)+log⁡(1.008)3.G_{0.4}=\frac{2\log(1.010)+\log(1.008)}3.

Do not keep displaying full-risky GG after changing the fraction. The lab displays deployed factors, deployed log growth, and its geometric equivalent separately. At fraction zero, all factors are one, all wealth stays in cash, and both growth measures are zero.

Repetition, stress rows, and convergence

The long-run interpretation uses a repeated outcome model, conventionally IID draws from the supplied distribution, repeated rebalancing to the chosen weights, and an integrable log-wealth increment. Costs, changing probabilities, and serial dependence require a richer analysis. Busseti, Ryu and Boyd describe the repeated-bet setup, a cash asset, and risk-constrained extensions. One-period expected log growth remains a well-defined objective without claiming that those long-run assumptions hold.

The API treats every supplied row as a hard solvency stress, even if its probability is zero. This is a deliberate stricter contract: a zero-probability all-ruin row is rejected. Remove such a row only if it truly lies outside the modeled support and stress policy. A tiny epsilon in a logarithm would conceal a different objective.

All public helpers validate finite returns, simplex weights and probabilities, and the positive wealth domain. Solver status uses a projected-gradient tolerance with a numerical floor of 10−910^{-9}; inspect the residual rather than assuming many printed decimals imply exact convergence.

Try it: permute scenario rows together with probabilities. Growth and weights should stay unchanged. Permuting probabilities alone describes a different model and can change the optimum.

Implementation walkthrough

The Python implementation and TypeScript implementation share the same validation, simplex projection, gradient, backtracking, and stopping rule. The result includes the full vector of wealth factors and its minimum, not only the final weights.

The solver starts at equal weight. Because all valid asset returns are at least -1, equal weight is positive in a scenario whenever at least one asset is above -1. A scenario where every asset is exactly -1 is rejected as infeasible before iteration.

Backtracking prevents a step from leaving the positive domain or lowering the objective. If the projected-gradient residual does not meet tolerance, the status is nonconverged. A feasible portfolio is not silently upgraded to an optimum.

Explore full, fractional, and failure states

Open the Kelly guided playground. The canonical state shows all three factors and the equal-weight optimum. Step through factor calculation, logarithms, probability weighting, and the optimizer. Move the fraction control and watch risky weights shrink while cash grows. The failure scenario places all assets at a 100% loss in one outcome and shows why the fully invested problem has no positive-domain solution.

What to notice: the minimum factor is as important as expected log growth. A result that does not expose its weakest scenario is difficult to review.

Tests that protect the definition

The shared fixture verifies the exact equal-weight objective and the concentrated endpoint. Scenario rows and their probabilities can be permuted together without changing the solution. Fractional Kelly must preserve the declared cash weight. Candidate evaluation rejects zero wealth, the optimizer rejects an all-ruin scenario, and simple returns below -1 fail input validation. Python and TypeScript reproduce the same result.

These tests prove the calculation, not the probability model. They do not show that the scenarios are exhaustive or that the allocation has acceptable drawdown.

Kelly, drawdown, and suitability

The original Kelly result is about asymptotic growth under its assumptions. Finite-horizon investors can experience deep drawdowns before that asymptotic logic becomes comforting. Risk-constrained Kelly adds an explicit drawdown- probability control. Fractional Kelly is a simpler heuristic. Both are distinct from full Kelly and should be labeled.

The historical-example decision is deferred. A named market example needs licensed scenario returns, defensible probabilities, point-in-time availability, and policies for costs, cash, leverage, drawdown, taxes, and rebalancing. Without those, backfilled Kelly weights would invite hindsight.

What full Kelly does not promise

It does not promise positive realized return, small drawdown, low volatility, liquidity, tax efficiency, or suitability. It assumes the supplied outcome model and one-period mapping are meaningful. Dependence over time, changing probabilities, fat tails outside the scenario set, execution costs, and funding constraints can materially alter the decision.

This tutorial is educational. It is not investment advice, and the synthetic weights are not recommendations.

Run the calculation yourself

From this topic folder, use Python 3.10 or later. No third-party numerical library is required:

Shell
python examples/python_example.py
python -m unittest discover -s tests -p "test_*.py"

The key call is shown below. For an interactive Python session, first make the local implementation importable:

Python
import sys
sys.path.insert(0, "implementations/python")
from kelly_allocation import kelly_allocation
print(kelly_allocation([[.1,-.05],[-.05,.1],[.02,.02]],[1/3]*3).to_dict())

The TypeScript example uses the same inputs. See the runnable commands for compilation and execution. Shared cases in the shared regression cases exercise boundary states in both languages; strings NaN and Infinity are test sentinels, not valid numerical inputs.

Summary

An auditable Kelly allocation exposes the complete chain: simple-return scenarios, probabilities, candidate weights, every wealth factor, expected log growth, convergence residual, and explicit cash for any fractional scale. The logarithm makes ruin impossible to hide mathematically; our implementation and interface should not hide it operationally either.

Kelly completes this family by changing the objective. Black-Litterman changes the mean through views, REF measures sampling instability, robust mean-variance protects against a parameter set, and DRO protects against a distribution class.

Choose the family method by the decision you face

Your real questionStart withWhat changesWhat it does not solve
How should uncertain views modify a market prior?Black-LittermanExpected returns through a prior and viewsLong-run growth under supplied scenarios
How unstable are optimized weights across plausible samples?Resampled Efficient FrontierRows, replicate moments, and rank-matched frontiersRuin avoidance outside observed samples
How should a bounded error in the mean affect allocation?Robust Mean-VarianceMean vector inside a declared ellipsoidLog-wealth optimization
Which portfolio ranks best under a declared distribution class and loss event?Distributionally Robust PortfolioThe admissible return distributionScenario-specific compounding
Which scenario mix maximizes long-run log growth?Kelly AllocationObjective and scenario wealth factorsDrawdown comfort, suitability, or scenario quality

Related concepts and learning handoff

The reviewed The Fintech Builder chapter KKT checks and limits of an optimality claim (17:33–18:43) helps review simplex feasibility and first-order optimality after the log-growth solve. It does not justify the scenario probabilities or prove that full Kelly is suitable for a person.

kelly allocation flow

ReferencesPrimary sources and evidence notes

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

KELLY1956 — A New Interpretation of Information Rate

  • Organization or authors: J. L. Kelly Jr.
  • Source type: Original peer-reviewed Bell System Technical Journal article.
  • Publication or effective date: July 1956, 35(4), 917–926.
  • Version: DOI 10.1002/j.1538-7305.1956.tb03809.x.
  • URL or DOI: Wiley record.
  • Accessed: 2026-09-15.
  • Jurisdiction: None; information-theoretic and gambling framework.
  • Supports: Maximum exponential growth-rate interpretation and arbitrary-odds extension.
  • Limitations: It is not a portfolio data contract, drawdown prescription, tax model, or profitability forecast.

BRB2016 — Risk-Constrained Kelly Gambling

  • Organization or authors: Enzo Busseti, Ernest K. Ryu, and Stephen Boyd.
  • Source type: Peer-reviewed Journal of Investing paper and author manuscript.
  • Publication or effective date: Fall 2016, 25(3), 118–134.
  • Version: Author manuscript dated 19 March 2016.
  • URL or DOI: Stanford paper and code page.
  • Accessed: 2026-09-15.
  • Jurisdiction: None.
  • Supports: General finite-outcome Kelly formulation, explicit probabilities and non-negative gross payoffs, and the distinction between drawdown-constrained and fractional approaches.
  • Limitations: The risk-constrained extension is a comparison, not part of the canonical objective.

SB2018 — Distributional Robust Kelly Gambling

  • Organization or authors: Qingyun Sun and Stephen Boyd.
  • Source type: Original research manuscript.
  • Publication or effective date: December 2018.
  • Version: arXiv 1812.10371.
  • URL or DOI: Stanford record.
  • Accessed: 2026-09-15.
  • Jurisdiction: None.
  • Supports: Distributionally robust Kelly as a distinct finite-outcome extension when probabilities are uncertain.
  • Limitations: That ambiguity-set problem is not implemented here and must not be conflated with ordinary Kelly.
kelly-allocation.ts
export class ContractError extends Error {
}
;
type V = number[];
type M = number[][];
const dot = (a: V, b: V) => a.reduce((s, x, i) => s + x * b[i], 0), norm = (x: V) => Math.sqrt(dot(x, x));
function simplex(v: V): V { const u = [...v].sort((a, b) => b - a); let s = 0, rho = -1; u.forEach((x, i) => { s += x; if (x - (s - 1) / (i + 1) > 0)
    rho = i; }); const theta = (u.slice(0, rho + 1).reduce((a, b) => a + b, 0) - 1) / (rho + 1); return v.map(x => Math.max(x - theta, 0)); }
export function logGrowth(weights: V, returns: M, probabilities: V): number { const w = weights.map(Number), rows = returns.map(r => r.map(Number)), p = probabilities.map(Number); if (!w.length || w.some(x => x < 0 || !Number.isFinite(x)) || Math.abs(w.reduce((a, b) => a + b, 0) - 1) > 1e-10)
    throw new ContractError("weights must be a finite simplex vector"); if (rows.length !== p.length || !rows.length || rows.some(r => r.length !== w.length))
    throw new ContractError("return scenario shape mismatch"); if (rows.flat().some(x => !Number.isFinite(x) || x < -1))
    throw new ContractError("returns must be finite and at least -1"); if (p.some(x => !Number.isFinite(x) || x < 0) || Math.abs(p.reduce((a, b) => a + b, 0) - 1) > 1e-10)
    throw new ContractError("probabilities must be a finite simplex vector"); const f = rows.map(r => 1 + dot(w, r)); if (Math.min(...f) <= 0)
    throw new ContractError("all scenario wealth factors must be strictly positive"); return p.reduce((s, x, i) => s + x * Math.log(f[i]), 0); }
export interface KellyResult {
    weights: V;
    expectedLogGrowth: number;
    wealthFactors: V;
    minimumWealthFactor: number;
    arithmeticExpectedReturn: number;
    iterations: number;
    projectedGradientNorm: number;
    status: "optimal" | "nonconverged";
}
export function kellyAllocation(returns: M, probabilities?: V, maxIterations = 30000, tolerance = 1e-12): KellyResult { const rows = returns.map(r => r.map(Number)); if (!rows.length || !rows[0]?.length || rows.some(r => r.length !== rows[0].length) || rows.flat().some(x => !Number.isFinite(x)))
    throw new ContractError("returns must be a finite rectangular matrix"); if (rows.flat().some(x => x < -1))
    throw new ContractError("simple returns below -1 are invalid"); const t = rows.length, n = rows[0].length, p = probabilities?.map(Number) ?? Array(t).fill(1 / t); if (p.length !== t || p.some(x => x < 0 || !Number.isFinite(x)) || Math.abs(p.reduce((a, b) => a + b, 0) - 1) > 1e-10)
    throw new ContractError("probabilities must be a finite simplex vector"); if (rows.some(r => Math.max(...r) <= -1))
    throw new ContractError("no simplex portfolio has positive wealth in every scenario"); if (!Number.isInteger(maxIterations) || maxIterations < 1 || !Number.isFinite(tolerance) || !(tolerance > 0))
    throw new ContractError("iteration controls invalid"); const terms = (w: V) => { const factors = rows.map(r => 1 + dot(w, r)); return { objective: Math.min(...factors) <= 0 ? -Infinity : p.reduce((s, x, i) => s + x * Math.log(factors[i]), 0), factors }; }, grad = (w: V, f: V) => Array.from({ length: n }, (_, j) => p.reduce((sum, x, s) => sum + x * rows[s][j] / f[s], 0)); let w = Array(n).fill(1 / n), z = terms(w), iteration = 0, pg = Infinity; for (iteration = 1; iteration <= maxIterations; iteration++) {
    const g = grad(w, z.factors), mapped = simplex(w.map((x, i) => x + g[i]));
    pg = norm(mapped.map((x, i) => x - w[i]));
    if (pg <= tolerance)
        break;
    let step = 1, accepted = false;
    while (step >= 1e-14) {
        const candidate = simplex(w.map((x, i) => x + step * g[i])), nz = terms(candidate);
        if (nz.objective >= z.objective + 1e-4 * dot(g, candidate.map((x, i) => x - w[i]))) {
            w = candidate;
            z = nz;
            accepted = true;
            break;
        }
        step *= .5;
    }
    if (!accepted)
        break;
} const g = grad(w, z.factors); pg = norm(simplex(w.map((x, i) => x + g[i])).map((x, i) => x - w[i])); return { weights: w, expectedLogGrowth: z.objective, wealthFactors: z.factors, minimumWealthFactor: Math.min(...z.factors), arithmeticExpectedReturn: p.reduce((s, x, i) => s + x * dot(w, rows[i]), 0), iterations: Math.min(iteration, maxIterations), projectedGradientNorm: pg, status: pg <= Math.max(tolerance, 1e-9) ? "optimal" : "nonconverged" }; }
export function fractionalKelly(fullWeights: V, fraction: number): {
    riskyWeights: V;
    cashWeight: number;
} { if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1)
    throw new ContractError("fraction must lie in [0,1]"); if (!fullWeights.length || fullWeights.some(x => !Number.isFinite(x) || x < 0) || Math.abs(fullWeights.reduce((a, b) => a + b, 0) - 1) > 1e-10)
    throw new ContractError("full weights must be a simplex vector"); return { riskyWeights: fullWeights.map(x => fraction * x), cashWeight: 1 - fraction }; }
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.