goldenChat base source add
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
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.GcPaperAccountRepository;
|
||||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||||
import com.goldenchart.repository.GcPaperTradeRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
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 final GcPaperAccountRepository accountRepo;
|
||||
private final GcPaperPositionRepository positionRepo;
|
||||
private final GcPaperTradeRepository tradeRepo;
|
||||
private final AppSettingsService appSettingsService;
|
||||
|
||||
// ── 조회 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 initial = app.getPaperInitialCapital() != null
|
||||
? app.getPaperInitialCapital().doubleValue() : 10_000_000;
|
||||
double total = cash + stockEval;
|
||||
double returnPct = initial > 0 ? (total - initial) / initial * 100 : 0;
|
||||
|
||||
return PaperSummaryDto.builder()
|
||||
.enabled(Boolean.TRUE.equals(app.getPaperTradingEnabled()))
|
||||
.initialCapital(initial)
|
||||
.cashBalance(cash)
|
||||
.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)
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<PaperTradeDto> listTrades(Long userId, String deviceId) {
|
||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app);
|
||||
return tradeRepo.findTop100ByAccountIdOrderByCreatedAtDesc(account.getId())
|
||||
.stream().map(this::toTradeDto).toList();
|
||||
}
|
||||
|
||||
// ── 주문 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public PaperTradeDto 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 source = req.getSource() != null ? req.getSource() : "MANUAL";
|
||||
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
|
||||
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());
|
||||
}
|
||||
if (qty <= 0) {
|
||||
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
|
||||
}
|
||||
|
||||
return executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source,
|
||||
req.getStrategyId(), price, qty, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 전략 시그널 발생 시 자동 모의매매.
|
||||
* liveStrategyCheck + paperAutoTradeEnabled + paperTradingEnabled 일 때만 실행.
|
||||
*/
|
||||
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;
|
||||
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck())) return;
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
if ("BUY".equals(signalType)) {
|
||||
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
double budget = cash * 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;
|
||||
}
|
||||
double qty = budget / denom;
|
||||
if (qty * execPrice < minOrder) return;
|
||||
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY",
|
||||
strategyId, price, qty, null);
|
||||
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();
|
||||
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY",
|
||||
strategyId, price, qty, null);
|
||||
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, qty, price);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 계좌 초기화 — 설정의 초기 자본으로 리셋 */
|
||||
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);
|
||||
|
||||
positionRepo.findByAccountIdOrderBySymbolAsc(account.getId())
|
||||
.forEach(positionRepo::delete);
|
||||
account.setCashBalance(initial);
|
||||
account.setRealizedPnl(BigDecimal.ZERO);
|
||||
accountRepo.save(account);
|
||||
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) {
|
||||
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 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;
|
||||
}
|
||||
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
|
||||
double held = pos.getQuantity().doubleValue();
|
||||
if (held <= 0) {
|
||||
throw new IllegalStateException("보유 수량이 없습니다.");
|
||||
}
|
||||
return held * pctVal / 100.0;
|
||||
}
|
||||
|
||||
// ── 내부 체결 ───────────────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
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);
|
||||
if (account.getCashBalance().compareTo(totalCost) < 0) {
|
||||
throw new IllegalStateException("주문가능 현금이 부족합니다.");
|
||||
}
|
||||
account.setCashBalance(account.getCashBalance().subtract(totalCost));
|
||||
upsertPositionBuy(account, market, koreanName, qty, price);
|
||||
accountRepo.save(account);
|
||||
|
||||
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())
|
||||
.build());
|
||||
return toTradeDto(trade);
|
||||
}
|
||||
|
||||
// SELL
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
||||
.orElseThrow(() -> new IllegalStateException("보유 수량이 없습니다."));
|
||||
if (pos.getQuantity().compareTo(qty) < 0) {
|
||||
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);
|
||||
|
||||
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())
|
||||
.build());
|
||||
return toTradeDto(trade);
|
||||
}
|
||||
|
||||
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());
|
||||
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)
|
||||
.build();
|
||||
return accountRepo.save(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())
|
||||
.createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user