diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java index c723040..8d6c334 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java @@ -114,4 +114,21 @@ public class BacktestSettingsDto { */ @Builder.Default private String tradeExecutionMode = "BACKTEST_ENGINE"; + + // ── 추세선 조건 (TREND_LINE_CROSS_*) ───────────────────────────────────── + /** TWO_POINT | REGRESSION | SWING */ + @Builder.Default + private String trendLineMode = "TWO_POINT"; + + /** 조건에 lookback 미지정 시 기본 N봉 */ + @Builder.Default + private Integer trendLineDefaultLookback = 10; + + /** SWING 모드 — 스윙 저점 좌측 확인 봉 수 */ + @Builder.Default + private Integer trendLineSwingPrecedingBars = 1; + + /** SWING 모드 — 스윙 저점 우측 확인 봉 수 */ + @Builder.Default + private Integer trendLineSwingFollowingBars = 1; } diff --git a/backend/src/main/java/com/goldenchart/dto/TrendLineConfig.java b/backend/src/main/java/com/goldenchart/dto/TrendLineConfig.java new file mode 100644 index 0000000..ca6f4ef --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/TrendLineConfig.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/TrendLineKind.java b/backend/src/main/java/com/goldenchart/dto/TrendLineKind.java new file mode 100644 index 0000000..39df327 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/TrendLineKind.java @@ -0,0 +1,23 @@ +package com.goldenchart.dto; + +/** + * 추세선 종류 — 돌파 방향과 짝을 이룸. + * + */ +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; + } +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java index 3779cf8..47e99b3 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java @@ -132,6 +132,23 @@ public class GcBacktestSettings { @Builder.Default private String tradeExecutionMode = "BACKTEST_ENGINE"; + /** TWO_POINT | REGRESSION | SWING */ + @Column(name = "trend_line_mode", nullable = false, length = 20) + @Builder.Default + private String trendLineMode = "TWO_POINT"; + + @Column(name = "trend_line_default_lookback", nullable = false) + @Builder.Default + private Integer trendLineDefaultLookback = 10; + + @Column(name = "trend_line_swing_preceding_bars", nullable = false) + @Builder.Default + private Integer trendLineSwingPrecedingBars = 1; + + @Column(name = "trend_line_swing_following_bars", nullable = false) + @Builder.Default + private Integer trendLineSwingFollowingBars = 1; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java index 2774c29..becec93 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java @@ -125,6 +125,21 @@ public class BacktestSettingsService { "SCAN_SIGNALS".equals(dto.getTradeExecutionMode()) ? "SCAN_SIGNALS" : "BACKTEST_ENGINE"); + entity.setTrendLineMode(normalizeTrendLineMode(dto.getTrendLineMode())); + entity.setTrendLineDefaultLookback( + dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1 + ? dto.getTrendLineDefaultLookback() : 10); + entity.setTrendLineSwingPrecedingBars( + dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0 + ? dto.getTrendLineSwingPrecedingBars() : 1); + entity.setTrendLineSwingFollowingBars( + dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0 + ? dto.getTrendLineSwingFollowingBars() : 1); + } + + private static String normalizeTrendLineMode(String mode) { + if ("REGRESSION".equals(mode) || "SWING".equals(mode)) return mode; + return "TWO_POINT"; } // ── 변환 헬퍼 ───────────────────────────────────────────────────────────── @@ -157,6 +172,10 @@ public class BacktestSettingsService { "SCAN_SIGNALS".equals(e.getTradeExecutionMode()) ? "SCAN_SIGNALS" : "BACKTEST_ENGINE") + .trendLineMode(normalizeTrendLineMode(e.getTrendLineMode())) + .trendLineDefaultLookback(e.getTrendLineDefaultLookback() != null ? e.getTrendLineDefaultLookback() : 10) + .trendLineSwingPrecedingBars(e.getTrendLineSwingPrecedingBars() != null ? e.getTrendLineSwingPrecedingBars() : 1) + .trendLineSwingFollowingBars(e.getTrendLineSwingFollowingBars() != null ? e.getTrendLineSwingFollowingBars() : 1) .build(); } } diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index f40bdd3..1cd8cf6 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -132,9 +132,11 @@ public class BacktestingService { series, primaryTf, buyDsl, sellDsl); String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null; + com.goldenchart.dto.TrendLineConfig trendLineConfig = + com.goldenchart.dto.TrendLineConfig.fromSettings(cfg); StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = new StrategyDslToTa4jAdapter.RuleBuildContext( - series, params, visual, market, null, false, seriesOverrides); + series, params, visual, market, null, false, seriesOverrides, trendLineConfig); Rule entryRule = adapter.toRule(buyDsl, ruleCtx); Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) ? adapter.toRule(sellDsl, ruleCtx) diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 022234f..5f6405f 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -4,9 +4,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.goldenchart.dto.BacktestResponse; +import com.goldenchart.dto.BacktestSettingsDto; import com.goldenchart.dto.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.OhlcvBar; +import com.goldenchart.dto.TrendLineConfig; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.storage.Ta4jStorage; @@ -38,6 +40,8 @@ public class LiveConditionStatusService { Map.entry("NEQ", "다름(≠)"), Map.entry("CROSS_UP", "상향 돌파"), Map.entry("CROSS_DOWN", "하향 돌파"), + Map.entry("TREND_LINE_CROSS_UP", "추세선 상향 돌파"), + Map.entry("TREND_LINE_CROSS_DOWN", "추세선 하향 돌파"), Map.entry("SLOPE_UP", "상승 중"), Map.entry("LOWEST_LTE", "N봉 최저 ≤"), Map.entry("LOWEST_GTE", "N봉 최저 ≥"), @@ -53,6 +57,27 @@ public class LiveConditionStatusService { private final ObjectMapper objectMapper; private final DynamicSubscriptionManager subscriptionManager; private final StrategyDslTimeframeNormalizer timeframeNormalizer; + private final BacktestSettingsService backtestSettingsService; + + private TrendLineConfig trendLineConfigFor(Long userId, String deviceId) { + BacktestSettingsDto settings = backtestSettingsService.get(userId, deviceId); + return TrendLineConfig.fromSettings(settings); + } + + private StrategyDslToTa4jAdapter.RuleBuildContext buildRuleCtx( + BarSeries series, + Map> params, + Map> visual, + String market, + Ta4jStorage storage, + boolean useConfirmedOnly, + Map seriesOverrides, + Long userId, + String deviceId) { + return new StrategyDslToTa4jAdapter.RuleBuildContext( + series, params, visual, market, storage, useConfirmedOnly, seriesOverrides, + trendLineConfigFor(userId, deviceId)); + } @Transactional(readOnly = true) public LiveConditionStatusDto evaluate(Long userId, String deviceId, @@ -104,7 +129,7 @@ public class LiveConditionStatusService { } if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) { - return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe, + return evaluateOnChartBars(userId, deviceId, strategy, market, strategyId, chartBars, chartTimeframe, barTimeSec, params, visual); } @@ -142,8 +167,8 @@ public class LiveConditionStatusService { wrapper.set("condition", p.condition()); StrategyDslToTa4jAdapter.RuleBuildContext ctx = - new StrategyDslToTa4jAdapter.RuleBuildContext( - series, params, visual, market, ta4jStorage, false, java.util.Map.of()); + buildRuleCtx(series, params, visual, market, ta4jStorage, false, + java.util.Map.of(), userId, deviceId); Rule rule = adapter.toRule(wrapper, ctx); boolean satisfied = rule.isSatisfied(index, null); Double current = adapter.readConditionFieldValue( @@ -167,9 +192,9 @@ public class LiveConditionStatusService { // [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영 Boolean overallEntryMet = evaluateOverallRule( - strategy.getBuyConditionJson(), market, params, visual, barTimeSec); + strategy.getBuyConditionJson(), market, params, visual, barTimeSec, userId, deviceId); Boolean overallExitMet = evaluateOverallRule( - strategy.getSellConditionJson(), market, params, visual, barTimeSec); + strategy.getSellConditionJson(), market, params, visual, barTimeSec, userId, deviceId); return LiveConditionStatusDto.builder() .market(market) @@ -267,6 +292,8 @@ public class LiveConditionStatusService { * 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지. */ private LiveConditionStatusDto evaluateOnChartBars( + Long userId, + String deviceId, GcStrategy strategy, String market, long strategyId, @@ -314,8 +341,8 @@ public class LiveConditionStatusService { wrapper.set("condition", p.condition()); StrategyDslToTa4jAdapter.RuleBuildContext ctx = - new StrategyDslToTa4jAdapter.RuleBuildContext( - series, params, visual, market, null, false, seriesOverrides); + buildRuleCtx(series, params, visual, market, null, false, + seriesOverrides, userId, deviceId); Rule rule = adapter.toRule(wrapper, ctx); boolean satisfied = rule.isSatisfied(index, null); Double current = adapter.readConditionFieldValue( @@ -336,9 +363,11 @@ public class LiveConditionStatusService { int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0; Boolean overallEntryMet = evaluateOverallRuleOnChart( - buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); + buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec, + userId, deviceId); Boolean overallExitMet = evaluateOverallRuleOnChart( - sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); + sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec, + userId, deviceId); return LiveConditionStatusDto.builder() .market(market) @@ -392,14 +421,16 @@ public class LiveConditionStatusService { String market, Map> params, Map> visual, - Long barTimeSec) { + Long barTimeSec, + Long userId, + String deviceId) { if (dsl == null || dsl.isNull()) return null; try { if (primarySeries == null || primarySeries.isEmpty()) return null; StrategyDslToTa4jAdapter.RuleBuildContext ctx = - new StrategyDslToTa4jAdapter.RuleBuildContext( - primarySeries, params, visual, market, null, false, seriesOverrides); + buildRuleCtx(primarySeries, params, visual, market, null, false, + seriesOverrides, userId, deviceId); Rule rule = adapter.toRule(dsl, ctx); int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); return rule.isSatisfied(index, null); @@ -441,7 +472,9 @@ public class LiveConditionStatusService { private Boolean evaluateOverallRule(String conditionJson, String market, Map> params, Map> visual, - Long barTimeSec) { + Long barTimeSec, + Long userId, + String deviceId) { if (conditionJson == null || conditionJson.isBlank()) return null; try { String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper); @@ -461,8 +494,8 @@ public class LiveConditionStatusService { if (series.isEmpty()) return null; StrategyDslToTa4jAdapter.RuleBuildContext ctx = - new StrategyDslToTa4jAdapter.RuleBuildContext( - series, params, visual, market, ta4jStorage, false, java.util.Map.of()); + buildRuleCtx(series, params, visual, market, ta4jStorage, false, + java.util.Map.of(), userId, deviceId); org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx); return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null); } catch (Exception e) { @@ -798,8 +831,8 @@ public class LiveConditionStatusService { int startIdx = resolveChartScanStartIndex(primarySeries, evalCount); StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = - new StrategyDslToTa4jAdapter.RuleBuildContext( - primarySeries, params, visual, market, null, false, seriesOverrides); + buildRuleCtx(primarySeries, params, visual, market, null, false, + seriesOverrides, userId, deviceId); Rule entryRule = (buyDsl != null && !buyDsl.isNull()) ? adapter.toRule(buyDsl, ruleCtx) : new org.ta4j.core.rules.BooleanRule(false); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index d692f99..9a64e3a 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -1,6 +1,8 @@ package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; +import com.goldenchart.dto.TrendLineConfig; +import com.goldenchart.dto.TrendLineKind; import com.goldenchart.storage.Ta4jStorage; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -122,20 +124,37 @@ public class StrategyDslToTa4jAdapter { * null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며 * CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다. */ - Map seriesOverrides + Map seriesOverrides, + /** 추세선 조건(TREND_LINE_CROSS_*) — 전략설정 gc_backtest_settings */ + TrendLineConfig trendLineConfig ) { /** visual 미전달 호환 (레거시) */ public RuleBuildContext(BarSeries primarySeries, Map> indicatorParams, String market, Ta4jStorage storage) { - this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of()); + this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of(), null); + } + + public RuleBuildContext(BarSeries primarySeries, + Map> indicatorParams, + Map> indicatorVisual, + String market, + Ta4jStorage storage, + boolean useConfirmedOnly, + Map seriesOverrides) { + this(primarySeries, indicatorParams, indicatorVisual, market, storage, + useConfirmedOnly, seriesOverrides, null); + } + + public TrendLineConfig effectiveTrendLineConfig() { + return trendLineConfig != null ? trendLineConfig : TrendLineConfig.defaults(); } /** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */ public RuleBuildContext withPrimary(BarSeries newPrimary) { return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual, - market, storage, useConfirmedOnly, seriesOverrides); + market, storage, useConfirmedOnly, seriesOverrides, trendLineConfig); } /** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */ @@ -170,20 +189,20 @@ public class StrategyDslToTa4jAdapter { public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams) { - return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of())); + return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of(), null)); } public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams, String market, Ta4jStorage storage) { - return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of())); + return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of(), null)); } public Rule toRule(JsonNode node, BarSeries series, Map> indicatorParams, Map> indicatorVisual, String market, Ta4jStorage storage) { - return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of())); + return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of(), null)); } /** @@ -194,7 +213,7 @@ public class StrategyDslToTa4jAdapter { Map> indicatorParams, Map seriesOverrides) { return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, - seriesOverrides != null ? seriesOverrides : Map.of())); + seriesOverrides != null ? seriesOverrides : Map.of(), null)); } /** @@ -204,7 +223,7 @@ public class StrategyDslToTa4jAdapter { Map> indicatorParams, Map> indicatorVisual, String market, Ta4jStorage storage) { - return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of())); + return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of(), null)); } public Rule toRule(JsonNode node, RuleBuildContext ctx) { @@ -508,6 +527,15 @@ public class StrategyDslToTa4jAdapter { // 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외) case "CROSS_UP" -> buildCrossUpRule(left, right); case "CROSS_DOWN" -> buildCrossDownRule(left, right); + case "TREND_LINE_CROSS_UP", "TREND_LINE_CROSS_DOWN" -> { + int lb = cond.path("lookbackPeriod").asInt(0); + if (lb <= 0) lb = ctx.effectiveTrendLineConfig().getDefaultLookback(); + lb = Math.max(2, lb); + boolean priceScale = isTrendLinePriceScaleField(leftField); + yield "TREND_LINE_CROSS_UP".equals(condType) + ? buildTrendLineCrossUpRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale) + : buildTrendLineCrossDownRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale); + } case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod); case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod); case "DIFF_GT" -> { @@ -1657,6 +1685,66 @@ public class StrategyDslToTa4jAdapter { }; } + /** + * N봉 추세선 돌파 — [i−N, i−1] 구간으로 고정된 동일 선을 i−1·i 에서 평가. + * (일반 CROSS 와 달리 추세선 창이 봉마다 밀리지 않음) + */ + private Rule buildTrendLineCrossUpRule( + Indicator 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 left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) { + return new Rule() { + @Override + public boolean isSatisfied(int index, TradingRecord tradingRecord) { + if (index < 1) return false; + TrendLineKind kind = TrendLineKind.forCrossDown(); + double linePrev = TrendLineMath.valueAtEval( + series, left, index, index - 1, lookback, config, kind, priceScale); + double lineCurr = TrendLineMath.valueAtEval( + series, left, index, index, lookback, config, kind, priceScale); + if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false; + Num l0 = left.getValue(index - 1); + Num l1 = left.getValue(index); + if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false; + Num r0 = series.numFactory().numOf(linePrev); + Num r1 = series.numFactory().numOf(lineCurr); + return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1); + } + }; + } + + /** 추세선 — 가격축(OHLC·MA 등) vs 오실레이터 */ + private static boolean isTrendLinePriceScaleField(String field) { + if (field == null || field.isBlank() || "NONE".equalsIgnoreCase(field)) return true; + String f = field.toUpperCase(); + return f.contains("CLOSE") || f.contains("PRICE") || f.contains("HIGH") || f.contains("LOW") + || f.contains("OPEN") || f.contains("MA") || f.contains("EMA") + || f.contains("UPPER") || f.contains("MIDDLE") || f.contains("LOWER") + || f.contains("BAND") || f.contains("DC_") || f.contains("VWAP") + || f.contains("CONVERSION") || f.contains("BASE") || f.contains("SPAN") + || f.contains("LAGGING") || f.contains("CLOUD"); + } + /** * CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다. * sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다. diff --git a/backend/src/main/java/com/goldenchart/service/TrendLineMath.java b/backend/src/main/java/com/goldenchart/service/TrendLineMath.java new file mode 100644 index 0000000..88b631b --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendLineMath.java @@ -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) 기준. + *

+ * 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 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 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 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 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 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 collectSwingIndices( + BarSeries series, + Indicator source, + int start, + int end, + int kLeft, + int kRight, + TrendLineKind kind, + boolean priceScale) { + List 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 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 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 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 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 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 source, int index, TrendLineKind kind, boolean priceScale) { + return yAtPivot(series, source, index, kind, priceScale); + } + + private static double twoPointAt( + BarSeries series, Indicator 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 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 source, int index) { + Double v = numAt(source, index); + return v != null ? v : Double.NaN; + } + + private static Double numAt(Indicator 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(); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/TrendLineValueIndicator.java b/backend/src/main/java/com/goldenchart/service/TrendLineValueIndicator.java new file mode 100644 index 0000000..7ae54d2 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/TrendLineValueIndicator.java @@ -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 { + + private final Indicator source; + private final BarSeries series; + private final int lookback; + private final TrendLineConfig config; + private final TrendLineKind kind; + private final boolean priceScale; + + public TrendLineValueIndicator( + Indicator 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; + } +} diff --git a/backend/src/main/resources/db/migration/V71__backtest_trend_line_settings.sql b/backend/src/main/resources/db/migration/V71__backtest_trend_line_settings.sql new file mode 100644 index 0000000..876ef42 --- /dev/null +++ b/backend/src/main/resources/db/migration/V71__backtest_trend_line_settings.sql @@ -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; diff --git a/backend/src/test/java/com/goldenchart/service/TrendLineCrossRuleTest.java b/backend/src/test/java/com/goldenchart/service/TrendLineCrossRuleTest.java new file mode 100644 index 0000000..85b5583 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/TrendLineCrossRuleTest.java @@ -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; + } +} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 1d381c2..cc6025f 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -6,11 +6,15 @@ import React, { useState, useEffect, useCallback } from 'react'; import type { Theme } from '../types'; import { loadBacktestSettings, + saveBacktestSettings, saveLiveStrategySettings, + type BacktestSettingsDto, type LiveStrategySettingsDto, + DEFAULT_BACKTEST_SETTINGS, } from '../utils/backendApi'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import { BacktestSettingsPanel } from './BacktestSettingsPanel'; +import TrendLineSettingsFields from './TrendLineSettingsFields'; import IndicatorMainDefaultsPanel, { type IndicatorSaveUiState, } from './IndicatorMainDefaultsPanel'; @@ -1366,15 +1370,32 @@ const StrategyPanel: React.FC = ({ ); const [liveSaving, setLiveSaving] = useState(false); const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY'); + const [trendLineCfg, setTrendLineCfg] = useState(DEFAULT_BACKTEST_SETTINGS); + const [trendLineSaving, setTrendLineSaving] = useState(false); useEffect(() => { void loadBacktestSettings().then(s => { - if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') { - setBacktestPositionMode(s.positionMode); + if (s) { + setTrendLineCfg(s); + if (s.positionMode === 'SIGNAL_ONLY' || s.positionMode === 'LONG_ONLY') { + setBacktestPositionMode(s.positionMode); + } } }); }, []); + const persistTrendLine = useCallback(async (patch: Partial) => { + const next = { ...trendLineCfg, ...patch }; + setTrendLineCfg(next); + setTrendLineSaving(true); + try { + const saved = await saveBacktestSettings(next); + if (saved) setTrendLineCfg(saved); + } finally { + setTrendLineSaving(false); + } + }, [trendLineCfg]); + useEffect(() => { if (liveSettingsProp) setLiveSettings(liveSettingsProp); }, [liveSettingsProp]); @@ -1582,6 +1603,19 @@ const StrategyPanel: React.FC = ({ )} + {/* ── 추세선 설정 ──────────────────────────────────────────────────── */} + +

+ 추세선 돌파 조건에서 이전 N봉 구간 추세선을 계산하는 방식입니다. + 2점·회귀·스윙 중 하나로 고정되며, 시그널 발생 봉마다 해당 시점의 선으로 평가합니다. + {trendLineSaving && 저장 중…} +

+ { void persistTrendLine(patch); }} + /> + + {/* ── 리스크 관리 ──────────────────────────────────────────────────── */} diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 5ce6433..603cc00 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -48,6 +48,8 @@ import { import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab'; import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab'; +import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab'; +import { buildTrendLineConditionNode } from '../utils/trendLinePalette'; import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage'; import { BAND_PALETTE_CHIP_ITEMS, @@ -319,7 +321,7 @@ export default function StrategyEditorPage({ const [sellCondition, setSellCondition] = useState(null); const [signalTab, setSignalTab] = useState('buy'); 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(() => loadPaletteItems('auxiliary')); const [compositePalette, setCompositePalette] = useState(() => loadPaletteItems('composite')); const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); @@ -1436,6 +1438,15 @@ export default function StrategyEditorPage({ scheduleStrategyPersist(); }, [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( () => buildStrategyTemplatePaletteRows({ def: DEF, @@ -2487,6 +2498,16 @@ export default function StrategyEditorPage({ > 횡보필터 + {indicatorSubTab === 'auxiliary' ? ( - ) : ( + ) : indicatorSubTab === 'range' ? ( + ) : ( + setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)} + onAddToCanvas={(item, lookback) => { + selectPalette('trendline', item.id); + applyTrendLine(item, lookback); + }} + /> )} )} diff --git a/frontend/src/components/TrendLineSettingsFields.tsx b/frontend/src/components/TrendLineSettingsFields.tsx new file mode 100644 index 0000000..82e2e3b --- /dev/null +++ b/frontend/src/components/TrendLineSettingsFields.tsx @@ -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) => void; +} + +export const TrendLineSettingsFields: React.FC = ({ cfg, onChange }) => { + const mode = cfg.trendLineMode ?? 'SWING'; + + return ( + <> +
+
+ 추세선 정의 + + 매수(상향 돌파)=하락 저항선(스윙 고점↓) · 매도(하향 돌파)=상승 지지선(스윙 저점↑). + 가격축은 Low/High, 오실레이터는 지표값 기준 스윙을 사용합니다. + +
+ +
+
+
+ 기본 lookback (봉) + + [i−N, i−1] 구간에서 피벗을 찾고 i 봉까지 연장합니다. 단기 30~50·중기 100~120 권장. + +
+ onChange({ trendLineDefaultLookback: Number(e.target.value) })} + /> +
+
+
+ 스윙 — 좌측 k봉 + + 피벗 판정: 해당 봉 Low/High가 좌·우 각 k봉보다 극단적일 때 스윙으로 인정 + +
+ onChange({ trendLineSwingPrecedingBars: Number(e.target.value) })} + /> +
+
+
+ 스윙 — 우측 k봉 + 피벗 판정 우측 비교 봉 수 (k) +
+ onChange({ trendLineSwingFollowingBars: Number(e.target.value) })} + /> +
+ + ); +}; + +export default TrendLineSettingsFields; diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index f42076a..fb07e5e 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -31,6 +31,11 @@ interface Props { stableStrategy?: boolean; ichimokuSituation?: boolean; secondaryIndicator?: string; + /** 추세선 돌파 — 드래그 payload type: trendLine */ + trendLine?: boolean; + trendLineIndicatorType?: string; + trendLineLeftField?: string; + trendLineLookback?: number; selected?: boolean; onSelect?: () => void; onAdd: () => void; @@ -38,10 +43,23 @@ interface Props { export default function PaletteChip({ 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) { - const buildPayload = (): PaletteDragPayload => ({ - type, value, label, + const buildPayload = (): PaletteDragPayload => { + if (trendLine) { + return { + type: 'trendLine', + value, + label, + indicatorType: trendLineIndicatorType, + leftField: trendLineLeftField, + lookback: trendLineLookback, + }; + } + return { + type, value, label, composite: composite || undefined, stochPair: stochPair || undefined, priceExtremeBreakout: priceExtremeBreakout || undefined, @@ -53,7 +71,8 @@ export default function PaletteChip({ period: periodValue, shortPeriod, longPeriod, - }); + }; + }; const pointerMode = needsPointerPaletteDrag(); diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 5245aa3..b435db1 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -31,6 +31,7 @@ import { type DefType, } from '../../utils/strategyEditorShared'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; +import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette'; import { isPaletteHtmlDrag, isPaletteHtmlDragActive, @@ -608,11 +609,14 @@ function StrategyEditorCanvasInner({ }, [setNodes]); 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 => { if (data.type === 'sidewaysFilter') { return buildSidewaysFilterNode(data.value, def); } + if (data.type === 'trendLine') { + return buildTrendLineConditionNodeFromDrag(data, signalTab, def); + } return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); }, [signalTab, def]); diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index 1d683c4..b3538df 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -36,6 +36,7 @@ import LogicGateOpToggle from './LogicGateOpToggle'; import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes'; import { START_NODE_ID } from '../../utils/strategyFlowLayout'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; +import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette'; import { clearPaletteDropHighlights, findPaletteDropKeyAtPoint, @@ -403,11 +404,14 @@ export default function StrategyListEditor({ }, [onEditorStateChange, onOrphansChange, orphans]); 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') { return buildSidewaysFilterNode(data.value, def); } + if (data.type === 'trendLine') { + return buildTrendLineConditionNodeFromDrag(data, signalTab, def); + } return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); }, [signalTab, def]); diff --git a/frontend/src/components/strategyEditor/TrendLinePaletteTab.tsx b/frontend/src/components/strategyEditor/TrendLinePaletteTab.tsx new file mode 100644 index 0000000..b8106f4 --- /dev/null +++ b/frontend/src/components/strategyEditor/TrendLinePaletteTab.tsx @@ -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 ( +
+
+ 추세선 돌파 + + {signalType === 'buy' ? '매수: 상향 돌파' : '매도: 하향 돌파'} + +
+ +
+ {items.map(item => ( + onSelectItem(item.id)} + onAdd={() => onAddToCanvas(item, defaultLookback)} + /> + ))} +
+ + {items.length === 0 && ( +

검색 결과가 없습니다.

+ )} +
+ ); +} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index e9a8ce2..d840c0a 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import TradingChart from '../TradingChart'; import ChartCustomOverlaySettingsModal from '../ChartCustomOverlaySettingsModal'; 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 { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useAppSettings } from '../../hooks/useAppSettings'; @@ -14,8 +14,10 @@ import { import { getIndicatorDef } from '../../utils/indicatorRegistry'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; 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 { buildSignalTrendLineDrawings } from '../../utils/buildSignalTrendLineDrawings'; +import { setIndicatorChartContext } from '../../utils/indicatorRegistry'; import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker'; import { BACKTEST_DISPLAY_BAR_COUNT, @@ -197,6 +199,8 @@ const StrategyEvaluationChart: React.FC = ({ const [marketSortDir, setMarketSortDir] = useState('desc'); const [marketFavs, setMarketFavs] = useState>(() => new Set(getFavorites())); const [signals, setSignals] = useState([]); + const [evalSettings, setEvalSettings] = useState(null); + const [signalDrawings, setSignalDrawings] = useState([]); const [backtestRunning, setBacktestRunning] = useState(false); const [backtestError, setBacktestError] = useState(null); const [magnifierActive, setMagnifierActive] = useState(false); @@ -415,7 +419,7 @@ const StrategyEvaluationChart: React.FC = ({ setBacktestRunning(true); setBacktestError(null); try { - const { signals: nextSignals } = await fetchStrategyEvaluationSignals({ + const result = await fetchStrategyEvaluationSignals({ strategyId: strategy.id, strategy, market, @@ -426,7 +430,8 @@ const StrategyEvaluationChart: React.FC = ({ useDraftDsl, }); if (gen !== backtestGenRef.current) return; - setSignals(nextSignals); + setEvalSettings(result.settings); + setSignals(result.signals); applyMarkers(); } catch (e) { if (gen !== backtestGenRef.current) return; @@ -461,6 +466,29 @@ const StrategyEvaluationChart: React.FC = ({ onSignalsChange?.(signals); }, [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(() => { onBacktestRunningChange?.(backtestRunning); }, [backtestRunning, onBacktestRunningChange]); @@ -972,7 +1000,7 @@ const StrategyEvaluationChart: React.FC = ({ mode="chart" indicators={indicators} drawingTool="cursor" - drawings={[]} + drawings={signalDrawings} logScale={false} onCrosshair={() => {}} onManagerReady={onManagerReady} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationIndicatorPalettePanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationIndicatorPalettePanel.tsx index fcddcae..d5e3c55 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationIndicatorPalettePanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationIndicatorPalettePanel.tsx @@ -5,6 +5,7 @@ import React, { useMemo, useState } from 'react'; import PaletteChip from '../strategyEditor/PaletteChip'; import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab'; import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab'; +import TrendLinePaletteTab from '../strategyEditor/TrendLinePaletteTab'; import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout'; import { BAND_PALETTE_CHIP_ITEMS, @@ -17,7 +18,7 @@ import type { useStrategyEvaluationEditor } from '../../hooks/useStrategyEvaluat type EditorApi = Pick< ReturnType, - 'def' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' + 'def' | 'signalTab' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' | 'applyTrendLine' >; interface Props { @@ -35,10 +36,10 @@ function paletteKey(type: string, id: string) { } const StrategyEvaluationIndicatorPalettePanel: React.FC = ({ editor }) => { - const { def, applyPalette, applyPaletteItem, applySidewaysFilter } = editor; + const { def, signalTab, applyPalette, applyPaletteItem, applySidewaysFilter, applyTrendLine } = editor; 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(() => loadPaletteItems('auxiliary')); const [compositePalette, setCompositePalette] = useState(() => loadPaletteItems('composite')); const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); @@ -141,6 +142,16 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC = ({ editor }) => > 횡보필터 + {indicatorSubTab === 'auxiliary' ? ( = ({ editor }) => applyPaletteItem(item); }} /> - ) : ( + ) : indicatorSubTab === 'range' ? ( = ({ editor }) => applySidewaysFilter(item.id); }} /> + ) : ( + setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)} + onAddToCanvas={(item, lookback) => { + selectPalette('trendline', item.id); + applyTrendLine(item, lookback); + }} + /> )} ); diff --git a/frontend/src/hooks/useStrategyEvaluationEditor.ts b/frontend/src/hooks/useStrategyEvaluationEditor.ts index 9a6c4b0..d207e9b 100644 --- a/frontend/src/hooks/useStrategyEvaluationEditor.ts +++ b/frontend/src/hooks/useStrategyEvaluationEditor.ts @@ -59,6 +59,7 @@ import { buildSidewaysFilterNode, loadSidewaysPaletteItems, } from '../utils/sidewaysFilterPaletteStorage'; +import { buildTrendLineConditionNode } from '../utils/trendLinePalette'; import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; import type { IndicatorVisualConfig } from './useIndicatorSettings'; import type { PaletteItem } from '../utils/strategyPaletteStorage'; @@ -490,6 +491,18 @@ export function useStrategyEvaluationEditor({ }); }, [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 => { const payload = buildTemplateEvaluationPayload(row, def); if (!payload) { @@ -541,6 +554,7 @@ export function useStrategyEvaluationEditor({ applyPalette, applyPaletteItem, applySidewaysFilter, + applyTrendLine, applyTemplateForEvaluation, saving, saveNow, diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index b24beb3..bc48403 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -1961,6 +1961,15 @@ color: 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 { 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); } +/* ── 추세선 돌파 ── */ +.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 { 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--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.se-palette-card--range .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--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.se-palette-card--range .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--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.se-palette-card--range .se-palette-card-period, .se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-period, diff --git a/frontend/src/styles/strategyEditorTheme.css b/frontend/src/styles/strategyEditorTheme.css index ff6ce87..f7cce83 100644 --- a/frontend/src/styles/strategyEditorTheme.css +++ b/frontend/src/styles/strategyEditorTheme.css @@ -77,6 +77,7 @@ --se-palette-section-band: #a78bfa; --se-palette-section-ind: #34d399; --se-palette-section-range: #f59e0b; + --se-palette-section-trendline: #38bdf8; --se-palette-strategy-name: #f59e0b; --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%); @@ -221,6 +222,7 @@ --se-palette-section-band: #7b1fa2; --se-palette-section-ind: #2e7d32; --se-palette-section-range: #e65100; + --se-palette-section-trendline: #0288d1; --se-palette-strategy-name: #e65100; --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%); @@ -290,6 +292,7 @@ --se-palette-section-band: #ce93d8; --se-palette-section-ind: #69f0ae; --se-palette-section-range: #ffb74d; + --se-palette-section-trendline: #4fc3f7; --se-palette-strategy-name: #ffb74d; --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%); diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 864b116..ca654f8 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1440,6 +1440,14 @@ export interface BacktestSettingsDto { analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY'; /** 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 = { @@ -1465,6 +1473,10 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { positionMode: 'LONG_ONLY', analysisMethod: 'MARK_TO_MARKET', tradeExecutionMode: 'BACKTEST_ENGINE', + trendLineMode: 'TWO_POINT', + trendLineDefaultLookback: 10, + trendLineSwingPrecedingBars: 1, + trendLineSwingFollowingBars: 1, }; /** 백테스팅 설정 로드 */ diff --git a/frontend/src/utils/buildSignalTrendLineDrawings.ts b/frontend/src/utils/buildSignalTrendLineDrawings.ts new file mode 100644 index 0000000..bddc7e2 --- /dev/null +++ b/frontend/src/utils/buildSignalTrendLineDrawings.ts @@ -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, +): Promise { + 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; +}): Promise { + 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(); + 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; +} diff --git a/frontend/src/utils/strategyEditorShared.tsx b/frontend/src/utils/strategyEditorShared.tsx index 09b636a..a5c8e92 100644 --- a/frontend/src/utils/strategyEditorShared.tsx +++ b/frontend/src/utils/strategyEditorShared.tsx @@ -418,6 +418,8 @@ const COND_OPTIONS = [ { v: 'NEQ', l: '다름 (!=)' }, { v: 'CROSS_UP', l: '상향 돌파' }, { v: 'CROSS_DOWN', l: '하향 돌파' }, + { v: 'TREND_LINE_CROSS_UP', l: '추세선 상향 돌파' }, + { v: 'TREND_LINE_CROSS_DOWN', l: '추세선 하향 돌파' }, { v: 'SLOPE_UP', l: '상승 기울기' }, { v: 'SLOPE_DOWN', l: '하락 기울기' }, { v: 'DIFF_GT', l: '차이 > 값' }, @@ -444,6 +446,7 @@ export const getCondOptionsForIndicator = (ind: string): CondTypeOpt[] => { const COMMON_COND_KEYS = [ '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', ]; @@ -899,6 +902,10 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D if (!isValid(updated.leftField)) updated.leftField = def.l; updated.rightField = 'NONE'; 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') { updated.leftField = 'NONE'; updated.rightField = 'NONE'; 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' ? '≤' : '≥'; 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); if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`; return `${indName} - ${rangeClause}${L} ${C}`; @@ -1215,11 +1227,12 @@ export const CondEditor: React.FC = ({ }; 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 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 rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue(); + const rightValue = isSlopeType || isTrendLineType ? 'NONE' : isHoldType ? 'NONE' : getRightValue(); const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def)); const handleRight = (v: string) => { diff --git a/frontend/src/utils/strategyToChartIndicators.ts b/frontend/src/utils/strategyToChartIndicators.ts index 83e9a61..8f7ede5 100644 --- a/frontend/src/utils/strategyToChartIndicators.ts +++ b/frontend/src/utils/strategyToChartIndicators.ts @@ -25,7 +25,7 @@ import { type StrategyIndicatorRef, } from './strategyIndicatorSync'; -const DSL_TO_REGISTRY: Record = { +export const DSL_TO_REGISTRY: Record = { RSI: 'RSI', MACD: 'MACD', MA: 'SMA', diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 70e7016..442a39d 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -104,6 +104,7 @@ export const CONDITION_LABEL: Record = { 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: 'N일 유지', @@ -118,7 +119,7 @@ export const CONDITION_LABEL: Record = { 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_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS]; diff --git a/frontend/src/utils/trendLineGeometry.ts b/frontend/src/utils/trendLineGeometry.ts new file mode 100644 index 0000000..a04df25 --- /dev/null +++ b/frontend/src/utils/trendLineGeometry.ts @@ -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, + }; +} diff --git a/frontend/src/utils/trendLinePalette.ts b/frontend/src/utils/trendLinePalette.ts new file mode 100644 index 0000000..547712d --- /dev/null +++ b/frontend/src/utils/trendLinePalette.ts @@ -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, + 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, + ); +}