edgekit

edgekit.allocate

Capital allocation across strategies as a multi-armed bandit — explore the streams you are unsure about, exploit the ones that are working, and measure the price of not knowing in advance. Thompson sampling, UCB, EWMA weighting, and the regret curve that scores them all.

What's inside. Three weighting rules over a panel of strategy returns — thompson (posterior-probability-of-being-best, the Bayesian explorer), ucb (mean plus an optimism bonus, the frequentist one) and ewma_weights (recency-weighted risk-adjusted momentum, the pragmatist) — plus regret, the cumulative gap to the best-in-hindsight arm that tells you whether any of the cleverness paid.

Convention: one column per strategy, weights sum to 1

Every function takes a pd.DataFrame of per-period returns with one column per strategy (R-multiples or fractional returns — just be consistent). Weight vectors come back as a pd.Series indexed by column name, non-negative and summing to 1. These are relative allocations: scale the whole book with edgekit.sizing, and remember the weights are only as causal as the window you compute them on — allocate at t using returns through t - 1.

Weighting rules#

Three personalities for the same job — pick by how much history you have and how much you trust it:

RuleExplores viaBest when
thompsonposterior uncertaintyshort / uneven histories; you want principled exploration
ucboptimism bonus (c * se)you want a deterministic, tunable exploration knob
ewma_weightsrecency (forgetting)regimes rotate and old evidence should expire

thompson#

Thompson sampling: put a Normal posterior on each strategy’s mean return (known-variance approximation — the posterior narrows as 1/sqrt(n)), draw from all posteriors many times, and weight each strategy by how often it wins the draw. Short, noisy histories keep wide posteriors and still get capital; long, mediocre ones get starved. Exploration falls out of the arithmetic — no epsilon knob to tune.

thompson(returns: pd.DataFrame, n_draws: int = 2000, rng=None) -> pd.Series
ParamTypeDefaultMeaning
returnspd.DataFramePer-period returns, one column per strategy.
n_drawsint2000Posterior draws used to estimate P(best).
rngnp.random.GeneratorNoneSeeded generator; defaults to bootstrap_rng().

Returns: a pd.Seriesof weights (each strategy’s probability of being the best arm), non-negative, summing to 1. Deterministic given a seeded rng.

import edgekit as ek
from edgekit.core import bootstrap_rng
w = ek.allocate.thompson(strat_rets, rng=bootstrap_rng())
w.sort_values(ascending=False)   # P(each strategy is the best one)
Thompson posteriors chart
Posterior distributions over each strategy's mean — overlap is where exploration budget goes.

ucb#

Upper Confidence Bound: score each strategy at roughly mean + c * se — its mean return plus an optimism bonus of c standard errors — then floor negative scores at zero and normalise. “Optimism in the face of uncertainty”: unproven strategies ride their upside until the data shrinks it. Larger c explores harder; c = 0 is pure greedy exploitation.

ucb(returns: pd.DataFrame, c: float = 2.0) -> pd.Series
ParamTypeDefaultMeaning
returnspd.DataFramePer-period returns, one column per strategy.
cfloat2.0Exploration coefficient — standard errors of optimism.

Returns: a pd.Series of normalised non-negative weights (strategies whose upper bound is still negative get exactly 0).

w = ek.allocate.ucb(strat_rets, c=2.0)
(w == 0).sum()   # arms whose optimistic case is still a loser

ewma_weights#

The pragmatist’s rule: exponentially-weighted mean return over EWMA volatility — a recency-weighted Sharpe — floored at zero and normalised. No posterior, no bonus term, just “give capital to what has recently worked, per unit of recent risk”. The halflife is the whole personality of the rule: 63 days forgets a quarter ago; shorter chases, longer forgives.

ewma_weights(returns: pd.DataFrame, halflife: int = 63) -> pd.Series
ParamTypeDefaultMeaning
returnspd.DataFramePer-period returns, one column per strategy.
halflifeint63EWMA halflife in periods (63 ~ a quarter of daily data).

Returns: a pd.Series of weights proportional to max(ewma_mean, 0) / ewma_vol, summing to 1 (strategies with negative recent mean get 0).

w = ek.allocate.ewma_weights(strat_rets, halflife=63)
# re-run on a schedule; weights drift as regimes rotate
weights_by_month = {m: ek.allocate.ewma_weights(strat_rets.loc[:m]) for m in month_ends}

Scoring the allocator#

regret#

Regret is the honest scoreboard for any allocation rule: the cumulative return of the single best strategy in hindsightminus what your weights actually realised. Every allocator pays some regret — hindsight is unfair — but a good one’s regret curve flattens as it learns, while a bad one’s keeps climbing at a constant slope. Pass no weights and it scores the equal-weight portfolio, the benchmark any clever rule must beat.

regret(returns: pd.DataFrame, weights: pd.DataFrame | None = None) -> pd.Series
ParamTypeDefaultMeaning
returnspd.DataFramePer-period returns, one column per strategy.
weightspd.DataFrame | NoneNonePer-period weights (same shape); equal-weight if None.

Returns: a pd.Series — the cumulative regret curve (best-in-hindsight cumulative return minus realised), indexed like the input.

reg_eq = ek.allocate.regret(strat_rets)                 # equal-weight baseline
reg_th = ek.allocate.regret(strat_rets, weights=W_th)   # your Thompson weights
# flattening curve = the allocator locked onto the right arm
(reg_th.diff().tail(63).mean(), reg_eq.diff().tail(63).mean())
bandit regret chart
Cumulative regret: Thompson flattens as it learns; equal-weight pays a constant toll.
!Bandit allocation cannot rescue dead strategies

These rules divide capital among streams that have already survived the gauntlet. Feeding an allocator a panel of unvetted backtests just concentrates capital in whichever one is most overfit — the bandit machinery will happily “learn” noise, and its regret will look great right up until the chosen arm reverts. Validate first, allocate second, and monitor with edgekit.monitor after.

See also#