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 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, String deviceId, Map markPrices) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcPaperAccount account = getOrCreateAccount(userId, deviceId, 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, String deviceId, Map markPrices) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); PaperSummaryDto s = getSummary(userId, deviceId, markPrices); return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset()); } public List 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 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 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, String deviceId) { return listTrades(userId, deviceId, null, null, null, null, null, 0, 100).getContent(); } @Transactional(readOnly = true) public PaperPageDto 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 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 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 PaperPlaceOrderResult placeOrder(Long userId, String deviceId, PaperOrderRequest req) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); 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 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) { qty = resolveAutoQuantity(account, app, req.getMarket(), side, price, req.getBudgetPct(), req.getKrwAmount()); } if (qty <= 0) { throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다."); } 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); } public void tryExecuteOnSignal(String deviceId, Long userId, String market, Long strategyId, String signalType, double price) { if (deviceId == null || deviceId.isBlank()) return; if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return; GcAppSettings app = appSettingsService.getEntity(userId, deviceId); 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(userId, deviceId, app); String positionMode = liveForMarket.stream() .map(GcLiveStrategySettings::getPositionMode) .filter(m -> m != null && !m.isBlank()) .findFirst() .orElse("LONG_ONLY"); if ("LONG_ONLY".equals(positionMode)) { GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market) .orElse(null); double held = posNow != null ? posNow.getQuantity().doubleValue() : 0; if ("BUY".equals(signalType) && held > 0) return; if ("SELL".equals(signalType) && held <= 0) 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)) { 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; executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY", 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) return; double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); if (avail <= 0) return; executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY", 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 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 device={} capital={}", deviceId, initial); return getSummary(userId, deviceId, null); } // ── 내부 ───────────────────────────────────────────────────────────────── 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 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) .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) .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, boolean autoTrade) { GcPaperAccount account = getOrCreateAccount(userId, deviceId, 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) .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) .price(price) .quantity(qty) .grossAmount(gross) .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); 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, String deviceId, GcAppSettings app) { String dev = deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous"; return accountRepo.findByDeviceId(dev).orElseGet(() -> { BigDecimal initial = app.getPaperInitialCapital() != null ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); GcPaperAccount a = GcPaperAccount.builder() .userId(userId) .deviceId(dev) .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()) .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(); } }