Files
goldenChart/frontend/src/components/BacktestHistoryPage.tsx
T
2026-05-23 15:11:48 +09:00

290 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;