From c20c806c19a58be7b1fe154db2cf5bc70c20326f Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 8 Jun 2026 10:08:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=84=EC=84=9D=EB=A0=88=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/goldenchart/dto/PaperTradeDto.java | 3 ++ .../goldenchart/entity/GcBacktestResult.java | 3 ++ .../com/goldenchart/entity/GcPaperOrder.java | 9 ++++ .../com/goldenchart/entity/GcPaperTrade.java | 12 +++++ .../service/BacktestingService.java | 24 +++++++-- .../BarCloseStrategyEvaluationService.java | 5 +- .../service/PaperTradingService.java | 51 +++++++++++++++--- .../service/TradeSignalService.java | 2 +- .../trading/TradingExecutionService.java | 10 +++- .../trading/pipeline/OrderExecutionQueue.java | 12 ++++- .../trading/pipeline/OrderRequest.java | 18 +++++-- .../websocket/LiveStrategyScheduler.java | 5 +- .../V64__paper_trade_execution_context.sql | 10 ++++ .../V65__backtest_execution_snapshot.sql | 3 ++ .../src/components/BacktestHistoryPage.tsx | 8 +-- .../analysisReport/AnalysisReportPage.tsx | 2 +- .../backtest/BacktestExecutionList.tsx | 14 +++-- frontend/src/utils/analysisReportContext.ts | 32 ++---------- frontend/src/utils/backendApi.ts | 4 ++ frontend/src/utils/backtestReportModel.ts | 41 +++------------ frontend/src/utils/liveExecutionGroups.ts | 52 ++++++++++++++----- frontend/src/utils/paperMetrics.ts | 42 ++++++++++++++- 22 files changed, 254 insertions(+), 108 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V64__paper_trade_execution_context.sql create mode 100644 backend/src/main/resources/db/migration/V65__backtest_execution_snapshot.sql diff --git a/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java b/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java index 233ded1..c173c87 100644 --- a/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java +++ b/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java @@ -12,6 +12,9 @@ public class PaperTradeDto { private String orderKind; private String source; private Long strategyId; + private String candleType; + private String strategyName; + private String executionType; private Double price; private Double quantity; private Double grossAmount; diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestResult.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestResult.java index e9ffcbc..7b60845 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcBacktestResult.java +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestResult.java @@ -28,6 +28,9 @@ public class GcBacktestResult { @Column(name = "settings_json", columnDefinition = "JSON") @JdbcTypeCode(SqlTypes.JSON) private String settingsJson; + @Column(name = "execution_snapshot_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) private String executionSnapshotJson; + @Column(name = "signals_json", columnDefinition = "JSON") @JdbcTypeCode(SqlTypes.JSON) private String signalsJson; diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java b/backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java index adc2c96..0a17ad7 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java @@ -61,6 +61,15 @@ public class GcPaperOrder { @Column(name = "strategy_id") private Long strategyId; + @Column(name = "candle_type", length = 10) + private String candleType; + + @Column(name = "strategy_name", length = 200) + private String strategyName; + + @Column(name = "execution_type", length = 30) + private String executionType; + /** * 분할매매(staged) 주문 여부. * TRUE 이면 체결(FILLED) 시 allocationService 단계를 자동 진행한다. diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java b/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java index bf7e746..2fdb1a3 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java @@ -35,6 +35,18 @@ public class GcPaperTrade { @Column(name = "strategy_id") private Long strategyId; + /** 체결 시점 전략 평가 시간봉 (1m, 10m, …) */ + @Column(name = "candle_type", length = 10) + private String candleType; + + /** 체결 시점 전략명 스냅샷 */ + @Column(name = "strategy_name", length = 200) + private String strategyName; + + /** CANDLE_CLOSE | REALTIME_TICK */ + @Column(name = "execution_type", length = 30) + private String executionType; + @Column(name = "price", precision = 20, scale = 2, nullable = false) private BigDecimal price; diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 2331ff9..dacac76 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -96,7 +96,7 @@ public class BacktestingService { : new BooleanRule(false); Rule exitRule = buildExitRule(baseExitRule, series, cfg); - return runBacktest(series, entryRule, exitRule, req, cfg, strategyName); + return runBacktest(series, entryRule, exitRule, req, cfg, strategyName, buyDsl, sellDsl, params); } // ── 청산 규칙 합성 ──────────────────────────────────────────────────────── @@ -124,7 +124,9 @@ public class BacktestingService { private BacktestResponse runBacktest(BarSeries series, Rule entryRule, Rule exitRule, BacktestRequest req, BacktestSettingsDto cfg, - String strategyName) { + String strategyName, + JsonNode execBuyDsl, JsonNode execSellDsl, + Map> execParams) { BaseTradingRecord record = new BaseTradingRecord(); @@ -295,7 +297,8 @@ public class BacktestingService { Stats stats = toStats(analysis, signals); // ── DB 저장 ─────────────────────────────────────────────────────────── - Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName); + Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName, + execBuyDsl, execSellDsl, execParams); return BacktestResponse.builder() .signals(signals) @@ -601,12 +604,24 @@ public class BacktestingService { private Long saveResult(BacktestRequest req, BacktestSettingsDto cfg, List signals, BacktestAnalysisDto analysis, - BarSeries series, String strategyName) { + BarSeries series, String strategyName, + JsonNode execBuyDsl, JsonNode execSellDsl, + Map> execParams) { try { List bars = req.getBars(); long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime(); long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime(); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("strategyId", req.getStrategyId()); + snapshot.put("strategyName", strategyName); + snapshot.put("symbol", req.getSymbol() != null ? req.getSymbol() : "UNKNOWN"); + snapshot.put("timeframe", req.getTimeframe()); + snapshot.put("barCount", bars.size()); + if (execParams != null && !execParams.isEmpty()) snapshot.put("indicatorParams", execParams); + if (execBuyDsl != null && !execBuyDsl.isNull()) snapshot.put("buyCondition", execBuyDsl); + if (execSellDsl != null && !execSellDsl.isNull()) snapshot.put("sellCondition", execSellDsl); + GcBacktestResult entity = GcBacktestResult.builder() .deviceId(req.getDeviceId()) .strategyId(req.getStrategyId()) @@ -617,6 +632,7 @@ public class BacktestingService { .fromTime(fromTime) .toTime(toTime) .settingsJson(objectMapper.writeValueAsString(cfg)) + .executionSnapshotJson(objectMapper.writeValueAsString(snapshot)) .signalsJson(objectMapper.writeValueAsString(signals)) .analysisJson(objectMapper.writeValueAsString(analysis)) .totalReturn(BigDecimal.valueOf(analysis.getTotalReturnPct())) diff --git a/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java b/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java index 5f8cfb2..c6ded57 100644 --- a/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java +++ b/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java @@ -87,10 +87,13 @@ public class BarCloseStrategyEvaluationService { closeSignal, signalBar.getClosePrice().doubleValue(), candleTimeEpoch, ct, signalExecType); if (s.getUserId() != null) { + String strategyName = tradeSignalService.resolveStrategyName( + s.getStrategyId(), null); orderExecutionQueue.submitSignal( s.getUserId(), market, s.getStrategyId(), closeSignal, - signalBar.getClosePrice().doubleValue()); + signalBar.getClosePrice().doubleValue(), + ct, strategyName, signalExecType); } } catch (Exception e) { log.warn("[BarCloseEval] 시그널 처리 실패 ({}/{} strategyId={}): {}", diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java index 6404485..bf43542 100644 --- a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java +++ b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java @@ -237,7 +237,7 @@ public class PaperTradingService { // 시장가(MARKET)를 포함한 모든 주문을 현재가 기준 지정가 미체결로 생성한다. // 체결은 PaperOrderMatcherService 가 다음 시세 틱에서 처리한다. GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind, - source, req.getStrategyId(), price, qty, false); + source, req.getStrategyId(), price, qty, false, null, null, null); return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build(); } @@ -261,6 +261,12 @@ public class PaperTradingService { public void tryExecuteOnSignal(Long userId, String market, Long strategyId, String signalType, double price) { + tryExecuteOnSignal(userId, market, strategyId, signalType, price, null, null, null); + } + + public void tryExecuteOnSignal(Long userId, String market, + Long strategyId, String signalType, double price, + String candleType, String strategyName, String executionType) { long uid; try { uid = TradingAccess.requireUserId(userId); @@ -332,7 +338,8 @@ public class PaperTradingService { if (qty * execPrice < minOrder) return; // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행) createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY", - strategyId, price, qty, true); + strategyId, price, qty, true, + candleType, strategyName, executionType); log.info("[Paper] STRATEGY BUY (staged) 미체결 생성 {} qty≈{} @ {}", market, qty, price); } else { double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); @@ -347,7 +354,8 @@ public class PaperTradingService { if (qty * execPrice < minOrder) return; // 알림 발생 시점 현재가 기준 미체결 주문 생성 createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY", - strategyId, price, qty, false); + strategyId, price, qty, false, + candleType, strategyName, executionType); log.info("[Paper] STRATEGY BUY 미체결 생성 {} qty≈{} @ {}", market, qty, price); } } else { @@ -364,12 +372,14 @@ public class PaperTradingService { if (qty * execPrice < minOrder) return; // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행) createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY", - strategyId, price, qty, true); + strategyId, price, qty, true, + candleType, strategyName, executionType); log.info("[Paper] STRATEGY SELL (staged) 미체결 생성 {} qty={} @ {}", market, qty, price); } else { // 알림 발생 시점 현재가 기준 미체결 주문 생성 createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY", - strategyId, price, avail, false); + strategyId, price, avail, false, + candleType, strategyName, executionType); log.info("[Paper] STRATEGY SELL 미체결 생성 {} qty={} @ {}", market, avail, price); } } @@ -440,7 +450,8 @@ public class PaperTradingService { double qty = order.getQuantity().doubleValue(); executeTrade(uid, app, order.getSymbol(), order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(), - limit, qty, null, "STRATEGY".equals(order.getSource())); + limit, qty, null, "STRATEGY".equals(order.getSource()), + order.getCandleType(), order.getStrategyName(), order.getExecutionType()); order.setStatus("FILLED"); order.setFilledQuantity(order.getQuantity()); @@ -532,7 +543,9 @@ public class PaperTradingService { private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app, String market, String side, String orderKind, String source, Long strategyId, - double limitPrice, double qty, boolean staged) { + double limitPrice, double qty, boolean staged, + String candleType, String strategyName, + String executionType) { double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); double execPrice = "BUY".equals(side) @@ -559,6 +572,9 @@ public class PaperTradingService { .orderKind(orderKind) .source(source) .strategyId(strategyId) + .candleType(candleType) + .strategyName(strategyName) + .executionType(executionType) .staged(staged) .build()); } @@ -581,6 +597,9 @@ public class PaperTradingService { .orderKind(orderKind) .source(source) .strategyId(strategyId) + .candleType(candleType) + .strategyName(strategyName) + .executionType(executionType) .staged(staged) .build()); } @@ -611,6 +630,15 @@ public class PaperTradingService { String market, String side, String orderKind, String source, Long strategyId, double inputPrice, double inputQty, String koreanName, boolean autoTrade) { + return executeTrade(userId, app, market, side, orderKind, source, strategyId, + inputPrice, inputQty, koreanName, autoTrade, null, null, null); + } + + private PaperTradeDto executeTrade(long userId, GcAppSettings app, + String market, String side, String orderKind, String source, + Long strategyId, double inputPrice, double inputQty, + String koreanName, boolean autoTrade, + String candleType, String strategyName, String executionType) { GcPaperAccount account = getOrCreateAccount(userId, app); double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); @@ -646,6 +674,9 @@ public class PaperTradingService { .orderKind(orderKind) .source(source) .strategyId(strategyId) + .candleType(candleType) + .strategyName(strategyName) + .executionType(executionType) .price(price) .quantity(qty) .grossAmount(gross) @@ -690,6 +721,9 @@ public class PaperTradingService { .orderKind(orderKind) .source(source) .strategyId(strategyId) + .candleType(candleType) + .strategyName(strategyName) + .executionType(executionType) .price(price) .quantity(qty) .grossAmount(gross) @@ -791,6 +825,9 @@ public class PaperTradingService { .orderKind(t.getOrderKind()) .source(t.getSource()) .strategyId(t.getStrategyId()) + .candleType(t.getCandleType()) + .strategyName(t.getStrategyName()) + .executionType(t.getExecutionType()) .price(t.getPrice().doubleValue()) .quantity(t.getQuantity().doubleValue()) .grossAmount(t.getGrossAmount().doubleValue()) diff --git a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java index 6320be1..4580757 100644 --- a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java +++ b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java @@ -122,7 +122,7 @@ public class TradeSignalService { return deviceId.equals(entity.getDeviceId()); } - private String resolveStrategyName(Long strategyId, String strategyName) { + public String resolveStrategyName(Long strategyId, String strategyName) { if (strategyName != null && !strategyName.isBlank()) return strategyName; if (strategyId == null) return null; return strategyRepo.findById(strategyId) diff --git a/backend/src/main/java/com/goldenchart/trading/TradingExecutionService.java b/backend/src/main/java/com/goldenchart/trading/TradingExecutionService.java index bf4089c..f2bc901 100644 --- a/backend/src/main/java/com/goldenchart/trading/TradingExecutionService.java +++ b/backend/src/main/java/com/goldenchart/trading/TradingExecutionService.java @@ -24,12 +24,20 @@ public class TradingExecutionService { public void executeSignal(Long userId, String market, Long strategyId, String side, double price) { + executeSignal(userId, market, strategyId, side, price, null, null, null); + } + + public void executeSignal(Long userId, String market, + Long strategyId, String side, double price, + String candleType, String strategyName, String executionType) { if (!TradingAccess.isRegisteredUser(userId)) return; GcAppSettings app = appSettingsService.getEntity(userId, null); TradingMode mode = TradingMode.fromString(app.getTradingMode()); if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) { - paperTradingService.tryExecuteOnSignal(userId, market, strategyId, side, price); + paperTradingService.tryExecuteOnSignal( + userId, market, strategyId, side, price, + candleType, strategyName, executionType); } if (mode.useLive()) { liveTradingService.tryExecuteOnSignal( diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/OrderExecutionQueue.java b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderExecutionQueue.java index 6ba645b..a293535 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/OrderExecutionQueue.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderExecutionQueue.java @@ -67,9 +67,16 @@ public class OrderExecutionQueue { public void submitSignal(Long userId, String market, Long strategyId, String signal, double price) { + submitSignal(userId, market, strategyId, signal, price, null, null, null); + } + + public void submitSignal(Long userId, String market, + Long strategyId, String signal, double price, + String candleType, String strategyName, String executionType) { if (!TradingAccess.isRegisteredUser(userId)) return; submit(OrderRequest.strategySignal( - TradingAccess.accountDeviceKey(userId), userId, market, strategyId, signal, price)); + TradingAccess.accountDeviceKey(userId), userId, market, strategyId, signal, price, + candleType, strategyName, executionType)); } private void runLoop() { @@ -97,7 +104,8 @@ public class OrderExecutionQueue { if (req.kind() == OrderRequest.OrderKind.STRATEGY_SIGNAL) { tradingExecutionService.executeSignal( req.userId(), req.market(), - req.strategyId(), req.side(), req.price()); + req.strategyId(), req.side(), req.price(), + req.candleType(), req.strategyName(), req.executionType()); } else { tradingExecutionService.executeRiskExit(req); } diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/OrderRequest.java b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderRequest.java index 1306b8d..08baa46 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/OrderRequest.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/OrderRequest.java @@ -11,7 +11,10 @@ public record OrderRequest( Long strategyId, String side, double price, - String reason + String reason, + String candleType, + String strategyName, + String executionType ) { public enum OrderKind { STRATEGY_SIGNAL, @@ -20,20 +23,25 @@ public record OrderRequest( } public static OrderRequest strategySignal(String deviceId, Long userId, String market, - Long strategyId, String side, double price) { + Long strategyId, String side, double price, + String candleType, String strategyName, + String executionType) { return new OrderRequest(OrderKind.STRATEGY_SIGNAL, deviceId, userId, market, - strategyId, side, price, "STRATEGY"); + strategyId, side, price, "STRATEGY", + candleType, strategyName, executionType); } public static OrderRequest stopLoss(String deviceId, Long userId, String market, double price, double lossPct) { return new OrderRequest(OrderKind.STOP_LOSS, deviceId, userId, market, - null, "SELL", price, "STOP_LOSS_" + lossPct + "%"); + null, "SELL", price, "STOP_LOSS_" + lossPct + "%", + null, null, null); } public static OrderRequest takeProfit(String deviceId, Long userId, String market, double price, double profitPct) { return new OrderRequest(OrderKind.TAKE_PROFIT, deviceId, userId, market, - null, "SELL", price, "TAKE_PROFIT_" + profitPct + "%"); + null, "SELL", price, "TAKE_PROFIT_" + profitPct + "%", + null, null, null); } } diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java index f22fa37..b771f3b 100644 --- a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -122,10 +122,13 @@ public class LiveStrategyScheduler { barStartEpoch, candleType, "REALTIME_TICK" ); if (s.getUserId() != null) { + String strategyName = tradeSignalService.resolveStrategyName( + s.getStrategyId(), null); orderExecutionQueue.submitSignal( s.getUserId(), market, s.getStrategyId(), signal, - lastBar.getClosePrice().doubleValue()); + lastBar.getClosePrice().doubleValue(), + candleType, strategyName, "REALTIME_TICK"); } } catch (Exception ex) { log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage()); diff --git a/backend/src/main/resources/db/migration/V64__paper_trade_execution_context.sql b/backend/src/main/resources/db/migration/V64__paper_trade_execution_context.sql new file mode 100644 index 0000000..01a8fe3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V64__paper_trade_execution_context.sql @@ -0,0 +1,10 @@ +-- 매매 실행 당시 전략·시간봉 스냅샷 (분석레포트 실시간 매매 탭) +ALTER TABLE gc_paper_trade + ADD COLUMN candle_type VARCHAR(10) NULL COMMENT '체결 시점 평가 시간봉' AFTER strategy_id, + ADD COLUMN strategy_name VARCHAR(200) NULL COMMENT '체결 시점 전략명' AFTER candle_type, + ADD COLUMN execution_type VARCHAR(30) NULL COMMENT 'CANDLE_CLOSE | REALTIME_TICK' AFTER strategy_name; + +ALTER TABLE gc_paper_order + ADD COLUMN candle_type VARCHAR(10) NULL AFTER strategy_id, + ADD COLUMN strategy_name VARCHAR(200) NULL AFTER candle_type, + ADD COLUMN execution_type VARCHAR(30) NULL AFTER strategy_name; diff --git a/backend/src/main/resources/db/migration/V65__backtest_execution_snapshot.sql b/backend/src/main/resources/db/migration/V65__backtest_execution_snapshot.sql new file mode 100644 index 0000000..f44d497 --- /dev/null +++ b/backend/src/main/resources/db/migration/V65__backtest_execution_snapshot.sql @@ -0,0 +1,3 @@ +-- 백테스트 실행 당시 전략 DSL·지표 파라미터 스냅샷 +ALTER TABLE gc_backtest_result + ADD COLUMN execution_snapshot_json JSON NULL COMMENT '실행 당시 symbol/timeframe/DSL/indicatorParams 스냅샷' AFTER settings_json; diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index aaa0bd1..b12ba51 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -251,14 +251,10 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { ); } if (tab === 'live' && selectedLive) { - return buildLiveReportModel( - selectedLive, - activeSignals, - chartProps?.timeframe ?? '1h', - ); + return buildLiveReportModel(selectedLive, activeSignals); } return null; - }, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, chartProps?.timeframe, strategyNamesById]); + }, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, strategyNamesById]); const showRightDetail = tab === 'backtest' ? backtestDetail?.analysis != null diff --git a/frontend/src/components/analysisReport/AnalysisReportPage.tsx b/frontend/src/components/analysisReport/AnalysisReportPage.tsx index 7c1feb2..0be1027 100644 --- a/frontend/src/components/analysisReport/AnalysisReportPage.tsx +++ b/frontend/src/components/analysisReport/AnalysisReportPage.tsx @@ -119,7 +119,7 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) { ); } if (sourceTab === 'live' && selectedLive) { - return buildLiveReportModel(selectedLive, ctx?.signals ?? [], '1h'); + return buildLiveReportModel(selectedLive, ctx?.signals ?? []); } return null; }, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]); diff --git a/frontend/src/components/backtest/BacktestExecutionList.tsx b/frontend/src/components/backtest/BacktestExecutionList.tsx index c09671c..08a9010 100644 --- a/frontend/src/components/backtest/BacktestExecutionList.tsx +++ b/frontend/src/components/backtest/BacktestExecutionList.tsx @@ -110,10 +110,12 @@ export default function BacktestExecutionList({ const code = item.symbol.replace(/^KRW-/i, '').toLowerCase(); const strategy = item.strategyLabel.toLowerCase(); const source = item.sourceLabel.toLowerCase(); + const tf = item.timeframe?.toLowerCase() ?? ''; return ko.includes(normalizedQuery) || code.includes(normalizedQuery) || strategy.includes(normalizedQuery) - || source.includes(normalizedQuery); + || source.includes(normalizedQuery) + || tf.includes(normalizedQuery); }); }, [liveItems, normalizedQuery]); @@ -133,7 +135,7 @@ export default function BacktestExecutionList({ setQuery(e.target.value)} aria-label="목록 검색" @@ -242,12 +244,16 @@ export default function BacktestExecutionList({
{ko} - {item.strategyLabel} · {item.sourceLabel} + + {item.strategyLabel} + {item.timeframe !== 'unknown' ? ` · ${formatTimeframeKo(item.timeframe)}` : ''} + {item.sourceLabel !== '자동' ? ` · ${item.sourceLabel}` : ''} + {fmtShortTimestamp(item.createdAt)}
- 체결 {item.tradeCount}건 + 라운드 {item.roundTripCount} · 체결 {item.tradeCount}건 {pct(item.totalReturnPct)}
diff --git a/frontend/src/utils/analysisReportContext.ts b/frontend/src/utils/analysisReportContext.ts index d405570..2363fa1 100644 --- a/frontend/src/utils/analysisReportContext.ts +++ b/frontend/src/utils/analysisReportContext.ts @@ -8,6 +8,7 @@ import type { LiveExecutionItem } from './liveExecutionGroups'; import type { EquityPoint, TradeHistoryRow } from './backtestEquity'; import { buildEquityFromSignals } from './backtestEquity'; import { paperTradesToSignals } from './liveExecutionGroups'; +import { buildAnalysisFromPaperTrades } from './paperMetrics'; import { repairUtf8Mojibake } from './textEncoding'; export type AnalysisSourceTab = 'backtest' | 'live'; @@ -72,37 +73,14 @@ export function buildContextFromLive(item: LiveExecutionItem): AnalysisReportCon const signals = paperTradesToSignals(item.trades); const cap = 10_000_000; const eq = buildEquityFromSignals(signals, cap, item.symbol); - const finalEquity = cap * (1 + item.totalReturnPct); - const analysis: BacktestAnalysis = { - initialCapital: cap, - finalEquity, - totalReturnPct: item.totalReturnPct, - totalProfitLoss: finalEquity - cap, - grossProfit: 0, - grossLoss: 0, - avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0, - profitLossRatio: item.profitFactor, - numberOfPositions: item.tradeCount, - numberOfWinning: Math.round(item.tradeCount * item.winRatePct), - numberOfLosing: Math.max(0, item.tradeCount - Math.round(item.tradeCount * item.winRatePct)), - numberOfBreakEven: 0, - winRate: item.winRatePct, - maxDrawdownPct: item.mddPct, - maxRunupPct: 0, - sharpeRatio: item.sharpeRatio, - sortinoRatio: 0, - calmarRatio: 0, - valueAtRisk95: 0, - expectedShortfall: 0, - buyAndHoldReturnPct: 0, - vsBuyAndHold: 0, - }; + const analysis = buildAnalysisFromPaperTrades(item.trades, cap); + const timeframe = item.timeframe !== 'unknown' ? item.timeframe : '—'; return { sourceTab: 'live', strategyName: repairUtf8Mojibake(item.strategyLabel), symbol: item.symbol, - timeframe: '1h', - barCount: 300, + timeframe, + barCount: item.trades.length, createdAt: item.createdAt, analysis, signals, diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 47da82f..c54ae73 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -702,6 +702,9 @@ export interface PaperTradeDto { orderKind: string; source: string; strategyId?: number | null; + candleType?: string | null; + strategyName?: string | null; + executionType?: string | null; price: number; quantity: number; grossAmount: number; @@ -1208,6 +1211,7 @@ export interface BacktestResultRecord { fromTime: number; toTime: number; settingsJson: string; + executionSnapshotJson?: string; signalsJson: string; analysisJson: string; totalReturn: number; diff --git a/frontend/src/utils/backtestReportModel.ts b/frontend/src/utils/backtestReportModel.ts index 3651bf2..8c48228 100644 --- a/frontend/src/utils/backtestReportModel.ts +++ b/frontend/src/utils/backtestReportModel.ts @@ -6,41 +6,17 @@ import type { } from './backendApi'; import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal'; import type { LiveExecutionItem } from './liveExecutionGroups'; +import { buildAnalysisFromPaperTrades } from './paperMetrics'; import { fmtListTimestamp, toUpbitMarket } from './backtestUiUtils'; import { getKoreanName } from './marketNameCache'; import { repairUtf8Mojibake } from './textEncoding'; -/** 실시간 매매 탭 — KPI만 있는 경우 최소 분석 객체 */ +/** 실시간 매매 탭 — 체결 이력 기반 분석 */ export function buildAnalysisFromLive( item: LiveExecutionItem, initialCapital = 10_000_000, ): BacktestAnalysis { - const finalEquity = initialCapital * (1 + item.totalReturnPct); - const winCount = Math.round(item.tradeCount * item.winRatePct); - return { - initialCapital, - finalEquity, - totalReturnPct: item.totalReturnPct, - totalProfitLoss: finalEquity - initialCapital, - grossProfit: 0, - grossLoss: 0, - avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0, - profitLossRatio: item.profitFactor, - numberOfPositions: item.tradeCount, - numberOfWinning: winCount, - numberOfLosing: Math.max(0, item.tradeCount - winCount), - numberOfBreakEven: 0, - winRate: item.winRatePct, - maxDrawdownPct: item.mddPct, - maxRunupPct: 0, - sharpeRatio: item.sharpeRatio, - sortinoRatio: 0, - calmarRatio: 0, - valueAtRisk95: 0, - expectedShortfall: 0, - buyAndHoldReturnPct: 0, - vsBuyAndHold: 0, - }; + return buildAnalysisFromPaperTrades(item.trades, initialCapital); } function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis { @@ -101,14 +77,11 @@ export function buildBacktestReportModel( record: BacktestResultRecord, analysis: BacktestAnalysis, signals: BacktestSignal[], - strategyNamesById: Record = {}, + _strategyNamesById: Record = {}, ): BacktestAnalysisReportModel { const market = toUpbitMarket(record.symbol); - const freshName = record.strategyId != null - ? strategyNamesById[record.strategyId] - : undefined; const strategyName = repairUtf8Mojibake( - freshName || record.strategyName || '전략 없음', + record.strategyName || '전략 없음', ); return { analysis, @@ -126,10 +99,10 @@ export function buildBacktestReportModel( export function buildLiveReportModel( item: LiveExecutionItem, signals: BacktestSignal[], - timeframe: string, initialCapital = 10_000_000, ): BacktestAnalysisReportModel { const market = toUpbitMarket(item.symbol); + const timeframe = item.timeframe !== 'unknown' ? item.timeframe : '—'; return { analysis: buildAnalysisFromLive(item, initialCapital), signals, @@ -137,7 +110,7 @@ export function buildLiveReportModel( symbol: item.symbol, symbolKo: getKoreanName(market), timeframe, - barCount: 300, + barCount: item.trades.length, createdAt: item.createdAt ? fmtListTimestamp(item.createdAt) : undefined, reportKind: 'live', }; diff --git a/frontend/src/utils/liveExecutionGroups.ts b/frontend/src/utils/liveExecutionGroups.ts index 6bfb782..3c6cd33 100644 --- a/frontend/src/utils/liveExecutionGroups.ts +++ b/frontend/src/utils/liveExecutionGroups.ts @@ -1,16 +1,21 @@ import type { PaperSummaryDto, PaperTradeDto } from './backendApi'; -import { computePaperMetrics } from './paperMetrics'; +import { buildAnalysisFromPaperTrades, computePaperMetrics } from './paperMetrics'; export interface LiveExecutionItem { id: string; symbol: string; strategyLabel: string; sourceLabel: string; + /** 체결 시점 평가 시간봉 */ + timeframe: string; + executionType?: string; + strategyId?: number | null; trades: PaperTradeDto[]; createdAt: string; totalReturnPct: number; winRatePct: number; tradeCount: number; + roundTripCount: number; mddPct: number; sharpeRatio: number; profitFactor: number; @@ -26,6 +31,27 @@ function sourceLabel(source: string): string { return '수동'; } +function resolveStrategyLabel( + t: PaperTradeDto, + strategyNames: Record, +): string { + if (t.strategyName) return t.strategyName; + if (t.strategyId != null) { + return strategyNames[t.strategyId] ?? `전략 #${t.strategyId}`; + } + return '수동 매매'; +} + +/** 자동매매: 종목·전략·시간봉·실행방식 기준 / 수동: 종목·일자 기준 */ +function groupKey(t: PaperTradeDto): string { + if (t.source === 'STRATEGY') { + const tf = t.candleType ?? 'unknown'; + const exec = t.executionType ?? ''; + return `${t.symbol}|${t.strategyId ?? 0}|${tf}|${exec}|STRATEGY`; + } + return `${t.symbol}|manual|${dayKey(t.createdAt)}|MANUAL`; +} + /** 가상투자·모의투자 체결 이력을 실행 단위 목록으로 그룹 */ export function buildLiveExecutionItems( trades: PaperTradeDto[], @@ -36,7 +62,7 @@ export function buildLiveExecutionItems( const groups = new Map(); for (const t of trades) { - const key = `${t.symbol}|${t.strategyId ?? 0}|${dayKey(t.createdAt)}|${t.source}`; + const key = groupKey(t); const list = groups.get(key) ?? []; list.push(t); groups.set(key, list); @@ -51,26 +77,25 @@ export function buildLiveExecutionItems( ); const first = sorted[0]; const last = sorted[sorted.length - 1]; + const analysis = buildAnalysisFromPaperTrades(sorted, initial); const metrics = computePaperMetrics(sorted, summary); - const buyVol = sorted.filter(t => t.side === 'BUY').reduce((s, t) => s + t.netAmount, 0); - const sellVol = sorted.filter(t => t.side === 'SELL').reduce((s, t) => s + t.netAmount, 0); - const pnl = sellVol - buyVol; - const totalReturnPct = initial > 0 ? pnl / initial : 0; return { id: key, symbol: first.symbol, - strategyLabel: first.strategyId != null - ? (strategyNames[first.strategyId] ?? `전략 #${first.strategyId}`) - : '수동 매매', + strategyLabel: resolveStrategyLabel(first, strategyNames), sourceLabel: sourceLabel(first.source), + timeframe: first.candleType ?? 'unknown', + executionType: first.executionType ?? undefined, + strategyId: first.strategyId, trades: sorted, createdAt: last.createdAt ?? first.createdAt ?? new Date().toISOString(), - totalReturnPct, - winRatePct: metrics.winRatePct / 100, + totalReturnPct: analysis.totalReturnPct, + winRatePct: analysis.winRate, tradeCount: sorted.length, - mddPct: metrics.mddPct / 100, - sharpeRatio: metrics.sharpeRatio, + roundTripCount: analysis.numberOfPositions, + mddPct: analysis.maxDrawdownPct, + sharpeRatio: analysis.sharpeRatio, profitFactor: metrics.profitFactor, }; }) @@ -84,6 +109,7 @@ export function paperTradesToSignals(trades: PaperTradeDto[]) { time: Math.floor(new Date(t.createdAt!).getTime() / 1000), type: t.side as 'BUY' | 'SELL', price: t.price, + quantity: t.quantity, barIndex: 0, })); } diff --git a/frontend/src/utils/paperMetrics.ts b/frontend/src/utils/paperMetrics.ts index 4a57d4e..f946102 100644 --- a/frontend/src/utils/paperMetrics.ts +++ b/frontend/src/utils/paperMetrics.ts @@ -1,4 +1,4 @@ -import type { PaperSummaryDto, PaperTradeDto } from './backendApi'; +import type { PaperSummaryDto, PaperTradeDto, BacktestAnalysis } from './backendApi'; export interface PaperPerformanceMetrics { mddPct: number; @@ -126,3 +126,43 @@ export function computePaperMetrics( profitFactor: Math.min(profitFactor, 99), }; } + +/** 실시간 매매 체결 이력 → 백테스트 분석 DTO (ledger·라운드트립 기준) */ +export function buildAnalysisFromPaperTrades( + trades: PaperTradeDto[], + initialCapital = 10_000_000, +): BacktestAnalysis { + const sorted = [...trades].sort((a, b) => + (a.createdAt ?? '').localeCompare(b.createdAt ?? ''), + ); + const metrics = computePaperMetrics(sorted, { initialCapital } as PaperSummaryDto); + const curve = buildEquityCurve(sorted, initialCapital); + const finalEquity = curve.length > 0 ? curve[curve.length - 1] : initialCapital; + const { wins, total, grossProfit, grossLoss } = roundTripStats(sorted); + const profitLossRatio = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0; + + return { + initialCapital, + finalEquity, + totalReturnPct: initialCapital > 0 ? (finalEquity - initialCapital) / initialCapital : 0, + totalProfitLoss: finalEquity - initialCapital, + grossProfit, + grossLoss, + avgReturnPct: total > 0 ? (finalEquity - initialCapital) / initialCapital / total : 0, + profitLossRatio: Math.min(profitLossRatio, 99), + numberOfPositions: total, + numberOfWinning: wins, + numberOfLosing: Math.max(0, total - wins), + numberOfBreakEven: 0, + winRate: total > 0 ? wins / total : 0, + maxDrawdownPct: metrics.mddPct / 100, + maxRunupPct: 0, + sharpeRatio: metrics.sharpeRatio, + sortinoRatio: 0, + calmarRatio: 0, + valueAtRisk95: 0, + expectedShortfall: 0, + buyAndHoldReturnPct: 0, + vsBuyAndHold: 0, + }; +}