Initial commit: Teen Patti live monitor with analytics

Live dashboard with real-time WebSocket updates, analytics page with
time-filtered stats, ClickHouse storage, and Caddy reverse proxy.
This commit is contained in:
2026-02-21 22:36:40 +05:00
commit 85f44e6a22
16 changed files with 3780 additions and 0 deletions

593
app/db.py Normal file
View File

@@ -0,0 +1,593 @@
"""
ClickHouse database operations.
"""
import json
import logging
import threading
import time
import clickhouse_connect
from . import config
log = logging.getLogger(__name__)
_client = None
_lock = threading.Lock()
def get_client():
global _client
if _client is None:
for attempt in range(30):
try:
_client = clickhouse_connect.get_client(
host=config.CLICKHOUSE_HOST,
port=config.CLICKHOUSE_PORT,
)
_client.ping()
log.info("Connected to ClickHouse at %s:%s", config.CLICKHOUSE_HOST, config.CLICKHOUSE_PORT)
return _client
except Exception as e:
log.warning("ClickHouse not ready (attempt %d): %s", attempt + 1, e)
time.sleep(2)
raise RuntimeError("Could not connect to ClickHouse")
return _client
def _with_lock(fn):
"""Decorator to serialize all ClickHouse operations."""
def wrapper(*args, **kwargs):
with _lock:
return fn(*args, **kwargs)
wrapper.__name__ = fn.__name__
return wrapper
@_with_lock
def insert_game(game: dict):
"""Insert a completed round into the games table."""
client = get_client()
client.insert("games",
[[
game["game_no"],
game["winner"],
game["total_pot"],
game.get("bet_a", 0),
game.get("bet_b", 0),
game.get("bet_c", 0),
game.get("hand_a", ""),
game.get("hand_b", ""),
game.get("hand_c", ""),
game.get("hand_type_a", 0),
game.get("hand_type_b", 0),
game.get("hand_type_c", 0),
game.get("cards_json", ""),
game.get("duration_s", 0),
]],
column_names=[
"game_no", "winner", "total_pot",
"bet_a", "bet_b", "bet_c",
"hand_a", "hand_b", "hand_c",
"hand_type_a", "hand_type_b", "hand_type_c",
"cards_json", "duration_s",
],
)
@_with_lock
def insert_bet(bet: dict):
"""Insert an individual user bet into the bets table."""
client = get_client()
client.insert("bets",
[[
bet["game_no"],
bet["user_id"],
bet["chair"],
bet["bet_amount"],
bet["total_bet"],
]],
column_names=["game_no", "user_id", "chair", "bet_amount", "total_bet"],
)
@_with_lock
def upsert_user(user: dict):
"""Insert/update a user profile."""
client = get_client()
client.insert("users",
[[
user["user_id"],
user.get("nick_name", ""),
user.get("rich_level", 0),
user.get("actor_level", 0),
user.get("gender", 0),
user.get("consume_total", 0),
user.get("earn_total", 0),
user.get("is_actor", 0),
user.get("portrait", ""),
]],
column_names=[
"user_id", "nick_name", "rich_level", "actor_level",
"gender", "consume_total", "earn_total", "is_actor", "portrait",
],
)
@_with_lock
def get_recent_games(n: int = 50) -> list[dict]:
"""Get last N completed games."""
client = get_client()
result = client.query(
"SELECT game_no, winner, total_pot, bet_a, bet_b, bet_c, "
"hand_a, hand_b, hand_c, hand_type_a, hand_type_b, hand_type_c, "
"cards_json, duration_s, created_at "
"FROM games ORDER BY game_no DESC LIMIT {n:UInt32}",
parameters={"n": n},
)
games = []
for row in result.result_rows:
games.append({
"game_no": row[0],
"winner": row[1],
"total_pot": row[2],
"bet_a": row[3],
"bet_b": row[4],
"bet_c": row[5],
"hand_a": row[6],
"hand_b": row[7],
"hand_c": row[8],
"hand_type_a": row[9],
"hand_type_b": row[10],
"hand_type_c": row[11],
"cards_json": row[12],
"duration_s": row[13],
"created_at": str(row[14]),
})
return games
@_with_lock
def get_leaderboard(n: int = 10) -> list[dict]:
"""
Get top N users by P&L.
P&L = sum of winning bets * 1.9 - sum of losing bets.
With 2.9x fixed payout: win → +1.9x bet, loss → -1.0x bet.
"""
client = get_client()
result = client.query(
"""
SELECT
b.user_id,
any(u.nick_name) AS nick_name,
count() AS total_bets,
countIf(b.chair = g.winner) AS wins,
countIf(b.chair != g.winner) AS losses,
toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9
- sumIf(b.bet_amount, b.chair != g.winner)) AS pnl,
sum(b.bet_amount) AS total_wagered
FROM bets b
JOIN games g ON b.game_no = g.game_no
LEFT JOIN users u ON b.user_id = u.user_id
GROUP BY b.user_id
HAVING total_bets >= 3
ORDER BY pnl DESC
LIMIT {n:UInt32}
""",
parameters={"n": n},
)
leaders = []
for row in result.result_rows:
leaders.append({
"user_id": row[0],
"nick_name": row[1] or str(row[0]),
"total_bets": row[2],
"wins": row[3],
"losses": row[4],
"pnl": row[5],
"total_wagered": row[6],
})
return leaders
@_with_lock
def get_win_distribution() -> dict:
"""Get win counts per chair + bet rank distribution."""
client = get_client()
result = client.query(
"SELECT winner, count() AS cnt FROM games GROUP BY winner ORDER BY winner"
)
dist = {"A": 0, "B": 0, "C": 0}
for row in result.result_rows:
chair = config.CHAIRS.get(row[0], "?")
if chair in dist:
dist[chair] = row[1]
# Bet rank distribution: how often the winning chair had high/mid/low bet
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 = 1, 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:
row = rank_result.result_rows[0]
bet_rank = {"high": row[0], "mid": row[1], "low": row[2]}
return {"chairs": dist, "bet_rank": bet_rank}
@_with_lock
def get_user_name(user_id: int) -> str | None:
"""Lookup user nickname from cache."""
client = get_client()
result = client.query(
"SELECT nick_name FROM users WHERE user_id = {uid:UInt64} LIMIT 1",
parameters={"uid": user_id},
)
if result.result_rows:
return result.result_rows[0][0] or None
return None
@_with_lock
def get_user_detail(user_id: int) -> dict | None:
"""Get full user profile + session betting stats."""
client = get_client()
# Profile
result = client.query(
"SELECT user_id, nick_name, rich_level, actor_level, gender, "
"consume_total, earn_total, is_actor, portrait "
"FROM users WHERE user_id = {uid:UInt64} LIMIT 1",
parameters={"uid": user_id},
)
if not result.result_rows:
return None
row = result.result_rows[0]
profile = {
"user_id": row[0],
"nick_name": row[1],
"rich_level": row[2],
"actor_level": row[3],
"gender": row[4],
"consume_total": row[5],
"earn_total": row[6],
"is_actor": row[7],
"portrait": row[8],
}
# Session betting stats
stats_result = client.query(
"""
SELECT
count() AS total_bets,
sum(b.bet_amount) AS total_wagered,
countDistinct(b.game_no) AS rounds_played,
countIf(b.chair = g.winner) AS wins,
countIf(b.chair != g.winner) AS losses,
toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9
- sumIf(b.bet_amount, b.chair != g.winner)) AS pnl
FROM bets b
LEFT JOIN games g ON b.game_no = g.game_no
WHERE b.user_id = {uid:UInt64}
""",
parameters={"uid": user_id},
)
if stats_result.result_rows:
sr = stats_result.result_rows[0]
profile["total_bets"] = sr[0]
profile["total_wagered"] = sr[1]
profile["rounds_played"] = sr[2]
profile["wins"] = sr[3]
profile["losses"] = sr[4]
profile["pnl"] = sr[5]
# Recent bets
bets_result = client.query(
"""
SELECT b.game_no, b.chair, b.bet_amount, b.total_bet,
g.winner, b.created_at
FROM bets b
LEFT JOIN games g ON b.game_no = g.game_no
WHERE b.user_id = {uid:UInt64}
ORDER BY b.created_at DESC
LIMIT 20
""",
parameters={"uid": user_id},
)
profile["recent_bets"] = []
for br in bets_result.result_rows:
profile["recent_bets"].append({
"game_no": br[0],
"chair": br[1],
"chair_name": config.CHAIRS.get(br[1], "?"),
"bet_amount": br[2],
"total_bet": br[3],
"winner": br[4],
"won": br[1] == br[4] if br[4] else None,
"created_at": str(br[5]),
})
return profile
@_with_lock
def get_biggest_winner() -> dict | None:
"""Get the single biggest winner by P&L this session."""
client = get_client()
result = client.query(
"""
SELECT
b.user_id,
any(u.nick_name) AS nick_name,
any(u.portrait) AS portrait,
any(u.rich_level) AS rich_level,
count() AS total_bets,
countIf(b.chair = g.winner) AS wins,
toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9
- sumIf(b.bet_amount, b.chair != g.winner)) AS pnl,
sum(b.bet_amount) AS total_wagered
FROM bets b
JOIN games g ON b.game_no = g.game_no
LEFT JOIN users u ON b.user_id = u.user_id
GROUP BY b.user_id
HAVING total_bets >= 3
ORDER BY pnl DESC
LIMIT 1
"""
)
if result.result_rows:
row = result.result_rows[0]
return {
"user_id": row[0],
"nick_name": row[1] or str(row[0]),
"portrait": row[2] or "",
"rich_level": row[3],
"total_bets": row[4],
"wins": row[5],
"pnl": row[6],
"total_wagered": row[7],
}
return None
@_with_lock
def get_analytics(period: str = "all") -> dict:
"""Get all analytics data for a given time period."""
client = get_client()
intervals = {
"1h": "now() - INTERVAL 1 HOUR",
"6h": "now() - INTERVAL 6 HOUR",
"24h": "now() - INTERVAL 24 HOUR",
"7d": "now() - INTERVAL 7 DAY",
}
cutoff_expr = intervals.get(period)
game_where = f"WHERE created_at >= {cutoff_expr}" if cutoff_expr else ""
bet_where = f"WHERE b.created_at >= {cutoff_expr}" if cutoff_expr else ""
# 1. Summary stats
summary_result = client.query(
f"SELECT count(), sum(total_pot), toInt64(avg(total_pot)) FROM games {game_where}"
)
sr = summary_result.result_rows[0] if summary_result.result_rows else (0, 0, 0)
# Bet counts
bet_summary = client.query(
f"SELECT count(), countDistinct(user_id) FROM bets {'WHERE created_at >= ' + cutoff_expr if cutoff_expr else ''}"
)
bs = bet_summary.result_rows[0] if bet_summary.result_rows else (0, 0)
summary = {
"total_games": sr[0],
"total_volume": int(sr[1] or 0),
"avg_pot": int(sr[2] or 0),
"total_bets_placed": bs[0],
"unique_bettors": bs[1],
}
# 2. Win distribution (chairs + bet rank)
dist_result = client.query(
f"SELECT winner, count() AS cnt FROM games {game_where} GROUP BY winner ORDER BY winner"
)
chairs_dist = {"A": 0, "B": 0, "C": 0}
for row in dist_result.result_rows:
chair = config.CHAIRS.get(row[0], "?")
if chair in chairs_dist:
chairs_dist[chair] = row[1]
rank_result = client.query(
f"""
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 = 1, bet_a, winner = 2, bet_b, bet_c) AS winner_bet
FROM games
{game_where + ' AND' if game_where else 'WHERE'} bet_a + bet_b + bet_c > 0
)
"""
)
bet_rank = {"high": 0, "mid": 0, "low": 0}
if rank_result.result_rows:
rr = rank_result.result_rows[0]
bet_rank = {"high": rr[0], "mid": rr[1], "low": rr[2]}
win_distribution = {"chairs": chairs_dist, "bet_rank": bet_rank}
# 3. Hand type distribution (winning hand types)
hand_type_result = client.query(
f"""
SELECT hand_type, count() AS cnt FROM (
SELECT multiIf(winner = 1, hand_type_a, winner = 2, hand_type_b, hand_type_c) AS hand_type
FROM games
{game_where}
)
WHERE hand_type > 0
GROUP BY hand_type
ORDER BY hand_type
"""
)
hand_type_distribution = {}
for row in hand_type_result.result_rows:
type_name = config.HAND_TYPES.get(row[0], f"Type {row[0]}")
hand_type_distribution[type_name] = row[1]
# 4. Leaderboard
leaderboard_result = client.query(
f"""
SELECT
b.user_id,
any(u.nick_name) AS nick_name,
count() AS total_bets,
countIf(b.chair = g.winner) AS wins,
countIf(b.chair != g.winner) AS losses,
toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9
- sumIf(b.bet_amount, b.chair != g.winner)) AS pnl,
sum(b.bet_amount) AS total_wagered
FROM bets b
JOIN games g ON b.game_no = g.game_no
LEFT JOIN users u ON b.user_id = u.user_id
{bet_where}
GROUP BY b.user_id
HAVING total_bets >= 3
ORDER BY pnl DESC
LIMIT 20
"""
)
leaderboard = []
for row in leaderboard_result.result_rows:
leaderboard.append({
"user_id": row[0],
"nick_name": row[1] or str(row[0]),
"total_bets": row[2],
"wins": row[3],
"losses": row[4],
"pnl": row[5],
"total_wagered": row[6],
})
# 5. Hourly volume
hourly_result = client.query(
f"""
SELECT
toStartOfHour(created_at) AS hour,
count() AS games,
sum(total_pot) AS volume
FROM games
{game_where}
GROUP BY hour
ORDER BY hour
"""
)
hourly_volume = []
for row in hourly_result.result_rows:
hourly_volume.append({
"hour": str(row[0]),
"games": row[1],
"volume": int(row[2] or 0),
})
# 6. Games list
games_result = client.query(
f"""
SELECT game_no, winner, total_pot, bet_a, bet_b, bet_c,
hand_a, hand_b, hand_c, hand_type_a, hand_type_b, hand_type_c,
cards_json, duration_s, created_at
FROM games
{game_where}
ORDER BY game_no DESC
LIMIT 200
"""
)
games = []
for row in games_result.result_rows:
games.append({
"game_no": row[0],
"winner": row[1],
"total_pot": row[2],
"bet_a": row[3],
"bet_b": row[4],
"bet_c": row[5],
"hand_a": row[6],
"hand_b": row[7],
"hand_c": row[8],
"hand_type_a": row[9],
"hand_type_b": row[10],
"hand_type_c": row[11],
"cards_json": row[12],
"duration_s": row[13],
"created_at": str(row[14]),
})
return {
"summary": summary,
"win_distribution": win_distribution,
"hand_type_distribution": hand_type_distribution,
"leaderboard": leaderboard,
"hourly_volume": hourly_volume,
"games": games,
}
@_with_lock
def get_hot_cold_players(n: int = 5) -> dict:
"""
Get players with highest and lowest P&L over their last 10 bets.
Returns {"hot": [...], "cold": [...]}.
"""
client = get_client()
sql = """
WITH ranked AS (
SELECT
b.user_id,
b.game_no,
b.chair,
b.bet_amount,
g.winner,
row_number() OVER (PARTITION BY b.user_id ORDER BY b.created_at DESC) AS rn
FROM bets b
JOIN games g ON b.game_no = g.game_no
),
last10 AS (
SELECT * FROM ranked WHERE rn <= 10
)
SELECT
l.user_id,
any(u.nick_name) AS nick_name,
count() AS total_bets,
countIf(l.chair = l.winner) AS wins,
toInt64(sumIf(l.bet_amount, l.chair = l.winner) * 1.9
- sumIf(l.bet_amount, l.chair != l.winner)) AS pnl
FROM last10 l
LEFT JOIN users u ON l.user_id = u.user_id
GROUP BY l.user_id
HAVING total_bets >= 5
ORDER BY pnl DESC
"""
result = client.query(sql)
all_players = []
for row in result.result_rows:
all_players.append({
"user_id": row[0],
"nick_name": row[1] or str(row[0]),
"total_bets": row[2],
"wins": row[3],
"pnl": row[4],
})
hot = [p for p in all_players if p["pnl"] > 0][:n]
cold = [p for p in all_players if p["pnl"] < 0][-n:]
cold.reverse() # most negative first
return {"hot": hot, "cold": cold}