diff --git a/app/db.py b/app/db.py index b0c222866..82a0e4bab 100644 --- a/app/db.py +++ b/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): """Get detailed prediction vs actual for the last N games.""" warmup = 30 @@ -1300,6 +1379,19 @@ def get_prediction_analysis() -> dict: idx = entry["index"] 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_values = _card_value_distribution(cards_data) face_cards = _face_card_frequency(cards_data) @@ -1342,3 +1434,89 @@ def get_prediction_analysis() -> dict: "suits": suits, "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, + }, + }, + } diff --git a/app/server.py b/app/server.py index af29ca3ff..d3187305e 100644 --- a/app/server.py +++ b/app/server.py @@ -45,6 +45,7 @@ class WebServer: 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("/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_static("/static/", STATIC_DIR, name="static") @@ -118,6 +119,15 @@ class WebServer: log.error("Prediction analysis query failed: %s", e) 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: period = request.query.get("period", "all") if period not in ("1h", "6h", "24h", "7d", "all"): diff --git a/static/predictions.html b/static/predictions.html index 6a2dcc92e..e2bc18dec 100644 --- a/static/predictions.html +++ b/static/predictions.html @@ -262,7 +262,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
Last 20 Predictions vs Actual
- +
GamePredicted2nd PickP(A)P(B)P(C)ActualResult
GamePredicted2nd PickP(A)P(B)P(C)WhalePublicActualResult
@@ -666,16 +666,46 @@ function renderCrowdStats(data) { function renderLast20(predictions) { const tbody = $('pred-history').querySelector('tbody'); if (!predictions || predictions.length === 0) { - tbody.innerHTML = 'Not enough data'; + tbody.innerHTML = 'Not enough data'; return; } const fullHits = predictions.filter(p => p.correct).length; const semiHits = predictions.filter(p => p.semi_correct).length; 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 => { const maxProb = Math.max(p.probs.A, p.probs.B, p.probs.C); const resultClass = p.correct ? 'correct' : (p.semi_correct ? 'semi' : 'wrong'); 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 = `${p.whale_pick} ${whLabel}`; + } else { + whaleCell = '--'; + } + + // Public cell + let pubCell; + if (p.public_pick) { + const puCls = p.public_hit ? 'correct' : 'wrong'; + const puLabel = p.public_hit ? 'HIT' : 'MISS'; + pubCell = `${p.public_pick} ${puLabel}`; + } else { + pubCell = '--'; + } + return ` #${p.game_no} ${p.predicted} @@ -684,14 +714,18 @@ function renderLast20(predictions) { const w = p.probs[c] / maxProb * 40; return ` ${pct(p.probs[c])}`; }).join('')} + ${whaleCell} + ${pubCell} ${p.actual} ${resultLabel} `; }).join('') + ` Accuracy (last ${predictions.length}) + ${whalePct !== '--' ? whalePct + '%' : '--'} + ${publicPct !== '--' ? publicPct + '%' : '--'} - ${(score/predictions.length*100).toFixed(1)}% (${score}/${predictions.length}) + Model: ${(score/predictions.length*100).toFixed(1)}% `; }