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