Source code for keeks.simulators.random_binary

import random
import warnings
from typing import TYPE_CHECKING

import numpy as np

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

if TYPE_CHECKING:
    from keeks.bankroll import BankRoll


[docs] class RandomBinarySimulator: """ Simulator for binary betting strategies with random probabilities. This simulator generates random probabilities for each trial, centered around 0.5 with a configurable standard deviation. It evaluates a betting strategy against these random probabilities and updates the bankroll accordingly. 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. trials : int, default=1000 The number of betting trials to simulate. stdev : float, default=0.1 The standard deviation of the normal distribution used to generate probabilities. Samples are clamped to [0.0, 1.0]. seed : int or None, default=None Seed for private outcome and probability generators. When omitted, the process-global ``random`` and ``numpy.random`` generators are used for backward compatibility. Raises ------ ValueError If ``payoff`` is not finite and positive, if ``loss``, ``fee_per_bet`` or ``stdev`` is not finite and nonnegative, or if ``trials`` is not a nonnegative integer, or if ``seed`` is not a nonnegative integer or ``None``. Notes ----- **RNG family and seeding.** This simulator mixes two generator families: bet outcomes draw from Python's :class:`random.Random` and win probabilities from a numpy :class:`numpy.random.Generator` (``default_rng``). With a ``seed``, both generators are private instances driven by that one integer and a seeded run replays byte-identically; without one, both fall back to the process-global generators (``random.random`` and ``numpy.random.normal``). Cross-family seeded comparisons with the multi-outcome and allocation simulators, which draw from spawned ``SeedSequence`` children only, are not aligned by seed alone - see the reproducibility contracts there. """ def __init__( self, payoff: float, loss: float, fee_per_bet: float, trials: int = 1000, stdev: float = 0.1, 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. It is subtracted from a winning settlement and added to a losing one. 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. trials : int, default=1000 The number of betting trials to simulate. stdev : float, default=0.1 The standard deviation, on the probability scale, of the normal distribution used to generate win probabilities. Samples are clamped to ``[0.0, 1.0]``. seed : int or None, default=None Seed for the simulator's private outcome and probability generators. When omitted, the process-global ``random`` and ``numpy.random`` generators are used for backward compatibility and no replay is promised. Raises ------ ValueError If ``payoff`` is not finite and positive, if ``loss``, ``fee_per_bet`` or ``stdev`` is not finite and nonnegative, 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.stdev = _validate_simulator_stdev(stdev, "Standard deviation") self.seed = _validate_simulator_seed(seed) self._outcome_rng = random.Random(self.seed) if self.seed is not None else None self._probability_rng = ( np.random.default_rng(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. For each trial, a random probability is generated, the strategy is evaluated with this 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 probability_rng = self._probability_rng 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) # The state getter already returns a fresh snapshot, so no copy # is needed before the validation-restore path below. probability_state = ( np.random.get_state() if probability_rng is None else probability_rng.bit_generator.state ) # Normal samples are unbounded; only [0, 1] values are probabilities. probability = min( 1.0, max( 0.0, np.random.normal(0.5, self.stdev, 1)[0] if probability_rng is None else probability_rng.normal(0.5, self.stdev), ), ) proportion = strategy.evaluate(probability, total_funds) try: proportion = _validate_stake_fraction(proportion) except ValueError: if probability_rng is None: np.random.set_state(probability_state) else: probability_rng.bit_generator.state = probability_state raise # 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 < 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,))