add whale/public picks to prediction history and new API endpoint
- Add _compute_whale_public_picks() to reconstruct whale/public picks from historical bets - Merge whale_pick, public_pick, whale_hit, public_hit into last_20_predictions - Add get_prediction_history(limit) for lightweight prediction+accuracy data - Add /api/prediction-history endpoint (default 100, max 500) - Add Whale and Public columns with HIT/MISS to Last 20 table in frontend
This commit is contained in:
178
app/db.py
178
app/db.py
@@ -1229,6 +1229,85 @@ def _backtest_theories(winners):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_whale_public_picks(client, game_nos):
|
||||||
|
"""Compute whale pick and public pick for a list of game_nos from bets data.
|
||||||
|
|
||||||
|
Returns {game_no: {whale_pick, public_pick, whale_count, public_count, bettor_counts}}.
|
||||||
|
"""
|
||||||
|
if not game_nos:
|
||||||
|
return {}
|
||||||
|
game_nos_csv = ",".join(str(g) for g in game_nos)
|
||||||
|
result = client.query(
|
||||||
|
f"SELECT game_no, user_id, chair, sum(bet_amount) AS total_user_bet "
|
||||||
|
f"FROM bets WHERE game_no IN ({game_nos_csv}) "
|
||||||
|
f"GROUP BY game_no, user_id, chair"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect per-game, per-user, per-chair totals
|
||||||
|
# game_data[game_no][user_id][chair] = amount
|
||||||
|
game_data = {}
|
||||||
|
for row in result.result_rows:
|
||||||
|
gno, uid, chair_id, amount = row[0], row[1], row[2], row[3]
|
||||||
|
if gno not in game_data:
|
||||||
|
game_data[gno] = {}
|
||||||
|
if uid not in game_data[gno]:
|
||||||
|
game_data[gno][uid] = {}
|
||||||
|
chair_name = config.CHAIRS.get(chair_id, "?")
|
||||||
|
if chair_name in CHAIR_LABELS:
|
||||||
|
game_data[gno][uid][chair_name] = game_data[gno][uid].get(chair_name, 0) + amount
|
||||||
|
|
||||||
|
picks = {}
|
||||||
|
for gno in game_nos:
|
||||||
|
users = game_data.get(gno, {})
|
||||||
|
if not users:
|
||||||
|
picks[gno] = {"whale_pick": None, "public_pick": None,
|
||||||
|
"whale_count": 0, "public_count": 0,
|
||||||
|
"bettor_counts": {"A": 0, "B": 0, "C": 0}}
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rank users by total bet across all chairs
|
||||||
|
user_totals = []
|
||||||
|
for uid, chairs in users.items():
|
||||||
|
user_totals.append((uid, sum(chairs.values())))
|
||||||
|
user_totals.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
whale_uids = set(uid for uid, _ in user_totals[:5])
|
||||||
|
pub_uids = set(uid for uid, _ in user_totals[5:])
|
||||||
|
|
||||||
|
# Sum whale money per chair
|
||||||
|
whale_money = {"A": 0, "B": 0, "C": 0}
|
||||||
|
for uid in whale_uids:
|
||||||
|
for c in CHAIR_LABELS:
|
||||||
|
whale_money[c] += users[uid].get(c, 0)
|
||||||
|
whale_total = sum(whale_money.values())
|
||||||
|
whale_pick = max(CHAIR_LABELS, key=lambda c: whale_money[c]) if whale_total > 0 else None
|
||||||
|
|
||||||
|
# Sum public money per chair
|
||||||
|
pub_money = {"A": 0, "B": 0, "C": 0}
|
||||||
|
for uid in pub_uids:
|
||||||
|
for c in CHAIR_LABELS:
|
||||||
|
pub_money[c] += users[uid].get(c, 0)
|
||||||
|
pub_total = sum(pub_money.values())
|
||||||
|
total_bettors = len(user_totals)
|
||||||
|
public_pick = max(CHAIR_LABELS, key=lambda c: pub_money[c]) if pub_total > 0 and total_bettors > 5 else None
|
||||||
|
|
||||||
|
# Count bettors per chair
|
||||||
|
bettor_counts = {"A": 0, "B": 0, "C": 0}
|
||||||
|
for uid, chairs in users.items():
|
||||||
|
for c in CHAIR_LABELS:
|
||||||
|
if chairs.get(c, 0) > 0:
|
||||||
|
bettor_counts[c] += 1
|
||||||
|
|
||||||
|
picks[gno] = {
|
||||||
|
"whale_pick": whale_pick,
|
||||||
|
"public_pick": public_pick,
|
||||||
|
"whale_count": len(whale_uids),
|
||||||
|
"public_count": len(pub_uids),
|
||||||
|
"bettor_counts": bettor_counts,
|
||||||
|
}
|
||||||
|
return picks
|
||||||
|
|
||||||
|
|
||||||
def _last_n_predictions(winners, n=20):
|
def _last_n_predictions(winners, n=20):
|
||||||
"""Get detailed prediction vs actual for the last N games."""
|
"""Get detailed prediction vs actual for the last N games."""
|
||||||
warmup = 30
|
warmup = 30
|
||||||
@@ -1300,6 +1379,19 @@ def get_prediction_analysis() -> dict:
|
|||||||
idx = entry["index"]
|
idx = entry["index"]
|
||||||
entry["game_no"] = game_nos[idx] if idx < len(game_nos) else 0
|
entry["game_no"] = game_nos[idx] if idx < len(game_nos) else 0
|
||||||
|
|
||||||
|
# Merge whale/public picks into last_20
|
||||||
|
last_20_game_nos = [e["game_no"] for e in last_20_raw if e.get("game_no")]
|
||||||
|
wp_data = _compute_whale_public_picks(client, last_20_game_nos)
|
||||||
|
for entry in last_20_raw:
|
||||||
|
gno = entry.get("game_no", 0)
|
||||||
|
wp = wp_data.get(gno, {})
|
||||||
|
entry["whale_pick"] = wp.get("whale_pick")
|
||||||
|
entry["public_pick"] = wp.get("public_pick")
|
||||||
|
entry["bettor_counts"] = wp.get("bettor_counts", {"A": 0, "B": 0, "C": 0})
|
||||||
|
actual = entry["actual"]
|
||||||
|
entry["whale_hit"] = (wp.get("whale_pick") == actual) if wp.get("whale_pick") else None
|
||||||
|
entry["public_hit"] = (wp.get("public_pick") == actual) if wp.get("public_pick") else None
|
||||||
|
|
||||||
# Card analysis
|
# Card analysis
|
||||||
card_values = _card_value_distribution(cards_data)
|
card_values = _card_value_distribution(cards_data)
|
||||||
face_cards = _face_card_frequency(cards_data)
|
face_cards = _face_card_frequency(cards_data)
|
||||||
@@ -1342,3 +1434,89 @@ def get_prediction_analysis() -> dict:
|
|||||||
"suits": suits,
|
"suits": suits,
|
||||||
"winning_cards": winning_cards,
|
"winning_cards": winning_cards,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@_with_lock
|
||||||
|
def get_prediction_history(limit: int = 100) -> dict:
|
||||||
|
"""Get prediction history with whale/public picks and accuracy summary."""
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Full winner sequence with game numbers
|
||||||
|
result = client.query("SELECT game_no, winner FROM games ORDER BY game_no ASC")
|
||||||
|
game_nos = [r[0] for r in result.result_rows if config.CHAIRS.get(r[1], "?") in CHAIR_LABELS]
|
||||||
|
winners = [config.CHAIRS.get(r[1], "?") for r in result.result_rows]
|
||||||
|
winners = [w for w in winners if w in CHAIR_LABELS]
|
||||||
|
|
||||||
|
# Get last N predictions
|
||||||
|
predictions = _last_n_predictions(winners, limit)
|
||||||
|
|
||||||
|
# Attach game_nos
|
||||||
|
for entry in predictions:
|
||||||
|
idx = entry["index"]
|
||||||
|
entry["game_no"] = game_nos[idx] if idx < len(game_nos) else 0
|
||||||
|
|
||||||
|
# Compute whale/public picks
|
||||||
|
pred_game_nos = [e["game_no"] for e in predictions if e.get("game_no")]
|
||||||
|
wp_data = _compute_whale_public_picks(client, pred_game_nos)
|
||||||
|
|
||||||
|
# Merge and compute accuracy
|
||||||
|
whale_hits = 0
|
||||||
|
whale_total = 0
|
||||||
|
public_hits = 0
|
||||||
|
public_total = 0
|
||||||
|
model_full_hits = 0
|
||||||
|
model_semi_hits = 0
|
||||||
|
|
||||||
|
for entry in predictions:
|
||||||
|
gno = entry.get("game_no", 0)
|
||||||
|
wp = wp_data.get(gno, {})
|
||||||
|
entry["whale_pick"] = wp.get("whale_pick")
|
||||||
|
entry["public_pick"] = wp.get("public_pick")
|
||||||
|
entry["bettor_counts"] = wp.get("bettor_counts", {"A": 0, "B": 0, "C": 0})
|
||||||
|
actual = entry["actual"]
|
||||||
|
entry["whale_hit"] = (wp.get("whale_pick") == actual) if wp.get("whale_pick") else None
|
||||||
|
entry["public_hit"] = (wp.get("public_pick") == actual) if wp.get("public_pick") else None
|
||||||
|
|
||||||
|
# Remove internal index from output
|
||||||
|
del entry["index"]
|
||||||
|
|
||||||
|
# Accumulate accuracy
|
||||||
|
if entry["correct"]:
|
||||||
|
model_full_hits += 1
|
||||||
|
elif entry["semi_correct"]:
|
||||||
|
model_semi_hits += 1
|
||||||
|
if entry["whale_hit"] is not None:
|
||||||
|
whale_total += 1
|
||||||
|
if entry["whale_hit"]:
|
||||||
|
whale_hits += 1
|
||||||
|
if entry["public_hit"] is not None:
|
||||||
|
public_total += 1
|
||||||
|
if entry["public_hit"]:
|
||||||
|
public_hits += 1
|
||||||
|
|
||||||
|
total_pred = len(predictions)
|
||||||
|
model_score = model_full_hits + model_semi_hits * 0.5
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_games": len(winners),
|
||||||
|
"limit": limit,
|
||||||
|
"predictions": predictions,
|
||||||
|
"accuracy": {
|
||||||
|
"model": {
|
||||||
|
"hits": model_full_hits,
|
||||||
|
"semi": model_semi_hits,
|
||||||
|
"total": total_pred,
|
||||||
|
"pct": round(model_score / total_pred * 100, 1) if total_pred else 0,
|
||||||
|
},
|
||||||
|
"whale": {
|
||||||
|
"hits": whale_hits,
|
||||||
|
"total": whale_total,
|
||||||
|
"pct": round(whale_hits / whale_total * 100, 1) if whale_total else 0,
|
||||||
|
},
|
||||||
|
"public": {
|
||||||
|
"hits": public_hits,
|
||||||
|
"total": public_total,
|
||||||
|
"pct": round(public_hits / public_total * 100, 1) if public_total else 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class WebServer:
|
|||||||
self.app.router.add_get("/api/patterns", self._handle_patterns)
|
self.app.router.add_get("/api/patterns", self._handle_patterns)
|
||||||
self.app.router.add_get("/predictions", self._handle_predictions_page)
|
self.app.router.add_get("/predictions", self._handle_predictions_page)
|
||||||
self.app.router.add_get("/api/predictions", self._handle_predictions)
|
self.app.router.add_get("/api/predictions", self._handle_predictions)
|
||||||
|
self.app.router.add_get("/api/prediction-history", self._handle_prediction_history)
|
||||||
self.app.router.add_get("/ws", self._handle_ws)
|
self.app.router.add_get("/ws", self._handle_ws)
|
||||||
self.app.router.add_static("/static/", STATIC_DIR, name="static")
|
self.app.router.add_static("/static/", STATIC_DIR, name="static")
|
||||||
|
|
||||||
@@ -118,6 +119,15 @@ class WebServer:
|
|||||||
log.error("Prediction analysis query failed: %s", e)
|
log.error("Prediction analysis query failed: %s", e)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def _handle_prediction_history(self, request: web.Request) -> web.Response:
|
||||||
|
limit = min(int(request.query.get("limit", 100)), 500)
|
||||||
|
try:
|
||||||
|
data = await _run_sync(db.get_prediction_history, limit)
|
||||||
|
return web.json_response(data)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Prediction history query failed: %s", e)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
async def _handle_analytics(self, request: web.Request) -> web.Response:
|
async def _handle_analytics(self, request: web.Request) -> web.Response:
|
||||||
period = request.query.get("period", "all")
|
period = request.query.get("period", "all")
|
||||||
if period not in ("1h", "6h", "24h", "7d", "all"):
|
if period not in ("1h", "6h", "24h", "7d", "all"):
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
|
|||||||
<div class="section-title">Last 20 Predictions vs Actual</div>
|
<div class="section-title">Last 20 Predictions vs Actual</div>
|
||||||
<div class="panel" style="max-height:500px;overflow-y:auto">
|
<div class="panel" style="max-height:500px;overflow-y:auto">
|
||||||
<table class="pred-history" id="pred-history">
|
<table class="pred-history" id="pred-history">
|
||||||
<thead><tr><th>Game</th><th>Predicted</th><th>2nd Pick</th><th>P(A)</th><th>P(B)</th><th>P(C)</th><th>Actual</th><th>Result</th></tr></thead>
|
<thead><tr><th>Game</th><th>Predicted</th><th>2nd Pick</th><th>P(A)</th><th>P(B)</th><th>P(C)</th><th>Whale</th><th>Public</th><th>Actual</th><th>Result</th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,16 +666,46 @@ function renderCrowdStats(data) {
|
|||||||
function renderLast20(predictions) {
|
function renderLast20(predictions) {
|
||||||
const tbody = $('pred-history').querySelector('tbody');
|
const tbody = $('pred-history').querySelector('tbody');
|
||||||
if (!predictions || predictions.length === 0) {
|
if (!predictions || predictions.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="8" style="color:var(--text3)">Not enough data</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="10" style="color:var(--text3)">Not enough data</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fullHits = predictions.filter(p => p.correct).length;
|
const fullHits = predictions.filter(p => p.correct).length;
|
||||||
const semiHits = predictions.filter(p => p.semi_correct).length;
|
const semiHits = predictions.filter(p => p.semi_correct).length;
|
||||||
const score = fullHits + semiHits * 0.5;
|
const score = fullHits + semiHits * 0.5;
|
||||||
|
|
||||||
|
// Whale/public accuracy
|
||||||
|
const whaleEntries = predictions.filter(p => p.whale_pick != null);
|
||||||
|
const whaleHits = whaleEntries.filter(p => p.whale_hit).length;
|
||||||
|
const publicEntries = predictions.filter(p => p.public_pick != null);
|
||||||
|
const publicHits = publicEntries.filter(p => p.public_hit).length;
|
||||||
|
const whalePct = whaleEntries.length > 0 ? (whaleHits / whaleEntries.length * 100).toFixed(1) : '--';
|
||||||
|
const publicPct = publicEntries.length > 0 ? (publicHits / publicEntries.length * 100).toFixed(1) : '--';
|
||||||
|
|
||||||
tbody.innerHTML = predictions.slice().reverse().map(p => {
|
tbody.innerHTML = predictions.slice().reverse().map(p => {
|
||||||
const maxProb = Math.max(p.probs.A, p.probs.B, p.probs.C);
|
const maxProb = Math.max(p.probs.A, p.probs.B, p.probs.C);
|
||||||
const resultClass = p.correct ? 'correct' : (p.semi_correct ? 'semi' : 'wrong');
|
const resultClass = p.correct ? 'correct' : (p.semi_correct ? 'semi' : 'wrong');
|
||||||
const resultLabel = p.correct ? 'HIT' : (p.semi_correct ? 'SEMI' : 'MISS');
|
const resultLabel = p.correct ? 'HIT' : (p.semi_correct ? 'SEMI' : 'MISS');
|
||||||
|
|
||||||
|
// Whale cell
|
||||||
|
let whaleCell;
|
||||||
|
if (p.whale_pick) {
|
||||||
|
const whCls = p.whale_hit ? 'correct' : 'wrong';
|
||||||
|
const whLabel = p.whale_hit ? 'HIT' : 'MISS';
|
||||||
|
whaleCell = `<td><span style="color:${CHAIR_COLORS[p.whale_pick]};font-weight:700">${p.whale_pick}</span> <span class="${whCls}" style="font-size:10px">${whLabel}</span></td>`;
|
||||||
|
} else {
|
||||||
|
whaleCell = '<td style="color:var(--text3)">--</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public cell
|
||||||
|
let pubCell;
|
||||||
|
if (p.public_pick) {
|
||||||
|
const puCls = p.public_hit ? 'correct' : 'wrong';
|
||||||
|
const puLabel = p.public_hit ? 'HIT' : 'MISS';
|
||||||
|
pubCell = `<td><span style="color:${CHAIR_COLORS[p.public_pick]};font-weight:700">${p.public_pick}</span> <span class="${puCls}" style="font-size:10px">${puLabel}</span></td>`;
|
||||||
|
} else {
|
||||||
|
pubCell = '<td style="color:var(--text3)">--</td>';
|
||||||
|
}
|
||||||
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td style="font-weight:700">#${p.game_no}</td>
|
<td style="font-weight:700">#${p.game_no}</td>
|
||||||
<td class="winner-cell" style="color:${CHAIR_COLORS[p.predicted]}">${p.predicted}</td>
|
<td class="winner-cell" style="color:${CHAIR_COLORS[p.predicted]}">${p.predicted}</td>
|
||||||
@@ -684,14 +714,18 @@ function renderLast20(predictions) {
|
|||||||
const w = p.probs[c] / maxProb * 40;
|
const w = p.probs[c] / maxProb * 40;
|
||||||
return `<td><span class="prob-bar" style="width:${w}px;background:${CHAIR_COLORS[c]}"></span> ${pct(p.probs[c])}</td>`;
|
return `<td><span class="prob-bar" style="width:${w}px;background:${CHAIR_COLORS[c]}"></span> ${pct(p.probs[c])}</td>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
${whaleCell}
|
||||||
|
${pubCell}
|
||||||
<td class="winner-cell" style="color:${CHAIR_COLORS[p.actual]}">${p.actual}</td>
|
<td class="winner-cell" style="color:${CHAIR_COLORS[p.actual]}">${p.actual}</td>
|
||||||
<td class="${resultClass}" style="font-weight:700">${resultLabel}</td>
|
<td class="${resultClass}" style="font-weight:700">${resultLabel}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('') +
|
}).join('') +
|
||||||
`<tr style="border-top:2px solid var(--border);font-weight:700">
|
`<tr style="border-top:2px solid var(--border);font-weight:700">
|
||||||
<td colspan="6" style="text-align:right;color:var(--text2)">Accuracy (last ${predictions.length})</td>
|
<td colspan="6" style="text-align:right;color:var(--text2)">Accuracy (last ${predictions.length})</td>
|
||||||
|
<td style="color:${whalePct !== '--' && parseFloat(whalePct) > 33.3 ? 'var(--green)' : 'var(--text2)'}">${whalePct !== '--' ? whalePct + '%' : '--'}</td>
|
||||||
|
<td style="color:${publicPct !== '--' && parseFloat(publicPct) > 33.3 ? 'var(--green)' : 'var(--text2)'}">${publicPct !== '--' ? publicPct + '%' : '--'}</td>
|
||||||
<td colspan="2" style="color:${score/predictions.length > 1/3 ? 'var(--green)' : 'var(--red)'}">
|
<td colspan="2" style="color:${score/predictions.length > 1/3 ? 'var(--green)' : 'var(--red)'}">
|
||||||
${(score/predictions.length*100).toFixed(1)}% (${score}/${predictions.length})
|
Model: ${(score/predictions.length*100).toFixed(1)}%
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user