diff --git a/crapssim/bet.py b/crapssim/bet.py index e9f27991..de9e0b5d 100644 --- a/crapssim/bet.py +++ b/crapssim/bet.py @@ -110,6 +110,7 @@ class TableSettings(TypedDict, total=False): commission_mode: Literal["on_win", "on_bet"] commission_rounding: Literal["none", "ceil_dollar", "nearest_dollar"] commission_floor: float + buy_vig_on_win: bool class Table(Protocol): @@ -195,6 +196,11 @@ def get_result(self, table: Table) -> BetResult: """ pass + def placement_cost(self, table: Table) -> float: + """Total bankroll required to put this bet in action on ``table``.""" + + return self.amount + def update_number(self, table: Table): """ Update the bet's number, if applicable @@ -766,7 +772,10 @@ def __repr__(self) -> str: class Buy(_SimpleBet): - """True-odds bet on 4/5/6/8/9/10 that charges commission per table policy.""" + """True-odds bet on 4/5/6/8/9/10 that charges commission per table policy. + + Commission may be taken on the win or upfront based on ``buy_vig_on_win``. + """ true_odds = {4: 2.0, 10: 2.0, 5: 1.5, 9: 1.5, 6: 1.2, 8: 1.2} losing_numbers: list[int] = [7] @@ -778,25 +787,43 @@ def __init__(self, number: int, amount: SupportsFloat) -> None: self.number = number self.payout_ratio = self.true_odds[number] self.winning_numbers = [number] + self.wager: float = self.amount + """Base amount that determines true-odds payouts.""" + self.vig_paid: float = 0.0 + """Commission already collected upfront (non-refundable).""" + + def placement_cost(self, table: "Table") -> float: + if table.settings.get("buy_vig_on_win", True): + return self.wager + commission = _compute_commission( + table, gross_win=self.wager, bet_amount=self.wager + ) + return self.wager + commission def get_result(self, table: "Table") -> BetResult: if table.dice.total == self.number: - gross_win = self.payout_ratio * self.amount - commission = _compute_commission( - table, gross_win=gross_win, bet_amount=self.amount - ) - result_amount = gross_win - commission + self.amount + gross_win = self.payout_ratio * self.wager + if table.settings.get("buy_vig_on_win", True): + commission = _compute_commission( + table, gross_win=gross_win, bet_amount=self.wager + ) + else: + commission = 0.0 + result_amount = gross_win - commission + self.wager remove = True elif table.dice.total == 7: - result_amount = -self.amount + result_amount = -(self.wager + self.vig_paid) remove = True else: result_amount = 0 remove = False - return BetResult(result_amount, remove, self.amount) + return BetResult(result_amount, remove, self.wager) def copy(self) -> "Buy": - return self.__class__(self.number, self.amount) + new_bet = self.__class__(self.number, self.amount) + new_bet.wager = self.wager + new_bet.vig_paid = self.vig_paid + return new_bet @property def _placed_key(self) -> Hashable: @@ -807,7 +834,10 @@ def __repr__(self) -> str: class Lay(_SimpleBet): - """True-odds bet against 4/5/6/8/9/10, paying if 7 arrives first.""" + """True-odds bet against 4/5/6/8/9/10, paying if 7 arrives first. + + Commission may be taken on the win or upfront based on ``buy_vig_on_win``. + """ true_odds = {4: 0.5, 10: 0.5, 5: 2 / 3, 9: 2 / 3, 6: 5 / 6, 8: 5 / 6} winning_numbers: list[int] = [7] @@ -819,25 +849,43 @@ def __init__(self, number: int, amount: SupportsFloat) -> None: self.number = number self.payout_ratio = self.true_odds[number] self.losing_numbers = [number] + self.wager: float = self.amount + """Base amount risked against the box number.""" + self.vig_paid: float = 0.0 + """Commission already collected upfront (non-refundable).""" + + def placement_cost(self, table: "Table") -> float: + if table.settings.get("buy_vig_on_win", True): + return self.wager + commission = _compute_commission( + table, gross_win=self.wager, bet_amount=self.wager + ) + return self.wager + commission def get_result(self, table: "Table") -> BetResult: if table.dice.total == 7: - gross_win = self.payout_ratio * self.amount - commission = _compute_commission( - table, gross_win=gross_win, bet_amount=self.amount - ) - result_amount = gross_win - commission + self.amount + gross_win = self.payout_ratio * self.wager + if table.settings.get("buy_vig_on_win", True): + commission = _compute_commission( + table, gross_win=gross_win, bet_amount=self.wager + ) + else: + commission = 0.0 + result_amount = gross_win - commission + self.wager remove = True elif table.dice.total == self.number: - result_amount = -self.amount + result_amount = -(self.wager + self.vig_paid) remove = True else: result_amount = 0 remove = False - return BetResult(result_amount, remove, self.amount) + return BetResult(result_amount, remove, self.wager) def copy(self) -> "Lay": - return self.__class__(self.number, self.amount) + new_bet = self.__class__(self.number, self.amount) + new_bet.wager = self.wager + new_bet.vig_paid = self.vig_paid + return new_bet @property def _placed_key(self) -> Hashable: diff --git a/crapssim/table.py b/crapssim/table.py index 8afa9153..a288e543 100644 --- a/crapssim/table.py +++ b/crapssim/table.py @@ -165,6 +165,7 @@ class TableSettings(TypedDict, total=False): commission_mode: Literal["on_win", "on_bet"] commission_rounding: Literal["none", "ceil_dollar", "nearest_dollar"] commission_floor: float + buy_vig_on_win: bool # existing: ATS_payouts, field_payouts, fire_payouts, hop_payouts, max odds, etc. """ @@ -177,6 +178,7 @@ class TableSettings(TypedDict, total=False): commission_mode: Literal["on_win", "on_bet"] commission_rounding: Literal["none", "ceil_dollar", "nearest_dollar"] commission_floor: float + buy_vig_on_win: bool class Table: @@ -194,6 +196,7 @@ def __init__(self, seed: int | None = None) -> None: "hop_payouts": {"easy": 15, "hard": 30}, "max_odds": {4: 3, 5: 4, 6: 5, 8: 5, 9: 4, 10: 3}, "max_dont_odds": {4: 6, 5: 6, 6: 6, 8: 6, 9: 6, 10: 6}, + "buy_vig_on_win": True, } self.pass_rolls: int = 0 self.last_roll: int | None = None @@ -394,13 +397,22 @@ def add_bet(self, bet: Bet) -> None: None: Always returns ``None``. """ existing_bets: list[Bet] = self.already_placed_bets(bet) + existing_cost = sum(x.placement_cost(self.table) for x in existing_bets) new_bet = sum(existing_bets + [bet]) - amount_available_to_bet = self.bankroll + sum(x.amount for x in existing_bets) + if hasattr(new_bet, "wager"): + new_bet.wager = new_bet.amount + new_cost = new_bet.placement_cost(self.table) + required_cash = new_cost - existing_cost - if new_bet.is_allowed(self) and new_bet.amount <= amount_available_to_bet: + if new_bet.is_allowed(self) and required_cash <= self.bankroll + 1e-9: for bet in existing_bets: self.bets.remove(bet) - self.bankroll -= bet.amount + self.bankroll -= required_cash + if hasattr(new_bet, "vig_paid"): + if self.table.settings.get("buy_vig_on_win", True): + new_bet.vig_paid = 0.0 + else: + new_bet.vig_paid = new_cost - new_bet.wager self.bets.append(new_bet) def already_placed_bets(self, bet: Bet) -> list[Bet]: diff --git a/tests/unit/test_buy_lay_commission.py b/tests/unit/test_buy_lay_commission.py new file mode 100644 index 00000000..ef16db6c --- /dev/null +++ b/tests/unit/test_buy_lay_commission.py @@ -0,0 +1,57 @@ +import math + +import pytest + +from crapssim.bet import Buy, _compute_commission +from crapssim.table import Table, TableUpdate + + +def test_buy_upfront_vig_debits_bankroll(): + table = Table() + table.settings["buy_vig_on_win"] = False + player = table.add_player(bankroll=100) + + for _ in range(5): + player.add_bet(Buy(4, 20)) + + assert len(player.bets) == 1 + placed_bet = player.bets[0] + assert placed_bet.amount == 80 + assert player.bankroll == pytest.approx(100 - 4 * (20 + 1)) + + +def test_buy_upfront_vig_loss_is_principal_plus_vig(): + table = Table() + table.settings["buy_vig_on_win"] = False + player = table.add_player(bankroll=100) + + starting_bankroll = player.bankroll + player.add_bet(Buy(4, 20)) + assert player.bankroll == pytest.approx(starting_bankroll - 21) + + TableUpdate.roll(table, fixed_outcome=(3, 4)) + TableUpdate.update_bets(table) + + assert not player.bets + assert player.bankroll == pytest.approx(starting_bankroll - 21) + + +def test_buy_vig_on_win_does_not_charge_at_placement(): + table = Table() + table.settings["buy_vig_on_win"] = True + player = table.add_player(bankroll=100) + + player.add_bet(Buy(4, 20)) + assert player.bankroll == pytest.approx(80) + active_bet = player.bets[0] + assert math.isclose(active_bet.vig_paid, 0.0) + + gross_win = active_bet.payout_ratio * active_bet.wager + commission = _compute_commission( + table, gross_win=gross_win, bet_amount=active_bet.wager + ) + + TableUpdate.roll(table, fixed_outcome=(2, 2)) + TableUpdate.update_bets(table) + + assert player.bankroll == pytest.approx(100 + gross_win - commission)