finance-dashboard/lib/backtest.js

165 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══════════════════════════════════════════════════════════
// 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,
};
}