전략평가 로직 개선
This commit is contained in:
@@ -13,8 +13,10 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
|
||||
* ws(s)://host/ws/trading (SockJS fallback 지원)
|
||||
*
|
||||
* 구독 채널 (명세서 5.2):
|
||||
* /sub/charts/{market}/{type} — 실시간 캔들 배포
|
||||
* /sub/verification-issues/events — 검증 이슈 등록·단계 변경
|
||||
* /sub/charts/{market}/{type} — 실시간 캔들 배포 (차트 렌더링 + 시그널 필드 포함, 하위 호환)
|
||||
* /sub/signals/user/{userId} — [항목5] 시그널 전용 미션 크리티컬 채널 (UserId 기준)
|
||||
* /sub/signals/device/{deviceId} — [항목5] 시그널 전용 채널 (DeviceId 기준, 비로그인 사용자)
|
||||
* /sub/verification-issues/events — 검증 이슈 등록·단계 변경
|
||||
*
|
||||
* 발행 채널:
|
||||
* /pub/... — 클라이언트 → 서버 (향후 확장용)
|
||||
|
||||
@@ -18,8 +18,27 @@ public class LiveConditionStatusDto {
|
||||
private String market;
|
||||
private Long strategyId;
|
||||
private String timeframe;
|
||||
/** 0~100 (충족 조건 비율) */
|
||||
|
||||
/**
|
||||
* 0~100 — 개별 CONDITION 리프들의 단순 충족 비율.
|
||||
* AND/OR/NOT 논리 게이트를 반영하지 않으므로 참고용으로만 사용한다.
|
||||
*/
|
||||
private int matchRate;
|
||||
|
||||
/**
|
||||
* [항목6] 매수 DSL 전체 논리 트리를 실제 평가한 결과.
|
||||
* {@code true} = 현재 지표 상태에서 매수 조건이 완전히 충족됨.
|
||||
* {@code null} = 평가 불가 (데이터 부족 등).
|
||||
*/
|
||||
private Boolean overallEntryMet;
|
||||
|
||||
/**
|
||||
* [항목6] 매도 DSL 전체 논리 트리를 실제 평가한 결과.
|
||||
* {@code true} = 현재 지표 상태에서 매도 조건이 완전히 충족됨.
|
||||
* {@code null} = 평가 불가 또는 매도 DSL 없음.
|
||||
*/
|
||||
private Boolean overallExitMet;
|
||||
|
||||
private List<LiveConditionRowDto> rows;
|
||||
private long updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* [항목5] 시그널 전용 STOMP 페이로드.
|
||||
*
|
||||
* <p>차트 캔들 스트림({@code /sub/charts/{market}/{candleType}})과 별도로
|
||||
* {@code /sub/signals/user/{userId}} 토픽으로 발행되어 시그널 지연·누락 위험을 줄인다.
|
||||
*
|
||||
* <p>차트 데이터는 틱 단위로 대량 발행되는 반면 시그널은 빈도가 낮지만 실시간성이
|
||||
* 미션 크리티컬한 데이터이므로 전용 채널로 분리한다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SignalEventDto {
|
||||
|
||||
/** 마켓 코드 (e.g. "KRW-BTC") */
|
||||
private String market;
|
||||
|
||||
/** 캔들 타입 (e.g. "1m") */
|
||||
private String candleType;
|
||||
|
||||
/** 전략 ID */
|
||||
private Long strategyId;
|
||||
|
||||
/** 전략 이름 */
|
||||
private String strategyName;
|
||||
|
||||
/** BUY | SELL */
|
||||
private String signalType;
|
||||
|
||||
/** 시그널 발생 시점 종가 */
|
||||
private Double price;
|
||||
|
||||
/** 캔들 시작 Unix 초 */
|
||||
private Long candleTime;
|
||||
|
||||
/** CANDLE_CLOSE | REALTIME_TICK */
|
||||
private String executionType;
|
||||
|
||||
/** 시그널 발생 Unix 밀리초 */
|
||||
private Long timestamp;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GcTradeSignalRepository extends JpaRepository<GcTradeSignal, Long> {
|
||||
|
||||
@@ -15,6 +16,13 @@ public interface GcTradeSignalRepository extends JpaRepository<GcTradeSignal, Lo
|
||||
List<GcTradeSignal> findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market);
|
||||
List<GcTradeSignal> findByUserIdAndMarketOrderByCreatedAtDesc(Long userId, String market);
|
||||
|
||||
/**
|
||||
* [항목3] LONG_ONLY 포지션 상태 복원 — 서버 재시작 후 in-memory 캐시가 비어 있을 때
|
||||
* 해당 마켓×전략의 가장 최근 시그널을 조회하여 BUY 여부로 포지션 오픈 상태를 판단한다.
|
||||
*/
|
||||
Optional<GcTradeSignal> findFirstByMarketAndStrategyIdOrderByCreatedAtDesc(
|
||||
String market, Long strategyId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId")
|
||||
void deleteByUserId(@Param("userId") Long userId);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.CandleBarDto;
|
||||
import com.goldenchart.dto.SignalEventDto;
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||
import com.goldenchart.websocket.SignalEventBroker;
|
||||
import com.goldenchart.websocket.TradingWebSocketBroker;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -33,6 +35,7 @@ public class BarCloseStrategyEvaluationService {
|
||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||
private final TradingWebSocketBroker broker;
|
||||
private final SignalEventBroker signalEventBroker;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
|
||||
/** market:candleType:barEndEpoch — 동일 마감봉 중복 평가 방지 */
|
||||
@@ -134,6 +137,8 @@ public class BarCloseStrategyEvaluationService {
|
||||
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
|
||||
GcLiveStrategySettings setting, String executionType) {
|
||||
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
||||
|
||||
// [기존] 차트 캔들 스트림에 signal 필드 포함하여 발행 (차트 마커 렌더링용, 하위 호환)
|
||||
CandleBarDto dto = CandleBarDto.builder()
|
||||
.time(barStartEpoch)
|
||||
.open(bar.getOpenPrice().doubleValue())
|
||||
@@ -149,6 +154,20 @@ public class BarCloseStrategyEvaluationService {
|
||||
.executionType(executionType)
|
||||
.build();
|
||||
broker.publish(market, candleType, dto);
|
||||
|
||||
// [항목5] 시그널 전용 토픽에 추가 발행 — 차트 스트림 병목과 독립된 미션 크리티컬 채널
|
||||
SignalEventDto signalEvent = SignalEventDto.builder()
|
||||
.market(market)
|
||||
.candleType(candleType)
|
||||
.strategyId(setting.getStrategyId())
|
||||
.signalType(signal)
|
||||
.price(bar.getClosePrice().doubleValue())
|
||||
.candleTime(barStartEpoch)
|
||||
.executionType(executionType)
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
signalEventBroker.publishToUser(setting.getUserId(), setting.getDeviceId(), signalEvent);
|
||||
|
||||
log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={}",
|
||||
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
|
||||
}
|
||||
|
||||
@@ -124,11 +124,19 @@ public class LiveConditionStatusService {
|
||||
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
||||
String tfSummary = String.join(", ", timeframes);
|
||||
|
||||
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
|
||||
Boolean overallEntryMet = evaluateOverallRule(
|
||||
strategy.getBuyConditionJson(), market, params, visual);
|
||||
Boolean overallExitMet = evaluateOverallRule(
|
||||
strategy.getSellConditionJson(), market, params, visual);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
.strategyId(strategyId)
|
||||
.timeframe(tfSummary)
|
||||
.matchRate(matchRate)
|
||||
.overallEntryMet(overallEntryMet)
|
||||
.overallExitMet(overallExitMet)
|
||||
.rows(rows)
|
||||
.updatedAt(System.currentTimeMillis())
|
||||
.build();
|
||||
@@ -145,6 +153,43 @@ public class LiveConditionStatusService {
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── [항목6] 전체 논리 트리 평가 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 전략 DSL 전체를 Ta4j Rule 로 변환하여 현재 시계열에 대해 평가한다.
|
||||
*
|
||||
* <p>기존 {@code matchRate} 는 단순 CONDITION 리프 충족 비율이지만,
|
||||
* 이 메서드는 AND/OR/NOT 게이트까지 완전히 반영하여 실제 시그널과 동일한
|
||||
* 논리 결과를 반환한다.
|
||||
*
|
||||
* @return {@code true} = 현재 조건 충족, {@code false} = 미충족,
|
||||
* {@code null} = 데이터 부족·평가 불가
|
||||
*/
|
||||
private Boolean evaluateOverallRule(String conditionJson, String market,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual) {
|
||||
if (conditionJson == null || conditionJson.isBlank()) return null;
|
||||
try {
|
||||
com.fasterxml.jackson.databind.JsonNode dsl =
|
||||
objectMapper.readTree(conditionJson);
|
||||
if (dsl == null || dsl.isNull()) return null;
|
||||
|
||||
// 기준 시리즈: 1m 봉
|
||||
if (!ta4jStorage.exists(market, "1m")) return null;
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, "1m");
|
||||
if (series.isEmpty()) return null;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
||||
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
|
||||
return rule.isSatisfied(series.getEndIndex(), null);
|
||||
} catch (Exception e) {
|
||||
log.debug("[LiveCondition] overall rule eval fail market={}: {}", market, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void collectConditions(String json, String timeframe, String side,
|
||||
List<PendingCond> out) {
|
||||
if (json == null || json.isBlank()) return;
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.entity.GcTradeSignal;
|
||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import com.goldenchart.repository.GcTradeSignalRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -57,6 +59,7 @@ public class LiveStrategyEvaluator {
|
||||
|
||||
private final GcLiveStrategySettingsRepository settingsRepo;
|
||||
private final GcStrategyRepository strategyRepo;
|
||||
private final GcTradeSignalRepository tradeSignalRepo;
|
||||
private final Ta4jStorage ta4jStorage;
|
||||
private final IndicatorSettingsService indicatorSettingsService;
|
||||
private final StrategySignalDeterminer determiner;
|
||||
@@ -79,6 +82,7 @@ public class LiveStrategyEvaluator {
|
||||
|
||||
/**
|
||||
* LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응.
|
||||
* 서버 재시작 시 gc_trade_signal 최신 레코드로 복원된다 (resolvePositionOpen 참조).
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Boolean> positionOpenCache = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -89,6 +93,24 @@ public class LiveStrategyEvaluator {
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Integer> realtimeSignaledIdx = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* [항목1] REALTIME_TICK buildTriggerRules 오버헤드 경감용 앱-레벨 캐시.
|
||||
*
|
||||
* <p>전략 DSL(GcStrategy)과 지표 파라미터(IndicatorSettings)는 사용자가 편집하지 않는 한
|
||||
* 변경되지 않으므로 30초 TTL로 캐시하여 3초 주기 재조회 오버헤드를 줄인다.
|
||||
*/
|
||||
private final ConcurrentHashMap<Long, StrategyCacheEntry> strategyCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, IndicatorParamsCacheEntry> indicatorCache = new ConcurrentHashMap<>();
|
||||
|
||||
private record StrategyCacheEntry(GcStrategy strategy, long cachedAt) {}
|
||||
private record IndicatorParamsCacheEntry(
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
long cachedAt) {}
|
||||
|
||||
private static final long STRATEGY_CACHE_TTL_MS = 30_000L;
|
||||
private static final long INDICATOR_CACHE_TTL_MS = 30_000L;
|
||||
|
||||
// ── 공개 API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -258,6 +280,14 @@ public class LiveStrategyEvaluator {
|
||||
tradingRecordCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||||
positionOpenCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||||
branchStateCache.invalidateStrategy(strategyId);
|
||||
// 앱 레벨 전략 DSL 캐시 무효화
|
||||
strategyCache.remove(strategyId);
|
||||
}
|
||||
|
||||
/** 지표 설정 변경 시 지표 파라미터 캐시 무효화 */
|
||||
public void invalidateIndicatorParams(Long userId, String deviceId) {
|
||||
String key = (userId != null ? userId : "") + ":" + (deviceId != null ? deviceId : "");
|
||||
indicatorCache.remove(key);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
@@ -308,7 +338,7 @@ public class LiveStrategyEvaluator {
|
||||
|
||||
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
||||
cacheKey, k -> new BaseTradingRecord());
|
||||
boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey));
|
||||
boolean isOpen = resolvePositionOpen(cacheKey, market, strategyId);
|
||||
String signal = determiner.determineSignalFromRules(
|
||||
rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY");
|
||||
|
||||
@@ -337,17 +367,16 @@ public class LiveStrategyEvaluator {
|
||||
|
||||
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
|
||||
String deviceId, Long userId, boolean useConfirmedOnly) {
|
||||
Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
|
||||
if (strategyOpt.isEmpty()) return null;
|
||||
GcStrategy strategy = resolveStrategy(strategyId);
|
||||
if (strategy == null) return null;
|
||||
|
||||
GcStrategy strategy = strategyOpt.get();
|
||||
if (!ta4jStorage.exists(market, candleType)) return null;
|
||||
|
||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||
Map<String, Map<String, Object>> indicatorParams =
|
||||
indicatorSettingsService.getAll(userId, deviceId);
|
||||
Map<String, Map<String, Object>> indicatorVisual =
|
||||
indicatorSettingsService.getAllVisual(userId, deviceId);
|
||||
|
||||
IndicatorParamsCacheEntry indicatorEntry = resolveIndicatorParams(userId, deviceId);
|
||||
Map<String, Map<String, Object>> indicatorParams = indicatorEntry.params();
|
||||
Map<String, Map<String, Object>> indicatorVisual = indicatorEntry.visual();
|
||||
|
||||
int barCount = series.getBarCount();
|
||||
if (barCount < 2) {
|
||||
@@ -369,4 +398,68 @@ public class LiveStrategyEvaluator {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 항목1: 전략 DSL / 지표 파라미터 앱-레벨 캐시 헬퍼 ─────────────────────
|
||||
|
||||
/**
|
||||
* strategyId 로 GcStrategy 를 조회하되 TTL 내에는 캐시를 재사용한다.
|
||||
* 사용자가 전략을 수정하면 invalidateStrategy 로 캐시를 무효화한다.
|
||||
*/
|
||||
private GcStrategy resolveStrategy(long strategyId) {
|
||||
StrategyCacheEntry cached = strategyCache.get(strategyId);
|
||||
if (cached != null && (System.currentTimeMillis() - cached.cachedAt()) < STRATEGY_CACHE_TTL_MS) {
|
||||
return cached.strategy();
|
||||
}
|
||||
Optional<GcStrategy> opt = strategyRepo.findById(strategyId);
|
||||
if (opt.isEmpty()) {
|
||||
strategyCache.remove(strategyId);
|
||||
return null;
|
||||
}
|
||||
strategyCache.put(strategyId, new StrategyCacheEntry(opt.get(), System.currentTimeMillis()));
|
||||
return opt.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* (userId, deviceId) 키로 지표 파라미터 / visual 을 조회하되 TTL 내에는 캐시를 재사용한다.
|
||||
*/
|
||||
private IndicatorParamsCacheEntry resolveIndicatorParams(Long userId, String deviceId) {
|
||||
String key = (userId != null ? userId : "") + ":" + (deviceId != null ? deviceId : "");
|
||||
IndicatorParamsCacheEntry cached = indicatorCache.get(key);
|
||||
if (cached != null && (System.currentTimeMillis() - cached.cachedAt()) < INDICATOR_CACHE_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
Map<String, Map<String, Object>> params = indicatorSettingsService.getAll(userId, deviceId);
|
||||
Map<String, Map<String, Object>> visual = indicatorSettingsService.getAllVisual(userId, deviceId);
|
||||
IndicatorParamsCacheEntry entry = new IndicatorParamsCacheEntry(params, visual, System.currentTimeMillis());
|
||||
indicatorCache.put(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ── 항목3: LONG_ONLY 포지션 상태 복원 (서버 재시작 중복 BUY 방지) ──────────
|
||||
|
||||
/**
|
||||
* 포지션 오픈 여부를 반환한다.
|
||||
*
|
||||
* <p>in-memory 캐시가 비어 있으면(서버 재시작 등) gc_trade_signal 테이블에서
|
||||
* 해당 전략×마켓의 가장 최근 시그널을 조회하여 포지션 상태를 복원한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>최신 시그널 = BUY → 포지션 오픈 (중복 BUY 차단)</li>
|
||||
* <li>최신 시그널 = SELL 또는 없음 → 포지션 없음</li>
|
||||
* </ul>
|
||||
*/
|
||||
private boolean resolvePositionOpen(String cacheKey, String market, long strategyId) {
|
||||
Boolean cached = positionOpenCache.get(cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
// DB 복원: 해당 마켓×전략의 최신 시그널
|
||||
Optional<GcTradeSignal> latest =
|
||||
tradeSignalRepo.findFirstByMarketAndStrategyIdOrderByCreatedAtDesc(market, strategyId);
|
||||
boolean isOpen = latest.map(s -> "BUY".equals(s.getSignalType())).orElse(false);
|
||||
positionOpenCache.put(cacheKey, isOpen);
|
||||
if (isOpen) {
|
||||
log.info("[Evaluator] 포지션 상태 복원 (DB) key={} → OPEN (BUY 포지션 유지)", cacheKey);
|
||||
}
|
||||
return isOpen;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,15 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
@Slf4j
|
||||
public class Ta4jStorage {
|
||||
|
||||
/** 인메모리 최대 캔들 보관 수 (기본 — 파이프라인 비활성 시) */
|
||||
/**
|
||||
* 인메모리 최대 캔들 보관 수 (기본 — 파이프라인 비활성 시).
|
||||
*
|
||||
* <p>[항목4] 지표 계산 미스매치 방지:
|
||||
* 전략에서 사용 가능한 최대 Period(기간)보다 최소 2~3배 이상 여유 있는 크기를 유지해야 한다.
|
||||
* 예를 들어 HMA(200), 신고가(120봉) 같이 큰 period 의 지표는 warm-up 완료 전까지 NaN 을 반환한다.
|
||||
* 기본값 1800봉은 최대 period 600봉 전략까지 3배 여유를 제공한다.
|
||||
* {@code upbit.api.warmup-count} 설정도 최소 max_period × 2 이상으로 함께 조정해야 한다.
|
||||
*/
|
||||
public static final int MAX_BAR_COUNT = 1800;
|
||||
|
||||
private final TradingPipelineProperties pipelineProperties;
|
||||
|
||||
@@ -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