249 lines
11 KiB
Java
249 lines
11 KiB
Java
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<LiveSummaryDto.Position> 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<LiveTradeDto> 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();
|
|
}
|
|
|
|
}
|