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:
593
app/db.py
Normal file
593
app/db.py
Normal 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}
|
||||
Reference in New Issue
Block a user