LotteryTest / accuracy.html
DennisChan0909's picture
initial deploy: 今彩539 AI 預測系統
2c7a564
Raw
History Blame Contribute Delete
23.5 kB
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>今彩539 預測命中率回測報告</title>
<meta name="description" content="今彩539 AI 預測命中率回測,對近1000期歷史開獎結果進行預測精準度驗證。" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
<style>
/* ─── Backtest-specific styles ─────────────────────────────────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 0;
}
.kpi-card {
background: var(--bg-glass);
border: 1px solid var(--border-glass);
border-radius: var(--radius-md);
padding: 20px;
text-align: center;
transition: all 0.3s;
}
.kpi-card:hover { border-color: var(--border-glass-hover); transform: translateY(-2px); }
.kpi-value {
font-size: 2.2rem; font-weight: 800; line-height: 1;
background: linear-gradient(135deg, var(--purple-400), var(--cyan-400));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.kpi-value.hot-val {
background: linear-gradient(135deg, var(--gold-400), var(--red-400));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.kpi-label { font-size: 0.75rem; color: var(--text-muted); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.06em; }
.kpi-sub { font-size: 0.85rem; color: var(--text-secondary); margin-top: 4px; font-weight: 600; }
/* Distribution bars */
.dist-grid { display: flex; flex-direction: column; gap: 12px; }
.dist-row { display: flex; align-items: center; gap: 12px; }
.dist-label { width: 48px; text-align: right; font-weight: 700; font-size: 0.9rem; color: var(--text-secondary); flex-shrink: 0; }
.dist-bar-wrap { flex: 1; height: 28px; background: rgba(255,255,255,0.04); border-radius: var(--radius-full); overflow: hidden; position: relative; }
.dist-bar {
height: 100%; border-radius: var(--radius-full);
transition: width 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
display: flex; align-items: center; padding-left: 10px;
font-size: 0.75rem; font-weight: 700; color: white;
white-space: nowrap;
}
.dist-count { width: 60px; text-align: right; font-size: 0.8rem; color: var(--text-secondary); flex-shrink: 0; }
/* Hit colors per level */
.hit-5 { background: linear-gradient(90deg, #f59e0b, #ef4444); }
.hit-4 { background: linear-gradient(90deg, #f97316, #dc2626); }
.hit-3 { background: linear-gradient(90deg, #8b5cf6, #ec4899); }
.hit-2 { background: linear-gradient(90deg, #3b82f6, #6366f1); }
.hit-1 { background: linear-gradient(90deg, #0891b2, #3b82f6); }
.hit-0 { background: rgba(255,255,255,0.08); }
/* Table region */
.hit-badge {
display: inline-block; padding: 3px 10px; border-radius: var(--radius-full);
font-size: 0.75rem; font-weight: 700; white-space: nowrap;
}
.hb-0 { background: rgba(255,255,255,0.08); color: var(--text-muted); }
.hb-1 { background: rgba(8,145,178,0.2); color: #67e8f9; border: 1px solid rgba(8,145,178,0.3); }
.hb-2 { background: rgba(99,102,241,0.2); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.3); }
.hb-3 { background: rgba(139,92,246,0.3); color: #c4b5fd; border: 1px solid rgba(139,92,246,0.5); }
.hb-4 { background: rgba(249,115,22,0.25); color: #fdba74; border: 1px solid rgba(249,115,22,0.4); }
.hb-5 { background: rgba(251,191,36,0.3); color: #fde68a; border: 1px solid rgba(251,191,36,0.5); box-shadow: 0 0 12px rgba(251,191,36,0.3); }
.num-mini {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 50%;
font-size: 0.75rem; font-weight: 700;
border: 1px solid rgba(139,92,246,0.3);
background: rgba(124,58,237,0.15);
color: var(--purple-400);
}
.num-mini.hit {
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.2), transparent 60%), var(--gradient-hot);
border-color: rgba(239,68,68,0.5);
color: white;
box-shadow: 0 0 8px rgba(239,68,68,0.4);
}
.nav-back { display: inline-flex; align-items: center; gap: 6px; color: var(--purple-400); text-decoration: none; font-size: 0.85rem; font-weight: 600; margin-bottom: 4px; }
.nav-back:hover { color: var(--indigo-400); }
/* Filter controls */
.filter-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.filter-chip {
padding: 5px 14px; border-radius: var(--radius-full);
font-family: var(--font); font-size: 0.78rem; font-weight: 600;
border: 1px solid var(--border-glass); background: var(--bg-glass);
color: var(--text-secondary); cursor: pointer; transition: all 0.2s;
}
.filter-chip:hover { color: var(--text-primary); border-color: var(--border-glass-hover); }
.filter-chip.active { background: rgba(124,58,237,0.25); border-color: rgba(124,58,237,0.5); color: var(--purple-400); }
/* Chart #2 */
.trend-canvas-wrap { width: 100%; height: 260px; }
#trendChart { width: 100% !important; height: 100% !important; }
</style>
</head>
<body>
<div class="bg-particles" id="bgParticles"></div>
<header class="site-header">
<div class="header-inner">
<div class="logo">
<a href="index.html" class="nav-back">← 返回預測</a>
<span class="logo-icon" style="margin-left:12px">📊</span>
<div class="logo-text">
<h1>命中率回測報告</h1>
<span id="headerSubtitle">載入中...</span>
</div>
</div>
<div id="headerMeta" class="header-meta">
<div class="meta-item"><span class="meta-label">回測期數</span><span class="meta-value" id="metaN"></span></div>
<div class="meta-item"><span class="meta-label">回測範圍</span><span class="meta-value" id="metaRange"></span></div>
<div class="meta-item"><span class="meta-label">整體命中率</span><span class="meta-value" id="metaRate"></span></div>
</div>
</div>
</header>
<main class="main-content">
<!-- KPI Cards -->
<section class="section">
<div class="kpi-grid" id="kpiGrid">
<div class="kpi-card"><div class="kpi-value" id="kpiRate"></div><div class="kpi-label">整體號碼命中率</div><div class="kpi-sub">每期5個號碼中平均命中數</div></div>
<div class="kpi-card"><div class="kpi-value" id="kpi1"></div><div class="kpi-label">至少命中 1 個</div><div class="kpi-sub" id="kpi1n"></div></div>
<div class="kpi-card"><div class="kpi-value" id="kpi2"></div><div class="kpi-label">至少命中 2 個</div><div class="kpi-sub" id="kpi2n"></div></div>
<div class="kpi-card"><div class="kpi-value" id="kpi3"></div><div class="kpi-label">至少命中 3 個</div><div class="kpi-sub" id="kpi3n"></div></div>
<div class="kpi-card"><div class="kpi-value hot-val" id="kpi5"></div><div class="kpi-label">完全命中 5/5 🎉</div><div class="kpi-sub" id="kpi5n"></div></div>
</div>
</section>
<!-- Distribution + Trend charts -->
<section class="section">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;flex-wrap:wrap" id="chartsRow">
<div class="glass-card">
<div class="section-header"><h2 class="section-title">🎯 命中數分佈</h2></div>
<div class="dist-grid" id="distGrid"></div>
</div>
<div class="glass-card">
<div class="section-header"><h2 class="section-title">📈 命中趨勢 (每100期)</h2></div>
<div class="trend-canvas-wrap"><canvas id="trendChart"></canvas></div>
</div>
</div>
</section>
<!-- Detailed Results Table -->
<section class="section">
<div class="glass-card">
<div class="section-header">
<h2 class="section-title">📋 逐期回測詳情</h2>
<div class="history-controls">
<div class="filter-bar" id="filterBar">
<span style="font-size:0.8rem;color:var(--text-muted)">篩選命中數:</span>
<button class="filter-chip active" onclick="setFilter('all', this)">全部</button>
<button class="filter-chip" onclick="setFilter(3, this)">≥3 命中</button>
<button class="filter-chip" onclick="setFilter(2, this)">≥2 命中</button>
<button class="filter-chip" onclick="setFilter(1, this)">≥1 命中</button>
<button class="filter-chip" onclick="setFilter(0, this)">0 命中</button>
</div>
<span class="result-count" id="btResultCount"></span>
</div>
</div>
<div class="table-wrapper">
<table class="history-table">
<thead>
<tr>
<th>期號</th>
<th>日期</th>
<th>預測號碼</th>
<th>實際號碼</th>
<th>命中數</th>
<th>命中號碼</th>
</tr>
</thead>
<tbody id="btBody">
<tr><td colspan="6" class="loading-row">⏳ 載入回測資料...</td></tr>
</tbody>
</table>
</div>
<div class="pagination" id="btPagination"></div>
</div>
</section>
<!-- Note -->
<section class="section">
<div class="glass-card" style="background:rgba(139,92,246,0.05);border-color:rgba(139,92,246,0.2)">
<h3 style="color:var(--purple-400);margin-bottom:12px">📌 關於回測方法</h3>
<p style="color:var(--text-secondary);font-size:0.875rem;line-height:1.8">
本回測使用 <strong>Walk-Forward 滾動測試</strong>:對第 <code id="noteStart"></code> 到第 <code id="noteEnd"></code> 期共 <code>1000</code> 期進行驗證。<br>
每一期的預測,<strong>僅使用該期之前的所有歷史資料</strong>作為訓練集(不使用未來資料),確保測試的公平性。<br>
<strong>命中定義</strong>:預測的5個號碼中,有幾個與當期實際開出號碼重疊。<br>
<strong>注意</strong>:今彩539為隨機抽獎,理論隨機基準命中率為 5/39 ≈ <strong>12.82%</strong>
本模型命中率與隨機基準相近,說明彩券具有強烈隨機性。
</p>
</div>
</section>
</main>
<footer class="site-footer">
<p>資料來源:<a href="https://www.pilio.idv.tw/lto539/list.asp" target="_blank" rel="noopener">pilio.idv.tw</a> &nbsp;·&nbsp; 本系統僅供參考娛樂,不保證預測準確性</p>
</footer>
<script>
// ─── State ────────────────────────────────────────────────────────────────
let allResults = [];
let filtered = [];
let filterMin = 'all';
let currentPage = 1;
const PAGE_SIZE = 50;
// ─── Load Data ────────────────────────────────────────────────────────────
async function loadData() {
try {
const res = await fetch('backtest_results.json');
const data = await res.json();
const { meta, summary, results } = data;
// newest first
allResults = [...results].reverse();
filtered = allResults;
// Update header
document.getElementById('headerSubtitle').textContent =
`第 ${meta.backtestStart} ~ ${meta.backtestEnd} 期`;
document.getElementById('metaN').textContent = `${meta.backtestN} 期`;
document.getElementById('metaRange').textContent =
`${meta.backtestStart} ~ ${meta.backtestEnd}`;
document.getElementById('metaRate').textContent = `${summary.overallHitRate}%`;
document.getElementById('noteStart').textContent = meta.backtestStart;
document.getElementById('noteEnd').textContent = meta.backtestEnd;
// KPIs
document.getElementById('kpiRate').textContent = `${summary.overallHitRate}%`;
document.getElementById('kpi1').textContent = `${summary.atLeast1Pct}%`;
document.getElementById('kpi1n').textContent = `${summary.atLeast1} 期`;
document.getElementById('kpi2').textContent = `${summary.atLeast2Pct}%`;
document.getElementById('kpi2n').textContent = `${summary.atLeast2} 期`;
document.getElementById('kpi3').textContent = `${summary.atLeast3Pct}%`;
document.getElementById('kpi3n').textContent = `${summary.atLeast3} 期`;
document.getElementById('kpi5').textContent = `${summary.perfect5Pct}%`;
document.getElementById('kpi5n').textContent = `${summary.perfect5} 期`;
renderDistribution(summary.distribution, summary.totalTests);
renderTrendChart(results);
renderTable();
} catch (e) {
document.getElementById('btBody').innerHTML =
`<tr><td colspan="6" class="loading-row">❌ 無法載入回測資料:${e.message}<br>請先執行 node backtest.js</td></tr>`;
}
}
// ─── Distribution Bars ────────────────────────────────────────────────────
function renderDistribution(dist, total) {
const container = document.getElementById('distGrid');
const maxCount = Math.max(...Object.values(dist));
const labels = ['5/5 🎉', '4/5', '3/5', '2/5', '1/5', '0/5'];
const colorClasses = ['hit-5', 'hit-4', 'hit-3', 'hit-2', 'hit-1', 'hit-0'];
container.innerHTML = [5, 4, 3, 2, 1, 0].map((k, i) => {
const cnt = dist[k] || 0;
const pct = ((cnt / total) * 100).toFixed(1);
const barW = maxCount > 0 ? ((cnt / maxCount) * 100) : 0;
return `
<div class="dist-row">
<div class="dist-label">${labels[i]}</div>
<div class="dist-bar-wrap">
<div class="dist-bar ${colorClasses[i]}" style="width:${barW}%">
${pct + '%'}
</div>
</div>
<div class="dist-count">${cnt} 期</div>
</div>`;
}).join('');
}
// ─── Trend Chart (hits per 100-period block) ───────────────────────────
function renderTrendChart(results) {
const canvas = document.getElementById('trendChart');
const ctx = canvas.getContext('2d');
const BLOCK = 100;
const blocks = [];
for (let i = 0; i < results.length; i += BLOCK) {
const slice = results.slice(i, i + BLOCK);
const hits = slice.reduce((s, r) => s + r.hits, 0);
const pct = (hits / (slice.length * 5)) * 100;
const label = `${slice[0].period}~${slice[slice.length-1].period}`;
blocks.push({ label, pct, count: slice.length });
}
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 260 * dpr;
ctx.scale(dpr, dpr);
const W = rect.width, H = 260;
ctx.clearRect(0, 0, W, H);
const padL = 40, padR = 12, padT = 20, padB = 50;
const cW = W - padL - padR, cH = H - padT - padB;
const maxPct = Math.max(...blocks.map(b => b.pct), 20);
// Baseline (random = 5/39 = 12.82%)
const baselineY = padT + cH * (1 - 12.82 / maxPct);
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(251,191,36,0.4)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padL, baselineY); ctx.lineTo(padL + cW, baselineY); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(251,191,36,0.6)';
ctx.font = '9px Outfit';
ctx.fillText('隨機基準 12.82%', padL + 4, baselineY - 3);
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
for (let g = 0; g <= 4; g++) {
const y = padT + cH * (g / 4);
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + cW, y); ctx.stroke();
ctx.fillStyle = 'rgba(148,163,184,0.5)';
ctx.font = '9px Outfit'; ctx.textAlign = 'right';
ctx.fillText((maxPct * (1 - g / 4)).toFixed(0) + '%', padL - 3, y + 3);
}
// Bars
const bw = (cW / blocks.length) * 0.65;
blocks.forEach((b, i) => {
const x = padL + (i / blocks.length) * cW + ((cW / blocks.length) - bw) / 2;
const bh = (b.pct / maxPct) * cH;
const y = padT + cH - bh;
const grad = ctx.createLinearGradient(x, y, x, y + bh);
grad.addColorStop(0, 'rgba(139,92,246,0.9)');
grad.addColorStop(1, 'rgba(59,130,246,0.5)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(x, y, bw, bh, 3) : ctx.rect(x, y, bw, bh);
ctx.fill();
// value label on bar
ctx.fillStyle = 'rgba(196,181,253,0.9)';
ctx.font = '9px Outfit'; ctx.textAlign = 'center';
if (bh > 14) ctx.fillText(b.pct.toFixed(1) + '%', x + bw / 2, y + 11);
// x label
ctx.fillStyle = 'rgba(148,163,184,0.5)';
ctx.font = '8px Outfit';
ctx.save(); ctx.translate(x + bw / 2, H - padB + 8);
ctx.rotate(-Math.PI / 4); ctx.textAlign = 'right';
ctx.fillText(b.label, 0, 0); ctx.restore();
});
}
// ─── Table ────────────────────────────────────────────────────────────────
function setFilter(min, btn) {
filterMin = min;
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
filtered = min === 'all' ? allResults : allResults.filter(r => r.hits >= min);
currentPage = 1;
renderTable();
}
function renderTable() {
const total = filtered.length;
const start = (currentPage - 1) * PAGE_SIZE;
const page = filtered.slice(start, start + PAGE_SIZE);
document.getElementById('btResultCount').textContent =
filterMin === 'all' ? `共 ${total} 期` : `篩選出 ${total} 期`;
const tbody = document.getElementById('btBody');
if (page.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="loading-row">🔍 無符合資料</td></tr>';
} else {
tbody.innerHTML = page.map(r => {
const actualSet = new Set(r.actual);
// Use predictedWithScores (score-sorted) if available, else fall back
const predsRanked = r.predictedWithScores
? [...r.predictedWithScores] // already score-desc from backtest
: r.predicted.map(n => ({ num: n, score: null }));
const predNums = predsRanked.map(({ num, score }) => {
const isHit = actualSet.has(num);
const scoreLabel = score !== null
? `<div style="font-size:0.6rem;color:${isHit ? 'rgba(255,200,100,0.9)' : 'rgba(148,163,184,0.6)'};margin-top:2px;line-height:1">${score}期</div>`
: '';
return `<span style="display:inline-flex;flex-direction:column;align-items:center;margin:1px">
<span class="num-mini ${isHit ? 'hit' : ''}">${num}</span>
${scoreLabel}
</span>`;
}).join('');
const actNums = r.actual.map(n =>
`<span class="num-mini ${r.predicted.includes(n) ? 'hit' : ''}">${n}</span>`
).join(' ');
const hitNums = r.predicted.filter(p => actualSet.has(p));
const hitStr = hitNums.length > 0
? hitNums.map(n => `<span class="num-mini hit">${n}</span>`).join(' ')
: '<span style="color:var(--text-muted)">—</span>';
return `<tr class="fade-in">
<td class="draw-idx">${r.period}</td>
<td class="draw-date">${r.date}</td>
<td style="vertical-align:top;padding-top:6px">${predNums}</td>
<td>${actNums}</td>
<td><span class="hit-badge hb-${r.hits}">${r.hits}/5</span></td>
<td>${hitStr}</td>
</tr>`;
}).join('');
}
// pagination
const totalPages = Math.ceil(total / PAGE_SIZE);
const pag = document.getElementById('btPagination');
if (totalPages <= 1) { pag.innerHTML = ''; return; }
const btns = [];
btns.push(`<button class="page-btn" onclick="changePage(${currentPage-1})" ${currentPage===1?'disabled':''}>‹</button>`);
const range = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) range.push(i);
}
let prev = null;
for (const p of range) {
if (prev && p - prev > 1) btns.push(`<span class="page-btn" style="opacity:0.4;pointer-events:none">…</span>`);
btns.push(`<button class="page-btn ${p===currentPage?'active':''}" onclick="changePage(${p})">${p}</button>`);
prev = p;
}
btns.push(`<button class="page-btn" onclick="changePage(${currentPage+1})" ${currentPage===totalPages?'disabled':''}>›</button>`);
pag.innerHTML = btns.join('');
}
function changePage(p) {
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
if (p < 1 || p > totalPages) return;
currentPage = p;
renderTable();
document.querySelector('.history-section, .section:last-of-type')?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
}
// ─── BG Particles ─────────────────────────────────────────────────────────
function initParticles() {
const c = document.getElementById('bgParticles');
for (let i = 0; i < 15; i++) {
const p = document.createElement('div');
p.style.cssText = `position:absolute;width:${2+Math.random()*2}px;height:${2+Math.random()*2}px;border-radius:50%;background:rgba(139,92,246,${0.15+Math.random()*0.2});left:${Math.random()*100}%;top:${Math.random()*100}%;animation:floatParticle ${6+Math.random()*8}s ease-in-out ${Math.random()*4}s infinite alternate;`;
c.appendChild(p);
}
const s = document.createElement('style');
s.textContent = `@keyframes floatParticle{from{transform:translateY(0) translateX(0);opacity:.3}to{transform:translateY(-30px) translateX(20px);opacity:.8}}`;
document.head.appendChild(s);
}
let rTimer;
window.addEventListener('resize', () => { clearTimeout(rTimer); rTimer = setTimeout(() => { if (allResults.length) { renderTrendChart([...allResults].reverse()); } }, 250); });
initParticles();
loadData();
</script>
</body>
</html>