290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
/**
|
||
* BacktestHistoryPage
|
||
* ─────────────────────────────────────────────────────
|
||
* [좌측] 체크박스 선택 + 삭제 가능한 이력 목록
|
||
* [우측] 카드 기반 대시보드 결과 뷰
|
||
*/
|
||
import React, { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
loadBacktestResults,
|
||
deleteBacktestResult,
|
||
type BacktestResultRecord,
|
||
type BacktestAnalysis,
|
||
type BacktestSignal,
|
||
} from '../utils/backendApi';
|
||
import { BacktestDashboard } from './BacktestResultModal';
|
||
|
||
// ── 유틸 ──────────────────────────────────────────────────────────────────
|
||
|
||
const pct = (v: number) =>
|
||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '–';
|
||
|
||
const fmtDateTime = (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')}`,
|
||
};
|
||
};
|
||
|
||
// ── 아이콘 ────────────────────────────────────────────────────────────────
|
||
|
||
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 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>
|
||
|
||
<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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 메인 페이지 ───────────────────────────────────────────────────────────
|
||
|
||
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 fetchList = useCallback(async () => {
|
||
setLoading(true);
|
||
const list = await loadBacktestResults();
|
||
setRecords(list);
|
||
if (list.length > 0) setSelected(prev => prev ?? list[0]);
|
||
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]);
|
||
|
||
const deleteAll = useCallback(async () => {
|
||
if (records.length === 0) return;
|
||
if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||
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 };
|
||
};
|
||
|
||
// ── 렌더 ─────────────────────────────────────────────────────────────
|
||
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>
|
||
</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>
|
||
) : records.length === 0 ? (
|
||
<div className="bh-empty-list">
|
||
<div className="bh-empty-icon">📊</div>
|
||
<p>백테스팅 이력이 없습니다.</p>
|
||
<p className="bh-empty-sub">차트 화면에서 전략을 선택하고<br/>백테스팅을 실행하세요.</p>
|
||
</div>
|
||
) : (
|
||
<div className="bh-list">
|
||
{records.map(r => (
|
||
<ListItem
|
||
key={r.id}
|
||
r={r}
|
||
checked={checked.has(r.id)}
|
||
active={selected?.id === r.id}
|
||
onToggle={() => toggleCheck(r.id)}
|
||
onClick={() => setSelected(r)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</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>
|
||
);
|
||
})()
|
||
) : (
|
||
<div className="bh-no-selection">
|
||
<div className="bh-no-sel-icon">📈</div>
|
||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default BacktestHistoryPage;
|