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, ABC

Abstract 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 loss plus transaction_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 evaluate is 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 own evaluate() 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 contract keeks.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], and sum(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:

tuple of float

Raises:

ValueError – If the probability vector is malformed (empty, non-finite, negative, or summing above 1 + PROBABILITY_SUM_TOLERANCE), if current_bankroll is 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:

float

Raises:

ValueError – If current_bankroll is 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 fraction F spread across the legs can lose at most F * (loss + transaction_cost_rate) of the bankroll - the worst leg’s charge being the binding one. Keeping that worst case within the bankroll requires F <= current_bankroll / (loss + transaction_cost_rate); expressed as a proportion of the bankroll the bankroll term cancels, so for a positive bankroll this is exactly min(1.0, 1 / (loss + transaction_cost_rate)). Every leg shares one scalar loss and transaction_cost_rate, so the worst leg’s per-leg bound is also the aggregate bound - the same value keeks.binary_strategies.base.BaseStrategy.get_max_safe_bet returns for a single binary bet with the same charges. A non-positive bankroll has no safe stake at all, so the answer there is 0.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: BaseMultiOutcomeStrategy

Kelly 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) where a_i = payoff_i - 1 - transaction_cost_rate is the net win per unit staked on leg i (the payoffs are decimal odds: a winning leg pays its payoff times its stake, stake included) and l = loss + transaction_cost_rate is 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 MultiOutcomeKellyCriterion computes it with keeks.binary_strategies.KellyCriterion (including its min_probability gate) 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-15 of a stake unit. The min_probability gate is a KellyCriterion feature 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 payoffs and loss, so 0.01 means one percent of the stake. Note this differs in unit from the simulators’ flat per-settlement fee_per_bet flat 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 payoffs is not a non-empty one-dimensional sequence of finite numbers greater than 0, if loss or transaction_cost_rate is not a finite nonnegative number with loss + transaction_cost_rate > 0, or if min_probability is 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 KellyCriterion sizes 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], and sum(result) <= 1 + PROBABILITY_SUM_TOLERANCE.

Return type:

tuple of float

Raises:

ValueError – If the probability vector is malformed or its length does not match the number of payoffs, or if current_bankroll is 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: object

Simulator 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 i of probabilities is settled with leg i of payoffs.

  • 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_rate taken by strategies in keeks.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 as payoffs.

  • 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 payoffs is not a non-empty one-dimensional sequence of finite numbers greater than 0, if loss or fee_per_bet is not finite and nonnegative, if probabilities is not a valid probability vector or differs in length from payoffs, or if trials is not a nonnegative integer, or if seed is not a nonnegative integer or None.

Notes

Reproducibility contract (public behavior). With a seed, every draw comes from a private numpy.random.Generator derived as the first child of numpy.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:

  1. Stop when the bankroll is depleted (total_funds <= 0).

  2. Fire the strategy’s update_bankroll hook when it has one.

  3. Validate the stake vector returned by evaluate (one fraction per leg, each in [0, 1], sum at most 1 + PROBABILITY_SUM_TOLERANCE). A trial the strategy stakes on nothing (every fraction zero) is skipped entirely: no draw is consumed, no fee is charged.

  4. 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.

  5. 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 charged loss * stake + fee_per_bet.

  6. 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): won holds one outcome flag per leg - True for the realized winner, False for every leg that lost the market draw (declined legs included: the draw realizes the whole market), and all None on a void or push, where no leg settled and every stake refunds; realized_returns holds one signed net return per leg, as a fraction of the bankroll before the trial, with 0.0 for 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 payoffs and loss must 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 strategy is a BaseMultiOutcomeStrategy whose payoffs or loss differ 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: object

Simulator 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). Bet m of the portfolio is settled with bet m of 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_rate taken by strategies in keeks.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 bets is empty or not a sequence of valid (probability,     payoff, loss) triples, if fee_per_bet is not finite and nonnegative, if trials is not a nonnegative integer, or if seed is not a nonnegative integer or None.

Notes

Reproducibility contract (public behavior). With a seed, every draw comes from a private numpy.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:

  1. Stop when the bankroll is depleted (total_funds <= 0).

  2. Fire the strategy’s update_bankroll hook when it has one.

  3. Validate the stake vector returned by evaluate (one fraction per bet, each in [0, 1], sum at most 1 + PROBABILITY_SUM_TOLERANCE). This is the portfolio’s aggregate-exposure check: stakes are the fractions times bettable_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 a ValueError, 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.

  4. 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 < probability wins. A bet staked at probability 0 never wins; at probability 1 it always wins. Declined bets draw nothing.

  5. Settle the batch net. Each staked bet wins (payoff - 1) * stake - fee_per_bet or loses loss * 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.

  6. 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): won holds one entry per bet - True when the bet’s draw won, False when it lost, None for declined bets - and realized_returns holds one signed net return per bet, as a fraction of the bankroll before the trial, with 0.0 for 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 payoffs must match the bets’ payoff multipliers and the loss every 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 strategy is a BaseMultiOutcomeStrategy whose payoffs or loss differ 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 same record_settlement hook calls.

  • Streams are deterministic derivatives of the seed. RepeatedMultiOutcomeSimulator derives its single settlement stream from the first child of numpy.random.SeedSequence(seed).spawn(1); PortfolioSimulator derives 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.