분석레포트 화면 추가

This commit is contained in:
Macbook
2026-06-03 15:57:02 +09:00
parent 3e17d6cfb8
commit a2ede568e0
48 changed files with 2593 additions and 94 deletions
+144
View File
@@ -0,0 +1,144 @@
import type {
BacktestAnalysis,
BacktestResultRecord,
BacktestSignal,
PaperTradeDto,
} from './backendApi';
import type { LiveExecutionItem } from './liveExecutionGroups';
import type { EquityPoint, TradeHistoryRow } from './backtestEquity';
import { buildEquityFromSignals } from './backtestEquity';
import { paperTradesToSignals } from './liveExecutionGroups';
import { repairUtf8Mojibake } from './textEncoding';
export type AnalysisSourceTab = 'backtest' | 'live';
export interface AnalysisReportContext {
sourceTab: AnalysisSourceTab;
strategyName: string;
symbol: string;
timeframe: string;
barCount: number;
createdAt?: string;
analysis: BacktestAnalysis | null;
signals: BacktestSignal[];
trades: TradeHistoryRow[];
equityCurve: EquityPoint[];
paperTrades: PaperTradeDto[];
backtestRecord?: BacktestResultRecord;
liveItem?: LiveExecutionItem;
compareBacktest?: BacktestResultRecord | null;
}
function parseBacktestRecord(r: BacktestResultRecord) {
let analysis: BacktestAnalysis | null = null;
let signals: BacktestSignal[] = [];
try {
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
} catch { /* ignore */ }
const cap = analysis?.initialCapital ?? 10_000_000;
const eq = buildEquityFromSignals(signals, cap, r.symbol);
return {
analysis,
signals,
trades: eq.trades,
equityCurve: eq.curve,
};
}
export function buildContextFromBacktest(
r: BacktestResultRecord,
compare?: BacktestResultRecord | null,
): AnalysisReportContext {
const parsed = parseBacktestRecord(r);
return {
sourceTab: 'backtest',
strategyName: repairUtf8Mojibake(r.strategyName || '전략 없음'),
symbol: r.symbol,
timeframe: r.timeframe,
barCount: r.barCount || 300,
createdAt: r.createdAt,
analysis: parsed.analysis,
signals: parsed.signals,
trades: parsed.trades,
equityCurve: parsed.equityCurve,
paperTrades: [],
backtestRecord: r,
compareBacktest: compare ?? null,
};
}
export function buildContextFromLive(item: LiveExecutionItem): AnalysisReportContext {
const signals = paperTradesToSignals(item.trades);
const cap = 10_000_000;
const eq = buildEquityFromSignals(signals, cap, item.symbol);
const finalEquity = cap * (1 + item.totalReturnPct);
const analysis: BacktestAnalysis = {
initialCapital: cap,
finalEquity,
totalReturnPct: item.totalReturnPct,
totalProfitLoss: finalEquity - cap,
grossProfit: 0,
grossLoss: 0,
avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0,
profitLossRatio: item.profitFactor,
numberOfPositions: item.tradeCount,
numberOfWinning: Math.round(item.tradeCount * item.winRatePct),
numberOfLosing: Math.max(0, item.tradeCount - Math.round(item.tradeCount * item.winRatePct)),
numberOfBreakEven: 0,
winRate: item.winRatePct,
maxDrawdownPct: item.mddPct,
maxRunupPct: 0,
sharpeRatio: item.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
return {
sourceTab: 'live',
strategyName: repairUtf8Mojibake(item.strategyLabel),
symbol: item.symbol,
timeframe: '1h',
barCount: 300,
createdAt: item.createdAt,
analysis,
signals,
trades: eq.trades,
equityCurve: eq.curve,
paperTrades: item.trades,
liveItem: item,
compareBacktest: null,
};
}
/** 월별 수익률 히트맵 셀 (0~1 비율) */
export function buildMonthlyHeatmap(
trades: TradeHistoryRow[],
initialCapital: number,
): { 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);
}
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 };
});
}
+156
View File
@@ -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 };
});
}
+39
View File
@@ -0,0 +1,39 @@
/**
* TRADING_REPORT_UI_DESIGN.pdf — The Quant Command Center 10 Dimensions
*/
export type AnalysisReportTypeId =
| 'standard'
| 'risk'
| 'efficiency'
| 'temporal'
| 'logs'
| 'allocation'
| 'stats'
| 'ai-feedback'
| 'comparative'
| 'timing';
export interface AnalysisReportTypeDef {
id: AnalysisReportTypeId;
tabLabel: string;
title: string;
subtitle: string;
}
export const ANALYSIS_REPORT_TYPES: AnalysisReportTypeDef[] = [
{ id: 'standard', tabLabel: '01 Standard', title: 'Standard', subtitle: 'Assets & P&L Summary' },
{ id: 'risk', tabLabel: '02 Risk', title: 'Risk', subtitle: 'Stress-Testing Capital Preservation' },
{ id: 'efficiency', tabLabel: '03 Efficiency', title: 'Efficiency', subtitle: 'Execution Quality & Profit Extraction' },
{ id: 'temporal', tabLabel: '04 Temporal', title: 'Temporal', subtitle: 'Seasonality & Time-Based Consistency' },
{ id: 'logs', tabLabel: '05 Logs', title: 'Logs', subtitle: 'Granular Transparency for Every Execution' },
{ id: 'allocation', tabLabel: '06 Allocation', title: 'Allocation', subtitle: 'Capital Distribution & Asset Weights' },
{ id: 'stats', tabLabel: '07 Stats', title: 'Stats', subtitle: 'Shape of Returns: Statistical Distribution' },
{ id: 'ai-feedback', tabLabel: '08 AI Feedback', title: 'AI Feedback', subtitle: 'Qualitative Insights via Local LLM' },
{ id: 'comparative', tabLabel: '09 Comparative', title: 'Comparative', subtitle: 'Strategy vs. Benchmark: Alpha Generation' },
{ id: 'timing', tabLabel: '10 Timing', title: 'Timing', subtitle: 'Holding Periods & Entry Precision' },
];
export function getReportType(id: AnalysisReportTypeId): AnalysisReportTypeDef {
return ANALYSIS_REPORT_TYPES.find(t => t.id === id) ?? ANALYSIS_REPORT_TYPES[0];
}
+1
View File
@@ -27,6 +27,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'),
P('메뉴-분석레포트', 'analysis-report', '레포트', '분석', '투자분석'),
P('메뉴-설정', 'settings', '환경설정'),
// ── 실시간 차트 · 툴바 ──
+3 -2
View File
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
| 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
@@ -21,7 +21,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
];
export const ALL_MENU_IDS = [
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings',
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
] as const;
@@ -37,6 +37,7 @@ export const MENU_LABELS: Record<string, string> = {
strategy: '투자전략',
'strategy-editor': '전략편집기',
backtest: '백테스팅',
'analysis-report': '분석레포트',
notifications: '알림목록',
settings: '설정',
settings_general: '설정 · 일반',