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

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
@@ -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<Integer> 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<Integer> 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<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
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<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
@@ -193,8 +228,80 @@ class PriceExtremeRuleTest {
"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 ─────────────────────────────────────────────────────────────
/** 횡보 후 한 봉에 종가 이탈 — 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;
}