157 lines
5.4 KiB
TypeScript
157 lines
5.4 KiB
TypeScript
import type { BacktestSignal } from './backendApi';
|
|
import type { TradeHistoryRow, EquityPoint } from './backtestEquity';
|
|
|
|
/** CAGR 추정 */
|
|
export function estimateCagr(totalReturnPct: number, signals: BacktestSignal[]): number {
|
|
if (signals.length < 2 || !isFinite(totalReturnPct)) return totalReturnPct;
|
|
const t0 = signals[0].time;
|
|
const t1 = signals[signals.length - 1].time;
|
|
const years = (t1 - t0) / (365.25 * 86400);
|
|
if (years <= 0.01) return totalReturnPct;
|
|
const mult = 1 + totalReturnPct;
|
|
if (mult <= 0) return totalReturnPct;
|
|
return Math.pow(mult, 1 / years) - 1;
|
|
}
|
|
|
|
/** 월별×연도 수익률 행렬 (히트맵 + Temporal 탭) */
|
|
export function buildMonthlyMatrix(
|
|
trades: TradeHistoryRow[],
|
|
): { year: number; month: number; returnPct: number }[] {
|
|
const byKey = new Map<string, number>();
|
|
for (const t of trades) {
|
|
if (t.pnlPct == null) continue;
|
|
const d = new Date(t.time * 1000);
|
|
const key = `${d.getFullYear()}-${d.getMonth()}`;
|
|
byKey.set(key, (byKey.get(key) ?? 0) + t.pnlPct);
|
|
}
|
|
const out: { year: number; month: number; returnPct: number }[] = [];
|
|
for (const [key, v] of byKey) {
|
|
const [y, m] = key.split('-').map(Number);
|
|
out.push({ year: y, month: m, returnPct: v });
|
|
}
|
|
return out.sort((a, b) => a.year - b.year || a.month - b.month);
|
|
}
|
|
|
|
/** YTD(연도별 합산) */
|
|
export function buildYtdByYear(monthly: { year: number; month: number; returnPct: number }[]): Map<number, number> {
|
|
const m = new Map<number, number>();
|
|
for (const r of monthly) {
|
|
m.set(r.year, (m.get(r.year) ?? 0) + r.returnPct);
|
|
}
|
|
return m;
|
|
}
|
|
|
|
/** 수익률 분포 버킷 (Stats 탭 히스토그램) */
|
|
export function buildReturnDistribution(
|
|
trades: TradeHistoryRow[],
|
|
): { bucket: string; from: number; to: number; count: number }[] {
|
|
const withPnl = trades.filter(t => t.pnlPct != null).map(t => (t.pnlPct ?? 0) * 100);
|
|
if (withPnl.length === 0) return [];
|
|
|
|
const buckets: { label: string; min: number; max: number }[] = [
|
|
{ label: '< -10%', min: -Infinity, max: -10 },
|
|
{ label: '-10~-5%', min: -10, max: -5 },
|
|
{ label: '-5~0%', min: -5, max: 0 },
|
|
{ label: '0~2%', min: 0, max: 2 },
|
|
{ label: '2~4%', min: 2, max: 4 },
|
|
{ label: '4~6%', min: 4, max: 6 },
|
|
{ label: '6~10%', min: 6, max: 10 },
|
|
{ label: '10~50%', min: 10, max: 50 },
|
|
{ label: '50~100%', min: 50, max: 100 },
|
|
{ label: '100~500%', min: 100, max: 500 },
|
|
{ label: '500%+', min: 500, max: Infinity },
|
|
];
|
|
|
|
return buckets.map(b => ({
|
|
bucket: b.label,
|
|
from: b.min,
|
|
to: b.max,
|
|
count: withPnl.filter(v => v >= b.min && v < b.max).length,
|
|
})).filter(b => b.count > 0 || (b.from >= -10 && b.to <= 10));
|
|
}
|
|
|
|
/** 보유시간(분/시간) 계산 — BUY 신호 후 SELL 신호까지 */
|
|
export interface HoldingPoint {
|
|
durationMin: number;
|
|
pnlPct: number;
|
|
symbol: string;
|
|
}
|
|
|
|
export function buildHoldingPoints(
|
|
signals: BacktestSignal[],
|
|
symbol: string,
|
|
): HoldingPoint[] {
|
|
const sorted = [...signals].sort((a, b) => a.time - b.time);
|
|
const pts: HoldingPoint[] = [];
|
|
const buys: BacktestSignal[] = [];
|
|
|
|
for (const s of sorted) {
|
|
if (s.type === 'BUY') {
|
|
buys.push(s);
|
|
} else if ((s.type === 'SELL' || s.type === 'SHORT_EXIT' || s.type === 'PARTIAL_SELL') && buys.length > 0) {
|
|
const buy = buys.pop()!;
|
|
const durSec = s.time - buy.time;
|
|
const pnl = buy.price > 0 ? (s.price - buy.price) / buy.price : 0;
|
|
pts.push({ durationMin: durSec / 60, pnlPct: pnl * 100, symbol });
|
|
}
|
|
}
|
|
return pts;
|
|
}
|
|
|
|
/** 자본 배분 슬라이스 (Allocation 탭) */
|
|
export interface AllocationSlice {
|
|
symbol: string;
|
|
pct: number;
|
|
capitalKrw: number;
|
|
color: string;
|
|
}
|
|
|
|
const SLICE_COLORS = ['#00e5ff', '#00e676', '#ffd700', '#ab47bc', '#ff7043', '#42a5f5'];
|
|
|
|
export function buildAllocationSlices(
|
|
trades: TradeHistoryRow[],
|
|
initialCapital: number,
|
|
): AllocationSlice[] {
|
|
const bySymbol = new Map<string, number>();
|
|
for (const t of trades) {
|
|
bySymbol.set(t.symbol, (bySymbol.get(t.symbol) ?? 0) + 1);
|
|
}
|
|
const total = [...bySymbol.values()].reduce((a, b) => a + b, 0) || 1;
|
|
return [...bySymbol.entries()]
|
|
.map(([sym, cnt], i) => ({
|
|
symbol: sym,
|
|
pct: cnt / total,
|
|
capitalKrw: initialCapital * (cnt / total),
|
|
color: SLICE_COLORS[i % SLICE_COLORS.length],
|
|
}))
|
|
.sort((a, b) => b.pct - a.pct);
|
|
}
|
|
|
|
/** 변동성 시리즈 (롤링 20봉 표준편차) */
|
|
export function buildVolatilitySeries(curve: EquityPoint[], window = 20): { time: number; vol: number }[] {
|
|
if (curve.length < 2) return [];
|
|
const returns: number[] = [];
|
|
for (let i = 1; i < curve.length; i++) {
|
|
const r = curve[i - 1].equity > 0 ? (curve[i].equity - curve[i - 1].equity) / curve[i - 1].equity : 0;
|
|
returns.push(r);
|
|
}
|
|
const out: { time: number; vol: number }[] = [];
|
|
for (let i = window - 1; i < returns.length; i++) {
|
|
const slice = returns.slice(i - window + 1, i + 1);
|
|
const mean = slice.reduce((a, b) => a + b, 0) / window;
|
|
const variance = slice.reduce((a, b) => a + (b - mean) ** 2, 0) / window;
|
|
out.push({ time: curve[i + 1].time, vol: Math.sqrt(variance) * 100 });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 드로다운 시리즈 */
|
|
export function drawdownSeries(curve: EquityPoint[]): { time: number; ddPct: number }[] {
|
|
let peak = curve[0]?.equity ?? 0;
|
|
return curve.map(p => {
|
|
if (p.equity > peak) peak = p.equity;
|
|
const dd = peak > 0 ? (p.equity - peak) / peak : 0;
|
|
return { time: p.time, ddPct: dd };
|
|
});
|
|
}
|