diff --git a/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java b/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java index 73a89f8..5befd32 100644 --- a/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java +++ b/backend/src/main/java/com/goldenchart/config/WebSocketConfig.java @@ -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/... — 클라이언트 → 서버 (향후 확장용) diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java index e13124b..e79707f 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionStatusDto.java @@ -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 rows; private long updatedAt; } diff --git a/backend/src/main/java/com/goldenchart/dto/SignalEventDto.java b/backend/src/main/java/com/goldenchart/dto/SignalEventDto.java new file mode 100644 index 0000000..550d498 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/SignalEventDto.java @@ -0,0 +1,49 @@ +package com.goldenchart.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * [항목5] 시그널 전용 STOMP 페이로드. + * + *

차트 캔들 스트림({@code /sub/charts/{market}/{candleType}})과 별도로 + * {@code /sub/signals/user/{userId}} 토픽으로 발행되어 시그널 지연·누락 위험을 줄인다. + * + *

차트 데이터는 틱 단위로 대량 발행되는 반면 시그널은 빈도가 낮지만 실시간성이 + * 미션 크리티컬한 데이터이므로 전용 채널로 분리한다. + */ +@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; +} diff --git a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java index 3351f5e..271be33 100644 --- a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java +++ b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java @@ -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 { @@ -15,6 +16,13 @@ public interface GcTradeSignalRepository extends JpaRepository findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market); List findByUserIdAndMarketOrderByCreatedAtDesc(Long userId, String market); + /** + * [항목3] LONG_ONLY 포지션 상태 복원 — 서버 재시작 후 in-memory 캐시가 비어 있을 때 + * 해당 마켓×전략의 가장 최근 시그널을 조회하여 BUY 여부로 포지션 오픈 상태를 판단한다. + */ + Optional findFirstByMarketAndStrategyIdOrderByCreatedAtDesc( + String market, Long strategyId); + @Modifying @Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId") void deleteByUserId(@Param("userId") Long userId); diff --git a/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java b/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java index b7d7ed8..5f8cfb2 100644 --- a/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java +++ b/backend/src/main/java/com/goldenchart/service/BarCloseStrategyEvaluationService.java @@ -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()); } diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index f4ad9db..462ec09 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -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 로 변환하여 현재 시계열에 대해 평가한다. + * + *

기존 {@code matchRate} 는 단순 CONDITION 리프 충족 비율이지만, + * 이 메서드는 AND/OR/NOT 게이트까지 완전히 반영하여 실제 시그널과 동일한 + * 논리 결과를 반환한다. + * + * @return {@code true} = 현재 조건 충족, {@code false} = 미충족, + * {@code null} = 데이터 부족·평가 불가 + */ + private Boolean evaluateOverallRule(String conditionJson, String market, + Map> params, + Map> 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 out) { if (json == null || json.isBlank()) return; diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 5f9d101..0fb2a70 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -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 positionOpenCache = new ConcurrentHashMap<>(); @@ -89,6 +93,24 @@ public class LiveStrategyEvaluator { */ private final ConcurrentHashMap realtimeSignaledIdx = new ConcurrentHashMap<>(); + /** + * [항목1] REALTIME_TICK buildTriggerRules 오버헤드 경감용 앱-레벨 캐시. + * + *

전략 DSL(GcStrategy)과 지표 파라미터(IndicatorSettings)는 사용자가 편집하지 않는 한 + * 변경되지 않으므로 30초 TTL로 캐시하여 3초 주기 재조회 오버헤드를 줄인다. + */ + private final ConcurrentHashMap strategyCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap indicatorCache = new ConcurrentHashMap<>(); + + private record StrategyCacheEntry(GcStrategy strategy, long cachedAt) {} + private record IndicatorParamsCacheEntry( + Map> params, + Map> 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 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> indicatorParams = - indicatorSettingsService.getAll(userId, deviceId); - Map> indicatorVisual = - indicatorSettingsService.getAllVisual(userId, deviceId); + + IndicatorParamsCacheEntry indicatorEntry = resolveIndicatorParams(userId, deviceId); + Map> indicatorParams = indicatorEntry.params(); + Map> 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 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> params = indicatorSettingsService.getAll(userId, deviceId); + Map> visual = indicatorSettingsService.getAllVisual(userId, deviceId); + IndicatorParamsCacheEntry entry = new IndicatorParamsCacheEntry(params, visual, System.currentTimeMillis()); + indicatorCache.put(key, entry); + return entry; + } + + // ── 항목3: LONG_ONLY 포지션 상태 복원 (서버 재시작 중복 BUY 방지) ────────── + + /** + * 포지션 오픈 여부를 반환한다. + * + *

in-memory 캐시가 비어 있으면(서버 재시작 등) gc_trade_signal 테이블에서 + * 해당 전략×마켓의 가장 최근 시그널을 조회하여 포지션 상태를 복원한다. + * + *

+ */ + private boolean resolvePositionOpen(String cacheKey, String market, long strategyId) { + Boolean cached = positionOpenCache.get(cacheKey); + if (cached != null) return cached; + + // DB 복원: 해당 마켓×전략의 최신 시그널 + Optional 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; + } } diff --git a/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java b/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java index ba95106..d5feb46 100644 --- a/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java +++ b/backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java @@ -34,7 +34,15 @@ import java.util.concurrent.ConcurrentHashMap; @Slf4j public class Ta4jStorage { - /** 인메모리 최대 캔들 보관 수 (기본 — 파이프라인 비활성 시) */ + /** + * 인메모리 최대 캔들 보관 수 (기본 — 파이프라인 비활성 시). + * + *

[항목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; diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index 192c38e..4d01b1f 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -95,63 +95,78 @@ public class BarBuilder { // ── 내부 처리 ────────────────────────────────────────────────────────────── - /** 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파 */ + /** + * 완성된 1분봉을 Ta4jStorage 에 저장하고 상위 타임프레임에 전파한다. + * + *

[항목2] 2-Phase 처리 — 멀티 타임프레임 레이스 컨디션 해소: + *

+ * 기존 방식(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 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); diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java index 1556a9f..f22fa37 100644 --- a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -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 저장 diff --git a/backend/src/main/java/com/goldenchart/websocket/SignalEventBroker.java b/backend/src/main/java/com/goldenchart/websocket/SignalEventBroker.java new file mode 100644 index 0000000..884b44a --- /dev/null +++ b/backend/src/main/java/com/goldenchart/websocket/SignalEventBroker.java @@ -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 브로커. + * + *

시그널을 차트 캔들 스트림과 분리하여 독립된 토픽으로 발행함으로써 + * 고부하 차트 스트림의 병목이 시그널 전달 지연에 영향을 주지 않도록 한다. + * + *

구독 토픽: {@code /sub/signals/user/{userId}} + * + *

    + *
  • 기존 {@code /sub/charts/{market}/{candleType}} 차트 스트림은 그대로 유지 (하위 호환)
  • + *
  • 이 브로커는 BUY/SELL 시그널 발생 시 추가적으로 시그널 전용 토픽에 발행한다
  • + *
+ */ +@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; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 1eaf54f..71d2204 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/frontend/src/hooks/useLiveStrategyMarkers.ts b/frontend/src/hooks/useLiveStrategyMarkers.ts index 7b89e0a..910ceca 100644 --- a/frontend/src/hooks/useLiveStrategyMarkers.ts +++ b/frontend/src/hooks/useLiveStrategyMarkers.ts @@ -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]); diff --git a/전략편집기 로직 및 동작흐름.md b/전략편집기 로직 및 동작흐름.md index a56ffcb..ab799c6 100644 --- a/전략편집기 로직 및 동작흐름.md +++ b/전략편집기 로직 및 동작흐름.md @@ -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 만 사용