"""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). """