복합지표 전략 추가

This commit is contained in:
Macbook
2026-06-12 13:26:53 +09:00
parent 9208418c33
commit 52137cf1db
27 changed files with 2712 additions and 55 deletions
@@ -0,0 +1,156 @@
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.TradingRecord;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DoubleNumFactory;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* 33변곡 — EMA33 IsRising/IsFalling + 종가/EMA 필터 + 33봉 종가 채널 돌파/이탈.
*/
class Inflection33RuleTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private StrategyDslToTa4jAdapter adapter;
@BeforeEach
void setUp() {
adapter = new StrategyDslToTa4jAdapter();
}
@Test
void closeMax33_resolvesAndCrossUpCanFire() throws Exception {
BarSeries series = buildTrendSeries(120, true);
Rule cross = rule(series, """
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA",
"conditionType": "CROSS_UP",
"leftField": "CLOSE_PRICE",
"rightField": "CLOSE_MAX_33"
}}
""");
List<Integer> hits = satisfiedIndices(cross, series);
assertTrue(hits.stream().noneMatch(i -> i < 33),
"33봉 미만에서는 CLOSE_MAX_33 채널이 정의되지 않아 돌파 조건이 없어야 함");
}
@Test
void closeMin33_resolvesAndCrossDownCanFire() throws Exception {
BarSeries series = buildTrendSeries(120, false);
Rule cross = rule(series, """
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA",
"conditionType": "CROSS_DOWN",
"leftField": "CLOSE_PRICE",
"rightField": "CLOSE_MIN_33"
}}
""");
List<Integer> hits = satisfiedIndices(cross, series);
assertTrue(hits.stream().noneMatch(i -> i < 33),
"33봉 미만에서는 CLOSE_MIN_33 채널이 정의되지 않아 이탈 조건이 없어야 함");
assertFalse(hits.isEmpty(), "하락 추세에서 33봉 종가 최저 이탈이 최소 1회 발생해야 함");
}
@Test
void inflection33Buy_andTree_warmupBefore33() throws Exception {
BarSeries series = buildTrendSeries(120, true);
Rule buy = rule(series, """
{ "type": "AND", "children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA", "conditionType": "SLOPE_UP",
"leftField": "EMA33", "rightField": "NONE", "slopePeriod": 2 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "EMA33" }},
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA", "conditionType": "CROSS_UP",
"leftField": "CLOSE_PRICE", "rightField": "CLOSE_MAX_33" }}
]}
""");
List<Integer> hits = satisfiedIndices(buy, series);
assertTrue(hits.stream().noneMatch(i -> i < 33),
"워밍업 구간(33봉 미만)에서는 33변곡 매수 복합 조건이 없어야 함");
}
@Test
void inflection33Sell_orTree_firesOnDowntrend() throws Exception {
BarSeries series = buildTrendSeries(120, false);
Rule sell = rule(series, """
{ "type": "OR", "children": [
{ "type": "AND", "children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA", "conditionType": "SLOPE_DOWN",
"leftField": "EMA33", "rightField": "NONE", "slopePeriod": 2 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA", "conditionType": "LT",
"leftField": "CLOSE_PRICE", "rightField": "EMA33" }}
]},
{ "type": "CONDITION", "condition": {
"indicatorType": "EMA", "conditionType": "CROSS_DOWN",
"leftField": "CLOSE_PRICE", "rightField": "CLOSE_MIN_33" }}
]}
""");
List<Integer> hits = satisfiedIndices(sell, series);
assertFalse(hits.isEmpty(), "하락 추세에서 33변곡 매도 OR 조건이 최소 1회 충족되어야 함");
}
// ── helpers ─────────────────────────────────────────────────────────────
private Rule rule(BarSeries series, String json) throws Exception {
JsonNode node = MAPPER.readTree(json);
return adapter.toRule(node, series, Map.of());
}
private List<Integer> satisfiedIndices(Rule rule, BarSeries series) {
List<Integer> hits = new ArrayList<>();
TradingRecord record = null;
for (int i = 0; i < series.getBarCount(); i++) {
if (rule.isSatisfied(i, record)) hits.add(i);
}
return hits;
}
private static BarSeries buildTrendSeries(int barCount, boolean up) {
Duration period = Duration.ofDays(1);
BarSeries series = new BaseBarSeriesBuilder()
.withName("trend")
.withNumFactory(DoubleNumFactory.getInstance())
.withBarBuilderFactory(new TimeBarBuilderFactory())
.build();
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
Instant base = Instant.parse("2024-01-01T00:00:00Z");
double price = 100.0;
for (int i = 0; i < barCount; i++) {
double delta = up ? 1.5 : -1.5;
double close = price + delta;
double high = Math.max(price, close) + 0.5;
double low = Math.min(price, close) - 0.5;
factory.createBarBuilder(series)
.timePeriod(period)
.endTime(base.plus(period.multipliedBy(i + 1L)))
.openPrice(price).highPrice(high).lowPrice(low).closePrice(close)
.volume(1000)
.add();
price = close;
}
return series;
}
}
@@ -118,6 +118,61 @@ class PriceExtremeRuleTest {
"어댑터가 DC_LOWER→NL_PRIOR 로 정규화하면 하락 구간에서 매도가 나와야 함");
}
@Test
void newHigh920Buy_andTree_firesOnUptrendWithVolume() throws Exception {
BarSeries series = buildTrendSeries(120, true);
Rule buy = rule(series, """
{ "type": "AND", "children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_9", "period": 9 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "CROSS_UP",
"leftField": "CLOSE_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_HIGH", "conditionType": "GTE",
"leftField": "HIGH_PRICE", "rightField": "NH_PRIOR_20", "period": 20 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "ADX", "conditionType": "GTE",
"leftField": "ADX_VALUE", "rightField": "ADX_25" }},
{ "type": "CONDITION", "condition": {
"indicatorType": "VOLUME", "conditionType": "GT",
"leftField": "VOLUME_VALUE", "rightField": "VOLUME_MA" }}
]}
""");
List<Integer> hits = satisfiedIndices(buy, series);
assertTrue(hits.stream().noneMatch(i -> i < 20),
"워밍업 구간(20봉 미만)에서는 복합 매수 조건이 없어야 함");
}
@Test
void newLow920Sell_andTree_firesOnDowntrend() throws Exception {
BarSeries series = buildTrendSeries(120, false);
Rule sell = rule(series, """
{ "type": "AND", "children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW", "conditionType": "LTE",
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_9", "period": 9 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW", "conditionType": "CROSS_DOWN",
"leftField": "CLOSE_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "NEW_LOW", "conditionType": "LTE",
"leftField": "LOW_PRICE", "rightField": "NL_PRIOR_20", "period": 20 }},
{ "type": "CONDITION", "condition": {
"indicatorType": "ADX", "conditionType": "GTE",
"leftField": "ADX_VALUE", "rightField": "ADX_20" }},
{ "type": "CONDITION", "condition": {
"indicatorType": "VOLUME", "conditionType": "GT",
"leftField": "VOLUME_VALUE", "rightField": "VOLUME_MA" }}
]}
""");
List<Integer> hits = satisfiedIndices(sell, series);
assertFalse(hits.isEmpty(), "하락 추세에서 9·20일 신저가 복합 매도 조건이 최소 1회 충족되어야 함");
}
// ── helpers ─────────────────────────────────────────────────────────────
private Rule rule(BarSeries series, String json) throws Exception {