add pattern analysis feature with web dashboard and CLI

New /patterns page with 9 analyses: chair win bias, bet rank
correlations, hand type distributions, pot size buckets, streaks,
hourly patterns, and recent-vs-overall comparison. Also adds a
standalone analyze.py CLI script for terminal output.
This commit is contained in:
2026-02-25 22:45:43 +05:00
parent e65b6b2cfb
commit 2b8e3dd456
6 changed files with 824 additions and 1 deletions

443
static/patterns.html Normal file
View File

@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Teen Patti Pattern Analysis</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, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.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; }
.nav-link {
font-size: 12px; color: var(--accent); text-decoration: none;
font-weight: 600; transition: color 0.2s;
}
.nav-link:hover { color: #a78bfa; }
.content { padding: 16px 20px; max-width: 1200px; margin: 0 auto; }
.loading {
text-align: center; padding: 60px; color: var(--text2); font-size: 14px;
}
.section {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 14px; margin-bottom: 16px;
}
.section-title {
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
color: var(--text2); margin-bottom: 10px; font-weight: 700;
}
.section-desc {
font-size: 11px; color: var(--text3); margin-bottom: 12px;
}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
/* Tables */
.ptable { width: 100%; border-collapse: collapse; font-size: 12px; }
.ptable th {
text-align: left; padding: 6px 8px;
border-bottom: 2px solid var(--border);
color: var(--text3); font-size: 10px;
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700;
}
.ptable td { padding: 5px 8px; border-bottom: 1px solid #2d314830; }
.ptable tr:hover { background: var(--surface2); }
.ptable .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
.ptable .pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--text2); }
.positive { color: var(--green); }
.negative { color: var(--red); }
.chair-a { color: var(--chair-a); font-weight: 700; }
.chair-b { color: var(--chair-b); font-weight: 700; }
.chair-c { color: var(--chair-c); font-weight: 700; }
/* H-bars */
.hbar-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; }
.hbar-label { font-weight: 700; min-width: 80px; font-size: 12px; }
.hbar-bg { flex: 1; height: 22px; border-radius: 4px; background: var(--surface3); overflow: hidden; }
.hbar-fill { height: 100%; border-radius: 4px; transition: width 0.4s ease; min-width: 2px; }
.hbar-value { font-weight: 700; min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; font-size: 12px; }
.hbar-pct { font-size: 11px; color: var(--text3); min-width: 45px; text-align: right; }
/* Chart */
.chart-container { position: relative; height: 250px; }
/* Streak cards */
.streak-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.streak-card {
background: var(--surface2); border-radius: 8px; padding: 12px; text-align: center;
border: 1px solid var(--border);
}
.streak-card-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text3); font-weight: 700; }
.streak-card-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
.streak-card-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
/* Comparison */
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.compare-panel {
background: var(--surface2); border-radius: 8px; padding: 12px;
border: 1px solid var(--border);
}
.compare-title {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px;
color: var(--text2); font-weight: 700; margin-bottom: 8px;
}
@media (max-width: 768px) {
.two-col, .three-col { grid-template-columns: 1fr; }
.streak-cards { grid-template-columns: 1fr; }
.compare-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>Pattern Analysis</h1>
<div class="nav-links">
<a href="/" class="nav-link">Live Dashboard &rarr;</a>
<a href="/analytics" class="nav-link">Analytics &rarr;</a>
</div>
</div>
<div class="content">
<div class="loading" id="loading">Loading pattern analysis...</div>
<div id="main" style="display:none">
<!-- 1. Chair Win Bias -->
<div class="two-col">
<div class="section">
<div class="section-title">Chair Win Bias</div>
<div class="section-desc">Win % per chair vs expected 33.3%. Sample: <span id="bias-n">0</span> games.</div>
<div id="chair-bias-bars"></div>
</div>
<div class="section">
<div class="section-title">Chair Win Distribution</div>
<div class="chart-container" style="height:200px">
<canvas id="chair-pie"></canvas>
</div>
</div>
</div>
<!-- 2 & 3. Bet Rank Analysis -->
<div class="two-col">
<div class="section">
<div class="section-title">Winner Bet Rank</div>
<div class="section-desc">How often does the winning chair have the highest, mid, or lowest bet?</div>
<div id="bet-rank-bars"></div>
</div>
<div class="section">
<div class="section-title">Per-Chair: Highest Bet Win Rate</div>
<div class="section-desc">When chair X has the highest bet, how often does X win?</div>
<table class="ptable">
<thead><tr><th>Chair</th><th style="text-align:right">Times Highest</th><th style="text-align:right">Wins</th><th style="text-align:right">Win %</th></tr></thead>
<tbody id="pcr-body"></tbody>
</table>
</div>
</div>
<!-- 4 & 5. Hand Type Distribution -->
<div class="two-col">
<div class="section">
<div class="section-title">Hand Type Distribution by Chair</div>
<div class="section-desc">Are better hands dealt to certain chairs more often?</div>
<div class="chart-container">
<canvas id="hand-type-chart"></canvas>
</div>
</div>
<div class="section">
<div class="section-title">Hand Type Win Rates</div>
<div class="section-desc">Which hand types win most often?</div>
<div id="hand-type-wins-bars"></div>
</div>
</div>
<!-- 6. Pot Size Buckets -->
<div class="section">
<div class="section-title">Win Rates by Pot Size</div>
<div class="section-desc">Does the pot size affect which chair wins?</div>
<table class="ptable">
<thead>
<tr><th>Bucket</th><th>Range</th><th style="text-align:right">Games</th>
<th style="text-align:right">A %</th><th style="text-align:right">B %</th><th style="text-align:right">C %</th></tr>
</thead>
<tbody id="pot-body"></tbody>
</table>
</div>
<!-- 7. Streak Analysis -->
<div class="section">
<div class="section-title">Streak Analysis</div>
<div class="streak-cards" id="streak-cards"></div>
</div>
<!-- 8. Hourly Patterns -->
<div class="section">
<div class="section-title">Hourly Win Patterns</div>
<div class="section-desc">Win rates by hour of day (server time).</div>
<div class="chart-container">
<canvas id="hourly-chart"></canvas>
</div>
</div>
<!-- 9. Recent vs Overall -->
<div class="section">
<div class="section-title">Recent (Last 100) vs All-Time</div>
<div class="section-desc">Spot shifts in chair dominance.</div>
<div class="compare-grid">
<div class="compare-panel">
<div class="compare-title">All-Time</div>
<div id="compare-all"></div>
</div>
<div class="compare-panel">
<div class="compare-title">Last 100 Games</div>
<div id="compare-recent"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
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 => n == null ? '0' : Number(n).toLocaleString();
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
const HAND_ORDER = ['Trail', 'Straight Flush', 'Straight', 'Flush', 'Pair', 'High Card'];
function renderHBar(container, items, colorFn) {
const total = items.reduce((s, i) => s + i.value, 0) || 1;
const max = Math.max(...items.map(i => i.value)) || 1;
container.innerHTML = items.map(item => {
const pct = (item.value / max * 100).toFixed(0);
const totalPct = (item.value / total * 100).toFixed(1);
const color = colorFn(item.label);
return `<div class="hbar-row">
<span class="hbar-label" style="color:${color}">${item.label}</span>
<div class="hbar-bg"><div class="hbar-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="hbar-value">${fmtFull(item.value)}</span>
<span class="hbar-pct">${totalPct}%</span>
</div>`;
}).join('');
}
function renderComparePanel(el, dist, total) {
const items = ['A','B','C'].map(ch => ({label: ch, value: dist[ch] || 0}));
renderHBar(el, items, l => CHAIR_COLORS[l]);
const note = document.createElement('div');
note.style.cssText = 'font-size:10px;color:var(--text3);margin-top:6px';
note.textContent = `${total} games`;
el.appendChild(note);
}
function render(data) {
// 1. Chair win bias
$('bias-n').textContent = fmtFull(data.chair_bias.total_games);
const biasItems = ['A','B','C'].map(ch => ({
label: `Chair ${ch}`, value: data.chair_bias[ch].wins,
}));
renderHBar($('chair-bias-bars'), biasItems, l => CHAIR_COLORS[l.replace('Chair ', '')]);
// Chair pie
new Chart($('chair-pie'), {
type: 'doughnut',
data: {
labels: ['A','B','C'],
datasets: [{
data: ['A','B','C'].map(ch => data.chair_bias[ch].wins),
backgroundColor: ['#3b82f6','#ec4899','#f59e0b'],
borderWidth: 0,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8b8fa3', font: { size: 11 } } },
tooltip: {
callbacks: {
label: ctx => {
const v = ctx.raw;
const t = data.chair_bias.total_games || 1;
return ` ${ctx.label}: ${v} (${(v/t*100).toFixed(1)}%)`;
}
}
}
}
}
});
// 2. Bet rank
const br = data.bet_rank;
renderHBar($('bet-rank-bars'), [
{label: 'High Bet', value: br.high},
{label: 'Mid Bet', value: br.mid},
{label: 'Low Bet', value: br.low},
], l => l.startsWith('High') ? '#f87171' : l.startsWith('Mid') ? '#fbbf24' : '#34d399');
// 3. Per-chair rank table
const pcrBody = $('pcr-body');
pcrBody.innerHTML = ['A','B','C'].map(ch => {
const d = data.per_chair_rank[ch] || {};
return `<tr>
<td class="chair-${ch.toLowerCase()}">${ch}</td>
<td class="num">${fmtFull(d.has_highest || 0)}</td>
<td class="num">${fmtFull(d.wins || 0)}</td>
<td class="num">${(d.win_pct || 0).toFixed(1)}%</td>
</tr>`;
}).join('');
// 4. Hand type distribution by chair (grouped bar chart)
const htByChair = data.hand_types_by_chair;
const htLabels = HAND_ORDER.filter(t =>
(htByChair.A[t] || 0) + (htByChair.B[t] || 0) + (htByChair.C[t] || 0) > 0
);
new Chart($('hand-type-chart'), {
type: 'bar',
data: {
labels: htLabels,
datasets: ['A','B','C'].map(ch => ({
label: ch,
data: htLabels.map(t => htByChair[ch][t] || 0),
backgroundColor: CHAIR_COLORS[ch] + '80',
borderColor: CHAIR_COLORS[ch],
borderWidth: 1,
})),
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } } },
scales: {
x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314820' } },
y: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314830' } },
}
}
});
// 5. Hand type wins
const htw = data.hand_type_wins;
const htwItems = HAND_ORDER.filter(t => htw[t]).map(t => ({label: t, value: htw[t]}));
renderHBar($('hand-type-wins-bars'), htwItems, () => '#6c5ce7');
// 6. Pot size buckets
const pb = data.pot_buckets;
const ranges = pb._ranges || {};
const bucketOrder = ['small','medium','large','whale'];
$('pot-body').innerHTML = bucketOrder.map(b => {
const d = pb[b];
if (!d) return '';
const t = d.total || 1;
return `<tr>
<td style="font-weight:700;text-transform:capitalize">${b}</td>
<td style="color:var(--text3)">${ranges[b] || ''}</td>
<td class="num">${fmtFull(d.total)}</td>
<td class="num" style="color:var(--chair-a)">${(d.A/t*100).toFixed(1)}%</td>
<td class="num" style="color:var(--chair-b)">${(d.B/t*100).toFixed(1)}%</td>
<td class="num" style="color:var(--chair-c)">${(d.C/t*100).toFixed(1)}%</td>
</tr>`;
}).join('');
// 7. Streaks
const streaks = data.streaks;
$('streak-cards').innerHTML = ['A','B','C'].map(ch => {
const s = streaks[ch];
return `<div class="streak-card">
<div class="streak-card-label" style="color:${CHAIR_COLORS[ch]}">Chair ${ch}</div>
<div class="streak-card-value" style="color:${CHAIR_COLORS[ch]}">${s.max_streak}</div>
<div class="streak-card-sub">Max Streak</div>
<div style="font-size:18px;font-weight:700;margin-top:8px;color:${s.current_streak >= 3 ? 'var(--green)' : 'var(--text2)'}">${s.current_streak}</div>
<div class="streak-card-sub">Current Streak</div>
</div>`;
}).join('');
// 8. Hourly patterns
const hourly = data.hourly;
const hours = Object.keys(hourly).map(Number).sort((a,b) => a - b);
new Chart($('hourly-chart'), {
type: 'bar',
data: {
labels: hours.map(h => String(h).padStart(2, '0') + ':00'),
datasets: ['A','B','C'].map(ch => ({
label: ch,
data: hours.map(h => {
const d = hourly[String(h)];
return d ? (d[ch] / d.total * 100) : 0;
}),
backgroundColor: CHAIR_COLORS[ch] + '80',
borderColor: CHAIR_COLORS[ch],
borderWidth: 1,
})),
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } },
tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${ctx.raw.toFixed(1)}%` } },
},
scales: {
x: { stacked: true, ticks: { color: '#5a5f75', font: { size: 9 } }, grid: { color: '#2d314820' } },
y: {
stacked: true, max: 100,
ticks: { color: '#5a5f75', callback: v => v + '%', font: { size: 10 } },
grid: { color: '#2d314830' },
},
}
}
});
// 9. Recent vs Overall
const rva = data.recent_vs_all;
renderComparePanel($('compare-all'), rva.all.dist, rva.all.total);
renderComparePanel($('compare-recent'), rva.recent.dist, rva.recent.total);
}
// Fetch and render
fetch('/api/patterns')
.then(r => r.json())
.then(data => {
if (data.error) {
$('loading').textContent = 'Error: ' + data.error;
return;
}
$('loading').style.display = 'none';
$('main').style.display = 'block';
render(data);
})
.catch(e => {
$('loading').textContent = 'Failed to load: ' + e.message;
});
</script>
</body>
</html>