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,
+ );
+}