추세선 전략추가
This commit is contained in:
@@ -114,4 +114,21 @@ public class BacktestSettingsDto {
|
||||
*/
|
||||
@Builder.Default
|
||||
private String tradeExecutionMode = "BACKTEST_ENGINE";
|
||||
|
||||
// ── 추세선 조건 (TREND_LINE_CROSS_*) ─────────────────────────────────────
|
||||
/** TWO_POINT | REGRESSION | SWING */
|
||||
@Builder.Default
|
||||
private String trendLineMode = "TWO_POINT";
|
||||
|
||||
/** 조건에 lookback 미지정 시 기본 N봉 */
|
||||
@Builder.Default
|
||||
private Integer trendLineDefaultLookback = 10;
|
||||
|
||||
/** SWING 모드 — 스윙 저점 좌측 확인 봉 수 */
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingPrecedingBars = 1;
|
||||
|
||||
/** SWING 모드 — 스윙 저점 우측 확인 봉 수 */
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingFollowingBars = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 추세선 조건 평가 설정 — gc_backtest_settings 와 동기.
|
||||
* SWING: 스윙 피벗(저점·고점) 2개 연결 · TWO_POINT/REGRESSION: 피벗 실패 시 폴백
|
||||
*/
|
||||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
public class TrendLineConfig {
|
||||
|
||||
public static final String MODE_TWO_POINT = "TWO_POINT";
|
||||
public static final String MODE_REGRESSION = "REGRESSION";
|
||||
public static final String MODE_SWING = "SWING";
|
||||
|
||||
@Builder.Default
|
||||
private String mode = MODE_SWING;
|
||||
|
||||
@Builder.Default
|
||||
private int defaultLookback = 10;
|
||||
|
||||
@Builder.Default
|
||||
private int swingPrecedingBars = 1;
|
||||
|
||||
@Builder.Default
|
||||
private int swingFollowingBars = 1;
|
||||
|
||||
public static TrendLineConfig defaults() {
|
||||
return TrendLineConfig.builder().build();
|
||||
}
|
||||
|
||||
public static TrendLineConfig fromSettings(BacktestSettingsDto dto) {
|
||||
if (dto == null) return defaults();
|
||||
String mode = dto.getTrendLineMode();
|
||||
if (mode == null || mode.isBlank()) mode = MODE_TWO_POINT;
|
||||
if (!MODE_TWO_POINT.equals(mode) && !MODE_REGRESSION.equals(mode) && !MODE_SWING.equals(mode)) {
|
||||
mode = MODE_TWO_POINT;
|
||||
}
|
||||
int lookback = dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
|
||||
? dto.getTrendLineDefaultLookback() : 10;
|
||||
int prec = dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
|
||||
? dto.getTrendLineSwingPrecedingBars() : 1;
|
||||
int fol = dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
|
||||
? dto.getTrendLineSwingFollowingBars() : 1;
|
||||
return TrendLineConfig.builder()
|
||||
.mode(mode)
|
||||
.defaultLookback(lookback)
|
||||
.swingPrecedingBars(prec)
|
||||
.swingFollowingBars(fol)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
/**
|
||||
* 추세선 종류 — 돌파 방향과 짝을 이룸.
|
||||
* <ul>
|
||||
* <li>{@link #UP} 상승(지지) 추세선 — 스윙 저점(Low) 연결, 저점이 높아져야 함</li>
|
||||
* <li>{@link #DOWN} 하락(저항) 추세선 — 스윙 고점(High) 연결, 고점이 낮아져야 함</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum TrendLineKind {
|
||||
/** 지지선 — TREND_LINE_CROSS_DOWN (하향 돌파) */
|
||||
UP,
|
||||
/** 저항선 — TREND_LINE_CROSS_UP (상향 돌파) */
|
||||
DOWN;
|
||||
|
||||
public static TrendLineKind forCrossUp() {
|
||||
return DOWN;
|
||||
}
|
||||
|
||||
public static TrendLineKind forCrossDown() {
|
||||
return UP;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,23 @@ public class GcBacktestSettings {
|
||||
@Builder.Default
|
||||
private String tradeExecutionMode = "BACKTEST_ENGINE";
|
||||
|
||||
/** TWO_POINT | REGRESSION | SWING */
|
||||
@Column(name = "trend_line_mode", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private String trendLineMode = "TWO_POINT";
|
||||
|
||||
@Column(name = "trend_line_default_lookback", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineDefaultLookback = 10;
|
||||
|
||||
@Column(name = "trend_line_swing_preceding_bars", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingPrecedingBars = 1;
|
||||
|
||||
@Column(name = "trend_line_swing_following_bars", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingFollowingBars = 1;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -125,6 +125,21 @@ public class BacktestSettingsService {
|
||||
"SCAN_SIGNALS".equals(dto.getTradeExecutionMode())
|
||||
? "SCAN_SIGNALS"
|
||||
: "BACKTEST_ENGINE");
|
||||
entity.setTrendLineMode(normalizeTrendLineMode(dto.getTrendLineMode()));
|
||||
entity.setTrendLineDefaultLookback(
|
||||
dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
|
||||
? dto.getTrendLineDefaultLookback() : 10);
|
||||
entity.setTrendLineSwingPrecedingBars(
|
||||
dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
|
||||
? dto.getTrendLineSwingPrecedingBars() : 1);
|
||||
entity.setTrendLineSwingFollowingBars(
|
||||
dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
|
||||
? dto.getTrendLineSwingFollowingBars() : 1);
|
||||
}
|
||||
|
||||
private static String normalizeTrendLineMode(String mode) {
|
||||
if ("REGRESSION".equals(mode) || "SWING".equals(mode)) return mode;
|
||||
return "TWO_POINT";
|
||||
}
|
||||
|
||||
// ── 변환 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
@@ -157,6 +172,10 @@ public class BacktestSettingsService {
|
||||
"SCAN_SIGNALS".equals(e.getTradeExecutionMode())
|
||||
? "SCAN_SIGNALS"
|
||||
: "BACKTEST_ENGINE")
|
||||
.trendLineMode(normalizeTrendLineMode(e.getTrendLineMode()))
|
||||
.trendLineDefaultLookback(e.getTrendLineDefaultLookback() != null ? e.getTrendLineDefaultLookback() : 10)
|
||||
.trendLineSwingPrecedingBars(e.getTrendLineSwingPrecedingBars() != null ? e.getTrendLineSwingPrecedingBars() : 1)
|
||||
.trendLineSwingFollowingBars(e.getTrendLineSwingFollowingBars() != null ? e.getTrendLineSwingFollowingBars() : 1)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +132,11 @@ public class BacktestingService {
|
||||
series, primaryTf, buyDsl, sellDsl);
|
||||
|
||||
String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null;
|
||||
com.goldenchart.dto.TrendLineConfig trendLineConfig =
|
||||
com.goldenchart.dto.TrendLineConfig.fromSettings(cfg);
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, null, false, seriesOverrides);
|
||||
series, params, visual, market, null, false, seriesOverrides, trendLineConfig);
|
||||
Rule entryRule = adapter.toRule(buyDsl, ruleCtx);
|
||||
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
|
||||
? adapter.toRule(sellDsl, ruleCtx)
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.goldenchart.dto.BacktestResponse;
|
||||
import com.goldenchart.dto.BacktestSettingsDto;
|
||||
import com.goldenchart.dto.LiveConditionRowDto;
|
||||
import com.goldenchart.dto.LiveConditionStatusDto;
|
||||
import com.goldenchart.dto.OhlcvBar;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -38,6 +40,8 @@ public class LiveConditionStatusService {
|
||||
Map.entry("NEQ", "다름(≠)"),
|
||||
Map.entry("CROSS_UP", "상향 돌파"),
|
||||
Map.entry("CROSS_DOWN", "하향 돌파"),
|
||||
Map.entry("TREND_LINE_CROSS_UP", "추세선 상향 돌파"),
|
||||
Map.entry("TREND_LINE_CROSS_DOWN", "추세선 하향 돌파"),
|
||||
Map.entry("SLOPE_UP", "상승 중"),
|
||||
Map.entry("LOWEST_LTE", "N봉 최저 ≤"),
|
||||
Map.entry("LOWEST_GTE", "N봉 최저 ≥"),
|
||||
@@ -53,6 +57,27 @@ public class LiveConditionStatusService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
private final StrategyDslTimeframeNormalizer timeframeNormalizer;
|
||||
private final BacktestSettingsService backtestSettingsService;
|
||||
|
||||
private TrendLineConfig trendLineConfigFor(Long userId, String deviceId) {
|
||||
BacktestSettingsDto settings = backtestSettingsService.get(userId, deviceId);
|
||||
return TrendLineConfig.fromSettings(settings);
|
||||
}
|
||||
|
||||
private StrategyDslToTa4jAdapter.RuleBuildContext buildRuleCtx(
|
||||
BarSeries series,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
String market,
|
||||
Ta4jStorage storage,
|
||||
boolean useConfirmedOnly,
|
||||
Map<String, BarSeries> seriesOverrides,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
return new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, storage, useConfirmedOnly, seriesOverrides,
|
||||
trendLineConfigFor(userId, deviceId));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
|
||||
@@ -104,7 +129,7 @@ public class LiveConditionStatusService {
|
||||
}
|
||||
|
||||
if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) {
|
||||
return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe,
|
||||
return evaluateOnChartBars(userId, deviceId, strategy, market, strategyId, chartBars, chartTimeframe,
|
||||
barTimeSec, params, visual);
|
||||
}
|
||||
|
||||
@@ -142,8 +167,8 @@ public class LiveConditionStatusService {
|
||||
wrapper.set("condition", p.condition());
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
||||
buildRuleCtx(series, params, visual, market, ta4jStorage, false,
|
||||
java.util.Map.of(), userId, deviceId);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
@@ -167,9 +192,9 @@ public class LiveConditionStatusService {
|
||||
|
||||
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
|
||||
Boolean overallEntryMet = evaluateOverallRule(
|
||||
strategy.getBuyConditionJson(), market, params, visual, barTimeSec);
|
||||
strategy.getBuyConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
|
||||
Boolean overallExitMet = evaluateOverallRule(
|
||||
strategy.getSellConditionJson(), market, params, visual, barTimeSec);
|
||||
strategy.getSellConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
@@ -267,6 +292,8 @@ public class LiveConditionStatusService {
|
||||
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
|
||||
*/
|
||||
private LiveConditionStatusDto evaluateOnChartBars(
|
||||
Long userId,
|
||||
String deviceId,
|
||||
GcStrategy strategy,
|
||||
String market,
|
||||
long strategyId,
|
||||
@@ -314,8 +341,8 @@ public class LiveConditionStatusService {
|
||||
wrapper.set("condition", p.condition());
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(series, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
@@ -336,9 +363,11 @@ public class LiveConditionStatusService {
|
||||
|
||||
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
||||
Boolean overallEntryMet = evaluateOverallRuleOnChart(
|
||||
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
|
||||
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
|
||||
userId, deviceId);
|
||||
Boolean overallExitMet = evaluateOverallRuleOnChart(
|
||||
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
|
||||
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
|
||||
userId, deviceId);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
@@ -392,14 +421,16 @@ public class LiveConditionStatusService {
|
||||
String market,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
Long barTimeSec) {
|
||||
Long barTimeSec,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
if (dsl == null || dsl.isNull()) return null;
|
||||
try {
|
||||
if (primarySeries == null || primarySeries.isEmpty()) return null;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(primarySeries, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule rule = adapter.toRule(dsl, ctx);
|
||||
int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
|
||||
return rule.isSatisfied(index, null);
|
||||
@@ -441,7 +472,9 @@ public class LiveConditionStatusService {
|
||||
private Boolean evaluateOverallRule(String conditionJson, String market,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
Long barTimeSec) {
|
||||
Long barTimeSec,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
if (conditionJson == null || conditionJson.isBlank()) return null;
|
||||
try {
|
||||
String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper);
|
||||
@@ -461,8 +494,8 @@ public class LiveConditionStatusService {
|
||||
if (series.isEmpty()) return null;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
||||
buildRuleCtx(series, params, visual, market, ta4jStorage, false,
|
||||
java.util.Map.of(), userId, deviceId);
|
||||
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
|
||||
return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
|
||||
} catch (Exception e) {
|
||||
@@ -798,8 +831,8 @@ public class LiveConditionStatusService {
|
||||
int startIdx = resolveChartScanStartIndex(primarySeries, evalCount);
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(primarySeries, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule entryRule = (buyDsl != null && !buyDsl.isNull())
|
||||
? adapter.toRule(buyDsl, ruleCtx)
|
||||
: new org.ta4j.core.rules.BooleanRule(false);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -122,20 +124,37 @@ public class StrategyDslToTa4jAdapter {
|
||||
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
|
||||
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
|
||||
*/
|
||||
Map<String, BarSeries> seriesOverrides
|
||||
Map<String, BarSeries> seriesOverrides,
|
||||
/** 추세선 조건(TREND_LINE_CROSS_*) — 전략설정 gc_backtest_settings */
|
||||
TrendLineConfig trendLineConfig
|
||||
) {
|
||||
/** visual 미전달 호환 (레거시) */
|
||||
public RuleBuildContext(BarSeries primarySeries,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
String market,
|
||||
Ta4jStorage storage) {
|
||||
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of());
|
||||
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of(), null);
|
||||
}
|
||||
|
||||
public RuleBuildContext(BarSeries primarySeries,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market,
|
||||
Ta4jStorage storage,
|
||||
boolean useConfirmedOnly,
|
||||
Map<String, BarSeries> seriesOverrides) {
|
||||
this(primarySeries, indicatorParams, indicatorVisual, market, storage,
|
||||
useConfirmedOnly, seriesOverrides, null);
|
||||
}
|
||||
|
||||
public TrendLineConfig effectiveTrendLineConfig() {
|
||||
return trendLineConfig != null ? trendLineConfig : TrendLineConfig.defaults();
|
||||
}
|
||||
|
||||
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
|
||||
public RuleBuildContext withPrimary(BarSeries newPrimary) {
|
||||
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
|
||||
market, storage, useConfirmedOnly, seriesOverrides);
|
||||
market, storage, useConfirmedOnly, seriesOverrides, trendLineConfig);
|
||||
}
|
||||
|
||||
/** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */
|
||||
@@ -170,20 +189,20 @@ public class StrategyDslToTa4jAdapter {
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +213,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, BarSeries> seriesOverrides) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false,
|
||||
seriesOverrides != null ? seriesOverrides : Map.of()));
|
||||
seriesOverrides != null ? seriesOverrides : Map.of(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +223,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, RuleBuildContext ctx) {
|
||||
@@ -508,6 +527,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
// 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외)
|
||||
case "CROSS_UP" -> buildCrossUpRule(left, right);
|
||||
case "CROSS_DOWN" -> buildCrossDownRule(left, right);
|
||||
case "TREND_LINE_CROSS_UP", "TREND_LINE_CROSS_DOWN" -> {
|
||||
int lb = cond.path("lookbackPeriod").asInt(0);
|
||||
if (lb <= 0) lb = ctx.effectiveTrendLineConfig().getDefaultLookback();
|
||||
lb = Math.max(2, lb);
|
||||
boolean priceScale = isTrendLinePriceScaleField(leftField);
|
||||
yield "TREND_LINE_CROSS_UP".equals(condType)
|
||||
? buildTrendLineCrossUpRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale)
|
||||
: buildTrendLineCrossDownRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale);
|
||||
}
|
||||
case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
|
||||
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
|
||||
case "DIFF_GT" -> {
|
||||
@@ -1657,6 +1685,66 @@ public class StrategyDslToTa4jAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* N봉 추세선 돌파 — [i−N, i−1] 구간으로 고정된 동일 선을 i−1·i 에서 평가.
|
||||
* (일반 CROSS 와 달리 추세선 창이 봉마다 밀리지 않음)
|
||||
*/
|
||||
private Rule buildTrendLineCrossUpRule(
|
||||
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
if (index < 1) return false;
|
||||
TrendLineKind kind = TrendLineKind.forCrossUp();
|
||||
double linePrev = TrendLineMath.valueAtEval(
|
||||
series, left, index, index - 1, lookback, config, kind, priceScale);
|
||||
double lineCurr = TrendLineMath.valueAtEval(
|
||||
series, left, index, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
|
||||
Num l0 = left.getValue(index - 1);
|
||||
Num l1 = left.getValue(index);
|
||||
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
|
||||
Num r0 = series.numFactory().numOf(linePrev);
|
||||
Num r1 = series.numFactory().numOf(lineCurr);
|
||||
return l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Rule buildTrendLineCrossDownRule(
|
||||
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
if (index < 1) return false;
|
||||
TrendLineKind kind = TrendLineKind.forCrossDown();
|
||||
double linePrev = TrendLineMath.valueAtEval(
|
||||
series, left, index, index - 1, lookback, config, kind, priceScale);
|
||||
double lineCurr = TrendLineMath.valueAtEval(
|
||||
series, left, index, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
|
||||
Num l0 = left.getValue(index - 1);
|
||||
Num l1 = left.getValue(index);
|
||||
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
|
||||
Num r0 = series.numFactory().numOf(linePrev);
|
||||
Num r1 = series.numFactory().numOf(lineCurr);
|
||||
return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 추세선 — 가격축(OHLC·MA 등) vs 오실레이터 */
|
||||
private static boolean isTrendLinePriceScaleField(String field) {
|
||||
if (field == null || field.isBlank() || "NONE".equalsIgnoreCase(field)) return true;
|
||||
String f = field.toUpperCase();
|
||||
return f.contains("CLOSE") || f.contains("PRICE") || f.contains("HIGH") || f.contains("LOW")
|
||||
|| f.contains("OPEN") || f.contains("MA") || f.contains("EMA")
|
||||
|| f.contains("UPPER") || f.contains("MIDDLE") || f.contains("LOWER")
|
||||
|| f.contains("BAND") || f.contains("DC_") || f.contains("VWAP")
|
||||
|| f.contains("CONVERSION") || f.contains("BASE") || f.contains("SPAN")
|
||||
|| f.contains("LAGGING") || f.contains("CLOUD");
|
||||
}
|
||||
|
||||
/**
|
||||
* CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다.
|
||||
* sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* N봉 구간 추세선 — 스윙 피벗(Pivot) 기준.
|
||||
* <p>
|
||||
* fit 구간 [anchor−N, anchor−1] (현재 봉 제외).
|
||||
* 상승 추세선(UP): 스윙 저점 2개(Low, Low₂>Low₁) · 하락 추세선(DOWN): 스윙 고점 2개(High, High₂<High₁).
|
||||
* anchor 봉 i 돌파 판정: 동일 선을 i−1·i 에 extrapolation.
|
||||
* 차트 선분: 피벗 A → anchor(i)까지 연장.
|
||||
*/
|
||||
public final class TrendLineMath {
|
||||
|
||||
private TrendLineMath() {}
|
||||
|
||||
private record PivotPair(int indexA, double yA, int indexB, double yB) {}
|
||||
|
||||
/** anchor 봉 기준 [anchor−N, anchor−1] 추세선을 evalIndex 에서 평가 */
|
||||
public static double valueAtEval(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int anchorIndex,
|
||||
int evalIndex,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (series == null || source == null || config == null || kind == null
|
||||
|| anchorIndex < 1 || lookback < 2) {
|
||||
return Double.NaN;
|
||||
}
|
||||
int start = anchorIndex - lookback;
|
||||
int end = anchorIndex - 1;
|
||||
if (start < series.getBeginIndex() || end < start) return Double.NaN;
|
||||
|
||||
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||
if (pivots != null) {
|
||||
return lineAt(pivots, evalIndex);
|
||||
}
|
||||
return fallbackValueAt(series, source, start, end, evalIndex, config, kind, priceScale);
|
||||
}
|
||||
|
||||
/** anchor = index — i 봉 돌파 판정용 */
|
||||
public static double valueAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int index,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
return valueAtEval(series, source, index, index, lookback, config, kind, priceScale);
|
||||
}
|
||||
|
||||
/** 시그널 차트용 — 피벗 기준 fit 후 index(시그널 봉)까지 연장 */
|
||||
public record Segment(long startTimeSec, double startPrice, long endTimeSec, double endPrice) {}
|
||||
|
||||
public static Segment segmentAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int index,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (series == null || source == null || config == null || kind == null
|
||||
|| index < 1 || lookback < 2) {
|
||||
return null;
|
||||
}
|
||||
int start = index - lookback;
|
||||
int end = index - 1;
|
||||
if (start < series.getBeginIndex() || end < start) return null;
|
||||
|
||||
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||
if (pivots != null) {
|
||||
double yAt = lineAt(pivots, index);
|
||||
if (!Double.isFinite(yAt)) return null;
|
||||
return new Segment(
|
||||
barTimeSec(series, pivots.indexA()), pivots.yA(),
|
||||
barTimeSec(series, index), yAt);
|
||||
}
|
||||
return fallbackSegment(series, source, start, end, index, config, kind, priceScale);
|
||||
}
|
||||
|
||||
// ── 피벗 선정 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static PivotPair resolvePivots(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
int kLeft = Math.max(0, config.getSwingPrecedingBars());
|
||||
int kRight = Math.max(0, config.getSwingFollowingBars());
|
||||
List<Integer> swings = collectSwingIndices(series, source, start, end, kLeft, kRight, kind, priceScale);
|
||||
if (swings.size() < 2) return null;
|
||||
|
||||
int swingA = swings.get(0);
|
||||
for (int j = 1; j < swings.size(); j++) {
|
||||
int swingB = swings.get(j);
|
||||
if (swingB <= swingA) continue;
|
||||
double yA = yAtPivot(series, source, swingA, kind, priceScale);
|
||||
double yB = yAtPivot(series, source, swingB, kind, priceScale);
|
||||
if (!Double.isFinite(yA) || !Double.isFinite(yB)) continue;
|
||||
if (kind == TrendLineKind.UP && yB > yA) {
|
||||
return new PivotPair(swingA, yA, swingB, yB);
|
||||
}
|
||||
if (kind == TrendLineKind.DOWN && yB < yA) {
|
||||
return new PivotPair(swingA, yA, swingB, yB);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Integer> collectSwingIndices(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int kLeft,
|
||||
int kRight,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
List<Integer> out = new ArrayList<>();
|
||||
for (int i = start + kLeft; i <= end - kRight; i++) {
|
||||
boolean isSwing = kind == TrendLineKind.UP
|
||||
? isSwingLow(series, source, i, kLeft, kRight, priceScale)
|
||||
: isSwingHigh(series, source, i, kLeft, kRight, priceScale);
|
||||
if (isSwing) out.add(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double yAtPivot(
|
||||
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||
if (priceScale) {
|
||||
return kind == TrendLineKind.UP
|
||||
? series.getBar(index).getLowPrice().doubleValue()
|
||||
: series.getBar(index).getHighPrice().doubleValue();
|
||||
}
|
||||
Double v = numAt(source, index);
|
||||
return v != null ? v : Double.NaN;
|
||||
}
|
||||
|
||||
private static boolean isSwingLow(
|
||||
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||
double low = priceScale
|
||||
? series.getBar(i).getLowPrice().doubleValue()
|
||||
: requireNum(source, i);
|
||||
if (!Double.isFinite(low)) return false;
|
||||
for (int j = i - prec; j <= i + fol; j++) {
|
||||
if (j == i) continue;
|
||||
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||
double v = priceScale
|
||||
? series.getBar(j).getLowPrice().doubleValue()
|
||||
: requireNum(source, j);
|
||||
if (!Double.isFinite(v) || v < low) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isSwingHigh(
|
||||
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||
double high = priceScale
|
||||
? series.getBar(i).getHighPrice().doubleValue()
|
||||
: requireNum(source, i);
|
||||
if (!Double.isFinite(high)) return false;
|
||||
for (int j = i - prec; j <= i + fol; j++) {
|
||||
if (j == i) continue;
|
||||
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||
double v = priceScale
|
||||
? series.getBar(j).getHighPrice().doubleValue()
|
||||
: requireNum(source, j);
|
||||
if (!Double.isFinite(v) || v > high) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double lineAt(PivotPair p, int evalIndex) {
|
||||
if (p.indexB() == p.indexA()) return p.yA();
|
||||
double t = (evalIndex - p.indexA()) / (double) (p.indexB() - p.indexA());
|
||||
return p.yA() + (p.yB() - p.yA()) * t;
|
||||
}
|
||||
|
||||
// ── 모드별 폴백 (피벗 2개 미충족 시) ─────────────────────────────────────
|
||||
|
||||
private static double fallbackValueAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int evalIndex,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||
return Double.NaN;
|
||||
}
|
||||
if (TrendLineConfig.MODE_REGRESSION.equals(normalizeMode(config.getMode()))) {
|
||||
return regressionAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
return twoPointAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
|
||||
private static Segment fallbackSegment(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int anchorIndex,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||
return null;
|
||||
}
|
||||
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||
if (!Double.isFinite(y0)) return null;
|
||||
double yAt = fallbackValueAt(series, source, start, end, anchorIndex, config, kind, priceScale);
|
||||
if (!Double.isFinite(yAt)) return null;
|
||||
return new Segment(barTimeSec(series, start), y0, barTimeSec(series, anchorIndex), yAt);
|
||||
}
|
||||
|
||||
private static double yAtEndpoint(
|
||||
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||
return yAtPivot(series, source, index, kind, priceScale);
|
||||
}
|
||||
|
||||
private static double twoPointAt(
|
||||
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||
TrendLineKind kind, boolean priceScale) {
|
||||
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||
double y1 = yAtEndpoint(series, source, end, kind, priceScale);
|
||||
if (!Double.isFinite(y0) || !Double.isFinite(y1) || end == start) return Double.NaN;
|
||||
double t = (evalIndex - start) / (double) (end - start);
|
||||
return y0 + (y1 - y0) * t;
|
||||
}
|
||||
|
||||
private static double regressionAt(
|
||||
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||
TrendLineKind kind, boolean priceScale) {
|
||||
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
||||
int count = 0;
|
||||
for (int i = start; i <= end; i++) {
|
||||
double y = yAtEndpoint(series, source, i, kind, priceScale);
|
||||
if (!Double.isFinite(y)) continue;
|
||||
double x = i - start;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumXX += x * x;
|
||||
count++;
|
||||
}
|
||||
if (count < 2) return Double.NaN;
|
||||
double denom = count * sumXX - sumX * sumX;
|
||||
if (Math.abs(denom) < 1e-12) return Double.NaN;
|
||||
double b = (count * sumXY - sumX * sumY) / denom;
|
||||
double a = (sumY - b * sumX) / count;
|
||||
return a + b * (evalIndex - start);
|
||||
}
|
||||
|
||||
private static String normalizeMode(String mode) {
|
||||
if (TrendLineConfig.MODE_REGRESSION.equals(mode)) return TrendLineConfig.MODE_REGRESSION;
|
||||
if (TrendLineConfig.MODE_SWING.equals(mode)) return TrendLineConfig.MODE_SWING;
|
||||
return TrendLineConfig.MODE_TWO_POINT;
|
||||
}
|
||||
|
||||
private static double requireNum(Indicator<Num> source, int index) {
|
||||
Double v = numAt(source, index);
|
||||
return v != null ? v : Double.NaN;
|
||||
}
|
||||
|
||||
private static Double numAt(Indicator<Num> source, int index) {
|
||||
if (index < 0) return null;
|
||||
Num n = source.getValue(index);
|
||||
if (n == null) return null;
|
||||
double v = n.doubleValue();
|
||||
return Double.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
private static long barTimeSec(BarSeries series, int index) {
|
||||
return series.getBar(index).getEndTime().getEpochSecond();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.indicators.AbstractIndicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import static org.ta4j.core.num.NaN.NaN;
|
||||
|
||||
/** bar index 에서 N봉 추세선 Y값 — left 지표·종가와 CROSS 비교용 */
|
||||
public class TrendLineValueIndicator extends AbstractIndicator<Num> {
|
||||
|
||||
private final Indicator<Num> source;
|
||||
private final BarSeries series;
|
||||
private final int lookback;
|
||||
private final TrendLineConfig config;
|
||||
private final TrendLineKind kind;
|
||||
private final boolean priceScale;
|
||||
|
||||
public TrendLineValueIndicator(
|
||||
Indicator<Num> source,
|
||||
BarSeries series,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
super(series);
|
||||
this.source = source;
|
||||
this.series = series;
|
||||
this.lookback = Math.max(2, lookback);
|
||||
this.config = config != null ? config : TrendLineConfig.defaults();
|
||||
this.kind = kind != null ? kind : TrendLineKind.DOWN;
|
||||
this.priceScale = priceScale;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Num getValue(int index) {
|
||||
double v = TrendLineMath.valueAt(series, source, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(v)) {
|
||||
return NaN;
|
||||
}
|
||||
return series.numFactory().numOf(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCountOfUnstableBars() {
|
||||
return lookback + 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user