#!/usr/bin/env python3
"""
Monte Carlo Poker Equity Calculator.
Calculates real equity for a hand against N opponents using Monte Carlo simulation
with phevaluator for fast hand evaluation.
Usage:
python equity_calculator.py '<json_input>'
Input JSON:
{
"hero_cards": ["Ah", "Kd"],
"community_cards": ["Qh", "7d", "2c"], // can be 0-5 cards
"num_opponents": 2,
"num_simulations": 10000,
"dead_cards": [], // optional, cards known to be out
"villain_range_tightness": 0.5 // optional, 0.0 = any two cards, 1.0 = top 5%
}
Output JSON:
{
"equity": 0.653,
"win_rate": 0.621,
"tie_rate": 0.032,
"simulations_run": 10000,
"hand_distribution": {
"high_card": 0.12,
"pair": 0.35,
"two_pair": 0.22,
...
}
}
"""
import json
import random
import sys
from collections import Counter
from typing import Optional
from phevaluator import evaluate_cards
# ── Card utilities ───────────────────────────────────────────────────
RANKS = "23456789TJQKA"
SUITS = "shdc"
ALL_CARDS = [f"{r}{s}" for r in RANKS for s in SUITS]
# Hand rank categories from phevaluator
# phevaluator returns rank 1 (Royal Flush) to 7462 (worst high card)
# Lower is better
RANK_CATEGORIES = [
(1, 10, "straight_flush"),
(11, 166, "four_of_a_kind"),
(167, 322, "full_house"),
(323, 1599, "flush"),
(1600, 1609, "straight"),
(1610, 2467, "three_of_a_kind"),
(2468, 3325, "two_pair"),
(3326, 6185, "one_pair"),
(6186, 7462, "high_card"),
]
def rank_to_category(rank: int) -> str:
"""Convert phevaluator rank to hand category name."""
for low, high, name in RANK_CATEGORIES:
if low <= rank <= high:
return name
return "unknown"
def card_rank_value(card: str) -> int:
"""Get numeric rank value of a card (2=0, A=12)."""
return RANKS.index(card[0])
# ── Range estimation (dynamically computed, no hardcoded hand lists) ──
_CACHED_HAND_RANKINGS: list[str] | None = None
def _generate_all_169_hands() -> list[str]:
"""Generate all 169 canonical starting hands (pairs, suited, offsuit)."""
rank_chars = "AKQJT98765432"
hands = []
for i, r1 in enumerate(rank_chars):
for j, r2 in enumerate(rank_chars):
if i == j:
hands.append(f"{r1}{r2}") # pair
elif i < j:
hands.append(f"{r1}{r2}s") # suited (higher rank first)
hands.append(f"{r1}{r2}o") # offsuit
return hands
def _representative_cards(canonical: str) -> tuple[str, str]:
"""Convert canonical notation to representative specific cards for equity calc."""
if len(canonical) == 2:
# Pair: e.g., "AA" -> ("Ah", "Ad")
return (f"{canonical[0]}h", f"{canonical[0]}d")
elif canonical[2] == "s":
# Suited: e.g., "AKs" -> ("Ah", "Kh")
return (f"{canonical[0]}h", f"{canonical[1]}h")
else:
# Offsuit: e.g., "AKo" -> ("Ah", "Kd")
return (f"{canonical[0]}h", f"{canonical[1]}d")
def _compute_hand_rankings() -> list[str]:
"""
Compute preflop hand rankings by running Monte Carlo equity for each
of the 169 canonical hands vs 1 random opponent. Sorted strongest first.
"""
hands = _generate_all_169_hands()
equities = []
for hand in hands:
c1, c2 = _representative_cards(hand)
used = {c1, c2}
deck = [c for c in ALL_CARDS if c not in used]
wins = 0
total = 0
# 500 iterations per hand — fast enough for 169 hands (~0.5s total)
for _ in range(500):
random.shuffle(deck)
opp = deck[:2]
board = deck[2:7]
hero_rank = evaluate_cards(c1, c2, *board)
opp_rank = evaluate_cards(opp[0], opp[1], *board)
if hero_rank < opp_rank:
wins += 1
elif hero_rank == opp_rank:
wins += 0.5
total += 1
equities.append((hand, wins / total if total > 0 else 0))
equities.sort(key=lambda x: -x[1])
return [h for h, _ in equities]
def get_hand_rankings() -> list[str]:
"""Get hand rankings, computing and caching on first call."""
global _CACHED_HAND_RANKINGS
if _CACHED_HAND_RANKINGS is None:
_CACHED_HAND_RANKINGS = _compute_hand_rankings()
return _CACHED_HAND_RANKINGS
def hand_to_canonical(card1: str, card2: str) -> str:
"""Convert two specific cards to canonical hand notation (e.g., AKs, AKo, AA)."""
r1 = card_rank_value(card1)
r2 = card_rank_value(card2)
s1 = card1[1]
s2 = card2[1]
if r1 < r2:
r1, r2 = r2, r1
s1, s2 = s2, s1
rank1 = RANKS[r1]
rank2 = RANKS[r2]
if r1 == r2:
return f"{rank1}{rank2}"
elif s1 == s2:
return f"{rank1}{rank2}s"
else:
return f"{rank1}{rank2}o"
def get_range_hands(tightness: float) -> set:
"""
Get set of canonical hands for a given tightness level.
Rankings are computed dynamically via Monte Carlo equity simulation.
tightness 0.0 = any two cards (169 hands)
tightness 1.0 = top ~5%
"""
rankings = get_hand_rankings()
total = len(rankings)
num_hands = max(1, int(total * (1.0 - tightness)))
return set(rankings[:num_hands])
def is_in_range(card1: str, card2: str, allowed_hands: set) -> bool:
"""Check if a specific two-card hand falls within the allowed range."""
canonical = hand_to_canonical(card1, card2)
return canonical in allowed_hands
# ── Monte Carlo Simulation ───────────────────────────────────────────
def run_equity_simulation(
hero_cards: list[str],
community_cards: list[str],
num_opponents: int,
num_simulations: int = 10000,
dead_cards: list[str] | None = None,
villain_range_tightness: float = 0.0,
) -> dict:
"""
Run Monte Carlo equity simulation.
Args:
hero_cards: List of 2 hero hole cards (e.g., ["Ah", "Kd"])
community_cards: List of 0-5 community cards
num_opponents: Number of opponents still in the hand
num_simulations: Number of MC iterations
dead_cards: Known dead/folded cards
villain_range_tightness: 0.0 = random, 1.0 = very tight range
Returns:
Equity results dict
"""
if dead_cards is None:
dead_cards = []
# Cards that can't be dealt
used_cards = set(hero_cards + community_cards + dead_cards)
deck = [c for c in ALL_CARDS if c not in used_cards]
cards_to_deal_community = 5 - len(community_cards)
cards_to_deal_total = cards_to_deal_community + (num_opponents * 2)
# Range filtering
range_hands = None
if villain_range_tightness > 0.01:
range_hands = get_range_hands(villain_range_tightness)
wins = 0
ties = 0
total = 0
hand_categories = Counter()
for _ in range(num_simulations):
random.shuffle(deck)
# Deal opponent hands with optional range filtering
opponents_cards = []
remaining_deck = list(deck)
valid_deal = True
for opp in range(num_opponents):
if range_hands:
# Try to find valid hand from range
found = False
for i in range(len(remaining_deck)):
for j in range(i + 1, len(remaining_deck)):
c1, c2 = remaining_deck[i], remaining_deck[j]
if is_in_range(c1, c2, range_hands):
opponents_cards.append([c1, c2])
remaining_deck = [
c for k, c in enumerate(remaining_deck)
if k != i and k != j
]
found = True
break
if found:
break
if not found:
# Fall back to random if can't find range hand
if len(remaining_deck) >= 2:
opponents_cards.append(remaining_deck[:2])
remaining_deck = remaining_deck[2:]
else:
valid_deal = False
break
else:
if len(remaining_deck) >= 2:
opponents_cards.append(remaining_deck[:2])
remaining_deck = remaining_deck[2:]
else:
valid_deal = False
break
if not valid_deal:
continue
# Deal remaining community cards
run_out = remaining_deck[:cards_to_deal_community]
full_board = community_cards + run_out
if len(full_board) != 5:
continue
# Evaluate hero hand
hero_rank = evaluate_cards(*(hero_cards + full_board))
category = rank_to_category(hero_rank)
hand_categories[category] += 1
# Evaluate opponent hands
best_opponent_rank = 7463 # Worse than worst hand
for opp_cards in opponents_cards:
opp_rank = evaluate_cards(*(opp_cards + full_board))
best_opponent_rank = min(best_opponent_rank, opp_rank)
# Compare (lower rank = better hand)
if hero_rank < best_opponent_rank:
wins += 1
elif hero_rank == best_opponent_rank:
ties += 1
total += 1
if total == 0:
return {"error": "No valid simulations completed"}
equity = (wins + ties * 0.5) / total
win_rate = wins / total
tie_rate = ties / total
# Normalize hand distribution
hand_dist = {}
for cat, count in hand_categories.items():
hand_dist[cat] = round(count / total, 4)
return {
"equity": round(equity, 4),
"win_rate": round(win_rate, 4),
"tie_rate": round(tie_rate, 4),
"simulations_run": total,
"hand_distribution": hand_dist,
}
# ── Entry point ──────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2:
print(json.dumps({"error": "Usage: python equity_calculator.py '<json_input>'"}))
sys.exit(1)
try:
params = json.loads(sys.argv[1])
except json.JSONDecodeError as e:
print(json.dumps({"error": f"Invalid JSON: {e}"}))
sys.exit(1)
hero_cards = params.get("hero_cards", [])
community_cards = params.get("community_cards", [])
num_opponents = params.get("num_opponents", 1)
num_simulations = params.get("num_simulations", 10000)
dead_cards = params.get("dead_cards", [])
villain_range_tightness = params.get("villain_range_tightness", 0.0)
if len(hero_cards) != 2:
print(json.dumps({"error": "hero_cards must have exactly 2 cards"}))
sys.exit(1)
result = run_equity_simulation(
hero_cards=hero_cards,
community_cards=community_cards,
num_opponents=num_opponents,
num_simulations=num_simulations,
dead_cards=dead_cards,
villain_range_tightness=villain_range_tightness,
)
print(json.dumps(result))
if __name__ == "__main__":
main()