Files
goldenChart/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java
T
2026-06-22 13:11:57 +09:00

447 lines
19 KiB
Java

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;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.Rule;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DoubleNumFactory;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* 9일 신고가 매수 / 9일 신저가 매도 — Rule·백테스트 루프 동작 검증.
*/
class PriceExtremeRuleTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private StrategyDslToTa4jAdapter adapter;
@BeforeEach
void setUp() {
adapter = new StrategyDslToTa4jAdapter();
}
@Test
void nhPrior_buyGte_firesOnBreakout_notOnFirstBars() 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": "GTE",
"leftField": "CLOSE_PRICE",
"rightField": "NH_PRIOR_9",
"period": 9
}}
""");
List<Integer> hits = satisfiedIndices(buy, series);
assertTrue(hits.stream().noneMatch(i -> i < 9),
"9봉 미만에서는 신고가선이 정의되지 않아 매수 조건이 없어야 함");
assertFalse(hits.isEmpty(), "돌파 봉에서 GTE 신고가 조건 충족");
}
@Test
void nlPrior_sellLte_firesOnBreakdown() throws Exception {
BarSeries series = buildTrendSeries(120, false);
Rule sell = rule(series, """
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW",
"conditionType": "LTE",
"leftField": "CLOSE_PRICE",
"rightField": "NL_PRIOR_9",
"period": 9
}}
""");
List<Integer> hits = satisfiedIndices(sell, series);
assertFalse(hits.isEmpty(), "하락 구간에서 신저가 이탈 조건이 최소 1회 충족되어야 함");
}
/** LONG_ONLY 백테스트 루프 — 매수 후 하락 시 매도가 나와야 함 */
@Test
void longOnlyLoop_buyThenSell_completesRoundTrip() throws Exception {
BarSeries series = buildConsolidationBreakoutTrendBars(true);
// 하락 leg 추가
series = appendDowntrendLeg(series, 40, 1.5);
Rule buy = rule(series, """
{ "type": "CONDITION", "condition": {
"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": "CROSS_DOWN",
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9
}}
""");
SimResult sim = simulateLongOnly(buy, sell, series);
assertTrue(sim.buyBars.size() >= 1, "매수 시그널 최소 1회");
assertTrue(sim.sellBars.size() >= 1,
"하락 구간에서 신저가 매도가 나와야 함. buys=" + sim.buyBars + " sells=" + sim.sellBars);
}
@Test
void normalizedLegacySell_inLoop_producesSellAfterAdapterMigrate() throws Exception {
BarSeries series = buildConsolidationBreakoutTrendBars(true);
series = appendDowntrendLeg(series, 40, 1.5);
Rule buy = rule(series, """
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9
}}
""");
Rule sellLegacy = rule(series, """
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW",
"conditionType": "CROSS_DOWN",
"composite": true,
"leftField": "CLOSE_PRICE",
"rightField": "DC_LOWER_9",
"leftPeriod": 9,
"rightPeriod": 20
}}
""");
SimResult sim = simulateLongOnly(buy, sellLegacy, series);
assertTrue(sim.buyBars.size() >= 1);
assertTrue(sim.sellBars.size() >= 1,
"어댑터가 DC_LOWER→NL_PRIOR 로 정규화하면 하락 구간에서 매도가 나와야 함");
}
@Test
void newHigh920Buy_andTree_firesOnUptrendWithVolume() throws Exception {
BarSeries series = buildConsolidationBreakoutTrendBars(true);
// balanced 가격 다리 — ADX·거래량은 별도 통합 테스트
Rule buy = rule(series, """
{ "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 }}
]}
""");
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 = buildConsolidationBreakoutTrendBars(false);
Rule sell = rule(series, """
{ "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 }}
]}
""");
List<Integer> hits = satisfiedIndices(sell, series);
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
void nhPrior_crossUp_firesOnRealPriceBreakout_notConstantNine() 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
}}
""");
List<Integer> hits = satisfiedIndices(buy, series);
assertFalse(hits.isEmpty(),
"97M대 종가가 9일 신고가선 상향 돌파 시 CROSS_UP — NH_PRIOR_9를 상수 9로 해석하면 영원히 false");
assertTrue(hits.stream().anyMatch(i -> i >= 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 ─────────────────────────────────────────────────────────────
/** 횡보 후 한 봉에 종가 이탈 — 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);
BarSeries series = new BaseBarSeriesBuilder()
.withName("flat-breakout")
.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(breakoutClose + 500).lowPrice(flatPrice - 500).closePrice(breakoutClose)
.volume(5000)
.add();
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());
}
private List<Integer> satisfiedIndices(Rule rule, BarSeries series) {
List<Integer> hits = new ArrayList<>();
TradingRecord record = null;
for (int i = 0; i < series.getBarCount(); i++) {
if (rule.isSatisfied(i, record)) hits.add(i);
}
return hits;
}
private static BarSeries buildTrendSeries(int barCount, boolean up) {
Duration period = Duration.ofDays(1);
BarSeries series = new BaseBarSeriesBuilder()
.withName("trend")
.withNumFactory(DoubleNumFactory.getInstance())
.withBarBuilderFactory(new TimeBarBuilderFactory())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
Instant base = Instant.parse("2024-01-01T00:00:00Z");
double price = 100.0;
for (int i = 0; i < barCount; i++) {
double delta = up ? 1.5 : -1.5;
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(volume)
.add();
price = close;
}
return series;
}
/** 초반 횡보 후 급락 → 회복 (차트와 유사) */
private static BarSeries buildCrashThenRecoverSeries(int barCount) {
Duration period = Duration.ofDays(1);
BarSeries series = new BaseBarSeriesBuilder()
.withName("crash-recover")
.withNumFactory(DoubleNumFactory.getInstance())
.withBarBuilderFactory(new TimeBarBuilderFactory())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
Instant base = Instant.parse("2024-01-01T00:00:00Z");
double price = 100_000_000;
for (int i = 0; i < barCount; i++) {
double chg;
if (i < 15) chg = (i % 3 - 1) * 0.002;
else if (i < 35) chg = -0.04;
else chg = 0.008;
double close = price * (1 + chg);
double high = Math.max(price, close) * 1.005;
double low = Math.min(price, close) * 0.995;
factory.createBarBuilder(series)
.timePeriod(period)
.endTime(base.plus(period.multipliedBy(i + 1L)))
.openPrice(price).highPrice(high).lowPrice(low).closePrice(close)
.volume(1000)
.add();
price = close;
}
return series;
}
private SimResult simulateLongOnly(Rule entryRule, Rule exitRule, BarSeries series) {
List<Integer> buyBars = new ArrayList<>();
List<Integer> sellBars = new ArrayList<>();
boolean inPosition = false;
org.ta4j.core.BaseTradingRecord record = new org.ta4j.core.BaseTradingRecord();
for (int i = 0; i < series.getBarCount(); i++) {
if (!inPosition) {
if (entryRule.isSatisfied(i, record)) {
buyBars.add(i);
inPosition = true;
}
} else if (exitRule.isSatisfied(i, record)) {
sellBars.add(i);
inPosition = false;
}
}
return new SimResult(buyBars, sellBars);
}
private record SimResult(List<Integer> buyBars, List<Integer> sellBars) {}
}