전략분봉 저장 에러 문제 수정
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* BacktestHistoryPage
|
||||
* ─────────────────────────────────────────────────────
|
||||
* [좌측] 체크박스 선택 + 삭제 가능한 이력 목록
|
||||
* [우측] 카드 기반 대시보드 결과 뷰
|
||||
* BacktestHistoryPage — 첨부 이미지 스타일 (타임라인 이력 + 대시보드)
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
loadBacktestResults,
|
||||
deleteBacktestResult,
|
||||
@@ -12,147 +9,75 @@ import {
|
||||
type BacktestAnalysis,
|
||||
type BacktestSignal,
|
||||
} from '../utils/backendApi';
|
||||
import { BacktestDashboard } from './BacktestResultModal';
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────────────────
|
||||
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
||||
import { BacktestResultDashboard } from './backtest/BacktestResultDashboard';
|
||||
import BacktestSparkline from './backtest/BacktestSparkline';
|
||||
|
||||
const pct = (v: number) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '–';
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%';
|
||||
|
||||
const fmtDateTime = (iso: string) => {
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
|
||||
time: `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`,
|
||||
};
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// ── 아이콘 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const IcTrash = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<polyline points="1,3 13,3"/><line x1="5" y1="3" x2="5" y2="1"/><line x1="9" y1="3" x2="9" y2="1"/>
|
||||
<line x1="6.5" y1="1" x2="7.5" y2="1"/>
|
||||
<path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
|
||||
<line x1="5.5" y1="6" x2="5.5" y2="10"/><line x1="7" y1="6" x2="7" y2="10"/><line x1="8.5" y1="6" x2="8.5" y2="10"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcTrashAll = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<polyline points="1,3 13,3"/><path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
|
||||
<line x1="5.5" y1="6" x2="5.5" y2="10"/>
|
||||
<line x1="8.5" y1="6" x2="8.5" y2="10"/>
|
||||
<line x1="4" y1="1.5" x2="10" y2="1.5" strokeDasharray="1.5 1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcRefresh = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M12 7 A5 5 0 1 1 9.5 2.5"/>
|
||||
<polyline points="10,1 10,4 13,4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── 목록 아이템 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ListItemProps {
|
||||
r: BacktestResultRecord;
|
||||
checked: boolean;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
function parseDetail(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 */ }
|
||||
return { analysis, signals };
|
||||
}
|
||||
|
||||
function ListItem({ r, checked, active, onToggle, onClick }: ListItemProps) {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const dt = fmtDateTime(r.createdAt);
|
||||
return (
|
||||
<div
|
||||
className={`bh-item ${active ? 'bh-item--active' : ''} ${checked ? 'bh-item--checked' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="bh-item-cb-wrap" onClick={e => { e.stopPropagation(); onToggle(); }}>
|
||||
<div className={`bh-cb ${checked ? 'bh-cb--on' : ''}`}>
|
||||
{checked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
|
||||
</div>
|
||||
</div>
|
||||
interface TimelineItemProps {
|
||||
r: BacktestResultRecord;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
<div className="bh-item-body">
|
||||
<div className="bh-item-row1">
|
||||
<span className="bh-item-name">{r.strategyName || '전략 없음'}</span>
|
||||
<span className={`bh-item-ret ${ret > 0 ? 'bh-pos' : ret < 0 ? 'bh-neg' : ''}`}>{pct(ret)}</span>
|
||||
</div>
|
||||
<div className="bh-item-row2">
|
||||
<span className="bh-item-symbol">{r.symbol}</span>
|
||||
<span className="bh-item-sep">·</span>
|
||||
<span className="bh-item-tf">{r.timeframe}</span>
|
||||
<span className="bh-item-sep">·</span>
|
||||
<span className="bh-item-trades">{r.totalTrades}회</span>
|
||||
</div>
|
||||
<div className="bh-item-row3">
|
||||
<span className="bh-item-date">{dt.date}</span>
|
||||
<span className="bh-item-time">{dt.time}</span>
|
||||
{(r.winRate ?? 0) > 0 && (
|
||||
<span className="bh-item-wr">승률 {((r.winRate ?? 0) * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
function TimelineItem({ r, active, onClick }: TimelineItemProps) {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const positive = ret >= 0;
|
||||
|
||||
const sparkCurve = useMemo(() => {
|
||||
const { analysis, signals } = parseDetail(r);
|
||||
const cap = analysis?.initialCapital ?? 10_000_000;
|
||||
return buildEquityFromSignals(signals, cap, r.symbol.replace(/^KRW-/, '')).curve;
|
||||
}, [r]);
|
||||
|
||||
return (
|
||||
<button type="button" className={`btd-timeline-item${active ? ' btd-timeline-item--active' : ''}`} onClick={onClick}>
|
||||
<span className="btd-timeline-node" />
|
||||
<div className="btd-timeline-card">
|
||||
<div className="btd-timeline-top">
|
||||
<span className="btd-timeline-name">{r.strategyName || '전략 없음'}</span>
|
||||
<BacktestSparkline curve={sparkCurve} positive={positive} />
|
||||
</div>
|
||||
<span className="btd-timeline-date">{fmtDate(r.createdAt)}</span>
|
||||
<span className={`btd-timeline-roi${positive ? ' up' : ' down'}`}>
|
||||
ROI {pct(ret)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 메인 페이지 ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BacktestHistoryPage() {
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── 로드 ────────────────────────────────────────────────────────────────
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const list = await loadBacktestResults();
|
||||
setRecords(list);
|
||||
if (list.length > 0) setSelected(prev => prev ?? list[0]);
|
||||
setSelected(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// ── 체크박스 ──────────────────────────────────────────────────────────
|
||||
const toggleCheck = useCallback((id: number) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setChecked(prev =>
|
||||
prev.size === records.length
|
||||
? new Set()
|
||||
: new Set(records.map(r => r.id))
|
||||
);
|
||||
}, [records]);
|
||||
|
||||
const allChecked = records.length > 0 && checked.size === records.length;
|
||||
const someChecked = checked.size > 0 && !allChecked;
|
||||
|
||||
// ── 삭제 ─────────────────────────────────────────────────────────────
|
||||
const deleteSelected = useCallback(async () => {
|
||||
if (checked.size === 0) return;
|
||||
if (!window.confirm(`선택한 ${checked.size}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||||
await Promise.all([...checked].map(id => deleteBacktestResult(id)));
|
||||
setRecords(prev => {
|
||||
const next = prev.filter(r => !checked.has(r.id));
|
||||
if (selected && checked.has(selected.id)) setSelected(next[0] ?? null);
|
||||
return next;
|
||||
});
|
||||
setChecked(new Set());
|
||||
}, [checked, selected]);
|
||||
useEffect(() => { void fetchList(); }, [fetchList]);
|
||||
|
||||
const deleteAll = useCallback(async () => {
|
||||
if (records.length === 0) return;
|
||||
@@ -160,95 +85,37 @@ export function BacktestHistoryPage() {
|
||||
await Promise.all(records.map(r => deleteBacktestResult(r.id)));
|
||||
setRecords([]);
|
||||
setSelected(null);
|
||||
setChecked(new Set());
|
||||
}, [records]);
|
||||
|
||||
// ── 상세 데이터 파싱 ──────────────────────────────────────────────────
|
||||
const getDetail = (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 (_) {}
|
||||
return { analysis, signals };
|
||||
};
|
||||
const selectedDetail = selected ? parseDetail(selected) : null;
|
||||
|
||||
// ── 렌더 ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="bh-page">
|
||||
|
||||
{/* ── 좌측 목록 ── */}
|
||||
<aside className="bh-sidebar">
|
||||
|
||||
{/* 사이드바 헤더 */}
|
||||
<div className="bh-sidebar-header">
|
||||
<div className="bh-sidebar-title-row">
|
||||
<span className="bh-sidebar-title">백테스팅 이력</span>
|
||||
<span className="bh-sidebar-count">{records.length}</span>
|
||||
</div>
|
||||
<div className="bh-sidebar-actions">
|
||||
<button
|
||||
className="bh-act-btn"
|
||||
title="선택 항목 삭제"
|
||||
disabled={checked.size === 0}
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
<IcTrash />
|
||||
{checked.size > 0 && <span className="bh-act-badge">{checked.size}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="bh-act-btn bh-act-btn--danger"
|
||||
title="전체 삭제"
|
||||
disabled={records.length === 0}
|
||||
onClick={deleteAll}
|
||||
>
|
||||
<IcTrashAll />
|
||||
</button>
|
||||
<button
|
||||
className="bh-act-btn"
|
||||
title="새로고침"
|
||||
onClick={fetchList}
|
||||
>
|
||||
<IcRefresh />
|
||||
</button>
|
||||
<div className="btd-page">
|
||||
<aside className="btd-sidebar">
|
||||
<div className="btd-sidebar-head">
|
||||
<span className="btd-sidebar-bar" />
|
||||
<h2 className="btd-sidebar-title">백테스팅 실행 이력</h2>
|
||||
<div className="btd-sidebar-actions">
|
||||
<button type="button" className="btd-icon-btn" title="새로고침" onClick={() => void fetchList()}>↻</button>
|
||||
<button type="button" className="btd-icon-btn btd-icon-btn--danger" title="전체 삭제" disabled={records.length === 0} onClick={() => void deleteAll()}>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전체 선택 바 */}
|
||||
{records.length > 0 && (
|
||||
<div className="bh-select-all-bar" onClick={toggleAll}>
|
||||
<div className={`bh-cb ${allChecked ? 'bh-cb--on' : someChecked ? 'bh-cb--indeterminate' : ''}`}>
|
||||
{allChecked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
|
||||
{someChecked && <div className="bh-cb-dash"/>}
|
||||
</div>
|
||||
<span className="bh-select-all-label">
|
||||
{someChecked || allChecked ? `${checked.size}개 선택됨` : '전체 선택'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목록 */}
|
||||
{loading ? (
|
||||
<div className="bh-loading">
|
||||
<div className="bh-loading-spinner"/>
|
||||
<span>로딩 중…</span>
|
||||
</div>
|
||||
<div className="btd-sidebar-empty">로딩 중…</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="bh-empty-list">
|
||||
<div className="bh-empty-icon">📊</div>
|
||||
<div className="btd-sidebar-empty">
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="bh-empty-sub">차트 화면에서 전략을 선택하고<br/>백테스팅을 실행하세요.</p>
|
||||
<p className="btd-sidebar-hint">차트 화면에서 전략을 선택하고<br />백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bh-list">
|
||||
<div className="btd-timeline">
|
||||
<span className="btd-timeline-line" />
|
||||
{records.map(r => (
|
||||
<ListItem
|
||||
<TimelineItem
|
||||
key={r.id}
|
||||
r={r}
|
||||
checked={checked.has(r.id)}
|
||||
active={selected?.id === r.id}
|
||||
onToggle={() => toggleCheck(r.id)}
|
||||
onClick={() => setSelected(r)}
|
||||
/>
|
||||
))}
|
||||
@@ -256,28 +123,20 @@ export function BacktestHistoryPage() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* ── 우측 대시보드 ── */}
|
||||
<main className="bh-content">
|
||||
{selected ? (
|
||||
(() => {
|
||||
const { analysis, signals } = getDetail(selected);
|
||||
return (
|
||||
<div className="bh-detail">
|
||||
<BacktestDashboard
|
||||
analysis={analysis}
|
||||
signals={signals}
|
||||
strategyName={selected.strategyName}
|
||||
symbol={selected.symbol}
|
||||
timeframe={selected.timeframe}
|
||||
barCount={selected.barCount}
|
||||
createdAt={selected.createdAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
<main className="btd-main">
|
||||
{selected && selectedDetail ? (
|
||||
<BacktestResultDashboard
|
||||
analysis={selectedDetail.analysis}
|
||||
signals={selectedDetail.signals}
|
||||
strategyName={selected.strategyName}
|
||||
symbol={selected.symbol}
|
||||
timeframe={selected.timeframe}
|
||||
barCount={selected.barCount}
|
||||
createdAt={selected.createdAt}
|
||||
/>
|
||||
) : (
|
||||
<div className="bh-no-selection">
|
||||
<div className="bh-no-sel-icon">📈</div>
|
||||
<div className="btd-empty">
|
||||
<span>📈</span>
|
||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
saveLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
|
||||
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
|
||||
|
||||
interface Strategy {
|
||||
id: number;
|
||||
@@ -91,6 +94,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
const monCount = monitoredMarkets.length;
|
||||
const selectedStrategy = strategies.find(s => s.id === stratId);
|
||||
|
||||
const candleType = settings.candleType ?? '1m';
|
||||
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
||||
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
|
||||
? '보유 자산 기준'
|
||||
@@ -156,16 +160,24 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
<span className="lsp-info-label">모니터링</span>
|
||||
<span className="lsp-info-val lsp-monitor-chips">
|
||||
{monitoredMarkets.slice(0, 8).map(m => (
|
||||
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
|
||||
<span key={m} className="lsp-monitor-chip" title={m}>
|
||||
{getKoreanName(m)}
|
||||
</span>
|
||||
))}
|
||||
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{isOn && selectedStrategy && (
|
||||
<div className="lsp-info-row">
|
||||
<span className="lsp-info-label">적용 전략</span>
|
||||
<span className="lsp-info-val">{selectedStrategy.name}</span>
|
||||
</div>
|
||||
<>
|
||||
<div className="lsp-info-row">
|
||||
<span className="lsp-info-label">적용 전략</span>
|
||||
<span className="lsp-info-val">{selectedStrategy.name}</span>
|
||||
</div>
|
||||
<div className="lsp-info-row">
|
||||
<span className="lsp-info-label">평가 분봉</span>
|
||||
<span className="lsp-info-val">{candleType}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,6 +211,20 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
|
||||
<span className="lsp-field-label">전략 평가 분봉</span>
|
||||
<select
|
||||
className="lsp-select"
|
||||
disabled={!isOn}
|
||||
value={candleType}
|
||||
onChange={e => persist({ candleType: e.target.value })}
|
||||
>
|
||||
{CANDLE_TYPES.map(ct => (
|
||||
<option key={ct} value={ct}>{ct}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
|
||||
<span className="lsp-section-label">실행 방식</span>
|
||||
<div className="lsp-option-group">
|
||||
@@ -276,7 +302,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
|
||||
<span className="lsp-status-text">
|
||||
{stratId
|
||||
? `${selectedStrategy?.name ?? '전략'} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
|
||||
? `${selectedStrategy?.name ?? '전략'} · ${candleType} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
|
||||
: '전략을 선택하세요'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 모의투자 화면 — 계좌 요약, 보유종목, 체결내역
|
||||
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
@@ -10,16 +10,31 @@ import {
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { computePaperMetrics } from '../utils/paperMetrics';
|
||||
import { fmtKrw } from './TradeOrderPanel';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
import type { TradeOrderFillRequest } from '../types';
|
||||
import PaperMiniChart from './paper/PaperMiniChart';
|
||||
import PaperIndicatorPanel from './paper/PaperIndicatorPanel';
|
||||
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
|
||||
|
||||
type TabId = 'overview' | 'positions' | 'trades';
|
||||
const COIN_COLORS: Record<string, string> = {
|
||||
BTC: '#f7931a', ETH: '#627eea', XRP: '#23292f', SOL: '#9945ff',
|
||||
DOGE: '#c2a633', ADA: '#0033ad', DOT: '#e6007a', AVAX: '#e84142',
|
||||
};
|
||||
const DONUT_COLORS = ['#3f7ef5', '#22c55e', '#a78bfa', '#fbbf24', '#38bdf8', '#64748b'];
|
||||
|
||||
type HistoryTab = 'open' | 'recent';
|
||||
type RightTab = 'trade' | 'orderbook';
|
||||
|
||||
interface Props {
|
||||
tickers?: Map<string, { tradePrice: number | null }>;
|
||||
onGoChart?: (market: string) => void;
|
||||
/** 외부 체결(알림 모달 등) 후 목록 갱신 트리거 */
|
||||
refreshKey?: number;
|
||||
defaultMarket?: string;
|
||||
paperTradingEnabled?: boolean;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
}
|
||||
|
||||
function buildMarkPrices(
|
||||
@@ -27,12 +42,10 @@ function buildMarkPrices(
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): Record<string, number> {
|
||||
const m: Record<string, number> = {};
|
||||
if (summary?.positions) {
|
||||
for (const p of summary.positions) {
|
||||
const t = tickers?.get(p.symbol)?.tradePrice;
|
||||
if (t != null && t > 0) m[p.symbol] = t;
|
||||
}
|
||||
}
|
||||
summary?.positions?.forEach(p => {
|
||||
const t = tickers?.get(p.symbol)?.tradePrice;
|
||||
if (t != null && t > 0) m[p.symbol] = t;
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -41,277 +54,313 @@ function positionPriceKey(
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): string {
|
||||
if (!positions?.length) return '';
|
||||
return positions
|
||||
.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`)
|
||||
.join('|');
|
||||
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({ tickers, onGoChart, refreshKey = 0 }) => {
|
||||
const [tab, setTab] = useState<TabId>('overview');
|
||||
function coinCode(symbol: string): string {
|
||||
return symbol.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function coinIcon(code: string): string {
|
||||
return code.slice(0, 1);
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({
|
||||
tickers,
|
||||
refreshKey = 0,
|
||||
defaultMarket = 'KRW-BTC',
|
||||
paperTradingEnabled = true,
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperOrderFilled,
|
||||
}) => {
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const fillSeqRef = useRef(0);
|
||||
|
||||
const tickersRef = useRef(tickers);
|
||||
tickersRef.current = tickers;
|
||||
|
||||
const summaryRef = useRef(summary);
|
||||
summaryRef.current = summary;
|
||||
|
||||
const loadData = useCallback(async (opts?: { showSpinner?: boolean }) => {
|
||||
const showSpinner = opts?.showSpinner ?? false;
|
||||
if (showSpinner) setRefreshing(true);
|
||||
setError(null);
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const base = await loadPaperSummary();
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
const full = Object.keys(marks).length > 0
|
||||
? await loadPaperSummary(marks)
|
||||
: base;
|
||||
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
|
||||
setSummary(full);
|
||||
setTrades(await loadPaperTrades());
|
||||
lastPriceKeyRef.current = positionPriceKey(full?.positions, tickersRef.current);
|
||||
} catch {
|
||||
if (showSpinner) setError('모의투자 데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (showSpinner) setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 최초 진입·수동 새로고침 */
|
||||
const refresh = useCallback(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
useEffect(() => { void loadData(); }, [loadData]);
|
||||
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshKey <= 0) return;
|
||||
void loadData();
|
||||
}, [refreshKey, loadData]);
|
||||
|
||||
/** 보유 종목 시세만 조용히 갱신 (tickers Map 참조 변경마다 전체 로딩 방지) */
|
||||
const priceKey = positionPriceKey(summary?.positions, tickers);
|
||||
const lastPriceKeyRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!priceKey || initialLoading) return;
|
||||
if (priceKey === lastPriceKeyRef.current) return;
|
||||
if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return;
|
||||
lastPriceKeyRef.current = priceKey;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const base = summaryRef.current;
|
||||
if (!base?.positions?.length) return;
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
if (Object.keys(marks).length === 0) return;
|
||||
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
|
||||
if (!Object.keys(marks).length) return;
|
||||
try {
|
||||
const full = await loadPaperSummary(marks);
|
||||
if (!cancelled) setSummary(full);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [priceKey, initialLoading]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 보유 종목과 체결 이력이 모두 삭제됩니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) {
|
||||
setSummary(res);
|
||||
setTrades([]);
|
||||
lastPriceKeyRef.current = '';
|
||||
alert('모의투자 계좌가 초기화되었습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
|
||||
const s = summary;
|
||||
const retUp = (s?.totalReturnPct ?? 0) >= 0;
|
||||
const showPanelLoading = initialLoading;
|
||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||
|
||||
const posQty = useMemo(() => {
|
||||
const p = s?.positions?.find(x => x.symbol === selectedMarket);
|
||||
return p?.quantity ?? 0;
|
||||
}, [s?.positions, selectedMarket]);
|
||||
|
||||
const allocation = useMemo(() => {
|
||||
if (!s) return [] as { label: string; value: number; pct: number }[];
|
||||
const items: { label: string; value: number }[] = [];
|
||||
if (s.cashBalance > 0) items.push({ label: 'KRW', value: s.cashBalance });
|
||||
s.positions.forEach(p => {
|
||||
const v = p.evalAmount ?? p.quantity * (p.markPrice ?? p.avgPrice);
|
||||
if (v > 0) items.push({ label: coinCode(p.symbol), value: v });
|
||||
});
|
||||
const total = items.reduce((a, x) => a + x.value, 0) || 1;
|
||||
return items.map(x => ({ ...x, pct: (x.value / total) * 100 }));
|
||||
}, [s]);
|
||||
|
||||
const donutStyle = useMemo(() => {
|
||||
if (!allocation.length) return { background: '#1f2335' };
|
||||
let acc = 0;
|
||||
const stops = allocation.map((a, i) => {
|
||||
const start = acc;
|
||||
acc += a.pct;
|
||||
return `${DONUT_COLORS[i % DONUT_COLORS.length]} ${start}% ${acc}%`;
|
||||
});
|
||||
return { background: `conic-gradient(${stops.join(', ')})` };
|
||||
}, [allocation]);
|
||||
|
||||
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||
const side = rowType === 'ask' ? 'buy' : 'sell';
|
||||
fillSeqRef.current += 1;
|
||||
const req: TradeOrderFillRequest = {
|
||||
market: selectedMarket,
|
||||
price,
|
||||
side,
|
||||
seq: fillSeqRef.current,
|
||||
};
|
||||
if (side === 'buy') setFillBuy(req); else setFillSell(req);
|
||||
setRightTab('trade');
|
||||
}, [selectedMarket]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) { setSummary(res); setTrades([]); }
|
||||
}, []);
|
||||
|
||||
if (initialLoading) {
|
||||
return <div className="ptd-page ptd-page--loading">모의투자 대시보드 로딩…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="paper-page">
|
||||
<header className="paper-header">
|
||||
<div>
|
||||
<h1 className="paper-title">모의투자</h1>
|
||||
<p className="paper-subtitle">
|
||||
실시간 차트·전략 시그널과 연동된 가상 계좌입니다. 실제 거래소 주문은 발생하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="paper-header-actions">
|
||||
<button type="button" className="paper-btn" onClick={refresh} disabled={refreshing}>
|
||||
{refreshing ? '새로고침 중…' : '새로고침'}
|
||||
</button>
|
||||
<button type="button" className="paper-btn paper-btn--danger" onClick={handleReset}>
|
||||
계좌 초기화
|
||||
</button>
|
||||
<div className="ptd-page">
|
||||
<header className="ptd-topbar">
|
||||
<h1 className="ptd-title">모의투자</h1>
|
||||
<div className="ptd-topbar-actions">
|
||||
<button type="button" className="ptd-btn" onClick={() => void loadData()}>새로고침</button>
|
||||
<button type="button" className="ptd-btn ptd-btn--danger" onClick={handleReset}>계좌 초기화</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!s?.enabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--warn">
|
||||
모의투자가 꺼져 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.
|
||||
</div>
|
||||
{!s?.enabled && (
|
||||
<div className="ptd-banner ptd-banner--warn">모의투자가 꺼져 있습니다. 설정에서 활성화하세요.</div>
|
||||
)}
|
||||
|
||||
{s?.enabled && s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 ON — 실시간 전략 시그널(BUY/SELL) 발생 시 모의 계좌에 자동 체결됩니다.
|
||||
(매수: 가용현금 {s.autoTradeBudgetPct}% · 매도: 보유 전량)
|
||||
<section className="ptd-metrics">
|
||||
<div className="ptd-metrics-grid">
|
||||
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
|
||||
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
|
||||
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
|
||||
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{s?.enabled && !s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 OFF — 시그널은 알림만 표시되며, 매매는 차트 우측 매수·매도에서 수동으로 진행합니다.
|
||||
</div>
|
||||
)}
|
||||
<div className="ptd-main">
|
||||
<aside className="ptd-col ptd-col--left">
|
||||
<div className="ptd-card ptd-asset-card">
|
||||
<div className="ptd-card-label">실시간 자산 현황</div>
|
||||
<div className="ptd-asset-total">{s ? fmtKrw(s.totalAsset) : '—'} <span>KRW</span></div>
|
||||
<div className={`ptd-asset-ret ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${retUp ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</div>
|
||||
<div className="ptd-asset-sub">초기자본 {s ? fmtKrw(s.initialCapital) : '—'} KRW</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="paper-banner paper-banner--error">{error}</div>}
|
||||
<div className="ptd-card">
|
||||
<div className="ptd-card-head">보유 포트폴리오</div>
|
||||
<table className="ptd-table">
|
||||
<thead>
|
||||
<tr><th>자산</th><th>현재가</th><th>수량</th><th>손익</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={4} className="ptd-muted">보유 종목 없음</td></tr>
|
||||
) : s!.positions.map(p => {
|
||||
const code = coinCode(p.symbol);
|
||||
const up = (p.profitLoss ?? 0) >= 0;
|
||||
return (
|
||||
<tr key={p.symbol} className={selectedMarket === p.symbol ? 'ptd-row--sel' : ''} onClick={() => setSelectedMarket(p.symbol)}>
|
||||
<td>
|
||||
<span className="ptd-coin" style={{ background: COIN_COLORS[code] ?? '#64748b' }}>{coinIcon(code)}</span>
|
||||
{code}
|
||||
</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<td>{p.quantity.toFixed(4)}</td>
|
||||
<td className={up ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'}
|
||||
{p.profitLossPct != null && <small> ({p.profitLossPct.toFixed(1)}%)</small>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="paper-summary-grid">
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 평가자산</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.totalAsset) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주문가능 현금</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.cashBalance) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주식 평가금액</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.stockEvalAmount) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 수익률</span>
|
||||
<span className={`paper-card-value ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${s.totalReturnPct >= 0 ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">평가손익</span>
|
||||
<span className={`paper-card-value ${(s?.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.unrealizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">실현손익</span>
|
||||
<span className={`paper-card-value ${(s?.realizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.realizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ptd-card ptd-donut-card">
|
||||
<div className="ptd-card-head">투자 비중</div>
|
||||
<div className="ptd-donut-wrap">
|
||||
<div className="ptd-donut" style={donutStyle}>
|
||||
<div className="ptd-donut-hole">
|
||||
<span>{s ? `${(s.totalAsset / 1_000_000).toFixed(1)}M` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="ptd-donut-legend">
|
||||
{allocation.map((a, i) => (
|
||||
<li key={a.label}>
|
||||
<span className="ptd-dot" style={{ background: DONUT_COLORS[i % DONUT_COLORS.length] }} />
|
||||
{a.label} {a.pct.toFixed(0)}%
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<nav className="paper-tabs">
|
||||
{(['overview', 'positions', 'trades'] as TabId[]).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`paper-tab${tab === id ? ' active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{id === 'overview' ? '계좌 개요' : id === 'positions' ? '보유종목' : '체결내역'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<main className="ptd-col ptd-col--center">
|
||||
<div className="ptd-card ptd-chart-card">
|
||||
<PaperMiniChart market={selectedMarket} />
|
||||
<PaperIndicatorPanel market={selectedMarket} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="paper-panel">
|
||||
{showPanelLoading && <p className="paper-muted">로딩 중...</p>}
|
||||
<aside className="ptd-col ptd-col--right" ref={orderAnchorRef}>
|
||||
<div className="ptd-right-panel">
|
||||
<div className="ptd-tabs ptd-tabs--main">
|
||||
<button type="button" className={`ptd-tab${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||
<button type="button" className={`ptd-tab${rightTab === 'orderbook' ? ' active' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
|
||||
</div>
|
||||
<div className="ptd-right-body">
|
||||
{rightTab === 'trade' ? (
|
||||
<>
|
||||
<div className="ptd-card ptd-order-card">
|
||||
<div className="ptd-card-head">주문</div>
|
||||
<div className="ptd-order-stack">
|
||||
<TradeOrderPanel
|
||||
side="buy"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={s?.cashBalance ?? 0}
|
||||
fillRequest={fillBuy}
|
||||
searchAnchorRef={orderAnchorRef}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableCoinQty={posQty}
|
||||
fillRequest={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showPanelLoading && tab === 'overview' && s && (
|
||||
<dl className="paper-dl">
|
||||
<dt>초기 자본</dt><dd>{fmtKrw(s.initialCapital)} KRW</dd>
|
||||
<dt>수수료율</dt><dd>{s.feeRatePct}%</dd>
|
||||
<dt>슬리피지</dt><dd>{s.slippagePct}%</dd>
|
||||
<dt>최소 주문</dt><dd>{fmtKrw(s.minOrderKrw)} KRW</dd>
|
||||
<dt>자동매매</dt>
|
||||
<dd>{s.autoTradeEnabled ? `ON (매수 시 가용현금 ${s.autoTradeBudgetPct}%)` : 'OFF (수동 매매)'}</dd>
|
||||
<dt>보유 종목 수</dt><dd>{s.positions.length}개</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'positions' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<th>수량</th>
|
||||
<th>평균단가</th>
|
||||
<th>현재가</th>
|
||||
<th>평가금액</th>
|
||||
<th>평가손익</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={7} className="paper-muted">보유 종목이 없습니다.</td></tr>
|
||||
) : s!.positions.map(p => (
|
||||
<tr key={p.symbol}>
|
||||
<td>
|
||||
<strong>{getKoreanName(p.symbol)}</strong>
|
||||
<span className="paper-sym">{p.symbol}</span>
|
||||
</td>
|
||||
<td>{p.quantity.toFixed(8).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(p.avgPrice)}</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<td>{p.evalAmount != null ? fmtKrw(p.evalAmount) : '—'}</td>
|
||||
<td className={(p.profitLoss ?? 0) >= 0 ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${fmtKrw(p.profitLoss)} (${(p.profitLossPct ?? 0).toFixed(2)}%)` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{onGoChart && (
|
||||
<button type="button" className="paper-link" onClick={() => onGoChart(p.symbol)}>
|
||||
차트
|
||||
</button>
|
||||
<div className="ptd-card ptd-history-card">
|
||||
<div className="ptd-tabs">
|
||||
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}>미체결</button>
|
||||
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}>최근체결</button>
|
||||
</div>
|
||||
{historyTab === 'open' ? (
|
||||
<p className="ptd-muted">미체결 주문 없음 (모의투자 즉시 체결)</p>
|
||||
) : (
|
||||
<table className="ptd-table ptd-table--compact">
|
||||
<thead>
|
||||
<tr><th>시간</th><th>자산</th><th>유형</th><th>가격</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.slice(0, 12).map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
|
||||
<td>{coinCode(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td><span className="ptd-status ptd-status--done">체결</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!trades.length && <tr><td colSpan={5} className="ptd-muted">체결 내역 없음</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'trades' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>출처</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>수수료</th>
|
||||
<th>체결후 현금</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr><td colSpan={8} className="paper-muted">체결 내역이 없습니다.</td></tr>
|
||||
) : trades.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="paper-time">{t.createdAt?.replace('T', ' ').slice(0, 19) ?? '—'}</td>
|
||||
<td>{getKoreanName(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side}</td>
|
||||
<td>{t.source === 'STRATEGY' ? '전략' : '수동'}</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td>{t.quantity.toFixed(6).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(t.feeAmount)}</td>
|
||||
<td>{fmtKrw(t.cashAfter)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<PaperCompactOrderbook market={selectedMarket} onPick={handleObPick} fillHeight hideHeader />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricCard({ icon, title, value, sub, tone }: {
|
||||
icon: string; title: string; value: string; sub: string; tone: 'up' | 'down';
|
||||
}) {
|
||||
return (
|
||||
<div className={`ptd-metric ptd-metric--${tone}`}>
|
||||
<span className="ptd-metric-icon">{icon}</span>
|
||||
<div>
|
||||
<div className="ptd-metric-title">{title}</div>
|
||||
<div className="ptd-metric-value">{value}</div>
|
||||
<div className="ptd-metric-sub">{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaperTradingPage;
|
||||
|
||||
@@ -147,6 +147,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevBarsKey = useRef<string>('');
|
||||
const prevIndKey = useRef<string>('');
|
||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||
const prevMarket = useRef<string>(market);
|
||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||
const prevChartType = useRef<ChartType>(chartType);
|
||||
const prevTheme = useRef<Theme>(theme);
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
@@ -252,6 +254,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
}, [candleOnlyMode, chartMgr, applyPaneLayout]);
|
||||
|
||||
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
|
||||
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const reloadAll = useCallback(async (
|
||||
mgr: ChartManager,
|
||||
newBars: OHLCVBar[],
|
||||
@@ -262,11 +266,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
) => {
|
||||
if (newBars.length === 0) return;
|
||||
|
||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||
reloadSafetyTimers.current = [];
|
||||
|
||||
barsRef.current = newBars;
|
||||
prevChartType.current = ct;
|
||||
prevTheme.current = th;
|
||||
prevLogScale.current = ls;
|
||||
prevBarsKey.current = barsKey(newBars);
|
||||
prevBarsKey.current = barsKey(newBars, market);
|
||||
prevIndKey.current = indKey(inds);
|
||||
prevSortedPKRef.current = sortedParamKey(inds);
|
||||
|
||||
@@ -275,15 +282,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
mgr.setLogScale(ls);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
|
||||
// 인디케이터를 순차적으로 등록 (async/await 보장)
|
||||
for (const ind of inds) {
|
||||
await mgr.addIndicator(ind);
|
||||
}
|
||||
|
||||
// 인디케이터 추가 완료 후 RAF × 2 대기
|
||||
// → LWC 가 모든 pane 을 DOM 에 확정한 뒤 높이·X축 재설정
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
applyPaneLayout(mgr); // ① pane 높이 재배분 (wrapper 높이 기준, 0이면 자동 재시도)
|
||||
applyPaneLayout(mgr);
|
||||
requestAnimationFrame(() => {
|
||||
const futureBars = mgr.hasIchimoku() ? 28 : 0;
|
||||
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
|
||||
@@ -291,12 +295,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
});
|
||||
})));
|
||||
|
||||
// 데이터 로드 완료 알림: 멀티차트 sync range 재적용 등 외부 콜백 처리용
|
||||
onDataLoaded?.();
|
||||
|
||||
// ── 안전망: 멀티레이아웃에서 CSS Grid 높이 확정이 늦어진 경우를 위한 지연 재적용
|
||||
// applyPaneLayout 이 이미 재시도 중이지만, setInitialVisibleRange 도 재실행 필요할 수 있음
|
||||
const safetyTimers = [300, 700, 1400].map(delay =>
|
||||
reloadSafetyTimers.current = [300, 700, 1400].map(delay =>
|
||||
setTimeout(() => {
|
||||
const m = managerRef.current;
|
||||
if (!m || !m.hasMainSeries()) return;
|
||||
@@ -307,10 +308,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||
}, delay)
|
||||
);
|
||||
// cleanup 시 타이머 해제 (컴포넌트 언마운트 대응)
|
||||
// 반환값이 없으므로 managerRef 체크로 충분
|
||||
void safetyTimers; // 타이머는 managerRef null 체크로 자동 무효화됨
|
||||
}, [applyPaneLayout, onDataLoaded]);
|
||||
}, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market]);
|
||||
|
||||
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -592,6 +590,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
clearTimeout(drawingSingleTimerRef.current);
|
||||
drawingSingleTimerRef.current = null;
|
||||
}
|
||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||
reloadSafetyTimers.current = [];
|
||||
ro.disconnect();
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
@@ -604,6 +604,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
|
||||
}, [displayTimezone, timeframe]);
|
||||
|
||||
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
|
||||
useEffect(() => {
|
||||
if (prevMarket.current === market && prevMarketTf.current === timeframe) return;
|
||||
prevMarket.current = market;
|
||||
prevMarketTf.current = timeframe;
|
||||
prevBarsKey.current = '';
|
||||
}, [market, timeframe]);
|
||||
|
||||
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
@@ -615,7 +623,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const bk = barsKey(bars);
|
||||
const bk = barsKey(bars, market);
|
||||
const ik = indKey(indicators);
|
||||
const barsChanged = bk !== prevBarsKey.current;
|
||||
const indChanged = ik !== prevIndKey.current;
|
||||
@@ -905,11 +913,10 @@ const PaneLegendPortal: React.FC<
|
||||
};
|
||||
|
||||
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
||||
function barsKey(bars: OHLCVBar[]): string {
|
||||
function barsKey(bars: OHLCVBar[], market = ''): string {
|
||||
if (bars.length === 0) return '';
|
||||
const last = bars[bars.length - 1];
|
||||
// 종목이 달라도 시간 범위·개수가 같을 수 있으므로 마지막 close 가격도 포함
|
||||
return `${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
||||
return `${market}:${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
onPick?: (price: number, rowType: 'ask' | 'bid') => void;
|
||||
fillHeight?: boolean;
|
||||
hideHeader?: boolean;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
function fmtPrice(p: number): string {
|
||||
if (!Number.isFinite(p)) return '-';
|
||||
return p >= 1000 ? p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : p.toFixed(4);
|
||||
}
|
||||
|
||||
function fmtSize(s: number): string {
|
||||
if (!Number.isFinite(s)) return '-';
|
||||
if (s >= 1000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
return s.toFixed(4);
|
||||
}
|
||||
|
||||
const PaperCompactOrderbook: React.FC<Props> = memo(({
|
||||
market,
|
||||
onPick,
|
||||
fillHeight = false,
|
||||
hideHeader = false,
|
||||
depth = 8,
|
||||
}) => {
|
||||
const { orderbook } = useUpbitOrderbook(market);
|
||||
const rowCount = fillHeight ? Math.max(depth, 12) : depth;
|
||||
const asks = orderbook.asks.slice(0, rowCount).reverse();
|
||||
const bids = orderbook.bids.slice(0, rowCount);
|
||||
const maxSize = Math.max(
|
||||
...asks.map(a => a.size),
|
||||
...bids.map(b => b.size),
|
||||
1,
|
||||
);
|
||||
const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}`}>
|
||||
{!hideHeader && <div className="ptd-ob-head">호가 (Depth)</div>}
|
||||
<div className="ptd-ob-body">
|
||||
{asks.map(a => (
|
||||
<button
|
||||
key={`a-${a.price}`}
|
||||
type="button"
|
||||
className="ptd-ob-row ptd-ob-row--ask"
|
||||
onClick={() => onPick?.(a.price, 'ask')}
|
||||
>
|
||||
<span className="ptd-ob-bar" style={{ width: `${(a.size / maxSize) * 100}%` }} />
|
||||
<span className="ptd-ob-price">{fmtPrice(a.price)}</span>
|
||||
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>
|
||||
{bids.map(b => (
|
||||
<button
|
||||
key={`b-${b.price}`}
|
||||
type="button"
|
||||
className="ptd-ob-row ptd-ob-row--bid"
|
||||
onClick={() => onPick?.(b.price, 'bid')}
|
||||
>
|
||||
<span className="ptd-ob-bar" style={{ width: `${(b.size / maxSize) * 100}%` }} />
|
||||
<span className="ptd-ob-price">{fmtPrice(b.price)}</span>
|
||||
<span className="ptd-ob-size">{fmtSize(b.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PaperCompactOrderbook.displayName = 'PaperCompactOrderbook';
|
||||
export default PaperCompactOrderbook;
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
||||
import type { Timeframe } from '../../types';
|
||||
|
||||
function computeRsi(closes: number[], period = 14): number[] {
|
||||
if (closes.length < period + 1) return [];
|
||||
const out: number[] = [];
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
for (let i = 1; i <= period; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
if (d >= 0) avgGain += d; else avgLoss -= d;
|
||||
}
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
for (let i = period; i < closes.length; i++) {
|
||||
const d = closes[i] - closes[i - 1];
|
||||
const gain = d > 0 ? d : 0;
|
||||
const loss = d < 0 ? -d : 0;
|
||||
avgGain = (avgGain * (period - 1) + gain) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||||
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
||||
out.push(100 - 100 / (1 + rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sparkPath(values: number[], w: number, h: number, min?: number, max?: number): string {
|
||||
if (!values.length) return '';
|
||||
const lo = min ?? Math.min(...values);
|
||||
const hi = max ?? Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / Math.max(values.length - 1, 1)) * w;
|
||||
const y = h - ((v - lo) / range) * (h - 4) - 2;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
timeframe?: Timeframe;
|
||||
}
|
||||
|
||||
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
|
||||
const [rsi, setRsi] = React.useState<number[]>([]);
|
||||
const [macd, setMacd] = React.useState<number[]>([]);
|
||||
const [cci, setCci] = React.useState<number[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const bars = await fetchUpbitCandles(market, timeframe, 120);
|
||||
if (cancelled) return;
|
||||
const closes = bars.map(b => b.close);
|
||||
setRsi(computeRsi(closes).slice(-40));
|
||||
const ema = (arr: number[], p: number) => {
|
||||
const k = 2 / (p + 1);
|
||||
let v = arr[0];
|
||||
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
|
||||
};
|
||||
const e12 = ema(closes, 12);
|
||||
const e26 = ema(closes, 26);
|
||||
setMacd(e12.slice(-40).map((v, i) => v - e26[closes.length - 40 + i]));
|
||||
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
|
||||
const cciVals: number[] = [];
|
||||
for (let i = 20; i < tp.length; i++) {
|
||||
const slice = tp.slice(i - 20, i);
|
||||
const sma = slice.reduce((a, b) => a + b, 0) / slice.length;
|
||||
const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length;
|
||||
cciVals.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
|
||||
}
|
||||
setCci(cciVals.slice(-40));
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe]);
|
||||
|
||||
const panels = useMemo(() => [
|
||||
{ label: 'RSI', values: rsi, color: '#a78bfa', ref: [30, 70] },
|
||||
{ label: 'MACD', values: macd, color: '#38bdf8' },
|
||||
{ label: 'CCI', values: cci, color: '#fbbf24', ref: [-100, 100] },
|
||||
], [rsi, macd, cci]);
|
||||
|
||||
return (
|
||||
<div className="ptd-indicators">
|
||||
{panels.map(p => (
|
||||
<div key={p.label} className="ptd-ind-panel">
|
||||
<div className="ptd-ind-label">{p.label}</div>
|
||||
<svg viewBox="0 0 120 48" className="ptd-ind-svg" preserveAspectRatio="none">
|
||||
{p.ref?.map((r, i) => {
|
||||
const lo = p.ref![0];
|
||||
const hi = p.ref![1];
|
||||
const y = 48 - ((r - lo) / (hi - lo)) * 44 - 2;
|
||||
return (
|
||||
<line key={i} x1="0" y1={y} x2="120" y2={y} stroke="rgba(122,162,247,0.15)" strokeDasharray="2 2" />
|
||||
);
|
||||
})}
|
||||
{p.values.length > 1 && (
|
||||
<path d={sparkPath(p.values, 120, 48)} fill="none" stroke={p.color} strokeWidth="1.5" />
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperIndicatorPanel;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../../types';
|
||||
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
||||
|
||||
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1D'];
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
wsLabel?: string;
|
||||
}
|
||||
|
||||
const PaperMiniChart: React.FC<Props> = ({ market, wsLabel = 'WebSocket <100ms' }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return undefined;
|
||||
const chart = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: '#121520' },
|
||||
textColor: '#9aa5ce',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(122,162,247,0.06)' },
|
||||
horzLines: { color: 'rgba(122,162,247,0.06)' },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(122,162,247,0.12)' },
|
||||
timeScale: { borderColor: 'rgba(122,162,247,0.12)' },
|
||||
crosshair: { mode: 1 },
|
||||
});
|
||||
const series = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#22c55e',
|
||||
downColor: '#ef4444',
|
||||
borderUpColor: '#22c55e',
|
||||
borderDownColor: '#ef4444',
|
||||
wickUpColor: '#22c55e',
|
||||
wickDownColor: '#ef4444',
|
||||
});
|
||||
chartRef.current = chart;
|
||||
seriesRef.current = series;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (containerRef.current) {
|
||||
chart.applyOptions({
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
});
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const bars = await fetchUpbitCandles(market, timeframe, 200);
|
||||
if (cancelled || !seriesRef.current) return;
|
||||
seriesRef.current.setData(
|
||||
bars.map(b => ({
|
||||
time: b.time as Time,
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
})),
|
||||
);
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe]);
|
||||
|
||||
return (
|
||||
<div className="ptd-chart-wrap">
|
||||
<div className="ptd-chart-head">
|
||||
<span className="ptd-chart-title">실시간 분석 차트</span>
|
||||
<span className="ptd-ws-badge">{wsLabel} 실시간 데이터 반영</span>
|
||||
</div>
|
||||
<div ref={containerRef} className="ptd-chart-canvas" />
|
||||
{loading && <div className="ptd-chart-loading">차트 로딩…</div>}
|
||||
<div className="ptd-tf-row">
|
||||
{TF_OPTIONS.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`ptd-tf-btn${timeframe === tf ? ' ptd-tf-btn--active' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperMiniChart;
|
||||
Reference in New Issue
Block a user