Files
goldenChart/frontend/src/components/analysisReport/AnalysisReportViews.tsx
T
2026-06-06 00:56:45 +09:00

1002 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Quant Command Center — 10 Dimensions
* TRADING_REPORT_UI_DESIGN.pdf 설계 기반
*/
import React, { useMemo, useState } from 'react';
import type { Theme } from '../../types';
import type { AnalysisReportTypeId } from '../../utils/analysisReportTypes';
import { getReportType } from '../../utils/analysisReportTypes';
import type { AnalysisReportContext } from '../../utils/analysisReportContext';
import type { BacktestResultRecord } from '../../utils/backendApi';
import {
estimateCagr,
buildMonthlyMatrix,
buildYtdByYear,
buildReturnDistribution,
buildHoldingPoints,
buildAllocationSlices,
buildVolatilitySeries,
drawdownSeries,
} from '../../utils/analysisReportMetrics';
import { repairUtf8Mojibake } from '../../utils/textEncoding';
import { formatUpbitKrwPrice } from '../../utils/safeFormat';
/* ── 포맷 유틸 ── */
const pct = (v: number, dec = 2) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%`;
const pctAbs= (v: number, dec = 1) => `${(v * 100).toFixed(dec)}%`;
const num = (v: number, dec = 2) => (isFinite(v) ? v.toFixed(dec) : '—');
const wonFmt = (v: number) => {
const abs = Math.abs(v);
const sign = v < 0 ? '-' : v > 0 ? '+' : '';
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(1)}억`;
if (abs >= 1e4) return `${sign}${(abs / 1e4).toFixed(0)}만`;
return `${sign}${formatUpbitKrwPrice(abs, '0')}원`;
};
const colorCls = (v: number) => (v > 0 ? 'up' : v < 0 ? 'down' : '');
interface Props {
typeId: AnalysisReportTypeId;
ctx: AnalysisReportContext | null;
theme: Theme;
compareBacktest?: BacktestResultRecord | null;
/** 현재 사용 안 함 — AnalysisReportPage 호환용 */
allBacktests?: BacktestResultRecord[];
compareLive?: unknown;
}
/* ── 공통 유틸 ── */
function fmtDateTime(ts: number) {
const d = new Date(ts * 1000);
return `${d.getFullYear().toString().slice(2)}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtDuration(sec: number) {
if (sec < 3600) return `${Math.round(sec / 60)}m`;
const h = Math.floor(sec / 3600);
const m = Math.round((sec % 3600) / 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
/* ── SVG 스파크 라인 ── */
function SparkLine({
values,
w = 400,
h = 100,
positive = true,
fill = true,
}: {
values: number[];
w?: number;
h?: number;
positive?: boolean;
fill?: boolean;
}) {
const path = useMemo(() => {
if (values.length < 2) return { line: '', area: '' };
const lo = Math.min(...values);
const hi = Math.max(...values);
const range = hi - lo || 1;
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * w;
const y = h - 8 - ((v - lo) / range) * (h - 16);
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
});
const line = pts.join(' ');
const area = `${line} L${w},${h} L0,${h} Z`;
return { line, area };
}, [values, w, h]);
const stroke = positive ? '#00e5ff' : '#ff1744';
const fillId = `sf${positive ? 'c' : 'r'}`;
return (
<svg className="qcc-spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
<defs>
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.4" />
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
</linearGradient>
</defs>
{fill && <path d={path.area} fill={`url(#${fillId})`} />}
<path d={path.line} fill="none" stroke={stroke} strokeWidth="2" />
</svg>
);
}
/* ── 도넛 차트 ── */
function DonutChart({
slices,
centerLabel,
centerValue,
}: {
slices: { pct: number; color: string }[];
centerLabel: string;
centerValue: string;
}) {
const r = 70;
const c = 2 * Math.PI * r;
let acc = 0;
const total = slices.reduce((s, x) => s + x.pct, 0) || 1;
return (
<svg viewBox="0 0 180 180" className="qcc-donut-svg">
{slices.map((sl, i) => {
const dash = (sl.pct / total) * c;
const offset = c * 0.25 - acc;
acc += dash;
return (
<circle
key={i}
cx="90" cy="90" r={r}
fill="none"
stroke={sl.color}
strokeWidth="20"
strokeDasharray={`${dash.toFixed(1)} ${(c - dash).toFixed(1)}`}
strokeDashoffset={offset.toFixed(1)}
/>
);
})}
<text x="90" y="85" textAnchor="middle" fontSize="11" fill="var(--se-text-muted)">{centerLabel}</text>
<text x="90" y="103" textAnchor="middle" fontSize="13" fontWeight="700" fill="#00e5ff">{centerValue}</text>
</svg>
);
}
/* ── VaR 반원 게이지 ── */
function VarGauge({ varPct }: { varPct: number }) {
const abs = Math.abs(varPct) * 100;
const degree = Math.min(180, abs * 9); // 0~20% → 0~180도
const rad = ((degree - 180) * Math.PI) / 180;
const x = 90 + 70 * Math.cos(rad);
const y = 90 + 70 * Math.sin(rad);
return (
<svg viewBox="0 0 180 100" className="qcc-var-svg">
<defs>
<linearGradient id="varGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#00e5ff" />
<stop offset="50%" stopColor="#ffd700" />
<stop offset="100%" stopColor="#ff1744" />
</linearGradient>
</defs>
<path d="M10,90 A80,80 0 0,1 170,90" fill="none" stroke="#1e2a3a" strokeWidth="18" />
<path d="M10,90 A80,80 0 0,1 170,90" fill="none" stroke="url(#varGrad)" strokeWidth="18" opacity="0.35" />
<circle cx={x.toFixed(1)} cy={y.toFixed(1)} r="6" fill="#fff" />
<text x="15" y="100" fontSize="9" fill="#00e5ff">Low Risk</text>
<text x="135" y="100" fontSize="9" fill="#ff1744">High Risk</text>
<text x="90" y="80" textAnchor="middle" fontSize="12" fontWeight="700" fill="#ffd700">
VaR: {abs.toFixed(2)}%
</text>
</svg>
);
}
/* ── 원형 게이지 (Efficiency) ── */
function CircularGauge({ value, label, max = 100 }: { value: number; label: string; max?: number }) {
const r = 60;
const c = 2 * Math.PI * r;
const pct2 = Math.min(1, Math.max(0, value / max));
const dash = pct2 * c;
const color = value >= max * 0.9 ? '#ffd700' : value >= max * 0.5 ? '#00e676' : '#ff1744';
return (
<svg viewBox="0 0 160 160" className="qcc-gauge-svg">
<circle cx="80" cy="80" r={r} fill="none" stroke="#1e2a3a" strokeWidth="14" />
<circle
cx="80" cy="80" r={r}
fill="none"
stroke={color}
strokeWidth="14"
strokeDasharray={`${dash.toFixed(1)} ${(c - dash).toFixed(1)}`}
strokeDashoffset={(c * 0.25).toFixed(1)}
strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 8px ${color})` }}
/>
<text x="80" y="74" textAnchor="middle" fontSize="22" fontWeight="900" fill={color}>
{value >= 99 ? '99+' : value.toFixed(value >= 10 ? 2 : 2)}
</text>
<text x="80" y="93" textAnchor="middle" fontSize="11" fill="var(--se-text-muted)">{label}</text>
</svg>
);
}
/* ── 산포도 (Timing) ── */
function ScatterPlot({ points }: { points: { x: number; y: number; label?: string }[] }) {
if (points.length === 0) {
return <div className="qcc-empty">보유시간 데이터 없음</div>;
}
const maxX = Math.max(...points.map(p => p.x), 120);
const maxY = Math.max(...points.map(p => p.y), 10);
const W = 400; const H = 220;
const pad = { l: 36, r: 12, t: 12, b: 28 };
const xScale = (v: number) => pad.l + (v / maxX) * (W - pad.l - pad.r);
const yScale = (v: number) => H - pad.b - (v / maxY) * (H - pad.t - pad.b);
const xTicks = [0, 15, 30, 45, 60, 80, 100, 120].filter(v => v <= maxX);
const yTicks = [0, 50, 100, 150, 200].filter(v => v <= maxY);
return (
<svg viewBox={`0 0 ${W} ${H}`} className="qcc-scatter">
{/* 그리드 */}
{xTicks.map(v => (
<g key={v}>
<line x1={xScale(v)} y1={pad.t} x2={xScale(v)} y2={H - pad.b} stroke="rgba(255,255,255,0.05)" />
<text x={xScale(v)} y={H - 4} textAnchor="middle" fontSize="8" fill="#556">{v}</text>
</g>
))}
{yTicks.map(v => (
<g key={v}>
<line x1={pad.l} y1={yScale(v)} x2={W - pad.r} y2={yScale(v)} stroke="rgba(255,255,255,0.05)" />
<text x={pad.l - 4} y={yScale(v) + 3} textAnchor="end" fontSize="8" fill="#556">{v}%</text>
</g>
))}
{/* 축 */}
<line x1={pad.l} y1={pad.t} x2={pad.l} y2={H - pad.b} stroke="#334" />
<line x1={pad.l} y1={H - pad.b} x2={W - pad.r} y2={H - pad.b} stroke="#334" />
{/* 점 */}
{points.map((p, i) => (
<circle key={i} cx={xScale(p.x)} cy={yScale(Math.min(p.y, maxY))}
r="4" fill="#00e676" opacity="0.75" />
))}
{/* 축 레이블 */}
<text x={(W + pad.l) / 2} y={H} textAnchor="middle" fontSize="8.5" fill="#778">
Holding Duration (Minutes/Hours)
</text>
<text x={8} y={H / 2} textAnchor="middle" fontSize="8.5" fill="#778"
transform={`rotate(-90,8,${H / 2})`}>P&L (%)</text>
</svg>
);
}
/* ── 뷰 쉘 ── */
function ViewShell({
ctx,
typeId,
children,
}: {
ctx: AnalysisReportContext | null;
typeId: AnalysisReportTypeId;
children: React.ReactNode;
}) {
const def = getReportType(typeId);
if (!ctx) {
return (
<div className="qcc-empty-page">
<span>📊</span>
<p>좌측 목록에서 백테스팅 또는 실시간 투자 실행을 선택하세요.</p>
</div>
);
}
return (
<div className="qcc-view">
<div className="qcc-view-meta">
<span className="qcc-badge">{ctx.symbol}</span>
<span className="qcc-badge">{ctx.timeframe}</span>
<span className="qcc-badge qcc-badge--muted">{ctx.sourceTab === 'backtest' ? '백테스팅' : '실시간'}</span>
<span className="qcc-badge qcc-badge--strategy">{repairUtf8Mojibake(ctx.strategyName)}</span>
</div>
{children}
</div>
);
}
/* ══════════════════════════════════════════════════════════
01 Standard — Assets & P&L + 3 Hero KPIs + Growth Chart
══════════════════════════════════════════════════════════ */
function StandardView({ ctx }: { ctx: AnalysisReportContext }) {
const a = ctx.analysis;
const cagr = a ? estimateCagr(a.totalReturnPct, ctx.signals) : 0;
const equityVals = ctx.equityCurve.map(p => p.equity);
const up = (a?.totalReturnPct ?? 0) >= 0;
return (
<div className="qcc-standard">
{/* 좌측: Assets & P&L Summary */}
<div className="qcc-panel qcc-panel--gold qcc-standard-left">
<div className="qcc-panel-title">Assets &amp; P&amp;L Summary</div>
<div className="qcc-summary-rows">
<div className="qcc-summary-row">
<span>Total Trades</span>
<strong>{a?.numberOfPositions ?? '—'}</strong>
</div>
<div className="qcc-summary-row">
<span>Win Rate</span>
<strong className={colorCls((a?.winRate ?? 0) - 0.5)}>{pctAbs(a?.winRate ?? 0)}</strong>
</div>
<div className="qcc-summary-row">
<span>Profit Factor</span>
<strong className={colorCls((a?.profitLossRatio ?? 0) - 1)}>{num(a?.profitLossRatio ?? 0)}</strong>
</div>
<div className="qcc-summary-row">
<span>Average P&amp;L</span>
<strong className={colorCls(a?.avgReturnPct ?? 0)}>{pct(a?.avgReturnPct ?? 0)}</strong>
</div>
<div className="qcc-summary-divider" />
<div className="qcc-summary-row">
<span>Gross Profit</span>
<strong className="up">{wonFmt(a?.grossProfit ?? 0)}</strong>
</div>
<div className="qcc-summary-row">
<span>Gross Loss</span>
<strong className="down">{wonFmt(a?.grossLoss ?? 0)}</strong>
</div>
<div className="qcc-summary-row">
<span>Net P&amp;L</span>
<strong className={colorCls(a?.totalProfitLoss ?? 0)}>{wonFmt(a?.totalProfitLoss ?? 0)}</strong>
</div>
</div>
</div>
{/* 우측: 3 Hero KPIs + Growth Chart */}
<div className="qcc-standard-right">
<div className="qcc-hero-row">
{/* Total ROI */}
<div className={`qcc-hero qcc-hero--roi qcc-hero--${up ? 'up' : 'down'}`}>
<div className="qcc-hero-label">Total ROI</div>
<div className="qcc-hero-value">{pct(a?.totalReturnPct ?? 0)}</div>
<div className="qcc-hero-sub">(CAGR: {pct(cagr)})</div>
<SparkLine values={equityVals.length >= 2 ? equityVals : [0, 1]} h={40} positive={up} />
</div>
{/* MDD */}
<div className="qcc-hero qcc-hero--mdd">
<div className="qcc-hero-label">Max Drawdown (MDD)</div>
<div className={`qcc-hero-value ${(a?.maxDrawdownPct ?? 0) < 0 ? 'down' : ''}`}>
{pct(a?.maxDrawdownPct ?? 0)}
</div>
<div className="qcc-hero-sub-line" />
</div>
{/* Sharpe */}
<div className="qcc-hero qcc-hero--sharpe">
<div className="qcc-hero-label">Sharpe Ratio</div>
<div className="qcc-hero-value qcc-hero-gold">{num(a?.sharpeRatio ?? 0, 2)}</div>
<SparkLine values={equityVals.length >= 2 ? equityVals : [0, 1]} h={36} fill={false} />
</div>
</div>
{/* Assets Growth Chart */}
<div className="qcc-panel qcc-panel--neutral qcc-growth-panel">
<div className="qcc-panel-title">Assets Growth Chart</div>
<div className="qcc-growth-chart-wrap">
<SparkLine values={equityVals.length >= 2 ? equityVals : [0, 1]} h={200} positive={up} />
</div>
</div>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
02 Risk — Drawdown Depth + Volatility + VaR Gauge
══════════════════════════════════════════════════════════ */
function RiskView({ ctx }: { ctx: AnalysisReportContext }) {
const a = ctx.analysis;
const dd = useMemo(() => drawdownSeries(ctx.equityCurve), [ctx.equityCurve]);
const vol = useMemo(() => buildVolatilitySeries(ctx.equityCurve), [ctx.equityCurve]);
const ddVals = dd.map(d => d.ddPct * 100);
const volVals = vol.map(v => v.vol);
const mdd = a?.maxDrawdownPct ?? 0;
return (
<div className="qcc-risk">
<div className="qcc-panel qcc-panel--cyan qcc-risk-left">
<div className="qcc-panel-title">Stress-Testing Capital Preservation</div>
<ul className="qcc-risk-bullets">
<li>
<strong>Capital Drawdown (Underwater Curve):</strong> Peak-to-trough declines evaluated across the backtest period.
Current MDD: <span className={mdd < 0 ? 'down' : ''}>{pct(mdd)}</span>.
</li>
<li>
<strong>Volatility Metrics:</strong> Annualized standard deviation of returns and Ulcer Index (depth and duration of drawdowns).
</li>
<li>
<strong>Value at Risk (VaR - 95%):</strong> Maximum expected loss over a specific timeframe at a 95% confidence interval.
</li>
</ul>
</div>
<div className="qcc-risk-right">
<div className="qcc-panel qcc-panel--neutral">
<div className="qcc-panel-title">Drawdown Depth <span className="qcc-panel-sub">Current MDD: {pct(mdd)}</span></div>
<div className="qcc-chart-wrap">
<SparkLine values={ddVals.length >= 2 ? ddVals : [0, -0.01]} h={160} positive={false} />
</div>
</div>
<div className="qcc-risk-bottom">
<div className="qcc-panel qcc-panel--neutral">
<div className="qcc-panel-title">Volatility Index</div>
<div className="qcc-chart-wrap">
<SparkLine values={volVals.length >= 2 ? volVals : [0.5, 0.5, 0.5]} h={100} positive={true} fill={false} />
</div>
<div className="qcc-vol-label">Low Volatility Channel</div>
</div>
<div className="qcc-panel qcc-panel--neutral">
<div className="qcc-panel-title">Value at Risk (VaR 95%)</div>
<VarGauge varPct={a?.valueAtRisk95 ?? 0.0001} />
</div>
</div>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
03 Efficiency — Profit Factor Gauge + Win Rate + Avg Win/Loss
══════════════════════════════════════════════════════════ */
function EfficiencyView({ ctx }: { ctx: AnalysisReportContext }) {
const a = ctx.analysis;
const pf = a?.profitLossRatio ?? 0;
const wr = (a?.winRate ?? 0) * 100;
const avgWin = a ? (a.grossProfit / (a.numberOfWinning || 1)) / (a.initialCapital || 1) * 100 : 0;
const avgLoss = a ? Math.abs(a.grossLoss / (a.numberOfLosing || 1)) / (a.initialCapital || 1) * 100 : 0;
const maxBar = Math.max(avgWin, avgLoss, 0.1);
return (
<div className="qcc-efficiency">
<div className="qcc-panel qcc-panel--gold qcc-efficiency-left">
<div className="qcc-panel-title">Execution Quality and Profit Extraction</div>
<div className="qcc-eff-desc">
<p><strong>Profit Factor [{pf >= 99 ? '99.00' : pf.toFixed(2)}]:</strong> Gross Profit / Gross Loss. {pf >= 10 ? 'An extreme metric indicating near-perfect trade filtering.' : 'Measures overall profitability efficiency.'}</p>
<p><strong>Win Rate [{pctAbs(a?.winRate ?? 0)}]:</strong> Profitable Trades / Total Trades ({a?.numberOfWinning ?? 0} consecutive wins).</p>
<p><strong>Average Profit vs. Average Loss:</strong> Avg P&amp;L stands at {pct(a?.avgReturnPct ?? 0)} {(a?.numberOfLosing ?? 0) === 0 ? 'with zero losing trades executed.' : '.'}</p>
</div>
</div>
<div className="qcc-efficiency-right">
<div className="qcc-eff-center">
<CircularGauge value={Math.min(pf, 99)} label="Profit Factor" max={99} />
<div className="qcc-winrate-bar-wrap">
<div className="qcc-winrate-bar" style={{ width: `${wr}%` }} />
<div className="qcc-winrate-label">Win Rate {wr.toFixed(1)}%</div>
</div>
</div>
<div className="qcc-eff-bars">
<div className="qcc-eff-bar-item">
<div className="qcc-eff-bar-label up">+{avgWin.toFixed(2)}%<br /><span>Avg Win</span></div>
<div className="qcc-eff-bar-track">
<div className="qcc-eff-bar-fill up" style={{ height: `${(avgWin / maxBar) * 90}%` }} />
</div>
</div>
<div className="qcc-eff-bar-item">
<div className="qcc-eff-bar-label down">{avgLoss > 0 ? `-${avgLoss.toFixed(2)}%` : '0.00%'}<br /><span>Avg Loss</span></div>
<div className="qcc-eff-bar-track">
<div className="qcc-eff-bar-fill down" style={{ height: `${(avgLoss / maxBar) * 90}%` }} />
</div>
</div>
</div>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
04 Temporal — Seasonality Monthly Matrix
══════════════════════════════════════════════════════════ */
const MON_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function TemporalView({ ctx }: { ctx: AnalysisReportContext }) {
const monthly = useMemo(() => buildMonthlyMatrix(ctx.trades), [ctx.trades]);
const ytdMap = useMemo(() => buildYtdByYear(monthly), [monthly]);
const years = useMemo(() => [...new Set(monthly.map(r => r.year))].sort(), [monthly]);
const map = useMemo(() => new Map(monthly.map(r => [`${r.year}-${r.month}`, r.returnPct])), [monthly]);
if (years.length === 0) {
return <div className="qcc-empty-page"><span>📅</span><p>거래 데이터가 부족합니다.</p></div>;
}
return (
<div className="qcc-temporal">
<div className="qcc-temporal-header">
<h2 className="qcc-section-title">Seasonality and Time-Based Consistency</h2>
<p className="qcc-section-sub">Monthly &amp; Yearly Performance Matrix: Verifying that the algorithm generates consistent alpha regardless of specific macro-market phases.</p>
</div>
<div className="qcc-temporal-table-wrap">
<table className="qcc-temporal-table">
<thead>
<tr>
<th />
{MON_LABELS.map(m => <th key={m}>{m}</th>)}
<th className="qcc-ytd-col">YTD</th>
</tr>
</thead>
<tbody>
{years.map(year => {
const ytd = ytdMap.get(year) ?? 0;
return (
<tr key={year}>
<td className="qcc-year-cell">{year}</td>
{MON_LABELS.map((_, mi) => {
const v = map.get(`${year}-${mi}`);
const intensity = v != null ? Math.min(1, Math.abs(v) * 8) : 0;
const bg = v == null
? 'transparent'
: v > 0
? `rgba(0,230,118,${0.15 + intensity * 0.65})`
: v < 0
? `rgba(255,23,68,${0.15 + intensity * 0.65})`
: 'rgba(60,70,90,0.3)';
return (
<td key={mi} style={{ background: bg }} className="qcc-month-cell">
{v != null ? pct(v, 2) : <span className="qcc-zero">0.00%</span>}
</td>
);
})}
<td className={`qcc-ytd-col ${colorCls(ytd)}`}>{pct(ytd, 2)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
05 Logs — Granular Trade Log (Entry+Exit Price, Duration)
══════════════════════════════════════════════════════════ */
interface TradeLogFull {
id: number;
dateTime: string;
symbol: string;
side: string;
entryPrice: number;
exitPrice: number;
qty: number;
pnlPct: number;
durationSec: number;
}
function buildFullLogRows(ctx: AnalysisReportContext): TradeLogFull[] {
if (ctx.sourceTab === 'live' && ctx.paperTrades.length > 0) {
return ctx.paperTrades.map((t, i) => {
const ts = t.createdAt ? Math.floor(new Date(t.createdAt).getTime() / 1000) : 0;
return {
id: t.id ?? i,
dateTime: ts ? fmtDateTime(ts) : '—',
symbol: t.symbol,
side: t.side,
entryPrice: t.side === 'SELL' ? t.price : t.price * 0.99,
exitPrice: t.price,
qty: t.quantity,
pnlPct: t.realizedPnlDelta != null ? t.realizedPnlDelta / (t.price * t.quantity || 1) * 100 : 0,
durationSec: 0,
};
});
}
const buys = new Map<string, { time: number; price: number; qty: number }>();
const rows: TradeLogFull[] = [];
const sorted = [...ctx.trades].sort((a, b) => a.time - b.time);
for (const t of sorted) {
if (t.side === 'buy') {
buys.set(t.symbol, { time: t.time, price: t.price, qty: t.quantity });
} else {
const buy = buys.get(t.symbol);
const entry = buy?.price ?? t.price;
const dur = buy ? t.time - buy.time : 0;
rows.push({
id: t.id,
dateTime: fmtDateTime(t.time),
symbol: t.symbol,
side: 'BUY/SELL',
entryPrice: entry,
exitPrice: t.price,
qty: t.quantity,
pnlPct: (t.pnlPct ?? 0) * 100,
durationSec: dur,
});
buys.delete(t.symbol);
}
}
return rows;
}
function LogsView({ ctx }: { ctx: AnalysisReportContext }) {
const rows = useMemo(() => buildFullLogRows(ctx), [ctx]);
return (
<div className="qcc-logs">
<h2 className="qcc-section-title">Granular Transparency for Every Execution</h2>
<p className="qcc-section-sub">Data derived from the GoldenChart execution feed (100ms latency WebSocket).</p>
<div className="qcc-logs-table-wrap">
<table className="qcc-logs-table">
<thead>
<tr>
<th>Date &amp; Time</th>
<th>Symbol</th>
<th>Side</th>
<th>Entry Price</th>
<th>Exit Price</th>
<th>Qty</th>
<th>P&amp;L (%)</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={8} className="qcc-empty-cell">거래 내역 없음</td></tr>
) : rows.map(r => (
<tr key={r.id} className={r.pnlPct > 0 ? 'qcc-row--up' : r.pnlPct < 0 ? 'qcc-row--down' : ''}>
<td>{r.dateTime}</td>
<td className="qcc-sym">{r.symbol.replace('KRW-', 'KRW-')}</td>
<td className="qcc-side">{r.side}</td>
<td className="qcc-num">{formatUpbitKrwPrice(r.entryPrice)}</td>
<td className="qcc-num">{formatUpbitKrwPrice(r.exitPrice)}</td>
<td className="qcc-num">{r.qty.toFixed(3)}</td>
<td className={r.pnlPct >= 0 ? 'up' : 'down'}>{r.pnlPct >= 0 ? '+' : ''}{r.pnlPct.toFixed(1)}%</td>
<td className="qcc-dur">{r.durationSec > 0 ? fmtDuration(r.durationSec) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
06 Allocation — Donut + Legend Table
══════════════════════════════════════════════════════════ */
function AllocationView({ ctx }: { ctx: AnalysisReportContext }) {
const a = ctx.analysis;
const slices = useMemo(
() => buildAllocationSlices(ctx.trades, a?.initialCapital ?? 10_000_000),
[ctx.trades, a],
);
const totalCapital = a?.initialCapital ?? 10_000_000;
return (
<div className="qcc-allocation">
<div className="qcc-alloc-left">
<h2 className="qcc-section-title">Capital Distribution<br />and Asset Weights</h2>
<p className="qcc-section-sub">
Dynamic Capital Allocation: Visualizing the distribution of the initial simulated capital across different cryptocurrency pairs during the backtest.
</p>
<p className="qcc-section-sub" style={{ marginTop: 8 }}>
Exposure Analysis: Validating risk distribution across multiple assets in accordance with the CAPITAL_PCT engine settings.
</p>
<div className="qcc-alloc-legend">
{slices.map(sl => (
<div key={sl.symbol} className="qcc-alloc-row">
<span className="qcc-alloc-dot" style={{ background: sl.color }} />
<span className="qcc-alloc-sym">{sl.symbol.replace('KRW-', 'KRW-')}</span>
<span className="qcc-alloc-pct">{(sl.pct * 100).toFixed(2)}%</span>
<span className="qcc-alloc-krw">{formatUpbitKrwPrice(sl.capitalKrw)}</span>
</div>
))}
</div>
</div>
<div className="qcc-alloc-right">
<DonutChart
slices={slices.map(sl => ({ pct: sl.pct * 100, color: sl.color }))}
centerLabel="Total Capital Deployed"
centerValue={`₩${Math.round(totalCapital / 1e8).toLocaleString()}억`}
/>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
07 Stats — Return Distribution Histogram
══════════════════════════════════════════════════════════ */
function StatsView({ ctx }: { ctx: AnalysisReportContext }) {
const buckets = useMemo(() => buildReturnDistribution(ctx.trades), [ctx.trades]);
const maxCount = Math.max(...buckets.map(b => b.count), 1);
const W = 500; const H = 240;
const pad = { l: 36, r: 20, t: 20, b: 32 };
const bw = buckets.length > 0 ? (W - pad.l - pad.r) / buckets.length - 2 : 0;
return (
<div className="qcc-stats">
<div className="qcc-panel qcc-panel--cyan qcc-stats-left">
<div className="qcc-panel-title">The Shape of Returns: Statistical Distribution</div>
<ul className="qcc-risk-bullets">
<li><strong>Right Tail:</strong> Outlier massive wins extreme positive P&L executions.</li>
<li><strong>Center Mass:</strong> The bulk of standard, high-probability trades falling in the +1% to +6% range.</li>
<li><strong>Left Tail:</strong> Frequency and severity of losing trades.</li>
</ul>
<div className="qcc-stats-summary">
{buckets.filter(b => b.count > 0).slice(0, 5).map(b => (
<div key={b.bucket} className="qcc-stats-row">
<span>{b.bucket}</span>
<strong>{b.count}</strong>
</div>
))}
</div>
</div>
<div className="qcc-stats-chart">
{buckets.length === 0 ? (
<div className="qcc-empty">거래 분포 데이터 없음</div>
) : (
<svg viewBox={`0 0 ${W} ${H}`} className="qcc-hist-svg">
{/* 그리드 */}
{[0, 25, 50, 75, 100].map(p => {
const y = H - pad.b - (p / 100) * (H - pad.t - pad.b);
return <line key={p} x1={pad.l} y1={y} x2={W - pad.r} y2={y} stroke="rgba(255,255,255,0.05)" />;
})}
{/* 막대 */}
{buckets.map((b, i) => {
const barH = (b.count / maxCount) * (H - pad.t - pad.b);
const x = pad.l + i * ((W - pad.l - pad.r) / buckets.length);
const isExtreme = b.from >= 100;
const fill = isExtreme ? '#ffd700' : b.from >= 0 ? '#00e676' : '#ff1744';
return (
<g key={b.bucket}>
<rect
x={x + 1} y={H - pad.b - barH}
width={bw} height={barH}
fill={fill} opacity="0.85"
/>
{b.count > 0 && (
<text x={x + bw / 2} y={H - pad.b - barH - 4}
textAnchor="middle" fontSize="8" fill={fill}>{b.count}</text>
)}
{isExtreme && b.count > 0 && (
<text x={x + bw / 2} y={H - pad.b - barH - 14}
textAnchor="middle" fontSize="9" fill="#ffd700">+{b.from}%</text>
)}
<text x={x + bw / 2} y={H - 4}
textAnchor="middle" fontSize="7" fill="#556">{b.bucket.replace('%', '')}</text>
</g>
);
})}
{/* Y축 */}
{[0, 25, 50, 75, 100].map(p => {
const cnt = Math.round((p / 100) * maxCount);
const y = H - pad.b - (p / 100) * (H - pad.t - pad.b);
return <text key={p} x={pad.l - 4} y={y + 3} textAnchor="end" fontSize="8" fill="#556">{cnt}</text>;
})}
{/* 종 곡선 (정규분포 근사) */}
{buckets.length >= 3 && (() => {
const pts = buckets.map((_, i) => {
const x = pad.l + (i + 0.5) * ((W - pad.l - pad.r) / buckets.length);
const normI = (i - buckets.length / 2) / (buckets.length / 4);
const bell = Math.exp(-0.5 * normI * normI) * 0.9;
const y = H - pad.b - bell * (H - pad.t - pad.b);
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
});
return <path d={pts.join(' ')} fill="none" stroke="#00e5ff" strokeWidth="2" opacity="0.6" />;
})()}
<text x={(W + pad.l) / 2} y={H} textAnchor="middle" fontSize="9" fill="#778">Return % Buckets</text>
<text x={10} y={H / 2} textAnchor="middle" fontSize="9" fill="#778"
transform={`rotate(-90,10,${H / 2})`}>Frequency</text>
</svg>
)}
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
08 AI Feedback — LLM Terminal Output
══════════════════════════════════════════════════════════ */
function AiFeedbackView({ ctx }: { ctx: AnalysisReportContext }) {
const a = ctx.analysis;
const mdd = a?.maxDrawdownPct ?? 0;
const sharpe = a?.sharpeRatio ?? 0;
const pf = a?.profitLossRatio ?? 0;
const wr = a?.winRate ?? 0;
const lines: { key: string; label: string; text: string }[] = [
{
key: 'market',
label: 'Market Context',
text: sharpe > 1
? `Strategy performed optimally during the backtest period. Sharpe ratio of ${sharpe.toFixed(2)} confirms risk-adjusted alpha generation with ${pctAbs(wr)} win rate.`
: `Strategy shows moderate performance. Win rate of ${pctAbs(wr)} with Sharpe ${sharpe.toFixed(2)} suggests potential for parameter optimization.`,
},
{
key: 'indicator',
label: 'Indicator Interpretation',
text: pf > 2
? `Profit Factor of ${pf.toFixed(2)} indicates highly efficient trade filtering. Gross profit significantly exceeds gross loss, validating the entry/exit logic.`
: `Profit Factor of ${pf.toFixed(2)} is ${pf >= 1 ? 'profitable but moderate' : 'below breakeven'}. Review entry conditions to improve signal quality.`,
},
{
key: 'weakness',
label: 'Weakness Detection',
text: Math.abs(mdd) > 0.1
? `MDD of ${pct(mdd)} suggests significant drawdown exposure. Consider adding an ATR-based trailing stop or reducing position sizing during volatile periods.`
: `Maximum drawdown remains controlled at ${pct(mdd)}. Current risk management parameters appear well-calibrated for the tested market conditions.`,
},
];
return (
<div className="qcc-ai">
<div className="qcc-panel qcc-panel--gold qcc-ai-panel">
<div className="qcc-ai-header">
<div>
<h2 className="qcc-section-title" style={{ color: '#ffd700' }}>Qualitative Insights via Local LLM (qwen3:4b)</h2>
<p className="qcc-section-sub">Powered by <span className="qcc-ai-hl">LangGraph strategy_tool</span> and <span className="qcc-ai-hl">backtest_tool</span> orchestration.</p>
</div>
<div className="qcc-ai-icon">
<svg viewBox="0 0 60 60" width="52" height="52" fill="none" stroke="#00e5ff" strokeWidth="1.5">
<circle cx="10" cy="10" r="6" /><circle cx="50" cy="10" r="6" />
<circle cx="10" cy="50" r="6" /><circle cx="50" cy="50" r="6" />
<circle cx="30" cy="30" r="7" />
<line x1="16" y1="10" x2="44" y2="10" /><line x1="10" y1="16" x2="10" y2="44" />
<line x1="16" y1="50" x2="44" y2="50" /><line x1="50" y1="16" x2="50" y2="44" />
<line x1="16" y1="16" x2="24" y2="24" /><line x1="44" y1="16" x2="36" y2="24" />
<line x1="16" y1="44" x2="24" y2="36" /><line x1="44" y1="44" x2="36" y2="36" />
</svg>
</div>
</div>
<div className="qcc-ai-terminal">
{lines.map(l => (
<div key={l.key} className="qcc-ai-line">
<span className="qcc-ai-prompt">&gt;</span>
<span className="qcc-ai-label"> {l.label}: </span>
<span className="qcc-ai-text">{l.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
09 Comparative — Strategy vs. Buy & Hold
══════════════════════════════════════════════════════════ */
function ComparativeView({ ctx }: { ctx: AnalysisReportContext }) {
const a = ctx.analysis;
const stratVals = ctx.equityCurve.map(p => p.equity);
const initial = a?.initialCapital ?? 10_000_000;
// Buy & Hold 시리즈 재구성 (첫 신호 진입가→마지막 신호 가격)
const benchVals = useMemo(() => {
const signals = ctx.signals;
if (signals.length < 2) return stratVals.map(() => initial);
const p0 = signals[0].price;
return ctx.equityCurve.map((_, i) => {
const s = signals[Math.min(i, signals.length - 1)];
return initial * (s.price / p0);
});
}, [ctx.signals, ctx.equityCurve, initial]);
const W = 600; const H = 300;
const pad = { l: 44, r: 80, t: 16, b: 28 };
const allVals = [...stratVals, ...benchVals];
const lo = Math.min(...allVals);
const hi = Math.max(...allVals);
const range = hi - lo || 1;
const xS = (i: number) => pad.l + (i / (stratVals.length - 1)) * (W - pad.l - pad.r);
const yS = (v: number) => H - pad.b - ((v - lo) / range) * (H - pad.t - pad.b);
const stratPath = stratVals.map((v, i) => `${i === 0 ? 'M' : 'L'}${xS(i).toFixed(1)},${yS(v).toFixed(1)}`).join(' ');
const benchPath = benchVals.map((v, i) => `${i === 0 ? 'M' : 'L'}${xS(i).toFixed(1)},${yS(v).toFixed(1)}`).join(' ');
const benchPct = a?.buyAndHoldReturnPct ?? (benchVals.length > 1 ? (benchVals[benchVals.length - 1] - initial) / initial : 0);
const stratRetFinal = a?.totalReturnPct ?? 0;
return (
<div className="qcc-comparative">
<div className="qcc-panel qcc-panel--cyan qcc-comp-header">
<h2 className="qcc-section-title">Strategy vs. Benchmark: Alpha Generation</h2>
<p className="qcc-section-sub">Relative Performance Analytics: Proving the value of active, algorithmically traded signals over passive market exposure.</p>
</div>
<div className="qcc-comp-chart-wrap qcc-panel qcc-panel--neutral">
<svg viewBox={`0 0 ${W} ${H}`} className="qcc-comp-svg">
{/* 그리드 */}
{[0, 25, 50, 75, 100].map(p => {
const v = lo + (p / 100) * range;
const y = yS(v);
const lbl = `${(((v - initial) / initial) * 100).toFixed(0)}%`;
return (
<g key={p}>
<line x1={pad.l} y1={y} x2={W - pad.r} y2={y} stroke="rgba(255,255,255,0.05)" />
<text x={pad.l - 4} y={y + 3} textAnchor="end" fontSize="8" fill="#556">{lbl}</text>
</g>
);
})}
{/* Bench area */}
<path d={`${benchPath} L${xS(benchVals.length - 1)},${H - pad.b} L${xS(0)},${H - pad.b} Z`}
fill="rgba(80,80,80,0.2)" />
<path d={benchPath} fill="none" stroke="#666" strokeWidth="1.5" strokeDasharray="4 3" />
{/* Strategy */}
<path
d={`${stratPath} L${xS(stratVals.length - 1)},${H - pad.b} L${xS(0)},${H - pad.b} Z`}
fill="rgba(0,230,118,0.08)" />
<path d={stratPath} fill="none" stroke="#00e676" strokeWidth="2.5"
style={{ filter: 'drop-shadow(0 0 4px #00e676)' }} />
{/* 우측 레이블 */}
<text x={W - pad.r + 6} y={yS(stratVals[stratVals.length - 1]) + 4} fontSize="9" fill="#00e676">
Return +{(stratRetFinal * 100).toFixed(0)}%
</text>
<text x={W - pad.r + 6} y={yS(stratVals[stratVals.length - 1]) + 16} fontSize="8" fill="#ff1744">
Max drawdown {pct(a?.maxDrawdownPct ?? 0)}
</text>
<text x={W - pad.r + 6} y={yS(stratVals[stratVals.length - 1]) + 28} fontSize="8" fill="#ffd700">
Sharpe {num(a?.sharpeRatio ?? 0)}
</text>
<text x={W - pad.r + 6} y={yS(benchVals[benchVals.length - 1]) + 4} fontSize="9" fill="#888">
Benchmark +{(benchPct * 100).toFixed(0)}%
</text>
</svg>
<div className="qcc-comp-legend">
<span className="qcc-comp-leg qcc-comp-leg--strat"> GoldenChart Strategy</span>
<span className="qcc-comp-leg qcc-comp-leg--bench"> Buy &amp; Hold Benchmark</span>
</div>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
10 Timing — Holding Periods & Entry Precision Scatter
══════════════════════════════════════════════════════════ */
function TimingView({ ctx }: { ctx: AnalysisReportContext }) {
const pts = useMemo(
() => buildHoldingPoints(ctx.signals, ctx.symbol),
[ctx.signals, ctx.symbol],
);
return (
<div className="qcc-timing">
<div className="qcc-panel qcc-panel--gold qcc-timing-left">
<div className="qcc-panel-title">Holding Periods &amp; Entry Precision</div>
<div className="qcc-eff-desc">
<p>
<span className="qcc-ai-label">Time-Efficiency: </span>
Analyzing how long capital is locked up to generate returns, evaluating{' '}
<span className="qcc-ai-hl">AVERAGE_HOLDING_PERIOD</span> against the execution type.
</p>
<p>
<span className="qcc-ai-label">Cluster Analysis: </span>
High concentration of short-duration trades signifies highly efficient, surgical market entries.
</p>
</div>
{pts.length > 0 && (
<div className="qcc-timing-stats">
<div className="qcc-timing-stat">
<span>Avg Duration</span>
<strong>{fmtDuration(pts.reduce((s, p) => s + p.durationMin * 60, 0) / pts.length)}</strong>
</div>
<div className="qcc-timing-stat">
<span>Total Trades</span>
<strong>{pts.length}</strong>
</div>
<div className="qcc-timing-stat">
<span>Avg P&amp;L</span>
<strong className="up">+{(pts.reduce((s, p) => s + p.pnlPct, 0) / pts.length).toFixed(2)}%</strong>
</div>
</div>
)}
</div>
<div className="qcc-panel qcc-panel--neutral qcc-timing-right">
<ScatterPlot
points={pts.map(p => ({ x: Math.min(p.durationMin, 200), y: Math.abs(p.pnlPct) }))}
/>
</div>
</div>
);
}
/* ══════════════════════════════════════════════════════════
메인 라우터
══════════════════════════════════════════════════════════ */
export default function AnalysisReportViews({ typeId, ctx, theme, compareBacktest }: Props) {
const body = useMemo(() => {
if (!ctx) return null;
switch (typeId) {
case 'standard': return <StandardView ctx={ctx} />;
case 'risk': return <RiskView ctx={ctx} />;
case 'efficiency': return <EfficiencyView ctx={ctx} />;
case 'temporal': return <TemporalView ctx={ctx} />;
case 'logs': return <LogsView ctx={ctx} />;
case 'allocation': return <AllocationView ctx={ctx} />;
case 'stats': return <StatsView ctx={ctx} />;
case 'ai-feedback': return <AiFeedbackView ctx={ctx} />;
case 'comparative': return <ComparativeView ctx={ctx} />;
case 'timing': return <TimingView ctx={ctx} />;
default: return <StandardView ctx={ctx} />;
}
}, [typeId, ctx, theme]);
return (
<ViewShell typeId={typeId} ctx={ctx}>
{body}
</ViewShell>
);
}