전략평가 로직 개선

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
@@ -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;
}
}