전략평가 로직 개선

This commit is contained in:
Macbook
2026-06-05 10:59:23 +09:00
parent 5278177bbb
commit 0aaf4ac19c
14 changed files with 441 additions and 60 deletions
@@ -95,63 +95,78 @@ public class BarBuilder {
// ── 내부 처리 ──────────────────────────────────────────────────────────────
/** 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파 */
/**
* 완성된 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);
// Ta4j Bar 생성
Bar bar = buildTa4jBar(partial, d1m, endTime);
// 1분봉 Ta4jStorage 저장
// ── 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: 모든 봉이 확정된 후 전략 평가 트리거 ─────────────────────
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
}
// 상위 타임프레임 전파
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 (barCloseEvaluation.shouldEvaluateOnClose(market, type)) {
int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
Bar maturedUpperBar = upperAfter.getBar(maturedUpper);
barCloseEvaluation.onMaturedBarClose(market, type, maturedUpper, maturedUpperBar);
}
} else {
log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행",
market, type, lastUpperEndInstant, upperEndInstant);
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
}
}
for (UpperCloseInfo info : closedUppers) {
if (barCloseEvaluation.shouldEvaluateOnClose(market, info.type())) {
barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar());
}
}
}
/** 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);
@@ -1,6 +1,7 @@
package com.goldenchart.websocket;
import com.goldenchart.dto.CandleBarDto;
import com.goldenchart.dto.SignalEventDto;
import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.service.LiveStrategyEvaluator;
@@ -38,6 +39,7 @@ public class LiveStrategyScheduler {
private final LiveStrategyEvaluator evaluator;
private final Ta4jStorage ta4jStorage;
private final TradingWebSocketBroker broker;
private final SignalEventBroker signalEventBroker;
private final TradeSignalService tradeSignalService;
private final OrderExecutionQueue orderExecutionQueue;
private final StrategyConditionTimeframeService conditionTimeframes;
@@ -94,6 +96,20 @@ public class LiveStrategyScheduler {
.build();
broker.publish(market, candleType, dto);
// [항목5] 시그널 전용 토픽에 추가 발행
SignalEventDto signalEvent = SignalEventDto.builder()
.market(market)
.candleType(candleType)
.strategyId(s.getStrategyId())
.signalType(signal)
.price(lastBar.getClosePrice().doubleValue())
.candleTime(barStartEpoch)
.executionType("REALTIME_TICK")
.timestamp(System.currentTimeMillis())
.build();
signalEventBroker.publishToUser(s.getUserId(), s.getDeviceId(), signalEvent);
log.info("[LiveStrategyScheduler] REALTIME_TICK signal={} market={}", signal, market);
// DB 저장
@@ -0,0 +1,61 @@
package com.goldenchart.websocket;
import com.goldenchart.dto.SignalEventDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
/**
* [항목5] 매매 시그널 전용 STOMP 브로커.
*
* <p>시그널을 차트 캔들 스트림과 분리하여 독립된 토픽으로 발행함으로써
* 고부하 차트 스트림의 병목이 시그널 전달 지연에 영향을 주지 않도록 한다.
*
* <p>구독 토픽: {@code /sub/signals/user/{userId}}
*
* <ul>
* <li>기존 {@code /sub/charts/{market}/{candleType}} 차트 스트림은 그대로 유지 (하위 호환)</li>
* <li>이 브로커는 BUY/SELL 시그널 발생 시 <b>추가적으로</b> 시그널 전용 토픽에 발행한다</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SignalEventBroker {
private final SimpMessagingTemplate messagingTemplate;
/**
* 시그널을 사용자 전용 토픽으로 발행한다.
*
* @param userId 수신 대상 사용자 ID (null 이면 deviceId 토픽으로 폴백)
* @param deviceId 수신 대상 디바이스 ID (userId 가 null 일 때 사용)
* @param event 발행할 시그널 이벤트
*/
public void publishToUser(Long userId, String deviceId, SignalEventDto event) {
String destination = resolveDestination(userId, deviceId);
if (destination == null) {
log.debug("[SignalEventBroker] 수신자 없음 — 발행 생략 market={} signal={}",
event.getMarket(), event.getSignalType());
return;
}
try {
messagingTemplate.convertAndSend(destination, event);
log.info("[SignalEventBroker] 시그널 전용 토픽 발행 → {} signal={} market={}",
destination, event.getSignalType(), event.getMarket());
} catch (Exception e) {
log.warn("[SignalEventBroker] 발행 실패 {}: {}", destination, e.getMessage());
}
}
private String resolveDestination(Long userId, String deviceId) {
if (userId != null) {
return "/sub/signals/user/" + userId;
}
if (deviceId != null && !deviceId.isBlank()) {
return "/sub/signals/device/" + deviceId;
}
return null;
}
}