Source code for keeks.simulators.repeated_binary

import random
import warnings
from typing import TYPE_CHECKING

from keeks.binary_strategies.base import BaseStrategy
from keeks.utils import (
    RuinError,
    _validate_simulator_controls,
    _validate_simulator_probability,
    _validate_simulator_seed,
    _validate_stake_fraction,
    _validate_strategy_odds,
)

if TYPE_CHECKING:
    from keeks.bankroll import BankRoll


[docs] class RepeatedBinarySimulator: """ Simulator for binary betting strategies with a fixed probability. This simulator uses the same probability for each trial, simulating repeated bets on events with identical odds. Parameters ---------- payoff : float The amount won per unit bet on a successful outcome. loss : float The amount lost per unit bet on an unsuccessful outcome. fee_per_bet : float The flat fee charged once per settled bet, regardless of outcome. This is an absolute bankroll amount, not a fraction of the stake, so it does not scale with bet size: it is subtracted from a winning settlement and added to a losing one. Note this differs in unit from the singular ``transaction_cost_rate`` taken by strategies in ``keeks.binary_strategies``, which is a per-unit fraction of the bet used for sizing. probability : float The fixed probability of a successful outcome for all trials. trials : int, default=1000 The number of betting trials to simulate. seed : int or None, default=None Seed for a private outcome generator. When omitted, the process-global ``random`` generator is used for backward compatibility. Raises ------ ValueError If ``payoff`` is not finite and positive, if ``loss`` or ``fee_per_bet`` is not finite and nonnegative, if ``probability`` is not finite within ``[0, 1]``, or if ``trials`` is not a nonnegative integer, or if ``seed`` is not a nonnegative integer or ``None``. Notes ----- **RNG family and seeding.** All outcome draws come from Python's :class:`random.Random` - this is the one simulator in the package on the stdlib generator family; the multi-outcome and allocation simulators draw from private numpy :class:`numpy.random.Generator` instances on spawned ``SeedSequence`` children, so cross-generation seeded comparisons are not aligned by seed alone. With a ``seed`` the outcome generator is private and a seeded run replays byte-identically; without one, the process-global ``random`` generator drives the draws. """ def __init__( self, payoff: float, loss: float, fee_per_bet: float, probability: float, trials: int = 1000, seed: int | None = None, ) -> None: """ Initialize the simulator. Parameters ---------- payoff : float The amount won per unit bet on a successful outcome: the *net* win, excluding the stake's return - decimal odds minus one. loss : float The amount lost per unit bet on an unsuccessful outcome: a positive multiplier on the staked amount. fee_per_bet : float The flat fee charged once per settled bet, in currency - an absolute bankroll amount, not a fraction of the stake, so it does not scale with bet size. This differs in unit from the singular ``transaction_cost_rate`` taken by strategies in ``keeks.binary_strategies``, which is a per-unit fraction of the stake used for sizing. probability : float The fixed win probability shared by every trial, in ``[0, 1]``. trials : int, default=1000 The number of betting trials to simulate. seed : int or None, default=None Seed for the simulator's private outcome generator. When omitted, the process-global ``random`` generator is used for backward compatibility and no replay is promised. Raises ------ ValueError If ``payoff`` is not finite and positive, if ``loss`` or ``fee_per_bet`` is not finite and nonnegative, if ``probability`` is not finite within ``[0, 1]``, or if ``trials`` is not a nonnegative integer, or if ``seed`` is not a nonnegative integer or ``None``. """ ( self.payoff, self.loss, self.fee_per_bet, self.trials, ) = _validate_simulator_controls(payoff, loss, fee_per_bet, trials) self.probability = _validate_simulator_probability(probability, "Probability") self.seed = _validate_simulator_seed(seed) self._outcome_rng = random.Random(self.seed) if self.seed is not None else None
[docs] def evaluate_strategy(self, strategy: BaseStrategy, bankroll: "BankRoll") -> None: """ Evaluate a betting strategy over multiple trials with fixed probability. For each trial, the strategy is evaluated with the fixed probability, and the bankroll is updated based on the outcome. The simulation stops early if the bankroll is depleted (bankruptcy). Parameters ---------- strategy : BaseStrategy The betting strategy to evaluate. 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 ``BaseStrategy`` whose ``payoff`` or ``loss`` differs from this simulator's, since it would then size bets against different odds than the ones the simulator settles at, or if the strategy returns a non-finite or out-of-range stake fraction. """ _validate_strategy_odds(strategy, self.payoff, 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 proportion = _validate_stake_fraction( strategy.evaluate(self.probability, total_funds) ) # Only process the bet if proportion > 0 (avoid charging costs on no-bet) if proportion > 0: current_bankroll = total_funds bet_amount = bankroll.bettable_funds * proportion try: outcome = ( random.random() if self._outcome_rng is None else self._outcome_rng.random() ) won = outcome < self.probability if won: amount = (self.payoff * bet_amount) - self.fee_per_bet if amount >= 0: bankroll.deposit(amount) else: bankroll.withdraw(abs(amount)) realized_return = amount / current_bankroll else: amount = (self.loss * bet_amount) + self.fee_per_bet bankroll.withdraw(amount) realized_return = -amount / current_bankroll except RuinError as exc: # Settlement exceeded a bankroll safeguard; stop the run # loudly rather than silently: the warning carries the # refused amount, the configured limit, and current funds. warnings.warn(f"Simulation stopped early: {exc}", stacklevel=2) break if record_settlement is not None: record_settlement((won,), (realized_return,))