Files
goldenChart/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java
T
2026-06-25 02:45:39 +09:00

1852 lines
92 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.dto.TrendLineKind;
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 변환 어댑터.
*
* <h3>DSL 타입 → 레지스트리(DB) 키 매핑</h3>
* <p>frontend strategyTypes.ts 의 PaletteItem.value (DSL indicatorType) 는
* 대문자 스네이크케이스이지만, indicatorRegistry.ts 의 type 키(=DB 저장 키)는
* PascalCase 이므로 반드시 변환 후 DB 파라미터를 조회해야 한다.</p>
*
* <h3>DSL 구조</h3>
* <pre>
* {
* "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
* }
* }
* </pre>
*/
@Component
@Slf4j
public class StrategyDslToTa4jAdapter {
/**
* DSL indicatorType(대문자 스네이크) → indicatorRegistry type(PascalCase, DB 키).
* frontend strategyTypes.ts DSL_TO_REGISTRY 와 동기화.
*/
private static final Map<String, String> 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<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market,
Ta4jStorage storage,
/**
* CANDLE_CLOSE 평가 시 true — CrossSeriesRule 이 상위봉의 마지막 확정봉
* (endIndex - 1)을 사용하도록 강제한다. provisional 봉 오염을 방지.
*/
boolean useConfirmedOnly,
/**
* 백테스트용 사전 집계 시리즈 맵 candleType→BarSeries.
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
*/
Map<String, BarSeries> seriesOverrides,
/** 추세선 조건(TREND_LINE_CROSS_*) — 전략설정 gc_backtest_settings */
TrendLineConfig trendLineConfig
) {
/** visual 미전달 호환 (레거시) */
public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams,
String market,
Ta4jStorage storage) {
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of(), null);
}
public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market,
Ta4jStorage storage,
boolean useConfirmedOnly,
Map<String, BarSeries> seriesOverrides) {
this(primarySeries, indicatorParams, indicatorVisual, market, storage,
useConfirmedOnly, seriesOverrides, null);
}
public TrendLineConfig effectiveTrendLineConfig() {
return trendLineConfig != null ? trendLineConfig : TrendLineConfig.defaults();
}
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
public RuleBuildContext withPrimary(BarSeries newPrimary) {
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
market, storage, useConfirmedOnly, seriesOverrides, trendLineConfig);
}
/** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */
public Map<String, Object> smaParams() {
if (indicatorParams == null) return Map.of();
Map<String, Object> 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<String, Map<String, Object>> indicatorParams) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of(), null));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of(), null));
}
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of(), null));
}
/**
* 백테스트 전용: 사전 집계된 상위봉 시리즈 맵을 함께 전달.
* CrossSeriesRule 이 타임스탬프 기반 룩업을 수행하여 라이브와 동일한 멀티 TF 평가를 보장한다.
*/
public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, BarSeries> seriesOverrides) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false,
seriesOverrides != null ? seriesOverrides : Map.of(), null));
}
/**
* CANDLE_CLOSE 전용: useConfirmedOnly=true 로 상위봉 확정봉 인덱스를 사용한다.
*/
public Rule toRuleOnCandleClose(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of(), null));
}
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<String, Map<String, Object>> 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<String, Object> indParams = ctx.indicatorParams() != null
? ctx.indicatorParams().getOrDefault(registryKey, Map.of())
: Map.of();
try {
Indicator<Num> 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<Rule> 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.
*
* <ul>
* <li><b>백테스트 모드</b>({@code isBacktest=true}): 기본봉 endTime ≤ 기준으로 상위봉 인덱스를
* 이진탐색하여 정확히 대응하는 확정봉을 사용한다. 라이브와 동일한 멀티 TF 시그널 보장.</li>
* <li><b>CANDLE_CLOSE 모드</b>({@code useConfirmedOnly=true}): 상위봉의 마지막 확정봉
* (endIndex - 1)을 사용한다. 진행 중인 provisional 봉을 참조하지 않음.</li>
* <li><b>REALTIME_TICK 모드</b>(둘 다 false): 상위봉의 {@code getEndIndex()}(현재 봉 포함)
* 를 사용한다. 실시간 지표값 반영.</li>
* </ul>
*/
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<Rule> 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<Rule> 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<Rule> rules = childRules(node, ctx);
if (rules.isEmpty()) return new BooleanRule(false);
return new NotRule(rules.get(0));
}
private List<Rule> childRules(JsonNode node, RuleBuildContext ctx) {
List<Rule> 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<String, Map<String, Object>> p) {
return buildAndRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private Rule buildOrRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildOrRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private Rule buildNotRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> p) {
return buildNotRule(node, new RuleBuildContext(series, p, Map.of(), null, null, false, Map.of()));
}
private List<Rule> childRules(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> 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<String, Map<String, Object>> 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<String, Object> globalParams = params != null
? params.getOrDefault(registryKey, Map.of())
: Map.of();
// 조건 노드에 직접 저장된 params가 있으면 전역 설정보다 우선 적용
// 예) {"kLength": 12, "smooth": 3} → 전역 Stochastic 설정(kLength=14)을 덮어씀
Map<String, Object> condNodeParams = extractConditionNodeParams(cond);
Map<String, Object> indParams = condNodeParams.isEmpty() ? globalParams
: mergeParams(globalParams, condNodeParams);
if ("ICHIMOKU_BB".equals(indType)) {
return buildIchimokuBbRule(series, ctx, cond, condType, candleRangeMode, candleRange);
}
try {
Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> 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 "TREND_LINE_CROSS_UP", "TREND_LINE_CROSS_DOWN" -> {
int lb = cond.path("lookbackPeriod").asInt(0);
if (lb <= 0) lb = ctx.effectiveTrendLineConfig().getDefaultLookback();
lb = Math.max(2, lb);
boolean priceScale = isTrendLinePriceScaleField(leftField);
yield "TREND_LINE_CROSS_UP".equals(condType)
? buildTrendLineCrossUpRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale)
: buildTrendLineCrossDownRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale);
}
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<Num> rollingMin = new LowestValueIndicator(left, lookbackPeriod);
yield new OrRule(new UnderIndicatorRule(rollingMin, right),
buildEqRule(rollingMin, right, series));
}
case "LOWEST_GTE" -> {
Indicator<Num> 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<Num> 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<Num> left, Indicator<Num> 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<String, Object> 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<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod, cond, ctx);
Indicator<Num> 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 과 동일 타임스탬프 비교 로직 적용.
*
* <p>기존 isBacktest=false 시 단순 {@code branch.getEndIndex()} 를 반환했는데,
* 이는 경계 시점(예: 5m=10:00, 1h=10:00 동시 마감)에 올바른 1h 봉을 가리키지만
* useConfirmedOnly=true 일 때 provisional 여부를 판단하지 않아 불일치 가능성이 있었다.
*
* <p>개선: isBacktest 여부와 무관하게 타임스탬프 비교로 통일한다.
* <ul>
* <li>backtest 또는 branch.lastEndTime ≤ trigger.endTime → 타임스탬프 기반 정확한 인덱스</li>
* <li>라이브 REALTIME_TICK (useConfirmedOnly=false) 이고 branch 가 아직 진행 중 → getEndIndex()</li>
* </ul>
*/
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<Num> left;
private final Indicator<Num> 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<Num> left,
Indicator<Num> 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<Num> resolveField(String field, String indType,
Map<String, Object> 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<Num> 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<String, Object> 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<Num> resolveField(String field, String indType,
Map<String, Object> 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<Num> 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<Num> resolveIndicatorField(String field, String indType,
Map<String, Object> p,
Map<String, Object> 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<String, Object> 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<Num> 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<String, Object> p) {
return new IchimokuTenkanSenIndicator(s, intP(p, "conversionPeriods", 9));
}
private IchimokuKijunSenIndicator ichimokuKijun(BarSeries s, Map<String, Object> p) {
return new IchimokuKijunSenIndicator(s, intP(p, "basePeriods", 26));
}
private int ichimokuSenkouDisplacement(Map<String, Object> p) {
return intP(p, "senkouDisplacement", intP(p, "displacement", 26));
}
private int ichimokuChikouDisplacement(Map<String, Object> p) {
return intP(p, "chikouDisplacement", intP(p, "displacement", 26));
}
private IchimokuSenkouSpanAIndicator ichimokuSpanA(BarSeries s, Map<String, Object> p) {
return new IchimokuSenkouSpanAIndicator(s, ichimokuTenkan(s, p), ichimokuKijun(s, p),
ichimokuSenkouDisplacement(p));
}
private IchimokuSenkouSpanBIndicator ichimokuSpanB(BarSeries s, Map<String, Object> p) {
return new IchimokuSenkouSpanBIndicator(s, intP(p, "laggingSpan2Periods", 52),
ichimokuSenkouDisplacement(p));
}
private IchimokuChikouSpanIndicator ichimokuLagging(BarSeries s, Map<String, Object> p) {
return new IchimokuChikouSpanIndicator(s, ichimokuChikouDisplacement(p));
}
/** 후행스팬(현재 종가) vs chikouDisplacement 봉 전 종가 — 삼역호전 3-1 */
private Rule buildChikouVsPriorCloseRule(BarSeries s, Map<String, Object> p, boolean above) {
int d = ichimokuChikouDisplacement(p);
Indicator<Num> close = new ClosePriceIndicator(s);
Indicator<Num> 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<String, Object> p) {
int d = ichimokuChikouDisplacement(p);
Indicator<Num> close = new ClosePriceIndicator(s);
NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p));
Indicator<Num> priorCloudTop = new PreviousValueIndicator(cloudTop, d);
return new OverIndicatorRule(close, priorCloudTop);
}
/** 후행스팬이 26봉 전 구름 상단을 상향 돌파하는 순간 (1회성 진입 트리거) */
private Rule buildChikouCrossAbovePriorCloudRule(BarSeries s, Map<String, Object> p) {
int d = ichimokuChikouDisplacement(p);
ClosePriceIndicator close = new ClosePriceIndicator(s);
NumericIndicator cloudTop = NumericIndicator.of(ichimokuSpanA(s, p)).max(ichimokuSpanB(s, p));
Indicator<Num> priorCloudTop = new PreviousValueIndicator(cloudTop, d);
return new CrossedUpIndicatorRule(close, priorCloudTop);
}
/**
* N봉 전 후행스팬(종가) vs N봉 전 볼린저밴드 — 동일 시점(현재봉 기준 N봉 전) 비교.
* cond.params.chikouDisplacement (기본 26), rightField: UPPER_BAND | MIDDLE_BAND | LOWER_BAND
*/
private Rule buildIchimokuBbRule(
BarSeries series,
RuleBuildContext ctx,
JsonNode cond,
String condType,
String candleRangeMode,
int candleRange) {
Map<String, Map<String, Object>> allParams = ctx.indicatorParams();
Map<String, Object> ichGlobal = allParams != null
? allParams.getOrDefault("IchimokuCloud", Map.of())
: Map.of();
Map<String, Object> bbGlobal = allParams != null
? allParams.getOrDefault("BollingerBands", Map.of())
: Map.of();
Map<String, Object> nodeParams = extractConditionNodeParams(cond);
Map<String, Object> ichParams = nodeParams.isEmpty() ? ichGlobal
: mergeParams(ichGlobal, nodeParams);
Map<String, Object> bbParams = nodeParams.isEmpty() ? bbGlobal
: mergeParams(bbGlobal, nodeParams);
int d = intP(ichParams, "chikouDisplacement",
ichimokuChikouDisplacement(ichParams));
String bandField = cond.path("rightField").asText("UPPER_BAND");
if (!"UPPER_BAND".equals(bandField) && !"MIDDLE_BAND".equals(bandField) && !"LOWER_BAND".equals(bandField)) {
bandField = "UPPER_BAND";
}
ClosePriceIndicator close = new ClosePriceIndicator(series);
Indicator<Num> bbLine = resolveField(bandField, "BOLLINGER", bbParams, series, -1, -1, cond, ctx);
// 후행스팬 = 해당 봉 종가. N봉 전 시점의 후행스팬·BB를 같은 인덱스(t-N)에서 비교.
Indicator<Num> priorLagging = new PreviousValueIndicator(close, d);
Indicator<Num> priorBb = new PreviousValueIndicator(bbLine, d);
Rule core = switch (condType) {
case "CROSS_UP" -> buildCrossUpRule(priorLagging, priorBb);
case "CROSS_DOWN" -> buildCrossDownRule(priorLagging, priorBb);
case "GT", "GTE" -> new OverIndicatorRule(priorLagging, priorBb);
case "LT", "LTE" -> new UnderIndicatorRule(priorLagging, priorBb);
default -> {
log.warn("[Adapter] ICHIMOKU_BB 미지원 conditionType: {}", condType);
yield new BooleanRule(false);
}
};
return applyCandleRangeWindow(core, candleRangeMode, candleRange, condType);
}
/** 종가가 구름 위 */
private Rule buildCloudAboveRule(BarSeries s, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Num> a, Indicator<Num> 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<Num> 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<Num> resolvePriceSource(BarSeries s, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Num> maOfSource(Indicator<Num> 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<Num> disparityIndicator(BarSeries s, int period, Map<String, Object> p) {
ClosePriceIndicator close = new ClosePriceIndicator(s);
SMAIndicator sma = new SMAIndicator(close, period);
return NumericIndicator.of(close).dividedBy(sma).multipliedBy(100);
}
private Indicator<Num> vrIndicator(BarSeries s, int period) {
return new VrIndicator(s, period);
}
private Indicator<Num> psychIndicator(BarSeries s, int period, boolean volumeWeighted) {
return new PsychologicalIndicator(s, period, volumeWeighted);
}
private Indicator<Num> newSentPsyIndicator(BarSeries s, int period) {
return new NewSentimentPsychologicalIndicator(s, period);
}
/** VR(거래량비율) = 상승일 거래량합 / 하락일 거래량합 × 100 */
private static final class VrIndicator extends CachedIndicator<Num> {
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<Num> {
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<Num> {
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<Num> priorHighestHigh(BarSeries s, int period) {
HighPriceIndicator high = new HighPriceIndicator(s);
HighestValueIndicator highest = new HighestValueIndicator(high, period);
return new PreviousValueIndicator(highest, 1);
}
/**
* 직전 N봉 저가 중 최저값 (현재 봉 제외).
*/
private Indicator<Num> 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(&gt;11): 레거시 기간 인코딩.
*/
public int resolveMovingAveragePeriod(String field, String prefix, Map<String, Object> 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<String, Map<String, Object>> params) {
if (field == null || field.isBlank()) return field;
Map<String, Object> 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<Num> left, Indicator<Num> 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<Num> left, Indicator<Num> 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);
}
};
}
/**
* N봉 추세선 돌파 — [i−N, i−1] 구간으로 고정된 동일 선을 i−1·i 에서 평가.
* (일반 CROSS 와 달리 추세선 창이 봉마다 밀리지 않음)
*/
private Rule buildTrendLineCrossUpRule(
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
return new Rule() {
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
if (index < 1) return false;
TrendLineKind kind = TrendLineKind.forCrossUp();
double linePrev = TrendLineMath.valueAtEval(
series, left, index, index - 1, lookback, config, kind, priceScale);
double lineCurr = TrendLineMath.valueAtEval(
series, left, index, index, lookback, config, kind, priceScale);
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
Num l0 = left.getValue(index - 1);
Num l1 = left.getValue(index);
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
Num r0 = series.numFactory().numOf(linePrev);
Num r1 = series.numFactory().numOf(lineCurr);
return l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1);
}
};
}
private Rule buildTrendLineCrossDownRule(
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
return new Rule() {
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
if (index < 1) return false;
TrendLineKind kind = TrendLineKind.forCrossDown();
double linePrev = TrendLineMath.valueAtEval(
series, left, index, index - 1, lookback, config, kind, priceScale);
double lineCurr = TrendLineMath.valueAtEval(
series, left, index, index, lookback, config, kind, priceScale);
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
Num l0 = left.getValue(index - 1);
Num l1 = left.getValue(index);
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
Num r0 = series.numFactory().numOf(linePrev);
Num r1 = series.numFactory().numOf(lineCurr);
return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1);
}
};
}
/** 추세선 — 가격축(OHLC·MA 등) vs 오실레이터 */
private static boolean isTrendLinePriceScaleField(String field) {
if (field == null || field.isBlank() || "NONE".equalsIgnoreCase(field)) return true;
String f = field.toUpperCase();
return f.contains("CLOSE") || f.contains("PRICE") || f.contains("HIGH") || f.contains("LOW")
|| f.contains("OPEN") || f.contains("MA") || f.contains("EMA")
|| f.contains("UPPER") || f.contains("MIDDLE") || f.contains("LOWER")
|| f.contains("BAND") || f.contains("DC_") || f.contains("VWAP")
|| f.contains("CONVERSION") || f.contains("BASE") || f.contains("SPAN")
|| f.contains("LAGGING") || f.contains("CLOUD");
}
/**
* CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다.
* sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다.
*/
private int resolveLinkedBasePeriod(JsonNode cond, Map<String, Object> 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<String, Object> 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<String, Object> p, String paramKey, int defaultVal) {
if (periodOverride > 0) return periodOverride;
return intP(p, paramKey, defaultVal);
}
private int intP(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> mergeParams(Map<String, Object> global,
Map<String, Object> overrides) {
Map<String, Object> merged = new java.util.LinkedHashMap<>(global);
merged.putAll(overrides);
return merged;
}
/** 패키지 단위 테스트 — condPeriod/sidePeriod 가 지표에 반영되는지 검증용 */
Indicator<Num> resolveIndicatorFieldForTest(String field, String indType,
Map<String, Object> p, BarSeries s,
int condPeriod, int sidePeriod) {
return resolveIndicatorField(field, indType, p, Map.of(), s, condPeriod, sidePeriod, null);
}
/** 패키지 단위 테스트 — cond JSON 포함 CCI 본선/신호선 연동 검증용 */
Indicator<Num> resolveIndicatorFieldForTest(String field, String indType,
Map<String, Object> p, BarSeries s,
int condPeriod, int sidePeriod,
JsonNode cond) {
return resolveIndicatorField(field, indType, p, Map.of(), s, condPeriod, sidePeriod, cond);
}
}