추세선 전략추가
This commit is contained in:
@@ -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 구간 [anchor−N, anchor−1] (현재 봉 제외).
|
||||||
|
* 상승 추세선(UP): 스윙 저점 2개(Low, Low₂>Low₁) · 하락 추세선(DOWN): 스윙 고점 2개(High, High₂<High₁).
|
||||||
|
* anchor 봉 i 돌파 판정: 동일 선을 i−1·i 에 extrapolation.
|
||||||
|
* 차트 선분: 피벗 A → anchor(i)까지 연장.
|
||||||
|
*/
|
||||||
|
public final class TrendLineMath {
|
||||||
|
|
||||||
|
private TrendLineMath() {}
|
||||||
|
|
||||||
|
private record PivotPair(int indexA, double yA, int indexB, double yB) {}
|
||||||
|
|
||||||
|
/** anchor 봉 기준 [anchor−N, anchor−1] 추세선을 evalIndex 에서 평가 */
|
||||||
|
public static double valueAtEval(
|
||||||
|
BarSeries series,
|
||||||
|
Indicator<Num> source,
|
||||||
|
int anchorIndex,
|
||||||
|
int evalIndex,
|
||||||
|
int lookback,
|
||||||
|
TrendLineConfig config,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
if (series == null || source == null || config == null || kind == null
|
||||||
|
|| anchorIndex < 1 || lookback < 2) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
int start = anchorIndex - lookback;
|
||||||
|
int end = anchorIndex - 1;
|
||||||
|
if (start < series.getBeginIndex() || end < start) return Double.NaN;
|
||||||
|
|
||||||
|
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||||
|
if (pivots != null) {
|
||||||
|
return lineAt(pivots, evalIndex);
|
||||||
|
}
|
||||||
|
return fallbackValueAt(series, source, start, end, evalIndex, config, kind, priceScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** anchor = index — i 봉 돌파 판정용 */
|
||||||
|
public static double valueAt(
|
||||||
|
BarSeries series,
|
||||||
|
Indicator<Num> source,
|
||||||
|
int index,
|
||||||
|
int lookback,
|
||||||
|
TrendLineConfig config,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
return valueAtEval(series, source, index, index, lookback, config, kind, priceScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 시그널 차트용 — 피벗 기준 fit 후 index(시그널 봉)까지 연장 */
|
||||||
|
public record Segment(long startTimeSec, double startPrice, long endTimeSec, double endPrice) {}
|
||||||
|
|
||||||
|
public static Segment segmentAt(
|
||||||
|
BarSeries series,
|
||||||
|
Indicator<Num> source,
|
||||||
|
int index,
|
||||||
|
int lookback,
|
||||||
|
TrendLineConfig config,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
if (series == null || source == null || config == null || kind == null
|
||||||
|
|| index < 1 || lookback < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int start = index - lookback;
|
||||||
|
int end = index - 1;
|
||||||
|
if (start < series.getBeginIndex() || end < start) return null;
|
||||||
|
|
||||||
|
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||||
|
if (pivots != null) {
|
||||||
|
double yAt = lineAt(pivots, index);
|
||||||
|
if (!Double.isFinite(yAt)) return null;
|
||||||
|
return new Segment(
|
||||||
|
barTimeSec(series, pivots.indexA()), pivots.yA(),
|
||||||
|
barTimeSec(series, index), yAt);
|
||||||
|
}
|
||||||
|
return fallbackSegment(series, source, start, end, index, config, kind, priceScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 피벗 선정 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static PivotPair resolvePivots(
|
||||||
|
BarSeries series,
|
||||||
|
Indicator<Num> source,
|
||||||
|
int start,
|
||||||
|
int end,
|
||||||
|
TrendLineConfig config,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
int kLeft = Math.max(0, config.getSwingPrecedingBars());
|
||||||
|
int kRight = Math.max(0, config.getSwingFollowingBars());
|
||||||
|
List<Integer> swings = collectSwingIndices(series, source, start, end, kLeft, kRight, kind, priceScale);
|
||||||
|
if (swings.size() < 2) return null;
|
||||||
|
|
||||||
|
int swingA = swings.get(0);
|
||||||
|
for (int j = 1; j < swings.size(); j++) {
|
||||||
|
int swingB = swings.get(j);
|
||||||
|
if (swingB <= swingA) continue;
|
||||||
|
double yA = yAtPivot(series, source, swingA, kind, priceScale);
|
||||||
|
double yB = yAtPivot(series, source, swingB, kind, priceScale);
|
||||||
|
if (!Double.isFinite(yA) || !Double.isFinite(yB)) continue;
|
||||||
|
if (kind == TrendLineKind.UP && yB > yA) {
|
||||||
|
return new PivotPair(swingA, yA, swingB, yB);
|
||||||
|
}
|
||||||
|
if (kind == TrendLineKind.DOWN && yB < yA) {
|
||||||
|
return new PivotPair(swingA, yA, swingB, yB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Integer> collectSwingIndices(
|
||||||
|
BarSeries series,
|
||||||
|
Indicator<Num> source,
|
||||||
|
int start,
|
||||||
|
int end,
|
||||||
|
int kLeft,
|
||||||
|
int kRight,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
List<Integer> out = new ArrayList<>();
|
||||||
|
for (int i = start + kLeft; i <= end - kRight; i++) {
|
||||||
|
boolean isSwing = kind == TrendLineKind.UP
|
||||||
|
? isSwingLow(series, source, i, kLeft, kRight, priceScale)
|
||||||
|
: isSwingHigh(series, source, i, kLeft, kRight, priceScale);
|
||||||
|
if (isSwing) out.add(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double yAtPivot(
|
||||||
|
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||||
|
if (priceScale) {
|
||||||
|
return kind == TrendLineKind.UP
|
||||||
|
? series.getBar(index).getLowPrice().doubleValue()
|
||||||
|
: series.getBar(index).getHighPrice().doubleValue();
|
||||||
|
}
|
||||||
|
Double v = numAt(source, index);
|
||||||
|
return v != null ? v : Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSwingLow(
|
||||||
|
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||||
|
double low = priceScale
|
||||||
|
? series.getBar(i).getLowPrice().doubleValue()
|
||||||
|
: requireNum(source, i);
|
||||||
|
if (!Double.isFinite(low)) return false;
|
||||||
|
for (int j = i - prec; j <= i + fol; j++) {
|
||||||
|
if (j == i) continue;
|
||||||
|
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||||
|
double v = priceScale
|
||||||
|
? series.getBar(j).getLowPrice().doubleValue()
|
||||||
|
: requireNum(source, j);
|
||||||
|
if (!Double.isFinite(v) || v < low) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSwingHigh(
|
||||||
|
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||||
|
double high = priceScale
|
||||||
|
? series.getBar(i).getHighPrice().doubleValue()
|
||||||
|
: requireNum(source, i);
|
||||||
|
if (!Double.isFinite(high)) return false;
|
||||||
|
for (int j = i - prec; j <= i + fol; j++) {
|
||||||
|
if (j == i) continue;
|
||||||
|
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||||
|
double v = priceScale
|
||||||
|
? series.getBar(j).getHighPrice().doubleValue()
|
||||||
|
: requireNum(source, j);
|
||||||
|
if (!Double.isFinite(v) || v > high) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double lineAt(PivotPair p, int evalIndex) {
|
||||||
|
if (p.indexB() == p.indexA()) return p.yA();
|
||||||
|
double t = (evalIndex - p.indexA()) / (double) (p.indexB() - p.indexA());
|
||||||
|
return p.yA() + (p.yB() - p.yA()) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 모드별 폴백 (피벗 2개 미충족 시) ─────────────────────────────────────
|
||||||
|
|
||||||
|
private static double fallbackValueAt(
|
||||||
|
BarSeries series,
|
||||||
|
Indicator<Num> source,
|
||||||
|
int start,
|
||||||
|
int end,
|
||||||
|
int evalIndex,
|
||||||
|
TrendLineConfig config,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
if (TrendLineConfig.MODE_REGRESSION.equals(normalizeMode(config.getMode()))) {
|
||||||
|
return regressionAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||||
|
}
|
||||||
|
return twoPointAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Segment fallbackSegment(
|
||||||
|
BarSeries series,
|
||||||
|
Indicator<Num> source,
|
||||||
|
int start,
|
||||||
|
int end,
|
||||||
|
int anchorIndex,
|
||||||
|
TrendLineConfig config,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||||
|
if (!Double.isFinite(y0)) return null;
|
||||||
|
double yAt = fallbackValueAt(series, source, start, end, anchorIndex, config, kind, priceScale);
|
||||||
|
if (!Double.isFinite(yAt)) return null;
|
||||||
|
return new Segment(barTimeSec(series, start), y0, barTimeSec(series, anchorIndex), yAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double yAtEndpoint(
|
||||||
|
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||||
|
return yAtPivot(series, source, index, kind, priceScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double twoPointAt(
|
||||||
|
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||||
|
TrendLineKind kind, boolean priceScale) {
|
||||||
|
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||||
|
double y1 = yAtEndpoint(series, source, end, kind, priceScale);
|
||||||
|
if (!Double.isFinite(y0) || !Double.isFinite(y1) || end == start) return Double.NaN;
|
||||||
|
double t = (evalIndex - start) / (double) (end - start);
|
||||||
|
return y0 + (y1 - y0) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double regressionAt(
|
||||||
|
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||||
|
TrendLineKind kind, boolean priceScale) {
|
||||||
|
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
||||||
|
int count = 0;
|
||||||
|
for (int i = start; i <= end; i++) {
|
||||||
|
double y = yAtEndpoint(series, source, i, kind, priceScale);
|
||||||
|
if (!Double.isFinite(y)) continue;
|
||||||
|
double x = i - start;
|
||||||
|
sumX += x;
|
||||||
|
sumY += y;
|
||||||
|
sumXY += x * y;
|
||||||
|
sumXX += x * x;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count < 2) return Double.NaN;
|
||||||
|
double denom = count * sumXX - sumX * sumX;
|
||||||
|
if (Math.abs(denom) < 1e-12) return Double.NaN;
|
||||||
|
double b = (count * sumXY - sumX * sumY) / denom;
|
||||||
|
double a = (sumY - b * sumX) / count;
|
||||||
|
return a + b * (evalIndex - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeMode(String mode) {
|
||||||
|
if (TrendLineConfig.MODE_REGRESSION.equals(mode)) return TrendLineConfig.MODE_REGRESSION;
|
||||||
|
if (TrendLineConfig.MODE_SWING.equals(mode)) return TrendLineConfig.MODE_SWING;
|
||||||
|
return TrendLineConfig.MODE_TWO_POINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double requireNum(Indicator<Num> source, int index) {
|
||||||
|
Double v = numAt(source, index);
|
||||||
|
return v != null ? v : Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double numAt(Indicator<Num> source, int index) {
|
||||||
|
if (index < 0) return null;
|
||||||
|
Num n = source.getValue(index);
|
||||||
|
if (n == null) return null;
|
||||||
|
double v = n.doubleValue();
|
||||||
|
return Double.isFinite(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long barTimeSec(BarSeries series, int index) {
|
||||||
|
return series.getBar(index).getEndTime().getEpochSecond();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.goldenchart.dto.TrendLineConfig;
|
||||||
|
import com.goldenchart.dto.TrendLineKind;
|
||||||
|
import org.ta4j.core.BarSeries;
|
||||||
|
import org.ta4j.core.Indicator;
|
||||||
|
import org.ta4j.core.indicators.AbstractIndicator;
|
||||||
|
import org.ta4j.core.num.Num;
|
||||||
|
|
||||||
|
import static org.ta4j.core.num.NaN.NaN;
|
||||||
|
|
||||||
|
/** bar index 에서 N봉 추세선 Y값 — left 지표·종가와 CROSS 비교용 */
|
||||||
|
public class TrendLineValueIndicator extends AbstractIndicator<Num> {
|
||||||
|
|
||||||
|
private final Indicator<Num> source;
|
||||||
|
private final BarSeries series;
|
||||||
|
private final int lookback;
|
||||||
|
private final TrendLineConfig config;
|
||||||
|
private final TrendLineKind kind;
|
||||||
|
private final boolean priceScale;
|
||||||
|
|
||||||
|
public TrendLineValueIndicator(
|
||||||
|
Indicator<Num> source,
|
||||||
|
BarSeries series,
|
||||||
|
int lookback,
|
||||||
|
TrendLineConfig config,
|
||||||
|
TrendLineKind kind,
|
||||||
|
boolean priceScale) {
|
||||||
|
super(series);
|
||||||
|
this.source = source;
|
||||||
|
this.series = series;
|
||||||
|
this.lookback = Math.max(2, lookback);
|
||||||
|
this.config = config != null ? config : TrendLineConfig.defaults();
|
||||||
|
this.kind = kind != null ? kind : TrendLineKind.DOWN;
|
||||||
|
this.priceScale = priceScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Num getValue(int index) {
|
||||||
|
double v = TrendLineMath.valueAt(series, source, index, lookback, config, kind, priceScale);
|
||||||
|
if (!Double.isFinite(v)) {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
return series.numFactory().numOf(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getCountOfUnstableBars() {
|
||||||
|
return lookback + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
"i−1·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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
[i−N, i−1] 구간에서 피벗을 찾고 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}
|
||||||
|
|||||||
+30
-4
@@ -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,
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* N봉 추세선 — backend TrendLineMath 와 동기 (스윙 피벗).
|
||||||
|
* fit [anchor−N, anchor−1], 상승(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user