Files
goldenChart/backend/src/main/java/com/goldenchart/service/PaperTradingService.java
T
2026-05-31 05:29:17 +09:00

708 lines
35 KiB
Java

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<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
double stockEval = 0;
double unrealized = 0;
List<PaperPositionDto> 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<PaperAllocationDto> 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<PaperAllocationDto> listAllocations(Long userId, String deviceId,
Map<String, Double> 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<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 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<GcLiveStrategySettings> 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<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());
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();
}
}