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);
}
}