Source code for keeks.simulators.random_uncertain_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 RandomUncertainBinarySimulator: """ Simulator for binary betting strategies with random probabilities and uncertainty. This simulator generates random probabilities for each trial, centered around 0.5 with a configurable standard deviation. It adds an additional uncertainty factor to the actual outcome probability, simulating imperfect information. 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]. uncertainty_stdev : float, default=0.05 The standard deviation of the normal distribution used to add uncertainty to the actual outcome probability. The resulting outcome probability is clamped to [0.0, 1.0]. seed : int or None, default=None Seed for private outcome, probability, and uncertainty 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``, ``stdev`` or ``uncertainty_stdev`` is not finite and nonnegative, if ``trials`` is not a nonnegative integer, or if ``seed`` is not a nonnegative integer or ``None``. Notes ----- **RNG family and seeding.** Like :class:`RandomBinarySimulator`, this simulator mixes two generator families: bet outcomes draw from Python's :class:`random.Random` and both the win probabilities and the uncertainty adjustments from one numpy :class:`numpy.random.Generator` (``default_rng``). With a ``seed``, all generators are private instances driven by that one integer and a seeded run replays byte-identically; without one, every draw falls back to the process-global generators. 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, uncertainty_stdev: float = 0.05, 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. 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]``. uncertainty_stdev : float, default=0.05 The standard deviation, on the probability scale, of the normal distribution used to perturb the actual outcome probability - the imperfect-information channel. The resulting outcome probability is clamped to ``[0.0, 1.0]``. seed : int or None, default=None Seed for the simulator's private outcome, probability, and uncertainty 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``, ``stdev`` or ``uncertainty_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.uncertainty_stdev = _validate_simulator_stdev( uncertainty_stdev, "Uncertainty 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 with uncertainty. For each trial, a random probability is generated, the strategy is evaluated with this probability, but the actual outcome is determined by the probability plus a random uncertainty factor. 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 outcome_probability = min( 1.0, max( 0.0, probability + ( np.random.normal(0, self.uncertainty_stdev, 1)[0] if probability_rng is None else probability_rng.normal(0, self.uncertainty_stdev) ), ), ) try: outcome = ( random.random() if self._outcome_rng is None else self._outcome_rng.random() ) won = outcome < 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,))