추세선 전략추가
This commit is contained in:
@@ -114,4 +114,21 @@ public class BacktestSettingsDto {
|
||||
*/
|
||||
@Builder.Default
|
||||
private String tradeExecutionMode = "BACKTEST_ENGINE";
|
||||
|
||||
// ── 추세선 조건 (TREND_LINE_CROSS_*) ─────────────────────────────────────
|
||||
/** TWO_POINT | REGRESSION | SWING */
|
||||
@Builder.Default
|
||||
private String trendLineMode = "TWO_POINT";
|
||||
|
||||
/** 조건에 lookback 미지정 시 기본 N봉 */
|
||||
@Builder.Default
|
||||
private Integer trendLineDefaultLookback = 10;
|
||||
|
||||
/** SWING 모드 — 스윙 저점 좌측 확인 봉 수 */
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingPrecedingBars = 1;
|
||||
|
||||
/** SWING 모드 — 스윙 저점 우측 확인 봉 수 */
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingFollowingBars = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 추세선 조건 평가 설정 — gc_backtest_settings 와 동기.
|
||||
* SWING: 스윙 피벗(저점·고점) 2개 연결 · TWO_POINT/REGRESSION: 피벗 실패 시 폴백
|
||||
*/
|
||||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
public class TrendLineConfig {
|
||||
|
||||
public static final String MODE_TWO_POINT = "TWO_POINT";
|
||||
public static final String MODE_REGRESSION = "REGRESSION";
|
||||
public static final String MODE_SWING = "SWING";
|
||||
|
||||
@Builder.Default
|
||||
private String mode = MODE_SWING;
|
||||
|
||||
@Builder.Default
|
||||
private int defaultLookback = 10;
|
||||
|
||||
@Builder.Default
|
||||
private int swingPrecedingBars = 1;
|
||||
|
||||
@Builder.Default
|
||||
private int swingFollowingBars = 1;
|
||||
|
||||
public static TrendLineConfig defaults() {
|
||||
return TrendLineConfig.builder().build();
|
||||
}
|
||||
|
||||
public static TrendLineConfig fromSettings(BacktestSettingsDto dto) {
|
||||
if (dto == null) return defaults();
|
||||
String mode = dto.getTrendLineMode();
|
||||
if (mode == null || mode.isBlank()) mode = MODE_TWO_POINT;
|
||||
if (!MODE_TWO_POINT.equals(mode) && !MODE_REGRESSION.equals(mode) && !MODE_SWING.equals(mode)) {
|
||||
mode = MODE_TWO_POINT;
|
||||
}
|
||||
int lookback = dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
|
||||
? dto.getTrendLineDefaultLookback() : 10;
|
||||
int prec = dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
|
||||
? dto.getTrendLineSwingPrecedingBars() : 1;
|
||||
int fol = dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
|
||||
? dto.getTrendLineSwingFollowingBars() : 1;
|
||||
return TrendLineConfig.builder()
|
||||
.mode(mode)
|
||||
.defaultLookback(lookback)
|
||||
.swingPrecedingBars(prec)
|
||||
.swingFollowingBars(fol)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
/**
|
||||
* 추세선 종류 — 돌파 방향과 짝을 이룸.
|
||||
* <ul>
|
||||
* <li>{@link #UP} 상승(지지) 추세선 — 스윙 저점(Low) 연결, 저점이 높아져야 함</li>
|
||||
* <li>{@link #DOWN} 하락(저항) 추세선 — 스윙 고점(High) 연결, 고점이 낮아져야 함</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum TrendLineKind {
|
||||
/** 지지선 — TREND_LINE_CROSS_DOWN (하향 돌파) */
|
||||
UP,
|
||||
/** 저항선 — TREND_LINE_CROSS_UP (상향 돌파) */
|
||||
DOWN;
|
||||
|
||||
public static TrendLineKind forCrossUp() {
|
||||
return DOWN;
|
||||
}
|
||||
|
||||
public static TrendLineKind forCrossDown() {
|
||||
return UP;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,23 @@ public class GcBacktestSettings {
|
||||
@Builder.Default
|
||||
private String tradeExecutionMode = "BACKTEST_ENGINE";
|
||||
|
||||
/** TWO_POINT | REGRESSION | SWING */
|
||||
@Column(name = "trend_line_mode", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private String trendLineMode = "TWO_POINT";
|
||||
|
||||
@Column(name = "trend_line_default_lookback", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineDefaultLookback = 10;
|
||||
|
||||
@Column(name = "trend_line_swing_preceding_bars", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingPrecedingBars = 1;
|
||||
|
||||
@Column(name = "trend_line_swing_following_bars", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer trendLineSwingFollowingBars = 1;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -125,6 +125,21 @@ public class BacktestSettingsService {
|
||||
"SCAN_SIGNALS".equals(dto.getTradeExecutionMode())
|
||||
? "SCAN_SIGNALS"
|
||||
: "BACKTEST_ENGINE");
|
||||
entity.setTrendLineMode(normalizeTrendLineMode(dto.getTrendLineMode()));
|
||||
entity.setTrendLineDefaultLookback(
|
||||
dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
|
||||
? dto.getTrendLineDefaultLookback() : 10);
|
||||
entity.setTrendLineSwingPrecedingBars(
|
||||
dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
|
||||
? dto.getTrendLineSwingPrecedingBars() : 1);
|
||||
entity.setTrendLineSwingFollowingBars(
|
||||
dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
|
||||
? dto.getTrendLineSwingFollowingBars() : 1);
|
||||
}
|
||||
|
||||
private static String normalizeTrendLineMode(String mode) {
|
||||
if ("REGRESSION".equals(mode) || "SWING".equals(mode)) return mode;
|
||||
return "TWO_POINT";
|
||||
}
|
||||
|
||||
// ── 변환 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
@@ -157,6 +172,10 @@ public class BacktestSettingsService {
|
||||
"SCAN_SIGNALS".equals(e.getTradeExecutionMode())
|
||||
? "SCAN_SIGNALS"
|
||||
: "BACKTEST_ENGINE")
|
||||
.trendLineMode(normalizeTrendLineMode(e.getTrendLineMode()))
|
||||
.trendLineDefaultLookback(e.getTrendLineDefaultLookback() != null ? e.getTrendLineDefaultLookback() : 10)
|
||||
.trendLineSwingPrecedingBars(e.getTrendLineSwingPrecedingBars() != null ? e.getTrendLineSwingPrecedingBars() : 1)
|
||||
.trendLineSwingFollowingBars(e.getTrendLineSwingFollowingBars() != null ? e.getTrendLineSwingFollowingBars() : 1)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +132,11 @@ public class BacktestingService {
|
||||
series, primaryTf, buyDsl, sellDsl);
|
||||
|
||||
String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null;
|
||||
com.goldenchart.dto.TrendLineConfig trendLineConfig =
|
||||
com.goldenchart.dto.TrendLineConfig.fromSettings(cfg);
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, null, false, seriesOverrides);
|
||||
series, params, visual, market, null, false, seriesOverrides, trendLineConfig);
|
||||
Rule entryRule = adapter.toRule(buyDsl, ruleCtx);
|
||||
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
|
||||
? adapter.toRule(sellDsl, ruleCtx)
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.goldenchart.dto.BacktestResponse;
|
||||
import com.goldenchart.dto.BacktestSettingsDto;
|
||||
import com.goldenchart.dto.LiveConditionRowDto;
|
||||
import com.goldenchart.dto.LiveConditionStatusDto;
|
||||
import com.goldenchart.dto.OhlcvBar;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.entity.GcStrategy;
|
||||
import com.goldenchart.repository.GcStrategyRepository;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
@@ -38,6 +40,8 @@ public class LiveConditionStatusService {
|
||||
Map.entry("NEQ", "다름(≠)"),
|
||||
Map.entry("CROSS_UP", "상향 돌파"),
|
||||
Map.entry("CROSS_DOWN", "하향 돌파"),
|
||||
Map.entry("TREND_LINE_CROSS_UP", "추세선 상향 돌파"),
|
||||
Map.entry("TREND_LINE_CROSS_DOWN", "추세선 하향 돌파"),
|
||||
Map.entry("SLOPE_UP", "상승 중"),
|
||||
Map.entry("LOWEST_LTE", "N봉 최저 ≤"),
|
||||
Map.entry("LOWEST_GTE", "N봉 최저 ≥"),
|
||||
@@ -53,6 +57,27 @@ public class LiveConditionStatusService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DynamicSubscriptionManager subscriptionManager;
|
||||
private final StrategyDslTimeframeNormalizer timeframeNormalizer;
|
||||
private final BacktestSettingsService backtestSettingsService;
|
||||
|
||||
private TrendLineConfig trendLineConfigFor(Long userId, String deviceId) {
|
||||
BacktestSettingsDto settings = backtestSettingsService.get(userId, deviceId);
|
||||
return TrendLineConfig.fromSettings(settings);
|
||||
}
|
||||
|
||||
private StrategyDslToTa4jAdapter.RuleBuildContext buildRuleCtx(
|
||||
BarSeries series,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
String market,
|
||||
Ta4jStorage storage,
|
||||
boolean useConfirmedOnly,
|
||||
Map<String, BarSeries> seriesOverrides,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
return new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, storage, useConfirmedOnly, seriesOverrides,
|
||||
trendLineConfigFor(userId, deviceId));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public LiveConditionStatusDto evaluate(Long userId, String deviceId,
|
||||
@@ -104,7 +129,7 @@ public class LiveConditionStatusService {
|
||||
}
|
||||
|
||||
if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) {
|
||||
return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe,
|
||||
return evaluateOnChartBars(userId, deviceId, strategy, market, strategyId, chartBars, chartTimeframe,
|
||||
barTimeSec, params, visual);
|
||||
}
|
||||
|
||||
@@ -142,8 +167,8 @@ public class LiveConditionStatusService {
|
||||
wrapper.set("condition", p.condition());
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
||||
buildRuleCtx(series, params, visual, market, ta4jStorage, false,
|
||||
java.util.Map.of(), userId, deviceId);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
@@ -167,9 +192,9 @@ public class LiveConditionStatusService {
|
||||
|
||||
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
|
||||
Boolean overallEntryMet = evaluateOverallRule(
|
||||
strategy.getBuyConditionJson(), market, params, visual, barTimeSec);
|
||||
strategy.getBuyConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
|
||||
Boolean overallExitMet = evaluateOverallRule(
|
||||
strategy.getSellConditionJson(), market, params, visual, barTimeSec);
|
||||
strategy.getSellConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
@@ -267,6 +292,8 @@ public class LiveConditionStatusService {
|
||||
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
|
||||
*/
|
||||
private LiveConditionStatusDto evaluateOnChartBars(
|
||||
Long userId,
|
||||
String deviceId,
|
||||
GcStrategy strategy,
|
||||
String market,
|
||||
long strategyId,
|
||||
@@ -314,8 +341,8 @@ public class LiveConditionStatusService {
|
||||
wrapper.set("condition", p.condition());
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(series, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule rule = adapter.toRule(wrapper, ctx);
|
||||
boolean satisfied = rule.isSatisfied(index, null);
|
||||
Double current = adapter.readConditionFieldValue(
|
||||
@@ -336,9 +363,11 @@ public class LiveConditionStatusService {
|
||||
|
||||
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
|
||||
Boolean overallEntryMet = evaluateOverallRuleOnChart(
|
||||
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
|
||||
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
|
||||
userId, deviceId);
|
||||
Boolean overallExitMet = evaluateOverallRuleOnChart(
|
||||
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec);
|
||||
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
|
||||
userId, deviceId);
|
||||
|
||||
return LiveConditionStatusDto.builder()
|
||||
.market(market)
|
||||
@@ -392,14 +421,16 @@ public class LiveConditionStatusService {
|
||||
String market,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
Long barTimeSec) {
|
||||
Long barTimeSec,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
if (dsl == null || dsl.isNull()) return null;
|
||||
try {
|
||||
if (primarySeries == null || primarySeries.isEmpty()) return null;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(primarySeries, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule rule = adapter.toRule(dsl, ctx);
|
||||
int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
|
||||
return rule.isSatisfied(index, null);
|
||||
@@ -441,7 +472,9 @@ public class LiveConditionStatusService {
|
||||
private Boolean evaluateOverallRule(String conditionJson, String market,
|
||||
Map<String, Map<String, Object>> params,
|
||||
Map<String, Map<String, Object>> visual,
|
||||
Long barTimeSec) {
|
||||
Long barTimeSec,
|
||||
Long userId,
|
||||
String deviceId) {
|
||||
if (conditionJson == null || conditionJson.isBlank()) return null;
|
||||
try {
|
||||
String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper);
|
||||
@@ -461,8 +494,8 @@ public class LiveConditionStatusService {
|
||||
if (series.isEmpty()) return null;
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ctx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, params, visual, market, ta4jStorage, false, java.util.Map.of());
|
||||
buildRuleCtx(series, params, visual, market, ta4jStorage, false,
|
||||
java.util.Map.of(), userId, deviceId);
|
||||
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
|
||||
return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
|
||||
} catch (Exception e) {
|
||||
@@ -798,8 +831,8 @@ public class LiveConditionStatusService {
|
||||
int startIdx = resolveChartScanStartIndex(primarySeries, evalCount);
|
||||
|
||||
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
|
||||
new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
primarySeries, params, visual, market, null, false, seriesOverrides);
|
||||
buildRuleCtx(primarySeries, params, visual, market, null, false,
|
||||
seriesOverrides, userId, deviceId);
|
||||
Rule entryRule = (buyDsl != null && !buyDsl.isNull())
|
||||
? adapter.toRule(buyDsl, ruleCtx)
|
||||
: new org.ta4j.core.rules.BooleanRule(false);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import com.goldenchart.storage.Ta4jStorage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -122,20 +124,37 @@ public class StrategyDslToTa4jAdapter {
|
||||
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
|
||||
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
|
||||
*/
|
||||
Map<String, BarSeries> seriesOverrides
|
||||
Map<String, BarSeries> seriesOverrides,
|
||||
/** 추세선 조건(TREND_LINE_CROSS_*) — 전략설정 gc_backtest_settings */
|
||||
TrendLineConfig trendLineConfig
|
||||
) {
|
||||
/** visual 미전달 호환 (레거시) */
|
||||
public RuleBuildContext(BarSeries primarySeries,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
String market,
|
||||
Ta4jStorage storage) {
|
||||
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of());
|
||||
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of(), null);
|
||||
}
|
||||
|
||||
public RuleBuildContext(BarSeries primarySeries,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market,
|
||||
Ta4jStorage storage,
|
||||
boolean useConfirmedOnly,
|
||||
Map<String, BarSeries> seriesOverrides) {
|
||||
this(primarySeries, indicatorParams, indicatorVisual, market, storage,
|
||||
useConfirmedOnly, seriesOverrides, null);
|
||||
}
|
||||
|
||||
public TrendLineConfig effectiveTrendLineConfig() {
|
||||
return trendLineConfig != null ? trendLineConfig : TrendLineConfig.defaults();
|
||||
}
|
||||
|
||||
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
|
||||
public RuleBuildContext withPrimary(BarSeries newPrimary) {
|
||||
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
|
||||
market, storage, useConfirmedOnly, seriesOverrides);
|
||||
market, storage, useConfirmedOnly, seriesOverrides, trendLineConfig);
|
||||
}
|
||||
|
||||
/** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */
|
||||
@@ -170,20 +189,20 @@ public class StrategyDslToTa4jAdapter {
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, BarSeries series,
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +213,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, BarSeries> seriesOverrides) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false,
|
||||
seriesOverrides != null ? seriesOverrides : Map.of()));
|
||||
seriesOverrides != null ? seriesOverrides : Map.of(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +223,7 @@ public class StrategyDslToTa4jAdapter {
|
||||
Map<String, Map<String, Object>> indicatorParams,
|
||||
Map<String, Map<String, Object>> indicatorVisual,
|
||||
String market, Ta4jStorage storage) {
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of()));
|
||||
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of(), null));
|
||||
}
|
||||
|
||||
public Rule toRule(JsonNode node, RuleBuildContext ctx) {
|
||||
@@ -508,6 +527,15 @@ public class StrategyDslToTa4jAdapter {
|
||||
// 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외)
|
||||
case "CROSS_UP" -> buildCrossUpRule(left, right);
|
||||
case "CROSS_DOWN" -> buildCrossDownRule(left, right);
|
||||
case "TREND_LINE_CROSS_UP", "TREND_LINE_CROSS_DOWN" -> {
|
||||
int lb = cond.path("lookbackPeriod").asInt(0);
|
||||
if (lb <= 0) lb = ctx.effectiveTrendLineConfig().getDefaultLookback();
|
||||
lb = Math.max(2, lb);
|
||||
boolean priceScale = isTrendLinePriceScaleField(leftField);
|
||||
yield "TREND_LINE_CROSS_UP".equals(condType)
|
||||
? buildTrendLineCrossUpRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale)
|
||||
: buildTrendLineCrossDownRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale);
|
||||
}
|
||||
case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
|
||||
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
|
||||
case "DIFF_GT" -> {
|
||||
@@ -1657,6 +1685,66 @@ public class StrategyDslToTa4jAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* N봉 추세선 돌파 — [i−N, i−1] 구간으로 고정된 동일 선을 i−1·i 에서 평가.
|
||||
* (일반 CROSS 와 달리 추세선 창이 봉마다 밀리지 않음)
|
||||
*/
|
||||
private Rule buildTrendLineCrossUpRule(
|
||||
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
if (index < 1) return false;
|
||||
TrendLineKind kind = TrendLineKind.forCrossUp();
|
||||
double linePrev = TrendLineMath.valueAtEval(
|
||||
series, left, index, index - 1, lookback, config, kind, priceScale);
|
||||
double lineCurr = TrendLineMath.valueAtEval(
|
||||
series, left, index, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
|
||||
Num l0 = left.getValue(index - 1);
|
||||
Num l1 = left.getValue(index);
|
||||
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
|
||||
Num r0 = series.numFactory().numOf(linePrev);
|
||||
Num r1 = series.numFactory().numOf(lineCurr);
|
||||
return l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Rule buildTrendLineCrossDownRule(
|
||||
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
|
||||
return new Rule() {
|
||||
@Override
|
||||
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
|
||||
if (index < 1) return false;
|
||||
TrendLineKind kind = TrendLineKind.forCrossDown();
|
||||
double linePrev = TrendLineMath.valueAtEval(
|
||||
series, left, index, index - 1, lookback, config, kind, priceScale);
|
||||
double lineCurr = TrendLineMath.valueAtEval(
|
||||
series, left, index, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
|
||||
Num l0 = left.getValue(index - 1);
|
||||
Num l1 = left.getValue(index);
|
||||
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
|
||||
Num r0 = series.numFactory().numOf(linePrev);
|
||||
Num r1 = series.numFactory().numOf(lineCurr);
|
||||
return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 추세선 — 가격축(OHLC·MA 등) vs 오실레이터 */
|
||||
private static boolean isTrendLinePriceScaleField(String field) {
|
||||
if (field == null || field.isBlank() || "NONE".equalsIgnoreCase(field)) return true;
|
||||
String f = field.toUpperCase();
|
||||
return f.contains("CLOSE") || f.contains("PRICE") || f.contains("HIGH") || f.contains("LOW")
|
||||
|| f.contains("OPEN") || f.contains("MA") || f.contains("EMA")
|
||||
|| f.contains("UPPER") || f.contains("MIDDLE") || f.contains("LOWER")
|
||||
|| f.contains("BAND") || f.contains("DC_") || f.contains("VWAP")
|
||||
|| f.contains("CONVERSION") || f.contains("BASE") || f.contains("SPAN")
|
||||
|| f.contains("LAGGING") || f.contains("CLOUD");
|
||||
}
|
||||
|
||||
/**
|
||||
* CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다.
|
||||
* sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* N봉 구간 추세선 — 스윙 피벗(Pivot) 기준.
|
||||
* <p>
|
||||
* fit 구간 [anchor−N, anchor−1] (현재 봉 제외).
|
||||
* 상승 추세선(UP): 스윙 저점 2개(Low, Low₂>Low₁) · 하락 추세선(DOWN): 스윙 고점 2개(High, High₂<High₁).
|
||||
* anchor 봉 i 돌파 판정: 동일 선을 i−1·i 에 extrapolation.
|
||||
* 차트 선분: 피벗 A → anchor(i)까지 연장.
|
||||
*/
|
||||
public final class TrendLineMath {
|
||||
|
||||
private TrendLineMath() {}
|
||||
|
||||
private record PivotPair(int indexA, double yA, int indexB, double yB) {}
|
||||
|
||||
/** anchor 봉 기준 [anchor−N, anchor−1] 추세선을 evalIndex 에서 평가 */
|
||||
public static double valueAtEval(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int anchorIndex,
|
||||
int evalIndex,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (series == null || source == null || config == null || kind == null
|
||||
|| anchorIndex < 1 || lookback < 2) {
|
||||
return Double.NaN;
|
||||
}
|
||||
int start = anchorIndex - lookback;
|
||||
int end = anchorIndex - 1;
|
||||
if (start < series.getBeginIndex() || end < start) return Double.NaN;
|
||||
|
||||
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||
if (pivots != null) {
|
||||
return lineAt(pivots, evalIndex);
|
||||
}
|
||||
return fallbackValueAt(series, source, start, end, evalIndex, config, kind, priceScale);
|
||||
}
|
||||
|
||||
/** anchor = index — i 봉 돌파 판정용 */
|
||||
public static double valueAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int index,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
return valueAtEval(series, source, index, index, lookback, config, kind, priceScale);
|
||||
}
|
||||
|
||||
/** 시그널 차트용 — 피벗 기준 fit 후 index(시그널 봉)까지 연장 */
|
||||
public record Segment(long startTimeSec, double startPrice, long endTimeSec, double endPrice) {}
|
||||
|
||||
public static Segment segmentAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int index,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (series == null || source == null || config == null || kind == null
|
||||
|| index < 1 || lookback < 2) {
|
||||
return null;
|
||||
}
|
||||
int start = index - lookback;
|
||||
int end = index - 1;
|
||||
if (start < series.getBeginIndex() || end < start) return null;
|
||||
|
||||
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
|
||||
if (pivots != null) {
|
||||
double yAt = lineAt(pivots, index);
|
||||
if (!Double.isFinite(yAt)) return null;
|
||||
return new Segment(
|
||||
barTimeSec(series, pivots.indexA()), pivots.yA(),
|
||||
barTimeSec(series, index), yAt);
|
||||
}
|
||||
return fallbackSegment(series, source, start, end, index, config, kind, priceScale);
|
||||
}
|
||||
|
||||
// ── 피벗 선정 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static PivotPair resolvePivots(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
int kLeft = Math.max(0, config.getSwingPrecedingBars());
|
||||
int kRight = Math.max(0, config.getSwingFollowingBars());
|
||||
List<Integer> swings = collectSwingIndices(series, source, start, end, kLeft, kRight, kind, priceScale);
|
||||
if (swings.size() < 2) return null;
|
||||
|
||||
int swingA = swings.get(0);
|
||||
for (int j = 1; j < swings.size(); j++) {
|
||||
int swingB = swings.get(j);
|
||||
if (swingB <= swingA) continue;
|
||||
double yA = yAtPivot(series, source, swingA, kind, priceScale);
|
||||
double yB = yAtPivot(series, source, swingB, kind, priceScale);
|
||||
if (!Double.isFinite(yA) || !Double.isFinite(yB)) continue;
|
||||
if (kind == TrendLineKind.UP && yB > yA) {
|
||||
return new PivotPair(swingA, yA, swingB, yB);
|
||||
}
|
||||
if (kind == TrendLineKind.DOWN && yB < yA) {
|
||||
return new PivotPair(swingA, yA, swingB, yB);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Integer> collectSwingIndices(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int kLeft,
|
||||
int kRight,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
List<Integer> out = new ArrayList<>();
|
||||
for (int i = start + kLeft; i <= end - kRight; i++) {
|
||||
boolean isSwing = kind == TrendLineKind.UP
|
||||
? isSwingLow(series, source, i, kLeft, kRight, priceScale)
|
||||
: isSwingHigh(series, source, i, kLeft, kRight, priceScale);
|
||||
if (isSwing) out.add(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static double yAtPivot(
|
||||
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||
if (priceScale) {
|
||||
return kind == TrendLineKind.UP
|
||||
? series.getBar(index).getLowPrice().doubleValue()
|
||||
: series.getBar(index).getHighPrice().doubleValue();
|
||||
}
|
||||
Double v = numAt(source, index);
|
||||
return v != null ? v : Double.NaN;
|
||||
}
|
||||
|
||||
private static boolean isSwingLow(
|
||||
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||
double low = priceScale
|
||||
? series.getBar(i).getLowPrice().doubleValue()
|
||||
: requireNum(source, i);
|
||||
if (!Double.isFinite(low)) return false;
|
||||
for (int j = i - prec; j <= i + fol; j++) {
|
||||
if (j == i) continue;
|
||||
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||
double v = priceScale
|
||||
? series.getBar(j).getLowPrice().doubleValue()
|
||||
: requireNum(source, j);
|
||||
if (!Double.isFinite(v) || v < low) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isSwingHigh(
|
||||
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
|
||||
double high = priceScale
|
||||
? series.getBar(i).getHighPrice().doubleValue()
|
||||
: requireNum(source, i);
|
||||
if (!Double.isFinite(high)) return false;
|
||||
for (int j = i - prec; j <= i + fol; j++) {
|
||||
if (j == i) continue;
|
||||
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
|
||||
double v = priceScale
|
||||
? series.getBar(j).getHighPrice().doubleValue()
|
||||
: requireNum(source, j);
|
||||
if (!Double.isFinite(v) || v > high) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double lineAt(PivotPair p, int evalIndex) {
|
||||
if (p.indexB() == p.indexA()) return p.yA();
|
||||
double t = (evalIndex - p.indexA()) / (double) (p.indexB() - p.indexA());
|
||||
return p.yA() + (p.yB() - p.yA()) * t;
|
||||
}
|
||||
|
||||
// ── 모드별 폴백 (피벗 2개 미충족 시) ─────────────────────────────────────
|
||||
|
||||
private static double fallbackValueAt(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int evalIndex,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||
return Double.NaN;
|
||||
}
|
||||
if (TrendLineConfig.MODE_REGRESSION.equals(normalizeMode(config.getMode()))) {
|
||||
return regressionAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
return twoPointAt(series, source, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
|
||||
private static Segment fallbackSegment(
|
||||
BarSeries series,
|
||||
Indicator<Num> source,
|
||||
int start,
|
||||
int end,
|
||||
int anchorIndex,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
|
||||
return null;
|
||||
}
|
||||
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||
if (!Double.isFinite(y0)) return null;
|
||||
double yAt = fallbackValueAt(series, source, start, end, anchorIndex, config, kind, priceScale);
|
||||
if (!Double.isFinite(yAt)) return null;
|
||||
return new Segment(barTimeSec(series, start), y0, barTimeSec(series, anchorIndex), yAt);
|
||||
}
|
||||
|
||||
private static double yAtEndpoint(
|
||||
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
|
||||
return yAtPivot(series, source, index, kind, priceScale);
|
||||
}
|
||||
|
||||
private static double twoPointAt(
|
||||
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||
TrendLineKind kind, boolean priceScale) {
|
||||
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
|
||||
double y1 = yAtEndpoint(series, source, end, kind, priceScale);
|
||||
if (!Double.isFinite(y0) || !Double.isFinite(y1) || end == start) return Double.NaN;
|
||||
double t = (evalIndex - start) / (double) (end - start);
|
||||
return y0 + (y1 - y0) * t;
|
||||
}
|
||||
|
||||
private static double regressionAt(
|
||||
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
|
||||
TrendLineKind kind, boolean priceScale) {
|
||||
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
||||
int count = 0;
|
||||
for (int i = start; i <= end; i++) {
|
||||
double y = yAtEndpoint(series, source, i, kind, priceScale);
|
||||
if (!Double.isFinite(y)) continue;
|
||||
double x = i - start;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumXX += x * x;
|
||||
count++;
|
||||
}
|
||||
if (count < 2) return Double.NaN;
|
||||
double denom = count * sumXX - sumX * sumX;
|
||||
if (Math.abs(denom) < 1e-12) return Double.NaN;
|
||||
double b = (count * sumXY - sumX * sumY) / denom;
|
||||
double a = (sumY - b * sumX) / count;
|
||||
return a + b * (evalIndex - start);
|
||||
}
|
||||
|
||||
private static String normalizeMode(String mode) {
|
||||
if (TrendLineConfig.MODE_REGRESSION.equals(mode)) return TrendLineConfig.MODE_REGRESSION;
|
||||
if (TrendLineConfig.MODE_SWING.equals(mode)) return TrendLineConfig.MODE_SWING;
|
||||
return TrendLineConfig.MODE_TWO_POINT;
|
||||
}
|
||||
|
||||
private static double requireNum(Indicator<Num> source, int index) {
|
||||
Double v = numAt(source, index);
|
||||
return v != null ? v : Double.NaN;
|
||||
}
|
||||
|
||||
private static Double numAt(Indicator<Num> source, int index) {
|
||||
if (index < 0) return null;
|
||||
Num n = source.getValue(index);
|
||||
if (n == null) return null;
|
||||
double v = n.doubleValue();
|
||||
return Double.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
private static long barTimeSec(BarSeries series, int index) {
|
||||
return series.getBar(index).getEndTime().getEpochSecond();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.Indicator;
|
||||
import org.ta4j.core.indicators.AbstractIndicator;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import static org.ta4j.core.num.NaN.NaN;
|
||||
|
||||
/** bar index 에서 N봉 추세선 Y값 — left 지표·종가와 CROSS 비교용 */
|
||||
public class TrendLineValueIndicator extends AbstractIndicator<Num> {
|
||||
|
||||
private final Indicator<Num> source;
|
||||
private final BarSeries series;
|
||||
private final int lookback;
|
||||
private final TrendLineConfig config;
|
||||
private final TrendLineKind kind;
|
||||
private final boolean priceScale;
|
||||
|
||||
public TrendLineValueIndicator(
|
||||
Indicator<Num> source,
|
||||
BarSeries series,
|
||||
int lookback,
|
||||
TrendLineConfig config,
|
||||
TrendLineKind kind,
|
||||
boolean priceScale) {
|
||||
super(series);
|
||||
this.source = source;
|
||||
this.series = series;
|
||||
this.lookback = Math.max(2, lookback);
|
||||
this.config = config != null ? config : TrendLineConfig.defaults();
|
||||
this.kind = kind != null ? kind : TrendLineKind.DOWN;
|
||||
this.priceScale = priceScale;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Num getValue(int index) {
|
||||
double v = TrendLineMath.valueAt(series, source, index, lookback, config, kind, priceScale);
|
||||
if (!Double.isFinite(v)) {
|
||||
return NaN;
|
||||
}
|
||||
return series.numFactory().numOf(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCountOfUnstableBars() {
|
||||
return lookback + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 추세선 전략 조건 — 백테스트/전략평가 공통 설정
|
||||
ALTER TABLE gc_backtest_settings
|
||||
ADD COLUMN trend_line_mode VARCHAR(20) NOT NULL DEFAULT 'TWO_POINT'
|
||||
COMMENT 'TWO_POINT | REGRESSION | SWING',
|
||||
ADD COLUMN trend_line_default_lookback INT NOT NULL DEFAULT 10,
|
||||
ADD COLUMN trend_line_swing_preceding_bars INT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN trend_line_swing_following_bars INT NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.goldenchart.dto.TrendLineConfig;
|
||||
import com.goldenchart.dto.TrendLineKind;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ta4j.core.BarSeries;
|
||||
import org.ta4j.core.BaseBarSeriesBuilder;
|
||||
import org.ta4j.core.Rule;
|
||||
import org.ta4j.core.TradingRecord;
|
||||
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
|
||||
import org.ta4j.core.num.DecimalNum;
|
||||
import org.ta4j.core.num.Num;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** N봉 추세선 돌파 — 스윙 피벗 (상승 지지 / 하락 저항) */
|
||||
class TrendLineCrossRuleTest {
|
||||
|
||||
private final StrategyDslToTa4jAdapter adapter = new StrategyDslToTa4jAdapter();
|
||||
private final ObjectMapper om = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void trendLineCrossUp_swingPivot_breaksDescendingResistance() throws Exception {
|
||||
BarSeries series = buildDescendingHighsThenBreakUp();
|
||||
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_UP", 8, TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build());
|
||||
|
||||
int last = series.getEndIndex();
|
||||
assertTrue(rule.isSatisfied(last, null), "저항(하락) 추세선 상향 돌파");
|
||||
assertFalse(rule.isSatisfied(last - 1, null), "직전 봉은 미돌파");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trendLineCrossDown_swingPivot_breaksAscendingSupport() throws Exception {
|
||||
BarSeries series = buildAscendingLowsThenBreakDown();
|
||||
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_DOWN", 8, TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build());
|
||||
|
||||
int last = series.getEndIndex();
|
||||
assertTrue(rule.isSatisfied(last, null), "지지(상승) 추세선 하향 돌파");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trendLineValueIndicator_matchesMath() {
|
||||
BarSeries series = buildDescendingHighsThenBreakUp();
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
TrendLineConfig cfg = TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build();
|
||||
TrendLineValueIndicator ind = new TrendLineValueIndicator(
|
||||
close, series, 8, cfg, TrendLineKind.DOWN, true);
|
||||
int idx = series.getEndIndex();
|
||||
double expected = TrendLineMath.valueAt(series, close, idx, 8, cfg, TrendLineKind.DOWN, true);
|
||||
Num v = ind.getValue(idx);
|
||||
assertEquals(expected, v.doubleValue(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void trendLineCross_usesFixedWindow_notCurrentBarInFit() {
|
||||
BarSeries series = buildDescendingHighsThenBreakUp();
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
TrendLineConfig cfg = TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build();
|
||||
int idx = series.getEndIndex();
|
||||
TrendLineKind kind = TrendLineKind.DOWN;
|
||||
|
||||
double lineAtPrev = TrendLineMath.valueAtEval(series, close, idx, idx - 1, 8, cfg, kind, true);
|
||||
double lineAtCurr = TrendLineMath.valueAtEval(series, close, idx, idx, 8, cfg, kind, true);
|
||||
assertTrue(Double.isFinite(lineAtPrev));
|
||||
assertTrue(Double.isFinite(lineAtCurr));
|
||||
|
||||
double shiftedWrong = TrendLineMath.valueAtEval(series, close, idx - 1, idx - 1, 8, cfg, kind, true);
|
||||
assertNotEquals(lineAtPrev, shiftedWrong, 1e-6,
|
||||
"i−1·i는 동일 [idx−N, idx−1] 창의 추세선이어야 함");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pivotUp_requiresHigherSecondLow() {
|
||||
BarSeries series = buildInvalidUpTrendFlatLows();
|
||||
ClosePriceIndicator close = new ClosePriceIndicator(series);
|
||||
TrendLineConfig cfg = TrendLineConfig.builder()
|
||||
.mode(TrendLineConfig.MODE_SWING)
|
||||
.defaultLookback(8)
|
||||
.swingPrecedingBars(1)
|
||||
.swingFollowingBars(1)
|
||||
.build();
|
||||
int idx = series.getEndIndex();
|
||||
double v = TrendLineMath.valueAtEval(
|
||||
series, close, idx, idx, 8, cfg, TrendLineKind.UP, true);
|
||||
assertTrue(Double.isNaN(v), "두 번째 저점이 더 높지 않으면 피벗 추세선 없음");
|
||||
}
|
||||
|
||||
private Rule buildTrendLineRule(BarSeries series, String condType, int lookback, TrendLineConfig cfg)
|
||||
throws Exception {
|
||||
String json = """
|
||||
{
|
||||
"type": "CONDITION",
|
||||
"condition": {
|
||||
"indicatorType": "MA",
|
||||
"conditionType": "%s",
|
||||
"leftField": "CLOSE_PRICE",
|
||||
"rightField": "NONE",
|
||||
"lookbackPeriod": %d
|
||||
}
|
||||
}
|
||||
""".formatted(condType, lookback);
|
||||
var dsl = om.readTree(json);
|
||||
var ctx = new StrategyDslToTa4jAdapter.RuleBuildContext(
|
||||
series, Map.of(), Map.of(), null, null, false, Map.of(), cfg);
|
||||
return adapter.toRule(dsl, ctx);
|
||||
}
|
||||
|
||||
/** 하락 저항선(고점↓) — 마지막 봉 종가 상향 돌파 */
|
||||
private static BarSeries buildDescendingHighsThenBreakUp() {
|
||||
double[] highs = {100, 101, 102, 115, 110, 108, 106, 104, 102, 108, 105, 112};
|
||||
double[] closes = {99, 100, 101, 113, 108, 106, 104, 102, 100, 106, 104, 111};
|
||||
return toSeriesFromHighs(highs, closes);
|
||||
}
|
||||
|
||||
/** 상승 지지선(저점↑) — 마지막 봉 종가 하향 돌파 */
|
||||
private static BarSeries buildAscendingLowsThenBreakDown() {
|
||||
double[] lows = {108, 109, 110, 95, 96, 97, 98, 99, 100, 101, 102, 108};
|
||||
double[] closes = {110, 111, 112, 97, 98, 99, 100, 101, 102, 103, 104, 101};
|
||||
return toSeriesFromLows(lows, closes);
|
||||
}
|
||||
|
||||
/** 두 스윙 저점이 같은 높이 — UP 피벗 불성립 */
|
||||
private static BarSeries buildInvalidUpTrendFlatLows() {
|
||||
double[] lows = {98, 98, 98, 95, 95, 95, 95, 95, 95, 95, 95, 94};
|
||||
double[] closes = {100, 100, 100, 97, 97, 97, 97, 97, 97, 97, 97, 96};
|
||||
return toSeriesFromLows(lows, closes);
|
||||
}
|
||||
|
||||
private static BarSeries toSeriesFromHighs(double[] highs, double[] closes) {
|
||||
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
|
||||
Instant t = Instant.parse("2024-01-01T00:00:00Z");
|
||||
for (int i = 0; i < highs.length; i++) {
|
||||
double c = closes[i];
|
||||
double h = highs[i];
|
||||
double l = Math.min(c, h) - 1;
|
||||
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
|
||||
t = t.plus(Duration.ofMinutes(1));
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
private static BarSeries toSeriesFromLows(double[] lows, double[] closes) {
|
||||
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
|
||||
Instant t = Instant.parse("2024-01-01T00:00:00Z");
|
||||
for (int i = 0; i < lows.length; i++) {
|
||||
double c = closes[i];
|
||||
double l = lows[i];
|
||||
double h = Math.max(c, l) + 1;
|
||||
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
|
||||
t = t.plus(Duration.ofMinutes(1));
|
||||
}
|
||||
return series;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import {
|
||||
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<StrategyPanelProps> = ({
|
||||
);
|
||||
const [liveSaving, setLiveSaving] = useState(false);
|
||||
const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY');
|
||||
const [trendLineCfg, setTrendLineCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
||||
const [trendLineSaving, setTrendLineSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBacktestSettings().then(s => {
|
||||
if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') {
|
||||
if (s) {
|
||||
setTrendLineCfg(s);
|
||||
if (s.positionMode === 'SIGNAL_ONLY' || s.positionMode === 'LONG_ONLY') {
|
||||
setBacktestPositionMode(s.positionMode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const persistTrendLine = useCallback(async (patch: Partial<BacktestSettingsDto>) => {
|
||||
const next = { ...trendLineCfg, ...patch };
|
||||
setTrendLineCfg(next);
|
||||
setTrendLineSaving(true);
|
||||
try {
|
||||
const saved = await saveBacktestSettings(next);
|
||||
if (saved) setTrendLineCfg(saved);
|
||||
} finally {
|
||||
setTrendLineSaving(false);
|
||||
}
|
||||
}, [trendLineCfg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveSettingsProp) setLiveSettings(liveSettingsProp);
|
||||
}, [liveSettingsProp]);
|
||||
@@ -1582,6 +1603,19 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 추세선 설정 ──────────────────────────────────────────────────── */}
|
||||
<SettingSection title="추세선 설정">
|
||||
<p className="stg-hint" style={{ marginBottom: 12 }}>
|
||||
추세선 돌파 조건에서 이전 N봉 구간 추세선을 계산하는 방식입니다.
|
||||
2점·회귀·스윙 중 하나로 고정되며, 시그널 발생 봉마다 해당 시점의 선으로 평가합니다.
|
||||
{trendLineSaving && <span style={{ marginLeft: 8, color: 'var(--text3)' }}>저장 중…</span>}
|
||||
</p>
|
||||
<TrendLineSettingsFields
|
||||
cfg={trendLineCfg}
|
||||
onChange={patch => { void persistTrendLine(patch); }}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 리스크 관리 ──────────────────────────────────────────────────── */}
|
||||
<SettingSection title="리스크 관리" grid>
|
||||
<StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
|
||||
|
||||
@@ -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<LogicNode | null>(null);
|
||||
const [signalTab, setSignalTab] = useState<SignalTab>('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<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => 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({
|
||||
>
|
||||
횡보필터
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
|
||||
onClick={() => {
|
||||
setIndicatorSubTab('trendline');
|
||||
setSelectedPaletteKey(null);
|
||||
}}
|
||||
>
|
||||
추세
|
||||
</button>
|
||||
</div>
|
||||
{indicatorSubTab === 'auxiliary' ? (
|
||||
<IndicatorPaletteTab
|
||||
@@ -2524,7 +2545,7 @@ export default function StrategyEditorPage({
|
||||
applyPaletteItem(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : indicatorSubTab === 'range' ? (
|
||||
<SidewaysFilterPaletteTab
|
||||
def={DEF}
|
||||
items={sidewaysPalette}
|
||||
@@ -2541,6 +2562,21 @@ export default function StrategyEditorPage({
|
||||
applySidewaysFilter(item.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TrendLinePaletteTab
|
||||
signalType={buySellTab(signalTab)}
|
||||
searchQuery={paletteSearch}
|
||||
selectedItemId={
|
||||
selectedPaletteKey?.startsWith('trendline:')
|
||||
? selectedPaletteKey.slice('trendline:'.length)
|
||||
: null
|
||||
}
|
||||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
|
||||
onAddToCanvas={(item, lookback) => {
|
||||
selectPalette('trendline', item.id);
|
||||
applyTrendLine(item, lookback);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 설정 화면 — 전략 설정 · 추세선 조건
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { BacktestSettingsDto } from '../utils/backendApi';
|
||||
|
||||
export const TREND_LINE_MODE_OPTIONS = [
|
||||
{
|
||||
value: 'SWING' as const,
|
||||
label: '스윙 피벗 (권장)',
|
||||
desc: 'N봉 내 스윙 고/저점 2개 — 상승=저점↑·하락=고점↓',
|
||||
},
|
||||
{
|
||||
value: 'TWO_POINT' as const,
|
||||
label: '2점 폴백',
|
||||
desc: '피벗 미충족 시 구간 양端(Low/High) 직선',
|
||||
},
|
||||
{
|
||||
value: 'REGRESSION' as const,
|
||||
label: '회귀 폴백',
|
||||
desc: '피벗 미충족 시 N봉 Low/High OLS',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
cfg: BacktestSettingsDto;
|
||||
onChange: (patch: Partial<BacktestSettingsDto>) => void;
|
||||
}
|
||||
|
||||
export const TrendLineSettingsFields: React.FC<Props> = ({ cfg, onChange }) => {
|
||||
const mode = cfg.trendLineMode ?? 'SWING';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">추세선 정의</span>
|
||||
<span className="stg-row-desc">
|
||||
매수(상향 돌파)=하락 저항선(스윙 고점↓) · 매도(하향 돌파)=상승 지지선(스윙 저점↑).
|
||||
가격축은 Low/High, 오실레이터는 지표값 기준 스윙을 사용합니다.
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
className="stg-select stg-select--wide"
|
||||
value={mode}
|
||||
onChange={e => onChange({ trendLineMode: e.target.value as BacktestSettingsDto['trendLineMode'] })}
|
||||
>
|
||||
{TREND_LINE_MODE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">기본 lookback (봉)</span>
|
||||
<span className="stg-row-desc">
|
||||
[i−N, i−1] 구간에서 피벗을 찾고 i 봉까지 연장합니다. 단기 30~50·중기 100~120 권장.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number"
|
||||
min={2}
|
||||
max={500}
|
||||
value={cfg.trendLineDefaultLookback ?? 10}
|
||||
onChange={e => onChange({ trendLineDefaultLookback: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">스윙 — 좌측 k봉</span>
|
||||
<span className="stg-row-desc">
|
||||
피벗 판정: 해당 봉 Low/High가 좌·우 각 k봉보다 극단적일 때 스윙으로 인정
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
value={cfg.trendLineSwingPrecedingBars ?? 1}
|
||||
onChange={e => onChange({ trendLineSwingPrecedingBars: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stg-row">
|
||||
<div className="stg-row-label">
|
||||
<span className="stg-row-title">스윙 — 우측 k봉</span>
|
||||
<span className="stg-row-desc">피벗 판정 우측 비교 봉 수 (k)</span>
|
||||
</div>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
value={cfg.trendLineSwingFollowingBars ?? 1}
|
||||
onChange={e => onChange({ trendLineSwingFollowingBars: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendLineSettingsFields;
|
||||
@@ -31,6 +31,11 @@ interface Props {
|
||||
stableStrategy?: boolean;
|
||||
ichimokuSituation?: boolean;
|
||||
secondaryIndicator?: string;
|
||||
/** 추세선 돌파 — 드래그 payload type: trendLine */
|
||||
trendLine?: boolean;
|
||||
trendLineIndicatorType?: string;
|
||||
trendLineLeftField?: string;
|
||||
trendLineLookback?: number;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onAdd: () => void;
|
||||
@@ -38,9 +43,22 @@ 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 => ({
|
||||
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,
|
||||
@@ -53,7 +71,8 @@ export default function PaletteChip({
|
||||
period: periodValue,
|
||||
shortPeriod,
|
||||
longPeriod,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const pointerMode = needsPointerPaletteDrag();
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import PaletteChip from './PaletteChip';
|
||||
import { loadBacktestSettings } from '../../utils/backendApi';
|
||||
import {
|
||||
filterTrendLinePaletteItems,
|
||||
type TrendLinePaletteItem,
|
||||
} from '../../utils/trendLinePalette';
|
||||
|
||||
interface Props {
|
||||
signalType: 'buy' | 'sell';
|
||||
searchQuery: string;
|
||||
selectedItemId: string | null;
|
||||
onSelectItem: (id: string | null) => void;
|
||||
onAddToCanvas: (item: TrendLinePaletteItem, lookback: number) => void;
|
||||
}
|
||||
|
||||
function trendLinePeriodLabel(lookback: number, signalType: 'buy' | 'sell'): string {
|
||||
const dir = signalType === 'buy' ? '상향' : '하향';
|
||||
return `${lookback}봉 · ${dir}`;
|
||||
}
|
||||
|
||||
export default function TrendLinePaletteTab({
|
||||
signalType,
|
||||
searchQuery,
|
||||
selectedItemId,
|
||||
onSelectItem,
|
||||
onAddToCanvas,
|
||||
}: Props) {
|
||||
const [defaultLookback, setDefaultLookback] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBacktestSettings().then(s => {
|
||||
if (s?.trendLineDefaultLookback != null && s.trendLineDefaultLookback > 1) {
|
||||
setDefaultLookback(s.trendLineDefaultLookback);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const items = useMemo(
|
||||
() => filterTrendLinePaletteItems(searchQuery),
|
||||
[searchQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="se-palette-section se-palette-section--trendline se-palette-section--scroll">
|
||||
<div className="se-palette-toolbar">
|
||||
<span className="se-palette-toolbar-title">추세선 돌파</span>
|
||||
<span className="se-palette-toolbar-hint">
|
||||
{signalType === 'buy' ? '매수: 상향 돌파' : '매도: 하향 돌파'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
{items.map(item => (
|
||||
<PaletteChip
|
||||
key={item.id}
|
||||
type="indicator"
|
||||
value={item.id}
|
||||
label={item.label}
|
||||
desc={item.desc}
|
||||
color="trendline"
|
||||
trendLine
|
||||
trendLineIndicatorType={item.indicatorType}
|
||||
trendLineLeftField={item.leftField}
|
||||
trendLineLookback={defaultLookback}
|
||||
period={trendLinePeriodLabel(defaultLookback, signalType)}
|
||||
selected={selectedItemId === item.id}
|
||||
onSelect={() => onSelectItem(item.id)}
|
||||
onAdd={() => onAddToCanvas(item, defaultLookback)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && (
|
||||
<p className="se-palette-empty">검색 결과가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
|
||||
import TradingChart from '../TradingChart';
|
||||
import 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<Props> = ({
|
||||
const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc');
|
||||
const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||
const [signals, setSignals] = useState<BacktestSignal[]>([]);
|
||||
const [evalSettings, setEvalSettings] = useState<BacktestSettingsDto | null>(null);
|
||||
const [signalDrawings, setSignalDrawings] = useState<Drawing[]>([]);
|
||||
const [backtestRunning, setBacktestRunning] = useState(false);
|
||||
const [backtestError, setBacktestError] = useState<string | null>(null);
|
||||
const [magnifierActive, setMagnifierActive] = useState(false);
|
||||
@@ -415,7 +419,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
mode="chart"
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[]}
|
||||
drawings={signalDrawings}
|
||||
logScale={false}
|
||||
onCrosshair={() => {}}
|
||||
onManagerReady={onManagerReady}
|
||||
|
||||
+30
-4
@@ -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<typeof useStrategyEvaluationEditor>,
|
||||
'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<Props> = ({ 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<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
|
||||
@@ -141,6 +142,16 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
|
||||
>
|
||||
횡보필터
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
|
||||
onClick={() => {
|
||||
setIndicatorSubTab('trendline');
|
||||
setSelectedPaletteKey(null);
|
||||
}}
|
||||
>
|
||||
추세
|
||||
</button>
|
||||
</div>
|
||||
{indicatorSubTab === 'auxiliary' ? (
|
||||
<IndicatorPaletteTab
|
||||
@@ -178,7 +189,7 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
|
||||
applyPaletteItem(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : indicatorSubTab === 'range' ? (
|
||||
<SidewaysFilterPaletteTab
|
||||
def={def as DefType}
|
||||
items={sidewaysPalette}
|
||||
@@ -195,6 +206,21 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
|
||||
applySidewaysFilter(item.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TrendLinePaletteTab
|
||||
signalType={signalTab}
|
||||
searchQuery={paletteSearch}
|
||||
selectedItemId={
|
||||
selectedPaletteKey?.startsWith('trendline:')
|
||||
? selectedPaletteKey.slice('trendline:'.length)
|
||||
: null
|
||||
}
|
||||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
|
||||
onAddToCanvas={(item, lookback) => {
|
||||
selectPalette('trendline', item.id);
|
||||
applyTrendLine(item, lookback);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<StrategyDto | null> => {
|
||||
const payload = buildTemplateEvaluationPayload(row, def);
|
||||
if (!payload) {
|
||||
@@ -541,6 +554,7 @@ export function useStrategyEvaluationEditor({
|
||||
applyPalette,
|
||||
applyPaletteItem,
|
||||
applySidewaysFilter,
|
||||
applyTrendLine,
|
||||
applyTemplateForEvaluation,
|
||||
saving,
|
||||
saveNow,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/** 백테스팅 설정 로드 */
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 전략평가 차트 — 선택 봉 기준 N봉 추세선 Drawing 생성
|
||||
*/
|
||||
import type { Drawing } from '../types';
|
||||
import type { OHLCVBar } from '../types';
|
||||
import type { BacktestSettingsDto, StrategyDto } from './backendApi';
|
||||
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
||||
import { trendLineConfigFromSettings, trendLineSegmentAt, trendLineKindForCross, isTrendLinePriceScaleField } from './trendLineGeometry';
|
||||
import { calculateIndicator } from './indicatorRegistry';
|
||||
import { DSL_TO_REGISTRY } from './strategyToChartIndicators';
|
||||
|
||||
function walkTrendLineConditions(
|
||||
node: LogicNode | null | undefined,
|
||||
side: 'buy' | 'sell',
|
||||
out: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }>,
|
||||
): void {
|
||||
if (!node) return;
|
||||
if (node.type === 'TIMEFRAME') {
|
||||
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
|
||||
return;
|
||||
}
|
||||
if (node.type === 'NOT') {
|
||||
const child = node.children?.[0];
|
||||
if (child) walkTrendLineConditions(child, side, out);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'AND' || node.type === 'OR') {
|
||||
node.children?.forEach(c => walkTrendLineConditions(c, side, out));
|
||||
return;
|
||||
}
|
||||
if ((!node.type || node.type === 'CONDITION') && node.condition) {
|
||||
const ct = node.condition.conditionType;
|
||||
if (ct === 'TREND_LINE_CROSS_UP' || ct === 'TREND_LINE_CROSS_DOWN') {
|
||||
out.push({ side, cond: node.condition });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registryType(dslType: string): string {
|
||||
return DSL_TO_REGISTRY[dslType] ?? dslType;
|
||||
}
|
||||
|
||||
function isPriceScaleField(field?: string): boolean {
|
||||
if (!field) return true;
|
||||
const f = field.toUpperCase();
|
||||
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|
||||
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|
||||
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|
||||
|| f.includes('BAND') || f.includes('DC_');
|
||||
}
|
||||
|
||||
async function resolveSourceValues(
|
||||
bars: OHLCVBar[],
|
||||
cond: ConditionDSL,
|
||||
getParams: (type: string) => Record<string, number | string | boolean>,
|
||||
): Promise<number[] | null> {
|
||||
const lf = (cond.leftField ?? 'CLOSE_PRICE').toUpperCase();
|
||||
if (lf === 'CLOSE_PRICE' || lf === 'CURRENT') {
|
||||
return bars.map(b => b.close);
|
||||
}
|
||||
if (lf === 'HIGH_PRICE') return bars.map(b => b.high);
|
||||
if (lf === 'LOW_PRICE') return bars.map(b => b.low);
|
||||
if (lf === 'OPEN_PRICE') return bars.map(b => b.open);
|
||||
|
||||
const regType = registryType(cond.indicatorType);
|
||||
try {
|
||||
const params = { ...getParams(regType), ...(cond.params ?? {}) };
|
||||
const { plots } = await calculateIndicator(regType, bars, params);
|
||||
const plotKeys = Object.keys(plots);
|
||||
if (plotKeys.length === 0) return null;
|
||||
|
||||
let plotKey = plotKeys[0]!;
|
||||
if (lf.includes('K') && plots.plot0) plotKey = 'plot0';
|
||||
else if (lf.includes('SIGNAL') && plots.plot1) plotKey = 'plot1';
|
||||
else if (lf.includes('MACD') && plots.plot0) plotKey = 'plot0';
|
||||
else if (lf.includes('UPPER') && plots.plot2) plotKey = 'plot2';
|
||||
else if (lf.includes('LOWER') && plots.plot0) plotKey = 'plot0';
|
||||
else if (lf.includes('MIDDLE') && plots.plot1) plotKey = 'plot1';
|
||||
|
||||
const plot = plots[plotKey as keyof typeof plots];
|
||||
if (!plot || !Array.isArray(plot)) return null;
|
||||
const byTime = new Map(plot.map((p: { time: number; value: number }) => [p.time, p.value]));
|
||||
return bars.map(b => {
|
||||
const v = byTime.get(b.time);
|
||||
return v != null && Number.isFinite(v) ? v : NaN;
|
||||
});
|
||||
} catch {
|
||||
return bars.map(b => b.close);
|
||||
}
|
||||
}
|
||||
|
||||
/** 선택 봉(barIndex) 기준 추세선만 반환 — 시그널 전체 순회하지 않음 */
|
||||
export async function buildSignalTrendLineDrawings(opts: {
|
||||
bars: OHLCVBar[];
|
||||
/** 캔들 선택봉 인덱스 */
|
||||
barIndex: number;
|
||||
strategy: StrategyDto | null;
|
||||
settings?: BacktestSettingsDto | null;
|
||||
getParams: (type: string) => Record<string, number | string | boolean>;
|
||||
}): Promise<Drawing[]> {
|
||||
const { bars, barIndex, strategy, settings, getParams } = opts;
|
||||
if (!strategy || bars.length === 0 || barIndex < 0 || barIndex >= bars.length) return [];
|
||||
|
||||
const conditions: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }> = [];
|
||||
walkTrendLineConditions(strategy.buyCondition as LogicNode | null, 'buy', conditions);
|
||||
walkTrendLineConditions(strategy.sellCondition as LogicNode | null, 'sell', conditions);
|
||||
if (conditions.length === 0) return [];
|
||||
|
||||
const priceConds = conditions.filter(c => isPriceScaleField(c.cond.leftField));
|
||||
if (priceConds.length === 0) return [];
|
||||
|
||||
const config = trendLineConfigFromSettings(settings);
|
||||
const barTimes = bars.map(b => b.time);
|
||||
const drawings: Drawing[] = [];
|
||||
|
||||
const sourceCache = new Map<string, number[]>();
|
||||
for (const { cond } of priceConds) {
|
||||
const key = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
|
||||
if (!sourceCache.has(key)) {
|
||||
const values = await resolveSourceValues(bars, cond, getParams);
|
||||
if (values) sourceCache.set(key, values);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { side, cond } of priceConds) {
|
||||
const cacheKey = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`;
|
||||
const sourceValues = sourceCache.get(cacheKey);
|
||||
if (!sourceValues) continue;
|
||||
|
||||
const lookback = Math.max(2, cond.lookbackPeriod ?? config.defaultLookback);
|
||||
const kind = trendLineKindForCross(cond.conditionType, side);
|
||||
const priceScale = isTrendLinePriceScaleField(cond.leftField);
|
||||
const seg = trendLineSegmentAt(
|
||||
barTimes, sourceValues, bars, barIndex, lookback, config, kind, priceScale,
|
||||
);
|
||||
if (!seg) continue;
|
||||
|
||||
const id = `tl-${barIndex}-${side}-${cond.conditionType}-${lookback}-${config.mode}`;
|
||||
drawings.push({
|
||||
id,
|
||||
type: 'trendline',
|
||||
points: [
|
||||
{ time: seg.startTimeSec, price: seg.startPrice },
|
||||
{ time: seg.endTimeSec, price: seg.endPrice },
|
||||
],
|
||||
color: side === 'buy' ? '#26a69a' : '#ef5350',
|
||||
lineWidth: 2,
|
||||
style: 'dashed',
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
return drawings;
|
||||
}
|
||||
@@ -418,6 +418,8 @@ const COND_OPTIONS = [
|
||||
{ v: 'NEQ', l: '다름 (!=)' },
|
||||
{ v: '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<CondEditorProps> = ({
|
||||
};
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type StrategyIndicatorRef,
|
||||
} from './strategyIndicatorSync';
|
||||
|
||||
const DSL_TO_REGISTRY: Record<string, string> = {
|
||||
export const DSL_TO_REGISTRY: Record<string, string> = {
|
||||
RSI: 'RSI',
|
||||
MACD: 'MACD',
|
||||
MA: 'SMA',
|
||||
|
||||
@@ -104,6 +104,7 @@ export const CONDITION_LABEL: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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];
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* N봉 추세선 — backend TrendLineMath 와 동기 (스윙 피벗).
|
||||
* fit [anchor−N, anchor−1], 상승(UP)=스윙 저점·하락(DOWN)=스윙 고점, anchor까지 연장.
|
||||
*/
|
||||
import type { BacktestSettingsDto } from './backendApi';
|
||||
|
||||
export type TrendLineMode = 'TWO_POINT' | 'REGRESSION' | 'SWING';
|
||||
export type TrendLineKind = 'UP' | 'DOWN';
|
||||
|
||||
export interface TrendLineConfig {
|
||||
mode: TrendLineMode;
|
||||
defaultLookback: number;
|
||||
swingPrecedingBars: number;
|
||||
swingFollowingBars: number;
|
||||
}
|
||||
|
||||
export interface TrendLineSegment {
|
||||
startTimeSec: number;
|
||||
startPrice: number;
|
||||
endTimeSec: number;
|
||||
endPrice: number;
|
||||
}
|
||||
|
||||
interface PivotPair {
|
||||
indexA: number;
|
||||
yA: number;
|
||||
indexB: number;
|
||||
yB: number;
|
||||
}
|
||||
|
||||
export function trendLineKindForCross(conditionType?: string, side?: 'buy' | 'sell'): TrendLineKind {
|
||||
if (conditionType === 'TREND_LINE_CROSS_UP') return 'DOWN';
|
||||
if (conditionType === 'TREND_LINE_CROSS_DOWN') return 'UP';
|
||||
return side === 'buy' ? 'DOWN' : 'UP';
|
||||
}
|
||||
|
||||
export function isTrendLinePriceScaleField(field?: string): boolean {
|
||||
if (!field) return true;
|
||||
const f = field.toUpperCase();
|
||||
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|
||||
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|
||||
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|
||||
|| f.includes('BAND') || f.includes('DC_') || f.includes('VWAP')
|
||||
|| f.includes('CONVERSION') || f.includes('BASE') || f.includes('SPAN')
|
||||
|| f.includes('LAGGING') || f.includes('CLOUD');
|
||||
}
|
||||
|
||||
export function trendLineConfigFromSettings(
|
||||
settings?: Pick<
|
||||
BacktestSettingsDto,
|
||||
| 'trendLineMode'
|
||||
| 'trendLineDefaultLookback'
|
||||
| 'trendLineSwingPrecedingBars'
|
||||
| 'trendLineSwingFollowingBars'
|
||||
> | null,
|
||||
): TrendLineConfig {
|
||||
let mode = settings?.trendLineMode ?? 'SWING';
|
||||
if (mode !== 'TWO_POINT' && mode !== 'REGRESSION' && mode !== 'SWING') {
|
||||
mode = 'SWING';
|
||||
}
|
||||
const defaultLookback =
|
||||
settings?.trendLineDefaultLookback != null && settings.trendLineDefaultLookback > 1
|
||||
? settings.trendLineDefaultLookback
|
||||
: 10;
|
||||
return {
|
||||
mode,
|
||||
defaultLookback,
|
||||
swingPrecedingBars: settings?.trendLineSwingPrecedingBars ?? 1,
|
||||
swingFollowingBars: settings?.trendLineSwingFollowingBars ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function numAt(values: number[], index: number): number | null {
|
||||
if (index < 0 || index >= values.length) return null;
|
||||
const v = values[index];
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function yAtPivot(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
index: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
if (priceScale) {
|
||||
return kind === 'UP' ? bars[index]!.low : bars[index]!.high;
|
||||
}
|
||||
return numAt(sourceValues, index) ?? NaN;
|
||||
}
|
||||
|
||||
function isSwingLow(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
i: number,
|
||||
prec: number,
|
||||
fol: number,
|
||||
priceScale: boolean,
|
||||
): boolean {
|
||||
const low = yAtPivot(bars, sourceValues, i, 'UP', priceScale);
|
||||
if (!Number.isFinite(low)) return false;
|
||||
for (let j = i - prec; j <= i + fol; j++) {
|
||||
if (j === i) continue;
|
||||
if (j < 0 || j >= bars.length) return false;
|
||||
const v = yAtPivot(bars, sourceValues, j, 'UP', priceScale);
|
||||
if (!Number.isFinite(v) || v < low) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSwingHigh(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
i: number,
|
||||
prec: number,
|
||||
fol: number,
|
||||
priceScale: boolean,
|
||||
): boolean {
|
||||
const high = yAtPivot(bars, sourceValues, i, 'DOWN', priceScale);
|
||||
if (!Number.isFinite(high)) return false;
|
||||
for (let j = i - prec; j <= i + fol; j++) {
|
||||
if (j === i) continue;
|
||||
if (j < 0 || j >= bars.length) return false;
|
||||
const v = yAtPivot(bars, sourceValues, j, 'DOWN', priceScale);
|
||||
if (!Number.isFinite(v) || v > high) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectSwingIndices(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
kLeft: number,
|
||||
kRight: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = start + kLeft; i <= end - kRight; i++) {
|
||||
const isSwing = kind === 'UP'
|
||||
? isSwingLow(bars, sourceValues, i, kLeft, kRight, priceScale)
|
||||
: isSwingHigh(bars, sourceValues, i, kLeft, kRight, priceScale);
|
||||
if (isSwing) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolvePivots(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): PivotPair | null {
|
||||
const kLeft = Math.max(0, config.swingPrecedingBars);
|
||||
const kRight = Math.max(0, config.swingFollowingBars);
|
||||
const swings = collectSwingIndices(bars, sourceValues, start, end, kLeft, kRight, kind, priceScale);
|
||||
if (swings.length < 2) return null;
|
||||
|
||||
const swingA = swings[0]!;
|
||||
for (let j = 1; j < swings.length; j++) {
|
||||
const swingB = swings[j]!;
|
||||
if (swingB <= swingA) continue;
|
||||
const yA = yAtPivot(bars, sourceValues, swingA, kind, priceScale);
|
||||
const yB = yAtPivot(bars, sourceValues, swingB, kind, priceScale);
|
||||
if (!Number.isFinite(yA) || !Number.isFinite(yB)) continue;
|
||||
if (kind === 'UP' && yB > yA) return { indexA: swingA, yA, indexB: swingB, yB };
|
||||
if (kind === 'DOWN' && yB < yA) return { indexA: swingA, yA, indexB: swingB, yB };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lineAt(p: PivotPair, evalIndex: number): number {
|
||||
if (p.indexB === p.indexA) return p.yA;
|
||||
const t = (evalIndex - p.indexA) / (p.indexB - p.indexA);
|
||||
return p.yA + (p.yB - p.yA) * t;
|
||||
}
|
||||
|
||||
function twoPointAt(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
evalIndex: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
|
||||
const y1 = yAtPivot(bars, sourceValues, end, kind, priceScale);
|
||||
if (!Number.isFinite(y0) || !Number.isFinite(y1) || end === start) return NaN;
|
||||
const t = (evalIndex - start) / (end - start);
|
||||
return y0 + (y1 - y0) * t;
|
||||
}
|
||||
|
||||
function regressionAt(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
evalIndex: number,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumXY = 0;
|
||||
let sumXX = 0;
|
||||
let count = 0;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const y = yAtPivot(bars, sourceValues, i, kind, priceScale);
|
||||
if (!Number.isFinite(y)) continue;
|
||||
const x = i - start;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumXX += x * x;
|
||||
count++;
|
||||
}
|
||||
if (count < 2) return NaN;
|
||||
const denom = count * sumXX - sumX * sumX;
|
||||
if (Math.abs(denom) < 1e-12) return NaN;
|
||||
const b = (count * sumXY - sumX * sumY) / denom;
|
||||
const a = (sumY - b * sumX) / count;
|
||||
return a + b * (evalIndex - start);
|
||||
}
|
||||
|
||||
function fallbackValueAt(
|
||||
bars: { low: number; high: number }[],
|
||||
sourceValues: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
evalIndex: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
if (config.mode === 'SWING') return NaN;
|
||||
if (config.mode === 'REGRESSION') {
|
||||
return regressionAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
return twoPointAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
|
||||
}
|
||||
|
||||
export function trendLineValueAtEval(
|
||||
sourceValues: number[],
|
||||
bars: { low: number; high: number }[],
|
||||
anchorIndex: number,
|
||||
evalIndex: number,
|
||||
lookback: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): number {
|
||||
if (anchorIndex < 1 || lookback < 2) return NaN;
|
||||
const start = anchorIndex - lookback;
|
||||
const end = anchorIndex - 1;
|
||||
if (start < 0 || end < start) return NaN;
|
||||
|
||||
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
|
||||
if (pivots) return lineAt(pivots, evalIndex);
|
||||
return fallbackValueAt(bars, sourceValues, start, end, evalIndex, config, kind, priceScale);
|
||||
}
|
||||
|
||||
export function trendLineSegmentAt(
|
||||
barTimes: number[],
|
||||
sourceValues: number[],
|
||||
bars: { low: number; high: number }[],
|
||||
index: number,
|
||||
lookback: number,
|
||||
config: TrendLineConfig,
|
||||
kind: TrendLineKind,
|
||||
priceScale: boolean,
|
||||
): TrendLineSegment | null {
|
||||
if (index < 1 || lookback < 2 || sourceValues.length !== barTimes.length) return null;
|
||||
const start = index - lookback;
|
||||
const end = index - 1;
|
||||
if (start < 0 || end < start) return null;
|
||||
|
||||
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
|
||||
if (pivots) {
|
||||
const yAt = lineAt(pivots, index);
|
||||
if (!Number.isFinite(yAt)) return null;
|
||||
return {
|
||||
startTimeSec: barTimes[pivots.indexA]!,
|
||||
startPrice: pivots.yA,
|
||||
endTimeSec: barTimes[index]!,
|
||||
endPrice: yAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (config.mode === 'SWING') return null;
|
||||
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
|
||||
const yAt = fallbackValueAt(bars, sourceValues, start, end, index, config, kind, priceScale);
|
||||
if (!Number.isFinite(y0) || !Number.isFinite(yAt)) return null;
|
||||
return {
|
||||
startTimeSec: barTimes[start]!,
|
||||
startPrice: y0,
|
||||
endTimeSec: barTimes[index]!,
|
||||
endPrice: yAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 전략편집기 — 추세선 돌파 조건 팔레트
|
||||
*/
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import {
|
||||
applyCondTypeDefaults,
|
||||
genId,
|
||||
getDefaultConditionFields,
|
||||
type DefType,
|
||||
} from './strategyEditorShared';
|
||||
import { initConditionPeriodsInherit } from './conditionPeriods';
|
||||
|
||||
export interface TrendLinePaletteItem {
|
||||
id: string;
|
||||
indicatorType: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
leftField?: string;
|
||||
}
|
||||
|
||||
export const TREND_LINE_PALETTE_ITEMS: TrendLinePaletteItem[] = [
|
||||
{ id: 'tl-close', indicatorType: 'MA', label: '종가', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
|
||||
{ id: 'tl-ma', indicatorType: 'MA', label: 'MA', desc: '이동평균 vs N봉 추세선', leftField: 'MA20' },
|
||||
{ id: 'tl-ema', indicatorType: 'EMA', label: 'EMA', desc: '지수이동평균 vs N봉 추세선', leftField: 'EMA20' },
|
||||
{ id: 'tl-boll', indicatorType: 'BOLLINGER', label: '볼린저', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
|
||||
{ id: 'tl-rsi', indicatorType: 'RSI', label: 'RSI', desc: 'RSI vs N봉 추세선' },
|
||||
{ id: 'tl-macd', indicatorType: 'MACD', label: 'MACD', desc: 'MACD선 vs N봉 추세선', leftField: 'MACD_LINE' },
|
||||
{ id: 'tl-stoch', indicatorType: 'STOCHASTIC', label: 'Stochastic', desc: '%K vs N봉 추세선', leftField: 'K' },
|
||||
{ id: 'tl-cci', indicatorType: 'CCI', label: 'CCI', desc: 'CCI vs N봉 추세선' },
|
||||
{ id: 'tl-adx', indicatorType: 'ADX', label: 'ADX', desc: 'ADX vs N봉 추세선' },
|
||||
{ id: 'tl-obv', indicatorType: 'OBV', label: 'OBV', desc: 'OBV vs N봉 추세선' },
|
||||
];
|
||||
|
||||
export function findTrendLinePaletteItem(id: string): TrendLinePaletteItem | undefined {
|
||||
return TREND_LINE_PALETTE_ITEMS.find(i => i.id === id);
|
||||
}
|
||||
|
||||
export function filterTrendLinePaletteItems(query: string): TrendLinePaletteItem[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return TREND_LINE_PALETTE_ITEMS;
|
||||
return TREND_LINE_PALETTE_ITEMS.filter(
|
||||
i => i.label.toLowerCase().includes(q)
|
||||
|| i.desc.toLowerCase().includes(q)
|
||||
|| i.indicatorType.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTrendLineConditionNode(
|
||||
item: Pick<TrendLinePaletteItem, 'indicatorType' | 'leftField'>,
|
||||
signalType: 'buy' | 'sell',
|
||||
def: DefType,
|
||||
lookback = 10,
|
||||
): LogicNode {
|
||||
const defaults = getDefaultConditionFields(item.indicatorType, signalType, def);
|
||||
const condType = signalType === 'buy' ? 'TREND_LINE_CROSS_UP' : 'TREND_LINE_CROSS_DOWN';
|
||||
let condition = applyCondTypeDefaults(
|
||||
{
|
||||
indicatorType: item.indicatorType,
|
||||
conditionType: condType,
|
||||
leftField: item.leftField ?? defaults.l,
|
||||
rightField: 'NONE',
|
||||
lookbackPeriod: Math.max(2, lookback),
|
||||
candleRange: 1,
|
||||
valuePeriodOverride: false,
|
||||
thresholdOverride: false,
|
||||
rightPeriodOverride: false,
|
||||
},
|
||||
condType,
|
||||
def,
|
||||
);
|
||||
condition = initConditionPeriodsInherit(item.indicatorType, condition, def);
|
||||
return { id: genId(), type: 'CONDITION', condition };
|
||||
}
|
||||
|
||||
export function buildTrendLineConditionNodeFromDrag(data: {
|
||||
value: string;
|
||||
indicatorType?: string;
|
||||
leftField?: string;
|
||||
lookback?: number;
|
||||
}, signalType: 'buy' | 'sell', def: DefType): LogicNode | null {
|
||||
const item = findTrendLinePaletteItem(data.value);
|
||||
const indicatorType = item?.indicatorType ?? data.indicatorType;
|
||||
if (!indicatorType) return null;
|
||||
return buildTrendLineConditionNode(
|
||||
{ indicatorType, leftField: item?.leftField ?? data.leftField },
|
||||
signalType,
|
||||
def,
|
||||
data.lookback ?? 10,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user