모의투자 관리 기능 추가

This commit is contained in:
Macbook
2026-05-31 03:47:07 +09:00
parent 9d7dddfa57
commit 16d0e2c226
78 changed files with 4688 additions and 972 deletions
@@ -9,7 +9,8 @@ public final class MenuIds {
public static final List<String> ALL = List.of( public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "notifications", "settings", "dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest", "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" "settings_alert", "settings_network", "settings_admin"
); );
} }
@@ -1,13 +1,14 @@
package com.goldenchart.controller; package com.goldenchart.controller;
import com.goldenchart.dto.PaperOrderRequest; import com.goldenchart.dto.*;
import com.goldenchart.dto.PaperSummaryDto;
import com.goldenchart.dto.PaperTradeDto;
import com.goldenchart.service.PaperTradingService; import com.goldenchart.service.PaperTradingService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -39,18 +40,81 @@ public class PaperTradingController {
return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), markPrices)); return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), markPrices));
} }
@GetMapping("/allocations")
public ResponseEntity<List<PaperAllocationDto>> allocations(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listAllocations(userId(h), deviceId(h), null));
}
@PutMapping("/allocations/bulk")
public ResponseEntity<List<PaperAllocationDto>> bulkAllocations(
@RequestHeader Map<String, String> h,
@RequestBody PaperAllocationBulkRequest body) {
return ResponseEntity.ok(paperTradingService.bulkAllocations(userId(h), deviceId(h), body));
}
@PatchMapping("/allocations/{symbol}")
public ResponseEntity<PaperAllocationDto> patchAllocation(
@RequestHeader Map<String, String> h,
@PathVariable String symbol,
@RequestBody PaperAllocationPatchRequest body) {
return ResponseEntity.ok(paperTradingService.patchAllocation(userId(h), deviceId(h), symbol, body));
}
@GetMapping("/trades") @GetMapping("/trades")
public ResponseEntity<List<PaperTradeDto>> trades(@RequestHeader Map<String, String> h) { public ResponseEntity<?> trades(
return ResponseEntity.ok(paperTradingService.listTrades(userId(h), deviceId(h))); @RequestHeader Map<String, String> 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<PaperPageDto<PaperLedgerEntryDto>> ledger(
@RequestHeader Map<String, String> 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<List<PaperDailySnapshotDto>> dailySnapshots(
@RequestHeader Map<String, String> 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<List<PaperOrderDto>> pendingOrders(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listPendingOrders(userId(h), deviceId(h)));
} }
@PostMapping("/orders") @PostMapping("/orders")
public ResponseEntity<PaperTradeDto> placeOrder( public ResponseEntity<PaperPlaceOrderResult> placeOrder(
@RequestHeader Map<String, String> h, @RequestHeader Map<String, String> h,
@RequestBody PaperOrderRequest body) { @RequestBody PaperOrderRequest body) {
return ResponseEntity.ok(paperTradingService.placeOrder(userId(h), deviceId(h), body)); return ResponseEntity.ok(paperTradingService.placeOrder(userId(h), deviceId(h), body));
} }
@PostMapping("/orders/{id}/cancel")
public ResponseEntity<PaperOrderDto> cancelOrder(
@RequestHeader Map<String, String> h,
@PathVariable Long id) {
return ResponseEntity.ok(paperTradingService.cancelOrder(userId(h), deviceId(h), id));
}
@PostMapping("/reset") @PostMapping("/reset")
public ResponseEntity<PaperSummaryDto> reset(@RequestHeader Map<String, String> h) { public ResponseEntity<PaperSummaryDto> reset(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.resetAccount(userId(h), deviceId(h))); return ResponseEntity.ok(paperTradingService.resetAccount(userId(h), deviceId(h)));
@@ -0,0 +1,22 @@
package com.goldenchart.dto;
import lombok.Data;
import java.util.List;
@Data
public class PaperAllocationBulkRequest {
private List<PaperAllocationItem> 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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -7,6 +7,8 @@ public class PaperOrderRequest {
private String market; private String market;
private String side; // BUY | SELL private String side; // BUY | SELL
private String orderKind; // limit | market private String orderKind; // limit | market
/** MARKET | LIMIT — 미지정 시 orderKind 기준 (limit→LIMIT, market→MARKET) */
private String orderType;
private Double price; private Double price;
private Double quantity; private Double quantity;
/** 수량 미입력 시 매수 예산 비율 (가용현금 %) — 기본값: 설정의 paperAutoTradeBudgetPct */ /** 수량 미입력 시 매수 예산 비율 (가용현금 %) — 기본값: 설정의 paperAutoTradeBudgetPct */
@@ -0,0 +1,16 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class PaperPageDto<T> {
private List<T> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
}
@@ -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;
}
@@ -21,5 +21,9 @@ public class PaperSummaryDto {
private Double minOrderKrw; private Double minOrderKrw;
private Boolean autoTradeEnabled; private Boolean autoTradeEnabled;
private Double autoTradeBudgetPct; private Double autoTradeBudgetPct;
private Double reservedCash;
private Double orderableCash;
private Double initialCapitalSnapshot;
private List<PaperPositionDto> positions; private List<PaperPositionDto> positions;
private List<PaperAllocationDto> allocations;
} }
@@ -18,5 +18,7 @@ public class PaperTradeDto {
private Double feeAmount; private Double feeAmount;
private Double netAmount; private Double netAmount;
private Double cashAfter; private Double cashAfter;
private Double realizedPnlDelta;
private Double positionQtyAfter;
private String createdAt; private String createdAt;
} }
@@ -29,6 +29,24 @@ public class GcPaperAccount {
@Builder.Default @Builder.Default
private BigDecimal realizedPnl = BigDecimal.ZERO; 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) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -54,6 +54,15 @@ public class GcPaperTrade {
@Column(name = "cash_after", precision = 20, scale = 2, nullable = false) @Column(name = "cash_after", precision = 20, scale = 2, nullable = false)
private BigDecimal cashAfter; 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) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -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<GcPaperCashLedger, Long> {
Page<GcPaperCashLedger> findByAccountIdAndCreatedAtBetweenOrderByCreatedAtDesc(
Long accountId, LocalDateTime from, LocalDateTime to, Pageable pageable);
Page<GcPaperCashLedger> findByAccountIdOrderByCreatedAtDesc(Long accountId, Pageable pageable);
}
@@ -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<GcPaperDailySnapshot, Long> {
List<GcPaperDailySnapshot> findByAccountIdAndSnapshotDateBetweenOrderBySnapshotDateAsc(
Long accountId, LocalDate from, LocalDate to);
Optional<GcPaperDailySnapshot> findByAccountIdAndSnapshotDate(Long accountId, LocalDate date);
}
@@ -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<GcPaperOrder, Long> {
List<GcPaperOrder> findByAccountIdAndStatusOrderByCreatedAtDesc(Long accountId, String status);
List<GcPaperOrder> findByStatusAndSymbol(String status, String symbol);
List<GcPaperOrder> findByStatus(String status);
}
@@ -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<GcPaperResetLog, Long> {
}
@@ -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<GcPaperSymbolAllocation, Long> {
List<GcPaperSymbolAllocation> findByAccountIdOrderBySortOrderAscSymbolAsc(Long accountId);
Optional<GcPaperSymbolAllocation> findByAccountIdAndSymbol(Long accountId, String symbol);
void deleteByAccountId(Long accountId);
}
@@ -1,10 +1,33 @@
package com.goldenchart.repository; package com.goldenchart.repository;
import com.goldenchart.entity.GcPaperTrade; 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.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
public interface GcPaperTradeRepository extends JpaRepository<GcPaperTrade, Long> { public interface GcPaperTradeRepository extends JpaRepository<GcPaperTrade, Long> {
List<GcPaperTrade> findTop100ByAccountIdOrderByCreatedAtDesc(Long accountId); List<GcPaperTrade> 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<GcPaperTrade> 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);
} }
@@ -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();
}
}
@@ -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<PaperAllocationDto> listWithMetrics(Long accountId, Map<String, Double> markPrices,
double totalAsset) {
List<GcPaperSymbolAllocation> rows = allocRepo.findByAccountIdOrderBySortOrderAscSymbolAsc(accountId);
List<PaperAllocationDto> out = new ArrayList<>();
for (GcPaperSymbolAllocation a : rows) {
out.add(toDto(a, accountId, markPrices, totalAsset));
}
return out;
}
public List<PaperAllocationDto> 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<String, Double> markPrices) {
Optional<GcPaperPosition> 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<GcPaperPosition> 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<String, Double> 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<GcPaperPosition> 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();
}
}
@@ -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<PaperLedgerEntryDto> 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<GcPaperCashLedger> 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.<PaperLedgerEntryDto>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();
}
}
@@ -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<GcPaperOrder> 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<String> 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) {
}
}
}
}
@@ -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<PaperDailySnapshotDto> 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<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
Map<String, Double> 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();
}
}
@@ -1,46 +1,56 @@
package com.goldenchart.service; package com.goldenchart.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.*; import com.goldenchart.dto.*;
import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.*;
import com.goldenchart.entity.GcPaperAccount; import com.goldenchart.repository.*;
import com.goldenchart.entity.GcPaperPosition; import com.goldenchart.storage.Ta4jStorage;
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 lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 모의투자(페이퍼 트레이딩) 체결·잔고·포지션 관리.
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@Transactional @Transactional
public class PaperTradingService { public class PaperTradingService {
private static final int SCALE_QTY = 12; private static final int SCALE_QTY = 12;
private static final int SCALE_KRW = 2; private static final int SCALE_KRW = 2;
private static final RoundingMode RM = RoundingMode.HALF_UP; private static final RoundingMode RM = RoundingMode.HALF_UP;
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private final GcPaperAccountRepository accountRepo; private final GcPaperAccountRepository accountRepo;
private final GcPaperPositionRepository positionRepo; private final GcPaperPositionRepository positionRepo;
private final GcPaperTradeRepository tradeRepo; private final GcPaperTradeRepository tradeRepo;
private final AppSettingsService appSettingsService; private final GcPaperOrderRepository orderRepo;
private final GcPaperResetLogRepository resetLogRepo;
private final AppSettingsService appSettingsService;
private final GcLiveStrategySettingsRepository liveSettingsRepo; 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<String, Double> markPrices) { public PaperSummaryDto getSummary(Long userId, String deviceId, Map<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
@@ -74,15 +84,26 @@ public class PaperTradingService {
} }
double cash = account.getCashBalance().doubleValue(); double cash = account.getCashBalance().doubleValue();
double initial = app.getPaperInitialCapital() != null double reserved = account.getReservedCash() != null
? app.getPaperInitialCapital().doubleValue() : 10_000_000; ? 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 total = cash + stockEval;
double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0; double returnPct = initialSnap > 0 ? (total - initialSnap) / initialSnap * 100 : 0;
List<PaperAllocationDto> allocations = allocationService.listWithMetrics(
account.getId(), markPrices, total);
return PaperSummaryDto.builder() return PaperSummaryDto.builder()
.enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled())) .enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled()))
.initialCapital(initial) .initialCapital(initialSnap)
.cashBalance(cash) .cashBalance(cash)
.reservedCash(reserved)
.orderableCash(orderable)
.initialCapitalSnapshot(initialSnap)
.stockEvalAmount(stockEval) .stockEvalAmount(stockEval)
.totalAsset(total) .totalAsset(total)
.unrealizedPnl(unrealized) .unrealizedPnl(unrealized)
@@ -95,22 +116,90 @@ public class PaperTradingService {
.autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null .autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95) ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95)
.positions(posDtos) .positions(posDtos)
.allocations(allocations)
.build(); .build();
} }
public List<PaperTradeDto> listTrades(Long userId, String deviceId) { @Transactional(readOnly = true)
public List<PaperAllocationDto> listAllocations(Long userId, String deviceId,
Map<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId()) PaperSummaryDto s = getSummary(userId, deviceId, markPrices);
.stream().map(this::toTradeDto).toList(); return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset());
}
public List<PaperAllocationDto> 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<PaperTradeDto> 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<GcPaperTrade> result = tradeRepo.findFiltered(
account.getId(), symbol, side, source, from, to, pr);
return PaperPageDto.<PaperTradeDto>builder()
.content(result.getContent().stream().map(this::toTradeDto).toList())
.page(result.getNumber())
.size(result.getSize())
.totalElements(result.getTotalElements())
.totalPages(result.getTotalPages())
.build();
}
public List<PaperTradeDto> listTradesLegacy(Long userId, String deviceId) {
return listTrades(userId, deviceId, null, null, null, null, null, 0, 100).getContent();
}
@Transactional(readOnly = true)
public PaperPageDto<PaperLedgerEntryDto> 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<PaperDailySnapshotDto> 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<PaperOrderDto> 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); GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) { if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요."); throw new IllegalStateException("모의투자가 비활성화되어 있습니다.");
} }
if (req.getMarket() == null || req.getSide() == null) { if (req.getMarket() == null || req.getSide() == null) {
throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다."); throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다.");
@@ -124,11 +213,13 @@ public class PaperTradingService {
throw new IllegalArgumentException("가격은 0보다 커야 합니다."); throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
} }
String orderType = resolveOrderType(req);
String source = req.getSource() != null ? req.getSource() : "MANUAL"; String source = req.getSource() != null ? req.getSource() : "MANUAL";
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit"; String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double qty = req.getQuantity() != null ? req.getQuantity() : 0; double qty = req.getQuantity() != null ? req.getQuantity() : 0;
if (qty <= 0) { if (qty <= 0) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
qty = resolveAutoQuantity(account, app, req.getMarket(), side, price, qty = resolveAutoQuantity(account, app, req.getMarket(), side, price,
req.getBudgetPct(), req.getKrwAmount()); req.getBudgetPct(), req.getKrwAmount());
} }
@@ -136,14 +227,34 @@ public class PaperTradingService {
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다."); throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
} }
return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source, if ("LIMIT".equals(orderType)) {
req.getStrategyId(), price, qty, null); 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, public void tryExecuteOnSignal(String deviceId, Long userId, String market,
Long strategyId, String signalType, double price) { Long strategyId, String signalType, double price) {
if (deviceId == null || deviceId.isBlank()) return; if (deviceId == null || deviceId.isBlank()) return;
@@ -166,91 +277,200 @@ public class PaperTradingService {
if ("BUY".equals(signalType)) { if ("BUY".equals(signalType)) {
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
double cash = account.getCashBalance().doubleValue(); 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 execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0); double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget < minOrder) { if (denom <= 0 || budget < minOrder) return;
log.debug("[Paper] auto BUY skip: budget={} min={}", budget, minOrder);
return;
}
double qty = budget / denom; double qty = budget / denom;
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY", 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); log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
} else { } else {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null); .orElse(null);
if (pos == null || pos.getQuantity().doubleValue() <= 0) { if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
log.debug("[Paper] auto SELL skip: no position {}", market); double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
return; if (avail <= 0) return;
}
double qty = pos.getQuantity().doubleValue();
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY", executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY",
strategyId, price, qty, null); strategyId, price, avail, null, true);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price); log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage()); 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) { public PaperSummaryDto resetAccount(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
BigDecimal initial = app.getPaperInitialCapital() != null BigDecimal initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
BigDecimal cashBefore = account.getCashBalance();
String positionsJson = null;
try {
List<GcPaperPosition> 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.setCashBalance(initial);
account.setRealizedPnl(BigDecimal.ZERO); 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); accountRepo.save(account);
allocationService.seedFromPositions(account.getId(), initial);
log.info("[Paper] account reset device={} capital={}", deviceId, initial); log.info("[Paper] account reset device={} capital={}", deviceId, initial);
return getSummary(userId, deviceId, null); return getSummary(userId, deviceId, null);
} }
/** // ── 내부 ─────────────────────────────────────────────────────────────────
* 수량 미입력(0) 시 자동매매와 동일한 방식으로 수량 산출.
* 매수: 가용현금 × budgetPct(또는 krwAmount) / (체결가 × (1+수수료)) private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app,
* 매도: 보유수량 × budgetPct String market, String side, String orderKind,
*/ String source, Long strategyId,
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app, double limitPrice, double qty) {
String market, String side,
double price, Double budgetPct, Double krwAmount) {
double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0); 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)) { if ("BUY".equals(side)) {
double cash = account.getCashBalance().doubleValue(); BigDecimal need = gross.add(fee);
double budget = krwAmount != null && krwAmount > 0 allocationService.validateBuy(account, app, market, need, false);
? krwAmount account.setReservedCash(account.getReservedCash().add(need));
: cash * pctVal / 100.0; accountRepo.save(account);
double execPrice = price * (1 + slip / 100.0); return orderRepo.save(GcPaperOrder.builder()
double denom = execPrice * (1 + feeRate / 100.0); .accountId(account.getId())
if (denom <= 0 || budget <= 0) { .symbol(market)
throw new IllegalStateException("주문 가능 금액이 부족합니다."); .side("BUY")
} .orderType("LIMIT")
return budget / denom; .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) GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
double held = pos.getQuantity().doubleValue(); double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (held <= 0) { if (qty > avail + 1e-12) {
throw new IllegalStateException("보유 수량이 없습니다."); 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, private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app,
String market, String side, String orderKind, String source, String market, String side, String orderKind, String source,
Long strategyId, double inputPrice, double inputQty, Long strategyId, double inputPrice, double inputQty,
String koreanName) { String koreanName, boolean autoTrade) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0); double slip = pct(app.getPaperSlippagePct(), 0);
@@ -271,13 +491,14 @@ public class PaperTradingService {
if ("BUY".equals(side)) { if ("BUY".equals(side)) {
BigDecimal totalCost = gross.add(fee); BigDecimal totalCost = gross.add(fee);
if (account.getCashBalance().compareTo(totalCost) < 0) { allocationService.validateBuy(account, app, market, totalCost, autoTrade);
throw new IllegalStateException("주문가능 현금이 부족합니다.");
}
account.setCashBalance(account.getCashBalance().subtract(totalCost)); account.setCashBalance(account.getCashBalance().subtract(totalCost));
upsertPositionBuy(account, market, koreanName, qty, price); upsertPositionBuy(account, market, koreanName, qty, price);
accountRepo.save(account); 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() GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId()) .accountId(account.getId())
.symbol(market) .symbol(market)
@@ -291,15 +512,17 @@ public class PaperTradingService {
.feeAmount(fee) .feeAmount(fee)
.netAmount(totalCost.negate()) .netAmount(totalCost.negate())
.cashAfter(account.getCashBalance()) .cashAfter(account.getCashBalance())
.positionQtyAfter(qtyAfter)
.build()); .build());
ledgerService.recordBuySettle(account, trade, totalCost);
return toTradeDto(trade); return toTradeDto(trade);
} }
// SELL
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
if (pos.getQuantity().compareTo(qty) < 0) { double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다."); if (qty.doubleValue() > avail + 1e-12) {
throw new IllegalStateException("매도 수량이 보유(가용) 수량을 초과합니다.");
} }
BigDecimal netProceeds = gross.subtract(fee); BigDecimal netProceeds = gross.subtract(fee);
@@ -317,6 +540,9 @@ public class PaperTradingService {
} }
accountRepo.save(account); 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() GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId()) .accountId(account.getId())
.symbol(market) .symbol(market)
@@ -330,14 +556,52 @@ public class PaperTradingService {
.feeAmount(fee) .feeAmount(fee)
.netAmount(netProceeds) .netAmount(netProceeds)
.cashAfter(account.getCashBalance()) .cashAfter(account.getCashBalance())
.realizedPnlDelta(realizedDelta)
.positionQtyAfter(qtyAfter)
.build()); .build());
ledgerService.recordSellSettle(account, trade, netProceeds);
return toTradeDto(trade); 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, private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
BigDecimal qty, BigDecimal price) { BigDecimal qty, BigDecimal price) {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
.orElse(null);
if (pos == null) { if (pos == null) {
positionRepo.save(GcPaperPosition.builder() positionRepo.save(GcPaperPosition.builder()
.accountId(account.getId()) .accountId(account.getId())
@@ -346,6 +610,9 @@ public class PaperTradingService {
.quantity(qty) .quantity(qty)
.avgPrice(price) .avgPrice(price)
.build()); .build());
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot() : BigDecimal.valueOf(10_000_000);
allocationService.getOrDefault(account.getId(), market, defaultMax);
return; return;
} }
BigDecimal totalQty = pos.getQuantity().add(qty); BigDecimal totalQty = pos.getQuantity().add(qty);
@@ -367,8 +634,14 @@ public class PaperTradingService {
.deviceId(dev) .deviceId(dev)
.cashBalance(initial) .cashBalance(initial)
.realizedPnl(BigDecimal.ZERO) .realizedPnl(BigDecimal.ZERO)
.reservedCash(BigDecimal.ZERO)
.initialCapitalSnapshot(initial)
.resetCount(0)
.status("ACTIVE")
.build(); .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()) .feeAmount(t.getFeeAmount().doubleValue())
.netAmount(t.getNetAmount().doubleValue()) .netAmount(t.getNetAmount().doubleValue())
.cashAfter(t.getCashAfter().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) .createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
.build(); .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();
}
} }
@@ -37,6 +37,7 @@ public class BarBuilder {
private final Ta4jStorage ta4jStorage; private final Ta4jStorage ta4jStorage;
private final TradingWebSocketBroker broker; private final TradingWebSocketBroker broker;
private final BarCloseStrategyEvaluationService barCloseEvaluation; private final BarCloseStrategyEvaluationService barCloseEvaluation;
private final com.goldenchart.service.PaperOrderMatcherService paperOrderMatcher;
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */ /** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 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 로 즉시 발행 (실시간 틱 렌더링용) // 현재 1분봉 상태를 STOMP 로 즉시 발행 (실시간 틱 렌더링용)
publishCurrentBar(market); publishCurrentBar(market);
paperOrderMatcher.onPriceTick(market, tradePrice);
} }
// ── 내부 처리 ────────────────────────────────────────────────────────────── // ── 내부 처리 ──────────────────────────────────────────────────────────────
@@ -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);
@@ -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='모의투자 미체결 주문';
@@ -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';
@@ -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));
}
}
+17
View File
@@ -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 제약이 없습니다. 참조 무결성은 애플리케이션 로직에 의존합니다. 1. **JPA FK vs 논리 참조**: `users`, `strategy_rule` 등을 참조하는 컬럼 다수가 JPA `@ManyToOne` 없이 Long ID만 보관하므로 DB 레벨 FK 제약이 없습니다. 참조 무결성은 애플리케이션 로직에 의존합니다.
@@ -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 순으로 확장하는 것을 권장합니다.
+65 -50
View File
@@ -376,6 +376,52 @@ html.theme-blue {
.tv-sym-btn:hover { background: var(--bg3); } .tv-sym-btn:hover { background: var(--bg3); }
.tv-sym-btn svg { color: var(--text2); flex-shrink: 0; } .tv-sym-btn svg { color: var(--text2); flex-shrink: 0; }
.tv-sym-text { color: var(--text); } .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 { .tv-sym-input {
font-size: 14px; font-size: 14px;
@@ -9908,17 +9954,15 @@ html.theme-blue {
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1); transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
} }
.rsp-wrap--open .rsp-panel { /* trade-right-panel 너비·패딩: styles/tradeRightPanel.css */
width: 420px;
}
/* ── 탭 헤더 ── */ /* ── 탭 헤더 (레거시 rsp-tab) ── */
.rsp-tabs { .rsp-tabs {
display: flex; display: flex;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
background: var(--bg3); background: var(--bg3);
min-width: 420px; min-width: 0;
} }
.rsp-tab { .rsp-tab {
flex: 1; flex: 1;
@@ -9939,43 +9983,22 @@ html.theme-blue {
font-weight: 600; font-weight: 600;
} }
/* 탭 본문 */ /* 탭 본문 (레거시) */
.rsp-body { .rsp-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
min-width: 420px; min-width: 300px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; 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 { .rsp-tabs.bps-right-tabs {
display: flex; display: flex;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
background: var(--bg3); background: var(--bg3);
min-width: 420px; min-width: 0;
} }
.rsp-tabs.bps-right-tabs .bps-right-tab { .rsp-tabs.bps-right-tabs .bps-right-tab {
flex: 1; flex: 1;
@@ -10434,6 +10457,9 @@ html.theme-blue {
color: var(--text); color: var(--text);
outline: none; outline: none;
} }
.top-field--price .top-input {
text-align: right;
}
.top-input--full { .top-input--full {
width: 100%; width: 100%;
border: 1px solid var(--border); 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); 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 { .tnl-page--with-right .tnl-side-wrap--right.tnl-side-wrap--open .tnl-right.trade-right-panel.tnl-right--open {
flex: 0 0 min(380px, 38vw);
width: min(380px, 38vw);
min-width: 300px;
max-width: 420px;
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
} }
.tnl-right-tabs { .tnl-right-tabs {
@@ -11788,21 +11810,6 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
display: flex; display: flex;
flex-direction: column; 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 { .tnl-row--selected {
border-color: var(--accent, #7aa2f7); border-color: var(--accent, #7aa2f7);
box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.35); 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; padding: 0 8px;
overflow: hidden; 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; } .app--mobile .upbit-market { max-width: 42vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── 메인: 차트 전체 너비, 사이드 패널 오버레이 ── */ /* ── 메인: 차트 전체 너비, 사이드 패널 오버레이 ── */
+16 -45
View File
@@ -100,11 +100,7 @@ import VerificationBoardPage from './components/VerificationBoardPage';
import DashboardPage from './components/DashboardPage'; import DashboardPage from './components/DashboardPage';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi'; import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar'; import ChartLegendBar from './components/ChartLegendBar';
import { import { resolveChartLiveQuote } from './utils/chartLiveQuote';
formatChartLiveChangePct,
formatChartLivePrice,
resolveChartLiveQuote,
} from './utils/chartLiveQuote';
import RightSidePanel from './components/RightSidePanel'; import RightSidePanel from './components/RightSidePanel';
import { import {
type BacktestSettingsDto, type BacktestSettingsDto,
@@ -129,7 +125,7 @@ import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } fr
import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry'; import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry';
import { syncDocumentTheme } from './utils/documentTheme'; import { syncDocumentTheme } from './utils/documentTheme';
import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions'; 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 { clearAdminUnlock } from './utils/adminUnlock';
import type { LoginResponse } from './utils/backendApi'; import type { LoginResponse } from './utils/backendApi';
import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateAppSettingsCache } from './hooks/useAppSettings';
@@ -137,25 +133,13 @@ import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import './App.css'; import './App.css';
import './styles/appPopup.css'; import './styles/appPopup.css';
import './styles/paperDashboard.css'; import './styles/paperDashboard.css';
import './styles/tradeRightPanel.css';
import './styles/virtualTradingDashboard.css'; import './styles/virtualTradingDashboard.css';
import './styles/backtestDashboard.css'; import './styles/backtestDashboard.css';
let _indCounter = 0; let _indCounter = 0;
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; } function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
// ─── 연결 상태 배지 ────────────────────────────────────────────────────────
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null;
const map: Record<WsStatus, { dot: string; label: string }> = {
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 <span className={`ws-badge ${dot}`}><span className="ws-dot" />{label}</span>;
};
/** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */ /** 모든 .ws-dot 요소에 형광 glow 플래시 (React state 없이 직접 DOM 조작) */
let _wsFlashTimer: ReturnType<typeof setTimeout> | null = null; let _wsFlashTimer: ReturnType<typeof setTimeout> | null = null;
function flashWsDot() { function flashWsDot() {
@@ -231,7 +215,11 @@ function App() {
const [mobileRightOpen, setMobileRightOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false);
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade'); const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
const [menuPage, setMenuPage] = useState<MenuPage>('chart'); const [menuPage, setMenuPage] = useState<MenuPage>('chart');
const [settingsInitialCategory, setSettingsInitialCategory] = useState<SettingsCategoryId | undefined>();
const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(null); const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(null);
useEffect(() => {
if (menuPage !== 'settings') setSettingsInitialCategory(undefined);
}, [menuPage]);
const chartVisible = menuPage === 'chart'; const chartVisible = menuPage === 'chart';
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -1770,6 +1758,10 @@ function App() {
paperTradingEnabled={paperTradingEnabled} paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={handlePaperOrderFilled} onPaperOrderFilled={handlePaperOrderFilled}
onOpenSettings={() => {
setSettingsInitialCategory('paper');
setMenuPage('settings');
}}
onGoChart={m => { onGoChart={m => {
goToMarketChart(m); goToMarketChart(m);
setMenuPage('chart'); setMenuPage('chart');
@@ -1957,6 +1949,7 @@ function App() {
const r = await sendFcmTest(); const r = await sendFcmTest();
window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.'); window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.');
}} }}
initialCategory={settingsInitialCategory}
/> />
)} )}
@@ -2083,34 +2076,12 @@ function App() {
}} }}
onToggleLiveStrategy={() => setShowLivePanel(v => !v)} onToggleLiveStrategy={() => setShowLivePanel(v => !v)}
liveStrategyActive={showLivePanel || monitoredMarkets.length > 0} liveStrategyActive={showLivePanel || monitoredMarkets.length > 0}
showChartQuote={useUpbit}
chartLiveQuote={liveQuote}
chartQuoteLoading={isLoading}
chartQuoteError={error}
/> />
{/* 업비트 연결 상태 바 */}
{useUpbit && (
<div className={`upbit-bar ${wsStatus}`}>
<WsBadge status={wsStatus} isUpbit={useUpbit} />
<span className="upbit-market">{getKoreanName(symbol)} <span style={{opacity:0.5, fontSize:'11px'}}>{symbol}</span></span>
{isLoading && <span className="upbit-loading"> ...</span>}
{error && <span className="upbit-error"> {error}</span>}
{!isLoading && !error && liveQuote.price != null && (
<div className="upbit-quote-group">
<span
className="upbit-price"
style={{ color: liveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
>
{formatChartLivePrice(liveQuote.price)}
</span>
{liveQuote.changePct != null && (
<span className={`upbit-change ${liveQuote.isUp ? 'up' : 'down'}`}>
{formatChartLiveChangePct(liveQuote.changePct)}
</span>
)}
</div>
)}
<span className="upbit-hint">💡 마켓: KRW-BTC, KRW-ETH, KRW-XRP ...</span>
</div>
)}
{isMobile && (showMarketPanel || mobileRightOpen) && ( {isMobile && (showMarketPanel || mobileRightOpen) && (
<button <button
type="button" type="button"
+192 -133
View File
@@ -1,44 +1,58 @@
/** /**
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표) * 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈)
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
loadPaperSummary, loadPaperSummary,
loadPaperTrades, loadPaperTradesPaged,
loadPaperPendingOrders,
loadStrategies,
resetPaperAccount, resetPaperAccount,
type PaperOrderDto,
type PaperPositionDto, type PaperPositionDto,
type PaperSummaryDto, type PaperSummaryDto,
type PaperTradeDto, type PaperTradeDto,
type StrategyDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
import type { TickerData } from '../hooks/useMarketTicker';
import { coerceFiniteNumber } from '../utils/safeFormat';
import PaperInvestmentKpi from './paper/PaperInvestmentKpi';
import PaperAllocationTable from './paper/PaperAllocationTable';
import PaperPendingOrdersList from './paper/PaperPendingOrdersList';
import PaperLedgerTab from './paper/PaperLedgerTab';
import PaperAssetTrendChart from './paper/PaperAssetTrendChart';
import { computePaperMetrics } from '../utils/paperMetrics'; import { computePaperMetrics } from '../utils/paperMetrics';
import { fmtKrw } from './TradeOrderPanel'; import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import TradeOrderPanel from './TradeOrderPanel';
import type { Theme, TradeOrderFillRequest } from '../types'; import type { Theme, TradeOrderFillRequest } from '../types';
import PaperAnalysisChart from './paper/PaperAnalysisChart'; import PaperAnalysisChart from './paper/PaperAnalysisChart';
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab'; import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab'; import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook'; import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
import PaperSplitPanel from './paper/PaperSplitPanel';
import BuilderPageShell from './layout/BuilderPageShell'; import BuilderPageShell from './layout/BuilderPageShell';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
type HistoryTab = 'open' | 'recent'; type HistoryTab = 'open' | 'recent' | 'ledger';
type CenterTab = 'invest' | 'chart';
type RightTab = 'trade' | 'orderbook' | 'history'; type RightTab = 'trade' | 'orderbook' | 'history';
type LeftTab = 'strategy' | 'settings'; type LeftTab = 'strategy' | 'settings';
interface Props { interface Props {
theme?: Theme; theme?: Theme;
tickers?: Map<string, { tradePrice: number | null }>; tickers?: Map<string, TickerData>;
onGoChart?: (market: string) => void; onGoChart?: (market: string) => void;
refreshKey?: number; refreshKey?: number;
defaultMarket?: string; defaultMarket?: string;
paperTradingEnabled?: boolean; paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean; paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void; onPaperOrderFilled?: () => void;
/** 설정 → 모의투자 카테고리로 이동 */
onOpenSettings?: () => void;
} }
function buildMarkPrices( function buildMarkPrices(
summary: PaperSummaryDto | null, summary: PaperSummaryDto | null,
tickers?: Map<string, { tradePrice: number | null }>, tickers?: Map<string, TickerData>,
): Record<string, number> { ): Record<string, number> {
const m: Record<string, number> = {}; const m: Record<string, number> = {};
summary?.positions?.forEach(p => { summary?.positions?.forEach(p => {
@@ -50,7 +64,7 @@ function buildMarkPrices(
function positionPriceKey( function positionPriceKey(
positions: PaperPositionDto[] | undefined, positions: PaperPositionDto[] | undefined,
tickers?: Map<string, { tradePrice: number | null }>, tickers?: Map<string, TickerData>,
): string { ): string {
if (!positions?.length) return ''; if (!positions?.length) return '';
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|'); return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
@@ -68,37 +82,52 @@ const PaperTradingPage: React.FC<Props> = ({
paperTradingEnabled = true, paperTradingEnabled = true,
paperAutoTradeEnabled = false, paperAutoTradeEnabled = false,
onPaperOrderFilled, onPaperOrderFilled,
onOpenSettings,
}) => { }) => {
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null); const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]); const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [pendingOrders, setPendingOrders] = useState<PaperOrderDto[]>([]);
const [dataRefreshKey, setDataRefreshKey] = useState(0);
const [initialLoading, setInitialLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [centerTab, setCenterTab] = useState<CenterTab>('invest');
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent'); const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
const [rightTab, setRightTab] = useState<RightTab>('trade'); const [rightTab, setRightTab] = useState<RightTab>('trade');
const [leftTab, setLeftTab] = useState<LeftTab>('strategy'); const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null); const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null); const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null); const orderAnchorRef = useRef<HTMLDivElement>(null);
const fillSeqRef = useRef(0);
const tickersRef = useRef(tickers); const tickersRef = useRef(tickers);
tickersRef.current = tickers; tickersRef.current = tickers;
const summaryRef = useRef(summary); const summaryRef = useRef(summary);
summaryRef.current = summary; summaryRef.current = summary;
const loadData = useCallback(async () => { const loadData = useCallback(async (opts?: { focusHistory?: boolean }) => {
try { try {
const base = await loadPaperSummary(); const base = await loadPaperSummary();
const marks = buildMarkPrices(base, tickersRef.current); const marks = buildMarkPrices(base, tickersRef.current);
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base; const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
setSummary(full); setSummary(full);
setTrades(await loadPaperTrades()); const tradePage = await loadPaperTradesPaged({ page: 0, size: 100 });
setTrades(tradePage.content);
setPendingOrders(await loadPaperPendingOrders());
setDataRefreshKey(k => k + 1);
} finally { } finally {
setInitialLoading(false); setInitialLoading(false);
} }
}, []); onPaperOrderFilled?.();
if (opts?.focusHistory) setRightTab('history');
}, [onPaperOrderFilled]);
useEffect(() => {
void Promise.all([
loadData(),
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
]);
}, [loadData]);
useEffect(() => { void loadData(); }, [loadData]);
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]); useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
const priceKey = positionPriceKey(summary?.positions, tickers); const priceKey = positionPriceKey(summary?.positions, tickers);
@@ -118,39 +147,84 @@ const PaperTradingPage: React.FC<Props> = ({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [priceKey, initialLoading]); }, [priceKey, initialLoading]);
const strategyNames = useMemo(
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
[strategies],
);
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]); const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
const s = summary; const s = summary;
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const posQty = useMemo(() => { const posQty = useMemo(() => {
const p = s?.positions?.find(x => x.symbol === selectedMarket); const p = s?.positions?.find(x => x.symbol === selectedMarket);
return p?.quantity ?? 0; return coerceFiniteNumber(p?.quantity) ?? 0;
}, [s?.positions, selectedMarket]); }, [s?.positions, selectedMarket]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => { const selectedAlloc = useMemo(
const side = rowType === 'ask' ? 'buy' : 'sell'; () => s?.allocations?.find(a => a.symbol === selectedMarket),
fillSeqRef.current += 1; [s?.allocations, selectedMarket],
const req: TradeOrderFillRequest = { );
market: selectedMarket,
price, const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0;
side,
seq: fillSeqRef.current, const tradePrice = useMemo(
}; () => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
if (side === 'buy') setFillBuy(req); else setFillSell(req); [tickers, selectedMarket],
);
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket);
const selectedTicker = tickers?.get(selectedMarket);
const orderbookPrevClose = selectedTicker
? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0)
: 0;
const orderbookTickerInfo = selectedTicker ? {
tradePrice: selectedTicker.tradePrice,
changeRate: selectedTicker.changeRate,
changePrice: selectedTicker.changePrice,
accTradePrice24: selectedTicker.accTradePrice24,
accTradeVolume24: selectedTicker.accTradeVolume24,
highPrice: selectedTicker.highPrice,
lowPrice: selectedTicker.lowPrice,
openingPrice: selectedTicker.openingPrice,
} : undefined;
const handleSelectMarket = useCallback((market: string) => {
setSelectedMarket(market);
const price = tickers?.get(market)?.tradePrice;
if (price != null && price > 0) {
const seq = Date.now();
setFillBuy({ market, price, side: 'buy', seq });
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
}
setRightTab('trade'); setRightTab('trade');
}, [tickers]);
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setRightTab('trade');
const side = rowType === 'bid' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req);
else setFillSell(req);
}, [selectedMarket]); }, [selectedMarket]);
const handleReset = useCallback(async () => { const handleReset = useCallback(async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return; if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
const res = await resetPaperAccount(); const res = await resetPaperAccount();
if (res) { setSummary(res); setTrades([]); } if (res) {
setSummary(res);
setTrades([]);
setPendingOrders([]);
}
}, []); }, []);
const rightTabs = ( const rightTabs = (
<div className="bps-right-tabs"> <div className="bps-right-tabs">
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}></button> <button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}></button>
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}></button> <button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}></button>
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}></button> <button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>
{trades.length > 0 ? ` (${trades.length})` : ''}
</button>
</div> </div>
); );
@@ -166,10 +240,16 @@ const PaperTradingPage: React.FC<Props> = ({
theme={theme} theme={theme}
title="모의투자" title="모의투자"
subtitle="Paper Trading" subtitle="Paper Trading"
pageClassName="bps-page--vtd bps-page--ptd"
loading={initialLoading} loading={initialLoading}
loadingText="모의투자 대시보드 로딩…" loadingText="모의투자 대시보드 로딩…"
collapsiblePanels
leftStorageKey="ptd-left-width" 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={( headerActions={(
<> <>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}></button> <button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}></button>
@@ -185,129 +265,108 @@ const PaperTradingPage: React.FC<Props> = ({
market={selectedMarket} market={selectedMarket}
summary={s} summary={s}
paperAutoTradeEnabled={paperAutoTradeEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled}
onMarketSelect={setSelectedMarket} onMarketSelect={handleSelectMarket}
/> />
) : ( ) : (
<PaperLeftSettingsTab <PaperLeftSettingsTab
onOpenSettings={onOpenSettings}
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }} onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
/> />
)} )}
centerHead={( centerHead={(
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span> <div className="ptd-center-head">
<div className="ptd-tabs ptd-tabs--center">
<button type="button" className={`ptd-tab${centerTab === 'invest' ? ' active' : ''}`} onClick={() => setCenterTab('invest')}> </button>
<button type="button" className={`ptd-tab${centerTab === 'chart' ? ' active' : ''}`} onClick={() => setCenterTab('chart')}></button>
</div>
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
</div>
)} )}
center={( center={centerTab === 'invest' ? (
<div className="ptd-card ptd-invest-card ptd-center-fill">
<PaperInvestmentKpi summary={s} />
<PaperAllocationTable
allocations={s?.allocations ?? []}
selectedMarket={selectedMarket}
onSelectMarket={handleSelectMarket}
onChanged={() => void loadData()}
/>
<div className="ptd-invest-metrics">
<PaperAssetTrendChart refreshKey={dataRefreshKey} />
<div className="ptd-metrics-grid">
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
</div>
</div>
</div>
) : (
<div className="ptd-card ptd-chart-card ptd-center-fill"> <div className="ptd-card ptd-chart-card ptd-center-fill">
<PaperAnalysisChart market={selectedMarket} theme={theme} /> <PaperAnalysisChart market={selectedMarket} theme={theme} />
</div> </div>
)} )}
rightTabs={rightTabs} rightTabs={rightTabs}
right={( right={(
<div className="ptd-right-body" ref={orderAnchorRef}> <TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? ( {rightTab === 'trade' ? (
<PaperSplitPanel <TradeSplitOrderPanel
className="ptd-split-panel--right" market={selectedMarket}
topTitle="매수" tradePrice={tradePrice}
bottomTitle="매도" availableKrw={orderableCash}
top={( availableSymbolBuyKrw={selectedAlloc?.availableBuyKrw}
<TradeOrderPanel fillBuy={fillBuy}
side="buy" fillSell={fillSell}
market={selectedMarket} searchAnchorRef={orderAnchorRef}
tradePrice={tradePrice} onMarketSelect={handleSelectMarket}
availableKrw={s?.cashBalance ?? 0} paperTradingEnabled={paperTradingEnabled}
fillRequest={fillBuy} paperAutoTradeEnabled={paperAutoTradeEnabled}
searchAnchorRef={orderAnchorRef} onPaperOrderFilled={() => void loadData({ focusHistory: true })}
onMarketSelect={setSelectedMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
)}
bottom={(
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={setSelectedMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
)}
/> />
) : rightTab === 'orderbook' ? ( ) : rightTab === 'orderbook' ? (
<PaperSplitPanel <TradeOrderbookPanelContent
className="ptd-split-panel--right" market={selectedMarket}
topTitle="매도 호가" asks={orderbook.asks}
bottomTitle="매수 호가" bids={orderbook.bids}
top={( totalAskSize={orderbook.totalAskSize}
<PaperCompactOrderbook totalBidSize={orderbook.totalBidSize}
market={selectedMarket} wsStatus={obWsStatus}
onPick={handleObPick} bestAsk={spread.bestAsk}
section="asks" bestBid={spread.bestBid}
fillHeight spread={spread.spread}
hideHeader spreadPct={spread.pct}
depth={10} prevClose={orderbookPrevClose}
/> tickerInfo={orderbookTickerInfo}
)} recentTrades={recentTrades}
bottom={( tradeStrength={tradeStrength ?? undefined}
<PaperCompactOrderbook onRowClick={handleOrderbookRowClick}
market={selectedMarket}
onPick={handleObPick}
section="bids"
fillHeight
hideHeader
depth={10}
/>
)}
/> />
) : ( ) : (
<PaperSplitPanel <div className="ptd-right-history">
className="ptd-split-panel--right" <div className="ptd-tabs ptd-tabs--history">
topTitle="체결 구분" <button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}></button>
bottomTitle={historyTab === 'open' ? '미체결' : '최근체결'} <button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}></button>
top={( <button type="button" className={`ptd-tab${historyTab === 'ledger' ? ' active' : ''}`} onClick={() => setHistoryTab('ledger')}></button>
<div className="ptd-tabs ptd-tabs--in-card"> </div>
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}></button> <div className="ptd-right-history-body">
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}></button> {historyTab === 'open' ? (
</div> <PaperPendingOrdersList orders={pendingOrders} onChanged={() => void loadData()} />
)} ) : historyTab === 'ledger' ? (
bottom={historyTab === 'open' ? ( <PaperLedgerTab refreshKey={dataRefreshKey} />
<p className="ptd-muted"> ( )</p> ) : (
) : ( <PaperTradeHistoryList
<table className="ptd-table ptd-table--compact"> trades={trades}
<thead> strategyNames={strategyNames}
<tr><th></th><th></th><th></th><th></th><th></th></tr> tickers={tickers}
</thead> onSelectMarket={handleSelectMarket}
<tbody> emptyText="체결 내역이 없습니다."
{trades.slice(0, 20).map(t => ( className="ptd-trade-history--fill"
<tr key={t.id}> />
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td> )}
<td>{coinCode(t.symbol)}</td> </div>
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td> </div>
<td>{fmtKrw(t.price)}</td>
<td><span className="ptd-status ptd-status--done"></span></td>
</tr>
))}
{!trades.length && <tr><td colSpan={5} className="ptd-muted"> </td></tr>}
</tbody>
</table>
)}
/>
)} )}
</div> </TradeRightPanelBody>
)}
footerLabel="성과 지표"
footer={(
<div className="ptd-metrics-grid">
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
</div>
)} )}
/> />
); );
+47 -90
View File
@@ -4,9 +4,13 @@
* - 호가 탭: 실시간 호가창 * - 호가 탭: 실시간 호가창
*/ */
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { OrderbookPanel } from './OrderbookPanel'; import {
import TradeOrderPanel from './TradeOrderPanel'; TradeSplitOrderPanel,
import PaperSplitPanel from './paper/PaperSplitPanel'; TradeOrderbookPanelContent,
TradeRightPanelTabs,
TradeRightPanelBody,
type TradeRightPanelTab,
} from './trade';
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook'; import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import type { OrderbookTickerInfo } from './OrderbookPanel'; import type { OrderbookTickerInfo } from './OrderbookPanel';
@@ -19,8 +23,8 @@ export interface RightSidePanelProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
/** 모바일 도크에서 호가/매매 탭 지정 */ /** 모바일 도크에서 호가/매매 탭 지정 */
activeTab?: PanelTab; activeTab?: TradeRightPanelTab;
onTabChange?: (tab: PanelTab) => void; onTabChange?: (tab: TradeRightPanelTab) => void;
market: string; market: string;
tradePrice: number | null; tradePrice: number | null;
asks: OrderbookDisplayUnit[]; asks: OrderbookDisplayUnit[];
@@ -44,11 +48,6 @@ export interface RightSidePanelProps {
onPaperOrderFilled?: () => void; onPaperOrderFilled?: () => void;
} }
const PANEL_TABS: { id: PanelTab; label: string }[] = [
{ id: 'trade', label: '매매' },
{ id: 'orderbook', label: '호가' },
];
const RightSidePanel: React.FC<RightSidePanelProps> = ({ const RightSidePanel: React.FC<RightSidePanelProps> = ({
open: openControlled, open: openControlled,
onOpenChange, onOpenChange,
@@ -77,7 +76,7 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
onPaperOrderFilled, onPaperOrderFilled,
}) => { }) => {
const [openInternal, setOpenInternal] = useState(false); const [openInternal, setOpenInternal] = useState(false);
const [tabInternal, setTabInternal] = useState<PanelTab>('trade'); const [tabInternal, setTabInternal] = useState<TradeRightPanelTab>('trade');
const tradeSectionRef = useRef<HTMLDivElement>(null); const tradeSectionRef = useRef<HTMLDivElement>(null);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market); const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
@@ -91,7 +90,7 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
}; };
const activeTab = activeTabControlled ?? tabInternal; const activeTab = activeTabControlled ?? tabInternal;
const setActiveTab = useCallback((tab: PanelTab) => { const setActiveTab = useCallback((tab: TradeRightPanelTab) => {
if (onTabChange) onTabChange(tab); if (onTabChange) onTabChange(tab);
else setTabInternal(tab); else setTabInternal(tab);
}, [onTabChange]); }, [onTabChange]);
@@ -130,91 +129,49 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
</svg> </svg>
</button> </button>
<div className="rsp-panel"> <div className="rsp-panel trade-right-panel">
<div className="rsp-tabs bps-right-tabs"> <TradeRightPanelTabs
{PANEL_TABS.map(tab => ( activeTab={activeTab}
<button onTabChange={setActiveTab}
key={tab.id} className="rsp-tabs"
type="button" />
className={`bps-right-tab${activeTab === tab.id ? ' bps-right-tab--on' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="rsp-body ptd-right-body" ref={tradeSectionRef}> <TradeRightPanelBody anchorRef={tradeSectionRef} className="rsp-body-wrap">
{activeTab === 'trade' && ( {activeTab === 'trade' && (
<PaperSplitPanel <TradeSplitOrderPanel
className="ptd-split-panel--right" market={market}
topTitle="매수" tradePrice={tradePrice}
bottomTitle="매도" availableKrw={availableKrw}
top={( availableCoinQty={availableCoinQty}
<div className="ptd-order-card"> fillRequest={fillRequest}
<TradeOrderPanel searchAnchorRef={tradeSectionRef}
side="buy" onMarketSelect={onMarketSelect}
market={market} paperTradingEnabled={paperTradingEnabled}
tradePrice={tradePrice} paperAutoTradeEnabled={paperAutoTradeEnabled}
availableKrw={availableKrw} onPaperOrderFilled={onPaperOrderFilled}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
)}
bottom={(
<div className="ptd-order-card">
<TradeOrderPanel
side="sell"
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableCoinQty={availableCoinQty}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
</div>
)}
/> />
)} )}
{activeTab === 'orderbook' && ( {activeTab === 'orderbook' && (
<div className="rsp-ob-stack ptd-ob-stack--fill"> <TradeOrderbookPanelContent
<div className="rsp-trade-card rsp-ob-card"> market={market}
<div className="rsp-trade-card-title rsp-ob-card-title"> </div> asks={asks}
<div className="rsp-trade-card-body rsp-ob-card-body"> bids={bids}
<OrderbookPanel totalAskSize={totalAskSize}
market={market} totalBidSize={totalBidSize}
asks={asks} wsStatus={wsStatus}
bids={bids} bestAsk={bestAsk}
totalAskSize={totalAskSize} bestBid={bestBid}
totalBidSize={totalBidSize} spread={spread}
wsStatus={wsStatus} spreadPct={spreadPct}
bestAsk={bestAsk} prevClose={prevClose}
bestBid={bestBid} tickerInfo={tickerInfo}
spread={spread} recentTrades={recentTrades}
spreadPct={spreadPct} tradeStrength={tradeStrength ?? undefined}
prevClose={prevClose} onRowClick={handleOrderbookRowClick}
tickerInfo={tickerInfo} />
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
)} )}
</div> </TradeRightPanelBody>
</div> </div>
</div> </div>
); );
@@ -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<Props> = ({ onCapture, onCancel }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const [phase, setPhase] = useState<Phase>('starting');
const [selection, setSelection] = useState<ScreenCaptureRect | null>(null);
const [error, setError] = useState<string | null>(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(
<div className="screen-capture-overlay" role="dialog" aria-modal aria-label="화면 영역 캡처">
<video ref={videoRef} className="screen-capture-video" playsInline />
<div
className="screen-capture-interaction"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={e => void handlePointerUp(e)}
onPointerCancel={() => {
dragStartRef.current = null;
setSelection(null);
setPhase('ready');
}}
>
{selection && selection.width > 0 && selection.height > 0 && (
<div
className="screen-capture-selection"
style={{
left: selection.left,
top: selection.top,
width: selection.width,
height: selection.height,
}}
/>
)}
</div>
<div className="screen-capture-toolbar">
<span className="screen-capture-hint">{error ?? hint}</span>
<button type="button" className="screen-capture-cancel" onClick={finishCancel}>
</button>
</div>
</div>,
document.body,
);
};
export default ScreenRegionCaptureOverlay;
+174 -123
View File
@@ -166,6 +166,8 @@ interface SettingsPageProps {
onVerificationIssueNotify?: (v: boolean) => void; onVerificationIssueNotify?: (v: boolean) => void;
trendSearchSettings?: TrendSearchAppSettings; trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void; onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
initialCategory?: SettingsCategoryId;
} }
// ── 카테고리 정의 ──────────────────────────────────────────────────────────── // ── 카테고리 정의 ────────────────────────────────────────────────────────────
@@ -233,6 +235,19 @@ const IcPaper = () => (
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/> <circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
</svg> </svg>
); );
const IcVirtual = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="6" width="8" height="10" rx="1.5"/>
<rect x="12" y="4" width="8" height="12" rx="1.5"/>
<path d="M10 11h2"/>
</svg>
);
const IcLive = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="9"/>
<path d="M8 11h6M11 8v6"/>
</svg>
);
const IcTrendSearch = () => ( const IcTrendSearch = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,17 9,11 13,14 18,6"/> <polyline points="4,17 9,11 13,14 18,6"/>
@@ -264,7 +279,9 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' }, { id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' }, { id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' }, { id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
{ id: 'paper', label: '가상매매', icon: <IcPaper />, desc: '가상 자본, 수수료, 투자대상 한도, 자동매매 ON/OFF 등 가상매매 설정' }, { id: 'paper', label: '모의투자', icon: <IcPaper />, desc: '모의투자 활성화, 자동매매, 초기 자본·수수료·슬리피지, 계좌 초기화' },
{ id: 'virtual', label: '가상매매', icon: <IcVirtual />, desc: '가상매매 화면 투자대상 목록 한도 등 가상매매 전용 설정' },
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' }, { id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' }, { id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' }, { id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
@@ -319,7 +336,7 @@ const StgCard: React.FC<{
// ── 각 카테고리 패널 ───────────────────────────────────────────────────────── // ── 각 카테고리 패널 ─────────────────────────────────────────────────────────
interface PaperPanelProps { interface PaperTradingSettingsPanelProps {
paperTradingEnabled?: boolean; paperTradingEnabled?: boolean;
onPaperTradingEnabled?: (v: boolean) => void; onPaperTradingEnabled?: (v: boolean) => void;
paperInitialCapital?: number; paperInitialCapital?: number;
@@ -334,21 +351,10 @@ interface PaperPanelProps {
onPaperAutoTradeEnabled?: (v: boolean) => void; onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number; paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void; onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void; onPaperAccountReset?: () => void;
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
} }
const PaperPanel: React.FC<PaperPanelProps> = ({ const PaperTradingSettingsPanel: React.FC<PaperTradingSettingsPanelProps> = ({
paperTradingEnabled = true, paperTradingEnabled = true,
onPaperTradingEnabled, onPaperTradingEnabled,
paperInitialCapital = 10_000_000, paperInitialCapital = 10_000_000,
@@ -363,63 +369,29 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onPaperAutoTradeEnabled, onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95, paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct, onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset, onPaperAccountReset,
tradingMode = 'PAPER', }) => (
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
}) => {
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
return (
<> <>
<SettingSection title="매매 운영 모드"> <SettingSection title="모의투자">
<SettingRow label="실행 대상" desc="가상투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다."> <SettingRow
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}> label="모의투자 활성화"
<option value="PAPER"></option> desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
<option value="LIVE"></option> >
<option value="BOTH"> + </option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="가상매매">
<SettingRow label="가상매매 활성화" desc="켜면 가상매매·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
<label className="stg-toggle"> <label className="stg-toggle">
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} /> <input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" /> <span className="stg-toggle-slider" />
</label> </label>
</SettingRow> </SettingRow>
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection> </SettingSection>
<SettingSection title="자동매매"> <SettingSection title="자동매매">
<SettingRow <SettingRow
label="자동매매 ON/OFF" label="자동매매"
desc="가상투자 화면 타이틀바에서 변경합니다. Match 충족 시 자동 매수·매도 여부를 제어합니다." desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
> >
<span className={`stg-badge ${paperAutoTradeEnabled ? 'stg-badge--on' : 'stg-badge--off'}`}> <label className="stg-toggle">
{paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'} <input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
</span> <span className="stg-toggle-slider" />
</label>
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label="자동 매수 예산 (%)" label="자동 매수 예산 (%)"
@@ -436,9 +408,6 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))} onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
/> />
</SettingRow> </SettingRow>
{!paperAutoTradeEnabled && (
<p className="stg-hint"> ON/OFF는 . OFF Match· .</p>
)}
</SettingSection> </SettingSection>
<SettingSection title="계좌·비용"> <SettingSection title="계좌·비용">
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다."> <SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
@@ -485,69 +454,135 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
</SettingRow> </SettingRow>
</SettingSection> </SettingSection>
<SettingSection title="계좌 관리"> <SettingSection title="계좌 관리">
<SettingRow label="계좌 초기화" desc="보유 종목·체결 이력을 삭제하고 초기 자본으로 현금을 되돌립니다."> <SettingRow
label="계좌 초기화"
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
>
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}> <button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
</button> </button>
</SettingRow> </SettingRow>
</SettingSection> </SettingSection>
<SettingSection title="실거래 (업비트 Open API)">
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
{hasUpbitKeys ? '연결됨' : '미등록'}
</span>
</SettingRow>
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
{(accessKey.trim() || secretKey.trim()) && (
<SettingRow label="">
<button
type="button"
className="stg-btn stg-btn--primary"
onClick={() => {
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
setAccessKey('');
setSecretKey('');
}}
>
API
</button>
</SettingRow>
)}
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
value={liveAutoTradeBudgetPct}
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
<span className="stg-unit">%</span>
</SettingRow>
</SettingSection>
</> </>
);
const VirtualTradingSettingsPanel: React.FC<{
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void;
}> = ({ virtualTargetMaxCount = 20, onVirtualTargetMaxCount }) => (
<SettingSection title="가상매매">
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1}
max={100}
step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
<p className="stg-hint">
·· .
</p>
</SettingSection>
);
const LiveTradingSettingsPanel: React.FC<{
tradingMode?: string;
onTradingMode?: (v: string) => void;
liveAutoTradeEnabled?: boolean;
onLiveAutoTradeEnabled?: (v: boolean) => void;
hasUpbitKeys?: boolean;
upbitAccessKeyMasked?: string | null;
onUpbitKeys?: (access: string, secret: string) => void;
liveAutoTradeBudgetPct?: number;
onLiveAutoTradeBudgetPct?: (v: number) => void;
}> = ({
tradingMode = 'PAPER',
onTradingMode,
liveAutoTradeEnabled = false,
onLiveAutoTradeEnabled,
hasUpbitKeys = false,
upbitAccessKeyMasked = null,
onUpbitKeys,
liveAutoTradeBudgetPct = 95,
onLiveAutoTradeBudgetPct,
}) => {
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
return (
<>
<SettingSection title="매매 운영 모드">
<SettingRow label="실행 대상" desc="모의투자만, 실거래(업비트 API)만, 또는 둘 다 병행할 수 있습니다.">
<select className="stg-select" value={tradingMode} onChange={e => onTradingMode?.(e.target.value)}>
<option value="PAPER"></option>
<option value="LIVE"></option>
<option value="BOTH"> + </option>
</select>
</SettingRow>
</SettingSection>
<SettingSection title="실거래 (업비트 Open API)">
<SettingRow label="실거래 자동매매" desc="ON이면 전략 시그널·손절/익절 시 업비트에 실제 주문합니다. API 키에 주문 권한이 필요합니다.">
<label className="stg-toggle">
<input type="checkbox" checked={liveAutoTradeEnabled} onChange={e => onLiveAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow label="API 키 상태" desc={hasUpbitKeys ? `등록됨 (${upbitAccessKeyMasked})` : 'Access / Secret 키를 입력 후 저장하세요.'}>
<span style={{ fontSize: 12, color: hasUpbitKeys ? 'var(--accent)' : 'var(--text3)' }}>
{hasUpbitKeys ? '연결됨' : '미등록'}
</span>
</SettingRow>
<SettingRow label="Access Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 DB에 AES 암호화되어 보관됩니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Access Key 입력' : 'Access Key'}
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
<SettingRow label="Secret Key" desc="입력 내용이 그대로 표시됩니다. 저장 시 암호화되며 API로 전체 키는 반환되지 않습니다.">
<input
type="text"
className="stg-input stg-input--sensitive"
placeholder={hasUpbitKeys ? '변경 시 새 Secret Key 입력' : 'Secret Key'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</SettingRow>
{(accessKey.trim() || secretKey.trim()) && (
<SettingRow label="">
<button
type="button"
className="stg-btn stg-btn--primary"
onClick={() => {
onUpbitKeys?.(accessKey.trim(), secretKey.trim());
setAccessKey('');
setSecretKey('');
}}
>
API
</button>
</SettingRow>
)}
<SettingRow label="실거래 자동매수 예산 (%)" desc="시장가 매수 시 KRW 잔고 대비 사용 비율 (모의투자 예산과 별도)">
<input type="number" className="stg-input stg-input--narrow" min={1} max={100} step={1}
value={liveAutoTradeBudgetPct}
onChange={e => onLiveAutoTradeBudgetPct?.(Number(e.target.value))} />
<span className="stg-unit">%</span>
</SettingRow>
</SettingSection>
</>
); );
}; };
@@ -1707,9 +1742,10 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onVerificationIssueNotify, onVerificationIssueNotify,
trendSearchSettings, trendSearchSettings,
onTrendSearchSettingsChange, onTrendSearchSettingsChange,
initialCategory,
}) => { }) => {
const categories = filterCategories(menuPermissions); const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>('general'); const [active, setActive] = useState<CategoryId>(initialCategory ?? 'general');
const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null); const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null);
useEffect(() => { useEffect(() => {
@@ -1719,6 +1755,13 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
} }
}, [categories, active]); }, [categories, active]);
useEffect(() => {
if (!initialCategory) return;
if (categories.some(c => c.id === initialCategory)) {
setActive(initialCategory);
}
}, [initialCategory, categories]);
useEffect(() => { useEffect(() => {
if (active !== 'indicators') setIndicatorSaveUi(null); if (active !== 'indicators') setIndicatorSaveUi(null);
}, [active]); }, [active]);
@@ -1811,7 +1854,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
</AdminPasswordGate> </AdminPasswordGate>
); );
case 'paper': return ( case 'paper': return (
<PaperPanel <PaperTradingSettingsPanel
paperTradingEnabled={paperTradingEnabled} paperTradingEnabled={paperTradingEnabled}
onPaperTradingEnabled={onPaperTradingEnabled} onPaperTradingEnabled={onPaperTradingEnabled}
paperInitialCapital={paperInitialCapital} paperInitialCapital={paperInitialCapital}
@@ -1826,9 +1869,17 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled} onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct} paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct} onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'virtual': return (
<VirtualTradingSettingsPanel
virtualTargetMaxCount={virtualTargetMaxCount} virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount} onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset} />
);
case 'live': return (
<LiveTradingSettingsPanel
tradingMode={tradingMode} tradingMode={tradingMode}
onTradingMode={onTradingMode} onTradingMode={onTradingMode}
liveAutoTradeEnabled={liveAutoTradeEnabled} liveAutoTradeEnabled={liveAutoTradeEnabled}
+62 -5
View File
@@ -36,6 +36,11 @@ import { getIndicatorListLabels } from '../utils/indicatorSettingsList';
import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types'; import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
import { MarketSearchPanel } from './MarketSearchPanel'; import { MarketSearchPanel } from './MarketSearchPanel';
import { getKoreanName } from '../utils/marketNameCache'; import { getKoreanName } from '../utils/marketNameCache';
import {
formatChartLiveChangePct,
formatChartLivePrice,
type ChartLiveQuote,
} from '../utils/chartLiveQuote';
import LayoutPicker from './LayoutPicker'; import LayoutPicker from './LayoutPicker';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes'; import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi'; import { loadStrategies } from '../utils/backendApi';
@@ -134,6 +139,11 @@ export interface ToolbarProps {
onOpenBulkIndicatorSettings?: () => void; onOpenBulkIndicatorSettings?: () => void;
/** 지표 추가 팝업 항목 — 해당 지표 설정 모달 */ /** 지표 추가 팝업 항목 — 해당 지표 설정 모달 */
onOpenIndicatorSettings?: (type: string) => void; onOpenIndicatorSettings?: (type: string) => void;
/** 종목 선택 우측 시세 (실시간 차트 — 하단 upbit-bar 대체) */
showChartQuote?: boolean;
chartLiveQuote?: ChartLiveQuote | null;
chartQuoteLoading?: boolean;
chartQuoteError?: string | null;
} }
// ─── SVG 아이콘 ──────────────────────────────────────────────────────────── // ─── SVG 아이콘 ────────────────────────────────────────────────────────────
@@ -800,6 +810,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
liveStrategyActive = false, liveStrategyActive = false,
onOpenBulkIndicatorSettings, onOpenBulkIndicatorSettings,
onOpenIndicatorSettings, onOpenIndicatorSettings,
showChartQuote = false,
chartLiveQuote = null,
chartQuoteLoading = false,
chartQuoteError = null,
}) => { }) => {
const [showMarket, setShowMarket] = React.useState(false); const [showMarket, setShowMarket] = React.useState(false);
const [showTfMenu, setShowTfMenu] = React.useState(false); const [showTfMenu, setShowTfMenu] = React.useState(false);
@@ -854,6 +868,13 @@ const Toolbar: React.FC<ToolbarProps> = ({
setShowLayout(true); setShowLayout(true);
}, [showLayout]); }, [showLayout]);
const openMarketSearch = useCallback(() => {
setShowMarket(v => !v);
setShowTfMenu(false);
setShowCtMenu(false);
setShowIndMenu(false);
}, []);
return ( return (
<> <>
{/* 종목 검색 패널 */} {/* 종목 검색 패널 */}
@@ -869,18 +890,54 @@ const Toolbar: React.FC<ToolbarProps> = ({
{/* ── Left ─────────────────────────────────────────────────────────── */} {/* ── Left ─────────────────────────────────────────────────────────── */}
<div className="tv-toolbar-left"> <div className="tv-toolbar-left">
{/* Symbol Search → 클릭 시 MarketSearchPanel 열기 */} {/* 종목 검색(아이콘) · 선택 종목 표시 — 클릭 시 MarketSearchPanel */}
<div className="tv-sym-wrap"> <div className="tv-sym-wrap">
<button <button
className="tv-sym-btn" type="button"
onClick={() => { setShowMarket(v => !v); setShowTfMenu(false); setShowCtMenu(false); setShowIndMenu(false); }} className="tv-sym-btn tv-sym-btn--search"
title="종목 변경" onClick={openMarketSearch}
title="종목 검색"
aria-expanded={showMarket}
aria-haspopup="dialog"
> >
<IcSearch /> <IcSearch />
<span className="tv-sym-text">{getKoreanName(symbol)}</span>
</button> </button>
</div> </div>
<div className="tv-toolbar-quote" aria-label="선택 종목">
<button
type="button"
className="tv-quote-ko"
onClick={openMarketSearch}
title="종목 변경"
aria-expanded={showMarket}
>
{getKoreanName(symbol)}
</button>
<span className="tv-quote-en">{symbol}</span>
{showChartQuote && chartQuoteLoading && (
<span className="tv-quote-status"> </span>
)}
{showChartQuote && chartQuoteError && (
<span className="tv-quote-status tv-quote-status--err"> {chartQuoteError}</span>
)}
{showChartQuote && !chartQuoteLoading && !chartQuoteError && chartLiveQuote?.price != null && (
<>
<span
className="tv-quote-price"
style={{ color: chartLiveQuote.isUp ? 'var(--up)' : 'var(--down)' }}
>
{formatChartLivePrice(chartLiveQuote.price)}
</span>
{chartLiveQuote.changePct != null && (
<span className={`tv-quote-change ${chartLiveQuote.isUp ? 'up' : 'down'}`}>
{formatChartLiveChangePct(chartLiveQuote.changePct)}
</span>
)}
</>
)}
</div>
{/* Compare */} {/* Compare */}
<button className="tv-icon-btn" title="워치리스트" onClick={onToggleWatch}> <button className="tv-icon-btn" title="워치리스트" onClick={onToggleWatch}>
<IcPlus /> <IcPlus />
+9
View File
@@ -131,6 +131,14 @@ const IcStrategyEditor = () => (
</svg> </svg>
); );
const IcPaper = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1.5" y="4" width="13" height="9" rx="1.5"/>
<path d="M1.5 6.5h13"/>
<circle cx="8" cy="10" r="1.5"/>
</svg>
);
const IcVirtual = () => ( const IcVirtual = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="12" height="12" rx="1.5"/> <rect x="2" y="2" width="12" height="12" rx="1.5"/>
@@ -158,6 +166,7 @@ const IcVerificationBoard = () => (
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> }, { page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> }, { page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> }, { page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> }, { page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> }, { page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
@@ -7,9 +7,12 @@ import type { TickerData } from '../hooks/useMarketTicker';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext'; import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi'; import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
import { coerceFiniteNumber } from '../utils/safeFormat'; import { coerceFiniteNumber } from '../utils/safeFormat';
import TradeOrderPanel from './TradeOrderPanel'; import {
import { OrderbookPanel } from './OrderbookPanel'; TradeSplitOrderPanel,
import PaperSplitPanel from './paper/PaperSplitPanel'; TradeOrderbookPanelContent,
TradeRightPanelTabs,
TradeRightPanelBody,
} from './trade';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import { useAppSettings } from '../hooks/useAppSettings'; import { useAppSettings } from '../hooks/useAppSettings';
@@ -504,93 +507,52 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</svg> </svg>
</button> </button>
<aside <aside
className={`tnl-right${rightOpen ? ' tnl-right--open' : ''}`} className={`tnl-right trade-right-panel${rightOpen ? ' tnl-right--open' : ''}`}
aria-label="매매·호가" aria-label="매매·호가"
aria-hidden={!rightOpen} aria-hidden={!rightOpen}
> >
<div className="bps-right-tabs tnl-right-tabs"> <TradeRightPanelTabs
<button activeTab={rightTab}
type="button" onTabChange={setRightTab}
className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} className="tnl-right-tabs"
onClick={() => setRightTab('trade')} />
> <TradeRightPanelBody
anchorRef={orderAnchorRef}
</button> bodyClassName="tnl-right-body"
<button >
type="button"
className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`}
onClick={() => setRightTab('orderbook')}
>
</button>
</div>
<div className="tnl-right-body ptd-right-body" ref={orderAnchorRef}>
{rightTab === 'trade' ? ( {rightTab === 'trade' ? (
<PaperSplitPanel <TradeSplitOrderPanel
className="ptd-split-panel--right" market={selectedMarket}
topTitle="매수" tradePrice={tradePrice}
bottomTitle="매도" availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
top={( fillBuy={fillBuy}
<div className="ptd-order-card"> fillSell={fillSell}
<TradeOrderPanel searchAnchorRef={orderAnchorRef}
side="buy" onMarketSelect={m => handleSelectMarket(m)}
market={selectedMarket} paperTradingEnabled={paperTradingEnabled}
tradePrice={tradePrice} paperAutoTradeEnabled={paperAutoTradeEnabled}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0} onPaperOrderFilled={() => void refreshPaperData()}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={m => handleSelectMarket(m)}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData()}
/>
</div>
)}
bottom={(
<div className="ptd-order-card">
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={m => handleSelectMarket(m)}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData()}
/>
</div>
)}
/> />
) : ( ) : (
<div className="rsp-ob-stack ptd-ob-stack--fill"> <TradeOrderbookPanelContent
<div className="rsp-trade-card rsp-ob-card"> market={selectedMarket}
<div className="rsp-trade-card-title rsp-ob-card-title"> </div> asks={orderbook.asks}
<div className="rsp-trade-card-body rsp-ob-card-body"> bids={orderbook.bids}
<OrderbookPanel totalAskSize={orderbook.totalAskSize}
market={selectedMarket} totalBidSize={orderbook.totalBidSize}
asks={orderbook.asks} wsStatus={obWsStatus}
bids={orderbook.bids} bestAsk={spread.bestAsk}
totalAskSize={orderbook.totalAskSize} bestBid={spread.bestBid}
totalBidSize={orderbook.totalBidSize} spread={spread.spread}
wsStatus={obWsStatus} spreadPct={spread.pct}
bestAsk={spread.bestAsk} prevClose={orderbookPrevClose}
bestBid={spread.bestBid} tickerInfo={orderbookTickerInfo}
spread={spread.spread} recentTrades={recentTrades}
spreadPct={spread.pct} tradeStrength={tradeStrength ?? undefined}
prevClose={orderbookPrevClose} onRowClick={handleOrderbookRowClick}
tickerInfo={orderbookTickerInfo} />
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
)} )}
</div> </TradeRightPanelBody>
</aside> </aside>
</div> </div>
</div> </div>
+88 -70
View File
@@ -17,6 +17,8 @@ export interface TradeOrderPanelProps {
/** 현재가 (KRW) — 종목 변경 시 초기값 */ /** 현재가 (KRW) — 종목 변경 시 초기값 */
tradePrice: number | null; tradePrice: number | null;
availableKrw?: number; availableKrw?: number;
/** 종목별 추가 매수 가능 (한도 기준) */
availableSymbolBuyKrw?: number;
/** 매도 시 보유 수량 (모의투자) */ /** 매도 시 보유 수량 (모의투자) */
availableCoinQty?: number; availableCoinQty?: number;
/** 호가·차트에서 자동 입력 */ /** 호가·차트에서 자동 입력 */
@@ -89,6 +91,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
market, market,
tradePrice, tradePrice,
availableKrw = 0, availableKrw = 0,
availableSymbolBuyKrw,
availableCoinQty = 0, availableCoinQty = 0,
fillRequest, fillRequest,
searchAnchorRef, searchAnchorRef,
@@ -99,6 +102,9 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
onPaperOrderFilled, onPaperOrderFilled,
}) => { }) => {
const isBuy = side === 'buy'; const isBuy = side === 'buy';
const buyBudgetKrw = isBuy && availableSymbolBuyKrw != null
? Math.min(availableKrw, availableSymbolBuyKrw)
: availableKrw;
const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market; const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market;
const code = coinCode(displayMarket); const code = coinCode(displayMarket);
const koreanName = getKoreanName(displayMarket); const koreanName = getKoreanName(displayMarket);
@@ -156,8 +162,8 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const applyPct = useCallback((pct: number) => { const applyPct = useCallback((pct: number) => {
setPctMode(pct); setPctMode(pct);
if (isBuy) { if (isBuy) {
if (orderKind === 'market' || availableKrw <= 0) return; if (orderKind === 'market' || buyBudgetKrw <= 0) return;
const budget = (availableKrw * pct) / 100; const budget = (buyBudgetKrw * pct) / 100;
const p = orderKind === 'limit' && price > 0 ? price : refPrice; const p = orderKind === 'limit' && price > 0 ? price : refPrice;
if (p > 0) { if (p > 0) {
const q = budget / p; const q = budget / p;
@@ -168,7 +174,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
if (availableCoinQty <= 0) return; if (availableCoinQty <= 0) return;
const q = (availableCoinQty * pct) / 100; const q = (availableCoinQty * pct) / 100;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8)); 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 bumpPrice = useCallback((delta: number) => {
const base = price > 0 ? price : refPrice; const base = price > 0 ? price : refPrice;
@@ -202,26 +208,36 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
return; return;
} }
try { try {
const trade = await placePaperOrder({ const result = await placePaperOrder({
market: displayMarket, market: displayMarket,
side: isBuy ? 'BUY' : 'SELL', side: isBuy ? 'BUY' : 'SELL',
orderKind: orderKind === 'market' ? 'market' : 'limit', orderKind: orderKind === 'market' ? 'market' : 'limit',
orderType: orderKind === 'market' ? 'MARKET' : 'LIMIT',
price: execPrice, price: execPrice,
quantity: qty, quantity: qty,
source: 'MANUAL', source: 'MANUAL',
}); });
if (!trade) { if (!result?.trade && !result?.order) {
window.alert('모의 주문에 실패했습니다.'); window.alert('모의 주문에 실패했습니다.');
return; return;
} }
onPaperOrderFilled?.(); onPaperOrderFilled?.();
window.alert( if (result.order) {
`[모의투자] ${koreanName || code} ${label} 체결\n` + window.alert(
`가격: ${fmtKrw(trade.price)} KRW\n` + `[모의투자] ${koreanName || code} ${label} 미체결 등록\n` +
`수량: ${trade.quantity} ${code}\n` + `지정가: ${fmtKrw(result.order.limitPrice ?? execPrice)} KRW\n` +
`수료: ${fmtKrw(trade.feeAmount)} KRW\n` + `: ${result.order.quantity} ${code}`,
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`, );
); } 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(''); setQtyStr('');
setPctMode(null); setPctMode(null);
} catch (e) { } catch (e) {
@@ -245,8 +261,12 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const minOrder = 5000; const minOrder = 5000;
const feeRate = 0.05; const feeRate = 0.05;
const balanceDisplay = isBuy
? `${buyBudgetKrw > 0 ? fmtKrw(buyBudgetKrw) : '-'} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`;
return ( return (
<div className={`top-panel top-panel--${side}`}> <div className={`top-panel top-panel--upbit top-panel--${side}`}>
<div className="top-panel-fields"> <div className="top-panel-fields">
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && ( {paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
<p className="top-paper-hint"> OFF · .</p> <p className="top-paper-hint"> OFF · .</p>
@@ -317,71 +337,70 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
</div> </div>
</div> </div>
<div className="top-row top-row--balance"> <div
className="top-row top-row--balance"
title={isBuy && availableSymbolBuyKrw != null
? `종목별 한도 ${fmtKrw(availableSymbolBuyKrw)} KRW`
: undefined}
>
<span className="top-label">{isBuy ? '주문가능' : '보유수량'}</span> <span className="top-label">{isBuy ? '주문가능' : '보유수량'}</span>
<span className="top-balance"> <span className="top-balance">{balanceDisplay}</span>
{isBuy
? `${fmtKrw(availableKrw)} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
</span>
</div> </div>
<div className="top-price-qty-block"> <div className="top-field top-field--price">
<div className="top-field top-field--price"> <label className="top-label">{priceLabel} (KRW)</label>
<label className="top-label">{priceLabel} (KRW)</label> <div className="top-input-group">
<div className="top-input-group">
<input
type="text"
className="top-input"
value={priceStr}
disabled={orderKind === 'market'}
onChange={handlePriceChange}
inputMode="numeric"
pattern="[0-9,]*"
autoComplete="off"
/>
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}></button>
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
</div>
</div>
<div className="top-field top-field--qty">
<label className="top-label"> ({code})</label>
<input <input
type="text" type="text"
className="top-input top-input--full" className="top-input"
value={qtyStr} value={priceStr}
disabled={orderKind === 'market'} disabled={orderKind === 'market'}
onChange={handleQtyChange} onChange={handlePriceChange}
inputMode="decimal" inputMode="numeric"
pattern="[0-9.]*" pattern="[0-9,]*"
autoComplete="off" autoComplete="off"
placeholder="0"
/> />
</div> <button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}></button>
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
<div className="top-pct-row top-pct-row--block">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
onClick={() => applyPct(p)}
>
{p === 100 ? '최대' : `${p}%`}
</button>
))}
<button
type="button"
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
onClick={() => setPctMode(-1)}
>
</button>
</div> </div>
</div> </div>
<div className="top-field"> <div className="top-field top-field--qty">
<label className="top-label"> ({code})</label>
<input
type="text"
className="top-input top-input--full"
value={qtyStr}
disabled={orderKind === 'market'}
onChange={handleQtyChange}
inputMode="decimal"
pattern="[0-9.]*"
autoComplete="off"
placeholder="0"
/>
</div>
<div className="top-pct-row top-pct-row--under-qty">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
onClick={() => applyPct(p)}
>
{`${p}%`}
</button>
))}
<button
type="button"
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
onClick={() => setPctMode(-1)}
>
</button>
</div>
<div className="top-field top-field--total">
<label className="top-label"> (KRW)</label> <label className="top-label"> (KRW)</label>
<input <input
type="text" type="text"
@@ -393,11 +412,10 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
<div className="top-meta"> <div className="top-meta">
<span className="top-meta-line"> <span className="top-meta-line">
<span>조건: 보통 </span>
<span className="top-meta-sep">·</span> <span className="top-meta-sep">·</span>
<span> {fmtKrw(minOrder)}</span> <span>: {fmtKrw(minOrder)} KRW</span>
<span className="top-meta-sep">·</span> <span className="top-meta-sep">·</span>
<span> {feeRate}%</span> <span>( ): {feeRate}%</span>
</span> </span>
</div> </div>
</div> </div>
+32 -67
View File
@@ -15,9 +15,7 @@ import {
} from '../utils/backendApi'; } from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types'; import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker'; import type { TickerData } from '../hooks/useMarketTicker';
import TradeOrderPanel from './TradeOrderPanel'; import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList'; import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
@@ -423,7 +421,7 @@ const VirtualTradingPage: React.FC<Props> = ({
leftDefaultWidth={380} leftDefaultWidth={380}
leftCollapsedStorageKey="vtd-left-open" leftCollapsedStorageKey="vtd-left-open"
rightStorageKey="vtd-right-width" rightStorageKey="vtd-right-width"
rightDefaultWidth={380} rightDefaultWidth={440}
rightCollapsedStorageKey="vtd-right-open" rightCollapsedStorageKey="vtd-right-open"
left={( left={(
<VirtualLeftTargetPanel <VirtualLeftTargetPanel
@@ -466,71 +464,38 @@ const VirtualTradingPage: React.FC<Props> = ({
)} )}
rightTabs={rightTabs} rightTabs={rightTabs}
right={( right={(
<div className="ptd-right-body" ref={orderAnchorRef}> <TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? ( {rightTab === 'trade' ? (
<PaperSplitPanel <TradeSplitOrderPanel
className="ptd-split-panel--right" market={selectedMarket}
topTitle="매수" tradePrice={tradePrice}
bottomTitle="매도" availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
top={( fillBuy={fillBuy}
<div className="ptd-order-card"> fillSell={fillSell}
<TradeOrderPanel searchAnchorRef={orderAnchorRef}
side="buy" onMarketSelect={handleSelectMarket}
market={selectedMarket} paperTradingEnabled={paperTradingEnabled}
tradePrice={tradePrice} paperAutoTradeEnabled={paperAutoTradeEnabled}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0} onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
</div>
)}
bottom={(
<div className="ptd-order-card">
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={handleSelectMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
</div>
)}
/> />
) : rightTab === 'orderbook' ? ( ) : rightTab === 'orderbook' ? (
<div className="rsp-ob-stack ptd-ob-stack--fill"> <TradeOrderbookPanelContent
<div className="rsp-trade-card rsp-ob-card"> market={selectedMarket}
<div className="rsp-trade-card-title rsp-ob-card-title"> </div> asks={orderbook.asks}
<div className="rsp-trade-card-body rsp-ob-card-body"> bids={orderbook.bids}
<OrderbookPanel totalAskSize={orderbook.totalAskSize}
market={selectedMarket} totalBidSize={orderbook.totalBidSize}
asks={orderbook.asks} wsStatus={obWsStatus}
bids={orderbook.bids} bestAsk={spread.bestAsk}
totalAskSize={orderbook.totalAskSize} bestBid={spread.bestBid}
totalBidSize={orderbook.totalBidSize} spread={spread.spread}
wsStatus={obWsStatus} spreadPct={spread.pct}
bestAsk={spread.bestAsk} prevClose={orderbookPrevClose}
bestBid={spread.bestBid} tickerInfo={orderbookTickerInfo}
spread={spread.spread} recentTrades={recentTrades}
spreadPct={spread.pct} tradeStrength={tradeStrength ?? undefined}
prevClose={orderbookPrevClose} onRowClick={handleOrderbookRowClick}
tickerInfo={orderbookTickerInfo} />
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
) : ( ) : (
<PaperTradeHistoryList <PaperTradeHistoryList
trades={trades} trades={trades}
@@ -541,7 +506,7 @@ const VirtualTradingPage: React.FC<Props> = ({
className="ptd-trade-history--fill" className="ptd-trade-history--fill"
/> />
)} )}
</div> </TradeRightPanelBody>
)} )}
/> />
); );
@@ -377,7 +377,7 @@ export default function BuilderPageShell({
<div className={`bps-side-wrap bps-side-wrap--right${rightOpen ? ' bps-side-wrap--open' : ''}`}> <div className={`bps-side-wrap bps-side-wrap--right${rightOpen ? ' bps-side-wrap--open' : ''}`}>
<PanelCollapseHandle side="right" open={rightOpen} onToggle={toggleRight} label="우측 패널" /> <PanelCollapseHandle side="right" open={rightOpen} onToggle={toggleRight} label="우측 패널" />
<aside <aside
className={`bps-right bps-right--collapsible${rightOpen ? ' bps-right--collapsible--open' : ''}`} className={`bps-right trade-right-panel bps-right--collapsible${rightOpen ? ' bps-right--collapsible--open' : ''}`}
> >
{rightTabs ?? (rightTitle ? ( {rightTabs ?? (rightTitle ? (
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}> <div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
@@ -397,7 +397,7 @@ export default function BuilderPageShell({
onPointerDown={handleRightSplitter} onPointerDown={handleRightSplitter}
/> />
<aside <aside
className="bps-right" className="bps-right trade-right-panel"
style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }} style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}
> >
{rightTabs ?? (rightTitle ? ( {rightTabs ?? (rightTitle ? (
@@ -0,0 +1,84 @@
import React, { useCallback } from 'react';
import type { PaperAllocationDto } from '../../utils/backendApi';
import { patchPaperAllocation } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
interface Props {
allocations: PaperAllocationDto[];
selectedMarket?: string;
onSelectMarket?: (market: string) => void;
onChanged?: () => void;
}
const PaperAllocationTable: React.FC<Props> = ({
allocations,
selectedMarket,
onSelectMarket,
onChanged,
}) => {
const toggleActive = useCallback(async (row: PaperAllocationDto) => {
await patchPaperAllocation(row.symbol, { isActive: !row.isActive });
onChanged?.();
}, [onChanged]);
if (!allocations.length) {
return <p className="ptd-muted"> . .</p>;
}
return (
<div className="ptd-alloc-table-wrap">
<table className="ptd-table ptd-alloc-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{allocations.map(row => {
const ret = row.symbolReturnPct ?? 0;
const selected = row.symbol === selectedMarket;
return (
<tr
key={row.symbol}
className={selected ? 'ptd-alloc-row--selected' : ''}
onClick={() => onSelectMarket?.(row.symbol)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') onSelectMarket?.(row.symbol); }}
>
<td>{row.koreanName || coinCode(row.symbol)}</td>
<td>{(row.weightPct ?? 0).toFixed(1)}%</td>
<td>{fmtKrw(row.maxInvestKrw)}</td>
<td>{fmtKrw(row.investedKrw ?? 0)}</td>
<td>{fmtKrw(row.evalAmount ?? 0)}</td>
<td className={ret >= 0 ? 'up' : 'down'}>{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%</td>
<td onClick={e => e.stopPropagation()}>
<button
type="button"
className={`ptd-alloc-toggle${row.isActive !== false ? ' ptd-alloc-toggle--on' : ''}`}
onClick={() => void toggleActive(row)}
title={row.isActive !== false ? '신규 매수 허용' : '신규 매수 차단'}
>
{row.isActive !== false ? 'ON' : 'OFF'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default PaperAllocationTable;
@@ -0,0 +1,62 @@
import React, { useEffect, useMemo, useState } from 'react';
import { loadPaperDailySnapshots, type PaperDailySnapshotDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
interface Props {
refreshKey?: number;
}
const PaperAssetTrendChart: React.FC<Props> = ({ refreshKey = 0 }) => {
const [snapshots, setSnapshots] = useState<PaperDailySnapshotDto[]>([]);
useEffect(() => {
let cancelled = false;
void loadPaperDailySnapshots().then(rows => {
if (!cancelled) setSnapshots(rows);
});
return () => { cancelled = true; };
}, [refreshKey]);
const { path, min, max, last } = useMemo(() => {
if (snapshots.length < 2) return { path: '', min: 0, max: 0, last: 0 };
const vals = snapshots.map(s => s.totalAsset);
const mn = Math.min(...vals);
const mx = Math.max(...vals);
const w = 280;
const h = 48;
const pad = 4;
const range = mx - mn || 1;
const pts = snapshots.map((s, i) => {
const x = pad + (i / (snapshots.length - 1)) * (w - pad * 2);
const y = h - pad - ((s.totalAsset - mn) / range) * (h - pad * 2);
return `${x},${y}`;
});
return { path: `M ${pts.join(' L ')}`, min: mn, max: mx, last: vals[vals.length - 1] };
}, [snapshots]);
if (snapshots.length < 2) {
return (
<div className="ptd-asset-trend ptd-asset-trend--empty">
<span className="ptd-muted"> .</span>
</div>
);
}
return (
<div className="ptd-asset-trend">
<div className="ptd-asset-trend-head">
<span> </span>
<span className="ptd-asset-trend-last">{fmtKrw(last)}</span>
</div>
<svg viewBox="0 0 280 48" className="ptd-asset-trend-svg" preserveAspectRatio="none">
<path d={path} fill="none" stroke="var(--gc-trade-buy, #ef5350)" strokeWidth="1.5" />
</svg>
<div className="ptd-asset-trend-range">
<span>{fmtKrw(min)}</span>
<span>{fmtKrw(max)}</span>
</div>
</div>
);
};
export default PaperAssetTrendChart;
@@ -0,0 +1,47 @@
import React from 'react';
import type { PaperSummaryDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
interface Props {
summary: PaperSummaryDto | null;
}
const PaperInvestmentKpi: React.FC<Props> = ({ summary: s }) => {
if (!s) return <p className="ptd-muted"> </p>;
const retTone = (s.totalReturnPct ?? 0) >= 0 ? 'up' : 'down';
const unTone = (s.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down';
return (
<div className="ptd-invest-kpi">
<div className="ptd-invest-kpi-row ptd-invest-kpi-row--main">
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.totalAsset)}</span>
</div>
<div className={`ptd-invest-kpi-cell ptd-invest-kpi-cell--${retTone}`}>
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{(s.totalReturnPct ?? 0) >= 0 ? '+' : ''}{(s.totalReturnPct ?? 0).toFixed(2)}%</span>
</div>
<div className={`ptd-invest-kpi-cell ptd-invest-kpi-cell--${unTone}`}>
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.unrealizedPnl)}</span>
</div>
</div>
<div className="ptd-invest-kpi-row">
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.cashBalance)}</span>
</div>
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.realizedPnl)}</span>
</div>
<div className="ptd-invest-kpi-cell">
<span className="ptd-invest-kpi-label"></span>
<span className="ptd-invest-kpi-value">{fmtKrw(s.orderableCash ?? s.cashBalance)}</span>
</div>
</div>
</div>
);
};
export default PaperInvestmentKpi;
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { loadPaperLedger, type PaperLedgerEntryDto } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
const ENTRY_LABELS: Record<string, string> = {
INITIAL: '초기 예탁',
BUY_SETTLE: '매수 결제',
SELL_SETTLE: '매도 입금',
RESET: '계좌 초기화',
FEE: '수수료',
ADJUST: '조정',
};
interface Props {
refreshKey?: number;
}
const PaperLedgerTab: React.FC<Props> = ({ refreshKey = 0 }) => {
const [entries, setEntries] = useState<PaperLedgerEntryDto[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
void loadPaperLedger({ page: 0, size: 50 }).then(page => {
if (!cancelled) {
setEntries(page.content);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [refreshKey]);
if (loading) return <p className="ptd-muted"> </p>;
if (!entries.length) return <p className="ptd-muted"> .</p>;
return (
<table className="ptd-table ptd-table--compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id}>
<td className="ptd-time">{e.createdAt?.slice(5, 16).replace('T', ' ') ?? '—'}</td>
<td>{ENTRY_LABELS[e.entryType] ?? e.entryType}</td>
<td className={e.amount >= 0 ? 'up' : 'down'}>{e.amount >= 0 ? '+' : ''}{fmtKrw(e.amount)}</td>
<td>{fmtKrw(e.cashAfter)}</td>
<td>{e.symbol?.replace(/^KRW-/, '') ?? '—'}</td>
</tr>
))}
</tbody>
</table>
);
};
export default PaperLedgerTab;
@@ -6,9 +6,10 @@ import PaperSplitPanel from './PaperSplitPanel';
interface Props { interface Props {
onSettingsSaved?: () => void; onSettingsSaved?: () => void;
onAccountReset?: () => void; onAccountReset?: () => void;
onOpenSettings?: () => void;
} }
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset }) => { const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset, onOpenSettings }) => {
const { defaults, save, isLoaded } = useAppSettings(); const { defaults, save, isLoaded } = useAppSettings();
if (!isLoaded) { if (!isLoaded) {
@@ -28,6 +29,11 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
const top = ( const top = (
<> <>
{onOpenSettings && (
<button type="button" className="bps-btn bps-btn--ghost ptd-left-settings-link" onClick={onOpenSettings}>
</button>
)}
<div className="ptd-left-section"> <div className="ptd-left-section">
<label className="ptd-left-row"> <label className="ptd-left-row">
<span> </span> <span> </span>
@@ -0,0 +1,58 @@
import React, { useCallback } from 'react';
import type { PaperOrderDto } from '../../utils/backendApi';
import { cancelPaperOrder } from '../../utils/backendApi';
import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
interface Props {
orders: PaperOrderDto[];
onChanged?: () => void;
}
const PaperPendingOrdersList: React.FC<Props> = ({ orders, onChanged }) => {
const handleCancel = useCallback(async (id: number) => {
if (!window.confirm('미체결 주문을 취소할까요?')) return;
await cancelPaperOrder(id);
onChanged?.();
}, [onChanged]);
if (!orders.length) {
return <p className="ptd-muted"> .</p>;
}
return (
<table className="ptd-table ptd-table--compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{orders.map(o => (
<tr key={o.id}>
<td>{coinCode(o.symbol)}</td>
<td className={o.side === 'BUY' ? 'up' : 'down'}>{o.side === 'BUY' ? '매수' : '매도'}</td>
<td>{fmtKrw(o.limitPrice ?? 0)}</td>
<td>{o.quantity}</td>
<td>{o.side === 'BUY' ? fmtKrw(o.reservedCash) : o.reservedQty}</td>
<td>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void handleCancel(o.id)}>
</button>
</td>
</tr>
))}
</tbody>
</table>
);
};
export default PaperPendingOrdersList;
@@ -129,6 +129,20 @@ const PaperTradeHistoryList: React.FC<Props> = ({
{strategyName} {strategyName}
</span> </span>
</div> </div>
{!isBuy && t.realizedPnlDelta != null && (
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"></span>
<span className={`vtd-trade-meta-value ${t.realizedPnlDelta >= 0 ? 'vtd-target-up' : 'vtd-target-dn'}`}>
{t.realizedPnlDelta >= 0 ? '+' : ''}{fmtKrw(t.realizedPnlDelta)}
</span>
</div>
)}
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"> </span>
<span className="vtd-trade-meta-value">{fmtKrw(t.cashAfter)}</span>
</div>
</div> </div>
); );
})} })}
@@ -0,0 +1,21 @@
/**
* 우측 매매 패널 — 매수 주문 화면 (공용)
*/
import React from 'react';
import TradeOrderPanel, { type TradeOrderPanelProps } from '../TradeOrderPanel';
export type TradeBuyOrderPanelProps = Omit<
TradeOrderPanelProps,
'side' | 'availableCoinQty'
>;
const TradeBuyOrderPanel: React.FC<TradeBuyOrderPanelProps> = ({
showSymbolField = true,
...props
}) => (
<div className="ptd-order-card ptd-order-card--trade-inline">
<TradeOrderPanel side="buy" showSymbolField={showSymbolField} {...props} />
</div>
);
export default TradeBuyOrderPanel;
@@ -0,0 +1,39 @@
/**
* 우측 호가 탭 — 실시간 호가 카드 (공용)
*/
import React from 'react';
import { OrderbookPanel } from '../OrderbookPanel';
import type { OrderbookDisplayUnit, WsStatus } from '../../hooks/useUpbitOrderbook';
import type { OrderbookTickerInfo } from '../OrderbookPanel';
import type { RecentTrade } from '../../hooks/useUpbitRecentTrades';
export interface TradeOrderbookPanelContentProps {
market: string;
asks: OrderbookDisplayUnit[];
bids: OrderbookDisplayUnit[];
totalAskSize: number;
totalBidSize: number;
wsStatus: WsStatus;
bestAsk: number;
bestBid: number;
spread: number;
spreadPct: number;
prevClose: number;
tickerInfo?: OrderbookTickerInfo;
recentTrades?: RecentTrade[];
tradeStrength?: number;
onRowClick?: (price: number, rowType: 'ask' | 'bid') => void;
}
const TradeOrderbookPanelContent: React.FC<TradeOrderbookPanelContentProps> = (props) => (
<div className="rsp-ob-stack ptd-ob-stack--fill">
<div className="rsp-trade-card rsp-ob-card">
<div className="rsp-trade-card-title rsp-ob-card-title"> </div>
<div className="rsp-trade-card-body rsp-ob-card-body">
<OrderbookPanel {...props} />
</div>
</div>
</div>
);
export default TradeOrderbookPanelContent;
@@ -0,0 +1,40 @@
/**
* 우측 패널 본문 — bps-right-body + ptd-right-body (paperDashboard compact 매매 UI)
*/
import React from 'react';
interface Props {
children: React.ReactNode;
className?: string;
bodyClassName?: string;
anchorRef?: React.Ref<HTMLDivElement>;
/** BuilderPageShell이 이미 bps-right-body를 제공할 때 (모의·가상매매) */
embeddedInShell?: boolean;
}
const TradeRightPanelBody: React.FC<Props> = ({
children,
className = '',
bodyClassName = '',
anchorRef,
embeddedInShell = false,
}) => {
const inner = (
<div
className={`ptd-right-body${bodyClassName ? ` ${bodyClassName}` : ''}`}
ref={anchorRef}
>
{children}
</div>
);
if (embeddedInShell) {
return inner;
}
return (
<div className={`bps-right-body${className ? ` ${className}` : ''}`}>
{inner}
</div>
);
};
export default TradeRightPanelBody;
@@ -0,0 +1,33 @@
/**
* 우측 패널 — 매매 / 호가 탭 (알림목록·모의투자·실시간 차트 공용)
*/
import React from 'react';
export type TradeRightPanelTab = 'trade' | 'orderbook';
interface Props {
activeTab: TradeRightPanelTab;
onTabChange: (tab: TradeRightPanelTab) => void;
className?: string;
}
const TradeRightPanelTabs: React.FC<Props> = ({ activeTab, onTabChange, className = '' }) => (
<div className={`bps-right-tabs${className ? ` ${className}` : ''}`}>
<button
type="button"
className={`bps-right-tab${activeTab === 'trade' ? ' bps-right-tab--on' : ''}`}
onClick={() => onTabChange('trade')}
>
</button>
<button
type="button"
className={`bps-right-tab${activeTab === 'orderbook' ? ' bps-right-tab--on' : ''}`}
onClick={() => onTabChange('orderbook')}
>
</button>
</div>
);
export default TradeRightPanelTabs;
@@ -0,0 +1,21 @@
/**
* 우측 매매 패널 — 매도 주문 화면 (공용)
*/
import React from 'react';
import TradeOrderPanel, { type TradeOrderPanelProps } from '../TradeOrderPanel';
export type TradeSellOrderPanelProps = Omit<
TradeOrderPanelProps,
'side' | 'availableKrw' | 'availableSymbolBuyKrw'
>;
const TradeSellOrderPanel: React.FC<TradeSellOrderPanelProps> = ({
showSymbolField = false,
...props
}) => (
<div className="ptd-order-card ptd-order-card--trade-inline">
<TradeOrderPanel side="sell" showSymbolField={showSymbolField} {...props} />
</div>
);
export default TradeSellOrderPanel;
@@ -0,0 +1,83 @@
/**
* 우측 매매 탭 — 상단 매수 / 하단 매도 분할 레이아웃 (알림목록·모의투자·가상매매·차트 공용)
*/
import React from 'react';
import type { TradeOrderFillRequest } from '../../types';
import PaperSplitPanel from '../paper/PaperSplitPanel';
import TradeBuyOrderPanel from './TradeBuyOrderPanel';
import TradeSellOrderPanel from './TradeSellOrderPanel';
export interface TradeSplitOrderPanelProps {
market: string;
tradePrice: number | null;
availableKrw?: number;
/** 종목별 추가 매수 가능 (모의투자 한도) */
availableSymbolBuyKrw?: number;
availableCoinQty?: number;
fillBuy?: TradeOrderFillRequest | null;
fillSell?: TradeOrderFillRequest | null;
/** 단일 fill (차트 호가 연동 — side에 따라 매수/매도에만 적용) */
fillRequest?: TradeOrderFillRequest | null;
searchAnchorRef?: React.RefObject<HTMLElement | null>;
onMarketSelect?: (market: string) => void;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
className?: string;
}
const TradeSplitOrderPanel: React.FC<TradeSplitOrderPanelProps> = ({
market,
tradePrice,
availableKrw = 0,
availableSymbolBuyKrw,
availableCoinQty = 0,
fillBuy,
fillSell,
fillRequest,
searchAnchorRef,
onMarketSelect,
paperTradingEnabled = false,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
className = '',
}) => {
const resolvedBuyFill = fillBuy ?? (fillRequest?.side === 'buy' ? fillRequest : null);
const resolvedSellFill = fillSell ?? (fillRequest?.side === 'sell' ? fillRequest : null);
return (
<PaperSplitPanel
className={`ptd-split-panel--right${className ? ` ${className}` : ''}`}
topTitle="매수"
bottomTitle="매도"
top={(
<TradeBuyOrderPanel
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableSymbolBuyKrw={availableSymbolBuyKrw}
fillRequest={resolvedBuyFill}
searchAnchorRef={searchAnchorRef}
onMarketSelect={onMarketSelect}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
bottom={(
<TradeSellOrderPanel
market={market}
tradePrice={tradePrice}
availableCoinQty={availableCoinQty}
fillRequest={resolvedSellFill}
onMarketSelect={onMarketSelect}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
/>
);
};
export default TradeSplitOrderPanel;
+11
View File
@@ -0,0 +1,11 @@
export { default as TradeBuyOrderPanel } from './TradeBuyOrderPanel';
export type { TradeBuyOrderPanelProps } from './TradeBuyOrderPanel';
export { default as TradeSellOrderPanel } from './TradeSellOrderPanel';
export type { TradeSellOrderPanelProps } from './TradeSellOrderPanel';
export { default as TradeSplitOrderPanel } from './TradeSplitOrderPanel';
export type { TradeSplitOrderPanelProps } from './TradeSplitOrderPanel';
export { default as TradeOrderbookPanelContent } from './TradeOrderbookPanelContent';
export type { TradeOrderbookPanelContentProps } from './TradeOrderbookPanelContent';
export { default as TradeRightPanelTabs } from './TradeRightPanelTabs';
export type { TradeRightPanelTab } from './TradeRightPanelTabs';
export { default as TradeRightPanelBody } from './TradeRightPanelBody';
@@ -1,8 +1,9 @@
/** /**
* 검증 이슈 첨부 이미지 — 파일 선택·드래그·클립보드 붙여넣기 * 검증 이슈 첨부 이미지 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기
* issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드) * issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드)
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ScreenRegionCaptureOverlay from '../ScreenRegionCaptureOverlay';
import { import {
deleteVerificationIssueImage, deleteVerificationIssueImage,
loadVerificationIssueImages, loadVerificationIssueImages,
@@ -68,6 +69,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null); const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
const [screenCaptureOpen, setScreenCaptureOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const zoneRef = useRef<HTMLDivElement>(null); const zoneRef = useRef<HTMLDivElement>(null);
@@ -170,6 +172,11 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
} }
}, [uploadFiles]); }, [uploadFiles]);
const handleScreenCapture = useCallback((file: File) => {
setScreenCaptureOpen(false);
void uploadFiles([file]);
}, [uploadFiles]);
const handleDeleteServer = async (imageId: number) => { const handleDeleteServer = async (imageId: number) => {
if (issueId == null) return; if (issueId == null) return;
if (!window.confirm('이 이미지를 삭제할까요?')) return; if (!window.confirm('이 이미지를 삭제할까요?')) return;
@@ -237,14 +244,25 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
<h3 className="vbd-attachments-title"> <h3 className="vbd-attachments-title">
{totalCount > 0 && `(${totalCount})`} {totalCount > 0 && `(${totalCount})`}
</h3> </h3>
<button <div className="vbd-attachments-actions">
type="button" <button
className="vbd-btn vbd-btn--sm" type="button"
onClick={() => fileInputRef.current?.click()} className="vbd-btn vbd-btn--sm"
disabled={disabled || uploading} title="화면 공유 후 드래그로 영역 캡처"
> onClick={() => setScreenCaptureOpen(true)}
disabled={disabled || uploading}
</button> >
</button>
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
>
</button>
</div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -282,8 +300,8 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
) : showEmptyHint ? ( ) : showEmptyHint ? (
<p className="vbd-attach-hint"> <p className="vbd-attach-hint">
{isDraft || saveOnSubmit {isDraft || saveOnSubmit
? '저장 시 함께 반영됩니다. 드래그·우클릭 붙여넣기·파일 선택' ? '저장 시 함께 반영됩니다. 화면 캡처·드래그·우클릭 붙여넣기·파일 선택'
: '이미지를 드래그하거나, 우클릭 → 클립보드 붙여넣기, 또는 「파일 선택'} : '화면 캡처·드래그·우클릭 붙여넣기·파일 선택'}
</p> </p>
) : ( ) : (
<div className="vbd-attach-grid"> <div className="vbd-attach-grid">
@@ -385,8 +403,18 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
<button type="button" onClick={() => void pasteFromClipboard()}> <button type="button" onClick={() => void pasteFromClipboard()}>
</button> </button>
<button type="button" onClick={() => { setCtxMenu(null); setScreenCaptureOpen(true); }}>
</button>
</div> </div>
)} )}
{screenCaptureOpen && (
<ScreenRegionCaptureOverlay
onCapture={handleScreenCapture}
onCancel={() => setScreenCaptureOpen(false)}
/>
)}
</div> </div>
); );
}; };
@@ -116,8 +116,8 @@ export function useVirtualBackendTradeSignals({
source: 'STRATEGY', source: 'STRATEGY',
strategyId, strategyId,
}) })
.then(trade => { .then(result => {
if (trade) { if (result?.trade) {
lastExecRef.current[execKey] = Date.now(); lastExecRef.current[execKey] = Date.now();
onFilled?.(); onFilled?.();
} }
+36 -6
View File
@@ -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; padding: 0 10px 10px 0;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -107,7 +112,8 @@
overflow: hidden; overflow: hidden;
} }
.bps-panel-body--tabs > .ptd-split-panel--left, .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; flex: 1;
min-height: 0; min-height: 0;
} }
@@ -246,7 +252,8 @@
margin-top: 4px; 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; flex: 1 1 0;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
@@ -257,11 +264,13 @@
padding: 0; 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; 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; flex: 1 1 0;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
@@ -415,6 +424,27 @@
container-name: trade-card; 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 { .bps-footer .ptd-metrics-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -934,10 +934,7 @@
min-width: 0; min-width: 0;
} }
.tnl-side-wrap--right .tnl-right-tabs, /* 우측 패널 너비: styles/tradeRightPanel.css (--gc-trade-right-panel-*) */
.tnl-side-wrap--right .tnl-right-body {
min-width: min(380px, 38vw);
}
.tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-tabs, .tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-tabs,
.tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-body { .tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-body {
+389
View File
@@ -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;
}
+78
View File
@@ -713,6 +713,84 @@
gap: 8px; 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 { .vbd-attachments-title {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
+105 -163
View File
@@ -2795,188 +2795,130 @@
opacity: 0.5; opacity: 0.5;
} }
/* ── 우측 매매 패널: 매수·매도 카드 간격·타이포 ── */ /* 우측 매매 패널 타이포·레이아웃: styles/tradeRightPanel.css (.trade-right-panel) */
.bps-page--vtd .ptd-split-panel--right .ptd-split-card-body {
padding: 8px 12px 10px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card > .top-panel { /* ── 모의투자 투자금 관리 (P1) ── */
gap: 0; .ptd-center-head {
justify-content: flex-start; display: flex;
padding: 6px 6px 0; align-items: center;
height: 100%; justify-content: space-between;
gap: 12px;
width: 100%;
} }
.ptd-tabs--center {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { flex-shrink: 0;
flex: 1 1 auto; }
.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; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; overflow: hidden;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 4px;
} }
.bps-page--ptd .ptd-tabs--history {
.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;
flex-shrink: 0; flex-shrink: 0;
} padding: 8px 10px 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 {
gap: 6px; gap: 6px;
} }
.bps-page--ptd .ptd-right-history-body {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-kind-btn { flex: 1;
padding: 7px 5px; 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; font-size: 11px;
opacity: 0.7;
} }
.ptd-invest-kpi-value {
.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;
font-size: 15px; font-size: 15px;
font-weight: 600;
} }
.ptd-alloc-table-wrap {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-balance { overflow: auto;
font-size: 13px; flex: 1;
}
/* paperDashboard 2열 그리드보다 우선 — 항상 세로 스택 */
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 0 0 auto;
min-height: 0; 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; display: flex;
flex-direction: column; 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; gap: 10px;
} }
.ptd-asset-trend {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-reset, padding: 8px 12px;
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-submit { border-radius: 6px;
padding: 10px 14px; background: var(--gc-surface-2, rgba(255,255,255,0.04));
font-size: 14px;
} }
.ptd-asset-trend-head {
/* 카드 높이 여유 있을 때: 간격 확대 + 하단 안내 텍스트 표시 */ display: flex;
@container trade-card (min-height: 440px) { justify-content: space-between;
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { font-size: 12px;
justify-content: space-between; margin-bottom: 4px;
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-svg {
@container trade-card (min-height: 520px) { width: 100%;
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields { height: 48px;
gap: 18px; display: block;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
gap: 16px;
}
} }
.ptd-asset-trend-range {
/* container query 미지원 fallback */ display: flex;
@supports not (container-type: size) { justify-content: space-between;
@media (max-height: 820px) { font-size: 10px;
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta { opacity: 0.6;
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;
}
}
} }
+6 -2
View File
@@ -20,7 +20,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
// ── 상단 메뉴 ── // ── 상단 메뉴 ──
P('메뉴-대시보드', 'dashboard', '홈'), P('메뉴-대시보드', 'dashboard', '홈'),
P('메뉴-실시간 차트', 'chart', '실시간차트', '캔들', '차트'), P('메뉴-실시간 차트', 'chart', '실시간차트', '캔들', '차트'),
P('메뉴-가상매매', 'virtual', '가상', '모의'), P('메뉴-모의투자', 'paper', '모의투자', '투자금', '페이퍼'),
P('메뉴-모의투자-투자금 관리', 'paper', 'allocation', '한도', 'KPI'),
P('메뉴-가상매매', 'virtual', '가상', '가상매매'),
P('메뉴-추세검색', 'trend', '추세', '검색'), P('메뉴-추세검색', 'trend', '추세', '검색'),
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'), P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'), P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
@@ -64,7 +66,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
P('메뉴-설정-보조지표 설정', 'indicators', '지표기본값', 'Main탭'), P('메뉴-설정-보조지표 설정', 'indicators', '지표기본값', 'Main탭'),
P('메뉴-설정-백테스팅', 'bt옵션', '자본'), P('메뉴-설정-백테스팅', 'bt옵션', '자본'),
P('메뉴-설정-전략 설정', 'strategy', '전략기본'), P('메뉴-설정-전략 설정', 'strategy', '전략기본'),
P('메뉴-설정-가상매매', 'paper', '가상자본'), P('메뉴-설정-모의투자', 'paper', '모의투자', '모의자본', '수수료'),
P('메뉴-설정-가상매매', 'virtual', '투자대상', '한도'),
P('메뉴-설정-실거래', 'live', '업비트', 'API'),
P('메뉴-설정-추세검색', 'trend설정', '배점'), P('메뉴-설정-추세검색', 'trend설정', '배점'),
P('메뉴-설정-알림 설정', 'alert설정', '소리', '팝업'), P('메뉴-설정-알림 설정', 'alert설정', '소리', '팝업'),
P('메뉴-설정-네트워크', 'network', 'API', 'WebSocket'), P('메뉴-설정-네트워크', 'network', 'API', 'WebSocket'),
+180 -16
View File
@@ -610,21 +610,42 @@ export interface PaperPositionDto {
profitLossPct?: number; 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 { export interface PaperSummaryDto {
enabled: boolean; enabled: boolean;
initialCapital: number; initialCapital: number;
cashBalance: number; cashBalance: number;
stockEvalAmount: number; stockEvalAmount: number;
totalAsset: number; totalAsset: number;
unrealizedPnl: number; unrealizedPnl: number;
realizedPnl: number; realizedPnl: number;
totalReturnPct: number; totalReturnPct: number;
feeRatePct: number; feeRatePct: number;
slippagePct: number; slippagePct: number;
minOrderKrw: number; minOrderKrw: number;
autoTradeEnabled: boolean; autoTradeEnabled: boolean;
autoTradeBudgetPct: number; autoTradeBudgetPct: number;
positions: PaperPositionDto[]; reservedCash?: number;
orderableCash?: number;
initialCapitalSnapshot?: number;
positions: PaperPositionDto[];
allocations?: PaperAllocationDto[];
} }
export interface PaperTradeDto { export interface PaperTradeDto {
@@ -640,13 +661,79 @@ export interface PaperTradeDto {
feeAmount: number; feeAmount: number;
netAmount: number; netAmount: number;
cashAfter: number; cashAfter: number;
realizedPnlDelta?: number | null;
positionQtyAfter?: number | null;
createdAt?: string | null; createdAt?: string | null;
} }
export interface PaperPageDto<T> {
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 { export interface PaperOrderRequest {
market: string; market: string;
side: 'BUY' | 'SELL'; side: 'BUY' | 'SELL';
orderKind?: 'limit' | 'market'; orderKind?: 'limit' | 'market';
orderType?: 'MARKET' | 'LIMIT';
price: number; price: number;
quantity: number; quantity: number;
/** 수량 0일 때 매수 예산 비율 (가용현금 %) */ /** 수량 0일 때 매수 예산 비율 (가용현금 %) */
@@ -671,13 +758,90 @@ export async function loadPaperTrades(): Promise<PaperTradeDto[]> {
return (await request<PaperTradeDto[]>('/paper/trades')) ?? []; return (await request<PaperTradeDto[]>('/paper/trades')) ?? [];
} }
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperTradeDto | null> { export async function loadPaperTradesPaged(params?: {
return request<PaperTradeDto>('/paper/orders', { symbol?: string;
side?: string;
source?: string;
from?: string;
to?: string;
page?: number;
size?: number;
}): Promise<PaperPageDto<PaperTradeDto>> {
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<PaperPageDto<PaperTradeDto>>(`/paper/trades${qs ? `?${qs}` : ''}`)) ?? {
content: [], page: 0, size: 50, totalElements: 0, totalPages: 0,
};
}
export async function loadPaperAllocations(): Promise<PaperAllocationDto[]> {
return (await request<PaperAllocationDto[]>('/paper/allocations')) ?? [];
}
export async function putPaperAllocationsBulk(items: PaperAllocationBulkItem[]): Promise<PaperAllocationDto[]> {
return (await request<PaperAllocationDto[]>('/paper/allocations/bulk', {
method: 'PUT',
body: JSON.stringify({ items }),
})) ?? [];
}
export async function patchPaperAllocation(
symbol: string,
body: Partial<Pick<PaperAllocationDto, 'maxInvestKrw' | 'budgetPct' | 'strategyId' | 'isActive' | 'pinned' | 'sortOrder' | 'koreanName'>>,
): Promise<PaperAllocationDto | null> {
return request<PaperAllocationDto>(`/paper/allocations/${encodeURIComponent(symbol)}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
export async function loadPaperLedger(params?: {
from?: string;
to?: string;
page?: number;
size?: number;
}): Promise<PaperPageDto<PaperLedgerEntryDto>> {
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<PaperPageDto<PaperLedgerEntryDto>>(`/paper/ledger${qs ? `?${qs}` : ''}`)) ?? {
content: [], page: 0, size: 50, totalElements: 0, totalPages: 0,
};
}
export async function loadPaperDailySnapshots(from?: string, to?: string): Promise<PaperDailySnapshotDto[]> {
const q = new URLSearchParams();
if (from) q.set('from', from);
if (to) q.set('to', to);
const qs = q.toString();
return (await request<PaperDailySnapshotDto[]>(`/paper/snapshots/daily${qs ? `?${qs}` : ''}`)) ?? [];
}
export async function loadPaperPendingOrders(): Promise<PaperOrderDto[]> {
return (await request<PaperOrderDto[]>('/paper/orders')) ?? [];
}
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperPlaceOrderResult | null> {
return request<PaperPlaceOrderResult>('/paper/orders', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
} }
export async function cancelPaperOrder(orderId: number): Promise<PaperOrderDto | null> {
return request<PaperOrderDto>(`/paper/orders/${orderId}/cancel`, { method: 'POST' });
}
export async function resetPaperAccount(): Promise<PaperSummaryDto | null> { export async function resetPaperAccount(): Promise<PaperSummaryDto | null> {
return request<PaperSummaryDto>('/paper/reset', { method: 'POST' }); return request<PaperSummaryDto>('/paper/reset', { method: 'POST' });
} }
+7 -5
View File
@@ -9,14 +9,15 @@ export type TopMenuId = MenuPage;
export type SettingsCategoryId = export type SettingsCategoryId =
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy' | '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[] = [ 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[] = [ 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 = [ export const ALL_MENU_IDS = [
@@ -43,7 +44,9 @@ export const MENU_LABELS: Record<string, string> = {
settings_indicators: '설정 · 보조지표', settings_indicators: '설정 · 보조지표',
settings_backtest: '설정 · 백테스팅', settings_backtest: '설정 · 백테스팅',
settings_strategy: '설정 · 전략', settings_strategy: '설정 · 전략',
settings_paper: '설정 · 가상매매', settings_paper: '설정 · 모의투자',
settings_virtual: '설정 · 가상매매',
settings_live: '설정 · 실거래',
'settings_trend-search': '설정 · 추세검색', 'settings_trend-search': '설정 · 추세검색',
settings_alert: '설정 · 알림', settings_alert: '설정 · 알림',
settings_network: '설정 · 네트워크', settings_network: '설정 · 네트워크',
@@ -69,7 +72,6 @@ export function canAccessMenu(
permissions: Record<string, boolean> | null | undefined, permissions: Record<string, boolean> | null | undefined,
menuId: string, menuId: string,
): boolean { ): boolean {
if (menuId === 'paper') return false;
if (menuId === 'strategy') return false; if (menuId === 'strategy') return false;
if (!permissions) return menuId === 'chart'; if (!permissions) return menuId === 'chart';
if (menuId === 'strategy-editor') { if (menuId === 'strategy-editor') {
+93
View File
@@ -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<File> {
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<Blob | null>(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<MediaStream> {
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 '화면 캡처에 실패했습니다.';
}
+16 -1
View File
@@ -1,4 +1,4 @@
import { saveLiveStrategySettings } from './backendApi'; import { putPaperAllocationsBulk, saveLiveStrategySettings } from './backendApi';
import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin'; import { pinStrategyEvaluationTimeframes } from './strategyTimeframePin';
import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage'; import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy'; 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 */ /** 가상투자 중지 — 대상 종목 체크 해제 + 전역 liveStrategyCheck OFF */
+21 -2
View File
@@ -674,6 +674,14 @@ export interface PaperPositionDto {
profitLossPct?: number; profitLossPct?: number;
} }
export interface PaperAllocationDto {
symbol: string;
maxInvestKrw: number;
availableBuyKrw?: number;
strategyId?: number | null;
isActive?: boolean;
}
export interface PaperSummaryDto { export interface PaperSummaryDto {
enabled: boolean; enabled: boolean;
initialCapital: number; initialCapital: number;
@@ -688,7 +696,11 @@ export interface PaperSummaryDto {
minOrderKrw: number; minOrderKrw: number;
autoTradeEnabled: boolean; autoTradeEnabled: boolean;
autoTradeBudgetPct: number; autoTradeBudgetPct: number;
orderableCash?: number;
reservedCash?: number;
initialCapitalSnapshot?: number;
positions: PaperPositionDto[]; positions: PaperPositionDto[];
allocations?: PaperAllocationDto[];
} }
export interface PaperTradeDto { export interface PaperTradeDto {
@@ -704,13 +716,20 @@ export interface PaperTradeDto {
feeAmount: number; feeAmount: number;
netAmount: number; netAmount: number;
cashAfter: number; cashAfter: number;
realizedPnlDelta?: number | null;
createdAt?: string | null; createdAt?: string | null;
} }
export interface PaperPlaceOrderResult {
trade?: PaperTradeDto | null;
order?: { id: number; symbol: string; side: string; status: string } | null;
}
export interface PaperOrderRequest { export interface PaperOrderRequest {
market: string; market: string;
side: 'BUY' | 'SELL'; side: 'BUY' | 'SELL';
orderKind?: 'limit' | 'market'; orderKind?: 'limit' | 'market';
orderType?: 'MARKET' | 'LIMIT';
price: number; price: number;
quantity: number; quantity: number;
/** 수량 0일 때 매수 예산 비율 (가용현금 %) */ /** 수량 0일 때 매수 예산 비율 (가용현금 %) */
@@ -735,8 +754,8 @@ export async function loadPaperTrades(): Promise<PaperTradeDto[]> {
return (await request<PaperTradeDto[]>('/paper/trades')) ?? []; return (await request<PaperTradeDto[]>('/paper/trades')) ?? [];
} }
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperTradeDto | null> { export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperPlaceOrderResult | null> {
return request<PaperTradeDto>('/paper/orders', { return request<PaperPlaceOrderResult>('/paper/orders', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });