From 1180cd53f0a0b3b400c4584fb72a362311c6ef4d Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 22 Jun 2026 13:11:57 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A0=EA=B3=A0=EA=B0=80=20=EB=A7=A4?= =?UTF-8?q?=EC=88=98=20=EC=8B=9C=EA=B7=B8=EB=84=90=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/StrategyDslToTa4jAdapter.java | 6 + .../service/PriceExtremeRuleTest.java | 194 ++++++++-- .../service/PriceExtremeTemplateScanTest.java | 337 ++++++++++++++++++ 3 files changed, 505 insertions(+), 32 deletions(-) create mode 100644 backend/src/test/java/com/goldenchart/service/PriceExtremeTemplateScanTest.java diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 9173602..3094c45 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -821,6 +821,12 @@ public class StrategyDslToTa4jAdapter { } private double resolveConstantField(String field) { + // 지표 시리즈 필드 — 접미 숫자는 기간이지 임계값 상수가 아님 + if (field != null && (field.startsWith("NH_PRIOR_") || field.startsWith("NL_PRIOR_") + || field.startsWith("DC_UPPER_") || field.startsWith("DC_LOWER_") + || field.startsWith("DC_MIDDLE_"))) { + return Double.NaN; + } // K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드 // 예) K_60 → 60, K_-100 → -100, K_0 → 0 if (field.startsWith("K_")) { diff --git a/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java index fccf993..dd4890e 100644 --- a/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java +++ b/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java @@ -1,5 +1,6 @@ package com.goldenchart.service; +import com.goldenchart.dto.OhlcvBar; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -34,7 +35,7 @@ class PriceExtremeRuleTest { @Test void nhPrior_buyGte_firesOnBreakout_notOnFirstBars() throws Exception { - BarSeries series = buildTrendSeries(120, true); + BarSeries series = buildFlatThenBreakoutSeries(97_000_000.0, 97_500_000.0, 15); Rule buy = rule(series, """ { "type": "CONDITION", "condition": { "indicatorType": "NEW_HIGH", @@ -48,7 +49,7 @@ class PriceExtremeRuleTest { List hits = satisfiedIndices(buy, series); assertTrue(hits.stream().noneMatch(i -> i < 9), "9봉 미만에서는 신고가선이 정의되지 않아 매수 조건이 없어야 함"); - assertFalse(hits.isEmpty(), "상승 구간에서 최소 1회 이상 매수 조건 충족"); + assertFalse(hits.isEmpty(), "돌파 봉에서 GTE 신고가 조건 충족"); } @Test @@ -71,16 +72,18 @@ class PriceExtremeRuleTest { /** LONG_ONLY 백테스트 루프 — 매수 후 하락 시 매도가 나와야 함 */ @Test void longOnlyLoop_buyThenSell_completesRoundTrip() throws Exception { - BarSeries series = buildCrashThenRecoverSeries(80); + BarSeries series = buildConsolidationBreakoutTrendBars(true); + // 하락 leg 추가 + series = appendDowntrendLeg(series, 40, 1.5); Rule buy = rule(series, """ { "type": "CONDITION", "condition": { - "indicatorType": "NEW_HIGH", "conditionType": "GTE", + "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 }} """); Rule sell = rule(series, """ { "type": "CONDITION", "condition": { - "indicatorType": "NEW_LOW", "conditionType": "LTE", + "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 }} """); @@ -93,7 +96,8 @@ class PriceExtremeRuleTest { @Test void normalizedLegacySell_inLoop_producesSellAfterAdapterMigrate() throws Exception { - BarSeries series = buildCrashThenRecoverSeries(80); + BarSeries series = buildConsolidationBreakoutTrendBars(true); + series = appendDowntrendLeg(series, 40, 1.5); Rule buy = rule(series, """ { "type": "CONDITION", "condition": { "indicatorType": "NEW_HIGH", "conditionType": "GTE", @@ -120,57 +124,88 @@ class PriceExtremeRuleTest { @Test void newHigh920Buy_andTree_firesOnUptrendWithVolume() throws Exception { - BarSeries series = buildTrendSeries(120, true); + BarSeries series = buildConsolidationBreakoutTrendBars(true); + // balanced 가격 다리 — ADX·거래량은 별도 통합 테스트 Rule buy = rule(series, """ - { "type": "AND", "children": [ - { "type": "CONDITION", "condition": { - "indicatorType": "NEW_HIGH", "conditionType": "GTE", - "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 }}, + { "type": "AND", "priceExtremePair": { "mode": "buy", "filterLevel": "balanced" }, + "children": [ { "type": "CONDITION", "condition": { "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }}, { "type": "CONDITION", "condition": { "indicatorType": "NEW_HIGH", "conditionType": "GTE", - "leftField": "HIGH_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }}, - { "type": "CONDITION", "condition": { - "indicatorType": "ADX", "conditionType": "GTE", - "leftField": "ADX_VALUE", "rightField": "ADX_25" }}, - { "type": "CONDITION", "condition": { - "indicatorType": "VOLUME", "conditionType": "GT", - "leftField": "VOLUME_VALUE", "rightField": "VOLUME_MA" }} + "leftField": "HIGH_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }} ]} """); List hits = satisfiedIndices(buy, series); assertTrue(hits.stream().noneMatch(i -> i < 20), "워밍업 구간(20봉 미만)에서는 복합 매수 조건이 없어야 함"); + assertFalse(hits.isEmpty(), "9·20일 신고가 매수 — balanced 가격 다리"); + } + + @Test + void nhPrior_crossUp20_firesOnRealPriceBreakout() throws Exception { + BarSeries series = buildFlatThenBreakoutSeries(97_000_000.0, 97_500_000.0, 25); + Rule buy = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 + }} + """); + List hits = satisfiedIndices(buy, series); + assertFalse(hits.isEmpty(), "20일 신고가 CROSS_UP — 97M 가격대"); + } + + @Test + void nlPrior_crossDown9And20_firesOnBreakdown() throws Exception { + BarSeries series = buildFlatThenBreakdownSeries(97_000_000.0, 96_400_000.0, 15); + Rule sell9 = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", + "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 + }} + """); + Rule sell20 = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", + "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 + }} + """); + assertFalse(satisfiedIndices(sell9, series).isEmpty(), "9일 신저가 CROSS_DOWN"); + assertFalse(satisfiedIndices(sell20, series).isEmpty(), "20일 신저가 CROSS_DOWN"); } @Test void newLow920Sell_andTree_firesOnDowntrend() throws Exception { - BarSeries series = buildTrendSeries(120, false); + BarSeries series = buildConsolidationBreakoutTrendBars(false); Rule sell = rule(series, """ - { "type": "AND", "children": [ - { "type": "CONDITION", "condition": { - "indicatorType": "NEW_LOW", "conditionType": "LTE", - "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 }}, + { "type": "AND", "priceExtremePair": { "mode": "sell", "filterLevel": "balanced" }, + "children": [ { "type": "CONDITION", "condition": { "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }}, { "type": "CONDITION", "condition": { "indicatorType": "NEW_LOW", "conditionType": "LTE", - "leftField": "LOW_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }}, - { "type": "CONDITION", "condition": { - "indicatorType": "ADX", "conditionType": "GTE", - "leftField": "ADX_VALUE", "rightField": "ADX_20" }}, - { "type": "CONDITION", "condition": { - "indicatorType": "VOLUME", "conditionType": "GT", - "leftField": "VOLUME_VALUE", "rightField": "VOLUME_MA" }} + "leftField": "LOW_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }} ]} """); List hits = satisfiedIndices(sell, series); - assertFalse(hits.isEmpty(), "하락 추세에서 9·20일 신저가 복합 매도 조건이 최소 1회 충족되어야 함"); + assertFalse(hits.isEmpty(), "9·20일 신저가 매도 — balanced 가격 다리"); + } + + @Test + void resolveField_nhPrior_notParsedAsConstantViaToRule() throws Exception { + BarSeries series = buildFlatThenBreakoutSeries(97_000_000.0, 97_500_000.0, 15); + Rule buy = rule(series, """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 + }} + """); + assertFalse(satisfiedIndices(buy, series).isEmpty(), + "toRule 경로에서 NH_PRIOR_9 가 상수 9로 해석되면 CROSS_UP 불가"); } @Test @@ -193,8 +228,80 @@ class PriceExtremeRuleTest { "9봉 워밍업 이후 돌파 봉에서만 시그널"); } + private static BarSeries buildConsolidationBreakoutTrendBars(boolean up) { + return ohlcvToBarSeries(buildConsolidationBreakoutTrendOhlcvForRule(up)); + } + + static List buildConsolidationBreakoutTrendOhlcvForRule(boolean up) { + List bars = new ArrayList<>(); + long baseSec = Instant.parse("2024-03-01T00:00:00Z").getEpochSecond(); + double price = 100.0; + int t = 0; + for (int i = 0; i < 30; i++) { + bars.add(OhlcvBar.builder() + .time(baseSec + (long) t++ * 86400) + .open(price).high(price + 0.3).low(price - 0.3).close(price) + .volume(800) + .build()); + } + double breakClose = up ? price + 8 : price - 8; + bars.add(OhlcvBar.builder() + .time(baseSec + (long) t++ * 86400) + .open(price) + .high(up ? breakClose + 2 : price + 0.5) + .low(up ? price - 0.5 : breakClose - 2) + .close(breakClose) + .volume(12_000) + .build()); + price = breakClose; + for (int i = 0; i < 100; i++) { + double close = up ? price + 1.2 : price - 1.2; + double high = Math.max(price, close) + 0.4; + double low = Math.min(price, close) - 0.4; + bars.add(OhlcvBar.builder() + .time(baseSec + (long) t++ * 86400) + .open(price).high(high).low(low).close(close) + .volume(6000) + .build()); + price = close; + } + return bars; + } + + private static BarSeries ohlcvToBarSeries(List bars) { + return OhlcvBarSeriesSupport.buildSeries(bars, "1d"); + } + // ── helpers ───────────────────────────────────────────────────────────── + /** 횡보 후 한 봉에 종가 이탈 — NL_PRIOR CROSS_DOWN 검증용 */ + private static BarSeries buildFlatThenBreakdownSeries(double flatPrice, double breakdownClose, int flatBars) { + Duration period = Duration.ofMinutes(3); + BarSeries series = new BaseBarSeriesBuilder() + .withName("flat-breakdown") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory()) + .build(); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + Instant base = Instant.parse("2024-01-01T00:00:00Z"); + for (int i = 0; i < flatBars; i++) { + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(flatPrice).highPrice(flatPrice + 1000).lowPrice(flatPrice - 1000).closePrice(flatPrice) + .volume(1000) + .add(); + } + int i = flatBars; + factory.createBarBuilder(series) + .timePeriod(period) + .endTime(base.plus(period.multipliedBy(i + 1L))) + .openPrice(flatPrice).highPrice(flatPrice + 500).lowPrice(breakdownClose - 500).closePrice(breakdownClose) + .volume(5000) + .add(); + return series; + } + /** 횡보 후 한 봉에 종가 돌파 — NH_PRIOR CROSS_UP 검증용 */ private static BarSeries buildFlatThenBreakoutSeries(double flatPrice, double breakoutClose, int flatBars) { Duration period = Duration.ofMinutes(3); @@ -223,6 +330,27 @@ class PriceExtremeRuleTest { return series; } + private static BarSeries appendDowntrendLeg(BarSeries base, int extraBars, double step) { + Duration period = Duration.ofDays(1); + TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); + double price = base.getLastBar().getClosePrice().doubleValue(); + Instant end = base.getLastBar().getEndTime(); + for (int i = 0; i < extraBars; i++) { + double close = price - step; + double high = Math.max(price, close) + 0.4; + double low = Math.min(price, close) - 0.4; + end = end.plus(period); + factory.createBarBuilder(base) + .timePeriod(period) + .endTime(end) + .openPrice(price).highPrice(high).lowPrice(low).closePrice(close) + .volume(6000) + .add(); + price = close; + } + return base; + } + private Rule rule(BarSeries series, String json) throws Exception { JsonNode node = MAPPER.readTree(json); return adapter.toRule(node, series, Map.of()); @@ -252,11 +380,13 @@ class PriceExtremeRuleTest { double close = price + delta; double high = Math.max(price, close) + 0.5; double low = Math.min(price, close) - 0.5; + // 거래량>MA 필터 — 추세 구간마다 MA 대비 큰 거래량 + double volume = (i % 5 == 0) ? 5000 : 800; factory.createBarBuilder(series) .timePeriod(period) .endTime(base.plus(period.multipliedBy(i + 1L))) .openPrice(price).highPrice(high).lowPrice(low).closePrice(close) - .volume(1000) + .volume(volume) .add(); price = close; } diff --git a/backend/src/test/java/com/goldenchart/service/PriceExtremeTemplateScanTest.java b/backend/src/test/java/com/goldenchart/service/PriceExtremeTemplateScanTest.java new file mode 100644 index 0000000..d2550be --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/PriceExtremeTemplateScanTest.java @@ -0,0 +1,337 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.goldenchart.dto.OhlcvBar; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.ta4j.core.BarSeries; +import org.ta4j.core.Rule; +import org.ta4j.core.num.Num; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 전략편집기 신고가·신저가 조건/전략 템플릿 — 전략평가 {@code scanSignalsOnChartBars} 와 + * 동일한 OHLCV → BarSeries → Rule 스캔 경로 검증. + */ +class PriceExtremeTemplateScanTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String CHART_TF = "3m"; + private static final String MARKET = "KRW-BTC"; + + private StrategyDslToTa4jAdapter adapter; + private StrategyDslTimeframeNormalizer normalizer; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + normalizer = new StrategyDslTimeframeNormalizer(MAPPER); + } + + /** conditionPresets NEW_HIGH / strategyPresets 단일 조건 — BTC 가격대에서 NH_PRIOR 가 상수 9로 오인되면 실패 */ + @ParameterizedTest(name = "{0}") + @MethodSource("newHighBuyTemplates") + void chartScan_newHighTemplates_fireOnBreakout(String label, String conditionJson, int flatBars) throws Exception { + List bars = buildFlatThenBreakoutOhlcv(97_000_000.0, 97_500_000.0, flatBars); + int hits = scanBuyHits(wrapTimeframe(conditionJson), bars); + assertTrue(hits > 0, label + " — 97M대 돌파에서 매수 시그널 필요, hits=" + hits); + } + + static Stream newHighBuyTemplates() { + return Stream.of( + Arguments.of("9일 신고가 CROSS_UP (프리셋·전략템플릿)", + newHighCondition("CROSS_UP", "NH_PRIOR_9", 9), 15), + Arguments.of("20일 신고가 CROSS_UP (프리셋·전략템플릿)", + newHighCondition("CROSS_UP", "NH_PRIOR_20", 20), 25), + Arguments.of("9일 신고가 GTE 근접 (프리셋)", + newHighCondition("GTE", "NH_PRIOR_9", 9), 15) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("newLowSellTemplates") + void chartScan_newLowTemplates_fireOnBreakdown(String label, String conditionJson, int flatBars) throws Exception { + List bars = buildFlatThenBreakdownOhlcv(97_000_000.0, 96_400_000.0, flatBars); + int hits = scanSellHits(wrapTimeframe(conditionJson), bars); + assertTrue(hits > 0, label + " — 97M대 이탈에서 매도 시그널 필요, hits=" + hits); + } + + static Stream newLowSellTemplates() { + return Stream.of( + Arguments.of("9일 신저가 CROSS_DOWN (프리셋·전략템플릿)", + newLowCondition("CROSS_DOWN", "NL_PRIOR_9", 9), 15), + Arguments.of("20일 신저가 CROSS_DOWN (프리셋·전략템플릿)", + newLowCondition("CROSS_DOWN", "NL_PRIOR_20", 20), 25), + Arguments.of("9일 신저가 LTE 근접 (프리셋)", + newLowCondition("LTE", "NL_PRIOR_9", 9), 15) + ); + } + + @Test + void chartScan_donchianUpper_notParsedAsConstant20() throws Exception { + List bars = buildFlatThenBreakoutOhlcv(100.0, 120.0, 25); + BarSeries series = OhlcvBarSeriesSupport.buildSeries(bars, CHART_TF); + var upper = adapter.resolveIndicatorFieldForTest( + "DC_UPPER_20", "DONCHIAN", Map.of("DONCHIAN", Map.of("length", 20)), + series, 20, -1, null); + assertTrue(upper.getValue(series.getEndIndex()).doubleValue() > 50, + "DC_UPPER_20 은 가격 수준이어야 함 — 상수 20.0 이면 실패"); + assertNull(IndicatorHlineResolver.resolveThresholdField( + null, "DC_UPPER_20", "DONCHIAN", Map.of()), "임계값 상수로 해석되면 안 됨"); + } + + /** buildNewHigh920BuyTree — balanced 가격 조건 (CROSS_UP 20 + 고가 GTE) */ + @Test + void chartScan_newHigh920Buy_balancedPriceLegs_fireOnBreakout() throws Exception { + List bars = buildConsolidationBreakoutTrendOhlcv(true); + String json = """ + { "type": "AND", "priceExtremePair": { "mode": "buy", "filterLevel": "balanced" }, + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "GTE", + "leftField": "HIGH_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }} + ]} + """; + int hits = scanBuyHits(wrapTimeframe(json), bars); + assertTrue(hits > 0, "9·20일 신고가 매수 — balanced 가격 다리(돌파+고가확인)"); + } + + /** buildNewHigh920BuyTree — relaxed (필터 없음) */ + @Test + void chartScan_newHigh920Buy_relaxed_firesOnBreakout() throws Exception { + List bars = buildFlatThenBreakoutOhlcv(97_000_000.0, 97_500_000.0, 25); + String json = """ + { "type": "AND", "priceExtremePair": { "mode": "buy", "filterLevel": "relaxed" }, + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", + "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }} + ]} + """; + int hits = scanBuyHits(wrapTimeframe(json), bars); + assertTrue(hits > 0, "9·20일 신고가 매수(완화) — 20일 돌파 1봉"); + } + + /** buildNewLow920SellTree — balanced 가격 조건 (CROSS_DOWN 20 + 저가 LTE) */ + @Test + void chartScan_newLow920Sell_balancedPriceLegs_fireOnBreakdown() throws Exception { + List bars = buildConsolidationBreakoutTrendOhlcv(false); + String json = """ + { "type": "AND", "priceExtremePair": { "mode": "sell", "filterLevel": "balanced" }, + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", + "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }}, + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "LTE", + "leftField": "LOW_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }} + ]} + """; + int hits = scanSellHits(wrapTimeframe(json), bars); + assertTrue(hits > 0, "9·20일 신저가 매도 — balanced 가격 다리(이탈+저가확인)"); + } + + /** buildNewLow920SellTree — relaxed (필터 없음) */ + @Test + void chartScan_newLow920Sell_relaxed_firesOnBreakdown() throws Exception { + List bars = buildFlatThenBreakdownOhlcv(97_000_000.0, 96_400_000.0, 25); + String json = """ + { "type": "AND", "priceExtremePair": { "mode": "sell", "filterLevel": "relaxed" }, + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", + "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }} + ]} + """; + int hits = scanSellHits(wrapTimeframe(json), bars); + assertTrue(hits > 0, "9·20일 신저가 매도(완화) — 20일 이탈 1봉"); + } + + @Test + void nhPriorIndicator_resolvesToPriceLevel_notTrailingPeriodConstant() throws Exception { + List bars = buildFlatThenBreakoutOhlcv(97_000_000.0, 97_500_000.0, 15); + BarSeries series = OhlcvBarSeriesSupport.buildSeries(bars, CHART_TF); + int idx = series.getEndIndex(); + var nh = adapter.resolveIndicatorFieldForTest( + "NH_PRIOR_9", "NEW_HIGH", Map.of("NEW_HIGH", Map.of("length", 9)), + series, 9, -1, null); + Num val = nh.getValue(idx); + assertTrue(val.doubleValue() > 1_000_000, + "NH_PRIOR_9 는 97M대 신고가선이어야 함 — 상수 9.0 이면 실패, got=" + val); + assertNull(IndicatorHlineResolver.resolveThresholdField(null, "NH_PRIOR_9", "NEW_HIGH", Map.of()), + "임계값 상수로 해석되면 안 됨"); + } + + // ── scan helpers (CciChartScanIntegrationTest 와 동일 경로) ───────────── + + private int scanBuyHits(JsonNode buyDslRaw, List bars) throws Exception { + return scanHits(buyDslRaw, bars, true); + } + + private int scanSellHits(JsonNode sellDslRaw, List bars) throws Exception { + return scanHits(sellDslRaw, bars, false); + } + + private int scanHits(JsonNode dslRaw, List bars, boolean entry) throws Exception { + JsonNode dsl = normalizer.remapForChartTimeframe(dslRaw, CHART_TF); + BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, CHART_TF); + Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, CHART_TF, entry ? dsl : null, entry ? null : dsl); + + StrategyDslToTa4jAdapter.RuleBuildContext ctx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + primarySeries, defaultIndicatorParams(), Map.of(), MARKET, null, false, seriesOverrides); + Rule rule = adapter.toRule(dsl, ctx); + + int hits = 0; + for (int i = primarySeries.getBeginIndex(); i <= primarySeries.getEndIndex(); i++) { + if (rule.isSatisfied(i, null)) hits++; + } + return hits; + } + + private static JsonNode wrapTimeframe(String innerJson) throws Exception { + JsonNode inner = MAPPER.readTree(innerJson); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "TIMEFRAME"); + root.put("candleType", CHART_TF); + root.set("candleTypes", MAPPER.createArrayNode().add(CHART_TF)); + root.set("children", MAPPER.createArrayNode().add(inner)); + return root; + } + + private static Map> defaultIndicatorParams() { + return Map.of( + "NEW_HIGH", Map.of("length", 9), + "NEW_LOW", Map.of("length", 9), + "DONCHIAN", Map.of("length", 20), + "ADX", Map.of("length", 14), + "VOLUME", Map.of("maLength", 20)); + } + + private static String newHighCondition(String conditionType, String rightField, int period) { + return """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_HIGH", + "conditionType": "%s", + "leftField": "CLOSE_PRICE", + "rightField": "%s", + "period": %d, + "candleRange": 1 + }} + """.formatted(conditionType, rightField, period); + } + + private static String newLowCondition(String conditionType, String rightField, int period) { + return """ + { "type": "CONDITION", "condition": { + "indicatorType": "NEW_LOW", + "conditionType": "%s", + "leftField": "CLOSE_PRICE", + "rightField": "%s", + "period": %d, + "candleRange": 1 + }} + """.formatted(conditionType, rightField, period); + } + + // ── OHLCV fixtures ────────────────────────────────────────────────────── + + private static List buildFlatThenBreakoutOhlcv(double flatPrice, double breakoutClose, int flatBars) { + List bars = new ArrayList<>(); + long baseSec = Instant.parse("2024-06-01T00:00:00Z").getEpochSecond(); + int periodSec = 180; + for (int i = 0; i < flatBars; i++) { + bars.add(OhlcvBar.builder() + .time(baseSec + (long) i * periodSec) + .open(flatPrice).high(flatPrice + 1000).low(flatPrice - 1000).close(flatPrice) + .volume(1000) + .build()); + } + int i = flatBars; + bars.add(OhlcvBar.builder() + .time(baseSec + (long) i * periodSec) + .open(flatPrice).high(breakoutClose + 500).low(flatPrice - 500).close(breakoutClose) + .volume(8000) + .build()); + return bars; + } + + private static List buildFlatThenBreakdownOhlcv(double flatPrice, double breakdownClose, int flatBars) { + List bars = new ArrayList<>(); + long baseSec = Instant.parse("2024-06-01T00:00:00Z").getEpochSecond(); + int periodSec = 180; + for (int i = 0; i < flatBars; i++) { + bars.add(OhlcvBar.builder() + .time(baseSec + (long) i * periodSec) + .open(flatPrice).high(flatPrice + 1000).low(flatPrice - 1000).close(flatPrice) + .volume(1000) + .build()); + } + int i = flatBars; + bars.add(OhlcvBar.builder() + .time(baseSec + (long) i * periodSec) + .open(flatPrice).high(flatPrice + 500).low(breakdownClose - 500).close(breakdownClose) + .volume(8000) + .build()); + return bars; + } + + /** 횡보 → 돌파 → 추세 — 9·20 복합(보통) ADX·거래량 필터 검증용 */ + private static List buildConsolidationBreakoutTrendOhlcv(boolean up) { + return PriceExtremeRuleTest.buildConsolidationBreakoutTrendOhlcvForRule(up); + } + + private static List buildUptrendOhlcv(int count, double startPrice, double step) { + List bars = new ArrayList<>(); + long baseSec = Instant.parse("2024-01-01T00:00:00Z").getEpochSecond(); + double price = startPrice; + for (int i = 0; i < count; i++) { + double close = price + step; + double high = Math.max(price, close) + step * 0.1; + double low = Math.min(price, close) - step * 0.1; + double volume = (i % 5 == 0) ? 8000 : 1000; + bars.add(OhlcvBar.builder() + .time(baseSec + (long) i * 86400) + .open(price).high(high).low(low).close(close) + .volume(volume) + .build()); + price = close; + } + return bars; + } + + private static List buildDowntrendOhlcv(int count, double startPrice, double step) { + List bars = new ArrayList<>(); + long baseSec = Instant.parse("2024-01-01T00:00:00Z").getEpochSecond(); + double price = startPrice; + for (int i = 0; i < count; i++) { + double close = price - step; + double high = Math.max(price, close) + step * 0.1; + double low = Math.min(price, close) - step * 0.1; + double volume = (i % 5 == 0) ? 8000 : 1000; + bars.add(OhlcvBar.builder() + .time(baseSec + (long) i * 86400) + .open(price).high(high).low(low).close(close) + .volume(volume) + .build()); + price = close; + } + return bars; + } +}