Source code for keeks.multi_outcome.simulators

"""
Repeated-play simulation over mutually exclusive markets and bet portfolios.

Two simulator families share the net-settlement flow the binary simulators
use and a ``record_settlement`` hook that reports each batch's full result.
:class:`RepeatedMultiOutcomeSimulator` bets the mutually exclusive legs of
one market per trial: one categorical draw realizes exactly one leg (the
probability mass below one is a void or push on which no leg settles).
:class:`PortfolioSimulator` places M independent binary bets per trial:
every staked bet settles on its own draw and the batch nets into one
bankroll transaction.
"""

import hashlib
import operator
import struct
import warnings
from collections.abc import Sequence
from typing import TYPE_CHECKING

import numpy as np

from keeks.multi_outcome.base import BaseMultiOutcomeStrategy, _validate_stake_fractions
from keeks.utils import (
    RuinError,
    _require_finite,
    _validate_simulator_probability,
    _validate_simulator_seed,
    validate_probabilities,
)

if TYPE_CHECKING:
    from keeks.bankroll import BankRoll

__author__ = "willmcginnis"


def _validate_payoffs(payoffs):
    """
    Validate a simulator's payoff vector and return it as a float tuple.

    Parameters
    ----------
    payoffs : sequence of float
        The payoff multiplier for each mutually exclusive leg, in leg order.

    Returns
    -------
    tuple of float
        The validated payoffs, one per leg, in leg order.

    Raises
    ------
    ValueError
        If ``payoffs`` is not a non-empty one-dimensional sequence of finite
        numbers greater than 0.
    """
    try:
        payoff_array = np.asarray(payoffs, dtype=float)
    except (TypeError, ValueError) as exc:
        raise ValueError("Payoffs must be a finite sequence") from exc
    if payoff_array.ndim != 1:
        raise ValueError("Payoffs must be one-dimensional")
    if payoff_array.size == 0:
        raise ValueError("Payoffs must be non-empty")
    if not np.all(np.isfinite(payoff_array)):
        raise ValueError("Payoffs must contain only finite values")
    if np.any(payoff_array <= 0):
        raise ValueError("Payoffs must be greater than 0")
    return tuple(payoff_array.tolist())


def _validate_strategy_odds(strategy, payoffs, loss):
    """
    Reject a strategy whose odds contradict the simulator's settlement odds.

    The simulator sizes every stake through ``strategy.evaluate`` but settles
    it with its own ``payoffs`` and ``loss``, so the two models have to agree
    for the run to describe anything. Only
    :class:`keeks.multi_outcome.base.BaseMultiOutcomeStrategy` instances are
    checked; a duck-typed strategy needs no ``payoffs``/``loss`` at all and
    its compatibility stays the caller's responsibility.

    The strategy's fractional ``transaction_cost_rate`` and the simulator's flat
    ``fee_per_bet`` fee are deliberately different units and are never
    compared.

    Raises
    ------
    ValueError
        If the strategy's ``payoffs`` or ``loss`` differ from the simulator's.
    """
    if not isinstance(strategy, BaseMultiOutcomeStrategy):
        return

    if tuple(strategy.payoffs) != tuple(payoffs):
        raise ValueError(
            f"Strategy payoffs ({strategy.payoffs!r}) does not match simulator "
            f"payoffs ({tuple(payoffs)!r}); the strategy sizes each stake with "
            "its own odds while the simulator settles with the simulator's, "
            "so the two must agree."
        )
    if strategy.loss != loss:
        raise ValueError(
            f"Strategy loss ({strategy.loss!r}) does not match simulator loss "
            f"({loss!r}); the strategy sizes each stake with its own odds "
            "while the simulator settles with the simulator's, so the two "
            "must agree."
        )


[docs] class RepeatedMultiOutcomeSimulator: """ 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 :class:`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 (:class:`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]) """ def __init__( self, payoffs: Sequence[float], loss: float, fee_per_bet: float, probabilities: np.typing.ArrayLike, trials: int = 1000, seed: int | None = None, ) -> None: self.payoffs: tuple[float, ...] = _validate_payoffs(payoffs) loss = _require_finite(loss, "Loss") if loss < 0: raise ValueError("Loss must be non-negative") fee_per_bet = _require_finite(fee_per_bet, "Fee per bet") if fee_per_bet < 0: raise ValueError("Fee per bet must be non-negative") self.loss: float = loss self.fee_per_bet: float = fee_per_bet self.probabilities: np.ndarray = validate_probabilities(probabilities) if len(self.payoffs) != len(self.probabilities): raise ValueError( "Payoffs and probabilities must have the same length: " f"got {len(self.payoffs)} payoffs and " f"{len(self.probabilities)} probabilities" ) # Cumulative bands for the categorical read: leg j realizes when the # trial's uniform falls below cumulative[j] and above cumulative[j-1]; # the mass above cumulative[-1] is the void region. self._cumulative = np.cumsum(self.probabilities) try: trials = operator.index(trials) except TypeError as exc: raise ValueError("Trials must be a nonnegative integer") from exc if trials < 0: raise ValueError("Trials must be a nonnegative integer") self.trials: int = trials self.seed: int | None = _validate_simulator_seed(seed) self._outcome_rng = ( np.random.default_rng(np.random.SeedSequence(self.seed).spawn(1)[0]) if self.seed is not None else None )
[docs] def evaluate_strategy( self, strategy: BaseMultiOutcomeStrategy, bankroll: "BankRoll" ) -> None: """ 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 ------- None The bankroll object is updated in-place with the results of the simulation. 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. """ _validate_strategy_odds(strategy, self.payoffs, self.loss) # Resolve state-dependent hooks once: neither the strategy's hook set # nor the bankroll value changes between the reads within one trial. update_bankroll = getattr(strategy, "update_bankroll", None) if not callable(update_bankroll): update_bankroll = None record_settlement = getattr(strategy, "record_settlement", None) if not callable(record_settlement): record_settlement = None for _ in range(self.trials): # Stop if bankrupt total_funds = bankroll.total_funds if total_funds <= 0: break if update_bankroll is not None: update_bankroll(total_funds) # Get the proportion to bet on each leg fractions = _validate_stake_fractions( strategy.evaluate(self.probabilities, total_funds), leg_count=len(self.probabilities), ) # Only process the market if the strategy staked something (avoid # charging costs on no-bet) if not any(fractions): continue outcome = ( self._outcome_rng.random() if self._outcome_rng is not None else np.random.random() ) realized = int(np.searchsorted(self._cumulative, outcome, side="right")) realized_leg = realized if realized < len(self.probabilities) else None returns = [0.0] * len(fractions) if realized_leg is None: # Void or push: the market refunds every stake, so no leg # settles and no fee is charged. if record_settlement is not None: record_settlement(tuple([None] * len(returns)), tuple(returns)) continue # Stakes come from the bankroll as it stood when the trial began; # settlements within the batch never resize later legs. bettable_funds = bankroll.bettable_funds bankroll_before = total_funds batch_ruined = False for leg, fraction in enumerate(fractions): if fraction <= 0: continue stake = bettable_funds * fraction try: if leg == realized_leg: amount = ((self.payoffs[leg] - 1) * stake) - self.fee_per_bet if amount >= 0: bankroll.deposit(amount) else: bankroll.withdraw(abs(amount)) returns[leg] = amount / bankroll_before else: amount = (self.loss * stake) + self.fee_per_bet bankroll.withdraw(amount) returns[leg] = -amount / bankroll_before except RuinError as exc: # Settlement exceeded a bankroll safeguard: that leg's # settlement leaves the bankroll unchanged. Finish the # rest of the batch, then stop - never truncate mid-batch. # Warn loudly: the message names the attempted amount, # the configured limit, and current funds. warnings.warn( f"Settlement refused; the simulation stops after " f"this batch: {exc}", stacklevel=2, ) batch_ruined = True if record_settlement is not None: # One outcome flag per leg: the market draw realizes every # leg at once - the realized leg wins, the others lose it. won = [leg == realized_leg for leg in range(len(fractions))] record_settlement(tuple(won), tuple(returns)) if batch_ruined: break
def _validate_bets(bets): """ Validate a portfolio's bet table and return it as float triples. Parameters ---------- bets : sequence of (probability, payoff, loss) triples One triple per independent bet: the bet's win probability (finite in ``[0, 1]``), its payoff multiplier (finite, greater than 0), and its loss multiplier (finite, nonnegative). Unlike a market's probability vector the win probabilities are independent events, so they carry no sum constraint - three bets at 0.5 each describe three separate markets, not a partition of one. Returns ------- tuple of (float, float, float) The validated bets, one triple per bet, in bet order. Raises ------ ValueError If ``bets`` is empty or not a sequence of exactly three-element ``(probability, payoff, loss)`` triples with a finite probability in ``[0, 1]``, a finite payoff greater than 0, and a finite nonnegative loss. Examples -------- >>> _validate_bets([(0.55, 2.0, 1.0), (0.3, 2.4, 0.5)]) ((0.55, 2.0, 1.0), (0.3, 2.4, 0.5)) """ try: bet_items = list(bets) except TypeError as exc: raise ValueError( "Bets must be a non-empty sequence of (probability, payoff, loss) triples" ) from exc if not bet_items: raise ValueError( "Bets must be a non-empty sequence of (probability, payoff, loss) triples" ) validated = [] for index, bet in enumerate(bet_items): try: probability, payoff, loss = bet except (TypeError, ValueError) as exc: raise ValueError( f"Bet {index} must be a (probability, payoff, loss) triple" ) from exc probability = _validate_simulator_probability( probability, f"Bet {index} probability" ) payoff = _require_finite(payoff, f"Bet {index} payoff") if payoff <= 0: raise ValueError(f"Bet {index} payoff must be greater than 0") loss = _require_finite(loss, f"Bet {index} loss") if loss < 0: raise ValueError(f"Bet {index} loss must be non-negative") validated.append((probability, payoff, loss)) return tuple(validated) def _validate_portfolio_strategy_odds(strategy, bets): """ Reject a strategy whose odds contradict the portfolio's bet odds. The simulator sizes every stake through ``strategy.evaluate`` but settles the bets with their own ``payoff`` and ``loss`` multipliers, so the two models have to agree for the run to describe anything. Only :class:`keeks.multi_outcome.base.BaseMultiOutcomeStrategy` instances are checked: the strategy's ``payoffs`` vector must equal the bets' payoff multipliers in bet order, and its scalar ``loss`` must equal every bet's loss multiplier - a strategy with one ``loss`` cannot faithfully size a portfolio whose bets settle at different loss multipliers. A duck-typed strategy needs no ``payoffs``/``loss`` at all and its compatibility stays the caller's responsibility. The strategy's fractional ``transaction_cost_rate`` and the simulator's flat ``fee_per_bet`` fee are deliberately different units and are never compared. Raises ------ ValueError If the strategy's ``payoffs`` differ from the bets' payoff multipliers, or its ``loss`` differs from any bet's loss. """ if not isinstance(strategy, BaseMultiOutcomeStrategy): return payoffs = tuple(bet[1] for bet in bets) if tuple(strategy.payoffs) != payoffs: raise ValueError( f"Strategy payoffs ({strategy.payoffs!r}) does not match portfolio " f"bet payoffs ({payoffs!r}); the strategy sizes each stake with " "its own odds while the simulator settles with the bets', so the " "two must agree." ) for index, bet in enumerate(bets): if strategy.loss != bet[2]: raise ValueError( f"Strategy loss ({strategy.loss!r}) does not match bet {index}'s " f"loss ({bet[2]!r}); the strategy sizes that stake with its " "own odds while the simulator settles with the bet's, so the " "two must agree." ) def _portfolio_rngs(seed, bets): """Build deterministic generators keyed by bet value and occurrence.""" occurrences = {} rngs = [] for bet in bets: packed_bet = struct.pack(">ddd", *bet) occurrence = occurrences.get(packed_bet, 0) occurrences[packed_bet] = occurrence + 1 identity = hashlib.blake2b(packed_bet, digest_size=16).digest() spawn_key = (*struct.unpack(">IIII", identity), occurrence) rngs.append( np.random.default_rng(np.random.SeedSequence(seed, spawn_key=spawn_key)) ) return rngs
[docs] class PortfolioSimulator: """ 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 :class:`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 :class:`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 (:class:`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] """ def __init__( self, bets: Sequence[tuple[float, float, float]], fee_per_bet: float = 0.0, trials: int = 1000, seed: int | None = None, ) -> None: self.bets: tuple[tuple[float, float, float], ...] = _validate_bets(bets) self.probabilities: np.ndarray = np.array( [bet[0] for bet in self.bets], dtype=float ) fee_per_bet = _require_finite(fee_per_bet, "Fee per bet") if fee_per_bet < 0: raise ValueError("Fee per bet must be non-negative") self.fee_per_bet: float = fee_per_bet try: trials = operator.index(trials) except TypeError as exc: raise ValueError("Trials must be a nonnegative integer") from exc if trials < 0: raise ValueError("Trials must be a nonnegative integer") self.trials: int = trials self.seed: int | None = _validate_simulator_seed(seed) self._rngs: list[np.random.Generator] | None if self.seed is not None: self._rngs = _portfolio_rngs(self.seed, self.bets) else: self._rngs = None
[docs] def evaluate_strategy( self, strategy: BaseMultiOutcomeStrategy, bankroll: "BankRoll" ) -> None: """ 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 ------- None The bankroll object is updated in-place with the results of the simulation. 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. """ _validate_portfolio_strategy_odds(strategy, self.bets) # Resolve state-dependent hooks once: neither the strategy's hook set # nor the bankroll value changes between the reads within one trial. update_bankroll = getattr(strategy, "update_bankroll", None) if not callable(update_bankroll): update_bankroll = None record_settlement = getattr(strategy, "record_settlement", None) if not callable(record_settlement): record_settlement = None for _ in range(self.trials): # Stop if bankrupt total_funds = bankroll.total_funds if total_funds <= 0: break if update_bankroll is not None: update_bankroll(total_funds) # Get the proportion to bet on each portfolio bet. The validator # enforces the aggregate-exposure bound (fractions summing to at # most one) and runs before any draw is consumed. fractions = _validate_stake_fractions( strategy.evaluate(self.probabilities, total_funds), leg_count=len(self.bets), ) # Only process the portfolio if the strategy staked something # (avoid charging costs on no-bet) if not any(fractions): continue # Stakes come from the bankroll as it stood when the trial began; # the batch's settlements never resize later bets. bettable_funds = bankroll.bettable_funds bankroll_before = total_funds won: list[bool | None] = [None] * len(fractions) amounts = [0.0] * len(fractions) net = 0.0 for index, fraction in enumerate(fractions): if fraction <= 0: continue stake = bettable_funds * fraction probability, payoff, loss = self.bets[index] outcome = ( self._rngs[index].random() if self._rngs is not None else np.random.random() ) if outcome < probability: won[index] = True amounts[index] = ((payoff - 1) * stake) - self.fee_per_bet else: won[index] = False amounts[index] = -((loss * stake) + self.fee_per_bet) net += amounts[index] # Net settlement: the whole batch crosses the bankroll as one # transaction, so its safeguards evaluate the batch total once. returns = [0.0] * len(fractions) batch_ruined = False try: if net >= 0: bankroll.deposit(net) else: bankroll.withdraw(-net) for index, amount in enumerate(amounts): if won[index] is not None: returns[index] = amount / bankroll_before except RuinError as exc: # The safeguard refused the batch's net settlement: the # bankroll is unchanged, every return reports 0.0, and the # simulation stops after this batch - never mid-portfolio. # Warn loudly so a stopped run is never silent. warnings.warn( f"Settlement refused; the simulation stops after this batch: {exc}", stacklevel=2, ) batch_ruined = True if record_settlement is not None: record_settlement(tuple(won), tuple(returns)) if batch_ruined: break