Files
goldenChart/backend/src/main/java/com/goldenchart/service/PaperTradingService.java
T
2026-06-08 10:08:39 +09:00

865 lines
43 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 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<String, Double> markPrices) {
long uid = TradingAccess.requireUserId(userId);
GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(uid, 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, Map<String, Double> 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<PaperAllocationDto> 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<PaperTradeDto> 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<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) {
return listTrades(userId, null, null, null, null, null, 0, 100).getContent();
}
@Transactional(readOnly = true)
public PaperPageDto<PaperLedgerEntryDto> 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<PaperDailySnapshotDto> 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<PaperOrderDto> 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<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(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) {
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) {
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<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 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();
}
}