전략분봉 저장 에러 문제 수정
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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user