Source code for keeks.binary_strategies.simple

import operator
from collections.abc import Sequence

import numpy as np

from keeks.binary_strategies.base import BaseStrategy
from keeks.utils import (
    _normalize_gamble,
    _require_finite,
    _validate_entry_price_scalars,
    _validate_probability,
    find_indifference_price,
)

__author__ = "willmcginnis"


[docs] class NaiveStrategy(BaseStrategy): """ A simple betting strategy that bets based on expected value. This strategy calculates the expected value of a bet and bets a fixed fraction of the bankroll if the expected value is positive. 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. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). """ def __init__( self, payoff: float, loss: float, transaction_cost_rate: float ) -> None: """ Initialize the NaiveStrategy. 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. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). """ super().__init__(payoff, loss, transaction_cost_rate)
[docs] def evaluate(self, probability: float, current_bankroll: float) -> float: """ Calculate the bet size based on expected value. The expected value is calculated as: EV = (probability * payoff) - ((1 - probability) * loss) - transaction_cost_rate If EV is positive, bet a fixed fraction of the bankroll. If EV is negative, do not bet. Parameters ---------- probability : float The probability of a successful outcome, typically between 0 and 1. current_bankroll : float The current bankroll amount. Returns ------- float The proportion of the bankroll to bet. """ probability = _validate_probability(probability) current_bankroll = _require_finite(current_bankroll, "Current bankroll") # Calculate expected value expected_value = ( (probability * self.payoff) - ((1 - probability) * self.loss) - self.transaction_cost_rate ) # If expected value is negative, do not bet if expected_value <= 0: return 0.0 # Calculate bet size based on expected value bet_size = expected_value / self.payoff # Ensure we never bet more than would result in negative bankroll return min(max(0, bet_size), self.get_max_safe_bet(current_bankroll))
[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. Naive strategy is risk-neutral and simply pays the expected value. 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 (unused, kept for API consistency). Must still 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 (the expected value, bounded above by ``current_wealth * max_search_fraction``) Raises ------ ValueError If the gamble arrays are malformed, or if any scalar control falls outside the ranges documented above. Notes ----- Naive strategy is risk-neutral: willing to pay exactly the expected value. This is the classic "expected value maximizer" that doesn't account for risk. The expected value can be unbounded (e.g. the St. Petersburg paradox), so the result is capped the same way every other strategy caps it. Pass ``max_search_fraction=1.0`` to allow paying the entire bankroll. """ outcomes, probabilities = _normalize_gamble(outcomes, probabilities) _validate_entry_price_scalars(current_wealth, tolerance, max_search_fraction) # Calculate expected value expected_value = np.sum(probabilities * outcomes) # For naive strategy, willing to pay expected value, which can be unbounded # (e.g., St. Petersburg paradox); respect the same wealth-fraction cap the # other strategies apply return min(current_wealth * max_search_fraction, max(0.0, expected_value))
[docs] class FixedFractionStrategy(BaseStrategy): """ A simple strategy that bets a fixed percentage of the bankroll. This strategy ignores probabilities and odds completely, and simply wagers a fixed fraction of the bankroll. It can be used as a baseline for comparison or as a simple approach for risk management. Parameters ---------- fraction : float The fixed fraction of the bankroll to bet (between 0 and 1). payoff : float The amount won per unit bet on a successful outcome. loss : float The amount lost per unit bet on an unsuccessful outcome. transaction_cost_rate : float, default=0 The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). min_probability : float, default=0.5 The minimum probability required to place a bet. """ def __init__( self, fraction: float, payoff: float, loss: float, transaction_cost_rate: float = 0, min_probability: float = 0.5, ) -> None: """ Initialize the FixedFractionStrategy. Parameters ---------- fraction : float The fixed fraction of the bankroll to bet (between 0 and 1). payoff : float The amount won per unit bet on a successful outcome. loss : float The amount lost per unit bet on an unsuccessful outcome. transaction_cost_rate : float, default=0 The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). min_probability : float, default=0.5 The minimum probability required to place a bet. """ if not 0 <= fraction <= 1: raise ValueError("Fraction must be between 0 and 1") if not 0 <= min_probability <= 1: raise ValueError("Minimum probability must be between 0 and 1") super().__init__(payoff, loss, transaction_cost_rate) self.fraction = fraction self.min_probability = min_probability
[docs] def evaluate(self, probability: float, current_bankroll: float) -> float: """ Return the fixed fraction if the probability meets the minimum threshold. Parameters ---------- probability : float The probability of a successful outcome, typically between 0 and 1. current_bankroll : float The current bankroll amount. Returns ------- float The fixed fraction if probability >= min_probability, otherwise 0. """ probability = _validate_probability(probability) current_bankroll = _require_finite(current_bankroll, "Current bankroll") if probability >= self.min_probability: # Ensure we never bet more than would result in negative bankroll return min(self.fraction, self.get_max_safe_bet(current_bankroll)) else: return 0.0
[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. Fixed fraction strategy simply pays a fixed fraction of current wealth. Parameters ---------- outcomes : array-like The possible payoffs from the gamble (unused) probabilities : array-like The probability of each outcome (unused) current_wealth : float Current wealth before the gamble. Must be finite and greater than 0. tolerance : float, default=0.01 Convergence tolerance (unused, kept for API consistency). Must still 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 (fixed fraction of wealth) Raises ------ ValueError If the gamble arrays are malformed, or if any scalar control falls outside the ranges documented above. Notes ----- Fixed fraction strategy doesn't analyze the gamble - it simply commits a fixed fraction of wealth regardless of the opportunity. This is a mechanical rule-based approach, not optimization-based. """ _normalize_gamble(outcomes, probabilities) _validate_entry_price_scalars(current_wealth, tolerance, max_search_fraction) # Pay the fixed fraction of current wealth return min(self.fraction * current_wealth, max_search_fraction * current_wealth)
[docs] class CPPIStrategy(BaseStrategy): """ Implementation of Constant Proportion Portfolio Insurance (CPPI) for binary betting. This strategy maintains a dynamic floor below which the bankroll should not fall, and invests a multiple of the cushion (amount above floor) in each bet. Parameters ---------- floor_fraction : float The fraction of initial bankroll to maintain as a floor. multiplier : float The multiplier to apply to the cushion for determining exposure. initial_bankroll : float The initial bankroll amount. payoff : float The amount won per unit bet on a successful outcome. loss : float The amount lost per unit bet on an unsuccessful outcome. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). min_probability : float, default=0.5 The minimum probability required to place a bet. """ def __init__( self, floor_fraction: float, multiplier: float, initial_bankroll: float, payoff: float, loss: float, transaction_cost_rate: float = 0, min_probability: float = 0.5, ) -> None: """Initialize the CPPI strategy.""" if not 0 < floor_fraction < 1: raise ValueError("Floor fraction must be between 0 and 1") multiplier = _require_finite(multiplier, "Multiplier") if multiplier <= 0: raise ValueError("Multiplier must be greater than 0") initial_bankroll = _require_finite(initial_bankroll, "Initial bankroll") if initial_bankroll <= 0: raise ValueError("Initial bankroll must be greater than 0") if not 0 <= min_probability <= 1: raise ValueError("Minimum probability must be between 0 and 1") super().__init__(payoff, loss, transaction_cost_rate) self.floor_fraction = floor_fraction self.multiplier = multiplier self.initial_bankroll = initial_bankroll self.floor = floor_fraction * initial_bankroll self.min_probability = min_probability self.current_bankroll = initial_bankroll self.peak_bankroll = initial_bankroll
[docs] def update_bankroll(self, new_bankroll: float) -> None: """ Update the current bankroll value and adjust floor based on peak value. Parameters ---------- new_bankroll : float The current value of the bankroll. """ self.current_bankroll = new_bankroll if new_bankroll > self.peak_bankroll: self.peak_bankroll = new_bankroll # Adjust floor to maintain the same fraction of peak value self.floor = self.floor_fraction * self.peak_bankroll
[docs] def evaluate(self, probability: float, current_bankroll: float) -> float: """ Calculate the CPPI bet size based on the cushion above the floor. Parameters ---------- probability : float The probability of a successful outcome, typically between 0 and 1. current_bankroll : float The current bankroll amount. Returns ------- float The proportion of the current bankroll to bet, or 0 if below the minimum probability threshold. """ probability = _validate_probability(probability) current_bankroll = _require_finite(current_bankroll, "Current bankroll") # Update current bankroll self.update_bankroll(current_bankroll) # Check probability threshold if probability < self.min_probability: return 0.0 # Calculate expected value per unit bet expected_value = probability * (self.payoff - self.transaction_cost_rate) - ( 1 - probability ) * (self.loss + self.transaction_cost_rate) # If expected value is negative, don't bet # Note: We allow small positive edges (even < 1%) for realistic scenarios # Professional bettors often operate with 0.5-2% edges if expected_value <= 0: return 0.0 # Calculate the cushion (amount above the floor) cushion = max(0, current_bankroll - self.floor) # Calculate the exposure (amount to bet) # Scale the multiplier by the expected value to be more conservative # when the edge is smaller adjusted_multiplier = self.multiplier * min(1.0, expected_value) exposure = adjusted_multiplier * cushion # Convert to a proportion of the current bankroll if current_bankroll <= 0: return 0.0 # Calculate proportion proportion = min(1.0, exposure / current_bankroll) # Adjust for transaction costs and ensure we don't risk going below floor if proportion > 0: # Calculate the maximum bet that ensures we stay above the floor # even in the worst case (loss + transaction cost) max_floor_bet = (current_bankroll - self.floor) / ( current_bankroll * (self.loss + self.transaction_cost_rate) ) # Get the maximum safe bet considering ruin max_safe_bet = self.get_max_safe_bet(current_bankroll) # Take the minimum of all constraints proportion = min(proportion, max_floor_bet, max_safe_bet) return max(0, proportion)
[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. CPPI protects a floor value, so entry price is based on the cushion above the floor, scaled by the multiplier. Parameters ---------- outcomes : array-like The possible payoffs from the gamble (unused in basic calculation) probabilities : array-like The probability of each outcome (unused in basic calculation) current_wealth : float Current wealth before the gamble. Must be finite and greater than 0. tolerance : float, default=0.01 Convergence tolerance (unused, kept for API consistency). Must still 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 (based on cushion above floor) Raises ------ ValueError If the gamble arrays are malformed, or if any scalar control falls outside the ranges documented above. Validation happens before any internal bankroll or floor state is updated. Notes ----- CPPI maintains a floor value and bets a multiple of the cushion. For entry price, we apply the same logic: pay multiplier × cushion, but never more than the cushion itself (to maintain floor). """ _normalize_gamble(outcomes, probabilities) _validate_entry_price_scalars(current_wealth, tolerance, max_search_fraction) # Update internal state with current wealth self.update_bankroll(current_wealth) # Calculate cushion above floor cushion = max(0, current_wealth - self.floor) # Pay up to multiplier × cushion, but capped at the cushion itself # (can't pay more than cushion without violating floor) max_price = min(self.multiplier * cushion, cushion) # Also respect max_search_fraction return min(max_price, max_search_fraction * current_wealth)
[docs] class DynamicBankrollManagement(BaseStrategy): """ A dynamic bankroll management strategy that adjusts bet sizes based on performance. This strategy adjusts the base bet fraction based on: 1. Recent performance (win/loss streak) 2. Volatility of returns 3. Current drawdown level 4. Probability of success Parameters ---------- base_fraction : float The base fraction of bankroll to bet. payoff : float The amount won per unit bet on a successful outcome. loss : float The amount lost per unit bet on an unsuccessful outcome. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). window_size : int, default=10 The number of recent results to consider for adjustments. max_fraction : float, default=0.2 The maximum fraction of bankroll that can be bet. min_fraction : float, default=0.05 The minimum fraction of bankroll to bet. min_probability : float, default=0.5 The minimum probability required to place a bet. Below this the strategy returns 0.0 (no bet). """ def __init__( self, base_fraction: float, payoff: float, loss: float, transaction_cost_rate: float, window_size: int = 10, max_fraction: float = 0.2, min_fraction: float = 0.05, min_probability: float = 0.5, ) -> None: """ Initialize the DynamicBankrollManagement strategy. """ if not 0 <= base_fraction <= 1: raise ValueError("Base fraction must be between 0 and 1") try: if isinstance(window_size, bool): raise TypeError window_size = operator.index(window_size) except TypeError as exc: raise ValueError("Window size must be positive integer") from exc if window_size <= 0: raise ValueError("Window size must be positive integer") if not 0 <= min_fraction <= max_fraction <= 1: raise ValueError( "Min fraction must be between 0 and max fraction, and max fraction must be between 0 and 1" ) if not 0 <= min_probability <= 1: raise ValueError("Minimum probability must be between 0 and 1") super().__init__(payoff, loss, transaction_cost_rate) self.base_fraction = base_fraction self.window_size = window_size self.max_fraction = max_fraction self.min_fraction = min_fraction self.min_probability = min_probability self.results = [] # Volatility cache for the current window; record_settlement is the # only in-library mutator of the window and invalidates it. External # code mutating ``results`` directly bypasses invalidation. self._volatility_cache = None self.initial_bankroll = None self.current_bankroll = None self.peak_bankroll = None
[docs] def record_settlement( self, won: Sequence[bool], realized_returns: Sequence[float] | None = None ) -> None: """ Record the result of a settled bet. Parameters ---------- won : sequence of bool One entry per settled option: ``True`` for a win, ``False`` for a loss. realized_returns : sequence of float, optional The realized simple return per option on the bankroll. If not provided, calculated from ``won`` and the strategy's odds. """ if realized_returns is None: realized_returns = (self.payoff,) if won[0] else (-self.loss,) self.results.append(realized_returns[0]) if len(self.results) > self.window_size: self.results.pop(0) self._volatility_cache = None
[docs] def get_streak_factor(self) -> float: """Calculate the adjustment factor based on recent performance.""" if not self.results: return 1.0 # Scale factor by window size to make smaller windows more responsive scale = min(len(self.results), self.window_size) / self.window_size wins = sum(1 for r in self.results if r > 0) losses = sum(1 for r in self.results if r < 0) if losses == 0: return 1.0 + (0.5 * scale) # Maximum boost for all wins if wins == 0: return 1.0 - (0.5 * scale) # Maximum reduction for all losses win_ratio = wins / (wins + losses) return 1.0 + ((win_ratio - 0.5) * scale)
[docs] def get_volatility_factor(self) -> float: """Calculate the adjustment factor based on return volatility.""" if not self.results: return 1.0 if self._volatility_cache is None: self._volatility_cache = np.std(np.array(self.results)) volatility = self._volatility_cache if volatility == 0: return 1.0 # Scale factor by window size to make smaller windows more responsive scale = min(len(self.results), self.window_size) / self.window_size return max(0.5, 1.0 - (volatility * scale))
[docs] def get_drawdown_factor(self) -> float: """Calculate the adjustment factor based on current drawdown.""" if self.current_bankroll is None or self.peak_bankroll is None: return 1.0 if self.peak_bankroll <= 0: # No positive peak to measure a drawdown against. return 1.0 drawdown = 1.0 - (self.current_bankroll / self.peak_bankroll) return max(0.5, 1.0 - drawdown)
[docs] def get_probability_factor(self, probability: float) -> float: """Calculate the adjustment factor based on probability.""" # Only apply probability factor if we have some results if not self.results: return 1.0 # Scale linearly from 0.5 at 50% probability to 1.5 at 100% probability return max(0.5, min(1.5, 1.0 + (probability - 0.5)))
[docs] def evaluate(self, probability: float, current_bankroll: float) -> float: """ Calculate the bet size based on all adjustment factors. Parameters ---------- probability : float The probability of a successful outcome. current_bankroll : float The current bankroll amount. Returns ------- float The proportion of the current bankroll to bet. """ probability = _validate_probability(probability) current_bankroll = _require_finite(current_bankroll, "Current bankroll") # Don't bet on sub-threshold probabilities if probability < self.min_probability: return 0.0 # Initialize or update bankroll tracking if self.initial_bankroll is None: self.initial_bankroll = current_bankroll self.peak_bankroll = current_bankroll self.current_bankroll = current_bankroll self.peak_bankroll = max(self.peak_bankroll, current_bankroll) # Get all adjustment factors streak_factor = self.get_streak_factor() volatility_factor = self.get_volatility_factor() drawdown_factor = self.get_drawdown_factor() probability_factor = self.get_probability_factor(probability) # Combine all factors combined_factor = ( streak_factor * volatility_factor * drawdown_factor * probability_factor ) # Calculate adjusted bet size bet_size = self.base_fraction * combined_factor # Ensure bet size is within min/max bounds bet_size = max(self.min_fraction, min(self.max_fraction, bet_size)) # Never exceed the ruin-safe fraction return min(bet_size, self.get_max_safe_bet(current_bankroll))
[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. Dynamic strategy adjusts based on history, but for a one-time decision with no history, we use the base fraction. Parameters ---------- outcomes : array-like The possible payoffs from the gamble (unused) probabilities : array-like The probability of each outcome (unused) current_wealth : float Current wealth before the gamble. Must be finite and greater than 0. tolerance : float, default=0.01 Convergence tolerance (unused, kept for API consistency). Must still 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 (base fraction of wealth) Raises ------ ValueError If the gamble arrays are malformed, or if any scalar control falls outside the ranges documented above. Notes ----- Dynamic strategy normally adjusts based on recent performance history. For a one-time decision with no history, we fall back to the base_fraction. This represents a neutral starting point before dynamic adjustments. """ _normalize_gamble(outcomes, probabilities) _validate_entry_price_scalars(current_wealth, tolerance, max_search_fraction) # Use base fraction since we have no history for a one-time decision return min( self.base_fraction * current_wealth, max_search_fraction * current_wealth )
[docs] class OptimalF(BaseStrategy): """ Implementation of the Optimal f strategy for binary betting. This strategy calculates the optimal fraction of bankroll to bet based on the win rate and payoff ratio. It's similar to Kelly but uses a different approach to risk management. 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. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). win_rate : float The historical or expected win rate (between 0 and 1). max_risk_fraction : float, default=0.2 The maximum fraction of bankroll that can be risked on a single bet. """ def __init__( self, payoff: float, loss: float, transaction_cost_rate: float, win_rate: float, max_risk_fraction: float = 0.2, ) -> None: """ Initialize the OptimalF strategy. 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. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). win_rate : float The historical or expected win rate (between 0 and 1). max_risk_fraction : float, default=0.2 The maximum fraction of bankroll that can be risked on a single bet. """ if not 0 <= win_rate <= 1: raise ValueError("Win rate must be between 0 and 1") if not 0 < max_risk_fraction <= 1: raise ValueError("Maximum risk fraction must be between 0 and 1") super().__init__(payoff, loss, transaction_cost_rate) self.win_rate = win_rate self.max_risk_fraction = max_risk_fraction
[docs] def evaluate(self, probability: float, current_bankroll: float) -> float: """ Calculate the optimal f bet size based on win rate and payoff ratio. This implementation follows Ralph Vince's approach to Optimal f, which is based on maximizing the terminal wealth relative (TWR) for a given risk-to-reward profile. Parameters ---------- probability : float The probability of a successful outcome, used to decide whether to bet. Probabilities below 0.5 return zero; bet sizing uses ``win_rate``. current_bankroll : float The current bankroll amount. Returns ------- float The optimal proportion of the bankroll to stake: Ralph Vince's optimal f converted from a risk fraction to a stake fraction. The two coincide only when ``loss + transaction_cost_rate`` is 1. """ probability = _validate_probability(probability) current_bankroll = _require_finite(current_bankroll, "Current bankroll") if probability < 0.5: # Use 0.5 as default minimum probability return 0.0 adjusted_win_rate = self.win_rate adjusted_loss_rate = 1 - adjusted_win_rate # Calculate the risk-to-reward ratio (R-multiple) reward = self.payoff - self.transaction_cost_rate risk = self.loss + self.transaction_cost_rate # If transaction costs make the bet unprofitable, don't bet if reward <= 0: return 0.0 # Calculate optimal f using the formula: # f* = W - (1-W)/(R/L) where W is win rate, R is reward, L is risk # This is more aligned with Ralph Vince's approach optimal_f = adjusted_win_rate - (adjusted_loss_rate / (reward / risk)) # Cap at our maximum risk fraction optimal_f = min(max(0, optimal_f), self.max_risk_fraction) # Vince's f is a *risk* fraction: staking a fraction s of the bankroll # puts s * (loss + transaction_cost_rate) at risk, so the TWR-optimal stake # is f* / (loss + transaction_cost_rate), the Kelly closed form # W/(l+c) - (1-W)/(b-c) for this game. evaluate() returns a stake # fraction, so convert; the two agree only when loss + cost == 1. stake_fraction = optimal_f / risk # Ensure we never bet more than would result in negative bankroll return min(stake_fraction, self.get_max_safe_bet(current_bankroll))
[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. Optimal F maximizes geometric growth (like Kelly), so we use log utility. 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 ------ ValueError If the gamble arrays are malformed, or if any scalar control falls outside the ranges documented above. Notes ----- Optimal F, like Kelly Criterion, aims to maximize geometric growth, which corresponds to log utility (γ=1.0). """ return find_indifference_price( outcomes=outcomes, probabilities=probabilities, current_wealth=current_wealth, risk_aversion=1.0, # Optimal F uses log utility like Kelly tolerance=tolerance, max_search_fraction=max_search_fraction, )
[docs] class MertonShare(BaseStrategy): """ Implementation of the Merton Share strategy using CRRA utility. This strategy is based on Robert Merton's portfolio optimization problem [1]_ with Constant Relative Risk Aversion (CRRA) utility. The optimal fraction to invest is proportional to the expected excess return divided by the product of risk aversion and variance. The formula is: f* = μ / (γ × σ²) [2]_ Where: - f* is the optimal fraction of wealth to invest - μ is the expected excess return (return above risk-free rate) - γ (gamma) is the coefficient of relative risk aversion - σ² (sigma squared) is the variance of returns For binary betting, we adapt this by: - Expected return is calculated from probability and payoff/loss ratios - Variance is estimated from the binary outcome distribution - Transaction costs are incorporated into the expected return 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. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). risk_aversion : float, default=2.0 The coefficient of relative risk aversion (γ). Common values: - 1.0: Low risk aversion - 2.0: Moderate risk aversion (most common empirical estimate) - 3.0-5.0: High risk aversion min_probability : float, default=0.5 The minimum probability required to place a bet. max_fraction : float, default=1.0 The maximum fraction of bankroll to bet (safety cap). References ---------- .. [1] Merton, R. C. (1969). "Lifetime Portfolio Selection under Uncertainty: The Continuous-Time Case". The Review of Economics and Statistics. .. [2] https://elmwealth.com/merton-share-derivations/ """ def __init__( self, payoff: float, loss: float, transaction_cost_rate: float, risk_aversion: float = 2.0, min_probability: float = 0.5, max_fraction: float = 1.0, ) -> None: """ Initialize the MertonShare strategy. 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. transaction_cost_rate : float The transaction cost as a fraction of each unit staked (per-unit, not a fixed per-transaction amount). risk_aversion : float, default=2.0 The coefficient of relative risk aversion. min_probability : float, default=0.5 The minimum probability required to place a bet. max_fraction : float, default=1.0 The maximum fraction of bankroll to bet. """ risk_aversion = _require_finite(risk_aversion, "Risk aversion") if risk_aversion <= 0: raise ValueError("Risk aversion must be greater than 0") if not 0 <= min_probability <= 1: raise ValueError("Minimum probability must be between 0 and 1") if not 0 < max_fraction <= 1: raise ValueError("Maximum fraction must be between 0 and 1") super().__init__(payoff, loss, transaction_cost_rate) self.risk_aversion = risk_aversion self.min_probability = min_probability self.max_fraction = max_fraction
[docs] def evaluate(self, probability: float, current_bankroll: float) -> float: """ Calculate the Merton Share bet size using CRRA utility. Parameters ---------- probability : float The probability of a successful outcome, typically between 0 and 1. current_bankroll : float The current bankroll amount. Returns ------- float The optimal proportion of the bankroll to bet based on Merton's formula. """ probability = _validate_probability(probability) current_bankroll = _require_finite(current_bankroll, "Current bankroll") if probability < self.min_probability: return 0.0 # Calculate expected return accounting for transaction costs # Expected return = p * (payoff - tc) - (1-p) * (loss + tc) expected_return = probability * (self.payoff - self.transaction_cost_rate) - ( 1 - probability ) * (self.loss + self.transaction_cost_rate) # Don't bet if expected return is non-positive if expected_return <= 0: return 0.0 # Calculate variance of returns for a binary outcome # For a binary bet: Var(R) = p * (payoff)^2 + (1-p) * (-loss)^2 - E[R]^2 # We use gross returns (before transaction costs) for variance calculation # Coerce to np.float64 before squaring: Python floats raise OverflowError # once payoff**2 exceeds float range (payoff ~1.34e154), while float64 # saturates to inf (with a numpy warning) and the guard below handles it. payoff = np.float64(self.payoff) loss = np.float64(self.loss) mean_squared_return = probability * payoff**2 + (1 - probability) * loss**2 variance = ( mean_squared_return - (probability * payoff - (1 - probability) * loss) ** 2 ) # Avoid division by zero; "not variance > 0" also catches NaN variance # (inf - inf after saturation), which a bare <= 0 test would let through. if not variance > 0: return 0.0 # Apply Merton's formula: f* = μ / (γ × σ²) merton_fraction = expected_return / (self.risk_aversion * variance) # Apply safety constraints merton_fraction = max(0.0, min(merton_fraction, self.max_fraction)) # Ensure we never bet more than would result in negative bankroll return min(merton_fraction, self.get_max_safe_bet(current_bankroll))
[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. MertonShare is derived from CRRA utility maximization with the risk_aversion parameter (γ) that was set during initialization. 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 ------ ValueError If the gamble arrays are malformed, or if any scalar control falls outside the ranges documented above. Notes ----- Uses the risk_aversion (γ) parameter set during initialization. For γ=1.0, this is equivalent to Kelly Criterion (log utility). Higher γ values indicate more risk aversion and lower willing payments. """ return find_indifference_price( outcomes=outcomes, probabilities=probabilities, current_wealth=current_wealth, risk_aversion=self.risk_aversion, # Use the strategy's γ parameter tolerance=tolerance, max_search_fraction=max_search_fraction, )