1710 lines
84 KiB
Java
1710 lines
84 KiB
Java
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 변환 어댑터.
|
||
*
|
||
* <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
|
||
) {
|
||
/** 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());
|
||
}
|
||
|
||
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
|
||
public RuleBuildContext withPrimary(BarSeries newPrimary) {
|
||
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
|
||
market, storage, useConfirmedOnly, seriesOverrides);
|
||
}
|
||
|
||
/** 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()));
|
||
}
|
||
|
||
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()));
|
||
}
|
||
|
||
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()));
|
||
}
|
||
|
||
/**
|
||
* 백테스트 전용: 사전 집계된 상위봉 시리즈 맵을 함께 전달.
|
||
* 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()));
|
||
}
|
||
|
||
/**
|
||
* 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()));
|
||
}
|
||
|
||
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);
|
||
|
||
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 "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);
|
||
}
|
||
|
||
/** 종가가 구름 위 */
|
||
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(>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);
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|