diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 36cb634..21fb0b3 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -51,6 +51,7 @@ public class LiveConditionStatusService { private final IndicatorSettingsService indicatorSettingsService; private final ObjectMapper objectMapper; private final DynamicSubscriptionManager subscriptionManager; + private final StrategyDslTimeframeNormalizer timeframeNormalizer; @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, @@ -102,7 +103,7 @@ public class LiveConditionStatusService { } if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) { - return evaluateOnChartBars(strategy, market, strategyId, pending, chartBars, chartTimeframe, + return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe, barTimeSec, params, visual); } @@ -215,19 +216,25 @@ public class LiveConditionStatusService { GcStrategy strategy, String market, long strategyId, - List pending, List chartBars, String chartTimeframe, Long barTimeSec, Map> params, Map> visual) { try { - JsonNode buyDsl = objectMapper.readTree( + JsonNode buyDslRaw = objectMapper.readTree( strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"); - JsonNode sellDsl = objectMapper.readTree( + JsonNode sellDslRaw = objectMapper.readTree( strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); + JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); + JsonNode sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDslRaw, primaryTf); + + List pending = new ArrayList<>(); + collectConditionsFromNode(buyDsl, "1m", "buy", pending); + collectConditionsFromNode(sellDsl, "1m", "sell", pending); + BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, primaryTf); if (primarySeries.isEmpty()) { return empty(market, strategyId); @@ -322,16 +329,13 @@ public class LiveConditionStatusService { Long barTimeSec) { if (dsl == null || dsl.isNull()) return null; try { - String primaryTf = extractPrimaryTimeframe(dsl); - BarSeries series = resolveChartSeries(primaryTf, OhlcvBarSeriesSupport.normalizeTf(primaryTf), - primarySeries, seriesOverrides, market); - if (series == null || series.isEmpty()) return null; + if (primarySeries == null || primarySeries.isEmpty()) return null; StrategyDslToTa4jAdapter.RuleBuildContext ctx = new StrategyDslToTa4jAdapter.RuleBuildContext( - series, params, visual, market, ta4jStorage, false, seriesOverrides); + primarySeries, params, visual, market, ta4jStorage, false, seriesOverrides); Rule rule = adapter.toRule(dsl, ctx); - int index = OhlcvBarSeriesSupport.resolveBarIndex(series, barTimeSec); + int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); return rule.isSatisfied(index, null); } catch (Exception e) { log.debug("[LiveCondition:chart] overall rule fail market={}: {}", market, e.getMessage()); @@ -339,6 +343,12 @@ public class LiveConditionStatusService { } } + private void collectConditionsFromNode(JsonNode node, String timeframe, String side, + List out) { + if (node == null || node.isNull()) return; + walk(node, timeframe, side, out, new HashSet<>()); + } + private LiveConditionStatusDto empty(String market, long strategyId) { return LiveConditionStatusDto.builder() .market(market) @@ -673,12 +683,14 @@ public class LiveConditionStatusService { indicatorSettingsService.getAllVisual(userId, deviceId); try { - JsonNode buyDsl = objectMapper.readTree( + JsonNode buyDslRaw = objectMapper.readTree( strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"); - JsonNode sellDsl = objectMapper.readTree( + JsonNode sellDslRaw = objectMapper.readTree( strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); + JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); + JsonNode sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDslRaw, primaryTf); BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, primaryTf); if (primarySeries.isEmpty()) { return List.of(); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index b98ec20..dcf8a77 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -472,4 +472,126 @@ public class StrategyDslTimeframeNormalizer { private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { return copyTimeframeWithCandleTypes(tf, Set.of(candleType)); } + + /** + * 전략평가·차트 시그널 스캔 — UI에서 선택한 분봉으로 평가. + * 단일 평가 분봉 전략(예: 3m 저장)을 5m 차트에서 테스트할 때 TIMEFRAME·조건 candleType을 치환한다. + * 멀티 TF(3m+1h 등) 또는 START에 복수 candleTypes가 있으면 치환하지 않는다. + */ + public JsonNode remapForChartTimeframe(JsonNode root, String chartTimeframe) { + if (root == null || root.isNull() || chartTimeframe == null || chartTimeframe.isBlank()) { + return root; + } + String chartTf = LiveStrategyTimeframeService.normalize(chartTimeframe); + if (!canRemapSingleEvaluationTimeframe(root)) { + return root; + } + String strategyTf = resolveSingleEvaluationTimeframe(root); + if (chartTf.equals(strategyTf)) { + return root; + } + ObjectNode copy = (ObjectNode) root.deepCopy(); + remapTimeframesRecursive(copy, strategyTf, chartTf); + return copy; + } + + public String remapJsonForChartTimeframe(String json, String chartTimeframe) { + if (json == null || json.isBlank()) return json; + try { + JsonNode root = objectMapper.readTree(json); + JsonNode remapped = remapForChartTimeframe(root, chartTimeframe); + return objectMapper.writeValueAsString(remapped); + } catch (Exception e) { + return json; + } + } + + /** TIMEFRAME START에 복수 candleTypes 또는 AND/OR 아래 서로 다른 TF가 있으면 false */ + private static boolean canRemapSingleEvaluationTimeframe(JsonNode root) { + if (root == null || root.isNull()) return false; + Set tfScopes = new LinkedHashSet<>(); + collectTimeframeScopeTypes(root, tfScopes); + if (tfScopes.size() > 1) return false; + return !hasMultiCandleTypesStart(root); + } + + private static void collectTimeframeScopeTypes(JsonNode node, Set out) { + if (node == null || node.isNull()) return; + String type = node.path("type").asText(""); + if ("TIMEFRAME".equals(type)) { + if (hasMultiCandleTypesStart(node)) { + out.add("__multi__"); + return; + } + String ct = LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")); + out.add(ct); + } + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode c : children) collectTimeframeScopeTypes(c, out); + } + JsonNode child = node.path("child"); + if (!child.isMissingNode() && !child.isNull()) collectTimeframeScopeTypes(child, out); + } + + private static boolean hasMultiCandleTypesStart(JsonNode tfNode) { + JsonNode arr = tfNode.path("candleTypes"); + if (!arr.isArray() || arr.size() <= 1) return false; + Set distinct = new LinkedHashSet<>(); + for (JsonNode t : arr) { + distinct.add(LiveStrategyTimeframeService.normalize(t.asText(""))); + } + return distinct.size() > 1; + } + + private static String resolveSingleEvaluationTimeframe(JsonNode root) { + Set scopes = new LinkedHashSet<>(); + collectTimeframeScopeTypes(root, scopes); + scopes.remove("__multi__"); + if (scopes.size() == 1) return scopes.iterator().next(); + Set meaningful = collectMeaningfulCandleTypes(root); + if (meaningful.size() == 1) return meaningful.iterator().next(); + return "1m"; + } + + private static void remapTimeframesRecursive(JsonNode node, String fromTf, String toTf) { + if (node == null || node.isNull() || !node.isObject()) return; + ObjectNode obj = (ObjectNode) node; + String type = obj.path("type").asText(""); + + if ("TIMEFRAME".equals(type)) { + obj.put("candleType", toTf); + if (obj.has("candleTypes")) { + ArrayNode arr = JsonNodeFactory.instance.arrayNode(); + arr.add(toTf); + obj.set("candleTypes", arr); + } + } + + if ("CONDITION".equals(type)) { + JsonNode cond = obj.path("condition"); + if (cond.isObject()) { + remapConditionCandleType((ObjectNode) cond, "leftCandleType", fromTf, toTf); + remapConditionCandleType((ObjectNode) cond, "rightCandleType", fromTf, toTf); + } + } + + JsonNode children = obj.path("children"); + if (children.isArray()) { + for (JsonNode c : children) remapTimeframesRecursive(c, fromTf, toTf); + } + JsonNode child = obj.path("child"); + if (!child.isMissingNode() && !child.isNull()) remapTimeframesRecursive(child, fromTf, toTf); + } + + private static void remapConditionCandleType(ObjectNode cond, String field, + String fromTf, String toTf) { + if (!cond.has(field)) return; + String raw = cond.path(field).asText(""); + if (raw.isBlank()) return; + String ct = LiveStrategyTimeframeService.normalize(raw); + if (fromTf.equals(ct)) { + cond.put(field, toTf); + } + } } diff --git a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java index ae52c5b..9f35a68 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java @@ -259,4 +259,39 @@ class StrategyDslTimeframeNormalizerTest { assertEquals("3m", root.path("candleTypes").get(1).asText()); assertEquals("5m", root.path("candleTypes").get(2).asText()); } + + @Test + void remapForChartTimeframe_single3mTo5m() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "3m", + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "ICHIMOKU", + "conditionType": "CLOUD_BREAK_UP" + } + }] + } + """; + JsonNode remapped = normalizer.remapForChartTimeframe(objectMapper.readTree(json), "5m"); + assertEquals("5m", remapped.path("candleType").asText()); + } + + @Test + void remapForChartTimeframe_skipsMultiTimeframe() throws Exception { + String json = """ + { + "type": "AND", + "children": [ + { "type": "TIMEFRAME", "candleType": "3m", "children": [] }, + { "type": "TIMEFRAME", "candleType": "1h", "children": [] } + ] + } + """; + JsonNode root = objectMapper.readTree(json); + JsonNode remapped = normalizer.remapForChartTimeframe(root, "5m"); + assertEquals("3m", remapped.path("children").get(0).path("candleType").asText()); + } } diff --git a/frontend/src/utils/strategyEvaluationSnapshot.ts b/frontend/src/utils/strategyEvaluationSnapshot.ts index cd9b50f..be1a085 100644 --- a/frontend/src/utils/strategyEvaluationSnapshot.ts +++ b/frontend/src/utils/strategyEvaluationSnapshot.ts @@ -29,7 +29,7 @@ export async function fetchEvaluationSnapshotAtBar( const baseRows = hydrated ? extractVirtualConditions(hydrated) : []; try { - await pinStrategyEvaluationTimeframes(normalizedMarket, strategyId); + await pinStrategyEvaluationTimeframes(normalizedMarket, strategyId, chartTimeframe); } catch { /* pin 실패해도 평가 시도 */ } diff --git a/frontend/src/utils/strategyTimeframePin.ts b/frontend/src/utils/strategyTimeframePin.ts index 137d908..70596d7 100644 --- a/frontend/src/utils/strategyTimeframePin.ts +++ b/frontend/src/utils/strategyTimeframePin.ts @@ -1,12 +1,18 @@ -import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes, type StrategyDto } from './backendApi'; +import { pinCandleWatch, loadStrategyTimeframes, repairStrategyTimeframes, type StrategyDto } from './backendApi'; import { normalizeStartCandleType } from './strategyStartNodes'; import { collectUiEvaluationTimeframes, syncStrategyTimeframesFromLayoutIfNeeded } from './strategyTimeframeSync'; -/** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */ +/** 전략 DSL 평가 분봉 pin — chartTimeframe 이 있으면 UI 선택 분봉 우선 */ export async function pinStrategyEvaluationTimeframes( market: string, strategyId: number, + chartTimeframe?: string | null, ): Promise { + if (chartTimeframe) { + const tf = normalizeStartCandleType(chartTimeframe); + await pinCandleWatch(market, tf); + return [tf]; + } const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId); if (!synced) return ['1m']; await repairStrategyTimeframes(strategyId);