모의투자 관리 기능 추가
This commit is contained in:
@@ -1,46 +1,56 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.dto.*;
|
||||
import com.goldenchart.entity.GcAppSettings;
|
||||
import com.goldenchart.entity.GcPaperAccount;
|
||||
import com.goldenchart.entity.GcPaperPosition;
|
||||
import com.goldenchart.entity.GcPaperTrade;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.repository.GcPaperAccountRepository;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.repository.GcPaperTradeRepository;
|
||||
import com.goldenchart.entity.*;
|
||||
import com.goldenchart.repository.*;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.ta4j.core.Bar;
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 모의투자(페이퍼 트레이딩) 체결·잔고·포지션 관리.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class PaperTradingService {
|
||||
|
||||
private static final int SCALE_QTY = 12;
|
||||
private static final int SCALE_KRW = 2;
|
||||
private static final RoundingMode RM = RoundingMode.HALF_UP;
|
||||
private static final int SCALE_QTY = 12;
|
||||
private static final int SCALE_KRW = 2;
|
||||
private static final RoundingMode RM = RoundingMode.HALF_UP;
|
||||
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
||||
|
||||
private final GcPaperAccountRepository accountRepo;
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final GcPaperTradeRepository tradeRepo;
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final GcPaperAccountRepository accountRepo;
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final GcPaperTradeRepository tradeRepo;
|
||||
private final GcPaperOrderRepository orderRepo;
|
||||
private final GcPaperResetLogRepository resetLogRepo;
|
||||
private final AppSettingsService appSettingsService;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final PaperAllocationService allocationService;
|
||||
private final PaperLedgerService ledgerService;
|
||||
private final PaperSnapshotService snapshotService;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// ── 조회 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PaperSummaryDto getSummary(Long userId, String deviceId, Map<String, Double> markPrices) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
@@ -74,15 +84,26 @@ public class PaperTradingService {
|
||||
}
|
||||
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
double initial = app.getPaperInitialCapital() != null
|
||||
? app.getPaperInitialCapital().doubleValue() : 10_000_000;
|
||||
double reserved = account.getReservedCash() != null
|
||||
? account.getReservedCash().doubleValue() : 0;
|
||||
double orderable = cash - reserved;
|
||||
double initialSnap = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot().doubleValue()
|
||||
: (app.getPaperInitialCapital() != null
|
||||
? app.getPaperInitialCapital().doubleValue() : 10_000_000);
|
||||
double total = cash + stockEval;
|
||||
double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0;
|
||||
double returnPct = initialSnap > 0 ? (total - initialSnap) / initialSnap * 100 : 0;
|
||||
|
||||
List<PaperAllocationDto> allocations = allocationService.listWithMetrics(
|
||||
account.getId(), markPrices, total);
|
||||
|
||||
return PaperSummaryDto.builder()
|
||||
.enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled()))
|
||||
.initialCapital(initial)
|
||||
.initialCapital(initialSnap)
|
||||
.cashBalance(cash)
|
||||
.reservedCash(reserved)
|
||||
.orderableCash(orderable)
|
||||
.initialCapitalSnapshot(initialSnap)
|
||||
.stockEvalAmount(stockEval)
|
||||
.totalAsset(total)
|
||||
.unrealizedPnl(unrealized)
|
||||
@@ -95,22 +116,90 @@ public class PaperTradingService {
|
||||
.autoTradeBudgetPct(app.getPaperAutoTradeBudgetPct() != null
|
||||
? app.getPaperAutoTradeBudgetPct().doubleValue() : 95)
|
||||
.positions(posDtos)
|
||||
.allocations(allocations)
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<PaperTradeDto> listTrades(Long userId, String deviceId) {
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperAllocationDto> listAllocations(Long userId, String deviceId,
|
||||
Map<String, Double> markPrices) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId())
|
||||
.stream().map(this::toTradeDto).toList();
|
||||
PaperSummaryDto s = getSummary(userId, deviceId, markPrices);
|
||||
return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset());
|
||||
}
|
||||
|
||||
public List<PaperAllocationDto> bulkAllocations(Long userId, String deviceId,
|
||||
PaperAllocationBulkRequest req) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot()
|
||||
: app.getPaperInitialCapital();
|
||||
return allocationService.bulkUpsert(account.getId(), req, defaultMax);
|
||||
}
|
||||
|
||||
public PaperAllocationDto patchAllocation(Long userId, String deviceId, String symbol,
|
||||
PaperAllocationPatchRequest req) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return allocationService.patch(account.getId(), symbol, req);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PaperPageDto<PaperTradeDto> listTrades(Long userId, String deviceId,
|
||||
String symbol, String side, String source,
|
||||
LocalDateTime from, LocalDateTime to,
|
||||
int page, int size) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size)));
|
||||
Page<GcPaperTrade> result = tradeRepo.findFiltered(
|
||||
account.getId(), symbol, side, source, from, to, pr);
|
||||
return PaperPageDto.<PaperTradeDto>builder()
|
||||
.content(result.getContent().stream().map(this::toTradeDto).toList())
|
||||
.page(result.getNumber())
|
||||
.size(result.getSize())
|
||||
.totalElements(result.getTotalElements())
|
||||
.totalPages(result.getTotalPages())
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<PaperTradeDto> listTradesLegacy(Long userId, String deviceId) {
|
||||
return listTrades(userId, deviceId, null, null, null, null, null, 0, 100).getContent();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PaperPageDto<PaperLedgerEntryDto> listLedger(Long userId, String deviceId,
|
||||
LocalDateTime from, LocalDateTime to,
|
||||
int page, int size) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return ledgerService.list(account.getId(), from, to, page, size);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperDailySnapshotDto> listDailySnapshots(Long userId, String deviceId,
|
||||
LocalDate from, LocalDate to) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return snapshotService.list(account.getId(), from, to);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PaperOrderDto> listPendingOrders(Long userId, String deviceId) {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId,
|
||||
appSettingsService.getEntity(userId, deviceId));
|
||||
return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING")
|
||||
.stream().map(this::toOrderDto).toList();
|
||||
}
|
||||
|
||||
// ── 주문 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public PaperTradeDto placeOrder(Long userId, String deviceId, PaperOrderRequest req) {
|
||||
public PaperPlaceOrderResult placeOrder(Long userId, String deviceId, PaperOrderRequest req) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
|
||||
throw new IllegalStateException("모의투자가 비활성화되어 있습니다. 설정에서 모의투자를 켜 주세요.");
|
||||
throw new IllegalStateException("모의투자가 비활성화되어 있습니다.");
|
||||
}
|
||||
if (req.getMarket() == null || req.getSide() == null) {
|
||||
throw new IllegalArgumentException("종목과 매수/매도 구분이 필요합니다.");
|
||||
@@ -124,11 +213,13 @@ public class PaperTradingService {
|
||||
throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
|
||||
}
|
||||
|
||||
String orderType = resolveOrderType(req);
|
||||
String source = req.getSource() != null ? req.getSource() : "MANUAL";
|
||||
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
|
||||
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
double qty = req.getQuantity() != null ? req.getQuantity() : 0;
|
||||
if (qty <= 0) {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
qty = resolveAutoQuantity(account, app, req.getMarket(), side, price,
|
||||
req.getBudgetPct(), req.getKrwAmount());
|
||||
}
|
||||
@@ -136,14 +227,34 @@ public class PaperTradingService {
|
||||
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
|
||||
}
|
||||
|
||||
return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
|
||||
req.getStrategyId(), price, qty, null);
|
||||
if ("LIMIT".equals(orderType)) {
|
||||
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
|
||||
source, req.getStrategyId(), price, qty);
|
||||
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
|
||||
}
|
||||
|
||||
PaperTradeDto trade = executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
|
||||
req.getStrategyId(), price, qty, null, false);
|
||||
return PaperPlaceOrderResult.builder().trade(trade).build();
|
||||
}
|
||||
|
||||
public PaperOrderDto cancelOrder(Long userId, String deviceId, Long orderId) {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId,
|
||||
appSettingsService.getEntity(userId, deviceId));
|
||||
GcPaperOrder order = orderRepo.findById(orderId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));
|
||||
if (!order.getAccountId().equals(account.getId())) {
|
||||
throw new IllegalArgumentException("권한이 없습니다.");
|
||||
}
|
||||
if (!"PENDING".equals(order.getStatus())) {
|
||||
throw new IllegalStateException("취소할 수 없는 주문 상태입니다.");
|
||||
}
|
||||
releaseReservation(account, order);
|
||||
order.setStatus("CANCELLED");
|
||||
orderRepo.save(order);
|
||||
return toOrderDto(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 전략 시그널 발생 시 자동 모의매매.
|
||||
* liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행.
|
||||
*/
|
||||
public void tryExecuteOnSignal(String deviceId, Long userId, String market,
|
||||
Long strategyId, String signalType, double price) {
|
||||
if (deviceId == null || deviceId.isBlank()) return;
|
||||
@@ -166,91 +277,200 @@ public class PaperTradingService {
|
||||
if ("BUY".equals(signalType)) {
|
||||
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
double budget = cash * budgetPct / 100.0;
|
||||
double reserved = account.getReservedCash() != null
|
||||
? account.getReservedCash().doubleValue() : 0;
|
||||
double budget = (cash - reserved) * budgetPct / 100.0;
|
||||
double execPrice = price * (1 + slip / 100.0);
|
||||
double denom = execPrice * (1 + feeRate / 100.0);
|
||||
if (denom <= 0 || budget < minOrder) {
|
||||
log.debug("[Paper] auto BUY skip: budget={} min={}", budget, minOrder);
|
||||
return;
|
||||
}
|
||||
if (denom <= 0 || budget < minOrder) return;
|
||||
double qty = budget / denom;
|
||||
if (qty * execPrice < minOrder) return;
|
||||
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY",
|
||||
strategyId, price, qty, null);
|
||||
strategyId, price, qty, null, true);
|
||||
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
|
||||
} else {
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElse(null);
|
||||
if (pos == null || pos.getQuantity().doubleValue() <= 0) {
|
||||
log.debug("[Paper] auto SELL skip: no position {}", market);
|
||||
return;
|
||||
}
|
||||
double qty = pos.getQuantity().doubleValue();
|
||||
if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
|
||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (avail <= 0) return;
|
||||
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY",
|
||||
strategyId, price, qty, null);
|
||||
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price);
|
||||
strategyId, price, avail, null, true);
|
||||
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 계좌 초기화 — 설정의 초기 자본으로 리셋 */
|
||||
public void tryFillPendingOrder(Long orderId, double markPrice) {
|
||||
GcPaperOrder order = orderRepo.findById(orderId).orElse(null);
|
||||
if (order == null || !"PENDING".equals(order.getStatus())) return;
|
||||
|
||||
boolean fill = false;
|
||||
double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0;
|
||||
if ("BUY".equals(order.getSide())) {
|
||||
fill = markPrice <= limit;
|
||||
} else if ("SELL".equals(order.getSide())) {
|
||||
fill = markPrice >= limit;
|
||||
}
|
||||
if (!fill) return;
|
||||
|
||||
GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null);
|
||||
if (account == null) return;
|
||||
GcAppSettings app = appSettingsService.getEntity(account.getUserId(), account.getDeviceId());
|
||||
|
||||
releaseReservation(account, order);
|
||||
double qty = order.getQuantity().doubleValue();
|
||||
executeTrade(account.getUserId(), account.getDeviceId(), app, order.getSymbol(),
|
||||
order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(),
|
||||
limit, qty, null, "STRATEGY".equals(order.getSource()));
|
||||
|
||||
order.setStatus("FILLED");
|
||||
order.setFilledQuantity(order.getQuantity());
|
||||
order.setFilledAt(LocalDateTime.now());
|
||||
order.setReservedCash(BigDecimal.ZERO);
|
||||
order.setReservedQty(BigDecimal.ZERO);
|
||||
orderRepo.save(order);
|
||||
}
|
||||
|
||||
public double resolveMarkPrice(String symbol) {
|
||||
try {
|
||||
if (!ta4jStorage.exists(symbol, "1m")) return 0;
|
||||
BarSeries series = ta4jStorage.getOrCreate(symbol, "1m");
|
||||
if (!series.isEmpty()) {
|
||||
Bar last = series.getLastBar();
|
||||
return last.getClosePrice().doubleValue();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public PaperSummaryDto resetAccount(Long userId, String deviceId) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
BigDecimal initial = app.getPaperInitialCapital() != null
|
||||
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
|
||||
BigDecimal cashBefore = account.getCashBalance();
|
||||
|
||||
String positionsJson = null;
|
||||
try {
|
||||
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
|
||||
positionsJson = objectMapper.writeValueAsString(positions);
|
||||
} catch (Exception e) {
|
||||
log.debug("[Paper] reset positions json skip: {}", e.getMessage());
|
||||
}
|
||||
|
||||
resetLogRepo.save(GcPaperResetLog.builder()
|
||||
.accountId(account.getId())
|
||||
.initialCapital(initial)
|
||||
.cashBefore(cashBefore)
|
||||
.positionsJson(positionsJson)
|
||||
.reason("USER_RESET")
|
||||
.build());
|
||||
|
||||
ledgerService.recordReset(account, cashBefore, initial);
|
||||
|
||||
orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING")
|
||||
.forEach(o -> {
|
||||
releaseReservation(account, o);
|
||||
o.setStatus("CANCELLED");
|
||||
orderRepo.save(o);
|
||||
});
|
||||
|
||||
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()).forEach(positionRepo::delete);
|
||||
allocationService.deleteAllForAccount(account.getId());
|
||||
|
||||
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId())
|
||||
.forEach(positionRepo::delete);
|
||||
account.setCashBalance(initial);
|
||||
account.setRealizedPnl(BigDecimal.ZERO);
|
||||
account.setReservedCash(BigDecimal.ZERO);
|
||||
account.setInitialCapitalSnapshot(initial);
|
||||
account.setResetCount((account.getResetCount() != null ? account.getResetCount() : 0) + 1);
|
||||
account.setLastResetAt(LocalDateTime.now());
|
||||
accountRepo.save(account);
|
||||
|
||||
allocationService.seedFromPositions(account.getId(), initial);
|
||||
log.info("[Paper] account reset device={} capital={}", deviceId, initial);
|
||||
return getSummary(userId, deviceId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수량 미입력(0) 시 자동매매와 동일한 방식으로 수량 산출.
|
||||
* 매수: 가용현금 × budgetPct(또는 krwAmount) / (체결가 × (1+수수료))
|
||||
* 매도: 보유수량 × budgetPct
|
||||
*/
|
||||
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app,
|
||||
String market, String side,
|
||||
double price, Double budgetPct, Double krwAmount) {
|
||||
// ── 내부 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app,
|
||||
String market, String side, String orderKind,
|
||||
String source, Long strategyId,
|
||||
double limitPrice, double qty) {
|
||||
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
||||
double slip = pct(app.getPaperSlippagePct(), 0);
|
||||
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
|
||||
double execPrice = "BUY".equals(side)
|
||||
? limitPrice * (1 + slip / 100.0) : limitPrice * (1 - slip / 100.0);
|
||||
BigDecimal qtyBd = BigDecimal.valueOf(qty).setScale(SCALE_QTY, RM);
|
||||
BigDecimal priceBd = BigDecimal.valueOf(execPrice).setScale(SCALE_KRW, RM);
|
||||
BigDecimal gross = priceBd.multiply(qtyBd).setScale(SCALE_KRW, RM);
|
||||
BigDecimal fee = gross.multiply(BigDecimal.valueOf(feeRate / 100.0)).setScale(SCALE_KRW, RM);
|
||||
|
||||
if ("BUY".equals(side)) {
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
double budget = krwAmount != null && krwAmount > 0
|
||||
? krwAmount
|
||||
: cash * pctVal / 100.0;
|
||||
double execPrice = price * (1 + slip / 100.0);
|
||||
double denom = execPrice * (1 + feeRate / 100.0);
|
||||
if (denom <= 0 || budget <= 0) {
|
||||
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
|
||||
}
|
||||
return budget / denom;
|
||||
BigDecimal need = gross.add(fee);
|
||||
allocationService.validateBuy(account, app, market, need, false);
|
||||
account.setReservedCash(account.getReservedCash().add(need));
|
||||
accountRepo.save(account);
|
||||
return orderRepo.save(GcPaperOrder.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
.side("BUY")
|
||||
.orderType("LIMIT")
|
||||
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
|
||||
.quantity(qtyBd)
|
||||
.status("PENDING")
|
||||
.reservedCash(need)
|
||||
.orderKind(orderKind)
|
||||
.source(source)
|
||||
.strategyId(strategyId)
|
||||
.build());
|
||||
}
|
||||
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
|
||||
double held = pos.getQuantity().doubleValue();
|
||||
if (held <= 0) {
|
||||
throw new IllegalStateException("보유 수량이 없습니다.");
|
||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (qty > avail + 1e-12) {
|
||||
throw new IllegalStateException("매도 가능 수량이 부족합니다.");
|
||||
}
|
||||
return held * pctVal / 100.0;
|
||||
return orderRepo.save(GcPaperOrder.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
.side("SELL")
|
||||
.orderType("LIMIT")
|
||||
.limitPrice(BigDecimal.valueOf(limitPrice).setScale(SCALE_KRW, RM))
|
||||
.quantity(qtyBd)
|
||||
.status("PENDING")
|
||||
.reservedQty(qtyBd)
|
||||
.orderKind(orderKind)
|
||||
.source(source)
|
||||
.strategyId(strategyId)
|
||||
.build());
|
||||
}
|
||||
|
||||
// ── 내부 체결 ───────────────────────────────────────────────────────────
|
||||
private void releaseReservation(GcPaperAccount account, GcPaperOrder order) {
|
||||
if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) {
|
||||
account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO));
|
||||
accountRepo.save(account);
|
||||
}
|
||||
}
|
||||
|
||||
private double availableSellQty(Long accountId, String symbol, double held) {
|
||||
double reserved = orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(accountId, "PENDING")
|
||||
.stream()
|
||||
.filter(o -> symbol.equals(o.getSymbol()) && "SELL".equals(o.getSide()))
|
||||
.mapToDouble(o -> o.getReservedQty().doubleValue())
|
||||
.sum();
|
||||
return Math.max(0, held - reserved);
|
||||
}
|
||||
|
||||
private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app,
|
||||
String market, String side, String orderKind, String source,
|
||||
Long strategyId, double inputPrice, double inputQty,
|
||||
String koreanName) {
|
||||
String koreanName, boolean autoTrade) {
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
||||
double slip = pct(app.getPaperSlippagePct(), 0);
|
||||
@@ -271,13 +491,14 @@ public class PaperTradingService {
|
||||
|
||||
if ("BUY".equals(side)) {
|
||||
BigDecimal totalCost = gross.add(fee);
|
||||
if (account.getCashBalance().compareTo(totalCost) < 0) {
|
||||
throw new IllegalStateException("주문가능 현금이 부족합니다.");
|
||||
}
|
||||
allocationService.validateBuy(account, app, market, totalCost, autoTrade);
|
||||
account.setCashBalance(account.getCashBalance().subtract(totalCost));
|
||||
upsertPositionBuy(account, market, koreanName, qty, price);
|
||||
accountRepo.save(account);
|
||||
|
||||
GcPaperPosition after = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
|
||||
BigDecimal qtyAfter = after != null ? after.getQuantity() : qty;
|
||||
|
||||
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
@@ -291,15 +512,17 @@ public class PaperTradingService {
|
||||
.feeAmount(fee)
|
||||
.netAmount(totalCost.negate())
|
||||
.cashAfter(account.getCashBalance())
|
||||
.positionQtyAfter(qtyAfter)
|
||||
.build());
|
||||
ledgerService.recordBuySettle(account, trade, totalCost);
|
||||
return toTradeDto(trade);
|
||||
}
|
||||
|
||||
// SELL
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
|
||||
if (pos.getQuantity().compareTo(qty) < 0) {
|
||||
throw new IllegalStateException("매도 수량이 보유 수량을 초과합니다.");
|
||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (qty.doubleValue() > avail + 1e-12) {
|
||||
throw new IllegalStateException("매도 수량이 보유(가용) 수량을 초과합니다.");
|
||||
}
|
||||
|
||||
BigDecimal netProceeds = gross.subtract(fee);
|
||||
@@ -317,6 +540,9 @@ public class PaperTradingService {
|
||||
}
|
||||
accountRepo.save(account);
|
||||
|
||||
GcPaperPosition afterPos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
|
||||
BigDecimal qtyAfter = afterPos != null ? afterPos.getQuantity() : BigDecimal.ZERO;
|
||||
|
||||
GcPaperTrade trade = tradeRepo.save(GcPaperTrade.builder()
|
||||
.accountId(account.getId())
|
||||
.symbol(market)
|
||||
@@ -330,14 +556,52 @@ public class PaperTradingService {
|
||||
.feeAmount(fee)
|
||||
.netAmount(netProceeds)
|
||||
.cashAfter(account.getCashBalance())
|
||||
.realizedPnlDelta(realizedDelta)
|
||||
.positionQtyAfter(qtyAfter)
|
||||
.build());
|
||||
ledgerService.recordSellSettle(account, trade, netProceeds);
|
||||
return toTradeDto(trade);
|
||||
}
|
||||
|
||||
private double resolveAutoQuantity(GcPaperAccount account, GcAppSettings app,
|
||||
String market, String side,
|
||||
double price, Double budgetPct, Double krwAmount) {
|
||||
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
||||
double slip = pct(app.getPaperSlippagePct(), 0);
|
||||
double pctVal = budgetPct != null ? budgetPct : pct(app.getPaperAutoTradeBudgetPct(), 95);
|
||||
|
||||
if ("BUY".equals(side)) {
|
||||
double reserved = account.getReservedCash() != null
|
||||
? account.getReservedCash().doubleValue() : 0;
|
||||
double cash = account.getCashBalance().doubleValue() - reserved;
|
||||
double budget = krwAmount != null && krwAmount > 0 ? krwAmount : cash * pctVal / 100.0;
|
||||
double execPrice = price * (1 + slip / 100.0);
|
||||
double denom = execPrice * (1 + feeRate / 100.0);
|
||||
if (denom <= 0 || budget <= 0) {
|
||||
throw new IllegalStateException("주문 가능 금액이 부족합니다.");
|
||||
}
|
||||
return budget / denom;
|
||||
}
|
||||
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
|
||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
||||
if (avail <= 0) throw new IllegalStateException("보유 수량이 없습니다.");
|
||||
return avail * pctVal / 100.0;
|
||||
}
|
||||
|
||||
private static String resolveOrderType(PaperOrderRequest req) {
|
||||
if (req.getOrderType() != null) {
|
||||
String t = req.getOrderType().toUpperCase();
|
||||
if ("MARKET".equals(t) || "LIMIT".equals(t)) return t;
|
||||
}
|
||||
String kind = req.getOrderKind() != null ? req.getOrderKind().toLowerCase() : "limit";
|
||||
return "market".equals(kind) ? "MARKET" : "LIMIT";
|
||||
}
|
||||
|
||||
private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
|
||||
BigDecimal qty, BigDecimal price) {
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElse(null);
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
|
||||
if (pos == null) {
|
||||
positionRepo.save(GcPaperPosition.builder()
|
||||
.accountId(account.getId())
|
||||
@@ -346,6 +610,9 @@ public class PaperTradingService {
|
||||
.quantity(qty)
|
||||
.avgPrice(price)
|
||||
.build());
|
||||
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
|
||||
? account.getInitialCapitalSnapshot() : BigDecimal.valueOf(10_000_000);
|
||||
allocationService.getOrDefault(account.getId(), market, defaultMax);
|
||||
return;
|
||||
}
|
||||
BigDecimal totalQty = pos.getQuantity().add(qty);
|
||||
@@ -367,8 +634,14 @@ public class PaperTradingService {
|
||||
.deviceId(dev)
|
||||
.cashBalance(initial)
|
||||
.realizedPnl(BigDecimal.ZERO)
|
||||
.reservedCash(BigDecimal.ZERO)
|
||||
.initialCapitalSnapshot(initial)
|
||||
.resetCount(0)
|
||||
.status("ACTIVE")
|
||||
.build();
|
||||
return accountRepo.save(a);
|
||||
a = accountRepo.save(a);
|
||||
ledgerService.recordInitial(a, initial);
|
||||
return a;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,7 +663,31 @@ public class PaperTradingService {
|
||||
.feeAmount(t.getFeeAmount().doubleValue())
|
||||
.netAmount(t.getNetAmount().doubleValue())
|
||||
.cashAfter(t.getCashAfter().doubleValue())
|
||||
.realizedPnlDelta(t.getRealizedPnlDelta() != null
|
||||
? t.getRealizedPnlDelta().doubleValue() : null)
|
||||
.positionQtyAfter(t.getPositionQtyAfter() != null
|
||||
? t.getPositionQtyAfter().doubleValue() : null)
|
||||
.createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
private PaperOrderDto toOrderDto(GcPaperOrder o) {
|
||||
return PaperOrderDto.builder()
|
||||
.id(o.getId())
|
||||
.symbol(o.getSymbol())
|
||||
.side(o.getSide())
|
||||
.orderType(o.getOrderType())
|
||||
.limitPrice(o.getLimitPrice() != null ? o.getLimitPrice().doubleValue() : null)
|
||||
.quantity(o.getQuantity().doubleValue())
|
||||
.filledQuantity(o.getFilledQuantity().doubleValue())
|
||||
.status(o.getStatus())
|
||||
.reservedCash(o.getReservedCash().doubleValue())
|
||||
.reservedQty(o.getReservedQty().doubleValue())
|
||||
.orderKind(o.getOrderKind())
|
||||
.source(o.getSource())
|
||||
.strategyId(o.getStrategyId())
|
||||
.createdAt(o.getCreatedAt() != null ? o.getCreatedAt().toString() : null)
|
||||
.updatedAt(o.getUpdatedAt() != null ? o.getUpdatedAt().toString() : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user