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:
2026-02-25 22:45:43 +05:00
parent e65b6b2cfb
commit 2b8e3dd456
6 changed files with 824 additions and 1 deletions

196
app/db.py
View File

@@ -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:
"""