N봉 기울기 판단로직 수정
This commit is contained in:
@@ -426,7 +426,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
|
||||
if (needsCrossTimeframeComposite(cond, ctx)) {
|
||||
Rule composite = buildCrossTimeframeCompositeRule(cond, ctx);
|
||||
return applyCandleRangeWindow(composite, candleRangeMode, candleRange);
|
||||
return applyCandleRangeWindow(composite, candleRangeMode, candleRange, condType);
|
||||
}
|
||||
|
||||
PriceExtremeNorm px = normalizePriceExtremeCondition(cond);
|
||||
@@ -466,8 +466,8 @@ public class StrategyDslToTa4jAdapter {
|
||||
// CrossedUp/Down: ta4j 0.22 에서는 2인자 생성자만 있음
|
||||
case "CROSS_UP" -> new CrossedUpIndicatorRule(left, right);
|
||||
case "CROSS_DOWN" -> new CrossedDownIndicatorRule(left, right);
|
||||
case "SLOPE_UP" -> new IsRisingRule(left, slopePeriod);
|
||||
case "SLOPE_DOWN" -> new IsFallingRule(left, slopePeriod);
|
||||
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(
|
||||
@@ -508,7 +508,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
yield new BooleanRule(false);
|
||||
}
|
||||
};
|
||||
Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange);
|
||||
Rule ranged = applyCandleRangeWindow(core, candleRangeMode, candleRange, condType);
|
||||
if (px != null) {
|
||||
return nanSafeCompareRule(ranged, left, right);
|
||||
}
|
||||
@@ -789,6 +789,18 @@ public class StrategyDslToTa4jAdapter {
|
||||
if (field.equals("CCI_VALUE") || indType.equals("CCI"))
|
||||
return new CciOnSourceIndicator(resolvePriceSource(s, CciOnSourceIndicator.normalizeParams(p)),
|
||||
periodOverride > 0 ? periodOverride : intP(p, "length", 13));
|
||||
if (field.equals("CCI_SIGNAL")) {
|
||||
Map<String, Object> norm = CciOnSourceIndicator.normalizeParams(p);
|
||||
CciOnSourceIndicator cci = new CciOnSourceIndicator(resolvePriceSource(s, norm), intP(p, "length", 13));
|
||||
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
||||
int maLen = intP(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));
|
||||
@@ -796,6 +808,17 @@ public class StrategyDslToTa4jAdapter {
|
||||
}
|
||||
if (field.equals("RSI_VALUE"))
|
||||
return new RSIIndicator(close, periodOverride > 0 ? periodOverride : intP(p, "length", 14));
|
||||
if (field.equals("RSI_SIGNAL")) {
|
||||
RSIIndicator rsi = new RSIIndicator(close, intP(p, "length", 14));
|
||||
String maType = p != null ? p.getOrDefault("maType", "SMA").toString() : "SMA";
|
||||
int maLen = intP(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
|
||||
if (field.equals("MACD_LINE")) {
|
||||
return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
|
||||
@@ -1062,8 +1085,16 @@ public class StrategyDslToTa4jAdapter {
|
||||
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 + "봉 내 없음 · ";
|
||||
@@ -1079,8 +1110,10 @@ public class StrategyDslToTa4jAdapter {
|
||||
}
|
||||
|
||||
/** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */
|
||||
private Rule applyCandleRangeWindow(Rule core, String mode, int candleRange) {
|
||||
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);
|
||||
@@ -1089,6 +1122,35 @@ public class StrategyDslToTa4jAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 기울기 조건.
|
||||
* 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;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.ta4j.core.bars.TimeBarBuilderFactory;
|
||||
import org.ta4j.core.num.DoubleNumFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 기울기 + N봉 내 존재 — N봉 구간 전체 추세 판정 (임의 시점 짧은 기울기 존재 여부 아님).
|
||||
*/
|
||||
class SlopeCandleRangeRuleTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private StrategyDslToTa4jAdapter adapter;
|
||||
private BarSeries series;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new StrategyDslToTa4jAdapter();
|
||||
// 중간 3봉만 상승, 전체 10봉은 횡보·하락 혼재 → 짧은 기울기만 존재
|
||||
series = buildOscillatingSeries(new double[] {
|
||||
100, 101, 100, 101, 100, 101, 102, 103, 102, 101
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void slopeUp_existsIn_usesFullWindowTrend_notAnyShortRiseInWindow() throws Exception {
|
||||
Rule rule = toSlopeRule("""
|
||||
"candleRangeMode": "EXISTS_IN",
|
||||
"candleRange": 10,
|
||||
"slopePeriod": 3
|
||||
""", "SLOPE_UP");
|
||||
int idx = series.getEndIndex();
|
||||
assertFalse(rule.isSatisfied(idx, null),
|
||||
"10봉 구간 전체가 상승 추세가 아니면 false (중간 3봉 상승만으로는 true 아님)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void slopeUp_current_usesSlopePeriod() throws Exception {
|
||||
BarSeries rising = buildOscillatingSeries(new double[] {
|
||||
100, 101, 102, 103, 104
|
||||
});
|
||||
Rule rule = toSlopeRule("""
|
||||
"candleRangeMode": "CURRENT",
|
||||
"slopePeriod": 3
|
||||
""", "SLOPE_UP", rising);
|
||||
assertTrue(rule.isSatisfied(rising.getEndIndex(), null),
|
||||
"현재 봉 기준 slopePeriod 구간 상승이면 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void slopeUp_existsIn_monotonicWindow_true() throws Exception {
|
||||
BarSeries rising = buildOscillatingSeries(new double[] {
|
||||
100, 101, 102, 103, 104, 105, 106, 107, 108, 109
|
||||
});
|
||||
Rule rule = toSlopeRule("""
|
||||
"candleRangeMode": "EXISTS_IN",
|
||||
"candleRange": 10,
|
||||
"slopePeriod": 3
|
||||
""", "SLOPE_UP", rising);
|
||||
assertTrue(rule.isSatisfied(rising.getEndIndex(), null),
|
||||
"10봉 구간 전체 상승 추세이면 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void slopeDown_notExistsIn_trueWhenWindowNotFalling() throws Exception {
|
||||
Rule rule = toSlopeRule("""
|
||||
"candleRangeMode": "NOT_EXISTS_IN",
|
||||
"candleRange": 10,
|
||||
"slopePeriod": 3
|
||||
""", "SLOPE_DOWN");
|
||||
assertTrue(rule.isSatisfied(series.getEndIndex(), null),
|
||||
"10봉 구간이 하락 추세가 아니면 NOT_EXISTS_IN(SLOPE_DOWN) true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void candleRangePrefix_slopeExistsIn_usesPeriodWording() throws Exception {
|
||||
JsonNode cond = MAPPER.readTree("""
|
||||
{
|
||||
"conditionType": "SLOPE_UP",
|
||||
"candleRangeMode": "EXISTS_IN",
|
||||
"candleRange": 8
|
||||
}
|
||||
""");
|
||||
assertEquals("최근 8봉 구간 ", StrategyDslToTa4jAdapter.candleRangeConditionPrefix(cond));
|
||||
}
|
||||
|
||||
private Rule toSlopeRule(String extraFields, String condType) throws Exception {
|
||||
return toSlopeRule(extraFields, condType, series);
|
||||
}
|
||||
|
||||
private Rule toSlopeRule(String extraFields, String condType, BarSeries s) throws Exception {
|
||||
String extra = extraFields == null || extraFields.isBlank()
|
||||
? ""
|
||||
: ",\n " + extraFields.strip().replaceAll(",\\s*$", "");
|
||||
String json = """
|
||||
{
|
||||
"id": "c-slope",
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "MA",
|
||||
"conditionType": "%s",
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "NONE"%s
|
||||
}
|
||||
}
|
||||
""".formatted(condType, extra);
|
||||
JsonNode node = MAPPER.readTree(json);
|
||||
Rule rule = adapter.toRule(node, s, java.util.Map.of());
|
||||
assertNotNull(rule);
|
||||
return rule;
|
||||
}
|
||||
|
||||
private static BarSeries buildOscillatingSeries(double[] closes) {
|
||||
BarSeries s = new BaseBarSeriesBuilder()
|
||||
.withName("test-slope")
|
||||
.withNumFactory(DoubleNumFactory.getInstance())
|
||||
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
||||
.build();
|
||||
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
||||
Instant base = Instant.parse("2024-06-01T00:00:00Z");
|
||||
Duration period = Duration.ofMinutes(1);
|
||||
for (int i = 0; i < closes.length; i++) {
|
||||
double close = closes[i];
|
||||
Instant end = base.plus(period.multipliedBy(i + 1L));
|
||||
factory.createBarBuilder(s)
|
||||
.timePeriod(period)
|
||||
.endTime(end)
|
||||
.openPrice(close - 1)
|
||||
.highPrice(close + 1)
|
||||
.lowPrice(close - 1)
|
||||
.closePrice(close)
|
||||
.volume(10 + i)
|
||||
.add();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user