poker-risk-engine / decision_engine.py
ThreeSixNine's picture
Upload decision_engine.py with huggingface_hub
3680f5b verified
Raw History Blame Contribute Delete
7.14 kB
"""Poker decision engine with a single, calibrated risk knob (r in [0, 1]).
Design (v0, documented limitations at bottom):
- Equity: Monte Carlo vs uniform random opponent hole card(s) and remaining
board, via the `treys` evaluator.
- The knob r simultaneously and MONOTONICALLY controls:
calling threshold : need = pot_odds - 0.20*r (risk-seeking calls thinner)
raise threshold : 0.62 - 0.10*r (aggression scales up)
bet threshold : 0.60 - 0.12*r (bluffier at high r)
bet sizing : 0.45 + 0.55 of pot, scaled by equity excess
- Position modifier: BTN -0.03, SB/BB +0.03, middle +0.00 on all thresholds.
- EV model (heuristic, for policy comparison only — not ground truth):
fold: 0
call: eq*(pot+to_call) - to_call
bet X: p_fold*pot + (1-p_fold)*(eq*(pot+2X) - X),
p_fold = clamp(0.6 - 1.2*eq, 0.05, 0.6) (opponent folds more vs big eq)
"""
import random
from dataclasses import dataclass
from treys import Card as TCard
from treys import Evaluator
@dataclass
class TableState:
hero: list # ['AS', 'KD'] ASCII card strings
board: list # up to 5 ASCII cards
pot: int # chips in middle, including current street bets
stack: int # hero chips behind
to_call: int # 0 if checked to hero
n_players: int # players still in the hand
position: str # BTN, SB, BB, UTG, MP, MP2, CO
street: str # preflop, flop, turn, river
# treys card ints are bit-encoded, not 0..51 — build the real 52-card deck once
_ALL_CARDS = [TCard.new(r + s) for r in "23456789TJQKA" for s in "shdc"]
_POS_MOD = {"BTN": -0.03, "CO": -0.02, "SB": 0.03, "BB": 0.03,
"UTG": 0.02, "UTG+1": 0.02, "MP": 0.0, "MP2": 0.0}
def parse_card(c):
c = c.strip().upper()
if c[-1] in "SHDC" and len(c) == 2:
return TCard.new(c[:-1] + c[-1].lower())
# unicode suit form 'A♠'
suit = {"♠": "s", "♥": "h", "♦": "d", "♣": "c"}.get(c[-1])
rank = c[:-1].replace("10", "T")
return TCard.new(rank + suit.lower())
class RiskEngine:
def __init__(self, n_sims=1500, seed=7):
self.ev = Evaluator()
self.n_sims = n_sims
self.rng = random.Random(seed)
def equity(self, hero, board, n_opponents=1):
"""Win/tie probability vs uniform random opponents. ~0.15-0.3 s for 1500 sims."""
hero_c = [parse_card(c) for c in hero]
board_c = [parse_card(c) for c in board]
known = set(hero_c + board_c)
deck = [c for c in _ALL_CARDS if c not in known]
wins = ties = 0
n_sims = self.n_sims
for _ in range(n_sims):
self.rng.shuffle(deck)
opp_hands = []
need = 2 * n_opponents + (5 - len(board_c))
used = deck[:need]
k = 0
full_board = board_c + used[k:k + (5 - len(board_c))]
k += (5 - len(board_c))
best = None
hero_score = self.ev.evaluate(hero_c, full_board)
all_scores = [hero_score]
for o in range(n_opponents):
oc = used[k:k + 2]
k += 2
all_scores.append(self.ev.evaluate(oc, full_board))
m = min(all_scores)
if all_scores[0] == m and all_scores.count(m) == 1:
wins += 1
elif all_scores[0] == m:
ties += 1
return (wins + 0.5 * ties) / n_sims
def thresholds(self, r, position):
mod = _POS_MOD.get(position, 0.0)
return {
"call_margin": 0.20 * r, # equity buffer below pot odds
"raise_thr": max(0.45, 0.62 - 0.10 * r - mod),
"bet_thr": max(0.42, 0.60 - 0.12 * r - mod),
"bet_frac": 0.45 + 0.55 * r, # of pot
}
def decide(self, state: TableState, r: float, eq=None):
"""Action for the state at risk knob r. eq may be precomputed."""
t = self.thresholds(r, state.position)
if eq is None:
eq = self.equity(state.hero, state.board,
max(1, state.n_players - 1))
pot, to_call, stack = state.pot, state.to_call, state.stack
pot_odds = to_call / (pot + to_call) if to_call > 0 else 0.0
need = max(0.0, pot_odds - t["call_margin"])
if to_call > 0:
if eq >= t["raise_thr"] and stack > to_call:
amt = int(min(stack - to_call,
(0.45 + 0.55 * r) * (pot + 2 * to_call)
* (0.5 + eq)))
return {"action": "raise", "amount": max(1, amt), "equity": eq,
"thresholds": t}
if eq >= need:
return {"action": "call", "amount": to_call, "equity": eq,
"thresholds": t}
return {"action": "fold", "amount": 0, "equity": eq,
"thresholds": t}
# checked to hero
if eq >= t["bet_thr"]:
amt = int(min(stack, t["bet_frac"] * pot * (0.5 + eq)))
return {"action": "bet" if state.street != "preflop" else "raise",
"amount": max(1, amt), "equity": eq, "thresholds": t}
return {"action": "check", "amount": 0, "equity": eq, "thresholds": t}
# --- heuristic EV (policy comparison only) ---
def ev_of(self, state, action, eq):
pot, to_call, stack = state.pot, state.to_call, state.stack
if action["action"] in ("fold", "check"):
return 0.0
if action["action"] == "call":
return eq * (pot + to_call) - to_call
X = min(stack, action["amount"])
p_fold = min(0.6, max(0.05, 0.6 - 1.2 * eq))
return p_fold * pot + (1 - p_fold) * (eq * (pot + 2 * X) - X)
def best_ev(self, state, eq):
"""Best heuristic EV among the candidate actions (oracle for decision loss)."""
cands = []
t = self.thresholds(0.5, state.position)
cands.append({"action": "fold", "amount": 0})
if state.to_call > 0:
cands.append({"action": "call", "amount": state.to_call})
if state.stack > state.to_call:
cands.append({"action": "raise",
"amount": min(state.stack - state.to_call,
int(0.8 * (state.pot + 2 * state.to_call)))})
else:
cands.append({"action": "check", "amount": 0})
cands.append({"action": "bet",
"amount": min(state.stack, int(0.8 * state.pot))})
return max(self.ev_of(state, c, eq) for c in cands)
LIMITATIONS = """v0 limitations, by design:
- One opponent model: uniform random hole cards. No opponent ranges/reads.
- Single-street decisions: no future-street planning, no ICM, no multiway
pot dynamics beyond equity-vs-n-opponents.
- EV model is heuristic (fold-equity linear in equity) and is used only to
COMPARE policies on the same footing, never as ground truth.
- Preflop uses the same equity MC (no chart knowledge).
"""