package com.goldenchart.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.dto.*; import com.goldenchart.entity.*; import com.goldenchart.repository.*; import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.trading.TradingAccess; 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 ZoneId KST = ZoneId.of("Asia/Seoul"); 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, Map markPrices) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); List positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()); double stockEval = 0; double unrealized = 0; List posDtos = new ArrayList<>(); for (GcPaperPosition p : positions) { double qty = p.getQuantity().doubleValue(); if (qty <= 0) continue; double avg = p.getAvgPrice().doubleValue(); Double mark = markPrices != null ? markPrices.get(p.getSymbol()) : null; double eval = mark != null ? mark * qty : avg * qty; double pl = mark != null ? (mark - avg) * qty : 0; double plPct = avg > 0 && mark != null ? (mark - avg) / avg * 100 : 0; stockEval += eval; unrealized += pl; posDtos.add(PaperPositionDto.builder() .id(p.getId()) .symbol(p.getSymbol()) .koreanName(p.getKoreanName()) .quantity(qty) .avgPrice(avg) .markPrice(mark) .evalAmount(eval) .profitLoss(pl) .profitLossPct(plPct) .build()); } double cash = account.getCashBalance().doubleValue(); 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 = initialSnap > 0 ? (total - initialSnap) / initialSnap * 100 : 0; List allocations = allocationService.listWithMetrics( account.getId(), markPrices, total); return PaperSummaryDto.builder() .enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled())) .initialCapital(initialSnap) .cashBalance(cash) .reservedCash(reserved) .orderableCash(orderable) .initialCapitalSnapshot(initialSnap) .stockEvalAmount(stockEval) .totalAsset(total) .unrealizedPnl(unrealized) .realizedPnl(account.getRealizedPnl().doubleValue()) .totalReturnPct(returnPct) .feeRatePct(app.getPaperFeeRatePct() != null ? app.getPaperFeeRatePct().doubleValue() : 0.05) .slippagePct(app.getPaperSlippagePct() != null ? app.getPaperSlippagePct().doubleValue() : 0) .minOrderKrw(app.getPaperMinOrderKrw() != null ? app.getPaperMinOrderKrw().doubleValue() : 5000) .autoTradeEnabled(Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) .autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95) .positions(posDtos) .allocations(allocations) .build(); } @Transactional(readOnly = true) public List listAllocations(Long userId, Map markPrices) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); PaperSummaryDto s = getSummary(uid, markPrices); return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset()); } public List bulkAllocations(Long userId, PaperAllocationBulkRequest req) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null ? account.getInitialCapitalSnapshot() : app.getPaperInitialCapital(); return allocationService.bulkUpsert(account.getId(), req, defaultMax); } public PaperAllocationDto patchAllocation(Long userId, String symbol, PaperAllocationPatchRequest req) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); return allocationService.patch(account.getId(), symbol, req); } @Transactional(readOnly = true) public PaperPageDto listTrades(Long userId, String symbol, String side, String source, LocalDateTime from, LocalDateTime to, int page, int size) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size))); Page result = tradeRepo.findFiltered( account.getId(), symbol, side, source, from, to, pr); return PaperPageDto.builder() .content(result.getContent().stream().map(this::toTradeDto).toList()) .page(result.getNumber()) .size(result.getSize()) .totalElements(result.getTotalElements()) .totalPages(result.getTotalPages()) .build(); } public List listTradesLegacy(Long userId) { return listTrades(userId, null, null, null, null, null, 0, 100).getContent(); } /** 체결 이력 일괄 삭제 (분석 목록에서 그룹 제거용) */ @Transactional public int deleteTradesBatch(Long userId, List ids) { if (ids == null || ids.isEmpty()) return 0; long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); List distinct = ids.stream().filter(id -> id != null && id > 0).distinct().toList(); if (distinct.isEmpty()) return 0; List owned = tradeRepo.findByAccountIdAndIdIn(account.getId(), distinct); if (owned.isEmpty()) return 0; tradeRepo.deleteAll(owned); return owned.size(); } @Transactional(readOnly = true) public PaperPageDto listLedger(Long userId, LocalDateTime from, LocalDateTime to, int page, int size) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); return ledgerService.list(account.getId(), from, to, page, size); } @Transactional(readOnly = true) public List listDailySnapshots(Long userId, LocalDate from, LocalDate to) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); return snapshotService.list(account.getId(), from, to); } @Transactional(readOnly = true) public List listPendingOrders(Long userId) { long uid = TradingAccess.requireUserId(userId); GcPaperAccount account = getOrCreateAccount(uid, appSettingsService.getEntity(uid, null)); return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING") .stream().map(this::toOrderDto).toList(); } // ── 주문 ───────────────────────────────────────────────────────────────── public PaperPlaceOrderResult placeOrder(Long userId, PaperOrderRequest req) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) { throw new IllegalStateException("모의투자가 비활성화되어 있습니다."); } if (req.getMarket() == null || req.getSide() == null) { throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다."); } String side = req.getSide().toUpperCase(); if (!"BUY".equals(side) && !"SELL".equals(side)) { throw new IllegalArgumentException("side는 BUY 또는 SELL 이어야 합니다."); } double price = req.getPrice() != null ? req.getPrice() : 0; if (price <= 0) { throw new IllegalArgumentException("가격은 0보다 커야 합니다."); } String source = req.getSource() != null ? req.getSource() : "MANUAL"; String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit"; GcPaperAccount account = getOrCreateAccount(uid, app); double qty = req.getQuantity() != null ? req.getQuantity() : 0; if (qty <= 0) { qty = resolveAutoQuantity(account, app, req.getMarket(), side, price, req.getBudgetPct(), req.getKrwAmount()); } if (qty <= 0) { throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다."); } // 시장가(MARKET)를 포함한 모든 주문을 현재가 기준 지정가 미체결로 생성한다. // 체결은 PaperOrderMatcherService 가 다음 시세 틱에서 처리한다. GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind, source, req.getStrategyId(), price, qty, false, null, null, null); return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build(); } public PaperOrderDto cancelOrder(Long userId, Long orderId) { long uid = TradingAccess.requireUserId(userId); GcPaperAccount account = getOrCreateAccount(uid, appSettingsService.getEntity(uid, null)); 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); } public void tryExecuteOnSignal(Long userId, String market, Long strategyId, String signalType, double price) { tryExecuteOnSignal(userId, market, strategyId, signalType, price, null, null, null); } public void tryExecuteOnSignal(Long userId, String market, Long strategyId, String signalType, double price, String candleType, String strategyName, String executionType) { long uid; try { uid = TradingAccess.requireUserId(userId); } catch (Exception e) { return; } if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return; GcAppSettings app = appSettingsService.getEntity(uid, null); if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return; if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return; List liveForMarket = liveSettingsRepo.findActiveByMarket(market).stream() .filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck())) .toList(); boolean marketActive = !liveForMarket.isEmpty(); if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return; try { GcPaperAccount account = getOrCreateAccount(uid, app); String positionMode = liveForMarket.stream() .map(GcLiveStrategySettings::getPositionMode) .filter(m -> m != null && !m.isBlank()) .findFirst() .orElse("LONG_ONLY"); BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null ? account.getInitialCapitalSnapshot() : (app.getPaperInitialCapital() != null ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000)); GcPaperSymbolAllocation alloc = allocationService.getOrDefaultEntity( account.getId(), market, defaultMax); boolean useStaged = PaperTradeStageService.usesStagedTrading(alloc); GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market) .orElse(null); double held = posNow != null ? posNow.getQuantity().doubleValue() : 0; if ("LONG_ONLY".equals(positionMode) && !useStaged) { if ("BUY".equals(signalType) && held > 0) return; if ("SELL".equals(signalType) && held <= 0) return; } // 비분할 모드에서 이미 동일 방향 미체결 주문이 있으면 중복 생성 방지 if (!useStaged && hasPendingOrderForSide(account.getId(), market, signalType)) { log.debug("[Paper] 이미 미체결 주문 존재 — 신규 생성 생략 {} {}", market, signalType); return; } double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); double minOrder = app.getPaperMinOrderKrw() != null ? app.getPaperMinOrderKrw().doubleValue() : 5000; if ("BUY".equals(signalType)) { if (useStaged) { if (held <= 0) { allocationService.resetTradeStages(account.getId(), market); alloc = allocationService.getOrDefaultEntity(account.getId(), market, defaultMax); } else if ("LONG_ONLY".equals(positionMode) && alloc.getBuyStageDone() != null && alloc.getBuyStageDone() >= 3) { return; } double stageKrw = PaperTradeStageService.nextBuyStageKrw(alloc); if (stageKrw <= 0) return; if (stageKrw < minOrder) return; double execPrice = price * (1 + slip / 100.0); double denom = execPrice * (1 + feeRate / 100.0); if (denom <= 0) return; double qty = stageKrw / denom; if (qty * execPrice < minOrder) return; // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행) createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY", strategyId, price, qty, true, candleType, strategyName, executionType); log.info("[Paper] STRATEGY BUY (staged) 미체결 생성 {} qty≈{} @ {}", market, qty, price); } else { double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); double cash = account.getCashBalance().doubleValue(); 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) return; double qty = budget / denom; if (qty * execPrice < minOrder) return; // 알림 발생 시점 현재가 기준 미체결 주문 생성 createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY", strategyId, price, qty, false, candleType, strategyName, executionType); log.info("[Paper] STRATEGY BUY 미체결 생성 {} qty≈{} @ {}", market, qty, price); } } else { if (held <= 0) return; double avail = availableSellQty(account.getId(), market, held); if (avail <= 0) return; if (useStaged) { double stageKrw = PaperTradeStageService.nextSellStageKrw(alloc); if (stageKrw <= 0) return; if (stageKrw < minOrder) return; double execPrice = price * (1 - slip / 100.0); double qty = PaperTradeStageService.sellQtyForStageKrw(stageKrw, avail, execPrice); if (qty * execPrice < minOrder) return; // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행) createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY", strategyId, price, qty, true, candleType, strategyName, executionType); log.info("[Paper] STRATEGY SELL (staged) 미체결 생성 {} qty={} @ {}", market, qty, price); } else { // 알림 발생 시점 현재가 기준 미체결 주문 생성 createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY", strategyId, price, avail, false, candleType, strategyName, executionType); log.info("[Paper] STRATEGY SELL 미체결 생성 {} qty={} @ {}", market, avail, price); } } } catch (Exception e) { log.warn("[Paper] auto trade 미체결 생성 실패 {} {}: {}", 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; double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0; // ── 체결 모드 조회 ─────────────────────────────────────────────────────── GcPaperAccount acctCheck = accountRepo.findById(order.getAccountId()).orElse(null); String fillMode = "LIMIT_ONLY"; double autoCancelPct = 1.0; if (acctCheck != null && acctCheck.getUserId() != null) { GcAppSettings appCheck = appSettingsService.getEntity(acctCheck.getUserId(), null); fillMode = appCheck.getPaperOrderFillMode() != null ? appCheck.getPaperOrderFillMode() : "LIMIT_ONLY"; autoCancelPct = appCheck.getPaperOrderAutoCancelPct() != null ? appCheck.getPaperOrderAutoCancelPct().doubleValue() : 1.0; } boolean fill = false; boolean autoCancel = false; switch (fillMode) { case "NEXT_TICK" -> fill = true; // B. 다음 틱 무조건 체결 case "AUTO_CANCEL" -> { // C. 허용폭 초과 시 자동 취소 double deviation = limit > 0 ? Math.abs(markPrice - limit) / limit * 100.0 : 0; if (deviation > autoCancelPct) { autoCancel = true; } else { // 허용폭 내이면 지정가 조건 충족 시 체결 if ("BUY".equals(order.getSide())) fill = markPrice <= limit; else if ("SELL".equals(order.getSide())) fill = markPrice >= limit; } } default -> { // A. LIMIT_ONLY (기본값) if ("BUY".equals(order.getSide())) fill = markPrice <= limit; else if ("SELL".equals(order.getSide())) fill = markPrice >= limit; } } if (autoCancel) { GcPaperAccount acctForCancel = accountRepo.findById(order.getAccountId()).orElse(null); if (acctForCancel != null) { releaseReservation(acctForCancel, order); order.setStatus("CANCELLED"); orderRepo.save(order); log.info("[Paper] AUTO_CANCEL 주문 자동취소 orderId={} symbol={} limit={} markPrice={} deviation>{}%", orderId, order.getSymbol(), limit, markPrice, autoCancelPct); } return; } if (!fill) return; GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null); if (account == null) return; if (account.getUserId() == null) return; long uid = account.getUserId(); GcAppSettings app = appSettingsService.getEntity(uid, null); releaseReservation(account, order); double qty = order.getQuantity().doubleValue(); executeTrade(uid, app, order.getSymbol(), order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(), limit, qty, null, "STRATEGY".equals(order.getSource()), order.getCandleType(), order.getStrategyName(), order.getExecutionType()); order.setStatus("FILLED"); order.setFilledQuantity(order.getQuantity()); order.setFilledAt(LocalDateTime.now()); order.setReservedCash(BigDecimal.ZERO); order.setReservedQty(BigDecimal.ZERO); orderRepo.save(order); // staged 주문이면 체결 시점에 분할매매 단계를 진행 if (Boolean.TRUE.equals(order.getStaged())) { try { if ("BUY".equals(order.getSide())) { allocationService.advanceBuyStageAfterTrade(order.getAccountId(), order.getSymbol()); log.info("[Paper] staged BUY 단계 진행 완료 {} orderId={}", order.getSymbol(), orderId); } else if ("SELL".equals(order.getSide())) { allocationService.advanceSellStageAfterTrade(order.getAccountId(), order.getSymbol()); log.info("[Paper] staged SELL 단계 진행 완료 {} orderId={}", order.getSymbol(), orderId); } } catch (Exception e) { log.warn("[Paper] staged 단계 진행 실패 orderId={}: {}", orderId, e.getMessage()); } } } public double resolveMarkPrice(String symbol) { return TradeExecutionPriceSupport.resolveLiveExecutionPrice(ta4jStorage, symbol, "1m", null); } public PaperSummaryDto resetAccount(Long userId) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); GcPaperAccount account = getOrCreateAccount(uid, app); BigDecimal initial = app.getPaperInitialCapital() != null ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); BigDecimal cashBefore = account.getCashBalance(); String positionsJson = null; try { List 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()); 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 userId={} capital={}", uid, initial); return getSummary(uid, null); } // ── 내부 ───────────────────────────────────────────────────────────────── private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app, String market, String side, String orderKind, String source, Long strategyId, double limitPrice, double qty, boolean staged, String candleType, String strategyName, String executionType) { double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); 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)) { 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) .candleType(candleType) .strategyName(strategyName) .executionType(executionType) .staged(staged) .build()); } GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) .orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다.")); double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); if (qty > avail + 1e-12) { throw new IllegalStateException("매도 가능 수량이 부족합니다."); } 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) .candleType(candleType) .strategyName(strategyName) .executionType(executionType) .staged(staged) .build()); } private boolean hasPendingOrderForSide(Long accountId, String symbol, String side) { return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(accountId, "PENDING") .stream() .anyMatch(o -> symbol.equals(o.getSymbol()) && side.equals(o.getSide())); } 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, GcAppSettings app, String market, String side, String orderKind, String source, Long strategyId, double inputPrice, double inputQty, String koreanName, boolean autoTrade) { return executeTrade(userId, app, market, side, orderKind, source, strategyId, inputPrice, inputQty, koreanName, autoTrade, null, null, null); } private PaperTradeDto executeTrade(long userId, GcAppSettings app, String market, String side, String orderKind, String source, Long strategyId, double inputPrice, double inputQty, String koreanName, boolean autoTrade, String candleType, String strategyName, String executionType) { GcPaperAccount account = getOrCreateAccount(userId, app); double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double slip = pct(app.getPaperSlippagePct(), 0); double minOrder = app.getPaperMinOrderKrw() != null ? app.getPaperMinOrderKrw().doubleValue() : 5000; double execPrice = "BUY".equals(side) ? inputPrice * (1 + slip / 100.0) : inputPrice * (1 - slip / 100.0); BigDecimal qty = BigDecimal.valueOf(inputQty).setScale(SCALE_QTY, RM); BigDecimal price = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM); BigDecimal gross = price.multiply(qty).setScale(SCALE_KRW, RM); BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM); if (gross.doubleValue() < minOrder) { throw new IllegalArgumentException("최소 주문 금액은 " + (long) minOrder + " KRW 입니다."); } if ("BUY".equals(side)) { BigDecimal totalCost = gross.add(fee); 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) .side("BUY") .orderKind(orderKind) .source(source) .strategyId(strategyId) .candleType(candleType) .strategyName(strategyName) .executionType(executionType) .price(price) .quantity(qty) .grossAmount(gross) .feeAmount(fee) .netAmount(totalCost.negate()) .cashAfter(account.getCashBalance()) .positionQtyAfter(qtyAfter) .build()); ledgerService.recordBuySettle(account, trade, totalCost); return toTradeDto(trade); } GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market) .orElseThrow(() -> 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); BigDecimal costBasis = pos.getAvgPrice().multiply(qty).setScale(SCALE_KRW, RM); BigDecimal realizedDelta = netProceeds.subtract(costBasis); account.setCashBalance(account.getCashBalance().add(netProceeds)); account.setRealizedPnl(account.getRealizedPnl().add(realizedDelta)); BigDecimal remaining = pos.getQuantity().subtract(qty); if (remaining.compareTo(BigDecimal.valueOf(0.00000001)) <= 0) { positionRepo.delete(pos); } else { pos.setQuantity(remaining); positionRepo.save(pos); } 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) .side("SELL") .orderKind(orderKind) .source(source) .strategyId(strategyId) .candleType(candleType) .strategyName(strategyName) .executionType(executionType) .price(price) .quantity(qty) .grossAmount(gross) .feeAmount(fee) .netAmount(netProceeds) .cashAfter(account.getCashBalance()) .realizedPnlDelta(realizedDelta) .positionQtyAfter(qtyAfter) .build()); ledgerService.recordSellSettle(account, trade, netProceeds); if ("STRATEGY".equals(source) && qtyAfter.compareTo(BigDecimal.valueOf(0.00000001)) <= 0) { allocationService.resetTradeStages(account.getId(), market); } 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 void upsertPositionBuy(GcPaperAccount account, String market, String koreanName, BigDecimal qty, BigDecimal price) { GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null); if (pos == null) { positionRepo.save(GcPaperPosition.builder() .accountId(account.getId()) .symbol(market) .koreanName(koreanName) .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); BigDecimal totalCost = pos.getAvgPrice().multiply(pos.getQuantity()).add(price.multiply(qty)); BigDecimal avg = totalCost.divide(totalQty, SCALE_KRW, RM); pos.setQuantity(totalQty); pos.setAvgPrice(avg); if (koreanName != null) pos.setKoreanName(koreanName); positionRepo.save(pos); } private GcPaperAccount getOrCreateAccount(long userId, GcAppSettings app) { return accountRepo.findByUserId(userId).orElseGet(() -> { String devKey = TradingAccess.accountDeviceKey(userId); BigDecimal initial = app.getPaperInitialCapital() != null ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); GcPaperAccount a = GcPaperAccount.builder() .userId(userId) .deviceId(devKey) .cashBalance(initial) .realizedPnl(BigDecimal.ZERO) .reservedCash(BigDecimal.ZERO) .initialCapitalSnapshot(initial) .resetCount(0) .status("ACTIVE") .build(); a = accountRepo.save(a); ledgerService.recordInitial(a, initial); return a; }); } private static double pct(BigDecimal v, double def) { return v != null ? v.doubleValue() : def; } private PaperTradeDto toTradeDto(GcPaperTrade t) { return PaperTradeDto.builder() .id(t.getId()) .symbol(t.getSymbol()) .side(t.getSide()) .orderKind(t.getOrderKind()) .source(t.getSource()) .strategyId(t.getStrategyId()) .candleType(t.getCandleType()) .strategyName(t.getStrategyName()) .executionType(t.getExecutionType()) .price(t.getPrice().doubleValue()) .quantity(t.getQuantity().doubleValue()) .grossAmount(t.getGrossAmount().doubleValue()) .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(); } }