/** * 今彩539 多模型比較回測 (Model Comparison Backtest) * * 同時測試 7 種不同預測策略,找出「至少命中 1 個」最高的模型。 * 測試範圍:最後 1000 期 */ const fs = require('fs'); const raw = JSON.parse(fs.readFileSync('lottery_539_data.json', 'utf8')); const draws = raw.draws; const TOTAL = draws.length; const BACKTEST_N = 1000; const START_IDX = TOTAL - BACKTEST_N; // ─── Helpers ────────────────────────────────────────────────────────────────── function createFreq() { const f = {}; for (let i = 1; i <= 39; i++) f[pad(i)] = 0; return f; } function pad(n) { return String(parseInt(n)).padStart(2, '0'); } function addToFreq(freq, draw) { for (const n of draw.numbers) freq[pad(n)]++; } // Gap: how many draws since last seen (per number) function getGaps(draws, upToIdx) { const gaps = {}; for (let i = 1; i <= 39; i++) gaps[pad(i)] = upToIdx; // default = never seen for (let i = upToIdx - 1; i >= Math.max(0, upToIdx - 500); i--) { for (const n of draws[i].numbers) { const k = pad(n); if (gaps[k] === upToIdx) gaps[k] = upToIdx - 1 - i; } } return gaps; } // Pair co-occurrence: which pairs show up together most function getPairFreq(draws, upToIdx) { const pairs = {}; const window = Math.min(upToIdx, 500); for (let i = upToIdx - window; i < upToIdx; i++) { const nums = draws[i].numbers.map(pad); for (let a = 0; a < nums.length; a++) { for (let b = a + 1; b < nums.length; b++) { const key = nums[a] + '-' + nums[b]; pairs[key] = (pairs[key] || 0) + 1; } } } return pairs; } // Score each number based on pair affinity to seed numbers function pairScore(num, seeds, pairFreq) { let score = 0; for (const s of seeds) { const a = [pad(num), s].sort().join('-'); score += pairFreq[a] || 0; } return score; } // Recent N-draw frequency function recentFreq(draws, upToIdx, n = 30) { const f = createFreq(); for (let i = Math.max(0, upToIdx - n); i < upToIdx; i++) { for (const n of draws[i].numbers) f[pad(n)]++; } return f; } // ─── Model Definitions ──────────────────────────────────────────────────────── // Compute scores and pick top 5 function topN(scores, n = 5) { return Object.entries(scores) .sort((a, b) => b[1] - a[1]) .slice(0, n) .map(([k]) => k) .sort((a, b) => parseInt(a) - parseInt(b)); } // SPREAD: pick highest-scored number from each of 5 buckets // Bucket ranges: [1-8], [9-15], [16-22], [23-30], [31-39] const 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 spreadPick(scores) { return BUCKETS.map(bucket => { return bucket .map(n => [pad(n), scores[pad(n)] || 0]) .sort((a, b) => b[1] - a[1])[0][0]; }).sort((a, b) => parseInt(a) - parseInt(b)); } // All models: fn(freq, gaps, draws, upToIdx) => predicted [5 numbers] const MODELS = { 'M1_Current': (freq, gaps, draws, idx) => { // Original: 40% hot + 30% overdue + 20% cold + 10% recent const total = idx * 5 || 1; const maxFreq = Math.max(...Object.values(freq)) || 1; const maxGap = Math.max(...Object.values(gaps)) || 1; const rf = recentFreq(draws, idx, 30); const rTotal = Math.min(idx, 30) * 5 || 1; const s = {}; for (let i = 1; i <= 39; i++) { const k = pad(i); s[k] = (freq[k] / total) * 40 + (gaps[k] / maxGap) * 30 + ((maxFreq - freq[k]) / maxFreq) * 20 + (rf[k] / rTotal) * 10; } return topN(s); }, 'M2_PureOverdue': (freq, gaps) => { // Pure gap — pick numbers absent longest return topN(gaps); }, 'M3_SpreadOverdue': (freq, gaps) => { // Spread across 5 buckets, pick most overdue in each return spreadPick(gaps); }, 'M4_SpreadCold': (freq, gaps, draws, idx) => { // Spread across 5 buckets, pick least frequent in each const s = {}; const maxF = Math.max(...Object.values(freq)) || 1; for (let i = 1; i <= 39; i++) { const k = pad(i); s[k] = maxF - freq[k]; // invert: cold = high score } return spreadPick(s); }, 'M5_SpreadHybrid': (freq, gaps, draws, idx) => { // SPREAD + weighted hybrid (overdue 50% + cold 30% + recent 20%) // Pick best from each bucket const total = idx * 5 || 1; const maxF = Math.max(...Object.values(freq)) || 1; const maxG = Math.max(...Object.values(gaps)) || 1; const rf = recentFreq(draws, idx, 50); const rTotal = Math.min(idx, 50) * 5 || 1; const s = {}; for (let i = 1; i <= 39; i++) { const k = pad(i); s[k] = (gaps[k] / maxG) * 50 + ((maxF - freq[k]) / maxF) * 30 + (rf[k] / rTotal) * 20; } return spreadPick(s); }, 'M6_RecentAntiTrend': (freq, gaps, draws, idx) => { // Anti-recent: avoid numbers that appeared in last 10 draws, favor overdue const rShort = recentFreq(draws, idx, 10); const maxG = Math.max(...Object.values(gaps)) || 1; const s = {}; for (let i = 1; i <= 39; i++) { const k = pad(i); // penalize recently drawn, reward absence s[k] = (gaps[k] / maxG) * 70 + (rShort[k] === 0 ? 30 : 0); } return spreadPick(s); }, 'M7_TopSpread': (freq, gaps, draws, idx) => { // Score all numbers with hybrid, then enforce spread constraint const total = idx * 5 || 1; const maxF = Math.max(...Object.values(freq)) || 1; const maxG = Math.max(...Object.values(gaps)) || 1; const rf50 = recentFreq(draws, idx, 50); const rTotal50 = Math.min(idx, 50) * 5 || 1; const rf15 = recentFreq(draws, idx, 15); const rTotal15 = Math.min(idx, 15) * 5 || 1; const s = {}; for (let i = 1; i <= 39; i++) { const k = pad(i); s[k] = (gaps[k] / maxG) * 40 // overdue + ((maxF - freq[k]) / maxF) * 25 // cold + (freq[k] / total) * 10 // some hot + (rf50[k] / rTotal50) * 15 // mid-term trend + (rf15[k] === 0 ? 10 : 0); // bonus if not seen in 15 draws } // First pick top-1 from each bucket (spread), then fill remaining from overall top const used = new Set(); const picks = []; // One per bucket for (const bucket of BUCKETS) { const best = bucket .map(n => [pad(n), s[pad(n)] || 0]) .sort((a, b) => b[1] - a[1])[0][0]; picks.push(best); used.add(best); } return picks.sort((a, b) => parseInt(a) - parseInt(b)); }, }; // ─── Run Comparison ─────────────────────────────────────────────────────────── console.log(`🔬 Multi-Model Comparison Backtest`); console.log(` Testing ${Object.keys(MODELS).length} models on draws ${START_IDX + 1}~${TOTAL}\n`); // Pre-build initial freq + gaps const initFreq = createFreq(); for (let i = 0; i < START_IDX; i++) addToFreq(initFreq, draws[i]); // For each model, track hit counters const modelFreqs = {}; const modelHits = {}; const modelCounts = {}; for (const name of Object.keys(MODELS)) { modelFreqs[name] = { ...initFreq }; modelHits[name] = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; modelCounts[name] = 0; } const startTime = Date.now(); for (let i = START_IDX; i < TOTAL; i++) { const actual = draws[i].numbers.map(pad); const actualSet = new Set(actual); const idx = i; // upToIdx (exclusive) // Compute shared expensive stats once per period const gaps = getGaps(draws, idx); for (const [name, modelFn] of Object.entries(MODELS)) { const freq = modelFreqs[name]; const predicted = modelFn(freq, gaps, draws, idx); const hits = predicted.filter(p => actualSet.has(p)).length; modelHits[name][hits]++; modelCounts[name]++; addToFreq(freq, draws[i]); } const done = i - START_IDX + 1; if (done % 100 === 0 || done === BACKTEST_N) { process.stdout.write(`\r Progress: ${done}/${BACKTEST_N} (${((done/BACKTEST_N)*100).toFixed(0)}%) — ${((Date.now()-startTime)/1000).toFixed(1)}s`); } } console.log('\n\n📊 Results:\n'); console.log('Model'.padEnd(22) + '0/5'.padStart(7) + '1/5'.padStart(7) + '2/5'.padStart(7) + '3+/5'.padStart(8) + ' ≥1 Hit%'.padStart(10) + ' ≥2 Hit%'.padStart(10) + ' Avg Hits'.padStart(12)); console.log('-'.repeat(85)); const summary = []; for (const [name, hits] of Object.entries(modelHits)) { const n = modelCounts[name]; const at1 = n - hits[0]; const at2 = hits[2] + hits[3] + hits[4] + hits[5]; const at3 = hits[3] + hits[4] + hits[5]; const avgHits = Object.entries(hits).reduce((s, [k, v]) => s + parseInt(k) * v, 0) / n; summary.push({ name, hits, n, at1, at2, at3, at1Pct: at1 / n, at2Pct: at2 / n, avgHits }); console.log( name.padEnd(22) + `${((hits[0]/n)*100).toFixed(1)}%`.padStart(7) + `${((hits[1]/n)*100).toFixed(1)}%`.padStart(7) + `${((hits[2]/n)*100).toFixed(1)}%`.padStart(7) + `${((at3/n)*100).toFixed(1)}%`.padStart(8) + `${(at1/n*100).toFixed(2)}%`.padStart(10) + `${(at2/n*100).toFixed(2)}%`.padStart(10) + `${avgHits.toFixed(3)}`.padStart(12) ); } // Sort by at-least-1 pct summary.sort((a, b) => b.at1Pct - a.at1Pct); const winner = summary[0]; console.log(`\n🏆 Best model for ≥1 hit: ${winner.name} → ${(winner.at1Pct * 100).toFixed(2)}%`); console.log(` (vs current M1: ${(summary.find(s=>s.name==='M1_Current').at1Pct*100).toFixed(2)}%)`); console.log(` Random baseline: ~51.64%`); // Save results fs.writeFileSync('model_comparison.json', JSON.stringify({ summary, winner: winner.name, generatedAt: new Date().toISOString() }, null, 2)); console.log('\n✅ model_comparison.json saved');