Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

Kelly Sizing With Asynchronous Signals

Textbook Kelly sizing assumes one bet at a time. Real signals arrive asynchronously and overlap, and the full fraction overcommits capital. Here is the fix.

Kelly sizing with asynchronous signals is where the textbook formula quietly stops applying. The classic Kelly bet is one wager, backed by the whole bankroll, settled before the next one is placed. Real signals arrive at irregular times and each fill locks capital up for a holding period, so several positions sit open at once. Staking the full Kelly fraction on every arrival commits a multiple of the account at exactly the moment the most positions are live.

What the textbook Kelly fraction assumes

The Kelly criterion answers a narrow question. Given a repeated bet with a known edge, what constant fraction of capital maximizes the long-run growth rate of wealth? For an even-money bet won with probability p, that fraction is f* = 2p - 1. A coin landing your way 55 percent of the time gives f* = 0.10, and staking a tenth of the account per flip compounds faster over the long run than any other fixed fraction. Betting above f* raises the swings and lowers the growth rate at the same time.

Two assumptions carry that result: the bet settles before the next one is placed, and the entire bankroll stands behind it. A live strategy breaks both in its first busy week.

Why Kelly sizing with asynchronous signals overcommits

A scan does not place one bet and wait. It fires when its conditions are met, twice in a week and then not for a month. Each fill holds for some number of sessions until the exit rule closes it. Write the average arrival rate as lambda, in signals per session, and the holding period as d sessions. The positions open at any moment are the signals that arrived in the last d sessions.

Stake f on each arrival and the capital committed at that moment is the number open times f. With f = 0.10 and five open, half the account is at work. With eleven open, the rule asks for 110 percent of it, which no cash balance can fund. The sizing formula never noticed, since it was never told the bets overlap.

The expected concurrency correction

Little's law gives the average without any simulation: the mean number of items in a system equals the arrival rate times the time each one spends there. Positions open on average is lambda * d, whatever the arrival pattern looks like.

That product is the missing denominator. Holding the average aggregate exposure at f* means staking f* / (lambda * d) per signal. In the scenario below, 60 signals across 120 sessions puts lambda at 0.5, a 10-session hold makes lambda * d equal to 5, and the 10 percent single-bet fraction becomes 2 percent per signal.

The panel runs that division across holding periods at a fixed arrival rate of half a signal per session. The per-signal stake falls as the hold lengthens, and it equals the textbook fraction only where one position is open on average.

QueryPer-signal Kelly stake by holding period, at 0.5 signals per session
The exact SQL behind every number
WITH
    0.5  AS arrival_rate,
    10.0 AS f_star_pct
SELECT
    concat(toString(hold_sessions), '-session hold')      AS holding_period,
    round(arrival_rate * hold_sessions, 2)                AS expected_open,
    round(f_star_pct / (arrival_rate * hold_sessions), 3) AS per_signal_pct
FROM
(
    SELECT arrayJoin([2, 5, 10, 20, 30]) AS hold_sessions
)
ORDER BY hold_sessions
Run this yourself

The correction treats every open position as drawing on one shared risk budget. That is the right treatment when they share a driver, which clustered signals usually do. A scan fires across many names at once when one condition turns on across the market, and what follows is a single exposure wearing several tickers, the plain definition of concentration risk.

The tail the average hides

The average is not the binding constraint. The worst moment is.

Arrivals bunch. In the fixed schedule below the mean is 5 open positions and the busiest session carries 11, more than twice the average, which is ordinary for any arrival process with clustering in it. Sizing at f* / (lambda * d) puts 22 percent of capital to work at that peak and 10 percent on a typical session. Sizing for the peak instead, f* over 11, would stake under 1 percent per signal and leave the account close to idle the rest of the time.

Two details make the peak worse than the arithmetic suggests. Stakes are set when a position opens and do not shrink when the balance does, so a losing stretch lifts the committed share of a smaller account. And clusters are where the open positions are least independent, so peak capital committed and peak shared exposure land in the same week. That overlap is what turns an overbet into the kind of maximum drawdown the single-bet arithmetic never described.

What a cash reserve for unseen signals costs

Sizing for the average invites the obvious question: what happens when the cluster arrives and the budget is already spent? Holding cash back for signals not yet seen is one answer, and it carries a price in forgone growth that can be computed exactly.

Write the deployed exposure as k times the optimal exposure, so k = 0.75 keeps a quarter of the risk budget in cash. Near the optimum the growth curve is a parabola, g(k * f*) = g(f*) * (2k - k^2), and the share of the optimal growth rate retained is 1 - (1 - k)^2. The price of a reserve is the square of the fraction held back.

  • Hold back 10 percent of the risk budget: 1 percent of the growth rate.
  • Hold back 25 percent: 6.25 percent.
  • Hold back 40 percent: 16 percent.
  • Hold back 50 percent: 25 percent, the familiar half-Kelly trade.

The panel sets that square rule beside the exact growth arithmetic for the 55 percent coin, where the deployed fraction is f* scaled by the share kept in play.

QueryCost of a cash reserve: square rule against exact growth arithmetic, 55 percent coin
The exact SQL behind every number
SELECT
    concat(toString(held_back_pct), '% held back')         AS reserve,
    round(100 * pow(held_back_pct / 100, 2), 2)            AS square_rule_forgone_pct,
    round(100 * (1 - growth_deployed / growth_optimum), 2) AS exact_forgone_pct
FROM
(
    WITH
        0.10 AS f_star,
        0.55 AS win_prob
    SELECT
        held_back_pct,
        f_star * (1 - held_back_pct / 100)                                AS deployed,
        win_prob * log(1 + deployed) + (1 - win_prob) * log(1 - deployed) AS growth_deployed,
        win_prob * log(1 + f_star) + (1 - win_prob) * log(1 - f_star)     AS growth_optimum
    FROM
    (
        SELECT arrayJoin([0, 10, 25, 40, 50]) AS held_back_pct
    )
)
ORDER BY held_back_pct
Run this yourself

The exact figures for the coin above agree. Its growth rate is 0.55 * ln(1.10) + 0.45 * ln(0.90), about 0.0050 per bet at f = 0.10, and about 0.0047 at f = 0.075 with a quarter held back. The haircut lands near 6 percent, as the square rule predicts.

A fixed scenario you can run

The listing below is a complete scenario in the Python standard library. Nothing is random and nothing is downloaded. The arrival schedule, the holding period and every win and loss are fixed in the source, so the output is identical on every machine.

Sixty signals land across 120 sessions. Quiet sessions carry one signal every fifth session. Four busy stretches fire two signals a session for five sessions running, and the signals inside a stretch share one outcome apart from a single one that runs against it, the conservative assumption the correction encodes. Thirty-three of the 60 win, the 55 percent the edge assumes.

# Kelly sizing with asynchronous signals.
# Standard library only: no randomness, no external data, no network.
from math import log

HORIZON = 120        # sessions in the scenario
HOLD = 10            # sessions a position stays open
WIN_PROB = 0.55      # the repeated even-money bet the strategy makes
F_STAR = 2 * WIN_PROB - 1                 # textbook single-bet Kelly, 0.10

CLUSTERS = ((16, 20), (46, 50), (76, 80), (106, 110))   # the busy stretches
CLUSTER_SIGN = (1, -1, 1, -1)   # the one thing every signal in a cluster shares
QUIET_EVERY = 5                 # one signal every fifth session otherwise

schedule = []        # (arrival_session, outcome), +1 a win and -1 a loss
quiet_seen = 0
for day in range(1, HORIZON + 1):
    inside = [i for i, (a, b) in enumerate(CLUSTERS) if a <= day <= b]
    if inside:
        schedule.append((day, CLUSTER_SIGN[inside[0]]))
        schedule.append((day, CLUSTER_SIGN[inside[0]]))   # two per busy session
    elif day % QUIET_EVERY == 0:
        schedule.append((day, -1 if quiet_seen % 3 == 0 else 1))
        quiet_seen += 1

for _, last_session in CLUSTERS:      # one signal per cluster runs against it
    i = max(j for j, (d, _) in enumerate(schedule) if d == last_session)
    schedule[i] = (schedule[i][0], -schedule[i][1])

assert len(schedule) == 60
assert sum(1 for _, o in schedule if o > 0) == 33      # 55 percent of 60

ARRIVAL_RATE = len(schedule) / HORIZON     # lambda, signals per session
EXPECTED_OPEN = ARRIVAL_RATE * HOLD        # lambda * d


def run(fraction):
    capital, live = 1.0, []      # live: (exit_session, stake, outcome)
    peak_open, peak_use = 0, 0.0
    for day in range(1, HORIZON + HOLD + 1):
        for exit_session, stake, outcome in live:
            if exit_session == day:
                capital += stake * outcome
        live = [p for p in live if p[0] != day]
        for arrival, outcome in schedule:
            if arrival == day:
                live.append((day + HOLD, fraction * capital, outcome))
        peak_open = max(peak_open, len(live))
        peak_use = max(peak_use, sum(s for _, s, _ in live) / capital)
    return capital, peak_open, peak_use


runs = (('full f*', run(F_STAR)),
        ('half f*', run(F_STAR / 2)),
        ('f*/(lam*d)', run(F_STAR / EXPECTED_OPEN)))
for name, (capital, peak_open, peak_use) in runs:
    print(f'{name:<11} log wealth {log(capital):+.4f}'
          f'  peak open {peak_open:2d}  peak capital used {peak_use:.2f}')

naive, halved, corrected = (r for _, r in runs)
assert naive[1] == halved[1] == corrected[1] > 2 * EXPECTED_OPEN
assert naive[2] > 1.0        # full Kelly asks for capital the account lacks
assert corrected[2] <= 1.0   # the corrected stake always fits
assert log(corrected[0]) > log(halved[0]) > log(naive[0])

Read the assertions at the bottom first. Peak overlap is the same 11 positions under all three rules, since the schedule does not depend on the stake. Under the full fraction, peak commitment exceeds the account. Under the corrected fraction it never does. And the corrected fraction ends with the highest log wealth of the three, ahead of the halved fraction, which is ahead of the full one. On this path the corrected run finishes above its starting capital, half Kelly close to flat, and the full fraction well below.

The tests check ordering and the capital constraint rather than exact floats, so the file survives interpreter churn. The scenario is synthetic, and it hands the strategy an edge it knows exactly.

Where the correction is too conservative

Dividing by expected concurrency assumes open positions rise and fall together. Where they genuinely do not, the aggregate can be larger: n independent positions of f* each carry n times the variance of one f* position, while a single position of n * f* carries n squared times. The correction is a floor on caution rather than a law.

Relaxing it means estimating correlations, which is the harder problem. A robust Kelly built on an estimated covariance matrix often trails plain equal weighting out of sample, the case made in when equal weight beats optimization. The concurrency correction asks for two counts instead: signals per session, and sessions held. Both come off a trade log without a single estimated parameter.

Sizing on realized risk rather than on a fraction of capital, as volatility targeting does, moves the same question into different units. The target applies to the whole portfolio, and each new signal has to fit inside a budget the open positions have already spent.

FAQ

What is the Kelly criterion in one sentence?

It is the constant fraction of capital that maximizes the long-run growth rate of a repeated bet with a known edge. For an even-money bet won with probability p, that fraction is 2p - 1, and staking more than it lowers growth while raising the swings.

How is Kelly sizing adjusted when signals overlap?

The expected-concurrency correction divides the single-bet fraction by the average number of positions open at once, which is the arrival rate times the holding period. One signal every other session with a ten-session hold gives five open on average, and a per-signal stake of a fifth of the textbook fraction.

Why does full Kelly on every signal overcommit capital?

Each open position holds its stake until its exit, and existing stakes do not shrink when new signals arrive. At the busiest moment the number open times the fraction can exceed 100 percent of the account, which no cash balance funds.

What does holding a cash reserve for future signals cost?

Near the Kelly optimum the growth curve is flat, and the price of holding back a share of the risk budget is that share squared. A quarter kept in reserve costs about 6 percent of the growth rate. Half kept back costs 25 percent.

Is half Kelly enough to fix the overlap problem?

Halving the fraction lowers the peak commitment, and it leaves that peak tied to the arrival pattern rather than to the sizing rule. In the scenario above, half Kelly still runs more than twice the aggregate exposure the single-bet arithmetic described.


Everything above runs on the standard library and a fixed schedule. The two inputs the correction needs, arrival rate and holding period, are counts from a trade log rather than estimates. Bring the same question in plain English to the Strasmore terminal to measure them against live names.