분석레포트 화면 추가
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
||||
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
|
||||
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
|
||||
P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'),
|
||||
P('메뉴-분석레포트', 'analysis-report', '레포트', '분석', '투자분석'),
|
||||
P('메뉴-설정', 'settings', '환경설정'),
|
||||
|
||||
// ── 실시간 차트 · 툴바 ──
|
||||
|
||||
@@ -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: '설정 · 일반',
|
||||
|
||||
Reference in New Issue
Block a user