Files
goldenChart/backend/src/test/java/com/goldenchart/service/PriceExtremeRuleTest.java
T

214 lines
8.4 KiB
Java

package com.goldenchart.service;
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 = buildTrendSeries(120, true);
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(), "상승 구간에서 최소 1회 이상 매수 조건 충족");
}
@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 = buildCrashThenRecoverSeries(80);
Rule buy = rule(series, """
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9
}}
""");
Rule sell = rule(series, """
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW", "conditionType": "LTE",
"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 = buildCrashThenRecoverSeries(80);
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 로 정규화하면 하락 구간에서 매도가 나와야 함");
}
// ── helpers ─────────────────────────────────────────────────────────────
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;
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 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) {}
}