goldenChat base source add
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
package com.goldenchart.websocket;
|
||||
|
||||
import com.goldenchart.dto.CandleBarDto;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.TradeSignalService;
|
||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.ta4j.core.Bar;
|
||||
import org.ta4j.core.BarSeries;
|
||||
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 업비트 WebSocket 틱(Trade) 데이터를 수신하여 분봉 단위 캔들을 조립하는 빌더.
|
||||
*
|
||||
* 명세서 3.2 준수:
|
||||
* - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신
|
||||
* - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기
|
||||
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파
|
||||
*
|
||||
* 지원 캔들 타입: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class BarBuilder {
|
||||
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final TradingWebSocketBroker broker;
|
||||
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||
private final TradeSignalService tradeSignalService;
|
||||
private final OrderExecutionQueue orderExecutionQueue;
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final LiveStrategyTimeframeService timeframeService;
|
||||
|
||||
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 15, 30, 60, 240, 1440) */
|
||||
private static final int[] UPPER_MINUTES = {3, 5, 15, 30, 60, 240, 1440};
|
||||
private static final String[] UPPER_TYPES = {"3m", "5m", "15m", "30m", "1h", "4h", "1d"};
|
||||
|
||||
/** 진행 중인 1분봉 상태 (market → PartialBar) */
|
||||
private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 봉 마감 직후 전략 판정 시그널 임시 보관 (market → "BUY"|"SELL"|"NONE").
|
||||
* publishCurrentBar 에서 단 1회 실어 보낸 뒤 제거된다.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, String> signalOnClose = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 시그널이 발생한 봉의 barStart epoch-second 임시 보관.
|
||||
* publishCurrentBar 에서 signal 과 함께 사용되고 제거된다.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Long> signalBarTimeEpoch = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 시그널이 발생한 봉의 최종 close 가격 임시 보관.
|
||||
* publishCurrentBar 에서 시그널 메시지의 price 로 사용된다.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Double> signalBarClose = new ConcurrentHashMap<>();
|
||||
|
||||
// ── 공개 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 업비트 trade 틱을 처리하여 1분봉을 조립한다.
|
||||
*
|
||||
* @param market 마켓 코드 (e.g. "KRW-BTC")
|
||||
* @param tradePrice 체결가
|
||||
* @param tradeVolume 체결량
|
||||
* @param tradeTimeMs 체결 Unix 밀리초
|
||||
*/
|
||||
public void onTick(String market, double tradePrice, double tradeVolume, long tradeTimeMs) {
|
||||
ZonedDateTime tickTime = ZonedDateTime.ofInstant(
|
||||
java.time.Instant.ofEpochMilli(tradeTimeMs), ZoneOffset.UTC);
|
||||
|
||||
// 해당 분의 시작 시간 (초 이하 제거)
|
||||
ZonedDateTime barStart = tickTime.withSecond(0).withNano(0);
|
||||
|
||||
PartialBar current = partialBars.get(market);
|
||||
|
||||
if (current == null || !current.barStart.equals(barStart)) {
|
||||
// ── 분 변경: 이전 Bar 확정 및 신규 Bar 열기 ─────────────────────
|
||||
if (current != null) {
|
||||
commitBar(market, current); // 이전 Bar 확정
|
||||
}
|
||||
// 신규 Bar 시작
|
||||
PartialBar newBar = new PartialBar(barStart, tradePrice, tradeVolume);
|
||||
partialBars.put(market, newBar);
|
||||
|
||||
// 새 분이 시작되는 첫 틱을 Ta4jStorage 에 provisional bar 로 추가.
|
||||
// 이렇게 해야 이후 동일 분 틱에서 updateLastBar 가 직전 확정봉을 오염시키지 않는다.
|
||||
Duration d1m = Duration.ofMinutes(1);
|
||||
ZonedDateTime endTime = barStart.plus(d1m);
|
||||
Bar provisionalBar = buildTa4jBar(newBar, d1m, endTime);
|
||||
ta4jStorage.addBar(market, "1m", provisionalBar);
|
||||
|
||||
} else {
|
||||
// ── 동일 분: 진행 중인 Bar 갱신 ────────────────────────────────
|
||||
current.update(tradePrice, tradeVolume);
|
||||
// Ta4jStorage 마지막 봉(현재 진행 중인 provisional bar)을 실시간 갱신
|
||||
ta4jStorage.updateLastBar(market, "1m", tradePrice, tradeVolume);
|
||||
}
|
||||
|
||||
// 현재 1분봉 상태를 STOMP 로 즉시 발행 (실시간 틱 렌더링용)
|
||||
publishCurrentBar(market);
|
||||
}
|
||||
|
||||
// ── 내부 처리 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파 */
|
||||
private void commitBar(String market, PartialBar partial) {
|
||||
Duration d1m = Duration.ofMinutes(1);
|
||||
ZonedDateTime endTime = partial.barStart.plus(d1m);
|
||||
|
||||
// Ta4j Bar 생성
|
||||
Bar bar = buildTa4jBar(partial, d1m, endTime);
|
||||
|
||||
// 1분봉 Ta4jStorage 저장
|
||||
ta4jStorage.addBar(market, "1m", bar);
|
||||
log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime);
|
||||
|
||||
String evalCandleType = timeframeService.resolveCandleTypeForMarket(market);
|
||||
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
|
||||
if ("1m".equals(evalCandleType)) {
|
||||
evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), partial, bar, true);
|
||||
}
|
||||
|
||||
// 상위 타임프레임 전파
|
||||
for (int i = 0; i < UPPER_MINUTES.length; i++) {
|
||||
int minutes = UPPER_MINUTES[i];
|
||||
String type = UPPER_TYPES[i];
|
||||
// 해당 상위 봉의 시작 시간 계산
|
||||
ZonedDateTime upperStart = alignToInterval(partial.barStart, minutes);
|
||||
ZonedDateTime upperEnd = upperStart.plusMinutes(minutes);
|
||||
|
||||
BarSeries upperSeries = ta4jStorage.getOrCreate(market, type);
|
||||
if (upperSeries.isEmpty()) {
|
||||
// 새 상위봉 열기
|
||||
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
|
||||
ta4jStorage.addBar(market, type, upperBar);
|
||||
} else {
|
||||
Bar lastUpper = upperSeries.getLastBar();
|
||||
// Ta4j 0.22: getEndTime() → Instant
|
||||
Instant lastUpperEndInstant = lastUpper.getEndTime();
|
||||
Instant upperEndInstant = upperEnd.toInstant();
|
||||
if (lastUpperEndInstant.equals(upperEndInstant)) {
|
||||
// 동일 구간 → 가격 갱신
|
||||
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
|
||||
} else if (lastUpperEndInstant.isBefore(upperEndInstant)) {
|
||||
// 다음 구간 → 새 Bar 추가 (이전 상위봉 마감)
|
||||
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
|
||||
ta4jStorage.addBar(market, type, upperBar);
|
||||
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
|
||||
if (type.equals(evalCandleType)) {
|
||||
int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
|
||||
evaluateStrategyOnBarClose(market, type, maturedUpper, partial, bar, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 봉 마감 시점 전략 평가 + 시그널 저장 + 주문 큐 적재.
|
||||
*
|
||||
* @param publishStompSignal 1m 만 STOMP 시그널 필드에 실어 보냄
|
||||
*/
|
||||
private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex,
|
||||
PartialBar partial, Bar bar, boolean publishStompSignal) {
|
||||
String closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex);
|
||||
String signalExecType = "CANDLE_CLOSE";
|
||||
|
||||
if ("NONE".equals(closeSignal)) {
|
||||
closeSignal = liveStrategyEvaluator.evaluateRealtimeAtClose(market, candleType, maturedIndex);
|
||||
signalExecType = "REALTIME_TICK";
|
||||
}
|
||||
|
||||
if (publishStompSignal) {
|
||||
signalOnClose.put(market, closeSignal);
|
||||
signalBarTimeEpoch.put(market, partial.barStart.toEpochSecond());
|
||||
signalBarClose.put(market, partial.close);
|
||||
}
|
||||
|
||||
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return;
|
||||
|
||||
try {
|
||||
final String execType = signalExecType;
|
||||
GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market)
|
||||
.stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck())
|
||||
&& execType.equals(s.getExecutionType())).findFirst().orElse(null);
|
||||
long candleTimeEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
||||
String devId = activeSetting != null ? activeSetting.getDeviceId() : null;
|
||||
Long uid = activeSetting != null ? activeSetting.getUserId() : null;
|
||||
Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null;
|
||||
tradeSignalService.save(
|
||||
devId, uid, market, stratId, null,
|
||||
closeSignal, partial.close, candleTimeEpoch, candleType, execType);
|
||||
if (devId != null) {
|
||||
orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, partial.close);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[BarBuilder] 시그널 처리 실패 ({}/{}): {}", market, candleType, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 진행 중인 1분봉을 STOMP 로 발행.
|
||||
*
|
||||
* <p><b>핵심 버그 수정 (백테스팅 데이터 오염 방지)</b><br>
|
||||
* 분 전환 시점에 {@code commitBar} 가 {@code signalOnClose = "NONE"} 을 항상 설정하므로,
|
||||
* 이전 코드에서는 {@code signal != null} 조건만으로 {@code closedBarTime} 을 결정했다.
|
||||
* 그 결과 분 전환 첫 틱에 발행되는 STOMP 메시지가:
|
||||
* - time = 직전 확정봉 barStart (잘못된 타임스탬프)
|
||||
* - close = 새 provisional 봉 첫 틱 가격 (잘못된 close)
|
||||
* 가 되어 프론트엔드 차트에서 직전 확정봉의 close 를 새 봉의 첫 틱으로 덮어쓰게 된다.
|
||||
* 이 오염된 close 값은 백테스팅 전송 데이터로도 활용되어 CCI 계산이 왜곡된다.
|
||||
*
|
||||
* <p>수정 원칙:
|
||||
* <ul>
|
||||
* <li>실제 BUY/SELL 시그널 → 마감봉 시각(closedBarTime)과 마감봉 close 를 사용</li>
|
||||
* <li>"NONE" 또는 null → 현재 진행 중인 봉 시각(p.barStart)과 실시간 close 를 사용</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void publishCurrentBar(String market) {
|
||||
PartialBar p = partialBars.get(market);
|
||||
if (p == null) return;
|
||||
|
||||
String signal = signalOnClose.remove(market);
|
||||
|
||||
// ★ "NONE" 은 실제 시그널이 아니므로 closedBarTime 을 사용하지 않는다.
|
||||
// 이전에는 signal != null 로만 판단해서 "NONE" 도 시각 보정 대상이 되었고,
|
||||
// 결과적으로 새 봉의 첫 틱 데이터가 직전 확정봉 시각으로 발행되어 차트 데이터가 오염됐다.
|
||||
boolean isRealSignal = "BUY".equals(signal) || "SELL".equals(signal);
|
||||
|
||||
Long closedBarTime = isRealSignal ? signalBarTimeEpoch.remove(market) : null;
|
||||
Double closedBarClose = isRealSignal ? signalBarClose.remove(market) : null;
|
||||
|
||||
// 잔여 맵 엔트리 정리 (실제 시그널 없는 경우)
|
||||
if (!isRealSignal) {
|
||||
signalBarTimeEpoch.remove(market);
|
||||
signalBarClose.remove(market);
|
||||
}
|
||||
|
||||
long barTime = (closedBarTime != null) ? closedBarTime : p.barStart.toEpochSecond();
|
||||
double barClose = (closedBarClose != null) ? closedBarClose : p.close;
|
||||
|
||||
CandleBarDto dto = CandleBarDto.builder()
|
||||
.time(barTime)
|
||||
.open(p.open)
|
||||
.high(p.high)
|
||||
.low(p.low)
|
||||
.close(barClose)
|
||||
.volume(p.volume)
|
||||
.signal(isRealSignal ? signal : null) // null 이면 @JsonInclude(NON_NULL) 로 직렬화 제외
|
||||
.build();
|
||||
broker.publish(market, "1m", dto);
|
||||
publishUpperTimeframes(market);
|
||||
}
|
||||
|
||||
/** 상위 분봉 구독자용 — Ta4jStorage 마지막 봉 스냅샷 (틱마다 1m 과 함께 발행) */
|
||||
private void publishUpperTimeframes(String market) {
|
||||
for (String type : UPPER_TYPES) {
|
||||
try {
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, type);
|
||||
if (series.isEmpty()) continue;
|
||||
Bar last = series.getLastBar();
|
||||
long barStart = last.getEndTime().getEpochSecond() - last.getTimePeriod().getSeconds();
|
||||
broker.publish(market, type, CandleBarDto.builder()
|
||||
.time(barStart)
|
||||
.open(last.getOpenPrice().doubleValue())
|
||||
.high(last.getHighPrice().doubleValue())
|
||||
.low(last.getLowPrice().doubleValue())
|
||||
.close(last.getClosePrice().doubleValue())
|
||||
.volume(last.getVolume().doubleValue())
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
log.trace("[BarBuilder] upper publish skip {}/{}: {}", market, type, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** PartialBar → Ta4j Bar 변환 */
|
||||
private Bar buildTa4jBar(PartialBar partial, Duration duration, ZonedDateTime endZdt) {
|
||||
BarSeries tmp = new org.ta4j.core.BaseBarSeriesBuilder()
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.withNumFactory(DoubleNumFactory.getInstance()) // Ta4j 0.22
|
||||
.build();
|
||||
// Ta4j 0.22: barBuilder().endTime() 은 Instant 파라미터
|
||||
Instant endInstant = endZdt.toInstant();
|
||||
return tmp.barBuilder()
|
||||
.timePeriod(duration)
|
||||
.endTime(endInstant)
|
||||
.openPrice(partial.open)
|
||||
.highPrice(partial.high)
|
||||
.lowPrice(partial.low)
|
||||
.closePrice(partial.close)
|
||||
.volume(partial.volume)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 업비트 일봉 기준 시각: KST (UTC+9) */
|
||||
private static final ZoneId ZONE_KST = ZoneId.of("Asia/Seoul");
|
||||
|
||||
/**
|
||||
* 분 단위 시간을 상위 interval 의 시작으로 정렬.
|
||||
*
|
||||
* 일봉(minutes == 1440)은 업비트와 동일하게 KST 00:00 기준으로 정렬한다.
|
||||
* 그 외 모든 타임프레임은 UTC epoch 기준 정렬(기존 방식 유지).
|
||||
*/
|
||||
private ZonedDateTime alignToInterval(ZonedDateTime time, int minutes) {
|
||||
if (minutes == 1440) {
|
||||
// 일봉: KST 자정(00:00) 기준 — 업비트 일봉 경계와 일치
|
||||
ZonedDateTime kst = time.withZoneSameInstant(ZONE_KST);
|
||||
return kst.toLocalDate().atStartOfDay(ZONE_KST);
|
||||
}
|
||||
long epochMin = time.toEpochSecond() / 60;
|
||||
long aligned = (epochMin / minutes) * minutes;
|
||||
return Instant.ofEpochSecond(aligned * 60).atZone(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
// ── 내부 상태 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 진행 중인 단일 캔들 상태 */
|
||||
static class PartialBar {
|
||||
final ZonedDateTime barStart;
|
||||
final double open;
|
||||
double high;
|
||||
double low;
|
||||
double close;
|
||||
double volume;
|
||||
|
||||
PartialBar(ZonedDateTime barStart, double price, double volume) {
|
||||
this.barStart = barStart;
|
||||
this.open = price;
|
||||
this.high = price;
|
||||
this.low = price;
|
||||
this.close = price;
|
||||
this.volume = volume;
|
||||
}
|
||||
|
||||
void update(double price, double vol) {
|
||||
if (price > this.high) this.high = price;
|
||||
if (price < this.low) this.low = price;
|
||||
this.close = price;
|
||||
this.volume += vol;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.goldenchart.websocket;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.dto.CandleBarDto;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
|
||||
import org.springframework.web.socket.messaging.SessionUnsubscribeEvent;
|
||||
import org.ta4j.core.Bar;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 동적 STOMP 구독 관리자 (명세서 3.3).
|
||||
*
|
||||
* 프론트엔드가 /sub/charts/{market}/{type} 채널을 구독하면:
|
||||
* 1. Ta4jStorage 에 해당 종목 BarSeries 가 없으면 업비트 REST API Warm-up
|
||||
* 2. UpbitWebSocketClient 구독 목록에 market 추가
|
||||
*
|
||||
* 구독자 수가 0이 되면:
|
||||
* 1. UpbitWebSocketClient 에서 market 제거
|
||||
* 2. Ta4jStorage 에서 BarSeries 삭제 (메모리 방어)
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DynamicSubscriptionManager {
|
||||
|
||||
// /sub/charts/{market}/{type} 패턴 파서
|
||||
private static final Pattern TOPIC_PATTERN =
|
||||
Pattern.compile("^/sub/charts/([^/]+)/([^/]+)$");
|
||||
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final UpbitWebSocketClient upbitWsClient;
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${upbit.api.base-url:https://api.upbit.com}")
|
||||
private String upbitBaseUrl;
|
||||
|
||||
@Value("${upbit.api.candle-limit:200}")
|
||||
private int candlePageSize;
|
||||
|
||||
/** EMA 수렴을 위한 warm-up 목표 봉 수 (페이지네이션으로 달성) */
|
||||
@Value("${upbit.api.warmup-count:600}")
|
||||
private int warmupTargetCount;
|
||||
|
||||
@Value("${upbit.api.request-delay-ms:110}")
|
||||
private long requestDelayMs;
|
||||
|
||||
/** 구독자 수 추적: "KRW-BTC:1m" → count */
|
||||
private final ConcurrentHashMap<String, AtomicInteger> subscriberCount =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** 워밍업 중인 키 추적 (중복 요청 방지) */
|
||||
private final ConcurrentHashMap<String, Boolean> warmingUp =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** 워밍업 전용 단일 스레드 — 업비트 API 레이트리밋 보호용 직렬 실행 */
|
||||
private final ExecutorService warmUpExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "warmup-thread");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
// ── STOMP 구독/구독해제 이벤트 리스너 ────────────────────────────────────
|
||||
|
||||
@EventListener
|
||||
public void handleSubscribe(SessionSubscribeEvent event) {
|
||||
String dest = getDestination(event.getMessage().getHeaders());
|
||||
if (dest == null) return;
|
||||
Matcher m = TOPIC_PATTERN.matcher(dest);
|
||||
if (!m.matches()) return;
|
||||
|
||||
String market = m.group(1);
|
||||
String candleType = m.group(2);
|
||||
String key = market + ":" + candleType;
|
||||
|
||||
int count = subscriberCount.computeIfAbsent(key, k -> new AtomicInteger(0))
|
||||
.incrementAndGet();
|
||||
log.info("[DynamicSubscriptionManager] 구독: {} (총 {}명)", key, count);
|
||||
|
||||
// Warm-up: 메모리에 없고 현재 워밍업 중이 아니면 비동기 초기화
|
||||
if (!ta4jStorage.exists(market, candleType)
|
||||
&& warmingUp.putIfAbsent(key, Boolean.TRUE) == null) {
|
||||
warmUpExecutor.submit(() -> {
|
||||
try {
|
||||
warmUp(market, candleType);
|
||||
} finally {
|
||||
warmingUp.remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
// UpbitWebSocket 구독 추가
|
||||
upbitWsClient.addMarket(market);
|
||||
if ("1m".equals(candleType)) {
|
||||
upbitWsClient.resubscribeAll();
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handleUnsubscribe(SessionUnsubscribeEvent event) {
|
||||
// unsubscribe 이벤트는 구독 ID 기반이므로 별도 매핑 필요 (간소화: 0 도달 시 GC)
|
||||
// 실제 구현에서는 sessionId + subscriptionId → topic 역매핑 테이블 필요
|
||||
// 여기서는 구독 수 감소만 처리
|
||||
String dest = getDestination(event.getMessage().getHeaders());
|
||||
if (dest == null) return;
|
||||
Matcher m = TOPIC_PATTERN.matcher(dest);
|
||||
if (!m.matches()) return;
|
||||
|
||||
String market = m.group(1);
|
||||
String candleType = m.group(2);
|
||||
String key = market + ":" + candleType;
|
||||
|
||||
AtomicInteger counter = subscriberCount.get(key);
|
||||
if (counter == null) return;
|
||||
|
||||
int remaining = counter.decrementAndGet();
|
||||
log.info("[DynamicSubscriptionManager] 구독 해제: {} (남은 {}명)", key, remaining);
|
||||
|
||||
if (remaining <= 0) {
|
||||
subscriberCount.remove(key);
|
||||
upbitWsClient.removeMarket(market);
|
||||
ta4jStorage.remove(market, candleType);
|
||||
log.info("[DynamicSubscriptionManager] GC: {} 메모리 해제", key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 전략 체크 등 STOMP 클라이언트 없이도 백엔드가 틱·캔들을 수집하도록 마켓을 고정 구독한다.
|
||||
* (관심종목 모니터링 — 프론트가 해당 토픽을 구독하면 평가·시그널 발행이 연결됨)
|
||||
*/
|
||||
public void ensureMarketPinned(String market, String candleType) {
|
||||
String key = market + ":" + candleType;
|
||||
if (!ta4jStorage.exists(market, candleType)
|
||||
&& warmingUp.putIfAbsent(key, Boolean.TRUE) == null) {
|
||||
warmUpExecutor.submit(() -> {
|
||||
try {
|
||||
warmUp(market, candleType);
|
||||
} finally {
|
||||
warmingUp.remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
upbitWsClient.addMarket(market);
|
||||
log.info("[DynamicSubscriptionManager] 실시간 전략용 마켓 고정: {}:{}", market, candleType);
|
||||
}
|
||||
|
||||
// ── Warm-up ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 업비트 REST API 로 최근 N개 캔들을 페이지네이션으로 가져와 Ta4jStorage 를 초기화한다.
|
||||
* EMA(200) 수렴에는 약 1000봉이 필요하므로 warmupTargetCount(기본 600)봉을 목표로 한다.
|
||||
*/
|
||||
private void warmUp(String market, String candleType) {
|
||||
String path = Ta4jStorage.CANDLE_TYPE_MAP.getOrDefault(candleType, "minutes/1");
|
||||
Duration duration = Ta4jStorage.parseDuration(candleType);
|
||||
List<Map<String, Object>> allRaw = new ArrayList<>();
|
||||
String toParam = null; // null = 최신부터
|
||||
int fetched = 0;
|
||||
|
||||
try {
|
||||
while (fetched < warmupTargetCount) {
|
||||
int pageSize = Math.min(candlePageSize, warmupTargetCount - fetched);
|
||||
String url = upbitBaseUrl + "/v1/candles/" + path
|
||||
+ "?market=" + market + "&count=" + pageSize
|
||||
+ (toParam != null ? "&to=" + toParam : "");
|
||||
|
||||
Thread.sleep(requestDelayMs);
|
||||
String json = webClientBuilder.build()
|
||||
.get().uri(url)
|
||||
.retrieve().bodyToMono(String.class).block();
|
||||
if (json == null || json.isBlank()) break;
|
||||
|
||||
List<Map<String, Object>> page =
|
||||
objectMapper.readValue(json, new TypeReference<>() {});
|
||||
if (page.isEmpty()) break;
|
||||
|
||||
allRaw.addAll(page);
|
||||
fetched += page.size();
|
||||
|
||||
// 다음 페이지: 가장 오래된 봉 시각보다 1초 이전을 to 로 설정.
|
||||
// 업비트 to 파라미터는 exclusive 이지만, 동일 시각 중복 방지를 위해 1초 뺀다.
|
||||
String oldest = (String) page.get(page.size() - 1).get("candle_date_time_utc");
|
||||
if (oldest == null) break;
|
||||
long oldestEpoch = parseEpoch(oldest);
|
||||
toParam = Instant.ofEpochSecond(oldestEpoch - 1)
|
||||
.atZone(ZoneOffset.UTC)
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
|
||||
+ "Z";
|
||||
|
||||
if (page.size() < pageSize) break; // 더 이상 데이터 없음
|
||||
}
|
||||
|
||||
// 업비트 응답은 최신→과거 순 → 오름차순 정렬 후 Ta4jStorage 에 적재
|
||||
allRaw.sort(Comparator.comparingLong(r -> parseEpoch((String) r.get("candle_date_time_utc"))));
|
||||
|
||||
for (Map<String, Object> r : allRaw) {
|
||||
long epochSec = parseEpoch((String) r.get("candle_date_time_utc"));
|
||||
Instant endInstant = Instant.ofEpochSecond(epochSec).plus(duration);
|
||||
try {
|
||||
BarSeries tmp = new BaseBarSeriesBuilder()
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.withNumFactory(DoubleNumFactory.getInstance()).build();
|
||||
Bar bar = tmp.barBuilder()
|
||||
.timePeriod(duration).endTime(endInstant)
|
||||
.openPrice(toDouble(r.get("opening_price")))
|
||||
.highPrice(toDouble(r.get("high_price")))
|
||||
.lowPrice(toDouble(r.get("low_price")))
|
||||
.closePrice(toDouble(r.get("trade_price")))
|
||||
.volume(toDouble(r.get("candle_acc_trade_volume")))
|
||||
.build();
|
||||
ta4jStorage.addBar(market, candleType, bar);
|
||||
} catch (Exception e) {
|
||||
log.trace("[DynamicSubscriptionManager] warmUp bar 스킵: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("[DynamicSubscriptionManager] Warm-up 완료: {} / {} ({} bars)", market, candleType, allRaw.size());
|
||||
} catch (Exception e) {
|
||||
log.error("[DynamicSubscriptionManager] Warm-up 실패 {}/{}: {}", market, candleType, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private String getDestination(org.springframework.messaging.MessageHeaders headers) {
|
||||
Object dest = headers.get("simpDestination");
|
||||
return dest instanceof String s ? s : null;
|
||||
}
|
||||
|
||||
private static long parseEpoch(String utcStr) {
|
||||
if (utcStr == null) return 0L;
|
||||
return ZonedDateTime.parse(utcStr + "Z",
|
||||
java.time.format.DateTimeFormatter.ISO_DATE_TIME).toEpochSecond();
|
||||
}
|
||||
|
||||
private static double toDouble(Object v) {
|
||||
return v == null ? 0.0 : ((Number) v).doubleValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.goldenchart.websocket;
|
||||
|
||||
import com.goldenchart.dto.CandleBarDto;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||
import com.goldenchart.service.LiveStrategyTimeframeService;
|
||||
import com.goldenchart.service.TradeSignalService;
|
||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 3초 주기 라운드 로빈 스케줄러.
|
||||
*
|
||||
* <p>설정 방식이 REALTIME_TICK 이고 isLiveCheck = true 인 종목에 대해
|
||||
* 현재 진행 중인 캔들 인덱스(series.getEndIndex())로 전략을 평가하여
|
||||
* BUY/SELL 시그널이 발생하면 STOMP 토픽으로 발행한다.
|
||||
*
|
||||
* <p>동시성 안전:
|
||||
* <ul>
|
||||
* <li>BarBuilder 의 partialBars 와 Ta4jStorage 는 ConcurrentHashMap 기반</li>
|
||||
* <li>이 스케줄러는 단일 스레드로 돌기 때문에 중복 평가 없음</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LiveStrategyScheduler {
|
||||
|
||||
private final GcLiveStrategySettingsRepository settingsRepo;
|
||||
private final LiveStrategyEvaluator evaluator;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final TradingWebSocketBroker broker;
|
||||
private final BarBuilder barBuilder;
|
||||
private final TradeSignalService tradeSignalService;
|
||||
private final OrderExecutionQueue orderExecutionQueue;
|
||||
private final LiveStrategyTimeframeService timeframeService;
|
||||
|
||||
/**
|
||||
* 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행.
|
||||
* fixedDelay=3000: 이전 실행이 끝난 뒤 3초 후 재실행 (오버랩 방지).
|
||||
*/
|
||||
@Scheduled(fixedDelay = 3000)
|
||||
public void tick() {
|
||||
List<GcLiveStrategySettings> activeSettings = settingsRepo.findAllByIsLiveCheckTrue();
|
||||
if (activeSettings.isEmpty()) return;
|
||||
|
||||
for (GcLiveStrategySettings s : activeSettings) {
|
||||
if (!"REALTIME_TICK".equals(s.getExecutionType())) continue;
|
||||
if (s.getStrategyId() == null) continue;
|
||||
|
||||
String market = s.getMarket();
|
||||
String candleType = timeframeService.resolveCandleType(
|
||||
market, s.getUserId(), s.getDeviceId());
|
||||
|
||||
if (!ta4jStorage.exists(market, candleType)) continue;
|
||||
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||
if (series.isEmpty()) continue;
|
||||
|
||||
String signal = evaluator.evaluateRealtimeTick(market, candleType);
|
||||
if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
||||
// 현재 진행 중인 캔들 정보 조회
|
||||
org.ta4j.core.Bar lastBar = series.getLastBar();
|
||||
// Ta4j 0.22: getEndTime() → Instant; 시작 시간 = endTime - duration
|
||||
long barStartEpoch = lastBar.getEndTime().getEpochSecond()
|
||||
- lastBar.getTimePeriod().getSeconds();
|
||||
|
||||
CandleBarDto dto = CandleBarDto.builder()
|
||||
.time(barStartEpoch)
|
||||
.open(lastBar.getOpenPrice().doubleValue())
|
||||
.high(lastBar.getHighPrice().doubleValue())
|
||||
.low(lastBar.getLowPrice().doubleValue())
|
||||
.close(lastBar.getClosePrice().doubleValue())
|
||||
.volume(lastBar.getVolume().doubleValue())
|
||||
.signal(signal)
|
||||
.build();
|
||||
|
||||
broker.publish(market, candleType, dto);
|
||||
log.info("[LiveStrategyScheduler] REALTIME_TICK signal={} market={}", signal, market);
|
||||
|
||||
// DB 저장
|
||||
try {
|
||||
tradeSignalService.save(
|
||||
s.getDeviceId(), s.getUserId(),
|
||||
market, s.getStrategyId(), null,
|
||||
signal,
|
||||
lastBar.getClosePrice().doubleValue(),
|
||||
barStartEpoch, candleType, "REALTIME_TICK"
|
||||
);
|
||||
if (s.getDeviceId() != null) {
|
||||
orderExecutionQueue.submitSignal(
|
||||
s.getDeviceId(), s.getUserId(), market,
|
||||
s.getStrategyId(), signal,
|
||||
lastBar.getClosePrice().doubleValue());
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.goldenchart.websocket;
|
||||
|
||||
import com.goldenchart.dto.CandleBarDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* STOMP 실시간 캔들 분배기 (명세서 3.5 / 5.2).
|
||||
*
|
||||
* BarBuilder 또는 LiveStrategyScheduler 에서 확정된 캔들 데이터를
|
||||
* 구독자에게 브로드캐스트한다.
|
||||
*
|
||||
* 토픽: /sub/charts/{market}/{candleType}
|
||||
* 페이로드: CandleBarDto (JSON 직렬화)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TradingWebSocketBroker {
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
/**
|
||||
* 특정 마켓·캔들타입 구독자에게 실시간 캔들 업데이트를 발행한다.
|
||||
*
|
||||
* @param market 업비트 마켓 코드 (e.g. "KRW-BTC")
|
||||
* @param candleType 캔들 타입 (e.g. "1m")
|
||||
* @param bar 전송할 캔들 데이터
|
||||
*/
|
||||
public void publish(String market, String candleType, CandleBarDto bar) {
|
||||
String destination = "/sub/charts/" + market + "/" + candleType;
|
||||
try {
|
||||
messagingTemplate.convertAndSend(destination, bar);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("[TradingWebSocketBroker] publish → {} close={}", destination, bar.getClose());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[TradingWebSocketBroker] 발행 실패 {}: {}", destination, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.goldenchart.websocket;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.trading.pipeline.TickActivityTracker;
|
||||
import com.goldenchart.trading.pipeline.TradeEventQueue;
|
||||
import com.goldenchart.trading.pipeline.TradeTickEvent;
|
||||
import com.goldenchart.trading.pipeline.TradingPipelineProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.java_websocket.client.WebSocketClient;
|
||||
import org.java_websocket.handshake.ServerHandshake;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 백엔드 업비트 WebSocket 클라이언트 (명세서 3.1).
|
||||
*
|
||||
* - 고정 관심종목(최대 20개) + 동적 차트 오픈 종목(최대 8개) 구독
|
||||
* - 수신한 trade 틱을 BarBuilder 로 전달하여 캔들 조립
|
||||
* - 연결 끊김 시 지수 백오프 재연결 (최대 60초 간격)
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UpbitWebSocketClient {
|
||||
|
||||
private final BarBuilder barBuilder;
|
||||
private final TradeEventQueue tradeEventQueue;
|
||||
private final TradingPipelineProperties pipelineProperties;
|
||||
private final TickActivityTracker activityTracker;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final AtomicLong messagesReceived = new AtomicLong();
|
||||
private final AtomicLong parseErrors = new AtomicLong();
|
||||
private volatile long lastMessageAtMs;
|
||||
private volatile long connectedAtMs;
|
||||
private volatile int lastCloseCode;
|
||||
private volatile String lastCloseReason = "";
|
||||
|
||||
@Value("${upbit.websocket.url:wss://api.upbit.com/websocket/v1}")
|
||||
private String wsUrl;
|
||||
|
||||
/** 현재 구독 중인 마켓 코드 집합 (thread-safe) */
|
||||
private final CopyOnWriteArraySet<String> subscribedMarkets = new CopyOnWriteArraySet<>();
|
||||
|
||||
private volatile InternalWsClient wsClient;
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
private final ScheduledExecutorService scheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "upbit-ws-reconnect");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private int reconnectDelaySec = 2;
|
||||
private static final int MAX_RECONNECT_DELAY = 60;
|
||||
|
||||
// ── 생명주기 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
running.set(true);
|
||||
connect();
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
running.set(false);
|
||||
scheduler.shutdownNow();
|
||||
if (wsClient != null) wsClient.close();
|
||||
log.info("[UpbitWebSocketClient] 종료");
|
||||
}
|
||||
|
||||
// ── 구독 관리 (DynamicSubscriptionManager 에서 호출) ──────────────────────
|
||||
|
||||
/**
|
||||
* 마켓을 구독 목록에 추가하고 웹소켓 재구독 패킷 전송.
|
||||
*/
|
||||
public synchronized void addMarket(String market) {
|
||||
if (subscribedMarkets.add(market)) {
|
||||
log.info("[UpbitWebSocketClient] 마켓 추가: {} (총 {})", market, subscribedMarkets.size());
|
||||
sendSubscribePacket();
|
||||
} else if (wsClient != null && wsClient.isOpen()) {
|
||||
// 이미 목록에 있어도 재연결 직후 구독 패킷이 누락될 수 있음
|
||||
sendSubscribePacket();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마켓을 구독 목록에서 제거하고 웹소켓 재구독 패킷 전송.
|
||||
*/
|
||||
public synchronized void removeMarket(String market) {
|
||||
if (subscribedMarkets.remove(market)) {
|
||||
log.info("[UpbitWebSocketClient] 마켓 제거: {}", market);
|
||||
sendSubscribePacket();
|
||||
}
|
||||
}
|
||||
|
||||
public Set<String> getSubscribedMarkets() {
|
||||
return Collections.unmodifiableSet(subscribedMarkets);
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return wsClient != null && wsClient.isOpen();
|
||||
}
|
||||
|
||||
public Map<String, Object> monitorSnapshot() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("connected", isConnected());
|
||||
m.put("subscribedMarkets", subscribedMarkets.size());
|
||||
m.put("messagesReceived", messagesReceived.get());
|
||||
m.put("parseErrors", parseErrors.get());
|
||||
m.put("lastMessageAtMs", lastMessageAtMs);
|
||||
m.put("connectedAtMs", connectedAtMs);
|
||||
m.put("lastCloseCode", lastCloseCode);
|
||||
m.put("lastCloseReason", lastCloseReason);
|
||||
m.put("reconnectDelaySec", reconnectDelaySec);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 기동·STOMP 구독 직후 — 업비트에 구독 패킷 재전송 */
|
||||
public void resubscribeAll() {
|
||||
sendSubscribePacket();
|
||||
}
|
||||
|
||||
/** 틱 정체 등 — 외부 모니터가 재연결을 요청 */
|
||||
public void requestReconnect(String reason) {
|
||||
log.warn("[UpbitWebSocketClient] 재연결 요청: {}", reason);
|
||||
if (wsClient != null && wsClient.isOpen()) {
|
||||
wsClient.close();
|
||||
} else if (running.get()) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 내부 연결 / 재연결 ────────────────────────────────────────────────────
|
||||
|
||||
private void connect() {
|
||||
if (!running.get()) return;
|
||||
try {
|
||||
wsClient = new InternalWsClient(new URI(wsUrl));
|
||||
wsClient.connect();
|
||||
} catch (Exception e) {
|
||||
log.error("[UpbitWebSocketClient] 연결 실패: {}", e.getMessage());
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleReconnect() {
|
||||
if (!running.get()) return;
|
||||
log.info("[UpbitWebSocketClient] {}초 후 재연결 시도...", reconnectDelaySec);
|
||||
scheduler.schedule(() -> {
|
||||
connect();
|
||||
reconnectDelaySec = Math.min(reconnectDelaySec * 2, MAX_RECONNECT_DELAY);
|
||||
}, reconnectDelaySec, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** 현재 subscribedMarkets 기반으로 업비트 구독 JSON 패킷 전송 */
|
||||
private void sendSubscribePacket() {
|
||||
if (wsClient == null || !wsClient.isOpen() || subscribedMarkets.isEmpty()) return;
|
||||
try {
|
||||
List<String> codes = new ArrayList<>(subscribedMarkets);
|
||||
// 업비트 WebSocket 구독 포맷
|
||||
String packet = objectMapper.writeValueAsString(List.of(
|
||||
Map.of("ticket", UUID.randomUUID().toString()),
|
||||
Map.of("type", "trade", "codes", codes, "isOnlyRealtime", true),
|
||||
Map.of("format", "SIMPLE")
|
||||
));
|
||||
wsClient.send(packet);
|
||||
log.debug("[UpbitWebSocketClient] 구독 패킷 전송: {}", codes);
|
||||
} catch (Exception e) {
|
||||
log.error("[UpbitWebSocketClient] 구독 패킷 전송 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 내부 WebSocket 구현 ───────────────────────────────────────────────────
|
||||
|
||||
private class InternalWsClient extends WebSocketClient {
|
||||
|
||||
InternalWsClient(URI uri) {
|
||||
super(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(ServerHandshake handshake) {
|
||||
log.info("[UpbitWebSocketClient] 연결 성공");
|
||||
reconnectDelaySec = 2; // 재연결 딜레이 리셋
|
||||
connectedAtMs = System.currentTimeMillis();
|
||||
sendSubscribePacket();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(String message) {
|
||||
// 업비트는 binary frame 으로 전송 — text fallback 무시
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(java.nio.ByteBuffer bytes) {
|
||||
try {
|
||||
byte[] payload = new byte[bytes.remaining()];
|
||||
bytes.get(payload);
|
||||
JsonNode node = objectMapper.readTree(payload);
|
||||
|
||||
String ty = node.path("ty").asText("");
|
||||
if (ty.isEmpty()) ty = node.path("type").asText("");
|
||||
if (!"trade".equals(ty)) return;
|
||||
|
||||
messagesReceived.incrementAndGet();
|
||||
lastMessageAtMs = System.currentTimeMillis();
|
||||
|
||||
String market = node.path("cd").asText("");
|
||||
if (market.isEmpty()) market = node.path("code").asText("");
|
||||
if (market.isEmpty()) return;
|
||||
|
||||
double tradePrice = node.path("tp").asDouble();
|
||||
if (tradePrice == 0) tradePrice = node.path("trade_price").asDouble();
|
||||
double tradeVolume = node.path("tv").asDouble();
|
||||
if (tradeVolume == 0) tradeVolume = node.path("trade_volume").asDouble();
|
||||
|
||||
long tradeTimeMs = node.path("tms").asLong();
|
||||
if (tradeTimeMs == 0) tradeTimeMs = node.path("ttms").asLong();
|
||||
if (tradeTimeMs == 0) tradeTimeMs = node.path("trade_timestamp").asLong();
|
||||
if (tradeTimeMs == 0) tradeTimeMs = node.path("timestamp").asLong();
|
||||
if (tradeTimeMs == 0) tradeTimeMs = System.currentTimeMillis();
|
||||
|
||||
activityTracker.record(market);
|
||||
if (pipelineProperties.isEnabled()) {
|
||||
tradeEventQueue.offer(TradeTickEvent.of(
|
||||
market, tradePrice, tradeVolume, tradeTimeMs));
|
||||
} else {
|
||||
barBuilder.onTick(market, tradePrice, tradeVolume, tradeTimeMs);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
parseErrors.incrementAndGet();
|
||||
log.warn("[UpbitWebSocketClient] 메시지 파싱 오류: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose(int code, String reason, boolean remote) {
|
||||
lastCloseCode = code;
|
||||
lastCloseReason = reason != null ? reason : "";
|
||||
log.warn("[UpbitWebSocketClient] 연결 종료 code={}, reason={}", code, reason);
|
||||
if (running.get()) scheduleReconnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
log.error("[UpbitWebSocketClient] 에러: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user