realtimeSignaledIdx = new ConcurrentHashMap<>();
/**
* [항목1] REALTIME_TICK buildTriggerRules 오버헤드 경감용 앱-레벨 캐시.
*
* 전략 DSL(GcStrategy)과 지표 파라미터(IndicatorSettings)는 사용자가 편집하지 않는 한
* 변경되지 않으므로 30초 TTL로 캐시하여 3초 주기 재조회 오버헤드를 줄인다.
*/
private final ConcurrentHashMap strategyCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap indicatorCache = new ConcurrentHashMap<>();
private record StrategyCacheEntry(GcStrategy strategy, long cachedAt) {}
private record IndicatorParamsCacheEntry(
Map> params,
Map> 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 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 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) 평가.
*
* 캐시 완전 우회: 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 설정에 대해 확정봉 인덱스로 전략을 평가한다.
*
*
배경: 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 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> indicatorParams = indicatorEntry.params();
Map> 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 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> params = indicatorSettingsService.getAll(userId, deviceId);
Map> visual = indicatorSettingsService.getAllVisual(userId, deviceId);
IndicatorParamsCacheEntry entry = new IndicatorParamsCacheEntry(params, visual, System.currentTimeMillis());
indicatorCache.put(key, entry);
return entry;
}
// ── 항목3: LONG_ONLY 포지션 상태 복원 (서버 재시작 중복 BUY 방지) ──────────
/**
* 포지션 오픈 여부를 반환한다.
*
* in-memory 캐시가 비어 있으면(서버 재시작 등) gc_trade_signal 테이블에서
* 해당 전략×마켓의 가장 최근 시그널을 조회하여 포지션 상태를 복원한다.
*
*
* - 최신 시그널 = BUY → 포지션 오픈 (중복 BUY 차단)
* - 최신 시그널 = SELL 또는 없음 → 포지션 없음
*
*/
/**
* LONG_ONLY 포지션 오픈 여부 복원 — gc_trade_signal + gc_paper_position 교차 검증.
*
* [항목3 보완] 서버 재시작 시 gc_trade_signal DB 의 최신 레코드만 보면 "마지막 신호=BUY"라서
* 포지션이 열려 있다고 판단할 수 있지만, 서버 다운 중 사용자가 수동으로 물량을 전량 매도했다면
* 실제 가상 잔고(gc_paper_position.quantity)는 0 이다. 이 경우 gc_trade_signal 에 SELL 기록이
* 없어도 포지션이 없다고 바르게 판정해야 한다.
*
*
로직 순서:
*
* - 캐시 히트 → 즉시 반환
* - gc_trade_signal 최신 레코드 조회 → 마지막 신호가 BUY 이면 "열림" 후보
* - "열림" 후보인 경우에만 gc_paper_position.quantity 교차 검증:
* quantity == 0 → 수동 청산된 것으로 판단, isOpen = false 로 보정
*
*/
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 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;
}
}