Files
goldenChart/frontend/src/utils/analysisReportMetrics.ts
T
2026-06-03 15:57:02 +09:00

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