전략평가 로직 개선
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user