package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.goldenchart.dto.LiveOrderRequest; import com.goldenchart.dto.LiveSummaryDto; import com.goldenchart.dto.LiveTradeDto; import com.goldenchart.dto.UpbitApiCredentials; import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.GcLiveTrade; import com.goldenchart.repository.GcLiveTradeRepository; import com.goldenchart.trading.TradingAccess; import com.goldenchart.trading.TradingMode; import com.goldenchart.upbit.UpbitOrderApiClient; 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; /** * 업비트 실거래 — 시그널·손절/익절 시 주문 실행. */ @Service @RequiredArgsConstructor @Slf4j public class LiveTradingService { private static final RoundingMode RM = RoundingMode.HALF_UP; private static final double MIN_ORDER_KRW = 5000; private final AppSettingsService appSettingsService; private final UpbitOrderApiClient upbitApi; private final GcLiveTradeRepository tradeRepo; @Transactional(readOnly = true) public LiveSummaryDto getSummary(Long userId) { long uid = TradingAccess.requireUserId(userId); GcAppSettings app = appSettingsService.getEntity(uid, null); UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); boolean configured = creds.isComplete(); double krw = 0; List positions = new ArrayList<>(); if (configured) { try { krw = upbitApi.getKrwBalance(creds.accessKey(), creds.secretKey()); var accounts = upbitApi.getAccounts(creds.accessKey(), creds.secretKey()); if (accounts != null && accounts.isArray()) { for (JsonNode a : accounts) { String cur = a.path("currency").asText(); if ("KRW".equals(cur)) continue; double bal = a.path("balance").asDouble(0); if (bal <= 0) continue; double avg = a.path("avg_buy_price").asDouble(0); positions.add(LiveSummaryDto.Position.builder() .symbol("KRW-" + cur) .quantity(bal) .avgPrice(avg) .build()); } } } catch (Exception e) { log.warn("[LiveTrading] summary 실패: {}", e.getMessage()); } } return LiveSummaryDto.builder() .enabled(Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) .configured(configured) .tradingMode(app.getTradingMode() != null ? app.getTradingMode() : "PAPER") .krwBalance(krw) .positions(positions) .build(); } @Transactional(readOnly = true) public List listTrades(Long userId) { long uid = TradingAccess.requireUserId(userId); return tradeRepo.findTop100ByUserIdOrderByCreatedAtDesc(uid) .stream().map(this::toDto).toList(); } /** * 전략 시그널·SL/TP 에서 호출. */ public void tryExecuteOnSignal(Long userId, String market, Long strategyId, String signalType, double price, String source) { 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); TradingMode mode = TradingMode.fromString(app.getTradingMode()); if (!mode.useLive()) return; if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) return; UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); if (!creds.isComplete()) { log.debug("[LiveTrading] API 키 없음 — skip {}", market); return; } if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && "STRATEGY".equals(source)) return; String access = creds.accessKey(); String secret = creds.secretKey(); try { if ("BUY".equals(signalType)) { double budgetPct = app.getLiveAutoTradeBudgetPct() != null ? app.getLiveAutoTradeBudgetPct().doubleValue() : (app.getPaperAutoTradeBudgetPct() != null ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95); double krw = upbitApi.getKrwBalance(access, secret); double budget = krw * budgetPct / 100.0; if (budget < MIN_ORDER_KRW) { log.debug("[LiveTrading] BUY skip: budget={}", budget); return; } JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget); recordTrade(uid, market, "BUY", source, strategyId, res, price, budget); log.info("[LiveTrading] BUY {} ~{} KRW", market, (long) budget); } else { double vol = upbitApi.getCoinBalance(access, secret, market); if (vol <= 0) { log.debug("[LiveTrading] SELL skip: no balance {}", market); return; } JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol); recordTrade(uid, market, "SELL", source, strategyId, res, price, vol); log.info("[LiveTrading] SELL {} vol={}", market, vol); } } catch (UpbitOrderApiClient.UpbitApiException e) { log.warn("[LiveTrading] 주문 실패 {} {}: {}", signalType, market, e.getMessage()); } } /** * 알림 모달·수동 주문 — 시장가 매수/매도. */ public LiveTradeDto placeManualOrder(Long userId, LiveOrderRequest req) { long uid = TradingAccess.requireUserId(userId); if (req == null || req.getMarket() == null || req.getSide() == null) { throw new IllegalArgumentException("market, side required"); } GcAppSettings app = appSettingsService.getEntity(uid, null); UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); if (!creds.isComplete()) { throw new IllegalStateException("업비트 API 키가 등록되지 않았습니다."); } String access = creds.accessKey(); String secret = creds.secretKey(); String market = req.getMarket(); String side = req.getSide().toUpperCase(); try { if ("BUY".equals(side)) { double budget = req.getKrwAmount() != null && req.getKrwAmount() > 0 ? req.getKrwAmount() : computeBuyBudget(app, access, secret); if (budget < MIN_ORDER_KRW) { throw new IllegalStateException("주문 가능 금액이 부족합니다."); } JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget); recordTrade(uid, market, "BUY", "MANUAL", req.getStrategyId(), res, 0, budget); return listTrades(uid).get(0); } if ("SELL".equals(side)) { double vol = req.getQuantity() != null && req.getQuantity() > 0 ? req.getQuantity() : upbitApi.getCoinBalance(access, secret, market); if (vol <= 0) throw new IllegalStateException("매도 가능 수량이 없습니다."); JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol); recordTrade(uid, market, "SELL", "MANUAL", req.getStrategyId(), res, 0, vol); return listTrades(uid).get(0); } throw new IllegalArgumentException("side must be BUY or SELL"); } catch (UpbitOrderApiClient.UpbitApiException e) { throw new IllegalStateException("업비트 주문 실패: " + e.getMessage(), e); } } private double computeBuyBudget(GcAppSettings app, String access, String secret) { double budgetPct = app.getLiveAutoTradeBudgetPct() != null ? app.getLiveAutoTradeBudgetPct().doubleValue() : (app.getPaperAutoTradeBudgetPct() != null ? app.getPaperAutoTradeBudgetPct().doubleValue() : 95); try { double krw = upbitApi.getKrwBalance(access, secret); return krw * budgetPct / 100.0; } catch (Exception e) { log.warn("[LiveTrading] balance read failed: {}", e.getMessage()); return 0; } } public boolean hasApiKeys(GcAppSettings app) { return appSettingsService.hasUpbitApiKeys(app); } private void recordTrade(long userId, String market, String side, String source, Long strategyId, JsonNode res, double refPrice, double amountOrVol) { String uuid = res != null ? res.path("uuid").asText(null) : null; double executed = res != null ? res.path("executed_volume").asDouble(amountOrVol) : amountOrVol; double paid = res != null ? res.path("paid_fee").asDouble(0) : 0; BigDecimal gross = "BUY".equals(side) ? BigDecimal.valueOf(amountOrVol).setScale(2, RM) : BigDecimal.valueOf(refPrice * executed).setScale(2, RM); tradeRepo.save(GcLiveTrade.builder() .deviceId(TradingAccess.accountDeviceKey(userId)) .userId(userId) .symbol(market) .side(side) .orderKind("market") .source(source) .strategyId(strategyId) .upbitOrderUuid(uuid) .price(BigDecimal.valueOf(refPrice).setScale(8, RM)) .quantity(BigDecimal.valueOf(executed).setScale(12, RM)) .grossAmount(gross) .feeAmount(BigDecimal.valueOf(paid).setScale(2, RM)) .state(res != null ? res.path("state").asText("done") : "done") .build()); } private LiveTradeDto toDto(GcLiveTrade t) { return LiveTradeDto.builder() .id(t.getId()) .symbol(t.getSymbol()) .side(t.getSide()) .source(t.getSource()) .price(t.getPrice().doubleValue()) .quantity(t.getQuantity().doubleValue()) .grossAmount(t.getGrossAmount().doubleValue()) .upbitOrderUuid(t.getUpbitOrderUuid()) .createdAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null) .build(); } }