분석레포트 화면 추가
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user