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 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 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 satisfiedIndices(Rule rule, BarSeries series) { List 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 buyBars = new ArrayList<>(); List 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 buyBars, List sellBars) {} }