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:
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"):
|
||||
|
||||
Reference in New Issue
Block a user