추세선 전략추가

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 @Builder.Default
private String tradeExecutionMode = "BACKTEST_ENGINE"; 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 @Builder.Default
private String tradeExecutionMode = "BACKTEST_ENGINE"; 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) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -125,6 +125,21 @@ public class BacktestSettingsService {
"SCAN_SIGNALS".equals(dto.getTradeExecutionMode()) "SCAN_SIGNALS".equals(dto.getTradeExecutionMode())
? "SCAN_SIGNALS" ? "SCAN_SIGNALS"
: "BACKTEST_ENGINE"); : "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".equals(e.getTradeExecutionMode())
? "SCAN_SIGNALS" ? "SCAN_SIGNALS"
: "BACKTEST_ENGINE") : "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(); .build();
} }
} }
@@ -132,9 +132,11 @@ public class BacktestingService {
series, primaryTf, buyDsl, sellDsl); series, primaryTf, buyDsl, sellDsl);
String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null; 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 = StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext( 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 entryRule = adapter.toRule(buyDsl, ruleCtx);
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
? adapter.toRule(sellDsl, ruleCtx) ? 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.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.goldenchart.dto.BacktestResponse; import com.goldenchart.dto.BacktestResponse;
import com.goldenchart.dto.BacktestSettingsDto;
import com.goldenchart.dto.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionRowDto;
import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.dto.OhlcvBar; import com.goldenchart.dto.OhlcvBar;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.entity.GcStrategy; import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.repository.GcStrategyRepository;
import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.storage.Ta4jStorage;
@@ -38,6 +40,8 @@ public class LiveConditionStatusService {
Map.entry("NEQ", "다름(≠)"), Map.entry("NEQ", "다름(≠)"),
Map.entry("CROSS_UP", "상향 돌파"), Map.entry("CROSS_UP", "상향 돌파"),
Map.entry("CROSS_DOWN", "하향 돌파"), Map.entry("CROSS_DOWN", "하향 돌파"),
Map.entry("TREND_LINE_CROSS_UP", "추세선 상향 돌파"),
Map.entry("TREND_LINE_CROSS_DOWN", "추세선 하향 돌파"),
Map.entry("SLOPE_UP", "상승 중"), Map.entry("SLOPE_UP", "상승 중"),
Map.entry("LOWEST_LTE", "N봉 최저 ≤"), Map.entry("LOWEST_LTE", "N봉 최저 ≤"),
Map.entry("LOWEST_GTE", "N봉 최저 ≥"), Map.entry("LOWEST_GTE", "N봉 최저 ≥"),
@@ -53,6 +57,27 @@ public class LiveConditionStatusService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DynamicSubscriptionManager subscriptionManager; private final DynamicSubscriptionManager subscriptionManager;
private final StrategyDslTimeframeNormalizer timeframeNormalizer; 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) @Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId, public LiveConditionStatusDto evaluate(Long userId, String deviceId,
@@ -104,7 +129,7 @@ public class LiveConditionStatusService {
} }
if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) { 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); barTimeSec, params, visual);
} }
@@ -142,8 +167,8 @@ public class LiveConditionStatusService {
wrapper.set("condition", p.condition()); wrapper.set("condition", p.condition());
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(series, params, visual, market, ta4jStorage, false,
series, params, visual, market, ta4jStorage, false, java.util.Map.of()); java.util.Map.of(), userId, deviceId);
Rule rule = adapter.toRule(wrapper, ctx); Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null); boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue( Double current = adapter.readConditionFieldValue(
@@ -167,9 +192,9 @@ public class LiveConditionStatusService {
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영 // [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
Boolean overallEntryMet = evaluateOverallRule( Boolean overallEntryMet = evaluateOverallRule(
strategy.getBuyConditionJson(), market, params, visual, barTimeSec); strategy.getBuyConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
Boolean overallExitMet = evaluateOverallRule( Boolean overallExitMet = evaluateOverallRule(
strategy.getSellConditionJson(), market, params, visual, barTimeSec); strategy.getSellConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
return LiveConditionStatusDto.builder() return LiveConditionStatusDto.builder()
.market(market) .market(market)
@@ -267,6 +292,8 @@ public class LiveConditionStatusService {
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지. * 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
*/ */
private LiveConditionStatusDto evaluateOnChartBars( private LiveConditionStatusDto evaluateOnChartBars(
Long userId,
String deviceId,
GcStrategy strategy, GcStrategy strategy,
String market, String market,
long strategyId, long strategyId,
@@ -314,8 +341,8 @@ public class LiveConditionStatusService {
wrapper.set("condition", p.condition()); wrapper.set("condition", p.condition());
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(series, params, visual, market, null, false,
series, params, visual, market, null, false, seriesOverrides); seriesOverrides, userId, deviceId);
Rule rule = adapter.toRule(wrapper, ctx); Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null); boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue( Double current = adapter.readConditionFieldValue(
@@ -336,9 +363,11 @@ public class LiveConditionStatusService {
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0; int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
Boolean overallEntryMet = evaluateOverallRuleOnChart( Boolean overallEntryMet = evaluateOverallRuleOnChart(
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
userId, deviceId);
Boolean overallExitMet = evaluateOverallRuleOnChart( Boolean overallExitMet = evaluateOverallRuleOnChart(
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
userId, deviceId);
return LiveConditionStatusDto.builder() return LiveConditionStatusDto.builder()
.market(market) .market(market)
@@ -392,14 +421,16 @@ public class LiveConditionStatusService {
String market, String market,
Map<String, Map<String, Object>> params, Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual, Map<String, Map<String, Object>> visual,
Long barTimeSec) { Long barTimeSec,
Long userId,
String deviceId) {
if (dsl == null || dsl.isNull()) return null; if (dsl == null || dsl.isNull()) return null;
try { try {
if (primarySeries == null || primarySeries.isEmpty()) return null; if (primarySeries == null || primarySeries.isEmpty()) return null;
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(primarySeries, params, visual, market, null, false,
primarySeries, params, visual, market, null, false, seriesOverrides); seriesOverrides, userId, deviceId);
Rule rule = adapter.toRule(dsl, ctx); Rule rule = adapter.toRule(dsl, ctx);
int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
return rule.isSatisfied(index, null); return rule.isSatisfied(index, null);
@@ -441,7 +472,9 @@ public class LiveConditionStatusService {
private Boolean evaluateOverallRule(String conditionJson, String market, private Boolean evaluateOverallRule(String conditionJson, String market,
Map<String, Map<String, Object>> params, Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual, Map<String, Map<String, Object>> visual,
Long barTimeSec) { Long barTimeSec,
Long userId,
String deviceId) {
if (conditionJson == null || conditionJson.isBlank()) return null; if (conditionJson == null || conditionJson.isBlank()) return null;
try { try {
String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper); String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper);
@@ -461,8 +494,8 @@ public class LiveConditionStatusService {
if (series.isEmpty()) return null; if (series.isEmpty()) return null;
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(series, params, visual, market, ta4jStorage, false,
series, params, visual, market, ta4jStorage, false, java.util.Map.of()); java.util.Map.of(), userId, deviceId);
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx); org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null); return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
} catch (Exception e) { } catch (Exception e) {
@@ -798,8 +831,8 @@ public class LiveConditionStatusService {
int startIdx = resolveChartScanStartIndex(primarySeries, evalCount); int startIdx = resolveChartScanStartIndex(primarySeries, evalCount);
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(primarySeries, params, visual, market, null, false,
primarySeries, params, visual, market, null, false, seriesOverrides); seriesOverrides, userId, deviceId);
Rule entryRule = (buyDsl != null && !buyDsl.isNull()) Rule entryRule = (buyDsl != null && !buyDsl.isNull())
? adapter.toRule(buyDsl, ruleCtx) ? adapter.toRule(buyDsl, ruleCtx)
: new org.ta4j.core.rules.BooleanRule(false); : new org.ta4j.core.rules.BooleanRule(false);
@@ -1,6 +1,8 @@
package com.goldenchart.service; package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.dto.TrendLineKind;
import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.storage.Ta4jStorage;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -122,20 +124,37 @@ public class StrategyDslToTa4jAdapter {
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며 * null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다. * CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
*/ */
Map<String, BarSeries> seriesOverrides Map<String, BarSeries> seriesOverrides,
/** 추세선 조건(TREND_LINE_CROSS_*) — 전략설정 gc_backtest_settings */
TrendLineConfig trendLineConfig
) { ) {
/** visual 미전달 호환 (레거시) */ /** visual 미전달 호환 (레거시) */
public RuleBuildContext(BarSeries primarySeries, public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
String market, String market,
Ta4jStorage storage) { 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 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */ /** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
public RuleBuildContext withPrimary(BarSeries newPrimary) { public RuleBuildContext withPrimary(BarSeries newPrimary) {
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual, return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
market, storage, useConfirmedOnly, seriesOverrides); market, storage, useConfirmedOnly, seriesOverrides, trendLineConfig);
} }
/** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */ /** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */
@@ -170,20 +189,20 @@ public class StrategyDslToTa4jAdapter {
public Rule toRule(JsonNode node, BarSeries series, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) { 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, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) { 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, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual, Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) { 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, Map<String, Object>> indicatorParams,
Map<String, BarSeries> seriesOverrides) { Map<String, BarSeries> seriesOverrides) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, 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>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual, Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) { 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) { public Rule toRule(JsonNode node, RuleBuildContext ctx) {
@@ -508,6 +527,15 @@ public class StrategyDslToTa4jAdapter {
// 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외) // 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외)
case "CROSS_UP" -> buildCrossUpRule(left, right); case "CROSS_UP" -> buildCrossUpRule(left, right);
case "CROSS_DOWN" -> buildCrossDownRule(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_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod); case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
case "DIFF_GT" -> { 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) 본선 기간을 일치시킨다. * CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다.
* sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다. * 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;
}
}
+35 -1
View File
@@ -6,11 +6,15 @@ import React, { useState, useEffect, useCallback } from 'react';
import type { Theme } from '../types'; import type { Theme } from '../types';
import { import {
loadBacktestSettings, loadBacktestSettings,
saveBacktestSettings,
saveLiveStrategySettings, saveLiveStrategySettings,
type BacktestSettingsDto,
type LiveStrategySettingsDto, type LiveStrategySettingsDto,
DEFAULT_BACKTEST_SETTINGS,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import { BacktestSettingsPanel } from './BacktestSettingsPanel'; import { BacktestSettingsPanel } from './BacktestSettingsPanel';
import TrendLineSettingsFields from './TrendLineSettingsFields';
import IndicatorMainDefaultsPanel, { import IndicatorMainDefaultsPanel, {
type IndicatorSaveUiState, type IndicatorSaveUiState,
} from './IndicatorMainDefaultsPanel'; } from './IndicatorMainDefaultsPanel';
@@ -1366,15 +1370,32 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
); );
const [liveSaving, setLiveSaving] = useState(false); const [liveSaving, setLiveSaving] = useState(false);
const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY'); const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY');
const [trendLineCfg, setTrendLineCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
const [trendLineSaving, setTrendLineSaving] = useState(false);
useEffect(() => { useEffect(() => {
void loadBacktestSettings().then(s => { void loadBacktestSettings().then(s => {
if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') { if (s) {
setTrendLineCfg(s);
if (s.positionMode === 'SIGNAL_ONLY' || s.positionMode === 'LONG_ONLY') {
setBacktestPositionMode(s.positionMode); setBacktestPositionMode(s.positionMode);
} }
}
}); });
}, []); }, []);
const persistTrendLine = useCallback(async (patch: Partial<BacktestSettingsDto>) => {
const next = { ...trendLineCfg, ...patch };
setTrendLineCfg(next);
setTrendLineSaving(true);
try {
const saved = await saveBacktestSettings(next);
if (saved) setTrendLineCfg(saved);
} finally {
setTrendLineSaving(false);
}
}, [trendLineCfg]);
useEffect(() => { useEffect(() => {
if (liveSettingsProp) setLiveSettings(liveSettingsProp); if (liveSettingsProp) setLiveSettings(liveSettingsProp);
}, [liveSettingsProp]); }, [liveSettingsProp]);
@@ -1582,6 +1603,19 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
)} )}
</SettingSection> </SettingSection>
{/* ── 추세선 설정 ──────────────────────────────────────────────────── */}
<SettingSection title="추세선 설정">
<p className="stg-hint" style={{ marginBottom: 12 }}>
N봉 .
2·· , .
{trendLineSaving && <span style={{ marginLeft: 8, color: 'var(--text3)' }}> </span>}
</p>
<TrendLineSettingsFields
cfg={trendLineCfg}
onChange={patch => { void persistTrendLine(patch); }}
/>
</SettingSection>
{/* ── 리스크 관리 ──────────────────────────────────────────────────── */} {/* ── 리스크 관리 ──────────────────────────────────────────────────── */}
<SettingSection title="리스크 관리" grid> <SettingSection title="리스크 관리" grid>
<StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비)."> <StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
+38 -2
View File
@@ -48,6 +48,8 @@ import {
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab'; import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab'; import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage';
import { import {
BAND_PALETTE_CHIP_ITEMS, BAND_PALETTE_CHIP_ITEMS,
@@ -319,7 +321,7 @@ export default function StrategyEditorPage({
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null); const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
const [signalTab, setSignalTab] = useState<SignalTab>('buy'); const [signalTab, setSignalTab] = useState<SignalTab>('buy');
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators'); const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary'); const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary')); const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite')); const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
@@ -1436,6 +1438,15 @@ export default function StrategyEditorPage({
scheduleStrategyPersist(); scheduleStrategyPersist();
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]); }, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]);
const applyTrendLine = useCallback((item: { indicatorType: string; leftField?: string }, lookback: number) => {
const newNode = buildTrendLineConditionNode(item, buySellTab(signalTab), DEF, lookback);
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, DEF, handleEditorStateChange]);
const templateRows = useMemo( const templateRows = useMemo(
() => buildStrategyTemplatePaletteRows({ () => buildStrategyTemplatePaletteRows({
def: DEF, def: DEF,
@@ -2487,6 +2498,16 @@ export default function StrategyEditorPage({
> >
</button> </button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('trendline');
setSelectedPaletteKey(null);
}}
>
</button>
</div> </div>
{indicatorSubTab === 'auxiliary' ? ( {indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab <IndicatorPaletteTab
@@ -2524,7 +2545,7 @@ export default function StrategyEditorPage({
applyPaletteItem(item); applyPaletteItem(item);
}} }}
/> />
) : ( ) : indicatorSubTab === 'range' ? (
<SidewaysFilterPaletteTab <SidewaysFilterPaletteTab
def={DEF} def={DEF}
items={sidewaysPalette} items={sidewaysPalette}
@@ -2541,6 +2562,21 @@ export default function StrategyEditorPage({
applySidewaysFilter(item.id); applySidewaysFilter(item.id);
}} }}
/> />
) : (
<TrendLinePaletteTab
signalType={buySellTab(signalTab)}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('trendline:')
? selectedPaletteKey.slice('trendline:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
onAddToCanvas={(item, lookback) => {
selectPalette('trendline', item.id);
applyTrendLine(item, lookback);
}}
/>
)} )}
</> </>
)} )}
@@ -0,0 +1,103 @@
/**
* 설정 화면 — 전략 설정 · 추세선 조건
*/
import React from 'react';
import type { BacktestSettingsDto } from '../utils/backendApi';
export const TREND_LINE_MODE_OPTIONS = [
{
value: 'SWING' as const,
label: '스윙 피벗 (권장)',
desc: 'N봉 내 스윙 고/저점 2개 — 상승=저점↑·하락=고점↓',
},
{
value: 'TWO_POINT' as const,
label: '2점 폴백',
desc: '피벗 미충족 시 구간 양端(Low/High) 직선',
},
{
value: 'REGRESSION' as const,
label: '회귀 폴백',
desc: '피벗 미충족 시 N봉 Low/High OLS',
},
];
interface Props {
cfg: BacktestSettingsDto;
onChange: (patch: Partial<BacktestSettingsDto>) => void;
}
export const TrendLineSettingsFields: React.FC<Props> = ({ cfg, onChange }) => {
const mode = cfg.trendLineMode ?? 'SWING';
return (
<>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> </span>
<span className="stg-row-desc">
( )= ( ) · ( )= ( ).
Low/High, .
</span>
</div>
<select
className="stg-select stg-select--wide"
value={mode}
onChange={e => onChange({ trendLineMode: e.target.value as BacktestSettingsDto['trendLineMode'] })}
>
{TREND_LINE_MODE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> lookback ()</span>
<span className="stg-row-desc">
[iN, i1] i . 30~50· 100~120 .
</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={2}
max={500}
value={cfg.trendLineDefaultLookback ?? 10}
onChange={e => onChange({ trendLineDefaultLookback: Number(e.target.value) })}
/>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> k봉</span>
<span className="stg-row-desc">
판정: 해당 Low/High가 · k봉보다
</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={0}
max={20}
value={cfg.trendLineSwingPrecedingBars ?? 1}
onChange={e => onChange({ trendLineSwingPrecedingBars: Number(e.target.value) })}
/>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> k봉</span>
<span className="stg-row-desc"> (k)</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={0}
max={20}
value={cfg.trendLineSwingFollowingBars ?? 1}
onChange={e => onChange({ trendLineSwingFollowingBars: Number(e.target.value) })}
/>
</div>
</>
);
};
export default TrendLineSettingsFields;
@@ -31,6 +31,11 @@ interface Props {
stableStrategy?: boolean; stableStrategy?: boolean;
ichimokuSituation?: boolean; ichimokuSituation?: boolean;
secondaryIndicator?: string; secondaryIndicator?: string;
/** 추세선 돌파 — 드래그 payload type: trendLine */
trendLine?: boolean;
trendLineIndicatorType?: string;
trendLineLeftField?: string;
trendLineLookback?: number;
selected?: boolean; selected?: boolean;
onSelect?: () => void; onSelect?: () => void;
onAdd: () => void; onAdd: () => void;
@@ -38,9 +43,22 @@ interface Props {
export default function PaletteChip({ export default function PaletteChip({
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod, type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator, selected = false, onSelect, onAdd, composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator,
trendLine = false, trendLineIndicatorType, trendLineLeftField, trendLineLookback,
selected = false, onSelect, onAdd,
}: Props) { }: Props) {
const buildPayload = (): PaletteDragPayload => ({ const buildPayload = (): PaletteDragPayload => {
if (trendLine) {
return {
type: 'trendLine',
value,
label,
indicatorType: trendLineIndicatorType,
leftField: trendLineLeftField,
lookback: trendLineLookback,
};
}
return {
type, value, label, type, value, label,
composite: composite || undefined, composite: composite || undefined,
stochPair: stochPair || undefined, stochPair: stochPair || undefined,
@@ -53,7 +71,8 @@ export default function PaletteChip({
period: periodValue, period: periodValue,
shortPeriod, shortPeriod,
longPeriod, longPeriod,
}); };
};
const pointerMode = needsPointerPaletteDrag(); const pointerMode = needsPointerPaletteDrag();
@@ -31,6 +31,7 @@ import {
type DefType, type DefType,
} from '../../utils/strategyEditorShared'; } from '../../utils/strategyEditorShared';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
import { import {
isPaletteHtmlDrag, isPaletteHtmlDrag,
isPaletteHtmlDragActive, isPaletteHtmlDragActive,
@@ -608,11 +609,14 @@ function StrategyEditorCanvasInner({
}, [setNodes]); }, [setNodes]);
const resolvePaletteDropNode = useCallback(( const resolvePaletteDropNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
): LogicNode | null => { ): LogicNode | null => {
if (data.type === 'sidewaysFilter') { if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def); return buildSidewaysFilterNode(data.value, def);
} }
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]); }, [signalTab, def]);
@@ -36,6 +36,7 @@ import LogicGateOpToggle from './LogicGateOpToggle';
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes'; import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
import { START_NODE_ID } from '../../utils/strategyFlowLayout'; import { START_NODE_ID } from '../../utils/strategyFlowLayout';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
import { import {
clearPaletteDropHighlights, clearPaletteDropHighlights,
findPaletteDropKeyAtPoint, findPaletteDropKeyAtPoint,
@@ -403,11 +404,14 @@ export default function StrategyListEditor({
}, [onEditorStateChange, onOrphansChange, orphans]); }, [onEditorStateChange, onOrphansChange, orphans]);
const resolvePaletteNode = useCallback(( const resolvePaletteNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
) => { ) => {
if (data.type === 'sidewaysFilter') { if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def); return buildSidewaysFilterNode(data.value, def);
} }
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]); }, [signalTab, def]);
@@ -0,0 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import PaletteChip from './PaletteChip';
import { loadBacktestSettings } from '../../utils/backendApi';
import {
filterTrendLinePaletteItems,
type TrendLinePaletteItem,
} from '../../utils/trendLinePalette';
interface Props {
signalType: 'buy' | 'sell';
searchQuery: string;
selectedItemId: string | null;
onSelectItem: (id: string | null) => void;
onAddToCanvas: (item: TrendLinePaletteItem, lookback: number) => void;
}
function trendLinePeriodLabel(lookback: number, signalType: 'buy' | 'sell'): string {
const dir = signalType === 'buy' ? '상향' : '하향';
return `${lookback}봉 · ${dir}`;
}
export default function TrendLinePaletteTab({
signalType,
searchQuery,
selectedItemId,
onSelectItem,
onAddToCanvas,
}: Props) {
const [defaultLookback, setDefaultLookback] = useState(10);
useEffect(() => {
void loadBacktestSettings().then(s => {
if (s?.trendLineDefaultLookback != null && s.trendLineDefaultLookback > 1) {
setDefaultLookback(s.trendLineDefaultLookback);
}
});
}, []);
const items = useMemo(
() => filterTrendLinePaletteItems(searchQuery),
[searchQuery],
);
return (
<div className="se-palette-section se-palette-section--trendline se-palette-section--scroll">
<div className="se-palette-toolbar">
<span className="se-palette-toolbar-title"> </span>
<span className="se-palette-toolbar-hint">
{signalType === 'buy' ? '매수: 상향 돌파' : '매도: 하향 돌파'}
</span>
</div>
<div className="se-palette-grid se-palette-grid--3">
{items.map(item => (
<PaletteChip
key={item.id}
type="indicator"
value={item.id}
label={item.label}
desc={item.desc}
color="trendline"
trendLine
trendLineIndicatorType={item.indicatorType}
trendLineLeftField={item.leftField}
trendLineLookback={defaultLookback}
period={trendLinePeriodLabel(defaultLookback, signalType)}
selected={selectedItemId === item.id}
onSelect={() => onSelectItem(item.id)}
onAdd={() => onAddToCanvas(item, defaultLookback)}
/>
))}
</div>
{items.length === 0 && (
<p className="se-palette-empty"> .</p>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
import TradingChart from '../TradingChart'; import TradingChart from '../TradingChart';
import ChartCustomOverlaySettingsModal from '../ChartCustomOverlaySettingsModal'; import ChartCustomOverlaySettingsModal from '../ChartCustomOverlaySettingsModal';
import { MarketSearchPanel } from '../MarketSearchPanel'; import { MarketSearchPanel } from '../MarketSearchPanel';
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types'; import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe, Drawing } from '../../types';
import type { ChartManager } from '../../utils/ChartManager'; import type { ChartManager } from '../../utils/ChartManager';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useAppSettings } from '../../hooks/useAppSettings'; import { useAppSettings } from '../../hooks/useAppSettings';
@@ -14,8 +14,10 @@ import {
import { getIndicatorDef } from '../../utils/indicatorRegistry'; import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { getKoreanName } from '../../utils/marketNameCache'; import { getKoreanName } from '../../utils/marketNameCache';
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi'; import type { BacktestSettingsDto, BacktestSignal, StrategyDto } from '../../utils/backendApi';
import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals'; import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals';
import { buildSignalTrendLineDrawings } from '../../utils/buildSignalTrendLineDrawings';
import { setIndicatorChartContext } from '../../utils/indicatorRegistry';
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker'; import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
import { import {
BACKTEST_DISPLAY_BAR_COUNT, BACKTEST_DISPLAY_BAR_COUNT,
@@ -197,6 +199,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc'); const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc');
const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites())); const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [signals, setSignals] = useState<BacktestSignal[]>([]); const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [evalSettings, setEvalSettings] = useState<BacktestSettingsDto | null>(null);
const [signalDrawings, setSignalDrawings] = useState<Drawing[]>([]);
const [backtestRunning, setBacktestRunning] = useState(false); const [backtestRunning, setBacktestRunning] = useState(false);
const [backtestError, setBacktestError] = useState<string | null>(null); const [backtestError, setBacktestError] = useState<string | null>(null);
const [magnifierActive, setMagnifierActive] = useState(false); const [magnifierActive, setMagnifierActive] = useState(false);
@@ -415,7 +419,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
setBacktestRunning(true); setBacktestRunning(true);
setBacktestError(null); setBacktestError(null);
try { try {
const { signals: nextSignals } = await fetchStrategyEvaluationSignals({ const result = await fetchStrategyEvaluationSignals({
strategyId: strategy.id, strategyId: strategy.id,
strategy, strategy,
market, market,
@@ -426,7 +430,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
useDraftDsl, useDraftDsl,
}); });
if (gen !== backtestGenRef.current) return; if (gen !== backtestGenRef.current) return;
setSignals(nextSignals); setEvalSettings(result.settings);
setSignals(result.signals);
applyMarkers(); applyMarkers();
} catch (e) { } catch (e) {
if (gen !== backtestGenRef.current) return; if (gen !== backtestGenRef.current) return;
@@ -461,6 +466,29 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onSignalsChange?.(signals); onSignalsChange?.(signals);
}, [signals, onSignalsChange]); }, [signals, onSignalsChange]);
useEffect(() => {
setIndicatorChartContext(market, timeframe);
if (!strategy || bars.length === 0) {
setSignalDrawings([]);
return;
}
if (selectedBarIndex < 0 || selectedBarIndex >= bars.length) {
setSignalDrawings([]);
return;
}
let cancelled = false;
void buildSignalTrendLineDrawings({
bars,
barIndex: selectedBarIndex,
strategy,
settings: evalSettings,
getParams,
}).then(drawings => {
if (!cancelled) setSignalDrawings(drawings);
});
return () => { cancelled = true; };
}, [bars, strategy, evalSettings, getParams, market, timeframe, selectedBarIndex]);
useEffect(() => { useEffect(() => {
onBacktestRunningChange?.(backtestRunning); onBacktestRunningChange?.(backtestRunning);
}, [backtestRunning, onBacktestRunningChange]); }, [backtestRunning, onBacktestRunningChange]);
@@ -972,7 +1000,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
mode="chart" mode="chart"
indicators={indicators} indicators={indicators}
drawingTool="cursor" drawingTool="cursor"
drawings={[]} drawings={signalDrawings}
logScale={false} logScale={false}
onCrosshair={() => {}} onCrosshair={() => {}}
onManagerReady={onManagerReady} onManagerReady={onManagerReady}
@@ -5,6 +5,7 @@ import React, { useMemo, useState } from 'react';
import PaletteChip from '../strategyEditor/PaletteChip'; import PaletteChip from '../strategyEditor/PaletteChip';
import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab'; import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab';
import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab'; import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from '../strategyEditor/TrendLinePaletteTab';
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout'; import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
import { import {
BAND_PALETTE_CHIP_ITEMS, BAND_PALETTE_CHIP_ITEMS,
@@ -17,7 +18,7 @@ import type { useStrategyEvaluationEditor } from '../../hooks/useStrategyEvaluat
type EditorApi = Pick< type EditorApi = Pick<
ReturnType<typeof useStrategyEvaluationEditor>, ReturnType<typeof useStrategyEvaluationEditor>,
'def' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' 'def' | 'signalTab' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' | 'applyTrendLine'
>; >;
interface Props { interface Props {
@@ -35,10 +36,10 @@ function paletteKey(type: string, id: string) {
} }
const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) => { const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) => {
const { def, applyPalette, applyPaletteItem, applySidewaysFilter } = editor; const { def, signalTab, applyPalette, applyPaletteItem, applySidewaysFilter, applyTrendLine } = editor;
const [paletteSearch, setPaletteSearch] = useState(''); const [paletteSearch, setPaletteSearch] = useState('');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary'); const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary')); const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite')); const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
@@ -141,6 +142,16 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
> >
</button> </button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('trendline');
setSelectedPaletteKey(null);
}}
>
</button>
</div> </div>
{indicatorSubTab === 'auxiliary' ? ( {indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab <IndicatorPaletteTab
@@ -178,7 +189,7 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
applyPaletteItem(item); applyPaletteItem(item);
}} }}
/> />
) : ( ) : indicatorSubTab === 'range' ? (
<SidewaysFilterPaletteTab <SidewaysFilterPaletteTab
def={def as DefType} def={def as DefType}
items={sidewaysPalette} items={sidewaysPalette}
@@ -195,6 +206,21 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
applySidewaysFilter(item.id); applySidewaysFilter(item.id);
}} }}
/> />
) : (
<TrendLinePaletteTab
signalType={signalTab}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('trendline:')
? selectedPaletteKey.slice('trendline:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
onAddToCanvas={(item, lookback) => {
selectPalette('trendline', item.id);
applyTrendLine(item, lookback);
}}
/>
)} )}
</div> </div>
); );
@@ -59,6 +59,7 @@ import {
buildSidewaysFilterNode, buildSidewaysFilterNode,
loadSidewaysPaletteItems, loadSidewaysPaletteItems,
} from '../utils/sidewaysFilterPaletteStorage'; } from '../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import type { IndicatorVisualConfig } from './useIndicatorSettings'; import type { IndicatorVisualConfig } from './useIndicatorSettings';
import type { PaletteItem } from '../utils/strategyPaletteStorage'; import type { PaletteItem } from '../utils/strategyPaletteStorage';
@@ -490,6 +491,18 @@ export function useStrategyEvaluationEditor({
}); });
}, [def, handleEditorStateChange]); }, [def, handleEditorStateChange]);
const applyTrendLine = useCallback((
item: { indicatorType: string; leftField?: string },
lookback: number,
) => {
const newNode = buildTrendLineConditionNode(item, signalTab, def, lookback);
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, def, handleEditorStateChange]);
const applyTemplateForEvaluation = useCallback(async (row: TemplatePaletteRow): Promise<StrategyDto | null> => { const applyTemplateForEvaluation = useCallback(async (row: TemplatePaletteRow): Promise<StrategyDto | null> => {
const payload = buildTemplateEvaluationPayload(row, def); const payload = buildTemplateEvaluationPayload(row, def);
if (!payload) { if (!payload) {
@@ -541,6 +554,7 @@ export function useStrategyEvaluationEditor({
applyPalette, applyPalette,
applyPaletteItem, applyPaletteItem,
applySidewaysFilter, applySidewaysFilter,
applyTrendLine,
applyTemplateForEvaluation, applyTemplateForEvaluation,
saving, saving,
saveNow, saveNow,
+37
View File
@@ -1961,6 +1961,15 @@
color: var(--se-palette-section-range, #f59e0b); color: var(--se-palette-section-range, #f59e0b);
box-shadow: inset 0 -2px 0 var(--se-palette-section-range, #f59e0b); box-shadow: inset 0 -2px 0 var(--se-palette-section-range, #f59e0b);
} }
.se-palette-subtab--trendline.se-palette-subtab--on {
color: var(--se-palette-section-trendline, #38bdf8);
box-shadow: inset 0 -2px 0 var(--se-palette-section-trendline, #38bdf8);
}
.se-palette-section--trendline h3,
.se-palette-section--trendline .se-palette-toolbar-title {
color: var(--se-palette-section-trendline, #38bdf8);
}
.se-palette-section--range .se-palette-toolbar-title { .se-palette-section--range .se-palette-toolbar-title {
color: var(--se-palette-section-range, #f59e0b); color: var(--se-palette-section-range, #f59e0b);
@@ -2471,6 +2480,31 @@ body.se-palette-drag-armed .se-palette-section--scroll {
0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent); 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
} }
/* ── 추세선 돌파 ── */
.se-palette-card--trendline {
--se-palette-trendline-accent: var(--se-palette-section-trendline, #38bdf8);
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 42%, transparent);
background: color-mix(in srgb, var(--se-palette-trendline-accent) 10%, var(--bg2));
box-shadow:
inset 3px 0 0 var(--se-palette-trendline-accent),
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
--se-palette-chip-caption: color-mix(in srgb, var(--se-palette-trendline-accent) 34%, #fff 66%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-palette-trendline-accent) 18%, #fff 82%);
--se-palette-chip-period-bg: color-mix(in srgb, #000 34%, transparent);
--se-palette-chip-period-border: color-mix(in srgb, var(--se-palette-trendline-accent) 40%, transparent);
}
.se-palette-card--trendline .se-palette-card-icon {
background: color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
color: var(--se-palette-trendline-accent);
}
.se-palette-card--trendline:hover {
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 65%, transparent);
box-shadow:
inset 3px 0 0 var(--se-palette-trendline-accent),
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
0 4px 14px color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
}
/* ── 복합지표 ── */ /* ── 복합지표 ── */
.se-palette-card--composite { .se-palette-card--composite {
border-color: color-mix(in srgb, #c084fc 42%, transparent); border-color: color-mix(in srgb, #c084fc 42%, transparent);
@@ -2581,6 +2615,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
/* 지표 탭 — 전략·보조 지표 컨트롤 이름 (오렌지) */ /* 지표 탭 — 전략·보조 지표 컨트롤 이름 (오렌지) */
.se-palette-panel .se-palette-card--band .se-palette-card-name, .se-palette-panel .se-palette-card--band .se-palette-card-name,
.se-palette-panel .se-palette-card--ind .se-palette-card-name, .se-palette-panel .se-palette-card--ind .se-palette-card-name,
.se-palette-panel .se-palette-card--trendline .se-palette-card-name,
.se-palette-panel .se-palette-card--composite .se-palette-card-name, .se-palette-panel .se-palette-card--composite .se-palette-card-name,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-name, .se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-name,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-name, .se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-name,
@@ -2592,6 +2627,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
/* 지표 탭 — 설명·기간 보조 텍스트 가독성 */ /* 지표 탭 — 설명·기간 보조 텍스트 가독성 */
.se-palette-panel .se-palette-card--band .se-palette-card-desc, .se-palette-panel .se-palette-card--band .se-palette-card-desc,
.se-palette-panel .se-palette-card--ind .se-palette-card-desc, .se-palette-panel .se-palette-card--ind .se-palette-card-desc,
.se-palette-panel .se-palette-card--trendline .se-palette-card-desc,
.se-palette-panel .se-palette-card--composite .se-palette-card-desc, .se-palette-panel .se-palette-card--composite .se-palette-card-desc,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-desc, .se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-desc,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-desc, .se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-desc,
@@ -2607,6 +2643,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
.se-palette-panel .se-palette-card--band .se-palette-card-period, .se-palette-panel .se-palette-card--band .se-palette-card-period,
.se-palette-panel .se-palette-card--ind .se-palette-card-period, .se-palette-panel .se-palette-card--ind .se-palette-card-period,
.se-palette-panel .se-palette-card--trendline .se-palette-card-period,
.se-palette-panel .se-palette-card--composite .se-palette-card-period, .se-palette-panel .se-palette-card--composite .se-palette-card-period,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-period, .se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-period,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-period, .se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-period,
@@ -77,6 +77,7 @@
--se-palette-section-band: #a78bfa; --se-palette-section-band: #a78bfa;
--se-palette-section-ind: #34d399; --se-palette-section-ind: #34d399;
--se-palette-section-range: #f59e0b; --se-palette-section-range: #f59e0b;
--se-palette-section-trendline: #38bdf8;
--se-palette-strategy-name: #f59e0b; --se-palette-strategy-name: #f59e0b;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 82%, #fff 18%); --se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 82%, #fff 18%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 90%, #fff 10%); --se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 90%, #fff 10%);
@@ -221,6 +222,7 @@
--se-palette-section-band: #7b1fa2; --se-palette-section-band: #7b1fa2;
--se-palette-section-ind: #2e7d32; --se-palette-section-ind: #2e7d32;
--se-palette-section-range: #e65100; --se-palette-section-range: #e65100;
--se-palette-section-trendline: #0288d1;
--se-palette-strategy-name: #e65100; --se-palette-strategy-name: #e65100;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text) 78%, #000 22%); --se-palette-chip-caption: color-mix(in srgb, var(--se-text) 78%, #000 22%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 92%, #000 8%); --se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 92%, #000 8%);
@@ -290,6 +292,7 @@
--se-palette-section-band: #ce93d8; --se-palette-section-band: #ce93d8;
--se-palette-section-ind: #69f0ae; --se-palette-section-ind: #69f0ae;
--se-palette-section-range: #ffb74d; --se-palette-section-range: #ffb74d;
--se-palette-section-trendline: #4fc3f7;
--se-palette-strategy-name: #ffb74d; --se-palette-strategy-name: #ffb74d;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 80%, #fff 20%); --se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 80%, #fff 20%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 88%, #fff 12%); --se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 88%, #fff 12%);
+12
View File
@@ -1440,6 +1440,14 @@ export interface BacktestSettingsDto {
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY'; analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
/** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */ /** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */
tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE'; tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE';
/** 추세선 조건 — TWO_POINT | REGRESSION | SWING */
trendLineMode?: 'TWO_POINT' | 'REGRESSION' | 'SWING';
/** 조건 lookbackPeriod 미지정 시 기본 N봉 */
trendLineDefaultLookback?: number;
/** SWING 모드 — 스윙 저점 판정 좌측 봉 수 */
trendLineSwingPrecedingBars?: number;
/** SWING 모드 — 스윙 저점 판정 우측 봉 수 */
trendLineSwingFollowingBars?: number;
} }
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
@@ -1465,6 +1473,10 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
positionMode: 'LONG_ONLY', positionMode: 'LONG_ONLY',
analysisMethod: 'MARK_TO_MARKET', analysisMethod: 'MARK_TO_MARKET',
tradeExecutionMode: 'BACKTEST_ENGINE', tradeExecutionMode: 'BACKTEST_ENGINE',
trendLineMode: 'TWO_POINT',
trendLineDefaultLookback: 10,
trendLineSwingPrecedingBars: 1,
trendLineSwingFollowingBars: 1,
}; };
/** 백테스팅 설정 로드 */ /** 백테스팅 설정 로드 */
@@ -0,0 +1,154 @@
/**
* N봉 Drawing
*/
import type { Drawing } from '../types';
import type { OHLCVBar } from '../types';
import type { BacktestSettingsDto, StrategyDto } from './backendApi';
import type { ConditionDSL, LogicNode } from './strategyTypes';
import { trendLineConfigFromSettings, trendLineSegmentAt, trendLineKindForCross, isTrendLinePriceScaleField } from './trendLineGeometry';
import { calculateIndicator } from './indicatorRegistry';
import { DSL_TO_REGISTRY } from './strategyToChartIndicators';
function walkTrendLineConditions(
node: LogicNode | null | undefined,
side: 'buy' | 'sell',
out: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }>,
): void {
if (!node) return;
if (node.type === 'TIMEFRAME') {
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
return;
}
if (node.type === 'NOT') {
const child = node.children?.[0];
if (child) walkTrendLineConditions(child, side, out);
return;
}
if (node.type === 'AND' || node.type === 'OR') {
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
return;
}
if ((!node.type || node.type === 'CONDITION') && node.condition) {
const ct = node.condition.conditionType;
if (ct === 'TREND_LINE_CROSS_UP' || ct === 'TREND_LINE_CROSS_DOWN') {
out.push({ side, cond: node.condition });
}
}
}
function registryType(dslType: string): string {
return DSL_TO_REGISTRY[dslType] ?? dslType;
}
function isPriceScaleField(field?: string): boolean {
if (!field) return true;
const f = field.toUpperCase();
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|| f.includes('BAND') || f.includes('DC_');
}
async function resolveSourceValues(
bars: OHLCVBar[],
cond: ConditionDSL,
getParams: (type: string) => Record<string, number | string | boolean>,
): Promise<number[] | null> {
const lf = (cond.leftField ?? 'CLOSE_PRICE').toUpperCase();
if (lf === 'CLOSE_PRICE' || lf === 'CURRENT') {
return bars.map(b => b.close);
}
if (lf === 'HIGH_PRICE') return bars.map(b => b.high);
if (lf === 'LOW_PRICE') return bars.map(b => b.low);
if (lf === 'OPEN_PRICE') return bars.map(b => b.open);
const regType = registryType(cond.indicatorType);
try {
const params = { ...getParams(regType), ...(cond.params ?? {}) };
const { plots } = await calculateIndicator(regType, bars, params);
const plotKeys = Object.keys(plots);
if (plotKeys.length === 0) return null;
let plotKey = plotKeys[0]!;
if (lf.includes('K') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('SIGNAL') && plots.plot1) plotKey = 'plot1';
else if (lf.includes('MACD') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('UPPER') && plots.plot2) plotKey = 'plot2';
else if (lf.includes('LOWER') && plots.plot0) plotKey = 'plot0';
else if (lf.includes('MIDDLE') && plots.plot1) plotKey = 'plot1';
const plot = plots[plotKey as keyof typeof plots];
if (!plot || !Array.isArray(plot)) return null;
const byTime = new Map(plot.map((p: { time: number; value: number }) => [p.time, p.value]));
return bars.map(b => {
const v = byTime.get(b.time);
return v != null && Number.isFinite(v) ? v : NaN;
});
} catch {
return bars.map(b => b.close);
}
}
/** 선택 봉(barIndex) 기준 추세선만 반환 — 시그널 전체 순회하지 않음 */
export async function buildSignalTrendLineDrawings(opts: {
bars: OHLCVBar[];
/** 캔들 선택봉 인덱스 */
barIndex: number;
strategy: StrategyDto | null;
settings?: BacktestSettingsDto | null;
getParams: (type: string) => Record<string, number | string | boolean>;
}): Promise<Drawing[]> {
const { bars, barIndex, strategy, settings, getParams } = opts;
if (!strategy || bars.length === 0 || barIndex < 0 || barIndex >= bars.length) return [];
const conditions: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }> = [];
walkTrendLineConditions(strategy.buyCondition as LogicNode | null, 'buy', conditions);
walkTrendLineConditions(strategy.sellCondition as LogicNode | null, 'sell', conditions);
if (conditions.length === 0) return [];
const priceConds = conditions.filter(c => isPriceScaleField(c.cond.leftField));
if (priceConds.length === 0) return [];
const config = trendLineConfigFromSettings(settings);
const barTimes = bars.map(b => b.time);
const drawings: Drawing[] = [];
const sourceCache = new Map<string, number[]>();
for (const { cond } of priceConds) {
const key = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
if (!sourceCache.has(key)) {
const values = await resolveSourceValues(bars, cond, getParams);
if (values) sourceCache.set(key, values);
}
}
for (const { side, cond } of priceConds) {
const cacheKey = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
const sourceValues = sourceCache.get(cacheKey);
if (!sourceValues) continue;
const lookback = Math.max(2, cond.lookbackPeriod ?? config.defaultLookback);
const kind = trendLineKindForCross(cond.conditionType, side);
const priceScale = isTrendLinePriceScaleField(cond.leftField);
const seg = trendLineSegmentAt(
barTimes, sourceValues, bars, barIndex, lookback, config, kind, priceScale,
);
if (!seg) continue;
const id = `tl-${barIndex}-${side}-${cond.conditionType}-${lookback}-${config.mode}`;
drawings.push({
id,
type: 'trendline',
points: [
{ time: seg.startTimeSec, price: seg.startPrice },
{ time: seg.endTimeSec, price: seg.endPrice },
],
color: side === 'buy' ? '#26a69a' : '#ef5350',
lineWidth: 2,
style: 'dashed',
visible: true,
});
}
return drawings;
}
+15 -2
View File
@@ -418,6 +418,8 @@ const COND_OPTIONS = [
{ v: 'NEQ', l: '다름 (!=)' }, { v: 'NEQ', l: '다름 (!=)' },
{ v: 'CROSS_UP', l: '상향 돌파' }, { v: 'CROSS_UP', l: '상향 돌파' },
{ v: 'CROSS_DOWN', l: '하향 돌파' }, { v: 'CROSS_DOWN', l: '하향 돌파' },
{ v: 'TREND_LINE_CROSS_UP', l: '추세선 상향 돌파' },
{ v: 'TREND_LINE_CROSS_DOWN', l: '추세선 하향 돌파' },
{ v: 'SLOPE_UP', l: '상승 기울기' }, { v: 'SLOPE_UP', l: '상승 기울기' },
{ v: 'SLOPE_DOWN', l: '하락 기울기' }, { v: 'SLOPE_DOWN', l: '하락 기울기' },
{ v: 'DIFF_GT', l: '차이 > 값' }, { v: 'DIFF_GT', l: '차이 > 값' },
@@ -444,6 +446,7 @@ export const getCondOptionsForIndicator = (ind: string): CondTypeOpt[] => {
const COMMON_COND_KEYS = [ const COMMON_COND_KEYS = [
'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN',
'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN',
'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS',
]; ];
@@ -899,6 +902,10 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D
if (!isValid(updated.leftField)) updated.leftField = def.l; if (!isValid(updated.leftField)) updated.leftField = def.l;
updated.rightField = 'NONE'; updated.rightField = 'NONE';
updated.slopePeriod = updated.slopePeriod ?? 3; updated.slopePeriod = updated.slopePeriod ?? 3;
} else if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(newCondType)) {
if (!isValid(updated.leftField)) updated.leftField = def.l;
updated.rightField = 'NONE';
updated.lookbackPeriod = updated.lookbackPeriod ?? 10;
} else if (newCondType === 'HOLD_N_DAYS') { } else if (newCondType === 'HOLD_N_DAYS') {
updated.leftField = 'NONE'; updated.rightField = 'NONE'; updated.leftField = 'NONE'; updated.rightField = 'NONE';
updated.holdDays = updated.holdDays ?? 3; updated.holdDays = updated.holdDays ?? 3;
@@ -1004,6 +1011,11 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥'; const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥';
return `${indName} - 최근 ${n}${L} 최저 ${op} ${R || '침체선'}`; return `${indName} - 최근 ${n}${L} 최저 ${op} ${R || '침체선'}`;
} }
if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(c.conditionType)) {
const n = c.lookbackPeriod ?? 10;
const dir = c.conditionType === 'TREND_LINE_CROSS_UP' ? '상향 돌파' : '하향 돌파';
return `${indName} - 이전 ${n}봉 추세선 ${dir} · ${L}`;
}
const rangeClause = formatCandleRangeClause(c); const rangeClause = formatCandleRangeClause(c);
if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`; if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`;
return `${indName} - ${rangeClause}${L} ${C}`; return `${indName} - ${rangeClause}${L} ${C}`;
@@ -1215,11 +1227,12 @@ export const CondEditor: React.FC<CondEditorProps> = ({
}; };
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType); const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
const isTrendLineType = ['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(normalized.conditionType);
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS'; const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType); const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType) || isTrendLineType;
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType); const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue(); const rightValue = isSlopeType || isTrendLineType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def)); const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
const handleRight = (v: string) => { const handleRight = (v: string) => {
@@ -25,7 +25,7 @@ import {
type StrategyIndicatorRef, type StrategyIndicatorRef,
} from './strategyIndicatorSync'; } from './strategyIndicatorSync';
const DSL_TO_REGISTRY: Record<string, string> = { export const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI', RSI: 'RSI',
MACD: 'MACD', MACD: 'MACD',
MA: 'SMA', MA: 'SMA',
+2 -1
View File
@@ -104,6 +104,7 @@ export const CONDITION_LABEL: Record<string, string> = {
GT: '초과(>)', LT: '미만(<)', GTE: '이상(≥)', LTE: '이하(≤)', GT: '초과(>)', LT: '미만(<)', GTE: '이상(≥)', LTE: '이하(≤)',
EQ: '같음(=)', NEQ: '다름(≠)', EQ: '같음(=)', NEQ: '다름(≠)',
CROSS_UP: '상향 돌파', CROSS_DOWN: '하향 돌파', CROSS_UP: '상향 돌파', CROSS_DOWN: '하향 돌파',
TREND_LINE_CROSS_UP: '추세선 상향 돌파', TREND_LINE_CROSS_DOWN: '추세선 하향 돌파',
SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중', SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중',
DIFF_GT: '차이>값', DIFF_LT: '차이<값', DIFF_GT: '차이>값', DIFF_LT: '차이<값',
HOLD_N_DAYS: 'N일 유지', HOLD_N_DAYS: 'N일 유지',
@@ -118,7 +119,7 @@ export const CONDITION_LABEL: Record<string, string> = {
VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율', VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율',
}; };
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS']; const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE']; const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE'];
const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS]; const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS];
+305
View File
@@ -0,0 +1,305 @@
/**
* N봉 backend TrendLineMath ( ).
* fit [anchorN, anchor1], (UP)= ·(DOWN)= , anchor까지 .
*/
import type { BacktestSettingsDto } from './backendApi';
export type TrendLineMode = 'TWO_POINT' | 'REGRESSION' | 'SWING';
export type TrendLineKind = 'UP' | 'DOWN';
export interface TrendLineConfig {
mode: TrendLineMode;
defaultLookback: number;
swingPrecedingBars: number;
swingFollowingBars: number;
}
export interface TrendLineSegment {
startTimeSec: number;
startPrice: number;
endTimeSec: number;
endPrice: number;
}
interface PivotPair {
indexA: number;
yA: number;
indexB: number;
yB: number;
}
export function trendLineKindForCross(conditionType?: string, side?: 'buy' | 'sell'): TrendLineKind {
if (conditionType === 'TREND_LINE_CROSS_UP') return 'DOWN';
if (conditionType === 'TREND_LINE_CROSS_DOWN') return 'UP';
return side === 'buy' ? 'DOWN' : 'UP';
}
export function isTrendLinePriceScaleField(field?: string): boolean {
if (!field) return true;
const f = field.toUpperCase();
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|| f.includes('BAND') || f.includes('DC_') || f.includes('VWAP')
|| f.includes('CONVERSION') || f.includes('BASE') || f.includes('SPAN')
|| f.includes('LAGGING') || f.includes('CLOUD');
}
export function trendLineConfigFromSettings(
settings?: Pick<
BacktestSettingsDto,
| 'trendLineMode'
| 'trendLineDefaultLookback'
| 'trendLineSwingPrecedingBars'
| 'trendLineSwingFollowingBars'
> | null,
): TrendLineConfig {
let mode = settings?.trendLineMode ?? 'SWING';
if (mode !== 'TWO_POINT' && mode !== 'REGRESSION' && mode !== 'SWING') {
mode = 'SWING';
}
const defaultLookback =
settings?.trendLineDefaultLookback != null && settings.trendLineDefaultLookback > 1
? settings.trendLineDefaultLookback
: 10;
return {
mode,
defaultLookback,
swingPrecedingBars: settings?.trendLineSwingPrecedingBars ?? 1,
swingFollowingBars: settings?.trendLineSwingFollowingBars ?? 1,
};
}
function numAt(values: number[], index: number): number | null {
if (index < 0 || index >= values.length) return null;
const v = values[index];
return Number.isFinite(v) ? v : null;
}
function yAtPivot(
bars: { low: number; high: number }[],
sourceValues: number[],
index: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (priceScale) {
return kind === 'UP' ? bars[index]!.low : bars[index]!.high;
}
return numAt(sourceValues, index) ?? NaN;
}
function isSwingLow(
bars: { low: number; high: number }[],
sourceValues: number[],
i: number,
prec: number,
fol: number,
priceScale: boolean,
): boolean {
const low = yAtPivot(bars, sourceValues, i, 'UP', priceScale);
if (!Number.isFinite(low)) return false;
for (let j = i - prec; j <= i + fol; j++) {
if (j === i) continue;
if (j < 0 || j >= bars.length) return false;
const v = yAtPivot(bars, sourceValues, j, 'UP', priceScale);
if (!Number.isFinite(v) || v < low) return false;
}
return true;
}
function isSwingHigh(
bars: { low: number; high: number }[],
sourceValues: number[],
i: number,
prec: number,
fol: number,
priceScale: boolean,
): boolean {
const high = yAtPivot(bars, sourceValues, i, 'DOWN', priceScale);
if (!Number.isFinite(high)) return false;
for (let j = i - prec; j <= i + fol; j++) {
if (j === i) continue;
if (j < 0 || j >= bars.length) return false;
const v = yAtPivot(bars, sourceValues, j, 'DOWN', priceScale);
if (!Number.isFinite(v) || v > high) return false;
}
return true;
}
function collectSwingIndices(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
kLeft: number,
kRight: number,
kind: TrendLineKind,
priceScale: boolean,
): number[] {
const out: number[] = [];
for (let i = start + kLeft; i <= end - kRight; i++) {
const isSwing = kind === 'UP'
? isSwingLow(bars, sourceValues, i, kLeft, kRight, priceScale)
: isSwingHigh(bars, sourceValues, i, kLeft, kRight, priceScale);
if (isSwing) out.push(i);
}
return out;
}
function resolvePivots(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): PivotPair | null {
const kLeft = Math.max(0, config.swingPrecedingBars);
const kRight = Math.max(0, config.swingFollowingBars);
const swings = collectSwingIndices(bars, sourceValues, start, end, kLeft, kRight, kind, priceScale);
if (swings.length < 2) return null;
const swingA = swings[0]!;
for (let j = 1; j < swings.length; j++) {
const swingB = swings[j]!;
if (swingB <= swingA) continue;
const yA = yAtPivot(bars, sourceValues, swingA, kind, priceScale);
const yB = yAtPivot(bars, sourceValues, swingB, kind, priceScale);
if (!Number.isFinite(yA) || !Number.isFinite(yB)) continue;
if (kind === 'UP' && yB > yA) return { indexA: swingA, yA, indexB: swingB, yB };
if (kind === 'DOWN' && yB < yA) return { indexA: swingA, yA, indexB: swingB, yB };
}
return null;
}
function lineAt(p: PivotPair, evalIndex: number): number {
if (p.indexB === p.indexA) return p.yA;
const t = (evalIndex - p.indexA) / (p.indexB - p.indexA);
return p.yA + (p.yB - p.yA) * t;
}
function twoPointAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
const y1 = yAtPivot(bars, sourceValues, end, kind, priceScale);
if (!Number.isFinite(y0) || !Number.isFinite(y1) || end === start) return NaN;
const t = (evalIndex - start) / (end - start);
return y0 + (y1 - y0) * t;
}
function regressionAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
let count = 0;
for (let i = start; i <= end; i++) {
const y = yAtPivot(bars, sourceValues, i, kind, priceScale);
if (!Number.isFinite(y)) continue;
const x = i - start;
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
count++;
}
if (count < 2) return NaN;
const denom = count * sumXX - sumX * sumX;
if (Math.abs(denom) < 1e-12) return NaN;
const b = (count * sumXY - sumX * sumY) / denom;
const a = (sumY - b * sumX) / count;
return a + b * (evalIndex - start);
}
function fallbackValueAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (config.mode === 'SWING') return NaN;
if (config.mode === 'REGRESSION') {
return regressionAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
}
return twoPointAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
}
export function trendLineValueAtEval(
sourceValues: number[],
bars: { low: number; high: number }[],
anchorIndex: number,
evalIndex: number,
lookback: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (anchorIndex < 1 || lookback < 2) return NaN;
const start = anchorIndex - lookback;
const end = anchorIndex - 1;
if (start < 0 || end < start) return NaN;
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
if (pivots) return lineAt(pivots, evalIndex);
return fallbackValueAt(bars, sourceValues, start, end, evalIndex, config, kind, priceScale);
}
export function trendLineSegmentAt(
barTimes: number[],
sourceValues: number[],
bars: { low: number; high: number }[],
index: number,
lookback: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): TrendLineSegment | null {
if (index < 1 || lookback < 2 || sourceValues.length !== barTimes.length) return null;
const start = index - lookback;
const end = index - 1;
if (start < 0 || end < start) return null;
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
if (pivots) {
const yAt = lineAt(pivots, index);
if (!Number.isFinite(yAt)) return null;
return {
startTimeSec: barTimes[pivots.indexA]!,
startPrice: pivots.yA,
endTimeSec: barTimes[index]!,
endPrice: yAt,
};
}
if (config.mode === 'SWING') return null;
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
const yAt = fallbackValueAt(bars, sourceValues, start, end, index, config, kind, priceScale);
if (!Number.isFinite(y0) || !Number.isFinite(yAt)) return null;
return {
startTimeSec: barTimes[start]!,
startPrice: y0,
endTimeSec: barTimes[index]!,
endPrice: yAt,
};
}
+90
View File
@@ -0,0 +1,90 @@
/**
*
*/
import type { LogicNode } from './strategyTypes';
import {
applyCondTypeDefaults,
genId,
getDefaultConditionFields,
type DefType,
} from './strategyEditorShared';
import { initConditionPeriodsInherit } from './conditionPeriods';
export interface TrendLinePaletteItem {
id: string;
indicatorType: string;
label: string;
desc: string;
leftField?: string;
}
export const TREND_LINE_PALETTE_ITEMS: TrendLinePaletteItem[] = [
{ id: 'tl-close', indicatorType: 'MA', label: '종가', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
{ id: 'tl-ma', indicatorType: 'MA', label: 'MA', desc: '이동평균 vs N봉 추세선', leftField: 'MA20' },
{ id: 'tl-ema', indicatorType: 'EMA', label: 'EMA', desc: '지수이동평균 vs N봉 추세선', leftField: 'EMA20' },
{ id: 'tl-boll', indicatorType: 'BOLLINGER', label: '볼린저', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
{ id: 'tl-rsi', indicatorType: 'RSI', label: 'RSI', desc: 'RSI vs N봉 추세선' },
{ id: 'tl-macd', indicatorType: 'MACD', label: 'MACD', desc: 'MACD선 vs N봉 추세선', leftField: 'MACD_LINE' },
{ id: 'tl-stoch', indicatorType: 'STOCHASTIC', label: 'Stochastic', desc: '%K vs N봉 추세선', leftField: 'K' },
{ id: 'tl-cci', indicatorType: 'CCI', label: 'CCI', desc: 'CCI vs N봉 추세선' },
{ id: 'tl-adx', indicatorType: 'ADX', label: 'ADX', desc: 'ADX vs N봉 추세선' },
{ id: 'tl-obv', indicatorType: 'OBV', label: 'OBV', desc: 'OBV vs N봉 추세선' },
];
export function findTrendLinePaletteItem(id: string): TrendLinePaletteItem | undefined {
return TREND_LINE_PALETTE_ITEMS.find(i => i.id === id);
}
export function filterTrendLinePaletteItems(query: string): TrendLinePaletteItem[] {
const q = query.trim().toLowerCase();
if (!q) return TREND_LINE_PALETTE_ITEMS;
return TREND_LINE_PALETTE_ITEMS.filter(
i => i.label.toLowerCase().includes(q)
|| i.desc.toLowerCase().includes(q)
|| i.indicatorType.toLowerCase().includes(q),
);
}
export function buildTrendLineConditionNode(
item: Pick<TrendLinePaletteItem, 'indicatorType' | 'leftField'>,
signalType: 'buy' | 'sell',
def: DefType,
lookback = 10,
): LogicNode {
const defaults = getDefaultConditionFields(item.indicatorType, signalType, def);
const condType = signalType === 'buy' ? 'TREND_LINE_CROSS_UP' : 'TREND_LINE_CROSS_DOWN';
let condition = applyCondTypeDefaults(
{
indicatorType: item.indicatorType,
conditionType: condType,
leftField: item.leftField ?? defaults.l,
rightField: 'NONE',
lookbackPeriod: Math.max(2, lookback),
candleRange: 1,
valuePeriodOverride: false,
thresholdOverride: false,
rightPeriodOverride: false,
},
condType,
def,
);
condition = initConditionPeriodsInherit(item.indicatorType, condition, def);
return { id: genId(), type: 'CONDITION', condition };
}
export function buildTrendLineConditionNodeFromDrag(data: {
value: string;
indicatorType?: string;
leftField?: string;
lookback?: number;
}, signalType: 'buy' | 'sell', def: DefType): LogicNode | null {
const item = findTrendLinePaletteItem(data.value);
const indicatorType = item?.indicatorType ?? data.indicatorType;
if (!indicatorType) return null;
return buildTrendLineConditionNode(
{ indicatorType, leftField: item?.leftField ?? data.leftField },
signalType,
def,
data.lookback ?? 10,
);
}