import abc
import numpy as np
from keeks.params import ParameterMixin
from keeks.utils import _require_finite
__author__ = "willmcginnis"
[docs]
class BaseStrategy(ParameterMixin, abc.ABC):
"""
Abstract base class for all binary betting strategies.
This class defines the interface that all binary betting strategies must implement.
Concrete strategy implementations should inherit from this class and implement
the evaluate method.
Beyond ``evaluate``, the simulators in ``keeks.simulators`` resolve two
optional hooks ``getattr``-style once per run and fire them around each
settled bet. This base class implements neither, so a strategy may omit
them freely: the simulator skips the hook and the strategy stays
stateless. A stateful strategy that needs the feedback but omits the
hook simply sizes from stale state - there is no error, which is why the
contract is documented here.
- ``update_bankroll(current_bankroll)`` - fired at the top of every
trial, before ``evaluate``, with the bankroll's current total funds,
so an adaptive strategy can size from the bankroll as it stands
(:class:`keeks.binary_strategies.CPPIStrategy` ratchets its floor from
it). Not fired once the bankroll is depleted - the simulation has
already stopped.
- ``record_settlement(won, realized_returns)`` - fired once per *settled*
bet, after the bankroll transfer: ``won`` holds the bet's realized
outcome (one entry per settled option - a single ``True`` or ``False``
for the binary simulators) and ``realized_returns`` the realized
simple return per option on the bankroll, positive or negative. Not
fired for trials the strategy sits out (a zero stake),
and not fired for a settlement a bankroll safeguard refused - a
refused settlement leaves the bankroll unchanged and stops the
simulation, so there is no result to report
(:class:`keeks.binary_strategies.DynamicBankrollManagement` tracks
streaks and volatility through this hook).
A hook should not raise: exceptions other than the simulator's own
``RuinError`` settlement handling propagate to the caller.
"""
def __init__(self, payoff: float, loss: float, transaction_cost_rate: float = 0):
"""
Initialize the strategy.
Parameters
----------
payoff : float
The payoff multiplier for winning: the *net* amount won per unit
staked, excluding the stake's return - decimal odds minus one, so
even money is ``1.0`` and decimal odds ``3.0`` are ``payoff=2.0``.
This is the opposite of the multi-outcome and allocation layers,
where the same name means the decimal odds themselves.
loss : float
The loss multiplier for losing.
transaction_cost_rate : float, optional
The transaction cost as a fraction of each unit staked, by default 0.
This is a per-unit *fractional* cost that enters the sizing formulas
alongside ``payoff`` and ``loss`` (Kelly, for instance, computes
``payoff - transaction_cost_rate``), so ``0.01`` means one percent of the
stake and the fee it represents grows with the bet.
The simulators in ``keeks.simulators`` take ``fee_per_bet``: an
*absolute* bankroll amount charged once per settled bet,
independent of stake size. The two are different units: passing
the same number to both models two very different costs.
Raises
------
ValueError
If any of payoff, loss or transaction_cost_rate is not a finite number, if
payoff is not positive, loss is negative, or loss + transaction_cost_rate
is not positive.
"""
payoff = _require_finite(payoff, "Payoff")
if payoff <= 0:
raise ValueError("Payoff must be greater than 0")
loss = _require_finite(loss, "Loss")
if loss < 0:
raise ValueError("Loss must be non-negative")
transaction_cost_rate = _require_finite(
transaction_cost_rate, "Transaction cost rate"
)
if transaction_cost_rate < 0:
raise ValueError("Transaction cost rate must be non-negative")
if loss + transaction_cost_rate <= 0:
raise ValueError(
"Total cost (loss + transaction_cost_rate) must be greater than 0"
)
self.payoff = payoff
self.loss = loss
self.transaction_cost_rate = transaction_cost_rate
# Constructor-only constant: for a positive bankroll the bankroll term
# in current_bankroll / (loss + transaction_cost_rate) cancels, so the cap
# is a fixed fraction.
self._max_safe_fraction = min(1.0, 1.0 / (loss + transaction_cost_rate))
[docs]
def get_max_safe_bet(self, current_bankroll: float) -> float:
"""
Calculate the maximum safe bet size based on current bankroll.
Parameters
----------
current_bankroll : float
The current bankroll to use for calculations.
Returns
-------
float
The maximum safe bet size as a proportion of bankroll. Zero when
there is nothing left to stake (``current_bankroll <= 0``).
Raises
------
ValueError
If ``current_bankroll`` is not finite.
Notes
-----
The maximum stake that cannot drive the bankroll negative is
``current_bankroll / (loss + transaction_cost_rate)``; expressed as a
proportion of the bankroll the bankroll term cancels, so for a positive
bankroll this is exactly ``min(1.0, 1 / (loss + transaction_cost_rate))``.
A non-positive bankroll has no safe stake at all, so the answer there is
``0.0``.
"""
current_bankroll = _require_finite(current_bankroll, "Current bankroll")
if current_bankroll <= 0:
return 0.0
return self._max_safe_fraction
[docs]
def calculate_max_entry_price(
self,
outcomes: np.typing.ArrayLike,
probabilities: np.typing.ArrayLike,
current_wealth: float,
tolerance: float = 0.01,
max_search_fraction: float = 0.5,
) -> float:
"""
Calculate maximum price willing to pay for a one-time gamble.
This method is only implemented for strategies derived from utility theory
(e.g., KellyCriterion, MertonShare). For heuristic strategies without
underlying utility functions, this method raises NotImplementedError.
Parameters
----------
outcomes : array-like
The possible payoffs from the gamble
probabilities : array-like
The probability of each outcome (must sum to ≤ 1)
current_wealth : float
Current wealth before the gamble. Must be finite and greater than 0.
tolerance : float, default=0.01
Convergence tolerance for binary search. Must be finite and greater
than 0.
max_search_fraction : float, default=0.5
Maximum fraction of wealth to consider as upper bound. Must be
finite and non-negative; values above 1.0 are allowed.
Returns
-------
float
Maximum price willing to pay for the gamble
Raises
------
NotImplementedError
If the strategy does not have a utility-theoretic basis for
calculating indifference prices.
ValueError
Raised by every shipped implementation if the gamble arrays are
malformed or if any scalar control falls outside the ranges
documented above. Overrides should validate with
``keeks.utils._validate_entry_price_scalars`` before doing work or
mutating internal state.
Warns
-----
RuntimeWarning
Emitted by the utility-based implementations, which delegate to
``keeks.utils.find_indifference_price``, when the gamble is still
worth buying at ``current_wealth * max_search_fraction``. The
returned price is then that bound rather than a solved indifference
price. See ``find_indifference_price`` for details.
Notes
-----
This method answers: "What's the maximum I'd pay to participate in this
one-time gamble?" It's fundamentally different from evaluate(), which
answers: "How much should I bet in a repeated betting scenario?"
Only strategies with underlying utility functions can meaningfully answer
the entry price question. Heuristic strategies (e.g., FixedFraction, CPPI)
don't have utility functions to derive indifference prices from.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support one-time entry price "
"calculation. This method is only available for utility-based strategies "
"like KellyCriterion and MertonShare.\n\n"
"Heuristic strategies (FixedFraction, CPPI, Dynamic, etc.) don't have "
"underlying utility functions to derive indifference prices from."
)
[docs]
@abc.abstractmethod
def evaluate(self, probability: float, current_bankroll: float) -> float:
"""
Evaluate the strategy for a given probability.
Parameters
----------
probability : float
The probability of winning.
current_bankroll : float
The current bankroll to use for calculations.
Returns
-------
float
The proportion of the bankroll to bet - ``0.0`` for a
nonpositive bankroll, where there is nothing left to stake.
Raises
------
ValueError
If ``probability`` is not finite and between 0 and 1 inclusive, or
if ``current_bankroll`` is not finite.
"""
pass