전략평가 로직 개선

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