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 @@
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 @@
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
+
+
+
+
+
+
+
+
+
Loading pattern analysis...
+
+
+
+
+
+
Chair Win Bias
+
Win % per chair vs expected 33.3%. Sample: 0 games.
+
+
+
+
Chair Win Distribution
+
+
+
+
+
+
+
+
+
+
Winner Bet Rank
+
How often does the winning chair have the highest, mid, or lowest bet?
+
+
+
+
Per-Chair: Highest Bet Win Rate
+
When chair X has the highest bet, how often does X win?
+
+ | Chair | Times Highest | Wins | Win % |
+
+
+
+
+
+
+
+
+
Hand Type Distribution by Chair
+
Are better hands dealt to certain chairs more often?
+
+
+
+
+
+
Hand Type Win Rates
+
Which hand types win most often?
+
+
+
+
+
+
+
Win Rates by Pot Size
+
Does the pot size affect which chair wins?
+
+
+ | Bucket | Range | Games |
+ A % | B % | C % |
+
+
+
+
+
+
+
+
+
+
+
Hourly Win Patterns
+
Win rates by hour of day (server time).
+
+
+
+
+
+
+
+
Recent (Last 100) vs All-Time
+
Spot shifts in chair dominance.
+
+
+
+
+
+
+
+
+