복합지표 전략 추가
This commit is contained in:
@@ -90,7 +90,7 @@ public class BacktestSettingsDto {
|
||||
// ── 포지션 종속성 모드 ─────────────────────────────────────────────────────
|
||||
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 제어 */
|
||||
@Builder.Default
|
||||
private String positionMode = "SIGNAL_ONLY";
|
||||
private String positionMode = "LONG_ONLY";
|
||||
|
||||
// ── 분할 청산 ──────────────────────────────────────────────────────────────
|
||||
@Builder.Default
|
||||
|
||||
@@ -199,21 +199,23 @@ public class BacktestingService {
|
||||
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
|
||||
long time = barStartEpoch(series, i);
|
||||
|
||||
// ── SIGNAL_ONLY 모드: 포지션·자금 상태 무관, 조건 충족 시마다 전체 시그널 생성 ──
|
||||
// ── SIGNAL_ONLY: 조건 충족 '시작' 봉만 시그널 (GTE 유지형 연속 중복 방지) ──
|
||||
if (signalOnly) {
|
||||
boolean entrySatisfied = entryRule.isSatisfied(i);
|
||||
boolean exitSatisfied = exitRule.isSatisfied(i);
|
||||
if (entrySatisfied) {
|
||||
boolean entryEdge = entrySatisfied && (i <= loopStart || !entryRule.isSatisfied(i - 1));
|
||||
boolean exitEdge = exitSatisfied && (i <= loopStart || !exitRule.isSatisfied(i - 1));
|
||||
if (entryEdge) {
|
||||
double effEntry = applySlippage(closePrice, cfg, true);
|
||||
String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||
signals.add(buildFillSignal(time, buyType, effEntry, i, 1.0));
|
||||
log.debug("[Backtest:SIGNAL_ONLY] BUY @ bar={} price={}", i, effEntry);
|
||||
log.debug("[Backtest:SIGNAL_ONLY] BUY(edge) @ bar={} price={}", i, effEntry);
|
||||
}
|
||||
if (exitSatisfied) {
|
||||
if (exitEdge) {
|
||||
double effExit = applySlippage(exitPrice, cfg, false);
|
||||
String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
||||
signals.add(buildFillSignal(time, sellType, effExit, i, 1.0));
|
||||
log.debug("[Backtest:SIGNAL_ONLY] SELL @ bar={} price={}", i, effExit);
|
||||
log.debug("[Backtest:SIGNAL_ONLY] SELL(edge) @ bar={} price={}", i, effExit);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -930,6 +930,14 @@ public class StrategyDslToTa4jAdapter {
|
||||
};
|
||||
}
|
||||
// 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")) {
|
||||
try { return new SMAIndicator(close, Integer.parseInt(field.substring(2))); }
|
||||
catch (NumberFormatException ignored) { /* fall */ }
|
||||
|
||||
@@ -57,8 +57,8 @@ public class StrategySignalDeterminer {
|
||||
int index, String positionMode) {
|
||||
try {
|
||||
if ("SIGNAL_ONLY".equals(positionMode)) {
|
||||
boolean enterOk = entryRule != null && entryRule.isSatisfied(index);
|
||||
boolean exitOk = exitRule != null && exitRule.isSatisfied(index);
|
||||
boolean enterOk = entryRule != null && isRisingEdge(entryRule, index);
|
||||
boolean exitOk = exitRule != null && isRisingEdge(exitRule, index);
|
||||
if (enterOk) return "BUY";
|
||||
if (exitOk) return "SELL";
|
||||
return "NONE";
|
||||
@@ -84,8 +84,15 @@ public class StrategySignalDeterminer {
|
||||
}
|
||||
|
||||
private String determineSignalOnly(Strategy strategy, int index) {
|
||||
if (strategy.getEntryRule().isSatisfied(index)) return "BUY";
|
||||
if (strategy.getExitRule().isSatisfied(index)) return "SELL";
|
||||
if (isRisingEdge(strategy.getEntryRule(), index)) return "BUY";
|
||||
if (isRisingEdge(strategy.getExitRule(), index)) return "SELL";
|
||||
return "NONE";
|
||||
}
|
||||
|
||||
/** GTE 등 유지형 조건 — 충족 시작 봉만 true (연속 시그널 방지) */
|
||||
private boolean isRisingEdge(Rule rule, int index) {
|
||||
if (rule == null || !rule.isSatisfied(index)) return false;
|
||||
if (index <= 0) return true;
|
||||
return !rule.isSatisfied(index - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user