package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.goldenchart.storage.Ta4jStorage; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.ta4j.core.BarSeries; import org.ta4j.core.Indicator; import org.ta4j.core.Rule; import org.ta4j.core.TradingRecord; import org.ta4j.core.indicators.*; import org.ta4j.core.indicators.adx.*; import org.ta4j.core.indicators.averages.*; import org.ta4j.core.indicators.bollinger.*; import org.ta4j.core.indicators.helpers.*; import org.ta4j.core.indicators.donchian.DonchianChannelLowerIndicator; import org.ta4j.core.indicators.donchian.DonchianChannelMiddleIndicator; import org.ta4j.core.indicators.donchian.DonchianChannelUpperIndicator; import org.ta4j.core.indicators.ichimoku.*; import org.ta4j.core.indicators.statistics.StandardDeviationIndicator; import org.ta4j.core.indicators.CachedIndicator; import org.ta4j.core.indicators.helpers.VolumeIndicator; import org.ta4j.core.indicators.volume.OnBalanceVolumeIndicator; import org.ta4j.core.indicators.numeric.NumericIndicator; import org.ta4j.core.indicators.numeric.UnaryOperationIndicator; import org.ta4j.core.num.Num; import org.ta4j.core.rules.*; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * frontend DSL(LogicNode 트리) → Ta4j Rule 변환 어댑터. * *

DSL 타입 → 레지스트리(DB) 키 매핑

*

frontend strategyTypes.ts 의 PaletteItem.value (DSL indicatorType) 는 * 대문자 스네이크케이스이지만, indicatorRegistry.ts 의 type 키(=DB 저장 키)는 * PascalCase 이므로 반드시 변환 후 DB 파라미터를 조회해야 한다.

* *

DSL 구조

*
 * {
 *   "type": "AND" | "OR" | "NOT" | "CONDITION",
 *   "children": [ ...LogicNode ],     // AND/OR/NOT 인 경우
 *   "condition": {                    // CONDITION 인 경우
 *     "indicatorType":  "CCI",
 *     "conditionType":  "CROSS_UP",
 *     "leftField":      "CCI_VALUE",
 *     "rightField":     "OVERBOUGHT_100",
 *     "targetValue":    null,
 *     "slopePeriod":    3,
 *     "holdDays":       3,
 *     "candleRange":    1
 *   }
 * }
 * 
*/ @Component @Slf4j public class StrategyDslToTa4jAdapter { /** * DSL indicatorType(대문자 스네이크) → indicatorRegistry type(PascalCase, DB 키). * frontend strategyTypes.ts DSL_TO_REGISTRY 와 동기화. */ private static final Map DSL_TO_REGISTRY = Map.ofEntries( Map.entry("RSI", "RSI"), Map.entry("MACD", "MACD"), Map.entry("CCI", "CCI"), Map.entry("ADX", "ADX"), Map.entry("DMI", "DMI"), Map.entry("OBV", "OBV"), Map.entry("TRIX", "TRIX"), Map.entry("EMA", "EMA"), Map.entry("MA", "SMA"), Map.entry("BOLLINGER", "BollingerBands"), Map.entry("STOCHASTIC", "Stochastic"), Map.entry("WILLIAMS_R", "WilliamsPercentRange"), Map.entry("ICHIMOKU", "IchimokuCloud"), Map.entry("VOLUME_OSC", "VolumeOscillator"), Map.entry("PSYCHOLOGICAL", "Psychological"), Map.entry("NEW_PSYCHOLOGICAL", "NewPsychological"), Map.entry("INVEST_PSYCHOLOGICAL", "InvestPsychological"), Map.entry("BWI", "BBBandWidth"), Map.entry("VR", "VR"), Map.entry("DISPARITY", "Disparity"), Map.entry("DONCHIAN", "DonchianChannels"), Map.entry("NEW_HIGH", "PriceExtreme"), Map.entry("NEW_LOW", "PriceExtreme"), Map.entry("VOLUME", "VOLUME") ); /** * DSL indicatorType 을 DB 저장 키(레지스트리 type)로 변환. * 매핑 없으면 원본 반환 (이미 레지스트리 형식인 경우). */ private static String toRegistryKey(String dslType) { return registryKeyForDsl(dslType); } /** DSL indicatorType → DB 레지스트리 키 (외부 hline resolver용) */ public static String registryKeyForDsl(String dslType) { return DSL_TO_REGISTRY.getOrDefault(dslType, dslType); } // ── Public API ──────────────────────────────────────────────────────────── public record RuleBuildContext( BarSeries primarySeries, Map> indicatorParams, Map> indicatorVisual, String market, Ta4jStorage storage, /** * CANDLE_CLOSE 평가 시 true — CrossSeriesRule 이 상위봉의 마지막 확정봉 * (endIndex - 1)을 사용하도록 강제한다. provisional 봉 오염을 방지. */ boolean useConfirmedOnly, /** * 백테스트용 사전 집계 시리즈 맵 candleType→BarSeries. * null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며 * CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다. */ Map seriesOverrides ) { /** visual 미전달 호환 (레거시) */ public RuleBuildContext(BarSeries primarySeries, Map> indicatorParams, String market, Ta4jStorage storage) { this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of()); } /** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */ public RuleBuildContext withPrimary(BarSeries newPrimary) { return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual, market, storage, useConfirmedOnly, seriesOverrides); } /** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */ public Map smaParams() { if (indicatorParams == null) return Map.of(); Map sma = indicatorParams.get("SMA"); return sma != null ? sma : Map.of(); } /** 사용 중인 시리즈 오버라이드가 있으면 백테스트 모드 */ public boolean isBacktest() { return seriesOverrides != null && !seriesOverrides.isEmpty(); } BarSeries resolveSeries(String candleType) { String ct = LiveStrategyTimeframeService.normalize(candleType); // 백테스트·차트 scan — 사전 집계 시리즈 우선 if (seriesOverrides != null && seriesOverrides.containsKey(ct)) { return seriesOverrides.get(ct); } // 차트봉 scan/eval — Ta4jStorage(실시간 1m 등)와 OHLCV 혼합 금지 (시그널 0·차트 불일치) if (seriesOverrides != null && !seriesOverrides.isEmpty()) { return primarySeries; } if (storage != null && market != null && !market.isBlank() && storage.exists(market, ct)) { return storage.getOrCreate(market, ct); } return primarySeries; } } public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams) { return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of())); } public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams, String market, Ta4jStorage storage) { return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of())); } public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams, Map> indicatorVisual, String market, Ta4jStorage storage) { return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of())); } /** * 백테스트 전용: 사전 집계된 상위봉 시리즈 맵을 함께 전달. * CrossSeriesRule 이 타임스탬프 기반 룩업을 수행하여 라이브와 동일한 멀티 TF 평가를 보장한다. */ public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams, Map seriesOverrides) { return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, seriesOverrides != null ? seriesOverrides : Map.of())); } /** * CANDLE_CLOSE 전용: useConfirmedOnly=true 로 상위봉 확정봉 인덱스를 사용한다. */ public Rule toRuleOnCandleClose(JsonNode node, BarSeries series, Map> indicatorParams, Map> indicatorVisual, String market, Ta4jStorage storage) { return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of())); } public Rule toRule(JsonNode node, RuleBuildContext ctx) { if (node == null || node.isNull()) return new BooleanRule(false); String type = node.path("type").asText("CONDITION"); return switch (type) { case "AND" -> buildAndRule(node, ctx); case "OR" -> buildOrRule(node, ctx); case "NOT" -> buildNotRule(node, ctx); case "TIMEFRAME" -> buildTimeframeRule(node, ctx); default -> buildConditionRuleNode(node, ctx); }; } /** CONDITION 래퍼 — nested condition 또는 인라인 조건 필드 모두 지원 */ private Rule buildConditionRuleNode(JsonNode node, RuleBuildContext ctx) { JsonNode nested = node.path("condition"); if (nested != null && !nested.isNull() && nested.has("indicatorType")) { return buildConditionRule(nested, ctx); } if (node.has("indicatorType")) { return buildConditionRule(node, ctx); } return new BooleanRule(false); } /** 조건 DSL left/right 필드의 현재 봉 수치 (가상투자 UI 표시용) */ public Double readConditionFieldValue(JsonNode cond, BarSeries series, Map> params, int index, boolean leftSide) { RuleBuildContext ctx = new RuleBuildContext(series, params != null ? params : Map.of(), Map.of(), null, null, false, Map.of()); return readConditionFieldValue(cond, ctx, index, leftSide); } /** scan·진단 — Rule 빌드와 동일 RuleBuildContext 사용 */ public Double readConditionFieldValue(JsonNode cond, RuleBuildContext ctx, int index, boolean leftSide) { if (cond == null || cond.isNull() || ctx == null || ctx.primarySeries() == null || index < 0) { return null; } BarSeries series = ctx.primarySeries(); if (index >= series.getBarCount()) return null; String field = leftSide ? cond.path("leftField").asText("NONE") : cond.path("rightField").asText("NONE"); if (field == null || field.isBlank() || "NONE".equals(field)) return null; String indType = cond.path("indicatorType").asText(""); int condPeriod = cond.path("period").asInt(-1); int leftPeriod = cond.path("leftPeriod").asInt(-1); int rightPeriod = cond.path("rightPeriod").asInt(-1); int sidePeriod = leftSide ? leftPeriod : rightPeriod; String registryKey = toRegistryKey(indType); Map indParams = ctx.indicatorParams() != null ? ctx.indicatorParams().getOrDefault(registryKey, Map.of()) : Map.of(); try { Indicator ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod, cond, ctx); Num v = ind.getValue(index); return v != null ? v.doubleValue() : null; } catch (Exception e) { log.debug("[Adapter] field value read fail {}: {}", field, e.getMessage()); return null; } } private Rule buildTimeframeRule(JsonNode node, RuleBuildContext ctx) { String ct = resolveTimeframeCandleType(node); BarSeries branchSeries = ctx.resolveSeries(ct); RuleBuildContext branchCtx = ctx.withPrimary(branchSeries); List rules = childRules(node, branchCtx); if (rules.isEmpty()) return new BooleanRule(false); Rule inner = rules.get(0); if (branchSeries == ctx.primarySeries()) return inner; return new CrossSeriesRule(inner, branchSeries, ctx.primarySeries(), ctx.useConfirmedOnly(), ctx.isBacktest()); } /** TIMEFRAME 노드 — candleTypes[0] 우선, 없으면 candleType (레거시 기본 1m) */ private static String resolveTimeframeCandleType(JsonNode node) { JsonNode types = node.path("candleTypes"); if (types.isArray() && !types.isEmpty()) { return LiveStrategyTimeframeService.normalize(types.get(0).asText("1m")); } return LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")); } /** * 다른 시간봉 시리즈로 빌드된 Rule. * *
    *
  • 백테스트 모드({@code isBacktest=true}): 기본봉 endTime ≤ 기준으로 상위봉 인덱스를 * 이진탐색하여 정확히 대응하는 확정봉을 사용한다. 라이브와 동일한 멀티 TF 시그널 보장.
  • *
  • CANDLE_CLOSE 모드({@code useConfirmedOnly=true}): 상위봉의 마지막 확정봉 * (endIndex - 1)을 사용한다. 진행 중인 provisional 봉을 참조하지 않음.
  • *
  • REALTIME_TICK 모드(둘 다 false): 상위봉의 {@code getEndIndex()}(현재 봉 포함) * 를 사용한다. 실시간 지표값 반영.
  • *
*/ private static final class CrossSeriesRule implements Rule { private final Rule inner; private final BarSeries branchSeries; private final BarSeries primarySeries; private final boolean useConfirmedOnly; private final boolean isBacktest; CrossSeriesRule(Rule inner, BarSeries branchSeries, BarSeries primarySeries, boolean useConfirmedOnly, boolean isBacktest) { this.inner = inner; this.branchSeries = branchSeries; this.primarySeries = primarySeries; this.useConfirmedOnly = useConfirmedOnly; this.isBacktest = isBacktest; } @Override public boolean isSatisfied(int index, TradingRecord tradingRecord) { if (branchSeries.getBarCount() == 0) return false; int evalIndex; if (branchSeries == primarySeries) { evalIndex = index; } else if (isBacktest) { // 백테스트: 기본봉 endTime 이하의 마지막 상위봉 인덱스 (타임스탬프 매핑) java.time.Instant primaryEndTime = primarySeries.getBar(index).getEndTime(); evalIndex = findLastIndexAtOrBefore(branchSeries, primaryEndTime); } else if (useConfirmedOnly) { // CANDLE_CLOSE — 타임스탬프 기반 확정봉 판정 (백테스트 룩업과 동일 로직) // // [버그 수정] 단순 getEndIndex()-1 을 사용하면 경계 시점(예: 5m=10:00, 1h=10:00 동시 마감)에 // 1h 봉이 이미 완전히 확정됐음에도 getEndIndex()-1 이 한 봉 이전(08:00-09:00)을 가리켜 // 백테스트와 1봉 차이가 발생했다. primaryEndTime 과 branchSeries 마지막 봉의 endTime 을 // 비교해 진짜 provisional 인지 이미 확정된 봉인지 구분한다. // // branchSeries.lastBar.endTime <= primary.endTime // → 상위봉이 기본봉보다 이른 시점에 이미 마감된 경우 (=확정봉) → getEndIndex() 사용 // branchSeries.lastBar.endTime > primary.endTime // → 상위봉 기간이 아직 열려 있는 경우 (=provisional) → getEndIndex()-1 사용 java.time.Instant primaryEndTime = primarySeries.getBar(index).getEndTime(); java.time.Instant branchLastEndTime = branchSeries.getLastBar().getEndTime(); if (!branchLastEndTime.isAfter(primaryEndTime)) { // 상위봉이 기본봉 기준으로 이미 확정된 봉 → getEndIndex() 직접 사용 evalIndex = branchSeries.getEndIndex(); } else { // 상위봉이 아직 형성 중(provisional) → 직전 확정봉 사용 evalIndex = Math.max(branchSeries.getBeginIndex(), branchSeries.getEndIndex() - 1); } } else { // REALTIME_TICK: 현재 진행 중인 봉 포함 evalIndex = branchSeries.getEndIndex(); } if (evalIndex < 0) return false; return inner.isSatisfied(evalIndex, tradingRecord); } /** 이진탐색: endTime <= targetTime 인 마지막 봉 인덱스 반환 (-1 if none) */ static int findLastIndexAtOrBefore(BarSeries series, java.time.Instant targetTime) { int lo = series.getBeginIndex(); int hi = series.getEndIndex(); int result = -1; while (lo <= hi) { int mid = (lo + hi) / 2; if (!series.getBar(mid).getEndTime().isAfter(targetTime)) { result = mid; lo = mid + 1; } else { hi = mid - 1; } } return result; } } // ── AND / OR / NOT ──────────────────────────────────────────────────────── private Rule buildAndRule(JsonNode node, RuleBuildContext ctx) { List rules = childRules(node, ctx); if (rules.isEmpty()) return new BooleanRule(false); Rule result = rules.get(0); for (int i = 1; i < rules.size(); i++) result = new AndRule(result, rules.get(i)); return result; } private Rule buildOrRule(JsonNode node, RuleBuildContext ctx) { List rules = childRules(node, ctx); if (rules.isEmpty()) return new BooleanRule(false); Rule result = rules.get(0); for (int i = 1; i < rules.size(); i++) result = new OrRule(result, rules.get(i)); return result; } private Rule buildNotRule(JsonNode node, RuleBuildContext ctx) { List rules = childRules(node, ctx); if (rules.isEmpty()) return new BooleanRule(false); return new NotRule(rules.get(0)); } private List childRules(JsonNode node, RuleBuildContext ctx) { List list = new ArrayList<>(); JsonNode children = node.path("children"); if (children.isArray()) { for (JsonNode child : children) list.add(toRule(child, ctx)); } JsonNode single = node.path("child"); if (list.isEmpty() && !single.isMissingNode() && !single.isNull()) { list.add(toRule(single, ctx)); } return list; } private Rule buildAndRule(JsonNode node, BarSeries series, Map> p) { return buildAndRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of())); } private Rule buildOrRule(JsonNode node, BarSeries series, Map> p) { return buildOrRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of())); } private Rule buildNotRule(JsonNode node, BarSeries series, Map> p) { return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of())); } private List childRules(JsonNode node, BarSeries series, Map> p) { return childRules(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of())); } // ── CONDITION ───────────────────────────────────────────────────────────── private Rule buildConditionRule(JsonNode cond, RuleBuildContext ctx) { if (cond == null || cond.isNull()) return new BooleanRule(false); BarSeries series = ctx.primarySeries(); Map> params = ctx.indicatorParams(); String indType = cond.path("indicatorType").asText(""); String condType = cond.path("conditionType").asText("GT"); String leftField = cond.path("leftField").asText("NONE"); String rightField = cond.path("rightField").asText("NONE"); int condPeriod = cond.path("period").asInt(-1); int leftPeriod = cond.path("leftPeriod").asInt(-1); int rightPeriod = cond.path("rightPeriod").asInt(-1); int slopePeriod = cond.path("slopePeriod").asInt(3); int holdDays = cond.path("holdDays").asInt(3); int lookbackPeriod = cond.path("lookbackPeriod").asInt(20); int candleRange = cond.path("candleRange").asInt(1); String candleRangeMode = resolveCandleRangeMode(cond, candleRange); if (needsCrossTimeframeComposite(cond, ctx)) { Rule composite = buildCrossTimeframeCompositeRule(cond, ctx); return applyCandleRangeWindow(composite, candleRangeMode, candleRange, condType); } PriceExtremeNorm px = normalizePriceExtremeCondition(cond); if (px != null) { indType = px.indType(); condType = px.condType(); leftField = px.leftField(); rightField = px.rightField(); condPeriod = px.period(); } // DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회 String registryKey = toRegistryKey(indType); Map globalParams = params != null ? params.getOrDefault(registryKey, Map.of()) : Map.of(); // 조건 노드에 직접 저장된 params가 있으면 전역 설정보다 우선 적용 // 예) {"kLength": 12, "smooth": 3} → 전역 Stochastic 설정(kLength=14)을 덮어씀 Map condNodeParams = extractConditionNodeParams(cond); Map indParams = condNodeParams.isEmpty() ? globalParams : mergeParams(globalParams, condNodeParams); try { Indicator left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod, cond, ctx); Indicator right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod, cond, ctx); Rule core = switch (condType) { case "GT" -> new OverIndicatorRule(left, right); case "LT" -> new UnderIndicatorRule(left, right); // GTE/LTE: OverIndicator OR 동일 (epsilon 비교) case "GTE" -> new OrRule(new OverIndicatorRule(left, right), buildEqRule(left, right, series)); case "LTE" -> new OrRule(new UnderIndicatorRule(left, right), buildEqRule(left, right, series)); case "EQ" -> buildEqRule(left, right, series); case "NEQ" -> new NotRule(buildEqRule(left, right, series)); // 직전봉≤·현재봉> 교차 (차트·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" -> { double v = cond.path("compareValue").asDouble(0); yield new OverIndicatorRule( NumericIndicator.of(left).minus(right).abs(), new ConstantIndicator<>(series, series.numFactory().numOf(v))); } case "DIFF_LT" -> { double v = cond.path("compareValue").asDouble(0); yield new UnderIndicatorRule( NumericIndicator.of(left).minus(right).abs(), new ConstantIndicator<>(series, series.numFactory().numOf(v))); } case "HOLD_N_DAYS" -> buildHoldRule(new OverIndicatorRule(left, right), holdDays); case "LOWEST_LTE" -> { Indicator rollingMin = new LowestValueIndicator(left, lookbackPeriod); yield new OrRule(new UnderIndicatorRule(rollingMin, right), buildEqRule(rollingMin, right, series)); } case "LOWEST_GTE" -> { Indicator rollingMin = new LowestValueIndicator(left, lookbackPeriod); yield new OrRule(new OverIndicatorRule(rollingMin, right), buildEqRule(rollingMin, right, series)); } // ── 일목균형표 전용 ──────────────────────────────────────────── case "ABOVE_CLOUD" -> buildCloudAboveRule(series, indParams); case "BELOW_CLOUD" -> buildCloudBelowRule(series, indParams); case "IN_CLOUD" -> buildInCloudRule(series, indParams); case "CLOUD_BREAK_UP" -> buildCloudBreakRule(series, indParams, true); case "CLOUD_BREAK_DOWN" -> buildCloudBreakRule(series, indParams, false); case "SPAN1_GT_SPAN2" -> new OverIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); case "SPAN1_LT_SPAN2" -> new UnderIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); case "SPAN1_CROSS_UP_SPAN2" -> new CrossedUpIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); case "SPAN1_CROSS_DOWN_SPAN2" -> new CrossedDownIndicatorRule(ichimokuSpanA(series, indParams), ichimokuSpanB(series, indParams)); case "LAGGING_GT_PRICE" -> buildChikouVsPriorCloseRule(series, indParams, true); case "LAGGING_LT_PRICE" -> buildChikouVsPriorCloseRule(series, indParams, false); case "LAGGING_ABOVE_PRIOR_CLOUD" -> buildChikouAbovePriorCloudRule(series, indParams); case "CHIKOU_CROSS_ABOVE_PRIOR_CLOUD" -> buildChikouCrossAbovePriorCloudRule(series, indParams); case "VOLUME_GT_MA_RATIO" -> { double ratio = cond.path("compareValue").asDouble(1.5); int len = intP(indParams, "length", 5); VolumeIndicator vol = new VolumeIndicator(series, 1); SMAIndicator volMa = new SMAIndicator(vol, len); Indicator threshold = NumericIndicator.of(volMa).multipliedBy(ratio); yield new OverIndicatorRule(vol, threshold); } default -> { log.warn("[Adapter] 미지원 conditionType: {}", condType); yield new BooleanRule(false); } }; Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange, condType); if (px != null) { return nanSafeCompareRule(ranged, left, right); } return ranged; } catch (Exception e) { log.warn("[Adapter] 조건 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage()); return new BooleanRule(false); } } /** * 신고가·신저가 DSL 정규화 — 구버전 DC_UPPER/DC_LOWER·복합지표 승격 필드를 NH_PRIOR/NL_PRIOR 로 통일. */ private record PriceExtremeNorm(String indType, String condType, String leftField, String rightField, int period) {} private PriceExtremeNorm normalizePriceExtremeCondition(JsonNode cond) { String ind = cond.path("indicatorType").asText(""); if (!"NEW_HIGH".equals(ind) && !"NEW_LOW".equals(ind)) return null; String ct = cond.path("conditionType").asText("GT"); String left = cond.path("leftField").asText("CLOSE_PRICE"); String right = cond.path("rightField").asText(""); int period = cond.path("period").asInt(-1); int leftP = cond.path("leftPeriod").asInt(-1); if (period <= 0) period = leftP > 0 ? leftP : 9; if (right.startsWith("DC_UPPER_")) { period = parseTrailingInt(right, "DC_UPPER_", period); right = "NH_PRIOR_" + period; } else if (right.startsWith("DC_LOWER_")) { period = parseTrailingInt(right, "DC_LOWER_", period); right = "NL_PRIOR_" + period; } else if (right.startsWith("NH_PRIOR_")) { period = parseTrailingInt(right, "NH_PRIOR_", period); } else if (right.startsWith("NL_PRIOR_")) { period = parseTrailingInt(right, "NL_PRIOR_", period); } else if (right.isBlank() || "NONE".equals(right)) { right = "NEW_HIGH".equals(ind) ? "NH_PRIOR_" + period : "NL_PRIOR_" + period; } if (left.isBlank() || "NONE".equals(left)) left = "CLOSE_PRICE"; // Donchian 당일봉 포함 DC_* + CROSS 는 거의 성립하지 않음 → PRIOR 필드 + GTE/LTE if ("NEW_HIGH".equals(ind) && "CROSS_UP".equals(ct) && !right.startsWith("NH_PRIOR_")) { ct = "GTE"; } else if ("NEW_LOW".equals(ind) && "CROSS_DOWN".equals(ct) && !right.startsWith("NL_PRIOR_")) { ct = "LTE"; } return new PriceExtremeNorm(ind, ct, left, right, period); } /** 직전 N봉 극값이 NaN 인 구간(워밍업)에서는 조건 미충족 */ private Rule nanSafeCompareRule(Rule inner, Indicator left, Indicator right) { return new Rule() { @Override public boolean isSatisfied(int index, TradingRecord tradingRecord) { Num l = left.getValue(index); Num r = right.getValue(index); if (l == null || r == null || l.isNaN() || r.isNaN()) return false; return inner.isSatisfied(index, tradingRecord); } }; } /** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */ private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) { if (!cond.path("composite").asBoolean(false)) return false; // 라이브: storage + market 필요 / 백테스트: seriesOverrides 맵으로 대체 boolean hasSource = (ctx.storage() != null && ctx.market() != null && !ctx.market().isBlank()) || ctx.isBacktest(); if (!hasSource) return false; String leftCt = LiveStrategyTimeframeService.normalize( cond.path("leftCandleType").asText("1m")); String rightCt = LiveStrategyTimeframeService.normalize( cond.path("rightCandleType").asText("1m")); return !leftCt.equals(rightCt); } private Rule buildCrossTimeframeCompositeRule(JsonNode cond, RuleBuildContext ctx) { BarSeries trigger = ctx.primarySeries(); String indType = cond.path("indicatorType").asText(""); String condType = cond.path("conditionType").asText("GT"); String leftField = cond.path("leftField").asText("NONE"); String rightField = cond.path("rightField").asText("NONE"); int condPeriod = cond.path("period").asInt(-1); int leftPeriod = cond.path("leftPeriod").asInt(-1); int rightPeriod = cond.path("rightPeriod").asInt(-1); String registryKey = toRegistryKey(indType); Map indParams = ctx.indicatorParams() != null ? ctx.indicatorParams().getOrDefault(registryKey, Map.of()) : Map.of(); String leftCt = LiveStrategyTimeframeService.normalize( cond.path("leftCandleType").asText("1m")); String rightCt = LiveStrategyTimeframeService.normalize( cond.path("rightCandleType").asText("1m")); BarSeries leftSeries = ctx.resolveSeries(leftCt); BarSeries rightSeries = ctx.resolveSeries(rightCt); try { Indicator left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx); Indicator right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod, cond, ctx); return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries, ctx.isBacktest(), ctx.useConfirmedOnly()); } catch (Exception e) { log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}", indType, condType, e.getMessage()); return new BooleanRule(false); } } /** * [버그 수정] 라이브 CANDLE_CLOSE 시 상위봉 인덱스 결정 — CrossSeriesRule 과 동일 타임스탬프 비교 로직 적용. * *

기존 isBacktest=false 시 단순 {@code branch.getEndIndex()} 를 반환했는데, * 이는 경계 시점(예: 5m=10:00, 1h=10:00 동시 마감)에 올바른 1h 봉을 가리키지만 * useConfirmedOnly=true 일 때 provisional 여부를 판단하지 않아 불일치 가능성이 있었다. * *

개선: isBacktest 여부와 무관하게 타임스탬프 비교로 통일한다. *

    *
  • backtest 또는 branch.lastEndTime ≤ trigger.endTime → 타임스탬프 기반 정확한 인덱스
  • *
  • 라이브 REALTIME_TICK (useConfirmedOnly=false) 이고 branch 가 아직 진행 중 → getEndIndex()
  • *
*/ private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex, boolean isBacktest, boolean useConfirmedOnly) { if (trigger == branch) return triggerIndex; java.time.Instant triggerEndTime = trigger.getBar(triggerIndex).getEndTime(); if (isBacktest) { return CrossSeriesRule.findLastIndexAtOrBefore(branch, triggerEndTime); } if (useConfirmedOnly) { // CANDLE_CLOSE — CrossSeriesRule 과 동일한 타임스탬프 비교 로직 java.time.Instant branchLastEndTime = branch.getLastBar().getEndTime(); if (!branchLastEndTime.isAfter(triggerEndTime)) { return branch.getEndIndex(); } else { return Math.max(branch.getBeginIndex(), branch.getEndIndex() - 1); } } // REALTIME_TICK: 현재 진행 중인 봉 포함 int end = branch.getEndIndex(); return end >= 0 ? end : 0; } private static final class CrossTimeframeCompositeRule implements Rule { private final String condType; private final Indicator left; private final Indicator right; private final BarSeries triggerSeries; private final BarSeries leftSeries; private final BarSeries rightSeries; private final boolean isBacktest; private final boolean useConfirmedOnly; CrossTimeframeCompositeRule(String condType, Indicator left, Indicator right, BarSeries triggerSeries, BarSeries leftSeries, BarSeries rightSeries, boolean isBacktest, boolean useConfirmedOnly) { this.condType = condType; this.left = left; this.right = right; this.triggerSeries = triggerSeries; this.leftSeries = leftSeries; this.rightSeries = rightSeries; this.isBacktest = isBacktest; this.useConfirmedOnly = useConfirmedOnly; } @Override public boolean isSatisfied(int index, TradingRecord tradingRecord) { int li = evalIndex(triggerSeries, leftSeries, index, isBacktest, useConfirmedOnly); int ri = evalIndex(triggerSeries, rightSeries, index, isBacktest, useConfirmedOnly); if (li < 1 || ri < 1) return false; Num l0 = left.getValue(li - 1); Num l1 = left.getValue(li); Num r0 = right.getValue(ri - 1); Num r1 = right.getValue(ri); if (l0 == null || l1 == null || r0 == null || r1 == null) return false; return switch (condType) { case "GT" -> l1.isGreaterThan(r1); case "LT" -> l1.isLessThan(r1); case "GTE" -> l1.isGreaterThan(r1) || l1.isEqual(r1); case "LTE" -> l1.isLessThan(r1) || l1.isEqual(r1); case "EQ" -> l1.isEqual(r1); case "NEQ" -> !l1.isEqual(r1); case "CROSS_UP" -> l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1); case "CROSS_DOWN" -> l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1); default -> false; }; } } // ── 필드 Resolver ───────────────────────────────────────────────────────── private Indicator resolveField(String field, String indType, Map p, BarSeries s, int condPeriod, int sidePeriod, JsonNode cond, RuleBuildContext ctx) { if (field == null || field.isBlank() || field.equals("NONE")) { return new ConstantIndicator<>(s, s.numFactory().numOf(0)); } // K_* 직접입력 — 상수 임계값 (HL_*·지표 필드 해석보다 우선) if (field.startsWith("K_")) { Indicator kConst = resolveDirectKConstant(field, indType, cond, ctx, s); if (kConst != null) return kConst; } return switch (field) { case "CLOSE_PRICE" -> new ClosePriceIndicator(s); case "OPEN_PRICE" -> new OpenPriceIndicator(s); case "HIGH_PRICE" -> new HighPriceIndicator(s); case "LOW_PRICE" -> new LowPriceIndicator(s); case "VOLUME_VALUE" -> new VolumeIndicator(s, 1); default -> { Double hlineVal = IndicatorHlineResolver.resolveThresholdField( cond, field, indType, ctx != null ? ctx.indicatorVisual() : Map.of()); if (hlineVal != null) { yield new ConstantIndicator<>(s, s.numFactory().numOf(hlineVal)); } double constant = resolveConstantField(field); if (!Double.isNaN(constant)) { 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, cond); } }; } /** @deprecated cond/ctx 없이 호출 — hline 역할(HL_OVER) 미해석 */ private Indicator resolveField(String field, String indType, Map p, BarSeries s, int condPeriod, int sidePeriod) { return resolveField(field, indType, p, s, condPeriod, sidePeriod, null, new RuleBuildContext(s, Map.of(), Map.of(), null, null, false, Map.of())); } /** K_55 등 직접입력 임계 — ConstantIndicator 또는 null(폴백) */ private Indicator resolveDirectKConstant(String field, String indType, JsonNode cond, RuleBuildContext ctx, BarSeries s) { Double val = IndicatorHlineResolver.resolveThresholdField( cond, field, indType, ctx != null ? ctx.indicatorVisual() : Map.of()); if (val == null) { double parsed = resolveConstantField(field); if (!Double.isNaN(parsed)) val = parsed; } if (val == null) return null; return new ConstantIndicator<>(s, s.numFactory().numOf(val)); } private double resolveConstantField(String field) { // 지표 시리즈 필드 — 접미 숫자는 기간이지 임계값 상수가 아님 if (field != null && (field.startsWith("NH_PRIOR_") || field.startsWith("NL_PRIOR_") || field.startsWith("DC_UPPER_") || field.startsWith("DC_LOWER_") || field.startsWith("DC_MIDDLE_"))) { return Double.NaN; } // K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드 // 예) K_60 → 60, K_-100 → -100, K_0 → 0 if (field.startsWith("K_")) { try { return Double.parseDouble(field.substring(2)); } catch (NumberFormatException e) { /* fall */ } } // NEG 접두사 (레거시): OVERSOLD_NEG80 → -80 if (field.contains("_NEG")) { String[] parts = field.split("_NEG"); try { return -Double.parseDouble(parts[parts.length - 1]); } catch (NumberFormatException e) { /* fall */ } } if (field.equals("ZERO_LINE")) return 0.0; // 마지막 _ 이후 숫자 추출 (레거시): OVERBOUGHT_70 → 70, ADX_25 → 25 int idx = field.lastIndexOf('_'); if (idx >= 0 && idx < field.length() - 1) { try { return Double.parseDouble(field.substring(idx + 1)); } catch (NumberFormatException e) { /* fall */ } } return Double.NaN; } private Indicator resolveIndicatorField(String field, String indType, Map p, Map smaParams, BarSeries s, int condPeriod, int sidePeriod, JsonNode cond) { ClosePriceIndicator close = new ClosePriceIndicator(s); int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1); // CCI — 기간 접미사(CCI_VALUE_13) 또는 기본 CCI_VALUE if (field.startsWith("CCI_VALUE_")) { int period = parseTrailingInt(field, "CCI_VALUE_", intP(p, "length", 13)); return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)), period); } if (field.equals("CCI_VALUE")) return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)), effectivePeriod(periodOverride, p, "length", 13)); if (field.equals("CCI_SIGNAL")) { Map norm = CciOnSourceIndicator.normalizeParams(p); 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 = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 10); if ("None".equals(maType)) return cci; return switch (maType) { case "EMA" -> new EMAIndicator(cci, maLen); case "WMA" -> new WMAIndicator(cci, maLen); default -> new SMAIndicator(cci, maLen); }; } // RSI — 기간 접미사(RSI_VALUE_9) 또는 기본 RSI_VALUE if (field.startsWith("RSI_VALUE_")) { int period = parseTrailingInt(field, "RSI_VALUE_", intP(p, "length", 14)); return new RSIIndicator(close, period); } if (field.equals("RSI_VALUE")) return new RSIIndicator(close, effectivePeriod(periodOverride, p, "length", 14)); if (field.equals("RSI_SIGNAL")) { 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 = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 14); if ("None".equals(maType)) return rsi; return switch (maType) { case "EMA" -> new EMAIndicator(rsi, maLen); case "WMA" -> new WMAIndicator(rsi, maLen); default -> new SMAIndicator(rsi, maLen); }; } // MACD — cond.period → fastLength (프론트 대표 기간) if (field.equals("MACD_LINE")) { return buildMacd(close, p, periodOverride); } if (field.equals("SIGNAL_LINE")) { MACDIndicator macd = buildMacd(close, p, periodOverride); return new EMAIndicator(macd, intP(p, "signalLength", 9)); } if (field.equals("HISTOGRAM")) { MACDIndicator macd = buildMacd(close, p, periodOverride); return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9))); } // ADX / DMI if (field.startsWith("ADX_VALUE_")) { int period = parseTrailingInt(field, "ADX_VALUE_", intP(p, "adxSmoothing", 14)); return new ADXIndicator(s, intP(p, "diLength", 14), period); } if (field.equals("ADX_VALUE")) return new ADXIndicator(s, intP(p, "diLength", 14), effectivePeriod(periodOverride, p, "adxSmoothing", 14)); if (field.equals("PDI") || field.equals("PLUS_DI")) { 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 = effectivePeriod(periodOverride, p, "diLength", intP(p, "length", 14)); return new MinusDIIndicator(s, diLen); } // Stochastic Slow — IndicatorService.calcStochastic 과 동일한 로직: // raw %K = StochasticOscillatorKIndicator(kLen) // slow %K = SMA(raw %K, smooth) // %D = SMA(slow %K, dSmoothing) if (field.equals("STOCH_K")) { 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 = 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); return new SMAIndicator(slowK, dSmooth); } // TRIX — 종가 3×EMA ROC% (업비트·키움) if (field.startsWith("TRIX_VALUE_")) { int len = parseTrailingInt(field, "TRIX_VALUE_", intP(p, "length", 12)); return buildTrixLine(close, len); } if (field.equals("TRIX_VALUE")) { return buildTrixLine(close, effectivePeriod(periodOverride, p, "length", 12)); } if (field.equals("TRIX_SIGNAL")) { 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 = resolveSignalSmoothingPeriod(cond, sidePeriod, p, "maLength", 9); return switch (maType) { case "EMA" -> new EMAIndicator(obv, maLen); case "WMA" -> new WMAIndicator(obv, maLen); default -> new SMAIndicator(obv, maLen); }; } // VR if (field.startsWith("VR_VALUE_")) { int period = parseTrailingInt(field, "VR_VALUE_", intP(p, "length", 10)); return vrIndicator(s, period); } if (field.equals("VR_VALUE") || indType.equals("VR")) return vrIndicator(s, effectivePeriod(periodOverride, p, "length", 10)); // 이격도 DISPARITY5 → period 5 if (field.startsWith("DISPARITY")) { int period = parseTrailingInt(field, "DISPARITY", 20); return disparityIndicator(s, period, p); } if (field.startsWith("PSY_VALUE_")) { int period = parseTrailingInt(field, "PSY_VALUE_", intP(p, "length", 12)); return psychIndicator(s, period, false); } if (field.equals("PSY_VALUE") || indType.equals("PSYCHOLOGICAL")) 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, 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, 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") || 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)); return priorHighestHigh(s, period); } if (field.startsWith("NL_PRIOR_")) { int period = parseTrailingInt(field, "NL_PRIOR_", intP(p, "length", 9)); return priorLowestLow(s, period); } // Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사 if (field.startsWith("DC_UPPER_")) { int period = parseTrailingInt(field, "DC_UPPER_", intP(p, "length", 20)); return new DonchianChannelUpperIndicator(s, period); } if (field.startsWith("DC_LOWER_")) { int period = parseTrailingInt(field, "DC_LOWER_", intP(p, "length", 20)); return new DonchianChannelLowerIndicator(s, period); } if (field.startsWith("DC_MIDDLE_")) { int period = parseTrailingInt(field, "DC_MIDDLE_", intP(p, "length", 20)); return new DonchianChannelMiddleIndicator(s, period); } // Bollinger Bands if (field.equals("UPPER_BAND") || field.equals("LOWER_BAND") || field.equals("MIDDLE_BAND")) { 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); StandardDeviationIndicator sd = StandardDeviationIndicator.ofSample(close, len); BollingerBandsMiddleIndicator mid = new BollingerBandsMiddleIndicator(basis); Num k = s.numFactory().numOf(mult); return switch (field) { case "UPPER_BAND" -> new BollingerBandsUpperIndicator(mid, sd, k); case "LOWER_BAND" -> new BollingerBandsLowerIndicator(mid, sd, k); default -> mid; }; } // MA (SMA) if (field.startsWith("CLOSE_MAX_")) { int period = parseTrailingInt(field, "CLOSE_MAX_", 33); return new HighestValueIndicator(close, period); } if (field.startsWith("CLOSE_MIN_")) { int period = parseTrailingInt(field, "CLOSE_MIN_", 33); return new LowestValueIndicator(close, period); } if (field.startsWith("MA") && !field.startsWith("MACD")) { int period = resolveMovingAveragePeriod(field, "MA", smaParams); if (period > 0) return new SMAIndicator(close, period); } if (field.startsWith("EMA")) { int period = resolveMovingAveragePeriod(field, "EMA", smaParams); if (period > 0) return new EMAIndicator(close, period); } // 일목균형표 if (field.equals("CONVERSION_LINE")) return ichimokuTenkan(s, p); if (field.equals("BASE_LINE")) return ichimokuKijun(s, p); if (field.equals("LEADING_SPAN1")) return ichimokuSpanA(s, p); if (field.equals("LEADING_SPAN2")) return ichimokuSpanB(s, p); if (field.equals("LAGGING_SPAN")) return ichimokuLagging(s, p); // 거래량 MA 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); } // ── 일목균형표 지표 빌더 ────────────────────────────────────────────────── private IchimokuTenkanSenIndicator ichimokuTenkan(BarSeries s, Map p) { return new IchimokuTenkanSenIndicator(s, intP(p, "conversionPeriods", 9)); } private IchimokuKijunSenIndicator ichimokuKijun(BarSeries s, Map p) { return new IchimokuKijunSenIndicator(s, intP(p, "basePeriods", 26)); } private int ichimokuSenkouDisplacement(Map p) { return intP(p, "senkouDisplacement", intP(p, "displacement", 26)); } private int ichimokuChikouDisplacement(Map p) { return intP(p, "chikouDisplacement", intP(p, "displacement", 26)); } private IchimokuSenkouSpanAIndicator ichimokuSpanA(BarSeries s, Map p) { return new IchimokuSenkouSpanAIndicator(s, ichimokuTenkan(s, p), ichimokuKijun(s, p), ichimokuSenkouDisplacement(p)); } private IchimokuSenkouSpanBIndicator ichimokuSpanB(BarSeries s, Map p) { return new IchimokuSenkouSpanBIndicator(s, intP(p, "laggingSpan2Periods", 52), ichimokuSenkouDisplacement(p)); } private IchimokuChikouSpanIndicator ichimokuLagging(BarSeries s, Map p) { return new IchimokuChikouSpanIndicator(s, ichimokuChikouDisplacement(p)); } /** 후행스팬(현재 종가) vs chikouDisplacement 봉 전 종가 — 삼역호전 3-1 */ private Rule buildChikouVsPriorCloseRule(BarSeries s, Map p, boolean above) { int d = ichimokuChikouDisplacement(p); Indicator close = new ClosePriceIndicator(s); Indicator priorClose = new PreviousValueIndicator(close, d); return above ? new OverIndicatorRule(close, priorClose) : new UnderIndicatorRule(close, priorClose); } /** 후행스팬(현재 종가) vs chikouDisplacement 봉 전 구름 상단 — 삼역호전 3-2 */ private Rule buildChikouAbovePriorCloudRule(BarSeries s, Map p) { int d = ichimokuChikouDisplacement(p); Indicator close = new ClosePriceIndicator(s); NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); Indicator priorCloudTop = new PreviousValueIndicator(cloudTop, d); return new OverIndicatorRule(close, priorCloudTop); } /** 후행스팬이 26봉 전 구름 상단을 상향 돌파하는 순간 (1회성 진입 트리거) */ private Rule buildChikouCrossAbovePriorCloudRule(BarSeries s, Map p) { int d = ichimokuChikouDisplacement(p); ClosePriceIndicator close = new ClosePriceIndicator(s); NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); Indicator priorCloudTop = new PreviousValueIndicator(cloudTop, d); return new CrossedUpIndicatorRule(close, priorCloudTop); } /** 종가가 구름 위 */ private Rule buildCloudAboveRule(BarSeries s, Map p) { NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); return new OverIndicatorRule(new ClosePriceIndicator(s), cloudTop); } /** 종가가 구름 아래 */ private Rule buildCloudBelowRule(BarSeries s, Map p) { NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p)); return new UnderIndicatorRule(new ClosePriceIndicator(s), cloudBottom); } /** 종가가 구름 안 */ private Rule buildInCloudRule(BarSeries s, Map p) { ClosePriceIndicator close = new ClosePriceIndicator(s); NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p)); return new AndRule( new OrRule(new OverIndicatorRule(close, cloudBottom), buildEqRule(close, cloudBottom, s)), new OrRule(new UnderIndicatorRule(close, cloudTop), buildEqRule(close, cloudTop, s))); } /** 구름 상향(true)/하향(false) 돌파 */ private Rule buildCloudBreakRule(BarSeries s, Map p, boolean up) { NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p)); NumericIndicator cloudBottom = NumericIndicator.of(ichimokuSpanA(s, p)).min(ichimokuSpanB(s, p)); ClosePriceIndicator close = new ClosePriceIndicator(s); if (up) return new CrossedUpIndicatorRule(close, cloudTop); else return new CrossedDownIndicatorRule(close, cloudBottom); } // ── 범용 Rule 빌더 ──────────────────────────────────────────────────────── /** |a - b| < epsilon → equal */ private Rule buildEqRule(Indicator a, Indicator b, BarSeries series) { NumericIndicator diff = NumericIndicator.of(a).minus(b).abs(); return new UnderIndicatorRule(diff, new ConstantIndicator<>(series, series.numFactory().numOf(1e-9))); } /** N봉 연속으로 inner 규칙이 성립 */ private Rule buildHoldRule(Rule inner, int n) { if (n <= 1) return inner; return new Rule() { @Override public boolean isSatisfied(int index, TradingRecord record) { if (index < n - 1) return false; for (int i = index - n + 1; i <= index; i++) { if (!inner.isSatisfied(i, record)) return false; } return true; } }; } /** live-conditions·알림 UI — 캔들 범위 접두어 */ public static String candleRangeConditionPrefix(JsonNode cond) { if (cond == null || cond.isNull()) return ""; int candleRange = cond.path("candleRange").asInt(1); String mode = cond.path("candleRangeMode").asText(""); String condType = cond.path("conditionType").asText(""); if (mode.isBlank()) return ""; int n = Math.max(2, candleRange); if (isSlopeConditionType(condType) && ("EXISTS_IN".equals(mode) || "NOT_EXISTS_IN".equals(mode))) { return switch (mode) { case "EXISTS_IN" -> "최근 " + n + "봉 구간 "; case "NOT_EXISTS_IN" -> "최근 " + n + "봉 구간 비추세 · "; default -> ""; }; } return switch (mode) { case "EXISTS_IN" -> "최근 " + n + "봉 내 "; case "NOT_EXISTS_IN" -> "최근 " + n + "봉 내 없음 · "; default -> ""; }; } /** candleRangeMode 가 명시된 경우에만 윈도우 평가 (candleRange 숫자만으로는 적용하지 않음) */ private String resolveCandleRangeMode(JsonNode cond, int candleRange) { String mode = cond.path("candleRangeMode").asText(""); if (mode.isBlank()) return "CURRENT"; return mode; } /** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */ private Rule applyCandleRangeWindow(Rule core, String mode, int candleRange, String condType) { if ("CURRENT".equals(mode)) return core; // 기울기 + N봉 모드: buildSlopeTrendRule 에서 구간 추세로 이미 평가 if (isSlopeConditionType(condType)) return core; int n = Math.max(2, candleRange); return switch (mode) { case "EXISTS_IN" -> buildExistsInWindowRule(core, n); case "NOT_EXISTS_IN" -> buildNotExistsInWindowRule(core, n); default -> core; }; } /** * 기울기 조건. * CURRENT: slopePeriod 구간 추세(현재 봉 기준). * EXISTS_IN / NOT_EXISTS_IN: candleRange(N) 구간 전체 그래프가 상승/하락 상태인지 현재 봉에서 판정 * (N봉 내 임의 시점의 짧은 기울기 존재 여부가 아님). */ private Rule buildSlopeTrendRule( Indicator left, boolean up, String candleRangeMode, int candleRange, int slopePeriod) { int bars = slopePeriod; if ("EXISTS_IN".equals(candleRangeMode) || "NOT_EXISTS_IN".equals(candleRangeMode)) { bars = Math.max(2, candleRange); } else { bars = Math.max(1, slopePeriod); } Rule trend = up ? new IsRisingRule(left, bars) : new IsFallingRule(left, bars); if ("NOT_EXISTS_IN".equals(candleRangeMode)) { return new NotRule(trend); } return trend; } private static boolean isSlopeConditionType(String condType) { return "SLOPE_UP".equals(condType) || "SLOPE_DOWN".equals(condType); } /** 최근 N봉 중 어느 한 봉이라도 inner 가 true */ private Rule buildExistsInWindowRule(Rule inner, int n) { if (n <= 1) return inner; return new Rule() { @Override public boolean isSatisfied(int index, TradingRecord record) { int start = index - n + 1; for (int i = start; i <= index; i++) { if (i < 0) continue; if (inner.isSatisfied(i, record)) return true; } return false; } }; } /** 최근 N봉 중 inner 가 true 인 봉이 하나도 없음 */ private Rule buildNotExistsInWindowRule(Rule inner, int n) { if (n <= 1) return new NotRule(inner); return new Rule() { @Override public boolean isSatisfied(int index, TradingRecord record) { int start = index - n + 1; for (int i = start; i <= index; i++) { if (i < 0) continue; if (inner.isSatisfied(i, record)) return false; } return true; } }; } // ── TRIX 헬퍼 ───────────────────────────────────────────────────────────── private Indicator resolvePriceSource(BarSeries s, Map p) { return switch (p.getOrDefault("src", "close").toString()) { case "open" -> new OpenPriceIndicator(s); case "high" -> new HighPriceIndicator(s); case "low" -> new LowPriceIndicator(s); case "hl2" -> new MedianPriceIndicator(s); case "hlc3" -> new TypicalPriceIndicator(s); default -> new ClosePriceIndicator(s); }; } private EMAIndicator tripleEma(ClosePriceIndicator close, int len) { 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); case "WMA" -> new WMAIndicator(source, len); case "SMMA (RMA)", "RMA" -> new WildersMAIndicator(source, len); default -> new SMAIndicator(source, len); }; } private int parseTrailingInt(String field, String prefix, int def) { if (!field.startsWith(prefix)) return def; try { return Integer.parseInt(field.substring(prefix.length())); } catch (NumberFormatException e) { return def; } } /** 이격도 = 종가 / SMA(period) * 100 */ private Indicator disparityIndicator(BarSeries s, int period, Map p) { ClosePriceIndicator close = new ClosePriceIndicator(s); SMAIndicator sma = new SMAIndicator(close, period); return NumericIndicator.of(close).dividedBy(sma).multipliedBy(100); } private Indicator vrIndicator(BarSeries s, int period) { return new VrIndicator(s, period); } private Indicator psychIndicator(BarSeries s, int period, boolean volumeWeighted) { return new PsychologicalIndicator(s, period, volumeWeighted); } private Indicator newSentPsyIndicator(BarSeries s, int period) { return new NewSentimentPsychologicalIndicator(s, period); } /** VR(거래량비율) = 상승일 거래량합 / 하락일 거래량합 × 100 */ private static final class VrIndicator extends CachedIndicator { private final int period; private final ClosePriceIndicator close; private final VolumeIndicator volume; VrIndicator(BarSeries series, int period) { super(series); this.period = period; this.close = new ClosePriceIndicator(series); this.volume = new VolumeIndicator(series, 1); } @Override protected Num calculate(int index) { if (index < period) return null; double upVol = 0, downVol = 0; for (int j = index - period + 1; j <= index; j++) { Num c = close.getValue(j); Num cPrev = close.getValue(j - 1); Num v = volume.getValue(j); if (c == null || cPrev == null || v == null) continue; if (c.isGreaterThan(cPrev)) upVol += v.doubleValue(); else if (c.isLessThan(cPrev)) downVol += v.doubleValue(); } if (downVol == 0) return null; return getBarSeries().numFactory().numOf(upVol / downVol * 100.0); } @Override public int getCountOfUnstableBars() { return period + 1; } } /** 심리도 / 투자심리도 */ private static final class PsychologicalIndicator extends CachedIndicator { private final int period; private final boolean volumeWeighted; private final ClosePriceIndicator close; private final VolumeIndicator volume; PsychologicalIndicator(BarSeries series, int period, boolean volumeWeighted) { super(series); this.period = period; this.volumeWeighted = volumeWeighted; this.close = new ClosePriceIndicator(series); this.volume = new VolumeIndicator(series, 1); } @Override protected Num calculate(int index) { if (index < period) return null; if (!volumeWeighted) { int up = 0; for (int j = index - period + 1; j <= index; j++) { Num c = close.getValue(j); Num cPrev = close.getValue(j - 1); if (c != null && cPrev != null && c.isGreaterThan(cPrev)) up++; } return getBarSeries().numFactory().numOf((double) up / period * 100.0); } double upVol = 0, total = 0; for (int j = index - period + 1; j <= index; j++) { Num c = close.getValue(j); Num cPrev = close.getValue(j - 1); Num v = volume.getValue(j); if (v == null) continue; total += v.doubleValue(); if (c != null && cPrev != null && c.isGreaterThan(cPrev)) upVol += v.doubleValue(); } if (total == 0) return null; return getBarSeries().numFactory().numOf(upVol / total * 100.0); } @Override public int getCountOfUnstableBars() { return period + 1; } } /** 신심리도 — 상승·하락 일수와 등락폭 반영 */ private static final class NewSentimentPsychologicalIndicator extends CachedIndicator { private final int period; private final ClosePriceIndicator close; NewSentimentPsychologicalIndicator(BarSeries series, int period) { super(series); this.period = period; this.close = new ClosePriceIndicator(series); } @Override protected Num calculate(int index) { if (index < period) return null; double upSize = 0, downSize = 0; int upDay = 0, downDay = 0; for (int j = index - period + 1; j <= index; j++) { Num c = close.getValue(j); Num cPrev = close.getValue(j - 1); if (c == null || cPrev == null) continue; double diff = c.doubleValue() - cPrev.doubleValue(); if (diff > 0) { upSize += diff; upDay++; } else if (diff < 0) { downSize += -diff; downDay++; } } double total = upSize + downSize; if (total == 0) return null; double val = (upDay * (upSize / total) - downDay * (downSize / total)) * 100.0 / period; return getBarSeries().numFactory().numOf(val); } @Override public int getCountOfUnstableBars() { return period + 1; } } /** * 직전 N봉 고가 중 최고값 (현재 봉 제외). * index t 에서 max(high[t-N]..high[t-1]). */ private Indicator priorHighestHigh(BarSeries s, int period) { HighPriceIndicator high = new HighPriceIndicator(s); HighestValueIndicator highest = new HighestValueIndicator(high, period); return new PreviousValueIndicator(highest, 1); } /** * 직전 N봉 저가 중 최저값 (현재 봉 제외). */ private Indicator priorLowestLow(BarSeries s, int period) { LowPriceIndicator low = new LowPriceIndicator(s); LowestValueIndicator lowest = new LowestValueIndicator(low, period); return new PreviousValueIndicator(lowest, 1); } // ── MA/EMA DSL (MA1~11 ↔ SMA period1~11) ───────────────────────────────── /** SMA 차트 MA1~MA11 기본 기간 — IndicatorService 와 동일 */ private static final int[] SMA_DEFAULT_PERIODS = { 5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120 }; private static final int MA_DSL_SLOT_MAX = 11; /** * MA/EMA DSL 필드 → SMA/EMA 계산 기간. * MA3·MA5(1~11): SMA 설정 period1~11. MA20·MA60(>11): 레거시 기간 인코딩. */ public int resolveMovingAveragePeriod(String field, String prefix, Map smaParams) { if (field == null || !field.startsWith(prefix)) return -1; if ("MA".equals(prefix) && field.startsWith("MACD")) return -1; try { int n = Integer.parseInt(field.substring(prefix.length())); if (n >= 1 && n <= MA_DSL_SLOT_MAX) { String periodKey = "period" + n; int def = SMA_DEFAULT_PERIODS[n - 1]; return Math.max(1, intP(smaParams, periodKey, def)); } if (n > MA_DSL_SLOT_MAX) return n; } catch (NumberFormatException ignored) { /* fall */ } return -1; } /** live-conditions·해석 UI — MA20일 형식 (슬롯·기간 설정값 기준) */ public String formatMovingAverageFieldLabel(String field, Map> params) { if (field == null || field.isBlank()) return field; Map sma = params != null ? params.getOrDefault("SMA", Map.of()) : Map.of(); if (field.startsWith("MA") && !field.startsWith("MACD")) { int period = resolveMovingAveragePeriod(field, "MA", sma); if (period <= 0) return field; return "MA" + period + "일"; } if (field.startsWith("EMA")) { int period = resolveMovingAveragePeriod(field, "EMA", sma); if (period <= 0) return field; return "EMA" + period + "일"; } return field; } // ── 파라미터 헬퍼 ───────────────────────────────────────────────────────── /** 직전봉 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.equals(valuePrefix) || leftField.startsWith(valuePrefix + "_")) { 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); if (v == null) return def; if (v instanceof Number n) return n.intValue(); try { return Integer.parseInt(v.toString()); } catch (NumberFormatException e) { return def; } } private double dblP(Map p, String k, double def) { if (p == null) return def; Object v = p.get(k); if (v == null) return def; if (v instanceof Number n) return n.doubleValue(); try { return Double.parseDouble(v.toString()); } catch (NumberFormatException e) { return def; } } /** * 조건 노드 JSON에서 직접 저장된 파라미터를 추출한다. * 전략 편집기가 저장한 조건별 override params (예: {"kLength": 12}). * 없거나 객체가 아니면 빈 맵 반환. */ @SuppressWarnings("unchecked") private Map extractConditionNodeParams(JsonNode cond) { if (cond == null || cond.isNull()) return Map.of(); JsonNode paramsNode = cond.path("params"); if (paramsNode.isMissingNode() || paramsNode.isNull() || !paramsNode.isObject()) { return Map.of(); } try { Map result = new java.util.LinkedHashMap<>(); paramsNode.fields().forEachRemaining(e -> { com.fasterxml.jackson.databind.JsonNode v = e.getValue(); if (v.isNumber()) result.put(e.getKey(), v.numberValue()); else if (v.isTextual()) result.put(e.getKey(), v.textValue()); else if (v.isBoolean()) result.put(e.getKey(), v.booleanValue()); }); return result; } catch (Exception ex) { return Map.of(); } } /** globalParams 위에 overrides를 덮어쓴 새 맵 반환 */ private Map mergeParams(Map global, Map overrides) { Map merged = new java.util.LinkedHashMap<>(global); 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); } }