527 lines
26 KiB
Java
527 lines
26 KiB
Java
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.GcPaperAccountRepository;
|
||
import com.goldenchart.repository.GcPaperPositionRepository;
|
||
import com.goldenchart.repository.GcStrategyRepository;
|
||
import com.goldenchart.repository.GcTradeSignalRepository;
|
||
import com.goldenchart.storage.Ta4jStorage;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.ta4j.core.BarSeries;
|
||
import org.ta4j.core.BaseTradingRecord;
|
||
import org.ta4j.core.Rule;
|
||
|
||
import org.springframework.stereotype.Service;
|
||
|
||
import java.math.BigDecimal;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.Optional;
|
||
import java.util.concurrent.ConcurrentHashMap;
|
||
|
||
/**
|
||
* 실시간 전략 체크 평가기.
|
||
*
|
||
* <p>두 가지 방식으로 전략 만족 여부를 판정한다:
|
||
* <ul>
|
||
* <li>방식 A (CANDLE_CLOSE): 봉 마감 직후 BarBuilder 에서 트리거 — Rule 캐시 사용,
|
||
* {@code useConfirmedOnly=true} 로 상위봉 확정봉 인덱스 사용.</li>
|
||
* <li>방식 B (REALTIME_TICK): 3초 스케줄러에서 현재 진행 중인 캔들 인덱스로 판정 —
|
||
* Rule 캐시 <b>우회</b>, 매 평가마다 신규 Rule 인스턴스 빌드.
|
||
* {@code useConfirmedOnly=false} 로 현재 봉 포함 최신 인덱스 사용.</li>
|
||
* </ul>
|
||
*
|
||
* <h3>REALTIME_TICK 캐시 우회 이유 (동시성 보장)</h3>
|
||
* <p>Ta4j {@code CachedIndicator} 는 {@code getValue(index)} 결과를 내부 리스트에 영구 저장한다.
|
||
* {@code Ta4jStorage.updateLastBar} 가 provisional 봉의 close price 를 갱신해도
|
||
* 이미 캐시된 값은 변하지 않으므로 Rule 이 구 가격으로 판정(Ghost Signal)한다.
|
||
* REALTIME_TICK 에서는 Rule 자체를 매번 재생성함으로써 캐시 오염을 원천 차단한다.
|
||
* 이 방식은 기존 "triggerRulesCache.remove + evaluate" 복합 연산의 스레드 안전성 문제도 해소한다.
|
||
*
|
||
* <p>포지션 종속성 모드:
|
||
* <ul>
|
||
* <li><b>LONG_ONLY</b>: TradingRecord 에 열린 포지션이 있어야 매도 시그널 발생.</li>
|
||
* <li><b>SIGNAL_ONLY</b>: 포지션 관계없이 지표 규칙만 충족되면 시그널 확정.</li>
|
||
* </ul>
|
||
*
|
||
* <p>동시성 안전:
|
||
* <ul>
|
||
* <li>ConcurrentHashMap 으로 market×candleType 키에 대한 Strategy 캐시 관리</li>
|
||
* <li>LONG_ONLY 모드용 TradingRecord 캐시도 ConcurrentHashMap 으로 관리</li>
|
||
* <li>Ta4jStorage.getOrCreate/updateLastBar 는 내부 synchronized 처리</li>
|
||
* </ul>
|
||
*/
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
@Slf4j
|
||
public class LiveStrategyEvaluator {
|
||
|
||
private final GcLiveStrategySettingsRepository settingsRepo;
|
||
private final GcStrategyRepository strategyRepo;
|
||
private final GcTradeSignalRepository tradeSignalRepo;
|
||
private final GcPaperAccountRepository paperAccountRepo;
|
||
private final GcPaperPositionRepository paperPositionRepo;
|
||
private final Ta4jStorage ta4jStorage;
|
||
private final IndicatorSettingsService indicatorSettingsService;
|
||
private final StrategySignalDeterminer determiner;
|
||
private final StrategyConditionTimeframeService conditionTimeframes;
|
||
private final StrategyTriggerBranchEvaluator triggerBranchEvaluator;
|
||
private final StrategyBranchStateCache branchStateCache;
|
||
|
||
/**
|
||
* 트리거 분봉별 Rule 캐시: "market:candleType:strategyId" → (entryRule, exitRule)
|
||
*/
|
||
private final ConcurrentHashMap<String, TriggerRules> triggerRulesCache = new ConcurrentHashMap<>();
|
||
|
||
private record TriggerRules(Rule entryRule, Rule exitRule) {}
|
||
|
||
/**
|
||
* LONG_ONLY 모드용 TradingRecord 캐시: "market:candleType:strategyId" → TradingRecord
|
||
* 시그널이 발생할 때마다 Record 를 갱신하여 포지션 상태를 유지한다.
|
||
*/
|
||
private final ConcurrentHashMap<String, BaseTradingRecord> tradingRecordCache = new ConcurrentHashMap<>();
|
||
|
||
/**
|
||
* LONG_ONLY 모드용 포지션 오픈 여부 추적: Ta4j API 버전 차이 대응.
|
||
* 서버 재시작 시 gc_trade_signal 최신 레코드로 복원된다 (resolvePositionOpen 참조).
|
||
*/
|
||
private final ConcurrentHashMap<String, Boolean> positionOpenCache = new ConcurrentHashMap<>();
|
||
|
||
/**
|
||
* REALTIME_TICK 중복 시그널 방지: "market:candleType" → 마지막으로 시그널을 낸 seriesIndex.
|
||
* 동일 provisional 봉에서 3초 스케줄러가 반복 실행되더라도 이미 시그널을 낸 인덱스면 무시.
|
||
* 새 provisional 봉(다음 분)이 시작되면 자동으로 다른 index 이므로 갱신된다.
|
||
*/
|
||
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 ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 방식 A — 봉 마감 직후 호출.
|
||
*
|
||
* @param market 마켓 코드
|
||
* @param candleType 캔들 타입
|
||
* @param maturedIndex 확정된 봉의 BarSeries 인덱스
|
||
* @return "BUY" | "SELL" | "NONE"
|
||
* @deprecated 마켓 단위 API — 멀티 전략 환경에서 첫 번째 non-NONE 만 반환하므로
|
||
* 시그널 발행 경로에서 사용 금지.
|
||
* 대신 {@link BarCloseStrategyEvaluationService#onMaturedBarClose} 를 사용할 것.
|
||
*/
|
||
@Deprecated
|
||
public String evaluateCandleClose(String market, String candleType, int maturedIndex) {
|
||
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
|
||
if (settings.isEmpty()) return "NONE";
|
||
|
||
if (!ta4jStorage.exists(market, candleType)) return "NONE";
|
||
log.debug("[Evaluator] CANDLE_CLOSE 평가: {} {} idx={} barCount={}",
|
||
market, candleType, maturedIndex,
|
||
ta4jStorage.getOrCreate(market, candleType).getBarCount());
|
||
|
||
for (GcLiveStrategySettings s : settings) {
|
||
String result = evaluateSettingOnCandleClose(s, market, candleType, maturedIndex);
|
||
if (!"NONE".equals(result)) return result;
|
||
}
|
||
return "NONE";
|
||
}
|
||
|
||
/**
|
||
* 단일 live 설정 — 봉 마감(CANDLE_CLOSE) 평가.
|
||
* useConfirmedOnly=true: CrossSeriesRule 이 상위봉의 확정봉(endIndex-1)을 사용.
|
||
*/
|
||
public String evaluateSettingOnCandleClose(GcLiveStrategySettings s, String market,
|
||
String candleType, int maturedIndex) {
|
||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE";
|
||
if (!"CANDLE_CLOSE".equals(s.getExecutionType())) return "NONE";
|
||
if (s.getStrategyId() == null) return "NONE";
|
||
if (!ta4jStorage.exists(market, candleType)) return "NONE";
|
||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE";
|
||
|
||
String result = evaluate(market, candleType, s.getStrategyId(),
|
||
s.getPositionMode(), maturedIndex,
|
||
s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */);
|
||
if (!"NONE".equals(result)) {
|
||
log.info("[Evaluator] CANDLE_CLOSE strategyId={} {} {} idx={} mode={} → {}",
|
||
s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 방식 B — 3초 스케줄러에서 호출.
|
||
*
|
||
* @param market 마켓 코드
|
||
* @param candleType 캔들 타입
|
||
* @return "BUY" | "SELL" | "NONE"
|
||
* @deprecated 마켓 단위 API — 멀티 전략 환경에서 첫 번째 non-NONE 만 반환.
|
||
* 시그널 발행 경로에서 사용 금지.
|
||
* 대신 {@link #evaluateSettingRealtimeTick} 를 설정별로 직접 호출할 것.
|
||
*/
|
||
@Deprecated
|
||
public String evaluateRealtimeTick(String market, String candleType) {
|
||
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
|
||
if (settings.isEmpty()) return "NONE";
|
||
|
||
for (GcLiveStrategySettings s : settings) {
|
||
String result = evaluateSettingRealtimeTick(s, market, candleType);
|
||
if (!"NONE".equals(result)) return result;
|
||
}
|
||
return "NONE";
|
||
}
|
||
|
||
/**
|
||
* 단일 live 설정 — 3초 스케줄러(REALTIME_TICK) 평가.
|
||
*
|
||
* <p><b>캐시 완전 우회</b>: REALTIME_TICK 은 provisional 봉의 close price 가 틱마다 바뀌므로
|
||
* Rule 을 매번 신규 빌드(useConfirmedOnly=false)하여 Ta4j CachedIndicator 캐시 오염을
|
||
* 원천 차단한다. 동시에 {@code triggerRulesCache} 복합 연산의 스레드 안전성 문제도 제거한다.
|
||
*/
|
||
public String evaluateSettingRealtimeTick(GcLiveStrategySettings s, String market,
|
||
String candleType) {
|
||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE";
|
||
if (!"REALTIME_TICK".equals(s.getExecutionType())) return "NONE";
|
||
if (s.getStrategyId() == null) return "NONE";
|
||
if (!ta4jStorage.exists(market, candleType)) return "NONE";
|
||
|
||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||
if (series.isEmpty()) return "NONE";
|
||
int currentIndex = series.getEndIndex();
|
||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE";
|
||
|
||
String dedupeKey = market + ":" + candleType + ":" + s.getStrategyId();
|
||
Integer lastSignaledIdx = realtimeSignaledIdx.get(dedupeKey);
|
||
if (lastSignaledIdx != null && lastSignaledIdx == currentIndex) return "NONE";
|
||
|
||
// 캐시 완전 우회: 매번 신규 Rule 빌드 (CachedIndicator stale 방지 + 동시성 보장)
|
||
String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
|
||
s.getPositionMode(), currentIndex,
|
||
s.getDeviceId(), s.getUserId(), false /* useConfirmedOnly */);
|
||
if (!"NONE".equals(result)) {
|
||
log.info("[Evaluator] REALTIME_TICK strategyId={} {} {} idx={} mode={} → {}",
|
||
s.getStrategyId(), market, candleType, currentIndex, s.getPositionMode(), result);
|
||
realtimeSignaledIdx.put(dedupeKey, currentIndex);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 봉 마감 직후 REALTIME_TICK 설정에 대해 확정봉 인덱스로 전략을 평가한다.
|
||
*
|
||
* <p>배경: CrossedDown/Up 교차 이벤트가 봉 마감 시점에 발생하면, 3초 스케줄러는
|
||
* 이미 다음 분의 provisional 봉(index N+1)을 평가하기 때문에 교차를 감지하지 못한다.
|
||
* 이 메서드를 {@code BarBuilder.commitBar} 에서 호출하면 CANDLE_CLOSE 와 동일한
|
||
* 타이밍에 확정봉 인덱스(N)로 평가하여 누락을 방지한다.
|
||
* useConfirmedOnly=true 로 상위봉도 확정봉 기준 평가.
|
||
*/
|
||
public String evaluateRealtimeAtClose(String market, String candleType, int maturedIndex) {
|
||
List<GcLiveStrategySettings> settings = settingsRepo.findActiveByMarket(market);
|
||
if (settings.isEmpty()) return "NONE";
|
||
|
||
for (GcLiveStrategySettings s : settings) {
|
||
String result = evaluateSettingRealtimeAtClose(s, market, candleType, maturedIndex);
|
||
if (!"NONE".equals(result)) return result;
|
||
}
|
||
return "NONE";
|
||
}
|
||
|
||
/**
|
||
* 단일 live 설정 — 봉 마감 시점 REALTIME_TICK 평가(교차 누락 방지).
|
||
* 캐시 우회 + useConfirmedOnly=true.
|
||
*/
|
||
public String evaluateSettingRealtimeAtClose(GcLiveStrategySettings s, String market,
|
||
String candleType, int maturedIndex) {
|
||
if (!Boolean.TRUE.equals(s.getIsLiveCheck())) return "NONE";
|
||
if (!"REALTIME_TICK".equals(s.getExecutionType())) return "NONE";
|
||
if (s.getStrategyId() == null) return "NONE";
|
||
if (!ta4jStorage.exists(market, candleType)) return "NONE";
|
||
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) return "NONE";
|
||
|
||
// 확정봉 기준 평가: 캐시 우회 + useConfirmedOnly=true
|
||
String result = evaluateWithFreshRules(market, candleType, s.getStrategyId(),
|
||
s.getPositionMode(), maturedIndex,
|
||
s.getDeviceId(), s.getUserId(), true /* useConfirmedOnly */);
|
||
if (!"NONE".equals(result)) {
|
||
log.info("[Evaluator] REALTIME_TICK @candle_close strategyId={} {} {} idx={} mode={} → {}",
|
||
s.getStrategyId(), market, candleType, maturedIndex, s.getPositionMode(), result);
|
||
realtimeSignaledIdx.put(market + ":" + candleType + ":" + s.getStrategyId(), maturedIndex);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** 설정 변경 시 해당 마켓 캐시 무효화 */
|
||
public void invalidateCache(String market) {
|
||
triggerRulesCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||
tradingRecordCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||
positionOpenCache.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||
realtimeSignaledIdx.keySet().removeIf(k -> k.startsWith(market + ":"));
|
||
branchStateCache.invalidateMarket(market);
|
||
}
|
||
|
||
/** 전략 DSL 변경 시 Rule·분기 상태 캐시 무효화 */
|
||
public void invalidateStrategy(long strategyId) {
|
||
String suffix = ":" + strategyId;
|
||
triggerRulesCache.keySet().removeIf(k -> k.endsWith(suffix));
|
||
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 ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* CANDLE_CLOSE 전용 캐시 사용 경로.
|
||
* Rule 을 캐시하여 재사용하며, useConfirmedOnly=true 로 빌드한 Rule 을 저장한다.
|
||
*/
|
||
private String evaluate(String market, String candleType, long strategyId,
|
||
String positionMode, int index,
|
||
String deviceId, Long userId, boolean useConfirmedOnly) {
|
||
String cacheKey = market + ":" + candleType + ":" + strategyId;
|
||
|
||
TriggerRules rules = triggerRulesCache.get(cacheKey);
|
||
if (rules == null) {
|
||
rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId, useConfirmedOnly);
|
||
if (rules == null) return "NONE";
|
||
triggerRulesCache.put(cacheKey, rules);
|
||
}
|
||
|
||
return applySignalLogic(rules, market, candleType, strategyId, positionMode, index, deviceId, userId);
|
||
}
|
||
|
||
/**
|
||
* REALTIME_TICK 전용 캐시 우회 경로.
|
||
* 매번 신규 Rule 인스턴스를 빌드하여 CachedIndicator stale 캐시를 완전히 차단한다.
|
||
* 빌드한 Rule 은 캐시에 저장하지 않는다.
|
||
*/
|
||
private String evaluateWithFreshRules(String market, String candleType, long strategyId,
|
||
String positionMode, int index,
|
||
String deviceId, Long userId, boolean useConfirmedOnly) {
|
||
TriggerRules rules = buildTriggerRules(market, candleType, strategyId, deviceId, userId, useConfirmedOnly);
|
||
if (rules == null) return "NONE";
|
||
return applySignalLogic(rules, market, candleType, strategyId, positionMode, index, deviceId, userId);
|
||
}
|
||
|
||
/** Rule 판정 + TradingRecord 갱신 공통 로직 */
|
||
private String applySignalLogic(TriggerRules rules, String market, String candleType,
|
||
long strategyId, String positionMode, int index,
|
||
String deviceId, Long userId) {
|
||
String cacheKey = market + ":" + candleType + ":" + strategyId;
|
||
try {
|
||
String mode = positionMode != null ? positionMode : "LONG_ONLY";
|
||
|
||
if ("SIGNAL_ONLY".equals(mode)) {
|
||
return determiner.determineSignalFromRules(
|
||
rules.entryRule(), rules.exitRule(), null, index, "SIGNAL_ONLY");
|
||
}
|
||
|
||
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
|
||
cacheKey, k -> new BaseTradingRecord());
|
||
boolean isOpen = resolvePositionOpen(cacheKey, market, strategyId, deviceId, userId);
|
||
String signal = determiner.determineSignalFromRules(
|
||
rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY");
|
||
|
||
if ("BUY".equals(signal) && !isOpen) {
|
||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||
org.ta4j.core.num.Num price = series.getBar(index).getClosePrice();
|
||
record.enter(index, price, series.numFactory().numOf(1));
|
||
positionOpenCache.put(cacheKey, true);
|
||
} else if ("SELL".equals(signal) && isOpen) {
|
||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||
org.ta4j.core.num.Num price = series.getBar(index).getClosePrice();
|
||
record.exit(index, price, series.numFactory().numOf(1));
|
||
positionOpenCache.put(cacheKey, false);
|
||
} else if ("BUY".equals(signal) || "SELL".equals(signal)) {
|
||
signal = "NONE";
|
||
}
|
||
return signal;
|
||
|
||
} catch (Exception e) {
|
||
log.warn("[Evaluator] 전략 판정 오류 key={} idx={}: {}", cacheKey, index, e.getMessage());
|
||
triggerRulesCache.remove(cacheKey);
|
||
tradingRecordCache.remove(cacheKey);
|
||
}
|
||
return "NONE";
|
||
}
|
||
|
||
private TriggerRules buildTriggerRules(String market, String candleType, long strategyId,
|
||
String deviceId, Long userId, boolean useConfirmedOnly) {
|
||
GcStrategy strategy = resolveStrategy(strategyId);
|
||
if (strategy == null) return null;
|
||
|
||
if (!ta4jStorage.exists(market, candleType)) return null;
|
||
|
||
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
|
||
|
||
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) {
|
||
log.warn("[Evaluator] 바 수 부족 strategyId={} market={} barCount={} (최소 2 이상 필요)",
|
||
strategyId, market, barCount);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
Rule entryRule = triggerBranchEvaluator.buildTriggerRule(
|
||
strategy.getBuyConditionJson(), market, strategyId, "buy",
|
||
candleType, indicatorParams, indicatorVisual, ta4jStorage, useConfirmedOnly);
|
||
Rule exitRule = triggerBranchEvaluator.buildTriggerRule(
|
||
strategy.getSellConditionJson(), market, strategyId, "sell",
|
||
candleType, indicatorParams, indicatorVisual, ta4jStorage, useConfirmedOnly);
|
||
return new TriggerRules(entryRule, exitRule);
|
||
} catch (Exception e) {
|
||
log.error("[Evaluator] Trigger Rule 빌드 실패 strategyId={}: {}", strategyId, e.getMessage());
|
||
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>
|
||
*/
|
||
/**
|
||
* LONG_ONLY 포지션 오픈 여부 복원 — gc_trade_signal + gc_paper_position 교차 검증.
|
||
*
|
||
* <p>[항목3 보완] 서버 재시작 시 gc_trade_signal DB 의 최신 레코드만 보면 "마지막 신호=BUY"라서
|
||
* 포지션이 열려 있다고 판단할 수 있지만, 서버 다운 중 사용자가 수동으로 물량을 전량 매도했다면
|
||
* 실제 가상 잔고(gc_paper_position.quantity)는 0 이다. 이 경우 gc_trade_signal 에 SELL 기록이
|
||
* 없어도 포지션이 없다고 바르게 판정해야 한다.
|
||
*
|
||
* <p>로직 순서:
|
||
* <ol>
|
||
* <li>캐시 히트 → 즉시 반환</li>
|
||
* <li>gc_trade_signal 최신 레코드 조회 → 마지막 신호가 BUY 이면 "열림" 후보</li>
|
||
* <li>"열림" 후보인 경우에만 gc_paper_position.quantity 교차 검증:
|
||
* quantity == 0 → 수동 청산된 것으로 판단, isOpen = false 로 보정</li>
|
||
* </ol>
|
||
*/
|
||
private boolean resolvePositionOpen(String cacheKey, String market, long strategyId,
|
||
String deviceId, Long userId) {
|
||
Boolean cached = positionOpenCache.get(cacheKey);
|
||
if (cached != null) return cached;
|
||
|
||
// Step 1: gc_trade_signal 최신 레코드로 1차 판정
|
||
Optional<GcTradeSignal> latest =
|
||
tradeSignalRepo.findFirstByMarketAndStrategyIdOrderByCreatedAtDesc(market, strategyId);
|
||
boolean isOpen = latest.map(s -> "BUY".equals(s.getSignalType())).orElse(false);
|
||
|
||
// Step 2: "열림" 후보인 경우만 gc_paper_position 교차 검증
|
||
if (isOpen) {
|
||
try {
|
||
Long accountId = resolveAccountId(userId, deviceId);
|
||
if (accountId != null) {
|
||
// 심볼 형식: gc_paper_position 은 market 값 그대로 저장 (e.g. "KRW-BTC")
|
||
boolean hasPosition = paperPositionRepo
|
||
.findByAccountIdAndSymbol(accountId, market)
|
||
.map(p -> p.getQuantity() != null
|
||
&& p.getQuantity().compareTo(BigDecimal.ZERO) > 0)
|
||
.orElse(false);
|
||
if (!hasPosition) {
|
||
isOpen = false;
|
||
log.info("[Evaluator] 포지션 교차검증: gc_trade_signal=BUY 지만 " +
|
||
"gc_paper_position.quantity=0 → 수동청산 감지, key={} → CLOSED", cacheKey);
|
||
}
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("[Evaluator] 포지션 교차검증 실패 key={}: {} — signal-only 판정 유지", cacheKey, e.getMessage());
|
||
}
|
||
}
|
||
|
||
positionOpenCache.put(cacheKey, isOpen);
|
||
if (isOpen) {
|
||
log.info("[Evaluator] 포지션 상태 복원 (DB+position 교차검증) key={} → OPEN", cacheKey);
|
||
}
|
||
return isOpen;
|
||
}
|
||
|
||
/** userId 또는 deviceId 로 가상계좌 ID 조회 */
|
||
private Long resolveAccountId(Long userId, String deviceId) {
|
||
if (userId != null) {
|
||
return paperAccountRepo.findByUserId(userId)
|
||
.map(a -> a.getId())
|
||
.orElse(null);
|
||
}
|
||
if (deviceId != null && !deviceId.isBlank()) {
|
||
return paperAccountRepo.findByDeviceId(deviceId)
|
||
.map(a -> a.getId())
|
||
.orElse(null);
|
||
}
|
||
return null;
|
||
}
|
||
}
|