모의투자 관리 기능 추가
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';
|
||||
Reference in New Issue
Block a user