finance-dashboard/lib/backtest.js

165 lines
7.3 KiB
JavaScript
Raw Permalink Normal View History

2026-06-03 09:21:58 +00:00
// ═══════════════════════════════════════════════════════════
// 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 需含 adjcloseparams 為策略參數
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,
};
}