Files
goldenChart/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java
T
2026-06-05 12:22:29 +09:00

527 lines
26 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}