package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.trading.pipeline.TradingPipelineProperties; 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.Map; import static org.junit.jupiter.api.Assertions.*; /** * 사용자 복합 전략 DSL(다중 START · AND/OR · MACD/DMI/ADX/CCI/RSI/VR)이 * StrategyDslToTa4jAdapter 에서 Rule 로 변환·평가 가능한지 검증. */ class ComplexStrategyDslAdapterTest { private static final String MARKET = "KRW-BTC"; private static final ObjectMapper MAPPER = new ObjectMapper(); private StrategyDslToTa4jAdapter adapter; private Ta4jStorage storage; private BarSeries primary1m; @BeforeEach void setUp() { adapter = new StrategyDslToTa4jAdapter(); TradingPipelineProperties props = new TradingPipelineProperties(); props.setEnabled(false); storage = new Ta4jStorage(props); primary1m = buildSyntheticSeries("1m", 260); seedStorage(MARKET, "1m", primary1m); seedStorage(MARKET, "3m", buildSyntheticSeries("3m", 120)); seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80)); } /** OR(AND, AND) — 4개 조건을 평탄 OR로 처리하면 true가 되는 케이스에서 false여야 함 */ @Test void orAndNested_doesNotFlattenChildrenToOr() throws Exception { JsonNode buy = MAPPER.readTree(""" { "type": "OR", "children": [ { "type": "AND", "children": [ { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1 }}, { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 }} ] }, { "type": "AND", "children": [ { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1 }}, { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 }} ] } ] } """); Rule rule = adapter.toRule(buy, primary1m, Map.of()); int idx = primary1m.getEndIndex(); // 평탄 OR(4조건)이면 close>K_1 하나만으로 true — AND 중첩이면 각 분기 false → 전체 false assertFalse(rule.isSatisfied(idx, null), "OR(AND)는 분기 내 모든 조건이 충족될 때만 true — 단일 true 자식으로는 true가 되면 안 됨"); } @Test void orAndNested_oneFullAndBranchSatisfies() throws Exception { JsonNode buy = MAPPER.readTree(""" { "type": "OR", "children": [ { "type": "AND", "children": [ { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 }}, { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_0", "candleRange": 1 }} ] }, { "type": "AND", "children": [ { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1 }}, { "type": "CONDITION", "condition": { "indicatorType": "MA", "conditionType": "GT", "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 }} ] } ] } """); Rule rule = adapter.toRule(buy, primary1m, Map.of()); assertTrue(rule.isSatisfied(primary1m.getEndIndex(), null), "OR(AND) — 한 분기의 AND가 모두 true이면 전체 true"); } /** Logic Expression 예시와 동일 구조의 매수 조건 */ @Test void buyCondition_compilesAndEvaluates() throws Exception { JsonNode buy = MAPPER.readTree(""" { "id": "buy-root", "type": "OR", "children": [ { "id": "buy-and-1", "type": "AND", "children": [ { "id": "c-macd", "type": "CONDITION", "condition": { "indicatorType": "MACD", "conditionType": "CROSS_UP", "leftField": "MACD_LINE", "rightField": "SIGNAL_LINE", "candleRange": 1, "period": 12 } }, { "id": "c-dmi", "type": "CONDITION", "condition": { "indicatorType": "DMI", "conditionType": "CROSS_UP", "leftField": "PDI", "rightField": "MDI", "candleRange": 1, "period": 14 } } ] }, { "id": "buy-and-2", "type": "AND", "children": [ { "id": "c-adx", "type": "CONDITION", "condition": { "indicatorType": "ADX", "conditionType": "CROSS_UP", "leftField": "ADX_VALUE", "rightField": "K_25", "candleRange": 1, "period": 14 } }, { "id": "c-cci", "type": "CONDITION", "condition": { "indicatorType": "CCI", "conditionType": "CROSS_UP", "leftField": "CCI_VALUE", "rightField": "K_100", "candleRange": 1, "period": 13 } } ] } ] } """); Rule rule = adapter.toRule(buy, primary1m, Map.of(), MARKET, storage); assertNotNull(rule); assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex())); } /** Logic Expression 예시와 동일 구조의 매도 조건 (1m AND 5m AND 3m) */ @Test void sellCondition_multiTimeframe_compilesAndEvaluates() throws Exception { JsonNode sell = MAPPER.readTree(""" { "id": "sell-root", "type": "AND", "children": [ { "id": "tf-1m", "type": "TIMEFRAME", "candleType": "1m", "children": [{ "id": "sell-1m-and", "type": "AND", "children": [ { "id": "c-rsi-1m", "type": "CONDITION", "condition": { "indicatorType": "RSI", "conditionType": "CROSS_DOWN", "leftField": "RSI_VALUE", "rightField": "K_70", "candleRange": 1, "period": 9 } }, { "id": "c-macd-1m", "type": "CONDITION", "condition": { "indicatorType": "MACD", "conditionType": "CROSS_DOWN", "leftField": "MACD_LINE", "rightField": "SIGNAL_LINE", "candleRange": 1, "period": 12 } }, { "id": "c-vr-1m", "type": "CONDITION", "condition": { "indicatorType": "VR", "conditionType": "CROSS_DOWN", "leftField": "VR_VALUE", "rightField": "K_200", "candleRange": 1, "period": 10 } } ] }] }, { "id": "tf-5m", "type": "TIMEFRAME", "candleType": "5m", "children": [{ "id": "sell-5m-and", "type": "AND", "children": [ { "id": "c-adx-5m", "type": "CONDITION", "condition": { "indicatorType": "ADX", "conditionType": "CROSS_DOWN", "leftField": "ADX_VALUE", "rightField": "K_28", "candleRange": 1, "period": 14 } }, { "id": "c-cci-5m", "type": "CONDITION", "condition": { "indicatorType": "CCI", "conditionType": "CROSS_DOWN", "leftField": "CCI_VALUE", "rightField": "K_26", "candleRange": 1, "period": 13 } } ] }] }, { "id": "tf-3m", "type": "TIMEFRAME", "candleType": "3m", "children": [{ "id": "sell-3m-and", "type": "AND", "children": [ { "id": "c-rsi-3m", "type": "CONDITION", "condition": { "indicatorType": "RSI", "conditionType": "CROSS_DOWN", "leftField": "RSI_VALUE", "rightField": "K_20", "candleRange": 1, "period": 9 } }, { "id": "c-vr-3m", "type": "CONDITION", "condition": { "indicatorType": "VR", "conditionType": "CROSS_DOWN", "leftField": "VR_VALUE", "rightField": "K_20", "candleRange": 1, "period": 10 } } ] }] } ] } """); Rule rule = adapter.toRule(sell, primary1m, Map.of(), MARKET, storage); assertNotNull(rule); assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex())); // CrossSeriesRule: 1m 트리거 인덱스에서 5m·3m 시리즈 endIndex 로 평가 int idx = primary1m.getEndIndex(); boolean result = rule.isSatisfied(idx, null); assertTrue(result || !result, "다중 시간봉 AND Rule 평가가 예외 없이 boolean 반환"); } /** 백테스트 경로( storage 미전달 ) — 단일 시리즈 fallback 동작 확인 */ @Test void sellCondition_backtestSingleSeries_fallback() throws Exception { JsonNode sell = MAPPER.readTree(""" { "type": "AND", "children": [ { "type": "TIMEFRAME", "candleType": "1m", "children": [ { "type": "CONDITION", "condition": { "indicatorType": "RSI", "conditionType": "CROSS_DOWN", "leftField": "RSI_VALUE", "rightField": "K_70", "period": 9 }} ]}, { "type": "TIMEFRAME", "candleType": "5m", "children": [ { "type": "CONDITION", "condition": { "indicatorType": "ADX", "conditionType": "CROSS_DOWN", "leftField": "ADX_VALUE", "rightField": "K_28", "period": 14 }} ]} ] } """); Rule rule = adapter.toRule(sell, primary1m, Map.of()); assertNotNull(rule); assertDoesNotThrow(() -> rule.isSatisfied(primary1m.getEndIndex(), null)); } @Test void stochLowestLte_compilesAndEvaluates() throws Exception { JsonNode cond = MAPPER.readTree(""" { "id": "c-stoch-lowest", "type": "CONDITION", "condition": { "indicatorType": "STOCHASTIC", "conditionType": "LOWEST_LTE", "leftField": "STOCH_K", "rightField": "HL_UNDER", "lookbackPeriod": 20, "candleRange": 1 } } """); Rule rule = adapter.toRule(cond, primary1m, Map.of()); assertNotNull(rule); assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex())); } // ── helpers ──────────────────────────────────────────────────────────────── private static void evaluateRange(Rule rule, int endIndex) { TradingRecord record = null; int start = Math.max(30, endIndex - 20); for (int i = start; i <= endIndex; i++) { rule.isSatisfied(i, record); } } private static BarSeries buildSyntheticSeries(String timeframe, int barCount) { Duration period = switch (timeframe) { case "3m" -> Duration.ofMinutes(3); case "5m" -> Duration.ofMinutes(5); default -> Duration.ofMinutes(1); }; BarSeries series = new BaseBarSeriesBuilder() .withName("test-" + timeframe) .withNumFactory(DoubleNumFactory.getInstance()) .withBarBuilderFactory(new TimeBarBuilderFactory()) .build(); TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); Instant base = Instant.parse("2024-06-01T00:00:00Z"); for (int i = 0; i < barCount; i++) { double wave = Math.sin(i * 0.07) * 8 + Math.cos(i * 0.02) * 4; double close = 50_000_000 + i * 120 + wave * 1000; double open = close - 500; double high = close + 800; double low = close - 800; Instant end = base.plus(period.multipliedBy(i + 1L)); factory.createBarBuilder(series) .timePeriod(period) .endTime(end) .openPrice(open).highPrice(high).lowPrice(low).closePrice(close) .volume(12.5 + i * 0.01) .add(); } return series; } private void seedStorage(String market, String candleType, BarSeries source) { for (int i = source.getBeginIndex(); i <= source.getEndIndex(); i++) { storage.addBar(market, candleType, source.getBar(i)); } } }