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.bars.TimeBarBuilderFactory; import org.ta4j.core.num.DoubleNumFactory; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; /** * RSI CROSS_UP — HL_MID(중앙선) vs K_50(직접입력) 동일 임계값 시그널 일치. */ class RsiThresholdCrossSignalTest { private static final ObjectMapper MAPPER = new ObjectMapper(); private StrategyDslToTa4jAdapter adapter; private BarSeries series; @BeforeEach void setUp() { adapter = new StrategyDslToTa4jAdapter(); series = buildOscillatingSeries(200); } @Test void rsiCrossUp_hlMid_equalsK50Override() throws Exception { assertSignalHitsEqual( """ { "indicatorType": "RSI", "conditionType": "CROSS_UP", "leftField": "RSI_VALUE_9", "rightField": "HL_MID", "period": 9, "valuePeriodOverride": true, "thresholdOverride": false, "candleRange": 1 } """, """ { "indicatorType": "RSI", "conditionType": "CROSS_UP", "leftField": "RSI_VALUE_9", "rightField": "K_50", "period": 9, "valuePeriodOverride": true, "thresholdOverride": true, "targetValue": 50, "candleRange": 1 } """); } @Test void rsiCrossUp_hlMid_equalsK50WithoutOverride() throws Exception { assertSignalHitsEqual( """ { "indicatorType": "RSI", "conditionType": "CROSS_UP", "leftField": "RSI_VALUE_9", "rightField": "HL_MID", "period": 9, "valuePeriodOverride": true, "candleRange": 1 } """, """ { "indicatorType": "RSI", "conditionType": "CROSS_UP", "leftField": "RSI_VALUE_9", "rightField": "K_50", "period": 9, "valuePeriodOverride": true, "candleRange": 1 } """); } private void assertSignalHitsEqual(String condA, String condB) throws Exception { Map> indicatorParams = Map.of( "RSI", Map.of("length", 9, "maLength", 3, "maType", "SMA", "src", "close")); Map> visual = Map.of( "RSI", Map.of("hlines", List.of( Map.of("label", "과열선", "price", 70), Map.of("label", "중앙선", "price", 50), Map.of("label", "침체선", "price", 30)))); List hitsA = scanHits(condA, indicatorParams, visual); List hitsB = scanHits(condB, indicatorParams, visual); assertEquals(hitsA, hitsB, "HL_MID and K_50 should produce identical CROSS_UP bars, A=" + hitsA.size() + " B=" + hitsB.size()); } private List scanHits(String condJson, Map> indicatorParams, Map> visual) throws Exception { JsonNode buyDsl = MAPPER.readTree(""" { "type": "TIMEFRAME", "candleTypes": ["3m"], "candleType": "3m", "children": [{ "type": "CONDITION", "condition": %s }] } """.formatted(condJson.trim())); StrategyDslToTa4jAdapter.RuleBuildContext ctx = new StrategyDslToTa4jAdapter.RuleBuildContext( series, indicatorParams, visual, null, null, false, Map.of()); Rule rule = adapter.toRule(buyDsl, ctx); java.util.ArrayList hits = new java.util.ArrayList<>(); for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) { if (rule.isSatisfied(i, null)) hits.add(i); } return hits; } private static BarSeries buildOscillatingSeries(int count) { BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder() .withName("test") .withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(3))) .withNumFactory(DoubleNumFactory.getInstance()); BarSeries s = builder.build(); Instant t = Instant.parse("2024-01-01T00:00:00Z"); double price = 100; for (int i = 0; i < count; i++) { double swing = Math.sin(i * 0.15) * 8 + Math.sin(i * 0.04) * 4; price = 100 + swing + (i * 0.02); double o = price - 0.5; double h = price + 1.5; double l = price - 1.5; double c = price; s.addBar(t, o, h, l, c, 1000); t = t.plus(Duration.ofMinutes(3)); } return s; } }