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.Map; import static org.junit.jupiter.api.Assertions.*; /** * CCI 본선(CCI_VALUE_20) ↔ 신호선(CCI_SIGNAL) 교차 — 차트와 동일 Rule 평가. */ class CciCrossSignalScanTest { private static final ObjectMapper MAPPER = new ObjectMapper(); private StrategyDslToTa4jAdapter adapter; private BarSeries series; @BeforeEach void setUp() { adapter = new StrategyDslToTa4jAdapter(); series = buildOscillatingSeries(150); } @Test void cciSignal_usesLeftFieldPeriod_notGlobalLength() throws Exception { JsonNode cond = MAPPER.readTree(""" { "indicatorType": "CCI", "conditionType": "CROSS_UP", "leftField": "CCI_VALUE_20", "rightField": "CCI_SIGNAL", "period": 20, "valuePeriodOverride": true } """); Map params = Map.of("length", 13, "maLength", 10, "maType", "SMA", "src", "hlc3"); var value20 = adapter.resolveIndicatorFieldForTest( "CCI_VALUE_20", "CCI", params, series, 20, -1, cond); var signalLinked = adapter.resolveIndicatorFieldForTest( "CCI_SIGNAL", "CCI", params, series, 20, -1, cond); var signalUnlinked = adapter.resolveIndicatorFieldForTest( "CCI_SIGNAL", "CCI", params, series, -1, -1, null); int idx = 80; assertNotEquals(signalLinked.getValue(idx).doubleValue(), signalUnlinked.getValue(idx).doubleValue(), 1e-9, "CCI_SIGNAL must use CCI(20) from leftField, not global length=13"); assertNotEquals(value20.getValue(idx).doubleValue(), signalLinked.getValue(idx).doubleValue(), 1e-9, "signal line is SMA(CCI), not raw CCI"); } @Test void cciCrossUp_firesOnAtLeastOneBar() throws Exception { JsonNode buyDsl = MAPPER.readTree(""" { "type": "TIMEFRAME", "candleType": "5m", "children": [{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "conditionType": "CROSS_UP", "leftField": "CCI_VALUE_20", "rightField": "CCI_SIGNAL", "period": 20, "valuePeriodOverride": true, "candleRange": 1 } }] } """); Map> indicatorParams = Map.of( "CCI", Map.of("length", 20, "maLength", 10, "maType", "SMA", "src", "hlc3")); StrategyDslToTa4jAdapter.RuleBuildContext ctx = new StrategyDslToTa4jAdapter.RuleBuildContext( series, indicatorParams, Map.of(), null, null, false, Map.of()); Rule rule = adapter.toRule(buyDsl, ctx); int hits = 0; for (int i = series.getBeginIndex(); i <= series.getEndIndex(); i++) { if (rule.isSatisfied(i, null)) hits++; } assertTrue(hits > 0, "CCI(20) cross signal(10) should fire at least once on oscillating series, got " + hits); } private static BarSeries buildOscillatingSeries(int count) { BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder() .withName("cci-cross-test") .withNumFactory(DoubleNumFactory.getInstance()) .withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(5), 1)); BarSeries s = builder.build(); Instant t = Instant.parse("2024-06-01T00:00:00Z"); double price = 100_000_000.0; for (int i = 0; i < count; i++) { double wave = Math.sin(i * 0.22) * 800_000 + Math.sin(i * 0.07) * 400_000; double close = price + wave + i * 5000; double open = close - 20_000; double high = close + 80_000; double low = close - 80_000; s.addBar(t, open, high, low, close, 50 + i); t = t.plus(Duration.ofMinutes(5)); } return s; } }