diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java index 21efc7b..1f0c887 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/CandleGapBackfillService.java @@ -19,6 +19,7 @@ import org.ta4j.core.num.DoubleNumFactory; import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Comparator; @@ -51,6 +52,15 @@ public class CandleGapBackfillService { private long requestDelayMs; private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"}; + /** 업비트 일봉 기준 시각: KST (UTC+9) — BarBuilder.alignToInterval(1440) 과 동일 */ + private static final ZoneId ZONE_KST = ZoneId.of("Asia/Seoul"); + + /** Bar 생성용 공유 팩토리 — 매번 BarSeries를 new 하는 낭비 제거 */ + private static final BarSeries BAR_FACTORY_SERIES = + new BaseBarSeriesBuilder() + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) + .build(); private volatile long lastRunAtMs; private final AtomicInteger lastRunMarkets = new AtomicInteger(); @@ -120,18 +130,26 @@ public class CandleGapBackfillService { int merged = 0; for (Map r : page) { long epochSec = parseEpoch((String) r.get("candle_date_time_utc")); - Instant endInstant = Instant.ofEpochSecond(epochSec).plus(duration); + // 1d 봉: Upbit REST candle_date_time_utc 는 UTC 자정 기준이지만 + // BarBuilder.alignToInterval(1440) 은 KST 자정 기준으로 endTime 을 계산한다. + // UTC open 시각이 속하는 KST 날짜의 종료 시각(KST 다음날 00:00)으로 변환하여 + // BarBuilder 와 동일한 경계를 사용한다. + Instant endInstant; + if ("1d".equals(candleType)) { + ZonedDateTime kstDay = Instant.ofEpochSecond(epochSec) + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(ZONE_KST); + endInstant = kstDay.toLocalDate().atStartOfDay(ZONE_KST).plusDays(1).toInstant(); + } else { + endInstant = Instant.ofEpochSecond(epochSec).plus(duration); + } double open = toDouble(r.get("opening_price")); double high = toDouble(r.get("high_price")); double low = toDouble(r.get("low_price")); double close = toDouble(r.get("trade_price")); double vol = toDouble(r.get("candle_acc_trade_volume")); - Bar bar = new BaseBarSeriesBuilder() - .withBarBuilderFactory(new TimeBarBuilderFactory()) - .withNumFactory(DoubleNumFactory.getInstance()) - .build() - .barBuilder() + Bar bar = BAR_FACTORY_SERIES.barBuilder() .timePeriod(duration) .endTime(endInstant) .openPrice(open).highPrice(high).lowPrice(low) diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/TickActivityTracker.java b/backend/src/main/java/com/goldenchart/trading/pipeline/TickActivityTracker.java index 591acd2..6922b52 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/TickActivityTracker.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/TickActivityTracker.java @@ -21,6 +21,14 @@ public class TickActivityTracker { return lastTickMs.getOrDefault(market, 0L); } + /** + * WebSocket 재연결 시 모든 타임스탬프 초기화. + * 이전 연결의 오래된 타임스탬프가 남아 재연결 직후 stale 판단 → 재연결 루프를 유발하는 것을 방지한다. + */ + public void resetAll() { + lastTickMs.clear(); + } + public Map snapshot() { return Map.copyOf(lastTickMs); } diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineProperties.java b/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineProperties.java index 87331d3..17e0e00 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineProperties.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/TradingPipelineProperties.java @@ -29,8 +29,8 @@ public class TradingPipelineProperties { /** BarSeries 메모리 상한 (OOM 방지) */ private int maxBarsPerSeries = 500; - /** 종목별 틱 미수신 시 재연결 요청(초) */ - private int staleTickSeconds = 30; + /** 종목별 틱 미수신 시 재연결 요청(초) — 저유동성 종목은 수 분간 체결 없는 게 정상이므로 넉넉히 설정 */ + private int staleTickSeconds = 120; /** 갭 백필 스케줄 (Spring cron) */ private String gapBackfillCron = "0 */5 * * * *"; diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/WebSocketStalenessMonitor.java b/backend/src/main/java/com/goldenchart/trading/pipeline/WebSocketStalenessMonitor.java index 3c86ec7..7208711 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/WebSocketStalenessMonitor.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/WebSocketStalenessMonitor.java @@ -59,18 +59,62 @@ public class WebSocketStalenessMonitor { ); } + /** + * 재연결 후 틱 수신까지 여유를 주기 위한 연결 직후 유예 기간 (ms). + * - 재연결 직후 이전 타임스탬프가 초기화되지만, 업비트가 새 구독에 응답하는데 수 초 소요 + * - 유예 기간 동안은 stale 체크를 건너뜀으로써 재연결 루프를 차단 + */ + private static final long CONNECT_GRACE_MS = 90_000; // 90초 + + /** + * WS가 살아있어도(메시지 수신 중) 일부 저유동성 종목만 stale 일 때는 재연결하지 않는다. + * 전체 메시지가 없어야 WS 자체가 죽었다고 판단한다. + */ + private static final long GLOBAL_DEAD_MS = 60_000; // 60초 동안 WS 메시지 없으면 연결 불량 + private void check() { lastCheckAtMs = System.currentTimeMillis(); long now = System.currentTimeMillis(); - long thresholdMs = props.getStaleTickSeconds() * 1000L; + + // ① 재연결 직후 유예 기간: 연결 후 CONNECT_GRACE_MS 이내라면 체크 건너뜀 + long connectedAt = upbitWebSocketClient.getConnectedAtMs(); + if (connectedAt > 0 && now - connectedAt < CONNECT_GRACE_MS) { + log.debug("[WebSocketStalenessMonitor] 연결 직후 유예 기간 — 체크 건너뜀 ({}ms 경과)", + now - connectedAt); + return; + } + + // ② WS 전체 헬스 체크: 최근에 메시지가 수신되고 있으면 연결 자체는 살아있음 + // 이 경우 저유동성 종목의 틱 부재는 정상으로 간주하여 재연결 불필요 + long lastMsg = upbitWebSocketClient.getLastMessageAtMs(); + if (lastMsg > 0 && now - lastMsg < GLOBAL_DEAD_MS) { + // WS는 살아있음 — stale 정보는 로그만 남기고 재연결하지 않음 + var subscribed = upbitWebSocketClient.getSubscribedMarkets(); + long thresholdMs = props.getStaleTickSeconds() * 1000L; + List stale = new ArrayList<>(); + for (String market : subscribed) { + long last = activityTracker.lastTickMs(market); + if (last == 0) continue; + if (now - last > thresholdMs) stale.add(market); + } + lastStaleCount = stale.size(); + if (!stale.isEmpty()) { + log.debug("[WebSocketStalenessMonitor] 저유동성 종목 틱 부재 {}개 (WS 정상) — 재연결 없음: {}", + stale.size(), stale); + } + return; + } + + // ③ WS 메시지 자체가 끊긴 경우 — 연결 불량으로 판단하여 재연결 var subscribed = upbitWebSocketClient.getSubscribedMarkets(); if (subscribed.isEmpty()) return; + long thresholdMs = props.getStaleTickSeconds() * 1000L; List stale = new ArrayList<>(); for (String market : subscribed) { long last = activityTracker.lastTickMs(market); if (last == 0) { - // 아직 한 번도 틱 없음 — 구독 직후 30초 유예는 threshold 로 커버 + // 아직 한 번도 틱 없음 — 구독 직후 유예는 위에서 처리 continue; } if (now - last > thresholdMs) { @@ -79,9 +123,13 @@ public class WebSocketStalenessMonitor { } lastStaleCount = stale.size(); if (!stale.isEmpty()) { - log.warn("[WebSocketStalenessMonitor] 틱 정체 {}종목 → WS 재연결 요청: {}", + log.warn("[WebSocketStalenessMonitor] WS 응답 없음 + 틱 정체 {}종목 → 재연결 요청: {}", stale.size(), stale); upbitWebSocketClient.requestReconnect("stale_ticks:" + stale); + } else if (lastMsg > 0 && now - lastMsg > GLOBAL_DEAD_MS) { + log.warn("[WebSocketStalenessMonitor] WS 메시지 없음 ({}초 이상) → 재연결 요청", + (now - lastMsg) / 1000); + upbitWebSocketClient.requestReconnect("no_messages"); } } } diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index 32aa2a4..16951f3 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -56,6 +56,17 @@ public class BarBuilder { /** 진행 중인 1분봉 상태 (market → PartialBar) */ private final ConcurrentHashMap partialBars = new ConcurrentHashMap<>(); + /** + * Bar 생성용 공유 팩토리 BarSeries. + * barBuilder()는 Series 상태를 읽지 않고 BarBuilderFactory·NumFactory 만 참조하므로 + * 싱글턴으로 재사용해도 안전하다. 매번 BaseBarSeriesBuilder를 생성하는 낭비를 제거. + */ + private static final org.ta4j.core.BarSeries BAR_FACTORY_SERIES = + new org.ta4j.core.BaseBarSeriesBuilder() + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .withNumFactory(DoubleNumFactory.getInstance()) + .build(); + // ── 공개 API ────────────────────────────────────────────────────────────── /** @@ -175,14 +186,15 @@ public class BarBuilder { = java.util.Collections.unmodifiableList(closedUppers); barCloseEvalExecutor.execute(() -> { try { - if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) { - BarSeries series1m = ta4jStorage.getOrCreate(market, "1m"); - barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), confirmedBar); - } + // shouldEvaluateOnClose → onMaturedBarClose 를 분리 호출하면 findActiveByMarket DB 쿼리가 + // 타임프레임 수(최대 9+1=10)만큼 중복 실행된다. + // onMaturedBarClose 내부가 이미 activeSettings 조회 + 빈 경우 early-return 처리하므로 + // shouldEvaluateOnClose 선행 체크를 제거하여 DB 쿼리를 타임프레임당 1회로 절감한다. + BarSeries series1m = ta4jStorage.getOrCreate(market, "1m"); + barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), confirmedBar); + for (UpperCloseInfo info : closedUppersSnap) { - if (barCloseEvaluation.shouldEvaluateOnClose(market, info.type())) { - barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar()); - } + barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar()); } } catch (Exception e) { log.error("[BarBuilder] Phase 2 전략 평가 오류 market={}: {}", market, e.getMessage(), e); @@ -232,15 +244,11 @@ public class BarBuilder { } } - /** PartialBar → Ta4j Bar 변환 */ + /** PartialBar → Ta4j Bar 변환 — BAR_FACTORY_SERIES를 재사용하여 임시 BarSeries 생성 제거 */ 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() + return BAR_FACTORY_SERIES.barBuilder() .timePeriod(duration) .endTime(endInstant) .openPrice(partial.open) diff --git a/backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java b/backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java index 4bc23e2..c5b3e34 100644 --- a/backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java +++ b/backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java @@ -117,6 +117,16 @@ public class UpbitWebSocketClient { return wsClient != null && wsClient.isOpen(); } + /** 마지막 연결 성공 시각 (ms) — staleness monitor 유예 기간 계산용 */ + public long getConnectedAtMs() { + return connectedAtMs; + } + + /** 마지막 메시지 수신 시각 (ms) — WS 전체 건강 상태 확인용 */ + public long getLastMessageAtMs() { + return lastMessageAtMs; + } + public Map monitorSnapshot() { Map m = new LinkedHashMap<>(); m.put("connected", isConnected()); @@ -199,6 +209,8 @@ public class UpbitWebSocketClient { log.info("[UpbitWebSocketClient] 연결 성공"); reconnectDelaySec = 2; // 재연결 딜레이 리셋 connectedAtMs = System.currentTimeMillis(); + // 이전 연결의 오래된 타임스탬프를 초기화하여 재연결 직후 즉시 stale 판단 → 재연결 루프 방지 + activityTracker.resetAll(); sendSubscribePacket(); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 71d2204..ce0ba41 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -60,7 +60,7 @@ upbit: api: base-url: https://api.upbit.com candle-limit: 1000 - request-delay-ms: 110 + request-delay-ms: 200 # [항목4] warm-up 목표 봉 수 — 지표 계산 미스매치 방지 # 전략에서 사용하는 최대 Period(기간)의 최소 2~3배 이상으로 설정해야 한다. # 예) 신고가(120봉), HMA(200), EMA(200) 사용 시 → 200×3 = 600봉 이상 필요 @@ -95,7 +95,7 @@ goldenchart: worker-threads: 6 order-rate-per-second: 5.0 max-bars-per-series: 500 - stale-tick-seconds: 30 + stale-tick-seconds: 120 gap-backfill-cron: "0 */5 * * * *" gap-backfill-candle-count: 20 trend-search: