// ═══════════════════════════════════════════════════════════ // backtest.js — 機械式策略回測(純函式,吃 marketdata 的歷史點) // 策略: // buyhold 買進持有(基準) // dca 定期定額(每月投入固定金額) // sma 均線趨勢(短均 > 長均在場、否則空手) // dip 逢大跌進場(距歷史高點回落 X% 才買進後續抱) // 皆以 adjclose(還原股價)計算,初始資金 10,000。 // 輸出權益曲線與統計(總報酬、年化、最大回撤、在場比例、交易次數、勝率), // 並附「買進持有」基準供對照。本工具僅供學習,過去績效不代表未來。 // ═══════════════════════════════════════════════════════════ const CAPITAL = 10000; export const STRATEGIES = { buyhold: { label: '買進持有', params: [] }, dca: { label: '定期定額(每月)', params: [{ key: 'monthly', label: '每月投入', def: 1000, min: 1 }] }, sma: { label: '均線趨勢(短>長在場)', params: [ { key: 'short', label: '短均線(日)', def: 50, min: 2 }, { key: 'long', label: '長均線(日)', def: 200, min: 3 }, ] }, dip: { label: '逢大跌進場(回落%後買進)', params: [ { key: 'drop', label: '距高點回落%', def: 15, min: 1 }, ] }, }; function sma(vals, i, n) { if (i + 1 < n) return null; let s = 0; for (let k = i - n + 1; k <= i; k++) s += vals[k]; return s / n; } function yearsBetween(a, b) { return Math.max((new Date(b) - new Date(a)) / (365.25 * 86400000), 1 / 365.25); } function maxDrawdown(curve) { let peak = -Infinity, mdd = 0; for (const p of curve) { if (p.val > peak) peak = p.val; const dd = (p.val - peak) / peak; if (dd < mdd) mdd = dd; } return -mdd * 100; // 正的百分比 } function statsFrom(curve, { trades = 1, roundTrips = [], inDays = null } = {}) { const first = curve[0].val, last = curve[curve.length - 1].val; const yrs = yearsBetween(curve[0].date, curve[curve.length - 1].date); const totalReturn = (last / first - 1) * 100; const cagr = (Math.pow(last / first, 1 / yrs) - 1) * 100; const wins = roundTrips.filter(r => r > 0).length; return { finalValue: last, totalReturn, cagr, maxDrawdown: maxDrawdown(curve), trades, winRate: roundTrips.length ? (wins / roundTrips.length) * 100 : null, exposure: inDays != null ? (inDays / curve.length) * 100 : 100, }; } // 買進持有:期初一次投入 CAPITAL function buyHold(points) { const a0 = points[0].adjclose; return points.map(p => ({ date: p.date, val: CAPITAL * (p.adjclose / a0) })); } // 定期定額:每月第一個交易日投入固定金額;基準=同總額在期初一次買進 function runDca(points, monthly) { let shares = 0, invested = 0, lastMonth = ''; const equity = []; for (const p of points) { const m = p.date.slice(0, 7); if (m !== lastMonth) { shares += monthly / p.adjclose; invested += monthly; lastMonth = m; } equity.push({ date: p.date, val: shares * p.adjclose, invested }); } // 正規化成「以總投入為基底」:把曲線換成相對總投入的價值,期初基底=首次投入 const totalInvested = invested; const a0 = points[0].adjclose; const benchmark = points.map(p => ({ date: p.date, val: totalInvested * (p.adjclose / a0) })); // DCA 報酬以總投入為分母 const last = equity[equity.length - 1].val; const yrs = yearsBetween(points[0].date, points[points.length - 1].date); const stats = { finalValue: last, totalReturn: (last / totalInvested - 1) * 100, cagr: (Math.pow(Math.max(last / totalInvested, 0.0001), 1 / yrs) - 1) * 100, maxDrawdown: maxDrawdown(equity.map(e => ({ val: e.val, date: e.date }))), trades: equity.filter((e, i) => i === 0 || e.invested !== equity[i - 1].invested).length, winRate: null, exposure: 100, invested: totalInvested, }; return { equity: equity.map(e => ({ date: e.date, val: e.val })), benchmark, stats, benchStats: statsFrom(benchmark), note: `共投入 ${stats.trades} 期、合計 ${Math.round(totalInvested).toLocaleString()} 元;基準線為「同總額在期初一次買進」。`, }; } // 在場/空手型策略的共用模擬器:signal[i] = 是否該在場 function simulateSignal(points, signal) { let cash = CAPITAL, shares = 0, holding = false, buyPrice = 0, inDays = 0, trades = 0; const roundTrips = []; const equity = []; for (let i = 0; i < points.length; i++) { const px = points[i].adjclose; const want = signal[i]; if (want && !holding) { shares = cash / px; cash = 0; holding = true; buyPrice = px; trades++; } else if (!want && holding) { cash = shares * px; shares = 0; holding = false; roundTrips.push((px / buyPrice - 1)); } if (holding) inDays++; equity.push({ date: points[i].date, val: holding ? shares * px : cash }); } return { equity, trades, roundTrips, inDays }; } function runSma(points, short, long) { short = Math.max(2, Math.round(short)); long = Math.max(short + 1, Math.round(long)); const vals = points.map(p => p.adjclose); const signal = points.map((_, i) => { const s = sma(vals, i, short), l = sma(vals, i, long); return (s != null && l != null) ? s > l : false; }); const sim = simulateSignal(points, signal); return { equity: sim.equity, benchmark: buyHold(points), stats: statsFrom(sim.equity, { trades: sim.trades, roundTrips: sim.roundTrips, inDays: sim.inDays }), benchStats: statsFrom(buyHold(points)), note: `${short}/${long} 日均線:短均上穿長均才在場,否則空手。`, }; } function runDip(points, dropPct) { const drop = Math.max(1, dropPct) / 100; let peak = -Infinity, entered = false; const signal = points.map(p => { if (p.adjclose > peak) peak = p.adjclose; if (!entered && (p.adjclose / peak - 1) <= -drop) entered = true; // 一旦回落達標即進場,之後續抱 return entered; }); const sim = simulateSignal(points, signal); return { equity: sim.equity, benchmark: buyHold(points), stats: statsFrom(sim.equity, { trades: sim.trades, roundTrips: sim.roundTrips, inDays: sim.inDays }), benchStats: statsFrom(buyHold(points)), note: `先空手等待,距歷史高點回落達 ${dropPct}% 才一次買進並續抱到期末(對照「直接買進持有」)。`, }; } // 主入口:points 需含 adjclose;params 為策略參數 export function runBacktest(points, { strategy = 'buyhold', monthly, short, long, drop } = {}) { if (!Array.isArray(points) || points.length < 3) throw new Error('歷史資料不足以回測'); const meta = STRATEGIES[strategy] || STRATEGIES.buyhold; let out; if (strategy === 'dca') out = runDca(points, monthly > 0 ? monthly : 1000); else if (strategy === 'sma') out = runSma(points, short || 50, long || 200); else if (strategy === 'dip') out = runDip(points, drop || 15); else { const eq = buyHold(points); out = { equity: eq, benchmark: null, stats: statsFrom(eq), benchStats: null, note: '期初一次投入並持有到期末。' }; } return { strategy, strategyLabel: meta.label, from: points[0].date, to: points[points.length - 1].date, capital: CAPITAL, ...out, }; }