Multi-Outcome Strategies and Simulators¶
Multi-outcome tools generalize the binary strategy surface to mutually exclusive markets — a 1X2 football match (home win, draw, away win), for instance — where exactly one leg settles per round, and to portfolios of independent binary bets — M bookmaker markets all settling at once.
The strategy contract is the binary contract made vector-valued: a strategy
receives one probability per leg and returns one stake fraction of the
bankroll per leg. Probabilities are validated by
keeks.utils.validate_probabilities() — finite, nonnegative, summing to
no more than one within tolerance — and probability mass below one models a
void or push round on which no leg settles. The returned fractions are each
in [0, 1] and sum to at most one: the legs together can never promise
more of the bankroll than it holds. Payoffs are decimal odds (a winning leg
pays its payoff multiplier times its stake, stake included), and strategies
price with a per-unit fractional transaction_cost_rate while the simulators
charge a flat per-settlement fee_per_bet fee — the two units are
documented at every entry point and are never interchangeable.
The Strategy Contract¶
- class keeks.multi_outcome.base.BaseMultiOutcomeStrategy(payoffs: Sequence[float], loss: float, transaction_cost_rate: float = 0)[source]¶
Bases:
ParameterMixin,ABCAbstract base class for all multi-outcome betting strategies.
This class defines the interface that all multi-outcome betting strategies must implement. A multi-outcome strategy sizes stakes on the mutually exclusive legs of one market - a 1X2 football match, for instance - in a single decision: exactly one leg settles, every losing leg’s stake is charged
lossplustransaction_cost_rate, and the winning leg pays its payoff multiplier times its stake.Concrete strategy implementations should inherit from this class and implement the evaluate method, which the base then enforces: every concrete
evaluateis wrapped so its returned stake vector is validated through_validate_stake_fractions()before the caller sees it - a subclass returning contract-violating stakes fails its ownevaluate()with the validator’s message plus the returned vector, instead of passing silently until the simulator’s boundary gate. The same holds for the allocation layer’s weight contract.- abstract evaluate(probabilities: Sequence[float], current_bankroll: float) tuple[float, ...][source]¶
Evaluate the strategy for a given probability vector.
- Parameters:
probabilities (sequence of float) – The probability of each mutually exclusive leg, in leg order. Must be a non-empty one-dimensional sequence of finite, nonnegative numbers whose sum is at most
1 + PROBABILITY_SUM_TOLERANCE- the contractkeeks.utils.validate_probabilities()enforces. Probability mass below one models a void or push outcome on which no leg settles.current_bankroll (float) – The current bankroll to use for calculations.
- Returns:
One stake fraction of the bankroll per leg:
len(result) == len(probabilities), every element finite and within[0, 1], andsum(result) <= 1 + PROBABILITY_SUM_TOLERANCE. Implementations accept any sequence input and return a tuple; the base class validates the returned vector through_validate_stake_fractions()before the caller sees it, so implementations need not (but may - it is idempotent) validate internally.- Return type:
- Raises:
ValueError – If the probability vector is malformed (empty, non-finite, negative, or summing above
1 + PROBABILITY_SUM_TOLERANCE), ifcurrent_bankrollis not finite, or if the stake vector leaving this strategy violates the contract above.
- get_max_safe_total_bet(current_bankroll: float) float[source]¶
Calculate the maximum safe aggregate stake across all legs.
- Parameters:
current_bankroll (float) – The current bankroll to use for calculations.
- Returns:
The maximum safe total stake size as a proportion of bankroll. Zero when there is nothing left to stake (
current_bankroll <= 0).- Return type:
- Raises:
ValueError – If
current_bankrollis not finite.
Notes
Under net settlement exactly one leg of the market wins and every losing leg’s stake is charged
loss + transaction_cost_rate, so a total stake fractionFspread across the legs can lose at mostF * (loss + transaction_cost_rate)of the bankroll - the worst leg’s charge being the binding one. Keeping that worst case within the bankroll requiresF <= current_bankroll / (loss + transaction_cost_rate); expressed as a proportion of the bankroll the bankroll term cancels, so for a positive bankroll this is exactlymin(1.0, 1 / (loss + transaction_cost_rate)). Every leg shares one scalarlossandtransaction_cost_rate, so the worst leg’s per-leg bound is also the aggregate bound - the same valuekeeks.binary_strategies.base.BaseStrategy.get_max_safe_betreturns for a single binary bet with the same charges. A non-positive bankroll has no safe stake at all, so the answer there is0.0.
Multi-Outcome Kelly Criterion¶
- class keeks.multi_outcome.kelly.MultiOutcomeKellyCriterion(payoffs: Sequence[float], loss: float, transaction_cost_rate: float = 0, min_probability: float = 0.5)[source]¶
Bases:
BaseMultiOutcomeStrategyKelly criterion for mutually exclusive markets with N legs.
Sizes one stake fraction per leg to maximize the expected log growth of the bankroll:
sum_i p_i * log(1 + a_i f_i - l * sum_{k != i} f_k)wherea_i = payoff_i - 1 - transaction_cost_rateis the net win per unit staked on legi(the payoffs are decimal odds: a winning leg pays its payoff times its stake, stake included) andl = loss + transaction_cost_rateis the per-unit charge on every losing leg. Exactly one leg realizes per round, so staking two legs is a hedge inside one market, not two independent bets.Two legs are special: for a fully priced book (the probabilities sum to one) where at most one leg prices a positive binary Kelly stake - which holds for every consistent two-outcome book - the joint optimum is that leg’s exact binary Kelly fraction and zero on the other, so
MultiOutcomeKellyCriterioncomputes it withkeeks.binary_strategies.KellyCriterion(including itsmin_probabilitygate) and reproduces the binary strategy exactly. When the probabilities leave void mass the delegation no longer applies - binary Kelly would count the refund branch as a loss - and the joint solver below runs instead; the same happens when both legs price a positive binary stake (an inconsistent, arbitrage two-sided book).At three legs and up the objective has no closed form in general and is maximized by deterministic coordinate ascent, converged to roughly
1e-15of a stake unit. Themin_probabilitygate is aKellyCriterionfeature of the two-leg fallback only; the general optimizer sizes every leg that carries a net win on its own merits, including legs below even odds.- Parameters:
payoffs (sequence of float) – The decimal-odds multiplier paid by each mutually exclusive leg, in leg order, on top of the stake’s own return. Every payoff must be finite and greater than 0; a leg whose payoff cannot beat its costs (
payoff <= 1 + transaction_cost_rate) is never staked.loss (float) – The loss multiplier applied to every losing leg’s stake.
transaction_cost_rate (float, optional) – The transaction cost as a fraction of each unit staked, by default 0. This is a per-unit fractional cost that enters the sizing formulas alongside
payoffsandloss, so0.01means one percent of the stake. Note this differs in unit from the simulators’ flat per-settlementfee_per_betflat fee.min_probability (float, default=0.5) – The minimum leg probability for the two-leg binary fallback to place a stake, mirroring
KellyCriterion’s lossy gate. It has no effect at three legs and up.
- Raises:
ValueError – If
payoffsis not a non-empty one-dimensional sequence of finite numbers greater than 0, iflossortransaction_cost_rateis not a finite nonnegative number withloss + transaction_cost_rate > 0, or ifmin_probabilityis outside[0, 1].
Examples
A two-leg market reproduces the binary Kelly stake exactly. Leg 1 at decimal odds 3.0 is a net win of 2.0 per unit staked - the same bet
KellyCriterionsizes from its net payoff:>>> strategy = MultiOutcomeKellyCriterion(payoffs=(3.0, 1.5), loss=1.0) >>> strategy.evaluate([0.5, 0.5], 1000.0) (0.25, 0.0) >>> KellyCriterion(payoff=2.0, loss=1.0, transaction_cost_rate=0).evaluate(0.5, 1000.0) 0.25
A 1X2 market where only leg 0 has a standalone edge still splits the stake: leg 1’s odds are rich enough that once leg 0 is staked its coupled marginal value turns positive, while no-edge leg 2 stays at zero. Only the joint solve sees the coupling - the per-leg binary answer for leg 0 alone is the first coordinate:
>>> strategy = MultiOutcomeKellyCriterion(payoffs=(3.2, 3.4, 2.4), loss=1.0) >>> stakes = strategy.evaluate([0.42, 0.27, 0.28], 1000.0) >>> tuple(round(stake, 6) for stake in stakes) (0.203681, 0.06253, 0.0) >>> KellyCriterion(payoff=2.2, loss=1.0, transaction_cost_rate=0, min_probability=0.4).evaluate(0.42, 1000.0) 0.15636363636363632
- evaluate(probabilities: Sequence[float], current_bankroll: float) tuple[float, ...][source]¶
Calculate the log-growth optimal stake fraction per leg.
- Parameters:
probabilities (sequence of float) – The probability of each mutually exclusive leg, in leg order. Must be a valid probability vector per
keeks.utils.validate_probabilities()and match the number of payoffs.current_bankroll (float) – The current bankroll amount. The optimal fractions do not depend on its size; a nonpositive bankroll has no safe stake at all.
- Returns:
One stake fraction per leg:
len(result) == len(probabilities), every element finite and within[0, 1], andsum(result) <= 1 + PROBABILITY_SUM_TOLERANCE.- Return type:
- Raises:
ValueError – If the probability vector is malformed or its length does not match the number of payoffs, or if
current_bankrollis not finite.
Repeated Multi-Outcome Simulator¶
- class keeks.multi_outcome.simulators.RepeatedMultiOutcomeSimulator(payoffs: Sequence[float], loss: float, fee_per_bet: float, probabilities: _Buffer | _SupportsArray[dtype[Any]] | _NestedSequence[_SupportsArray[dtype[Any]]] | bool | int | float | complex | str | bytes | _NestedSequence[bool | int | float | complex | str | bytes], trials: int = 1000, seed: int | None = None)[source]¶
Bases:
objectSimulator for multi-outcome strategies on a fixed mutually exclusive market.
Every trial bets on the legs of one market - a 1X2 football match, for instance - with the same win probabilities, payoff multipliers, and loss multiplier. Exactly one leg realizes per trial: the strategy returns one stake fraction per leg, one uniform draw picks the realized leg, the realized leg settles as a win, and every other leg the strategy staked settles as a loss.
- Parameters:
payoffs (sequence of float) – The payoff multiplier for each mutually exclusive leg, in leg order. Every payoff must be finite and greater than 0. Leg
iofprobabilitiesis settled with legiofpayoffs.loss (float) – The loss multiplier applied to every losing leg’s stake.
fee_per_bet (float) – The flat fee charged once per settled leg, regardless of outcome. This is an absolute bankroll amount, not a fraction of the stake: it is subtracted from the winning leg’s settlement and added to every losing leg’s. Legs the strategy declines (zero stake) settle nothing and pay no fee. Note this differs in unit from the singular
transaction_cost_ratetaken by strategies inkeeks.multi_outcome, which is a per-unit fraction of the stake used for sizing.probabilities (sequence of float) – The fixed probability of each leg for all trials. Must be a non-empty one-dimensional sequence of finite nonnegative numbers summing to at most
1 + PROBABILITY_SUM_TOLERANCE; probability mass below one is the chance of a void or push round on which no leg settles. Must have the same length aspayoffs.trials (int, default=1000) – The number of betting trials to simulate.
seed (int or None, default=None) – Seed for the private settlement stream. When omitted, numpy’s global generator drives the draws and no replay is promised.
- Raises:
ValueError – If
payoffsis not a non-empty one-dimensional sequence of finite numbers greater than 0, iflossorfee_per_betis not finite and nonnegative, ifprobabilitiesis not a valid probability vector or differs in length frompayoffs, or iftrialsis not a nonnegative integer, or ifseedis not a nonnegative integer orNone.
Notes
Reproducibility contract (public behavior). With a
seed, every draw comes from a privatenumpy.random.Generatorderived as the first child ofnumpy.random.SeedSequence(seed).spawn(1). Rerunning a seeded construction replays byte-identically: the same bankroll history and the same hook calls. Spawned children, not the raw seed, power the streams of this API family, so each stream owns an independent child seed and a stream added later cannot shift the settlement stream’s draws. Without a seed the draws come from numpy’s global generator.Trial semantics. Each trial runs the same flow as the binary simulators, generalized to N legs:
Stop when the bankroll is depleted (
total_funds <= 0).Fire the strategy’s
update_bankrollhook when it has one.Validate the stake vector returned by
evaluate(one fraction per leg, each in[0, 1], sum at most1 + PROBABILITY_SUM_TOLERANCE). A trial the strategy stakes on nothing (every fraction zero) is skipped entirely: no draw is consumed, no fee is charged.Draw one uniform and read it against the cumulative probabilities: the realized leg is the first whose cumulative band contains the draw, and a draw above the total probability mass is a void or push. A void or push round refunds every stake: no leg settles, no fee is charged, and the trial’s draw is still consumed.
Otherwise settle the batch in leg order. Stakes are computed once from the bankroll as it stood when the trial began, so settlements within the batch never resize later legs. Each staked leg settles through the same net-settlement flow the binary simulators use: the realized leg nets
(payoff - 1) * stake - fee_per_bet(deposited, or withdrawn when the fee dominates) and every other staked leg is chargedloss * stake + fee_per_bet.When a bankroll safeguard refuses a settlement (
RuinError), that leg’s settlement leaves the bankroll unchanged and reports a 0.0 return, the remaining legs of the batch still settle, and the simulation stops after the batch completes - never mid-batch.
The ``record_settlement`` hook. Strategies expose an N-ary settlement hook with the signature
record_settlement(won, realized_returns):wonholds one outcome flag per leg -Truefor the realized winner,Falsefor every leg that lost the market draw (declined legs included: the draw realizes the whole market), and allNoneon a void or push, where no leg settled and every stake refunds;realized_returnsholds one signed net return per leg, as a fraction of the bankroll before the trial, with0.0for legs that settled nothing: declined legs, void rounds, and settlements a safeguard refused. The hook fires once per staked trial, including voids, after the batch settles.Examples
>>> from keeks.multi_outcome.simulators import RepeatedMultiOutcomeSimulator >>> simulator = RepeatedMultiOutcomeSimulator( ... payoffs=(3.2, 3.4, 2.4), ... loss=1.0, ... fee_per_bet=0.0, ... probabilities=(0.42, 0.27, 0.28), ... trials=10_000, ... seed=42, ... ) >>> simulator.payoffs (3.2, 3.4, 2.4) >>> simulator.probabilities array([0.42, 0.27, 0.28])
- evaluate_strategy(strategy: BaseMultiOutcomeStrategy, bankroll: BankRoll) None[source]¶
Evaluate a multi-outcome strategy over multiple trials on a fixed market.
For each trial, the strategy is evaluated with the fixed probability vector, one leg realizes, and the bankroll is updated through the settlement of the batch. The simulation stops early if the bankroll is depleted (bankruptcy) or a settlement batch trips a bankroll safeguard (after that batch completes - see the class notes).
- Parameters:
strategy (BaseMultiOutcomeStrategy) – The betting strategy to evaluate. Its
payoffsandlossmust match this simulator’s.bankroll (BankRoll) – The bankroll to use for the simulation.
- Returns:
The bankroll object is updated in-place with the results of the simulation.
- Return type:
None
- Raises:
ValueError – If
strategyis aBaseMultiOutcomeStrategywhosepayoffsorlossdiffer from this simulator’s, since it would then size stakes against different odds than the ones the simulator settles at, or if the strategy returns an invalid stake vector.
Portfolio Simulator¶
- class keeks.multi_outcome.simulators.PortfolioSimulator(bets: Sequence[tuple[float, float, float]], fee_per_bet: float = 0.0, trials: int = 1000, seed: int | None = None)[source]¶
Bases:
objectSimulator for a portfolio of simultaneous independent binary bets.
Every trial places M independent binary bets against one bankroll snapshot - M bookmaker markets all settling at once, for instance. Each bet carries its own win probability, payoff multiplier, and loss multiplier, every staked bet settles win or lose on its own draw, and the batch’s settlements net into exactly one bankroll transaction. This is the portfolio counterpart of
RepeatedMultiOutcomeSimulator: there exactly one leg of one market realizes per trial, here every staked bet settles.- Parameters:
bets (sequence of (probability, payoff, loss) triples) – One triple per independent bet: the win probability (finite in
[0, 1]), the payoff multiplier (finite, greater than 0), and the loss multiplier (finite, nonnegative). Betmof the portfolio is settled with betmof the stake vector the strategy returns. The win probabilities are independent events, so unlike a market’s probability vector they carry no sum constraint - three bets at 0.5 each describe three separate markets, not a partition of one.fee_per_bet (float, default=0.0) – The flat fee charged once per settled bet, regardless of outcome. This is an absolute bankroll amount, not a fraction of the stake: it is subtracted from a winning bet’s settlement and added to a losing bet’s. Bets the strategy declines (zero stake) settle nothing and pay no fee. Note this differs in unit from the singular
transaction_cost_ratetaken by strategies inkeeks.multi_outcome, which is a per-unit fraction of the stake used for sizing.trials (int, default=1000) – The number of betting trials to simulate.
seed (int or None, default=None) – Seed for the private settlement streams. When omitted, numpy’s global generator drives the draws and no replay is promised.
- Raises:
ValueError – If
betsis empty or not a sequence of valid(probability, payoff, loss)triples, iffee_per_betis not finite and nonnegative, iftrialsis not a nonnegative integer, or ifseedis not a nonnegative integer orNone.
Notes
Reproducibility contract (public behavior). With a
seed, every draw comes from a privatenumpy.random.Generator. Each generator is derived from the simulator seed and a stable BLAKE2 digest of the validated bet’s exact three IEEE-754 float values, so a heterogeneous surviving bet keeps its stream when other bets are inserted, removed, or reordered. Identical tuples use their zero-based occurrence ordinal in portfolio order to receive independent deterministic streams; because those bets are indistinguishable, reordering identical duplicates does not attach an ordinal to a particular duplicate. Rerunning a seeded construction replays byte-identically: the same bankroll history and the same hook calls. Without a seed the draws come from numpy’s global generator.Trial semantics. Each trial runs the same flow as the binary simulators, generalized to a portfolio:
Stop when the bankroll is depleted (
total_funds <= 0).Fire the strategy’s
update_bankrollhook when it has one.Validate the stake vector returned by
evaluate(one fraction per bet, each in[0, 1], sum at most1 + PROBABILITY_SUM_TOLERANCE). This is the portfolio’s aggregate-exposure check: stakes are the fractions timesbettable_funds, so the total staked across the portfolio can never exceed the bettable funds for the trial, and a vector over that bound is rejected - the run aborts with aValueError, the stakes are never silently reduced to fit. A trial the strategy stakes on nothing (every fraction zero) is skipped entirely: no draw is consumed, no fee is charged.Draw one uniform per staked bet in bet order, from that bet’s own stream, and read it against the bet’s win probability:
outcome < probabilitywins. A bet staked at probability 0 never wins; at probability 1 it always wins. Declined bets draw nothing.Settle the batch net. Each staked bet wins
(payoff - 1) * stake - fee_per_betor losesloss * stake + fee_per_bet; the signed amounts sum to one net delta and the bankroll receives exactly one deposit (net gain) or withdrawal (net loss) - one history entry per settled batch. Stakes are computed once from the bankroll as it stood when the trial began, so no settlement ever resizes a later bet.The drawdown check is batch-level: because the batch settles through one transaction, the bankroll’s safeguards (bankruptcy,
max_transaction_loss) evaluate the batch’s net total once - not once per bet. When a safeguard refuses the net settlement (RuinError), the whole batch leaves the bankroll unchanged, every bet reports a 0.0 return, and the simulation stops after the batch completes - never mid-portfolio.
The ``record_settlement`` hook. Strategies expose an N-ary settlement hook with the signature
record_settlement(won, realized_returns):wonholds one entry per bet -Truewhen the bet’s draw won,Falsewhen it lost,Nonefor declined bets - andrealized_returnsholds one signed net return per bet, as a fraction of the bankroll before the trial, with0.0for declined bets and for settlements a safeguard refused. The hook fires once per staked trial, after the batch settles.Examples
>>> from keeks.multi_outcome.simulators import PortfolioSimulator >>> simulator = PortfolioSimulator( ... bets=[(0.55, 2.0, 1.0), (0.45, 3.0, 1.0), (0.30, 2.4, 1.0)], ... fee_per_bet=0.0, ... trials=10_000, ... seed=42, ... ) >>> simulator.bets[0] (0.55, 2.0, 1.0) >>> simulator.probabilities.tolist() [0.55, 0.45, 0.3]
- evaluate_strategy(strategy: BaseMultiOutcomeStrategy, bankroll: BankRoll) None[source]¶
Evaluate a multi-outcome strategy over a portfolio of binary bets.
For each trial, the strategy is evaluated with the bets’ win probabilities, every staked bet settles on its own draw, and the batch’s net result is applied to the bankroll in one transaction. The simulation stops early if the bankroll is depleted or a settlement batch trips a bankroll safeguard (after that batch completes - see the class notes).
- Parameters:
strategy (BaseMultiOutcomeStrategy) – The betting strategy to evaluate. One stake fraction per bet; for ABC strategies the
payoffsmust match the bets’ payoff multipliers and thelossevery bet’s loss multiplier.bankroll (BankRoll) – The bankroll to use for the simulation.
- Returns:
The bankroll object is updated in-place with the results of the simulation.
- Return type:
None
- Raises:
ValueError – If
strategyis aBaseMultiOutcomeStrategywhosepayoffsorlossdiffer from the bets’ odds, since it would then size stakes against different odds than the ones the simulator settles at, or if the strategy returns an invalid stake vector.
Seeding and Reproducibility¶
Both simulators treat seeding as public, testable behavior — the same contract the binary simulators introduced in v0.6.0, generalized to per-stream spawned children:
With a ``seed``, a run replays byte-identically. Every draw comes from a private
numpy.random.Generator, never from numpy’s global state, so rerunning a seeded construction reproduces the same bankroll history and the samerecord_settlementhook calls.Streams are deterministic derivatives of the seed.
RepeatedMultiOutcomeSimulatorderives its single settlement stream from the first child ofnumpy.random.SeedSequence(seed).spawn(1);PortfolioSimulatorderives each stream from the seed and a stable BLAKE2 digest of the validated bet’s exact three IEEE-754 float values. Adding, removing, or reordering heterogeneous bets therefore never shifts a surviving bet’s stream. Duplicate identical tuples use zero-based occurrence ordinals in portfolio order to receive independent deterministic streams. Since identical bets are indistinguishable, reordering duplicates need not preserve a distinguishable stream for a particular duplicate.Without a seed, no replay is promised. The draws then come from numpy’s global generator, matching the unseeded binary simulators.
The test suite pins this contract with bit-exactness golden files for both simulators, so a change that alters a seeded stream fails CI before it reaches a user.
A runnable 1X2 example — the multi-outcome Kelly criterion against flat and
favorite-only staking on a home-draw-away market — ships as
examples/multi_outcome_1x2.py in the repository.