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.
This commit is contained in:
166
analyze.py
Executable file
166
analyze.py
Executable file
@@ -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()
|
||||
196
app/db.py
196
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:
|
||||
"""
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -212,7 +212,10 @@
|
||||
|
||||
<div class="header">
|
||||
<h1>Teen Patti Analytics</h1>
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<div style="display:flex;gap:14px">
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<a href="/patterns" class="nav-link">Patterns →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="period-bar">
|
||||
|
||||
@@ -602,6 +602,7 @@
|
||||
</div>
|
||||
<div class="status">
|
||||
<a href="/analytics" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Analytics →</a>
|
||||
<a href="/patterns" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Patterns →</a>
|
||||
<div id="status-dot" class="status-dot"></div>
|
||||
<span id="status-text">Connecting...</span>
|
||||
</div>
|
||||
|
||||
443
static/patterns.html
Normal file
443
static/patterns.html
Normal file
@@ -0,0 +1,443 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Teen Patti Pattern Analysis</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--surface2: #232736;
|
||||
--surface3: #2a2e42;
|
||||
--border: #2d3148;
|
||||
--text: #e4e6f0;
|
||||
--text2: #8b8fa3;
|
||||
--text3: #5a5f75;
|
||||
--accent: #6c5ce7;
|
||||
--chair-a: #3b82f6;
|
||||
--chair-b: #ec4899;
|
||||
--chair-c: #f59e0b;
|
||||
--green: #10b981;
|
||||
--red: #ef4444;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 20px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.nav-links { display: flex; gap: 14px; }
|
||||
.nav-link {
|
||||
font-size: 12px; color: var(--accent); text-decoration: none;
|
||||
font-weight: 600; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #a78bfa; }
|
||||
.content { padding: 16px 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.loading {
|
||||
text-align: center; padding: 60px; color: var(--text2); font-size: 14px;
|
||||
}
|
||||
.section {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 14px; margin-bottom: 16px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
|
||||
color: var(--text2); margin-bottom: 10px; font-weight: 700;
|
||||
}
|
||||
.section-desc {
|
||||
font-size: 11px; color: var(--text3); margin-bottom: 12px;
|
||||
}
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
.ptable { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.ptable th {
|
||||
text-align: left; padding: 6px 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
color: var(--text3); font-size: 10px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700;
|
||||
}
|
||||
.ptable td { padding: 5px 8px; border-bottom: 1px solid #2d314830; }
|
||||
.ptable tr:hover { background: var(--surface2); }
|
||||
.ptable .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.ptable .pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--text2); }
|
||||
.positive { color: var(--green); }
|
||||
.negative { color: var(--red); }
|
||||
.chair-a { color: var(--chair-a); font-weight: 700; }
|
||||
.chair-b { color: var(--chair-b); font-weight: 700; }
|
||||
.chair-c { color: var(--chair-c); font-weight: 700; }
|
||||
|
||||
/* H-bars */
|
||||
.hbar-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; }
|
||||
.hbar-label { font-weight: 700; min-width: 80px; font-size: 12px; }
|
||||
.hbar-bg { flex: 1; height: 22px; border-radius: 4px; background: var(--surface3); overflow: hidden; }
|
||||
.hbar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; min-width: 2px; }
|
||||
.hbar-value { font-weight: 700; min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
.hbar-pct { font-size: 11px; color: var(--text3); min-width: 45px; text-align: right; }
|
||||
|
||||
/* Chart */
|
||||
.chart-container { position: relative; height: 250px; }
|
||||
|
||||
/* Streak cards */
|
||||
.streak-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.streak-card {
|
||||
background: var(--surface2); border-radius: 8px; padding: 12px; text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.streak-card-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text3); font-weight: 700; }
|
||||
.streak-card-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
|
||||
.streak-card-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
/* Comparison */
|
||||
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.compare-panel {
|
||||
background: var(--surface2); border-radius: 8px; padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.compare-title {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px;
|
||||
color: var(--text2); font-weight: 700; margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.two-col, .three-col { grid-template-columns: 1fr; }
|
||||
.streak-cards { grid-template-columns: 1fr; }
|
||||
.compare-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>Pattern Analysis</h1>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<a href="/analytics" class="nav-link">Analytics →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="loading" id="loading">Loading pattern analysis...</div>
|
||||
<div id="main" style="display:none">
|
||||
|
||||
<!-- 1. Chair Win Bias -->
|
||||
<div class="two-col">
|
||||
<div class="section">
|
||||
<div class="section-title">Chair Win Bias</div>
|
||||
<div class="section-desc">Win % per chair vs expected 33.3%. Sample: <span id="bias-n">0</span> games.</div>
|
||||
<div id="chair-bias-bars"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Chair Win Distribution</div>
|
||||
<div class="chart-container" style="height:200px">
|
||||
<canvas id="chair-pie"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 & 3. Bet Rank Analysis -->
|
||||
<div class="two-col">
|
||||
<div class="section">
|
||||
<div class="section-title">Winner Bet Rank</div>
|
||||
<div class="section-desc">How often does the winning chair have the highest, mid, or lowest bet?</div>
|
||||
<div id="bet-rank-bars"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Per-Chair: Highest Bet Win Rate</div>
|
||||
<div class="section-desc">When chair X has the highest bet, how often does X win?</div>
|
||||
<table class="ptable">
|
||||
<thead><tr><th>Chair</th><th style="text-align:right">Times Highest</th><th style="text-align:right">Wins</th><th style="text-align:right">Win %</th></tr></thead>
|
||||
<tbody id="pcr-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 & 5. Hand Type Distribution -->
|
||||
<div class="two-col">
|
||||
<div class="section">
|
||||
<div class="section-title">Hand Type Distribution by Chair</div>
|
||||
<div class="section-desc">Are better hands dealt to certain chairs more often?</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="hand-type-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Hand Type Win Rates</div>
|
||||
<div class="section-desc">Which hand types win most often?</div>
|
||||
<div id="hand-type-wins-bars"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Pot Size Buckets -->
|
||||
<div class="section">
|
||||
<div class="section-title">Win Rates by Pot Size</div>
|
||||
<div class="section-desc">Does the pot size affect which chair wins?</div>
|
||||
<table class="ptable">
|
||||
<thead>
|
||||
<tr><th>Bucket</th><th>Range</th><th style="text-align:right">Games</th>
|
||||
<th style="text-align:right">A %</th><th style="text-align:right">B %</th><th style="text-align:right">C %</th></tr>
|
||||
</thead>
|
||||
<tbody id="pot-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 7. Streak Analysis -->
|
||||
<div class="section">
|
||||
<div class="section-title">Streak Analysis</div>
|
||||
<div class="streak-cards" id="streak-cards"></div>
|
||||
</div>
|
||||
|
||||
<!-- 8. Hourly Patterns -->
|
||||
<div class="section">
|
||||
<div class="section-title">Hourly Win Patterns</div>
|
||||
<div class="section-desc">Win rates by hour of day (server time).</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="hourly-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Recent vs Overall -->
|
||||
<div class="section">
|
||||
<div class="section-title">Recent (Last 100) vs All-Time</div>
|
||||
<div class="section-desc">Spot shifts in chair dominance.</div>
|
||||
<div class="compare-grid">
|
||||
<div class="compare-panel">
|
||||
<div class="compare-title">All-Time</div>
|
||||
<div id="compare-all"></div>
|
||||
</div>
|
||||
<div class="compare-panel">
|
||||
<div class="compare-title">Last 100 Games</div>
|
||||
<div id="compare-recent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const fmt = n => {
|
||||
if (n == null) return '0';
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1e6) return (n/1e6).toFixed(1) + 'M';
|
||||
if (abs >= 1e3) return (n/1e3).toFixed(1) + 'K';
|
||||
return String(n);
|
||||
};
|
||||
const fmtFull = n => n == null ? '0' : Number(n).toLocaleString();
|
||||
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
|
||||
const HAND_ORDER = ['Trail', 'Straight Flush', 'Straight', 'Flush', 'Pair', 'High Card'];
|
||||
|
||||
function renderHBar(container, items, colorFn) {
|
||||
const total = items.reduce((s, i) => s + i.value, 0) || 1;
|
||||
const max = Math.max(...items.map(i => i.value)) || 1;
|
||||
container.innerHTML = items.map(item => {
|
||||
const pct = (item.value / max * 100).toFixed(0);
|
||||
const totalPct = (item.value / total * 100).toFixed(1);
|
||||
const color = colorFn(item.label);
|
||||
return `<div class="hbar-row">
|
||||
<span class="hbar-label" style="color:${color}">${item.label}</span>
|
||||
<div class="hbar-bg"><div class="hbar-fill" style="width:${pct}%;background:${color}"></div></div>
|
||||
<span class="hbar-value">${fmtFull(item.value)}</span>
|
||||
<span class="hbar-pct">${totalPct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderComparePanel(el, dist, total) {
|
||||
const items = ['A','B','C'].map(ch => ({label: ch, value: dist[ch] || 0}));
|
||||
renderHBar(el, items, l => CHAIR_COLORS[l]);
|
||||
const note = document.createElement('div');
|
||||
note.style.cssText = 'font-size:10px;color:var(--text3);margin-top:6px';
|
||||
note.textContent = `${total} games`;
|
||||
el.appendChild(note);
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
// 1. Chair win bias
|
||||
$('bias-n').textContent = fmtFull(data.chair_bias.total_games);
|
||||
const biasItems = ['A','B','C'].map(ch => ({
|
||||
label: `Chair ${ch}`, value: data.chair_bias[ch].wins,
|
||||
}));
|
||||
renderHBar($('chair-bias-bars'), biasItems, l => CHAIR_COLORS[l.replace('Chair ', '')]);
|
||||
|
||||
// Chair pie
|
||||
new Chart($('chair-pie'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['A','B','C'],
|
||||
datasets: [{
|
||||
data: ['A','B','C'].map(ch => data.chair_bias[ch].wins),
|
||||
backgroundColor: ['#3b82f6','#ec4899','#f59e0b'],
|
||||
borderWidth: 0,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3', font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const v = ctx.raw;
|
||||
const t = data.chair_bias.total_games || 1;
|
||||
return ` ${ctx.label}: ${v} (${(v/t*100).toFixed(1)}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Bet rank
|
||||
const br = data.bet_rank;
|
||||
renderHBar($('bet-rank-bars'), [
|
||||
{label: 'High Bet', value: br.high},
|
||||
{label: 'Mid Bet', value: br.mid},
|
||||
{label: 'Low Bet', value: br.low},
|
||||
], l => l.startsWith('High') ? '#f87171' : l.startsWith('Mid') ? '#fbbf24' : '#34d399');
|
||||
|
||||
// 3. Per-chair rank table
|
||||
const pcrBody = $('pcr-body');
|
||||
pcrBody.innerHTML = ['A','B','C'].map(ch => {
|
||||
const d = data.per_chair_rank[ch] || {};
|
||||
return `<tr>
|
||||
<td class="chair-${ch.toLowerCase()}">${ch}</td>
|
||||
<td class="num">${fmtFull(d.has_highest || 0)}</td>
|
||||
<td class="num">${fmtFull(d.wins || 0)}</td>
|
||||
<td class="num">${(d.win_pct || 0).toFixed(1)}%</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// 4. Hand type distribution by chair (grouped bar chart)
|
||||
const htByChair = data.hand_types_by_chair;
|
||||
const htLabels = HAND_ORDER.filter(t =>
|
||||
(htByChair.A[t] || 0) + (htByChair.B[t] || 0) + (htByChair.C[t] || 0) > 0
|
||||
);
|
||||
new Chart($('hand-type-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: htLabels,
|
||||
datasets: ['A','B','C'].map(ch => ({
|
||||
label: ch,
|
||||
data: htLabels.map(t => htByChair[ch][t] || 0),
|
||||
backgroundColor: CHAIR_COLORS[ch] + '80',
|
||||
borderColor: CHAIR_COLORS[ch],
|
||||
borderWidth: 1,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314820' } },
|
||||
y: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314830' } },
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Hand type wins
|
||||
const htw = data.hand_type_wins;
|
||||
const htwItems = HAND_ORDER.filter(t => htw[t]).map(t => ({label: t, value: htw[t]}));
|
||||
renderHBar($('hand-type-wins-bars'), htwItems, () => '#6c5ce7');
|
||||
|
||||
// 6. Pot size buckets
|
||||
const pb = data.pot_buckets;
|
||||
const ranges = pb._ranges || {};
|
||||
const bucketOrder = ['small','medium','large','whale'];
|
||||
$('pot-body').innerHTML = bucketOrder.map(b => {
|
||||
const d = pb[b];
|
||||
if (!d) return '';
|
||||
const t = d.total || 1;
|
||||
return `<tr>
|
||||
<td style="font-weight:700;text-transform:capitalize">${b}</td>
|
||||
<td style="color:var(--text3)">${ranges[b] || ''}</td>
|
||||
<td class="num">${fmtFull(d.total)}</td>
|
||||
<td class="num" style="color:var(--chair-a)">${(d.A/t*100).toFixed(1)}%</td>
|
||||
<td class="num" style="color:var(--chair-b)">${(d.B/t*100).toFixed(1)}%</td>
|
||||
<td class="num" style="color:var(--chair-c)">${(d.C/t*100).toFixed(1)}%</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// 7. Streaks
|
||||
const streaks = data.streaks;
|
||||
$('streak-cards').innerHTML = ['A','B','C'].map(ch => {
|
||||
const s = streaks[ch];
|
||||
return `<div class="streak-card">
|
||||
<div class="streak-card-label" style="color:${CHAIR_COLORS[ch]}">Chair ${ch}</div>
|
||||
<div class="streak-card-value" style="color:${CHAIR_COLORS[ch]}">${s.max_streak}</div>
|
||||
<div class="streak-card-sub">Max Streak</div>
|
||||
<div style="font-size:18px;font-weight:700;margin-top:8px;color:${s.current_streak >= 3 ? 'var(--green)' : 'var(--text2)'}">${s.current_streak}</div>
|
||||
<div class="streak-card-sub">Current Streak</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// 8. Hourly patterns
|
||||
const hourly = data.hourly;
|
||||
const hours = Object.keys(hourly).map(Number).sort((a,b) => a - b);
|
||||
new Chart($('hourly-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: hours.map(h => String(h).padStart(2, '0') + ':00'),
|
||||
datasets: ['A','B','C'].map(ch => ({
|
||||
label: ch,
|
||||
data: hours.map(h => {
|
||||
const d = hourly[String(h)];
|
||||
return d ? (d[ch] / d.total * 100) : 0;
|
||||
}),
|
||||
backgroundColor: CHAIR_COLORS[ch] + '80',
|
||||
borderColor: CHAIR_COLORS[ch],
|
||||
borderWidth: 1,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } },
|
||||
tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${ctx.raw.toFixed(1)}%` } },
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { color: '#5a5f75', font: { size: 9 } }, grid: { color: '#2d314820' } },
|
||||
y: {
|
||||
stacked: true, max: 100,
|
||||
ticks: { color: '#5a5f75', callback: v => v + '%', font: { size: 10 } },
|
||||
grid: { color: '#2d314830' },
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 9. Recent vs Overall
|
||||
const rva = data.recent_vs_all;
|
||||
renderComparePanel($('compare-all'), rva.all.dist, rva.all.total);
|
||||
renderComparePanel($('compare-recent'), rva.recent.dist, rva.recent.total);
|
||||
}
|
||||
|
||||
// Fetch and render
|
||||
fetch('/api/patterns')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
$('loading').textContent = 'Error: ' + data.error;
|
||||
return;
|
||||
}
|
||||
$('loading').style.display = 'none';
|
||||
$('main').style.display = 'block';
|
||||
render(data);
|
||||
})
|
||||
.catch(e => {
|
||||
$('loading').textContent = 'Failed to load: ' + e.message;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user