모의투자 관리 기능 추가

This commit is contained in:
Macbook
2026-05-31 03:47:07 +09:00
parent 9d7dddfa57
commit 16d0e2c226
78 changed files with 4688 additions and 972 deletions
@@ -9,7 +9,8 @@ public final class MenuIds {
public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "notifications", "settings",
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_paper", "settings_trend-search",
"settings_strategy", "settings_paper", "settings_virtual", "settings_live",
"settings_trend-search",
"settings_alert", "settings_network", "settings_admin"
);
}
@@ -1,13 +1,14 @@
package com.goldenchart.controller;
import com.goldenchart.dto.PaperOrderRequest;
import com.goldenchart.dto.PaperSummaryDto;
import com.goldenchart.dto.PaperTradeDto;
import com.goldenchart.dto.*;
import com.goldenchart.service.PaperTradingService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -39,18 +40,81 @@ public class PaperTradingController {
return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), markPrices));
}
@GetMapping("/allocations")
public ResponseEntity<List<PaperAllocationDto>> allocations(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listAllocations(userId(h), deviceId(h), null));
}
@PutMapping("/allocations/bulk")
public ResponseEntity<List<PaperAllocationDto>> bulkAllocations(
@RequestHeader Map<String, String> h,
@RequestBody PaperAllocationBulkRequest body) {
return ResponseEntity.ok(paperTradingService.bulkAllocations(userId(h), deviceId(h), body));
}
@PatchMapping("/allocations/{symbol}")
public ResponseEntity<PaperAllocationDto> patchAllocation(
@RequestHeader Map<String, String> h,
@PathVariable String symbol,
@RequestBody PaperAllocationPatchRequest body) {
return ResponseEntity.ok(paperTradingService.patchAllocation(userId(h), deviceId(h), symbol, body));
}
@GetMapping("/trades")
public ResponseEntity<List<PaperTradeDto>> trades(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listTrades(userId(h), deviceId(h)));
public ResponseEntity<?> trades(
@RequestHeader Map<String, String> h,
@RequestParam(required = false) String symbol,
@RequestParam(required = false) String side,
@RequestParam(required = false) String source,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) Integer size) {
if (size == null && symbol == null && side == null && source == null && from == null && to == null) {
return ResponseEntity.ok(paperTradingService.listTradesLegacy(userId(h), deviceId(h)));
}
int pageSize = size != null ? size : 50;
return ResponseEntity.ok(paperTradingService.listTrades(
userId(h), deviceId(h), symbol, side, source, from, to, page, pageSize));
}
@GetMapping("/ledger")
public ResponseEntity<PaperPageDto<PaperLedgerEntryDto>> ledger(
@RequestHeader Map<String, String> h,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
return ResponseEntity.ok(paperTradingService.listLedger(userId(h), deviceId(h), from, to, page, size));
}
@GetMapping("/snapshots/daily")
public ResponseEntity<List<PaperDailySnapshotDto>> dailySnapshots(
@RequestHeader Map<String, String> h,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return ResponseEntity.ok(paperTradingService.listDailySnapshots(userId(h), deviceId(h), from, to));
}
@GetMapping("/orders")
public ResponseEntity<List<PaperOrderDto>> pendingOrders(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listPendingOrders(userId(h), deviceId(h)));
}
@PostMapping("/orders")
public ResponseEntity<PaperTradeDto> placeOrder(
public ResponseEntity<PaperPlaceOrderResult> placeOrder(
@RequestHeader Map<String, String> h,
@RequestBody PaperOrderRequest body) {
return ResponseEntity.ok(paperTradingService.placeOrder(userId(h), deviceId(h), body));
}
@PostMapping("/orders/{id}/cancel")
public ResponseEntity<PaperOrderDto> cancelOrder(
@RequestHeader Map<String, String> h,
@PathVariable Long id) {
return ResponseEntity.ok(paperTradingService.cancelOrder(userId(h), deviceId(h), id));
}
@PostMapping("/reset")
public ResponseEntity<PaperSummaryDto> reset(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.resetAccount(userId(h), deviceId(h)));
@@ -0,0 +1,22 @@
package com.goldenchart.dto;
import lombok.Data;
import java.util.List;
@Data
public class PaperAllocationBulkRequest {
private List<PaperAllocationItem> items;
@Data
public static class PaperAllocationItem {
private String symbol;
private String koreanName;
private Double maxInvestKrw;
private Double budgetPct;
private Long strategyId;
private Boolean isActive;
private Boolean pinned;
private Integer sortOrder;
}
}
@@ -0,0 +1,23 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PaperAllocationDto {
private Long id;
private String symbol;
private String koreanName;
private Double maxInvestKrw;
private Double budgetPct;
private Long strategyId;
private Boolean isActive;
private Boolean pinned;
private Integer sortOrder;
private Double investedKrw;
private Double availableBuyKrw;
private Double evalAmount;
private Double symbolReturnPct;
private Double weightPct;
}
@@ -0,0 +1,14 @@
package com.goldenchart.dto;
import lombok.Data;
@Data
public class PaperAllocationPatchRequest {
private Double maxInvestKrw;
private Double budgetPct;
private Long strategyId;
private Boolean isActive;
private Boolean pinned;
private Integer sortOrder;
private String koreanName;
}
@@ -0,0 +1,16 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PaperDailySnapshotDto {
private String snapshotDate;
private Double cashBalance;
private Double stockEvalAmount;
private Double totalAsset;
private Double dailyPnl;
private Double dailyReturnPct;
private Double cumulativeReturnPct;
}
@@ -0,0 +1,17 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PaperLedgerEntryDto {
private Long id;
private String entryType;
private Double amount;
private Double cashBefore;
private Double cashAfter;
private Long refTradeId;
private String symbol;
private String createdAt;
}
@@ -0,0 +1,24 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PaperOrderDto {
private Long id;
private String symbol;
private String side;
private String orderType;
private Double limitPrice;
private Double quantity;
private Double filledQuantity;
private String status;
private Double reservedCash;
private Double reservedQty;
private String orderKind;
private String source;
private Long strategyId;
private String createdAt;
private String updatedAt;
}
@@ -7,6 +7,8 @@ public class PaperOrderRequest {
private String market;
private String side; // BUY | SELL
private String orderKind; // limit | market
/** MARKET | LIMIT — 미지정 시 orderKind 기준 (limit→LIMIT, market→MARKET) */
private String orderType;
private Double price;
private Double quantity;
/** 수량 미입력 시 매수 예산 비율 (가용현금 %) — 기본값: 설정의 paperAutoTradeBudgetPct */
@@ -0,0 +1,16 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class PaperPageDto<T> {
private List<T> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
}
@@ -0,0 +1,11 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PaperPlaceOrderResult {
private PaperTradeDto trade;
private PaperOrderDto order;
}
@@ -21,5 +21,9 @@ public class PaperSummaryDto {
private Double minOrderKrw;
private Boolean autoTradeEnabled;
private Double autoTradeBudgetPct;
private Double reservedCash;
private Double orderableCash;
private Double initialCapitalSnapshot;
private List<PaperPositionDto> positions;
private List<PaperAllocationDto> allocations;
}
@@ -18,5 +18,7 @@ public class PaperTradeDto {
private Double feeAmount;
private Double netAmount;
private Double cashAfter;
private Double realizedPnlDelta;
private Double positionQtyAfter;
private String createdAt;
}
@@ -29,6 +29,24 @@ public class GcPaperAccount {
@Builder.Default
private BigDecimal realizedPnl = BigDecimal.ZERO;
@Column(name = "reserved_cash", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal reservedCash = BigDecimal.ZERO;
@Column(name = "initial_capital_snapshot", precision = 20, scale = 2)
private BigDecimal initialCapitalSnapshot;
@Column(name = "reset_count", nullable = false)
@Builder.Default
private Integer resetCount = 0;
@Column(name = "last_reset_at")
private LocalDateTime lastResetAt;
@Column(name = "status", length = 20, nullable = false)
@Builder.Default
private String status = "ACTIVE";
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -0,0 +1,46 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_paper_cash_ledger")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcPaperCashLedger {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", nullable = false)
private Long accountId;
@Column(name = "entry_type", length = 30, nullable = false)
private String entryType;
@Column(name = "amount", precision = 20, scale = 2, nullable = false)
private BigDecimal amount;
@Column(name = "cash_before", precision = 20, scale = 2, nullable = false)
private BigDecimal cashBefore;
@Column(name = "cash_after", precision = 20, scale = 2, nullable = false)
private BigDecimal cashAfter;
@Column(name = "ref_trade_id")
private Long refTradeId;
@Column(name = "symbol", length = 50)
private String symbol;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -0,0 +1,55 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_paper_daily_snapshot",
uniqueConstraints = @UniqueConstraint(name = "uk_paper_snapshot_account_date",
columnNames = {"account_id", "snapshot_date"}))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcPaperDailySnapshot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", nullable = false)
private Long accountId;
@Column(name = "snapshot_date", nullable = false)
private LocalDate snapshotDate;
@Column(name = "cash_balance", precision = 20, scale = 2, nullable = false)
private BigDecimal cashBalance;
@Column(name = "stock_eval_amount", precision = 20, scale = 2, nullable = false)
private BigDecimal stockEvalAmount;
@Column(name = "total_asset", precision = 20, scale = 2, nullable = false)
private BigDecimal totalAsset;
@Column(name = "daily_pnl", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal dailyPnl = BigDecimal.ZERO;
@Column(name = "daily_return_pct", precision = 10, scale = 4, nullable = false)
@Builder.Default
private BigDecimal dailyReturnPct = BigDecimal.ZERO;
@Column(name = "cumulative_return_pct", precision = 10, scale = 4, nullable = false)
@Builder.Default
private BigDecimal cumulativeReturnPct = BigDecimal.ZERO;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -0,0 +1,83 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_paper_order")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcPaperOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", nullable = false)
private Long accountId;
@Column(name = "symbol", length = 50, nullable = false)
private String symbol;
@Column(name = "side", length = 10, nullable = false)
private String side;
@Column(name = "order_type", length = 20, nullable = false)
@Builder.Default
private String orderType = "LIMIT";
@Column(name = "limit_price", precision = 20, scale = 2)
private BigDecimal limitPrice;
@Column(name = "quantity", precision = 30, scale = 12, nullable = false)
private BigDecimal quantity;
@Column(name = "filled_quantity", precision = 30, scale = 12, nullable = false)
@Builder.Default
private BigDecimal filledQuantity = BigDecimal.ZERO;
@Column(name = "status", length = 20, nullable = false)
@Builder.Default
private String status = "PENDING";
@Column(name = "reserved_cash", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal reservedCash = BigDecimal.ZERO;
@Column(name = "reserved_qty", precision = 30, scale = 12, nullable = false)
@Builder.Default
private BigDecimal reservedQty = BigDecimal.ZERO;
@Column(name = "order_kind", length = 20, nullable = false)
@Builder.Default
private String orderKind = "limit";
@Column(name = "source", length = 20, nullable = false)
@Builder.Default
private String source = "MANUAL";
@Column(name = "strategy_id")
private Long strategyId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "filled_at")
private LocalDateTime filledAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,40 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_paper_reset_log")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcPaperResetLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", nullable = false)
private Long accountId;
@Column(name = "reset_at", nullable = false)
private LocalDateTime resetAt;
@Column(name = "initial_capital", precision = 20, scale = 2, nullable = false)
private BigDecimal initialCapital;
@Column(name = "cash_before", precision = 20, scale = 2, nullable = false)
private BigDecimal cashBefore;
@Column(name = "positions_json", columnDefinition = "JSON")
private String positionsJson;
@Column(name = "reason", length = 200)
private String reason;
@PrePersist
protected void onCreate() {
if (resetAt == null) resetAt = LocalDateTime.now();
}
}
@@ -0,0 +1,67 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_paper_symbol_allocation",
uniqueConstraints = @UniqueConstraint(name = "uk_paper_alloc_account_symbol",
columnNames = {"account_id", "symbol"}))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcPaperSymbolAllocation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", nullable = false)
private Long accountId;
@Column(name = "symbol", length = 50, nullable = false)
private String symbol;
@Column(name = "korean_name", length = 100)
private String koreanName;
@Column(name = "max_invest_krw", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal maxInvestKrw = BigDecimal.ZERO;
@Column(name = "budget_pct", precision = 8, scale = 4)
private BigDecimal budgetPct;
@Column(name = "strategy_id")
private Long strategyId;
@Column(name = "is_active", nullable = false)
@Builder.Default
private Boolean isActive = true;
@Column(name = "pinned", nullable = false)
@Builder.Default
private Boolean pinned = false;
@Column(name = "sort_order", nullable = false)
@Builder.Default
private Integer sortOrder = 0;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -54,6 +54,15 @@ public class GcPaperTrade {
@Column(name = "cash_after", precision = 20, scale = 2, nullable = false)
private BigDecimal cashAfter;
@Column(name = "realized_pnl_delta", precision = 20, scale = 2)
private BigDecimal realizedPnlDelta;
@Column(name = "position_qty_after", precision = 30, scale = 12)
private BigDecimal positionQtyAfter;
@Column(name = "note", length = 200)
private String note;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -0,0 +1,15 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcPaperCashLedger;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
public interface GcPaperCashLedgerRepository extends JpaRepository<GcPaperCashLedger, Long> {
Page<GcPaperCashLedger> findByAccountIdAndCreatedAtBetweenOrderByCreatedAtDesc(
Long accountId, LocalDateTime from, LocalDateTime to, Pageable pageable);
Page<GcPaperCashLedger> findByAccountIdOrderByCreatedAtDesc(Long accountId, Pageable pageable);
}
@@ -0,0 +1,15 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcPaperDailySnapshot;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface GcPaperDailySnapshotRepository extends JpaRepository<GcPaperDailySnapshot, Long> {
List<GcPaperDailySnapshot> findByAccountIdAndSnapshotDateBetweenOrderBySnapshotDateAsc(
Long accountId, LocalDate from, LocalDate to);
Optional<GcPaperDailySnapshot> findByAccountIdAndSnapshotDate(Long accountId, LocalDate date);
}
@@ -0,0 +1,12 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcPaperOrder;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GcPaperOrderRepository extends JpaRepository<GcPaperOrder, Long> {
List<GcPaperOrder> findByAccountIdAndStatusOrderByCreatedAtDesc(Long accountId, String status);
List<GcPaperOrder> findByStatusAndSymbol(String status, String symbol);
List<GcPaperOrder> findByStatus(String status);
}
@@ -0,0 +1,7 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcPaperResetLog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GcPaperResetLogRepository extends JpaRepository<GcPaperResetLog, Long> {
}
@@ -0,0 +1,13 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcPaperSymbolAllocation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface GcPaperSymbolAllocationRepository extends JpaRepository<GcPaperSymbolAllocation, Long> {
List<GcPaperSymbolAllocation> findByAccountIdOrderBySortOrderAscSymbolAsc(Long accountId);
Optional<GcPaperSymbolAllocation> findByAccountIdAndSymbol(Long accountId, String symbol);
void deleteByAccountId(Long accountId);
}
@@ -1,10 +1,33 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcPaperTrade;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface GcPaperTradeRepository extends JpaRepository<GcPaperTrade, Long> {
List<GcPaperTrade> findTop100ByAccountIdOrderByCreatedAtDesc(Long accountId);
@Query("""
SELECT t FROM GcPaperTrade t WHERE t.accountId = :accountId
AND (:symbol IS NULL OR t.symbol = :symbol)
AND (:side IS NULL OR t.side = :side)
AND (:source IS NULL OR t.source = :source)
AND (:from IS NULL OR t.createdAt >= :from)
AND (:to IS NULL OR t.createdAt <= :to)
ORDER BY t.createdAt DESC
""")
Page<GcPaperTrade> findFiltered(
@Param("accountId") Long accountId,
@Param("symbol") String symbol,
@Param("side") String side,
@Param("source") String source,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
}
@@ -0,0 +1,22 @@
package com.goldenchart.scheduler;
import com.goldenchart.service.PaperSnapshotService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class PaperSnapshotScheduler {
private final PaperSnapshotService snapshotService;
/** KST 00:05 — 일별 자산 스냅샷 */
@Scheduled(cron = "0 5 0 * * *", zone = "Asia/Seoul")
public void captureDailySnapshots() {
log.info("[PaperSnapshotScheduler] EOD snapshot start");
snapshotService.captureEodForAllAccounts();
}
}
@@ -0,0 +1,196 @@
package com.goldenchart.service;
import com.goldenchart.dto.*;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcPaperAccount;
import com.goldenchart.entity.GcPaperPosition;
import com.goldenchart.entity.GcPaperSymbolAllocation;
import com.goldenchart.repository.GcPaperPositionRepository;
import com.goldenchart.repository.GcPaperSymbolAllocationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional
public class PaperAllocationService {
private static final RoundingMode RM = RoundingMode.HALF_UP;
private final GcPaperSymbolAllocationRepository allocRepo;
private final GcPaperPositionRepository positionRepo;
@Transactional(readOnly = true)
public List<PaperAllocationDto> listWithMetrics(Long accountId, Map<String, Double> markPrices,
double totalAsset) {
List<GcPaperSymbolAllocation> rows = allocRepo.findByAccountIdOrderBySortOrderAscSymbolAsc(accountId);
List<PaperAllocationDto> out = new ArrayList<>();
for (GcPaperSymbolAllocation a : rows) {
out.add(toDto(a, accountId, markPrices, totalAsset));
}
return out;
}
public List<PaperAllocationDto> bulkUpsert(Long accountId, PaperAllocationBulkRequest req,
BigDecimal defaultMaxKrw) {
if (req == null || req.getItems() == null) return listWithMetrics(accountId, null, 0);
int order = 0;
for (PaperAllocationBulkRequest.PaperAllocationItem item : req.getItems()) {
if (item.getSymbol() == null || item.getSymbol().isBlank()) continue;
GcPaperSymbolAllocation row = allocRepo.findByAccountIdAndSymbol(accountId, item.getSymbol())
.orElse(GcPaperSymbolAllocation.builder()
.accountId(accountId)
.symbol(item.getSymbol())
.build());
if (item.getKoreanName() != null) row.setKoreanName(item.getKoreanName());
if (item.getMaxInvestKrw() != null) {
row.setMaxInvestKrw(BigDecimal.valueOf(item.getMaxInvestKrw()).setScale(2, RM));
} else if (row.getMaxInvestKrw() == null || row.getMaxInvestKrw().signum() == 0) {
row.setMaxInvestKrw(defaultMaxKrw);
}
if (item.getBudgetPct() != null) row.setBudgetPct(BigDecimal.valueOf(item.getBudgetPct()));
if (item.getStrategyId() != null) row.setStrategyId(item.getStrategyId());
if (item.getIsActive() != null) row.setIsActive(item.getIsActive());
if (item.getPinned() != null) row.setPinned(item.getPinned());
row.setSortOrder(item.getSortOrder() != null ? item.getSortOrder() : order++);
allocRepo.save(row);
}
return listWithMetrics(accountId, null, 0);
}
public PaperAllocationDto patch(Long accountId, String symbol, PaperAllocationPatchRequest req) {
GcPaperSymbolAllocation row = allocRepo.findByAccountIdAndSymbol(accountId, symbol)
.orElseThrow(() -> new IllegalArgumentException("종목 한도 설정이 없습니다: " + symbol));
if (req.getMaxInvestKrw() != null) {
row.setMaxInvestKrw(BigDecimal.valueOf(req.getMaxInvestKrw()).setScale(2, RM));
}
if (req.getBudgetPct() != null) row.setBudgetPct(BigDecimal.valueOf(req.getBudgetPct()));
if (req.getStrategyId() != null) row.setStrategyId(req.getStrategyId());
if (req.getIsActive() != null) row.setIsActive(req.getIsActive());
if (req.getPinned() != null) row.setPinned(req.getPinned());
if (req.getSortOrder() != null) row.setSortOrder(req.getSortOrder());
if (req.getKoreanName() != null) row.setKoreanName(req.getKoreanName());
allocRepo.save(row);
return toDto(row, accountId, null, 0);
}
public GcPaperSymbolAllocation getOrDefault(Long accountId, String symbol, BigDecimal defaultMax) {
return allocRepo.findByAccountIdAndSymbol(accountId, symbol)
.orElseGet(() -> {
GcPaperSymbolAllocation a = GcPaperSymbolAllocation.builder()
.accountId(accountId)
.symbol(symbol)
.maxInvestKrw(defaultMax)
.isActive(true)
.build();
return allocRepo.save(a);
});
}
/**
* 매수 가능 금액 검증. autoTrade=true 시 전역 auto budget % 적용.
*/
public void validateBuy(GcPaperAccount account, GcAppSettings app, String symbol,
BigDecimal needKrw, boolean autoTrade) {
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double orderable = account.getCashBalance().doubleValue() - reserved;
if (autoTrade) {
double pct = app.getPaperAutoTradeBudgetPct() != null
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95;
orderable = orderable * pct / 100.0;
}
if (needKrw.doubleValue() > orderable + 0.01) {
throw new IllegalStateException("주문가능 현금이 부족합니다.");
}
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot()
: (app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000));
GcPaperSymbolAllocation alloc = getOrDefault(account.getId(), symbol, defaultMax);
if (!Boolean.TRUE.equals(alloc.getIsActive())) {
throw new IllegalStateException("해당 종목은 신규 매수가 비활성화되어 있습니다.");
}
double invested = investedKrw(account.getId(), symbol, null);
double max = alloc.getMaxInvestKrw().doubleValue();
double available = Math.max(0, max - invested);
if (needKrw.doubleValue() > available + 0.01) {
throw new IllegalStateException("종목 투자 한도를 초과합니다.");
}
}
public double investedKrw(Long accountId, String symbol, Map<String, Double> markPrices) {
Optional<GcPaperPosition> pos = positionRepo.findByAccountIdAndSymbol(accountId, symbol);
if (pos.isEmpty() || pos.get().getQuantity().doubleValue() <= 0) return 0;
GcPaperPosition p = pos.get();
double qty = p.getQuantity().doubleValue();
Double mark = markPrices != null ? markPrices.get(symbol) : null;
if (mark != null && mark > 0) return mark * qty;
return p.getAvgPrice().doubleValue() * qty;
}
public void deleteAllForAccount(Long accountId) {
allocRepo.deleteByAccountId(accountId);
}
public void seedFromPositions(Long accountId, BigDecimal defaultMax) {
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(accountId);
int sortOrder = 0;
for (GcPaperPosition p : positions) {
if (p.getQuantity().doubleValue() <= 0) continue;
final int order = sortOrder++;
allocRepo.findByAccountIdAndSymbol(accountId, p.getSymbol()).orElseGet(() ->
allocRepo.save(GcPaperSymbolAllocation.builder()
.accountId(accountId)
.symbol(p.getSymbol())
.koreanName(p.getKoreanName())
.maxInvestKrw(defaultMax)
.sortOrder(order)
.build()));
}
}
private PaperAllocationDto toDto(GcPaperSymbolAllocation a, Long accountId,
Map<String, Double> markPrices, double totalAsset) {
double invested = investedKrw(accountId, a.getSymbol(), markPrices);
double max = a.getMaxInvestKrw().doubleValue();
double available = Math.max(0, max - invested);
double eval = invested;
double cost = 0;
Optional<GcPaperPosition> pos = positionRepo.findByAccountIdAndSymbol(accountId, a.getSymbol());
if (pos.isPresent() && pos.get().getQuantity().doubleValue() > 0) {
double qty = pos.get().getQuantity().doubleValue();
double avg = pos.get().getAvgPrice().doubleValue();
cost = avg * qty;
Double mark = markPrices != null ? markPrices.get(a.getSymbol()) : null;
eval = mark != null ? mark * qty : cost;
}
double retPct = cost > 0 ? (eval - cost) / cost * 100 : 0;
double weight = totalAsset > 0 ? eval / totalAsset * 100 : 0;
return PaperAllocationDto.builder()
.id(a.getId())
.symbol(a.getSymbol())
.koreanName(a.getKoreanName())
.maxInvestKrw(max)
.budgetPct(a.getBudgetPct() != null ? a.getBudgetPct().doubleValue() : null)
.strategyId(a.getStrategyId())
.isActive(a.getIsActive())
.pinned(a.getPinned())
.sortOrder(a.getSortOrder())
.investedKrw(invested)
.availableBuyKrw(available)
.evalAmount(eval)
.symbolReturnPct(retPct)
.weightPct(weight)
.build();
}
}
@@ -0,0 +1,108 @@
package com.goldenchart.service;
import com.goldenchart.dto.PaperLedgerEntryDto;
import com.goldenchart.dto.PaperPageDto;
import com.goldenchart.entity.GcPaperAccount;
import com.goldenchart.entity.GcPaperCashLedger;
import com.goldenchart.entity.GcPaperTrade;
import com.goldenchart.repository.GcPaperCashLedgerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Transactional
public class PaperLedgerService {
public static final String INITIAL = "INITIAL";
public static final String BUY_SETTLE = "BUY_SETTLE";
public static final String SELL_SETTLE = "SELL_SETTLE";
public static final String RESET = "RESET";
private final GcPaperCashLedgerRepository ledgerRepo;
public void recordInitial(GcPaperAccount account, BigDecimal initialCash) {
ledgerRepo.save(GcPaperCashLedger.builder()
.accountId(account.getId())
.entryType(INITIAL)
.amount(initialCash)
.cashBefore(BigDecimal.ZERO)
.cashAfter(initialCash)
.build());
}
public void recordBuySettle(GcPaperAccount account, GcPaperTrade trade, BigDecimal totalCost) {
BigDecimal before = account.getCashBalance().add(totalCost);
ledgerRepo.save(GcPaperCashLedger.builder()
.accountId(account.getId())
.entryType(BUY_SETTLE)
.amount(totalCost.negate())
.cashBefore(before)
.cashAfter(account.getCashBalance())
.refTradeId(trade.getId())
.symbol(trade.getSymbol())
.build());
}
public void recordSellSettle(GcPaperAccount account, GcPaperTrade trade, BigDecimal netProceeds) {
BigDecimal before = account.getCashBalance().subtract(netProceeds);
ledgerRepo.save(GcPaperCashLedger.builder()
.accountId(account.getId())
.entryType(SELL_SETTLE)
.amount(netProceeds)
.cashBefore(before)
.cashAfter(account.getCashBalance())
.refTradeId(trade.getId())
.symbol(trade.getSymbol())
.build());
}
public void recordReset(GcPaperAccount account, BigDecimal cashBefore, BigDecimal newCash) {
ledgerRepo.save(GcPaperCashLedger.builder()
.accountId(account.getId())
.entryType(RESET)
.amount(newCash.subtract(cashBefore))
.cashBefore(cashBefore)
.cashAfter(newCash)
.build());
}
@Transactional(readOnly = true)
public PaperPageDto<PaperLedgerEntryDto> list(Long accountId, LocalDateTime from, LocalDateTime to,
int page, int size) {
PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size)));
Page<GcPaperCashLedger> result = (from != null || to != null)
? ledgerRepo.findByAccountIdAndCreatedAtBetweenOrderByCreatedAtDesc(
accountId,
from != null ? from : LocalDateTime.of(2000, 1, 1, 0, 0),
to != null ? to : LocalDateTime.now().plusYears(10),
pr)
: ledgerRepo.findByAccountIdOrderByCreatedAtDesc(accountId, pr);
return PaperPageDto.<PaperLedgerEntryDto>builder()
.content(result.getContent().stream().map(this::toDto).toList())
.page(result.getNumber())
.size(result.getSize())
.totalElements(result.getTotalElements())
.totalPages(result.getTotalPages())
.build();
}
private PaperLedgerEntryDto toDto(GcPaperCashLedger e) {
return PaperLedgerEntryDto.builder()
.id(e.getId())
.entryType(e.getEntryType())
.amount(e.getAmount().doubleValue())
.cashBefore(e.getCashBefore().doubleValue())
.cashAfter(e.getCashAfter().doubleValue())
.refTradeId(e.getRefTradeId())
.symbol(e.getSymbol())
.createdAt(e.getCreatedAt() != null ? e.getCreatedAt().toString() : null)
.build();
}
}
@@ -0,0 +1,53 @@
package com.goldenchart.service;
import com.goldenchart.entity.GcPaperOrder;
import com.goldenchart.repository.GcPaperOrderRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
@Slf4j
public class PaperOrderMatcherService {
private final GcPaperOrderRepository orderRepo;
private final PaperTradingService paperTradingService;
public PaperOrderMatcherService(GcPaperOrderRepository orderRepo,
@Lazy PaperTradingService paperTradingService) {
this.orderRepo = orderRepo;
this.paperTradingService = paperTradingService;
}
public void onPriceTick(String symbol, double price) {
if (symbol == null || price <= 0) return;
List<GcPaperOrder> pending = orderRepo.findByStatusAndSymbol("PENDING", symbol);
for (GcPaperOrder o : pending) {
try {
paperTradingService.tryFillPendingOrder(o.getId(), price);
} catch (Exception e) {
log.debug("[PaperOrderMatcher] fill skip order={}: {}", o.getId(), e.getMessage());
}
}
}
@Scheduled(fixedDelay = 3000)
public void pollPendingOrders() {
Set<String> symbols = new HashSet<>();
for (GcPaperOrder o : orderRepo.findByStatus("PENDING")) {
symbols.add(o.getSymbol());
}
for (String symbol : symbols) {
try {
double price = paperTradingService.resolveMarkPrice(symbol);
if (price > 0) onPriceTick(symbol, price);
} catch (Exception ignored) {
}
}
}
}
@@ -0,0 +1,120 @@
package com.goldenchart.service;
import com.goldenchart.dto.PaperDailySnapshotDto;
import com.goldenchart.entity.GcPaperAccount;
import com.goldenchart.entity.GcPaperDailySnapshot;
import com.goldenchart.entity.GcPaperPosition;
import com.goldenchart.repository.GcPaperAccountRepository;
import com.goldenchart.repository.GcPaperDailySnapshotRepository;
import com.goldenchart.repository.GcPaperPositionRepository;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class PaperSnapshotService {
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private final GcPaperDailySnapshotRepository snapshotRepo;
private final GcPaperAccountRepository accountRepo;
private final GcPaperPositionRepository positionRepo;
private final Ta4jStorage ta4jStorage;
@Transactional(readOnly = true)
public List<PaperDailySnapshotDto> list(Long accountId, LocalDate from, LocalDate to) {
LocalDate f = from != null ? from : LocalDate.now(KST).minusDays(90);
LocalDate t = to != null ? to : LocalDate.now(KST);
return snapshotRepo.findByAccountIdAndSnapshotDateBetweenOrderBySnapshotDateAsc(accountId, f, t)
.stream().map(this::toDto).toList();
}
public void captureEodForAllAccounts() {
LocalDate today = LocalDate.now(KST);
for (GcPaperAccount account : accountRepo.findAll()) {
try {
captureEod(account, today);
} catch (Exception e) {
log.warn("[PaperSnapshot] account {} failed: {}", account.getId(), e.getMessage());
}
}
}
public void captureEod(GcPaperAccount account, LocalDate date) {
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
Map<String, Double> marks = new HashMap<>();
double stockEval = 0;
for (GcPaperPosition p : positions) {
double qty = p.getQuantity().doubleValue();
if (qty <= 0) continue;
double mark = resolveMark(p.getSymbol(), p.getAvgPrice().doubleValue());
marks.put(p.getSymbol(), mark);
stockEval += mark * qty;
}
double cash = account.getCashBalance().doubleValue();
double total = cash + stockEval;
double initial = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot().doubleValue() : cash;
double cumRet = initial > 0 ? (total - initial) / initial * 100 : 0;
GcPaperDailySnapshot prev = snapshotRepo
.findByAccountIdAndSnapshotDate(account.getId(), date.minusDays(1))
.orElse(null);
double prevTotal = prev != null ? prev.getTotalAsset().doubleValue() : initial;
double dailyPnl = total - prevTotal;
double dailyRet = prevTotal > 0 ? dailyPnl / prevTotal * 100 : 0;
GcPaperDailySnapshot snap = snapshotRepo.findByAccountIdAndSnapshotDate(account.getId(), date)
.orElse(GcPaperDailySnapshot.builder()
.accountId(account.getId())
.snapshotDate(date)
.build());
snap.setCashBalance(BigDecimal.valueOf(cash).setScale(2, RoundingMode.HALF_UP));
snap.setStockEvalAmount(BigDecimal.valueOf(stockEval).setScale(2, RoundingMode.HALF_UP));
snap.setTotalAsset(BigDecimal.valueOf(total).setScale(2, RoundingMode.HALF_UP));
snap.setDailyPnl(BigDecimal.valueOf(dailyPnl).setScale(2, RoundingMode.HALF_UP));
snap.setDailyReturnPct(BigDecimal.valueOf(dailyRet).setScale(4, RoundingMode.HALF_UP));
snap.setCumulativeReturnPct(BigDecimal.valueOf(cumRet).setScale(4, RoundingMode.HALF_UP));
snapshotRepo.save(snap);
}
private double resolveMark(String symbol, double fallback) {
try {
if (!ta4jStorage.exists(symbol, "1m")) return fallback;
BarSeries series = ta4jStorage.getOrCreate(symbol, "1m");
if (!series.isEmpty()) {
Bar last = series.getLastBar();
return last.getClosePrice().doubleValue();
}
} catch (Exception ignored) {
}
return fallback;
}
private PaperDailySnapshotDto toDto(GcPaperDailySnapshot s) {
return PaperDailySnapshotDto.builder()
.snapshotDate(s.getSnapshotDate().toString())
.cashBalance(s.getCashBalance().doubleValue())
.stockEvalAmount(s.getStockEvalAmount().doubleValue())
.totalAsset(s.getTotalAsset().doubleValue())
.dailyPnl(s.getDailyPnl().doubleValue())
.dailyReturnPct(s.getDailyReturnPct().doubleValue())
.cumulativeReturnPct(s.getCumulativeReturnPct().doubleValue())
.build();
}
}
@@ -1,46 +1,56 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.*;
import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcPaperAccount;
import com.goldenchart.entity.GcPaperPosition;
import com.goldenchart.entity.GcPaperTrade;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.repository.GcPaperAccountRepository;
import com.goldenchart.repository.GcPaperPositionRepository;
import com.goldenchart.repository.GcPaperTradeRepository;
import com.goldenchart.entity.*;
import com.goldenchart.repository.*;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 모의투자(페이퍼 트레이딩) 체결·잔고·포지션 관리.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class PaperTradingService {
private static final int SCALE_QTY = 12;
private static final int SCALE_KRW = 2;
private static final RoundingMode RM = RoundingMode.HALF_UP;
private static final int SCALE_QTY = 12;
private static final int SCALE_KRW = 2;
private static final RoundingMode RM = RoundingMode.HALF_UP;
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private final GcPaperAccountRepository accountRepo;
private final GcPaperPositionRepository positionRepo;
private final GcPaperTradeRepository tradeRepo;
private final AppSettingsService appSettingsService;
private final GcPaperAccountRepository accountRepo;
private final GcPaperPositionRepository positionRepo;
private final GcPaperTradeRepository tradeRepo;
private final GcPaperOrderRepository orderRepo;
private final GcPaperResetLogRepository resetLogRepo;
private final AppSettingsService appSettingsService;
private final GcLiveStrategySettingsRepository liveSettingsRepo;
private final PaperAllocationService allocationService;
private final PaperLedgerService ledgerService;
private final PaperSnapshotService snapshotService;
private final Ta4jStorage ta4jStorage;
private final ObjectMapper objectMapper;
// ── 조회 ─────────────────────────────────────────────────────────────────
@Transactional(readOnly = true)
public PaperSummaryDto getSummary(Long userId, String deviceId, Map<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
@@ -74,15 +84,26 @@ public class PaperTradingService {
}
double cash = account.getCashBalance().doubleValue();
double initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital().doubleValue() : 10_000_000;
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double orderable = cash - reserved;
double initialSnap = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot().doubleValue()
: (app.getPaperInitialCapital() != null
? app.getPaperInitialCapital().doubleValue() : 10_000_000);
double total = cash + stockEval;
double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0;
double returnPct = initialSnap > 0 ? (total - initialSnap) / initialSnap * 100 : 0;
List<PaperAllocationDto> allocations = allocationService.listWithMetrics(
account.getId(), markPrices, total);
return PaperSummaryDto.builder()
.enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled()))
.initialCapital(initial)
.initialCapital(initialSnap)
.cashBalance(cash)
.reservedCash(reserved)
.orderableCash(orderable)
.initialCapitalSnapshot(initialSnap)
.stockEvalAmount(stockEval)
.totalAsset(total)
.unrealizedPnl(unrealized)
@@ -95,22 +116,90 @@ public class PaperTradingService {
.autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95)
.positions(posDtos)
.allocations(allocations)
.build();
}
public List<PaperTradeDto> listTrades(Long userId, String deviceId) {
@Transactional(readOnly = true)
public List<PaperAllocationDto> listAllocations(Long userId, String deviceId,
Map<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId())
.stream().map(this::toTradeDto).toList();
PaperSummaryDto s = getSummary(userId, deviceId, markPrices);
return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset());
}
public List<PaperAllocationDto> bulkAllocations(Long userId, String deviceId,
PaperAllocationBulkRequest req) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot()
: app.getPaperInitialCapital();
return allocationService.bulkUpsert(account.getId(), req, defaultMax);
}
public PaperAllocationDto patchAllocation(Long userId, String deviceId, String symbol,
PaperAllocationPatchRequest req) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
return allocationService.patch(account.getId(), symbol, req);
}
@Transactional(readOnly = true)
public PaperPageDto<PaperTradeDto> listTrades(Long userId, String deviceId,
String symbol, String side, String source,
LocalDateTime from, LocalDateTime to,
int page, int size) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size)));
Page<GcPaperTrade> result = tradeRepo.findFiltered(
account.getId(), symbol, side, source, from, to, pr);
return PaperPageDto.<PaperTradeDto>builder()
.content(result.getContent().stream().map(this::toTradeDto).toList())
.page(result.getNumber())
.size(result.getSize())
.totalElements(result.getTotalElements())
.totalPages(result.getTotalPages())
.build();
}
public List<PaperTradeDto> listTradesLegacy(Long userId, String deviceId) {
return listTrades(userId, deviceId, null, null, null, null, null, 0, 100).getContent();
}
@Transactional(readOnly = true)
public PaperPageDto<PaperLedgerEntryDto> listLedger(Long userId, String deviceId,
LocalDateTime from, LocalDateTime to,
int page, int size) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
return ledgerService.list(account.getId(), from, to, page, size);
}
@Transactional(readOnly = true)
public List<PaperDailySnapshotDto> listDailySnapshots(Long userId, String deviceId,
LocalDate from, LocalDate to) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
return snapshotService.list(account.getId(), from, to);
}
@Transactional(readOnly = true)
public List<PaperOrderDto> listPendingOrders(Long userId, String deviceId) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId,
appSettingsService.getEntity(userId, deviceId));
return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING")
.stream().map(this::toOrderDto).toList();
}
// ── 주문 ─────────────────────────────────────────────────────────────────
public PaperTradeDto placeOrder(Long userId, String deviceId, PaperOrderRequest req) {
public PaperPlaceOrderResult placeOrder(Long userId, String deviceId, PaperOrderRequest req) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요.");
throw new IllegalStateException("모의투자가 비활성화되어 있습니다.");
}
if (req.getMarket() == null || req.getSide() == null) {
throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다.");
@@ -124,11 +213,13 @@ public class PaperTradingService {
throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
}
String orderType = resolveOrderType(req);
String source = req.getSource() != null ? req.getSource() : "MANUAL";
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double qty = req.getQuantity() != null ? req.getQuantity() : 0;
if (qty <= 0) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
qty = resolveAutoQuantity(account, app, req.getMarket(), side, price,
req.getBudgetPct(), req.getKrwAmount());
}
@@ -136,14 +227,34 @@ public class PaperTradingService {
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
}
return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
req.getStrategyId(), price, qty, null);
if ("LIMIT".equals(orderType)) {
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
source, req.getStrategyId(), price, qty);
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
}
PaperTradeDto trade = executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
req.getStrategyId(), price, qty, null, false);
return PaperPlaceOrderResult.builder().trade(trade).build();
}
public PaperOrderDto cancelOrder(Long userId, String deviceId, Long orderId) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId,
appSettingsService.getEntity(userId, deviceId));
GcPaperOrder order = orderRepo.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));
if (!order.getAccountId().equals(account.getId())) {
throw new IllegalArgumentException("권한이 없습니다.");
}
if (!"PENDING".equals(order.getStatus())) {
throw new IllegalStateException("취소할 수 없는 주문 상태입니다.");
}
releaseReservation(account, order);
order.setStatus("CANCELLED");
orderRepo.save(order);
return toOrderDto(order);
}
/**
* 실시간 전략 시그널 발생 시 자동 모의매매.
* liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행.
*/
public void tryExecuteOnSignal(String deviceId, Long userId, String market,
Long strategyId, String signalType, double price) {
if (deviceId == null || deviceId.isBlank()) return;
@@ -166,91 +277,200 @@ public class PaperTradingService {
if ("BUY".equals(signalType)) {
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
double cash = account.getCashBalance().doubleValue();
double budget = cash * budgetPct / 100.0;
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double budget = (cash - reserved) * budgetPct / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget < minOrder) {
log.debug("[Paper] auto BUY skip: budget={} min={}", budget, minOrder);
return;
}
if (denom <= 0 || budget < minOrder) return;
double qty = budget / denom;
if (qty * execPrice < minOrder) return;
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, null);
strategyId, price, qty, null, true);
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
} else {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
if (pos == null || pos.getQuantity().doubleValue() <= 0) {
log.debug("[Paper] auto SELL skip: no position {}", market);
return;
}
double qty = pos.getQuantity().doubleValue();
if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (avail <= 0) return;
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY",
strategyId, price, qty, null);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price);
strategyId, price, avail, null, true);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
}
} catch (Exception e) {
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
}
}
/** 계좌 초기화 — 설정의 초기 자본으로 리셋 */
public void tryFillPendingOrder(Long orderId, double markPrice) {
GcPaperOrder order = orderRepo.findById(orderId).orElse(null);
if (order == null || !"PENDING".equals(order.getStatus())) return;
boolean fill = false;
double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0;
if ("BUY".equals(order.getSide())) {
fill = markPrice <= limit;
} else if ("SELL".equals(order.getSide())) {
fill = markPrice >= limit;
}
if (!fill) return;
GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null);
if (account == null) return;
GcAppSettings app = appSettingsService.getEntity(account.getUserId(), account.getDeviceId());
releaseReservation(account, order);
double qty = order.getQuantity().doubleValue();
executeTrade(account.getUserId(), account.getDeviceId(), app, order.getSymbol(),
order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(),
limit, qty, null, "STRATEGY".equals(order.getSource()));
order.setStatus("FILLED");
order.setFilledQuantity(order.getQuantity());
order.setFilledAt(LocalDateTime.now());
order.setReservedCash(BigDecimal.ZERO);
order.setReservedQty(BigDecimal.ZERO);
orderRepo.save(order);
}
public double resolveMarkPrice(String symbol) {
try {
if (!ta4jStorage.exists(symbol, "1m")) return 0;
BarSeries series = ta4jStorage.getOrCreate(symbol, "1m");
if (!series.isEmpty()) {
Bar last = series.getLastBar();
return last.getClosePrice().doubleValue();
}
} catch (Exception ignored) {
}
return 0;
}
public PaperSummaryDto resetAccount(Long userId, String deviceId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
BigDecimal initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
BigDecimal cashBefore = account.getCashBalance();
String positionsJson = null;
try {
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
positionsJson = objectMapper.writeValueAsString(positions);
} catch (Exception e) {
log.debug("[Paper] reset positions json skip: {}", e.getMessage());
}
resetLogRepo.save(GcPaperResetLog.builder()
.accountId(account.getId())
.initialCapital(initial)
.cashBefore(cashBefore)
.positionsJson(positionsJson)
.reason("USER_RESET")
.build());
ledgerService.recordReset(account, cashBefore, initial);
orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING")
.forEach(o -> {
releaseReservation(account, o);
o.setStatus("CANCELLED");
orderRepo.save(o);
});
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()).forEach(positionRepo::delete);
allocationService.deleteAllForAccount(account.getId());
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId())
.forEach(positionRepo::delete);
account.setCashBalance(initial);
account.setRealizedPnl(BigDecimal.ZERO);
account.setReservedCash(BigDecimal.ZERO);
account.setInitialCapitalSnapshot(initial);
account.setResetCount((account.getResetCount() != null ? account.getResetCount() : 0) + 1);
account.setLastResetAt(LocalDateTime.now());
accountRepo.save(account);
allocationService.seedFromPositions(account.getId(), initial);
log.info("[Paper] account reset device={} capital={}", deviceId, initial);
return getSummary(userId, deviceId, null);
}
/**
* 수량 미입력(0) 시 자동매매와 동일한 방식으로 수량 산출.
* 매수: 가용현금 × budgetPct(또는 krwAmount) / (체결가 × (1+수수료))
* 매도: 보유수량 × budgetPct
*/
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app,
String market, String side,
double price, Double budgetPct, Double krwAmount) {
// ── 내부 ─────────────────────────────────────────────────────────────────
private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app,
String market, String side, String orderKind,
String source, Long strategyId,
double limitPrice, double qty) {
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
double execPrice = "BUY".equals(side)
? limitPrice * (1 + slip / 100.0) : limitPrice * (1 - slip / 100.0);
BigDecimal qtyBd = BigDecimal.valueOf(qty).setScale(SCALE_QTY, RM);
BigDecimal priceBd = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM);
BigDecimal gross = priceBd.multiply(qtyBd).setScale(SCALE_KRW, RM);
BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM);
if ("BUY".equals(side)) {
double cash = account.getCashBalance().doubleValue();
double budget = krwAmount != null && krwAmount > 0
? krwAmount
: cash * pctVal / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget <= 0) {
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
}
return budget / denom;
BigDecimal need = gross.add(fee);
allocationService.validateBuy(account, app, market, need, false);
account.setReservedCash(account.getReservedCash().add(need));
accountRepo.save(account);
return orderRepo.save(GcPaperOrder.builder()
.accountId(account.getId())
.symbol(market)
.side("BUY")
.orderType("LIMIT")
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
.quantity(qtyBd)
.status("PENDING")
.reservedCash(need)
.orderKind(orderKind)
.source(source)
.strategyId(strategyId)
.build());
}
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
double held = pos.getQuantity().doubleValue();
if (held <= 0) {
throw new IllegalStateException("보유 수량이 없습니다.");
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (qty > avail + 1e-12) {
throw new IllegalStateException("매도 가능 수량이 부족합니다.");
}
return held * pctVal / 100.0;
return orderRepo.save(GcPaperOrder.builder()
.accountId(account.getId())
.symbol(market)
.side("SELL")
.orderType("LIMIT")
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
.quantity(qtyBd)
.status("PENDING")
.reservedQty(qtyBd)
.orderKind(orderKind)
.source(source)
.strategyId(strategyId)
.build());
}
// ── 내부 체결 ───────────────────────────────────────────────────────────
private void releaseReservation(GcPaperAccount account, GcPaperOrder order) {
if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) {
account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO));
accountRepo.save(account);
}
}
private double availableSellQty(Long accountId, String symbol, double held) {
double reserved = orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(accountId, "PENDING")
.stream()
.filter(o -> symbol.equals(o.getSymbol()) && "SELL".equals(o.getSide()))
.mapToDouble(o -> o.getReservedQty().doubleValue())
.sum();
return Math.max(0, held - reserved);
}
private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app,
String market, String side, String orderKind, String source,
Long strategyId, double inputPrice, double inputQty,
String koreanName) {
String koreanName, boolean autoTrade) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
@@ -271,13 +491,14 @@ public class PaperTradingService {
if ("BUY".equals(side)) {
BigDecimal totalCost = gross.add(fee);
if (account.getCashBalance().compareTo(totalCost) < 0) {
throw new IllegalStateException("주문가능 현금이 부족합니다.");
}
allocationService.validateBuy(account, app, market, totalCost, autoTrade);
account.setCashBalance(account.getCashBalance().subtract(totalCost));
upsertPositionBuy(account, market, koreanName, qty, price);
accountRepo.save(account);
GcPaperPosition after = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
BigDecimal qtyAfter = after != null ? after.getQuantity() : qty;
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId())
.symbol(market)
@@ -291,15 +512,17 @@ public class PaperTradingService {
.feeAmount(fee)
.netAmount(totalCost.negate())
.cashAfter(account.getCashBalance())
.positionQtyAfter(qtyAfter)
.build());
ledgerService.recordBuySettle(account, trade, totalCost);
return toTradeDto(trade);
}
// SELL
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
if (pos.getQuantity().compareTo(qty) < 0) {
throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다.");
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (qty.doubleValue() > avail + 1e-12) {
throw new IllegalStateException("매도 수량이 보유(가용) 수량을 초과합니다.");
}
BigDecimal netProceeds = gross.subtract(fee);
@@ -317,6 +540,9 @@ public class PaperTradingService {
}
accountRepo.save(account);
GcPaperPosition afterPos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
BigDecimal qtyAfter = afterPos != null ? afterPos.getQuantity() : BigDecimal.ZERO;
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
.accountId(account.getId())
.symbol(market)
@@ -330,14 +556,52 @@ public class PaperTradingService {
.feeAmount(fee)
.netAmount(netProceeds)
.cashAfter(account.getCashBalance())
.realizedPnlDelta(realizedDelta)
.positionQtyAfter(qtyAfter)
.build());
ledgerService.recordSellSettle(account, trade, netProceeds);
return toTradeDto(trade);
}
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app,
String market, String side,
double price, Double budgetPct, Double krwAmount) {
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
if ("BUY".equals(side)) {
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double cash = account.getCashBalance().doubleValue() - reserved;
double budget = krwAmount != null && krwAmount > 0 ? krwAmount : cash * pctVal / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget <= 0) {
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
}
return budget / denom;
}
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (avail <= 0) throw new IllegalStateException("보유 수량이 없습니다.");
return avail * pctVal / 100.0;
}
private static String resolveOrderType(PaperOrderRequest req) {
if (req.getOrderType() != null) {
String t = req.getOrderType().toUpperCase();
if ("MARKET".equals(t) || "LIMIT".equals(t)) return t;
}
String kind = req.getOrderKind() != null ? req.getOrderKind().toLowerCase() : "limit";
return "market".equals(kind) ? "MARKET" : "LIMIT";
}
private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
BigDecimal qty, BigDecimal price) {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
if (pos == null) {
positionRepo.save(GcPaperPosition.builder()
.accountId(account.getId())
@@ -346,6 +610,9 @@ public class PaperTradingService {
.quantity(qty)
.avgPrice(price)
.build());
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot() : BigDecimal.valueOf(10_000_000);
allocationService.getOrDefault(account.getId(), market, defaultMax);
return;
}
BigDecimal totalQty = pos.getQuantity().add(qty);
@@ -367,8 +634,14 @@ public class PaperTradingService {
.deviceId(dev)
.cashBalance(initial)
.realizedPnl(BigDecimal.ZERO)
.reservedCash(BigDecimal.ZERO)
.initialCapitalSnapshot(initial)
.resetCount(0)
.status("ACTIVE")
.build();
return accountRepo.save(a);
a = accountRepo.save(a);
ledgerService.recordInitial(a, initial);
return a;
});
}
@@ -390,7 +663,31 @@ public class PaperTradingService {
.feeAmount(t.getFeeAmount().doubleValue())
.netAmount(t.getNetAmount().doubleValue())
.cashAfter(t.getCashAfter().doubleValue())
.realizedPnlDelta(t.getRealizedPnlDelta() != null
? t.getRealizedPnlDelta().doubleValue() : null)
.positionQtyAfter(t.getPositionQtyAfter() != null
? t.getPositionQtyAfter().doubleValue() : null)
.createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
.build();
}
private PaperOrderDto toOrderDto(GcPaperOrder o) {
return PaperOrderDto.builder()
.id(o.getId())
.symbol(o.getSymbol())
.side(o.getSide())
.orderType(o.getOrderType())
.limitPrice(o.getLimitPrice() != null ? o.getLimitPrice().doubleValue() : null)
.quantity(o.getQuantity().doubleValue())
.filledQuantity(o.getFilledQuantity().doubleValue())
.status(o.getStatus())
.reservedCash(o.getReservedCash().doubleValue())
.reservedQty(o.getReservedQty().doubleValue())
.orderKind(o.getOrderKind())
.source(o.getSource())
.strategyId(o.getStrategyId())
.createdAt(o.getCreatedAt() != null ? o.getCreatedAt().toString() : null)
.updatedAt(o.getUpdatedAt() != null ? o.getUpdatedAt().toString() : null)
.build();
}
}
@@ -37,6 +37,7 @@ public class BarBuilder {
private final Ta4jStorage ta4jStorage;
private final TradingWebSocketBroker broker;
private final BarCloseStrategyEvaluationService barCloseEvaluation;
private final com.goldenchart.service.PaperOrderMatcherService paperOrderMatcher;
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
@@ -89,6 +90,7 @@ public class BarBuilder {
// 현재 1분봉 상태를 STOMP 로 즉시 발행 (실시간 틱 렌더링용)
publishCurrentBar(market);
paperOrderMatcher.onPriceTick(market, tradePrice);
}
// ── 내부 처리 ──────────────────────────────────────────────────────────────
@@ -0,0 +1,109 @@
-- V51: 모의투자 투자금 관리 (종목별 한도, 원장, 일별 스냅샷, reset 이력)
ALTER TABLE gc_paper_account
ADD COLUMN reserved_cash DECIMAL(20, 2) NOT NULL DEFAULT 0
COMMENT '미체결 예약 금액 합',
ADD COLUMN initial_capital_snapshot DECIMAL(20, 2) NULL
COMMENT '마지막 초기화 시점 기준 초기자본',
ADD COLUMN reset_count INT NOT NULL DEFAULT 0,
ADD COLUMN last_reset_at DATETIME NULL,
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
COMMENT 'ACTIVE | FROZEN';
ALTER TABLE gc_paper_trade
ADD COLUMN realized_pnl_delta DECIMAL(20, 2) NULL COMMENT '매도 체결 실현손익',
ADD COLUMN position_qty_after DECIMAL(30, 12) NULL COMMENT '체결 후 잔량',
ADD COLUMN note VARCHAR(200) NULL;
CREATE INDEX idx_paper_trade_account_symbol_created
ON gc_paper_trade (account_id, symbol, created_at);
CREATE INDEX idx_paper_trade_account_created_desc
ON gc_paper_trade (account_id, created_at DESC);
CREATE TABLE gc_paper_symbol_allocation (
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
symbol VARCHAR(50) NOT NULL,
korean_name VARCHAR(100) NULL,
max_invest_krw DECIMAL(20, 2) NOT NULL DEFAULT 0,
budget_pct DECIMAL(8, 4) NULL,
strategy_id BIGINT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
pinned TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_paper_alloc_account_symbol (account_id, symbol),
INDEX idx_paper_alloc_account (account_id),
CONSTRAINT fk_paper_alloc_account FOREIGN KEY (account_id)
REFERENCES gc_paper_account(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='모의투자 종목별 투자 한도';
CREATE TABLE gc_paper_cash_ledger (
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
entry_type VARCHAR(30) NOT NULL,
amount DECIMAL(20, 2) NOT NULL,
cash_before DECIMAL(20, 2) NOT NULL,
cash_after DECIMAL(20, 2) NOT NULL,
ref_trade_id BIGINT NULL,
symbol VARCHAR(50) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_paper_ledger_account_created (account_id, created_at DESC),
CONSTRAINT fk_paper_ledger_account FOREIGN KEY (account_id)
REFERENCES gc_paper_account(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='모의투자 자금 변동 원장';
CREATE TABLE gc_paper_daily_snapshot (
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
snapshot_date DATE NOT NULL,
cash_balance DECIMAL(20, 2) NOT NULL,
stock_eval_amount DECIMAL(20, 2) NOT NULL,
total_asset DECIMAL(20, 2) NOT NULL,
daily_pnl DECIMAL(20, 2) NOT NULL DEFAULT 0,
daily_return_pct DECIMAL(10, 4) NOT NULL DEFAULT 0,
cumulative_return_pct DECIMAL(10, 4) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_paper_snapshot_account_date (account_id, snapshot_date),
CONSTRAINT fk_paper_snapshot_account FOREIGN KEY (account_id)
REFERENCES gc_paper_account(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='모의투자 일별 자산 스냅샷';
CREATE TABLE gc_paper_reset_log (
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
reset_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
initial_capital DECIMAL(20, 2) NOT NULL,
cash_before DECIMAL(20, 2) NOT NULL,
positions_json JSON NULL,
reason VARCHAR(200) NULL,
PRIMARY KEY (id),
INDEX idx_paper_reset_account (account_id),
CONSTRAINT fk_paper_reset_account FOREIGN KEY (account_id)
REFERENCES gc_paper_account(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='모의투자 계좌 초기화 이력';
-- 기존 계좌: initial_capital_snapshot 백필 (device별 app_settings)
UPDATE gc_paper_account a
LEFT JOIN gc_app_settings s ON s.device_id = a.device_id
SET a.initial_capital_snapshot = COALESCE(s.paper_initial_capital, 10000000)
WHERE a.initial_capital_snapshot IS NULL;
-- 보유 종목 → allocation seed (max = 초기자본 스냅샷)
INSERT INTO gc_paper_symbol_allocation (account_id, symbol, korean_name, max_invest_krw, is_active, sort_order)
SELECT p.account_id, p.symbol, p.korean_name,
COALESCE(a.initial_capital_snapshot, 10000000),
1, 0
FROM gc_paper_position p
JOIN gc_paper_account a ON a.id = p.account_id
WHERE p.quantity > 0
ON DUPLICATE KEY UPDATE korean_name = COALESCE(VALUES(korean_name), gc_paper_symbol_allocation.korean_name);
@@ -0,0 +1,28 @@
-- V52: 모의투자 미체결 주문
CREATE TABLE gc_paper_order (
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
symbol VARCHAR(50) NOT NULL,
side VARCHAR(10) NOT NULL COMMENT 'BUY | SELL',
order_type VARCHAR(20) NOT NULL DEFAULT 'LIMIT' COMMENT 'MARKET | LIMIT',
limit_price DECIMAL(20, 2) NULL,
quantity DECIMAL(30, 12) NOT NULL,
filled_quantity DECIMAL(30, 12) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
COMMENT 'PENDING | PARTIAL | FILLED | CANCELLED',
reserved_cash DECIMAL(20, 2) NOT NULL DEFAULT 0,
reserved_qty DECIMAL(30, 12) NOT NULL DEFAULT 0,
order_kind VARCHAR(20) NOT NULL DEFAULT 'limit',
source VARCHAR(20) NOT NULL DEFAULT 'MANUAL',
strategy_id BIGINT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
filled_at DATETIME NULL,
PRIMARY KEY (id),
INDEX idx_paper_order_account_status (account_id, status),
INDEX idx_paper_order_symbol_status (symbol, status),
CONSTRAINT fk_paper_order_account FOREIGN KEY (account_id)
REFERENCES gc_paper_account(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='모의투자 미체결 주문';
@@ -0,0 +1,6 @@
-- 설정 화면: 가상매매·실거래 카테고리 권한 (기존 settings_paper와 동일 허용)
INSERT INTO gc_role_menu_permission (role, menu_id, allowed)
SELECT role, 'settings_virtual', allowed FROM gc_role_menu_permission WHERE menu_id = 'settings_paper';
INSERT INTO gc_role_menu_permission (role, menu_id, allowed)
SELECT role, 'settings_live', allowed FROM gc_role_menu_permission WHERE menu_id = 'settings_paper';