백테스팅 목록 아이템 삭제 기능추가

This commit is contained in:
Macbook
2026-06-12 00:43:07 +09:00
parent 423c60183e
commit 0d338fffe4
9 changed files with 244 additions and 34 deletions
@@ -8,6 +8,7 @@ import { MarketSearchPanel } from './MarketSearchPanel';
import {
loadBacktestResults,
deleteBacktestResult,
deletePaperTradesBatch,
loadPaperTrades,
loadPaperSummary,
loadStrategies,
@@ -41,7 +42,7 @@ import {
buildLiveReportModel,
} from '../utils/backtestReportModel';
import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
import { PAPER_TRADES_CHANGED_EVENT, notifyPaperTradesChanged } from '../utils/paperTradeEvents';
import {
readStoredSize,
storeSize,
@@ -222,6 +223,42 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
await fetchAll();
}, [records, fetchAll]);
const handleDeleteBacktest = useCallback(async (record: BacktestResultRecord) => {
if (record.id == null) return;
const market = toUpbitMarket(record.symbol);
const ko = getKoreanName(market);
if (!window.confirm(`"${ko}" 백테스팅 결과를 삭제하시겠습니까?`)) return;
try {
await deleteBacktestResult(record.id);
setRecords(prev => {
const next = prev.filter(r => r.id !== record.id);
setSelectedBacktest(sel => (sel?.id === record.id ? next[0] ?? null : sel));
return next;
});
} catch (e) {
window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.');
}
}, []);
const handleDeleteLive = useCallback(async (item: LiveExecutionItem) => {
const ids = item.trades.map(t => t.id).filter(id => id > 0);
if (ids.length === 0) return;
const market = toUpbitMarket(item.symbol);
const ko = getKoreanName(market);
if (!window.confirm(`"${ko}" ${item.strategyLabel} 실시간 매매 이력(${ids.length}건)을 삭제하시겠습니까?`)) return;
try {
const deleted = await deletePaperTradesBatch(ids);
if (deleted <= 0) {
window.alert('삭제할 체결 이력을 찾지 못했습니다.');
return;
}
await refreshLiveTrades();
notifyPaperTradesChanged();
} catch (e) {
window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.');
}
}, [refreshLiveTrades]);
const handleQuickRun = useCallback(async () => {
if (!runStrategyId) {
window.alert('전략을 선택해주세요.');
@@ -519,6 +556,8 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
selectedLiveId={selectedLive?.id ?? null}
onSelectBacktest={r => { setSelectedBacktest(r); setTab('backtest'); }}
onSelectLive={item => { setSelectedLive(item); setTab('live'); }}
onDeleteBacktest={r => void handleDeleteBacktest(r)}
onDeleteLive={item => void handleDeleteLive(item)}
/>
</aside>
@@ -5,6 +5,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { Theme, Timeframe } from '../../types';
import {
loadBacktestResults,
deleteBacktestResult,
deletePaperTradesBatch,
loadPaperTrades,
loadPaperSummary,
loadStrategies,
@@ -16,7 +18,7 @@ import {
buildContextFromLive,
} from '../../utils/analysisReportContext';
import { buildLiveExecutionItems, type LiveExecutionItem } from '../../utils/liveExecutionGroups';
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
import { PAPER_TRADES_CHANGED_EVENT, notifyPaperTradesChanged } from '../../utils/paperTradeEvents';
import BacktestExecutionList, { type ExecutionListTab } from '../backtest/BacktestExecutionList';
import BuilderPageShell from '../layout/BuilderPageShell';
import AnalysisReportCenterPanel from './AnalysisReportCenterPanel';
@@ -108,6 +110,38 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
}, [refreshLive, records]);
const handleDeleteBacktest = useCallback(async (record: BacktestResultRecord) => {
if (record.id == null) return;
if (!window.confirm('이 백테스팅 결과를 삭제하시겠습니까?')) return;
try {
await deleteBacktestResult(record.id);
setRecords(prev => {
const next = prev.filter(r => r.id !== record.id);
setSelectedBacktest(sel => (sel?.id === record.id ? next[0] ?? null : sel));
return next;
});
} catch (e) {
window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.');
}
}, []);
const handleDeleteLive = useCallback(async (item: LiveExecutionItem) => {
const ids = item.trades.map(t => t.id).filter(id => id > 0);
if (ids.length === 0) return;
if (!window.confirm(`실시간 매매 이력 ${ids.length}건을 삭제하시겠습니까?`)) return;
try {
const deleted = await deletePaperTradesBatch(ids);
if (deleted <= 0) {
window.alert('삭제할 체결 이력을 찾지 못했습니다.');
return;
}
await refreshLive(records);
notifyPaperTradesChanged();
} catch (e) {
window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.');
}
}, [refreshLive, records]);
const compareBacktest = useMemo(() => {
if (!selectedBacktest) return null;
return pickCompareBacktest(selectedBacktest, records);
@@ -228,6 +262,8 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
setSelectedLive(item);
setSourceTab('live');
}}
onDeleteBacktest={r => void handleDeleteBacktest(r)}
onDeleteLive={item => void handleDeleteLive(item)}
/>
)}
center={(
@@ -29,8 +29,19 @@ interface Props {
selectedLiveId: string | null;
onSelectBacktest: (r: BacktestResultRecord) => void;
onSelectLive: (item: LiveExecutionItem) => void;
onDeleteBacktest?: (r: BacktestResultRecord) => void;
onDeleteLive?: (item: LiveExecutionItem) => void;
}
const IcTrash = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
function parseBacktestSpark(r: BacktestResultRecord) {
try {
const signals = r.signalsJson ? JSON.parse(r.signalsJson) : [];
@@ -55,6 +66,8 @@ export default function BacktestExecutionList({
selectedLiveId,
onSelectBacktest,
onSelectLive,
onDeleteBacktest,
onDeleteLive,
}: Props) {
const [sort, setSort] = useState<BacktestListSort>('return');
const [query, setQuery] = useState('');
@@ -197,20 +210,36 @@ export default function BacktestExecutionList({
const ko = getKoreanName(market);
const strategy = r.strategyName || '전략 없음';
return (
<button type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectBacktest(r)}>
<div className="btd-history-card-row">
<div className="btd-history-card-main">
<span className="btd-history-ko">{ko}</span>
<span className="btd-history-strategy">{strategy} · {formatTimeframeKo(resolveBacktestRecordExecTimeframe(r))}</span>
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
<div className={`btd-history-card${active ? ' btd-history-card--active' : ''}`}>
<button type="button" className="btd-history-card-body" onClick={() => onSelectBacktest(r)}>
<div className="btd-history-card-row">
<div className="btd-history-card-main">
<span className="btd-history-ko">{ko}</span>
<span className="btd-history-strategy">{strategy} · {formatTimeframeKo(resolveBacktestRecordExecTimeframe(r))}</span>
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
</div>
<div className="btd-history-card-side">
<BacktestSparkline curve={parseBacktestSpark(r)} positive={positive} width={56} height={22} />
<span className="btd-history-win"> {pctAbs(r.winRate ?? 0)}</span>
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}> {pct(ret)}</span>
</div>
</div>
<div className="btd-history-card-side">
<BacktestSparkline curve={parseBacktestSpark(r)} positive={positive} width={56} height={22} />
<span className="btd-history-win"> {pctAbs(r.winRate ?? 0)}</span>
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}> {pct(ret)}</span>
</div>
</div>
</button>
</button>
{onDeleteBacktest && r.id != null && (
<button
type="button"
className="btd-history-card-delete"
title="백테스팅 결과 삭제"
aria-label="백테스팅 결과 삭제"
onClick={e => {
e.stopPropagation();
onDeleteBacktest(r);
}}
>
<IcTrash />
</button>
)}
</div>
);
}}
</VirtualScroll>
@@ -241,24 +270,40 @@ export default function BacktestExecutionList({
const market = toUpbitMarket(item.symbol);
const ko = getKoreanName(market);
return (
<button type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectLive(item)}>
<div className="btd-history-card-row">
<div className="btd-history-card-main">
<span className="btd-history-ko">{ko}</span>
<span className="btd-history-strategy">
{item.strategyLabel}
{item.timeframe !== 'unknown' ? ` · ${formatTimeframeKo(item.timeframe)}` : ''}
{item.sourceLabel !== '자동' ? ` · ${item.sourceLabel}` : ''}
</span>
<span className="btd-history-date">{fmtShortTimestamp(item.createdAt)}</span>
<div className={`btd-history-card${active ? ' btd-history-card--active' : ''}`}>
<button type="button" className="btd-history-card-body" onClick={() => onSelectLive(item)}>
<div className="btd-history-card-row">
<div className="btd-history-card-main">
<span className="btd-history-ko">{ko}</span>
<span className="btd-history-strategy">
{item.strategyLabel}
{item.timeframe !== 'unknown' ? ` · ${formatTimeframeKo(item.timeframe)}` : ''}
{item.sourceLabel !== '자동' ? ` · ${item.sourceLabel}` : ''}
</span>
<span className="btd-history-date">{fmtShortTimestamp(item.createdAt)}</span>
</div>
<div className="btd-history-card-side">
<BacktestSparkline curve={parseLiveSpark(item)} positive={positive} width={56} height={22} />
<span className="btd-history-win"> {item.roundTripCount} · {item.tradeCount}</span>
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>{pct(item.totalReturnPct)}</span>
</div>
</div>
<div className="btd-history-card-side">
<BacktestSparkline curve={parseLiveSpark(item)} positive={positive} width={56} height={22} />
<span className="btd-history-win"> {item.roundTripCount} · {item.tradeCount}</span>
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>{pct(item.totalReturnPct)}</span>
</div>
</div>
</button>
</button>
{onDeleteLive && item.trades.some(t => t.id > 0) && (
<button
type="button"
className="btd-history-card-delete"
title="실시간 매매 이력 삭제"
aria-label="실시간 매매 이력 삭제"
onClick={e => {
e.stopPropagation();
onDeleteLive(item);
}}
>
<IcTrash />
</button>
)}
</div>
);
}}
</VirtualScroll>