1002 lines
44 KiB
TypeScript
1002 lines
44 KiB
TypeScript
/**
|
||
* 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 & P&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&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&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&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 & 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 & Time</th>
|
||
<th>Symbol</th>
|
||
<th>Side</th>
|
||
<th>Entry Price</th>
|
||
<th>Exit Price</th>
|
||
<th>Qty</th>
|
||
<th>P&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">></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 & 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 & 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&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>
|
||
);
|
||
}
|