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