Files
3pmonitor/static/predictions.html
Junaid Saeed Uppal 9762c0f9bf add bet impact simulator, visitor log page, and fix console logging
- Bet impact simulator on /predictions shows rank headroom and safe bet amounts
- Password-protected /visitors page with visitor log table and stats
- Console now logs real visitor IPs instead of Cloudflare tunnel IPs
2026-02-26 10:19:14 +05:00

1286 lines
59 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Predictions &amp; Game Theory</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --surface2: #232736; --surface3: #2a2e42;
--border: #2d3148; --text: #e4e6f0; --text2: #8b8fa3; --text3: #5a5f75;
--accent: #6c5ce7;
--chair-a: #3b82f6; --chair-b: #ec4899; --chair-c: #f59e0b;
--green: #10b981; --red: #ef4444;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border);
}
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
.nav-links { display: flex; gap: 14px; align-items: center; }
.nav-link { font-size: 12px; color: var(--accent); text-decoration: none; font-weight: 600; transition: color 0.2s; }
.nav-link:hover { color: #a78bfa; }
.ws-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--red); display: inline-block; margin-left: 6px; }
.ws-dot.connected { background: var(--green); }
.content { padding: 16px 20px; max-width: 1400px; margin: 0 auto; }
.loading { text-align: center; padding: 60px; color: var(--text2); font-size: 14px; }
.section { margin-bottom: 24px; }
.section-title {
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text2); margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
/* Live game bar */
.live-bar {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 20px; margin-bottom: 16px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
}
.live-bar .game-no { font-size: 16px; font-weight: 800; }
.live-bar .phase { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.phase-BETTING { background: #10b981; color: #fff; }
.phase-REVEALING { background: #f59e0b; color: #000; }
.phase-ENDED { background: #ef4444; color: #fff; }
.phase-NEW { background: #6c5ce7; color: #fff; }
.live-bar .bet-pills { display: flex; gap: 12px; }
.bet-pill { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; }
.bet-pill .dot { width: 8px; height: 8px; border-radius: 50%; }
.live-bar .timer { font-size: 14px; font-weight: 700; color: var(--green); }
.live-bar .pot { font-size: 13px; color: var(--text2); }
/* Hero prediction cards */
.pred-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 16px; }
.pred-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 18px; text-align: center; position: relative; transition: all 0.3s;
}
.pred-card.best {
border-color: var(--green);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3), 0 0 40px rgba(16, 185, 129, 0.1);
}
.pred-card.second {
border-color: var(--accent);
box-shadow: 0 0 12px rgba(108, 92, 231, 0.2);
}
.pred-card .chair-label { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
.pred-card .prob { font-size: 36px; font-weight: 800; letter-spacing: -2px; }
.pred-card .rank-badge {
display: inline-block; margin-top: 4px; padding: 3px 10px; border-radius: 12px;
font-size: 11px; font-weight: 700;
}
.rank-badge.best-badge { background: var(--green); color: #fff; }
.rank-badge.second-badge { background: var(--accent); color: #fff; }
.pred-card .bet-info {
margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border);
font-size: 12px; color: var(--text2);
}
.pred-card .bet-val { font-size: 18px; font-weight: 700; color: var(--text); }
.pred-card .ev-line { font-size: 11px; margin-top: 6px; }
.ev-positive { color: var(--green); }
.ev-negative { color: var(--red); }
/* Bet advisor */
.bet-advisor {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 18px; margin-bottom: 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; }
.advisor-item .adv-note { font-size: 10px; color: var(--text3); margin-top: 2px; }
/* Trend panels */
.trends-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.trend-panel {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px;
}
.trend-panel .panel-title { font-size: 12px; font-weight: 700; color: var(--text2); margin-bottom: 10px; text-transform: uppercase; }
.trend-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; }
.trend-rank { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; letter-spacing: 0.3px; }
.trend-rank-1 { background: #fbbf2430; color: #fbbf24; }
.trend-rank-2 { background: #94a3b830; color: #94a3b8; }
.trend-chair { font-weight: 900; font-size: 16px; min-width: 22px; text-align: center; }
.trend-bar-bg { flex: 1; height: 18px; border-radius: 4px; background: var(--surface3); overflow: hidden; }
.trend-bar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; min-width: 2px; }
.trend-bar-fill.chair-A { background: linear-gradient(90deg, #3b82f680, #3b82f6); }
.trend-bar-fill.chair-B { background: linear-gradient(90deg, #ec489980, #ec4899); }
.trend-bar-fill.chair-C { background: linear-gradient(90deg, #f59e0b80, #f59e0b); }
.trend-pct { font-weight: 800; min-width: 40px; text-align: right; font-variant-numeric: tabular-nums; font-size: 12px; }
.trend-amt { font-size: 10px; color: var(--text3); min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; }
.trend-note { font-size: 10px; color: var(--text3); margin-top: 4px; font-style: italic; }
.trend-empty { font-size: 11px; color: var(--text3); padding: 10px 0; }
/* Historical crowd analysis */
.crowd-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.crowd-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center;
}
.crowd-card .cc-label { font-size: 11px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
.crowd-card .cc-value { font-size: 22px; font-weight: 800; }
.crowd-card .cc-note { font-size: 10px; color: var(--text3); margin-top: 2px; }
.signal-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.signal-table th, .signal-table td { padding: 8px 12px; text-align: center; border-bottom: 1px solid var(--border); }
.signal-table th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
.panel-title { font-size: 12px; font-weight: 700; color: var(--text2); margin-bottom: 12px; text-transform: uppercase; }
.heatmap { width: 100%; border-collapse: collapse; font-size: 12px; }
.heatmap th, .heatmap td { padding: 6px 8px; text-align: center; border: 1px solid var(--border); }
.heatmap th { background: var(--surface2); color: var(--text2); font-weight: 600; font-size: 11px; }
.stat-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; text-align: center; }
.stat-card .label { font-size: 12px; color: var(--text2); font-weight: 600; margin-bottom: 4px; }
.stat-card .value { font-size: 28px; font-weight: 800; }
.test-result { margin-bottom: 8px; }
.test-result .test-label { font-size: 11px; color: var(--text3); font-weight: 600; text-transform: uppercase; }
.test-result .test-value { font-size: 16px; font-weight: 700; }
.test-result .test-interpret { font-size: 12px; color: var(--text2); margin-top: 2px; }
.significant { color: var(--red); }
.not-significant { color: var(--green); }
.chart-container { position: relative; height: 300px; }
.chart-container-sm { position: relative; height: 250px; }
.chart-container-tall { position: relative; height: 480px; }
.backtest-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px; }
.bt-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
.bt-card .bt-name { font-size: 11px; color: var(--text2); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
.bt-card .bt-acc { font-size: 24px; font-weight: 800; }
.bt-card .bt-acc.above { color: var(--green); }
.bt-card .bt-acc.below { color: var(--red); }
.pred-history { width: 100%; border-collapse: collapse; font-size: 12px; }
.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; }
.result-flash {
display: none; padding: 10px 16px; border-radius: 8px; margin-bottom: 16px;
font-weight: 700; font-size: 14px; text-align: center; animation: flashIn 0.3s ease-out;
}
.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); } }
/* Bet impact simulator */
.impact-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 12px; }
.impact-table th, .impact-table td { padding: 8px 10px; text-align: center; border-bottom: 1px solid var(--border); }
.impact-table th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; background: var(--surface2); }
.impact-rank { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase; }
.impact-rank.high { background: rgba(239,68,68,0.2); color: var(--red); }
.impact-rank.mid { background: rgba(108,92,231,0.2); color: var(--accent); }
.impact-rank.low { background: rgba(16,185,129,0.2); color: var(--green); }
.impact-headroom { font-weight: 800; font-variant-numeric: tabular-nums; }
.impact-recs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.impact-rec {
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 16px; text-align: center;
}
.impact-rec .rec-label { font-size: 10px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
.impact-rec .rec-value { font-size: 20px; font-weight: 800; }
.impact-rec .rec-note { font-size: 10px; color: var(--text3); margin-top: 4px; }
@media (max-width: 768px) {
.pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats, .impact-recs { grid-template-columns: 1fr; }
.advisor-grid { grid-template-columns: 1fr; gap: 10px; }
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
.pred-card .prob { font-size: 28px; }
.live-bar { gap: 10px; }
.live-bar .bet-pills { width: 100%; justify-content: space-between; }
}
</style>
</head>
<body>
<div class="header">
<h1>Predictions &amp; Game Theory</h1>
<div class="nav-links">
<a href="/" class="nav-link">Live Dashboard &rarr;</a>
<a href="/analytics" class="nav-link">Analytics &rarr;</a>
<a href="/patterns" class="nav-link">Patterns &rarr;</a>
<div id="ws-dot" class="ws-dot" title="WebSocket"></div>
</div>
</div>
<div class="content">
<div id="loading" class="loading">Loading prediction data...</div>
<div id="main" style="display:none">
<!-- Live Game Bar -->
<div id="live-bar" class="live-bar" style="display:none">
<div class="game-no">Round <span id="game-no">&mdash;</span></div>
<span id="phase" class="phase phase-NEW">&mdash;</span>
<span id="timer" class="timer"></span>
<div class="bet-pills">
<div class="bet-pill"><div class="dot" style="background:var(--chair-a)"></div>A: <span id="live-bet-a">0</span></div>
<div class="bet-pill"><div class="dot" style="background:var(--chair-b)"></div>B: <span id="live-bet-b">0</span></div>
<div class="bet-pill"><div class="dot" style="background:var(--chair-c)"></div>C: <span id="live-bet-c">0</span></div>
</div>
<div class="pot">Pot: <span id="live-pot">0</span></div>
</div>
<!-- Result Flash -->
<div id="result-flash" class="result-flash"></div>
<!-- Bayesian Prediction Hero -->
<div class="section">
<div class="section-title">Recommendation <span id="pred-game-label" style="font-weight:400;text-transform:none;color:var(--text3)"></span></div>
<div id="pred-cards" class="pred-cards"></div>
<!-- Bet Size Advisor -->
<div id="bet-advisor" class="bet-advisor">
<div class="panel-title">Bet Size Advisor</div>
<div id="advisor-content" class="advisor-grid"></div>
</div>
<!-- Bet Impact Simulator -->
<div id="bet-impact" class="bet-advisor" style="margin-top:12px">
<div class="panel-title">Bet Impact Simulator</div>
<div id="impact-content"></div>
</div>
<div class="panel" style="margin-top:8px">
<div class="panel-title">Signal Breakdown</div>
<table class="signal-table" id="signal-table">
<thead><tr><th>Signal</th><th>Weight</th><th>A</th><th>B</th><th>C</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- Live Trends: Whale + Public -->
<div class="section">
<div class="section-title">Live Market Sentiment</div>
<div class="trends-grid">
<div class="trend-panel">
<div class="panel-title">Whale Trend (Top 5 Bettors)</div>
<div id="whale-trend"><div class="trend-empty">Waiting for bets...</div></div>
<div id="whale-note" class="trend-note"></div>
</div>
<div class="trend-panel">
<div class="panel-title">Public Trend (Total Pool)</div>
<div id="public-trend"><div class="trend-empty">Waiting for bets...</div></div>
<div id="public-note" class="trend-note"></div>
</div>
</div>
<!-- Historical: did crowd pick the winner? -->
<div id="crowd-stats" class="crowd-stats"></div>
</div>
<!-- Last 50 Predictions vs Actual -->
<div class="section">
<div class="section-title" style="display:flex;align-items:center;gap:12px">
Last 50 Predictions vs Actual
<button id="view-all-btn" onclick="openHistoryModal()" style="font-size:11px;padding:3px 12px;border-radius:12px;border:1px solid var(--accent);background:transparent;color:var(--accent);cursor:pointer;font-weight:600;text-transform:uppercase;letter-spacing:0.3px">View All History</button>
</div>
<div class="panel" style="max-height:600px;overflow-y:auto">
<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>Whale</th><th>Public</th><th>Actual</th><th>Result</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- Full History Modal -->
<div id="history-modal" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)">
<div style="position:absolute;inset:20px;background:var(--bg);border:1px solid var(--border);border-radius:12px;display:flex;flex-direction:column;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);background:var(--surface)">
<div style="font-size:15px;font-weight:700">Full Prediction History</div>
<button onclick="closeHistoryModal()" style="background:none;border:none;color:var(--text2);font-size:22px;cursor:pointer;padding:0 6px;line-height:1">&times;</button>
</div>
<div id="modal-accuracy" style="padding:12px 20px;border-bottom:1px solid var(--border);background:var(--surface2);font-size:12px;display:flex;gap:24px;flex-wrap:wrap"></div>
<div style="flex:1;overflow-y:auto;padding:0 20px">
<table class="pred-history" id="modal-table">
<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>
</table>
</div>
<div id="modal-pagination" style="display:flex;align-items:center;justify-content:center;gap:16px;padding:12px 20px;border-top:1px solid var(--border);background:var(--surface)">
</div>
</div>
</div>
<!-- Markov Matrices -->
<div class="section">
<div class="section-title">Markov Transition Matrices</div>
<div class="two-col">
<div class="panel">
<div class="panel-title">1st Order &mdash; P(next | last)</div>
<table class="heatmap" id="markov1-table"></table>
</div>
<div class="panel">
<div class="panel-title">2nd Order &mdash; P(next | last two)</div>
<table class="heatmap" id="markov2-table"></table>
</div>
</div>
</div>
<!-- Autocorrelation -->
<div class="section">
<div class="section-title">Autocorrelation (Lags 1&ndash;5)</div>
<div class="panel">
<div class="chart-container-sm"><canvas id="autocorr-chart"></canvas></div>
</div>
</div>
<!-- Statistical Tests -->
<div class="section">
<div class="section-title">Statistical Tests</div>
<div class="two-col">
<div class="panel">
<div class="panel-title">Chi-Squared Goodness of Fit</div>
<div id="chi-results"></div>
</div>
<div class="panel">
<div class="panel-title">Wald-Wolfowitz Runs Test</div>
<div id="runs-results"></div>
</div>
</div>
</div>
<!-- Theory Backtesting -->
<div class="section">
<div class="section-title">Theory Backtesting</div>
<div id="backtest-cards" class="backtest-grid"></div>
<div class="panel">
<div class="panel-title">Rolling Accuracy (Last 200 Games)</div>
<div class="chart-container"><canvas id="backtest-chart"></canvas></div>
</div>
</div>
<!-- Card Value Distribution -->
<div class="section">
<div class="section-title">Card Value Distribution by Chair (Last 500 Games)</div>
<div class="panel">
<div class="chart-container"><canvas id="card-value-chart"></canvas></div>
</div>
</div>
<!-- Face Card Frequency -->
<div class="section">
<div class="section-title">Face Card Frequency (J, Q, K, A)</div>
<div id="face-cards" class="stat-cards"></div>
</div>
<!-- Suit Distribution -->
<div class="section">
<div class="section-title">Suit Distribution by Chair</div>
<div class="panel">
<div class="chart-container-sm"><canvas id="suit-chart"></canvas></div>
</div>
</div>
<!-- Top Winning Cards -->
<div class="section">
<div class="section-title">Top 20 Cards in Winning Hands</div>
<div class="panel">
<div class="chart-container-tall"><canvas id="winning-cards-chart"></canvas></div>
</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const CHAIRS = ['A', 'B', 'C'];
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
const CHAIR_MAP = {1:'C', 2:'B', 3:'A'};
const PAYOUT = 2.9; // winner gets 2.9x
const fmt = n => {
if (n == null) return '0';
const abs = Math.abs(n);
if (abs >= 1e6) return (n/1e6).toFixed(1) + 'M';
if (abs >= 1e3) return (n/1e3).toFixed(1) + 'K';
return String(n);
};
const fmtFull = n => Number(n).toLocaleString();
const CHART_DEFAULTS = {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#8b8fa3', font: { size: 11 } } } },
scales: {
x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d3148' } },
y: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d3148' } }
}
};
// ── State ──
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};
function heatColor(val) {
const diff = val - 1/3;
if (diff > 0) {
const intensity = Math.min(diff * 6, 1);
return `rgba(16, 185, 129, ${0.15 + intensity * 0.5})`;
} else {
const intensity = Math.min(Math.abs(diff) * 6, 1);
return `rgba(239, 68, 68, ${0.15 + intensity * 0.5})`;
}
}
function pct(v) { return (v * 100).toFixed(1) + '%'; }
// ── Recommendation with EV + ranked picks ──
function renderPrediction(data) {
const container = $('pred-cards');
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;
container.innerHTML = CHAIRS.map(c => {
const p = data.prediction[c];
const isBest = c === best;
const isSecond = c === second;
const bet = liveBets[c] || 0;
// EV per unit bet: P(win) * 2.9 - 1 (you risk 1, get 2.9 back if win)
const ev = p * PAYOUT - 1;
const evClass = ev >= 0 ? 'ev-positive' : 'ev-negative';
const evSign = ev >= 0 ? '+' : '';
let cls = '';
let badge = '';
if (isBest) { cls = 'best'; badge = `<div class="rank-badge best-badge">TOP PICK</div>`; }
else if (isSecond) { cls = 'second'; badge = `<div class="rank-badge second-badge">2ND PICK</div>`; }
return `<div class="pred-card ${cls}">
<div class="chair-label" style="color:${CHAIR_COLORS[c]}">Chair ${c}</div>
<div class="prob" style="color:${CHAIR_COLORS[c]}">${pct(p)}</div>
${badge}
<div class="ev-line ${evClass}">EV: ${evSign}${(ev * 100).toFixed(1)}% per bet</div>
<div class="bet-info">
<div style="color:var(--text3)">Current Bets</div>
<div class="bet-val" style="color:${CHAIR_COLORS[c]}">${fmt(bet)}</div>
</div>
</div>`;
}).join('');
// Signal table
const tbody = $('signal-table').querySelector('tbody');
const sigNames = {'base_rate':'Base Rate','markov_1':'Markov-1','markov_2':'Markov-2','recent_20':'Recent 20','streak':'Streak','balance':'Balance'};
tbody.innerHTML = Object.entries(data.signals).map(([key, sig]) =>
`<tr><td style="text-align:left">${sigNames[key]||key}</td><td>${(sig.weight*100).toFixed(0)}%</td>` +
CHAIRS.map(c => `<td style="color:${CHAIR_COLORS[c]}">${pct(sig.probs[c])}</td>`).join('') + '</tr>'
).join('') +
`<tr style="font-weight:700;border-top:2px solid var(--border)"><td style="text-align:left">Combined</td><td>100%</td>` +
CHAIRS.map(c => `<td style="color:${CHAIR_COLORS[c]}">${pct(data.prediction[c])}</td>`).join('') + '</tr>';
// Bet advisor
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;
// Kelly criterion: f* = (bp - q) / b where b = payout-1, p = win prob, q = 1-p
const b = PAYOUT - 1; // net payout ratio (1.9)
const kellyFraction = Math.max(0, (b * pBest - (1 - pBest)) / b);
// Cap Kelly at 25% for safety
const safeFraction = Math.min(kellyFraction, 0.25);
// Bet rank insight from historical data
const br = predictionData?.bet_rank || {};
const brTotal = (br.high || 0) + (br.mid || 0) + (br.low || 0);
const lowWinPct = brTotal > 0 ? ((br.low || 0) / brTotal * 100).toFixed(0) : '?';
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 wOnBest = recs.whaleAvg[best] || 0;
const wOn2nd = recs.whaleAvg[second] || 0;
whaleHtml = `
<div class="advisor-item">
<div class="adv-label">Bet Like Whales</div>
<div class="adv-value" style="color:${CHAIR_COLORS[best]}">${fmt(wOnBest)} <span style="font-size:12px;color:var(--text2)">on ${best}</span></div>
<div class="adv-value" style="font-size:15px;color:${CHAIR_COLORS[second]};margin-top:2px">${fmt(wOn2nd)} <span style="font-size:11px;color:var(--text2)">on ${second}</span></div>
<div class="adv-note">${recs.whaleCount} whales avg this round</div>
</div>`;
const pOnBest = recs.pubAvg[best] || 0;
const pOn2nd = recs.pubAvg[second] || 0;
pubHtml = `
<div class="advisor-item">
<div class="adv-label">Bet Like Public</div>
<div class="adv-value" style="color:${CHAIR_COLORS[best]}">${fmt(pOnBest)} <span style="font-size:12px;color:var(--text2)">on ${best}</span></div>
<div class="adv-value" style="font-size:15px;color:${CHAIR_COLORS[second]};margin-top:2px">${fmt(pOn2nd)} <span style="font-size:11px;color:var(--text2)">on ${second}</span></div>
<div class="adv-note">${recs.pubCount} bettors avg this round</div>
</div>`;
}
el.innerHTML = `
<div class="advisor-item">
<div class="adv-label">Best Chair</div>
<div class="adv-value" style="color:${CHAIR_COLORS[best]}">${best} (${pct(pBest)})</div>
<div class="adv-note ${evClass}">EV: ${ev >= 0 ? '+' : ''}${(ev * 100).toFixed(1)}% per unit</div>
</div>
<div class="advisor-item">
<div class="adv-label">Kelly Fraction</div>
<div class="adv-value">${(safeFraction * 100).toFixed(1)}%</div>
<div class="adv-note">of bankroll per bet${kellyFraction > 0.25 ? ' (capped 25%)' : ''}</div>
</div>
<div class="advisor-item">
<div class="adv-label">Winner Bet Rank</div>
<div class="adv-value">${lowWinPct}% low</div>
<div class="adv-note">Lowest-bet chair wins ${lowWinPct}% &middot; Highest wins ${highWinPct}%</div>
</div>
${whaleHtml}
${pubHtml}
`;
renderBetImpact();
}
// ── Bet Impact Simulator ──
function getRank(chair, bets) {
const vals = CHAIRS.map(c => bets[c] || 0);
const v = bets[chair] || 0;
const maxV = Math.max(...vals);
const minV = Math.min(...vals);
if (maxV === minV) return 'mid';
if (v >= maxV) return 'high';
if (v <= minV) return 'low';
return 'mid';
}
function rankWinRate(rank) {
const br = predictionData?.bet_rank || {};
const total = (br.high || 0) + (br.mid || 0) + (br.low || 0);
if (total === 0) return null;
return (br[rank] || 0) / total * 100;
}
function computeHeadroom(chair, bets) {
const curRank = getRank(chair, bets);
const others = CHAIRS.filter(c => c !== chair).map(c => bets[c] || 0);
const myBet = bets[chair] || 0;
const maxOther = Math.max(...others);
const minOther = Math.min(...others);
if (curRank === 'low') {
// Can add up to (minOther - myBet) before leaving low
// If two others are equal, second-lowest is minOther
const sorted = others.slice().sort((a, b) => a - b);
return Math.max(0, sorted[0] - myBet);
} else if (curRank === 'mid') {
// Can add up to (maxOther - myBet) before becoming high
return Math.max(0, maxOther - myBet);
} else {
// Already high — headroom is infinite (rank can't go higher)
return Infinity;
}
}
function renderBetImpact() {
const el = $('impact-content');
const totalBets = liveBets.A + liveBets.B + liveBets.C;
if (totalBets === 0) {
el.innerHTML = '<div style="color:var(--text3);font-size:12px;padding:8px 0">Waiting for bets to calculate impact...</div>';
return;
}
// Table
let rows = '';
for (const c of CHAIRS) {
const curRank = getRank(c, liveBets);
const curWR = rankWinRate(curRank);
const headroom = computeHeadroom(c, liveBets);
// What rank would the chair move to?
let nextRank = '--';
let nextWR = null;
let wrChange = '';
if (headroom !== Infinity && headroom < 1e9) {
// Simulate adding headroom+1
const simBets = {...liveBets, [c]: (liveBets[c] || 0) + headroom + 1};
const nr = getRank(c, simBets);
nextRank = nr;
nextWR = rankWinRate(nr);
if (curWR !== null && nextWR !== null) {
const diff = nextWR - curWR;
wrChange = `<span style="color:${diff >= 0 ? 'var(--green)' : 'var(--red)'}">${diff >= 0 ? '+' : ''}${diff.toFixed(1)}%</span>`;
}
}
rows += `<tr>
<td style="font-weight:700;color:${CHAIR_COLORS[c]}">Chair ${c}</td>
<td><span class="impact-rank ${curRank}">${curRank}</span></td>
<td>${curWR !== null ? curWR.toFixed(1) + '%' : '--'}</td>
<td class="impact-headroom" style="color:${headroom === Infinity ? 'var(--green)' : headroom === 0 ? 'var(--red)' : 'var(--text)'}">${headroom === Infinity ? 'No limit' : fmt(headroom)}</td>
<td>${nextRank !== '--' ? `<span class="impact-rank ${nextRank}">${nextRank}</span>` : '--'}</td>
<td>${wrChange || '--'}</td>
</tr>`;
}
// Recommendations: safe bet = 80% of headroom for top pick & 2nd pick
const ranked = CHAIRS.slice().sort((a, b) => (predictionData?.prediction?.[b] || 0) - (predictionData?.prediction?.[a] || 0));
const best = ranked[0], second = ranked[1];
const headBest = computeHeadroom(best, liveBets);
const headSecond = computeHeadroom(second, liveBets);
const safeBest = headBest === Infinity ? 'No limit' : fmt(Math.floor(headBest * 0.8));
const safeSecond = headSecond === Infinity ? 'No limit' : fmt(Math.floor(headSecond * 0.8));
const bestRank = getRank(best, liveBets);
const secondRank = getRank(second, liveBets);
el.innerHTML = `
<table class="impact-table">
<thead><tr><th>Chair</th><th>Rank</th><th>Win Rate</th><th>Max Bet to Keep Rank</th><th>Next Rank</th><th>Win Rate Change</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="impact-recs">
<div class="impact-rec">
<div class="rec-label">Safe Bet on ${best} (Top Pick)</div>
<div class="rec-value" style="color:${CHAIR_COLORS[best]}">${safeBest}</div>
<div class="rec-note">80% of headroom &middot; keeps <span class="impact-rank ${bestRank}" style="font-size:9px">${bestRank}</span> rank</div>
</div>
<div class="impact-rec">
<div class="rec-label">Safe Bet on ${second} (2nd Pick)</div>
<div class="rec-value" style="color:${CHAIR_COLORS[second]}">${safeSecond}</div>
<div class="rec-note">80% of headroom &middot; keeps <span class="impact-rank ${secondRank}" style="font-size:9px">${secondRank}</span> rank</div>
</div>
</div>
`;
}
// ── Whale & Public Trends ──
function renderWhaleTrend() {
const el = $('whale-trend');
const note = $('whale-note');
const bettors = Object.values(roundBettors);
if (bettors.length < 2) {
el.innerHTML = '<div class="trend-empty">Waiting for bets...</div>';
note.textContent = '';
return;
}
const whales = bettors.sort((a, b) => b.total - a.total).slice(0, 5);
const chairTotals = {A: 0, B: 0, C: 0};
for (const w of whales) { for (const c of CHAIRS) chairTotals[c] += w.chairs[c] || 0; }
const total = chairTotals.A + chairTotals.B + chairTotals.C;
if (total === 0) { el.innerHTML = '<div class="trend-empty">No whale bets yet</div>'; note.textContent = ''; return; }
const ranked = CHAIRS.map(c => ({chair: c, amount: chairTotals[c], pct: chairTotals[c] / total * 100}))
.sort((a, b) => b.amount - a.amount);
el.innerHTML = renderTrendBars(ranked.slice(0, 2));
const topChair = ranked[0].chair;
const whaleCount = whales.length;
const towardTop = whales.filter(w => {
const maxCh = CHAIRS.reduce((a, b) => (w.chairs[a]||0) >= (w.chairs[b]||0) ? a : b);
return maxCh === topChair;
}).length;
if (ranked[0].pct >= 70) note.textContent = `${towardTop}/${whaleCount} whales heavy on ${topChair} (strong lean)`;
else if (ranked[0].pct >= 50) note.textContent = `${towardTop}/${whaleCount} whales favor ${topChair}, split on ${ranked[1].chair}`;
else note.textContent = `Whales split across ${ranked[0].chair} & ${ranked[1].chair}`;
}
function renderPublicTrend() {
const el = $('public-trend');
const note = $('public-note');
const a = liveBets.A, b = liveBets.B, c = liveBets.C;
const total = a + b + c;
if (total === 0) { el.innerHTML = '<div class="trend-empty">No bets yet</div>'; note.textContent = ''; return; }
const ranked = [
{chair: 'A', amount: a, pct: a/total*100},
{chair: 'B', amount: b, pct: b/total*100},
{chair: 'C', amount: c, pct: c/total*100},
].sort((x, y) => y.amount - x.amount);
// Count unique bettors per chair
const bettorCount = {A: 0, B: 0, C: 0};
for (const rb of Object.values(roundBettors)) {
for (const ch of CHAIRS) if ((rb.chairs[ch] || 0) > 0) bettorCount[ch]++;
}
const totalBettors = Object.keys(roundBettors).length;
const bettorRanked = CHAIRS.map(c => ({chair: c, count: bettorCount[c], pct: totalBettors > 0 ? bettorCount[c] / totalBettors * 100 : 0}))
.sort((x, y) => y.count - x.count);
let bettorHtml = '';
if (totalBettors > 0) {
bettorHtml = `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
<div style="font-size:10px;color:var(--text3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Most Bettors On</div>` +
bettorRanked.filter(r => r.count > 0).map((r, i) => `
<div class="trend-row">
<span class="trend-chair" style="color:${CHAIR_COLORS[r.chair]};font-size:14px">${r.chair}</span>
<div class="trend-bar-bg">
<div class="trend-bar-fill chair-${r.chair}" style="width:${r.pct}%"></div>
</div>
<span class="trend-pct" style="color:${CHAIR_COLORS[r.chair]}">${r.count}</span>
<span class="trend-amt">${r.pct.toFixed(0)}%</span>
</div>`).join('') +
`<div style="font-size:10px;color:var(--text3);margin-top:4px">${totalBettors} total bettors</div></div>`;
}
el.innerHTML = renderTrendBars(ranked.slice(0, 2)) + bettorHtml;
// Check if public favorite matches our prediction
if (currentPrediction) {
const pubFav = ranked[0].chair;
const mostBettors = bettorRanked[0].chair;
if (pubFav === currentPrediction) {
note.textContent = `Public agrees with model pick (${pubFav})`;
} else {
note.textContent = `Public favors ${pubFav}, model picks ${currentPrediction} \u2014 contrarian opportunity?`;
}
if (mostBettors !== pubFav) {
note.textContent += ` \u00b7 Most people on ${mostBettors} but most money on ${pubFav}`;
}
}
}
function renderTrendBars(items) {
return items.map((r, i) => `
<div class="trend-row">
<span class="trend-rank trend-rank-${i+1}">${i===0?'1ST':'2ND'}</span>
<span class="trend-chair" style="color:${CHAIR_COLORS[r.chair]}">${r.chair}</span>
<div class="trend-bar-bg">
<div class="trend-bar-fill chair-${r.chair}" style="width:${r.pct}%"></div>
</div>
<span class="trend-pct" style="color:${CHAIR_COLORS[r.chair]}">${r.pct.toFixed(0)}%</span>
<span class="trend-amt">${fmt(r.amount)}</span>
</div>
`).join('');
}
function renderCrowdStats(data) {
const br = data.bet_rank || {};
const brTotal = (br.high || 0) + (br.mid || 0) + (br.low || 0);
if (brTotal === 0) { $('crowd-stats').innerHTML = ''; return; }
const highPct = (br.high / brTotal * 100).toFixed(1);
const midPct = (br.mid / brTotal * 100).toFixed(1);
const lowPct = (br.low / brTotal * 100).toFixed(1);
$('crowd-stats').innerHTML = `
<div class="crowd-card">
<div class="cc-label">Most-Bet Chair Wins</div>
<div class="cc-value" style="color:var(--green)">${highPct}%</div>
<div class="cc-note">${fmtFull(br.high)} / ${fmtFull(brTotal)} games</div>
</div>
<div class="crowd-card">
<div class="cc-label">Mid-Bet Chair Wins</div>
<div class="cc-value" style="color:var(--accent)">${midPct}%</div>
<div class="cc-note">${fmtFull(br.mid)} / ${fmtFull(brTotal)} games</div>
</div>
<div class="crowd-card">
<div class="cc-label">Least-Bet Chair Wins</div>
<div class="cc-value" style="color:var(--red)">${lowPct}%</div>
<div class="cc-note">${fmtFull(br.low)} / ${fmtFull(brTotal)} games</div>
</div>
`;
}
function renderPredictionRows(predictions, tbody) {
if (!predictions || predictions.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="color:var(--text3)">Not enough data</td></tr>';
return;
}
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 with semi-win
let whaleCell;
if (p.whale_pick) {
const whCls = p.whale_hit ? 'correct' : (p.whale_semi ? 'semi' : 'wrong');
const whLabel = p.whale_hit ? 'HIT' : (p.whale_semi ? 'SEMI' : '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 with semi-win
let pubCell;
if (p.public_pick) {
const puCls = p.public_hit ? 'correct' : (p.public_semi ? 'semi' : 'wrong');
const puLabel = p.public_hit ? 'HIT' : (p.public_semi ? 'SEMI' : '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>
<td style="font-weight:700">#${p.game_no}</td>
<td class="winner-cell" style="color:${CHAIR_COLORS[p.predicted]}">${p.predicted}</td>
<td style="color:${CHAIR_COLORS[p.second_predicted]}">${p.second_predicted}</td>
${CHAIRS.map(c => {
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>`;
}).join('')}
${whaleCell}
${pubCell}
<td class="winner-cell" style="color:${CHAIR_COLORS[p.actual]}">${p.actual}</td>
<td class="${resultClass}" style="font-weight:700">${resultLabel}</td>
</tr>`;
}).join('');
}
function computeAccuracy(predictions) {
const fullHits = predictions.filter(p => p.correct).length;
const semiHits = predictions.filter(p => p.semi_correct).length;
const score = fullHits + semiHits * 0.5;
const whaleEntries = predictions.filter(p => p.whale_pick != null);
const whaleHits = whaleEntries.filter(p => p.whale_hit).length;
const whaleSemi = whaleEntries.filter(p => p.whale_semi).length;
const whaleScore = whaleHits + whaleSemi * 0.5;
const whalePct = whaleEntries.length > 0 ? (whaleScore / whaleEntries.length * 100).toFixed(1) : '--';
const publicEntries = predictions.filter(p => p.public_pick != null);
const publicHits = publicEntries.filter(p => p.public_hit).length;
const publicSemi = publicEntries.filter(p => p.public_semi).length;
const publicScore = publicHits + publicSemi * 0.5;
const publicPct = publicEntries.length > 0 ? (publicScore / publicEntries.length * 100).toFixed(1) : '--';
const modelPct = predictions.length > 0 ? (score / predictions.length * 100).toFixed(1) : '--';
return { score, fullHits, semiHits, whaleHits, whaleSemi, whaleScore, whalePct, whaleTotal: whaleEntries.length,
publicHits, publicSemi, publicScore, publicPct, publicTotal: publicEntries.length, modelPct, total: predictions.length };
}
function renderLast20(predictions) {
const tbody = $('pred-history').querySelector('tbody');
if (!predictions || predictions.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="color:var(--text3)">Not enough data</td></tr>';
return;
}
const acc = computeAccuracy(predictions);
renderPredictionRows(predictions, tbody);
// Append accuracy row
tbody.innerHTML += `<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 style="color:${acc.whalePct !== '--' && parseFloat(acc.whalePct) > 33.3 ? 'var(--green)' : 'var(--text2)'}">${acc.whalePct !== '--' ? acc.whalePct + '%' : '--'}</td>
<td style="color:${acc.publicPct !== '--' && parseFloat(acc.publicPct) > 33.3 ? 'var(--green)' : 'var(--text2)'}">${acc.publicPct !== '--' ? acc.publicPct + '%' : '--'}</td>
<td colspan="2" style="color:${parseFloat(acc.modelPct) > 33.3 ? 'var(--green)' : 'var(--red)'}">
Model: ${acc.modelPct}%
</td>
</tr>`;
}
// ── Full History Modal ──
let historyCache = null;
let historyPage = 1;
const ROWS_PER_PAGE = 50;
function openHistoryModal() {
$('history-modal').style.display = 'block';
if (historyCache) { renderHistoryPage(1); return; }
$('modal-table').querySelector('tbody').innerHTML = '<tr><td colspan="10" style="color:var(--text3)">Loading...</td></tr>';
$('modal-pagination').innerHTML = '';
$('modal-accuracy').innerHTML = '';
fetch('/api/prediction-history?limit=500').then(r => r.json()).then(data => {
historyCache = data;
renderHistoryPage(1);
}).catch(err => {
$('modal-table').querySelector('tbody').innerHTML = `<tr><td colspan="10" style="color:var(--red)">Failed: ${err.message}</td></tr>`;
});
}
function closeHistoryModal() {
$('history-modal').style.display = 'none';
}
// Close on overlay click
$('history-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeHistoryModal();
});
function renderHistoryPage(page) {
if (!historyCache) return;
const preds = historyCache.predictions || [];
const totalPages = Math.max(1, Math.ceil(preds.length / ROWS_PER_PAGE));
page = Math.max(1, Math.min(page, totalPages));
historyPage = page;
// Accuracy summary bar
const accData = historyCache.accuracy;
$('modal-accuracy').innerHTML = `
<span><b>Model:</b> <span style="color:var(--green)">${accData.model.pct}%</span> <span style="color:var(--text3)">(${accData.model.hits} hits + ${accData.model.semi} semi / ${accData.model.total})</span></span>
<span><b>Whale:</b> <span style="color:${accData.whale.pct > 33.3 ? 'var(--green)' : 'var(--text2)'}">${accData.whale.pct}%</span> <span style="color:var(--text3)">(${accData.whale.hits} hits${accData.whale.semi ? ' + ' + accData.whale.semi + ' semi' : ''} / ${accData.whale.total})</span></span>
<span><b>Public:</b> <span style="color:${accData.public.pct > 33.3 ? 'var(--green)' : 'var(--text2)'}">${accData.public.pct}%</span> <span style="color:var(--text3)">(${accData.public.hits} hits${accData.public.semi ? ' + ' + accData.public.semi + ' semi' : ''} / ${accData.public.total})</span></span>
<span style="color:var(--text3)">${preds.length} predictions total</span>
`;
// Slice for current page (preds are oldest-first, show newest-first)
const reversed = preds.slice().reverse();
const start = (page - 1) * ROWS_PER_PAGE;
const pageData = reversed.slice(start, start + ROWS_PER_PAGE);
const tbody = $('modal-table').querySelector('tbody');
// Reverse pageData back so renderPredictionRows (which reverses internally) shows correct order
renderPredictionRows(pageData.slice().reverse(), tbody);
// Pagination controls
$('modal-pagination').innerHTML = `
<button onclick="renderHistoryPage(${page - 1})" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:pointer;font-size:12px" ${page <= 1 ? 'disabled style="opacity:0.4;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:default;font-size:12px"' : ''}>&#8592; Prev</button>
<span style="font-size:12px;color:var(--text2)">Page ${page} of ${totalPages}</span>
<button onclick="renderHistoryPage(${page + 1})" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:pointer;font-size:12px" ${page >= totalPages ? 'disabled style="opacity:0.4;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:default;font-size:12px"' : ''}>Next &#8594;</button>
`;
}
function renderMarkov1(m) {
const t = $('markov1-table');
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">\u2192${c}</th>`).join('') + '</tr>';
for (const src of CHAIRS) {
html += `<tr><th style="color:${CHAIR_COLORS[src]}">${src}</th>`;
for (const dst of CHAIRS) { const v = m.matrix[src]?.[dst] || 0; html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`; }
html += '</tr>';
}
t.innerHTML = html;
}
function renderMarkov2(m) {
const t = $('markov2-table');
const keys = [];
for (const a of CHAIRS) for (const b of CHAIRS) keys.push(a+b);
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">\u2192${c}</th>`).join('') + '</tr>';
for (const key of keys) {
html += `<tr><th>${key}</th>`;
for (const dst of CHAIRS) { const v = m.matrix[key]?.[dst] || 0; html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`; }
html += '</tr>';
}
t.innerHTML = html;
}
function renderAutocorrelation(ac) {
const ctx = $('autocorr-chart').getContext('2d');
const threshold = ac.length > 0 && ac[0].significant !== undefined ? 1.96 / Math.sqrt(100) : 0.196;
new Chart(ctx, {
type: 'bar',
data: {
labels: ac.map(a => `Lag ${a.lag}`),
datasets: [{ label: 'Autocorrelation', data: ac.map(a => a.r),
backgroundColor: ac.map(a => a.significant ? '#ef4444' : '#6c5ce7'), borderRadius: 4 }]
},
options: {
...CHART_DEFAULTS,
plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } },
scales: { ...CHART_DEFAULTS.scales, y: { ...CHART_DEFAULTS.scales.y, suggestedMin: -0.3, suggestedMax: 0.3 } }
},
plugins: [{
id: 'thresholdLines',
afterDraw(chart) {
const {ctx, chartArea, scales} = chart;
ctx.save(); ctx.setLineDash([5, 5]); ctx.strokeStyle = '#ef444480'; ctx.lineWidth = 1;
for (const val of [threshold, -threshold]) {
const y = scales.y.getPixelForValue(val);
ctx.beginPath(); ctx.moveTo(chartArea.left, y); ctx.lineTo(chartArea.right, y); ctx.stroke();
}
ctx.restore();
}
}]
});
}
function renderChiSquared(chi) {
$('chi-results').innerHTML = `
<div class="test-result"><div class="test-label">Chi-Squared Statistic</div><div class="test-value">${chi.chi2}</div></div>
<div class="test-result"><div class="test-label">p-value</div><div class="test-value ${chi.significant ? 'significant' : 'not-significant'}">${chi.p_value}</div></div>
<div class="test-result"><div class="test-label">Expected Count (each chair)</div><div class="test-value">${chi.expected}</div></div>
<div class="test-result"><div class="test-label">Observed</div><div class="test-value">A: ${chi.counts.A} &nbsp; B: ${chi.counts.B} &nbsp; C: ${chi.counts.C}</div></div>
<div class="test-result"><div class="test-label">Interpretation</div>
<div class="test-interpret ${chi.significant ? 'significant' : 'not-significant'}">
${chi.significant ? 'Distribution is significantly non-uniform (p < 0.05)' : 'Distribution is consistent with uniform (p >= 0.05)'}
</div></div>`;
}
function renderRunsTest(runs) {
$('runs-results').innerHTML = `
<div class="test-result"><div class="test-label">Observed Runs</div><div class="test-value">${runs.runs}</div></div>
<div class="test-result"><div class="test-label">Expected Runs</div><div class="test-value">${runs.expected_runs || '\u2014'}</div></div>
<div class="test-result"><div class="test-label">Z-Score</div><div class="test-value">${runs.z_score}</div></div>
<div class="test-result"><div class="test-label">p-value</div><div class="test-value ${runs.significant ? 'significant' : 'not-significant'}">${runs.p_value}</div></div>
<div class="test-result"><div class="test-label">Interpretation</div>
<div class="test-interpret ${runs.significant ? 'significant' : 'not-significant'}">${runs.interpretation}</div></div>`;
}
function renderBacktest(bt) {
if (bt.error) { $('backtest-cards').innerHTML = `<div style="color:var(--text2)">${bt.error}</div>`; return; }
const names = {base_rate:'Base Rate',markov_1:'Markov-1',markov_2:'Markov-2',recent_20:'Recent 20',streak:'Streak',balance:'Balance',combined:'Combined'};
$('backtest-cards').innerHTML = Object.entries(bt.accuracy).map(([key, acc]) => {
const fh = bt.full_hits?.[key] ?? '?';
const sh = bt.semi_hits?.[key] ?? '?';
return `<div class="bt-card"><div class="bt-name">${names[key]||key}</div>
<div class="bt-acc ${acc > bt.random_baseline ? 'above' : 'below'}">${acc}%</div>
<div style="font-size:10px;color:var(--text3);margin-top:4px">${fh} hits + ${sh} semi</div>
<div style="font-size:10px;color:var(--text3)">vs ${bt.random_baseline}% random</div></div>`;
}).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',balance:'#f472b6',combined:'#6c5ce7'};
const datasets = Object.entries(bt.rolling_accuracy).map(([key, data]) => ({
label: names[key]||key, data, borderColor: colors[key]||'#fff', backgroundColor: 'transparent',
borderWidth: key === 'combined' ? 3 : 1.5, pointRadius: 0, tension: 0.3,
}));
new Chart(ctx, {
type: 'line',
data: { labels: bt.rolling_accuracy.combined.map((_, i) => i + 1), datasets },
options: { ...CHART_DEFAULTS, scales: {
...CHART_DEFAULTS.scales,
y: { ...CHART_DEFAULTS.scales.y, title: { display: true, text: 'Accuracy %', color: '#5a5f75' } },
x: { ...CHART_DEFAULTS.scales.x, title: { display: true, text: 'Game', color: '#5a5f75' }, ticks: { ...CHART_DEFAULTS.scales.x.ticks, maxTicksLimit: 10 } }
}},
plugins: [{ id: 'bl', afterDraw(chart) {
const {ctx, chartArea, scales} = chart;
const y = scales.y.getPixelForValue(bt.random_baseline);
ctx.save(); ctx.setLineDash([5,5]); ctx.strokeStyle='#ef444480'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(chartArea.left,y); ctx.lineTo(chartArea.right,y); ctx.stroke(); ctx.restore();
}}]
});
}
}
function renderCardValues(cv) {
new Chart($('card-value-chart').getContext('2d'), {
type: 'bar',
data: { labels: cv.labels, datasets: CHAIRS.map(c => ({
label: `Chair ${c}`, data: cv.labels.map(v => cv.chairs[c]?.[v] || 0),
backgroundColor: CHAIR_COLORS[c] + '99', borderColor: CHAIR_COLORS[c], borderWidth: 1, borderRadius: 3,
}))},
options: CHART_DEFAULTS
});
}
function renderFaceCards(fc) {
$('face-cards').innerHTML = CHAIRS.map(c => {
const d = fc[c];
return `<div class="stat-card"><div class="label" style="color:${CHAIR_COLORS[c]}">Chair ${c}</div>
<div class="value" style="color:${CHAIR_COLORS[c]}">${d.pct}%</div>
<div style="font-size:11px;color:var(--text3);margin-top:4px">${d.face_cards} / ${d.total_cards} cards</div></div>`;
}).join('');
}
function renderSuits(s) {
const suitColors = {'\u2660':'#e4e6f0','\u2665':'#ef4444','\u2663':'#10b981','\u2666':'#3b82f6'};
new Chart($('suit-chart').getContext('2d'), {
type: 'bar',
data: { labels: CHAIRS.map(c => `Chair ${c}`), datasets: s.labels.map(suit => ({
label: suit, data: CHAIRS.map(c => s.chairs[c]?.[suit] || 0),
backgroundColor: (suitColors[suit]||'#8b8fa3') + '99', borderColor: suitColors[suit]||'#8b8fa3', borderWidth: 1, borderRadius: 3,
}))},
options: CHART_DEFAULTS
});
}
function renderWinningCards(wc) {
if (!wc || wc.length === 0) {
$('winning-cards-chart').parentElement.innerHTML = '<div style="color:var(--text3);text-align:center;padding:40px">No card data available</div>';
return;
}
const colors = wc.map(c => {
const suit = c.card.slice(-1);
return (suit === '\u2665' || suit === '\u2666') ? '#ef4444' : '#e4e6f0';
});
new Chart($('winning-cards-chart').getContext('2d'), {
type: 'bar',
data: { labels: wc.map(c => c.card), datasets: [{ label: 'Appearances', data: wc.map(c => c.count),
backgroundColor: colors.map(c => c + '80'), borderColor: colors, borderWidth: 1, borderRadius: 3 }]},
options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y',
plugins: { legend: { display: false } },
scales: { x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d3148' } },
y: { ticks: { color: '#e4e6f0', font: { size: 12 } }, grid: { display: false } } }
}
});
}
function render(data) {
predictionData = data;
renderPrediction(data);
renderCrowdStats(data);
renderLast20(data.last_20_predictions);
renderMarkov1(data.markov1);
renderMarkov2(data.markov2);
renderAutocorrelation(data.autocorrelation);
renderChiSquared(data.chi_squared);
renderRunsTest(data.runs_test);
renderBacktest(data.backtest);
renderCardValues(data.card_values);
renderFaceCards(data.face_cards);
renderSuits(data.suits);
renderWinningCards(data.winning_cards);
$('loading').style.display = 'none';
$('main').style.display = 'block';
}
// ── WebSocket ──
let ws, reconnectDelay = 1000;
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => { $('ws-dot').classList.add('connected'); reconnectDelay = 1000; };
ws.onclose = () => { $('ws-dot').classList.remove('connected'); setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay*1.5, 30000); };
ws.onmessage = e => { const msg = JSON.parse(e.data); handleEvent(msg.type, msg.data); };
}
function handleEvent(type, data) {
switch(type) {
case 'game_state': updateGameState(data); break;
case 'user_bet': trackBet(data); break;
case 'round_result': onRoundResult(data); break;
}
}
function updateGameState(s) {
$('live-bar').style.display = 'flex';
$('game-no').textContent = '#' + s.game_no;
const phase = $('phase');
phase.textContent = s.status_name;
phase.className = 'phase phase-' + s.status_name;
const timer = $('timer');
timer.textContent = (s.status === 1 && s.remaining_s > 0) ? Math.ceil(s.remaining_s) + 's' : '';
if (s.game_no !== liveGameNo) {
liveGameNo = s.game_no;
liveBets = {A: 0, B: 0, C: 0};
roundBettors = {};
setTimeout(() => { $('result-flash').classList.remove('show'); }, 5000);
}
const a = s.bets?.A || 0, b = s.bets?.B || 0, c = s.bets?.C || 0;
liveBets = {A: a, B: b, C: c};
$('live-bet-a').textContent = fmt(a);
$('live-bet-b').textContent = fmt(b);
$('live-bet-c').textContent = fmt(c);
$('live-pot').textContent = fmt(s.total_pot || (a + b + c));
updatePredCardBets();
renderPublicTrend();
if (predictionData) renderBetAdvisor(predictionData, a + b + c);
}
function trackBet(bet) {
const uid = bet.user_id;
if (!roundBettors[uid]) {
roundBettors[uid] = { user_id: uid, nick_name: bet.nick_name, chairs: {A:0, B:0, C:0}, total: 0 };
}
const rb = roundBettors[uid];
if (bet.nick_name && bet.nick_name !== String(uid)) rb.nick_name = bet.nick_name;
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() {
document.querySelectorAll('.pred-card').forEach((card, i) => {
const c = CHAIRS[i];
const betEl = card.querySelector('.bet-val');
if (betEl) betEl.textContent = fmt(liveBets[c] || 0);
});
}
function getWhaleTopChair() {
const bettors = Object.values(roundBettors);
if (bettors.length < 2) return null;
const whales = bettors.sort((a, b) => b.total - a.total).slice(0, 5);
const ct = {A:0, B:0, C:0};
for (const w of whales) for (const c of CHAIRS) ct[c] += w.chairs[c] || 0;
const total = ct.A + ct.B + ct.C;
if (total === 0) return null;
return CHAIRS.reduce((a, b) => ct[a] >= ct[b] ? a : b);
}
function getPublicTopChair() {
const total = liveBets.A + liveBets.B + liveBets.C;
if (total === 0) return null;
return CHAIRS.reduce((a, b) => liveBets[a] >= liveBets[b] ? a : b);
}
function onRoundResult(data) {
const winner = data.winner_name || CHAIR_MAP[data.winner] || '?';
const flash = $('result-flash');
// Check whale / public picks vs winner
const whalePick = getWhaleTopChair();
const pubPick = getPublicTopChair();
let extra = [];
if (whalePick) extra.push(whalePick === winner ? `Whales picked ${whalePick} \u2714` : `Whales picked ${whalePick} \u2718`);
if (pubPick) extra.push(pubPick === winner ? `Public picked ${pubPick} \u2714` : `Public picked ${pubPick} \u2718`);
if (currentPrediction) {
const hit = currentPrediction === winner;
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 <b>${currentPrediction}</b> \u2014 <b>${winner}</b> won. ${label}` +
(extra.length ? ` &nbsp;\u00b7&nbsp; ${extra.join(' &nbsp;\u00b7&nbsp; ')}` : '');
}
historyCache = null; // invalidate so modal fetches fresh data
setTimeout(refreshPredictions, 2000);
}
function refreshPredictions() {
fetch('/api/predictions').then(r => r.json()).then(data => {
predictionData = data;
renderPrediction(data);
renderCrowdStats(data);
renderLast20(data.last_20_predictions);
}).catch(() => {});
}
// ── Init ──
fetch('/api/predictions').then(r => r.json()).then(render)
.catch(err => { $('loading').textContent = 'Failed to load: ' + err.message; });
connect();
</script>
</body>
</html>