From 2b8e3dd4563654c5c522b1c45a90b87c9dc0f80e Mon Sep 17 00:00:00 2001 From: Junaid Saeed Uppal Date: Wed, 25 Feb 2026 22:45:43 +0500 Subject: [PATCH] add pattern analysis feature with web dashboard and CLI New /patterns page with 9 analyses: chair win bias, bet rank correlations, hand type distributions, pot size buckets, streaks, hourly patterns, and recent-vs-overall comparison. Also adds a standalone analyze.py CLI script for terminal output. --- analyze.py | 166 ++++++++++++++++ app/db.py | 196 +++++++++++++++++++ app/server.py | 14 ++ static/analytics.html | 5 +- static/index.html | 1 + static/patterns.html | 443 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 824 insertions(+), 1 deletion(-) create mode 100755 analyze.py create mode 100644 static/patterns.html diff --git a/analyze.py b/analyze.py new file mode 100755 index 000000000..b9e1f7dfd --- /dev/null +++ b/analyze.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Standalone CLI script for Teen Patti pattern analysis. + +Usage: + python analyze.py --host localhost --port 8123 +""" + +import argparse +import sys + + +def fmt_pct(n, total): + return f"{n/total*100:.1f}%" if total else "0.0%" + + +def print_table(headers, rows, col_widths=None): + """Print a simple formatted table.""" + if not col_widths: + col_widths = [max(len(str(h)), *(len(str(r[i])) for r in rows)) + for i, h in enumerate(headers)] + # Header + hdr = " ".join(str(h).ljust(w) for h, w in zip(headers, col_widths)) + print(hdr) + print("-" * len(hdr)) + for row in rows: + print(" ".join(str(c).ljust(w) for c, w in zip(row, col_widths))) + + +def main(): + parser = argparse.ArgumentParser(description="Teen Patti Pattern Analysis CLI") + parser.add_argument("--host", default="localhost", help="ClickHouse host") + parser.add_argument("--port", type=int, default=8123, help="ClickHouse HTTP port") + args = parser.parse_args() + + # Set config before importing db + from app import config + config.CLICKHOUSE_HOST = args.host + config.CLICKHOUSE_PORT = args.port + + from app import db + + print(f"Connecting to ClickHouse at {args.host}:{args.port}...") + try: + data = db.get_pattern_analysis() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + total = data["chair_bias"]["total_games"] + print(f"\n{'='*60}") + print(f" TEEN PATTI PATTERN ANALYSIS ({total:,} games)") + print(f"{'='*60}\n") + + # 1. Chair Win Bias + print("1. CHAIR WIN BIAS (expected 33.3%)") + cb = data["chair_bias"] + rows = [] + for ch in ("A", "B", "C"): + d = cb[ch] + diff = d["pct"] - 33.3 + sign = "+" if diff >= 0 else "" + rows.append([ch, f"{d['wins']:,}", f"{d['pct']:.1f}%", f"{sign}{diff:.1f}%"]) + print_table(["Chair", "Wins", "Win %", "vs Expected"], rows) + + # 2. Bet Rank Analysis + print("\n2. BET RANK ANALYSIS") + br = data["bet_rank"] + br_total = br["high"] + br["mid"] + br["low"] + rows = [] + for rank in ("high", "mid", "low"): + rows.append([rank.capitalize(), f"{br[rank]:,}", fmt_pct(br[rank], br_total)]) + print_table(["Rank", "Wins", "Win %"], rows) + + # 3. Per-Chair Bet Rank + print("\n3. PER-CHAIR: HIGHEST BET WIN RATE") + print(" When chair X has the highest bet, how often does X win?") + pcr = data["per_chair_rank"] + rows = [] + for ch in ("A", "B", "C"): + d = pcr.get(ch, {}) + rows.append([ch, f"{d.get('has_highest', 0):,}", + f"{d.get('wins', 0):,}", f"{d.get('win_pct', 0):.1f}%"]) + print_table(["Chair", "Times Highest", "Wins", "Win %"], rows) + + # 4. Hand Type Distribution by Chair + print("\n4. HAND TYPE DISTRIBUTION BY CHAIR") + htbc = data["hand_types_by_chair"] + hand_order = ["Trail", "Straight Flush", "Straight", "Flush", "Pair", "High Card"] + rows = [] + for ht in hand_order: + a = htbc["A"].get(ht, 0) + b = htbc["B"].get(ht, 0) + c = htbc["C"].get(ht, 0) + if a + b + c == 0: + continue + rows.append([ht, f"{a:,}", f"{b:,}", f"{c:,}"]) + print_table(["Hand Type", "Chair A", "Chair B", "Chair C"], rows) + + # 5. Hand Type Win Rates + print("\n5. HAND TYPE WIN RATES") + htw = data["hand_type_wins"] + htw_total = sum(htw.values()) + rows = [] + for ht in hand_order: + v = htw.get(ht, 0) + if v == 0: + continue + rows.append([ht, f"{v:,}", fmt_pct(v, htw_total)]) + print_table(["Hand Type", "Wins", "Win %"], rows) + + # 6. Pot Size Buckets + print("\n6. WIN RATES BY POT SIZE") + pb = data["pot_buckets"] + ranges = pb.get("_ranges", {}) + rows = [] + for bucket in ("small", "medium", "large", "whale"): + d = pb.get(bucket) + if not d: + continue + t = d["total"] or 1 + rows.append([ + bucket.capitalize(), ranges.get(bucket, ""), + f"{d['total']:,}", + f"{d['A']/t*100:.1f}%", f"{d['B']/t*100:.1f}%", f"{d['C']/t*100:.1f}%", + ]) + print_table(["Bucket", "Range", "Games", "A %", "B %", "C %"], rows) + + # 7. Streaks + print("\n7. STREAK ANALYSIS") + streaks = data["streaks"] + rows = [] + for ch in ("A", "B", "C"): + s = streaks[ch] + rows.append([ch, str(s["max_streak"]), str(s["current_streak"])]) + print_table(["Chair", "Max Streak", "Current Streak"], rows) + + # 8. Hourly Patterns + print("\n8. HOURLY PATTERNS (win % by hour)") + hourly = data["hourly"] + hours = sorted(hourly.keys(), key=lambda h: int(h)) + rows = [] + for h in hours: + d = hourly[h] + t = d["total"] or 1 + rows.append([ + f"{int(h):02d}:00", str(d["total"]), + f"{d['A']/t*100:.1f}%", f"{d['B']/t*100:.1f}%", f"{d['C']/t*100:.1f}%", + ]) + print_table(["Hour", "Games", "A %", "B %", "C %"], rows) + + # 9. Recent vs Overall + print("\n9. RECENT (LAST 100) vs ALL-TIME") + rva = data["recent_vs_all"] + for label, section in [("All-Time", rva["all"]), ("Last 100", rva["recent"])]: + t = section["total"] or 1 + d = section["dist"] + parts = " | ".join(f"{ch}: {d[ch]:>4} ({d[ch]/t*100:.1f}%)" for ch in ("A", "B", "C")) + print(f" {label:>10} [{t:>5} games] {parts}") + + print(f"\n{'='*60}") + print(" Done.") + + +if __name__ == "__main__": + main() diff --git a/app/db.py b/app/db.py index b1fa09cb4..77b224e28 100644 --- a/app/db.py +++ b/app/db.py @@ -571,6 +571,202 @@ def get_analytics(period: str = "all") -> dict: } +@_with_lock +def get_pattern_analysis() -> dict: + """Run all pattern analysis queries and return a single dict.""" + client = get_client() + + # 1. Chair win bias + result = client.query( + "SELECT winner, count() AS cnt FROM games GROUP BY winner ORDER BY winner" + ) + chair_wins = {} + total_games = 0 + for row in result.result_rows: + chair = config.CHAIRS.get(row[0], "?") + chair_wins[chair] = row[1] + total_games += row[1] + chair_bias = {"total_games": total_games} + for ch in ("A", "B", "C"): + wins = chair_wins.get(ch, 0) + pct = round(wins / total_games * 100, 2) if total_games else 0 + chair_bias[ch] = {"wins": wins, "pct": pct} + + # 2. Bet rank analysis — how often the highest/mid/lowest bet chair wins + rank_result = client.query(""" + SELECT + countIf(winner_bet >= greatest(bet_a, bet_b, bet_c)) AS high, + countIf(winner_bet > least(bet_a, bet_b, bet_c) + AND winner_bet < greatest(bet_a, bet_b, bet_c)) AS mid, + countIf(winner_bet <= least(bet_a, bet_b, bet_c)) AS low + FROM ( + SELECT bet_a, bet_b, bet_c, + multiIf(winner = 3, bet_a, winner = 2, bet_b, bet_c) AS winner_bet + FROM games WHERE bet_a + bet_b + bet_c > 0 + ) + """) + bet_rank = {"high": 0, "mid": 0, "low": 0} + if rank_result.result_rows: + r = rank_result.result_rows[0] + bet_rank = {"high": r[0], "mid": r[1], "low": r[2]} + + # 3. Per-chair bet rank — when chair X has max bet, how often does X win? + pcr = client.query(""" + SELECT + countIf(bet_a >= greatest(bet_a, bet_b, bet_c) AND bet_a > 0) AS a_high, + countIf(bet_a >= greatest(bet_a, bet_b, bet_c) AND bet_a > 0 AND winner = 3) AS a_win, + countIf(bet_b >= greatest(bet_a, bet_b, bet_c) AND bet_b > 0) AS b_high, + countIf(bet_b >= greatest(bet_a, bet_b, bet_c) AND bet_b > 0 AND winner = 2) AS b_win, + countIf(bet_c >= greatest(bet_a, bet_b, bet_c) AND bet_c > 0) AS c_high, + countIf(bet_c >= greatest(bet_a, bet_b, bet_c) AND bet_c > 0 AND winner = 1) AS c_win + FROM games WHERE bet_a + bet_b + bet_c > 0 + """) + per_chair_rank = {} + if pcr.result_rows: + r = pcr.result_rows[0] + for i, ch in enumerate(("A", "B", "C")): + has = r[i * 2] + wins = r[i * 2 + 1] + per_chair_rank[ch] = { + "has_highest": has, "wins": wins, + "win_pct": round(wins / has * 100, 2) if has else 0, + } + + # 4. Hand type distribution by chair (all dealt hands, not just winners) + ht_result = client.query(""" + SELECT 'A' AS chair, hand_type_a AS ht, count() AS cnt + FROM games WHERE hand_type_a > 0 GROUP BY ht + UNION ALL + SELECT 'B', hand_type_b, count() + FROM games WHERE hand_type_b > 0 GROUP BY hand_type_b + UNION ALL + SELECT 'C', hand_type_c, count() + FROM games WHERE hand_type_c > 0 GROUP BY hand_type_c + ORDER BY chair, ht + """) + hand_types_by_chair = {"A": {}, "B": {}, "C": {}} + for row in ht_result.result_rows: + ch = row[0] + type_name = config.HAND_TYPES.get(row[1], f"Type {row[1]}") + hand_types_by_chair[ch][type_name] = row[2] + + # 5. Hand type win rates (winning hand type distribution) + htw = client.query(""" + SELECT hand_type, count() AS cnt FROM ( + SELECT multiIf(winner = 3, hand_type_a, winner = 2, hand_type_b, hand_type_c) AS hand_type + FROM games + ) WHERE hand_type > 0 + GROUP BY hand_type ORDER BY hand_type + """) + hand_type_wins = {} + for row in htw.result_rows: + type_name = config.HAND_TYPES.get(row[0], f"Type {row[0]}") + hand_type_wins[type_name] = row[1] + + # 6. Pot size buckets — win rates by pot quartile + qr = client.query(""" + SELECT + quantile(0.25)(total_pot) AS q1, + quantile(0.5)(total_pot) AS q2, + quantile(0.75)(total_pot) AS q3 + FROM games + """) + pot_buckets = {} + if qr.result_rows: + q1, q2, q3 = int(qr.result_rows[0][0]), int(qr.result_rows[0][1]), int(qr.result_rows[0][2]) + br = client.query(f""" + SELECT + multiIf( + total_pot <= {q1}, 'small', + total_pot <= {q2}, 'medium', + total_pot <= {q3}, 'large', + 'whale' + ) AS bucket, + winner, count() AS cnt + FROM games GROUP BY bucket, winner + """) + for row in br.result_rows: + bucket, chair_id, cnt = row[0], row[1], row[2] + chair = config.CHAIRS.get(chair_id, "?") + if bucket not in pot_buckets: + pot_buckets[bucket] = {"A": 0, "B": 0, "C": 0, "total": 0} + pot_buckets[bucket][chair] = cnt + pot_buckets[bucket]["total"] += cnt + pot_buckets["_ranges"] = { + "small": f"0–{q1}", "medium": f"{q1+1}–{q2}", + "large": f"{q2+1}–{q3}", "whale": f">{q3}", + } + + # 7. Streak analysis — compute in Python from ordered winners + streak_result = client.query( + "SELECT winner FROM games ORDER BY game_no ASC" + ) + winners_list = [config.CHAIRS.get(r[0], "?") for r in streak_result.result_rows] + streaks = {} + for ch in ("A", "B", "C"): + max_s = cur = 0 + for w in winners_list: + if w == ch: + cur += 1 + max_s = max(max_s, cur) + else: + cur = 0 + # current streak from the end + current = 0 + for w in reversed(winners_list): + if w == ch: + current += 1 + else: + break + streaks[ch] = {"max_streak": max_s, "current_streak": current} + + # 8. Hourly patterns — win rates by hour of day + hr_result = client.query(""" + SELECT toHour(created_at) AS hr, winner, count() AS cnt + FROM games GROUP BY hr, winner ORDER BY hr, winner + """) + hourly = {} + for row in hr_result.result_rows: + h = str(row[0]) + chair = config.CHAIRS.get(row[1], "?") + if h not in hourly: + hourly[h] = {"A": 0, "B": 0, "C": 0, "total": 0} + hourly[h][chair] = row[2] + hourly[h]["total"] += row[2] + + # 9. Recent (last 100) vs overall + recent = client.query(""" + SELECT winner, count() AS cnt FROM ( + SELECT winner FROM games ORDER BY game_no DESC LIMIT 100 + ) GROUP BY winner + """) + recent_dist = {"A": 0, "B": 0, "C": 0} + recent_total = 0 + for row in recent.result_rows: + chair = config.CHAIRS.get(row[0], "?") + if chair in recent_dist: + recent_dist[chair] = row[1] + recent_total += row[1] + + return { + "chair_bias": chair_bias, + "bet_rank": bet_rank, + "per_chair_rank": per_chair_rank, + "hand_types_by_chair": hand_types_by_chair, + "hand_type_wins": hand_type_wins, + "pot_buckets": pot_buckets, + "streaks": streaks, + "hourly": hourly, + "recent_vs_all": { + "recent": {"dist": recent_dist, "total": recent_total}, + "all": { + "dist": {ch: chair_bias[ch]["wins"] for ch in ("A", "B", "C")}, + "total": total_games, + }, + }, + } + + @_with_lock def get_hot_cold_players(n: int = 5) -> dict: """ diff --git a/app/server.py b/app/server.py index cb54ba625..460078294 100644 --- a/app/server.py +++ b/app/server.py @@ -41,6 +41,8 @@ class WebServer: self.app.router.add_get("/api/hot-cold", self._handle_hot_cold) self.app.router.add_get("/analytics", self._handle_analytics_page) self.app.router.add_get("/api/analytics", self._handle_analytics) + self.app.router.add_get("/patterns", self._handle_patterns_page) + self.app.router.add_get("/api/patterns", self._handle_patterns) self.app.router.add_get("/ws", self._handle_ws) self.app.router.add_static("/static/", STATIC_DIR, name="static") @@ -90,6 +92,18 @@ class WebServer: path = os.path.join(STATIC_DIR, "analytics.html") return web.FileResponse(path) + async def _handle_patterns_page(self, request: web.Request) -> web.Response: + path = os.path.join(STATIC_DIR, "patterns.html") + return web.FileResponse(path) + + async def _handle_patterns(self, request: web.Request) -> web.Response: + try: + data = await _run_sync(db.get_pattern_analysis) + return web.json_response(data) + except Exception as e: + log.error("Pattern analysis query failed: %s", e) + return web.json_response({"error": str(e)}, status=500) + async def _handle_analytics(self, request: web.Request) -> web.Response: period = request.query.get("period", "all") if period not in ("1h", "6h", "24h", "7d", "all"): diff --git a/static/analytics.html b/static/analytics.html index ea99f2303..0524bb656 100644 --- a/static/analytics.html +++ b/static/analytics.html @@ -212,7 +212,10 @@

Teen Patti Analytics

- Live Dashboard → +
+ Live Dashboard → + Patterns → +
diff --git a/static/index.html b/static/index.html index 8b71f5c77..35f954c78 100644 --- a/static/index.html +++ b/static/index.html @@ -602,6 +602,7 @@
Analytics → + Patterns →
Connecting...
diff --git a/static/patterns.html b/static/patterns.html new file mode 100644 index 000000000..ca2c0f7b6 --- /dev/null +++ b/static/patterns.html @@ -0,0 +1,443 @@ + + + + + +Teen Patti Pattern Analysis + + + + + +
+

Pattern Analysis

+ +
+ +
+
Loading pattern analysis...
+ +
+ + + +