From 0d338fffe48cbdf8eb46c8eea15d263a5f467e70 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 12 Jun 2026 00:43:07 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=ED=85=8C=EC=8A=A4=ED=8C=85=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaperTradingController.java | 9 ++ .../dto/PaperTradeDeleteRequest.java | 10 ++ .../repository/GcPaperTradeRepository.java | 3 + .../service/PaperTradingService.java | 15 +++ .../src/components/BacktestHistoryPage.tsx | 41 ++++++- .../analysisReport/AnalysisReportPage.tsx | 38 ++++++- .../backtest/BacktestExecutionList.tsx | 105 +++++++++++++----- frontend/src/styles/backtestDashboard.css | 47 +++++++- frontend/src/utils/backendApi.ts | 10 ++ 9 files changed, 244 insertions(+), 34 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperTradeDeleteRequest.java diff --git a/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java b/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java index 4470b61..489f4d7 100644 --- a/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java +++ b/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java @@ -75,6 +75,15 @@ public class PaperTradingController { uid, symbol, side, source, from, to, page, pageSize)); } + @DeleteMapping("/trades/batch") + public ResponseEntity> deleteTradesBatch( + @RequestHeader Map h, + @RequestBody PaperTradeDeleteRequest body) { + long uid = TradingControllerSupport.requireRegisteredUser(h); + int deleted = paperTradingService.deleteTradesBatch(uid, body != null ? body.getIds() : null); + return ResponseEntity.ok(Map.of("deleted", deleted)); + } + @GetMapping("/ledger") public ResponseEntity> ledger( @RequestHeader Map h, diff --git a/backend/src/main/java/com/goldenchart/dto/PaperTradeDeleteRequest.java b/backend/src/main/java/com/goldenchart/dto/PaperTradeDeleteRequest.java new file mode 100644 index 0000000..50747a9 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperTradeDeleteRequest.java @@ -0,0 +1,10 @@ +package com.goldenchart.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class PaperTradeDeleteRequest { + private List ids; +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java index c1d71d6..d4703a4 100644 --- a/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; public interface GcPaperTradeRepository extends JpaRepository { @@ -30,4 +31,6 @@ public interface GcPaperTradeRepository extends JpaRepository findByAccountIdAndIdIn(Long accountId, Collection ids); } diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java index bf43542..7a5ceb5 100644 --- a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java +++ b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java @@ -173,6 +173,21 @@ public class PaperTradingService { return listTrades(userId, null, null, null, null, null, 0, 100).getContent(); } + /** 체결 이력 일괄 삭제 (분석 목록에서 그룹 제거용) */ + @Transactional + public int deleteTradesBatch(Long userId, List ids) { + if (ids == null || ids.isEmpty()) return 0; + long uid = TradingAccess.requireUserId(userId); + GcAppSettings app = appSettingsService.getEntity(uid, null); + GcPaperAccount account = getOrCreateAccount(uid, app); + List distinct = ids.stream().filter(id -> id != null && id > 0).distinct().toList(); + if (distinct.isEmpty()) return 0; + List owned = tradeRepo.findByAccountIdAndIdIn(account.getId(), distinct); + if (owned.isEmpty()) return 0; + tradeRepo.deleteAll(owned); + return owned.size(); + } + @Transactional(readOnly = true) public PaperPageDto listLedger(Long userId, LocalDateTime from, LocalDateTime to, diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index b83e912..f2859e3 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -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)} /> diff --git a/frontend/src/components/analysisReport/AnalysisReportPage.tsx b/frontend/src/components/analysisReport/AnalysisReportPage.tsx index 7c9a5df..8187ab2 100644 --- a/frontend/src/components/analysisReport/AnalysisReportPage.tsx +++ b/frontend/src/components/analysisReport/AnalysisReportPage.tsx @@ -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={( diff --git a/frontend/src/components/backtest/BacktestExecutionList.tsx b/frontend/src/components/backtest/BacktestExecutionList.tsx index 559a293..f7abdf1 100644 --- a/frontend/src/components/backtest/BacktestExecutionList.tsx +++ b/frontend/src/components/backtest/BacktestExecutionList.tsx @@ -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 = () => ( + + + + + + +); + 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('return'); const [query, setQuery] = useState(''); @@ -197,20 +210,36 @@ export default function BacktestExecutionList({ const ko = getKoreanName(market); const strategy = r.strategyName || '전략 없음'; return ( - + + {onDeleteBacktest && r.id != null && ( + + )} + ); }} @@ -241,24 +270,40 @@ export default function BacktestExecutionList({ const market = toUpbitMarket(item.symbol); const ko = getKoreanName(market); return ( - + + {onDeleteLive && item.trades.some(t => t.id > 0) && ( + + )} + ); }} diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index 0e53bd6..77e9afa 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -1353,16 +1353,59 @@ } .btd-history-card { + display: flex; + align-items: stretch; + gap: 2px; width: 100%; text-align: left; border: 1px solid var(--btd-divider); border-radius: 8px; background: var(--btd-surface); - padding: 10px; - cursor: pointer; + padding: 0; transition: border-color 0.15s, box-shadow 0.15s; } +.btd-history-card-body { + flex: 1; + min-width: 0; + border: none; + background: transparent; + padding: 10px; + text-align: left; + cursor: pointer; + color: inherit; + font: inherit; +} + +.btd-history-card-delete { + flex-shrink: 0; + align-self: center; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin-right: 6px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--btd-text-muted, #787b86); + cursor: pointer; + opacity: 0.72; + transition: color 0.15s, background 0.15s, opacity 0.15s; +} + +.btd-history-card:hover .btd-history-card-delete, +.btd-history-card--active .btd-history-card-delete { + opacity: 1; +} + +.btd-history-card-delete:hover { + color: var(--btd-bear, #ef5350); + background: color-mix(in srgb, var(--btd-bear, #ef5350) 14%, transparent); +} + .btd-history-card:hover { border-color: color-mix(in srgb, var(--btd-gold) 35%, var(--btd-divider)); } diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 31af7c9..df2b470 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1348,6 +1348,16 @@ export async function deleteBacktestResult(id: number): Promise { await request(`/backtest-results/${id}`, { method: 'DELETE' }); } +/** 모의·가상투자 체결 이력 일괄 삭제 (실시간 매매 목록 그룹 제거) */ +export async function deletePaperTradesBatch(ids: number[]): Promise { + if (ids.length === 0) return 0; + const res = await request<{ deleted?: number }>('/paper/trades/batch', { + method: 'DELETE', + body: JSON.stringify({ ids }), + }); + return res?.deleted ?? 0; +} + // ───────────────────────────────────────────────────────────────────────────── // 실시간 전략 체크 설정 API // ─────────────────────────────────────────────────────────────────────────────