Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 66 additions & 18 deletions crapssim/bet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand All @@ -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]
Expand All @@ -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:
Expand Down
18 changes: 15 additions & 3 deletions crapssim/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/test_buy_lay_commission.py
Original file line number Diff line number Diff line change
@@ -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)
Loading