전략분봉 저장 에러 문제 수정
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import type { EquityPoint, TradeMarker } from '../../utils/backtestEquity';
|
||||
|
||||
interface Props {
|
||||
curve: EquityPoint[];
|
||||
markers: TradeMarker[];
|
||||
}
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtMonth(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
interface LineSegment {
|
||||
d: string;
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
const BacktestAssetChart: React.FC<Props> = memo(({ curve, markers }) => {
|
||||
const W = 800;
|
||||
const H = 280;
|
||||
const pad = { t: 22, r: 14, b: 26, l: 48 };
|
||||
const innerW = W - pad.l - pad.r;
|
||||
const innerH = H - pad.t - pad.b;
|
||||
|
||||
const chart = useMemo(() => {
|
||||
if (curve.length < 2) {
|
||||
const y = pad.t + innerH * 0.5;
|
||||
return {
|
||||
areaPath: `M${pad.l},${y} L${pad.l + innerW},${y} L${pad.l + innerW},${pad.t + innerH} L${pad.l},${pad.t + innerH} Z`,
|
||||
segments: [] as LineSegment[],
|
||||
yTicks: [0, 1],
|
||||
xLabels: [] as { x: number; label: string }[],
|
||||
markerPts: [] as Array<{ x: number; y: number; m: TradeMarker }>,
|
||||
toY: (_e: number) => y,
|
||||
};
|
||||
}
|
||||
|
||||
const times = curve.map(p => p.time);
|
||||
const equities = curve.map(p => p.equity);
|
||||
const tMin = Math.min(...times);
|
||||
const tMax = Math.max(...times);
|
||||
const eMin = Math.min(...equities) * 0.985;
|
||||
const eMax = Math.max(...equities) * 1.015;
|
||||
const tRange = tMax - tMin || 1;
|
||||
const eRange = eMax - eMin || 1;
|
||||
|
||||
const toX = (t: number) => pad.l + ((t - tMin) / tRange) * innerW;
|
||||
const toY = (e: number) => pad.t + innerH - ((e - eMin) / eRange) * innerH;
|
||||
|
||||
const linePath = curve.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p.time).toFixed(1)},${toY(p.equity).toFixed(1)}`).join(' ');
|
||||
const areaPath = `${linePath} L${toX(curve[curve.length - 1].time).toFixed(1)},${pad.t + innerH} L${toX(curve[0].time).toFixed(1)},${pad.t + innerH} Z`;
|
||||
|
||||
const segments: LineSegment[] = [];
|
||||
for (let i = 1; i < curve.length; i++) {
|
||||
segments.push({
|
||||
d: `M${toX(curve[i - 1].time).toFixed(1)},${toY(curve[i - 1].equity).toFixed(1)} L${toX(curve[i].time).toFixed(1)},${toY(curve[i].equity).toFixed(1)}`,
|
||||
up: curve[i].equity >= curve[i - 1].equity,
|
||||
});
|
||||
}
|
||||
|
||||
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(r => eMin + eRange * r);
|
||||
const xLabels: { x: number; label: string }[] = [];
|
||||
const step = Math.max(1, Math.floor(curve.length / 5));
|
||||
for (let i = 0; i < curve.length; i += step) {
|
||||
xLabels.push({ x: toX(curve[i].time), label: i === 0 ? fmtMonth(curve[i].time) : fmtDate(curve[i].time) });
|
||||
}
|
||||
const last = curve[curve.length - 1];
|
||||
if (!xLabels.some(l => l.label === fmtDate(last.time))) {
|
||||
xLabels.push({ x: toX(last.time), label: fmtDate(last.time) });
|
||||
}
|
||||
|
||||
const markerPts = markers.map(m => ({ x: toX(m.time), y: toY(m.equity), m }));
|
||||
|
||||
return { areaPath, segments, yTicks, xLabels, markerPts, toY };
|
||||
}, [curve, markers, innerH, innerW, pad.l, pad.t]);
|
||||
|
||||
const fmtY = (v: number) => {
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (v >= 10_000) return `${Math.round(v / 10_000)}만`;
|
||||
return Math.round(v).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="btd-chart-wrap">
|
||||
<svg className="btd-chart-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="btd-area-grad-up" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(34,197,94,0.28)" />
|
||||
<stop offset="100%" stopColor="rgba(34,197,94,0.02)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{[0.25, 0.5, 0.75].map(r => {
|
||||
const y = pad.t + innerH * (1 - r);
|
||||
return <line key={r} x1={pad.l} y1={y} x2={W - pad.r} y2={y} stroke="rgba(201,162,39,0.07)" strokeWidth="1" />;
|
||||
})}
|
||||
|
||||
<text x={pad.l - 8} y={pad.t - 6} textAnchor="start" fill="#6272a4" fontSize="8">자본금</text>
|
||||
|
||||
{chart.yTicks.map(v => (
|
||||
<text
|
||||
key={v}
|
||||
x={pad.l - 4}
|
||||
y={chart.toY(v)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
fill="#6272a4"
|
||||
fontSize="8.5"
|
||||
>
|
||||
{fmtY(v)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<path d={chart.areaPath} fill="url(#btd-area-grad-up)" />
|
||||
{chart.segments.map((seg, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={seg.d}
|
||||
fill="none"
|
||||
stroke={seg.up ? '#22c55e' : '#ef4444'}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
|
||||
{chart.markerPts.map(({ x, y, m }, i) => (
|
||||
<g key={`${m.time}-${m.type}-${i}`}>
|
||||
{m.type === 'buy' ? (
|
||||
<>
|
||||
<polygon points={`${x},${y - 9} ${x - 4},${y - 2} ${x + 4},${y - 2}`} fill="#22c55e" />
|
||||
<text x={x} y={y - 12} textAnchor="middle" fill="#22c55e" fontSize="7.5" fontWeight="700">
|
||||
{fmtDate(m.time).slice(5)}
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<polygon points={`${x},${y + 9} ${x - 4},${y + 2} ${x + 4},${y + 2}`} fill="#ef4444" />
|
||||
<text x={x} y={y + 20} textAnchor="middle" fill={m.pnlPct != null && m.pnlPct >= 0 ? '#22c55e' : '#ef4444'} fontSize="7.5" fontWeight="700">
|
||||
{m.pnlPct != null ? `${m.pnlPct >= 0 ? '+' : ''}${(m.pnlPct * 100).toFixed(1)}%` : '매도'}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{chart.xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={H - 5} textAnchor="middle" fill="#6272a4" fontSize="8">
|
||||
{l.label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BacktestAssetChart.displayName = 'BacktestAssetChart';
|
||||
export default BacktestAssetChart;
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* BacktestResultDashboard — 첨부 이미지 스타일 백테스팅 결과 대시보드
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import type { BacktestAnalysis, BacktestSignal } from '../../utils/backendApi';
|
||||
import { buildEquityFromSignals, type TradeHistoryRow } from '../../utils/backtestEquity';
|
||||
import BacktestAssetChart from './BacktestAssetChart';
|
||||
|
||||
export interface BacktestResultDashboardProps {
|
||||
analysis: BacktestAnalysis | null;
|
||||
signals: BacktestSignal[];
|
||||
strategyName: string;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
barCount: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
const pct = (v: number, dec = 1) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '0.0%';
|
||||
|
||||
const pctAbs = (v: number, dec = 0) =>
|
||||
isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '–';
|
||||
|
||||
const num = (v: number, dec = 2) =>
|
||||
isFinite(v) ? v.toFixed(dec) : '–';
|
||||
|
||||
function fmtDateTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}-${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 SectionTitle({ title, sub }: { title: string; sub?: string }) {
|
||||
return (
|
||||
<div className="btd-section-head">
|
||||
<span className="btd-section-bar" />
|
||||
<div>
|
||||
<h2 className="btd-section-title">{title}</h2>
|
||||
{sub && <p className="btd-section-sub">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BacktestResultDashboard({
|
||||
analysis,
|
||||
signals,
|
||||
strategyName: _strategyName,
|
||||
symbol,
|
||||
timeframe: _timeframe,
|
||||
barCount: _barCount,
|
||||
createdAt: _createdAt,
|
||||
}: BacktestResultDashboardProps) {
|
||||
const a = analysis;
|
||||
|
||||
const { curve, markers, trades } = useMemo(() => {
|
||||
if (!a) return { curve: [], markers: [], trades: [] };
|
||||
return buildEquityFromSignals(signals, a.initialCapital, symbol.startsWith('KRW-') ? symbol : `KRW-${symbol.replace(/^KRW-/, '')}`);
|
||||
}, [a, signals, symbol]);
|
||||
|
||||
if (!a) {
|
||||
return (
|
||||
<div className="btd-empty">
|
||||
<span>📊</span>
|
||||
<p>결과 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const profitFactor = a.profitLossRatio > 0 ? a.profitLossRatio : (
|
||||
a.grossLoss !== 0 ? Math.abs(a.grossProfit / a.grossLoss) : 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="btd-dashboard">
|
||||
<div className="btd-section btd-section--kpi">
|
||||
<SectionTitle title="핵심 성과 지표" sub="Core KPIs" />
|
||||
<div className="btd-kpi-grid">
|
||||
<div className="btd-kpi-card">
|
||||
<div className="btd-kpi-label">총 수익률 (ROI)</div>
|
||||
<div className={`btd-kpi-value ${a.totalReturnPct >= 0 ? 'up' : 'down'}`}>
|
||||
{pct(a.totalReturnPct)}
|
||||
</div>
|
||||
<p className="btd-kpi-desc">초기 자본 대비 최종 수익</p>
|
||||
</div>
|
||||
|
||||
<div className="btd-kpi-card">
|
||||
<div className="btd-kpi-label">최대 낙폭 (MDD)</div>
|
||||
<div className={`btd-kpi-value ${a.maxDrawdownPct <= 0 ? 'down' : ''}`}>
|
||||
{pct(a.maxDrawdownPct)}
|
||||
</div>
|
||||
<p className="btd-kpi-desc">기간 중 최대 손실폭</p>
|
||||
</div>
|
||||
|
||||
<div className="btd-kpi-card btd-kpi-card--sharpe">
|
||||
<div className="btd-kpi-label">샤프 비율 (Sharpe Ratio)</div>
|
||||
<div className="btd-kpi-row">
|
||||
<span className={`btd-kpi-value ${a.sharpeRatio >= 1 ? 'up' : a.sharpeRatio >= 0 ? 'neutral' : 'down'}`}>{num(a.sharpeRatio)}</span>
|
||||
<span className="btd-kpi-icon" aria-hidden>⚖</span>
|
||||
</div>
|
||||
<p className="btd-kpi-desc">위험 대비 초과 수익</p>
|
||||
</div>
|
||||
|
||||
<div className="btd-kpi-card btd-kpi-card--dual">
|
||||
<div className="btd-kpi-label">승률 및 Profit Factor</div>
|
||||
<div className="btd-kpi-dual">
|
||||
<div className="btd-kpi-value up">{pctAbs(a.winRate)}</div>
|
||||
<div className="btd-kpi-divider" />
|
||||
<div className="btd-kpi-value gold">{num(profitFactor)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="btd-section btd-section--chart">
|
||||
<SectionTitle title="자산 곡선 및 차트 시각화" sub="Asset Curve" />
|
||||
<div className="btd-chart-card">
|
||||
<BacktestAssetChart curve={curve} markers={markers} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="btd-section btd-section--table">
|
||||
<SectionTitle title="상세 거래 내역" sub="Trade History Table" />
|
||||
<div className="btd-table-card">
|
||||
<div className="btd-table-scroll">
|
||||
<table className="btd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>거래 일시</th>
|
||||
<th>종목</th>
|
||||
<th>유형</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>손익 (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="btd-table-empty">거래 내역이 없습니다.</td>
|
||||
</tr>
|
||||
) : trades.map((t: TradeHistoryRow) => (
|
||||
<tr key={t.id}>
|
||||
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
|
||||
<td>{t.symbol.startsWith('KRW-') ? t.symbol : `KRW-${t.symbol}`}</td>
|
||||
<td className={t.side === 'buy' ? 'buy' : 'sell'}>
|
||||
{t.side === 'buy' ? '매수' : '매도'}
|
||||
</td>
|
||||
<td>{Math.round(t.price).toLocaleString()}</td>
|
||||
<td>{t.quantity.toFixed(4)}</td>
|
||||
<td className={t.pnlPct != null ? (t.pnlPct >= 0 ? 'up' : 'down') : ''}>
|
||||
{t.pnlPct != null ? pct(t.pnlPct) : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BacktestResultDashboard;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { equityToSparkline, sparklinePath } from '../../utils/backtestEquity';
|
||||
import type { EquityPoint } from '../../utils/backtestEquity';
|
||||
|
||||
interface Props {
|
||||
curve: EquityPoint[];
|
||||
positive?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const BacktestSparkline: React.FC<Props> = memo(({
|
||||
curve,
|
||||
positive = true,
|
||||
width = 72,
|
||||
height = 28,
|
||||
}) => {
|
||||
const path = useMemo(() => {
|
||||
const values = equityToSparkline(curve);
|
||||
return sparklinePath(values, width, height);
|
||||
}, [curve, width, height]);
|
||||
|
||||
const stroke = positive ? '#22c55e' : '#ef4444';
|
||||
const fill = positive ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
|
||||
|
||||
return (
|
||||
<svg className="btd-sparkline" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<path d={`${path} L${width},${height} L0,${height} Z`} fill={fill} stroke="none" />
|
||||
<path d={path} fill="none" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
BacktestSparkline.displayName = 'BacktestSparkline';
|
||||
export default BacktestSparkline;
|
||||
Reference in New Issue
Block a user