전략편집기 목록방식 오류 수정
This commit is contained in:
@@ -34,7 +34,8 @@ public final class IndicatorHlineResolver {
|
||||
}
|
||||
}
|
||||
|
||||
if (override) {
|
||||
// thresholdOverride·targetValue 는 임계값(rightField) 전용 — RSI_VALUE 등 본선 필드에는 적용하지 않음
|
||||
if (override && isThresholdRoleField(field)) {
|
||||
if (cond.has("targetValue") && !cond.path("targetValue").isNull()) {
|
||||
return cond.path("targetValue").asDouble();
|
||||
}
|
||||
@@ -49,6 +50,16 @@ public final class IndicatorHlineResolver {
|
||||
return legacyNumericField(field);
|
||||
}
|
||||
|
||||
/** HL_*·K_*·OVERBOUGHT_70 등 임계값 필드 — RSI_VALUE 등 지표 본선은 제외 */
|
||||
private static boolean isThresholdRoleField(String field) {
|
||||
if (field == null || field.isBlank()) return false;
|
||||
if (field.startsWith("K_")) return true;
|
||||
if ("HL_OVER".equals(field) || "HL_MID".equals(field) || "HL_UNDER".equals(field)) {
|
||||
return true;
|
||||
}
|
||||
return legacyNumericField(field) != null;
|
||||
}
|
||||
|
||||
private static Double fromHlineRole(
|
||||
String role,
|
||||
String dslIndicatorType,
|
||||
|
||||
@@ -203,6 +203,23 @@ public class LiveConditionStatusService {
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
private String probeEntryRule(Rule entryRule, JsonNode buyDsl,
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx,
|
||||
int startIdx) {
|
||||
JsonNode cond = findFirstCondition(buyDsl);
|
||||
if (cond == null) return "no-cond";
|
||||
BarSeries series = ruleCtx.primarySeries();
|
||||
int probeIdx = Math.min(series.getEndIndex(), startIdx + 13);
|
||||
Double left = adapter.readConditionFieldValue(cond, ruleCtx, probeIdx, true);
|
||||
Double right = adapter.readConditionFieldValue(cond, ruleCtx, probeIdx, false);
|
||||
int hits = 0;
|
||||
for (int i = startIdx; i <= series.getEndIndex(); i++) {
|
||||
if (entryRule.isSatisfied(i, null)) hits++;
|
||||
}
|
||||
return String.format("rule=%s hits=%d probe@%d L=%s R=%s",
|
||||
entryRule.getClass().getSimpleName(), hits, probeIdx, left, right);
|
||||
}
|
||||
|
||||
/** scan 0건 시 DSL 임계값 요약 로그 */
|
||||
private static String summarizeBuyCondition(JsonNode buyDsl) {
|
||||
if (buyDsl == null || buyDsl.isNull()) return "null";
|
||||
@@ -817,9 +834,10 @@ public class LiveConditionStatusService {
|
||||
}
|
||||
}
|
||||
if (signals.isEmpty()) {
|
||||
log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{} cond={}",
|
||||
log.info("[LiveCondition:scan] 0 signals market={} strategy={} tf={} bars={} eval={}..{} cond={} probe={}",
|
||||
market, strategyId, primaryTf, primarySeries.getBarCount(), startIdx,
|
||||
primarySeries.getEndIndex(), summarizeBuyCondition(buyDsl));
|
||||
primarySeries.getEndIndex(), summarizeBuyCondition(buyDsl),
|
||||
probeEntryRule(entryRule, buyDsl, ruleCtx, startIdx));
|
||||
} else {
|
||||
log.debug("[LiveCondition:scan] market={} strategy={} tf={} buy={} sell={}",
|
||||
market, strategyId, primaryTf, buyHits, sellHits);
|
||||
|
||||
@@ -236,7 +236,18 @@ public class StrategyDslToTa4jAdapter {
|
||||
public Double readConditionFieldValue(JsonNode cond, BarSeries series,
|
||||
Map<String, Map<String, Object>> params,
|
||||
int index, boolean leftSide) {
|
||||
if (cond == null || cond.isNull() || series == null || index < 0) return null;
|
||||
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
|
||||
@@ -251,13 +262,11 @@ public class StrategyDslToTa4jAdapter {
|
||||
int sidePeriod = leftSide ? leftPeriod : rightPeriod;
|
||||
|
||||
String registryKey = toRegistryKey(indType);
|
||||
Map<String, Object> indParams = params != null
|
||||
? params.getOrDefault(registryKey, Map.of())
|
||||
Map<String, Object> indParams = ctx.indicatorParams() != null
|
||||
? ctx.indicatorParams().getOrDefault(registryKey, Map.of())
|
||||
: Map.of();
|
||||
|
||||
try {
|
||||
RuleBuildContext ctx = new RuleBuildContext(series, params != null ? params : Map.of(),
|
||||
Map.of(), null, null, false, Map.of());
|
||||
Indicator<Num> ind = resolveField(field, indType, indParams, series, condPeriod, sidePeriod, cond, ctx);
|
||||
Num v = ind.getValue(index);
|
||||
return v != null ? v.doubleValue() : null;
|
||||
@@ -762,6 +771,11 @@ public class StrategyDslToTa4jAdapter {
|
||||
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);
|
||||
@@ -792,6 +806,20 @@ public class StrategyDslToTa4jAdapter {
|
||||
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) {
|
||||
// K_ 접두사: 프론트엔드가 hline 실제값으로 생성한 상수 필드
|
||||
// 예) K_60 → 60, K_-100 → -100, K_0 → 0
|
||||
|
||||
@@ -52,6 +52,17 @@ class IndicatorHlineResolverTest {
|
||||
assertEquals(48.0, val, 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rsiValue_withThresholdOverride_doesNotUseTargetValue() throws Exception {
|
||||
var cond = MAPPER.readTree("""
|
||||
{"thresholdOverride": true, "targetValue": 55}
|
||||
""");
|
||||
assertNull(IndicatorHlineResolver.resolveThresholdField(
|
||||
cond, "RSI_VALUE", "RSI", Map.of()));
|
||||
assertEquals(55.0, IndicatorHlineResolver.resolveThresholdField(
|
||||
cond, "K_55", "RSI", Map.of()), 0.0001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankField_returnsNull() {
|
||||
assertNull(IndicatorHlineResolver.resolveThresholdField(
|
||||
|
||||
@@ -31,6 +31,70 @@ class RsiChartScanIntegrationTest {
|
||||
normalizer = new StrategyDslTimeframeNormalizer(MAPPER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chartScan_strategy195Exact_k55_vs_hlMid() throws Exception {
|
||||
String chartTf = "3m";
|
||||
List<OhlcvBar> bars = buildOscillatingOhlcvBars(300, chartTf);
|
||||
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(bars, chartTf);
|
||||
|
||||
JsonNode buyDslRaw = MAPPER.readTree("""
|
||||
{
|
||||
"type": "TIMEFRAME",
|
||||
"candleTypes": ["3m"],
|
||||
"candleType": "3m",
|
||||
"children": [{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "RSI",
|
||||
"conditionType": "CROSS_UP",
|
||||
"leftField": "RSI_VALUE",
|
||||
"rightField": "K_55",
|
||||
"targetValue": 55,
|
||||
"thresholdOverride": true,
|
||||
"valuePeriodOverride": false,
|
||||
"leftCandleType": "3m",
|
||||
"rightCandleType": "3m",
|
||||
"candleRange": 1
|
||||
}
|
||||
}]
|
||||
}
|
||||
""");
|
||||
|
||||
JsonNode buyDsl = normalizer.remapForChartTimeframe(
|
||||
StrategyConditionThresholdNormalizer.normalizeTree(buyDslRaw), chartTf);
|
||||
Map<String, BarSeries> seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides(
|
||||
primarySeries, chartTf, buyDsl, null);
|
||||
Map<String, Map<String, Object>> params = Map.of(
|
||||
"RSI", Map.of("length", 9, "maLength", 5, "maType", "SMA", "src", "close"));
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, Map.of(), "KRW-BTC", null, false, seriesOverrides);
|
||||
Rule ruleK55 = adapter.toRule(buyDsl, ctx);
|
||||
int hits55 = countHitsFrom(ruleK55, primarySeries, 100);
|
||||
|
||||
JsonNode buyHlMid = buyDsl.deepCopy();
|
||||
JsonNode cond = buyHlMid.path("children").get(0).path("condition");
|
||||
var condObj = (com.fasterxml.jackson.databind.node.ObjectNode) cond;
|
||||
condObj.put("rightField", "HL_MID");
|
||||
condObj.remove("targetValue");
|
||||
condObj.put("thresholdOverride", false);
|
||||
Rule ruleMid = adapter.toRule(buyHlMid, ctx);
|
||||
int hitsMid = countHitsFrom(ruleMid, primarySeries, 100);
|
||||
|
||||
assertFalse(hits55 == 0, "strategy195-like K_55 should produce signals, got " + hits55);
|
||||
assertTrue(hits55 <= hitsMid,
|
||||
"K_55 hits=" + hits55 + " should be <= HL_MID hits=" + hitsMid);
|
||||
}
|
||||
|
||||
private static int countHitsFrom(Rule rule, BarSeries series, int startIdx) {
|
||||
int hits = 0;
|
||||
for (int i = startIdx; i <= series.getEndIndex(); i++) {
|
||||
if (rule.isSatisfied(i, null)) hits++;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
@Test
|
||||
void chartScan_rsiCrossUpK55_firesSignals() throws Exception {
|
||||
String chartTf = "3m";
|
||||
|
||||
Reference in New Issue
Block a user