추세선 전략추가

This commit is contained in:
Macbook
2026-06-25 02:45:39 +09:00
parent ee8cd50781
commit f9447ea52f
31 changed files with 1793 additions and 49 deletions
@@ -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 구간 [anchorN, anchor1] (현재 봉 제외).
* 상승 추세선(UP): 스윙 저점 2개(Low, Low₂&gt;Low₁) · 하락 추세선(DOWN): 스윙 고점 2개(High, High₂&lt;High₁).
* anchor 봉 i 돌파 판정: 동일 선을 i1·i 에 extrapolation.
* 차트 선분: 피벗 A → anchor(i)까지 연장.
*/
public final class TrendLineMath {
private TrendLineMath() {}
private record PivotPair(int indexA, double yA, int indexB, double yB) {}
/** anchor 봉 기준 [anchorN, anchor1] 추세선을 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;
}
}
@@ -0,0 +1,7 @@
-- 추세선 전략 조건 — 백테스트/전략평가 공통 설정
ALTER TABLE gc_backtest_settings
ADD COLUMN trend_line_mode VARCHAR(20) NOT NULL DEFAULT 'TWO_POINT'
COMMENT 'TWO_POINT | REGRESSION | SWING',
ADD COLUMN trend_line_default_lookback INT NOT NULL DEFAULT 10,
ADD COLUMN trend_line_swing_preceding_bars INT NOT NULL DEFAULT 1,
ADD COLUMN trend_line_swing_following_bars INT NOT NULL DEFAULT 1;
@@ -0,0 +1,179 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.dto.TrendLineKind;
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.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.Num;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/** N봉 추세선 돌파 — 스윙 피벗 (상승 지지 / 하락 저항) */
class TrendLineCrossRuleTest {
private final StrategyDslToTa4jAdapter adapter = new StrategyDslToTa4jAdapter();
private final ObjectMapper om = new ObjectMapper();
@Test
void trendLineCrossUp_swingPivot_breaksDescendingResistance() throws Exception {
BarSeries series = buildDescendingHighsThenBreakUp();
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_UP", 8, TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build());
int last = series.getEndIndex();
assertTrue(rule.isSatisfied(last, null), "저항(하락) 추세선 상향 돌파");
assertFalse(rule.isSatisfied(last - 1, null), "직전 봉은 미돌파");
}
@Test
void trendLineCrossDown_swingPivot_breaksAscendingSupport() throws Exception {
BarSeries series = buildAscendingLowsThenBreakDown();
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_DOWN", 8, TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build());
int last = series.getEndIndex();
assertTrue(rule.isSatisfied(last, null), "지지(상승) 추세선 하향 돌파");
}
@Test
void trendLineValueIndicator_matchesMath() {
BarSeries series = buildDescendingHighsThenBreakUp();
ClosePriceIndicator close = new ClosePriceIndicator(series);
TrendLineConfig cfg = TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build();
TrendLineValueIndicator ind = new TrendLineValueIndicator(
close, series, 8, cfg, TrendLineKind.DOWN, true);
int idx = series.getEndIndex();
double expected = TrendLineMath.valueAt(series, close, idx, 8, cfg, TrendLineKind.DOWN, true);
Num v = ind.getValue(idx);
assertEquals(expected, v.doubleValue(), 1e-9);
}
@Test
void trendLineCross_usesFixedWindow_notCurrentBarInFit() {
BarSeries series = buildDescendingHighsThenBreakUp();
ClosePriceIndicator close = new ClosePriceIndicator(series);
TrendLineConfig cfg = TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build();
int idx = series.getEndIndex();
TrendLineKind kind = TrendLineKind.DOWN;
double lineAtPrev = TrendLineMath.valueAtEval(series, close, idx, idx - 1, 8, cfg, kind, true);
double lineAtCurr = TrendLineMath.valueAtEval(series, close, idx, idx, 8, cfg, kind, true);
assertTrue(Double.isFinite(lineAtPrev));
assertTrue(Double.isFinite(lineAtCurr));
double shiftedWrong = TrendLineMath.valueAtEval(series, close, idx - 1, idx - 1, 8, cfg, kind, true);
assertNotEquals(lineAtPrev, shiftedWrong, 1e-6,
"i1·i는 동일 [idx−N, idx−1] 창의 추세선이어야 함");
}
@Test
void pivotUp_requiresHigherSecondLow() {
BarSeries series = buildInvalidUpTrendFlatLows();
ClosePriceIndicator close = new ClosePriceIndicator(series);
TrendLineConfig cfg = TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build();
int idx = series.getEndIndex();
double v = TrendLineMath.valueAtEval(
series, close, idx, idx, 8, cfg, TrendLineKind.UP, true);
assertTrue(Double.isNaN(v), "두 번째 저점이 더 높지 않으면 피벗 추세선 없음");
}
private Rule buildTrendLineRule(BarSeries series, String condType, int lookback, TrendLineConfig cfg)
throws Exception {
String json = """
{
"type": "CONDITION",
"condition": {
"indicatorType": "MA",
"conditionType": "%s",
"leftField": "CLOSE_PRICE",
"rightField": "NONE",
"lookbackPeriod": %d
}
}
""".formatted(condType, lookback);
var dsl = om.readTree(json);
var ctx = new StrategyDslToTa4jAdapter.RuleBuildContext(
series, Map.of(), Map.of(), null, null, false, Map.of(), cfg);
return adapter.toRule(dsl, ctx);
}
/** 하락 저항선(고점↓) — 마지막 봉 종가 상향 돌파 */
private static BarSeries buildDescendingHighsThenBreakUp() {
double[] highs = {100, 101, 102, 115, 110, 108, 106, 104, 102, 108, 105, 112};
double[] closes = {99, 100, 101, 113, 108, 106, 104, 102, 100, 106, 104, 111};
return toSeriesFromHighs(highs, closes);
}
/** 상승 지지선(저점↑) — 마지막 봉 종가 하향 돌파 */
private static BarSeries buildAscendingLowsThenBreakDown() {
double[] lows = {108, 109, 110, 95, 96, 97, 98, 99, 100, 101, 102, 108};
double[] closes = {110, 111, 112, 97, 98, 99, 100, 101, 102, 103, 104, 101};
return toSeriesFromLows(lows, closes);
}
/** 두 스윙 저점이 같은 높이 — UP 피벗 불성립 */
private static BarSeries buildInvalidUpTrendFlatLows() {
double[] lows = {98, 98, 98, 95, 95, 95, 95, 95, 95, 95, 95, 94};
double[] closes = {100, 100, 100, 97, 97, 97, 97, 97, 97, 97, 97, 96};
return toSeriesFromLows(lows, closes);
}
private static BarSeries toSeriesFromHighs(double[] highs, double[] closes) {
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
Instant t = Instant.parse("2024-01-01T00:00:00Z");
for (int i = 0; i < highs.length; i++) {
double c = closes[i];
double h = highs[i];
double l = Math.min(c, h) - 1;
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
t = t.plus(Duration.ofMinutes(1));
}
return series;
}
private static BarSeries toSeriesFromLows(double[] lows, double[] closes) {
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
Instant t = Instant.parse("2024-01-01T00:00:00Z");
for (int i = 0; i < lows.length; i++) {
double c = closes[i];
double l = lows[i];
double h = Math.max(c, l) + 1;
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
t = t.plus(Duration.ofMinutes(1));
}
return series;
}
}