444 lines
17 KiB
Java
444 lines
17 KiB
Java
package com.goldenchart.service;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.goldenchart.storage.Ta4jStorage;
|
|
import com.goldenchart.trading.pipeline.TradingPipelineProperties;
|
|
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.Map;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* 사용자 복합 전략 DSL(다중 START · AND/OR · MACD/DMI/ADX/CCI/RSI/VR)이
|
|
* StrategyDslToTa4jAdapter 에서 Rule 로 변환·평가 가능한지 검증.
|
|
*/
|
|
class ComplexStrategyDslAdapterTest {
|
|
|
|
private static final String MARKET = "KRW-BTC";
|
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
|
|
private StrategyDslToTa4jAdapter adapter;
|
|
private Ta4jStorage storage;
|
|
private BarSeries primary1m;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
adapter = new StrategyDslToTa4jAdapter();
|
|
TradingPipelineProperties props = new TradingPipelineProperties();
|
|
props.setEnabled(false);
|
|
storage = new Ta4jStorage(props);
|
|
primary1m = buildSyntheticSeries("1m", 260);
|
|
seedStorage(MARKET, "1m", primary1m);
|
|
seedStorage(MARKET, "3m", buildSyntheticSeries("3m", 120));
|
|
seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80));
|
|
}
|
|
|
|
/** OR(AND, AND) — 4개 조건을 평탄 OR로 처리하면 true가 되는 케이스에서 false여야 함 */
|
|
@Test
|
|
void orAndNested_doesNotFlattenChildrenToOr() throws Exception {
|
|
JsonNode buy = MAPPER.readTree("""
|
|
{
|
|
"type": "OR",
|
|
"children": [
|
|
{
|
|
"type": "AND",
|
|
"children": [
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
|
|
}},
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
|
}}
|
|
]
|
|
},
|
|
{
|
|
"type": "AND",
|
|
"children": [
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
|
|
}},
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
|
}}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
Rule rule = adapter.toRule(buy, primary1m, Map.of());
|
|
int idx = primary1m.getEndIndex();
|
|
// 평탄 OR(4조건)이면 close>K_1 하나만으로 true — AND 중첩이면 각 분기 false → 전체 false
|
|
assertFalse(rule.isSatisfied(idx, null),
|
|
"OR(AND)는 분기 내 모든 조건이 충족될 때만 true — 단일 true 자식으로는 true가 되면 안 됨");
|
|
}
|
|
|
|
@Test
|
|
void orAndNested_oneFullAndBranchSatisfies() throws Exception {
|
|
JsonNode buy = MAPPER.readTree("""
|
|
{
|
|
"type": "OR",
|
|
"children": [
|
|
{
|
|
"type": "AND",
|
|
"children": [
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
|
}},
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_0", "candleRange": 1
|
|
}}
|
|
]
|
|
},
|
|
{
|
|
"type": "AND",
|
|
"children": [
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
|
|
}},
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "MA", "conditionType": "GT",
|
|
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
|
|
}}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
Rule rule = adapter.toRule(buy, primary1m, Map.of());
|
|
assertTrue(rule.isSatisfied(primary1m.getEndIndex(), null),
|
|
"OR(AND) — 한 분기의 AND가 모두 true이면 전체 true");
|
|
}
|
|
|
|
/** Logic Expression 예시와 동일 구조의 매수 조건 */
|
|
@Test
|
|
void buyCondition_compilesAndEvaluates() throws Exception {
|
|
JsonNode buy = MAPPER.readTree("""
|
|
{
|
|
"id": "buy-root",
|
|
"type": "OR",
|
|
"children": [
|
|
{
|
|
"id": "buy-and-1",
|
|
"type": "AND",
|
|
"children": [
|
|
{
|
|
"id": "c-macd",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "MACD",
|
|
"conditionType": "CROSS_UP",
|
|
"leftField": "MACD_LINE",
|
|
"rightField": "SIGNAL_LINE",
|
|
"candleRange": 1,
|
|
"period": 12
|
|
}
|
|
},
|
|
{
|
|
"id": "c-dmi",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "DMI",
|
|
"conditionType": "CROSS_UP",
|
|
"leftField": "PDI",
|
|
"rightField": "MDI",
|
|
"candleRange": 1,
|
|
"period": 14
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "buy-and-2",
|
|
"type": "AND",
|
|
"children": [
|
|
{
|
|
"id": "c-adx",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "ADX",
|
|
"conditionType": "CROSS_UP",
|
|
"leftField": "ADX_VALUE",
|
|
"rightField": "K_25",
|
|
"candleRange": 1,
|
|
"period": 14
|
|
}
|
|
},
|
|
{
|
|
"id": "c-cci",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "CCI",
|
|
"conditionType": "CROSS_UP",
|
|
"leftField": "CCI_VALUE",
|
|
"rightField": "K_100",
|
|
"candleRange": 1,
|
|
"period": 13
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
Rule rule = adapter.toRule(buy, primary1m, Map.of(), MARKET, storage);
|
|
assertNotNull(rule);
|
|
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
|
|
}
|
|
|
|
/** Logic Expression 예시와 동일 구조의 매도 조건 (1m AND 5m AND 3m) */
|
|
@Test
|
|
void sellCondition_multiTimeframe_compilesAndEvaluates() throws Exception {
|
|
JsonNode sell = MAPPER.readTree("""
|
|
{
|
|
"id": "sell-root",
|
|
"type": "AND",
|
|
"children": [
|
|
{
|
|
"id": "tf-1m",
|
|
"type": "TIMEFRAME",
|
|
"candleType": "1m",
|
|
"children": [{
|
|
"id": "sell-1m-and",
|
|
"type": "AND",
|
|
"children": [
|
|
{
|
|
"id": "c-rsi-1m",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "RSI",
|
|
"conditionType": "CROSS_DOWN",
|
|
"leftField": "RSI_VALUE",
|
|
"rightField": "K_70",
|
|
"candleRange": 1,
|
|
"period": 9
|
|
}
|
|
},
|
|
{
|
|
"id": "c-macd-1m",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "MACD",
|
|
"conditionType": "CROSS_DOWN",
|
|
"leftField": "MACD_LINE",
|
|
"rightField": "SIGNAL_LINE",
|
|
"candleRange": 1,
|
|
"period": 12
|
|
}
|
|
},
|
|
{
|
|
"id": "c-vr-1m",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "VR",
|
|
"conditionType": "CROSS_DOWN",
|
|
"leftField": "VR_VALUE",
|
|
"rightField": "K_200",
|
|
"candleRange": 1,
|
|
"period": 10
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
},
|
|
{
|
|
"id": "tf-5m",
|
|
"type": "TIMEFRAME",
|
|
"candleType": "5m",
|
|
"children": [{
|
|
"id": "sell-5m-and",
|
|
"type": "AND",
|
|
"children": [
|
|
{
|
|
"id": "c-adx-5m",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "ADX",
|
|
"conditionType": "CROSS_DOWN",
|
|
"leftField": "ADX_VALUE",
|
|
"rightField": "K_28",
|
|
"candleRange": 1,
|
|
"period": 14
|
|
}
|
|
},
|
|
{
|
|
"id": "c-cci-5m",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "CCI",
|
|
"conditionType": "CROSS_DOWN",
|
|
"leftField": "CCI_VALUE",
|
|
"rightField": "K_26",
|
|
"candleRange": 1,
|
|
"period": 13
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
},
|
|
{
|
|
"id": "tf-3m",
|
|
"type": "TIMEFRAME",
|
|
"candleType": "3m",
|
|
"children": [{
|
|
"id": "sell-3m-and",
|
|
"type": "AND",
|
|
"children": [
|
|
{
|
|
"id": "c-rsi-3m",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "RSI",
|
|
"conditionType": "CROSS_DOWN",
|
|
"leftField": "RSI_VALUE",
|
|
"rightField": "K_20",
|
|
"candleRange": 1,
|
|
"period": 9
|
|
}
|
|
},
|
|
{
|
|
"id": "c-vr-3m",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "VR",
|
|
"conditionType": "CROSS_DOWN",
|
|
"leftField": "VR_VALUE",
|
|
"rightField": "K_20",
|
|
"candleRange": 1,
|
|
"period": 10
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
}
|
|
]
|
|
}
|
|
""");
|
|
|
|
Rule rule = adapter.toRule(sell, primary1m, Map.of(), MARKET, storage);
|
|
assertNotNull(rule);
|
|
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
|
|
|
|
// CrossSeriesRule: 1m 트리거 인덱스에서 5m·3m 시리즈 endIndex 로 평가
|
|
int idx = primary1m.getEndIndex();
|
|
boolean result = rule.isSatisfied(idx, null);
|
|
assertTrue(result || !result, "다중 시간봉 AND Rule 평가가 예외 없이 boolean 반환");
|
|
}
|
|
|
|
/** 백테스트 경로( storage 미전달 ) — 단일 시리즈 fallback 동작 확인 */
|
|
@Test
|
|
void sellCondition_backtestSingleSeries_fallback() throws Exception {
|
|
JsonNode sell = MAPPER.readTree("""
|
|
{
|
|
"type": "AND",
|
|
"children": [
|
|
{ "type": "TIMEFRAME", "candleType": "1m", "children": [
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "RSI", "conditionType": "CROSS_DOWN",
|
|
"leftField": "RSI_VALUE", "rightField": "K_70", "period": 9
|
|
}}
|
|
]},
|
|
{ "type": "TIMEFRAME", "candleType": "5m", "children": [
|
|
{ "type": "CONDITION", "condition": {
|
|
"indicatorType": "ADX", "conditionType": "CROSS_DOWN",
|
|
"leftField": "ADX_VALUE", "rightField": "K_28", "period": 14
|
|
}}
|
|
]}
|
|
]
|
|
}
|
|
""");
|
|
|
|
Rule rule = adapter.toRule(sell, primary1m, Map.of());
|
|
assertNotNull(rule);
|
|
assertDoesNotThrow(() -> rule.isSatisfied(primary1m.getEndIndex(), null));
|
|
}
|
|
|
|
@Test
|
|
void stochLowestLte_compilesAndEvaluates() throws Exception {
|
|
JsonNode cond = MAPPER.readTree("""
|
|
{
|
|
"id": "c-stoch-lowest",
|
|
"type": "CONDITION",
|
|
"condition": {
|
|
"indicatorType": "STOCHASTIC",
|
|
"conditionType": "LOWEST_LTE",
|
|
"leftField": "STOCH_K",
|
|
"rightField": "HL_UNDER",
|
|
"lookbackPeriod": 20,
|
|
"candleRange": 1
|
|
}
|
|
}
|
|
""");
|
|
|
|
Rule rule = adapter.toRule(cond, primary1m, Map.of());
|
|
assertNotNull(rule);
|
|
assertDoesNotThrow(() -> evaluateRange(rule, primary1m.getEndIndex()));
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
private static void evaluateRange(Rule rule, int endIndex) {
|
|
TradingRecord record = null;
|
|
int start = Math.max(30, endIndex - 20);
|
|
for (int i = start; i <= endIndex; i++) {
|
|
rule.isSatisfied(i, record);
|
|
}
|
|
}
|
|
|
|
private static BarSeries buildSyntheticSeries(String timeframe, int barCount) {
|
|
Duration period = switch (timeframe) {
|
|
case "3m" -> Duration.ofMinutes(3);
|
|
case "5m" -> Duration.ofMinutes(5);
|
|
default -> Duration.ofMinutes(1);
|
|
};
|
|
BarSeries series = new BaseBarSeriesBuilder()
|
|
.withName("test-" + timeframe)
|
|
.withNumFactory(DoubleNumFactory.getInstance())
|
|
.withBarBuilderFactory(new TimeBarBuilderFactory())
|
|
.build();
|
|
TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
|
|
Instant base = Instant.parse("2024-06-01T00:00:00Z");
|
|
for (int i = 0; i < barCount; i++) {
|
|
double wave = Math.sin(i * 0.07) * 8 + Math.cos(i * 0.02) * 4;
|
|
double close = 50_000_000 + i * 120 + wave * 1000;
|
|
double open = close - 500;
|
|
double high = close + 800;
|
|
double low = close - 800;
|
|
Instant end = base.plus(period.multipliedBy(i + 1L));
|
|
factory.createBarBuilder(series)
|
|
.timePeriod(period)
|
|
.endTime(end)
|
|
.openPrice(open).highPrice(high).lowPrice(low).closePrice(close)
|
|
.volume(12.5 + i * 0.01)
|
|
.add();
|
|
}
|
|
return series;
|
|
}
|
|
|
|
private void seedStorage(String market, String candleType, BarSeries source) {
|
|
for (int i = source.getBeginIndex(); i <= source.getEndIndex(); i++) {
|
|
storage.addBar(market, candleType, source.getBar(i));
|
|
}
|
|
}
|
|
}
|