165 lines
7.3 KiB
JavaScript
165 lines
7.3 KiB
JavaScript
// ═══════════════════════════════════════════════════════════
|
||
// 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,
|
||
};
|
||
}
|