Download decision_engine.py from ThreeSixNine/poker-risk-engine: direct link, hf CLI and curl.
- Browser
- Download file 7.14 kB
-
https://huggingface.co/ThreeSixNine/poker-risk-engine/resolve/main/decision_engine.py
- Command line
-
hf download hf://ThreeSixNine/poker-risk-engine/decision_engine.py
-
curl -L -o decision_engine.py https://huggingface.co/ThreeSixNine/poker-risk-engine/resolve/main/decision_engine.py
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 | |
| 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). | |
| """ | |