전략평가 로직 개선
This commit is contained in:
@@ -13,7 +13,9 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
|
|||||||
* ws(s)://host/ws/trading (SockJS fallback 지원)
|
* ws(s)://host/ws/trading (SockJS fallback 지원)
|
||||||
*
|
*
|
||||||
* 구독 채널 (명세서 5.2):
|
* 구독 채널 (명세서 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 — 검증 이슈 등록·단계 변경
|
* /sub/verification-issues/events — 검증 이슈 등록·단계 변경
|
||||||
*
|
*
|
||||||
* 발행 채널:
|
* 발행 채널:
|
||||||
|
|||||||
@@ -18,8 +18,27 @@ public class LiveConditionStatusDto {
|
|||||||
private String market;
|
private String market;
|
||||||
private Long strategyId;
|
private Long strategyId;
|
||||||
private String timeframe;
|
private String timeframe;
|
||||||
/** 0~100 (충족 조건 비율) */
|
|
||||||
|
/**
|
||||||
|
* 0~100 — 개별 CONDITION 리프들의 단순 충족 비율.
|
||||||
|
* AND/OR/NOT 논리 게이트를 반영하지 않으므로 참고용으로만 사용한다.
|
||||||
|
*/
|
||||||
private int matchRate;
|
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 List<LiveConditionRowDto> rows;
|
||||||
private long updatedAt;
|
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 org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface GcTradeSignalRepository extends JpaRepository<GcTradeSignal, Long> {
|
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> findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market);
|
||||||
List<GcTradeSignal> findByUserIdAndMarketOrderByCreatedAtDesc(Long userId, String market);
|
List<GcTradeSignal> findByUserIdAndMarketOrderByCreatedAtDesc(Long userId, String market);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [항목3] LONG_ONLY 포지션 상태 복원 — 서버 재시작 후 in-memory 캐시가 비어 있을 때
|
||||||
|
* 해당 마켓×전략의 가장 최근 시그널을 조회하여 BUY 여부로 포지션 오픈 상태를 판단한다.
|
||||||
|
*/
|
||||||
|
Optional<GcTradeSignal> findFirstByMarketAndStrategyIdOrderByCreatedAtDesc(
|
||||||
|
String market, Long strategyId);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId")
|
@Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId")
|
||||||
void deleteByUserId(@Param("userId") Long userId);
|
void deleteByUserId(@Param("userId") Long userId);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.goldenchart.service;
|
package com.goldenchart.service;
|
||||||
|
|
||||||
import com.goldenchart.dto.CandleBarDto;
|
import com.goldenchart.dto.CandleBarDto;
|
||||||
|
import com.goldenchart.dto.SignalEventDto;
|
||||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||||
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
|
||||||
|
import com.goldenchart.websocket.SignalEventBroker;
|
||||||
import com.goldenchart.websocket.TradingWebSocketBroker;
|
import com.goldenchart.websocket.TradingWebSocketBroker;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -33,6 +35,7 @@ public class BarCloseStrategyEvaluationService {
|
|||||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||||
private final TradingWebSocketBroker broker;
|
private final TradingWebSocketBroker broker;
|
||||||
|
private final SignalEventBroker signalEventBroker;
|
||||||
private final Ta4jStorage ta4jStorage;
|
private final Ta4jStorage ta4jStorage;
|
||||||
|
|
||||||
/** market:candleType:barEndEpoch — 동일 마감봉 중복 평가 방지 */
|
/** market:candleType:barEndEpoch — 동일 마감봉 중복 평가 방지 */
|
||||||
@@ -134,6 +137,8 @@ public class BarCloseStrategyEvaluationService {
|
|||||||
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
|
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
|
||||||
GcLiveStrategySettings setting, String executionType) {
|
GcLiveStrategySettings setting, String executionType) {
|
||||||
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
||||||
|
|
||||||
|
// [기존] 차트 캔들 스트림에 signal 필드 포함하여 발행 (차트 마커 렌더링용, 하위 호환)
|
||||||
CandleBarDto dto = CandleBarDto.builder()
|
CandleBarDto dto = CandleBarDto.builder()
|
||||||
.time(barStartEpoch)
|
.time(barStartEpoch)
|
||||||
.open(bar.getOpenPrice().doubleValue())
|
.open(bar.getOpenPrice().doubleValue())
|
||||||
@@ -149,6 +154,20 @@ public class BarCloseStrategyEvaluationService {
|
|||||||
.executionType(executionType)
|
.executionType(executionType)
|
||||||
.build();
|
.build();
|
||||||
broker.publish(market, candleType, dto);
|
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={}",
|
log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={}",
|
||||||
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
|
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,19 @@ public class LiveConditionStatusService {
|
|||||||
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
||||||
String tfSummary = String.join(", ", timeframes);
|
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()
|
return LiveConditionStatusDto.builder()
|
||||||
.market(market)
|
.market(market)
|
||||||
.strategyId(strategyId)
|
.strategyId(strategyId)
|
||||||
.timeframe(tfSummary)
|
.timeframe(tfSummary)
|
||||||
.matchRate(matchRate)
|
.matchRate(matchRate)
|
||||||
|
.overallEntryMet(overallEntryMet)
|
||||||
|
.overallExitMet(overallExitMet)
|
||||||
.rows(rows)
|
.rows(rows)
|
||||||
.updatedAt(System.currentTimeMillis())
|
.updatedAt(System.currentTimeMillis())
|
||||||
.build();
|
.build();
|
||||||
@@ -145,6 +153,43 @@ public class LiveConditionStatusService {
|
|||||||
.build();
|
.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,
|
private void collectConditions(String json, String timeframe, String side,
|
||||||
List<PendingCond> out) {
|
List<PendingCond> out) {
|
||||||
if (json == null || json.isBlank()) return;
|
if (json == null || json.isBlank()) return;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.goldenchart.service;
|
|||||||
|
|
||||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||||
import com.goldenchart.entity.GcStrategy;
|
import com.goldenchart.entity.GcStrategy;
|
||||||
|
import com.goldenchart.entity.GcTradeSignal;
|
||||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||||
import com.goldenchart.repository.GcStrategyRepository;
|
import com.goldenchart.repository.GcStrategyRepository;
|
||||||
|
import com.goldenchart.repository.GcTradeSignalRepository;
|
||||||
import com.goldenchart.storage.Ta4jStorage;
|
import com.goldenchart.storage.Ta4jStorage;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -57,6 +59,7 @@ public class LiveStrategyEvaluator {
|
|||||||
|
|
||||||
private final GcLiveStrategySettingsRepository settingsRepo;
|
private final GcLiveStrategySettingsRepository settingsRepo;
|
||||||
private final GcStrategyRepository strategyRepo;
|
private final GcStrategyRepository strategyRepo;
|
||||||
|
private final GcTradeSignalRepository tradeSignalRepo;
|
||||||
private final Ta4jStorage ta4jStorage;
|
private final Ta4jStorage ta4jStorage;
|
||||||
private final IndicatorSettingsService indicatorSettingsService;
|
private final IndicatorSettingsService indicatorSettingsService;
|
||||||
private final StrategySignalDeterminer determiner;
|
private final StrategySignalDeterminer determiner;
|
||||||
@@ -79,6 +82,7 @@ public class LiveStrategyEvaluator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응.
|
* LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응.
|
||||||
|
* 서버 재시작 시 gc_trade_signal 최신 레코드로 복원된다 (resolvePositionOpen 참조).
|
||||||
*/
|
*/
|
||||||
private final ConcurrentHashMap<String, Boolean> positionOpenCache = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Boolean> positionOpenCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -89,6 +93,24 @@ public class LiveStrategyEvaluator {
|
|||||||
*/
|
*/
|
||||||
private final ConcurrentHashMap<String, Integer> realtimeSignaledIdx = new ConcurrentHashMap<>();
|
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 ─────────────────────────────────────────────────────────────
|
// ── 공개 API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,6 +280,14 @@ public class LiveStrategyEvaluator {
|
|||||||
tradingRecordCache.keySet().removeIf(k -> k.endsWith(suffix));
|
tradingRecordCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||||||
positionOpenCache.keySet().removeIf(k -> k.endsWith(suffix));
|
positionOpenCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||||||
branchStateCache.invalidateStrategy(strategyId);
|
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 ───────────────────────────────────────────────────────────────
|
// ── Private ───────────────────────────────────────────────────────────────
|
||||||
@@ -308,7 +338,7 @@ public class LiveStrategyEvaluator {
|
|||||||
|
|
||||||
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
||||||
cacheKey, k -> new BaseTradingRecord());
|
cacheKey, k -> new BaseTradingRecord());
|
||||||
boolean isOpen = Boolean.TRUE.equals(positionOpenCache.get(cacheKey));
|
boolean isOpen = resolvePositionOpen(cacheKey, market, strategyId);
|
||||||
String signal = determiner.determineSignalFromRules(
|
String signal = determiner.determineSignalFromRules(
|
||||||
rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY");
|
rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY");
|
||||||
|
|
||||||
@@ -337,17 +367,16 @@ public class LiveStrategyEvaluator {
|
|||||||
|
|
||||||
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
|
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
|
||||||
String deviceId, Long userId, boolean useConfirmedOnly) {
|
String deviceId, Long userId, boolean useConfirmedOnly) {
|
||||||
Optional<GcStrategy> strategyOpt = strategyRepo.findById(strategyId);
|
GcStrategy strategy = resolveStrategy(strategyId);
|
||||||
if (strategyOpt.isEmpty()) return null;
|
if (strategy == null) return null;
|
||||||
|
|
||||||
GcStrategy strategy = strategyOpt.get();
|
|
||||||
if (!ta4jStorage.exists(market, candleType)) return null;
|
if (!ta4jStorage.exists(market, candleType)) return null;
|
||||||
|
|
||||||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||||||
Map<String, Map<String, Object>> indicatorParams =
|
|
||||||
indicatorSettingsService.getAll(userId, deviceId);
|
IndicatorParamsCacheEntry indicatorEntry = resolveIndicatorParams(userId, deviceId);
|
||||||
Map<String, Map<String, Object>> indicatorVisual =
|
Map<String, Map<String, Object>> indicatorParams = indicatorEntry.params();
|
||||||
indicatorSettingsService.getAllVisual(userId, deviceId);
|
Map<String, Map<String, Object>> indicatorVisual = indicatorEntry.visual();
|
||||||
|
|
||||||
int barCount = series.getBarCount();
|
int barCount = series.getBarCount();
|
||||||
if (barCount < 2) {
|
if (barCount < 2) {
|
||||||
@@ -369,4 +398,68 @@ public class LiveStrategyEvaluator {
|
|||||||
return null;
|
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
|
@Slf4j
|
||||||
public class Ta4jStorage {
|
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;
|
public static final int MAX_BAR_COUNT = 1800;
|
||||||
|
|
||||||
private final TradingPipelineProperties pipelineProperties;
|
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) {
|
private void commitBar(String market, PartialBar partial) {
|
||||||
Duration d1m = Duration.ofMinutes(1);
|
Duration d1m = Duration.ofMinutes(1);
|
||||||
ZonedDateTime endTime = partial.barStart.plus(d1m);
|
ZonedDateTime endTime = partial.barStart.plus(d1m);
|
||||||
|
|
||||||
// Ta4j Bar 생성
|
|
||||||
Bar bar = buildTa4jBar(partial, d1m, endTime);
|
Bar bar = buildTa4jBar(partial, d1m, endTime);
|
||||||
|
|
||||||
// 1분봉 Ta4jStorage 저장
|
// ── Phase 1: 모든 타임프레임 스토리지 업데이트 ────────────────────────
|
||||||
|
|
||||||
ta4jStorage.addBar(market, "1m", bar);
|
ta4jStorage.addBar(market, "1m", bar);
|
||||||
log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime);
|
log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime);
|
||||||
|
|
||||||
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
|
// 마감된 상위봉 정보(타입, maturedIndex, bar) — Phase 2 에서 사용
|
||||||
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
|
java.util.List<UpperCloseInfo> closedUppers = new java.util.ArrayList<>();
|
||||||
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 상위 타임프레임 전파
|
|
||||||
for (int i = 0; i < UPPER_MINUTES.length; i++) {
|
for (int i = 0; i < UPPER_MINUTES.length; i++) {
|
||||||
int minutes = UPPER_MINUTES[i];
|
int minutes = UPPER_MINUTES[i];
|
||||||
String type = UPPER_TYPES[i];
|
String type = UPPER_TYPES[i];
|
||||||
// 해당 상위 봉의 시작 시간 계산
|
|
||||||
ZonedDateTime upperStart = alignToInterval(partial.barStart, minutes);
|
ZonedDateTime upperStart = alignToInterval(partial.barStart, minutes);
|
||||||
ZonedDateTime upperEnd = upperStart.plusMinutes(minutes);
|
ZonedDateTime upperEnd = upperStart.plusMinutes(minutes);
|
||||||
|
|
||||||
BarSeries upperSeries = ta4jStorage.getOrCreate(market, type);
|
BarSeries upperSeries = ta4jStorage.getOrCreate(market, type);
|
||||||
if (upperSeries.isEmpty()) {
|
if (upperSeries.isEmpty()) {
|
||||||
// 새 상위봉 열기
|
|
||||||
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
|
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
|
||||||
ta4jStorage.addBar(market, type, upperBar);
|
ta4jStorage.addBar(market, type, upperBar);
|
||||||
} else {
|
} else {
|
||||||
Bar lastUpper = upperSeries.getLastBar();
|
Instant lastUpperEndInstant = upperSeries.getLastBar().getEndTime();
|
||||||
// Ta4j 0.22: getEndTime() → Instant
|
|
||||||
Instant lastUpperEndInstant = lastUpper.getEndTime();
|
|
||||||
Instant upperEndInstant = upperEnd.toInstant();
|
Instant upperEndInstant = upperEnd.toInstant();
|
||||||
if (lastUpperEndInstant.equals(upperEndInstant)) {
|
if (lastUpperEndInstant.equals(upperEndInstant)) {
|
||||||
// 동일 구간 → 가격 갱신
|
|
||||||
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
|
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
|
||||||
} else if (lastUpperEndInstant.isBefore(upperEndInstant)) {
|
} else if (lastUpperEndInstant.isBefore(upperEndInstant)) {
|
||||||
// 다음 구간 → 새 Bar 추가 (이전 상위봉 마감)
|
// 상위봉 마감 → 새 봉 추가
|
||||||
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
|
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
|
||||||
ta4jStorage.addBar(market, type, upperBar);
|
ta4jStorage.addBar(market, type, upperBar);
|
||||||
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
|
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
|
||||||
if (barCloseEvaluation.shouldEvaluateOnClose(market, type)) {
|
int maturedIdx = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
|
||||||
int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
|
closedUppers.add(new UpperCloseInfo(type, maturedIdx, upperAfter.getBar(maturedIdx)));
|
||||||
Bar maturedUpperBar = upperAfter.getBar(maturedUpper);
|
|
||||||
barCloseEvaluation.onMaturedBarClose(market, type, maturedUpper, maturedUpperBar);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행",
|
log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행",
|
||||||
market, type, lastUpperEndInstant, upperEndInstant);
|
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 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
|
/** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
|
||||||
private void publishCurrentBar(String market) {
|
private void publishCurrentBar(String market) {
|
||||||
PartialBar p = partialBars.get(market);
|
PartialBar p = partialBars.get(market);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.goldenchart.websocket;
|
package com.goldenchart.websocket;
|
||||||
|
|
||||||
import com.goldenchart.dto.CandleBarDto;
|
import com.goldenchart.dto.CandleBarDto;
|
||||||
|
import com.goldenchart.dto.SignalEventDto;
|
||||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||||
import com.goldenchart.service.LiveStrategyEvaluator;
|
import com.goldenchart.service.LiveStrategyEvaluator;
|
||||||
@@ -38,6 +39,7 @@ public class LiveStrategyScheduler {
|
|||||||
private final LiveStrategyEvaluator evaluator;
|
private final LiveStrategyEvaluator evaluator;
|
||||||
private final Ta4jStorage ta4jStorage;
|
private final Ta4jStorage ta4jStorage;
|
||||||
private final TradingWebSocketBroker broker;
|
private final TradingWebSocketBroker broker;
|
||||||
|
private final SignalEventBroker signalEventBroker;
|
||||||
private final TradeSignalService tradeSignalService;
|
private final TradeSignalService tradeSignalService;
|
||||||
private final OrderExecutionQueue orderExecutionQueue;
|
private final OrderExecutionQueue orderExecutionQueue;
|
||||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||||
@@ -94,6 +96,20 @@ public class LiveStrategyScheduler {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
broker.publish(market, candleType, dto);
|
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);
|
log.info("[LiveStrategyScheduler] REALTIME_TICK signal={} market={}", signal, market);
|
||||||
|
|
||||||
// DB 저장
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,8 +61,11 @@ upbit:
|
|||||||
base-url: https://api.upbit.com
|
base-url: https://api.upbit.com
|
||||||
candle-limit: 1000
|
candle-limit: 1000
|
||||||
request-delay-ms: 110
|
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봉으로 설정.
|
# EMA(200) 완전 수렴: ~1150봉 필요. 여유분 포함 1500봉으로 설정.
|
||||||
|
# Ta4jStorage.MAX_BAR_COUNT(1800) 이하여야 하며,
|
||||||
# 1500봉 = 200봉×8페이지 / 110ms 지연 = 구독당 약 880ms 추가 소요
|
# 1500봉 = 200봉×8페이지 / 110ms 지연 = 구독당 약 880ms 추가 소요
|
||||||
warmup-count: 1500
|
warmup-count: 1500
|
||||||
websocket:
|
websocket:
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* useLiveStrategyMarkers
|
* 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) 수신 시 마커 누적 (종목별)
|
* - signal 필드(BUY/SELL) 수신 시 마커 누적 (종목별)
|
||||||
* - 동일 종목·타임스탬프·시그널 중복 방지
|
* - 동일 종목·타임스탬프·시그널 중복 방지
|
||||||
* - activeMarket 차트에만 마커 렌더링, 알림 팝업은 모든 모니터링 종목에 대해 발생
|
* - activeMarket 차트에만 마커 렌더링, 알림 팝업은 모든 모니터링 종목에 대해 발생
|
||||||
@@ -9,7 +14,7 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { parseStompJson } from '../utils/stompMessage';
|
import { parseStompJson } from '../utils/stompMessage';
|
||||||
import { subscribeStompTopic } from '../utils/stompChartBroker';
|
import { subscribeStompTopic } from '../utils/stompChartBroker';
|
||||||
import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership';
|
import { signalBelongsToCurrentSession, getCurrentSignalSession } from '../utils/tradeSignalOwnership';
|
||||||
|
|
||||||
export interface LiveMarker {
|
export interface LiveMarker {
|
||||||
time: number;
|
time: number;
|
||||||
@@ -129,7 +134,8 @@ export function useLiveStrategyMarkers({
|
|||||||
? subscriptions
|
? subscriptions
|
||||||
: markets.map(m => ({ market: m, candleType }));
|
: 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}`;
|
const topic = `/sub/charts/${market}/${ct}`;
|
||||||
return subscribeStompTopic(topic, msg => {
|
return subscribeStompTopic(topic, msg => {
|
||||||
const data = parseStompJson<{
|
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 () => {
|
return () => {
|
||||||
unsubs.forEach(u => u());
|
chartUnsubs.forEach(u => u());
|
||||||
|
signalUnsub();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [marketsKey, subsKey, candleType, enabled]);
|
}, [marketsKey, subsKey, candleType, enabled]);
|
||||||
|
|||||||
+2
-2
@@ -629,8 +629,8 @@ flowchart TB
|
|||||||
4. **실행 방식** — `CANDLE_CLOSE` vs `REALTIME_TICK`
|
4. **실행 방식** — `CANDLE_CLOSE` vs `REALTIME_TICK`
|
||||||
5. **데이터** — `Ta4jStorage.exists(market, candleType)`, warm-up(`pinCandleWatch`)
|
5. **데이터** — `Ta4jStorage.exists(market, candleType)`, warm-up(`pinCandleWatch`)
|
||||||
6. **포지션 모드** — `LONG_ONLY`에서 이미 포지션일 때 BUY 무시
|
6. **포지션 모드** — `LONG_ONLY`에서 이미 포지션일 때 BUY 무시
|
||||||
7. **STOMP** — 구독 토픽 `/sub/charts/{market}/{candleType}` 와 평가 분봉 일치
|
7. **STOMP** — 차트 스트림 `/sub/charts/{market}/{candleType}` (하위 호환) + 시그널 전용 `/sub/signals/user/{userId}` 구독 확인
|
||||||
8. **UI 현황 vs 시그널** — `live-conditions` matchRate ≠ 실제 BUY/SELL 발화 조건 (UI는 리프별 Rule, 시그널은 트리 전체)
|
8. **UI 현황 vs 시그널** — `live-conditions` `matchRate` = 리프 단순 충족률, `overallEntryMet`/`overallExitMet` = 트리 전체 평가 결과 (실제 시그널 발화와 동일 로직)
|
||||||
9. **마감봉 평가** — `BarCloseStrategyEvaluationService` + DSL `usesTimeframe` 게이트; 갭은 `CandleGapBackfillService` 후 보정
|
9. **마감봉 평가** — `BarCloseStrategyEvaluationService` + DSL `usesTimeframe` 게이트; 갭은 `CandleGapBackfillService` 후 보정
|
||||||
10. **프론트 평가 금지** — 가상투자 일치율·자동매매는 백엔드 API/STOMP 만 사용
|
10. **프론트 평가 금지** — 가상투자 일치율·자동매매는 백엔드 API/STOMP 만 사용
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user