Spaces:
Sleeping
Sleeping
| /** | |
| * 今彩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'); | |