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