From 16d0e2c2267b1c99f6815001b01b7f3263b9477b Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 31 May 2026 03:47:07 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EC=9D=98=ED=88=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/goldenchart/auth/MenuIds.java | 3 +- .../controller/PaperTradingController.java | 76 ++- .../dto/PaperAllocationBulkRequest.java | 22 + .../goldenchart/dto/PaperAllocationDto.java | 23 + .../dto/PaperAllocationPatchRequest.java | 14 + .../dto/PaperDailySnapshotDto.java | 16 + .../goldenchart/dto/PaperLedgerEntryDto.java | 17 + .../com/goldenchart/dto/PaperOrderDto.java | 24 + .../goldenchart/dto/PaperOrderRequest.java | 2 + .../com/goldenchart/dto/PaperPageDto.java | 16 + .../dto/PaperPlaceOrderResult.java | 11 + .../com/goldenchart/dto/PaperSummaryDto.java | 4 + .../com/goldenchart/dto/PaperTradeDto.java | 2 + .../goldenchart/entity/GcPaperAccount.java | 18 + .../goldenchart/entity/GcPaperCashLedger.java | 46 ++ .../entity/GcPaperDailySnapshot.java | 55 +++ .../com/goldenchart/entity/GcPaperOrder.java | 83 ++++ .../goldenchart/entity/GcPaperResetLog.java | 40 ++ .../entity/GcPaperSymbolAllocation.java | 67 +++ .../com/goldenchart/entity/GcPaperTrade.java | 9 + .../GcPaperCashLedgerRepository.java | 15 + .../GcPaperDailySnapshotRepository.java | 15 + .../repository/GcPaperOrderRepository.java | 12 + .../repository/GcPaperResetLogRepository.java | 7 + .../GcPaperSymbolAllocationRepository.java | 13 + .../repository/GcPaperTradeRepository.java | 23 + .../scheduler/PaperSnapshotScheduler.java | 22 + .../service/PaperAllocationService.java | 196 ++++++++ .../service/PaperLedgerService.java | 108 ++++ .../service/PaperOrderMatcherService.java | 53 ++ .../service/PaperSnapshotService.java | 120 +++++ .../service/PaperTradingService.java | 465 +++++++++++++---- .../com/goldenchart/websocket/BarBuilder.java | 2 + .../V51__paper_investment_management.sql | 109 ++++ .../migration/V52__paper_pending_orders.sql | 28 ++ ...V53__settings_virtual_live_permissions.sql | 6 + .../service/PaperAllocationServiceTest.java | 79 +++ db_struct.md | 17 + docs/모의투자_투자금_관리_설계.md | 466 ++++++++++++++++++ frontend/src/App.css | 115 +++-- frontend/src/App.tsx | 61 +-- frontend/src/components/PaperTradingPage.tsx | 325 +++++++----- frontend/src/components/RightSidePanel.tsx | 137 ++--- .../components/ScreenRegionCaptureOverlay.tsx | 187 +++++++ frontend/src/components/SettingsPage.tsx | 297 ++++++----- frontend/src/components/Toolbar.tsx | 67 ++- frontend/src/components/TopMenuBar.tsx | 9 + .../components/TradeNotificationListPage.tsx | 128 ++--- frontend/src/components/TradeOrderPanel.tsx | 158 +++--- .../src/components/VirtualTradingPage.tsx | 99 ++-- .../components/layout/BuilderPageShell.tsx | 4 +- .../components/paper/PaperAllocationTable.tsx | 84 ++++ .../components/paper/PaperAssetTrendChart.tsx | 62 +++ .../components/paper/PaperInvestmentKpi.tsx | 47 ++ .../src/components/paper/PaperLedgerTab.tsx | 63 +++ .../components/paper/PaperLeftSettingsTab.tsx | 8 +- .../paper/PaperPendingOrdersList.tsx | 58 +++ .../paper/PaperTradeHistoryList.tsx | 14 + .../components/trade/TradeBuyOrderPanel.tsx | 21 + .../trade/TradeOrderbookPanelContent.tsx | 39 ++ .../components/trade/TradeRightPanelBody.tsx | 40 ++ .../components/trade/TradeRightPanelTabs.tsx | 33 ++ .../components/trade/TradeSellOrderPanel.tsx | 21 + .../components/trade/TradeSplitOrderPanel.tsx | 83 ++++ frontend/src/components/trade/index.ts | 11 + .../VerificationIssueAttachmentsSection.tsx | 50 +- .../hooks/useVirtualBackendTradeSignals.ts | 4 +- frontend/src/styles/paperDashboard.css | 42 +- frontend/src/styles/tradeNotificationList.css | 5 +- frontend/src/styles/tradeRightPanel.css | 389 +++++++++++++++ frontend/src/styles/verificationBoard.css | 78 +++ .../src/styles/virtualTradingDashboard.css | 268 ++++------ frontend/src/utils/appNavigationPaths.ts | 8 +- frontend/src/utils/backendApi.ts | 196 +++++++- frontend/src/utils/permissions.ts | 12 +- frontend/src/utils/screenRegionCapture.ts | 93 ++++ frontend/src/utils/virtualLiveStrategySync.ts | 17 +- packages/shared/src/api/backendApi.ts | 23 +- 78 files changed, 4688 insertions(+), 972 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperDailySnapshotDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperLedgerEntryDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperOrderDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperPageDto.java create mode 100644 backend/src/main/java/com/goldenchart/dto/PaperPlaceOrderResult.java create mode 100644 backend/src/main/java/com/goldenchart/entity/GcPaperCashLedger.java create mode 100644 backend/src/main/java/com/goldenchart/entity/GcPaperDailySnapshot.java create mode 100644 backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java create mode 100644 backend/src/main/java/com/goldenchart/entity/GcPaperResetLog.java create mode 100644 backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java create mode 100644 backend/src/main/java/com/goldenchart/repository/GcPaperCashLedgerRepository.java create mode 100644 backend/src/main/java/com/goldenchart/repository/GcPaperDailySnapshotRepository.java create mode 100644 backend/src/main/java/com/goldenchart/repository/GcPaperOrderRepository.java create mode 100644 backend/src/main/java/com/goldenchart/repository/GcPaperResetLogRepository.java create mode 100644 backend/src/main/java/com/goldenchart/repository/GcPaperSymbolAllocationRepository.java create mode 100644 backend/src/main/java/com/goldenchart/scheduler/PaperSnapshotScheduler.java create mode 100644 backend/src/main/java/com/goldenchart/service/PaperAllocationService.java create mode 100644 backend/src/main/java/com/goldenchart/service/PaperLedgerService.java create mode 100644 backend/src/main/java/com/goldenchart/service/PaperOrderMatcherService.java create mode 100644 backend/src/main/java/com/goldenchart/service/PaperSnapshotService.java create mode 100644 backend/src/main/resources/db/migration/V51__paper_investment_management.sql create mode 100644 backend/src/main/resources/db/migration/V52__paper_pending_orders.sql create mode 100644 backend/src/main/resources/db/migration/V53__settings_virtual_live_permissions.sql create mode 100644 backend/src/test/java/com/goldenchart/service/PaperAllocationServiceTest.java create mode 100644 docs/모의투자_투자금_관리_설계.md create mode 100644 frontend/src/components/ScreenRegionCaptureOverlay.tsx create mode 100644 frontend/src/components/paper/PaperAllocationTable.tsx create mode 100644 frontend/src/components/paper/PaperAssetTrendChart.tsx create mode 100644 frontend/src/components/paper/PaperInvestmentKpi.tsx create mode 100644 frontend/src/components/paper/PaperLedgerTab.tsx create mode 100644 frontend/src/components/paper/PaperPendingOrdersList.tsx create mode 100644 frontend/src/components/trade/TradeBuyOrderPanel.tsx create mode 100644 frontend/src/components/trade/TradeOrderbookPanelContent.tsx create mode 100644 frontend/src/components/trade/TradeRightPanelBody.tsx create mode 100644 frontend/src/components/trade/TradeRightPanelTabs.tsx create mode 100644 frontend/src/components/trade/TradeSellOrderPanel.tsx create mode 100644 frontend/src/components/trade/TradeSplitOrderPanel.tsx create mode 100644 frontend/src/components/trade/index.ts create mode 100644 frontend/src/styles/tradeRightPanel.css create mode 100644 frontend/src/utils/screenRegionCapture.ts diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index 8b81549..20e60b5 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -9,7 +9,8 @@ public final class MenuIds { public static final List ALL = List.of( "dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", - "settings_strategy", "settings_paper", "settings_trend-search", + "settings_strategy", "settings_paper", "settings_virtual", "settings_live", + "settings_trend-search", "settings_alert", "settings_network", "settings_admin" ); } diff --git a/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java b/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java index 960d183..7a253d3 100644 --- a/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java +++ b/backend/src/main/java/com/goldenchart/controller/PaperTradingController.java @@ -1,13 +1,14 @@ package com.goldenchart.controller; -import com.goldenchart.dto.PaperOrderRequest; -import com.goldenchart.dto.PaperSummaryDto; -import com.goldenchart.dto.PaperTradeDto; +import com.goldenchart.dto.*; import com.goldenchart.service.PaperTradingService; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -39,18 +40,81 @@ public class PaperTradingController { return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), markPrices)); } + @GetMapping("/allocations") + public ResponseEntity> allocations(@RequestHeader Map h) { + return ResponseEntity.ok(paperTradingService.listAllocations(userId(h), deviceId(h), null)); + } + + @PutMapping("/allocations/bulk") + public ResponseEntity> bulkAllocations( + @RequestHeader Map h, + @RequestBody PaperAllocationBulkRequest body) { + return ResponseEntity.ok(paperTradingService.bulkAllocations(userId(h), deviceId(h), body)); + } + + @PatchMapping("/allocations/{symbol}") + public ResponseEntity patchAllocation( + @RequestHeader Map h, + @PathVariable String symbol, + @RequestBody PaperAllocationPatchRequest body) { + return ResponseEntity.ok(paperTradingService.patchAllocation(userId(h), deviceId(h), symbol, body)); + } + @GetMapping("/trades") - public ResponseEntity> trades(@RequestHeader Map h) { - return ResponseEntity.ok(paperTradingService.listTrades(userId(h), deviceId(h))); + public ResponseEntity trades( + @RequestHeader Map h, + @RequestParam(required = false) String symbol, + @RequestParam(required = false) String side, + @RequestParam(required = false) String source, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(required = false) Integer size) { + if (size == null && symbol == null && side == null && source == null && from == null && to == null) { + return ResponseEntity.ok(paperTradingService.listTradesLegacy(userId(h), deviceId(h))); + } + int pageSize = size != null ? size : 50; + return ResponseEntity.ok(paperTradingService.listTrades( + userId(h), deviceId(h), symbol, side, source, from, to, page, pageSize)); + } + + @GetMapping("/ledger") + public ResponseEntity> ledger( + @RequestHeader Map h, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + return ResponseEntity.ok(paperTradingService.listLedger(userId(h), deviceId(h), from, to, page, size)); + } + + @GetMapping("/snapshots/daily") + public ResponseEntity> dailySnapshots( + @RequestHeader Map h, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return ResponseEntity.ok(paperTradingService.listDailySnapshots(userId(h), deviceId(h), from, to)); + } + + @GetMapping("/orders") + public ResponseEntity> pendingOrders(@RequestHeader Map h) { + return ResponseEntity.ok(paperTradingService.listPendingOrders(userId(h), deviceId(h))); } @PostMapping("/orders") - public ResponseEntity placeOrder( + public ResponseEntity placeOrder( @RequestHeader Map h, @RequestBody PaperOrderRequest body) { return ResponseEntity.ok(paperTradingService.placeOrder(userId(h), deviceId(h), body)); } + @PostMapping("/orders/{id}/cancel") + public ResponseEntity cancelOrder( + @RequestHeader Map h, + @PathVariable Long id) { + return ResponseEntity.ok(paperTradingService.cancelOrder(userId(h), deviceId(h), id)); + } + @PostMapping("/reset") public ResponseEntity reset(@RequestHeader Map h) { return ResponseEntity.ok(paperTradingService.resetAccount(userId(h), deviceId(h))); diff --git a/backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java b/backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java new file mode 100644 index 0000000..c4e5397 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperAllocationBulkRequest.java @@ -0,0 +1,22 @@ +package com.goldenchart.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class PaperAllocationBulkRequest { + private List items; + + @Data + public static class PaperAllocationItem { + private String symbol; + private String koreanName; + private Double maxInvestKrw; + private Double budgetPct; + private Long strategyId; + private Boolean isActive; + private Boolean pinned; + private Integer sortOrder; + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java b/backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java new file mode 100644 index 0000000..05b565f --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperAllocationDto.java @@ -0,0 +1,23 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PaperAllocationDto { + private Long id; + private String symbol; + private String koreanName; + private Double maxInvestKrw; + private Double budgetPct; + private Long strategyId; + private Boolean isActive; + private Boolean pinned; + private Integer sortOrder; + private Double investedKrw; + private Double availableBuyKrw; + private Double evalAmount; + private Double symbolReturnPct; + private Double weightPct; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java b/backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java new file mode 100644 index 0000000..2d1f36c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperAllocationPatchRequest.java @@ -0,0 +1,14 @@ +package com.goldenchart.dto; + +import lombok.Data; + +@Data +public class PaperAllocationPatchRequest { + private Double maxInvestKrw; + private Double budgetPct; + private Long strategyId; + private Boolean isActive; + private Boolean pinned; + private Integer sortOrder; + private String koreanName; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperDailySnapshotDto.java b/backend/src/main/java/com/goldenchart/dto/PaperDailySnapshotDto.java new file mode 100644 index 0000000..e5a8f70 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperDailySnapshotDto.java @@ -0,0 +1,16 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PaperDailySnapshotDto { + private String snapshotDate; + private Double cashBalance; + private Double stockEvalAmount; + private Double totalAsset; + private Double dailyPnl; + private Double dailyReturnPct; + private Double cumulativeReturnPct; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperLedgerEntryDto.java b/backend/src/main/java/com/goldenchart/dto/PaperLedgerEntryDto.java new file mode 100644 index 0000000..52e6605 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperLedgerEntryDto.java @@ -0,0 +1,17 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PaperLedgerEntryDto { + private Long id; + private String entryType; + private Double amount; + private Double cashBefore; + private Double cashAfter; + private Long refTradeId; + private String symbol; + private String createdAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperOrderDto.java b/backend/src/main/java/com/goldenchart/dto/PaperOrderDto.java new file mode 100644 index 0000000..8a8ddab --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperOrderDto.java @@ -0,0 +1,24 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PaperOrderDto { + private Long id; + private String symbol; + private String side; + private String orderType; + private Double limitPrice; + private Double quantity; + private Double filledQuantity; + private String status; + private Double reservedCash; + private Double reservedQty; + private String orderKind; + private String source; + private Long strategyId; + private String createdAt; + private String updatedAt; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperOrderRequest.java b/backend/src/main/java/com/goldenchart/dto/PaperOrderRequest.java index 8b91f1c..0d44626 100644 --- a/backend/src/main/java/com/goldenchart/dto/PaperOrderRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/PaperOrderRequest.java @@ -7,6 +7,8 @@ public class PaperOrderRequest { private String market; private String side; // BUY | SELL private String orderKind; // limit | market + /** MARKET | LIMIT — 미지정 시 orderKind 기준 (limit→LIMIT, market→MARKET) */ + private String orderType; private Double price; private Double quantity; /** 수량 미입력 시 매수 예산 비율 (가용현금 %) — 기본값: 설정의 paperAutoTradeBudgetPct */ diff --git a/backend/src/main/java/com/goldenchart/dto/PaperPageDto.java b/backend/src/main/java/com/goldenchart/dto/PaperPageDto.java new file mode 100644 index 0000000..db522f2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperPageDto.java @@ -0,0 +1,16 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class PaperPageDto { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperPlaceOrderResult.java b/backend/src/main/java/com/goldenchart/dto/PaperPlaceOrderResult.java new file mode 100644 index 0000000..3d5f08c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/PaperPlaceOrderResult.java @@ -0,0 +1,11 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PaperPlaceOrderResult { + private PaperTradeDto trade; + private PaperOrderDto order; +} diff --git a/backend/src/main/java/com/goldenchart/dto/PaperSummaryDto.java b/backend/src/main/java/com/goldenchart/dto/PaperSummaryDto.java index 59e9573..179ce42 100644 --- a/backend/src/main/java/com/goldenchart/dto/PaperSummaryDto.java +++ b/backend/src/main/java/com/goldenchart/dto/PaperSummaryDto.java @@ -21,5 +21,9 @@ public class PaperSummaryDto { private Double minOrderKrw; private Boolean autoTradeEnabled; private Double autoTradeBudgetPct; + private Double reservedCash; + private Double orderableCash; + private Double initialCapitalSnapshot; private List positions; + private List allocations; } diff --git a/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java b/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java index 00d395a..233ded1 100644 --- a/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java +++ b/backend/src/main/java/com/goldenchart/dto/PaperTradeDto.java @@ -18,5 +18,7 @@ public class PaperTradeDto { private Double feeAmount; private Double netAmount; private Double cashAfter; + private Double realizedPnlDelta; + private Double positionQtyAfter; private String createdAt; } diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperAccount.java b/backend/src/main/java/com/goldenchart/entity/GcPaperAccount.java index 1abd688..b70a737 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcPaperAccount.java +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperAccount.java @@ -29,6 +29,24 @@ public class GcPaperAccount { @Builder.Default private BigDecimal realizedPnl = BigDecimal.ZERO; + @Column(name = "reserved_cash", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal reservedCash = BigDecimal.ZERO; + + @Column(name = "initial_capital_snapshot", precision = 20, scale = 2) + private BigDecimal initialCapitalSnapshot; + + @Column(name = "reset_count", nullable = false) + @Builder.Default + private Integer resetCount = 0; + + @Column(name = "last_reset_at") + private LocalDateTime lastResetAt; + + @Column(name = "status", length = 20, nullable = false) + @Builder.Default + private String status = "ACTIVE"; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperCashLedger.java b/backend/src/main/java/com/goldenchart/entity/GcPaperCashLedger.java new file mode 100644 index 0000000..bcd4630 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperCashLedger.java @@ -0,0 +1,46 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_cash_ledger") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperCashLedger { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "entry_type", length = 30, nullable = false) + private String entryType; + + @Column(name = "amount", precision = 20, scale = 2, nullable = false) + private BigDecimal amount; + + @Column(name = "cash_before", precision = 20, scale = 2, nullable = false) + private BigDecimal cashBefore; + + @Column(name = "cash_after", precision = 20, scale = 2, nullable = false) + private BigDecimal cashAfter; + + @Column(name = "ref_trade_id") + private Long refTradeId; + + @Column(name = "symbol", length = 50) + private String symbol; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperDailySnapshot.java b/backend/src/main/java/com/goldenchart/entity/GcPaperDailySnapshot.java new file mode 100644 index 0000000..e01135a --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperDailySnapshot.java @@ -0,0 +1,55 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_daily_snapshot", + uniqueConstraints = @UniqueConstraint(name = "uk_paper_snapshot_account_date", + columnNames = {"account_id", "snapshot_date"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperDailySnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "snapshot_date", nullable = false) + private LocalDate snapshotDate; + + @Column(name = "cash_balance", precision = 20, scale = 2, nullable = false) + private BigDecimal cashBalance; + + @Column(name = "stock_eval_amount", precision = 20, scale = 2, nullable = false) + private BigDecimal stockEvalAmount; + + @Column(name = "total_asset", precision = 20, scale = 2, nullable = false) + private BigDecimal totalAsset; + + @Column(name = "daily_pnl", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal dailyPnl = BigDecimal.ZERO; + + @Column(name = "daily_return_pct", precision = 10, scale = 4, nullable = false) + @Builder.Default + private BigDecimal dailyReturnPct = BigDecimal.ZERO; + + @Column(name = "cumulative_return_pct", precision = 10, scale = 4, nullable = false) + @Builder.Default + private BigDecimal cumulativeReturnPct = BigDecimal.ZERO; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java b/backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java new file mode 100644 index 0000000..02a569b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperOrder.java @@ -0,0 +1,83 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_order") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "symbol", length = 50, nullable = false) + private String symbol; + + @Column(name = "side", length = 10, nullable = false) + private String side; + + @Column(name = "order_type", length = 20, nullable = false) + @Builder.Default + private String orderType = "LIMIT"; + + @Column(name = "limit_price", precision = 20, scale = 2) + private BigDecimal limitPrice; + + @Column(name = "quantity", precision = 30, scale = 12, nullable = false) + private BigDecimal quantity; + + @Column(name = "filled_quantity", precision = 30, scale = 12, nullable = false) + @Builder.Default + private BigDecimal filledQuantity = BigDecimal.ZERO; + + @Column(name = "status", length = 20, nullable = false) + @Builder.Default + private String status = "PENDING"; + + @Column(name = "reserved_cash", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal reservedCash = BigDecimal.ZERO; + + @Column(name = "reserved_qty", precision = 30, scale = 12, nullable = false) + @Builder.Default + private BigDecimal reservedQty = BigDecimal.ZERO; + + @Column(name = "order_kind", length = 20, nullable = false) + @Builder.Default + private String orderKind = "limit"; + + @Column(name = "source", length = 20, nullable = false) + @Builder.Default + private String source = "MANUAL"; + + @Column(name = "strategy_id") + private Long strategyId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "filled_at") + private LocalDateTime filledAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperResetLog.java b/backend/src/main/java/com/goldenchart/entity/GcPaperResetLog.java new file mode 100644 index 0000000..ef86e3c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperResetLog.java @@ -0,0 +1,40 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_reset_log") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperResetLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "reset_at", nullable = false) + private LocalDateTime resetAt; + + @Column(name = "initial_capital", precision = 20, scale = 2, nullable = false) + private BigDecimal initialCapital; + + @Column(name = "cash_before", precision = 20, scale = 2, nullable = false) + private BigDecimal cashBefore; + + @Column(name = "positions_json", columnDefinition = "JSON") + private String positionsJson; + + @Column(name = "reason", length = 200) + private String reason; + + @PrePersist + protected void onCreate() { + if (resetAt == null) resetAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java b/backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java new file mode 100644 index 0000000..e47cea7 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperSymbolAllocation.java @@ -0,0 +1,67 @@ +package com.goldenchart.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "gc_paper_symbol_allocation", + uniqueConstraints = @UniqueConstraint(name = "uk_paper_alloc_account_symbol", + columnNames = {"account_id", "symbol"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GcPaperSymbolAllocation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "symbol", length = 50, nullable = false) + private String symbol; + + @Column(name = "korean_name", length = 100) + private String koreanName; + + @Column(name = "max_invest_krw", precision = 20, scale = 2, nullable = false) + @Builder.Default + private BigDecimal maxInvestKrw = BigDecimal.ZERO; + + @Column(name = "budget_pct", precision = 8, scale = 4) + private BigDecimal budgetPct; + + @Column(name = "strategy_id") + private Long strategyId; + + @Column(name = "is_active", nullable = false) + @Builder.Default + private Boolean isActive = true; + + @Column(name = "pinned", nullable = false) + @Builder.Default + private Boolean pinned = false; + + @Column(name = "sort_order", nullable = false) + @Builder.Default + private Integer sortOrder = 0; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java b/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java index 666fb79..bf7e746 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java +++ b/backend/src/main/java/com/goldenchart/entity/GcPaperTrade.java @@ -54,6 +54,15 @@ public class GcPaperTrade { @Column(name = "cash_after", precision = 20, scale = 2, nullable = false) private BigDecimal cashAfter; + @Column(name = "realized_pnl_delta", precision = 20, scale = 2) + private BigDecimal realizedPnlDelta; + + @Column(name = "position_qty_after", precision = 30, scale = 12) + private BigDecimal positionQtyAfter; + + @Column(name = "note", length = 200) + private String note; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperCashLedgerRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperCashLedgerRepository.java new file mode 100644 index 0000000..9b261e1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperCashLedgerRepository.java @@ -0,0 +1,15 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperCashLedger; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; + +public interface GcPaperCashLedgerRepository extends JpaRepository { + Page findByAccountIdAndCreatedAtBetweenOrderByCreatedAtDesc( + Long accountId, LocalDateTime from, LocalDateTime to, Pageable pageable); + + Page findByAccountIdOrderByCreatedAtDesc(Long accountId, Pageable pageable); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperDailySnapshotRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperDailySnapshotRepository.java new file mode 100644 index 0000000..291d220 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperDailySnapshotRepository.java @@ -0,0 +1,15 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperDailySnapshot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface GcPaperDailySnapshotRepository extends JpaRepository { + List findByAccountIdAndSnapshotDateBetweenOrderBySnapshotDateAsc( + Long accountId, LocalDate from, LocalDate to); + + Optional findByAccountIdAndSnapshotDate(Long accountId, LocalDate date); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperOrderRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperOrderRepository.java new file mode 100644 index 0000000..43022fc --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperOrderRepository.java @@ -0,0 +1,12 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperOrder; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GcPaperOrderRepository extends JpaRepository { + List findByAccountIdAndStatusOrderByCreatedAtDesc(Long accountId, String status); + List findByStatusAndSymbol(String status, String symbol); + List findByStatus(String status); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperResetLogRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperResetLogRepository.java new file mode 100644 index 0000000..937c38d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperResetLogRepository.java @@ -0,0 +1,7 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperResetLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GcPaperResetLogRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperSymbolAllocationRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperSymbolAllocationRepository.java new file mode 100644 index 0000000..06be09d --- /dev/null +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperSymbolAllocationRepository.java @@ -0,0 +1,13 @@ +package com.goldenchart.repository; + +import com.goldenchart.entity.GcPaperSymbolAllocation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface GcPaperSymbolAllocationRepository extends JpaRepository { + List findByAccountIdOrderBySortOrderAscSymbolAsc(Long accountId); + Optional findByAccountIdAndSymbol(Long accountId, String symbol); + void deleteByAccountId(Long accountId); +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java b/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java index ebef318..c1d71d6 100644 --- a/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java +++ b/backend/src/main/java/com/goldenchart/repository/GcPaperTradeRepository.java @@ -1,10 +1,33 @@ package com.goldenchart.repository; import com.goldenchart.entity.GcPaperTrade; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; public interface GcPaperTradeRepository extends JpaRepository { List findTop100ByAccountIdOrderByCreatedAtDesc(Long accountId); + + @Query(""" + SELECT t FROM GcPaperTrade t WHERE t.accountId = :accountId + AND (:symbol IS NULL OR t.symbol = :symbol) + AND (:side IS NULL OR t.side = :side) + AND (:source IS NULL OR t.source = :source) + AND (:from IS NULL OR t.createdAt >= :from) + AND (:to IS NULL OR t.createdAt <= :to) + ORDER BY t.createdAt DESC + """) + Page findFiltered( + @Param("accountId") Long accountId, + @Param("symbol") String symbol, + @Param("side") String side, + @Param("source") String source, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + Pageable pageable); } diff --git a/backend/src/main/java/com/goldenchart/scheduler/PaperSnapshotScheduler.java b/backend/src/main/java/com/goldenchart/scheduler/PaperSnapshotScheduler.java new file mode 100644 index 0000000..853c725 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/scheduler/PaperSnapshotScheduler.java @@ -0,0 +1,22 @@ +package com.goldenchart.scheduler; + +import com.goldenchart.service.PaperSnapshotService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PaperSnapshotScheduler { + + private final PaperSnapshotService snapshotService; + + /** KST 00:05 — 일별 자산 스냅샷 */ + @Scheduled(cron = "0 5 0 * * *", zone = "Asia/Seoul") + public void captureDailySnapshots() { + log.info("[PaperSnapshotScheduler] EOD snapshot start"); + snapshotService.captureEodForAllAccounts(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/PaperAllocationService.java b/backend/src/main/java/com/goldenchart/service/PaperAllocationService.java new file mode 100644 index 0000000..63c2fb1 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PaperAllocationService.java @@ -0,0 +1,196 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.*; +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcPaperAccount; +import com.goldenchart.entity.GcPaperPosition; +import com.goldenchart.entity.GcPaperSymbolAllocation; +import com.goldenchart.repository.GcPaperPositionRepository; +import com.goldenchart.repository.GcPaperSymbolAllocationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class PaperAllocationService { + + private static final RoundingMode RM = RoundingMode.HALF_UP; + + private final GcPaperSymbolAllocationRepository allocRepo; + private final GcPaperPositionRepository positionRepo; + + @Transactional(readOnly = true) + public List listWithMetrics(Long accountId, Map markPrices, + double totalAsset) { + List rows = allocRepo.findByAccountIdOrderBySortOrderAscSymbolAsc(accountId); + List out = new ArrayList<>(); + for (GcPaperSymbolAllocation a : rows) { + out.add(toDto(a, accountId, markPrices, totalAsset)); + } + return out; + } + + public List bulkUpsert(Long accountId, PaperAllocationBulkRequest req, + BigDecimal defaultMaxKrw) { + if (req == null || req.getItems() == null) return listWithMetrics(accountId, null, 0); + int order = 0; + for (PaperAllocationBulkRequest.PaperAllocationItem item : req.getItems()) { + if (item.getSymbol() == null || item.getSymbol().isBlank()) continue; + GcPaperSymbolAllocation row = allocRepo.findByAccountIdAndSymbol(accountId, item.getSymbol()) + .orElse(GcPaperSymbolAllocation.builder() + .accountId(accountId) + .symbol(item.getSymbol()) + .build()); + if (item.getKoreanName() != null) row.setKoreanName(item.getKoreanName()); + if (item.getMaxInvestKrw() != null) { + row.setMaxInvestKrw(BigDecimal.valueOf(item.getMaxInvestKrw()).setScale(2, RM)); + } else if (row.getMaxInvestKrw() == null || row.getMaxInvestKrw().signum() == 0) { + row.setMaxInvestKrw(defaultMaxKrw); + } + if (item.getBudgetPct() != null) row.setBudgetPct(BigDecimal.valueOf(item.getBudgetPct())); + if (item.getStrategyId() != null) row.setStrategyId(item.getStrategyId()); + if (item.getIsActive() != null) row.setIsActive(item.getIsActive()); + if (item.getPinned() != null) row.setPinned(item.getPinned()); + row.setSortOrder(item.getSortOrder() != null ? item.getSortOrder() : order++); + allocRepo.save(row); + } + return listWithMetrics(accountId, null, 0); + } + + public PaperAllocationDto patch(Long accountId, String symbol, PaperAllocationPatchRequest req) { + GcPaperSymbolAllocation row = allocRepo.findByAccountIdAndSymbol(accountId, symbol) + .orElseThrow(() -> new IllegalArgumentException("종목 한도 설정이 없습니다: " + symbol)); + if (req.getMaxInvestKrw() != null) { + row.setMaxInvestKrw(BigDecimal.valueOf(req.getMaxInvestKrw()).setScale(2, RM)); + } + if (req.getBudgetPct() != null) row.setBudgetPct(BigDecimal.valueOf(req.getBudgetPct())); + if (req.getStrategyId() != null) row.setStrategyId(req.getStrategyId()); + if (req.getIsActive() != null) row.setIsActive(req.getIsActive()); + if (req.getPinned() != null) row.setPinned(req.getPinned()); + if (req.getSortOrder() != null) row.setSortOrder(req.getSortOrder()); + if (req.getKoreanName() != null) row.setKoreanName(req.getKoreanName()); + allocRepo.save(row); + return toDto(row, accountId, null, 0); + } + + public GcPaperSymbolAllocation getOrDefault(Long accountId, String symbol, BigDecimal defaultMax) { + return allocRepo.findByAccountIdAndSymbol(accountId, symbol) + .orElseGet(() -> { + GcPaperSymbolAllocation a = GcPaperSymbolAllocation.builder() + .accountId(accountId) + .symbol(symbol) + .maxInvestKrw(defaultMax) + .isActive(true) + .build(); + return allocRepo.save(a); + }); + } + + /** + * 매수 가능 금액 검증. autoTrade=true 시 전역 auto budget % 적용. + */ + public void validateBuy(GcPaperAccount account, GcAppSettings app, String symbol, + BigDecimal needKrw, boolean autoTrade) { + double reserved = account.getReservedCash() != null + ? account.getReservedCash().doubleValue() : 0; + double orderable = account.getCashBalance().doubleValue() - reserved; + if (autoTrade) { + double pct = app.getPaperAutoTradeBudgetPct() != null + ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95; + orderable = orderable * pct / 100.0; + } + if (needKrw.doubleValue() > orderable + 0.01) { + throw new IllegalStateException("주문가능 현금이 부족합니다."); + } + + BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null + ? account.getInitialCapitalSnapshot() + : (app.getPaperInitialCapital() != null + ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000)); + GcPaperSymbolAllocation alloc = getOrDefault(account.getId(), symbol, defaultMax); + if (!Boolean.TRUE.equals(alloc.getIsActive())) { + throw new IllegalStateException("해당 종목은 신규 매수가 비활성화되어 있습니다."); + } + double invested = investedKrw(account.getId(), symbol, null); + double max = alloc.getMaxInvestKrw().doubleValue(); + double available = Math.max(0, max - invested); + if (needKrw.doubleValue() > available + 0.01) { + throw new IllegalStateException("종목 투자 한도를 초과합니다."); + } + } + + public double investedKrw(Long accountId, String symbol, Map markPrices) { + Optional pos = positionRepo.findByAccountIdAndSymbol(accountId, symbol); + if (pos.isEmpty() || pos.get().getQuantity().doubleValue() <= 0) return 0; + GcPaperPosition p = pos.get(); + double qty = p.getQuantity().doubleValue(); + Double mark = markPrices != null ? markPrices.get(symbol) : null; + if (mark != null && mark > 0) return mark * qty; + return p.getAvgPrice().doubleValue() * qty; + } + + public void deleteAllForAccount(Long accountId) { + allocRepo.deleteByAccountId(accountId); + } + + public void seedFromPositions(Long accountId, BigDecimal defaultMax) { + List positions = positionRepo.findByAccountIdOrderBySymbolAsc(accountId); + int sortOrder = 0; + for (GcPaperPosition p : positions) { + if (p.getQuantity().doubleValue() <= 0) continue; + final int order = sortOrder++; + allocRepo.findByAccountIdAndSymbol(accountId, p.getSymbol()).orElseGet(() -> + allocRepo.save(GcPaperSymbolAllocation.builder() + .accountId(accountId) + .symbol(p.getSymbol()) + .koreanName(p.getKoreanName()) + .maxInvestKrw(defaultMax) + .sortOrder(order) + .build())); + } + } + + private PaperAllocationDto toDto(GcPaperSymbolAllocation a, Long accountId, + Map markPrices, double totalAsset) { + double invested = investedKrw(accountId, a.getSymbol(), markPrices); + double max = a.getMaxInvestKrw().doubleValue(); + double available = Math.max(0, max - invested); + double eval = invested; + double cost = 0; + Optional pos = positionRepo.findByAccountIdAndSymbol(accountId, a.getSymbol()); + if (pos.isPresent() && pos.get().getQuantity().doubleValue() > 0) { + double qty = pos.get().getQuantity().doubleValue(); + double avg = pos.get().getAvgPrice().doubleValue(); + cost = avg * qty; + Double mark = markPrices != null ? markPrices.get(a.getSymbol()) : null; + eval = mark != null ? mark * qty : cost; + } + double retPct = cost > 0 ? (eval - cost) / cost * 100 : 0; + double weight = totalAsset > 0 ? eval / totalAsset * 100 : 0; + return PaperAllocationDto.builder() + .id(a.getId()) + .symbol(a.getSymbol()) + .koreanName(a.getKoreanName()) + .maxInvestKrw(max) + .budgetPct(a.getBudgetPct() != null ? a.getBudgetPct().doubleValue() : null) + .strategyId(a.getStrategyId()) + .isActive(a.getIsActive()) + .pinned(a.getPinned()) + .sortOrder(a.getSortOrder()) + .investedKrw(invested) + .availableBuyKrw(available) + .evalAmount(eval) + .symbolReturnPct(retPct) + .weightPct(weight) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/PaperLedgerService.java b/backend/src/main/java/com/goldenchart/service/PaperLedgerService.java new file mode 100644 index 0000000..6e0e2ad --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PaperLedgerService.java @@ -0,0 +1,108 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.PaperLedgerEntryDto; +import com.goldenchart.dto.PaperPageDto; +import com.goldenchart.entity.GcPaperAccount; +import com.goldenchart.entity.GcPaperCashLedger; +import com.goldenchart.entity.GcPaperTrade; +import com.goldenchart.repository.GcPaperCashLedgerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +public class PaperLedgerService { + + public static final String INITIAL = "INITIAL"; + public static final String BUY_SETTLE = "BUY_SETTLE"; + public static final String SELL_SETTLE = "SELL_SETTLE"; + public static final String RESET = "RESET"; + + private final GcPaperCashLedgerRepository ledgerRepo; + + public void recordInitial(GcPaperAccount account, BigDecimal initialCash) { + ledgerRepo.save(GcPaperCashLedger.builder() + .accountId(account.getId()) + .entryType(INITIAL) + .amount(initialCash) + .cashBefore(BigDecimal.ZERO) + .cashAfter(initialCash) + .build()); + } + + public void recordBuySettle(GcPaperAccount account, GcPaperTrade trade, BigDecimal totalCost) { + BigDecimal before = account.getCashBalance().add(totalCost); + ledgerRepo.save(GcPaperCashLedger.builder() + .accountId(account.getId()) + .entryType(BUY_SETTLE) + .amount(totalCost.negate()) + .cashBefore(before) + .cashAfter(account.getCashBalance()) + .refTradeId(trade.getId()) + .symbol(trade.getSymbol()) + .build()); + } + + public void recordSellSettle(GcPaperAccount account, GcPaperTrade trade, BigDecimal netProceeds) { + BigDecimal before = account.getCashBalance().subtract(netProceeds); + ledgerRepo.save(GcPaperCashLedger.builder() + .accountId(account.getId()) + .entryType(SELL_SETTLE) + .amount(netProceeds) + .cashBefore(before) + .cashAfter(account.getCashBalance()) + .refTradeId(trade.getId()) + .symbol(trade.getSymbol()) + .build()); + } + + public void recordReset(GcPaperAccount account, BigDecimal cashBefore, BigDecimal newCash) { + ledgerRepo.save(GcPaperCashLedger.builder() + .accountId(account.getId()) + .entryType(RESET) + .amount(newCash.subtract(cashBefore)) + .cashBefore(cashBefore) + .cashAfter(newCash) + .build()); + } + + @Transactional(readOnly = true) + public PaperPageDto list(Long accountId, LocalDateTime from, LocalDateTime to, + int page, int size) { + PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size))); + Page result = (from != null || to != null) + ? ledgerRepo.findByAccountIdAndCreatedAtBetweenOrderByCreatedAtDesc( + accountId, + from != null ? from : LocalDateTime.of(2000, 1, 1, 0, 0), + to != null ? to : LocalDateTime.now().plusYears(10), + pr) + : ledgerRepo.findByAccountIdOrderByCreatedAtDesc(accountId, pr); + return PaperPageDto.builder() + .content(result.getContent().stream().map(this::toDto).toList()) + .page(result.getNumber()) + .size(result.getSize()) + .totalElements(result.getTotalElements()) + .totalPages(result.getTotalPages()) + .build(); + } + + private PaperLedgerEntryDto toDto(GcPaperCashLedger e) { + return PaperLedgerEntryDto.builder() + .id(e.getId()) + .entryType(e.getEntryType()) + .amount(e.getAmount().doubleValue()) + .cashBefore(e.getCashBefore().doubleValue()) + .cashAfter(e.getCashAfter().doubleValue()) + .refTradeId(e.getRefTradeId()) + .symbol(e.getSymbol()) + .createdAt(e.getCreatedAt() != null ? e.getCreatedAt().toString() : null) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/PaperOrderMatcherService.java b/backend/src/main/java/com/goldenchart/service/PaperOrderMatcherService.java new file mode 100644 index 0000000..d8bf28b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PaperOrderMatcherService.java @@ -0,0 +1,53 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcPaperOrder; +import com.goldenchart.repository.GcPaperOrderRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +@Slf4j +public class PaperOrderMatcherService { + + private final GcPaperOrderRepository orderRepo; + private final PaperTradingService paperTradingService; + + public PaperOrderMatcherService(GcPaperOrderRepository orderRepo, + @Lazy PaperTradingService paperTradingService) { + this.orderRepo = orderRepo; + this.paperTradingService = paperTradingService; + } + + public void onPriceTick(String symbol, double price) { + if (symbol == null || price <= 0) return; + List pending = orderRepo.findByStatusAndSymbol("PENDING", symbol); + for (GcPaperOrder o : pending) { + try { + paperTradingService.tryFillPendingOrder(o.getId(), price); + } catch (Exception e) { + log.debug("[PaperOrderMatcher] fill skip order={}: {}", o.getId(), e.getMessage()); + } + } + } + + @Scheduled(fixedDelay = 3000) + public void pollPendingOrders() { + Set symbols = new HashSet<>(); + for (GcPaperOrder o : orderRepo.findByStatus("PENDING")) { + symbols.add(o.getSymbol()); + } + for (String symbol : symbols) { + try { + double price = paperTradingService.resolveMarkPrice(symbol); + if (price > 0) onPriceTick(symbol, price); + } catch (Exception ignored) { + } + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/PaperSnapshotService.java b/backend/src/main/java/com/goldenchart/service/PaperSnapshotService.java new file mode 100644 index 0000000..f9f2ba0 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PaperSnapshotService.java @@ -0,0 +1,120 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.PaperDailySnapshotDto; +import com.goldenchart.entity.GcPaperAccount; +import com.goldenchart.entity.GcPaperDailySnapshot; +import com.goldenchart.entity.GcPaperPosition; +import com.goldenchart.repository.GcPaperAccountRepository; +import com.goldenchart.repository.GcPaperDailySnapshotRepository; +import com.goldenchart.repository.GcPaperPositionRepository; +import com.goldenchart.storage.Ta4jStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class PaperSnapshotService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final GcPaperDailySnapshotRepository snapshotRepo; + private final GcPaperAccountRepository accountRepo; + private final GcPaperPositionRepository positionRepo; + private final Ta4jStorage ta4jStorage; + + @Transactional(readOnly = true) + public List list(Long accountId, LocalDate from, LocalDate to) { + LocalDate f = from != null ? from : LocalDate.now(KST).minusDays(90); + LocalDate t = to != null ? to : LocalDate.now(KST); + return snapshotRepo.findByAccountIdAndSnapshotDateBetweenOrderBySnapshotDateAsc(accountId, f, t) + .stream().map(this::toDto).toList(); + } + + public void captureEodForAllAccounts() { + LocalDate today = LocalDate.now(KST); + for (GcPaperAccount account : accountRepo.findAll()) { + try { + captureEod(account, today); + } catch (Exception e) { + log.warn("[PaperSnapshot] account {} failed: {}", account.getId(), e.getMessage()); + } + } + } + + public void captureEod(GcPaperAccount account, LocalDate date) { + List positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()); + Map marks = new HashMap<>(); + double stockEval = 0; + for (GcPaperPosition p : positions) { + double qty = p.getQuantity().doubleValue(); + if (qty <= 0) continue; + double mark = resolveMark(p.getSymbol(), p.getAvgPrice().doubleValue()); + marks.put(p.getSymbol(), mark); + stockEval += mark * qty; + } + double cash = account.getCashBalance().doubleValue(); + double total = cash + stockEval; + double initial = account.getInitialCapitalSnapshot() != null + ? account.getInitialCapitalSnapshot().doubleValue() : cash; + double cumRet = initial > 0 ? (total - initial) / initial * 100 : 0; + + GcPaperDailySnapshot prev = snapshotRepo + .findByAccountIdAndSnapshotDate(account.getId(), date.minusDays(1)) + .orElse(null); + double prevTotal = prev != null ? prev.getTotalAsset().doubleValue() : initial; + double dailyPnl = total - prevTotal; + double dailyRet = prevTotal > 0 ? dailyPnl / prevTotal * 100 : 0; + + GcPaperDailySnapshot snap = snapshotRepo.findByAccountIdAndSnapshotDate(account.getId(), date) + .orElse(GcPaperDailySnapshot.builder() + .accountId(account.getId()) + .snapshotDate(date) + .build()); + snap.setCashBalance(BigDecimal.valueOf(cash).setScale(2, RoundingMode.HALF_UP)); + snap.setStockEvalAmount(BigDecimal.valueOf(stockEval).setScale(2, RoundingMode.HALF_UP)); + snap.setTotalAsset(BigDecimal.valueOf(total).setScale(2, RoundingMode.HALF_UP)); + snap.setDailyPnl(BigDecimal.valueOf(dailyPnl).setScale(2, RoundingMode.HALF_UP)); + snap.setDailyReturnPct(BigDecimal.valueOf(dailyRet).setScale(4, RoundingMode.HALF_UP)); + snap.setCumulativeReturnPct(BigDecimal.valueOf(cumRet).setScale(4, RoundingMode.HALF_UP)); + snapshotRepo.save(snap); + } + + private double resolveMark(String symbol, double fallback) { + try { + if (!ta4jStorage.exists(symbol, "1m")) return fallback; + BarSeries series = ta4jStorage.getOrCreate(symbol, "1m"); + if (!series.isEmpty()) { + Bar last = series.getLastBar(); + return last.getClosePrice().doubleValue(); + } + } catch (Exception ignored) { + } + return fallback; + } + + private PaperDailySnapshotDto toDto(GcPaperDailySnapshot s) { + return PaperDailySnapshotDto.builder() + .snapshotDate(s.getSnapshotDate().toString()) + .cashBalance(s.getCashBalance().doubleValue()) + .stockEvalAmount(s.getStockEvalAmount().doubleValue()) + .totalAsset(s.getTotalAsset().doubleValue()) + .dailyPnl(s.getDailyPnl().doubleValue()) + .dailyReturnPct(s.getDailyReturnPct().doubleValue()) + .cumulativeReturnPct(s.getCumulativeReturnPct().doubleValue()) + .build(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java index 9ed667c..3d2f0eb 100644 --- a/backend/src/main/java/com/goldenchart/service/PaperTradingService.java +++ b/backend/src/main/java/com/goldenchart/service/PaperTradingService.java @@ -1,46 +1,56 @@ package com.goldenchart.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.dto.*; -import com.goldenchart.entity.GcAppSettings; -import com.goldenchart.entity.GcPaperAccount; -import com.goldenchart.entity.GcPaperPosition; -import com.goldenchart.entity.GcPaperTrade; -import com.goldenchart.repository.GcLiveStrategySettingsRepository; -import com.goldenchart.repository.GcPaperAccountRepository; -import com.goldenchart.repository.GcPaperPositionRepository; -import com.goldenchart.repository.GcPaperTradeRepository; +import com.goldenchart.entity.*; +import com.goldenchart.repository.*; +import com.goldenchart.storage.Ta4jStorage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; -/** - * 모의투자(페이퍼 트레이딩) 체결·잔고·포지션 관리. - */ @Service @RequiredArgsConstructor @Slf4j @Transactional public class PaperTradingService { - private static final int SCALE_QTY = 12; - private static final int SCALE_KRW = 2; - private static final RoundingMode RM = RoundingMode.HALF_UP; + private static final int SCALE_QTY = 12; + private static final int SCALE_KRW = 2; + private static final RoundingMode RM = RoundingMode.HALF_UP; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private final GcPaperAccountRepository accountRepo; - private final GcPaperPositionRepository positionRepo; - private final GcPaperTradeRepository tradeRepo; - private final AppSettingsService appSettingsService; + private final GcPaperAccountRepository accountRepo; + private final GcPaperPositionRepository positionRepo; + private final GcPaperTradeRepository tradeRepo; + private final GcPaperOrderRepository orderRepo; + private final GcPaperResetLogRepository resetLogRepo; + private final AppSettingsService appSettingsService; private final GcLiveStrategySettingsRepository liveSettingsRepo; + private final PaperAllocationService allocationService; + private final PaperLedgerService ledgerService; + private final PaperSnapshotService snapshotService; + private final Ta4jStorage ta4jStorage; + private final ObjectMapper objectMapper; // ── 조회 ───────────────────────────────────────────────────────────────── + @Transactional(readOnly = true) public PaperSummaryDto getSummary(Long userId, String deviceId, Map markPrices) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); @@ -74,15 +84,26 @@ public class PaperTradingService { } double cash = account.getCashBalance().doubleValue(); - double initial = app.getPaperInitialCapital() != null - ? app.getPaperInitialCapital().doubleValue() : 10_000_000; + double reserved = account.getReservedCash() != null + ? account.getReservedCash().doubleValue() : 0; + double orderable = cash - reserved; + double initialSnap = account.getInitialCapitalSnapshot() != null + ? account.getInitialCapitalSnapshot().doubleValue() + : (app.getPaperInitialCapital() != null + ? app.getPaperInitialCapital().doubleValue() : 10_000_000); double total = cash + stockEval; - double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0; + double returnPct = initialSnap > 0 ? (total - initialSnap) / initialSnap * 100 : 0; + + List allocations = allocationService.listWithMetrics( + account.getId(), markPrices, total); return PaperSummaryDto.builder() .enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled())) - .initialCapital(initial) + .initialCapital(initialSnap) .cashBalance(cash) + .reservedCash(reserved) + .orderableCash(orderable) + .initialCapitalSnapshot(initialSnap) .stockEvalAmount(stockEval) .totalAsset(total) .unrealizedPnl(unrealized) @@ -95,22 +116,90 @@ public class PaperTradingService { .autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95) .positions(posDtos) + .allocations(allocations) .build(); } - public List listTrades(Long userId, String deviceId) { + @Transactional(readOnly = true) + public List listAllocations(Long userId, String deviceId, + Map markPrices) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); - return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId()) - .stream().map(this::toTradeDto).toList(); + PaperSummaryDto s = getSummary(userId, deviceId, markPrices); + return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset()); + } + + public List bulkAllocations(Long userId, String deviceId, + PaperAllocationBulkRequest req) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null + ? account.getInitialCapitalSnapshot() + : app.getPaperInitialCapital(); + return allocationService.bulkUpsert(account.getId(), req, defaultMax); + } + + public PaperAllocationDto patchAllocation(Long userId, String deviceId, String symbol, + PaperAllocationPatchRequest req) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + return allocationService.patch(account.getId(), symbol, req); + } + + @Transactional(readOnly = true) + public PaperPageDto listTrades(Long userId, String deviceId, + String symbol, String side, String source, + LocalDateTime from, LocalDateTime to, + int page, int size) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size))); + Page result = tradeRepo.findFiltered( + account.getId(), symbol, side, source, from, to, pr); + return PaperPageDto.builder() + .content(result.getContent().stream().map(this::toTradeDto).toList()) + .page(result.getNumber()) + .size(result.getSize()) + .totalElements(result.getTotalElements()) + .totalPages(result.getTotalPages()) + .build(); + } + + public List listTradesLegacy(Long userId, String deviceId) { + return listTrades(userId, deviceId, null, null, null, null, null, 0, 100).getContent(); + } + + @Transactional(readOnly = true) + public PaperPageDto listLedger(Long userId, String deviceId, + LocalDateTime from, LocalDateTime to, + int page, int size) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + return ledgerService.list(account.getId(), from, to, page, size); + } + + @Transactional(readOnly = true) + public List listDailySnapshots(Long userId, String deviceId, + LocalDate from, LocalDate to) { + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); + return snapshotService.list(account.getId(), from, to); + } + + @Transactional(readOnly = true) + public List listPendingOrders(Long userId, String deviceId) { + GcPaperAccount account = getOrCreateAccount(userId, deviceId, + appSettingsService.getEntity(userId, deviceId)); + return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING") + .stream().map(this::toOrderDto).toList(); } // ── 주문 ───────────────────────────────────────────────────────────────── - public PaperTradeDto placeOrder(Long userId, String deviceId, PaperOrderRequest req) { + public PaperPlaceOrderResult placeOrder(Long userId, String deviceId, PaperOrderRequest req) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) { - throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요."); + throw new IllegalStateException("모의투자가 비활성화되어 있습니다."); } if (req.getMarket() == null || req.getSide() == null) { throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다."); @@ -124,11 +213,13 @@ public class PaperTradingService { throw new IllegalArgumentException("가격은 0보다 커야 합니다."); } + String orderType = resolveOrderType(req); String source = req.getSource() != null ? req.getSource() : "MANUAL"; String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit"; + + GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); double qty = req.getQuantity() != null ? req.getQuantity() : 0; if (qty <= 0) { - GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); qty = resolveAutoQuantity(account, app, req.getMarket(), side, price, req.getBudgetPct(), req.getKrwAmount()); } @@ -136,14 +227,34 @@ public class PaperTradingService { throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다."); } - return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source, - req.getStrategyId(), price, qty, null); + if ("LIMIT".equals(orderType)) { + GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind, + source, req.getStrategyId(), price, qty); + return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build(); + } + + PaperTradeDto trade = executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source, + req.getStrategyId(), price, qty, null, false); + return PaperPlaceOrderResult.builder().trade(trade).build(); + } + + public PaperOrderDto cancelOrder(Long userId, String deviceId, Long orderId) { + GcPaperAccount account = getOrCreateAccount(userId, deviceId, + appSettingsService.getEntity(userId, deviceId)); + GcPaperOrder order = orderRepo.findById(orderId) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + if (!order.getAccountId().equals(account.getId())) { + throw new IllegalArgumentException("권한이 없습니다."); + } + if (!"PENDING".equals(order.getStatus())) { + throw new IllegalStateException("취소할 수 없는 주문 상태입니다."); + } + releaseReservation(account, order); + order.setStatus("CANCELLED"); + orderRepo.save(order); + return toOrderDto(order); } - /** - * 실시간 전략 시그널 발생 시 자동 모의매매. - * liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행. - */ public void tryExecuteOnSignal(String deviceId, Long userId, String market, Long strategyId, String signalType, double price) { if (deviceId == null || deviceId.isBlank()) return; @@ -166,91 +277,200 @@ public class PaperTradingService { if ("BUY".equals(signalType)) { double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); double cash = account.getCashBalance().doubleValue(); - double budget = cash * budgetPct / 100.0; + 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) { - log.debug("[Paper] auto BUY skip: budget={} min={}", budget, minOrder); - return; - } + if (denom <= 0 || budget < minOrder) return; double qty = budget / denom; if (qty * execPrice < minOrder) return; executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY", - strategyId, price, qty, null); + 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) { - log.debug("[Paper] auto SELL skip: no position {}", market); - return; - } - double qty = pos.getQuantity().doubleValue(); + if (pos == null || pos.getQuantity().doubleValue() <= 0) return; + double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); + if (avail <= 0) return; executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY", - strategyId, price, qty, null); - log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price); + 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()); } } - /** 계좌 초기화 — 설정의 초기 자본으로 리셋 */ + public void tryFillPendingOrder(Long orderId, double markPrice) { + GcPaperOrder order = orderRepo.findById(orderId).orElse(null); + if (order == null || !"PENDING".equals(order.getStatus())) return; + + boolean fill = false; + double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0; + if ("BUY".equals(order.getSide())) { + fill = markPrice <= limit; + } else if ("SELL".equals(order.getSide())) { + fill = markPrice >= limit; + } + if (!fill) return; + + GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null); + if (account == null) return; + GcAppSettings app = appSettingsService.getEntity(account.getUserId(), account.getDeviceId()); + + releaseReservation(account, order); + double qty = order.getQuantity().doubleValue(); + executeTrade(account.getUserId(), account.getDeviceId(), app, order.getSymbol(), + order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(), + limit, qty, null, "STRATEGY".equals(order.getSource())); + + order.setStatus("FILLED"); + order.setFilledQuantity(order.getQuantity()); + order.setFilledAt(LocalDateTime.now()); + order.setReservedCash(BigDecimal.ZERO); + order.setReservedQty(BigDecimal.ZERO); + orderRepo.save(order); + } + + public double resolveMarkPrice(String symbol) { + try { + if (!ta4jStorage.exists(symbol, "1m")) return 0; + BarSeries series = ta4jStorage.getOrCreate(symbol, "1m"); + if (!series.isEmpty()) { + Bar last = series.getLastBar(); + return last.getClosePrice().doubleValue(); + } + } catch (Exception ignored) { + } + return 0; + } + public PaperSummaryDto resetAccount(Long userId, String deviceId) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); BigDecimal initial = app.getPaperInitialCapital() != null ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); + BigDecimal cashBefore = account.getCashBalance(); + + String positionsJson = null; + try { + List positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()); + positionsJson = objectMapper.writeValueAsString(positions); + } catch (Exception e) { + log.debug("[Paper] reset positions json skip: {}", e.getMessage()); + } + + resetLogRepo.save(GcPaperResetLog.builder() + .accountId(account.getId()) + .initialCapital(initial) + .cashBefore(cashBefore) + .positionsJson(positionsJson) + .reason("USER_RESET") + .build()); + + ledgerService.recordReset(account, cashBefore, initial); + + orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING") + .forEach(o -> { + releaseReservation(account, o); + o.setStatus("CANCELLED"); + orderRepo.save(o); + }); + + positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()).forEach(positionRepo::delete); + allocationService.deleteAllForAccount(account.getId()); - positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()) - .forEach(positionRepo::delete); account.setCashBalance(initial); account.setRealizedPnl(BigDecimal.ZERO); + account.setReservedCash(BigDecimal.ZERO); + account.setInitialCapitalSnapshot(initial); + account.setResetCount((account.getResetCount() != null ? account.getResetCount() : 0) + 1); + account.setLastResetAt(LocalDateTime.now()); accountRepo.save(account); + + allocationService.seedFromPositions(account.getId(), initial); log.info("[Paper] account reset device={} capital={}", deviceId, initial); return getSummary(userId, deviceId, null); } - /** - * 수량 미입력(0) 시 자동매매와 동일한 방식으로 수량 산출. - * 매수: 가용현금 × budgetPct(또는 krwAmount) / (체결가 × (1+수수료)) - * 매도: 보유수량 × budgetPct - */ - private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app, - String market, String side, - double price, Double budgetPct, Double krwAmount) { + // ── 내부 ───────────────────────────────────────────────────────────────── + + private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app, + String market, String side, String orderKind, + String source, Long strategyId, + double limitPrice, double qty) { double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); - double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95); + double execPrice = "BUY".equals(side) + ? limitPrice * (1 + slip / 100.0) : limitPrice * (1 - slip / 100.0); + BigDecimal qtyBd = BigDecimal.valueOf(qty).setScale(SCALE_QTY, RM); + BigDecimal priceBd = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM); + BigDecimal gross = priceBd.multiply(qtyBd).setScale(SCALE_KRW, RM); + BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM); if ("BUY".equals(side)) { - double cash = account.getCashBalance().doubleValue(); - double budget = krwAmount != null && krwAmount > 0 - ? krwAmount - : cash * pctVal / 100.0; - double execPrice = price * (1 + slip / 100.0); - double denom = execPrice * (1 + feeRate / 100.0); - if (denom <= 0 || budget <= 0) { - throw new IllegalStateException("주문 가능 금액이 부족합니다."); - } - return budget / denom; + BigDecimal need = gross.add(fee); + allocationService.validateBuy(account, app, market, need, false); + account.setReservedCash(account.getReservedCash().add(need)); + accountRepo.save(account); + return orderRepo.save(GcPaperOrder.builder() + .accountId(account.getId()) + .symbol(market) + .side("BUY") + .orderType("LIMIT") + .limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM)) + .quantity(qtyBd) + .status("PENDING") + .reservedCash(need) + .orderKind(orderKind) + .source(source) + .strategyId(strategyId) + .build()); } GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); - double held = pos.getQuantity().doubleValue(); - if (held <= 0) { - throw new IllegalStateException("보유 수량이 없습니다."); + double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); + if (qty > avail + 1e-12) { + throw new IllegalStateException("매도 가능 수량이 부족합니다."); } - return held * pctVal / 100.0; + return orderRepo.save(GcPaperOrder.builder() + .accountId(account.getId()) + .symbol(market) + .side("SELL") + .orderType("LIMIT") + .limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM)) + .quantity(qtyBd) + .status("PENDING") + .reservedQty(qtyBd) + .orderKind(orderKind) + .source(source) + .strategyId(strategyId) + .build()); } - // ── 내부 체결 ─────────────────────────────────────────────────────────── + private void releaseReservation(GcPaperAccount account, GcPaperOrder order) { + if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) { + account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO)); + accountRepo.save(account); + } + } + + private double availableSellQty(Long accountId, String symbol, double held) { + double reserved = orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(accountId, "PENDING") + .stream() + .filter(o -> symbol.equals(o.getSymbol()) && "SELL".equals(o.getSide())) + .mapToDouble(o -> o.getReservedQty().doubleValue()) + .sum(); + return Math.max(0, held - reserved); + } private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app, String market, String side, String orderKind, String source, Long strategyId, double inputPrice, double inputQty, - String koreanName) { + String koreanName, boolean autoTrade) { GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); @@ -271,13 +491,14 @@ public class PaperTradingService { if ("BUY".equals(side)) { BigDecimal totalCost = gross.add(fee); - if (account.getCashBalance().compareTo(totalCost) < 0) { - throw new IllegalStateException("주문가능 현금이 부족합니다."); - } + allocationService.validateBuy(account, app, market, totalCost, autoTrade); account.setCashBalance(account.getCashBalance().subtract(totalCost)); upsertPositionBuy(account, market, koreanName, qty, price); accountRepo.save(account); + GcPaperPosition after = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null); + BigDecimal qtyAfter = after != null ? after.getQuantity() : qty; + GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder() .accountId(account.getId()) .symbol(market) @@ -291,15 +512,17 @@ public class PaperTradingService { .feeAmount(fee) .netAmount(totalCost.negate()) .cashAfter(account.getCashBalance()) + .positionQtyAfter(qtyAfter) .build()); + ledgerService.recordBuySettle(account, trade, totalCost); return toTradeDto(trade); } - // SELL GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); - if (pos.getQuantity().compareTo(qty) < 0) { - throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다."); + double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); + if (qty.doubleValue() > avail + 1e-12) { + throw new IllegalStateException("매도 수량이 보유(가용) 수량을 초과합니다."); } BigDecimal netProceeds = gross.subtract(fee); @@ -317,6 +540,9 @@ public class PaperTradingService { } accountRepo.save(account); + GcPaperPosition afterPos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null); + BigDecimal qtyAfter = afterPos != null ? afterPos.getQuantity() : BigDecimal.ZERO; + GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder() .accountId(account.getId()) .symbol(market) @@ -330,14 +556,52 @@ public class PaperTradingService { .feeAmount(fee) .netAmount(netProceeds) .cashAfter(account.getCashBalance()) + .realizedPnlDelta(realizedDelta) + .positionQtyAfter(qtyAfter) .build()); + ledgerService.recordSellSettle(account, trade, netProceeds); return toTradeDto(trade); } + private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app, + String market, String side, + double price, Double budgetPct, Double krwAmount) { + double feeRate = pct(app.getPaperFeeRatePct(), 0.05); + double slip = pct(app.getPaperSlippagePct(), 0); + double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95); + + if ("BUY".equals(side)) { + double reserved = account.getReservedCash() != null + ? account.getReservedCash().doubleValue() : 0; + double cash = account.getCashBalance().doubleValue() - reserved; + double budget = krwAmount != null && krwAmount > 0 ? krwAmount : cash * pctVal / 100.0; + double execPrice = price * (1 + slip / 100.0); + double denom = execPrice * (1 + feeRate / 100.0); + if (denom <= 0 || budget <= 0) { + throw new IllegalStateException("주문 가능 금액이 부족합니다."); + } + return budget / denom; + } + + GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) + .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); + double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); + if (avail <= 0) throw new IllegalStateException("보유 수량이 없습니다."); + return avail * pctVal / 100.0; + } + + private static String resolveOrderType(PaperOrderRequest req) { + if (req.getOrderType() != null) { + String t = req.getOrderType().toUpperCase(); + if ("MARKET".equals(t) || "LIMIT".equals(t)) return t; + } + String kind = req.getOrderKind() != null ? req.getOrderKind().toLowerCase() : "limit"; + return "market".equals(kind) ? "MARKET" : "LIMIT"; + } + private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName, BigDecimal qty, BigDecimal price) { - GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) - .orElse(null); + GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null); if (pos == null) { positionRepo.save(GcPaperPosition.builder() .accountId(account.getId()) @@ -346,6 +610,9 @@ public class PaperTradingService { .quantity(qty) .avgPrice(price) .build()); + BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null + ? account.getInitialCapitalSnapshot() : BigDecimal.valueOf(10_000_000); + allocationService.getOrDefault(account.getId(), market, defaultMax); return; } BigDecimal totalQty = pos.getQuantity().add(qty); @@ -367,8 +634,14 @@ public class PaperTradingService { .deviceId(dev) .cashBalance(initial) .realizedPnl(BigDecimal.ZERO) + .reservedCash(BigDecimal.ZERO) + .initialCapitalSnapshot(initial) + .resetCount(0) + .status("ACTIVE") .build(); - return accountRepo.save(a); + a = accountRepo.save(a); + ledgerService.recordInitial(a, initial); + return a; }); } @@ -390,7 +663,31 @@ public class PaperTradingService { .feeAmount(t.getFeeAmount().doubleValue()) .netAmount(t.getNetAmount().doubleValue()) .cashAfter(t.getCashAfter().doubleValue()) + .realizedPnlDelta(t.getRealizedPnlDelta() != null + ? t.getRealizedPnlDelta().doubleValue() : null) + .positionQtyAfter(t.getPositionQtyAfter() != null + ? t.getPositionQtyAfter().doubleValue() : null) .createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null) .build(); } + + private PaperOrderDto toOrderDto(GcPaperOrder o) { + return PaperOrderDto.builder() + .id(o.getId()) + .symbol(o.getSymbol()) + .side(o.getSide()) + .orderType(o.getOrderType()) + .limitPrice(o.getLimitPrice() != null ? o.getLimitPrice().doubleValue() : null) + .quantity(o.getQuantity().doubleValue()) + .filledQuantity(o.getFilledQuantity().doubleValue()) + .status(o.getStatus()) + .reservedCash(o.getReservedCash().doubleValue()) + .reservedQty(o.getReservedQty().doubleValue()) + .orderKind(o.getOrderKind()) + .source(o.getSource()) + .strategyId(o.getStrategyId()) + .createdAt(o.getCreatedAt() != null ? o.getCreatedAt().toString() : null) + .updatedAt(o.getUpdatedAt() != null ? o.getUpdatedAt().toString() : null) + .build(); + } } diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index f07ac07..192c38e 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -37,6 +37,7 @@ public class BarBuilder { private final Ta4jStorage ta4jStorage; private final TradingWebSocketBroker broker; private final BarCloseStrategyEvaluationService barCloseEvaluation; + private final com.goldenchart.service.PaperOrderMatcherService paperOrderMatcher; /** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */ private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440}; @@ -89,6 +90,7 @@ public class BarBuilder { // 현재 1분봉 상태를 STOMP 로 즉시 발행 (실시간 틱 렌더링용) publishCurrentBar(market); + paperOrderMatcher.onPriceTick(market, tradePrice); } // ── 내부 처리 ────────────────────────────────────────────────────────────── diff --git a/backend/src/main/resources/db/migration/V51__paper_investment_management.sql b/backend/src/main/resources/db/migration/V51__paper_investment_management.sql new file mode 100644 index 0000000..d55e349 --- /dev/null +++ b/backend/src/main/resources/db/migration/V51__paper_investment_management.sql @@ -0,0 +1,109 @@ +-- V51: 모의투자 투자금 관리 (종목별 한도, 원장, 일별 스냅샷, reset 이력) + +ALTER TABLE gc_paper_account + ADD COLUMN reserved_cash DECIMAL(20, 2) NOT NULL DEFAULT 0 + COMMENT '미체결 예약 금액 합', + ADD COLUMN initial_capital_snapshot DECIMAL(20, 2) NULL + COMMENT '마지막 초기화 시점 기준 초기자본', + ADD COLUMN reset_count INT NOT NULL DEFAULT 0, + ADD COLUMN last_reset_at DATETIME NULL, + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' + COMMENT 'ACTIVE | FROZEN'; + +ALTER TABLE gc_paper_trade + ADD COLUMN realized_pnl_delta DECIMAL(20, 2) NULL COMMENT '매도 체결 실현손익', + ADD COLUMN position_qty_after DECIMAL(30, 12) NULL COMMENT '체결 후 잔량', + ADD COLUMN note VARCHAR(200) NULL; + +CREATE INDEX idx_paper_trade_account_symbol_created + ON gc_paper_trade (account_id, symbol, created_at); + +CREATE INDEX idx_paper_trade_account_created_desc + ON gc_paper_trade (account_id, created_at DESC); + +CREATE TABLE gc_paper_symbol_allocation ( + id BIGINT NOT NULL AUTO_INCREMENT, + account_id BIGINT NOT NULL, + symbol VARCHAR(50) NOT NULL, + korean_name VARCHAR(100) NULL, + max_invest_krw DECIMAL(20, 2) NOT NULL DEFAULT 0, + budget_pct DECIMAL(8, 4) NULL, + strategy_id BIGINT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + pinned TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_paper_alloc_account_symbol (account_id, symbol), + INDEX idx_paper_alloc_account (account_id), + CONSTRAINT fk_paper_alloc_account FOREIGN KEY (account_id) + REFERENCES gc_paper_account(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 종목별 투자 한도'; + +CREATE TABLE gc_paper_cash_ledger ( + id BIGINT NOT NULL AUTO_INCREMENT, + account_id BIGINT NOT NULL, + entry_type VARCHAR(30) NOT NULL, + amount DECIMAL(20, 2) NOT NULL, + cash_before DECIMAL(20, 2) NOT NULL, + cash_after DECIMAL(20, 2) NOT NULL, + ref_trade_id BIGINT NULL, + symbol VARCHAR(50) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_paper_ledger_account_created (account_id, created_at DESC), + CONSTRAINT fk_paper_ledger_account FOREIGN KEY (account_id) + REFERENCES gc_paper_account(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 자금 변동 원장'; + +CREATE TABLE gc_paper_daily_snapshot ( + id BIGINT NOT NULL AUTO_INCREMENT, + account_id BIGINT NOT NULL, + snapshot_date DATE NOT NULL, + cash_balance DECIMAL(20, 2) NOT NULL, + stock_eval_amount DECIMAL(20, 2) NOT NULL, + total_asset DECIMAL(20, 2) NOT NULL, + daily_pnl DECIMAL(20, 2) NOT NULL DEFAULT 0, + daily_return_pct DECIMAL(10, 4) NOT NULL DEFAULT 0, + cumulative_return_pct DECIMAL(10, 4) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_paper_snapshot_account_date (account_id, snapshot_date), + CONSTRAINT fk_paper_snapshot_account FOREIGN KEY (account_id) + REFERENCES gc_paper_account(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 일별 자산 스냅샷'; + +CREATE TABLE gc_paper_reset_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + account_id BIGINT NOT NULL, + reset_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + initial_capital DECIMAL(20, 2) NOT NULL, + cash_before DECIMAL(20, 2) NOT NULL, + positions_json JSON NULL, + reason VARCHAR(200) NULL, + PRIMARY KEY (id), + INDEX idx_paper_reset_account (account_id), + CONSTRAINT fk_paper_reset_account FOREIGN KEY (account_id) + REFERENCES gc_paper_account(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 계좌 초기화 이력'; + +-- 기존 계좌: initial_capital_snapshot 백필 (device별 app_settings) +UPDATE gc_paper_account a +LEFT JOIN gc_app_settings s ON s.device_id = a.device_id +SET a.initial_capital_snapshot = COALESCE(s.paper_initial_capital, 10000000) +WHERE a.initial_capital_snapshot IS NULL; + +-- 보유 종목 → allocation seed (max = 초기자본 스냅샷) +INSERT INTO gc_paper_symbol_allocation (account_id, symbol, korean_name, max_invest_krw, is_active, sort_order) +SELECT p.account_id, p.symbol, p.korean_name, + COALESCE(a.initial_capital_snapshot, 10000000), + 1, 0 +FROM gc_paper_position p +JOIN gc_paper_account a ON a.id = p.account_id +WHERE p.quantity > 0 +ON DUPLICATE KEY UPDATE korean_name = COALESCE(VALUES(korean_name), gc_paper_symbol_allocation.korean_name); diff --git a/backend/src/main/resources/db/migration/V52__paper_pending_orders.sql b/backend/src/main/resources/db/migration/V52__paper_pending_orders.sql new file mode 100644 index 0000000..18a2fbf --- /dev/null +++ b/backend/src/main/resources/db/migration/V52__paper_pending_orders.sql @@ -0,0 +1,28 @@ +-- V52: 모의투자 미체결 주문 + +CREATE TABLE gc_paper_order ( + id BIGINT NOT NULL AUTO_INCREMENT, + account_id BIGINT NOT NULL, + symbol VARCHAR(50) NOT NULL, + side VARCHAR(10) NOT NULL COMMENT 'BUY | SELL', + order_type VARCHAR(20) NOT NULL DEFAULT 'LIMIT' COMMENT 'MARKET | LIMIT', + limit_price DECIMAL(20, 2) NULL, + quantity DECIMAL(30, 12) NOT NULL, + filled_quantity DECIMAL(30, 12) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + COMMENT 'PENDING | PARTIAL | FILLED | CANCELLED', + reserved_cash DECIMAL(20, 2) NOT NULL DEFAULT 0, + reserved_qty DECIMAL(30, 12) NOT NULL DEFAULT 0, + order_kind VARCHAR(20) NOT NULL DEFAULT 'limit', + source VARCHAR(20) NOT NULL DEFAULT 'MANUAL', + strategy_id BIGINT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + filled_at DATETIME NULL, + PRIMARY KEY (id), + INDEX idx_paper_order_account_status (account_id, status), + INDEX idx_paper_order_symbol_status (symbol, status), + CONSTRAINT fk_paper_order_account FOREIGN KEY (account_id) + REFERENCES gc_paper_account(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='모의투자 미체결 주문'; diff --git a/backend/src/main/resources/db/migration/V53__settings_virtual_live_permissions.sql b/backend/src/main/resources/db/migration/V53__settings_virtual_live_permissions.sql new file mode 100644 index 0000000..f4e0a76 --- /dev/null +++ b/backend/src/main/resources/db/migration/V53__settings_virtual_live_permissions.sql @@ -0,0 +1,6 @@ +-- 설정 화면: 가상매매·실거래 카테고리 권한 (기존 settings_paper와 동일 허용) +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) +SELECT role, 'settings_virtual', allowed FROM gc_role_menu_permission WHERE menu_id = 'settings_paper'; + +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) +SELECT role, 'settings_live', allowed FROM gc_role_menu_permission WHERE menu_id = 'settings_paper'; diff --git a/backend/src/test/java/com/goldenchart/service/PaperAllocationServiceTest.java b/backend/src/test/java/com/goldenchart/service/PaperAllocationServiceTest.java new file mode 100644 index 0000000..a3309e4 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/PaperAllocationServiceTest.java @@ -0,0 +1,79 @@ +package com.goldenchart.service; + +import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcPaperAccount; +import com.goldenchart.entity.GcPaperSymbolAllocation; +import com.goldenchart.repository.GcPaperPositionRepository; +import com.goldenchart.repository.GcPaperSymbolAllocationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PaperAllocationServiceTest { + + @Mock private GcPaperSymbolAllocationRepository allocRepo; + @Mock private GcPaperPositionRepository positionRepo; + + private PaperAllocationService service; + + @BeforeEach + void setUp() { + service = new PaperAllocationService(allocRepo, positionRepo); + } + + @Test + void validateBuy_rejectsWhenExceedsSymbolLimit() { + GcPaperAccount account = GcPaperAccount.builder() + .id(1L) + .cashBalance(BigDecimal.valueOf(10_000_000)) + .reservedCash(BigDecimal.ZERO) + .initialCapitalSnapshot(BigDecimal.valueOf(10_000_000)) + .build(); + GcAppSettings app = new GcAppSettings(); + app.setPaperAutoTradeBudgetPct(BigDecimal.valueOf(100)); + + GcPaperSymbolAllocation alloc = GcPaperSymbolAllocation.builder() + .accountId(1L) + .symbol("KRW-BTC") + .maxInvestKrw(BigDecimal.valueOf(100_000)) + .isActive(true) + .build(); + when(allocRepo.findByAccountIdAndSymbol(1L, "KRW-BTC")).thenReturn(Optional.of(alloc)); + when(positionRepo.findByAccountIdAndSymbol(1L, "KRW-BTC")).thenReturn(Optional.empty()); + + assertThrows(IllegalStateException.class, () -> + service.validateBuy(account, app, "KRW-BTC", BigDecimal.valueOf(200_000), false)); + } + + @Test + void validateBuy_rejectsInactiveSymbol() { + GcPaperAccount account = GcPaperAccount.builder() + .id(1L) + .cashBalance(BigDecimal.valueOf(10_000_000)) + .reservedCash(BigDecimal.ZERO) + .initialCapitalSnapshot(BigDecimal.valueOf(10_000_000)) + .build(); + GcAppSettings app = new GcAppSettings(); + + GcPaperSymbolAllocation alloc = GcPaperSymbolAllocation.builder() + .accountId(1L) + .symbol("KRW-BTC") + .maxInvestKrw(BigDecimal.valueOf(5_000_000)) + .isActive(false) + .build(); + when(allocRepo.findByAccountIdAndSymbol(1L, "KRW-BTC")).thenReturn(Optional.of(alloc)); + + assertThrows(IllegalStateException.class, () -> + service.validateBuy(account, app, "KRW-BTC", BigDecimal.valueOf(10_000), false)); + } +} diff --git a/db_struct.md b/db_struct.md index a5fa501..92673a9 100644 --- a/db_struct.md +++ b/db_struct.md @@ -867,6 +867,23 @@ goldenChart/ --- +## 모의투자 (Paper Trading) — V18 + V51 + V52 + +| 테이블 | 설명 | +|--------|------| +| `gc_paper_account` | device당 1계좌. `cash_balance`, `reserved_cash`, `initial_capital_snapshot`, `realized_pnl`, `reset_count` | +| `gc_paper_position` | 종목별 보유 수량·평단 | +| `gc_paper_trade` | 체결 이력. `realized_pnl_delta`, `position_qty_after`, `cash_after` | +| `gc_paper_symbol_allocation` | 종목별 `max_invest_krw`, `strategy_id`, `is_active` (UK: account_id + symbol) | +| `gc_paper_cash_ledger` | 자금 원장 (`INITIAL`, `BUY_SETTLE`, `SELL_SETTLE`, `RESET`) | +| `gc_paper_daily_snapshot` | 일별 EOD 자산 (UK: account_id + snapshot_date) | +| `gc_paper_reset_log` | 계좌 초기화 감사 이력 | +| `gc_paper_order` | 미체결 주문 (`PENDING` / `FILLED` / `CANCELLED`) | + +API prefix: `/paper/*` — [PaperTradingController](backend/src/main/java/com/goldenchart/controller/PaperTradingController.java) + +--- + ## 주의사항 1. **JPA FK vs 논리 참조**: `users`, `strategy_rule` 등을 참조하는 컬럼 다수가 JPA `@ManyToOne` 없이 Long ID만 보관하므로 DB 레벨 FK 제약이 없습니다. 참조 무결성은 애플리케이션 로직에 의존합니다. diff --git a/docs/모의투자_투자금_관리_설계.md b/docs/모의투자_투자금_관리_설계.md new file mode 100644 index 0000000..6cc9553 --- /dev/null +++ b/docs/모의투자_투자금_관리_설계.md @@ -0,0 +1,466 @@ +# 모의투자 투자금·종목별 관리 설계 + +상용 주식/HTS 프로그램(키움 영웅문 모의, NH/미래 HTS 모의, TradingView Paper 등)에서 통상 제공하는 **계좌·투자금·종목별 배분·수익률·거래내역** 구조를 기준으로, goldenChart **현재 구현(as-is)** 과 **목표(to-be)** 를 정리한 설계서입니다. + +--- + +## 1. 목표 + +| 목표 | 설명 | +|------|------| +| **투자금 단일 원천** | 모의 계좌의 현금·총자산·초기자본·실현/평가 손익을 DB에서 일관되게 관리 | +| **종목별 투자 한도** | 종목마다 최대 투자금(또는 비율)을 두고 매수 시 한도 초과 방지 | +| **투자금 설정 vs 관리 분리** | 설정(규칙·초기화) 화면과 운영(잔고·보유·체결) 화면 역할 분리 | +| **종목별 매수/매도** | 수동 주문·전략 자동매매가 동일 계좌·동일 한도 규칙을 따름 | +| **가상매매 연동** | 가상투자 대시보드·모의투자 메뉴가 **동일 `gc_paper_*` 계좌** 를 참조 | + +--- + +## 2. 현재 구현 (As-Is) + +### 2.1 DB (V18) + +``` +gc_app_settings ← 전역: 초기자본, 수수료, 슬리피지, 자동매매 ON/OFF, 매수 예산 % + │ + ▼ +gc_paper_account ← device_id당 1계좌: cash_balance, realized_pnl + │ + ├── gc_paper_position ← 종목별: quantity, avg_price (한도·배분 없음) + └── gc_paper_trade ← 체결 이력: BUY/SELL, fee, cash_after +``` + +### 2.2 API + +| 메서드 | 경로 | 용도 | +|--------|------|------| +| GET/POST | `/api/paper/summary` | 계좌 요약·보유·평가손익(markPrices 선택) | +| GET | `/api/paper/trades` | 체결 목록 | +| POST | `/api/paper/orders` | 즉시 체결 주문 | +| POST | `/api/paper/reset` | 계좌 초기화 | + +### 2.3 화면 + +| 화면 | 위치 | 역할 | +|------|------|------| +| **가상매매 설정** | 설정 → 가상매매 | 초기자본·수수료·자동매매 (전역) | +| **모의투자 설정 탭** | 모의투자 좌측 | 위와 동일 항목 + 계좌 초기화 | +| **모의투자 대시보드** | 메뉴 모의투자 | KPI·보유·체결·차트·수동 매수/매도 | +| **가상투자** | 메뉴 가상투자 | 전략 모니터링 + `placePaperOrder` 자동/수동 | + +### 2.4 부족한 점 (Gap) + +- **종목별 투자금 한도** 없음 → 전역 `paper_auto_trade_budget_pct` 만 존재 +- **현금 변동 원장**(입금/출금/수수료/초기화) 미분리 → `cash_after` 만 체결에 기록 +- **일별 자산 스냅샷** 없음 → 수익률 차트는 프론트가 체결만 재구성 +- **미체결 주문** 없음 → 모두 즉시 체결 +- **종목별 실현손익·회전율** 집계 테이블 없음 +- **투자금 설정** 이 설정 앱 + 모의투자에 이중 노출 + +--- + +## 3. 상용 모의투자 벤치마크 + +| 영역 | 통상 기능 | goldenChart 매핑 | +|------|-----------|------------------| +| 계좌 개설 | 초기 예탁금 설정 | `paper_initial_capital` + `gc_paper_account` | +| 계좌 초기화 | 잔고·보유·내역 리셋 | `POST /paper/reset` (유지·이력 테이블 추가 권장) | +| 예수금/주문가능 | 현금, D+0 가용 | `cash_balance` − `reserved_cash` | +| 종목별 매수한도 | 종목당 상한 또는 비중 상한 | **신규** `gc_paper_symbol_allocation` | +| 보유종목 | 수량·평단·평가·손익률·비중 | `gc_paper_position` + summary API 확장 | +| 체결내역 | 일자·종목·매수/매도·가격·수량·수수료 | `gc_paper_trade` (필터·페이징 추가) | +| 자산추이 | 일별 총자산 그래프 | **신규** `gc_paper_daily_snapshot` | +| 원장/자금변동 | 입출금·수수료·배당·이자 | **신규** `gc_paper_cash_ledger` | +| 수동 주문 | 지정가/시장가·수량·금액 | `TradeOrderPanel` + `/paper/orders` | +| 자동매매 | 전략 시그널 연동 | STOMP 시그널 → `placePaperOrder` | + +--- + +## 4. DB 설계 (To-Be) + +### 4.1 ER 개요 + +```mermaid +erDiagram + gc_app_settings ||--o{ gc_paper_account : "defaults" + gc_paper_account ||--o{ gc_paper_position : holds + gc_paper_account ||--o{ gc_paper_trade : fills + gc_paper_account ||--o{ gc_paper_symbol_allocation : limits + gc_paper_account ||--o{ gc_paper_cash_ledger : cash_flow + gc_paper_account ||--o{ gc_paper_daily_snapshot : eod + gc_paper_account ||--o{ gc_paper_reset_log : history + gc_paper_symbol_allocation }o--|| gc_strategy : optional +``` + +### 4.2 테이블 상세 + +#### (기존 확장) `gc_paper_account` + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `user_id` / `device_id` | | 기존 | +| `cash_balance` | DECIMAL(20,2) | 예수금 | +| `reserved_cash` | DECIMAL(20,2) **NEW** | 미체결·예약 금액 합 (Phase 2) | +| `realized_pnl` | DECIMAL(20,2) | 누적 실현손익 | +| `initial_capital_snapshot` | DECIMAL(20,2) **NEW** | 마지막 초기화 시점 기준 초기자본 | +| `reset_count` | INT **NEW** | 초기화 횟수 | +| `last_reset_at` | DATETIME **NEW** | | +| `status` | VARCHAR(20) **NEW** | `ACTIVE` \| `FROZEN` | +| `created_at` / `updated_at` | | | + +**파생 지표(API 계산, 컬럼 비저장 권장)** + +- `orderable_cash` = `cash_balance` − `reserved_cash` +- `stock_eval_amount` = Σ(보유 수량 × markPrice) +- `total_asset` = `cash_balance` + `stock_eval_amount` +- `unrealized_pnl` = `stock_eval_amount` − Σ(수량 × 평단) +- `total_return_pct` = (`total_asset` − `initial_capital_snapshot`) / `initial_capital_snapshot` × 100 + +#### (신규) `gc_paper_symbol_allocation` — 종목별 투자금 설정 + +상용 HTS의 **종목별 매수 한도 / 관심종목 투자비중** 에 해당. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `account_id` | BIGINT FK | | +| `symbol` | VARCHAR(50) | KRW-BTC 등 | +| `korean_name` | VARCHAR(100) | | +| `max_invest_krw` | DECIMAL(20,2) | 종목 최대 투자금(상한) | +| `budget_pct` | DECIMAL(8,4) NULL | 총자산 대비 % (max와 택1 또는 min 적용) | +| `strategy_id` | BIGINT NULL | 연결 전략(가상투자 카드와 동기) | +| `is_active` | TINYINT(1) | 0이면 신규 매수 차단 | +| `pinned` | TINYINT(1) | 관심 고정 | +| `sort_order` | INT | UI 정렬 | +| `created_at` / `updated_at` | | | + +**UK**: `(account_id, symbol)` + +**파생(체결·보유 기준 계산)** + +| 필드 | 계산 | +|------|------| +| `invested_krw` | 보유 평가금 또는 `quantity × avg_price` | +| `available_buy_krw` | `max_invest_krw` − `invested_krw` (음수면 0) | +| `symbol_return_pct` | (평가 − 원가) / 원가 × 100 | +| `weight_pct` | `eval_amount` / `total_asset` × 100 | + +#### (기존 유지) `gc_paper_position` + +| 컬럼 | 비고 | +|------|------| +| `quantity`, `avg_price` | 평단가 가중평균 유지 (기존 로직) | +| (선택) `first_buy_at` | 최초 매수 시각 — 통계용 | + +#### (기존 확장) `gc_paper_trade` + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| 기존 컬럼 | | | +| `realized_pnl_delta` | DECIMAL(20,2) NULL **NEW** | 매도 시 해당 체결 실현손익 | +| `position_qty_after` | DECIMAL(30,12) NULL **NEW** | 체결 후 잔량 | +| `note` | VARCHAR(200) NULL **NEW** | 메모 | + +**인덱스 추가**: `(account_id, symbol, created_at)`, `(account_id, created_at DESC)` + +#### (신규) `gc_paper_cash_ledger` — 자금 변동 원장 + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `account_id` | BIGINT FK | | +| `entry_type` | VARCHAR(30) | 아래 enum | +| `amount` | DECIMAL(20,2) | +입금 / −출금 | +| `cash_before` | DECIMAL(20,2) | | +| `cash_after` | DECIMAL(20,2) | | +| `ref_trade_id` | BIGINT NULL | 체결 연결 | +| `symbol` | VARCHAR(50) NULL | | +| `created_at` | DATETIME | | + +**`entry_type` enum** + +- `INITIAL` — 계좌 생성·초기화 시 초기 예탁 +- `BUY_SETTLE` — 매수 결제 +- `SELL_SETTLE` — 매도 입금 +- `FEE` — 수수료 (체결에 포함 시 생략 가능) +- `ADJUST` — 관리자/시스템 조정 +- `RESET` — 초기화 전 스냅샷 역산용 + +#### (신규) `gc_paper_daily_snapshot` — 일별 자산 (수익률 차트) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `account_id` | BIGINT FK | | +| `snapshot_date` | DATE | KST 기준 | +| `cash_balance` | DECIMAL(20,2) | | +| `stock_eval_amount` | DECIMAL(20,2) | EOD 시세 기준 | +| `total_asset` | DECIMAL(20,2) | | +| `daily_pnl` | DECIMAL(20,2) | 전일 대비 | +| `daily_return_pct` | DECIMAL(10,4) | | +| `cumulative_return_pct` | DECIMAL(10,4) | 초기자본 대비 | + +**UK**: `(account_id, snapshot_date)` — 스케줄러: 매일 00:05 KST 또는 마지막 틱 후 + +#### (신규) `gc_paper_reset_log` — 초기화 이력 + +| 컬럼 | 설명 | +|------|------| +| `account_id`, `reset_at`, `initial_capital`, `cash_before`, `positions_json`, `reason` | 감사·복구 | + +#### (Phase 2, 선택) `gc_paper_order` — 미체결 주문 + +지정가·예약 매수 등 확장 시. 현재는 **즉시 체결** 유지하고 Phase 2로 미룸. + +### 4.3 전역 설정 (`gc_app_settings` — 기존 유지) + +| 컬럼 | 용도 | +|------|------| +| `paper_trading_enabled` | 모의투자 마스터 스위치 | +| `paper_initial_capital` | 초기화·신규 계좌 기본 예탁금 | +| `paper_fee_rate_pct` / `paper_slippage_pct` / `paper_min_order_krw` | | +| `paper_auto_trade_enabled` / `paper_auto_trade_budget_pct` | **전역** 자동매수 시 가용현금 % | + +**규칙 우선순위 (매수 가능 금액)** + +``` +min( + orderable_cash, + orderable_cash × paper_auto_trade_budget_pct / 100, -- 자동매매 시 + symbol.available_buy_krw -- 종목 한도 +) +``` + +### 4.4 마이그레이션 제안 + +| 파일 | 내용 | +|------|------| +| `V51__paper_investment_management.sql` | account 확장, symbol_allocation, cash_ledger, daily_snapshot, reset_log, trade 인덱스 | +| 데이터 백필 | 기존 account → `initial_capital_snapshot` = app.initial_capital; allocation은 보유 종목만 max=초기자본으로 seed | + +--- + +## 5. 화면 설계 + +### 5.1 메뉴·정보 구조 + +``` +설정 + └── [모의투자 설정] ← 투자금·규칙·초기화 (관리자/최초 설정 성격) + +모의투자 (Paper Trading) ← 투자금 관리·운영 허브 + ├── [투자금 관리] (기본) ← 계좌 요약·보유·종목별 수익 + ├── [거래/주문] ← 매수·매도 패널 (기존 우측) + ├── [거래내역] ← 체결·필터 + └── [성과/분석] ← MDD·샤프·자산추이 (기존 차트 강화) + +가상투자 (Virtual) ← 전략 모니터 (체결은 동일 paper API) +``` + +**역할 분리 원칙** + +| 화면 | 사용자 질문 | +|------|-------------| +| **투자금 설정** | “얼마로 시작하고, 수수료·자동매매·종목당 한도는?” | +| **투자금 관리** | “지금 얼마 벌었고, 종목별로 얼마 묶여 있나?” | +| **거래/주문** | “이 종목 사고/팔려면?” | + +### 5.2 투자금 설정 화면 (`PaperInvestmentSettingsPage` 또는 설정 탭 통합) + +**레이아웃 (1열 폼 + 위험 작업 하단)** + +``` +┌─────────────────────────────────────────┐ +│ 모의투자 설정 │ +├─────────────────────────────────────────┤ +│ [ON] 모의투자 사용 │ +│ 초기 자본 (KRW) [ 10,000,000 ] │ +│ 수수료 (%) [ 0.05 ] │ +│ 슬리피지 (%) [ 0.00 ] │ +│ 최소 주문 (KRW) [ 5,000 ] │ +├─────────────────────────────────────────┤ +│ 자동매매 │ +│ [ON] 전략 시그널 자동 체결 │ +│ 매수 예산 (가용현금 %) [ 95 ] │ +├─────────────────────────────────────────┤ +│ 기본 종목 한도 (신규 종목 추가 시) │ +│ ○ 총자산의 [ 20 ] % ○ 고정 [ 2,000,000]│ +├─────────────────────────────────────────┤ +│ [ 계좌 초기화 ] (confirm + reset_log) │ +└─────────────────────────────────────────┘ +``` + +- **저장**: `gc_app_settings` + (선택) 계좌 `initial_capital_snapshot` 동기 +- **초기화**: `POST /paper/reset` + `gc_paper_reset_log` + ledger `RESET` + +### 5.3 투자금 관리 화면 (`PaperInvestmentManage` — 모의투자 메인 상단/탭) + +**상용 HTS “계좌평가 / 잔고” 패널에 해당** + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 총자산 ₩12,345,678 │ 수익률 +12.3% │ 평가손익 +234,567 │ +│ 예수금 ₩3,000,000 │ 실현손익 +50,000 │ 주문가능 ₩2,850,000 │ +└──────────────────────────────────────────────────────────────┘ +┌─ 보유 종목 (종목별 투자금·수익률) ─────────────────────────────┐ +│ 종목 │ 비중 │ 투자한도 │ 투입금 │ 평가 │ 수익률 │ +│ BTC │ 35% │ 5,000,000 │ 4.2M │ 4.5M │ +7.1% │ +│ ETH │ 20% │ 3,000,000 │ 2.8M │ 2.5M │ -10.7% │ +│ [+ 종목 추가] 한도·전략 연결 │ +└──────────────────────────────────────────────────────────────┘ +``` + +**행 동작** + +- 행 클릭 → 차트/주문 패널 종목 전환 +- 한도 편집 → `PATCH /paper/allocations/{symbol}` +- 비활성 토글 → 해당 종목 신규 매수 차단 + +### 5.4 모의투자 대시보드 (기존 `PaperTradingPage` 개편) + +``` +┌──────────┬─────────────────────────────┬──────────────┐ +│ 좌측 │ 중앙 │ 우측 │ +│ · 전략 │ [투자금 관리] [성과차트] │ 매수/매도 │ +│ · 설정 │ 보유·KPI │ 호가/체결 │ +└──────────┴─────────────────────────────┴──────────────┘ +``` + +- 좌측 **설정** → §5.2 로 링크 또는 임베드 +- 중앙 **투자금 관리** → §5.3 +- 우측 **TradeOrderPanel** — 종목별 `available_buy_krw` 표시 + +### 5.5 거래내역 화면 + +| 컬럼 | 설명 | +|------|------| +| 체결시각 | | +| 종목 | | +| 구분 | 매수(빨강) / 매도(파랑) — `--gc-trade-buy/sell` | +| 가격·수량·금액 | | +| 수수료 | | +| 실현손익 | 매도 행만 | +| 출처 | MANUAL / STRATEGY | +| 체결 후 예수금 | `cash_after` | + +**필터**: 기간, 종목, side, source · **페이징** API + +### 5.6 가상투자와의 동기화 + +| 가상투자 UI | DB 매핑 | +|-------------|---------| +| 투자대상 목록 | `gc_paper_symbol_allocation` (symbol, strategy_id, pinned) | +| 카드 매수/매도 | `/paper/orders` + 한도 검증 | +| 자동매매 | `paper_auto_trade_*` + 종목 `max_invest_krw` | + +**권장**: 가상투자 `targets` 저장 시 백엔드 `PUT /paper/allocations/bulk` 로 동기화 (localStorage는 캐시만). + +--- + +## 6. 기능 설계 + +### 6.1 API (신규·확장) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/paper/summary` | 기존 + `orderableCash`, `initialCapitalSnapshot`, `allocations[]` | +| GET | `/paper/allocations` | 종목별 한도·투입·가용·수익률 | +| PUT | `/paper/allocations` | bulk upsert (가상투자 동기) | +| PATCH | `/paper/allocations/{symbol}` | 단일 종목 한도 수정 | +| GET | `/paper/trades` | `?symbol=&side=&from=&to=&page=` | +| GET | `/paper/ledger` | 자금 원장 | +| GET | `/paper/snapshots/daily` | 자산 추이 | +| POST | `/paper/orders` | **한도·orderable 검증** 추가 | +| POST | `/paper/reset` | reset_log + ledger + allocation 정책에 따라 재생성 | + +### 6.2 매수 검증 플로우 + +```mermaid +sequenceDiagram + participant UI as 주문 UI + participant API as PaperTradingService + participant DB as gc_paper_* + + UI->>API: POST /paper/orders BUY + API->>DB: load account + allocation + position + API->>API: needKrw = price×qty + fee + alt needKrw > orderable_cash + API-->>UI: 400 가용현금 부족 + else needKrw > available_buy_krw + API-->>UI: 400 종목 투자한도 초과 + else + API->>DB: trade + position + ledger + API-->>UI: PaperTradeDto + end +``` + +### 6.3 매도 검증 + +- 보유 `quantity` ≥ 매도 수량 +- 전량 매도 시 `realized_pnl_delta` 계산 → `account.realized_pnl` 누적 +- (선택) 매도 후에도 allocation 행 유지, `invested_krw`=0 + +### 6.4 일별 스냅샷 배치 + +- 대상: `paper_trading_enabled` 이고 당일 체결/시세 변동 있는 계좌 +- 입력: EOD mark prices (Ta4jStorage 또는 REST) +- UPSERT `gc_paper_daily_snapshot` + +### 6.5 종목별 수익률 정의 (화면 표기 통일) + +| 지표 | 정의 | +|------|------| +| **종목 평가수익률** | (mark×qty − avg×qty) / (avg×qty) × 100 | +| **종목 실현손익** | 해당 종목 매도 체결의 `realized_pnl_delta` 합 | +| **계좌 총수익률** | (total_asset − initial_capital_snapshot) / initial × 100 | +| **당일 수익률** | daily_snapshot.daily_return_pct | + +--- + +## 7. 서비스·모듈 구조 (백엔드) + +``` +PaperTradingController +PaperTradingService ← 체결·잔고 (기존) +PaperAllocationService ← 종목별 한도 CRUD·검증 +PaperLedgerService ← 원장 기록 +PaperSnapshotService ← 일별 스냅샷·조회 +PaperAnalyticsService ← MDD/샤프 (체결·스냅샷 기반, 기존 paperMetrics 이관 가능) +``` + +--- + +## 8. 구현 단계 (로드맵) + +| 단계 | 범위 | 산출물 | +|------|------|--------| +| **P0** | DB V51 + allocation CRUD + 매수 한도 검증 | 마이그레이션, API, 단위 테스트 | +| **P1** | 투자금 관리 UI (보유+한도+KPI) | PaperTradingPage 탭, bulk sync 가상투자 | +| **P2** | cash_ledger + 거래내역 필터/페이징 | 원장 화면 | +| **P3** | daily_snapshot + 자산추이 차트 | 스케줄러 | +| **P4** | (선택) 미체결 주문 | gc_paper_order | + +--- + +## 9. goldenChart 적용 시 유의사항 + +1. **단일 계좌**: `device_id` 기준 1계좌 유지 (상용 모의도 대부분 1:1). +2. **평가 시세**: summary는 `POST /paper/summary` + markPrices 패턴 유지. +3. **색상**: 매수 `#ef5350`, 매도 `#4dabf7` (`--gc-trade-buy/sell`). +4. **전략 평가와 분리**: Ta4j 시그널은 `gc_paper_*` 체결만 변경; 전략 DSL 평가는 기존 백엔드 단일 경로 유지. +5. **설정 이중화 해소**: `SettingsPage` 가상매매 = 전역 기본값, 모의투자 좌측 설정 = 동일 API 바인딩 또는 “설정으로 이동” 링크만. + +--- + +## 10. 요약 + +| 계층 | 설계 요지 | +|------|-----------| +| **DB** | 계좌 + **종목별 allocation** + **cash 원장** + **일별 스냅샷** + 체결 확장 | +| **투자금 설정** | 전역 규칙·초기자본·초기화 (app_settings + reset_log) | +| **투자금 관리** | 총자산·예수금·종목별 한도/투입/수익률·비중 | +| **매수/매도** | 가용현금 ∧ 종목한도 ∧ (자동매매 %) 검증 후 체결 | +| **거래내역** | paper_trade + ledger, 필터·페이징 | + +현재 V18 스키마는 **MVP 체결·보유** 에 적합하고, 상용 수준의 **종목별 투자금·원장·일별 성과** 는 §4.4 `V51` 마이그레이션과 §8 P0~P3 순으로 확장하는 것을 권장합니다. diff --git a/frontend/src/App.css b/frontend/src/App.css index 5009cae..e9eec52 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -376,6 +376,52 @@ html.theme-blue { .tv-sym-btn:hover { background: var(--bg3); } .tv-sym-btn svg { color: var(--text2); flex-shrink: 0; } .tv-sym-text { color: var(--text); } +.tv-sym-btn--search { + width: 32px; + height: 32px; + padding: 0; + justify-content: center; +} + +/* 종목 검색 아이콘 우측 — 선택 종목(한글명 클릭 가능)·심볼·현재가 */ +.tv-toolbar-quote { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + padding: 0 4px 0 0; + font-size: 12px; + white-space: nowrap; +} +.tv-quote-ko { + padding: 4px 6px; + margin: 0; + border: none; + border-radius: var(--radius); + background: transparent; + font-size: 14px; + font-weight: 700; + color: var(--text); + cursor: pointer; + transition: background 0.12s; +} +.tv-quote-ko:hover { background: var(--bg3); } +.tv-quote-en { + font-size: 11px; + font-weight: 500; + color: var(--text3); +} +.tv-quote-price { + font-size: 13px; + font-weight: 700; +} +.tv-quote-change.up { color: var(--up); font-weight: 600; } +.tv-quote-change.down { color: var(--down); font-weight: 600; } +.tv-quote-status { + color: var(--text2); + font-size: 11px; +} +.tv-quote-status--err { color: var(--down); } .tv-sym-input { font-size: 14px; @@ -9908,17 +9954,15 @@ html.theme-blue { border-left: 1px solid var(--border); transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1); } -.rsp-wrap--open .rsp-panel { - width: 420px; -} +/* trade-right-panel 너비·패딩: styles/tradeRightPanel.css */ -/* ── 탭 헤더 ── */ +/* ── 탭 헤더 (레거시 rsp-tab) ── */ .rsp-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; background: var(--bg3); - min-width: 420px; + min-width: 0; } .rsp-tab { flex: 1; @@ -9939,43 +9983,22 @@ html.theme-blue { font-weight: 600; } -/* 탭 본문 */ +/* 탭 본문 (레거시) */ .rsp-body { flex: 1; min-height: 0; - min-width: 420px; + min-width: 300px; display: flex; flex-direction: column; overflow: hidden; } -/* 실시간 차트 우측 — 알림목록·가상매매와 동일 PaperSplitPanel 레이아웃 */ -.rsp-body.ptd-right-body { - padding: 0; - gap: 0; -} -.rsp-body .ptd-split-panel--right { - flex: 1; - min-height: 0; - height: 100%; - padding: 8px 10px 6px; - box-sizing: border-box; -} -.rsp-body .ptd-ob-stack--fill { - flex: 1; - min-height: 0; - height: 100%; -} -.rsp-body .ptd-split-panel--right .ptd-order-card .top-actions { - margin-top: auto; -} - .rsp-tabs.bps-right-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; background: var(--bg3); - min-width: 420px; + min-width: 0; } .rsp-tabs.bps-right-tabs .bps-right-tab { flex: 1; @@ -10434,6 +10457,9 @@ html.theme-blue { color: var(--text); outline: none; } +.top-field--price .top-input { + text-align: right; +} .top-input--full { width: 100%; border: 1px solid var(--border); @@ -11770,11 +11796,7 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1), flex-basis 0.22s cubic-bezier(0.4, 0, 0.2, 1); } -.tnl-page--with-right .tnl-side-wrap--right.tnl-side-wrap--open .tnl-right.tnl-right--open { - flex: 0 0 min(380px, 38vw); - width: min(380px, 38vw); - min-width: 300px; - max-width: 420px; +.tnl-page--with-right .tnl-side-wrap--right.tnl-side-wrap--open .tnl-right.trade-right-panel.tnl-right--open { border-left: 1px solid var(--border); } .tnl-right-tabs { @@ -11788,21 +11810,6 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } display: flex; flex-direction: column; } -.tnl-page--with-right .ptd-right-body { - flex: 1; - min-height: 0; -} -.tnl-page--with-right .ptd-split-panel--right { - height: 100%; -} -.tnl-page--with-right .ptd-ob-stack--fill { - height: 100%; -} - -/* 매매 시그널 알림 우측 — 가상매매·실시간 차트와 동일 compact (paperDashboard.css) */ -.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-actions { - margin-top: auto; -} .tnl-row--selected { border-color: var(--accent, #7aa2f7); box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.35); @@ -12658,7 +12665,15 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } padding: 0 8px; overflow: hidden; } -.app--mobile .upbit-hint { display: none; } +.app--mobile .tv-toolbar-quote { + gap: 4px; + max-width: min(42vw, 200px); + overflow: hidden; +} +.app--mobile .tv-quote-ko { font-size: 12px; padding: 3px 4px; } +.app--mobile .tv-quote-en { font-size: 10px; } +.app--mobile .tv-quote-price { font-size: 11px; } +.app--mobile .tv-quote-change { font-size: 10px; } .app--mobile .upbit-market { max-width: 42vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ── 메인: 차트 전체 너비, 사이드 패널 오버레이 ── */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8818ae1..0091b92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -100,11 +100,7 @@ import VerificationBoardPage from './components/VerificationBoardPage'; import DashboardPage from './components/DashboardPage'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import ChartLegendBar from './components/ChartLegendBar'; -import { - formatChartLiveChangePct, - formatChartLivePrice, - resolveChartLiveQuote, -} from './utils/chartLiveQuote'; +import { resolveChartLiveQuote } from './utils/chartLiveQuote'; import RightSidePanel from './components/RightSidePanel'; import { type BacktestSettingsDto, @@ -129,7 +125,7 @@ import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } fr import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry'; import { syncDocumentTheme } from './utils/documentTheme'; import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions'; -import { firstAllowedTopMenu, normalizeRole } from './utils/permissions'; +import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions'; import { clearAdminUnlock } from './utils/adminUnlock'; import type { LoginResponse } from './utils/backendApi'; import { invalidateAppSettingsCache } from './hooks/useAppSettings'; @@ -137,25 +133,13 @@ import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; import './App.css'; import './styles/appPopup.css'; import './styles/paperDashboard.css'; +import './styles/tradeRightPanel.css'; import './styles/virtualTradingDashboard.css'; import './styles/backtestDashboard.css'; let _indCounter = 0; function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; } -// ─── 연결 상태 배지 ──────────────────────────────────────────────────────── -const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => { - if (!isUpbit) return null; - const map: Record = { - connecting: { dot: 'dot-yellow', label: '연결 중...' }, - connected: { dot: 'dot-lime', label: '실시간' }, - disconnected: { dot: 'dot-red', label: '재연결 중' }, - error: { dot: 'dot-red', label: '오류' }, - }; - const { dot, label } = map[status]; - return {label}; -}; - /** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */ let _wsFlashTimer: ReturnType | null = null; function flashWsDot() { @@ -231,7 +215,11 @@ function App() { const [mobileRightOpen, setMobileRightOpen] = useState(false); const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade'); const [menuPage, setMenuPage] = useState('chart'); + const [settingsInitialCategory, setSettingsInitialCategory] = useState(); const [verificationFocusIssueId, setVerificationFocusIssueId] = useState(null); + useEffect(() => { + if (menuPage !== 'settings') setSettingsInitialCategory(undefined); + }, [menuPage]); const chartVisible = menuPage === 'chart'; const isMobile = useIsMobile(); @@ -1770,6 +1758,10 @@ function App() { paperTradingEnabled={paperTradingEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperOrderFilled={handlePaperOrderFilled} + onOpenSettings={() => { + setSettingsInitialCategory('paper'); + setMenuPage('settings'); + }} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); @@ -1957,6 +1949,7 @@ function App() { const r = await sendFcmTest(); window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.'); }} + initialCategory={settingsInitialCategory} /> )} @@ -2083,34 +2076,12 @@ function App() { }} onToggleLiveStrategy={() => setShowLivePanel(v => !v)} liveStrategyActive={showLivePanel || monitoredMarkets.length > 0} + showChartQuote={useUpbit} + chartLiveQuote={liveQuote} + chartQuoteLoading={isLoading} + chartQuoteError={error} /> - {/* 업비트 연결 상태 바 */} - {useUpbit && ( -
- - {getKoreanName(symbol)} {symbol} - {isLoading && 데이터 로딩 중...} - {error && ⚠ {error}} - {!isLoading && !error && liveQuote.price != null && ( -
- - {formatChartLivePrice(liveQuote.price)} - - {liveQuote.changePct != null && ( - - {formatChartLiveChangePct(liveQuote.changePct)} - - )} -
- )} - 💡 업비트 마켓: KRW-BTC, KRW-ETH, KRW-XRP ... -
- )} - {isMobile && (showMarketPanel || mobileRightOpen) && ( - + ); @@ -166,10 +240,16 @@ const PaperTradingPage: React.FC = ({ theme={theme} title="모의투자" subtitle="Paper Trading" + pageClassName="bps-page--vtd bps-page--ptd" loading={initialLoading} loadingText="모의투자 대시보드 로딩…" + collapsiblePanels leftStorageKey="ptd-left-width" - footerStorageKey="ptd-footer-height" + leftDefaultWidth={380} + leftCollapsedStorageKey="ptd-left-open" + rightStorageKey="ptd-right-width" + rightDefaultWidth={440} + rightCollapsedStorageKey="ptd-right-open" headerActions={( <> @@ -185,129 +265,108 @@ const PaperTradingPage: React.FC = ({ market={selectedMarket} summary={s} paperAutoTradeEnabled={paperAutoTradeEnabled} - onMarketSelect={setSelectedMarket} + onMarketSelect={handleSelectMarket} /> ) : ( { setSummary(null); setTrades([]); void loadData(); }} /> )} centerHead={( - {coinCode(selectedMarket)} / KRW +
+
+ + +
+ {coinCode(selectedMarket)} / KRW +
)} - center={( + center={centerTab === 'invest' ? ( +
+ + void loadData()} + /> +
+ +
+ + + + +
+
+
+ ) : (
)} rightTabs={rightTabs} right={( -
+ {rightTab === 'trade' ? ( - { onPaperOrderFilled?.(); void loadData(); }} - /> - )} - bottom={( - { onPaperOrderFilled?.(); void loadData(); }} - /> - )} + void loadData({ focusHistory: true })} /> ) : rightTab === 'orderbook' ? ( - - )} - bottom={( - - )} + ) : ( - - - -
- )} - bottom={historyTab === 'open' ? ( -

미체결 주문 없음 (모의투자 즉시 체결)

- ) : ( - - - - - - {trades.slice(0, 20).map(t => ( - - - - - - - - ))} - {!trades.length && } - -
시간자산유형가격상태
{t.createdAt?.slice(11, 19) ?? '—'}{coinCode(t.symbol)}{t.side === 'BUY' ? '매수' : '매도'}{fmtKrw(t.price)}체결
체결 내역 없음
- )} - /> +
+
+ + + +
+
+ {historyTab === 'open' ? ( + void loadData()} /> + ) : historyTab === 'ledger' ? ( + + ) : ( + + )} +
+
)} - - )} - footerLabel="성과 지표" - footer={( -
- - - - -
+ )} /> ); diff --git a/frontend/src/components/RightSidePanel.tsx b/frontend/src/components/RightSidePanel.tsx index fc7315e..3cf8fbb 100644 --- a/frontend/src/components/RightSidePanel.tsx +++ b/frontend/src/components/RightSidePanel.tsx @@ -4,9 +4,13 @@ * - 호가 탭: 실시간 호가창 */ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { OrderbookPanel } from './OrderbookPanel'; -import TradeOrderPanel from './TradeOrderPanel'; -import PaperSplitPanel from './paper/PaperSplitPanel'; +import { + TradeSplitOrderPanel, + TradeOrderbookPanelContent, + TradeRightPanelTabs, + TradeRightPanelBody, + type TradeRightPanelTab, +} from './trade'; import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import type { OrderbookTickerInfo } from './OrderbookPanel'; @@ -19,8 +23,8 @@ export interface RightSidePanelProps { open?: boolean; onOpenChange?: (open: boolean) => void; /** 모바일 도크에서 호가/매매 탭 지정 */ - activeTab?: PanelTab; - onTabChange?: (tab: PanelTab) => void; + activeTab?: TradeRightPanelTab; + onTabChange?: (tab: TradeRightPanelTab) => void; market: string; tradePrice: number | null; asks: OrderbookDisplayUnit[]; @@ -44,11 +48,6 @@ export interface RightSidePanelProps { onPaperOrderFilled?: () => void; } -const PANEL_TABS: { id: PanelTab; label: string }[] = [ - { id: 'trade', label: '매매' }, - { id: 'orderbook', label: '호가' }, -]; - const RightSidePanel: React.FC = ({ open: openControlled, onOpenChange, @@ -77,7 +76,7 @@ const RightSidePanel: React.FC = ({ onPaperOrderFilled, }) => { const [openInternal, setOpenInternal] = useState(false); - const [tabInternal, setTabInternal] = useState('trade'); + const [tabInternal, setTabInternal] = useState('trade'); const tradeSectionRef = useRef(null); const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market); @@ -91,7 +90,7 @@ const RightSidePanel: React.FC = ({ }; const activeTab = activeTabControlled ?? tabInternal; - const setActiveTab = useCallback((tab: PanelTab) => { + const setActiveTab = useCallback((tab: TradeRightPanelTab) => { if (onTabChange) onTabChange(tab); else setTabInternal(tab); }, [onTabChange]); @@ -130,91 +129,49 @@ const RightSidePanel: React.FC = ({ -
-
- {PANEL_TABS.map(tab => ( - - ))} -
+
+ -
+ {activeTab === 'trade' && ( - - -
- )} - bottom={( -
- -
- )} + )} {activeTab === 'orderbook' && ( -
-
-
실시간 호가
-
- -
-
-
+ )} -
+
); diff --git a/frontend/src/components/ScreenRegionCaptureOverlay.tsx b/frontend/src/components/ScreenRegionCaptureOverlay.tsx new file mode 100644 index 0000000..3eda311 --- /dev/null +++ b/frontend/src/components/ScreenRegionCaptureOverlay.tsx @@ -0,0 +1,187 @@ +/** + * 화면(탭/창) 공유 스트림 위에서 드래그로 영역 선택 → PNG File 반환 + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + captureErrorMessage, + clientRectToVideoPixels, + cropVideoFrameToFile, + requestDisplayCaptureStream, + stopMediaStream, + type ScreenCaptureRect, +} from '../utils/screenRegionCapture'; + +interface Props { + onCapture: (file: File) => void; + onCancel: () => void; +} + +type Phase = 'starting' | 'ready' | 'selecting' | 'processing'; + +const MIN_DRAG_PX = 8; + +const ScreenRegionCaptureOverlay: React.FC = ({ onCapture, onCancel }) => { + const videoRef = useRef(null); + const streamRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const [phase, setPhase] = useState('starting'); + const [selection, setSelection] = useState(null); + const [error, setError] = useState(null); + + const cleanup = useCallback(() => { + stopMediaStream(streamRef.current); + streamRef.current = null; + }, []); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const stream = await requestDisplayCaptureStream(); + if (cancelled) { + stopMediaStream(stream); + return; + } + streamRef.current = stream; + const video = videoRef.current; + if (!video) { + stopMediaStream(stream); + onCancel(); + return; + } + video.srcObject = stream; + video.muted = true; + await video.play(); + if (cancelled) return; + setPhase('ready'); + } catch (e) { + if (!cancelled) { + setError(captureErrorMessage(e)); + setTimeout(onCancel, 2200); + } + } + })(); + + return () => { + cancelled = true; + cleanup(); + }; + }, [cleanup, onCancel]); + + const finishCancel = useCallback(() => { + cleanup(); + onCancel(); + }, [cleanup, onCancel]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') finishCancel(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [finishCancel]); + + const normalizeRect = (x0: number, y0: number, x1: number, y1: number): ScreenCaptureRect => { + const left = Math.min(x0, x1); + const top = Math.min(y0, y1); + return { + left, + top, + width: Math.abs(x1 - x0), + height: Math.abs(y1 - y0), + }; + }; + + const handlePointerDown = (e: React.PointerEvent) => { + if (phase !== 'ready' && phase !== 'selecting') return; + if (e.button !== 0) return; + dragStartRef.current = { x: e.clientX, y: e.clientY }; + setSelection({ left: e.clientX, top: e.clientY, width: 0, height: 0 }); + setPhase('selecting'); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragStartRef.current) return; + const start = dragStartRef.current; + setSelection(normalizeRect(start.x, start.y, e.clientX, e.clientY)); + }; + + const handlePointerUp = async (e: React.PointerEvent) => { + if (!dragStartRef.current) return; + const start = dragStartRef.current; + dragStartRef.current = null; + const clientRect = normalizeRect(start.x, start.y, e.clientX, e.clientY); + if (clientRect.width < MIN_DRAG_PX || clientRect.height < MIN_DRAG_PX) { + setSelection(null); + setPhase('ready'); + return; + } + + const video = videoRef.current; + if (!video) { + finishCancel(); + return; + } + + setPhase('processing'); + try { + const vidRect = clientRectToVideoPixels(clientRect, video); + if (!vidRect) throw new Error('REGION_TOO_SMALL'); + const file = await cropVideoFrameToFile(video, vidRect); + cleanup(); + onCapture(file); + } catch (err) { + setError(captureErrorMessage(err)); + setPhase('ready'); + setSelection(null); + } + }; + + const hint = + phase === 'starting' + ? '화면 공유 준비 중…' + : phase === 'processing' + ? '캡처 처리 중…' + : '캡처할 영역을 드래그하세요 · Esc 취소'; + + return createPortal( +
+
diff --git a/frontend/src/components/TradeOrderPanel.tsx b/frontend/src/components/TradeOrderPanel.tsx index 6d8968d..8bb8d1e 100644 --- a/frontend/src/components/TradeOrderPanel.tsx +++ b/frontend/src/components/TradeOrderPanel.tsx @@ -17,6 +17,8 @@ export interface TradeOrderPanelProps { /** 현재가 (KRW) — 종목 변경 시 초기값 */ tradePrice: number | null; availableKrw?: number; + /** 종목별 추가 매수 가능 (한도 기준) */ + availableSymbolBuyKrw?: number; /** 매도 시 보유 수량 (모의투자) */ availableCoinQty?: number; /** 호가·차트에서 자동 입력 */ @@ -89,6 +91,7 @@ const TradeOrderPanel: React.FC = ({ market, tradePrice, availableKrw = 0, + availableSymbolBuyKrw, availableCoinQty = 0, fillRequest, searchAnchorRef, @@ -99,6 +102,9 @@ const TradeOrderPanel: React.FC = ({ onPaperOrderFilled, }) => { const isBuy = side === 'buy'; + const buyBudgetKrw = isBuy && availableSymbolBuyKrw != null + ? Math.min(availableKrw, availableSymbolBuyKrw) + : availableKrw; const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market; const code = coinCode(displayMarket); const koreanName = getKoreanName(displayMarket); @@ -156,8 +162,8 @@ const TradeOrderPanel: React.FC = ({ const applyPct = useCallback((pct: number) => { setPctMode(pct); if (isBuy) { - if (orderKind === 'market' || availableKrw <= 0) return; - const budget = (availableKrw * pct) / 100; + if (orderKind === 'market' || buyBudgetKrw <= 0) return; + const budget = (buyBudgetKrw * pct) / 100; const p = orderKind === 'limit' && price > 0 ? price : refPrice; if (p > 0) { const q = budget / p; @@ -168,7 +174,7 @@ const TradeOrderPanel: React.FC = ({ if (availableCoinQty <= 0) return; const q = (availableCoinQty * pct) / 100; setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8)); - }, [isBuy, availableKrw, availableCoinQty, orderKind, price, refPrice]); + }, [isBuy, buyBudgetKrw, availableCoinQty, orderKind, price, refPrice]); const bumpPrice = useCallback((delta: number) => { const base = price > 0 ? price : refPrice; @@ -202,26 +208,36 @@ const TradeOrderPanel: React.FC = ({ return; } try { - const trade = await placePaperOrder({ + const result = await placePaperOrder({ market: displayMarket, side: isBuy ? 'BUY' : 'SELL', orderKind: orderKind === 'market' ? 'market' : 'limit', + orderType: orderKind === 'market' ? 'MARKET' : 'LIMIT', price: execPrice, quantity: qty, source: 'MANUAL', }); - if (!trade) { + if (!result?.trade && !result?.order) { window.alert('모의 주문에 실패했습니다.'); return; } onPaperOrderFilled?.(); - window.alert( - `[모의투자] ${koreanName || code} ${label} 체결\n` + - `가격: ${fmtKrw(trade.price)} KRW\n` + - `수량: ${trade.quantity} ${code}\n` + - `수수료: ${fmtKrw(trade.feeAmount)} KRW\n` + - `체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`, - ); + if (result.order) { + window.alert( + `[모의투자] ${koreanName || code} ${label} 미체결 등록\n` + + `지정가: ${fmtKrw(result.order.limitPrice ?? execPrice)} KRW\n` + + `수량: ${result.order.quantity} ${code}`, + ); + } else if (result.trade) { + const trade = result.trade; + window.alert( + `[모의투자] ${koreanName || code} ${label} 체결\n` + + `가격: ${fmtKrw(trade.price)} KRW\n` + + `수량: ${trade.quantity} ${code}\n` + + `수수료: ${fmtKrw(trade.feeAmount)} KRW\n` + + `체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`, + ); + } setQtyStr(''); setPctMode(null); } catch (e) { @@ -245,8 +261,12 @@ const TradeOrderPanel: React.FC = ({ const minOrder = 5000; const feeRate = 0.05; + const balanceDisplay = isBuy + ? `${buyBudgetKrw > 0 ? fmtKrw(buyBudgetKrw) : '-'} KRW` + : `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`; + return ( -
+
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (

자동매매 OFF · 수동 주문만 모의 계좌에 체결됩니다.

@@ -317,71 +337,70 @@ const TradeOrderPanel: React.FC = ({
-
+
{isBuy ? '주문가능' : '보유수량'} - - {isBuy - ? `${fmtKrw(availableKrw)} KRW` - : `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`} - + {balanceDisplay}
-
-
- -
- - - -
-
- -
- +
+ +
-
- -
- {PCT_BTNS.map(p => ( - - ))} - + +
-
+
+ + +
+ +
+ {PCT_BTNS.map(p => ( + + ))} + +
+ +
= ({
- 조건: 보통 ▾ · - 최소 {fmtKrw(minOrder)} + 최소주문: {fmtKrw(minOrder)} KRW · - 수수료 {feeRate}% + 수수료(부가세 포함): {feeRate}%
diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 179a22b..ef62132 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -15,9 +15,7 @@ import { } from '../utils/backendApi'; import type { Theme, TradeOrderFillRequest } from '../types'; import type { TickerData } from '../hooks/useMarketTicker'; -import TradeOrderPanel from './TradeOrderPanel'; -import { OrderbookPanel } from './OrderbookPanel'; -import PaperSplitPanel from './paper/PaperSplitPanel'; +import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import PaperTradeHistoryList from './paper/PaperTradeHistoryList'; @@ -423,7 +421,7 @@ const VirtualTradingPage: React.FC = ({ leftDefaultWidth={380} leftCollapsedStorageKey="vtd-left-open" rightStorageKey="vtd-right-width" - rightDefaultWidth={380} + rightDefaultWidth={440} rightCollapsedStorageKey="vtd-right-open" left={( = ({ )} rightTabs={rightTabs} right={( -
+ {rightTab === 'trade' ? ( - - void refreshPaperData({ focusHistory: true })} - /> -
- )} - bottom={( -
- void refreshPaperData({ focusHistory: true })} - /> -
- )} + void refreshPaperData({ focusHistory: true })} /> ) : rightTab === 'orderbook' ? ( -
-
-
실시간 호가
-
- -
-
-
+ ) : ( = ({ className="ptd-trade-history--fill" /> )} -
+ )} /> ); diff --git a/frontend/src/components/layout/BuilderPageShell.tsx b/frontend/src/components/layout/BuilderPageShell.tsx index 37243de..c35c0a7 100644 --- a/frontend/src/components/layout/BuilderPageShell.tsx +++ b/frontend/src/components/layout/BuilderPageShell.tsx @@ -377,7 +377,7 @@ export default function BuilderPageShell({
); }; diff --git a/frontend/src/hooks/useVirtualBackendTradeSignals.ts b/frontend/src/hooks/useVirtualBackendTradeSignals.ts index b8ed8cb..410f909 100644 --- a/frontend/src/hooks/useVirtualBackendTradeSignals.ts +++ b/frontend/src/hooks/useVirtualBackendTradeSignals.ts @@ -116,8 +116,8 @@ export function useVirtualBackendTradeSignals({ source: 'STRATEGY', strategyId, }) - .then(trade => { - if (trade) { + .then(result => { + if (result?.trade) { lastExecRef.current[execKey] = Date.now(); onFilled?.(); } diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index 6cde3b8..5de0bf9 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -1,6 +1,11 @@ -/* 모의투자 — 좌측 패널·우측 탭·성과 지표 하단선 정렬 */ +/* 모의투자 — 가상매매(vtd)와 동일 3열·접기 레이아웃 */ -.bps-page .bps-main { +.bps-page--ptd.bps-page--vtd .bps-main { + padding: 0 10px 10px 0; + box-sizing: border-box; +} + +.bps-page--ptd .bps-main { padding: 0 10px 10px 0; box-sizing: border-box; } @@ -107,7 +112,8 @@ overflow: hidden; } .bps-panel-body--tabs > .ptd-split-panel--left, -.bps-right-body > .ptd-right-body > .ptd-split-panel--right { +.bps-right-body > .ptd-right-body > .ptd-split-panel--right, +.trade-right-panel .bps-right-body > .ptd-right-body > .ptd-split-panel--right { flex: 1; min-height: 0; } @@ -246,7 +252,8 @@ margin-top: 4px; } -.bps-right-body .ptd-right-body { +.bps-right-body .ptd-right-body, +.trade-right-panel .bps-right-body .ptd-right-body { flex: 1 1 0; min-height: 0; height: 100%; @@ -257,11 +264,13 @@ padding: 0; } -.bps-right-body .ptd-right-body > .ptd-split-panel { +.bps-right-body .ptd-right-body > .ptd-split-panel, +.trade-right-panel .bps-right-body .ptd-right-body > .ptd-split-panel { gap: 8px; } -.bps-right-body .ptd-right-body > .ptd-split-panel--right { +.bps-right-body .ptd-right-body > .ptd-split-panel--right, +.trade-right-panel .bps-right-body .ptd-right-body > .ptd-split-panel--right { flex: 1 1 0; min-height: 0; height: 100%; @@ -415,6 +424,27 @@ container-name: trade-card; } +/* 우측 매매 패널: container query·압축 숨김 비활성 (tradeRightPanel.css와 함께) */ +.trade-right-panel .ptd-order-card, +.trade-right-panel .ptd-order-card.ptd-order-card--trade-inline { + container-type: normal; + container-name: none; +} + +.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline .top-panel-fields { + flex: 1 1 0; + min-height: 0; + overflow: visible; +} + +.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline > .top-panel { + overflow: visible; +} + +.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline .top-actions { + margin-top: auto; +} + .bps-footer .ptd-metrics-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/frontend/src/styles/tradeNotificationList.css b/frontend/src/styles/tradeNotificationList.css index ffb490b..e062411 100644 --- a/frontend/src/styles/tradeNotificationList.css +++ b/frontend/src/styles/tradeNotificationList.css @@ -934,10 +934,7 @@ min-width: 0; } -.tnl-side-wrap--right .tnl-right-tabs, -.tnl-side-wrap--right .tnl-right-body { - min-width: min(380px, 38vw); -} +/* 우측 패널 너비: styles/tradeRightPanel.css (--gc-trade-right-panel-*) */ .tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-tabs, .tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-body { diff --git a/frontend/src/styles/tradeRightPanel.css b/frontend/src/styles/tradeRightPanel.css new file mode 100644 index 0000000..2944008 --- /dev/null +++ b/frontend/src/styles/tradeRightPanel.css @@ -0,0 +1,389 @@ +/** + * 우측 매매·호가 패널 — 알림목록 · 실시간 차트 · 모의/가상매매 공통 + */ +:root { + --gc-trade-right-panel-width: 440px; + --gc-trade-right-panel-min: 400px; + --gc-trade-right-panel-max: 480px; + --gc-trade-right-panel-pad-x: 12px; + --gc-trade-right-panel-pad-y: 8px; + --gc-trade-row-label-w: 88px; + --gc-trade-row-gap: 14px; + --gc-trade-card-pad: 14px 14px 10px; + --gc-trade-title-body-gap: 14px; + --gc-trade-upbit-accent: #1976d2; + --gc-trade-upbit-input-border: color-mix(in srgb, var(--border) 85%, var(--text3)); +} + +/* 패널 열림 너비 */ +.rsp-wrap--open .rsp-panel.trade-right-panel, +.tnl-page--with-right .tnl-side-wrap--right.tnl-side-wrap--open .tnl-right.trade-right-panel.tnl-right--open { + flex: 0 0 var(--gc-trade-right-panel-width); + width: var(--gc-trade-right-panel-width); + min-width: var(--gc-trade-right-panel-min); + max-width: var(--gc-trade-right-panel-max); + box-sizing: border-box; +} + +.trade-right-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--bg2); +} + +.trade-right-panel .bps-right-tabs { + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--bg3); + min-width: 0; + width: 100%; +} + +.trade-right-panel .bps-right-body, +.trade-right-panel .bps-right-body.rsp-body-wrap { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.trade-right-panel .bps-right-body .ptd-right-body { + flex: 1 1 0; + min-height: 0; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0; + padding: 0; +} + +.trade-right-panel .bps-right-body .ptd-right-body > .ptd-split-panel--right { + flex: 1 1 0; + min-height: 0; + height: 100%; + padding: var(--gc-trade-right-panel-pad-y) var(--gc-trade-right-panel-pad-x) 6px; + box-sizing: border-box; +} + +.trade-right-panel .ptd-ob-stack--fill { + flex: 1; + min-height: 0; + height: 100%; +} + +/* ── 매매 카드: 업비트 매수 탭 레이아웃 ── */ +.trade-right-panel .ptd-split-panel--right .ptd-split-card { + border-radius: 4px; + background: var(--bg2); + box-shadow: none; +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card--top { + border-color: color-mix(in srgb, var(--gc-trade-buy) 35%, var(--border)); + background: var(--bg2); +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card--bottom { + border-color: color-mix(in srgb, var(--gc-trade-sell) 35%, var(--border)); + background: var(--bg2); +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card-head { + display: flex; + align-items: center; + min-height: 42px; + padding: 12px 14px 11px; + font-size: 13px; + font-weight: 700; + border-bottom: 1px solid var(--border); + box-sizing: border-box; +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card--top .ptd-split-card-head { + color: var(--gc-trade-buy); + border-bottom-color: color-mix(in srgb, var(--gc-trade-buy) 30%, var(--border)); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--gc-trade-buy) 24%, var(--bg2)) 0%, + color-mix(in srgb, var(--gc-trade-buy) 10%, var(--bg2)) 50%, + var(--bg2) 100% + ); +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card--bottom .ptd-split-card-head { + color: var(--gc-trade-sell); + border-bottom-color: color-mix(in srgb, var(--gc-trade-sell) 30%, var(--border)); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--gc-trade-sell) 24%, var(--bg2)) 0%, + color-mix(in srgb, var(--gc-trade-sell) 10%, var(--bg2)) 50%, + var(--bg2) 100% + ); +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card-body { + padding: var(--gc-trade-card-pad); +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card-body:has(.ptd-order-card--trade-inline) { + display: flex; + flex-direction: column; + justify-content: stretch; + align-items: stretch; + overflow-x: hidden; + overflow-y: auto; + min-height: 0; +} + +.trade-right-panel .ptd-split-panel--right .ptd-split-card-body > .ptd-order-card--trade-inline { + flex: 1 1 0; + width: 100%; + min-height: 0; + height: 100%; + max-height: 100%; + overflow: visible; +} + +.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline > .top-panel { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + gap: var(--gc-trade-row-gap); + padding: 0 0 4px; + flex: 1 1 0; + min-height: 0; + height: 100%; + max-height: 100%; + overflow: hidden; +} + +.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline .top-panel-fields { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + justify-content: flex-start; + gap: var(--gc-trade-row-gap); + overflow: visible; + padding: var(--gc-trade-title-body-gap) 0 0; +} + +/* 타이틀바 바로 아래 → 주문유형 행까지 추가 간격 (종목 행이 있을 때) */ +.trade-right-panel .ptd-order-card--trade-inline .top-field:has(.top-order-kind) { + margin-top: 4px; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-paper-hint { + margin: 0 0 2px; + padding: 4px 6px; + font-size: 10px; + line-height: 1.3; +} + +/* 업비트형 폼: 라벨 좌 · 값/입력 우 */ +.trade-right-panel .ptd-order-card--trade-inline .top-panel--upbit { + font-size: 12px; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-field, +.trade-right-panel .ptd-order-card--trade-inline .top-row--balance { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin: 0; + flex-shrink: 0; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-field > .top-label, +.trade-right-panel .ptd-order-card--trade-inline .top-row--balance > .top-label { + flex: 0 0 var(--gc-trade-row-label-w); + margin: 0; + padding: 0; + font-size: 12px; + font-weight: 400; + color: var(--text2); + line-height: 1.3; + white-space: nowrap; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-field > :not(.top-label) { + flex: 1 1 0; + min-width: 0; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-row--balance > .top-balance { + flex: 1 1 0; + text-align: right; + font-size: 13px; + font-weight: 700; + color: var(--text); + line-height: 1.25; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-label-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-left: 3px; + border-radius: 50%; + border: 1px solid var(--gc-trade-upbit-input-border); + font-size: 9px; + color: var(--text3); + vertical-align: middle; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-order-kind { + display: flex; + gap: 4px; + width: 100%; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-kind-btn { + flex: 1 1 0; + min-width: 0; + padding: 7px 4px; + font-size: 11px; + border: 1px solid var(--gc-trade-upbit-input-border); + border-radius: 4px; + background: var(--bg); + color: var(--text2); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-kind-btn--active { + border-color: var(--gc-trade-upbit-accent); + color: var(--gc-trade-upbit-accent); + background: color-mix(in srgb, var(--gc-trade-upbit-accent) 8%, var(--bg)); + font-weight: 600; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-input-group { + border: 1px solid var(--gc-trade-upbit-input-border); + border-radius: 4px; + background: var(--bg); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-input, +.trade-right-panel .ptd-order-card--trade-inline .top-input--full { + padding: 8px 10px; + font-size: 13px; + line-height: 1.25; + border: 1px solid var(--gc-trade-upbit-input-border); + border-radius: 4px; + background: var(--bg); + color: var(--text); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-input-group .top-input { + border: none; + border-radius: 0; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-field--price .top-input, +.trade-right-panel .ptd-order-card--trade-inline .top-field--qty .top-input, +.trade-right-panel .ptd-order-card--trade-inline .top-field--total .top-input { + text-align: right; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-input--readonly { + background: var(--bg3); + color: var(--text2); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-symbol-field { + border: 1px solid var(--gc-trade-upbit-input-border); + border-radius: 4px; + background: var(--bg); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-symbol-input { + padding: 8px 10px; + font-size: 13px; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-step { + width: 30px; + font-size: 15px; + background: var(--bg3); + border-left: 1px solid var(--gc-trade-upbit-input-border); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-pct-row--under-qty { + display: flex; + flex-wrap: nowrap; + gap: 4px; + margin: 0 0 0 calc(var(--gc-trade-row-label-w) + 8px); + padding: 0; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-pct-row--under-qty .top-pct-btn { + flex: 1 1 0; + min-width: 0; + margin: 0; + padding: 6px 2px; + font-size: 11px; + border: 1px solid var(--gc-trade-upbit-input-border); + border-radius: 4px; + background: var(--bg); + color: var(--text2); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-pct-row--under-qty .top-pct-btn--active { + border-color: var(--gc-trade-upbit-accent); + color: var(--gc-trade-upbit-accent); + background: color-mix(in srgb, var(--gc-trade-upbit-accent) 8%, var(--bg)); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-meta { + display: flex; + justify-content: flex-end; + margin: 2px 0 0; + padding: 0; + font-size: 11px; + color: var(--text3); +} + +.trade-right-panel .ptd-order-card--trade-inline .top-meta-line { + gap: 2px; +} + +.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline .top-actions { + flex: 0 0 auto; + margin-top: auto; + padding-top: 12px; + border-top: none; + gap: 6px; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-reset { + flex: 0 0 28%; + max-width: 120px; + justify-content: center; + padding: 11px 10px; + font-size: 13px; + font-weight: 600; + border: none; + border-radius: 4px; + background: color-mix(in srgb, var(--text3) 55%, var(--bg3)); + color: #fff; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-submit { + flex: 1 1 0; + padding: 11px 12px; + font-size: 14px; + font-weight: 700; + border: none; + border-radius: 4px; +} + +.trade-right-panel .ptd-order-card--trade-inline .top-paper-hint { + display: block; +} diff --git a/frontend/src/styles/verificationBoard.css b/frontend/src/styles/verificationBoard.css index 515b858..e6b1444 100644 --- a/frontend/src/styles/verificationBoard.css +++ b/frontend/src/styles/verificationBoard.css @@ -713,6 +713,84 @@ gap: 8px; } +.vbd-attachments-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* ── 화면 영역 캡처 오버레이 ── */ +.screen-capture-overlay { + position: fixed; + inset: 0; + z-index: 200000; + cursor: crosshair; + user-select: none; +} + +.screen-capture-video { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; + pointer-events: none; +} + +.screen-capture-interaction { + position: fixed; + inset: 0; + z-index: 1; + touch-action: none; +} + +.screen-capture-selection { + position: fixed; + box-sizing: border-box; + border: 2px solid #fff; + background: transparent; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45); + pointer-events: none; +} + +.screen-capture-toolbar { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 2; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + border-radius: 8px; + background: rgba(20, 22, 28, 0.92); + color: #fff; + font-size: 13px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); + pointer-events: auto; +} + +.screen-capture-hint { + white-space: nowrap; +} + +.screen-capture-cancel { + padding: 4px 12px; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 6px; + background: transparent; + color: #fff; + font-size: 12px; + cursor: pointer; +} + +.screen-capture-cancel:hover { + background: rgba(255, 255, 255, 0.12); +} + .vbd-attachments-title { margin: 0; font-size: 14px; diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index 917f481..56ce146 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -2795,188 +2795,130 @@ opacity: 0.5; } -/* ── 우측 매매 패널: 매수·매도 카드 간격·타이포 ── */ -.bps-page--vtd .ptd-split-panel--right .ptd-split-card-body { - padding: 8px 12px 10px; -} +/* 우측 매매 패널 타이포·레이아웃: styles/tradeRightPanel.css (.trade-right-panel) */ -.bps-page--vtd .ptd-split-panel--right .ptd-order-card > .top-panel { - gap: 0; - justify-content: flex-start; - padding: 6px 6px 0; - height: 100%; +/* ── 모의투자 투자금 관리 (P1) ── */ +.ptd-center-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; } - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { - flex: 1 1 auto; +.ptd-tabs--center { + flex-shrink: 0; +} +.ptd-invest-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + overflow: auto; + min-height: 0; +} +.ptd-invest-metrics { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 8px; + border-top: 1px solid var(--se-border); +} +.bps-page--ptd .ptd-right-history { + flex: 1; min-height: 0; display: flex; flex-direction: column; - justify-content: flex-start; - gap: 12px; - overflow-y: auto; - overflow-x: hidden; - padding-bottom: 4px; + overflow: hidden; } - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-field, -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-row--balance { - margin: 0; +.bps-page--ptd .ptd-tabs--history { flex-shrink: 0; -} - -/* 기본: 공간 부족 시 하단 안내 텍스트 숨김 */ -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta { - display: none; - margin: 0; - padding: 0; - flex-shrink: 0; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-label { - margin-bottom: 7px; - font-size: 12px; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-input, -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-input--full { - padding: 9px 11px; - font-size: 13px; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-symbol-input { - padding: 9px 11px; - font-size: 13px; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-order-kind { + padding: 8px 10px 0; gap: 6px; } - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-kind-btn { - padding: 7px 5px; +.bps-page--ptd .ptd-right-history-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px 10px 10px; +} +.bps-page--ptd .ptd-right-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.ptd-invest-kpi { + display: flex; + flex-direction: column; + gap: 8px; +} +.ptd-invest-kpi-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.ptd-invest-kpi-cell { + padding: 8px 10px; + border-radius: 6px; + background: var(--gc-surface-2, rgba(255,255,255,0.04)); +} +.ptd-invest-kpi-cell--up .ptd-invest-kpi-value { color: var(--gc-trade-buy, #ef5350); } +.ptd-invest-kpi-cell--down .ptd-invest-kpi-value { color: var(--gc-trade-sell, #4dabf7); } +.ptd-invest-kpi-label { + display: block; font-size: 11px; + opacity: 0.7; } - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-row--balance { - padding: 4px 0 2px; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-pct-row { - margin-top: 8px; - gap: 6px; - flex-shrink: 0; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-pct-btn { - padding: 6px 4px; - font-size: 11px; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-step { - width: 32px; +.ptd-invest-kpi-value { font-size: 15px; + font-weight: 600; } - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-balance { - font-size: 13px; -} - -/* paperDashboard 2열 그리드보다 우선 — 항상 세로 스택 */ -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block { - flex: 0 0 auto; +.ptd-alloc-table-wrap { + overflow: auto; + flex: 1; min-height: 0; +} +.ptd-alloc-row--selected { + background: rgba(255, 255, 255, 0.06); +} +.ptd-alloc-toggle { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid rgba(255,255,255,0.15); + background: transparent; + cursor: pointer; +} +.ptd-alloc-toggle--on { + border-color: var(--gc-trade-buy, #ef5350); + color: var(--gc-trade-buy, #ef5350); +} +.ptd-footer-stack { display: flex; flex-direction: column; - justify-content: flex-start; - gap: 12px; - margin: 0; - overflow: visible; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-field, -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-pct-row--block { - margin: 0; - flex-shrink: 0; -} - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-actions { - flex-shrink: 0; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid color-mix(in srgb, var(--border) 65%, transparent); gap: 10px; } - -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-reset, -.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-submit { - padding: 10px 14px; - font-size: 14px; +.ptd-asset-trend { + padding: 8px 12px; + border-radius: 6px; + background: var(--gc-surface-2, rgba(255,255,255,0.04)); } - -/* 카드 높이 여유 있을 때: 간격 확대 + 하단 안내 텍스트 표시 */ -@container trade-card (min-height: 440px) { - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { - justify-content: space-between; - gap: 14px; - } - - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block { - flex: 1 1 auto; - justify-content: space-evenly; - gap: 14px; - } - - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta { - display: flex; - font-size: 11px; - padding-top: 2px; - } +.ptd-asset-trend-head { + display: flex; + justify-content: space-between; + font-size: 12px; + margin-bottom: 4px; } - -@container trade-card (min-height: 520px) { - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { - gap: 18px; - } - - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block { - gap: 16px; - } +.ptd-asset-trend-svg { + width: 100%; + height: 48px; + display: block; } - -/* container query 미지원 fallback */ -@supports not (container-type: size) { - @media (max-height: 820px) { - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta { - display: none; - } - - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { - justify-content: flex-start; - gap: 12px; - } - - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block { - flex: 0 0 auto; - justify-content: flex-start; - } - } - - @media (min-height: 821px) { - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta { - display: flex; - font-size: 11px; - padding-top: 2px; - } - - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { - justify-content: space-between; - gap: 14px; - } - - .bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block { - flex: 1 1 auto; - justify-content: space-evenly; - } - } +.ptd-asset-trend-range { + display: flex; + justify-content: space-between; + font-size: 10px; + opacity: 0.6; } diff --git a/frontend/src/utils/appNavigationPaths.ts b/frontend/src/utils/appNavigationPaths.ts index 457d0d1..84fd047 100644 --- a/frontend/src/utils/appNavigationPaths.ts +++ b/frontend/src/utils/appNavigationPaths.ts @@ -20,7 +20,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [ // ── 상단 메뉴 ── P('메뉴-대시보드', 'dashboard', '홈'), P('메뉴-실시간 차트', 'chart', '실시간차트', '캔들', '차트'), - P('메뉴-가상매매', 'virtual', '가상', '모의'), + P('메뉴-모의투자', 'paper', '모의투자', '투자금', '페이퍼'), + P('메뉴-모의투자-투자금 관리', 'paper', 'allocation', '한도', 'KPI'), + P('메뉴-가상매매', 'virtual', '가상', '가상매매'), P('메뉴-추세검색', 'trend', '추세', '검색'), P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'), P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'), @@ -64,7 +66,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [ P('메뉴-설정-보조지표 설정', 'indicators', '지표기본값', 'Main탭'), P('메뉴-설정-백테스팅', 'bt옵션', '자본'), P('메뉴-설정-전략 설정', 'strategy', '전략기본'), - P('메뉴-설정-가상매매', 'paper', '가상자본'), + P('메뉴-설정-모의투자', 'paper', '모의투자', '모의자본', '수수료'), + P('메뉴-설정-가상매매', 'virtual', '투자대상', '한도'), + P('메뉴-설정-실거래', 'live', '업비트', 'API'), P('메뉴-설정-추세검색', 'trend설정', '배점'), P('메뉴-설정-알림 설정', 'alert설정', '소리', '팝업'), P('메뉴-설정-네트워크', 'network', 'API', 'WebSocket'), diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 9c57b28..06c3e7c 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -610,21 +610,42 @@ export interface PaperPositionDto { profitLossPct?: number; } +export interface PaperAllocationDto { + id?: number; + symbol: string; + koreanName?: string | null; + maxInvestKrw: number; + budgetPct?: number | null; + strategyId?: number | null; + isActive?: boolean; + pinned?: boolean; + sortOrder?: number; + investedKrw?: number; + availableBuyKrw?: number; + evalAmount?: number; + symbolReturnPct?: number; + weightPct?: number; +} + export interface PaperSummaryDto { - enabled: boolean; - initialCapital: number; - cashBalance: number; - stockEvalAmount: number; - totalAsset: number; - unrealizedPnl: number; - realizedPnl: number; - totalReturnPct: number; - feeRatePct: number; - slippagePct: number; - minOrderKrw: number; - autoTradeEnabled: boolean; - autoTradeBudgetPct: number; - positions: PaperPositionDto[]; + enabled: boolean; + initialCapital: number; + cashBalance: number; + stockEvalAmount: number; + totalAsset: number; + unrealizedPnl: number; + realizedPnl: number; + totalReturnPct: number; + feeRatePct: number; + slippagePct: number; + minOrderKrw: number; + autoTradeEnabled: boolean; + autoTradeBudgetPct: number; + reservedCash?: number; + orderableCash?: number; + initialCapitalSnapshot?: number; + positions: PaperPositionDto[]; + allocations?: PaperAllocationDto[]; } export interface PaperTradeDto { @@ -640,13 +661,79 @@ export interface PaperTradeDto { feeAmount: number; netAmount: number; cashAfter: number; + realizedPnlDelta?: number | null; + positionQtyAfter?: number | null; createdAt?: string | null; } +export interface PaperPageDto { + content: T[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface PaperLedgerEntryDto { + id: number; + entryType: string; + amount: number; + cashBefore: number; + cashAfter: number; + refTradeId?: number | null; + symbol?: string | null; + createdAt?: string | null; +} + +export interface PaperDailySnapshotDto { + snapshotDate: string; + cashBalance: number; + stockEvalAmount: number; + totalAsset: number; + dailyPnl: number; + dailyReturnPct: number; + cumulativeReturnPct: number; +} + +export interface PaperOrderDto { + id: number; + symbol: string; + side: 'BUY' | 'SELL'; + orderType: string; + limitPrice?: number | null; + quantity: number; + filledQuantity: number; + status: string; + reservedCash: number; + reservedQty: number; + orderKind: string; + source: string; + strategyId?: number | null; + createdAt?: string | null; + updatedAt?: string | null; +} + +export interface PaperPlaceOrderResult { + trade?: PaperTradeDto | null; + order?: PaperOrderDto | null; +} + +export interface PaperAllocationBulkItem { + symbol: string; + koreanName?: string; + maxInvestKrw?: number; + budgetPct?: number; + strategyId?: number | null; + isActive?: boolean; + pinned?: boolean; + sortOrder?: number; +} + export interface PaperOrderRequest { market: string; side: 'BUY' | 'SELL'; orderKind?: 'limit' | 'market'; + orderType?: 'MARKET' | 'LIMIT'; price: number; quantity: number; /** 수량 0일 때 매수 예산 비율 (가용현금 %) */ @@ -671,13 +758,90 @@ export async function loadPaperTrades(): Promise { return (await request('/paper/trades')) ?? []; } -export async function placePaperOrder(body: PaperOrderRequest): Promise { - return request('/paper/orders', { +export async function loadPaperTradesPaged(params?: { + symbol?: string; + side?: string; + source?: string; + from?: string; + to?: string; + page?: number; + size?: number; +}): Promise> { + const q = new URLSearchParams(); + if (params?.symbol) q.set('symbol', params.symbol); + if (params?.side) q.set('side', params.side); + if (params?.source) q.set('source', params.source); + if (params?.from) q.set('from', params.from); + if (params?.to) q.set('to', params.to); + if (params?.page != null) q.set('page', String(params.page)); + if (params?.size != null) q.set('size', String(params.size)); + const qs = q.toString(); + return (await request>(`/paper/trades${qs ? `?${qs}` : ''}`)) ?? { + content: [], page: 0, size: 50, totalElements: 0, totalPages: 0, + }; +} + +export async function loadPaperAllocations(): Promise { + return (await request('/paper/allocations')) ?? []; +} + +export async function putPaperAllocationsBulk(items: PaperAllocationBulkItem[]): Promise { + return (await request('/paper/allocations/bulk', { + method: 'PUT', + body: JSON.stringify({ items }), + })) ?? []; +} + +export async function patchPaperAllocation( + symbol: string, + body: Partial>, +): Promise { + return request(`/paper/allocations/${encodeURIComponent(symbol)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export async function loadPaperLedger(params?: { + from?: string; + to?: string; + page?: number; + size?: number; +}): Promise> { + const q = new URLSearchParams(); + if (params?.from) q.set('from', params.from); + if (params?.to) q.set('to', params.to); + if (params?.page != null) q.set('page', String(params.page)); + if (params?.size != null) q.set('size', String(params.size)); + const qs = q.toString(); + return (await request>(`/paper/ledger${qs ? `?${qs}` : ''}`)) ?? { + content: [], page: 0, size: 50, totalElements: 0, totalPages: 0, + }; +} + +export async function loadPaperDailySnapshots(from?: string, to?: string): Promise { + const q = new URLSearchParams(); + if (from) q.set('from', from); + if (to) q.set('to', to); + const qs = q.toString(); + return (await request(`/paper/snapshots/daily${qs ? `?${qs}` : ''}`)) ?? []; +} + +export async function loadPaperPendingOrders(): Promise { + return (await request('/paper/orders')) ?? []; +} + +export async function placePaperOrder(body: PaperOrderRequest): Promise { + return request('/paper/orders', { method: 'POST', body: JSON.stringify(body), }); } +export async function cancelPaperOrder(orderId: number): Promise { + return request(`/paper/orders/${orderId}/cancel`, { method: 'POST' }); +} + export async function resetPaperAccount(): Promise { return request('/paper/reset', { method: 'POST' }); } diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts index c00d0be..6e87d69 100644 --- a/frontend/src/utils/permissions.ts +++ b/frontend/src/utils/permissions.ts @@ -9,14 +9,15 @@ export type TopMenuId = MenuPage; export type SettingsCategoryId = | 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy' - | 'paper' | 'trend-search' | 'alert' | 'network' | 'admin'; + | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin'; export const TOP_MENU_IDS: TopMenuId[] = [ - 'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board', + 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board', ]; export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ - 'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'trend-search', 'alert', 'network', 'admin', + 'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'virtual', 'live', + 'trend-search', 'alert', 'network', 'admin', ]; export const ALL_MENU_IDS = [ @@ -43,7 +44,9 @@ export const MENU_LABELS: Record = { settings_indicators: '설정 · 보조지표', settings_backtest: '설정 · 백테스팅', settings_strategy: '설정 · 전략', - settings_paper: '설정 · 가상매매', + settings_paper: '설정 · 모의투자', + settings_virtual: '설정 · 가상매매', + settings_live: '설정 · 실거래', 'settings_trend-search': '설정 · 추세검색', settings_alert: '설정 · 알림', settings_network: '설정 · 네트워크', @@ -69,7 +72,6 @@ export function canAccessMenu( permissions: Record | null | undefined, menuId: string, ): boolean { - if (menuId === 'paper') return false; if (menuId === 'strategy') return false; if (!permissions) return menuId === 'chart'; if (menuId === 'strategy-editor') { diff --git a/frontend/src/utils/screenRegionCapture.ts b/frontend/src/utils/screenRegionCapture.ts new file mode 100644 index 0000000..11af307 --- /dev/null +++ b/frontend/src/utils/screenRegionCapture.ts @@ -0,0 +1,93 @@ +/** 화면 캡처 영역 → 업로드용 PNG File */ + +export interface ScreenCaptureRect { + left: number; + top: number; + width: number; + height: number; +} + +export function clientRectToVideoPixels( + rect: ScreenCaptureRect, + videoEl: HTMLVideoElement, +): ScreenCaptureRect | null { + const bounds = videoEl.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) return null; + const vw = videoEl.videoWidth; + const vh = videoEl.videoHeight; + if (vw <= 0 || vh <= 0) return null; + + const toVidX = (cx: number) => ((cx - bounds.left) / bounds.width) * vw; + const toVidY = (cy: number) => ((cy - bounds.top) / bounds.height) * vh; + + const x1 = Math.max(0, Math.min(vw, Math.round(toVidX(rect.left)))); + const y1 = Math.max(0, Math.min(vh, Math.round(toVidY(rect.top)))); + const x2 = Math.max(0, Math.min(vw, Math.round(toVidX(rect.left + rect.width)))); + const y2 = Math.max(0, Math.min(vh, Math.round(toVidY(rect.top + rect.height)))); + + const width = x2 - x1; + const height = y2 - y1; + if (width < 2 || height < 2) return null; + + return { left: x1, top: y1, width, height }; +} + +export async function cropVideoFrameToFile( + videoEl: HTMLVideoElement, + rect: ScreenCaptureRect, +): Promise { + const canvas = document.createElement('canvas'); + canvas.width = rect.width; + canvas.height = rect.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('CANVAS_FAILED'); + + ctx.drawImage( + videoEl, + rect.left, + rect.top, + rect.width, + rect.height, + 0, + 0, + rect.width, + rect.height, + ); + + const blob = await new Promise(resolve => { + canvas.toBlob(resolve, 'image/png'); + }); + if (!blob) throw new Error('BLOB_FAILED'); + + return new File([blob], `screen-capture-${Date.now()}.png`, { + type: 'image/png', + lastModified: Date.now(), + }); +} + +export async function requestDisplayCaptureStream(): Promise { + if (!navigator.mediaDevices?.getDisplayMedia) { + throw new Error('DISPLAY_CAPTURE_UNSUPPORTED'); + } + return navigator.mediaDevices.getDisplayMedia({ + video: { displaySurface: 'browser' } as MediaTrackConstraints, + audio: false, + }); +} + +export function stopMediaStream(stream: MediaStream | null): void { + stream?.getTracks().forEach(t => t.stop()); +} + +export function captureErrorMessage(e: unknown): string { + if (e instanceof Error) { + if (e.name === 'NotAllowedError' || e.message === 'DISPLAY_CAPTURE_UNSUPPORTED') { + return '화면 캡처 권한이 거부되었거나 이 브라우저에서 지원하지 않습니다.'; + } + if (e.message === 'REGION_TOO_SMALL') { + return '선택 영역이 너무 작습니다. 다시 드래그해 주세요.'; + } + if (e.message) return e.message; + } + return '화면 캡처에 실패했습니다.'; +} diff --git a/frontend/src/utils/virtualLiveStrategySync.ts b/frontend/src/utils/virtualLiveStrategySync.ts index 0106d92..4e9ada9 100644 --- a/frontend/src/utils/virtualLiveStrategySync.ts +++ b/frontend/src/utils/virtualLiveStrategySync.ts @@ -1,4 +1,4 @@ -import { saveLiveStrategySettings } from './backendApi'; +import { putPaperAllocationsBulk, saveLiveStrategySettings } from './backendApi'; import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin'; import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage'; import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy'; @@ -47,6 +47,21 @@ export async function syncVirtualTargetsToBackend( }), ), ); + + try { + await putPaperAllocationsBulk( + targets.map((t, i) => ({ + symbol: t.market, + koreanName: t.koreanName, + strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId), + pinned: !!t.pinned, + sortOrder: i, + isActive: true, + })), + ); + } catch { + /* paper allocation sync best-effort */ + } } /** 가상투자 중지 — 대상 종목 체크 해제 + 전역 liveStrategyCheck OFF */ diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index c3d249c..5d04458 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -674,6 +674,14 @@ export interface PaperPositionDto { profitLossPct?: number; } +export interface PaperAllocationDto { + symbol: string; + maxInvestKrw: number; + availableBuyKrw?: number; + strategyId?: number | null; + isActive?: boolean; +} + export interface PaperSummaryDto { enabled: boolean; initialCapital: number; @@ -688,7 +696,11 @@ export interface PaperSummaryDto { minOrderKrw: number; autoTradeEnabled: boolean; autoTradeBudgetPct: number; + orderableCash?: number; + reservedCash?: number; + initialCapitalSnapshot?: number; positions: PaperPositionDto[]; + allocations?: PaperAllocationDto[]; } export interface PaperTradeDto { @@ -704,13 +716,20 @@ export interface PaperTradeDto { feeAmount: number; netAmount: number; cashAfter: number; + realizedPnlDelta?: number | null; createdAt?: string | null; } +export interface PaperPlaceOrderResult { + trade?: PaperTradeDto | null; + order?: { id: number; symbol: string; side: string; status: string } | null; +} + export interface PaperOrderRequest { market: string; side: 'BUY' | 'SELL'; orderKind?: 'limit' | 'market'; + orderType?: 'MARKET' | 'LIMIT'; price: number; quantity: number; /** 수량 0일 때 매수 예산 비율 (가용현금 %) */ @@ -735,8 +754,8 @@ export async function loadPaperTrades(): Promise { return (await request('/paper/trades')) ?? []; } -export async function placePaperOrder(body: PaperOrderRequest): Promise { - return request('/paper/orders', { +export async function placePaperOrder(body: PaperOrderRequest): Promise { + return request('/paper/orders', { method: 'POST', body: JSON.stringify(body), });