From 4f2d98d4ba359d9c2f4e9e602788bcddc6039eea Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 31 May 2026 17:34:26 +0900 Subject: [PATCH] =?UTF-8?q?=ED=88=AC=EC=9E=90=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/PaperAllocationBulkRequest.java | 6 + .../goldenchart/dto/PaperAllocationDto.java | 8 + .../dto/PaperAllocationPatchRequest.java | 6 + .../entity/GcPaperSymbolAllocation.java | 32 ++++ .../service/PaperAllocationService.java | 52 +++++++ .../service/PaperTradeStageService.java | 91 +++++++++++ .../service/PaperTradingService.java | 95 +++++++++--- .../V55__paper_symbol_trade_stages.sql | 10 ++ .../V56__paper_symbol_trade_stages_int.sql | 4 + .../service/PaperTradeStageServiceTest.java | 44 ++++++ frontend/src/components/PaperTradingPage.tsx | 45 ++++-- .../components/paper/PaperAllocationTable.tsx | 8 + .../components/paper/PaperLeftSettingsTab.tsx | 33 +++- .../components/paper/PaperLeftStrategyTab.tsx | 48 ++++-- .../paper/PaperSymbolStageSettings.tsx | 144 ++++++++++++++++++ .../paper/PaperTradeHistoryList.tsx | 19 ++- frontend/src/styles/paperDashboard.css | 68 +++++++++ .../src/styles/virtualTradingDashboard.css | 5 + frontend/src/utils/backendApi.ts | 18 ++- 모의투자 처리과정.md | 16 +- 20 files changed, 696 insertions(+), 56 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/service/PaperTradeStageService.java create mode 100644 backend/src/main/resources/db/migration/V55__paper_symbol_trade_stages.sql create mode 100644 backend/src/main/resources/db/migration/V56__paper_symbol_trade_stages_int.sql create mode 100644 backend/src/test/java/com/goldenchart/service/PaperTradeStageServiceTest.java create mode 100644 frontend/src/components/paper/PaperSymbolStageSettings.tsx diff --git a/backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java b/backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java index c4e5397..dfcda9f 100644 --- a/backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java @@ -18,5 +18,11 @@ public class PaperAllocationBulkRequest { private Boolean isActive; private Boolean pinned; private Integer sortOrder; + private Double buyStage1Krw; + private Double buyStage2Krw; + private Double buyStage3Krw; + private Double sellStage1Krw; + private Double sellStage2Krw; + private Double sellStage3Krw; } } diff --git a/backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java b/backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java index 05b565f..4ed9948 100644 --- a/backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java +++ b/backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java @@ -20,4 +20,12 @@ public class PaperAllocationDto { private Double evalAmount; private Double symbolReturnPct; private Double weightPct; + private Double buyStage1Krw; + private Double buyStage2Krw; + private Double buyStage3Krw; + private Double sellStage1Krw; + private Double sellStage2Krw; + private Double sellStage3Krw; + private Integer buyStageDone; + private Integer sellStageDone; } diff --git a/backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java b/backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java index 2d1f36c..60d2f5f 100644 --- a/backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java @@ -11,4 +11,10 @@ public class PaperAllocationPatchRequest { private Boolean pinned; private Integer sortOrder; private String koreanName; + private Double buyStage1Krw; + private Double buyStage2Krw; + private Double buyStage3Krw; + private Double sellStage1Krw; + private Double sellStage2Krw; + private Double sellStage3Krw; } diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java b/backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java index e47cea7..5854961 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java @@ -48,6 +48,38 @@ public class GcPaperSymbolAllocation { @Builder.Default private Integer sortOrder = 0; + @Column(name = "buy_stage1_krw", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal buyStage1Krw = BigDecimal.ZERO; + + @Column(name = "buy_stage2_krw", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal buyStage2Krw = BigDecimal.ZERO; + + @Column(name = "buy_stage3_krw", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal buyStage3Krw = BigDecimal.ZERO; + + @Column(name = "sell_stage1_krw", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal sellStage1Krw = BigDecimal.ZERO; + + @Column(name = "sell_stage2_krw", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal sellStage2Krw = BigDecimal.ZERO; + + @Column(name = "sell_stage3_krw", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal sellStage3Krw = BigDecimal.ZERO; + + @Column(name = "buy_stage_done", nullable = false) + @Builder.Default + private Integer buyStageDone = 0; + + @Column(name = "sell_stage_done", nullable = false) + @Builder.Default + private Integer sellStageDone = 0; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/goldenchart/service/PaperAllocationService.java b/backend/src/main/java/com/goldenchart/service/PaperAllocationService.java index 63c2fb1..1a20901 100644 --- a/backend/src/main/java/com/goldenchart/service/PaperAllocationService.java +++ b/backend/src/main/java/com/goldenchart/service/PaperAllocationService.java @@ -61,6 +61,8 @@ public class PaperAllocationService { if (item.getIsActive() != null) row.setIsActive(item.getIsActive()); if (item.getPinned() != null) row.setPinned(item.getPinned()); row.setSortOrder(item.getSortOrder() != null ? item.getSortOrder() : order++); + applyStageAmounts(row, item.getBuyStage1Krw(), item.getBuyStage2Krw(), item.getBuyStage3Krw(), + item.getSellStage1Krw(), item.getSellStage2Krw(), item.getSellStage3Krw()); allocRepo.save(row); } return listWithMetrics(accountId, null, 0); @@ -78,10 +80,37 @@ public class PaperAllocationService { if (req.getPinned() != null) row.setPinned(req.getPinned()); if (req.getSortOrder() != null) row.setSortOrder(req.getSortOrder()); if (req.getKoreanName() != null) row.setKoreanName(req.getKoreanName()); + applyStageAmounts(row, req.getBuyStage1Krw(), req.getBuyStage2Krw(), req.getBuyStage3Krw(), + req.getSellStage1Krw(), req.getSellStage2Krw(), req.getSellStage3Krw()); allocRepo.save(row); return toDto(row, accountId, null, 0); } + public void resetTradeStages(Long accountId, String symbol) { + allocRepo.findByAccountIdAndSymbol(accountId, symbol).ifPresent(row -> { + PaperTradeStageService.resetTradeStages(row); + allocRepo.save(row); + }); + } + + public void advanceBuyStageAfterTrade(Long accountId, String symbol) { + allocRepo.findByAccountIdAndSymbol(accountId, symbol).ifPresent(row -> { + PaperTradeStageService.advanceBuyStage(row); + allocRepo.save(row); + }); + } + + public void advanceSellStageAfterTrade(Long accountId, String symbol) { + allocRepo.findByAccountIdAndSymbol(accountId, symbol).ifPresent(row -> { + PaperTradeStageService.advanceSellStage(row); + allocRepo.save(row); + }); + } + + public GcPaperSymbolAllocation getOrDefaultEntity(Long accountId, String symbol, BigDecimal defaultMax) { + return getOrDefault(accountId, symbol, defaultMax); + } + public GcPaperSymbolAllocation getOrDefault(Long accountId, String symbol, BigDecimal defaultMax) { return allocRepo.findByAccountIdAndSymbol(accountId, symbol) .orElseGet(() -> { @@ -191,6 +220,29 @@ public class PaperAllocationService { .evalAmount(eval) .symbolReturnPct(retPct) .weightPct(weight) + .buyStage1Krw(stageKrw(a.getBuyStage1Krw())) + .buyStage2Krw(stageKrw(a.getBuyStage2Krw())) + .buyStage3Krw(stageKrw(a.getBuyStage3Krw())) + .sellStage1Krw(stageKrw(a.getSellStage1Krw())) + .sellStage2Krw(stageKrw(a.getSellStage2Krw())) + .sellStage3Krw(stageKrw(a.getSellStage3Krw())) + .buyStageDone(a.getBuyStageDone() != null ? a.getBuyStageDone() : 0) + .sellStageDone(a.getSellStageDone() != null ? a.getSellStageDone() : 0) .build(); } + + private static void applyStageAmounts(GcPaperSymbolAllocation row, + Double b1, Double b2, Double b3, + Double s1, Double s2, Double s3) { + if (b1 != null) row.setBuyStage1Krw(BigDecimal.valueOf(Math.max(0, b1)).setScale(2, RM)); + if (b2 != null) row.setBuyStage2Krw(BigDecimal.valueOf(Math.max(0, b2)).setScale(2, RM)); + if (b3 != null) row.setBuyStage3Krw(BigDecimal.valueOf(Math.max(0, b3)).setScale(2, RM)); + if (s1 != null) row.setSellStage1Krw(BigDecimal.valueOf(Math.max(0, s1)).setScale(2, RM)); + if (s2 != null) row.setSellStage2Krw(BigDecimal.valueOf(Math.max(0, s2)).setScale(2, RM)); + if (s3 != null) row.setSellStage3Krw(BigDecimal.valueOf(Math.max(0, s3)).setScale(2, RM)); + } + + private static double stageKrw(BigDecimal v) { + return v != null ? v.doubleValue() : 0; + } } diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradeStageService.java b/backend/src/main/java/com/goldenchart/service/PaperTradeStageService.java new file mode 100644 index 0000000..f945158 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PaperTradeStageService.java @@ -0,0 +1,91 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcPaperSymbolAllocation; + +import java.math.BigDecimal; + +/** + * 모의투자 자동매매 — 종목별 1~3차 분할 매수·매도 금액·진행 단계. + */ +public final class PaperTradeStageService { + + private static final int MAX_STAGES = 3; + + private PaperTradeStageService() {} + + public static boolean usesStagedTrading(GcPaperSymbolAllocation alloc) { + return sumBuyStages(alloc) > 0 || sumSellStages(alloc) > 0; + } + + public static double sumBuyStages(GcPaperSymbolAllocation alloc) { + return stageKrw(alloc.getBuyStage1Krw()) + + stageKrw(alloc.getBuyStage2Krw()) + + stageKrw(alloc.getBuyStage3Krw()); + } + + public static double sumSellStages(GcPaperSymbolAllocation alloc) { + return stageKrw(alloc.getSellStage1Krw()) + + stageKrw(alloc.getSellStage2Krw()) + + stageKrw(alloc.getSellStage3Krw()); + } + + /** 다음 매수 단계(1~3) 금액. 없으면 0 */ + public static double nextBuyStageKrw(GcPaperSymbolAllocation alloc) { + int done = stageDone(alloc.getBuyStageDone()); + int next = done + 1; + if (next > MAX_STAGES) return 0; + return stageAmount(alloc, next, true); + } + + /** 다음 매도 단계(1~3) 금액. 없으면 0 */ + public static double nextSellStageKrw(GcPaperSymbolAllocation alloc) { + int done = stageDone(alloc.getSellStageDone()); + int next = done + 1; + if (next > MAX_STAGES) return 0; + return stageAmount(alloc, next, false); + } + + public static void advanceBuyStage(GcPaperSymbolAllocation alloc) { + int done = stageDone(alloc.getBuyStageDone()); + if (done < MAX_STAGES) { + alloc.setBuyStageDone(done + 1); + } + } + + public static void advanceSellStage(GcPaperSymbolAllocation alloc) { + int done = stageDone(alloc.getSellStageDone()); + if (done < MAX_STAGES) { + alloc.setSellStageDone(done + 1); + } + } + + public static void resetTradeStages(GcPaperSymbolAllocation alloc) { + alloc.setBuyStageDone(0); + alloc.setSellStageDone(0); + } + + /** 매도 수량: 단계 금액 기준, 보유 가용 수량 상한 */ + public static double sellQtyForStageKrw(double stageKrw, double availQty, double execPrice) { + if (stageKrw <= 0 || availQty <= 0 || execPrice <= 0) return 0; + double byKrw = stageKrw / execPrice; + return Math.min(availQty, byKrw); + } + + private static int stageDone(Integer done) { + if (done == null || done < 0) return 0; + return Math.min(MAX_STAGES, done); + } + + private static double stageAmount(GcPaperSymbolAllocation alloc, int stage1Based, boolean buy) { + return switch (stage1Based) { + case 1 -> buy ? stageKrw(alloc.getBuyStage1Krw()) : stageKrw(alloc.getSellStage1Krw()); + case 2 -> buy ? stageKrw(alloc.getBuyStage2Krw()) : stageKrw(alloc.getSellStage2Krw()); + case 3 -> buy ? stageKrw(alloc.getBuyStage3Krw()) : stageKrw(alloc.getSellStage3Krw()); + default -> 0; + }; + } + + private static double stageKrw(BigDecimal v) { + return v != null ? Math.max(0, v.doubleValue()) : 0; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java index ea5aa0c..d116567 100644 --- a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java +++ b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java @@ -290,41 +290,85 @@ public class PaperTradingService { .filter(m -> m != null && !m.isBlank()) .findFirst() .orElse("LONG_ONLY"); - if ("LONG_ONLY".equals(positionMode)) { - GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market) - .orElse(null); - double held = posNow != null ? posNow.getQuantity().doubleValue() : 0; + BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null + ? account.getInitialCapitalSnapshot() + : (app.getPaperInitialCapital() != null + ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000)); + GcPaperSymbolAllocation alloc = allocationService.getOrDefaultEntity( + account.getId(), market, defaultMax); + boolean staged = PaperTradeStageService.usesStagedTrading(alloc); + + GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market) + .orElse(null); + double held = posNow != null ? posNow.getQuantity().doubleValue() : 0; + + if ("LONG_ONLY".equals(positionMode) && !staged) { if ("BUY".equals(signalType) && held > 0) return; if ("SELL".equals(signalType) && held <= 0) return; } + double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); double minOrder = app.getPaperMinOrderKrw() != null ? app.getPaperMinOrderKrw().doubleValue() : 5000; if ("BUY".equals(signalType)) { - double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); - double cash = account.getCashBalance().doubleValue(); - double reserved = account.getReservedCash() != null - ? account.getReservedCash().doubleValue() : 0; - double budget = (cash - reserved) * budgetPct / 100.0; - double execPrice = price * (1 + slip / 100.0); - double denom = execPrice * (1 + feeRate / 100.0); - if (denom <= 0 || budget < minOrder) return; - double qty = budget / denom; - if (qty * execPrice < minOrder) return; - executeTrade(uid, app, market, "BUY", "market", "STRATEGY", - strategyId, price, qty, null, true); - log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price); + if (staged) { + if (held <= 0) { + allocationService.resetTradeStages(account.getId(), market); + alloc = allocationService.getOrDefaultEntity(account.getId(), market, defaultMax); + } else if ("LONG_ONLY".equals(positionMode) + && alloc.getBuyStageDone() != null && alloc.getBuyStageDone() >= 3) { + return; + } + double stageKrw = PaperTradeStageService.nextBuyStageKrw(alloc); + if (stageKrw <= 0) return; + if (stageKrw < minOrder) return; + double execPrice = price * (1 + slip / 100.0); + double denom = execPrice * (1 + feeRate / 100.0); + if (denom <= 0) return; + double qty = stageKrw / denom; + if (qty * execPrice < minOrder) return; + executeTrade(uid, app, market, "BUY", "market", "STRATEGY", + strategyId, price, qty, null, true); + allocationService.advanceBuyStageAfterTrade(account.getId(), market); + log.info("[Paper] STRATEGY BUY (staged) {} qty≈{} @ {}", market, qty, price); + } else { + double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); + double cash = account.getCashBalance().doubleValue(); + double reserved = account.getReservedCash() != null + ? account.getReservedCash().doubleValue() : 0; + double budget = (cash - reserved) * budgetPct / 100.0; + double execPrice = price * (1 + slip / 100.0); + double denom = execPrice * (1 + feeRate / 100.0); + if (denom <= 0 || budget < minOrder) return; + double qty = budget / denom; + if (qty * execPrice < minOrder) return; + executeTrade(uid, app, market, "BUY", "market", "STRATEGY", + strategyId, price, qty, null, true); + log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price); + } } else { - GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) - .orElse(null); - if (pos == null || pos.getQuantity().doubleValue() <= 0) return; - double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); + if (held <= 0) return; + double avail = availableSellQty(account.getId(), market, held); if (avail <= 0) return; - executeTrade(uid, app, market, "SELL", "market", "STRATEGY", - strategyId, price, avail, null, true); - log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price); + + if (staged) { + double stageKrw = PaperTradeStageService.nextSellStageKrw(alloc); + if (stageKrw <= 0) return; + if (stageKrw < minOrder) return; + double execPrice = price * (1 - slip / 100.0); + double qty = PaperTradeStageService.sellQtyForStageKrw(stageKrw, avail, execPrice); + if (qty * execPrice < minOrder) return; + executeTrade(uid, app, market, "SELL", "market", "STRATEGY", + strategyId, price, qty, null, true); + allocationService.advanceSellStageAfterTrade(account.getId(), market); + log.info("[Paper] STRATEGY SELL (staged) {} qty={} @ {}", market, qty, price); + } else { + executeTrade(uid, app, market, "SELL", "market", "STRATEGY", + strategyId, price, avail, null, true); + log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price); + } } } catch (Exception e) { log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage()); @@ -591,6 +635,9 @@ public class PaperTradingService { .positionQtyAfter(qtyAfter) .build()); ledgerService.recordSellSettle(account, trade, netProceeds); + if ("STRATEGY".equals(source) && qtyAfter.compareTo(BigDecimal.valueOf(0.00000001)) <= 0) { + allocationService.resetTradeStages(account.getId(), market); + } return toTradeDto(trade); } diff --git a/backend/src/main/resources/db/migration/V55__paper_symbol_trade_stages.sql b/backend/src/main/resources/db/migration/V55__paper_symbol_trade_stages.sql new file mode 100644 index 0000000..8a5d1dd --- /dev/null +++ b/backend/src/main/resources/db/migration/V55__paper_symbol_trade_stages.sql @@ -0,0 +1,10 @@ +-- 종목별 분할 매수·매도 단계 (1~3차) 및 진행 상태 +ALTER TABLE gc_paper_symbol_allocation + ADD COLUMN buy_stage1_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '1차 매수 금액', + ADD COLUMN buy_stage2_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '2차 매수 금액', + ADD COLUMN buy_stage3_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '3차 매수 금액', + ADD COLUMN sell_stage1_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '1차 매도 금액', + ADD COLUMN sell_stage2_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '2차 매도 금액', + ADD COLUMN sell_stage3_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '3차 매도 금액', + ADD COLUMN buy_stage_done TINYINT NOT NULL DEFAULT 0 COMMENT '완료한 매수 단계 0~3', + ADD COLUMN sell_stage_done TINYINT NOT NULL DEFAULT 0 COMMENT '완료한 매도 단계 0~3'; diff --git a/backend/src/main/resources/db/migration/V56__paper_symbol_trade_stages_int.sql b/backend/src/main/resources/db/migration/V56__paper_symbol_trade_stages_int.sql new file mode 100644 index 0000000..c9a2cd4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V56__paper_symbol_trade_stages_int.sql @@ -0,0 +1,4 @@ +-- V55 TINYINT → Hibernate Integer 매핑과 맞추기 (schema-validation) +ALTER TABLE gc_paper_symbol_allocation + MODIFY COLUMN buy_stage_done INT NOT NULL DEFAULT 0 COMMENT '완료한 매수 단계 0~3', + MODIFY COLUMN sell_stage_done INT NOT NULL DEFAULT 0 COMMENT '완료한 매도 단계 0~3'; diff --git a/backend/src/test/java/com/goldenchart/service/PaperTradeStageServiceTest.java b/backend/src/test/java/com/goldenchart/service/PaperTradeStageServiceTest.java new file mode 100644 index 0000000..d59fe64 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/PaperTradeStageServiceTest.java @@ -0,0 +1,44 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcPaperSymbolAllocation; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; + +class PaperTradeStageServiceTest { + + @Test + void stagedBuyAdvancesBySignal() { + GcPaperSymbolAllocation a = GcPaperSymbolAllocation.builder() + .buyStage1Krw(BigDecimal.valueOf(100_000)) + .buyStage2Krw(BigDecimal.valueOf(200_000)) + .buyStageDone(0) + .build(); + assertTrue(PaperTradeStageService.usesStagedTrading(a)); + assertEquals(100_000, PaperTradeStageService.nextBuyStageKrw(a)); + PaperTradeStageService.advanceBuyStage(a); + assertEquals(200_000, PaperTradeStageService.nextBuyStageKrw(a)); + assertEquals(0, PaperTradeStageService.nextSellStageKrw(a)); + } + + @Test + void resetAfterFlatPosition() { + GcPaperSymbolAllocation a = GcPaperSymbolAllocation.builder() + .buyStageDone(2) + .sellStageDone(1) + .build(); + PaperTradeStageService.resetTradeStages(a); + assertEquals(0, a.getBuyStageDone()); + assertEquals(0, a.getSellStageDone()); + } + + @Test + void sellQtyCappedByAvailable() { + double qty = PaperTradeStageService.sellQtyForStageKrw(500_000, 0.5, 100_000); + assertEquals(0.5, qty, 1e-9); + double partial = PaperTradeStageService.sellQtyForStageKrw(500_000, 0.001, 100_000); + assertEquals(0.001, partial, 1e-9); + } +} diff --git a/frontend/src/components/PaperTradingPage.tsx b/frontend/src/components/PaperTradingPage.tsx index 5e92ee0..2b35d12 100644 --- a/frontend/src/components/PaperTradingPage.tsx +++ b/frontend/src/components/PaperTradingPage.tsx @@ -104,6 +104,8 @@ const PaperTradingPage: React.FC = ({ ); const [fillBuy, setFillBuy] = useState(null); const [fillSell, setFillSell] = useState(null); + /** 우측 거래내역 목록에서 선택한 체결 행 */ + const [selectedTradeId, setSelectedTradeId] = useState(null); const orderAnchorRef = useRef(null); useEffect(() => { @@ -185,10 +187,14 @@ const PaperTradingPage: React.FC = ({ return coerceFiniteNumber(p?.quantity) ?? 0; }, [s?.positions, selectedMarket]); - const investmentTargets = useMemo( - () => s?.allocations ?? [], - [s?.allocations], - ); + /** 가상매매 투자대상 순서·종목과 일치 (allocations는 sync 부가 정보) */ + const investmentTargets = useMemo(() => { + const allocs = s?.allocations ?? []; + const bySymbol = new Map(allocs.map(a => [a.symbol, a])); + return virtualTargets + .map(t => bySymbol.get(t.market)) + .filter((a): a is NonNullable => a != null); + }, [s?.allocations, virtualTargets]); const selectedAlloc = useMemo( () => investmentTargets.find(a => a.symbol === selectedMarket), @@ -236,17 +242,29 @@ const PaperTradingPage: React.FC = ({ openingPrice: selectedTicker.openingPrice, } : undefined; - const handleSelectMarket = useCallback((market: string) => { + const applyMarketAndOrderPrice = useCallback((market: string, price?: number | null) => { setSelectedMarket(market); - const price = tickers?.get(market)?.tradePrice; - if (price != null && price > 0) { + const fillPrice = price != null && price > 0 ? price : tickers?.get(market)?.tradePrice; + if (fillPrice != null && fillPrice > 0) { const seq = Date.now(); - setFillBuy({ market, price, side: 'buy', seq }); - setFillSell({ market, price, side: 'sell', seq: seq + 1 }); + setFillBuy({ market, price: fillPrice, side: 'buy', seq }); + setFillSell({ market, price: fillPrice, side: 'sell', seq: seq + 1 }); } - setRightTab('trade'); }, [tickers]); + const handleSelectMarket = useCallback((market: string) => { + setSelectedTradeId(null); + applyMarketAndOrderPrice(market); + setRightTab('trade'); + }, [applyMarketAndOrderPrice]); + + /** 거래내역 클릭 — 차트·매매 가격만 반영, 우측 탭은 거래내역 유지 */ + const handleTradeHistorySelect = useCallback((trade: PaperTradeDto) => { + setSelectedTradeId(trade.id); + applyMarketAndOrderPrice(trade.symbol, trade.price); + setCenterTab('chart'); + }, [applyMarketAndOrderPrice]); + const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { setRightTab('trade'); const side = rowType === 'bid' ? 'buy' : 'sell'; @@ -314,11 +332,15 @@ const PaperTradingPage: React.FC = ({ ) : ( void loadData()} onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }} @@ -441,7 +463,8 @@ const PaperTradingPage: React.FC = ({ trades={trades} strategyNames={strategyNames} tickers={tickers} - onSelectMarket={handleSelectMarket} + selectedTradeId={selectedTradeId} + onSelectTrade={handleTradeHistorySelect} emptyText="체결 내역이 없습니다." className="ptd-trade-history--fill" /> diff --git a/frontend/src/components/paper/PaperAllocationTable.tsx b/frontend/src/components/paper/PaperAllocationTable.tsx index 897330e..1e53d3a 100644 --- a/frontend/src/components/paper/PaperAllocationTable.tsx +++ b/frontend/src/components/paper/PaperAllocationTable.tsx @@ -115,6 +115,14 @@ const PaperAllocationTable: React.FC = ({ {ret >= 0 ? '+' : ''}{ret.toFixed(2)}% + {(row.buyStage1Krw || row.buyStage2Krw || row.sellStage1Krw) ? ( +
+ 분할 + + 매수 {row.buyStageDone ?? 0}/3 · 매도 {row.sellStageDone ?? 0}/3 + +
+ ) : null} ); diff --git a/frontend/src/components/paper/PaperLeftSettingsTab.tsx b/frontend/src/components/paper/PaperLeftSettingsTab.tsx index dca49f7..096d081 100644 --- a/frontend/src/components/paper/PaperLeftSettingsTab.tsx +++ b/frontend/src/components/paper/PaperLeftSettingsTab.tsx @@ -1,9 +1,12 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useAppSettings } from '../../hooks/useAppSettings'; -import { resetPaperAccount } from '../../utils/backendApi'; +import { resetPaperAccount, type PaperAllocationDto } from '../../utils/backendApi'; import PaperSplitPanel from './PaperSplitPanel'; +import PaperSymbolStageSettings from './PaperSymbolStageSettings'; interface Props { + selectedMarket?: string; + allocations?: PaperAllocationDto[]; onSettingsSaved?: () => void; onAccountReset?: () => void; onOpenSettings?: () => void; @@ -16,8 +19,18 @@ const SettingsFieldRow: React.FC<{ label: string; children: React.ReactNode }> = ); -const PaperLeftSettingsTab: React.FC = ({ onSettingsSaved, onAccountReset, onOpenSettings }) => { +const PaperLeftSettingsTab: React.FC = ({ + selectedMarket, + allocations = [], + onSettingsSaved, + onAccountReset, + onOpenSettings, +}) => { const { defaults, save, isLoaded } = useAppSettings(); + const selectedAlloc = useMemo( + () => allocations.find(a => a.symbol === selectedMarket), + [allocations, selectedMarket], + ); if (!isLoaded) { return

설정 로딩…

; @@ -78,7 +91,17 @@ const PaperLeftSettingsTab: React.FC = ({ onSettingsSaved, onAccountReset ); const bottom = ( -
+
+ {selectedMarket ? ( + + ) : ( +

중앙 투자금 관리에서 종목을 선택하면 분할 매매 단계를 설정할 수 있습니다.

+ )} +
@@ -135,7 +158,7 @@ const PaperLeftSettingsTab: React.FC = ({ onSettingsSaved, onAccountReset diff --git a/frontend/src/components/paper/PaperLeftStrategyTab.tsx b/frontend/src/components/paper/PaperLeftStrategyTab.tsx index 698a5af..41d41f6 100644 --- a/frontend/src/components/paper/PaperLeftStrategyTab.tsx +++ b/frontend/src/components/paper/PaperLeftStrategyTab.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { loadStrategies, loadLiveStrategySettings, @@ -6,13 +6,18 @@ import { type PaperSummaryDto, type StrategyDto, } from '../../utils/backendApi'; -import { loadVirtualSession } from '../../utils/virtualTradingStorage'; +import { loadVirtualSession, type VirtualTargetItem } from '../../utils/virtualTradingStorage'; +import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames'; +import type { TickerData } from '../../hooks/useMarketTicker'; import { fmtKrw } from '../TradeOrderPanel'; import PaperSplitPanel from './PaperSplitPanel'; interface Props { market: string; summary: PaperSummaryDto | null; + /** 가상매매 투자대상 — 보유 목록은 이 종목만 표시 */ + virtualTargets?: VirtualTargetItem[]; + tickers?: Map; paperAutoTradeEnabled?: boolean; onMarketSelect?: (market: string) => void; } @@ -20,6 +25,8 @@ interface Props { const PaperLeftStrategyTab: React.FC = ({ market, summary, + virtualTargets = [], + tickers, paperAutoTradeEnabled = false, onMarketSelect, }) => { @@ -91,7 +98,23 @@ const PaperLeftStrategyTab: React.FC = ({ ); - const positions = summary?.positions ?? []; + const targetMarkets = useMemo( + () => new Set(virtualTargets.map(t => t.market)), + [virtualTargets], + ); + + const positions = useMemo(() => { + const all = (summary?.positions ?? []).filter(p => p.quantity > 0); + if (targetMarkets.size === 0) return []; + const order = virtualTargets.map(t => t.market); + return all + .filter(p => targetMarkets.has(p.symbol)) + .sort((a, b) => { + const ia = order.indexOf(a.symbol); + const ib = order.indexOf(b.symbol); + return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib); + }); + }, [summary?.positions, targetMarkets, virtualTargets]); const bottom = (
@@ -99,14 +122,21 @@ const PaperLeftStrategyTab: React.FC = ({ 보유 목록 {positions.length}종목
- {positions.length === 0 ? ( -

보유 종목이 없습니다.

+ {targetMarkets.size === 0 ? ( +

가상매매 투자대상이 없습니다. 가상투자에서 종목을 추가하세요.

+ ) : positions.length === 0 ? ( +

투자대상 종목 중 보유 수량이 있는 종목이 없습니다.

) : (
{positions.map(p => { - const code = p.symbol.replace(/^KRW-/, ''); - const name = p.koreanName || code; + const ticker = tickers?.get(p.symbol); + const vt = virtualTargets.find(t => t.market === p.symbol); + const { koreanName: ko, englishName: en } = resolveVirtualTargetNames( + p.symbol, + p.koreanName ?? vt?.koreanName ?? ticker?.koreanName, + vt?.englishName, + ); const up = (p.profitLoss ?? 0) >= 0; const selected = market === p.symbol; const retPct = p.profitLossPct; @@ -132,8 +162,8 @@ const PaperLeftStrategyTab: React.FC = ({ >
- {name} - {code}/KRW + {ko} + {en}
{p.profitLoss != null && (
diff --git a/frontend/src/components/paper/PaperSymbolStageSettings.tsx b/frontend/src/components/paper/PaperSymbolStageSettings.tsx new file mode 100644 index 0000000..754683c --- /dev/null +++ b/frontend/src/components/paper/PaperSymbolStageSettings.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import type { PaperAllocationDto } from '../../utils/backendApi'; +import { patchPaperAllocation } from '../../utils/backendApi'; +import { fmtKrw } from '../TradeOrderPanel'; + +interface Props { + symbol: string; + allocation?: PaperAllocationDto | null; + onSaved?: () => void; +} + +type StageForm = { + buy1: string; + buy2: string; + buy3: string; + sell1: string; + sell2: string; + sell3: string; +}; + +function toForm(row?: PaperAllocationDto | null): StageForm { + const n = (v?: number) => (v != null && v > 0 ? String(Math.round(v)) : ''); + return { + buy1: n(row?.buyStage1Krw), + buy2: n(row?.buyStage2Krw), + buy3: n(row?.buyStage3Krw), + sell1: n(row?.sellStage1Krw), + sell2: n(row?.sellStage2Krw), + sell3: n(row?.sellStage3Krw), + }; +} + +function parseKrw(s: string): number { + const v = Number(String(s).replace(/,/g, '').trim()); + return Number.isFinite(v) && v > 0 ? Math.round(v) : 0; +} + +const PaperSymbolStageSettings: React.FC = ({ symbol, allocation, onSaved }) => { + const [form, setForm] = useState(() => toForm(allocation)); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(null); + + useEffect(() => { + setForm(toForm(allocation)); + setMsg(null); + }, [symbol, allocation?.id, allocation?.buyStage1Krw, allocation?.sellStage1Krw]); + + const save = useCallback(async () => { + setSaving(true); + setMsg(null); + try { + await patchPaperAllocation(symbol, { + buyStage1Krw: parseKrw(form.buy1), + buyStage2Krw: parseKrw(form.buy2), + buyStage3Krw: parseKrw(form.buy3), + sellStage1Krw: parseKrw(form.sell1), + sellStage2Krw: parseKrw(form.sell2), + sellStage3Krw: parseKrw(form.sell3), + }); + setMsg('저장되었습니다.'); + onSaved?.(); + } catch (e) { + setMsg(e instanceof Error ? e.message : '저장 실패'); + } finally { + setSaving(false); + } + }, [symbol, form, onSaved]); + + const name = allocation?.koreanName || symbol.replace(/^KRW-/, ''); + const buyDone = allocation?.buyStageDone ?? 0; + const sellDone = allocation?.sellStageDone ?? 0; + + return ( +
+
+ {name} + {symbol} +
+

+ 자동매매 시그널마다 다음 단계 금액만 체결합니다. 매수·매도 금액을 0으로 두면 해당 차수는 건너뜁니다. +

+
+ 진행: 매수 {buyDone}/3 · 매도 {sellDone}/3 + {(buyDone > 0 || sellDone > 0) && ( + (전량 매도 시 매수·매도 단계 초기화) + )} +
+
+
+ 매수 단계 (KRW) + {(['buy1', 'buy2', 'buy3'] as const).map((key, i) => ( + + ))} +
+
+ 매도 단계 (KRW) + {(['sell1', 'sell2', 'sell3'] as const).map((key, i) => ( + + ))} +
+
+ {allocation?.maxInvestKrw != null && allocation.maxInvestKrw > 0 && ( +

+ 매수 합계 {fmtKrw( + parseKrw(form.buy1) + parseKrw(form.buy2) + parseKrw(form.buy3), + )} · 종목 한도 {fmtKrw(allocation.maxInvestKrw)} +

+ )} + + {msg &&

{msg}

} +
+ ); +}; + +export default PaperSymbolStageSettings; diff --git a/frontend/src/components/paper/PaperTradeHistoryList.tsx b/frontend/src/components/paper/PaperTradeHistoryList.tsx index 3bbfe61..fe66707 100644 --- a/frontend/src/components/paper/PaperTradeHistoryList.tsx +++ b/frontend/src/components/paper/PaperTradeHistoryList.tsx @@ -27,6 +27,10 @@ interface Props { tickers?: Map; emptyText?: string; className?: string; + selectedTradeId?: number | null; + /** 투자관리 — 체결 행 전체 전달(탭 유지·선택 표시) */ + onSelectTrade?: (trade: PaperTradeDto) => void; + /** 가상투자 등 — 종목만 (기존 동작) */ onSelectMarket?: (market: string) => void; } @@ -36,6 +40,8 @@ const PaperTradeHistoryList: React.FC = ({ tickers, emptyText = '거래 내역이 없습니다.', className = '', + selectedTradeId = null, + onSelectTrade, onSelectMarket, }) => (
@@ -59,7 +65,12 @@ const PaperTradeHistoryList: React.FC = ({ ? (strategyNames[t.strategyId] ?? `#${t.strategyId}`) : '—'; const isBuy = t.side === 'BUY'; - const clickable = Boolean(onSelectMarket); + const clickable = Boolean(onSelectTrade || onSelectMarket); + const selected = selectedTradeId != null && selectedTradeId === t.id; + const handleActivate = () => { + if (onSelectTrade) onSelectTrade(t); + else onSelectMarket?.(t.symbol); + }; const sideCls = isBuy ? 'vtd-trade-item--buy' : 'vtd-trade-item--sell'; const colorCls = isBuy ? 'vtd-target-up' : 'vtd-target-dn'; @@ -71,14 +82,16 @@ const PaperTradeHistoryList: React.FC = ({ 'vtd-trade-item', sideCls, clickable ? 'vtd-trade-item--click' : '', + selected ? 'vtd-trade-item--selected' : '', ].filter(Boolean).join(' ')} role={clickable ? 'button' : undefined} tabIndex={clickable ? 0 : undefined} - onClick={clickable ? () => onSelectMarket!(t.symbol) : undefined} + aria-pressed={clickable ? selected : undefined} + onClick={clickable ? handleActivate : undefined} onKeyDown={clickable ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - onSelectMarket!(t.symbol); + handleActivate(); } } : undefined} > diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index 7d0a515..51e6540 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -582,6 +582,74 @@ flex-shrink: 0; } +.ptd-settings-panel--stages { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + overflow: auto; +} + +.ptd-settings-panel-divider { + height: 1px; + background: color-mix(in srgb, var(--border, #2a2e39) 80%, transparent); + margin: 4px 0; +} + +.ptd-stage-settings-head { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ptd-stage-settings-hint { + font-size: 11px; + line-height: 1.4; + margin: 0; +} + +.ptd-stage-progress { + font-size: 12px; + color: var(--text2, #787b86); +} + +.ptd-stage-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; +} + +.ptd-stage-col-title { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--text2, #787b86); + margin-bottom: 4px; +} + +.ptd-stage-field { + margin-top: 4px; +} + +.ptd-stage-save { + width: 100%; +} + +.ptd-stage-msg { + font-size: 12px; + margin: 0; + color: var(--accent, #3f7ef5); +} + +.ptd-stage-msg--err { + color: var(--danger, #ef5350); +} + +.ptd-alloc-stage-badge { + font-size: 11px; + color: var(--text2, #787b86); +} + .ptd-split-panel--left .ptd-split-card-body:has(.ptd-settings-panel) { display: flex; flex-direction: column; diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index b5b6763..38c9eb6 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -575,6 +575,11 @@ outline-offset: 2px; } +.vtd-trade-item--selected { + box-shadow: inset 0 0 0 1px var(--accent, #3f7ef5); + background: color-mix(in srgb, var(--accent, #3f7ef5) 12%, var(--se-panel-card-bg, #1e222d)); +} + .vtd-trade-item--buy { border-color: color-mix(in srgb, var(--gc-trade-buy) 22%, var(--se-border)); } diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 06c3e7c..68cd4db 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -625,6 +625,14 @@ export interface PaperAllocationDto { evalAmount?: number; symbolReturnPct?: number; weightPct?: number; + buyStage1Krw?: number; + buyStage2Krw?: number; + buyStage3Krw?: number; + sellStage1Krw?: number; + sellStage2Krw?: number; + sellStage3Krw?: number; + buyStageDone?: number; + sellStageDone?: number; } export interface PaperSummaryDto { @@ -727,6 +735,12 @@ export interface PaperAllocationBulkItem { isActive?: boolean; pinned?: boolean; sortOrder?: number; + buyStage1Krw?: number; + buyStage2Krw?: number; + buyStage3Krw?: number; + sellStage1Krw?: number; + sellStage2Krw?: number; + sellStage3Krw?: number; } export interface PaperOrderRequest { @@ -794,7 +808,9 @@ export async function putPaperAllocationsBulk(items: PaperAllocationBulkItem[]): export async function patchPaperAllocation( symbol: string, - body: Partial>, + body: Partial>, ): Promise { return request(`/paper/allocations/${encodeURIComponent(symbol)}`, { method: 'PATCH', diff --git a/모의투자 처리과정.md b/모의투자 처리과정.md index 811b189..224c396 100644 --- a/모의투자 처리과정.md +++ b/모의투자 처리과정.md @@ -276,14 +276,24 @@ trading_mode: | 5 | `live_strategy_check = true` **OR** 해당 `market`에 `is_live_check=true` 설정 존재 | | 6 | `trading_mode`가 PAPER 또는 BOTH (`TradingExecutionService` 단계) | -**포지션 모드 `LONG_ONLY` (실제 계좌 보유 기준):** +**포지션 모드 `LONG_ONLY` (분할 매매 미설정 시):** | 시그널 | 스킵 조건 | |--------|-----------| | BUY | 해당 종목 **이미 보유** (`quantity > 0`) | | SELL | 해당 종목 **미보유** | -**매수 금액 계산:** +**분할 매매 (종목별 1~3차, `gc_paper_symbol_allocation`):** + +- 투자관리 **설정** 탭 → 선택 종목: `buy_stage1~3_krw`, `sell_stage1~3_krw` (KRW) +- 매수·매도 단계 금액 중 **하나라도 > 0** 이면 분할 모드 (`PaperTradeStageService.usesStagedTrading`) +- **BUY 시그널마다** 다음 매수 차수 금액만 체결 (`buy_stage_done` 0→1→2→3) +- **SELL 시그널마다** 다음 매도 차수 금액만큼 수량 체결 (`sell_stage_done` 증가) +- **전량 매도** 후 `buy_stage_done`·`sell_stage_done` **0으로 초기화** +- 분할 모드에서는 보유 중에도 2·3차 **추가 매수** 가능 (LONG_ONLY 일괄 매수 스킵 해제) +- 단계 금액이 모두 0이면 기존 방식(가용현금 % 일괄 매수·전량 매도) + +**매수 금액 (분할 미설정 시):** ```text budget = (cash_balance - reserved_cash) × paper_auto_trade_budget_pct / 100 @@ -296,7 +306,7 @@ qty = budget / (execPrice × (1 + fee%)) | 7 | `budget < paper_min_order_krw` | | 8 | `qty × execPrice < min_order` | -**매도:** 보유 수량 전량(가용 수량, 미체결 매도 예약 제외) +**매도 (분할 미설정):** 보유 수량 전량(가용 수량, 미체결 매도 예약 제외) **체결:** `executeTrade(..., source="STRATEGY", autoTrade=true)`