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