diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index bc2bd13..78a693c 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -463,9 +463,9 @@ public class StrategyDslToTa4jAdapter { buildEqRule(left, right, series)); case "EQ" -> buildEqRule(left, right, series); case "NEQ" -> new NotRule(buildEqRule(left, right, series)); - // CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음 - case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right); - case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right); + // 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외) + case "CROSS_UP" -> buildCrossUpRule(left, right); + case "CROSS_DOWN" -> buildCrossDownRule(left, right); case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod); case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod); case "DIFF_GT" -> { @@ -750,7 +750,7 @@ public class StrategyDslToTa4jAdapter { yield new ConstantIndicator<>(s, s.numFactory().numOf(constant)); } Map sma = ctx != null ? ctx.smaParams() : Map.of(); - yield resolveIndicatorField(field, indType, p, sma, s, condPeriod, sidePeriod); + yield resolveIndicatorField(field, indType, p, sma, s, condPeriod, sidePeriod, cond); } }; } @@ -787,7 +787,8 @@ public class StrategyDslToTa4jAdapter { Map p, Map smaParams, BarSeries s, - int condPeriod, int sidePeriod) { + int condPeriod, int sidePeriod, + JsonNode cond) { ClosePriceIndicator close = new ClosePriceIndicator(s); int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1); @@ -798,13 +799,13 @@ public class StrategyDslToTa4jAdapter { } if (field.equals("CCI_VALUE") || indType.equals("CCI")) return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)), - periodOverride > 0 ? periodOverride : intP(p, "length", 13)); + effectivePeriod(periodOverride, p, "length", 13)); if (field.equals("CCI_SIGNAL")) { Map norm = CciOnSourceIndicator.normalizeParams(p); - int cciLen = periodOverride > 0 ? periodOverride : intP(p, "length", 13); + int cciLen = resolveLinkedBasePeriod(cond, p, "CCI_VALUE", periodOverride, "length", 13); CciOnSourceIndicator cci = new CciOnSourceIndicator(resolvePriceSource(s, norm), cciLen); String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA"; - int maLen = intP(p, "maLength", 10); + int maLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 10); if ("None".equals(maType)) return cci; return switch (maType) { case "EMA" -> new EMAIndicator(cci, maLen); @@ -818,12 +819,12 @@ public class StrategyDslToTa4jAdapter { return new RSIIndicator(close, period); } if (field.equals("RSI_VALUE")) - return new RSIIndicator(close, periodOverride > 0 ? periodOverride : intP(p, "length", 14)); + return new RSIIndicator(close, effectivePeriod(periodOverride, p, "length", 14)); if (field.equals("RSI_SIGNAL")) { - int rsiLen = periodOverride > 0 ? periodOverride : intP(p, "length", 14); + int rsiLen = resolveLinkedBasePeriod(cond, p, "RSI_VALUE", periodOverride, "length", 14); RSIIndicator rsi = new RSIIndicator(close, rsiLen); String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA"; - int maLen = intP(p, "maLength", 14); + int maLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 14); if ("None".equals(maType)) return rsi; return switch (maType) { case "EMA" -> new EMAIndicator(rsi, maLen); @@ -831,17 +832,16 @@ public class StrategyDslToTa4jAdapter { default -> new SMAIndicator(rsi, maLen); }; } - // MACD + // MACD — cond.period → fastLength (프론트 대표 기간) if (field.equals("MACD_LINE")) { - return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)); + return buildMacd(close, p, periodOverride); } if (field.equals("SIGNAL_LINE")) { - return new EMAIndicator( - new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)), - intP(p, "signalLength", 9)); + MACDIndicator macd = buildMacd(close, p, periodOverride); + return new EMAIndicator(macd, intP(p, "signalLength", 9)); } if (field.equals("HISTOGRAM")) { - MACDIndicator macd = new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26)); + MACDIndicator macd = buildMacd(close, p, periodOverride); return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9))); } // ADX / DMI @@ -850,13 +850,14 @@ public class StrategyDslToTa4jAdapter { return new ADXIndicator(s, intP(p, "diLength", 14), period); } if (field.equals("ADX_VALUE")) - return new ADXIndicator(s, intP(p, "diLength", 14), intP(p, "adxSmoothing", 14)); + return new ADXIndicator(s, intP(p, "diLength", 14), + effectivePeriod(periodOverride, p, "adxSmoothing", 14)); if (field.equals("PDI") || field.equals("PLUS_DI")) { - int diLen = intP(p, "diLength", intP(p, "length", 14)); + int diLen = effectivePeriod(periodOverride, p, "diLength", intP(p, "length", 14)); return new PlusDIIndicator(s, diLen); } if (field.equals("MDI") || field.equals("MINUS_DI")) { - int diLen = intP(p, "diLength", intP(p, "length", 14)); + int diLen = effectivePeriod(periodOverride, p, "diLength", intP(p, "length", 14)); return new MinusDIIndicator(s, diLen); } // Stochastic Slow — IndicatorService.calcStochastic 과 동일한 로직: @@ -864,12 +865,12 @@ public class StrategyDslToTa4jAdapter { // slow %K = SMA(raw %K, smooth) // %D = SMA(slow %K, dSmoothing) if (field.equals("STOCH_K")) { - int kLen = intP(p, "kLength", 14); + int kLen = effectivePeriod(periodOverride, p, "kLength", 14); int smooth = intP(p, "smooth", 3); return new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth); } if (field.equals("STOCH_D")) { - int kLen = intP(p, "kLength", 14); + int kLen = effectivePeriod(periodOverride, p, "kLength", 14); int smooth = intP(p, "smooth", 3); int dSmooth = intP(p, "dSmoothing", 3); SMAIndicator slowK = new SMAIndicator(new StochasticOscillatorKIndicator(s, kLen), smooth); @@ -878,29 +879,23 @@ public class StrategyDslToTa4jAdapter { // TRIX — 종가 3×EMA ROC% (업비트·키움) if (field.startsWith("TRIX_VALUE_")) { int len = parseTrailingInt(field, "TRIX_VALUE_", intP(p, "length", 12)); - EMAIndicator e3 = tripleEma(close, len); - PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); - return NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100); + return buildTrixLine(close, len); } - if (field.equals("TRIX_VALUE")) { - int len = intP(p, "length", 12); - EMAIndicator e3 = tripleEma(close, len); - PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); - return NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100); + if (field.equals("TRIX_VALUE") || indType.equals("TRIX")) { + return buildTrixLine(close, effectivePeriod(periodOverride, p, "length", 12)); } if (field.equals("TRIX_SIGNAL")) { - int len = intP(p, "length", 12); - EMAIndicator e3 = tripleEma(close, len); - PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); - NumericIndicator trix = NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100); - return new EMAIndicator(trix, intP(p, "signalLength", 9)); + int trixLen = resolveLinkedBasePeriod(cond, p, "TRIX_VALUE", periodOverride, "length", 12); + NumericIndicator trix = buildTrixLine(close, trixLen); + int sigLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "signalLength", 9); + return new EMAIndicator(trix, sigLen); } // OBV if (field.equals("OBV_LINE")) return new OnBalanceVolumeIndicator(s); if (field.equals("OBV_SIGNAL")) { OnBalanceVolumeIndicator obv = new OnBalanceVolumeIndicator(s); String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA"; - int maLen = intP(p, "maLength", 9); + int maLen = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 9); return switch (maType) { case "EMA" -> new EMAIndicator(obv, maLen); case "WMA" -> new WMAIndicator(obv, maLen); @@ -913,7 +908,7 @@ public class StrategyDslToTa4jAdapter { return vrIndicator(s, period); } if (field.equals("VR_VALUE") || indType.equals("VR")) - return vrIndicator(s, intP(p, "length", 10)); + return vrIndicator(s, effectivePeriod(periodOverride, p, "length", 10)); // 이격도 DISPARITY5 → period 5 if (field.startsWith("DISPARITY")) { int period = parseTrailingInt(field, "DISPARITY", 20); @@ -924,25 +919,33 @@ public class StrategyDslToTa4jAdapter { return psychIndicator(s, period, false); } if (field.equals("PSY_VALUE") || indType.equals("PSYCHOLOGICAL")) - return psychIndicator(s, intP(p, "length", 12), false); + return psychIndicator(s, effectivePeriod(periodOverride, p, "length", 12), false); if (field.startsWith("NEW_PSY_VALUE_")) { int period = parseTrailingInt(field, "NEW_PSY_VALUE_", intP(p, "length", 10)); return newSentPsyIndicator(s, period); } if (field.equals("NEW_PSY_VALUE") || indType.equals("NEW_PSYCHOLOGICAL")) - return newSentPsyIndicator(s, intP(p, "length", 10)); + return newSentPsyIndicator(s, effectivePeriod(periodOverride, p, "length", 10)); if (field.startsWith("INVEST_PSY_VALUE_")) { int period = parseTrailingInt(field, "INVEST_PSY_VALUE_", intP(p, "length", 10)); return psychIndicator(s, period, false); } if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL")) - return psychIndicator(s, intP(p, "length", 10), false); + return psychIndicator(s, effectivePeriod(periodOverride, p, "length", 10), false); + // BWI (Bollinger Band Width) + if (field.startsWith("BWI_VALUE_")) { + int period = parseTrailingInt(field, "BWI_VALUE_", intP(p, "length", 20)); + return bollingerBandWidth(s, p, period); + } + if (field.equals("BWI_VALUE") || indType.equals("BWI")) + return bollingerBandWidth(s, p, effectivePeriod(periodOverride, p, "length", 20)); // Williams %R if (field.startsWith("WILLIAMS_R_VALUE_")) { int period = parseTrailingInt(field, "WILLIAMS_R_VALUE_", intP(p, "length", 14)); return new WilliamsRIndicator(s, period); } - if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14)); + if (field.equals("WILLIAMS_R_VALUE") || indType.equals("WILLIAMS_R")) + return new WilliamsRIndicator(s, effectivePeriod(periodOverride, p, "length", 14)); // 신고가·신저가 — 직전 N봉 최고/최저 (당일 봉 제외, PreviousValueIndicator) if (field.startsWith("NH_PRIOR_")) { int period = parseTrailingInt(field, "NH_PRIOR_", intP(p, "length", 9)); @@ -967,7 +970,7 @@ public class StrategyDslToTa4jAdapter { } // Bollinger Bands if (field.equals("UPPER_BAND") || field.equals("LOWER_BAND") || field.equals("MIDDLE_BAND")) { - int len = intP(p, "length", 20); + int len = effectivePeriod(periodOverride, p, "length", 20); double mult = dblP(p, "mult", 2.0); String maType = p.getOrDefault("maType", "SMA").toString(); Indicator basis = maOfSource(close, s, maType, len); @@ -1004,7 +1007,13 @@ public class StrategyDslToTa4jAdapter { if (field.equals("LEADING_SPAN2")) return ichimokuSpanB(s, p); if (field.equals("LAGGING_SPAN")) return ichimokuLagging(s, p); // 거래량 MA - if (field.equals("VOLUME_MA")) return new SMAIndicator(new VolumeIndicator(s, 1), intP(p, "length", 20)); + if (field.equals("VOLUME_VALUE") || indType.equals("VOLUME")) + return new VolumeIndicator(s, 1); + if (field.equals("VOLUME_MA")) + return new SMAIndicator(new VolumeIndicator(s, 1), effectivePeriod(periodOverride, p, "length", 20)); + // 거래량 오실레이터 — cond.period → shortLength (프론트 대표 기간) + if (field.equals("VOLUME_OSC_VALUE") || indType.equals("VOLUME_OSC")) + return volumeOscillator(s, p, periodOverride); log.warn("[Adapter] 미지원 필드: {} (indicatorType={})", field, indType); return new ClosePriceIndicator(s); @@ -1240,6 +1249,41 @@ public class StrategyDslToTa4jAdapter { return new EMAIndicator(new EMAIndicator(new EMAIndicator(close, len), len), len); } + /** TRIX 본선 — 종가 3×EMA ROC% */ + private NumericIndicator buildTrixLine(ClosePriceIndicator close, int len) { + EMAIndicator e3 = tripleEma(close, len); + PreviousValueIndicator prev3 = new PreviousValueIndicator(e3); + return NumericIndicator.of(e3).minus(prev3).dividedBy(prev3).multipliedBy(100); + } + + private MACDIndicator buildMacd(ClosePriceIndicator close, Map p, int periodOverride) { + int fast = effectivePeriod(periodOverride, p, "fastLength", 12); + return new MACDIndicator(close, fast, intP(p, "slowLength", 26)); + } + + /** BWI — IndicatorService.calcBBBandWidth 와 동일 */ + private BollingerBandWidthIndicator bollingerBandWidth(BarSeries s, Map p, int len) { + ClosePriceIndicator close = new ClosePriceIndicator(s); + double mult = dblP(p, "mult", 2.0); + SMAIndicator sma = new SMAIndicator(close, len); + StandardDeviationIndicator std = StandardDeviationIndicator.ofSample(close, len); + BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(sma); + Num k = s.numFactory().numOf(mult); + BollingerBandsUpperIndicator upper = new BollingerBandsUpperIndicator(mid, std, k); + BollingerBandsLowerIndicator lower = new BollingerBandsLowerIndicator(mid, std, k); + return new BollingerBandWidthIndicator(upper, mid, lower); + } + + /** VolumeOscillator — IndicatorService.calcVolumeOscillator 와 동일 */ + private NumericIndicator volumeOscillator(BarSeries s, Map p, int periodOverride) { + int shortLen = effectivePeriod(periodOverride, p, "shortLength", 5); + int longLen = intP(p, "longLength", 10); + VolumeIndicator vol = new VolumeIndicator(s, 1); + EMAIndicator shortEma = new EMAIndicator(vol, shortLen); + EMAIndicator longEma = new EMAIndicator(vol, longLen); + return NumericIndicator.of(shortEma).minus(longEma).dividedBy(longEma).multipliedBy(100); + } + private Indicator maOfSource(Indicator source, BarSeries s, String maType, int len) { return switch (maType) { case "EMA" -> new EMAIndicator(source, len); @@ -1463,6 +1507,76 @@ public class StrategyDslToTa4jAdapter { // ── 파라미터 헬퍼 ───────────────────────────────────────────────────────── + /** 직전봉 left≤right → 현재봉 left>right (차트 교차·CrossTimeframeCompositeRule 과 동일) */ + private Rule buildCrossUpRule(Indicator left, Indicator right) { + return new Rule() { + @Override + public boolean isSatisfied(int index, TradingRecord tradingRecord) { + if (index < 1) return false; + Num l0 = left.getValue(index - 1); + Num l1 = left.getValue(index); + Num r0 = right.getValue(index - 1); + Num r1 = right.getValue(index); + if (l0 == null || l1 == null || r0 == null || r1 == null) return false; + if (l0.isNaN() || l1.isNaN() || r0.isNaN() || r1.isNaN()) return false; + return l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1); + } + }; + } + + private Rule buildCrossDownRule(Indicator left, Indicator right) { + return new Rule() { + @Override + public boolean isSatisfied(int index, TradingRecord tradingRecord) { + if (index < 1) return false; + Num l0 = left.getValue(index - 1); + Num l1 = left.getValue(index); + Num r0 = right.getValue(index - 1); + Num r1 = right.getValue(index); + if (l0 == null || l1 == null || r0 == null || r1 == null) return false; + if (l0.isNaN() || l1.isNaN() || r0.isNaN() || r1.isNaN()) return false; + return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1); + } + }; + } + + /** + * CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다. + * sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다. + */ + private int resolveLinkedBasePeriod(JsonNode cond, Map p, String valuePrefix, + int periodOverride, String lengthKey, int defaultLen) { + if (cond != null && !cond.isNull()) { + String leftField = cond.path("leftField").asText(""); + if (leftField.startsWith(valuePrefix + "_")) { + int fromField = parseTrailingInt(leftField, valuePrefix + "_", defaultLen); + if (fromField > 0) return fromField; + } + int leftPeriod = cond.path("leftPeriod").asInt(-1); + if (leftPeriod > 0) return leftPeriod; + int condPeriod = cond.path("period").asInt(-1); + if (condPeriod > 0) return condPeriod; + } + return effectivePeriod(periodOverride, p, lengthKey, defaultLen); + } + + /** 신호선 SMA/EMA 길이 — rightPeriod·sidePeriod 우선, 없으면 지표 설정 maLength */ + private int resolveSignalSmoothingPeriod(JsonNode cond, int sidePeriod, + Map p, String maKey, int defaultMa) { + if (sidePeriod > 0) return sidePeriod; + if (cond != null && !cond.isNull()) { + int rightPeriod = cond.path("rightPeriod").asInt(-1); + if (rightPeriod > 0) return rightPeriod; + } + return intP(p, maKey, defaultMa); + } + + /** 조건 period / left·right sidePeriod 가 지표 length 계열 파라미터보다 우선 */ + private int effectivePeriod(int periodOverride, Map p, String paramKey, int defaultVal) { + if (periodOverride > 0) return periodOverride; + return intP(p, paramKey, defaultVal); + } + private int intP(Map p, String k, int def) { if (p == null) return def; Object v = p.get(k); @@ -1512,4 +1626,19 @@ public class StrategyDslToTa4jAdapter { merged.putAll(overrides); return merged; } + + /** 패키지 단위 테스트 — condPeriod/sidePeriod 가 지표에 반영되는지 검증용 */ + Indicator resolveIndicatorFieldForTest(String field, String indType, + Map p, BarSeries s, + int condPeriod, int sidePeriod) { + return resolveIndicatorField(field, indType, p, Map.of(), s, condPeriod, sidePeriod, null); + } + + /** 패키지 단위 테스트 — cond JSON 포함 CCI 본선/신호선 연동 검증용 */ + Indicator resolveIndicatorFieldForTest(String field, String indType, + Map p, BarSeries s, + int condPeriod, int sidePeriod, + JsonNode cond) { + return resolveIndicatorField(field, indType, p, Map.of(), s, condPeriod, sidePeriod, cond); + } } diff --git a/backend/src/test/java/com/goldenchart/service/CciCrossSignalScanTest.java b/backend/src/test/java/com/goldenchart/service/CciCrossSignalScanTest.java new file mode 100644 index 0000000..80df20d --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/CciCrossSignalScanTest.java @@ -0,0 +1,116 @@ +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; + } +} diff --git a/backend/src/test/java/com/goldenchart/service/OscillatorPeriodOverrideTest.java b/backend/src/test/java/com/goldenchart/service/OscillatorPeriodOverrideTest.java new file mode 100644 index 0000000..403e5b0 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/OscillatorPeriodOverrideTest.java @@ -0,0 +1,113 @@ +package com.goldenchart.service; + +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.Indicator; +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.*; + +/** + * 조건 노드 period / leftPeriod / rightPeriod 가 각 지표 필드에 반영되는지 검증. + * (CCI·RSI·TRIX 등 본선/신호선 교차 시 동일 기간 사용) + */ +class OscillatorPeriodOverrideTest { + + private StrategyDslToTa4jAdapter adapter; + private BarSeries series; + + @BeforeEach + void setUp() { + adapter = new StrategyDslToTa4jAdapter(); + series = buildSeries(120); + } + + @Test + void trixSignal_respectsConditionPeriod() { + Map p = Map.of("length", 12, "signalLength", 9); + Indicator defaultSig = adapter.resolveIndicatorFieldForTest( + "TRIX_SIGNAL", "TRIX", p, series, -1, -1); + Indicator overrideSig = adapter.resolveIndicatorFieldForTest( + "TRIX_SIGNAL", "TRIX", p, series, 20, -1); + assertValuesDiffer(defaultSig, overrideSig, 80); + } + + @Test + void stochK_respectsConditionPeriod() { + Map p = Map.of("kLength", 14, "smooth", 3, "dSmoothing", 3); + Indicator defaultK = adapter.resolveIndicatorFieldForTest( + "STOCH_K", "STOCHASTIC", p, series, -1, -1); + Indicator overrideK = adapter.resolveIndicatorFieldForTest( + "STOCH_K", "STOCHASTIC", p, series, 21, -1); + assertValuesDiffer(defaultK, overrideK, 90); + } + + @Test + void williamsR_respectsConditionPeriod() { + Map p = Map.of("length", 14); + Indicator defaultW = adapter.resolveIndicatorFieldForTest( + "WILLIAMS_R_VALUE", "WILLIAMS_R", p, series, -1, -1); + Indicator overrideW = adapter.resolveIndicatorFieldForTest( + "WILLIAMS_R_VALUE", "WILLIAMS_R", p, series, 20, -1); + assertValuesDiffer(defaultW, overrideW, 90); + } + + @Test + void bwiValue_resolvesInsteadOfCloseFallback() { + Map p = Map.of("length", 20, "mult", 2.0); + Indicator bwi = adapter.resolveIndicatorFieldForTest( + "BWI_VALUE", "BWI", p, series, -1, -1); + Indicator close = adapter.resolveIndicatorFieldForTest( + "UNKNOWN_FIELD", "BWI", p, series, -1, -1); + assertValuesDiffer(bwi, close, 80); + } + + @Test + void cciSignalAndValue_useSamePeriodWhenCondPeriodSet() { + Map p = Map.of("length", 13, "maLength", 10, "maType", "SMA"); + Indicator value = adapter.resolveIndicatorFieldForTest( + "CCI_VALUE", "CCI", p, series, 20, -1); + Indicator signal = adapter.resolveIndicatorFieldForTest( + "CCI_SIGNAL", "CCI", p, series, 20, -1); + // 동일 condPeriod → 신호선은 본선 SMA이므로 값이 항상 같지는 않지만, + // 기본(13) 대비 override(20) 시 둘 다 변해야 함 + Indicator valueDefault = adapter.resolveIndicatorFieldForTest( + "CCI_VALUE", "CCI", p, series, -1, -1); + assertNotEquals(value.getValue(80).doubleValue(), valueDefault.getValue(80).doubleValue(), 1e-6); + assertNotEquals(signal.getValue(80).doubleValue(), + adapter.resolveIndicatorFieldForTest("CCI_SIGNAL", "CCI", p, series, -1, -1) + .getValue(80).doubleValue(), 1e-6); + } + + private static void assertValuesDiffer(Indicator a, Indicator b, int index) { + assertNotEquals(a.getValue(index).doubleValue(), b.getValue(index).doubleValue(), 1e-9, + "period override should change indicator values at index " + index); + } + + private static BarSeries buildSeries(int count) { + BaseBarSeriesBuilder builder = new BaseBarSeriesBuilder() + .withName("test") + .withNumFactory(DoubleNumFactory.getInstance()) + .withBarBuilderFactory(new TimeBarBuilderFactory(Duration.ofMinutes(1), 1)); + BarSeries s = builder.build(); + Instant t = Instant.parse("2024-01-01T00:00:00Z"); + double price = 100.0; + for (int i = 0; i < count; i++) { + double drift = Math.sin(i * 0.15) * 3 + i * 0.05; + double close = price + drift; + double open = close - 0.3; + double high = close + 1.2; + double low = close - 1.0; + s.addBar(t, open, high, low, close, 1000 + i * 10); + t = t.plus(Duration.ofMinutes(1)); + } + return s; + } +} diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx index 281aaf7..69f2dc2 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -87,6 +87,10 @@ interface MarketSearchPanelProps { marketInfos?: HookMarketInfo[]; marketLoading?: boolean; refreshAllTickers?: () => Promise; + /** 내부 훅 사용 시 전 종목 REST·폴링 (기본 true). 전략평가 검색은 false 권장 */ + tickerLoadFull?: boolean; + /** 내부 훅 우선 종목 (전략평가 차트 종목 등) */ + tickerPriorityMarkets?: string[]; } export const MarketSearchPanel: React.FC = ({ @@ -94,7 +98,7 @@ export const MarketSearchPanel: React.FC = ({ variant = 'overlay', query: controlledQuery, onQueryChange, actionMode = 'favorite', onAdd, onRemove, addedMarkets, maxTargetsReached = false, tickers: tickersProp, marketInfos: marketInfosProp, marketLoading: marketLoadingProp, - refreshAllTickers: refreshProp, + refreshAllTickers: refreshProp, tickerLoadFull = true, tickerPriorityMarkets, }) => { const embedded = variant === 'embedded'; const isCompact = useIsCompactDevice(); @@ -102,7 +106,8 @@ export const MarketSearchPanel: React.FC = ({ const internalFeed = useMarketTicker({ enabled: !useExternalFeed, - loadFull: true, + loadFull: tickerLoadFull, + priorityMarkets: tickerPriorityMarkets ?? (currentMarket ? [currentMarket] : undefined), }); const tickers = tickersProp ?? internalFeed.tickers; diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 275266b..9757f64 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -31,6 +31,7 @@ import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import { getKoreanName } from '../utils/marketNameCache'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; +import { useMarketTicker } from '../hooks/useMarketTicker'; import { mergeEvalGetParams, buildEvalParamsFromStrategy, @@ -69,6 +70,7 @@ interface Props { export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const { getParams: baseGetParams, getVisualConfig } = useIndicatorSettings(); const [leftTab, setLeftTab] = useState('strategy'); + const [marketSearchOpen, setMarketSearchOpen] = useState(false); const [strategies, setStrategies] = useState([]); const [strategySearch, setStrategySearch] = useState(''); const [selectedStrategyId, setSelectedStrategyId] = useState(null); @@ -100,6 +102,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { [baseGetParams, appliedIndicatorParams], ); + /** 종목 탭·종목검색 전용 ticker — 차트 OHLCV·scan-signals 와 분리 (upbitWsBroker ticker 채널) */ + const marketTabActive = leftOpen && leftTab === 'market'; + const marketTickerOpts = useMemo(() => ({ + enabled: marketTabActive || marketSearchOpen, + loadFull: marketTabActive, + priorityMarkets: [market], + }), [marketTabActive, marketSearchOpen, market]); + const marketFeed = useMarketTicker(marketTickerOpts); + const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT)); const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true)); const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT)); @@ -543,7 +554,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { ) : ( { void handleOpenReport(); }} reportDisabled={reportDisabled} reportLoading={reportLoading} + marketTickers={marketFeed.tickers} + marketInfos={marketFeed.marketInfos} + marketLoading={marketFeed.loading} + refreshMarketTickers={marketFeed.refreshAllTickers} + onMarketSearchOpenChange={setMarketSearchOpen} /> void; reportDisabled?: boolean; reportLoading?: boolean; + /** StrategyEvaluationPage 공유 ticker (종목 탭·검색과 단일 useMarketTicker) */ + marketTickers?: Map; + marketInfos?: MarketInfo[]; + marketLoading?: boolean; + refreshMarketTickers?: () => Promise; + onMarketSearchOpenChange?: (open: boolean) => void; } const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; @@ -109,6 +116,11 @@ const StrategyEvaluationChart: React.FC = ({ onOpenReport, reportDisabled = false, reportLoading = false, + marketTickers, + marketInfos, + marketLoading, + refreshMarketTickers, + onMarketSearchOpenChange, }) => { const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings(); const getParams = getParamsOverride ?? baseGetParams; @@ -405,6 +417,10 @@ const StrategyEvaluationChart: React.FC = ({ return () => window.removeEventListener('keydown', onKeyDown); }, [bars.length, selectedBarIndex, stepBar]); + useEffect(() => { + onMarketSearchOpenChange?.(showMarketSearch); + }, [showMarketSearch, onMarketSearchOpenChange]); + const selectedBar = bars[selectedBarIndex] ?? null; return ( @@ -611,6 +627,10 @@ const StrategyEvaluationChart: React.FC = ({ currentMarket={market} query={marketQuery} onQueryChange={setMarketQuery} + tickers={marketTickers} + marketInfos={marketInfos} + marketLoading={marketLoading} + refreshAllTickers={refreshMarketTickers} onSelect={m => { onMarketChange(m); setShowMarketSearch(false); diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationMarketTab.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationMarketTab.tsx index 7dcb51c..f56afee 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationMarketTab.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationMarketTab.tsx @@ -1,34 +1,28 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import MarketPanel from '../MarketPanel'; -import { useMarketTicker } from '../../hooks/useMarketTicker'; +import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker'; interface Props { activeMarket: string; onSelectMarket: (market: string) => void; - /** 종목 탭 표시 중 — 전 종목 시세 로드 */ - enabled: boolean; + /** StrategyEvaluationPage 공유 useMarketTicker — 차트·시그널과 분리된 ticker 전용 */ + tickers: Map; + marketInfos: MarketInfo[]; + loading: boolean; + usdRate: number; + refreshAllTickers?: () => Promise; } -/** 전략평가 좌측 — 실시간 차트 MarketPanel 과 동일 종목 목록 */ +/** 전략평가 좌측 — 실시간 차트 MarketPanel 과 동일 종목 목록 (현재가 WS/REST만, OHLCV·시그널 무관) */ const StrategyEvaluationMarketTab: React.FC = ({ activeMarket, onSelectMarket, - enabled, + tickers, + marketInfos, + loading, + usdRate, + refreshAllTickers, }) => { - const tickerOpts = useMemo(() => ({ - enabled, - loadFull: enabled, - priorityMarkets: [activeMarket], - }), [enabled, activeMarket]); - - const { - tickers, - marketInfos, - loading, - usdRate, - refreshAllTickers, - } = useMarketTicker(tickerOpts); - return (