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 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 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 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 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 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 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 buildConsolidationBreakoutTrendOhlcvForRule(boolean up) { List 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 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 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; // 거래량>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 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) {} }