diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index ef50166..7561fa6 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -222,10 +222,14 @@ public class LiveConditionStatusService { Map> params, Map> visual) { try { - JsonNode buyDslRaw = objectMapper.readTree( - strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"); - JsonNode sellDslRaw = objectMapper.readTree( - strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"); + JsonNode buyDslRaw = timeframeNormalizer.normalize( + objectMapper.readTree( + strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"), + strategy.getName()); + JsonNode sellDslRaw = timeframeNormalizer.normalize( + objectMapper.readTree( + strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"), + strategy.getName()); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); @@ -325,6 +329,16 @@ public class LiveConditionStatusService { return primarySeries; } + /** 백테스트·scan-signals 와 동일 — 마지막 evalCount 봉만 스캔 */ + private static int resolveChartScanStartIndex(BarSeries series, int evalCount) { + if (series == null || series.isEmpty() || evalCount <= 0) { + return series != null ? series.getBeginIndex() : 0; + } + int total = series.getBarCount(); + if (evalCount >= total) return series.getBeginIndex(); + return series.getEndIndex() - evalCount + 1; + } + private Boolean evaluateOverallRuleOnChart(JsonNode dsl, BarSeries primarySeries, Map seriesOverrides, String market, @@ -687,10 +701,15 @@ public class LiveConditionStatusService { indicatorSettingsService.getAllVisual(userId, deviceId); try { - JsonNode buyDslRaw = objectMapper.readTree( - strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"); - JsonNode sellDslRaw = objectMapper.readTree( - strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"); + String strategyName = strategy.getName(); + JsonNode buyDslRaw = timeframeNormalizer.normalize( + objectMapper.readTree( + strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"), + strategyName); + JsonNode sellDslRaw = timeframeNormalizer.normalize( + objectMapper.readTree( + strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"), + strategyName); String primaryTf = OhlcvBarSeriesSupport.normalizeTf(chartTimeframe); JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDslRaw, primaryTf); @@ -706,22 +725,27 @@ public class LiveConditionStatusService { int evalCount = evaluationBarCount != null && evaluationBarCount > 0 ? evaluationBarCount : primarySeries.getBarCount(); - int startIdx = Math.max( - primarySeries.getBeginIndex(), - primarySeries.getEndIndex() - evalCount + 1); + int startIdx = resolveChartScanStartIndex(primarySeries, evalCount); + + StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + primarySeries, params, visual, market, null, false, seriesOverrides); + Rule entryRule = (buyDsl != null && !buyDsl.isNull()) + ? adapter.toRule(buyDsl, ruleCtx) + : new org.ta4j.core.rules.BooleanRule(false); + Rule exitRule = (sellDsl != null && !sellDsl.isNull()) + ? adapter.toRule(sellDsl, ruleCtx) + : new org.ta4j.core.rules.BooleanRule(false); List signals = new ArrayList<>(); + int buyHits = 0; + int sellHits = 0; for (int i = startIdx; i <= primarySeries.getEndIndex(); i++) { long barTimeSec = barOpenEpochSec(primarySeries, i); double close = primarySeries.getBar(i).getClosePrice().doubleValue(); - Boolean entryMet = evaluateOverallRuleOnChart( - buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); - Boolean exitMet = evaluateOverallRuleOnChart( - sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); - - // SIGNAL_ONLY — 조건 충족 봉마다 마커 (포지션 교대 없음, 차트 교차점 표시용) - if (Boolean.TRUE.equals(entryMet)) { + if (entryRule.isSatisfied(i, null)) { + buyHits++; signals.add(BacktestResponse.Signal.builder() .time(barTimeSec) .type("BUY") @@ -730,7 +754,8 @@ public class LiveConditionStatusService { .quantity(0) .build()); } - if (Boolean.TRUE.equals(exitMet)) { + if (exitRule.isSatisfied(i, null)) { + sellHits++; signals.add(BacktestResponse.Signal.builder() .time(barTimeSec) .type("SELL") @@ -740,6 +765,14 @@ public class LiveConditionStatusService { .build()); } } + if (signals.isEmpty()) { + log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{}", + market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx, + primarySeries.getEndIndex()); + } else { + log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}", + market, strategyId, primaryTf, buyHits, sellHits); + } return signals; } catch (Exception e) { log.warn("[LiveCondition:scan] fail market={} strategy={}: {}", diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 75c9c97..f3a0d3e 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -393,6 +393,10 @@ public class StrategyDslToTa4jAdapter { if (children.isArray()) { for (JsonNode child : children) list.add(toRule(child, ctx)); } + JsonNode single = node.path("child"); + if (list.isEmpty() && !single.isMissingNode() && !single.isNull()) { + list.add(toRule(single, ctx)); + } return list; } @@ -810,7 +814,7 @@ public class StrategyDslToTa4jAdapter { int period = parseTrailingInt(field, "CCI_VALUE_", intP(p, "length", 13)); return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)), period); } - if (field.equals("CCI_VALUE") || indType.equals("CCI")) + if (field.equals("CCI_VALUE")) return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)), effectivePeriod(periodOverride, p, "length", 13)); if (field.equals("CCI_SIGNAL")) { @@ -894,7 +898,7 @@ public class StrategyDslToTa4jAdapter { int len = parseTrailingInt(field, "TRIX_VALUE_", intP(p, "length", 12)); return buildTrixLine(close, len); } - if (field.equals("TRIX_VALUE") || indType.equals("TRIX")) { + if (field.equals("TRIX_VALUE")) { return buildTrixLine(close, effectivePeriod(periodOverride, p, "length", 12)); } if (field.equals("TRIX_SIGNAL")) { @@ -1561,14 +1565,16 @@ public class StrategyDslToTa4jAdapter { int periodOverride, String lengthKey, int defaultLen) { if (cond != null && !cond.isNull()) { String leftField = cond.path("leftField").asText(""); - if (leftField.startsWith(valuePrefix + "_")) { - int fromField = parseTrailingInt(leftField, valuePrefix + "_", defaultLen); - if (fromField > 0) return fromField; + if (leftField.equals(valuePrefix) || leftField.startsWith(valuePrefix + "_")) { + if (leftField.startsWith(valuePrefix + "_")) { + int fromField = parseTrailingInt(leftField, valuePrefix + "_", defaultLen); + if (fromField > 0) return fromField; + } + int leftPeriod = cond.path("leftPeriod").asInt(-1); + if (leftPeriod > 0) return leftPeriod; + int condPeriod = cond.path("period").asInt(-1); + if (condPeriod > 0) return condPeriod; } - int leftPeriod = cond.path("leftPeriod").asInt(-1); - if (leftPeriod > 0) return leftPeriod; - int condPeriod = cond.path("period").asInt(-1); - if (condPeriod > 0) return condPeriod; } return effectivePeriod(periodOverride, p, lengthKey, defaultLen); } diff --git a/backend/src/test/java/com/goldenchart/service/CciChartScanIntegrationTest.java b/backend/src/test/java/com/goldenchart/service/CciChartScanIntegrationTest.java index 7120a32..9a24019 100644 --- a/backend/src/test/java/com/goldenchart/service/CciChartScanIntegrationTest.java +++ b/backend/src/test/java/com/goldenchart/service/CciChartScanIntegrationTest.java @@ -117,6 +117,165 @@ class CciChartScanIntegrationTest { assertTrue(hits > 0, "candleTypes[0]=3m must win over stale candleType=1m, got " + hits); } + @Test + void chartScan_5mStrategy_remappedTo3mChart_firesSignals() throws Exception { + String chartTf = "3m"; + List bars = buildOscillatingOhlcvBars(150, chartTf); + BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf); + + JsonNode buyDslRaw = MAPPER.readTree(""" + { + "type": "TIMEFRAME", + "candleTypes": ["5m"], + "candleType": "5m", + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "conditionType": "CROSS_UP", + "leftField": "CCI_VALUE_20", + "rightField": "CCI_SIGNAL", + "period": 20, + "valuePeriodOverride": true, + "candleRange": 1 + } + }] + } + """); + JsonNode buyDsl = normalizer.remapForChartTimeframe(buyDslRaw, chartTf); + assertEquals("3m", buyDsl.path("candleType").asText()); + assertEquals("3m", buyDsl.path("candleTypes").get(0).asText()); + Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, chartTf, buyDsl, null); + + Map> params = Map.of( + "CCI", Map.of("length", 20, "maLength", 10, "maType", "SMA", "src", "hlc3")); + + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + primarySeries, params, Map.of(), "KRW-BTC", null, false, seriesOverrides); + Rule rule = adapter.toRule(buyDsl, ctx); + + int hits = 0; + for (int i = primarySeries.getBeginIndex(); i <= primarySeries.getEndIndex(); i++) { + if (rule.isSatisfied(i, null)) hits++; + } + assertTrue(hits > 0, "5m strategy on 3m chart should detect CCI crosses after remap, got " + hits); + } + + @Test + void chartScan_5mNativeOn3mChart_aggregatedSeries_firesSignals() throws Exception { + String chartTf = "3m"; + List bars = buildOscillatingOhlcvBars(150, chartTf); + BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf); + + // remap 없음 — 5m TIMEFRAME + 3m 차트 (CrossSeriesRule 집계 경로) + JsonNode buyDsl = MAPPER.readTree(""" + { + "type": "TIMEFRAME", + "candleTypes": ["5m"], + "candleType": "5m", + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "conditionType": "CROSS_UP", + "leftField": "CCI_VALUE_20", + "rightField": "CCI_SIGNAL", + "period": 20, + "valuePeriodOverride": true + } + }] + } + """); + Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, chartTf, buyDsl, null); + + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + primarySeries, Map.of("CCI", Map.of("length", 20, "maLength", 10, "maType", "SMA")), + Map.of(), "KRW-BTC", null, false, seriesOverrides); + Rule rule = adapter.toRule(buyDsl, ctx); + + int hits = 0; + for (int i = primarySeries.getBeginIndex(); i <= primarySeries.getEndIndex(); i++) { + if (rule.isSatisfied(i, null)) hits++; + } + assertTrue(hits > 0, "5m native on 3m chart via aggregation should fire, got " + hits); + } + + @Test + void cciValueField_period20_matchesValue20Suffix() throws Exception { + BarSeries series = buildOscillatingOhlcvBars(150, "3m").stream() + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toList(), + list -> OhlcvBarSeriesSupport.buildSeries(list, "3m"))); + Map p = Map.of("length", 13, "maLength", 10, "maType", "SMA", "src", "hlc3"); + + JsonNode condPlain = MAPPER.readTree(""" + {"period":20,"leftField":"CCI_VALUE","rightField":"CCI_SIGNAL","valuePeriodOverride":true} + """); + JsonNode condSuffix = MAPPER.readTree(""" + {"period":20,"leftField":"CCI_VALUE_20","rightField":"CCI_SIGNAL","valuePeriodOverride":true} + """); + + var plainValue = adapter.resolveIndicatorFieldForTest( + "CCI_VALUE", "CCI", p, series, 20, -1, condPlain); + var suffixValue = adapter.resolveIndicatorFieldForTest( + "CCI_VALUE_20", "CCI", p, series, 20, -1, condSuffix); + var plainSignal = adapter.resolveIndicatorFieldForTest( + "CCI_SIGNAL", "CCI", p, series, 20, -1, condPlain); + var suffixSignal = adapter.resolveIndicatorFieldForTest( + "CCI_SIGNAL", "CCI", p, series, 20, -1, condSuffix); + + int idx = 80; + assertEquals(suffixValue.getValue(idx).doubleValue(), plainValue.getValue(idx).doubleValue(), 1e-6, + "CCI_VALUE+period must match CCI_VALUE_20"); + assertEquals(suffixSignal.getValue(idx).doubleValue(), plainSignal.getValue(idx).doubleValue(), 1e-6, + "CCI_SIGNAL must match for both left field forms"); + assertNotEquals(suffixValue.getValue(idx).doubleValue(), suffixSignal.getValue(idx).doubleValue(), 1e-6, + "CCI_SIGNAL must differ from raw CCI at index " + idx); + } + + @Test + void chartScan_cciValueFieldWithPeriod20_firesSignals() throws Exception { + String chartTf = "3m"; + List bars = buildOscillatingOhlcvBars(150, chartTf); + BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf); + + JsonNode buyDsl = MAPPER.readTree(""" + { + "type": "TIMEFRAME", + "candleTypes": ["3m"], + "candleType": "3m", + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "conditionType": "CROSS_UP", + "leftField": "CCI_VALUE", + "rightField": "CCI_SIGNAL", + "period": 20, + "valuePeriodOverride": true + } + }] + } + """); + Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, chartTf, buyDsl, null); + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + primarySeries, Map.of("CCI", Map.of("length", 13, "maLength", 10, "maType", "SMA")), + Map.of(), null, null, false, seriesOverrides); + Rule rule = adapter.toRule(buyDsl, ctx); + + int hits = 0; + for (int i = primarySeries.getBeginIndex(); i <= primarySeries.getEndIndex(); i++) { + if (rule.isSatisfied(i, null)) hits++; + } + assertTrue(hits > 0, "CCI_VALUE + period=20 must use period not global length=13, got " + hits); + } + private static List buildOscillatingOhlcvBars(int count, String tf) { long tfSec = switch (OhlcvBarSeriesSupport.normalizeTf(tf)) { case "3m" -> 180; diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index f90ccf1..2e5822e 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1654,7 +1654,7 @@ export async function fetchLiveConditionScanSignals( evaluationBarCount?: number | null, ): Promise { if (chartBars.length === 0 || !chartTimeframe) return []; - const list = await request('/strategy/live-conditions/scan-signals', { + const list = await requestOrThrow('/strategy/live-conditions/scan-signals', { method: 'POST', cache: 'no-store', body: JSON.stringify({