447 lines
19 KiB
Java
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) {}
|
|
}
|