전략평가 시 부하문제 개선

This commit is contained in:
Macbook
2026-06-05 23:53:00 +09:00
parent 032ec69290
commit 1d4e606f77
7 changed files with 120 additions and 26 deletions
@@ -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<String, Object> 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)
@@ -21,6 +21,14 @@ public class TickActivityTracker {
return lastTickMs.getOrDefault(market, 0L);
}
/**
* WebSocket 재연결 시 모든 타임스탬프 초기화.
* 이전 연결의 오래된 타임스탬프가 남아 재연결 직후 stale 판단 → 재연결 루프를 유발하는 것을 방지한다.
*/
public void resetAll() {
lastTickMs.clear();
}
public Map<String, Long> snapshot() {
return Map.copyOf(lastTickMs);
}
@@ -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 * * * *";
@@ -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();
// ① 재연결 직후 유예 기간: 연결 후 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<String> 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<String> 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");
}
}
}
@@ -56,6 +56,17 @@ public class BarBuilder {
/** 진행 중인 1분봉 상태 (market → PartialBar) */
private final ConcurrentHashMap<String, PartialBar> 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,15 +186,16 @@ public class BarBuilder {
= java.util.Collections.unmodifiableList(closedUppers);
barCloseEvalExecutor.execute(() -> {
try {
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
// 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());
}
}
} 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)
@@ -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<String, Object> monitorSnapshot() {
Map<String, Object> 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();
}
+2 -2
View File
@@ -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: