diff --git a/app/db.py b/app/db.py index 394b66a00..b0c222866 100644 --- a/app/db.py +++ b/app/db.py @@ -1123,7 +1123,8 @@ def _backtest_theories(winners): return {"error": "Not enough data for backtesting"} theories = ["base_rate", "markov_1", "markov_2", "recent_20", "streak", "combined"] - correct = {t: 0 for t in theories} + full_hits = {t: 0 for t in theories} + semi_hits = {t: 0 for t in theories} total_tested = 0 rolling = {t: [] for t in theories} # rolling accuracy over last 200 @@ -1135,24 +1136,24 @@ def _backtest_theories(winners): total_h = len(history) # Base rate base = {c: history.count(c) / total_h for c in CHAIR_LABELS} - base_pick = max(CHAIR_LABELS, key=lambda c: base[c]) + base_ranked = sorted(CHAIR_LABELS, key=lambda c: base[c], reverse=True) # Markov-1 m1, _ = _markov_matrix_1(history) last = history[-1] m1_probs = m1.get(last, {c: 1 / 3 for c in CHAIR_LABELS}) - m1_pick = max(CHAIR_LABELS, key=lambda c: m1_probs.get(c, 0)) + m1_ranked = sorted(CHAIR_LABELS, key=lambda c: m1_probs.get(c, 0), reverse=True) # Markov-2 m2, _ = _markov_matrix_2(history) key2 = f"{history[-2]}{history[-1]}" m2_probs = m2.get(key2, {c: 1 / 3 for c in CHAIR_LABELS}) - m2_pick = max(CHAIR_LABELS, key=lambda c: m2_probs.get(c, 0)) + m2_ranked = sorted(CHAIR_LABELS, key=lambda c: m2_probs.get(c, 0), reverse=True) # Recent-20 recent = history[-20:] if len(history) >= 20 else history rec = {c: recent.count(c) / len(recent) for c in CHAIR_LABELS} - rec_pick = max(CHAIR_LABELS, key=lambda c: rec[c]) + rec_ranked = sorted(CHAIR_LABELS, key=lambda c: rec[c], reverse=True) # Streak streak_chair = history[-1] @@ -1173,7 +1174,7 @@ def _backtest_theories(winners): streak_probs = {c: streak_probs[c] / s_total for c in CHAIR_LABELS} else: streak_probs = {c: 1 / 3 for c in CHAIR_LABELS} - streak_pick = max(CHAIR_LABELS, key=lambda c: streak_probs[c]) + streak_ranked = sorted(CHAIR_LABELS, key=lambda c: streak_probs[c], reverse=True) # Combined Bayesian combined = {c: 0 for c in CHAIR_LABELS} @@ -1182,19 +1183,28 @@ def _backtest_theories(winners): for sig_name, weight in weights.items(): for c in CHAIR_LABELS: combined[c] += weight * signals[sig_name].get(c, 1 / 3) - combined_pick = max(CHAIR_LABELS, key=lambda c: combined[c]) + combined_ranked = sorted(CHAIR_LABELS, key=lambda c: combined[c], reverse=True) - picks = { - "base_rate": base_pick, "markov_1": m1_pick, "markov_2": m2_pick, - "recent_20": rec_pick, "streak": streak_pick, "combined": combined_pick, + ranked = { + "base_rate": base_ranked, "markov_1": m1_ranked, "markov_2": m2_ranked, + "recent_20": rec_ranked, "streak": streak_ranked, "combined": combined_ranked, } for t in theories: - hit = 1 if picks[t] == actual else 0 - if picks[t] == actual: - correct[t] += 1 - rolling[t].append(hit) + pick = ranked[t][0] + second = ranked[t][1] + if pick == actual: + full_hits[t] += 1 + rolling[t].append(1.0) + elif second == actual: + semi_hits[t] += 1 + rolling[t].append(0.5) + else: + rolling[t].append(0.0) - accuracy = {t: round(correct[t] / total_tested * 100, 2) if total_tested else 0 for t in theories} + accuracy = { + t: round((full_hits[t] + semi_hits[t] * 0.5) / total_tested * 100, 2) if total_tested else 0 + for t in theories + } # Rolling accuracy over last 200 games window = 200 @@ -1212,6 +1222,8 @@ def _backtest_theories(winners): return { "total_tested": total_tested, "accuracy": accuracy, + "full_hits": {t: full_hits[t] for t in theories}, + "semi_hits": {t: semi_hits[t] for t in theories}, "rolling_accuracy": rolling_accuracy, "random_baseline": 33.33, } @@ -1230,12 +1242,16 @@ def _last_n_predictions(winners, n=20): m1, _ = _markov_matrix_1(history) m2, _ = _markov_matrix_2(history) pred, _ = _bayesian_prediction(history, m1, m2) - predicted = max(CHAIR_LABELS, key=lambda c: pred[c]) + ranked = sorted(CHAIR_LABELS, key=lambda c: pred[c], reverse=True) + predicted = ranked[0] + second_predicted = ranked[1] results.append({ "index": i, "predicted": predicted, + "second_predicted": second_predicted, "actual": actual, "correct": predicted == actual, + "semi_correct": predicted != actual and second_predicted == actual, "probs": {c: round(pred[c], 4) for c in CHAIR_LABELS}, }) return results diff --git a/static/predictions.html b/static/predictions.html index b0c276750..a0cdaccae 100644 --- a/static/predictions.html +++ b/static/predictions.html @@ -88,7 +88,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans- background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 16px; } -.advisor-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; } +.advisor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; } .advisor-item { text-align: center; } .advisor-item .adv-label { font-size: 11px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; } .advisor-item .adv-value { font-size: 20px; font-weight: 800; } @@ -163,6 +163,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans- .pred-history th, .pred-history td { padding: 7px 10px; text-align: center; border-bottom: 1px solid var(--border); } .pred-history th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; background: var(--surface2); position: sticky; top: 0; } .pred-history .correct { color: var(--green); } +.pred-history .semi { color: #f59e0b; } .pred-history .wrong { color: var(--red); } .pred-history .winner-cell { font-weight: 800; } .prob-bar { display: inline-block; height: 6px; border-radius: 3px; vertical-align: middle; } @@ -173,6 +174,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans- } .result-flash.show { display: block; } .result-flash.win { background: rgba(16,185,129,0.15); border: 1px solid var(--green); color: var(--green); } +.result-flash.semi { background: rgba(245,158,11,0.15); border: 1px solid #f59e0b; color: #f59e0b; } .result-flash.loss { background: rgba(239,68,68,0.15); border: 1px solid var(--red); color: var(--red); } @keyframes flashIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } @@ -260,7 +262,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
Last 20 Predictions vs Actual
- +
GamePredictedP(A)P(B)P(C)ActualResult
GamePredicted2nd PickP(A)P(B)P(C)ActualResult
@@ -376,6 +378,7 @@ let predictionData = null; let liveBets = {A: 0, B: 0, C: 0}; let liveGameNo = null; let currentPrediction = null; +let currentSecondPrediction = null; let roundBettors = {}; // Track whale-pick wins and public-pick wins from round results we witness let crowdTracker = {whaleCorrect: 0, publicCorrect: 0, roundsSeen: 0}; @@ -399,6 +402,7 @@ function renderPrediction(data) { const ranked = CHAIRS.slice().sort((a, b) => data.prediction[b] - data.prediction[a]); const best = ranked[0], second = ranked[1]; currentPrediction = best; + currentSecondPrediction = second; $('pred-game-label').textContent = data.total_games ? `(based on ${data.total_games} games)` : ''; const pot = liveBets.A + liveBets.B + liveBets.C; @@ -442,10 +446,33 @@ function renderPrediction(data) { renderBetAdvisor(data, pot); } +function computeBetRecommendations() { + const bettors = Object.values(roundBettors); + if (bettors.length < 2) return null; + const sorted = bettors.slice().sort((a, b) => b.total - a.total); + const whales = sorted.slice(0, 5); + const pub = sorted.slice(5); + + const whaleAvg = {A: 0, B: 0, C: 0}; + const pubAvg = {A: 0, B: 0, C: 0}; + + if (whales.length > 0) { + for (const w of whales) for (const c of CHAIRS) whaleAvg[c] += w.chairs[c] || 0; + for (const c of CHAIRS) whaleAvg[c] = Math.round(whaleAvg[c] / whales.length); + } + if (pub.length > 0) { + for (const p of pub) for (const c of CHAIRS) pubAvg[c] += p.chairs[c] || 0; + for (const c of CHAIRS) pubAvg[c] = Math.round(pubAvg[c] / pub.length); + } + + return { whaleAvg, pubAvg, whaleCount: whales.length, pubCount: pub.length }; +} + function renderBetAdvisor(data, pot) { const el = $('advisor-content'); const ranked = CHAIRS.slice().sort((a, b) => data.prediction[b] - data.prediction[a]); const best = ranked[0]; + const second = ranked[1]; const pBest = data.prediction[best]; const ev = pBest * PAYOUT - 1; @@ -462,6 +489,30 @@ function renderBetAdvisor(data, pot) { const highWinPct = brTotal > 0 ? ((br.high || 0) / brTotal * 100).toFixed(0) : '?'; const evClass = ev >= 0 ? 'ev-positive' : 'ev-negative'; + + // Whale & public bet recommendations + const recs = computeBetRecommendations(); + let whaleHtml = '', pubHtml = ''; + if (recs) { + const wPrimary = recs.whaleAvg[best] || 0; + const wSecondary = Math.round(wPrimary / 2); + whaleHtml = ` +
+
Whale Avg Bet
+
${fmt(wPrimary)}
+
on ${best} (primary) · ${fmt(wSecondary)} on ${second} (2nd)
+
`; + + const pPrimary = recs.pubAvg[best] || 0; + const pSecondary = Math.round(pPrimary / 2); + pubHtml = ` +
+
Public Avg Bet
+
${fmt(pPrimary)}
+
on ${best} (primary) · ${fmt(pSecondary)} on ${second} (2nd)
+
`; + } + el.innerHTML = `
Best Chair
@@ -478,6 +529,8 @@ function renderBetAdvisor(data, pot) {
${lowWinPct}% low
Lowest-bet chair wins ${lowWinPct}% · Highest wins ${highWinPct}%
+ ${whaleHtml} + ${pubHtml} `; } @@ -582,27 +635,32 @@ 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 correctCount = predictions.filter(p => p.correct).length; + const fullHits = predictions.filter(p => p.correct).length; + const semiHits = predictions.filter(p => p.semi_correct).length; + const score = fullHits + semiHits * 0.5; 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'); return ` #${p.game_no} ${p.predicted} + ${p.second_predicted} ${CHAIRS.map(c => { const w = p.probs[c] / maxProb * 40; return ` ${pct(p.probs[c])}`; }).join('')} ${p.actual} - ${p.correct ? 'HIT' : 'MISS'} + ${resultLabel} `; }).join('') + ` - Accuracy (last ${predictions.length}) - - ${(correctCount/predictions.length*100).toFixed(1)}% (${correctCount}/${predictions.length}) + Accuracy (last ${predictions.length}) + + ${(score/predictions.length*100).toFixed(1)}% (${score}/${predictions.length}) `; } @@ -686,11 +744,14 @@ function renderRunsTest(runs) { function renderBacktest(bt) { if (bt.error) { $('backtest-cards').innerHTML = `
${bt.error}
`; return; } const names = {base_rate:'Base Rate',markov_1:'Markov-1',markov_2:'Markov-2',recent_20:'Recent 20',streak:'Streak',combined:'Combined'}; - $('backtest-cards').innerHTML = Object.entries(bt.accuracy).map(([key, acc]) => - `
${names[key]||key}
+ $('backtest-cards').innerHTML = Object.entries(bt.accuracy).map(([key, acc]) => { + const fh = bt.full_hits?.[key] ?? '?'; + const sh = bt.semi_hits?.[key] ?? '?'; + return `
${names[key]||key}
${acc}%
-
vs ${bt.random_baseline}% random
` - ).join(''); +
${fh} hits + ${sh} semi
+
vs ${bt.random_baseline}% random
`; + }).join(''); if (bt.rolling_accuracy) { const ctx = $('backtest-chart').getContext('2d'); const colors = {base_rate:'#8b8fa3',markov_1:'#3b82f6',markov_2:'#ec4899',recent_20:'#f59e0b',streak:'#10b981',combined:'#6c5ce7'}; @@ -845,6 +906,7 @@ function trackBet(bet) { rb.chairs[bet.chair_name] = (rb.chairs[bet.chair_name] || 0) + bet.bet_amount; rb.total += bet.bet_amount; renderWhaleTrend(); + if (predictionData) renderBetAdvisor(predictionData, liveBets.A + liveBets.B + liveBets.C); } function updatePredCardBets() { @@ -885,8 +947,11 @@ function onRoundResult(data) { if (currentPrediction) { const hit = currentPrediction === winner; - flash.className = `result-flash show ${hit ? 'win' : 'loss'}`; - flash.innerHTML = `Round #${data.game_no}: Predicted ${currentPrediction} \u2014 ${winner} won. ${hit ? 'HIT!' : 'MISS'}` + + const semiHit = !hit && currentSecondPrediction === winner; + const cls = hit ? 'win' : (semiHit ? 'semi' : 'loss'); + const label = hit ? 'HIT!' : (semiHit ? 'SEMI-WIN (2nd pick)' : 'MISS'); + flash.className = `result-flash show ${cls}`; + flash.innerHTML = `Round #${data.game_no}: Predicted ${currentPrediction} \u2014 ${winner} won. ${label}` + (extra.length ? `  \u00b7  ${extra.join('  \u00b7  ')}` : ''); }