모의투자 관리 기능 추가

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(
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_paper", "settings_trend-search",
"settings_strategy", "settings_paper", "settings_virtual", "settings_live",
"settings_trend-search",
"settings_alert", "settings_network", "settings_admin"
);
}
@@ -1,13 +1,14 @@
package com.goldenchart.controller;
import com.goldenchart.dto.PaperOrderRequest;
import com.goldenchart.dto.PaperSummaryDto;
import com.goldenchart.dto.PaperTradeDto;
import com.goldenchart.dto.*;
import com.goldenchart.service.PaperTradingService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -39,18 +40,81 @@ public class PaperTradingController {
return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), markPrices));
}
@GetMapping("/allocations")
public ResponseEntity<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")
public ResponseEntity<List<PaperTradeDto>> trades(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listTrades(userId(h), deviceId(h)));
public ResponseEntity<?> trades(
@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")
public ResponseEntity<PaperTradeDto> placeOrder(
public ResponseEntity<PaperPlaceOrderResult> placeOrder(
@RequestHeader Map<String, String> h,
@RequestBody PaperOrderRequest 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")
public ResponseEntity<PaperSummaryDto> reset(@RequestHeader Map<String, String> 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 side; // BUY | SELL
private String orderKind; // limit | market
/** MARKET | LIMIT — 미지정 시 orderKind 기준 (limit→LIMIT, market→MARKET) */
private String orderType;
private Double price;
private Double quantity;
/** 수량 미입력 시 매수 예산 비율 (가용현금 %) — 기본값: 설정의 paperAutoTradeBudgetPct */
@@ -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 Boolean autoTradeEnabled;
private Double autoTradeBudgetPct;
private Double reservedCash;
private Double orderableCash;
private Double initialCapitalSnapshot;
private List<PaperPositionDto> positions;
private List<PaperAllocationDto> allocations;
}
@@ -18,5 +18,7 @@ public class PaperTradeDto {
private Double feeAmount;
private Double netAmount;
private Double cashAfter;
private Double realizedPnlDelta;
private Double positionQtyAfter;
private String createdAt;
}
@@ -29,6 +29,24 @@ public class GcPaperAccount {
@Builder.Default
private BigDecimal realizedPnl = BigDecimal.ZERO;
@Column(name = "reserved_cash", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal reservedCash = BigDecimal.ZERO;
@Column(name = "initial_capital_snapshot", precision = 20, scale = 2)
private BigDecimal initialCapitalSnapshot;
@Column(name = "reset_count", nullable = false)
@Builder.Default
private Integer resetCount = 0;
@Column(name = "last_reset_at")
private LocalDateTime lastResetAt;
@Column(name = "status", length = 20, nullable = false)
@Builder.Default
private String status = "ACTIVE";
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -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)
private BigDecimal cashAfter;
@Column(name = "realized_pnl_delta", precision = 20, scale = 2)
private BigDecimal realizedPnlDelta;
@Column(name = "position_qty_after", precision = 30, scale = 12)
private BigDecimal positionQtyAfter;
@Column(name = "note", length = 200)
private String note;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -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;
import com.goldenchart.entity.GcPaperTrade;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface GcPaperTradeRepository extends JpaRepository<GcPaperTrade, Long> {
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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.*;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcPaperAccount;
import com.goldenchart.entity.GcPaperPosition;
import com.goldenchart.entity.GcPaperTrade;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.repository.GcPaperAccountRepository;
import com.goldenchart.repository.GcPaperPositionRepository;
import com.goldenchart.repository.GcPaperTradeRepository;
import com.goldenchart.entity.*;
import com.goldenchart.repository.*;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 모의투자(페이퍼 트레이딩) 체결·잔고·포지션 관리.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class PaperTradingService {
private static final int SCALE_QTY = 12;
private static final int SCALE_KRW = 2;
private static final RoundingMode RM = RoundingMode.HALF_UP;
private static final int SCALE_QTY = 12;
private static final int SCALE_KRW = 2;
private static final RoundingMode RM = RoundingMode.HALF_UP;
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private final GcPaperAccountRepository accountRepo;
private final GcPaperPositionRepository positionRepo;
private final GcPaperTradeRepository tradeRepo;
private final AppSettingsService appSettingsService;
private final GcPaperAccountRepository accountRepo;
private final GcPaperPositionRepository positionRepo;
private final GcPaperTradeRepository tradeRepo;
private final GcPaperOrderRepository orderRepo;
private final GcPaperResetLogRepository resetLogRepo;
private final AppSettingsService appSettingsService;
private final GcLiveStrategySettingsRepository liveSettingsRepo;
private final PaperAllocationService allocationService;
private final PaperLedgerService ledgerService;
private final PaperSnapshotService snapshotService;
private final Ta4jStorage ta4jStorage;
private final ObjectMapper objectMapper;
// ── 조회 ─────────────────────────────────────────────────────────────────
@Transactional(readOnly = true)
public PaperSummaryDto getSummary(Long userId, String deviceId, Map<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
@@ -74,15 +84,26 @@ public class PaperTradingService {
}
double cash = account.getCashBalance().doubleValue();
double initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital().doubleValue() : 10_000_000;
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double orderable = cash - reserved;
double initialSnap = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot().doubleValue()
: (app.getPaperInitialCapital() != null
? app.getPaperInitialCapital().doubleValue() : 10_000_000);
double total = cash + stockEval;
double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0;
double returnPct = initialSnap > 0 ? (total - initialSnap) / initialSnap * 100 : 0;
List<PaperAllocationDto> allocations = allocationService.listWithMetrics(
account.getId(), markPrices, total);
return PaperSummaryDto.builder()
.enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled()))
.initialCapital(initial)
.initialCapital(initialSnap)
.cashBalance(cash)
.reservedCash(reserved)
.orderableCash(orderable)
.initialCapitalSnapshot(initialSnap)
.stockEvalAmount(stockEval)
.totalAsset(total)
.unrealizedPnl(unrealized)
@@ -95,22 +116,90 @@ public class PaperTradingService {
.autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95)
.positions(posDtos)
.allocations(allocations)
.build();
}
public List<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);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId())
.stream().map(this::toTradeDto).toList();
PaperSummaryDto s = getSummary(userId, deviceId, markPrices);
return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset());
}
public List<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);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요.");
throw new IllegalStateException("모의투자가 비활성화되어 있습니다.");
}
if (req.getMarket() == null || req.getSide() == null) {
throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다.");
@@ -124,11 +213,13 @@ public class PaperTradingService {
throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
}
String orderType = resolveOrderType(req);
String source = req.getSource() != null ? req.getSource() : "MANUAL";
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double qty = req.getQuantity() != null ? req.getQuantity() : 0;
if (qty <= 0) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
qty = resolveAutoQuantity(account, app, req.getMarket(), side, price,
req.getBudgetPct(), req.getKrwAmount());
}
@@ -136,14 +227,34 @@ public class PaperTradingService {
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
}
return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
req.getStrategyId(), price, qty, null);
if ("LIMIT".equals(orderType)) {
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
source, req.getStrategyId(), price, qty);
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
}
PaperTradeDto trade = executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
req.getStrategyId(), price, qty, null, false);
return PaperPlaceOrderResult.builder().trade(trade).build();
}
public PaperOrderDto cancelOrder(Long userId, String deviceId, Long orderId) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId,
appSettingsService.getEntity(userId, deviceId));
GcPaperOrder order = orderRepo.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));
if (!order.getAccountId().equals(account.getId())) {
throw new IllegalArgumentException("권한이 없습니다.");
}
if (!"PENDING".equals(order.getStatus())) {
throw new IllegalStateException("취소할 수 없는 주문 상태입니다.");
}
releaseReservation(account, order);
order.setStatus("CANCELLED");
orderRepo.save(order);
return toOrderDto(order);
}
/**
* 실시간 전략 시그널 발생 시 자동 모의매매.
* liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행.
*/
public void tryExecuteOnSignal(String deviceId, Long userId, String market,
Long strategyId, String signalType, double price) {
if (deviceId == null || deviceId.isBlank()) return;
@@ -166,91 +277,200 @@ public class PaperTradingService {
if ("BUY".equals(signalType)) {
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
double cash = account.getCashBalance().doubleValue();
double budget = cash * budgetPct / 100.0;
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double budget = (cash - reserved) * budgetPct / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget < minOrder) {
log.debug("[Paper] auto BUY skip: budget={} min={}", budget, minOrder);
return;
}
if (denom <= 0 || budget < minOrder) return;
double qty = budget / denom;
if (qty * execPrice < minOrder) return;
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, null);
strategyId, price, qty, null, true);
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
} else {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
if (pos == null || pos.getQuantity().doubleValue() <= 0) {
log.debug("[Paper] auto SELL skip: no position {}", market);
return;
}
double qty = pos.getQuantity().doubleValue();
if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (avail <= 0) return;
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY",
strategyId, price, qty, null);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price);
strategyId, price, avail, null, true);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
}
} catch (Exception e) {
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
}
}
/** 계좌 초기화 — 설정의 초기 자본으로 리셋 */
public void tryFillPendingOrder(Long orderId, double markPrice) {
GcPaperOrder order = orderRepo.findById(orderId).orElse(null);
if (order == null || !"PENDING".equals(order.getStatus())) return;
boolean fill = false;
double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0;
if ("BUY".equals(order.getSide())) {
fill = markPrice <= limit;
} else if ("SELL".equals(order.getSide())) {
fill = markPrice >= limit;
}
if (!fill) return;
GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null);
if (account == null) return;
GcAppSettings app = appSettingsService.getEntity(account.getUserId(), account.getDeviceId());
releaseReservation(account, order);
double qty = order.getQuantity().doubleValue();
executeTrade(account.getUserId(), account.getDeviceId(), app, order.getSymbol(),
order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(),
limit, qty, null, "STRATEGY".equals(order.getSource()));
order.setStatus("FILLED");
order.setFilledQuantity(order.getQuantity());
order.setFilledAt(LocalDateTime.now());
order.setReservedCash(BigDecimal.ZERO);
order.setReservedQty(BigDecimal.ZERO);
orderRepo.save(order);
}
public double resolveMarkPrice(String symbol) {
try {
if (!ta4jStorage.exists(symbol, "1m")) return 0;
BarSeries series = ta4jStorage.getOrCreate(symbol, "1m");
if (!series.isEmpty()) {
Bar last = series.getLastBar();
return last.getClosePrice().doubleValue();
}
} catch (Exception ignored) {
}
return 0;
}
public PaperSummaryDto resetAccount(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
BigDecimal initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
BigDecimal cashBefore = account.getCashBalance();
String positionsJson = null;
try {
List<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.setRealizedPnl(BigDecimal.ZERO);
account.setReservedCash(BigDecimal.ZERO);
account.setInitialCapitalSnapshot(initial);
account.setResetCount((account.getResetCount() != null ? account.getResetCount() : 0) + 1);
account.setLastResetAt(LocalDateTime.now());
accountRepo.save(account);
allocationService.seedFromPositions(account.getId(), initial);
log.info("[Paper] account reset device={} capital={}", deviceId, initial);
return getSummary(userId, deviceId, null);
}
/**
* 수량 미입력(0) 시 자동매매와 동일한 방식으로 수량 산출.
* 매수: 가용현금 × budgetPct(또는 krwAmount) / (체결가 × (1+수수료))
* 매도: 보유수량 × budgetPct
*/
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app,
String market, String side,
double price, Double budgetPct, Double krwAmount) {
// ── 내부 ─────────────────────────────────────────────────────────────────
private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app,
String market, String side, String orderKind,
String source, Long strategyId,
double limitPrice, double qty) {
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
double execPrice = "BUY".equals(side)
? limitPrice * (1 + slip / 100.0) : limitPrice * (1 - slip / 100.0);
BigDecimal qtyBd = BigDecimal.valueOf(qty).setScale(SCALE_QTY, RM);
BigDecimal priceBd = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM);
BigDecimal gross = priceBd.multiply(qtyBd).setScale(SCALE_KRW, RM);
BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM);
if ("BUY".equals(side)) {
double cash = account.getCashBalance().doubleValue();
double budget = krwAmount != null && krwAmount > 0
? krwAmount
: cash * pctVal / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget <= 0) {
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
}
return budget / denom;
BigDecimal need = gross.add(fee);
allocationService.validateBuy(account, app, market, need, false);
account.setReservedCash(account.getReservedCash().add(need));
accountRepo.save(account);
return orderRepo.save(GcPaperOrder.builder()
.accountId(account.getId())
.symbol(market)
.side("BUY")
.orderType("LIMIT")
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
.quantity(qtyBd)
.status("PENDING")
.reservedCash(need)
.orderKind(orderKind)
.source(source)
.strategyId(strategyId)
.build());
}
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
double held = pos.getQuantity().doubleValue();
if (held <= 0) {
throw new IllegalStateException("보유 수량이 없습니다.");
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (qty > avail + 1e-12) {
throw new IllegalStateException("매도 가능 수량이 부족합니다.");
}
return held * pctVal / 100.0;
return orderRepo.save(GcPaperOrder.builder()
.accountId(account.getId())
.symbol(market)
.side("SELL")
.orderType("LIMIT")
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
.quantity(qtyBd)
.status("PENDING")
.reservedQty(qtyBd)
.orderKind(orderKind)
.source(source)
.strategyId(strategyId)
.build());
}
// ── 내부 체결 ───────────────────────────────────────────────────────────
private void releaseReservation(GcPaperAccount account, GcPaperOrder order) {
if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) {
account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO));
accountRepo.save(account);
}
}
private double availableSellQty(Long accountId, String symbol, double held) {
double reserved = orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(accountId, "PENDING")
.stream()
.filter(o -> symbol.equals(o.getSymbol()) && "SELL".equals(o.getSide()))
.mapToDouble(o -> o.getReservedQty().doubleValue())
.sum();
return Math.max(0, held - reserved);
}
private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app,
String market, String side, String orderKind, String source,
Long strategyId, double inputPrice, double inputQty,
String koreanName) {
String koreanName, boolean autoTrade) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
@@ -271,13 +491,14 @@ public class PaperTradingService {
if ("BUY".equals(side)) {
BigDecimal totalCost = gross.add(fee);
if (account.getCashBalance().compareTo(totalCost) < 0) {
throw new IllegalStateException("주문가능 현금이 부족합니다.");
}
allocationService.validateBuy(account, app, market, totalCost, autoTrade);
account.setCashBalance(account.getCashBalance().subtract(totalCost));
upsertPositionBuy(account, market, koreanName, qty, price);
accountRepo.save(account);
GcPaperPosition after = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
BigDecimal qtyAfter = after != null ? after.getQuantity() : qty;
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId())
.symbol(market)
@@ -291,15 +512,17 @@ public class PaperTradingService {
.feeAmount(fee)
.netAmount(totalCost.negate())
.cashAfter(account.getCashBalance())
.positionQtyAfter(qtyAfter)
.build());
ledgerService.recordBuySettle(account, trade, totalCost);
return toTradeDto(trade);
}
// SELL
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
if (pos.getQuantity().compareTo(qty) < 0) {
throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다.");
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (qty.doubleValue() > avail + 1e-12) {
throw new IllegalStateException("매도 수량이 보유(가용) 수량을 초과합니다.");
}
BigDecimal netProceeds = gross.subtract(fee);
@@ -317,6 +540,9 @@ public class PaperTradingService {
}
accountRepo.save(account);
GcPaperPosition afterPos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
BigDecimal qtyAfter = afterPos != null ? afterPos.getQuantity() : BigDecimal.ZERO;
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId())
.symbol(market)
@@ -330,14 +556,52 @@ public class PaperTradingService {
.feeAmount(fee)
.netAmount(netProceeds)
.cashAfter(account.getCashBalance())
.realizedPnlDelta(realizedDelta)
.positionQtyAfter(qtyAfter)
.build());
ledgerService.recordSellSettle(account, trade, netProceeds);
return toTradeDto(trade);
}
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app,
String market, String side,
double price, Double budgetPct, Double krwAmount) {
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
if ("BUY".equals(side)) {
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double cash = account.getCashBalance().doubleValue() - reserved;
double budget = krwAmount != null && krwAmount > 0 ? krwAmount : cash * pctVal / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget <= 0) {
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
}
return budget / denom;
}
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (avail <= 0) throw new IllegalStateException("보유 수량이 없습니다.");
return avail * pctVal / 100.0;
}
private static String resolveOrderType(PaperOrderRequest req) {
if (req.getOrderType() != null) {
String t = req.getOrderType().toUpperCase();
if ("MARKET".equals(t) || "LIMIT".equals(t)) return t;
}
String kind = req.getOrderKind() != null ? req.getOrderKind().toLowerCase() : "limit";
return "market".equals(kind) ? "MARKET" : "LIMIT";
}
private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
BigDecimal qty, BigDecimal price) {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
if (pos == null) {
positionRepo.save(GcPaperPosition.builder()
.accountId(account.getId())
@@ -346,6 +610,9 @@ public class PaperTradingService {
.quantity(qty)
.avgPrice(price)
.build());
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot() : BigDecimal.valueOf(10_000_000);
allocationService.getOrDefault(account.getId(), market, defaultMax);
return;
}
BigDecimal totalQty = pos.getQuantity().add(qty);
@@ -367,8 +634,14 @@ public class PaperTradingService {
.deviceId(dev)
.cashBalance(initial)
.realizedPnl(BigDecimal.ZERO)
.reservedCash(BigDecimal.ZERO)
.initialCapitalSnapshot(initial)
.resetCount(0)
.status("ACTIVE")
.build();
return accountRepo.save(a);
a = accountRepo.save(a);
ledgerService.recordInitial(a, initial);
return a;
});
}
@@ -390,7 +663,31 @@ public class PaperTradingService {
.feeAmount(t.getFeeAmount().doubleValue())
.netAmount(t.getNetAmount().doubleValue())
.cashAfter(t.getCashAfter().doubleValue())
.realizedPnlDelta(t.getRealizedPnlDelta() != null
? t.getRealizedPnlDelta().doubleValue() : null)
.positionQtyAfter(t.getPositionQtyAfter() != null
? t.getPositionQtyAfter().doubleValue() : null)
.createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
.build();
}
private PaperOrderDto toOrderDto(GcPaperOrder o) {
return PaperOrderDto.builder()
.id(o.getId())
.symbol(o.getSymbol())
.side(o.getSide())
.orderType(o.getOrderType())
.limitPrice(o.getLimitPrice() != null ? o.getLimitPrice().doubleValue() : null)
.quantity(o.getQuantity().doubleValue())
.filledQuantity(o.getFilledQuantity().doubleValue())
.status(o.getStatus())
.reservedCash(o.getReservedCash().doubleValue())
.reservedQty(o.getReservedQty().doubleValue())
.orderKind(o.getOrderKind())
.source(o.getSource())
.strategyId(o.getStrategyId())
.createdAt(o.getCreatedAt() != null ? o.getCreatedAt().toString() : null)
.updatedAt(o.getUpdatedAt() != null ? o.getUpdatedAt().toString() : null)
.build();
}
}
@@ -37,6 +37,7 @@ public class BarBuilder {
private final Ta4jStorage ta4jStorage;
private final TradingWebSocketBroker broker;
private final BarCloseStrategyEvaluationService barCloseEvaluation;
private final com.goldenchart.service.PaperOrderMatcherService paperOrderMatcher;
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
@@ -89,6 +90,7 @@ public class BarBuilder {
// 현재 1분봉 상태를 STOMP 로 즉시 발행 (실시간 틱 렌더링용)
publishCurrentBar(market);
paperOrderMatcher.onPriceTick(market, tradePrice);
}
// ── 내부 처리 ──────────────────────────────────────────────────────────────
@@ -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 제약이 없습니다. 참조 무결성은 애플리케이션 로직에 의존합니다.
@@ -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 svg { color: var(--text2); flex-shrink: 0; }
.tv-sym-text { color: var(--text); }
.tv-sym-btn--search {
width: 32px;
height: 32px;
padding: 0;
justify-content: center;
}
/* 종목 검색 아이콘 우측 — 선택 종목(한글명 클릭 가능)·심볼·현재가 */
.tv-toolbar-quote {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 0 4px 0 0;
font-size: 12px;
white-space: nowrap;
}
.tv-quote-ko {
padding: 4px 6px;
margin: 0;
border: none;
border-radius: var(--radius);
background: transparent;
font-size: 14px;
font-weight: 700;
color: var(--text);
cursor: pointer;
transition: background 0.12s;
}
.tv-quote-ko:hover { background: var(--bg3); }
.tv-quote-en {
font-size: 11px;
font-weight: 500;
color: var(--text3);
}
.tv-quote-price {
font-size: 13px;
font-weight: 700;
}
.tv-quote-change.up { color: var(--up); font-weight: 600; }
.tv-quote-change.down { color: var(--down); font-weight: 600; }
.tv-quote-status {
color: var(--text2);
font-size: 11px;
}
.tv-quote-status--err { color: var(--down); }
.tv-sym-input {
font-size: 14px;
@@ -9908,17 +9954,15 @@ html.theme-blue {
border-left: 1px solid var(--border);
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.rsp-wrap--open .rsp-panel {
width: 420px;
}
/* trade-right-panel 너비·패딩: styles/tradeRightPanel.css */
/* ── 탭 헤더 ── */
/* ── 탭 헤더 (레거시 rsp-tab) ── */
.rsp-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg3);
min-width: 420px;
min-width: 0;
}
.rsp-tab {
flex: 1;
@@ -9939,43 +9983,22 @@ html.theme-blue {
font-weight: 600;
}
/* 탭 본문 */
/* 탭 본문 (레거시) */
.rsp-body {
flex: 1;
min-height: 0;
min-width: 420px;
min-width: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 실시간 차트 우측 — 알림목록·가상매매와 동일 PaperSplitPanel 레이아웃 */
.rsp-body.ptd-right-body {
padding: 0;
gap: 0;
}
.rsp-body .ptd-split-panel--right {
flex: 1;
min-height: 0;
height: 100%;
padding: 8px 10px 6px;
box-sizing: border-box;
}
.rsp-body .ptd-ob-stack--fill {
flex: 1;
min-height: 0;
height: 100%;
}
.rsp-body .ptd-split-panel--right .ptd-order-card .top-actions {
margin-top: auto;
}
.rsp-tabs.bps-right-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg3);
min-width: 420px;
min-width: 0;
}
.rsp-tabs.bps-right-tabs .bps-right-tab {
flex: 1;
@@ -10434,6 +10457,9 @@ html.theme-blue {
color: var(--text);
outline: none;
}
.top-field--price .top-input {
text-align: right;
}
.top-input--full {
width: 100%;
border: 1px solid var(--border);
@@ -11770,11 +11796,7 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1), flex-basis 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.tnl-page--with-right .tnl-side-wrap--right.tnl-side-wrap--open .tnl-right.tnl-right--open {
flex: 0 0 min(380px, 38vw);
width: min(380px, 38vw);
min-width: 300px;
max-width: 420px;
.tnl-page--with-right .tnl-side-wrap--right.tnl-side-wrap--open .tnl-right.trade-right-panel.tnl-right--open {
border-left: 1px solid var(--border);
}
.tnl-right-tabs {
@@ -11788,21 +11810,6 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
display: flex;
flex-direction: column;
}
.tnl-page--with-right .ptd-right-body {
flex: 1;
min-height: 0;
}
.tnl-page--with-right .ptd-split-panel--right {
height: 100%;
}
.tnl-page--with-right .ptd-ob-stack--fill {
height: 100%;
}
/* 매매 시그널 알림 우측 — 가상매매·실시간 차트와 동일 compact (paperDashboard.css) */
.tnl-page--with-right .ptd-split-panel--right .ptd-order-card .top-actions {
margin-top: auto;
}
.tnl-row--selected {
border-color: var(--accent, #7aa2f7);
box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.35);
@@ -12658,7 +12665,15 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
padding: 0 8px;
overflow: hidden;
}
.app--mobile .upbit-hint { display: none; }
.app--mobile .tv-toolbar-quote {
gap: 4px;
max-width: min(42vw, 200px);
overflow: hidden;
}
.app--mobile .tv-quote-ko { font-size: 12px; padding: 3px 4px; }
.app--mobile .tv-quote-en { font-size: 10px; }
.app--mobile .tv-quote-price { font-size: 11px; }
.app--mobile .tv-quote-change { font-size: 10px; }
.app--mobile .upbit-market { max-width: 42vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── 메인: 차트 전체 너비, 사이드 패널 오버레이 ── */
+16 -45
View File
@@ -100,11 +100,7 @@ import VerificationBoardPage from './components/VerificationBoardPage';
import DashboardPage from './components/DashboardPage';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar';
import {
formatChartLiveChangePct,
formatChartLivePrice,
resolveChartLiveQuote,
} from './utils/chartLiveQuote';
import { resolveChartLiveQuote } from './utils/chartLiveQuote';
import RightSidePanel from './components/RightSidePanel';
import {
type BacktestSettingsDto,
@@ -129,7 +125,7 @@ import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } fr
import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry';
import { syncDocumentTheme } from './utils/documentTheme';
import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions';
import { firstAllowedTopMenu, normalizeRole } from './utils/permissions';
import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions';
import { clearAdminUnlock } from './utils/adminUnlock';
import type { LoginResponse } from './utils/backendApi';
import { invalidateAppSettingsCache } from './hooks/useAppSettings';
@@ -137,25 +133,13 @@ import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import './App.css';
import './styles/appPopup.css';
import './styles/paperDashboard.css';
import './styles/tradeRightPanel.css';
import './styles/virtualTradingDashboard.css';
import './styles/backtestDashboard.css';
let _indCounter = 0;
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
// ─── 연결 상태 배지 ────────────────────────────────────────────────────────
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null;
const map: Record<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 조작) */
let _wsFlashTimer: ReturnType<typeof setTimeout> | null = null;
function flashWsDot() {
@@ -231,7 +215,11 @@ function App() {
const [mobileRightOpen, setMobileRightOpen] = useState(false);
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
const [menuPage, setMenuPage] = useState<MenuPage>('chart');
const [settingsInitialCategory, setSettingsInitialCategory] = useState<SettingsCategoryId | undefined>();
const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(null);
useEffect(() => {
if (menuPage !== 'settings') setSettingsInitialCategory(undefined);
}, [menuPage]);
const chartVisible = menuPage === 'chart';
const isMobile = useIsMobile();
@@ -1770,6 +1758,10 @@ function App() {
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={handlePaperOrderFilled}
onOpenSettings={() => {
setSettingsInitialCategory('paper');
setMenuPage('settings');
}}
onGoChart={m => {
goToMarketChart(m);
setMenuPage('chart');
@@ -1957,6 +1949,7 @@ function App() {
const r = await sendFcmTest();
window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.');
}}
initialCategory={settingsInitialCategory}
/>
)}
@@ -2083,34 +2076,12 @@ function App() {
}}
onToggleLiveStrategy={() => setShowLivePanel(v => !v)}
liveStrategyActive={showLivePanel || monitoredMarkets.length > 0}
showChartQuote={useUpbit}
chartLiveQuote={liveQuote}
chartQuoteLoading={isLoading}
chartQuoteError={error}
/>
{/* 업비트 연결 상태 바 */}
{useUpbit && (
<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) && (
<button
type="button"
+192 -133
View File
@@ -1,44 +1,58 @@
/**
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표)
* 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadPaperTrades,
loadPaperTradesPaged,
loadPaperPendingOrders,
loadStrategies,
resetPaperAccount,
type PaperOrderDto,
type PaperPositionDto,
type PaperSummaryDto,
type PaperTradeDto,
type StrategyDto,
} 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 { fmtKrw } from './TradeOrderPanel';
import TradeOrderPanel from './TradeOrderPanel';
import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import type { Theme, TradeOrderFillRequest } from '../types';
import PaperAnalysisChart from './paper/PaperAnalysisChart';
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import PaperSplitPanel from './paper/PaperSplitPanel';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
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 LeftTab = 'strategy' | 'settings';
interface Props {
theme?: Theme;
tickers?: Map<string, { tradePrice: number | null }>;
tickers?: Map<string, TickerData>;
onGoChart?: (market: string) => void;
refreshKey?: number;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
/** 설정 → 모의투자 카테고리로 이동 */
onOpenSettings?: () => void;
}
function buildMarkPrices(
summary: PaperSummaryDto | null,
tickers?: Map<string, { tradePrice: number | null }>,
tickers?: Map<string, TickerData>,
): Record<string, number> {
const m: Record<string, number> = {};
summary?.positions?.forEach(p => {
@@ -50,7 +64,7 @@ function buildMarkPrices(
function positionPriceKey(
positions: PaperPositionDto[] | undefined,
tickers?: Map<string, { tradePrice: number | null }>,
tickers?: Map<string, TickerData>,
): string {
if (!positions?.length) return '';
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
@@ -68,37 +82,52 @@ const PaperTradingPage: React.FC<Props> = ({
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
onOpenSettings,
}) => {
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [pendingOrders, setPendingOrders] = useState<PaperOrderDto[]>([]);
const [dataRefreshKey, setDataRefreshKey] = useState(0);
const [initialLoading, setInitialLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [centerTab, setCenterTab] = useState<CenterTab>('invest');
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
const fillSeqRef = useRef(0);
const tickersRef = useRef(tickers);
tickersRef.current = tickers;
const summaryRef = useRef(summary);
summaryRef.current = summary;
const loadData = useCallback(async () => {
const loadData = useCallback(async (opts?: { focusHistory?: boolean }) => {
try {
const base = await loadPaperSummary();
const marks = buildMarkPrices(base, tickersRef.current);
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
setSummary(full);
setTrades(await loadPaperTrades());
const tradePage = await loadPaperTradesPaged({ page: 0, size: 100 });
setTrades(tradePage.content);
setPendingOrders(await loadPaperPendingOrders());
setDataRefreshKey(k => k + 1);
} finally {
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]);
const priceKey = positionPriceKey(summary?.positions, tickers);
@@ -118,39 +147,84 @@ const PaperTradingPage: React.FC<Props> = ({
return () => { cancelled = true; };
}, [priceKey, initialLoading]);
const strategyNames = useMemo(
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
[strategies],
);
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
const s = summary;
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const posQty = useMemo(() => {
const p = s?.positions?.find(x => x.symbol === selectedMarket);
return p?.quantity ?? 0;
return coerceFiniteNumber(p?.quantity) ?? 0;
}, [s?.positions, selectedMarket]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
const side = rowType === 'ask' ? 'buy' : 'sell';
fillSeqRef.current += 1;
const req: TradeOrderFillRequest = {
market: selectedMarket,
price,
side,
seq: fillSeqRef.current,
};
if (side === 'buy') setFillBuy(req); else setFillSell(req);
const selectedAlloc = useMemo(
() => s?.allocations?.find(a => a.symbol === selectedMarket),
[s?.allocations, selectedMarket],
);
const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0;
const tradePrice = useMemo(
() => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
[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');
}, [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]);
const handleReset = useCallback(async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
const res = await resetPaperAccount();
if (res) { setSummary(res); setTrades([]); }
if (res) {
setSummary(res);
setTrades([]);
setPendingOrders([]);
}
}, []);
const rightTabs = (
<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 === '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>
);
@@ -166,10 +240,16 @@ const PaperTradingPage: React.FC<Props> = ({
theme={theme}
title="모의투자"
subtitle="Paper Trading"
pageClassName="bps-page--vtd bps-page--ptd"
loading={initialLoading}
loadingText="모의투자 대시보드 로딩…"
collapsiblePanels
leftStorageKey="ptd-left-width"
footerStorageKey="ptd-footer-height"
leftDefaultWidth={380}
leftCollapsedStorageKey="ptd-left-open"
rightStorageKey="ptd-right-width"
rightDefaultWidth={440}
rightCollapsedStorageKey="ptd-right-open"
headerActions={(
<>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}></button>
@@ -185,129 +265,108 @@ const PaperTradingPage: React.FC<Props> = ({
market={selectedMarket}
summary={s}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onMarketSelect={setSelectedMarket}
onMarketSelect={handleSelectMarket}
/>
) : (
<PaperLeftSettingsTab
onOpenSettings={onOpenSettings}
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
/>
)}
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">
<PaperAnalysisChart market={selectedMarket} theme={theme} />
</div>
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
<TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={s?.cashBalance ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
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(); }}
/>
)}
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={orderableCash}
availableSymbolBuyKrw={selectedAlloc?.availableBuyKrw}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void loadData({ focusHistory: true })}
/>
) : rightTab === 'orderbook' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매도 호가"
bottomTitle="매수 호가"
top={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="asks"
fillHeight
hideHeader
depth={10}
/>
)}
bottom={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="bids"
fillHeight
hideHeader
depth={10}
/>
)}
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
) : (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="체결 구분"
bottomTitle={historyTab === 'open' ? '미체결' : '최근체결'}
top={(
<div className="ptd-tabs ptd-tabs--in-card">
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}></button>
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}></button>
</div>
)}
bottom={historyTab === 'open' ? (
<p className="ptd-muted"> ( )</p>
) : (
<table className="ptd-table ptd-table--compact">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{trades.slice(0, 20).map(t => (
<tr key={t.id}>
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
<td>{coinCode(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
<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 className="ptd-right-history">
<div className="ptd-tabs ptd-tabs--history">
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}></button>
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}></button>
<button type="button" className={`ptd-tab${historyTab === 'ledger' ? ' active' : ''}`} onClick={() => setHistoryTab('ledger')}></button>
</div>
<div className="ptd-right-history-body">
{historyTab === 'open' ? (
<PaperPendingOrdersList orders={pendingOrders} onChanged={() => void loadData()} />
) : historyTab === 'ledger' ? (
<PaperLedgerTab refreshKey={dataRefreshKey} />
) : (
<PaperTradeHistoryList
trades={trades}
strategyNames={strategyNames}
tickers={tickers}
onSelectMarket={handleSelectMarket}
emptyText="체결 내역이 없습니다."
className="ptd-trade-history--fill"
/>
)}
</div>
</div>
)}
</div>
)}
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>
</TradeRightPanelBody>
)}
/>
);
+47 -90
View File
@@ -4,9 +4,13 @@
* - 호가 탭: 실시간 호가창
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { OrderbookPanel } from './OrderbookPanel';
import TradeOrderPanel from './TradeOrderPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import {
TradeSplitOrderPanel,
TradeOrderbookPanelContent,
TradeRightPanelTabs,
TradeRightPanelBody,
type TradeRightPanelTab,
} from './trade';
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import type { OrderbookTickerInfo } from './OrderbookPanel';
@@ -19,8 +23,8 @@ export interface RightSidePanelProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** 모바일 도크에서 호가/매매 탭 지정 */
activeTab?: PanelTab;
onTabChange?: (tab: PanelTab) => void;
activeTab?: TradeRightPanelTab;
onTabChange?: (tab: TradeRightPanelTab) => void;
market: string;
tradePrice: number | null;
asks: OrderbookDisplayUnit[];
@@ -44,11 +48,6 @@ export interface RightSidePanelProps {
onPaperOrderFilled?: () => void;
}
const PANEL_TABS: { id: PanelTab; label: string }[] = [
{ id: 'trade', label: '매매' },
{ id: 'orderbook', label: '호가' },
];
const RightSidePanel: React.FC<RightSidePanelProps> = ({
open: openControlled,
onOpenChange,
@@ -77,7 +76,7 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
onPaperOrderFilled,
}) => {
const [openInternal, setOpenInternal] = useState(false);
const [tabInternal, setTabInternal] = useState<PanelTab>('trade');
const [tabInternal, setTabInternal] = useState<TradeRightPanelTab>('trade');
const tradeSectionRef = useRef<HTMLDivElement>(null);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
@@ -91,7 +90,7 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
};
const activeTab = activeTabControlled ?? tabInternal;
const setActiveTab = useCallback((tab: PanelTab) => {
const setActiveTab = useCallback((tab: TradeRightPanelTab) => {
if (onTabChange) onTabChange(tab);
else setTabInternal(tab);
}, [onTabChange]);
@@ -130,91 +129,49 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
</svg>
</button>
<div className="rsp-panel">
<div className="rsp-tabs bps-right-tabs">
{PANEL_TABS.map(tab => (
<button
key={tab.id}
type="button"
className={`bps-right-tab${activeTab === tab.id ? ' bps-right-tab--on' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="rsp-panel trade-right-panel">
<TradeRightPanelTabs
activeTab={activeTab}
onTabChange={setActiveTab}
className="rsp-tabs"
/>
<div className="rsp-body ptd-right-body" ref={tradeSectionRef}>
<TradeRightPanelBody anchorRef={tradeSectionRef} className="rsp-body-wrap">
{activeTab === 'trade' && (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<div className="ptd-order-card">
<TradeOrderPanel
side="buy"
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
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>
)}
<TradeSplitOrderPanel
market={market}
tradePrice={tradePrice}
availableKrw={availableKrw}
availableCoinQty={availableCoinQty}
fillRequest={fillRequest}
searchAnchorRef={tradeSectionRef}
onMarketSelect={onMarketSelect}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
{activeTab === 'orderbook' && (
<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
market={market}
asks={asks}
bids={bids}
totalAskSize={totalAskSize}
totalBidSize={totalBidSize}
wsStatus={wsStatus}
bestAsk={bestAsk}
bestBid={bestBid}
spread={spread}
spreadPct={spreadPct}
prevClose={prevClose}
tickerInfo={tickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
<TradeOrderbookPanelContent
market={market}
asks={asks}
bids={bids}
totalAskSize={totalAskSize}
totalBidSize={totalBidSize}
wsStatus={wsStatus}
bestAsk={bestAsk}
bestBid={bestBid}
spread={spread}
spreadPct={spreadPct}
prevClose={prevClose}
tickerInfo={tickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
)}
</div>
</TradeRightPanelBody>
</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;
trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
initialCategory?: SettingsCategoryId;
}
// ── 카테고리 정의 ────────────────────────────────────────────────────────────
@@ -233,6 +235,19 @@ const IcPaper = () => (
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none"/>
</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 = () => (
<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"/>
@@ -264,7 +279,9 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, 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: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
@@ -319,7 +336,7 @@ const StgCard: React.FC<{
// ── 각 카테고리 패널 ─────────────────────────────────────────────────────────
interface PaperPanelProps {
interface PaperTradingSettingsPanelProps {
paperTradingEnabled?: boolean;
onPaperTradingEnabled?: (v: boolean) => void;
paperInitialCapital?: number;
@@ -334,21 +351,10 @@ interface PaperPanelProps {
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => 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,
onPaperTradingEnabled,
paperInitialCapital = 10_000_000,
@@ -363,63 +369,29 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
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="매매 운영 모드">
<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="가상매매">
<SettingRow label="가상매매 활성화" desc="켜면 가상매매·실시간 차트 매매 패널·전략 Match 자동매매가 가상 계좌로 체결됩니다.">
<SettingSection title="모의투자">
<SettingRow
label="모의투자 활성화"
desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
>
<label className="stg-toggle">
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</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 title="자동매매">
<SettingRow
label="자동매매 ON/OFF"
desc="가상투자 화면 타이틀바에서 변경합니다. Match 충족 시 자동 매수·매도 여부를 제어합니다."
label="자동매매"
desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
>
<span className={`stg-badge ${paperAutoTradeEnabled ? 'stg-badge--on' : 'stg-badge--off'}`}>
{paperAutoTradeEnabled ? 'ON (타이틀바)' : 'OFF (타이틀바)'}
</span>
<label className="stg-toggle">
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="자동 매수 예산 (%)"
@@ -436,9 +408,6 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
/>
</SettingRow>
{!paperAutoTradeEnabled && (
<p className="stg-hint"> ON/OFF는 . OFF Match· .</p>
)}
</SettingSection>
<SettingSection title="계좌·비용">
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
@@ -485,69 +454,135 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
</SettingRow>
</SettingSection>
<SettingSection title="계좌 관리">
<SettingRow label="계좌 초기화" desc="보유 종목·체결 이력을 삭제하고 초기 자본으로 현금을 되돌립니다.">
<SettingRow
label="계좌 초기화"
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
>
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
</button>
</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>
</>
);
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,
trendSearchSettings,
onTrendSearchSettingsChange,
initialCategory,
}) => {
const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>('general');
const [active, setActive] = useState<CategoryId>(initialCategory ?? 'general');
const [indicatorSaveUi, setIndicatorSaveUi] = useState<IndicatorSaveUiState | null>(null);
useEffect(() => {
@@ -1719,6 +1755,13 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
}
}, [categories, active]);
useEffect(() => {
if (!initialCategory) return;
if (categories.some(c => c.id === initialCategory)) {
setActive(initialCategory);
}
}, [initialCategory, categories]);
useEffect(() => {
if (active !== 'indicators') setIndicatorSaveUi(null);
}, [active]);
@@ -1811,7 +1854,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
</AdminPasswordGate>
);
case 'paper': return (
<PaperPanel
<PaperTradingSettingsPanel
paperTradingEnabled={paperTradingEnabled}
onPaperTradingEnabled={onPaperTradingEnabled}
paperInitialCapital={paperInitialCapital}
@@ -1826,9 +1869,17 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'virtual': return (
<VirtualTradingSettingsPanel
virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'live': return (
<LiveTradingSettingsPanel
tradingMode={tradingMode}
onTradingMode={onTradingMode}
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 { MarketSearchPanel } from './MarketSearchPanel';
import { getKoreanName } from '../utils/marketNameCache';
import {
formatChartLiveChangePct,
formatChartLivePrice,
type ChartLiveQuote,
} from '../utils/chartLiveQuote';
import LayoutPicker from './LayoutPicker';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi';
@@ -134,6 +139,11 @@ export interface ToolbarProps {
onOpenBulkIndicatorSettings?: () => void;
/** 지표 추가 팝업 항목 — 해당 지표 설정 모달 */
onOpenIndicatorSettings?: (type: string) => void;
/** 종목 선택 우측 시세 (실시간 차트 — 하단 upbit-bar 대체) */
showChartQuote?: boolean;
chartLiveQuote?: ChartLiveQuote | null;
chartQuoteLoading?: boolean;
chartQuoteError?: string | null;
}
// ─── SVG 아이콘 ────────────────────────────────────────────────────────────
@@ -800,6 +810,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
liveStrategyActive = false,
onOpenBulkIndicatorSettings,
onOpenIndicatorSettings,
showChartQuote = false,
chartLiveQuote = null,
chartQuoteLoading = false,
chartQuoteError = null,
}) => {
const [showMarket, setShowMarket] = React.useState(false);
const [showTfMenu, setShowTfMenu] = React.useState(false);
@@ -854,6 +868,13 @@ const Toolbar: React.FC<ToolbarProps> = ({
setShowLayout(true);
}, [showLayout]);
const openMarketSearch = useCallback(() => {
setShowMarket(v => !v);
setShowTfMenu(false);
setShowCtMenu(false);
setShowIndMenu(false);
}, []);
return (
<>
{/* 종목 검색 패널 */}
@@ -869,18 +890,54 @@ const Toolbar: React.FC<ToolbarProps> = ({
{/* ── Left ─────────────────────────────────────────────────────────── */}
<div className="tv-toolbar-left">
{/* Symbol Search → 클릭 시 MarketSearchPanel 열기 */}
{/* 종목 검색(아이콘) · 선택 종목 표시 — 클릭 시 MarketSearchPanel */}
<div className="tv-sym-wrap">
<button
className="tv-sym-btn"
onClick={() => { setShowMarket(v => !v); setShowTfMenu(false); setShowCtMenu(false); setShowIndMenu(false); }}
title="종목 변경"
type="button"
className="tv-sym-btn tv-sym-btn--search"
onClick={openMarketSearch}
title="종목 검색"
aria-expanded={showMarket}
aria-haspopup="dialog"
>
<IcSearch />
<span className="tv-sym-text">{getKoreanName(symbol)}</span>
</button>
</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 */}
<button className="tv-icon-btn" title="워치리스트" onClick={onToggleWatch}>
<IcPlus />
+9
View File
@@ -131,6 +131,14 @@ const IcStrategyEditor = () => (
</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 = () => (
<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"/>
@@ -158,6 +166,7 @@ const IcVerificationBoard = () => (
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'virtual', label: '가상매매', icon: <IcVirtual /> },
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
@@ -7,9 +7,12 @@ import type { TickerData } from '../hooks/useMarketTicker';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
import { coerceFiniteNumber } from '../utils/safeFormat';
import TradeOrderPanel from './TradeOrderPanel';
import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import {
TradeSplitOrderPanel,
TradeOrderbookPanelContent,
TradeRightPanelTabs,
TradeRightPanelBody,
} from './trade';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import { useAppSettings } from '../hooks/useAppSettings';
@@ -504,93 +507,52 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</svg>
</button>
<aside
className={`tnl-right${rightOpen ? ' tnl-right--open' : ''}`}
className={`tnl-right trade-right-panel${rightOpen ? ' tnl-right--open' : ''}`}
aria-label="매매·호가"
aria-hidden={!rightOpen}
>
<div className="bps-right-tabs tnl-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 === 'orderbook' ? ' bps-right-tab--on' : ''}`}
onClick={() => setRightTab('orderbook')}
>
</button>
</div>
<div className="tnl-right-body ptd-right-body" ref={orderAnchorRef}>
<TradeRightPanelTabs
activeTab={rightTab}
onTabChange={setRightTab}
className="tnl-right-tabs"
/>
<TradeRightPanelBody
anchorRef={orderAnchorRef}
bodyClassName="tnl-right-body"
>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<div className="ptd-order-card">
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
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>
)}
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={m => handleSelectMarket(m)}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData()}
/>
) : (
<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
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
)}
</div>
</TradeRightPanelBody>
</aside>
</div>
</div>
+88 -70
View File
@@ -17,6 +17,8 @@ export interface TradeOrderPanelProps {
/** 현재가 (KRW) — 종목 변경 시 초기값 */
tradePrice: number | null;
availableKrw?: number;
/** 종목별 추가 매수 가능 (한도 기준) */
availableSymbolBuyKrw?: number;
/** 매도 시 보유 수량 (모의투자) */
availableCoinQty?: number;
/** 호가·차트에서 자동 입력 */
@@ -89,6 +91,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
market,
tradePrice,
availableKrw = 0,
availableSymbolBuyKrw,
availableCoinQty = 0,
fillRequest,
searchAnchorRef,
@@ -99,6 +102,9 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
onPaperOrderFilled,
}) => {
const isBuy = side === 'buy';
const buyBudgetKrw = isBuy && availableSymbolBuyKrw != null
? Math.min(availableKrw, availableSymbolBuyKrw)
: availableKrw;
const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market;
const code = coinCode(displayMarket);
const koreanName = getKoreanName(displayMarket);
@@ -156,8 +162,8 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const applyPct = useCallback((pct: number) => {
setPctMode(pct);
if (isBuy) {
if (orderKind === 'market' || availableKrw <= 0) return;
const budget = (availableKrw * pct) / 100;
if (orderKind === 'market' || buyBudgetKrw <= 0) return;
const budget = (buyBudgetKrw * pct) / 100;
const p = orderKind === 'limit' && price > 0 ? price : refPrice;
if (p > 0) {
const q = budget / p;
@@ -168,7 +174,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
if (availableCoinQty <= 0) return;
const q = (availableCoinQty * pct) / 100;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
}, [isBuy, availableKrw, availableCoinQty, orderKind, price, refPrice]);
}, [isBuy, buyBudgetKrw, availableCoinQty, orderKind, price, refPrice]);
const bumpPrice = useCallback((delta: number) => {
const base = price > 0 ? price : refPrice;
@@ -202,26 +208,36 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
return;
}
try {
const trade = await placePaperOrder({
const result = await placePaperOrder({
market: displayMarket,
side: isBuy ? 'BUY' : 'SELL',
orderKind: orderKind === 'market' ? 'market' : 'limit',
orderType: orderKind === 'market' ? 'MARKET' : 'LIMIT',
price: execPrice,
quantity: qty,
source: 'MANUAL',
});
if (!trade) {
if (!result?.trade && !result?.order) {
window.alert('모의 주문에 실패했습니다.');
return;
}
onPaperOrderFilled?.();
window.alert(
`[모의투자] ${koreanName || code} ${label} 체결\n` +
`가격: ${fmtKrw(trade.price)} KRW\n` +
`수량: ${trade.quantity} ${code}\n` +
`수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
);
if (result.order) {
window.alert(
`[모의투자] ${koreanName || code} ${label} 미체결 등록\n` +
`지정가: ${fmtKrw(result.order.limitPrice ?? execPrice)} KRW\n` +
`: ${result.order.quantity} ${code}`,
);
} else if (result.trade) {
const trade = result.trade;
window.alert(
`[모의투자] ${koreanName || code} ${label} 체결\n` +
`가격: ${fmtKrw(trade.price)} KRW\n` +
`수량: ${trade.quantity} ${code}\n` +
`수수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
);
}
setQtyStr('');
setPctMode(null);
} catch (e) {
@@ -245,8 +261,12 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const minOrder = 5000;
const feeRate = 0.05;
const balanceDisplay = isBuy
? `${buyBudgetKrw > 0 ? fmtKrw(buyBudgetKrw) : '-'} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`;
return (
<div className={`top-panel top-panel--${side}`}>
<div className={`top-panel top-panel--upbit top-panel--${side}`}>
<div className="top-panel-fields">
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
<p className="top-paper-hint"> OFF · .</p>
@@ -317,71 +337,70 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
</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-balance">
{isBuy
? `${fmtKrw(availableKrw)} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
</span>
<span className="top-balance">{balanceDisplay}</span>
</div>
<div className="top-price-qty-block">
<div className="top-field top-field--price">
<label className="top-label">{priceLabel} (KRW)</label>
<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>
<div className="top-field top-field--price">
<label className="top-label">{priceLabel} (KRW)</label>
<div className="top-input-group">
<input
type="text"
className="top-input top-input--full"
value={qtyStr}
className="top-input"
value={priceStr}
disabled={orderKind === 'market'}
onChange={handleQtyChange}
inputMode="decimal"
pattern="[0-9.]*"
onChange={handlePriceChange}
inputMode="numeric"
pattern="[0-9,]*"
autoComplete="off"
placeholder="0"
/>
</div>
<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>
<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">
<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>
<input
type="text"
@@ -393,11 +412,10 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
<div className="top-meta">
<span className="top-meta-line">
<span>조건: 보통 </span>
<span className="top-meta-sep">·</span>
<span> {fmtKrw(minOrder)}</span>
<span>: {fmtKrw(minOrder)} KRW</span>
<span className="top-meta-sep">·</span>
<span> {feeRate}%</span>
<span>( ): {feeRate}%</span>
</span>
</div>
</div>
+32 -67
View File
@@ -15,9 +15,7 @@ import {
} from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker';
import TradeOrderPanel from './TradeOrderPanel';
import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
@@ -423,7 +421,7 @@ const VirtualTradingPage: React.FC<Props> = ({
leftDefaultWidth={380}
leftCollapsedStorageKey="vtd-left-open"
rightStorageKey="vtd-right-width"
rightDefaultWidth={380}
rightDefaultWidth={440}
rightCollapsedStorageKey="vtd-right-open"
left={(
<VirtualLeftTargetPanel
@@ -466,71 +464,38 @@ const VirtualTradingPage: React.FC<Props> = ({
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
<TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<div className="ptd-order-card">
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
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>
)}
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
) : rightTab === 'orderbook' ? (
<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
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
) : (
<PaperTradeHistoryList
trades={trades}
@@ -541,7 +506,7 @@ const VirtualTradingPage: React.FC<Props> = ({
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' : ''}`}>
<PanelCollapseHandle side="right" open={rightOpen} onToggle={toggleRight} label="우측 패널" />
<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 ? (
<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}
/>
<aside
className="bps-right"
className="bps-right trade-right-panel"
style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}
>
{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 {
onSettingsSaved?: () => 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();
if (!isLoaded) {
@@ -28,6 +29,11 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
const top = (
<>
{onOpenSettings && (
<button type="button" className="bps-btn bps-btn--ghost ptd-left-settings-link" onClick={onOpenSettings}>
</button>
)}
<div className="ptd-left-section">
<label className="ptd-left-row">
<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}
</span>
</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>
);
})}
@@ -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 로컬 보관(등록 시 저장 후 업로드)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ScreenRegionCaptureOverlay from '../ScreenRegionCaptureOverlay';
import {
deleteVerificationIssueImage,
loadVerificationIssueImages,
@@ -68,6 +69,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
const [screenCaptureOpen, setScreenCaptureOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
@@ -170,6 +172,11 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
}
}, [uploadFiles]);
const handleScreenCapture = useCallback((file: File) => {
setScreenCaptureOpen(false);
void uploadFiles([file]);
}, [uploadFiles]);
const handleDeleteServer = async (imageId: number) => {
if (issueId == null) return;
if (!window.confirm('이 이미지를 삭제할까요?')) return;
@@ -237,14 +244,25 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
<h3 className="vbd-attachments-title">
{totalCount > 0 && `(${totalCount})`}
</h3>
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
>
</button>
<div className="vbd-attachments-actions">
<button
type="button"
className="vbd-btn vbd-btn--sm"
title="화면 공유 후 드래그로 영역 캡처"
onClick={() => setScreenCaptureOpen(true)}
disabled={disabled || uploading}
>
</button>
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
>
</button>
</div>
<input
ref={fileInputRef}
type="file"
@@ -282,8 +300,8 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
) : showEmptyHint ? (
<p className="vbd-attach-hint">
{isDraft || saveOnSubmit
? '저장 시 함께 반영됩니다. 드래그·우클릭 붙여넣기·파일 선택'
: '이미지를 드래그하거나, 우클릭 → 클립보드 붙여넣기, 또는 「파일 선택'}
? '저장 시 함께 반영됩니다. 화면 캡처·드래그·우클릭 붙여넣기·파일 선택'
: '화면 캡처·드래그·우클릭 붙여넣기·파일 선택'}
</p>
) : (
<div className="vbd-attach-grid">
@@ -385,8 +403,18 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
<button type="button" onClick={() => void pasteFromClipboard()}>
</button>
<button type="button" onClick={() => { setCtxMenu(null); setScreenCaptureOpen(true); }}>
</button>
</div>
)}
{screenCaptureOpen && (
<ScreenRegionCaptureOverlay
onCapture={handleScreenCapture}
onCancel={() => setScreenCaptureOpen(false)}
/>
)}
</div>
);
};
@@ -116,8 +116,8 @@ export function useVirtualBackendTradeSignals({
source: 'STRATEGY',
strategyId,
})
.then(trade => {
if (trade) {
.then(result => {
if (result?.trade) {
lastExecRef.current[execKey] = Date.now();
onFilled?.();
}
+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;
box-sizing: border-box;
}
@@ -107,7 +112,8 @@
overflow: hidden;
}
.bps-panel-body--tabs > .ptd-split-panel--left,
.bps-right-body > .ptd-right-body > .ptd-split-panel--right {
.bps-right-body > .ptd-right-body > .ptd-split-panel--right,
.trade-right-panel .bps-right-body > .ptd-right-body > .ptd-split-panel--right {
flex: 1;
min-height: 0;
}
@@ -246,7 +252,8 @@
margin-top: 4px;
}
.bps-right-body .ptd-right-body {
.bps-right-body .ptd-right-body,
.trade-right-panel .bps-right-body .ptd-right-body {
flex: 1 1 0;
min-height: 0;
height: 100%;
@@ -257,11 +264,13 @@
padding: 0;
}
.bps-right-body .ptd-right-body > .ptd-split-panel {
.bps-right-body .ptd-right-body > .ptd-split-panel,
.trade-right-panel .bps-right-body .ptd-right-body > .ptd-split-panel {
gap: 8px;
}
.bps-right-body .ptd-right-body > .ptd-split-panel--right {
.bps-right-body .ptd-right-body > .ptd-split-panel--right,
.trade-right-panel .bps-right-body .ptd-right-body > .ptd-split-panel--right {
flex: 1 1 0;
min-height: 0;
height: 100%;
@@ -415,6 +424,27 @@
container-name: trade-card;
}
/* 우측 매매 패널: container query·압축 숨김 비활성 (tradeRightPanel.css와 함께) */
.trade-right-panel .ptd-order-card,
.trade-right-panel .ptd-order-card.ptd-order-card--trade-inline {
container-type: normal;
container-name: none;
}
.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline .top-panel-fields {
flex: 1 1 0;
min-height: 0;
overflow: visible;
}
.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline > .top-panel {
overflow: visible;
}
.trade-right-panel .ptd-split-panel--right .ptd-order-card--trade-inline .top-actions {
margin-top: auto;
}
.bps-footer .ptd-metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -934,10 +934,7 @@
min-width: 0;
}
.tnl-side-wrap--right .tnl-right-tabs,
.tnl-side-wrap--right .tnl-right-body {
min-width: min(380px, 38vw);
}
/* 우측 패널 너비: styles/tradeRightPanel.css (--gc-trade-right-panel-*) */
.tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-tabs,
.tnl-side-wrap--right:not(.tnl-side-wrap--open) .tnl-right-body {
+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;
}
.vbd-attachments-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* ── 화면 영역 캡처 오버레이 ── */
.screen-capture-overlay {
position: fixed;
inset: 0;
z-index: 200000;
cursor: crosshair;
user-select: none;
}
.screen-capture-video {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
pointer-events: none;
}
.screen-capture-interaction {
position: fixed;
inset: 0;
z-index: 1;
touch-action: none;
}
.screen-capture-selection {
position: fixed;
box-sizing: border-box;
border: 2px solid #fff;
background: transparent;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.screen-capture-toolbar {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
border-radius: 8px;
background: rgba(20, 22, 28, 0.92);
color: #fff;
font-size: 13px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
pointer-events: auto;
}
.screen-capture-hint {
white-space: nowrap;
}
.screen-capture-cancel {
padding: 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 6px;
background: transparent;
color: #fff;
font-size: 12px;
cursor: pointer;
}
.screen-capture-cancel:hover {
background: rgba(255, 255, 255, 0.12);
}
.vbd-attachments-title {
margin: 0;
font-size: 14px;
+105 -163
View File
@@ -2795,188 +2795,130 @@
opacity: 0.5;
}
/* ── 우측 매매 패널: 매수·매도 카드 간격·타이포 ── */
.bps-page--vtd .ptd-split-panel--right .ptd-split-card-body {
padding: 8px 12px 10px;
}
/* 우측 매매 패널 타이포·레이아웃: styles/tradeRightPanel.css (.trade-right-panel) */
.bps-page--vtd .ptd-split-panel--right .ptd-order-card > .top-panel {
gap: 0;
justify-content: flex-start;
padding: 6px 6px 0;
height: 100%;
/* ── 모의투자 투자금 관리 (P1) ── */
.ptd-center-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
flex: 1 1 auto;
.ptd-tabs--center {
flex-shrink: 0;
}
.ptd-invest-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
overflow: auto;
min-height: 0;
}
.ptd-invest-metrics {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 8px;
border-top: 1px solid var(--se-border);
}
.bps-page--ptd .ptd-right-history {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 4px;
overflow: hidden;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-field,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-row--balance {
margin: 0;
.bps-page--ptd .ptd-tabs--history {
flex-shrink: 0;
}
/* 기본: 공간 부족 시 하단 안내 텍스트 숨김 */
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: none;
margin: 0;
padding: 0;
flex-shrink: 0;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-label {
margin-bottom: 7px;
font-size: 12px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-input,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-input--full {
padding: 9px 11px;
font-size: 13px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-symbol-input {
padding: 9px 11px;
font-size: 13px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-order-kind {
padding: 8px 10px 0;
gap: 6px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-kind-btn {
padding: 7px 5px;
.bps-page--ptd .ptd-right-history-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 10px 10px;
}
.bps-page--ptd .ptd-right-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ptd-invest-kpi {
display: flex;
flex-direction: column;
gap: 8px;
}
.ptd-invest-kpi-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.ptd-invest-kpi-cell {
padding: 8px 10px;
border-radius: 6px;
background: var(--gc-surface-2, rgba(255,255,255,0.04));
}
.ptd-invest-kpi-cell--up .ptd-invest-kpi-value { color: var(--gc-trade-buy, #ef5350); }
.ptd-invest-kpi-cell--down .ptd-invest-kpi-value { color: var(--gc-trade-sell, #4dabf7); }
.ptd-invest-kpi-label {
display: block;
font-size: 11px;
opacity: 0.7;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-row--balance {
padding: 4px 0 2px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-pct-row {
margin-top: 8px;
gap: 6px;
flex-shrink: 0;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-pct-btn {
padding: 6px 4px;
font-size: 11px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-step {
width: 32px;
.ptd-invest-kpi-value {
font-size: 15px;
font-weight: 600;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-balance {
font-size: 13px;
}
/* paperDashboard 2열 그리드보다 우선 — 항상 세로 스택 */
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 0 0 auto;
.ptd-alloc-table-wrap {
overflow: auto;
flex: 1;
min-height: 0;
}
.ptd-alloc-row--selected {
background: rgba(255, 255, 255, 0.06);
}
.ptd-alloc-toggle {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid rgba(255,255,255,0.15);
background: transparent;
cursor: pointer;
}
.ptd-alloc-toggle--on {
border-color: var(--gc-trade-buy, #ef5350);
color: var(--gc-trade-buy, #ef5350);
}
.ptd-footer-stack {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 12px;
margin: 0;
overflow: visible;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-field,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-pct-row--block {
margin: 0;
flex-shrink: 0;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-actions {
flex-shrink: 0;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
gap: 10px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-reset,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-submit {
padding: 10px 14px;
font-size: 14px;
.ptd-asset-trend {
padding: 8px 12px;
border-radius: 6px;
background: var(--gc-surface-2, rgba(255,255,255,0.04));
}
/* 카드 높이 여유 있을 때: 간격 확대 + 하단 안내 텍스트 표시 */
@container trade-card (min-height: 440px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
justify-content: space-between;
gap: 14px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 1 1 auto;
justify-content: space-evenly;
gap: 14px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: flex;
font-size: 11px;
padding-top: 2px;
}
.ptd-asset-trend-head {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 4px;
}
@container trade-card (min-height: 520px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
gap: 18px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
gap: 16px;
}
.ptd-asset-trend-svg {
width: 100%;
height: 48px;
display: block;
}
/* container query 미지원 fallback */
@supports not (container-type: size) {
@media (max-height: 820px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: none;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
justify-content: flex-start;
gap: 12px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 0 0 auto;
justify-content: flex-start;
}
}
@media (min-height: 821px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: flex;
font-size: 11px;
padding-top: 2px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
justify-content: space-between;
gap: 14px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 1 1 auto;
justify-content: space-evenly;
}
}
.ptd-asset-trend-range {
display: flex;
justify-content: space-between;
font-size: 10px;
opacity: 0.6;
}
+6 -2
View File
@@ -20,7 +20,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
// ── 상단 메뉴 ──
P('메뉴-대시보드', 'dashboard', '홈'),
P('메뉴-실시간 차트', 'chart', '실시간차트', '캔들', '차트'),
P('메뉴-가상매매', 'virtual', '가상', '모의'),
P('메뉴-모의투자', 'paper', '모의투자', '투자금', '페이퍼'),
P('메뉴-모의투자-투자금 관리', 'paper', 'allocation', '한도', 'KPI'),
P('메뉴-가상매매', 'virtual', '가상', '가상매매'),
P('메뉴-추세검색', 'trend', '추세', '검색'),
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
@@ -64,7 +66,9 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
P('메뉴-설정-보조지표 설정', 'indicators', '지표기본값', 'Main탭'),
P('메뉴-설정-백테스팅', 'bt옵션', '자본'),
P('메뉴-설정-전략 설정', 'strategy', '전략기본'),
P('메뉴-설정-가상매매', 'paper', '가상자본'),
P('메뉴-설정-모의투자', 'paper', '모의투자', '모의자본', '수수료'),
P('메뉴-설정-가상매매', 'virtual', '투자대상', '한도'),
P('메뉴-설정-실거래', 'live', '업비트', 'API'),
P('메뉴-설정-추세검색', 'trend설정', '배점'),
P('메뉴-설정-알림 설정', 'alert설정', '소리', '팝업'),
P('메뉴-설정-네트워크', 'network', 'API', 'WebSocket'),
+180 -16
View File
@@ -610,21 +610,42 @@ export interface PaperPositionDto {
profitLossPct?: number;
}
export interface PaperAllocationDto {
id?: number;
symbol: string;
koreanName?: string | null;
maxInvestKrw: number;
budgetPct?: number | null;
strategyId?: number | null;
isActive?: boolean;
pinned?: boolean;
sortOrder?: number;
investedKrw?: number;
availableBuyKrw?: number;
evalAmount?: number;
symbolReturnPct?: number;
weightPct?: number;
}
export interface PaperSummaryDto {
enabled: boolean;
initialCapital: number;
cashBalance: number;
stockEvalAmount: number;
totalAsset: number;
unrealizedPnl: number;
realizedPnl: number;
totalReturnPct: number;
feeRatePct: number;
slippagePct: number;
minOrderKrw: number;
autoTradeEnabled: boolean;
autoTradeBudgetPct: number;
positions: PaperPositionDto[];
enabled: boolean;
initialCapital: number;
cashBalance: number;
stockEvalAmount: number;
totalAsset: number;
unrealizedPnl: number;
realizedPnl: number;
totalReturnPct: number;
feeRatePct: number;
slippagePct: number;
minOrderKrw: number;
autoTradeEnabled: boolean;
autoTradeBudgetPct: number;
reservedCash?: number;
orderableCash?: number;
initialCapitalSnapshot?: number;
positions: PaperPositionDto[];
allocations?: PaperAllocationDto[];
}
export interface PaperTradeDto {
@@ -640,13 +661,79 @@ export interface PaperTradeDto {
feeAmount: number;
netAmount: number;
cashAfter: number;
realizedPnlDelta?: number | null;
positionQtyAfter?: number | null;
createdAt?: string | null;
}
export interface PaperPageDto<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 {
market: string;
side: 'BUY' | 'SELL';
orderKind?: 'limit' | 'market';
orderType?: 'MARKET' | 'LIMIT';
price: number;
quantity: number;
/** 수량 0일 때 매수 예산 비율 (가용현금 %) */
@@ -671,13 +758,90 @@ export async function loadPaperTrades(): Promise<PaperTradeDto[]> {
return (await request<PaperTradeDto[]>('/paper/trades')) ?? [];
}
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperTradeDto | null> {
return request<PaperTradeDto>('/paper/orders', {
export async function loadPaperTradesPaged(params?: {
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',
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> {
return request<PaperSummaryDto>('/paper/reset', { method: 'POST' });
}
+7 -5
View File
@@ -9,14 +9,15 @@ export type TopMenuId = MenuPage;
export type SettingsCategoryId =
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
| 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'trend-search', 'alert', 'network', 'admin',
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'virtual', 'live',
'trend-search', 'alert', 'network', 'admin',
];
export const ALL_MENU_IDS = [
@@ -43,7 +44,9 @@ export const MENU_LABELS: Record<string, string> = {
settings_indicators: '설정 · 보조지표',
settings_backtest: '설정 · 백테스팅',
settings_strategy: '설정 · 전략',
settings_paper: '설정 · 가상매매',
settings_paper: '설정 · 모의투자',
settings_virtual: '설정 · 가상매매',
settings_live: '설정 · 실거래',
'settings_trend-search': '설정 · 추세검색',
settings_alert: '설정 · 알림',
settings_network: '설정 · 네트워크',
@@ -69,7 +72,6 @@ export function canAccessMenu(
permissions: Record<string, boolean> | null | undefined,
menuId: string,
): boolean {
if (menuId === 'paper') return false;
if (menuId === 'strategy') return false;
if (!permissions) return menuId === 'chart';
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 type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy';
@@ -47,6 +47,21 @@ export async function syncVirtualTargetsToBackend(
}),
),
);
try {
await putPaperAllocationsBulk(
targets.map((t, i) => ({
symbol: t.market,
koreanName: t.koreanName,
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
pinned: !!t.pinned,
sortOrder: i,
isActive: true,
})),
);
} catch {
/* paper allocation sync best-effort */
}
}
/** 가상투자 중지 — 대상 종목 체크 해제 + 전역 liveStrategyCheck OFF */
+21 -2
View File
@@ -674,6 +674,14 @@ export interface PaperPositionDto {
profitLossPct?: number;
}
export interface PaperAllocationDto {
symbol: string;
maxInvestKrw: number;
availableBuyKrw?: number;
strategyId?: number | null;
isActive?: boolean;
}
export interface PaperSummaryDto {
enabled: boolean;
initialCapital: number;
@@ -688,7 +696,11 @@ export interface PaperSummaryDto {
minOrderKrw: number;
autoTradeEnabled: boolean;
autoTradeBudgetPct: number;
orderableCash?: number;
reservedCash?: number;
initialCapitalSnapshot?: number;
positions: PaperPositionDto[];
allocations?: PaperAllocationDto[];
}
export interface PaperTradeDto {
@@ -704,13 +716,20 @@ export interface PaperTradeDto {
feeAmount: number;
netAmount: number;
cashAfter: number;
realizedPnlDelta?: number | null;
createdAt?: string | null;
}
export interface PaperPlaceOrderResult {
trade?: PaperTradeDto | null;
order?: { id: number; symbol: string; side: string; status: string } | null;
}
export interface PaperOrderRequest {
market: string;
side: 'BUY' | 'SELL';
orderKind?: 'limit' | 'market';
orderType?: 'MARKET' | 'LIMIT';
price: number;
quantity: number;
/** 수량 0일 때 매수 예산 비율 (가용현금 %) */
@@ -735,8 +754,8 @@ export async function loadPaperTrades(): Promise<PaperTradeDto[]> {
return (await request<PaperTradeDto[]>('/paper/trades')) ?? [];
}
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperTradeDto | null> {
return request<PaperTradeDto>('/paper/orders', {
export async function placePaperOrder(body: PaperOrderRequest): Promise<PaperPlaceOrderResult | null> {
return request<PaperPlaceOrderResult>('/paper/orders', {
method: 'POST',
body: JSON.stringify(body),
});