신고가 매수 시그널 오류 수정
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user