모의투자 관리 기능 추가
This commit is contained in:
@@ -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> {
|
||||
}
|
||||
+13
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 '화면 캡처에 실패했습니다.';
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user