전략평가 로직 개선

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
@@ -13,7 +13,9 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
* ws(s)://host/ws/trading (SockJS fallback 지원)
*
* 구독 채널 (명세서 5.2):
* /sub/charts/{market}/{type} — 실시간 캔들 배포
* /sub/charts/{market}/{type} — 실시간 캔들 배포 (차트 렌더링 + 시그널 필드 포함, 하위 호환)
* /sub/signals/user/{userId} — [항목5] 시그널 전용 미션 크리티컬 채널 (UserId 기준)
* /sub/signals/device/{deviceId} — [항목5] 시그널 전용 채널 (DeviceId 기준, 비로그인 사용자)
* /sub/verification-issues/events — 검증 이슈 등록·단계 변경
*
* 발행 채널:
@@ -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,54 +95,53 @@ 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);
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
}
// 마감된 상위봉 정보(타입, 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 {
Bar lastUpper = upperSeries.getLastBar();
// Ta4j 0.22: getEndTime() → Instant
Instant lastUpperEndInstant = lastUpper.getEndTime();
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 추가 (이전 상위봉 마감)
// 상위봉 마감 → 새 봉 추가
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);
}
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);
@@ -150,8 +149,24 @@ public class BarBuilder {
}
}
}
// ── Phase 2: 모든 봉이 확정된 후 전략 평가 트리거 ─────────────────────
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
}
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;
}
}
+4 -1
View File
@@ -61,8 +61,11 @@ upbit:
base-url: https://api.upbit.com
candle-limit: 1000
request-delay-ms: 110
# EMA 수렴을 위한 warm-up 목표치 (페이지네이션으로 달성)
# [항목4] warm-up 목표 봉 수 — 지표 계산 미스매치 방지
# 전략에서 사용하는 최대 Period(기간)의 최소 2~3배 이상으로 설정해야 한다.
# 예) 신고가(120봉), HMA(200), EMA(200) 사용 시 → 200×3 = 600봉 이상 필요
# EMA(200) 완전 수렴: ~1150봉 필요. 여유분 포함 1500봉으로 설정.
# Ta4jStorage.MAX_BAR_COUNT(1800) 이하여야 하며,
# 1500봉 = 200봉×8페이지 / 110ms 지연 = 구독당 약 880ms 추가 소요
warmup-count: 1500
websocket:
+47 -4
View File
@@ -1,7 +1,12 @@
/**
* useLiveStrategyMarkers
*
* STOMP WebSocket 으로 /sub/charts/{market}/{candleType} 구독:
* STOMP WebSocket 으로 두 가지 채널을 구독한다:
*
* 1. /sub/charts/{market}/{candleType} — 차트 캔들 스트림 내 signal 필드 (하위 호환)
* 2. /sub/signals/user/{userId} — [항목5] 시그널 전용 미션 크리티컬 채널
* (로그인 시 userId, 비로그인 시 /sub/signals/device/{deviceId})
*
* - signal 필드(BUY/SELL) 수신 시 마커 누적 (종목별)
* - 동일 종목·타임스탬프·시그널 중복 방지
* - activeMarket 차트에만 마커 렌더링, 알림 팝업은 모든 모니터링 종목에 대해 발생
@@ -9,7 +14,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { parseStompJson } from '../utils/stompMessage';
import { subscribeStompTopic } from '../utils/stompChartBroker';
import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership';
import { signalBelongsToCurrentSession, getCurrentSignalSession } from '../utils/tradeSignalOwnership';
export interface LiveMarker {
time: number;
@@ -129,7 +134,8 @@ export function useLiveStrategyMarkers({
? subscriptions
: markets.map(m => ({ market: m, candleType }));
const unsubs = subs.map(({ market, candleType: ct }) => {
// 차트 캔들 스트림 구독 (하위 호환 — 차트 마커용)
const chartUnsubs = subs.map(({ market, candleType: ct }) => {
const topic = `/sub/charts/${market}/${ct}`;
return subscribeStompTopic(topic, msg => {
const data = parseStompJson<{
@@ -156,8 +162,45 @@ export function useLiveStrategyMarkers({
});
});
// [항목5] 시그널 전용 토픽 구독 — 차트 스트림 병목과 독립된 미션 크리티컬 채널
const { userId: sessionUserId, deviceId: sessionDeviceId } = getCurrentSignalSession();
const signalTopic = sessionUserId != null
? `/sub/signals/user/${sessionUserId}`
: sessionDeviceId
? `/sub/signals/device/${sessionDeviceId}`
: null;
const signalUnsub = signalTopic
? subscribeStompTopic(signalTopic, msg => {
const data = parseStompJson<{
market?: string;
candleType?: string;
strategyId?: number | null;
signalType?: string;
price?: number;
candleTime?: number;
executionType?: string | null;
timestamp?: number;
}>(msg);
if (!data?.signalType || data.signalType === 'NONE') return;
if (!data.market) return;
// 전용 토픽은 이미 사용자 전용 채널이므로 소유자 검증 불필요
const ct = data.candleType ?? candleType;
pushMarker(data.market, ct, {
time: data.candleTime ?? Math.floor((data.timestamp ?? Date.now()) / 1000),
close: data.price ?? 0,
signal: data.signalType,
strategyId: data.strategyId,
userId: sessionUserId,
deviceId: sessionDeviceId ?? undefined,
executionType: data.executionType,
});
})
: () => {};
return () => {
unsubs.forEach(u => u());
chartUnsubs.forEach(u => u());
signalUnsub();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marketsKey, subsKey, candleType, enabled]);
+2 -2
View File
@@ -629,8 +629,8 @@ flowchart TB
4. **실행 방식**`CANDLE_CLOSE` vs `REALTIME_TICK`
5. **데이터**`Ta4jStorage.exists(market, candleType)`, warm-up(`pinCandleWatch`)
6. **포지션 모드**`LONG_ONLY`에서 이미 포지션일 때 BUY 무시
7. **STOMP**구독 토픽 `/sub/charts/{market}/{candleType}` 와 평가 분봉 일치
8. **UI 현황 vs 시그널**`live-conditions` matchRate ≠ 실제 BUY/SELL 발화 조건 (UI는 리프별 Rule, 시그널은 트리 전체)
7. **STOMP**차트 스트림 `/sub/charts/{market}/{candleType}` (하위 호환) + 시그널 전용 `/sub/signals/user/{userId}` 구독 확인
8. **UI 현황 vs 시그널**`live-conditions` `matchRate` = 리프 단순 충족률, `overallEntryMet`/`overallExitMet` = 트리 전체 평가 결과 (실제 시그널 발화와 동일 로직)
9. **마감봉 평가**`BarCloseStrategyEvaluationService` + DSL `usesTimeframe` 게이트; 갭은 `CandleGapBackfillService` 후 보정
10. **프론트 평가 금지** — 가상투자 일치율·자동매매는 백엔드 API/STOMP 만 사용