Files
goldenChart/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java
T
2026-06-05 12:22:29 +09:00

302 lines
14 KiB
Java

package com.goldenchart.websocket;
import com.goldenchart.dto.CandleBarDto;
import com.goldenchart.service.BarCloseStrategyEvaluationService;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
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.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
/**
* 업비트 WebSocket 틱(Trade) 데이터를 수신하여 분봉 단위 캔들을 조립하는 빌더.
*
* 명세서 3.2 준수:
* - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신
* - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d)으로 집계 전파
*
* 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1w, 1d
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class BarBuilder {
private final Ta4jStorage ta4jStorage;
private final TradingWebSocketBroker broker;
private final BarCloseStrategyEvaluationService barCloseEvaluation;
private final com.goldenchart.service.PaperOrderMatcherService paperOrderMatcher;
/**
* [항목4] Phase 2 비동기 실행용 스레드 풀.
* 웹소켓 수신 스레드가 전략 평가 연산에 블로킹되지 않도록 분리한다.
*/
@org.springframework.beans.factory.annotation.Autowired
@Qualifier("barCloseEvalExecutor")
private Executor barCloseEvalExecutor;
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"};
/** 진행 중인 1분봉 상태 (market → PartialBar) */
private final ConcurrentHashMap<String, PartialBar> partialBars = 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);
paperOrderMatcher.onPriceTick(market, tradePrice);
}
// ── 내부 처리 ──────────────────────────────────────────────────────────────
/**
* 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파한다.
*
* <p>[항목2] 2-Phase 처리 — 멀티 타임프레임 레이스 컨디션 해소:
* <ul>
* <li><b>Phase 1</b>: 1m 봉과 모든 상위봉을 Ta4jStorage 에 먼저 확정한다.</li>
* <li><b>Phase 2</b>: 모든 저장이 완료된 후에야 전략 평가를 트리거한다.</li>
* </ul>
* 기존 방식(1m 저장 → 1m 전략 평가 → 상위봉 저장 → 상위봉 전략 평가)에서는
* 1m 전략이 1h CrossSeriesRule 을 참조할 때 1h 봉이 아직 확정 전이어서
* 잘못된 인덱스를 바라보는 레이스 컨디션이 있었다.
*/
private void commitBar(String market, PartialBar partial) {
Duration d1m = Duration.ofMinutes(1);
ZonedDateTime endTime = partial.barStart.plus(d1m);
Bar bar = buildTa4jBar(partial, d1m, endTime);
// ── Phase 1: 모든 타임프레임 스토리지 업데이트 ────────────────────────
ta4jStorage.addBar(market, "1m", bar);
log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime);
// 마감된 상위봉 정보(타입, maturedIndex, bar) — Phase 2 에서 사용
java.util.List<UpperCloseInfo> closedUppers = new java.util.ArrayList<>();
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 {
Instant lastUpperEndInstant = upperSeries.getLastBar().getEndTime();
Instant upperEndInstant = upperEnd.toInstant();
if (lastUpperEndInstant.equals(upperEndInstant)) {
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
} else if (lastUpperEndInstant.isBefore(upperEndInstant)) {
// 상위봉 마감 → 새 봉 추가
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
int maturedIdx = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
closedUppers.add(new UpperCloseInfo(type, maturedIdx, upperAfter.getBar(maturedIdx)));
} else {
log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행",
market, type, lastUpperEndInstant, upperEndInstant);
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
}
}
}
// ── Phase 2: 모든 봉이 확정된 후 전략 평가 트리거 (비동기) ──────────────
//
// [항목4] 웹소켓 수신 스레드(업비트 연결 유지 담당)가 전략 평가 연산 때문에 블로킹되면
// 다음 분봉 시작 틱을 제때 수신하지 못할 수 있다. Phase 1 완료 후 Phase 2 를
// 전용 스레드 풀(barCloseEvalExecutor)에 제출하여 WebSocket 파이프라인을 즉시 반환한다.
//
// 스레드 안전성: Ta4jStorage·positionOpenCache 는 ConcurrentHashMap, maturedIdx 는
// Phase 1 에서 이미 확정된 값이므로 비동기 실행 중 Phase 1 이 재실행되어 인덱스가 바뀌어도
// 올바른 봉을 참조한다.
final Bar confirmedBar = bar;
final java.util.List<UpperCloseInfo> closedUppersSnap
= 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);
}
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);
}
});
}
/** Phase 1 에서 마감된 상위봉 정보를 Phase 2 로 전달하는 내부 레코드 */
private record UpperCloseInfo(String type, int maturedIdx, Bar bar) {}
/** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
private void publishCurrentBar(String market) {
PartialBar p = partialBars.get(market);
if (p == null) return;
CandleBarDto dto = CandleBarDto.builder()
.time(p.barStart.toEpochSecond())
.open(p.open)
.high(p.high)
.low(p.low)
.close(p.close)
.volume(p.volume)
.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;
}
}
}