/** * 今彩539 預測準確率回測引擎 (Backtest Engine) * * 策略:以第 i 期之前的所有歷史資料為訓練集, * 預測第 i 期號碼,再與實際開獎號碼對比。 * 回測範圍:最後 1000 期 (第 4806 ~ 5805 期) * * 命中定義: * 預測 5 個號碼中,有幾個出現在當期實際開獎號碼內 * 5/5 = 完全命中, 4/5, 3/5, 2/5, 1/5, 0/5 */ const fs = require('fs'); // ─── Load Data ──────────────────────────────────────────────────────────────── const raw = JSON.parse(fs.readFileSync('lottery_539_data.json', 'utf8')); const draws = raw.draws; // sorted oldest → newest const TOTAL = draws.length; // 5805 const BACKTEST_N = 1000; // how many periods to backtest const START_IDX = TOTAL - BACKTEST_N; // first index to backtest (0-based) console.log(`📊 Total draws: ${TOTAL}`); console.log(`🔁 Backtesting draws ${START_IDX + 1} ~ ${TOTAL} (last ${BACKTEST_N} periods)`); console.log(` Using up to ${START_IDX} prior draws as training data per prediction\n`); // ─── M3 SpreadOverdue Model ─────────────────────────────────────────────────── const SPREAD_BUCKETS = [ [1,2,3,4,5,6,7,8], [9,10,11,12,13,14,15], [16,17,18,19,20,21,22], [23,24,25,26,27,28,29,30], [31,32,33,34,35,36,37,38,39], ]; function pad(n) { return String(parseInt(n)).padStart(2, '0'); } function getGaps(draws, upToIdx) { const gaps = {}; for (let i = 1; i <= 39; i++) gaps[pad(i)] = upToIdx; const scanFrom = Math.max(0, upToIdx - 600); for (let i = upToIdx - 1; i >= scanFrom; i--) for (const n of draws[i].numbers) if (gaps[pad(n)] === upToIdx) gaps[pad(n)] = upToIdx - 1 - i; return gaps; } // Returns [{num, score}] sorted by score DESC (most overdue first) function predictAt(draws, upToIdx) { const gaps = getGaps(draws, upToIdx); return SPREAD_BUCKETS.map(bucket => bucket.map(n => ({ num: pad(n), score: gaps[pad(n)] || 0 })) .sort((a, b) => b.score - a.score)[0] ).sort((a, b) => b.score - a.score); } // ─── Run Backtest ───────────────────────────────────────────────────────────── console.log('⚙️ Running M3 SpreadOverdue backtest...'); const results = []; const hitCounts = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; const startTime = Date.now(); let lastReported = 0; for (let i = START_IDX; i < TOTAL; i++) { const periodNum = i + 1; const actual = draws[i].numbers; const date = draws[i].date; // DecayFreq: returns [{num, score}] sorted by score ASC (coldest first = highest priority) const picksWithScore = predictAt(draws, i); // predictedWithScores: sorted by score ASC = highest-priority first for display const predictedSorted = picksWithScore; // already sorted coldest→hottest // numerically sorted plain array for backward compat const predictedNums = picksWithScore.map(p => p.num).sort((a, b) => parseInt(a) - parseInt(b)); const actualSet = new Set(actual.map(n => pad(n))); let hits = 0; for (const p of predictedNums) { if (actualSet.has(p)) hits++; } hitCounts[hits]++; results.push({ period: periodNum, date, predicted: predictedNums, predictedWithScores: predictedSorted.map(p => ({ num: p.num, score: p.score })), actual: actual.map(n => pad(n)), hits, }); const done = i - START_IDX + 1; if (done - lastReported >= 100 || done === BACKTEST_N) { const pct = ((done / BACKTEST_N) * 100).toFixed(0); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); process.stdout.write(`\r Progress: ${done}/${BACKTEST_N} (${pct}%) — ${elapsed}s elapsed`); lastReported = done; } } console.log('\n\n✅ Backtest complete!\n'); // ─── Compute Summary Stats ──────────────────────────────────────────────────── const totalTests = results.length; const totalHits = results.reduce((s, r) => s + r.hits, 0); const possibleHits = totalTests * 5; const overallRate = ((totalHits / possibleHits) * 100).toFixed(2); // Periods with at least 1 hit const atLeast1 = results.filter(r => r.hits >= 1).length; const atLeast2 = results.filter(r => r.hits >= 2).length; const atLeast3 = results.filter(r => r.hits >= 3).length; const perfect5 = results.filter(r => r.hits === 5).length; console.log(`📊 Backtest Summary (${START_IDX + 1}–${TOTAL} 期)`); console.log(` Total tested: ${totalTests}`); console.log(` Total hits: ${totalHits} / ${possibleHits}`); console.log(` Overall hit rate: ${overallRate}%`); console.log(` At least 1 hit: ${atLeast1} (${((atLeast1 / totalTests) * 100).toFixed(1)}%)`); console.log(` At least 2 hits: ${atLeast2} (${((atLeast2 / totalTests) * 100).toFixed(1)}%)`); console.log(` At least 3 hits: ${atLeast3} (${((atLeast3 / totalTests) * 100).toFixed(1)}%)`); console.log(` Perfect 5/5: ${perfect5} (${((perfect5 / totalTests) * 100).toFixed(2)}%)`); console.log(''); console.log(' Distribution:'); for (let k = 5; k >= 0; k--) { const cnt = hitCounts[k]; const pct = ((cnt / totalTests) * 100).toFixed(1); console.log(` ${k}/5 hits: ${String(cnt).padStart(4)} periods (${pct}%)`); } // ─── Save JSON ──────────────────────────────────────────────────────────────── const output = { meta: { generatedAt: new Date().toISOString(), totalDraws: TOTAL, backtestStart: START_IDX + 1, backtestEnd: TOTAL, backtestN: BACKTEST_N, }, summary: { totalTests, totalHits, possibleHits, overallHitRate: parseFloat(overallRate), atLeast1, atLeast1Pct: parseFloat(((atLeast1 / totalTests) * 100).toFixed(2)), atLeast2, atLeast2Pct: parseFloat(((atLeast2 / totalTests) * 100).toFixed(2)), atLeast3, atLeast3Pct: parseFloat(((atLeast3 / totalTests) * 100).toFixed(2)), perfect5, perfect5Pct: parseFloat(((perfect5 / totalTests) * 100).toFixed(2)), distribution: hitCounts, }, results, }; fs.writeFileSync('backtest_results.json', JSON.stringify(output, null, 2), 'utf8'); console.log('\n✅ backtest_results.json saved'); // ─── Generate Markdown Report ───────────────────────────────────────────────── console.log('📝 Generating markdown report...'); const lines = [ '# 今彩539 預測命中率回測報告', '', `> 回測期間:第 **${START_IDX + 1}** 期 ~ 第 **${TOTAL}** 期 (共 **${totalTests}** 期)`, `> 訓練資料:每期預測使用該期之前所有歷史資料`, `> 產生時間:${new Date().toLocaleString('zh-TW')}`, '', '---', '', '## 📊 總體命中率統計', '', '| 指標 | 數值 |', '|---|---|', `| 回測期數 | ${totalTests} 期 |`, `| 總命中號碼數 | ${totalHits} / ${possibleHits} |`, `| 號碼命中率 | **${overallRate}%** |`, `| 至少命中 1 個號碼 | ${atLeast1} 期 (${((atLeast1 / totalTests) * 100).toFixed(1)}%) |`, `| 至少命中 2 個號碼 | ${atLeast2} 期 (${((atLeast2 / totalTests) * 100).toFixed(1)}%) |`, `| 至少命中 3 個號碼 | ${atLeast3} 期 (${((atLeast3 / totalTests) * 100).toFixed(1)}%) |`, `| 完全命中 (5/5) | ${perfect5} 期 (${((perfect5 / totalTests) * 100).toFixed(2)}%) |`, '', '## 🎯 命中數分佈', '', '| 命中數 | 期數 | 佔比 |', '|:---:|:---:|:---:|', ]; for (let k = 5; k >= 0; k--) { const cnt = hitCounts[k]; const pct = ((cnt / totalTests) * 100).toFixed(1); const bar = '█'.repeat(Math.round(cnt / totalTests * 20)); lines.push(`| ${k}/5 | ${cnt} | ${pct}% ${bar} |`); } lines.push('', '---', '', '## 📋 逐期回測結果 (由新到舊)', ''); lines.push('| 期號 | 日期 | 預測號碼 | 實際號碼 | 命中數 | 命中號碼 |'); lines.push('|:---:|:---:|:---:|:---:|:---:|:---:|'); // Show newest first const reversed = [...results].reverse(); for (const r of reversed) { const actualSet = new Set(r.actual); const hitNums = r.predicted.filter(p => actualSet.has(p)); const hitStr = hitNums.length > 0 ? hitNums.join(', ') : '—'; const hitsLabel = r.hits === 5 ? `**${r.hits}/5** 🎉` : r.hits >= 3 ? `**${r.hits}/5** ⭐` : `${r.hits}/5`; lines.push(`| ${r.period} | ${r.date} | ${r.predicted.join(', ')} | ${r.actual.join(', ')} | ${hitsLabel} | ${hitStr} |`); } fs.writeFileSync('backtest_report.md', lines.join('\n'), 'utf8'); const kb = (fs.statSync('backtest_report.md').size / 1024).toFixed(1); console.log(`✅ backtest_report.md saved (${kb} KB)\n`);