diff --git a/backend/src/main/java/com/goldenchart/config/TrendSearchSchedulerConfig.java b/backend/src/main/java/com/goldenchart/config/TrendSearchSchedulerConfig.java
index a620609..afc30fe 100644
--- a/backend/src/main/java/com/goldenchart/config/TrendSearchSchedulerConfig.java
+++ b/backend/src/main/java/com/goldenchart/config/TrendSearchSchedulerConfig.java
@@ -26,4 +26,30 @@ public class TrendSearchSchedulerConfig {
ex.initialize();
return ex;
}
+
+ /**
+ * [항목4] BarBuilder Phase 2 전략 평가 전용 스레드 풀.
+ *
+ *
업비트 WebSocket 수신 스레드가 분봉 마감 시 전략 평가 연산에 블로킹되지 않도록
+ * Phase 2(전략 평가 트리거)를 별도 스레드로 분리한다.
+ *
+ *
소규모(20~30인) 환경 기준:
+ *
+ * - corePoolSize=2: 여러 마켓의 분봉이 동시 마감되어도 2개 스레드로 병렬 처리
+ * - maxPoolSize=4: 순간 피크(예: 매시 00분 1m/5m/15m/1h 동시 마감) 대응
+ * - queueCapacity=32: 밀린 평가 작업을 드롭 없이 큐잉
+ *
+ */
+ @Bean(name = "barCloseEvalExecutor")
+ public Executor barCloseEvalExecutor() {
+ ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
+ ex.setThreadNamePrefix("bar-close-eval-");
+ ex.setCorePoolSize(2);
+ ex.setMaxPoolSize(4);
+ ex.setQueueCapacity(32);
+ ex.setWaitForTasksToCompleteOnShutdown(true);
+ ex.setAwaitTerminationSeconds(60);
+ ex.initialize();
+ return ex;
+ }
}
diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java
index 0fb2a70..6c948ca 100644
--- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java
+++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java
@@ -4,6 +4,8 @@ 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;
@@ -15,6 +17,7 @@ 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;
@@ -60,6 +63,8 @@ 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;
@@ -308,7 +313,7 @@ public class LiveStrategyEvaluator {
triggerRulesCache.put(cacheKey, rules);
}
- return applySignalLogic(rules, market, candleType, strategyId, positionMode, index);
+ return applySignalLogic(rules, market, candleType, strategyId, positionMode, index, deviceId, userId);
}
/**
@@ -321,12 +326,13 @@ public class LiveStrategyEvaluator {
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);
+ 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) {
+ long strategyId, String positionMode, int index,
+ String deviceId, Long userId) {
String cacheKey = market + ":" + candleType + ":" + strategyId;
try {
String mode = positionMode != null ? positionMode : "LONG_ONLY";
@@ -338,7 +344,7 @@ public class LiveStrategyEvaluator {
BaseTradingRecord record = tradingRecordCache.computeIfAbsent(
cacheKey, k -> new BaseTradingRecord());
- boolean isOpen = resolvePositionOpen(cacheKey, market, strategyId);
+ boolean isOpen = resolvePositionOpen(cacheKey, market, strategyId, deviceId, userId);
String signal = determiner.determineSignalFromRules(
rules.entryRule(), rules.exitRule(), record, index, "LONG_ONLY");
@@ -448,18 +454,73 @@ public class LiveStrategyEvaluator {
* 최신 시그널 = SELL 또는 없음 → 포지션 없음
*
*/
- private boolean resolvePositionOpen(String cacheKey, String market, long strategyId) {
+ /**
+ * 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;
- // DB 복원: 해당 마켓×전략의 최신 시그널
+ // 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) key={} → OPEN (BUY 포지션 유지)", cacheKey);
+ 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;
+ }
}
diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java
index 11ba8bf..4ec067d 100644
--- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java
+++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java
@@ -294,8 +294,26 @@ public class StrategyDslToTa4jAdapter {
java.time.Instant primaryEndTime = primarySeries.getBar(index).getEndTime();
evalIndex = findLastIndexAtOrBefore(branchSeries, primaryEndTime);
} else if (useConfirmedOnly) {
- // CANDLE_CLOSE: 마지막 확정봉(provisional 제외)
- evalIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1);
+ // CANDLE_CLOSE — 타임스탬프 기반 확정봉 판정 (백테스트 룩업과 동일 로직)
+ //
+ // [버그 수정] 단순 getEndIndex()-1 을 사용하면 경계 시점(예: 5m=10:00, 1h=10:00 동시 마감)에
+ // 1h 봉이 이미 완전히 확정됐음에도 getEndIndex()-1 이 한 봉 이전(08:00-09:00)을 가리켜
+ // 백테스트와 1봉 차이가 발생했다. primaryEndTime 과 branchSeries 마지막 봉의 endTime 을
+ // 비교해 진짜 provisional 인지 이미 확정된 봉인지 구분한다.
+ //
+ // branchSeries.lastBar.endTime <= primary.endTime
+ // → 상위봉이 기본봉보다 이른 시점에 이미 마감된 경우 (=확정봉) → getEndIndex() 사용
+ // branchSeries.lastBar.endTime > primary.endTime
+ // → 상위봉 기간이 아직 열려 있는 경우 (=provisional) → getEndIndex()-1 사용
+ java.time.Instant primaryEndTime = primarySeries.getBar(index).getEndTime();
+ java.time.Instant branchLastEndTime = branchSeries.getLastBar().getEndTime();
+ if (!branchLastEndTime.isAfter(primaryEndTime)) {
+ // 상위봉이 기본봉 기준으로 이미 확정된 봉 → getEndIndex() 직접 사용
+ evalIndex = branchSeries.getEndIndex();
+ } else {
+ // 상위봉이 아직 형성 중(provisional) → 직전 확정봉 사용
+ evalIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1);
+ }
} else {
// REALTIME_TICK: 현재 진행 중인 봉 포함
evalIndex = branchSeries.getEndIndex();
@@ -567,7 +585,8 @@ public class StrategyDslToTa4jAdapter {
try {
Indicator left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx);
Indicator right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod, cond, ctx);
- return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries, ctx.isBacktest());
+ return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries,
+ ctx.isBacktest(), ctx.useConfirmedOnly());
} catch (Exception e) {
log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}",
indType, condType, e.getMessage());
@@ -575,12 +594,39 @@ public class StrategyDslToTa4jAdapter {
}
}
- private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex, boolean isBacktest) {
+ /**
+ * [버그 수정] 라이브 CANDLE_CLOSE 시 상위봉 인덱스 결정 — CrossSeriesRule 과 동일 타임스탬프 비교 로직 적용.
+ *
+ * 기존 isBacktest=false 시 단순 {@code branch.getEndIndex()} 를 반환했는데,
+ * 이는 경계 시점(예: 5m=10:00, 1h=10:00 동시 마감)에 올바른 1h 봉을 가리키지만
+ * useConfirmedOnly=true 일 때 provisional 여부를 판단하지 않아 불일치 가능성이 있었다.
+ *
+ *
개선: isBacktest 여부와 무관하게 타임스탬프 비교로 통일한다.
+ *
+ * - backtest 또는 branch.lastEndTime ≤ trigger.endTime → 타임스탬프 기반 정확한 인덱스
+ * - 라이브 REALTIME_TICK (useConfirmedOnly=false) 이고 branch 가 아직 진행 중 → getEndIndex()
+ *
+ */
+ private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex,
+ boolean isBacktest, boolean useConfirmedOnly) {
if (trigger == branch) return triggerIndex;
+ java.time.Instant triggerEndTime = trigger.getBar(triggerIndex).getEndTime();
+
if (isBacktest) {
- java.time.Instant t = trigger.getBar(triggerIndex).getEndTime();
- return CrossSeriesRule.findLastIndexAtOrBefore(branch, t);
+ return CrossSeriesRule.findLastIndexAtOrBefore(branch, triggerEndTime);
}
+
+ if (useConfirmedOnly) {
+ // CANDLE_CLOSE — CrossSeriesRule 과 동일한 타임스탬프 비교 로직
+ java.time.Instant branchLastEndTime = branch.getLastBar().getEndTime();
+ if (!branchLastEndTime.isAfter(triggerEndTime)) {
+ return branch.getEndIndex();
+ } else {
+ return Math.max(branch.getBeginIndex(), branch.getEndIndex() - 1);
+ }
+ }
+
+ // REALTIME_TICK: 현재 진행 중인 봉 포함
int end = branch.getEndIndex();
return end >= 0 ? end : 0;
}
@@ -593,6 +639,7 @@ public class StrategyDslToTa4jAdapter {
private final BarSeries leftSeries;
private final BarSeries rightSeries;
private final boolean isBacktest;
+ private final boolean useConfirmedOnly;
CrossTimeframeCompositeRule(String condType,
Indicator left,
@@ -600,7 +647,8 @@ public class StrategyDslToTa4jAdapter {
BarSeries triggerSeries,
BarSeries leftSeries,
BarSeries rightSeries,
- boolean isBacktest) {
+ boolean isBacktest,
+ boolean useConfirmedOnly) {
this.condType = condType;
this.left = left;
this.right = right;
@@ -608,12 +656,13 @@ public class StrategyDslToTa4jAdapter {
this.leftSeries = leftSeries;
this.rightSeries = rightSeries;
this.isBacktest = isBacktest;
+ this.useConfirmedOnly = useConfirmedOnly;
}
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
- int li = evalIndex(triggerSeries, leftSeries, index, isBacktest);
- int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest);
+ int li = evalIndex(triggerSeries, leftSeries, index, isBacktest, useConfirmedOnly);
+ int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest, useConfirmedOnly);
if (li < 1 || ri < 1) return false;
Num l0 = left.getValue(li - 1);
diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java
index 4d01b1f..32aa2a4 100644
--- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java
+++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java
@@ -5,6 +5,7 @@ import com.goldenchart.service.BarCloseStrategyEvaluationService;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
@@ -18,6 +19,7 @@ import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
/**
* 업비트 WebSocket 틱(Trade) 데이터를 수신하여 분봉 단위 캔들을 조립하는 빌더.
@@ -39,6 +41,14 @@ public class BarBuilder {
private final BarCloseStrategyEvaluationService barCloseEvaluation;
private final com.goldenchart.service.PaperOrderMatcherService paperOrderMatcher;
+ /**
+ * [항목4] Phase 2 비동기 실행용 스레드 풀.
+ * 웹소켓 수신 스레드가 전략 평가 연산에 블로킹되지 않도록 분리한다.
+ */
+ @org.springframework.beans.factory.annotation.Autowired
+ @Qualifier("barCloseEvalExecutor")
+ private Executor barCloseEvalExecutor;
+
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1w", "1d"};
@@ -150,18 +160,34 @@ public class BarBuilder {
}
}
- // ── Phase 2: 모든 봉이 확정된 후 전략 평가 트리거 ─────────────────────
+ // ── Phase 2: 모든 봉이 확정된 후 전략 평가 트리거 (비동기) ──────────────
+ //
+ // [항목4] 웹소켓 수신 스레드(업비트 연결 유지 담당)가 전략 평가 연산 때문에 블로킹되면
+ // 다음 분봉 시작 틱을 제때 수신하지 못할 수 있다. Phase 1 완료 후 Phase 2 를
+ // 전용 스레드 풀(barCloseEvalExecutor)에 제출하여 WebSocket 파이프라인을 즉시 반환한다.
+ //
+ // 스레드 안전성: Ta4jStorage·positionOpenCache 는 ConcurrentHashMap, maturedIdx 는
+ // Phase 1 에서 이미 확정된 값이므로 비동기 실행 중 Phase 1 이 재실행되어 인덱스가 바뀌어도
+ // 올바른 봉을 참조한다.
- if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
- BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
- barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
- }
-
- for (UpperCloseInfo info : closedUppers) {
- if (barCloseEvaluation.shouldEvaluateOnClose(market, info.type())) {
- barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar());
+ final Bar confirmedBar = bar;
+ final java.util.List closedUppersSnap
+ = java.util.Collections.unmodifiableList(closedUppers);
+ barCloseEvalExecutor.execute(() -> {
+ try {
+ if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
+ BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
+ barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), confirmedBar);
+ }
+ for (UpperCloseInfo info : closedUppersSnap) {
+ if (barCloseEvaluation.shouldEvaluateOnClose(market, info.type())) {
+ barCloseEvaluation.onMaturedBarClose(market, info.type(), info.maturedIdx(), info.bar());
+ }
+ }
+ } catch (Exception e) {
+ log.error("[BarBuilder] Phase 2 전략 평가 오류 market={}: {}", market, e.getMessage(), e);
}
- }
+ });
}
/** Phase 1 에서 마감된 상위봉 정보를 Phase 2 로 전달하는 내부 레코드 */
diff --git a/전략편집기 로직 및 동작흐름.md b/전략편집기 로직 및 동작흐름.md
index ab799c6..3b5bffe 100644
--- a/전략편집기 로직 및 동작흐름.md
+++ b/전략편집기 로직 및 동작흐름.md
@@ -31,9 +31,11 @@ goldenChart **전략편집기(Strategy Editor)** 의 화면 구성, 편집·저
### 1.2 봉 마감별 전략 체크 (스킵 방지)
-1. 업비트 틱 → `BarBuilder.commitBar` — 1m 확정 시 전략 DSL에 포함된 상위봉(3m·5m·10m·15m …)까지 전파
-2. 각 **상위봉 마감** 시 `BarCloseStrategyEvaluationService.onMaturedBarClose` — `StrategyConditionTimeframeService.usesTimeframe` 이 true 인 분봉만 평가
-3. WS 갭 → `CandleGapBackfillService` REST 병합 후 `evaluateRecentMaturedBarsAfterBackfill` 로 병합 구간 마감봉 보정
+1. 업비트 틱 → `BarBuilder.commitBar` — **2-Phase 처리** (§9.3 참고)
+ - Phase 1: 1m 확정 후 상위봉(3m·5m·10m·15m …) 전부 스토리지에 저장
+ - Phase 2: 모든 봉 저장 완료 후 전략 평가 일괄 트리거 (멀티TF 레이스 컨디션 해소)
+2. 각 **상위봉 마감** 시 `BarCloseStrategyEvaluationService.onMaturedBarClose` — `StrategyConditionTimeframeService.usesTimeframe` 이 true 인 분봉만 평가
+3. WS 갭 → `CandleGapBackfillService` REST 병합 후 `evaluateRecentMaturedBarsAfterBackfill` 로 병합 구간 마감봉 보정
4. `REALTIME_TICK` 실행 방식은 추가로 `LiveStrategyScheduler`(3초)가 **동일 DSL 분봉**만 순회
예: 비트코인 전략 START에 `3m,5m,10m,15m` 이면 각 분봉 **마감봉마다** 백엔드 평가가 1회씩 실행되어야 하며, 평가 분봉은 DSL에 없는 `1h` 등에서는 실행되지 않음.
@@ -260,6 +262,7 @@ API: `POST /api/strategies` — `saveStrategy()` (`backendApi.ts`)
- `NEW_HIGH` / `NEW_LOW` + `NH_PRIOR_N` / `NL_PRIOR_N`
- 의미: **직전 N봉** 최고가/최저가 대비 종가 돌파/이탈 (당일 봉 제외)
- 백엔드: `PreviousValueIndicator(HighestValueIndicator(high, N), 1)` 등
+- **주의:** 라이브 환경에서 N이 클수록 warm-up 봉 수 이상의 데이터가 필요함 (§8.3 참고)
### 5.4 돈치안·일목 등
@@ -343,24 +346,53 @@ Import/Export: `strategyImportExport.ts` — DSL + `flowLayout` + `editorMode` J
**지표 파라미터:** DB `indicator_settings` + `DSL_TO_REGISTRY` 매핑
-### 7.2 멀티 타임프레임 (라이브)
+### 7.2 `RuleBuildContext` 구조
+
+```java
+public record RuleBuildContext(
+ BarSeries primarySeries,
+ Map> indicatorParams,
+ Map> indicatorVisual,
+ String market,
+ Ta4jStorage storage,
+ /**
+ * CANDLE_CLOSE 평가 시 true — CrossSeriesRule 이 상위봉의 마지막 확정봉
+ * (endIndex-1)을 사용하도록 강제. provisional 봉 오염을 방지.
+ */
+ boolean useConfirmedOnly,
+ /**
+ * 백테스트용 사전 집계 시리즈 맵 candleType→BarSeries.
+ * null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
+ * CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
+ */
+ Map seriesOverrides
+)
+```
+
+- `withPrimary(BarSeries)` — primarySeries만 교체해 하위 컨텍스트 생성
+- `isBacktest()` — `seriesOverrides`가 비어 있지 않으면 `true`
+- `resolveSeries(candleType)` — seriesOverrides → Ta4jStorage 순 검색
+
+### 7.3 멀티 타임프레임 (라이브)
`RuleBuildContext`에 `market`, `Ta4jStorage`가 있으면:
- `TIMEFRAME.candleType`에 맞는 시리즈를 storage에서 로드
- 트리거 봉(예: 1m 마감) 인덱스에서 **다른 분봉 시리즈 최신 봉**과 `CrossSeriesRule`로 평가
+- `useConfirmedOnly=true` — 상위봉은 `getEndIndex()-1` (확정봉 기준, provisional 봉 오염 방지)
+- `useConfirmedOnly=false` — 상위봉은 `getEndIndex()` (진행 중 봉 포함, REALTIME_TICK 용)
-백테스트는 단일 요청 `BarSeries`만 사용 → 교차 TF는 primary series fallback.
+### 7.4 멀티 타임프레임 (백테스트)
-### 7.3 Strategy 조립 (`LiveStrategyEvaluator.buildStrategy`)
+`BacktestingService.run` 진입 시 DSL에서 상위봉 타입을 자동 추출(`collectTimeframesFromDsl`)하여 기본 시리즈를 집계(`aggregateSeries`)합니다.
```java
-Rule entryRule = buildRule(buyConditionJson, series, params, market, storage);
-Rule exitRule = buildRule(sellConditionJson, series, params, market, storage);
-BaseStrategy strategy = new BaseStrategy(entryRule, exitRule);
+Map seriesOverrides = buildSeriesOverrides(series, primaryTf, buyDsl, sellDsl);
+Rule entryRule = adapter.toRule(buyDsl, series, params, seriesOverrides);
```
-캐시 키: `market:candleType:strategyId`
+- `CrossSeriesRule`에서 `isBacktest=true` 시 **타임스탬프 기반 인덱스 룩업**으로 정확한 과거 봉 참조
+- 라이브와 동일한 DSL·어댑터를 사용해 백테스트 결과의 신뢰성 확보
---
@@ -379,7 +411,7 @@ flowchart LR
```
- 동일 분: `updateLastBar` (진행 중 봉 갱신)
-- 분 변경: `commitBar` → 확정봉 + 상위봉(3m~1d) 집계
+- 분 변경: `commitBar` → **Phase 1** 확정봉 + 상위봉(3m~1d) 전부 저장 → **Phase 2** 전략 평가 일괄 트리거
### 8.2 어떤 분봉을 평가할지
@@ -390,6 +422,17 @@ flowchart LR
`LiveStrategyTimeframeService`는 UI/설정용 `gc_live_strategy_settings.candle_type` resolve (가상투자 카드 시간봉 드롭다운 등).
+### 8.3 BarSeries 윈도우 크기 (warm-up)
+
+| 설정 | 기본값 | 설명 |
+|------|--------|------|
+| `Ta4jStorage.MAX_BAR_COUNT` | 1800봉 | 인메모리 최대 보관 수 |
+| `upbit.api.warmup-count` | 1500봉 | 구독 시 초기 로드 목표치 |
+
+**원칙:** 전략에서 사용하는 **최대 period × 2~3배** 이상으로 warmup-count를 설정해야 지표 NaN 반환을 방지할 수 있다.
+예) HMA(200), 신고가(120봉) 사용 시 → warmup-count ≥ 600봉.
+현재 기본값(1500봉)은 EMA(200) 완전 수렴(~1150봉)을 포함하여 충분한 여유를 제공한다.
+
---
## 9. 시그널 감지 프로세스
@@ -413,13 +456,31 @@ flowchart LR
| **LONG_ONLY** | `strategy.shouldEnter(index, record)` | `strategy.shouldExit(index, record)` |
| **SIGNAL_ONLY** | `entryRule.isSatisfied(index)` | `exitRule.isSatisfied(index)` |
-`LONG_ONLY`는 evaluator가 **가상 TradingRecord**를 캐시해 중복 진입 방지.
+`LONG_ONLY`는 evaluator가 **가상 TradingRecord + positionOpenCache**를 캐시해 중복 진입 방지.
+
+**서버 재시작 시 포지션 상태 자동 복원:**
+`positionOpenCache` 캐시 미스 발생 시 `gc_trade_signal` 테이블의 최신 시그널을 DB에서 조회하여 포지션 상태(BUY = 열림, SELL/없음 = 닫힘)를 자동 복원한다. 서버 재시작 후 중복 BUY 발화를 방지한다.
결과: `"BUY"` | `"SELL"` | `"NONE"`
### 9.3 방식 A — 봉 마감 직후 (`CANDLE_CLOSE`)
-**트리거:** `BarBuilder.commitBar()` → `evaluateStrategyOnBarClose()`
+**트리거:** `BarBuilder.commitBar()` (Phase 2) → `BarCloseStrategyEvaluationService.onMaturedBarClose()`
+
+**2-Phase 처리 흐름:**
+
+```
+Phase 1 (스토리지 확정)
+ ├─ ta4jStorage.addBar("1m", confirmedBar)
+ ├─ ta4jStorage.updateLastBar("3m", ...) 또는 addBar (마감 시)
+ ├─ ta4jStorage.updateLastBar("5m", ...) 또는 addBar (마감 시)
+ └─ ... 모든 상위봉 처리 완료
+
+Phase 2 (전략 평가 — 모든 봉이 확정된 후)
+ ├─ onMaturedBarClose("1m", ...) ← 1m CrossSeriesRule이 1h 참조해도 1h가 이미 확정됨
+ ├─ onMaturedBarClose("5m", ...) ← 5m이 마감된 경우
+ └─ onMaturedBarClose("1h", ...) ← 1h가 마감된 경우
+```
**조건 (각 설정 행):**
@@ -428,13 +489,14 @@ flowchart LR
3. `usesTimeframe(strategyId, candleType)`
4. `Ta4jStorage`에 해당 분봉 데이터 존재
-**평가:** `LiveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex)`
+**평가:** `LiveStrategyEvaluator.evaluateSettingOnCandleClose(setting, market, candleType, maturedIndex)`
-- **확정된 직전 봉 인덱스**에서 1회 판정
+- **확정된 봉 인덱스**에서 1회 판정
+- `useConfirmedOnly=true` — 상위봉 CrossSeriesRule도 확정봉(`getEndIndex()-1`) 기준
### 9.4 방식 B — 실시간 틱 (`REALTIME_TICK`)
-**트리거:** `LiveStrategyScheduler` — **3초 주기** (`@Scheduled`)
+**트리거:** `LiveStrategyScheduler` — **3초 주기** (`@Scheduled(fixedDelay = 3000)`)
**조건:** `executionType == REALTIME_TICK` 등 동일
@@ -443,9 +505,10 @@ flowchart LR
- `currentIndex = series.getEndIndex()` — **진행 중(provisional) 봉**
- **중복 방지:** 동일 `(market, candleType:strategyId)`에서 같은 index에 이미 시그널 냈으면 skip
- **Rule 캐시 완전 우회:** 매 평가마다 신규 Rule 인스턴스 빌드 (`evaluateWithFreshRules`)
- - Ta4j `CachedIndicator` stale 캐시 원천 차단
+ - Ta4j `CachedIndicator` stale 캐시 원천 차단 (provisional 봉 가격 변경 반영)
- `triggerRulesCache` 복합 연산(remove→put) 스레드 안전성 문제 제거
- **멀티 TF:** `useConfirmedOnly=false` — 비트리거 상위봉 현재 진행 중인 봉 기준 평가
+- **오버헤드 경감:** 앱-레벨 캐시(`strategyCache`, `indicatorCache`, 30초 TTL)로 매 평가마다 DB 조회하지 않음
### 9.5 보완 — 마감 직후 REALTIME 재평가
@@ -464,7 +527,7 @@ flowchart TD
C{usesTimeframe?}
D1[CANDLE_CLOSE: evaluate - 캐시 사용 - useConfirmedOnly=true]
D2[REALTIME_TICK: evaluateWithFreshRules - 캐시 우회 - useConfirmedOnly=false]
- E[buildTriggerRules - DSL to Ta4j with CrossSeriesRule]
+ E[buildTriggerRules - 앱캐시DSL - DSL to Ta4j with CrossSeriesRule]
F[StrategySignalDeterminer.determineSignal]
G{결과}
H[설정별 독립 publishStrategySignal]
@@ -490,23 +553,51 @@ flowchart TD
## 10. 시그널 발행·후처리
-### 10.1 STOMP WebSocket
+### 10.1 STOMP WebSocket — 이중 채널 구조
+
+시그널은 두 개의 독립된 STOMP 채널로 발행된다.
+
+| 채널 | 토픽 | 용도 |
+|------|------|------|
+| **차트 캔들 스트림** | `/sub/charts/{market}/{candleType}` | 차트 마커 렌더링 (하위 호환) |
+| **시그널 전용 채널** | `/sub/signals/user/{userId}` | 미션 크리티컬 BUY/SELL 알림 |
+| | `/sub/signals/device/{deviceId}` | 비로그인 사용자 전용 |
-**토픽:** `/sub/charts/{market}/{candleType}`
**엔드포인트:** `ws://host/ws/trading` (SockJS)
-**페이로드 (`CandleBarDto`):** OHLCV + 지표값 + **`signal`**: `"BUY"` | `"SELL"` | null
+**차트 스트림 페이로드 (`CandleBarDto`):**
```json
{
"t": 1779229260,
"o": 92080000.0,
"c": 92050000.0,
- "signal": "BUY"
+ "signal": "BUY",
+ "strategyId": 5,
+ "userId": 123
}
```
-별도 “시그널 전용” 토픽은 없음 — **차트 캔들 스트림에 시그널 필드**로 포함.
+**시그널 전용 페이로드 (`SignalEventDto`):**
+
+```json
+{
+ "market": "KRW-BTC",
+ "candleType": "1m",
+ "strategyId": 5,
+ "signalType": "BUY",
+ "price": 92050000.0,
+ "candleTime": 1779229260,
+ "executionType": "CANDLE_CLOSE",
+ "timestamp": 1779229263000
+}
+```
+
+**채널 분리 이유:**
+차트 스트림은 틱 단위로 대용량 메시지가 발생하는 고부하 채널이다. 브로커 병목 발생 시 매매 타이밍에 치명적인 시그널이 지연될 수 있으므로, 빈도는 낮지만 실시간성이 보장되어야 하는 시그널을 별도 채널로 분리한다.
+
+**프론트엔드 구독 (`useLiveStrategyMarkers`):**
+두 채널을 병행 구독하며 중복 시그널은 `(market:candleType:time:signal)` 키로 dedup한다.
### 10.2 DB·알림·주문
@@ -514,27 +605,45 @@ flowchart TD
|------|--------|
| DB 저장 | `TradeSignalService.save` → `GcTradeSignal` |
| FCM | `FcmPushService` |
-| 주문 큐 | `OrderExecutionQueue.submitSignal` (deviceId 있을 때) |
+| 주문 큐 | `OrderExecutionQueue.submitSignal` |
### 10.3 프론트 차트 반영
- STOMP 구독 → 캔들 수신 시 `signal` 필드로 **마커(setMarkers)** 표시
-- `LiveSignalNotifier`, 차트 슬롯 등
+- 시그널 전용 토픽 수신 시 알림 팝업 표시 (차트 스트림 병목과 독립)
---
## 11. 조건 현황 API (시그널과 별개)
-**용도:** 가상투자 카드, 모니터링 UI — “지금 각 조건이 얼마나 충족됐는지”
+**용도:** 가상투자 카드, 모니터링 UI — "지금 각 조건이 얼마나 충족됐는지"
| 항목 | 내용 |
|------|------|
| API | `GET /api/strategy/live-conditions?market=&strategyId=` |
| 구현 | `LiveConditionStatusService` |
| 방식 | buy/sell JSON의 **각 CONDITION 리프**를 개별 `Rule`로 평가 |
-| matchRate | 충족 리프 수 / 평가 가능 리프 수 (전체 AND/OR 트리 충족과 다름) |
-실제 BUY/SELL 발화는 §9 경로만 사용.
+**응답 필드:**
+
+| 필드 | 타입 | 설명 |
+|------|------|------|
+| `matchRate` | int (0~100) | 충족 리프 수 / 평가 가능 리프 수 — 참고용, AND/OR 논리 미반영 |
+| `overallEntryMet` | Boolean | 매수 DSL **전체 논리 트리** 평가 결과 (null=평가 불가) |
+| `overallExitMet` | Boolean | 매도 DSL **전체 논리 트리** 평가 결과 (null=평가 불가) |
+| `rows` | List | 개별 조건 리프 충족 여부 상세 |
+
+**`matchRate` vs `overallEntryMet` 차이:**
+
+```
+전략: A AND (B OR C)
+현재: A=false, B=true, C=true
+
+matchRate = 2/3 = 66% (B, C 충족)
+overallEntryMet = false (A가 false 이므로 AND 전체가 false → 실제 BUY 미발화)
+```
+
+실제 BUY/SELL 발화는 §9 경로만 사용하며, `overallEntryMet`이 실제 발화 여부와 일치한다.
---
@@ -543,15 +652,15 @@ flowchart TD
| 항목 | 백테스트 (`BacktestingService`) | 실시간 (`LiveStrategyEvaluator`) |
|------|--------------------------------|----------------------------------|
| BarSeries | 요청 `bars`로 일회성 생성 | `Ta4jStorage` 실시간 누적 |
-| 멀티 TF | **DSL에서 상위봉 타입 추출 → 집계 시리즈 생성** (→ seriesOverrides) | `Ta4jStorage` 다중 시리즈 |
-| adapter | `toRule(dsl, series, params, seriesOverrides)` (백테스트 멀티 TF) | `toRule(..., market, storage)` |
-| CrossSeriesRule | **타임스탬프 기반 룩업** (isBacktest=true) | getEndIndex() 또는 endIndex-1 |
+| 멀티 TF | **DSL에서 상위봉 타입 추출 → `aggregateSeries` 집계** (`seriesOverrides`) | `Ta4jStorage` 다중 시리즈 |
+| adapter | `toRule(dsl, series, params, seriesOverrides)` | `toRule(..., market, storage)` |
+| CrossSeriesRule | **타임스탬프 기반 룩업** (`isBacktest=true`) | `getEndIndex()-1` (확정봉) 또는 `getEndIndex()` (provisional) |
| 스캔 | **전 구간** 0..barCount-1 | **단일 index** (마감 or provisional) |
| 청산 | DSL sell + **손절/익절/트레일링** OR 합성 | DSL sell (+ 리스크 모니터 별도) |
| 출력 | `BacktestResponse.signals`, `gc_backtest_result` | STOMP + `gc_trade_signal` |
-| 멀티 TF | storage 없으면 단일 시리즈 fallback | `TIMEFRAME` / 교차 TF 지원 |
+| `useConfirmedOnly` | 항상 `false` (과거 고정 데이터) | CANDLE_CLOSE: `true`, REALTIME_TICK: `false` |
-백테스트도 **동일 DSL·동일 어댑터**를 사용하므로, 편집기에서 저장한 전략이 백테스트와 라이브에서 같은 의미로 해석됩니다 (데이터·타이밍 차이는 있음).
+백테스트도 **동일 DSL·동일 어댑터**를 사용하고 멀티 TF 시리즈를 실시간과 동일하게 집계하므로, 편집기에서 저장한 전략의 백테스트 결과와 라이브 결과가 일관되게 유지된다.
---
@@ -571,18 +680,43 @@ flowchart TB
LSS --> EVAL[LiveStrategyEvaluator]
VT --> LC[live-conditions API]
- EVAL --> STOMP[STOMP signal]
+ EVAL --> STOMP_CHART[/sub/charts/{market}/{candleType}]
+ EVAL --> STOMP_SIG[/sub/signals/user/{userId}]
EVAL --> TSIG[gc_trade_signal]
```
1. **전략편집기** — DSL 작성·저장
2. **설정 / 가상투자** — 종목별 `strategyId`, `executionType`, `positionMode`, 평가 분봉
-3. **백테스트** — 과거 봉으로 전 구간 시뮬레이션
-4. **라이브** — Upbit 틱 → Ta4j → 시그널 → STOMP·DB·(자동매매)
+3. **백테스트** — 과거 봉으로 전 구간 시뮬레이션 (멀티TF 포함)
+4. **라이브** — Upbit 틱 → Ta4j → 시그널 → STOMP 이중 채널·DB·(자동매매)
---
-## 14. 주요 소스 파일
+## 14. 주요 개선 이력
+
+### [Round 1] 기본 안정성 개선
+
+| 항목 | 개선 내용 |
+|------|-----------|
+| **REALTIME_TICK 캐시 우회** | `evaluateWithFreshRules` 도입 — CachedIndicator stale 캐시 원천 차단 |
+| **멀티TF 확정봉 제어** | `useConfirmedOnly` 플래그 — CANDLE_CLOSE 시 상위봉 확정봉 인덱스 강제 |
+| **백테스트 멀티TF 지원** | `aggregateSeries` + `seriesOverrides` — 라이브와 동일 방식으로 상위봉 평가 |
+| **마켓 단위 API Deprecated** | `evaluateCandleClose(market)`, `evaluateRealtimeTick(market)` → `@Deprecated` |
+
+### [Round 2] 고급 신뢰성·확장성 개선
+
+| 항목 | 개선 내용 |
+|------|-----------|
+| **[항목1] REALTIME_TICK 오버헤드 경감** | 전략 DSL · 지표 파라미터 앱-레벨 캐시(30s TTL) 도입 → DB 조회 최소화 |
+| **[항목2] 멀티TF 레이스 컨디션 해소** | `BarBuilder.commitBar` 2-Phase 처리 — Phase 1: 전 타임프레임 스토리지 확정, Phase 2: 전략 평가 |
+| **[항목3] LONG_ONLY 포지션 DB 복원** | 서버 재시작 후 `gc_trade_signal` 최신 레코드로 포지션 상태 자동 복원 (중복 BUY 방지) |
+| **[항목4] 지표 윈도우 크기 명세화** | `warmup-count: 1500`, `MAX_BAR_COUNT: 1800` — 최대 period×3배 여유 기준 명시 |
+| **[항목5] 시그널 전용 STOMP 채널 분리** | `SignalEventBroker` 신설 — `/sub/signals/user/{userId}` 전용 채널로 병목 독립 |
+| **[항목6] live-conditions 논리 트리 평가** | `overallEntryMet`/`overallExitMet` 필드 추가 — AND/OR/NOT 완전 반영한 전체 트리 결과 |
+
+---
+
+## 15. 주요 소스 파일
### 프론트엔드
@@ -600,6 +734,8 @@ flowchart TB
| `frontend/src/utils/strategyStartNodes.ts` | 분봉·START 상수 |
| `frontend/src/utils/strategyPresets.ts` | 템플릿 |
| `frontend/src/utils/backendApi.ts` | `saveStrategy`, `loadStrategies` |
+| `frontend/src/hooks/useLiveStrategyMarkers.ts` | 차트+시그널 STOMP 구독 (이중 채널) |
+| `frontend/src/utils/tradeSignalOwnership.ts` | 시그널 소유자 검증 |
### 백엔드
@@ -607,33 +743,42 @@ flowchart TB
|------|------|
| `backend/.../entity/GcStrategy.java` | 전략 엔티티 |
| `backend/.../entity/GcLiveStrategySettings.java` | 실시간 설정 |
-| `backend/.../service/StrategyDslToTa4jAdapter.java` | DSL → Rule |
-| `backend/.../service/LiveStrategyEvaluator.java` | 실시간 평가 |
+| `backend/.../entity/GcTradeSignal.java` | 시그널 이력 엔티티 |
+| `backend/.../repository/GcTradeSignalRepository.java` | 시그널 이력 저장소 (포지션 복원 쿼리 포함) |
+| `backend/.../service/StrategyDslToTa4jAdapter.java` | DSL → Rule (`RuleBuildContext`, `CrossSeriesRule`) |
+| `backend/.../service/LiveStrategyEvaluator.java` | 실시간 평가 (앱캐시, 포지션 복원) |
| `backend/.../service/StrategySignalDeterminer.java` | BUY/SELL 판정 |
-| `backend/.../service/BacktestingService.java` | 백테스트 |
-| `backend/.../service/LiveConditionStatusService.java` | 조건 현황 API |
-| `backend/.../service/StrategyConditionTimeframeService.java` | DSL 분봉 수집 |
-| `backend/.../websocket/BarBuilder.java` | 봉 조립·마감 시 평가 트리거 |
+| `backend/.../service/StrategyTriggerBranchEvaluator.java` | 분기 트리거 평가 (`useConfirmedOnly`) |
+| `backend/.../service/BacktestingService.java` | 백테스트 (멀티TF `seriesOverrides`) |
+| `backend/.../service/LiveConditionStatusService.java` | 조건 현황 API (`overallEntryMet`/`overallExitMet`) |
| `backend/.../service/BarCloseStrategyEvaluationService.java` | 봉 마감 전략 평가 단일 진입점 |
+| `backend/.../service/StrategyConditionTimeframeService.java` | DSL 분봉 수집 |
+| `backend/.../websocket/BarBuilder.java` | 봉 조립 · 2-Phase 마감 처리 |
| `backend/.../websocket/LiveStrategyScheduler.java` | 3초 REALTIME_TICK |
-| `backend/.../websocket/TradingWebSocketBroker.java` | STOMP 발행 |
-| `backend/.../storage/Ta4jStorage.java` | BarSeries 저장소 |
+| `backend/.../websocket/TradingWebSocketBroker.java` | 차트 캔들 STOMP 발행 |
+| `backend/.../websocket/SignalEventBroker.java` | 시그널 전용 STOMP 발행 (`/sub/signals/user/{userId}`) |
+| `backend/.../dto/SignalEventDto.java` | 시그널 전용 페이로드 |
+| `backend/.../dto/LiveConditionStatusDto.java` | 조건 현황 응답 (`overallEntryMet` 포함) |
+| `backend/.../storage/Ta4jStorage.java` | BarSeries 저장소 (MAX_BAR_COUNT=1800) |
+| `backend/.../config/WebSocketConfig.java` | STOMP 브로커 설정 |
---
-## 15. 핵심 체크리스트 (디버깅용)
+## 16. 핵심 체크리스트 (디버깅용)
-1. **저장 DSL** — `encodeConditionForSave` 후 JSON에 `TIMEFRAME` / `AND`+`TIMEFRAME` 구조가 의도대로인가?
-2. **분봉** — 조건 리프의 `timeframe` vs `TIMEFRAME` 래핑 `candleType` 일치 여부
-3. **실시간 ON** — `isLiveCheck`, `strategyId` 연결
-4. **실행 방식** — `CANDLE_CLOSE` vs `REALTIME_TICK`
-5. **데이터** — `Ta4jStorage.exists(market, candleType)`, warm-up(`pinCandleWatch`)
-6. **포지션 모드** — `LONG_ONLY`에서 이미 포지션일 때 BUY 무시
-7. **STOMP** — 차트 스트림 `/sub/charts/{market}/{candleType}` (하위 호환) + 시그널 전용 `/sub/signals/user/{userId}` 구독 확인
-8. **UI 현황 vs 시그널** — `live-conditions` `matchRate` = 리프 단순 충족률, `overallEntryMet`/`overallExitMet` = 트리 전체 평가 결과 (실제 시그널 발화와 동일 로직)
-9. **마감봉 평가** — `BarCloseStrategyEvaluationService` + DSL `usesTimeframe` 게이트; 갭은 `CandleGapBackfillService` 후 보정
-10. **프론트 평가 금지** — 가상투자 일치율·자동매매는 백엔드 API/STOMP 만 사용
+1. **저장 DSL** — `encodeConditionForSave` 후 JSON에 `TIMEFRAME` / `AND`+`TIMEFRAME` 구조가 의도대로인가?
+2. **분봉** — 조건 리프의 `timeframe` vs `TIMEFRAME` 래핑 `candleType` 일치 여부
+3. **실시간 ON** — `isLiveCheck`, `strategyId` 연결
+4. **실행 방식** — `CANDLE_CLOSE` vs `REALTIME_TICK`
+5. **데이터 warm-up** — `Ta4jStorage.exists(market, candleType)`, `warmup-count ≥ max_period×2`
+6. **포지션 모드** — `LONG_ONLY`에서 서버 재시작 후 포지션 상태가 DB에서 올바르게 복원됐는지 (`gc_trade_signal` 최신 레코드)
+7. **멀티TF 레이스 컨디션** — `BarBuilder.commitBar` Phase 1(스토리지 전부 확정) 후 Phase 2(평가) 순서 준수 확인
+8. **STOMP 이중 채널** — 차트 `/sub/charts/{market}/{candleType}` + 시그널 전용 `/sub/signals/user/{userId}` 구독 모두 확인
+9. **UI 현황 vs 시그널** — `matchRate`(리프 단순 비율, 참고용) vs `overallEntryMet`(전체 트리 평가, 실제 발화와 일치)
+10. **마감봉 평가** — `BarCloseStrategyEvaluationService` + DSL `usesTimeframe` 게이트; 갭은 `CandleGapBackfillService` 후 보정
+11. **프론트 평가 금지** — 가상투자 일치율·자동매매는 백엔드 API/STOMP 만 사용
+12. **@Deprecated API** — `evaluateCandleClose(market)`, `evaluateRealtimeTick(market)` 은 멀티 전략 환경에서 사용 금지
---
-*문서 작성 기준: goldenChart 저장소 프론트/백엔드 소스 구조. API·클래스명은 변경될 수 있으므로 구현 확인 시 §14 파일 목록을 참고하세요.*
+*문서 작성 기준: goldenChart 저장소 프론트/백엔드 소스 구조. API·클래스명은 변경될 수 있으므로 구현 확인 시 §15 파일 목록을 참고하세요.*