신고가 매수 시그널 오류 수정

This commit is contained in:
Macbook
2026-06-22 13:11:57 +09:00
parent 53747b8773
commit 1180cd53f0
3 changed files with 505 additions and 32 deletions
@@ -821,6 +821,12 @@ public class StrategyDslToTa4jAdapter {
} }
private double resolveConstantField(String field) { 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_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드
// 예) K_60 → 60, K_-100 → -100, K_0 → 0 // 예) K_60 → 60, K_-100 → -100, K_0 → 0
if (field.startsWith("K_")) { if (field.startsWith("K_")) {
@@ -1,5 +1,6 @@
package com.goldenchart.service; package com.goldenchart.service;
import com.goldenchart.dto.OhlcvBar;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -34,7 +35,7 @@ class PriceExtremeRuleTest {
@Test @Test
void nhPrior_buyGte_firesOnBreakout_notOnFirstBars() throws Exception { 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, """ Rule buy = rule(series, """
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "indicatorType": "NEW_HIGH",
@@ -48,7 +49,7 @@ class PriceExtremeRuleTest {
List<Integer> hits = satisfiedIndices(buy, series); List<Integer> hits = satisfiedIndices(buy, series);
assertTrue(hits.stream().noneMatch(i -> i < 9), assertTrue(hits.stream().noneMatch(i -> i < 9),
"9봉 미만에서는 신고가선이 정의되지 않아 매수 조건이 없어야 함"); "9봉 미만에서는 신고가선이 정의되지 않아 매수 조건이 없어야 함");
assertFalse(hits.isEmpty(), "상승 구간에서 최소 1회 이상 매수 조건 충족"); assertFalse(hits.isEmpty(), "돌파 봉에서 GTE 신고가 조건 충족");
} }
@Test @Test
@@ -71,16 +72,18 @@ class PriceExtremeRuleTest {
/** LONG_ONLY 백테스트 루프 — 매수 후 하락 시 매도가 나와야 함 */ /** LONG_ONLY 백테스트 루프 — 매수 후 하락 시 매도가 나와야 함 */
@Test @Test
void longOnlyLoop_buyThenSell_completesRoundTrip() throws Exception { 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, """ Rule buy = rule(series, """
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "GTE", "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP",
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9
}} }}
"""); """);
Rule sell = rule(series, """ Rule sell = rule(series, """
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW", "conditionType": "LTE", "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN",
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9
}} }}
"""); """);
@@ -93,7 +96,8 @@ class PriceExtremeRuleTest {
@Test @Test
void normalizedLegacySell_inLoop_producesSellAfterAdapterMigrate() throws Exception { void normalizedLegacySell_inLoop_producesSellAfterAdapterMigrate() throws Exception {
BarSeries series = buildCrashThenRecoverSeries(80); BarSeries series = buildConsolidationBreakoutTrendBars(true);
series = appendDowntrendLeg(series, 40, 1.5);
Rule buy = rule(series, """ Rule buy = rule(series, """
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "GTE", "indicatorType": "NEW_HIGH", "conditionType": "GTE",
@@ -120,57 +124,88 @@ class PriceExtremeRuleTest {
@Test @Test
void newHigh920Buy_andTree_firesOnUptrendWithVolume() throws Exception { void newHigh920Buy_andTree_firesOnUptrendWithVolume() throws Exception {
BarSeries series = buildTrendSeries(120, true); BarSeries series = buildConsolidationBreakoutTrendBars(true);
// balanced 가격 다리 — ADX·거래량은 별도 통합 테스트
Rule buy = rule(series, """ Rule buy = rule(series, """
{ "type": "AND", "children": [ { "type": "AND", "priceExtremePair": { "mode": "buy", "filterLevel": "balanced" },
{ "type": "CONDITION", "condition": { "children": [
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 }},
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP", "indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP",
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }}, "leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }},
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "GTE", "indicatorType": "NEW_HIGH", "conditionType": "GTE",
"leftField": "HIGH_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }}, "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" }}
]} ]}
"""); """);
List<Integer> hits = satisfiedIndices(buy, series); List<Integer> hits = satisfiedIndices(buy, series);
assertTrue(hits.stream().noneMatch(i -> i < 20), assertTrue(hits.stream().noneMatch(i -> i < 20),
"워밍업 구간(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<Integer> 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 @Test
void newLow920Sell_andTree_firesOnDowntrend() throws Exception { void newLow920Sell_andTree_firesOnDowntrend() throws Exception {
BarSeries series = buildTrendSeries(120, false); BarSeries series = buildConsolidationBreakoutTrendBars(false);
Rule sell = rule(series, """ Rule sell = rule(series, """
{ "type": "AND", "children": [ { "type": "AND", "priceExtremePair": { "mode": "sell", "filterLevel": "balanced" },
{ "type": "CONDITION", "condition": { "children": [
"indicatorType": "NEW_LOW", "conditionType": "LTE",
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 }},
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN", "indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN",
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }}, "leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }},
{ "type": "CONDITION", "condition": { { "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW", "conditionType": "LTE", "indicatorType": "NEW_LOW", "conditionType": "LTE",
"leftField": "LOW_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }}, "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" }}
]} ]}
"""); """);
List<Integer> hits = satisfiedIndices(sell, series); List<Integer> 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 @Test
@@ -193,8 +228,80 @@ class PriceExtremeRuleTest {
"9봉 워밍업 이후 돌파 봉에서만 시그널"); "9봉 워밍업 이후 돌파 봉에서만 시그널");
} }
private static BarSeries buildConsolidationBreakoutTrendBars(boolean up) {
return ohlcvToBarSeries(buildConsolidationBreakoutTrendOhlcvForRule(up));
}
static List<OhlcvBar> buildConsolidationBreakoutTrendOhlcvForRule(boolean up) {
List<OhlcvBar> 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<OhlcvBar> bars) {
return OhlcvBarSeriesSupport.buildSeries(bars, "1d");
}
// ── helpers ───────────────────────────────────────────────────────────── // ── 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 검증용 */ /** 횡보 후 한 봉에 종가 돌파 — NH_PRIOR CROSS_UP 검증용 */
private static BarSeries buildFlatThenBreakoutSeries(double flatPrice, double breakoutClose, int flatBars) { private static BarSeries buildFlatThenBreakoutSeries(double flatPrice, double breakoutClose, int flatBars) {
Duration period = Duration.ofMinutes(3); Duration period = Duration.ofMinutes(3);
@@ -223,6 +330,27 @@ class PriceExtremeRuleTest {
return series; 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 { private Rule rule(BarSeries series, String json) throws Exception {
JsonNode node = MAPPER.readTree(json); JsonNode node = MAPPER.readTree(json);
return adapter.toRule(node, series, Map.of()); return adapter.toRule(node, series, Map.of());
@@ -252,11 +380,13 @@ class PriceExtremeRuleTest {
double close = price + delta; double close = price + delta;
double high = Math.max(price, close) + 0.5; double high = Math.max(price, close) + 0.5;
double low = Math.min(price, close) - 0.5; double low = Math.min(price, close) - 0.5;
// 거래량>MA 필터 — 추세 구간마다 MA 대비 큰 거래량
double volume = (i % 5 == 0) ? 5000 : 800;
factory.createBarBuilder(series) factory.createBarBuilder(series)
.timePeriod(period) .timePeriod(period)
.endTime(base.plus(period.multipliedBy(i + 1L))) .endTime(base.plus(period.multipliedBy(i + 1L)))
.openPrice(price).highPrice(high).lowPrice(low).closePrice(close) .openPrice(price).highPrice(high).lowPrice(low).closePrice(close)
.volume(1000) .volume(volume)
.add(); .add();
price = close; price = close;
} }
@@ -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<OhlcvBar> 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<Arguments> 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<OhlcvBar> 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<Arguments> 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<OhlcvBar> 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<OhlcvBar> 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<OhlcvBar> 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<OhlcvBar> 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<OhlcvBar> 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<OhlcvBar> 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<OhlcvBar> bars) throws Exception {
return scanHits(buyDslRaw, bars, true);
}
private int scanSellHits(JsonNode sellDslRaw, List<OhlcvBar> bars) throws Exception {
return scanHits(sellDslRaw, bars, false);
}
private int scanHits(JsonNode dslRaw, List<OhlcvBar> bars, boolean entry) throws Exception {
JsonNode dsl = normalizer.remapForChartTimeframe(dslRaw, CHART_TF);
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, CHART_TF);
Map<String, BarSeries> 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<String, Map<String, Object>> 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<OhlcvBar> buildFlatThenBreakoutOhlcv(double flatPrice, double breakoutClose, int flatBars) {
List<OhlcvBar> 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<OhlcvBar> buildFlatThenBreakdownOhlcv(double flatPrice, double breakdownClose, int flatBars) {
List<OhlcvBar> 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<OhlcvBar> buildConsolidationBreakoutTrendOhlcv(boolean up) {
return PriceExtremeRuleTest.buildConsolidationBreakoutTrendOhlcvForRule(up);
}
private static List<OhlcvBar> buildUptrendOhlcv(int count, double startPrice, double step) {
List<OhlcvBar> 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<OhlcvBar> buildDowntrendOhlcv(int count, double startPrice, double step) {
List<OhlcvBar> 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;
}
}