goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -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();
}
}