전략평가 로직 개선
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user