Compare commits

...

10 Commits

Author SHA1 Message Date
Macbook 5edbb4d318 전략편집기 수정 2026-07-01 01:10:22 +09:00
Macbook e72af1a9ed 전략빌더 미니맵 제거 2026-07-01 00:15:48 +09:00
Macbook 935ae02373 전략 목록 드래그 2026-07-01 00:07:22 +09:00
Macbook 5438696fb3 수정 2026-06-30 22:40:16 +09:00
Macbook a3f65c26d0 ai 전략 agent 수정 2026-06-30 22:29:14 +09:00
Macbook 25cbadbffb ai 전략 기능 추가 2026-06-30 21:59:13 +09:00
Macbook f9447ea52f 추세선 전략추가 2026-06-25 02:45:39 +09:00
Macbook ee8cd50781 수익률, 전략명 위치 변경 2026-06-25 01:46:27 +09:00
Macbook 2ae79ff71d 전략편집기 평가탭 전략평가후 수익률 표시 2026-06-25 01:42:12 +09:00
Macbook a2816b9163 전략편집기 평가탭 시간봉 설정오류 수정 2026-06-25 01:36:32 +09:00
52 changed files with 4875 additions and 99 deletions
@@ -0,0 +1,46 @@
package com.goldenchart.controller;
import com.goldenchart.dto.StrategyAgentChatRequest;
import com.goldenchart.dto.StrategyAgentChatResponse;
import com.goldenchart.service.StrategyAgentChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL 생성/수정.
*
* POST /api/strategy/agent/chat
*/
@RestController
@RequestMapping("/strategy/agent")
@RequiredArgsConstructor
@Slf4j
public class StrategyAgentController {
private final StrategyAgentChatService agentService;
@PostMapping("/chat")
public ResponseEntity<?> chat(
@RequestBody StrategyAgentChatRequest body,
@RequestHeader Map<String, String> headers,
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
TradingControllerSupport.requireRegisteredUser(headers);
try {
Long userId = TradingControllerSupport.parseUserId(headers);
StrategyAgentChatResponse res = agentService.chat(body, userId, deviceId);
return ResponseEntity.ok(res);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
} catch (IllegalStateException e) {
return ResponseEntity.status(503).body(Map.of("message", e.getMessage()));
} catch (Exception e) {
log.warn("[StrategyAgent] chat 오류: {}", e.getMessage());
return ResponseEntity.internalServerError()
.body(Map.of("message", "AI 전략 생성 중 오류가 발생했습니다."));
}
}
}
@@ -1,10 +1,13 @@
package com.goldenchart.controller; package com.goldenchart.controller;
import com.goldenchart.dto.LlmModelsRequest;
import com.goldenchart.dto.LlmModelsResponse;
import com.goldenchart.dto.LlmTestResponse; import com.goldenchart.dto.LlmTestResponse;
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse; import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse;
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse; import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse;
import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest; import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest;
import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse; import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse;
import com.goldenchart.service.StrategyAgentChatService;
import com.goldenchart.service.StrategyEvaluationAiVerifyJobService; import com.goldenchart.service.StrategyEvaluationAiVerifyJobService;
import com.goldenchart.service.StrategyEvaluationLlmService; import com.goldenchart.service.StrategyEvaluationLlmService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -28,6 +31,7 @@ public class StrategyEvaluationController {
private final StrategyEvaluationLlmService llmService; private final StrategyEvaluationLlmService llmService;
private final StrategyEvaluationAiVerifyJobService aiVerifyJobService; private final StrategyEvaluationAiVerifyJobService aiVerifyJobService;
private final StrategyAgentChatService agentChatService;
@PostMapping("/ai-verify/jobs") @PostMapping("/ai-verify/jobs")
public ResponseEntity<?> startAiVerifyJob( public ResponseEntity<?> startAiVerifyJob(
@@ -89,6 +93,20 @@ public class StrategyEvaluationController {
} }
} }
@PostMapping("/llm-models")
public ResponseEntity<?> llmModels(
@RequestBody(required = false) LlmModelsRequest body,
@RequestHeader Map<String, String> headers) {
TradingControllerSupport.requireRegisteredUser(headers);
if (body == null || body.getHost() == null || body.getHost().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "호스트가 필요합니다."));
}
LlmModelsResponse res = agentChatService.listModels(
body.getHost(), body.getPort(), body.getUseHttps(),
body.getModelsPath(), body.getTimeoutMs());
return ResponseEntity.ok(res);
}
@PostMapping("/llm-test") @PostMapping("/llm-test")
public ResponseEntity<?> llmTest( public ResponseEntity<?> llmTest(
@RequestHeader Map<String, String> headers, @RequestHeader Map<String, String> headers,
@@ -114,4 +114,21 @@ public class BacktestSettingsDto {
*/ */
@Builder.Default @Builder.Default
private String tradeExecutionMode = "BACKTEST_ENGINE"; private String tradeExecutionMode = "BACKTEST_ENGINE";
// ── 추세선 조건 (TREND_LINE_CROSS_*) ─────────────────────────────────────
/** TWO_POINT | REGRESSION | SWING */
@Builder.Default
private String trendLineMode = "TWO_POINT";
/** 조건에 lookback 미지정 시 기본 N봉 */
@Builder.Default
private Integer trendLineDefaultLookback = 10;
/** SWING 모드 — 스윙 저점 좌측 확인 봉 수 */
@Builder.Default
private Integer trendLineSwingPrecedingBars = 1;
/** SWING 모드 — 스윙 저점 우측 확인 봉 수 */
@Builder.Default
private Integer trendLineSwingFollowingBars = 1;
} }
@@ -0,0 +1,14 @@
package com.goldenchart.dto;
import lombok.Data;
/** LLM 모델 목록 조회 — 저장 전 입력값으로 /v1/models 프록시 */
@Data
public class LlmModelsRequest {
private String host;
private Integer port;
private Boolean useHttps;
/** OpenAI 호환 모델 목록 경로 (기본 /v1/models) */
private String modelsPath;
private Long timeoutMs;
}
@@ -0,0 +1,19 @@
package com.goldenchart.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/** LLM 모델 목록 조회 결과 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LlmModelsResponse {
private boolean ok;
private List<String> models;
private String message;
}
@@ -0,0 +1,23 @@
package com.goldenchart.dto;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import java.util.List;
/** AI 전략 에이전트 — 멀티턴 대화 요청 */
@Data
public class StrategyAgentChatRequest {
/** user/assistant 대화 히스토리 (system 제외) */
private List<ChatMessage> messages;
/** 현재 편집기 상태 컨텍스트 (buy/sell DSL, candleType, 지표 설정 등) */
private JsonNode context;
@Data
public static class ChatMessage {
private String role;
private String content;
}
}
@@ -0,0 +1,25 @@
package com.goldenchart.dto;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** AI 전략 에이전트 — 응답 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StrategyAgentChatResponse {
/** 사용자에게 보여줄 한국어 설명 (json 블록 제거됨) */
private String reply;
/** "ask"(추가 질문) | "build"(전략 생성/수정) */
private String action;
/** build일 때 매수 조건 LogicNode 트리 (없으면 null) */
private JsonNode buyCondition;
/** build일 때 매도 조건 LogicNode 트리 (없으면 null) */
private JsonNode sellCondition;
private String model;
private long latencyMs;
}
@@ -0,0 +1,52 @@
package com.goldenchart.dto;
import lombok.*;
/**
* 추세선 조건 평가 설정 — gc_backtest_settings 와 동기.
* SWING: 스윙 피벗(저점·고점) 2개 연결 · TWO_POINT/REGRESSION: 피벗 실패 시 폴백
*/
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class TrendLineConfig {
public static final String MODE_TWO_POINT = "TWO_POINT";
public static final String MODE_REGRESSION = "REGRESSION";
public static final String MODE_SWING = "SWING";
@Builder.Default
private String mode = MODE_SWING;
@Builder.Default
private int defaultLookback = 10;
@Builder.Default
private int swingPrecedingBars = 1;
@Builder.Default
private int swingFollowingBars = 1;
public static TrendLineConfig defaults() {
return TrendLineConfig.builder().build();
}
public static TrendLineConfig fromSettings(BacktestSettingsDto dto) {
if (dto == null) return defaults();
String mode = dto.getTrendLineMode();
if (mode == null || mode.isBlank()) mode = MODE_TWO_POINT;
if (!MODE_TWO_POINT.equals(mode) && !MODE_REGRESSION.equals(mode) && !MODE_SWING.equals(mode)) {
mode = MODE_TWO_POINT;
}
int lookback = dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
? dto.getTrendLineDefaultLookback() : 10;
int prec = dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
? dto.getTrendLineSwingPrecedingBars() : 1;
int fol = dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
? dto.getTrendLineSwingFollowingBars() : 1;
return TrendLineConfig.builder()
.mode(mode)
.defaultLookback(lookback)
.swingPrecedingBars(prec)
.swingFollowingBars(fol)
.build();
}
}
@@ -0,0 +1,23 @@
package com.goldenchart.dto;
/**
* 추세선 종류 — 돌파 방향과 짝을 이룸.
* <ul>
* <li>{@link #UP} 상승(지지) 추세선 — 스윙 저점(Low) 연결, 저점이 높아져야 함</li>
* <li>{@link #DOWN} 하락(저항) 추세선 — 스윙 고점(High) 연결, 고점이 낮아져야 함</li>
* </ul>
*/
public enum TrendLineKind {
/** 지지선 — TREND_LINE_CROSS_DOWN (하향 돌파) */
UP,
/** 저항선 — TREND_LINE_CROSS_UP (상향 돌파) */
DOWN;
public static TrendLineKind forCrossUp() {
return DOWN;
}
public static TrendLineKind forCrossDown() {
return UP;
}
}
@@ -132,6 +132,23 @@ public class GcBacktestSettings {
@Builder.Default @Builder.Default
private String tradeExecutionMode = "BACKTEST_ENGINE"; private String tradeExecutionMode = "BACKTEST_ENGINE";
/** TWO_POINT | REGRESSION | SWING */
@Column(name = "trend_line_mode", nullable = false, length = 20)
@Builder.Default
private String trendLineMode = "TWO_POINT";
@Column(name = "trend_line_default_lookback", nullable = false)
@Builder.Default
private Integer trendLineDefaultLookback = 10;
@Column(name = "trend_line_swing_preceding_bars", nullable = false)
@Builder.Default
private Integer trendLineSwingPrecedingBars = 1;
@Column(name = "trend_line_swing_following_bars", nullable = false)
@Builder.Default
private Integer trendLineSwingFollowingBars = 1;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -125,6 +125,21 @@ public class BacktestSettingsService {
"SCAN_SIGNALS".equals(dto.getTradeExecutionMode()) "SCAN_SIGNALS".equals(dto.getTradeExecutionMode())
? "SCAN_SIGNALS" ? "SCAN_SIGNALS"
: "BACKTEST_ENGINE"); : "BACKTEST_ENGINE");
entity.setTrendLineMode(normalizeTrendLineMode(dto.getTrendLineMode()));
entity.setTrendLineDefaultLookback(
dto.getTrendLineDefaultLookback() != null && dto.getTrendLineDefaultLookback() > 1
? dto.getTrendLineDefaultLookback() : 10);
entity.setTrendLineSwingPrecedingBars(
dto.getTrendLineSwingPrecedingBars() != null && dto.getTrendLineSwingPrecedingBars() >= 0
? dto.getTrendLineSwingPrecedingBars() : 1);
entity.setTrendLineSwingFollowingBars(
dto.getTrendLineSwingFollowingBars() != null && dto.getTrendLineSwingFollowingBars() >= 0
? dto.getTrendLineSwingFollowingBars() : 1);
}
private static String normalizeTrendLineMode(String mode) {
if ("REGRESSION".equals(mode) || "SWING".equals(mode)) return mode;
return "TWO_POINT";
} }
// ── 변환 헬퍼 ───────────────────────────────────────────────────────────── // ── 변환 헬퍼 ─────────────────────────────────────────────────────────────
@@ -157,6 +172,10 @@ public class BacktestSettingsService {
"SCAN_SIGNALS".equals(e.getTradeExecutionMode()) "SCAN_SIGNALS".equals(e.getTradeExecutionMode())
? "SCAN_SIGNALS" ? "SCAN_SIGNALS"
: "BACKTEST_ENGINE") : "BACKTEST_ENGINE")
.trendLineMode(normalizeTrendLineMode(e.getTrendLineMode()))
.trendLineDefaultLookback(e.getTrendLineDefaultLookback() != null ? e.getTrendLineDefaultLookback() : 10)
.trendLineSwingPrecedingBars(e.getTrendLineSwingPrecedingBars() != null ? e.getTrendLineSwingPrecedingBars() : 1)
.trendLineSwingFollowingBars(e.getTrendLineSwingFollowingBars() != null ? e.getTrendLineSwingFollowingBars() : 1)
.build(); .build();
} }
} }
@@ -132,9 +132,11 @@ public class BacktestingService {
series, primaryTf, buyDsl, sellDsl); series, primaryTf, buyDsl, sellDsl);
String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null; String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null;
com.goldenchart.dto.TrendLineConfig trendLineConfig =
com.goldenchart.dto.TrendLineConfig.fromSettings(cfg);
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext( new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, null, false, seriesOverrides); series, params, visual, market, null, false, seriesOverrides, trendLineConfig);
Rule entryRule = adapter.toRule(buyDsl, ruleCtx); Rule entryRule = adapter.toRule(buyDsl, ruleCtx);
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
? adapter.toRule(sellDsl, ruleCtx) ? adapter.toRule(sellDsl, ruleCtx)
@@ -4,9 +4,11 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.goldenchart.dto.BacktestResponse; import com.goldenchart.dto.BacktestResponse;
import com.goldenchart.dto.BacktestSettingsDto;
import com.goldenchart.dto.LiveConditionRowDto; import com.goldenchart.dto.LiveConditionRowDto;
import com.goldenchart.dto.LiveConditionStatusDto; import com.goldenchart.dto.LiveConditionStatusDto;
import com.goldenchart.dto.OhlcvBar; import com.goldenchart.dto.OhlcvBar;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.entity.GcStrategy; import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.repository.GcStrategyRepository;
import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.storage.Ta4jStorage;
@@ -38,6 +40,8 @@ public class LiveConditionStatusService {
Map.entry("NEQ", "다름(≠)"), Map.entry("NEQ", "다름(≠)"),
Map.entry("CROSS_UP", "상향 돌파"), Map.entry("CROSS_UP", "상향 돌파"),
Map.entry("CROSS_DOWN", "하향 돌파"), Map.entry("CROSS_DOWN", "하향 돌파"),
Map.entry("TREND_LINE_CROSS_UP", "추세선 상향 돌파"),
Map.entry("TREND_LINE_CROSS_DOWN", "추세선 하향 돌파"),
Map.entry("SLOPE_UP", "상승 중"), Map.entry("SLOPE_UP", "상승 중"),
Map.entry("LOWEST_LTE", "N봉 최저 ≤"), Map.entry("LOWEST_LTE", "N봉 최저 ≤"),
Map.entry("LOWEST_GTE", "N봉 최저 ≥"), Map.entry("LOWEST_GTE", "N봉 최저 ≥"),
@@ -53,6 +57,27 @@ public class LiveConditionStatusService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DynamicSubscriptionManager subscriptionManager; private final DynamicSubscriptionManager subscriptionManager;
private final StrategyDslTimeframeNormalizer timeframeNormalizer; private final StrategyDslTimeframeNormalizer timeframeNormalizer;
private final BacktestSettingsService backtestSettingsService;
private TrendLineConfig trendLineConfigFor(Long userId, String deviceId) {
BacktestSettingsDto settings = backtestSettingsService.get(userId, deviceId);
return TrendLineConfig.fromSettings(settings);
}
private StrategyDslToTa4jAdapter.RuleBuildContext buildRuleCtx(
BarSeries series,
Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual,
String market,
Ta4jStorage storage,
boolean useConfirmedOnly,
Map<String, BarSeries> seriesOverrides,
Long userId,
String deviceId) {
return new StrategyDslToTa4jAdapter.RuleBuildContext(
series, params, visual, market, storage, useConfirmedOnly, seriesOverrides,
trendLineConfigFor(userId, deviceId));
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public LiveConditionStatusDto evaluate(Long userId, String deviceId, public LiveConditionStatusDto evaluate(Long userId, String deviceId,
@@ -104,7 +129,7 @@ public class LiveConditionStatusService {
} }
if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) { if (chartBars != null && !chartBars.isEmpty() && chartTimeframe != null && !chartTimeframe.isBlank()) {
return evaluateOnChartBars(strategy, market, strategyId, chartBars, chartTimeframe, return evaluateOnChartBars(userId, deviceId, strategy, market, strategyId, chartBars, chartTimeframe,
barTimeSec, params, visual); barTimeSec, params, visual);
} }
@@ -142,8 +167,8 @@ public class LiveConditionStatusService {
wrapper.set("condition", p.condition()); wrapper.set("condition", p.condition());
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(series, params, visual, market, ta4jStorage, false,
series, params, visual, market, ta4jStorage, false, java.util.Map.of()); java.util.Map.of(), userId, deviceId);
Rule rule = adapter.toRule(wrapper, ctx); Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null); boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue( Double current = adapter.readConditionFieldValue(
@@ -167,9 +192,9 @@ public class LiveConditionStatusService {
// [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영 // [항목6] 전체 논리 트리 평가 — matchRate 와 달리 AND/OR/NOT 게이트 완전 반영
Boolean overallEntryMet = evaluateOverallRule( Boolean overallEntryMet = evaluateOverallRule(
strategy.getBuyConditionJson(), market, params, visual, barTimeSec); strategy.getBuyConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
Boolean overallExitMet = evaluateOverallRule( Boolean overallExitMet = evaluateOverallRule(
strategy.getSellConditionJson(), market, params, visual, barTimeSec); strategy.getSellConditionJson(), market, params, visual, barTimeSec, userId, deviceId);
return LiveConditionStatusDto.builder() return LiveConditionStatusDto.builder()
.market(market) .market(market)
@@ -267,6 +292,8 @@ public class LiveConditionStatusService {
* 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지. * 차트·백테스트와 동일 OHLCV 로 조건 평가 — Ta4jStorage 와의 불일치 방지.
*/ */
private LiveConditionStatusDto evaluateOnChartBars( private LiveConditionStatusDto evaluateOnChartBars(
Long userId,
String deviceId,
GcStrategy strategy, GcStrategy strategy,
String market, String market,
long strategyId, long strategyId,
@@ -314,8 +341,8 @@ public class LiveConditionStatusService {
wrapper.set("condition", p.condition()); wrapper.set("condition", p.condition());
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(series, params, visual, market, null, false,
series, params, visual, market, null, false, seriesOverrides); seriesOverrides, userId, deviceId);
Rule rule = adapter.toRule(wrapper, ctx); Rule rule = adapter.toRule(wrapper, ctx);
boolean satisfied = rule.isSatisfied(index, null); boolean satisfied = rule.isSatisfied(index, null);
Double current = adapter.readConditionFieldValue( Double current = adapter.readConditionFieldValue(
@@ -336,9 +363,11 @@ public class LiveConditionStatusService {
int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0; int matchRate = evaluable > 0 ? Math.round(100f * met / evaluable) : 0;
Boolean overallEntryMet = evaluateOverallRuleOnChart( Boolean overallEntryMet = evaluateOverallRuleOnChart(
buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); buyDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
userId, deviceId);
Boolean overallExitMet = evaluateOverallRuleOnChart( Boolean overallExitMet = evaluateOverallRuleOnChart(
sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec); sellDsl, primarySeries, seriesOverrides, market, params, visual, barTimeSec,
userId, deviceId);
return LiveConditionStatusDto.builder() return LiveConditionStatusDto.builder()
.market(market) .market(market)
@@ -392,14 +421,16 @@ public class LiveConditionStatusService {
String market, String market,
Map<String, Map<String, Object>> params, Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual, Map<String, Map<String, Object>> visual,
Long barTimeSec) { Long barTimeSec,
Long userId,
String deviceId) {
if (dsl == null || dsl.isNull()) return null; if (dsl == null || dsl.isNull()) return null;
try { try {
if (primarySeries == null || primarySeries.isEmpty()) return null; if (primarySeries == null || primarySeries.isEmpty()) return null;
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(primarySeries, params, visual, market, null, false,
primarySeries, params, visual, market, null, false, seriesOverrides); seriesOverrides, userId, deviceId);
Rule rule = adapter.toRule(dsl, ctx); Rule rule = adapter.toRule(dsl, ctx);
int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); int index = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec);
return rule.isSatisfied(index, null); return rule.isSatisfied(index, null);
@@ -441,7 +472,9 @@ public class LiveConditionStatusService {
private Boolean evaluateOverallRule(String conditionJson, String market, private Boolean evaluateOverallRule(String conditionJson, String market,
Map<String, Map<String, Object>> params, Map<String, Map<String, Object>> params,
Map<String, Map<String, Object>> visual, Map<String, Map<String, Object>> visual,
Long barTimeSec) { Long barTimeSec,
Long userId,
String deviceId) {
if (conditionJson == null || conditionJson.isBlank()) return null; if (conditionJson == null || conditionJson.isBlank()) return null;
try { try {
String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper); String normalized = StrategyConditionThresholdNormalizer.normalizeJson(conditionJson, objectMapper);
@@ -461,8 +494,8 @@ public class LiveConditionStatusService {
if (series.isEmpty()) return null; if (series.isEmpty()) return null;
StrategyDslToTa4jAdapter.RuleBuildContext ctx = StrategyDslToTa4jAdapter.RuleBuildContext ctx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(series, params, visual, market, ta4jStorage, false,
series, params, visual, market, ta4jStorage, false, java.util.Map.of()); java.util.Map.of(), userId, deviceId);
org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx); org.ta4j.core.Rule rule = adapter.toRule(dsl, ctx);
return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null); return rule.isSatisfied(resolveBarIndex(series, barTimeSec), null);
} catch (Exception e) { } catch (Exception e) {
@@ -798,8 +831,8 @@ public class LiveConditionStatusService {
int startIdx = resolveChartScanStartIndex(primarySeries, evalCount); int startIdx = resolveChartScanStartIndex(primarySeries, evalCount);
StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx =
new StrategyDslToTa4jAdapter.RuleBuildContext( buildRuleCtx(primarySeries, params, visual, market, null, false,
primarySeries, params, visual, market, null, false, seriesOverrides); seriesOverrides, userId, deviceId);
Rule entryRule = (buyDsl != null && !buyDsl.isNull()) Rule entryRule = (buyDsl != null && !buyDsl.isNull())
? adapter.toRule(buyDsl, ruleCtx) ? adapter.toRule(buyDsl, ruleCtx)
: new org.ta4j.core.rules.BooleanRule(false); : new org.ta4j.core.rules.BooleanRule(false);
@@ -0,0 +1,406 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.goldenchart.dto.LlmModelsResponse;
import com.goldenchart.dto.StrategyAgentChatRequest;
import com.goldenchart.dto.StrategyAgentChatResponse;
import com.goldenchart.service.LlmSettingsResolver.ResolvedLlmSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL(LogicNode 트리)을 생성/수정한다.
*
* <p>설정에 저장된 LLM(OpenAI 호환)을 사용한다. 주식/코인 매매 전략 전문가로서 조언·전략 추천을 제공하며,
* 정보가 부족하면 되묻고(action=ask), 충분하면 전략을 생성/수정하고(action=build), 초기화 요청 시 비운다(action=clear).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class StrategyAgentChatService {
/** ```json ... ``` 또는 ``` ... ``` 코드펜스 추출 */
private static final Pattern JSON_FENCE =
Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);
private static final String SYSTEM_PROMPT = """
당신은 GoldenChart 전략편집기의 AI 전략 에이전트이자, 보조지표·캔들차트·차트패턴을 활용한
주식/코인 매매 전략에 정통한 전문 트레이더입니다.
사용자의 자연어 요청을 받아 매매 시그널 전략을 GoldenChart 전략 DSL(JSON)로 구성/수정하며,
동시에 전문가 관점의 조언을 제공합니다.
【전문가 역할 — 조언】
- 단순히 시키는 대로만 만들지 말고, 트레이더의 시각에서 더 나은 구성을 제안하세요.
예: 추세장/횡보장 적합성, 휩쏘(whipsaw) 위험, 후행성·과최적화 위험, 손절·필터 보강, 다중 시간봉 정합성.
- 지표의 의미와 신호의 강·약을 짧게 설명하고(왜 이 조건이 매수/매도 신호인지), 보완 필터를 권할 수 있으면 권하세요.
- 비현실적이거나 모순된 조건(예: 같은 봉에서 과매수 매수 + 과매도 매도)은 정중히 지적하고 대안을 제시하세요.
- 설명은 간결한 한국어로. 과도하게 길게 쓰지 말고 핵심 위주로.
【전략 추천 — recommend】
- 사용자가 "추천해줘"(예: "일목균형표 매수/매도 전략 추천해줘")라고 하면, 보통 2~4개의 구체적 전략 후보를
번호로 제시하고 각 후보의 신호 논리·장단점·적합 시장을 한두 줄로 설명한 뒤,
"몇 번을 적용할까요?"처럼 선택을 유도하세요. 이때는 DSL을 만들지 말고 질문 턴으로 둡니다. (action=ask)
- 사용자가 번호를 고르거나 "그걸로 해줘"라고 하면 해당 후보를 DSL로 구성합니다. (action=build)
- 사용자가 처음부터 "바로 만들어줘"라고 하면 가장 대표적인 후보 하나를 바로 build 해도 됩니다.
【평가 결과 해석·문제 진단 — analyze】
- 사용자가 평가탭 결과 해석/분석/문제 진단(예: "매수 시그널이 왜 안 나와?", "이 결과 해석해줘",
"매도가 너무 자주 나오는 원인 분석해줘")을 요청하면, 【현재 편집기 상태】의 evaluation 데이터
(market·timeframe·barCount·signalsTotal·buyCount·sellCount·recentSignals·summary)와 현재 DSL을 근거로
전문 트레이더 관점에서 분석합니다. 이때는 보통 설명만 제공하고 DSL은 바꾸지 않습니다. (action=ask)
- evaluation 데이터가 없거나 signalsTotal이 비어있으면, 먼저 "평가탭에서 평가를 실행해 주세요"라고 안내하세요.
- 시그널이 전혀/거의 안 나오는 흔한 원인과 해결책을 구체적으로 제시:
· 조건 과다 AND 결합(특히 여러 CROSS 이벤트의 동시 충족은 매우 드묾) → 핵심 조건만 남기거나 일부를 EXISTS_IN(N봉 이내)로 완화
· 임계값이 극단적(예: RSI 10/90) → 30/70 등 현실값으로 조정
· CROSS_UP/DOWN을 같은 봉에서 다수 요구 → 윈도우(candleRangeMode=EXISTS_IN, candleRange=N)로 시간차 허용
· 타임프레임 불일치 / 평가봉 수 부족(워밍업 미달로 일목·장기 MA 미계산) → 평가 구간 확대
· 상호배타 조건(같은 봉 과매수 매수 + 과매도) → 논리 재구성
- 시그널이 과도하게 많으면: 필터(추세 방향, 거래량 확인) 추가, AND 보강, 윈도우 축소를 권하세요.
- 분석 끝에 개선책을 제안하고 "적용해 드릴까요?"로 물으세요. 사용자가 동의하면 해당 수정안을 build로 반영합니다.
【행동 규칙 — 멀티턴 에이전트】
- 요청이 모호하거나 핵심 정보가 빠졌으면, 전략을 만들지 말고 한국어로 짧고 구체적으로 되물으세요. (action=ask)
핵심 정보 예: 매수/매도 구분, 지표 기간(예: 일목 9/26/52), 임계값(예: RSI 30), 캔들 종류(예: 5분봉),
"N봉 이내" 같은 윈도우 조건 여부.
- 정보가 충분하면 전략을 생성하거나, 이미 전략이 있으면 그것을 수정합니다. (action=build)
- 사용자가 "초기화", "리셋", "새 전략", "새 조건", "처음부터 다시", "다 지워" 등 전략을 비우고 처음부터
새로 작성하길 원하면, 모든 조건을 비웁니다. (action=clear)
- 현재 편집기 상태(【현재 편집기 상태】의 buyCondition/sellCondition)가 비어있지 않으면, 처음부터 새로
만들지 말고 기존 DSL을 최소 변경으로 수정하세요. (단, 초기화 요청이면 clear)
- 한 번에 한두 가지만 묻고, 합리적인 기본값이 있으면 가정하고 진행하되 가정한 내용을 설명에 명시하세요.
【출력 형식】
- 항상 한국어 설명을 먼저 작성합니다.
- action=build일 때만, 설명 뒤에 단 하나의 ```json 코드블록을 추가합니다. 형식:
```json
{ "action": "build", "buyCondition": <LogicNode 또는 null>, "sellCondition": <LogicNode 또는 null> }
```
- action=clear일 때는 설명 뒤에 ```json { "action": "clear" } ``` 블록만 추가합니다(조건 없음).
- action=ask일 때는 json 코드블록을 절대 넣지 마세요(질문만).
- 매수 전략만 요청되면 sellCondition은 null로 둡니다(반대도 동일).
【LogicNode 스키마】
- 노드: { "id": string, "type": "AND"|"OR"|"NOT"|"CONDITION"|"TIMEFRAME", "children": [LogicNode], "condition": ConditionDSL }
- AND/OR: children 배열을 결합. NOT: children[0] 부정. CONDITION: leaf 조건(condition 필수).
- TIMEFRAME: { "type":"TIMEFRAME", "candleType":"5m", "children":[...] } — 하위 조건을 해당 분봉으로 평가.
- id는 고유 문자열(예: "n1","n2"...).
- ConditionDSL: {
"indicatorType": string, "conditionType": string,
"leftField": string, "rightField": string,
"period": number?, "leftPeriod": number?, "rightPeriod": number?,
"candleRangeMode": "CURRENT"|"EXISTS_IN"|"NOT_EXISTS_IN"?, "candleRange": number?,
"valuePeriodOverride": boolean?, "thresholdOverride": boolean?, "targetValue": number?
}
【conditionType】
- 공통: GT, LT, GTE, LTE, EQ, NEQ, CROSS_UP, CROSS_DOWN, SLOPE_UP, SLOPE_DOWN, DIFF_GT, DIFF_LT, HOLD_N_DAYS, LOWEST_LTE, LOWEST_GTE, TREND_LINE_CROSS_UP, TREND_LINE_CROSS_DOWN
- 일목 전용(indicatorType=ICHIMOKU): ABOVE_CLOUD, BELOW_CLOUD, IN_CLOUD, CLOUD_BREAK_UP, CLOUD_BREAK_DOWN, SPAN1_GT_SPAN2, SPAN1_LT_SPAN2, SPAN1_CROSS_UP_SPAN2, SPAN1_CROSS_DOWN_SPAN2, LAGGING_GT_PRICE, LAGGING_LT_PRICE, LAGGING_ABOVE_PRIOR_CLOUD, CHIKOU_CROSS_ABOVE_PRIOR_CLOUD
- 거래량(indicatorType=VOLUME): VOLUME_GT_MA_RATIO
- CROSS_UP/CROSS_DOWN은 "직전봉→현재봉" 교차 이벤트이며 단순 수준 비교(GT/LT)와 다릅니다.
【indicatorType 과 주요 필드(leftField/rightField)】
- 가격/거래량: CLOSE_PRICE, OPEN_PRICE, HIGH_PRICE, LOW_PRICE, VOLUME_VALUE, VOLUME_MA, NONE
- RSI: leftField RSI_VALUE / rightField HL_OVER|HL_MID|HL_UNDER 또는 K_{숫자}
- MACD: MACD_LINE, SIGNAL_LINE, HISTOGRAM / rightField SIGNAL_LINE 또는 HL_MID(0선)
- STOCHASTIC: STOCH_K, STOCH_D / rightField STOCH_D, HL_OVER|HL_UNDER, K_{숫자}
- CCI: CCI_VALUE / OVERBOUGHT_100, OVERSOLD_NEG100, K_{숫자}
- ADX: ADX_VALUE / ADX_25, K_{숫자}; DMI: PDI, MDI (서로 교차)
- MA: MA1..MA11 (period1..11) — SMA 슬롯; EMA: EMA1..EMA11
- BOLLINGER: UPPER_BAND, MIDDLE_BAND, LOWER_BAND (가격과 비교)
- DONCHIAN: DC_UPPER_{9|10|20|55}, DC_LOWER_{...}, DC_MIDDLE_20
- ICHIMOKU: CONVERSION_LINE(전환선), BASE_LINE(기준선), LEADING_SPAN1, LEADING_SPAN2, LAGGING_SPAN(후행스팬)
- 임계값 직접지정: rightField "K_{숫자}", thresholdOverride=true, targetValue=숫자
【기간 지정】
- 전략 전용 기간을 쓰려면 valuePeriodOverride=true 와 period 를 함께 지정(예: RSI 9 → period:9).
- 일목 기본 9/26/52는 indicatorType=ICHIMOKU의 conditionType만으로 충분(별도 period 불필요).
【윈도우(N봉 이내)】
- "최근 N봉 내 1회라도" → candleRangeMode:"EXISTS_IN", candleRange:N (N>=2)
- "최근 N봉 내 한 번도 없음""NOT_EXISTS_IN"
- 미지정 시 현재 봉만 평가(CURRENT).
【지표의 의미와 수학 — 조건 설계 기준】
- 추세 지표(MA/EMA/일목/볼린저 중심선): 방향성. 골든크로스(단기 MA가 장기 MA를 CROSS_UP)=매수 전환,
데드크로스(CROSS_DOWN)=매도 전환. 정배열/역배열로 추세 강도 판단.
- 모멘텀/오실레이터(RSI·STOCH·CCI·Williams%R): 과매수/과매도 + 교차. RSI<30 과매도, >70 과매수.
"과매도에서 상향돌파"는 RSI가 30선을 CROSS_UP(반등 매수), "과매수 이탈"은 70선 CROSS_DOWN(매도).
수준 비교(GT/LT)와 교차(CROSS_UP/DOWN)는 다릅니다 — 교차는 직전봉→현재봉 2봉 이벤트.
- MACD: MACD_LINE이 SIGNAL_LINE을 CROSS_UP=매수, 0선(HL_MID) 상향돌파=추세 강화. 히스토그램 부호 전환도 신호.
- 볼린저밴드: LOWER_BAND 터치 후 반등=평균회귀 매수, UPPER_BAND 돌파=추세 추종. 밴드폭으로 변동성 판단.
- 거래량: 신호의 확인용 필터로 권장. VOLUME_GT_MA_RATIO로 평균 대비 급증을 동반한 돌파만 채택하면 휩쏘 감소.
- 다중 조건 결합: 추세(방향) AND 모멘텀(타이밍) AND 거래량(확인) 형태가 견고. 단일 오실레이터만 쓰면 추세장에서 약함.
- 좋은 매수=여러 근거의 AND, 좋은 매도=빠른 이탈 위해 OR(손절·반전 중 하나라도)가 흔함.
【일목균형표(ICHIMOKU) 전략 지식 — 추천 재료】
- 구성: 전환선(CONVERSION_LINE, 9), 기준선(BASE_LINE, 26), 선행스팬1/2(LEADING_SPAN1/2, 구름), 후행스팬(LAGGING_SPAN, 26봉 전).
- 대표 매수 신호:
1) 전환선이 기준선을 상향돌파(CROSS_UP) — 단기 모멘텀 전환(약~중 신호).
2) 가격이 구름 위로 상향돌파(CLOUD_BREAK_UP) 또는 구름 위(ABOVE_CLOUD) — 추세 전환/유지(강 신호).
3) 후행스팬이 26봉 전 종가를 상향돌파(LAGGING_GT_PRICE) — 후행 확인(신뢰도↑, 후행성 있음).
4) 삼역호전(전환>기준 AND 가격>구름 AND 후행>가격)=강력 매수. AND로 결합.
- 대표 매도 신호: 전환선<기준선(CROSS_DOWN), 가격 구름 하향돌파(CLOUD_BREAK_DOWN/BELOW_CLOUD), 후행<가격(LAGGING_LT_PRICE).
- 추천 시 강도/지연 트레이드오프를 설명: (1)은 빠르지만 휩쏘 위험, (3)/삼역호전은 느리지만 신뢰도 높음.
【예시 0 — 전략 추천(ask)】
사용자: "일목균형표 매수 전략 추천해줘"
응답(요지): "일목균형표 매수 전략 3가지를 추천드립니다.
1) 전환선 > 기준선 상향돌파 — 빠른 진입, 휩쏘 주의(횡보장 비권장)
2) 가격의 구름 상향돌파 — 추세 전환 포착, 신뢰도 중상
3) 삼역호전(전환>기준 AND 가격>구름 위 AND 후행스팬>가격) — 가장 보수적·신뢰도 높음, 진입 지연
어떤 전략을 적용할까요? (예: 3번, 또는 '거래량 필터도 추가')" (json 블록 없음)
【예시 0-A — 평가 결과 분석(ask)】
사용자: "매수 시그널이 하나도 안 나와. 왜 그런지 분석해줘"
응답(요지, evaluation 데이터 기반): "현재 매수 조건이 '전환선>기준 상향돌파 AND 후행스팬>가격 상향돌파 AND
RSI 80 상향돌파' 3개의 CROSS 이벤트를 같은 봉에서 모두 요구합니다. 서로 다른 교차가 한 봉에 동시에 일어날
확률은 매우 낮아 시그널이 0건입니다. 또 RSI 80 돌파는 과매수 영역이라 매수 타이밍과 상충합니다.
해결책: ① RSI 조건 제거 또는 30선 상향돌파로 변경, ② 후행스팬 조건을 '최근 4봉 이내(EXISTS_IN)'로 완화,
③ 핵심은 '전환선>기준 상향돌파'만 남기기. 이 방향으로 수정해 적용해 드릴까요?" (json 블록 없음)
【예시 1 — 정보 부족(ask)】
사용자: "골든크로스로 매수해줘"
응답: "어떤 이동평균선 조합으로 골든크로스를 잡을까요? 예를 들어 단기 5 / 장기 20처럼 기간을 알려주세요. 캔들은 어떤 분봉인가요?"
(json 블록 없음)
【예시 2 — 일목 전환선이 기준선 상향돌파 매수(build)】
응답: "일목균형표 전환선이 기준선을 상향돌파할 때 매수하도록 구성했습니다."
```json
{ "action": "build",
"buyCondition": { "id":"n1","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"CROSS_UP","leftField":"CONVERSION_LINE","rightField":"BASE_LINE","candleRangeMode":"CURRENT"} },
"sellCondition": null }
```
【예시 3 — 후행스팬이 26봉 전 캔들 상향돌파 AND 전환선이 기준선 상향돌파(build)】
응답: "후행스팬의 가격 상향돌파와 전환선의 기준선 상향돌파를 모두 만족할 때 매수합니다."
```json
{ "action": "build",
"buyCondition": { "id":"r","type":"AND","children":[
{ "id":"a","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"LAGGING_GT_PRICE","leftField":"LAGGING_SPAN","rightField":"NONE","candleRangeMode":"CURRENT"} },
{ "id":"b","type":"CONDITION","condition":{"indicatorType":"ICHIMOKU","conditionType":"CROSS_UP","leftField":"CONVERSION_LINE","rightField":"BASE_LINE","candleRangeMode":"CURRENT"} }
] },
"sellCondition": null }
```
""";
private final WebClient.Builder webClientBuilder;
private final ObjectMapper objectMapper;
private final LlmSettingsResolver llmSettingsResolver;
public StrategyAgentChatResponse chat(StrategyAgentChatRequest request, Long userId, String deviceId) throws Exception {
ResolvedLlmSettings cfg = llmSettingsResolver.resolve(userId, deviceId);
if (!cfg.enabled()) {
throw new IllegalStateException("LLM 사용이 비활성화되어 있습니다. 설정 > LLM에서 사용을 켜 주세요.");
}
if (request == null || request.getMessages() == null || request.getMessages().isEmpty()) {
throw new IllegalArgumentException("messages 가 필요합니다.");
}
long started = System.currentTimeMillis();
ObjectNode body = buildChatBody(cfg, request);
String responseJson = postChat(cfg, body);
JsonNode root = objectMapper.readTree(responseJson);
String content = extractAssistantContent(root);
if (content == null || content.isBlank()) {
throw new IllegalStateException("LLM 응답에서 텍스트를 찾을 수 없습니다.");
}
return parseAssistantContent(content, root.path("model").asText(cfg.model()),
System.currentTimeMillis() - started);
}
/** 저장 전 입력값(host/port/useHttps/modelsPath)으로 /v1/models 프록시 조회 */
public LlmModelsResponse listModels(String host, Integer port, Boolean useHttps,
String modelsPath, Long timeoutMs) {
int p = port != null ? Math.max(1, Math.min(65535, port)) : 16000;
boolean https = useHttps != null && useHttps;
String baseUrl = LlmSettingsResolver.buildBaseUrl(host, p, https);
String path = (modelsPath == null || modelsPath.isBlank()) ? "/v1/models"
: (modelsPath.startsWith("/") ? modelsPath.trim() : "/" + modelsPath.trim());
long timeout = timeoutMs != null ? Math.max(2_000L, Math.min(60_000L, timeoutMs)) : 15_000L;
try {
WebClient client = webClientBuilder.baseUrl(baseUrl).build();
String json = client.get()
.uri(path)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(timeout));
if (json == null || json.isBlank()) {
return LlmModelsResponse.builder().ok(false).models(List.of())
.message("모델 목록 응답이 비어 있습니다.").build();
}
List<String> models = parseModelIds(objectMapper.readTree(json));
if (models.isEmpty()) {
return LlmModelsResponse.builder().ok(false).models(List.of())
.message("모델 목록을 찾을 수 없습니다. (" + baseUrl + path + ")").build();
}
return LlmModelsResponse.builder().ok(true).models(models)
.message(models.size() + "개 모델").build();
} catch (WebClientResponseException e) {
log.warn("[StrategyAgent] 모델 목록 HTTP {} — {}", e.getStatusCode(), e.getMessage());
return LlmModelsResponse.builder().ok(false).models(List.of())
.message("모델 서버 응답 오류: " + e.getStatusCode().value()).build();
} catch (Exception e) {
log.warn("[StrategyAgent] 모델 목록 조회 실패: {}", e.getMessage());
return LlmModelsResponse.builder().ok(false).models(List.of())
.message("모델 서버에 연결할 수 없습니다. (" + baseUrl + path + ")").build();
}
}
private List<String> parseModelIds(JsonNode root) {
List<String> out = new ArrayList<>();
JsonNode data = root.path("data");
if (data.isArray()) {
for (JsonNode m : data) {
String id = m.path("id").asText(null);
if (id != null && !id.isBlank()) out.add(id);
}
} else if (root.path("models").isArray()) {
// 일부 서버는 {"models":[{"name":...}]} 형태(예: Ollama)
for (JsonNode m : root.path("models")) {
String id = m.path("id").asText(m.path("name").asText(null));
if (id != null && !id.isBlank()) out.add(id);
}
}
return out;
}
private ObjectNode buildChatBody(ResolvedLlmSettings cfg, StrategyAgentChatRequest request) throws Exception {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", cfg.model());
// DSL 출력은 길 수 있어 최소 2048 토큰 확보
body.put("max_tokens", Math.max(cfg.maxTokens(), 2048));
body.put("temperature", cfg.temperature());
StringBuilder system = new StringBuilder(SYSTEM_PROMPT);
if (request.getContext() != null && !request.getContext().isNull()) {
system.append("\n\n【현재 편집기 상태】\n")
.append(objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(request.getContext()));
}
ArrayNode messages = body.putArray("messages");
messages.addObject().put("role", "system").put("content", system.toString());
for (StrategyAgentChatRequest.ChatMessage m : request.getMessages()) {
if (m == null || m.getContent() == null || m.getContent().isBlank()) continue;
String role = "assistant".equals(m.getRole()) ? "assistant" : "user";
messages.addObject().put("role", role).put("content", m.getContent());
}
return body;
}
private String postChat(ResolvedLlmSettings cfg, ObjectNode body) {
WebClient client = webClientBuilder.baseUrl(cfg.baseUrl()).build();
try {
String responseJson = client.post()
.uri(cfg.chatPath())
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(body)
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofMillis(cfg.timeoutMs()));
if (responseJson == null || responseJson.isBlank()) {
throw new IllegalStateException("LLM 응답이 비어 있습니다.");
}
return responseJson;
} catch (WebClientResponseException e) {
log.warn("[StrategyAgent] LLM HTTP {} — {}", e.getStatusCode(), e.getResponseBodyAsString());
throw new IllegalStateException("LLM 서버 응답 오류: " + e.getStatusCode().value());
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
log.warn("[StrategyAgent] LLM 연결 실패: {}", e.getMessage());
throw new IllegalStateException("LLM 서버에 연결할 수 없습니다. (" + cfg.baseUrl() + cfg.chatPath() + ")");
}
}
private static String extractAssistantContent(JsonNode root) {
JsonNode choices = root.path("choices");
if (!choices.isArray() || choices.isEmpty()) return null;
JsonNode message = choices.get(0).path("message");
if (message.hasNonNull("content")) {
return message.path("content").asText(null);
}
return null;
}
/** 어시스턴트 content에서 설명과 ```json DSL 블록을 분리·파싱 */
StrategyAgentChatResponse parseAssistantContent(String content, String model, long latencyMs) {
String reply = content.trim();
JsonNode buy = null;
JsonNode sell = null;
String action = "ask";
Matcher matcher = JSON_FENCE.matcher(content);
String jsonBlock = null;
while (matcher.find()) {
String candidate = matcher.group(1).trim();
if (candidate.startsWith("{") || candidate.startsWith("[")) {
jsonBlock = candidate;
}
}
if (jsonBlock != null) {
try {
JsonNode parsed = objectMapper.readTree(jsonBlock);
JsonNode buyNode = firstNonMissing(parsed, "buyCondition", "buy");
JsonNode sellNode = firstNonMissing(parsed, "sellCondition", "sell");
buy = nullIfEmpty(buyNode);
sell = nullIfEmpty(sellNode);
String parsedAction = parsed.path("action").asText(null);
if (buy != null || sell != null) {
action = "build";
} else if (parsedAction != null) {
action = parsedAction;
}
// 설명에서 json 코드펜스 제거
reply = JSON_FENCE.matcher(content).replaceAll("").trim();
if (reply.isEmpty()) {
reply = action.equals("build") ? "요청하신 조건으로 전략을 구성했습니다." : content.trim();
}
} catch (Exception e) {
log.warn("[StrategyAgent] DSL JSON 파싱 실패: {}", e.getMessage());
action = "ask";
}
}
return StrategyAgentChatResponse.builder()
.reply(reply)
.action(action)
.buyCondition(buy)
.sellCondition(sell)
.model(model)
.latencyMs(latencyMs)
.build();
}
private static JsonNode firstNonMissing(JsonNode parent, String... keys) {
for (String key : keys) {
JsonNode node = parent.get(key);
if (node != null && !node.isMissingNode()) return node;
}
return null;
}
private static JsonNode nullIfEmpty(JsonNode node) {
if (node == null || node.isNull() || node.isMissingNode()) return null;
if (node.isObject() && node.isEmpty()) return null;
return node;
}
}
@@ -1,6 +1,8 @@
package com.goldenchart.service; package com.goldenchart.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.dto.TrendLineKind;
import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.storage.Ta4jStorage;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -122,20 +124,37 @@ public class StrategyDslToTa4jAdapter {
* null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며 * null 이 아니고 비어 있지 않으면 백테스트 모드로 동작하며
* CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다. * CrossSeriesRule 에서 타임스탬프 기반 룩업을 수행한다.
*/ */
Map<String, BarSeries> seriesOverrides Map<String, BarSeries> seriesOverrides,
/** 추세선 조건(TREND_LINE_CROSS_*) — 전략설정 gc_backtest_settings */
TrendLineConfig trendLineConfig
) { ) {
/** visual 미전달 호환 (레거시) */ /** visual 미전달 호환 (레거시) */
public RuleBuildContext(BarSeries primarySeries, public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
String market, String market,
Ta4jStorage storage) { Ta4jStorage storage) {
this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of()); this(primarySeries, indicatorParams, Map.of(), market, storage, false, Map.of(), null);
}
public RuleBuildContext(BarSeries primarySeries,
Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual,
String market,
Ta4jStorage storage,
boolean useConfirmedOnly,
Map<String, BarSeries> seriesOverrides) {
this(primarySeries, indicatorParams, indicatorVisual, market, storage,
useConfirmedOnly, seriesOverrides, null);
}
public TrendLineConfig effectiveTrendLineConfig() {
return trendLineConfig != null ? trendLineConfig : TrendLineConfig.defaults();
} }
/** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */ /** primarySeries 만 교체하고 나머지 필드 복사 (하위 컨텍스트 생성용) */
public RuleBuildContext withPrimary(BarSeries newPrimary) { public RuleBuildContext withPrimary(BarSeries newPrimary) {
return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual, return new RuleBuildContext(newPrimary, indicatorParams, indicatorVisual,
market, storage, useConfirmedOnly, seriesOverrides); market, storage, useConfirmedOnly, seriesOverrides, trendLineConfig);
} }
/** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */ /** MA1~11 슬롯 → period1~11 (SMA 보조지표 설정) */
@@ -170,20 +189,20 @@ public class StrategyDslToTa4jAdapter {
public Rule toRule(JsonNode node, BarSeries series, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams) { Map<String, Map<String, Object>> indicatorParams) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of())); return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, Map.of(), null));
} }
public Rule toRule(JsonNode node, BarSeries series, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
String market, Ta4jStorage storage) { String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of())); return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), market, storage, false, Map.of(), null));
} }
public Rule toRule(JsonNode node, BarSeries series, public Rule toRule(JsonNode node, BarSeries series,
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual, Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) { String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of())); return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, false, Map.of(), null));
} }
/** /**
@@ -194,7 +213,7 @@ public class StrategyDslToTa4jAdapter {
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, BarSeries> seriesOverrides) { Map<String, BarSeries> seriesOverrides) {
return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false, return toRule(node, new RuleBuildContext(series, indicatorParams, Map.of(), null, null, false,
seriesOverrides != null ? seriesOverrides : Map.of())); seriesOverrides != null ? seriesOverrides : Map.of(), null));
} }
/** /**
@@ -204,7 +223,7 @@ public class StrategyDslToTa4jAdapter {
Map<String, Map<String, Object>> indicatorParams, Map<String, Map<String, Object>> indicatorParams,
Map<String, Map<String, Object>> indicatorVisual, Map<String, Map<String, Object>> indicatorVisual,
String market, Ta4jStorage storage) { String market, Ta4jStorage storage) {
return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of())); return toRule(node, new RuleBuildContext(series, indicatorParams, indicatorVisual, market, storage, true, Map.of(), null));
} }
public Rule toRule(JsonNode node, RuleBuildContext ctx) { public Rule toRule(JsonNode node, RuleBuildContext ctx) {
@@ -508,6 +527,15 @@ public class StrategyDslToTa4jAdapter {
// 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외) // 직전봉≤·현재봉> 교차 (차트·CrossTimeframeCompositeRule 과 동일, NaN 구간 제외)
case "CROSS_UP" -> buildCrossUpRule(left, right); case "CROSS_UP" -> buildCrossUpRule(left, right);
case "CROSS_DOWN" -> buildCrossDownRule(left, right); case "CROSS_DOWN" -> buildCrossDownRule(left, right);
case "TREND_LINE_CROSS_UP", "TREND_LINE_CROSS_DOWN" -> {
int lb = cond.path("lookbackPeriod").asInt(0);
if (lb <= 0) lb = ctx.effectiveTrendLineConfig().getDefaultLookback();
lb = Math.max(2, lb);
boolean priceScale = isTrendLinePriceScaleField(leftField);
yield "TREND_LINE_CROSS_UP".equals(condType)
? buildTrendLineCrossUpRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale)
: buildTrendLineCrossDownRule(left, series, lb, ctx.effectiveTrendLineConfig(), priceScale);
}
case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod); case "SLOPE_UP" -> buildSlopeTrendRule(left, true, candleRangeMode, candleRange, slopePeriod);
case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod); case "SLOPE_DOWN" -> buildSlopeTrendRule(left, false, candleRangeMode, candleRange, slopePeriod);
case "DIFF_GT" -> { case "DIFF_GT" -> {
@@ -1657,6 +1685,66 @@ public class StrategyDslToTa4jAdapter {
}; };
} }
/**
* N봉 추세선 돌파 — [i−N, i−1] 구간으로 고정된 동일 선을 i−1·i 에서 평가.
* (일반 CROSS 와 달리 추세선 창이 봉마다 밀리지 않음)
*/
private Rule buildTrendLineCrossUpRule(
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
return new Rule() {
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
if (index < 1) return false;
TrendLineKind kind = TrendLineKind.forCrossUp();
double linePrev = TrendLineMath.valueAtEval(
series, left, index, index - 1, lookback, config, kind, priceScale);
double lineCurr = TrendLineMath.valueAtEval(
series, left, index, index, lookback, config, kind, priceScale);
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
Num l0 = left.getValue(index - 1);
Num l1 = left.getValue(index);
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
Num r0 = series.numFactory().numOf(linePrev);
Num r1 = series.numFactory().numOf(lineCurr);
return l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1);
}
};
}
private Rule buildTrendLineCrossDownRule(
Indicator<Num> left, BarSeries series, int lookback, TrendLineConfig config, boolean priceScale) {
return new Rule() {
@Override
public boolean isSatisfied(int index, TradingRecord tradingRecord) {
if (index < 1) return false;
TrendLineKind kind = TrendLineKind.forCrossDown();
double linePrev = TrendLineMath.valueAtEval(
series, left, index, index - 1, lookback, config, kind, priceScale);
double lineCurr = TrendLineMath.valueAtEval(
series, left, index, index, lookback, config, kind, priceScale);
if (!Double.isFinite(linePrev) || !Double.isFinite(lineCurr)) return false;
Num l0 = left.getValue(index - 1);
Num l1 = left.getValue(index);
if (l0 == null || l1 == null || l0.isNaN() || l1.isNaN()) return false;
Num r0 = series.numFactory().numOf(linePrev);
Num r1 = series.numFactory().numOf(lineCurr);
return l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1);
}
};
}
/** 추세선 — 가격축(OHLC·MA 등) vs 오실레이터 */
private static boolean isTrendLinePriceScaleField(String field) {
if (field == null || field.isBlank() || "NONE".equalsIgnoreCase(field)) return true;
String f = field.toUpperCase();
return f.contains("CLOSE") || f.contains("PRICE") || f.contains("HIGH") || f.contains("LOW")
|| f.contains("OPEN") || f.contains("MA") || f.contains("EMA")
|| f.contains("UPPER") || f.contains("MIDDLE") || f.contains("LOWER")
|| f.contains("BAND") || f.contains("DC_") || f.contains("VWAP")
|| f.contains("CONVERSION") || f.contains("BASE") || f.contains("SPAN")
|| f.contains("LAGGING") || f.contains("CLOUD");
}
/** /**
* CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다. * CCI_VALUE_20 등 좌측 본선 기간과 신호선(CCI_SIGNAL) 본선 기간을 일치시킨다.
* sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다. * sidePeriod(우측)는 신호 SMA 길이용 — 본선 CCI/RSI/TRIX 기간으로 쓰지 않는다.
@@ -0,0 +1,294 @@
package com.goldenchart.service;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.dto.TrendLineKind;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.num.Num;
import java.util.ArrayList;
import java.util.List;
/**
* N봉 구간 추세선 — 스윙 피벗(Pivot) 기준.
* <p>
* fit 구간 [anchorN, anchor1] (현재 봉 제외).
* 상승 추세선(UP): 스윙 저점 2개(Low, Low₂&gt;Low₁) · 하락 추세선(DOWN): 스윙 고점 2개(High, High₂&lt;High₁).
* anchor 봉 i 돌파 판정: 동일 선을 i1·i 에 extrapolation.
* 차트 선분: 피벗 A → anchor(i)까지 연장.
*/
public final class TrendLineMath {
private TrendLineMath() {}
private record PivotPair(int indexA, double yA, int indexB, double yB) {}
/** anchor 봉 기준 [anchorN, anchor1] 추세선을 evalIndex 에서 평가 */
public static double valueAtEval(
BarSeries series,
Indicator<Num> source,
int anchorIndex,
int evalIndex,
int lookback,
TrendLineConfig config,
TrendLineKind kind,
boolean priceScale) {
if (series == null || source == null || config == null || kind == null
|| anchorIndex < 1 || lookback < 2) {
return Double.NaN;
}
int start = anchorIndex - lookback;
int end = anchorIndex - 1;
if (start < series.getBeginIndex() || end < start) return Double.NaN;
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
if (pivots != null) {
return lineAt(pivots, evalIndex);
}
return fallbackValueAt(series, source, start, end, evalIndex, config, kind, priceScale);
}
/** anchor = index — i 봉 돌파 판정용 */
public static double valueAt(
BarSeries series,
Indicator<Num> source,
int index,
int lookback,
TrendLineConfig config,
TrendLineKind kind,
boolean priceScale) {
return valueAtEval(series, source, index, index, lookback, config, kind, priceScale);
}
/** 시그널 차트용 — 피벗 기준 fit 후 index(시그널 봉)까지 연장 */
public record Segment(long startTimeSec, double startPrice, long endTimeSec, double endPrice) {}
public static Segment segmentAt(
BarSeries series,
Indicator<Num> source,
int index,
int lookback,
TrendLineConfig config,
TrendLineKind kind,
boolean priceScale) {
if (series == null || source == null || config == null || kind == null
|| index < 1 || lookback < 2) {
return null;
}
int start = index - lookback;
int end = index - 1;
if (start < series.getBeginIndex() || end < start) return null;
PivotPair pivots = resolvePivots(series, source, start, end, config, kind, priceScale);
if (pivots != null) {
double yAt = lineAt(pivots, index);
if (!Double.isFinite(yAt)) return null;
return new Segment(
barTimeSec(series, pivots.indexA()), pivots.yA(),
barTimeSec(series, index), yAt);
}
return fallbackSegment(series, source, start, end, index, config, kind, priceScale);
}
// ── 피벗 선정 ───────────────────────────────────────────────────────────
private static PivotPair resolvePivots(
BarSeries series,
Indicator<Num> source,
int start,
int end,
TrendLineConfig config,
TrendLineKind kind,
boolean priceScale) {
int kLeft = Math.max(0, config.getSwingPrecedingBars());
int kRight = Math.max(0, config.getSwingFollowingBars());
List<Integer> swings = collectSwingIndices(series, source, start, end, kLeft, kRight, kind, priceScale);
if (swings.size() < 2) return null;
int swingA = swings.get(0);
for (int j = 1; j < swings.size(); j++) {
int swingB = swings.get(j);
if (swingB <= swingA) continue;
double yA = yAtPivot(series, source, swingA, kind, priceScale);
double yB = yAtPivot(series, source, swingB, kind, priceScale);
if (!Double.isFinite(yA) || !Double.isFinite(yB)) continue;
if (kind == TrendLineKind.UP && yB > yA) {
return new PivotPair(swingA, yA, swingB, yB);
}
if (kind == TrendLineKind.DOWN && yB < yA) {
return new PivotPair(swingA, yA, swingB, yB);
}
}
return null;
}
private static List<Integer> collectSwingIndices(
BarSeries series,
Indicator<Num> source,
int start,
int end,
int kLeft,
int kRight,
TrendLineKind kind,
boolean priceScale) {
List<Integer> out = new ArrayList<>();
for (int i = start + kLeft; i <= end - kRight; i++) {
boolean isSwing = kind == TrendLineKind.UP
? isSwingLow(series, source, i, kLeft, kRight, priceScale)
: isSwingHigh(series, source, i, kLeft, kRight, priceScale);
if (isSwing) out.add(i);
}
return out;
}
private static double yAtPivot(
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
if (priceScale) {
return kind == TrendLineKind.UP
? series.getBar(index).getLowPrice().doubleValue()
: series.getBar(index).getHighPrice().doubleValue();
}
Double v = numAt(source, index);
return v != null ? v : Double.NaN;
}
private static boolean isSwingLow(
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
double low = priceScale
? series.getBar(i).getLowPrice().doubleValue()
: requireNum(source, i);
if (!Double.isFinite(low)) return false;
for (int j = i - prec; j <= i + fol; j++) {
if (j == i) continue;
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
double v = priceScale
? series.getBar(j).getLowPrice().doubleValue()
: requireNum(source, j);
if (!Double.isFinite(v) || v < low) return false;
}
return true;
}
private static boolean isSwingHigh(
BarSeries series, Indicator<Num> source, int i, int prec, int fol, boolean priceScale) {
double high = priceScale
? series.getBar(i).getHighPrice().doubleValue()
: requireNum(source, i);
if (!Double.isFinite(high)) return false;
for (int j = i - prec; j <= i + fol; j++) {
if (j == i) continue;
if (j < series.getBeginIndex() || j > series.getEndIndex()) return false;
double v = priceScale
? series.getBar(j).getHighPrice().doubleValue()
: requireNum(source, j);
if (!Double.isFinite(v) || v > high) return false;
}
return true;
}
private static double lineAt(PivotPair p, int evalIndex) {
if (p.indexB() == p.indexA()) return p.yA();
double t = (evalIndex - p.indexA()) / (double) (p.indexB() - p.indexA());
return p.yA() + (p.yB() - p.yA()) * t;
}
// ── 모드별 폴백 (피벗 2개 미충족 시) ─────────────────────────────────────
private static double fallbackValueAt(
BarSeries series,
Indicator<Num> source,
int start,
int end,
int evalIndex,
TrendLineConfig config,
TrendLineKind kind,
boolean priceScale) {
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
return Double.NaN;
}
if (TrendLineConfig.MODE_REGRESSION.equals(normalizeMode(config.getMode()))) {
return regressionAt(series, source, start, end, evalIndex, kind, priceScale);
}
return twoPointAt(series, source, start, end, evalIndex, kind, priceScale);
}
private static Segment fallbackSegment(
BarSeries series,
Indicator<Num> source,
int start,
int end,
int anchorIndex,
TrendLineConfig config,
TrendLineKind kind,
boolean priceScale) {
if (TrendLineConfig.MODE_SWING.equals(normalizeMode(config.getMode()))) {
return null;
}
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
if (!Double.isFinite(y0)) return null;
double yAt = fallbackValueAt(series, source, start, end, anchorIndex, config, kind, priceScale);
if (!Double.isFinite(yAt)) return null;
return new Segment(barTimeSec(series, start), y0, barTimeSec(series, anchorIndex), yAt);
}
private static double yAtEndpoint(
BarSeries series, Indicator<Num> source, int index, TrendLineKind kind, boolean priceScale) {
return yAtPivot(series, source, index, kind, priceScale);
}
private static double twoPointAt(
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
TrendLineKind kind, boolean priceScale) {
double y0 = yAtEndpoint(series, source, start, kind, priceScale);
double y1 = yAtEndpoint(series, source, end, kind, priceScale);
if (!Double.isFinite(y0) || !Double.isFinite(y1) || end == start) return Double.NaN;
double t = (evalIndex - start) / (double) (end - start);
return y0 + (y1 - y0) * t;
}
private static double regressionAt(
BarSeries series, Indicator<Num> source, int start, int end, int evalIndex,
TrendLineKind kind, boolean priceScale) {
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
int count = 0;
for (int i = start; i <= end; i++) {
double y = yAtEndpoint(series, source, i, kind, priceScale);
if (!Double.isFinite(y)) continue;
double x = i - start;
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
count++;
}
if (count < 2) return Double.NaN;
double denom = count * sumXX - sumX * sumX;
if (Math.abs(denom) < 1e-12) return Double.NaN;
double b = (count * sumXY - sumX * sumY) / denom;
double a = (sumY - b * sumX) / count;
return a + b * (evalIndex - start);
}
private static String normalizeMode(String mode) {
if (TrendLineConfig.MODE_REGRESSION.equals(mode)) return TrendLineConfig.MODE_REGRESSION;
if (TrendLineConfig.MODE_SWING.equals(mode)) return TrendLineConfig.MODE_SWING;
return TrendLineConfig.MODE_TWO_POINT;
}
private static double requireNum(Indicator<Num> source, int index) {
Double v = numAt(source, index);
return v != null ? v : Double.NaN;
}
private static Double numAt(Indicator<Num> source, int index) {
if (index < 0) return null;
Num n = source.getValue(index);
if (n == null) return null;
double v = n.doubleValue();
return Double.isFinite(v) ? v : null;
}
private static long barTimeSec(BarSeries series, int index) {
return series.getBar(index).getEndTime().getEpochSecond();
}
}
@@ -0,0 +1,51 @@
package com.goldenchart.service;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.dto.TrendLineKind;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.indicators.AbstractIndicator;
import org.ta4j.core.num.Num;
import static org.ta4j.core.num.NaN.NaN;
/** bar index 에서 N봉 추세선 Y값 — left 지표·종가와 CROSS 비교용 */
public class TrendLineValueIndicator extends AbstractIndicator<Num> {
private final Indicator<Num> source;
private final BarSeries series;
private final int lookback;
private final TrendLineConfig config;
private final TrendLineKind kind;
private final boolean priceScale;
public TrendLineValueIndicator(
Indicator<Num> source,
BarSeries series,
int lookback,
TrendLineConfig config,
TrendLineKind kind,
boolean priceScale) {
super(series);
this.source = source;
this.series = series;
this.lookback = Math.max(2, lookback);
this.config = config != null ? config : TrendLineConfig.defaults();
this.kind = kind != null ? kind : TrendLineKind.DOWN;
this.priceScale = priceScale;
}
@Override
public Num getValue(int index) {
double v = TrendLineMath.valueAt(series, source, index, lookback, config, kind, priceScale);
if (!Double.isFinite(v)) {
return NaN;
}
return series.numFactory().numOf(v);
}
@Override
public int getCountOfUnstableBars() {
return lookback + 1;
}
}
@@ -0,0 +1,7 @@
-- 추세선 전략 조건 — 백테스트/전략평가 공통 설정
ALTER TABLE gc_backtest_settings
ADD COLUMN trend_line_mode VARCHAR(20) NOT NULL DEFAULT 'TWO_POINT'
COMMENT 'TWO_POINT | REGRESSION | SWING',
ADD COLUMN trend_line_default_lookback INT NOT NULL DEFAULT 10,
ADD COLUMN trend_line_swing_preceding_bars INT NOT NULL DEFAULT 1,
ADD COLUMN trend_line_swing_following_bars INT NOT NULL DEFAULT 1;
@@ -0,0 +1,179 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.TrendLineConfig;
import com.goldenchart.dto.TrendLineKind;
import org.junit.jupiter.api.Test;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.Rule;
import org.ta4j.core.TradingRecord;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.DecimalNum;
import org.ta4j.core.num.Num;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/** N봉 추세선 돌파 — 스윙 피벗 (상승 지지 / 하락 저항) */
class TrendLineCrossRuleTest {
private final StrategyDslToTa4jAdapter adapter = new StrategyDslToTa4jAdapter();
private final ObjectMapper om = new ObjectMapper();
@Test
void trendLineCrossUp_swingPivot_breaksDescendingResistance() throws Exception {
BarSeries series = buildDescendingHighsThenBreakUp();
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_UP", 8, TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build());
int last = series.getEndIndex();
assertTrue(rule.isSatisfied(last, null), "저항(하락) 추세선 상향 돌파");
assertFalse(rule.isSatisfied(last - 1, null), "직전 봉은 미돌파");
}
@Test
void trendLineCrossDown_swingPivot_breaksAscendingSupport() throws Exception {
BarSeries series = buildAscendingLowsThenBreakDown();
Rule rule = buildTrendLineRule(series, "TREND_LINE_CROSS_DOWN", 8, TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build());
int last = series.getEndIndex();
assertTrue(rule.isSatisfied(last, null), "지지(상승) 추세선 하향 돌파");
}
@Test
void trendLineValueIndicator_matchesMath() {
BarSeries series = buildDescendingHighsThenBreakUp();
ClosePriceIndicator close = new ClosePriceIndicator(series);
TrendLineConfig cfg = TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build();
TrendLineValueIndicator ind = new TrendLineValueIndicator(
close, series, 8, cfg, TrendLineKind.DOWN, true);
int idx = series.getEndIndex();
double expected = TrendLineMath.valueAt(series, close, idx, 8, cfg, TrendLineKind.DOWN, true);
Num v = ind.getValue(idx);
assertEquals(expected, v.doubleValue(), 1e-9);
}
@Test
void trendLineCross_usesFixedWindow_notCurrentBarInFit() {
BarSeries series = buildDescendingHighsThenBreakUp();
ClosePriceIndicator close = new ClosePriceIndicator(series);
TrendLineConfig cfg = TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build();
int idx = series.getEndIndex();
TrendLineKind kind = TrendLineKind.DOWN;
double lineAtPrev = TrendLineMath.valueAtEval(series, close, idx, idx - 1, 8, cfg, kind, true);
double lineAtCurr = TrendLineMath.valueAtEval(series, close, idx, idx, 8, cfg, kind, true);
assertTrue(Double.isFinite(lineAtPrev));
assertTrue(Double.isFinite(lineAtCurr));
double shiftedWrong = TrendLineMath.valueAtEval(series, close, idx - 1, idx - 1, 8, cfg, kind, true);
assertNotEquals(lineAtPrev, shiftedWrong, 1e-6,
"i1·i는 동일 [idx−N, idx−1] 창의 추세선이어야 함");
}
@Test
void pivotUp_requiresHigherSecondLow() {
BarSeries series = buildInvalidUpTrendFlatLows();
ClosePriceIndicator close = new ClosePriceIndicator(series);
TrendLineConfig cfg = TrendLineConfig.builder()
.mode(TrendLineConfig.MODE_SWING)
.defaultLookback(8)
.swingPrecedingBars(1)
.swingFollowingBars(1)
.build();
int idx = series.getEndIndex();
double v = TrendLineMath.valueAtEval(
series, close, idx, idx, 8, cfg, TrendLineKind.UP, true);
assertTrue(Double.isNaN(v), "두 번째 저점이 더 높지 않으면 피벗 추세선 없음");
}
private Rule buildTrendLineRule(BarSeries series, String condType, int lookback, TrendLineConfig cfg)
throws Exception {
String json = """
{
"type": "CONDITION",
"condition": {
"indicatorType": "MA",
"conditionType": "%s",
"leftField": "CLOSE_PRICE",
"rightField": "NONE",
"lookbackPeriod": %d
}
}
""".formatted(condType, lookback);
var dsl = om.readTree(json);
var ctx = new StrategyDslToTa4jAdapter.RuleBuildContext(
series, Map.of(), Map.of(), null, null, false, Map.of(), cfg);
return adapter.toRule(dsl, ctx);
}
/** 하락 저항선(고점↓) — 마지막 봉 종가 상향 돌파 */
private static BarSeries buildDescendingHighsThenBreakUp() {
double[] highs = {100, 101, 102, 115, 110, 108, 106, 104, 102, 108, 105, 112};
double[] closes = {99, 100, 101, 113, 108, 106, 104, 102, 100, 106, 104, 111};
return toSeriesFromHighs(highs, closes);
}
/** 상승 지지선(저점↑) — 마지막 봉 종가 하향 돌파 */
private static BarSeries buildAscendingLowsThenBreakDown() {
double[] lows = {108, 109, 110, 95, 96, 97, 98, 99, 100, 101, 102, 108};
double[] closes = {110, 111, 112, 97, 98, 99, 100, 101, 102, 103, 104, 101};
return toSeriesFromLows(lows, closes);
}
/** 두 스윙 저점이 같은 높이 — UP 피벗 불성립 */
private static BarSeries buildInvalidUpTrendFlatLows() {
double[] lows = {98, 98, 98, 95, 95, 95, 95, 95, 95, 95, 95, 94};
double[] closes = {100, 100, 100, 97, 97, 97, 97, 97, 97, 97, 97, 96};
return toSeriesFromLows(lows, closes);
}
private static BarSeries toSeriesFromHighs(double[] highs, double[] closes) {
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
Instant t = Instant.parse("2024-01-01T00:00:00Z");
for (int i = 0; i < highs.length; i++) {
double c = closes[i];
double h = highs[i];
double l = Math.min(c, h) - 1;
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
t = t.plus(Duration.ofMinutes(1));
}
return series;
}
private static BarSeries toSeriesFromLows(double[] lows, double[] closes) {
BarSeries series = new BaseBarSeriesBuilder().withNumTypeOf(DecimalNum.class).build();
Instant t = Instant.parse("2024-01-01T00:00:00Z");
for (int i = 0; i < lows.length; i++) {
double c = closes[i];
double l = lows[i];
double h = Math.max(c, l) + 1;
series.addBar(t, t.plus(Duration.ofMinutes(1)), c, h, l, c, 1);
t = t.plus(Duration.ofMinutes(1));
}
return series;
}
}
+28
View File
@@ -78,6 +78,34 @@
proxy_buffering off; proxy_buffering off;
} }
location = /api/strategy/evaluation/llm-models {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 90s;
proxy_send_timeout 90s;
proxy_buffering off;
}
# ── AI 전략 에이전트 (멀티턴 LLM — 일반 /api/ 180s 회피) ───
location /api/strategy/agent/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_buffering off;
client_max_body_size 16m;
}
# ── GoldenChart Spring Boot Backend 프록시 ──────────────────────── # ── GoldenChart Spring Boot Backend 프록시 ────────────────────────
location /api/ { location /api/ {
proxy_pass http://backend:8080; proxy_pass http://backend:8080;
+1 -1
View File
@@ -9,7 +9,7 @@
--toolbar-h: 44px; --toolbar-h: 44px;
--sidebar-w: 44px; --sidebar-w: 44px;
--bottombar-h:28px; --bottombar-h:28px;
/* 기본 테마 변수 (다크 테마) — document.body 로 포털된 컴포넌트에서도 사용 가능하도록 :root에 선언 */ /* 기본 테마 변수 (다크 테마) — document.body 로 포털된 컴포넌트에서도 사용 가능하도록 :root에 선언 */
--bg: #1a1b26; --bg: #1a1b26;
--bg1: #1a1b26; --bg1: #1a1b26;
+36 -2
View File
@@ -6,11 +6,15 @@ import React, { useState, useEffect, useCallback } from 'react';
import type { Theme } from '../types'; import type { Theme } from '../types';
import { import {
loadBacktestSettings, loadBacktestSettings,
saveBacktestSettings,
saveLiveStrategySettings, saveLiveStrategySettings,
type BacktestSettingsDto,
type LiveStrategySettingsDto, type LiveStrategySettingsDto,
DEFAULT_BACKTEST_SETTINGS,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import { BacktestSettingsPanel } from './BacktestSettingsPanel'; import { BacktestSettingsPanel } from './BacktestSettingsPanel';
import TrendLineSettingsFields from './TrendLineSettingsFields';
import IndicatorMainDefaultsPanel, { import IndicatorMainDefaultsPanel, {
type IndicatorSaveUiState, type IndicatorSaveUiState,
} from './IndicatorMainDefaultsPanel'; } from './IndicatorMainDefaultsPanel';
@@ -1366,15 +1370,32 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
); );
const [liveSaving, setLiveSaving] = useState(false); const [liveSaving, setLiveSaving] = useState(false);
const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY'); const [backtestPositionMode, setBacktestPositionMode] = useState<'LONG_ONLY' | 'SIGNAL_ONLY'>('LONG_ONLY');
const [trendLineCfg, setTrendLineCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
const [trendLineSaving, setTrendLineSaving] = useState(false);
useEffect(() => { useEffect(() => {
void loadBacktestSettings().then(s => { void loadBacktestSettings().then(s => {
if (s?.positionMode === 'SIGNAL_ONLY' || s?.positionMode === 'LONG_ONLY') { if (s) {
setBacktestPositionMode(s.positionMode); 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(() => { useEffect(() => {
if (liveSettingsProp) setLiveSettings(liveSettingsProp); if (liveSettingsProp) setLiveSettings(liveSettingsProp);
}, [liveSettingsProp]); }, [liveSettingsProp]);
@@ -1582,6 +1603,19 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
)} )}
</SettingSection> </SettingSection>
{/* ── 추세선 설정 ──────────────────────────────────────────────────── */}
<SettingSection title="추세선 설정">
<p className="stg-hint" style={{ marginBottom: 12 }}>
N봉 .
2·· , .
{trendLineSaving && <span style={{ marginLeft: 8, color: 'var(--text3)' }}> </span>}
</p>
<TrendLineSettingsFields
cfg={trendLineCfg}
onChange={patch => { void persistTrendLine(patch); }}
/>
</SettingSection>
{/* ── 리스크 관리 ──────────────────────────────────────────────────── */} {/* ── 리스크 관리 ──────────────────────────────────────────────────── */}
<SettingSection title="리스크 관리" grid> <SettingSection title="리스크 관리" grid>
<StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비)."> <StgCard label="기본 리스크 비율 (%)" desc="진입 시 포지션당 기본 리스크 비율 (자본 대비).">
+251 -8
View File
@@ -5,7 +5,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react'; import { ReactFlowProvider } from '@xyflow/react';
import DraggableModalFrame from './DraggableModalFrame'; import DraggableModalFrame from './DraggableModalFrame';
import type { Theme } from '../types/index'; import type { Theme } from '../types/index';
import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type BacktestSignal, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators'; import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
import { fetchBacktestCandleBundle } from '../utils/backtestWarmup'; import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
@@ -45,9 +45,14 @@ import {
isStartNodeId, isStartNodeId,
type StartCombineOp, type StartCombineOp,
} from '../utils/strategyStartNodes'; } from '../utils/strategyStartNodes';
import SavedStrategyDragBadge from './strategyEditor/SavedStrategyDragBadge';
import type { PaletteDragPayload } from '../utils/paletteDragSession';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab'; import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab'; import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage';
import { import {
BAND_PALETTE_CHIP_ITEMS, BAND_PALETTE_CHIP_ITEMS,
@@ -168,7 +173,7 @@ import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal'
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { MarketSearchPanel } from './MarketSearchPanel'; import { MarketSearchPanel } from './MarketSearchPanel';
import { getKoreanName } from '../utils/marketNameCache'; import { getKoreanName } from '../utils/marketNameCache';
import type { Timeframe } from '../types'; import type { Timeframe, OHLCVBar } from '../types';
import { import {
BACKTEST_STRATEGY_TIMEFRAME, BACKTEST_STRATEGY_TIMEFRAME,
buildQuickRunTimeframeSelectOptions, buildQuickRunTimeframeSelectOptions,
@@ -178,6 +183,14 @@ import {
} from '../utils/backtestRunTimeframe'; } from '../utils/backtestRunTimeframe';
import '../styles/strategyEditor.css'; import '../styles/strategyEditor.css';
import '../styles/strategyEditorTheme.css'; import '../styles/strategyEditorTheme.css';
import '../styles/strategyEvaluation.css';
import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup';
import {
buildStrategyEvaluationToolbarSummary,
formatEvaluationReturnPct,
} from '../utils/strategyEvaluationReport';
import { resolveEvaluationCommissionRate } from '../utils/strategyEvaluationSignals';
import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart';
type SignalTab = 'buy' | 'sell' | 'eval'; type SignalTab = 'buy' | 'sell' | 'eval';
type BuySellTab = 'buy' | 'sell'; type BuySellTab = 'buy' | 'sell';
@@ -310,8 +323,8 @@ export default function StrategyEditorPage({
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null); const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null); const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
const [signalTab, setSignalTab] = useState<SignalTab>('buy'); const [signalTab, setSignalTab] = useState<SignalTab>('buy');
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators'); const [rightTab, setRightTab] = useState<'indicators' | 'templates' | 'ai'>('indicators');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary'); const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary')); const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite')); const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
@@ -359,6 +372,20 @@ export default function StrategyEditorPage({
const [qbMarketQ, setQbMarketQ] = useState(''); const [qbMarketQ, setQbMarketQ] = useState('');
const qbMarketBtnRef = useRef<HTMLButtonElement>(null); const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
// ── 평가 탭 — 수익률 요약 ─────────────────────────────────────────────────
const [evalBacktestSignals, setEvalBacktestSignals] = useState<BacktestSignal[]>([]);
const [evalBars, setEvalBars] = useState<OHLCVBar[]>([]);
const [evalWindowMeta, setEvalWindowMeta] = useState<StrategyEvaluationWindowMeta>({
evaluationBarCount: BACKTEST_DISPLAY_BAR_COUNT,
warmupBarCount: 0,
isAtLatestWindow: true,
windowEndTimeSec: null,
});
const [evalSignalScanRunning, setEvalSignalScanRunning] = useState(false);
const [evalMarket, setEvalMarket] = useState('KRW-BTC');
const [evalInitialCapital, setEvalInitialCapital] = useState(10_000_000);
const [evalCommissionRate, setEvalCommissionRate] = useState(0.0015);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT)); const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth()); const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth());
const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true)); const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true));
@@ -1414,6 +1441,30 @@ export default function StrategyEditorPage({
scheduleStrategyPersist(); scheduleStrategyPersist();
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]); }, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]);
const applyTrendLine = useCallback((item: { indicatorType: string; leftField?: string }, lookback: number) => {
const newNode = buildTrendLineConditionNode(item, buySellTab(signalTab), DEF, lookback);
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, DEF, handleEditorStateChange]);
const buildSavedStrategyDragPayload = useCallback((s: StrategyDto): PaletteDragPayload | null => {
const side = buySellTab(signalTab);
const primaryRaw = side === 'sell' ? s.sellCondition : s.buyCondition;
const fallbackRaw = side === 'sell' ? s.buyCondition : s.sellCondition;
const primary = decodeConditionForEditor((primaryRaw ?? null) as LogicNode | null).root;
const condition = primary ?? decodeConditionForEditor((fallbackRaw ?? null) as LogicNode | null).root;
if (!condition) return null;
return {
type: 'savedStrategy',
value: String(s.id),
label: s.name ?? `전략 #${s.id}`,
condition,
};
}, [signalTab]);
const templateRows = useMemo( const templateRows = useMemo(
() => buildStrategyTemplatePaletteRows({ () => buildStrategyTemplatePaletteRows({
def: DEF, def: DEF,
@@ -1430,6 +1481,34 @@ export default function StrategyEditorPage({
[templateRows, templateSearch], [templateRows, templateSearch],
); );
const applyAiStrategy = useCallback((buyDsl: LogicNode | null, sellDsl: LogicNode | null) => {
if (editorMode === 'graph') layoutFlushRef.current?.();
const buyDecoded = decodeConditionForEditor(buyDsl);
const sellDecoded = decodeConditionForEditor(sellDsl);
setBuyCondition(buyDecoded.root);
setSellCondition(sellDecoded.root);
setBuyOrphans([]);
setSellOrphans([]);
setSelectedNodeId(null);
setBuyLayout(prev => ({
...prev,
startCombineOp: normalizeStartCombineOp(buyDecoded.startCombineOp),
}));
setSellLayout(prev => ({
...prev,
startCombineOp: normalizeStartCombineOp(sellDecoded.startCombineOp),
}));
resetFlowLayout(layoutStrategyKey, 'buy');
resetFlowLayout(layoutStrategyKey, 'sell');
const targetTab: BuySellTab = buyDsl ? 'buy' : (sellDsl ? 'sell' : buySellTab(signalTab));
if (targetTab !== signalTab) setSignalTab(targetTab);
scheduleStrategyPersist();
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => { const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
if (editorMode === 'graph') layoutFlushRef.current?.(); if (editorMode === 'graph') layoutFlushRef.current?.();
@@ -1690,6 +1769,100 @@ export default function StrategyEditorPage({
[qbStrategyTimeframe], [qbStrategyTimeframe],
); );
useEffect(() => {
void loadBacktestSettings().then(s => {
if (s?.initialCapital) setEvalInitialCapital(s.initialCapital);
setEvalCommissionRate(resolveEvaluationCommissionRate(s));
});
}, []);
useEffect(() => {
if (signalTab !== 'eval') {
setEvalBacktestSignals([]);
setEvalBars([]);
setEvalSignalScanRunning(false);
}
}, [signalTab]);
const evalAnalysisSummary = useMemo(() => {
if (signalTab !== 'eval' || evalBacktestSignals.length === 0) return null;
const { evalBars: slice } = resolveEvaluationBarSlice(evalBars, evalWindowMeta.evaluationBarCount);
const lastEvalBar = slice[slice.length - 1];
return buildStrategyEvaluationToolbarSummary(evalBacktestSignals, {
initialCapital: evalInitialCapital,
symbol: evalMarket.replace(/^KRW-/, ''),
commissionRate: evalCommissionRate,
lastBarClose: lastEvalBar?.close,
lastBarTimeSec: lastEvalBar?.time,
});
}, [
signalTab,
evalBacktestSignals,
evalBars,
evalWindowMeta.evaluationBarCount,
evalInitialCapital,
evalMarket,
evalCommissionRate,
]);
const buildAiContext = useCallback((): AiStrategyContext => {
const buyEncoded = encodeConditionForSave(buyEditorState);
const sellEncoded = encodeConditionForSave(sellEditorState);
const evaluation = evalBacktestSignals.length > 0 || signalTab === 'eval'
? {
market: evalMarket,
timeframe: qbStrategyTimeframe,
barCount: evalBars.length,
evaluationBarCount: evalWindowMeta.evaluationBarCount,
scanRunning: evalSignalScanRunning,
signalsTotal: evalBacktestSignals.length,
buyCount: evalBacktestSignals.filter(s => s.type === 'BUY').length,
sellCount: evalBacktestSignals.filter(s => s.type === 'SELL').length,
recentSignals: evalBacktestSignals.slice(-12).map(s => ({
type: s.type,
time: s.time,
timeLabel: new Date(s.time * 1000).toLocaleString('ko-KR'),
price: s.price,
barIndex: s.barIndex,
})),
summary: evalAnalysisSummary ?? null,
}
: null;
return {
buyCondition: buyEncoded,
sellCondition: sellEncoded,
signalTab: buySellTab(signalTab),
candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType,
evaluation,
};
}, [
buyEditorState,
sellEditorState,
signalTab,
currentEditorState,
evalBacktestSignals,
evalMarket,
qbStrategyTimeframe,
evalBars,
evalWindowMeta.evaluationBarCount,
evalSignalScanRunning,
evalAnalysisSummary,
]);
const handleEvalSignalsChange = useCallback((signals: BacktestSignal[]) => {
setEvalBacktestSignals(signals);
}, []);
const handleEvalBarsLoaded = useCallback((bars: OHLCVBar[]) => {
setEvalBars(bars);
}, []);
const handleEvalWindowMetaChange = useCallback((meta: StrategyEvaluationWindowMeta) => {
setEvalWindowMeta(meta);
}, []);
const operators = [ const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' }, { type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
{ type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' }, { type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' },
@@ -1933,9 +2106,10 @@ export default function StrategyEditorPage({
/> />
</svg> </svg>
</button> </button>
<span className={`se-strat-status${s.enabled ? ' se-strat-status--on' : ''}`}> <SavedStrategyDragBadge
{s.enabled ? '활성' : '비활성'} enabled={!!s.enabled}
</span> buildPayload={() => buildSavedStrategyDragPayload(s)}
/>
</div> </div>
</div> </div>
); );
@@ -1983,6 +2157,36 @@ export default function StrategyEditorPage({
</button> </button>
</div> </div>
{stratName && <span className="se-editing-name">{stratName}</span>} {stratName && <span className="se-editing-name">{stratName}</span>}
{signalTab === 'eval' && (
evalSignalScanRunning ? (
<span className="se-eval-return-summary seval-header-analysis-busy"> </span>
) : evalAnalysisSummary ? (
<span
className="se-eval-return-summary seval-analysis-summary"
title="일괄매수(최초 전액) · 분할매수(40/30/잔여) · 전략평가와 동일 기준"
>
<span className={`seval-analysis-item seval-analysis-return${
evalAnalysisSummary.fullBuyReturnPct > 0
? ' seval-analysis-return--pos'
: evalAnalysisSummary.fullBuyReturnPct < 0
? ' seval-analysis-return--neg'
: ''
}`}>
:<strong>{formatEvaluationReturnPct(evalAnalysisSummary.fullBuyReturnPct)}</strong>
</span>
<span className="seval-analysis-sep" aria-hidden>·</span>
<span className={`seval-analysis-item seval-analysis-return${
evalAnalysisSummary.splitBuyReturnPct > 0
? ' seval-analysis-return--pos'
: evalAnalysisSummary.splitBuyReturnPct < 0
? ' seval-analysis-return--neg'
: ''
}`}>
:<strong>{formatEvaluationReturnPct(evalAnalysisSummary.splitBuyReturnPct)}</strong>
</span>
</span>
) : null
)}
{signalTab !== 'eval' && hasMultipleStartSections(currentEditorState) && ( {signalTab !== 'eval' && hasMultipleStartSections(currentEditorState) && (
<StartCombineOpControl <StartCombineOpControl
value={normalizeStartCombineOp(currentEditorState.startCombineOp)} value={normalizeStartCombineOp(currentEditorState.startCombineOp)}
@@ -2044,6 +2248,11 @@ export default function StrategyEditorPage({
stratDesc={stratDesc} stratDesc={stratDesc}
buyEditorState={buyEditorState} buyEditorState={buyEditorState}
sellEditorState={sellEditorState} sellEditorState={sellEditorState}
onSignalsChange={handleEvalSignalsChange}
onBarsLoaded={handleEvalBarsLoaded}
onEvalWindowMetaChange={handleEvalWindowMetaChange}
onBacktestRunningChange={setEvalSignalScanRunning}
onMarketChange={setEvalMarket}
/> />
) : editorMode === 'graph' ? ( ) : editorMode === 'graph' ? (
<> <>
@@ -2068,6 +2277,8 @@ export default function StrategyEditorPage({
onStartCandleTypesChange={handleStartCandleTypesChange} onStartCandleTypesChange={handleStartCandleTypesChange}
onExtraStartIdsChange={handleExtraStartIdsChange} onExtraStartIdsChange={handleExtraStartIdsChange}
onExtraRootsChange={handleExtraRootsChange} onExtraRootsChange={handleExtraRootsChange}
startCombineOp={normalizeStartCombineOp(currentLayout.startCombineOp)}
onStartCombineOpChange={handleStartCombineOpChange}
/> />
</ReactFlowProvider> </ReactFlowProvider>
@@ -2302,6 +2513,7 @@ export default function StrategyEditorPage({
<div className="se-right-tabs"> <div className="se-right-tabs">
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}></button> <button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}></button>
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>릿</button> <button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>릿</button>
<button type="button" className={rightTab === 'ai' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('ai')}>AI </button>
</div> </div>
<div className="se-right-body"> <div className="se-right-body">
{rightTab === 'indicators' && ( {rightTab === 'indicators' && (
@@ -2382,6 +2594,16 @@ export default function StrategyEditorPage({
> >
</button> </button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('trendline');
setSelectedPaletteKey(null);
}}
>
</button>
</div> </div>
{indicatorSubTab === 'auxiliary' ? ( {indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab <IndicatorPaletteTab
@@ -2419,7 +2641,7 @@ export default function StrategyEditorPage({
applyPaletteItem(item); applyPaletteItem(item);
}} }}
/> />
) : ( ) : indicatorSubTab === 'range' ? (
<SidewaysFilterPaletteTab <SidewaysFilterPaletteTab
def={DEF} def={DEF}
items={sidewaysPalette} items={sidewaysPalette}
@@ -2436,6 +2658,21 @@ export default function StrategyEditorPage({
applySidewaysFilter(item.id); applySidewaysFilter(item.id);
}} }}
/> />
) : (
<TrendLinePaletteTab
signalType={buySellTab(signalTab)}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('trendline:')
? selectedPaletteKey.slice('trendline:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
onAddToCanvas={(item, lookback) => {
selectPalette('trendline', item.id);
applyTrendLine(item, lookback);
}}
/>
)} )}
</> </>
)} )}
@@ -2452,6 +2689,12 @@ export default function StrategyEditorPage({
onDelete={handleTemplateToolbarDelete} onDelete={handleTemplateToolbarDelete}
/> />
)} )}
{rightTab === 'ai' && (
<AiStrategyPanel
getContext={buildAiContext}
onApplyStrategy={applyAiStrategy}
/>
)}
</div> </div>
</div> </div>
</aside> </aside>
@@ -0,0 +1,103 @@
/**
* ·
*/
import React from 'react';
import type { BacktestSettingsDto } from '../utils/backendApi';
export const TREND_LINE_MODE_OPTIONS = [
{
value: 'SWING' as const,
label: '스윙 피벗 (권장)',
desc: 'N봉 내 스윙 고/저점 2개 — 상승=저점↑·하락=고점↓',
},
{
value: 'TWO_POINT' as const,
label: '2점 폴백',
desc: '피벗 미충족 시 구간 양端(Low/High) 직선',
},
{
value: 'REGRESSION' as const,
label: '회귀 폴백',
desc: '피벗 미충족 시 N봉 Low/High OLS',
},
];
interface Props {
cfg: BacktestSettingsDto;
onChange: (patch: Partial<BacktestSettingsDto>) => void;
}
export const TrendLineSettingsFields: React.FC<Props> = ({ cfg, onChange }) => {
const mode = cfg.trendLineMode ?? 'SWING';
return (
<>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> </span>
<span className="stg-row-desc">
( )= ( ) · ( )= ( ).
Low/High, .
</span>
</div>
<select
className="stg-select stg-select--wide"
value={mode}
onChange={e => onChange({ trendLineMode: e.target.value as BacktestSettingsDto['trendLineMode'] })}
>
{TREND_LINE_MODE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> lookback ()</span>
<span className="stg-row-desc">
[iN, i1] i . 30~50· 100~120 .
</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={2}
max={500}
value={cfg.trendLineDefaultLookback ?? 10}
onChange={e => onChange({ trendLineDefaultLookback: Number(e.target.value) })}
/>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> k봉</span>
<span className="stg-row-desc">
판정: 해당 Low/High가 · k봉보다
</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={0}
max={20}
value={cfg.trendLineSwingPrecedingBars ?? 1}
onChange={e => onChange({ trendLineSwingPrecedingBars: Number(e.target.value) })}
/>
</div>
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title"> k봉</span>
<span className="stg-row-desc"> (k)</span>
</div>
<input
className="stg-input stg-input--wide"
type="number"
min={0}
max={20}
value={cfg.trendLineSwingFollowingBars ?? 1}
onChange={e => onChange({ trendLineSwingFollowingBars: Number(e.target.value) })}
/>
</div>
</>
);
};
export default TrendLineSettingsFields;
@@ -1,7 +1,7 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import type { LlmAppSettings } from '../../utils/llmSettings'; import type { LlmAppSettings } from '../../utils/llmSettings';
import { buildLlmEndpointUrl, buildLlmBaseUrl } from '../../utils/llmSettings'; import { buildLlmEndpointUrl, buildLlmBaseUrl } from '../../utils/llmSettings';
import { fetchLlmConnectionTest } from '../../utils/backendApi'; import { fetchLlmConnectionTest, fetchLlmModels } from '../../utils/backendApi';
interface Props { interface Props {
settings: LlmAppSettings; settings: LlmAppSettings;
@@ -34,6 +34,9 @@ const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = (
const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => { const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [loadingModels, setLoadingModels] = useState(false);
const [models, setModels] = useState<string[]>([]);
const [modelsMessage, setModelsMessage] = useState<{ ok: boolean; message: string } | null>(null);
const patch = (p: Partial<LlmAppSettings>) => { const patch = (p: Partial<LlmAppSettings>) => {
setTestResult(null); setTestResult(null);
@@ -57,6 +60,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
} }
}; };
const handleFetchModels = async () => {
setLoadingModels(true);
setModelsMessage(null);
try {
const res = await fetchLlmModels({
host: settings.host,
port: settings.port,
useHttps: settings.useHttps,
modelsPath: settings.modelsPath,
});
setModels(res.models ?? []);
setModelsMessage({ ok: res.ok, message: res.message ?? (res.ok ? '조회 완료' : '조회 실패') });
if (res.ok && res.models?.length && !res.models.includes(settings.model)) {
patch({ model: res.models[0] });
}
} catch (e) {
const msg = e instanceof Error ? e.message : '모델 조회 실패';
setModels([]);
setModelsMessage({ ok: false, message: msg });
} finally {
setLoadingModels(false);
}
};
const modelOptions = useMemo(() => {
const set = new Set(models);
if (settings.model) set.add(settings.model);
return Array.from(set);
}, [models, settings.model]);
return ( return (
<div className="llm-settings-panel"> <div className="llm-settings-panel">
<SettingSection title="LLM 사용"> <SettingSection title="LLM 사용">
@@ -114,6 +147,15 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
placeholder="/v1/chat/completions" placeholder="/v1/chat/completions"
/> />
</SettingRow> </SettingRow>
<SettingRow label="모델 목록 경로" desc="OpenAI 호환 모델 목록 엔드포인트 (드롭다운 조회용)">
<input
className="stg-input stg-input--wide"
type="text"
value={settings.modelsPath}
onChange={e => patch({ modelsPath: e.target.value })}
placeholder="/v1/models"
/>
</SettingRow>
<SettingRow label="연결 URL 미리보기" desc="저장 후 백엔드가 이 주소로 LLM에 요청합니다."> <SettingRow label="연결 URL 미리보기" desc="저장 후 백엔드가 이 주소로 LLM에 요청합니다.">
<code className="stg-code-preview">{endpointPreview}</code> <code className="stg-code-preview">{endpointPreview}</code>
</SettingRow> </SettingRow>
@@ -137,7 +179,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
</SettingSection> </SettingSection>
<SettingSection title="모델·생성 옵션"> <SettingSection title="모델·생성 옵션">
<SettingRow label="모델" desc="LLM 서버에 등록된 모델 ID"> <SettingRow label="모델" desc="URL 입력 후 '모델 조회'로 목록을 받아 선택하세요. 조회가 안 되면 직접 입력할 수 있습니다.">
<div className="stg-row-inline">
<select
className="stg-input stg-input--wide"
value={settings.model}
onChange={e => patch({ model: e.target.value })}
>
{modelOptions.length === 0 && <option value=""> </option>}
{modelOptions.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button
type="button"
className="stg-btn-test"
disabled={loadingModels || !settings.host}
onClick={handleFetchModels}
>
{loadingModels ? '조회 중…' : '모델 조회'}
</button>
</div>
</SettingRow>
{modelsMessage && (
<SettingRow label="" desc="">
<span className={`stg-badge ${modelsMessage.ok ? 'stg-badge--ok' : 'stg-badge--err'}`}>
{modelsMessage.message}
</span>
</SettingRow>
)}
<SettingRow label="모델 직접 입력" desc="목록에 없는 모델 ID를 직접 입력할 때 사용">
<input <input
className="stg-input stg-input--wide" className="stg-input stg-input--wide"
type="text" type="text"
@@ -0,0 +1,279 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { LogicNode } from '../../utils/strategyTypes';
import {
requestStrategyAgentChat,
type StrategyAgentChatMessage,
} from '../../utils/strategyAgentChat';
export interface AiStrategyContext {
buyCondition: LogicNode | null;
sellCondition: LogicNode | null;
signalTab: 'buy' | 'sell';
candleType?: string;
/** 평가탭 결과 (해석·문제 진단용) — 평가 미실행 시 null */
evaluation?: unknown;
[key: string]: unknown;
}
interface Props {
/** 전송·스냅샷 시점의 현재 편집기 상태를 반환 */
getContext: () => AiStrategyContext;
/** 매수·매도 조건을 편집기에 교체 적용 (null = 해당 측 비움) */
onApplyStrategy: (buy: LogicNode | null, sell: LogicNode | null) => void;
}
type Turn = {
id: number;
role: 'user' | 'assistant';
content: string;
action?: 'ask' | 'build';
/** build 턴 — 적용 직전 스냅샷(되돌리기용) */
snapshot?: { buy: LogicNode | null; sell: LogicNode | null };
/** build 턴 — 적용된 결과 */
produced?: { buy: LogicNode | null; sell: LogicNode | null };
reverted?: boolean;
};
const SUGGESTIONS = [
'일목균형표 매수 전략 추천해줘',
'전환선이 기준선을 상향돌파하면 매수',
'RSI 30 이하에서 상향돌파하면 매수, 70 이상이면 매도',
'평가 결과를 해석하고 매수 시그널이 안 나오는 원인을 분석해줘',
];
const CLEAR_REPLY =
'전략을 초기화했습니다. 모든 조건을 비워 처음 상태로 되돌렸어요. 원하는 매매 전략을 자연어로 설명해 주세요.';
/** 초기화/리셋/새로 작성 의도 감지 — LLM 없이 즉시 처리 (서버 503에도 동작) */
export function isResetRequest(text: string): boolean {
const t = text.trim();
if (!t) return false;
// "조건 추가"처럼 추가 의도가 섞이면 초기화로 보지 않음
if (/추가|더해|덧붙|넣어|보태/.test(t)) return false;
return /(초기화|리셋|reset|clear|처음\s*부터|처음\s*으로|다시\s*처음|새\s*전략|새로운\s*전략|새\s*조건|새로운\s*조건|전부\s*삭제|전체\s*삭제|모두\s*삭제|다\s*지워|다\s*삭제|비우[기게]|싹\s*비워|새로\s*시작|처음\s*상태)/i.test(t);
}
const AiStrategyPanel: React.FC<Props> = ({ getContext, onApplyStrategy }) => {
const [turns, setTurns] = useState<Turn[]>([]);
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const idRef = useRef(0);
const scrollRef = useRef<HTMLDivElement | null>(null);
const nextId = () => ++idRef.current;
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, []);
const history = useMemo<StrategyAgentChatMessage[]>(
() => turns.map(t => ({ role: t.role, content: t.content })),
[turns],
);
/** 모든 조건을 비우고(초기화) 되돌리기 가능한 어시스턴트 턴 생성 */
const buildClearTurn = useCallback((ctx: AiStrategyContext, reply?: string): Turn => {
const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition };
onApplyStrategy(null, null);
return {
id: nextId(),
role: 'assistant',
content: reply || CLEAR_REPLY,
action: 'build',
snapshot,
produced: { buy: null, sell: null },
};
}, [onApplyStrategy]);
const send = useCallback(async (text: string) => {
const trimmed = text.trim();
if (!trimmed || busy) return;
setError(null);
const ctx = getContext();
// 초기화/리셋 요청 — LLM 호출 없이 즉시 처리
if (isResetRequest(trimmed)) {
const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed };
const clearTurn = buildClearTurn(ctx);
setInput('');
setTurns(prev => [...prev, userTurn, clearTurn]);
scrollToBottom();
return;
}
const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed };
const outgoing: StrategyAgentChatMessage[] = [...history, { role: 'user', content: trimmed }];
setTurns(prev => [...prev, userTurn]);
setInput('');
setBusy(true);
scrollToBottom();
try {
const res = await requestStrategyAgentChat(outgoing, ctx);
if (res.action === 'clear') {
setTurns(prev => [...prev, buildClearTurn(ctx, res.reply)]);
} else if (res.action === 'build' && res.hasStrategy) {
const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition };
// AI가 생략한 측은 기존 조건 유지
const produced = {
buy: res.buyCondition ?? ctx.buyCondition,
sell: res.sellCondition ?? ctx.sellCondition,
};
onApplyStrategy(produced.buy, produced.sell);
setTurns(prev => [...prev, {
id: nextId(),
role: 'assistant',
content: res.reply || '요청하신 조건으로 전략을 구성했습니다.',
action: 'build',
snapshot,
produced,
}]);
} else {
setTurns(prev => [...prev, {
id: nextId(),
role: 'assistant',
content: res.reply || '요청을 더 구체적으로 알려주세요.',
action: 'ask',
}]);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'AI 전략 생성 중 오류가 발생했습니다.';
setError(msg);
setTurns(prev => [...prev, {
id: nextId(),
role: 'assistant',
content: `오류: ${msg}`,
action: 'ask',
}]);
} finally {
setBusy(false);
scrollToBottom();
}
}, [busy, history, getContext, onApplyStrategy, scrollToBottom, buildClearTurn]);
const handleRevert = useCallback((turnId: number) => {
setTurns(prev => prev.map(t => {
if (t.id !== turnId || !t.snapshot) return t;
onApplyStrategy(t.snapshot.buy, t.snapshot.sell);
return { ...t, reverted: true };
}));
}, [onApplyStrategy]);
const handleReapply = useCallback((turnId: number) => {
setTurns(prev => prev.map(t => {
if (t.id !== turnId || !t.produced) return t;
onApplyStrategy(t.produced.buy, t.produced.sell);
return { ...t, reverted: false };
}));
}, [onApplyStrategy]);
const handleReset = useCallback(() => {
if (busy) return;
setTurns([]);
setError(null);
}, [busy]);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send(input);
}
};
return (
<div className="se-ai-panel">
<div className="se-ai-head">
<span className="se-ai-title">AI </span>
<button
type="button"
className="se-ai-reset"
onClick={handleReset}
disabled={busy || turns.length === 0}
>
</button>
</div>
<div className="se-ai-messages" ref={scrollRef}>
{turns.length === 0 && (
<div className="se-ai-empty">
<p> .</p>
<p className="se-ai-empty-sub"> , .</p>
<div className="se-ai-suggestions">
{SUGGESTIONS.map(s => (
<button key={s} type="button" className="se-ai-suggestion" onClick={() => void send(s)} disabled={busy}>
{s}
</button>
))}
</div>
</div>
)}
{turns.map(t => (
<div key={t.id} className={`se-ai-msg se-ai-msg--${t.role}`}>
<div className="se-ai-bubble">
{t.content.split('\n').map((line, i) => <p key={i}>{line || '\u00A0'}</p>)}
</div>
{t.action === 'build' && t.produced && (
<div className="se-ai-apply-row">
{t.reverted ? (
<>
<span className="se-ai-tag se-ai-tag--reverted"></span>
<button type="button" className="se-ai-mini-btn" onClick={() => handleReapply(t.id)} disabled={busy}>
</button>
</>
) : (
<>
<span className="se-ai-tag se-ai-tag--applied"> </span>
{t.snapshot && (
<button type="button" className="se-ai-mini-btn" onClick={() => handleRevert(t.id)} disabled={busy}>
</button>
)}
</>
)}
</div>
)}
</div>
))}
{busy && (
<div className="se-ai-msg se-ai-msg--assistant">
<div className="se-ai-bubble se-ai-bubble--loading"> </div>
</div>
)}
</div>
{error && <div className="se-ai-error">{error}</div>}
<div className="se-ai-input-row">
<textarea
className="se-ai-input"
placeholder="예) 전환선이 기준선을 상향돌파하면 매수 (Enter 전송 · Shift+Enter 줄바꿈)"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKeyDown}
rows={2}
disabled={busy}
/>
<button
type="button"
className="se-ai-send"
onClick={() => void send(input)}
disabled={busy || !input.trim()}
>
</button>
</div>
</div>
);
};
export default AiStrategyPanel;
@@ -205,6 +205,14 @@ export const StartNode = memo(function StartNode({ id, data }: NodeProps) {
activeSourceSide={d.activeSourceSide} activeSourceSide={d.activeSourceSide}
canSourceConnect={d.canSourceConnect !== false} canSourceConnect={d.canSourceConnect !== false}
/> />
{/* START 결합 엣지(START끼리 자동 연결) 전용 target 핸들 — 비대화형, 엣지 부착용 */}
<Handle
type="target"
position={Position.Top}
id="t-top"
isConnectable={false}
className="se-flow-handle se-flow-handle--start-combine-tgt"
/>
<div className="se-flow-start-layout"> <div className="se-flow-start-layout">
<div className="se-flow-start-title-row"> <div className="se-flow-start-title-row">
<div className="se-flow-start-title-left"> <div className="se-flow-start-title-left">
@@ -31,6 +31,11 @@ interface Props {
stableStrategy?: boolean; stableStrategy?: boolean;
ichimokuSituation?: boolean; ichimokuSituation?: boolean;
secondaryIndicator?: string; secondaryIndicator?: string;
/** 추세선 돌파 — 드래그 payload type: trendLine */
trendLine?: boolean;
trendLineIndicatorType?: string;
trendLineLeftField?: string;
trendLineLookback?: number;
selected?: boolean; selected?: boolean;
onSelect?: () => void; onSelect?: () => void;
onAdd: () => void; onAdd: () => void;
@@ -38,10 +43,23 @@ interface Props {
export default function PaletteChip({ export default function PaletteChip({
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod, type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator, selected = false, onSelect, onAdd, composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, ichimokuBb = false, stableStrategy = false, ichimokuSituation = false, secondaryIndicator,
trendLine = false, trendLineIndicatorType, trendLineLeftField, trendLineLookback,
selected = false, onSelect, onAdd,
}: Props) { }: Props) {
const buildPayload = (): PaletteDragPayload => ({ const buildPayload = (): PaletteDragPayload => {
type, value, label, if (trendLine) {
return {
type: 'trendLine',
value,
label,
indicatorType: trendLineIndicatorType,
leftField: trendLineLeftField,
lookback: trendLineLookback,
};
}
return {
type, value, label,
composite: composite || undefined, composite: composite || undefined,
stochPair: stochPair || undefined, stochPair: stochPair || undefined,
priceExtremeBreakout: priceExtremeBreakout || undefined, priceExtremeBreakout: priceExtremeBreakout || undefined,
@@ -53,7 +71,8 @@ export default function PaletteChip({
period: periodValue, period: periodValue,
shortPeriod, shortPeriod,
longPeriod, longPeriod,
}); };
};
const pointerMode = needsPointerPaletteDrag(); const pointerMode = needsPointerPaletteDrag();
@@ -0,0 +1,81 @@
import React from 'react';
import {
handlePaletteMouseDown,
handlePalettePointerDown,
handlePaletteTouchStart,
endHtmlDragMouseTracking,
needsPointerPaletteDrag,
startHtmlDragMouseTracking,
writePaletteHtmlDragData,
type PaletteDragPayload,
} from '../../utils/paletteDragSession';
interface Props {
enabled: boolean;
/** 드래그 시작 시점에 현재 탭(매수/매도)에 맞는 조건 payload 생성. 없으면 드래그 불가 */
buildPayload: () => PaletteDragPayload | null;
}
/**
* () .
* DnD (HTML5 + pointer) .
*/
export default function SavedStrategyDragBadge({ enabled, buildPayload }: Props) {
const pointerMode = needsPointerPaletteDrag();
const onDragStart = (e: React.DragEvent) => {
if (pointerMode) {
e.preventDefault();
return;
}
const payload = buildPayload();
if (!payload) {
e.preventDefault();
return;
}
e.stopPropagation();
writePaletteHtmlDragData(e, payload);
startHtmlDragMouseTracking(e.clientX, e.clientY, payload);
};
const onDragEnd = () => {
endHtmlDragMouseTracking();
};
const onPointerDown = (e: React.PointerEvent) => {
if (!pointerMode) return;
const payload = buildPayload();
if (!payload) return;
handlePalettePointerDown(e, payload, e.currentTarget as HTMLElement);
};
const onMouseDown = (e: React.MouseEvent) => {
if (!pointerMode) return;
const payload = buildPayload();
if (!payload) return;
handlePaletteMouseDown(e, payload, e.currentTarget as HTMLElement);
};
const onTouchStart = (e: React.TouchEvent) => {
if (!pointerMode) return;
const payload = buildPayload();
if (!payload) return;
handlePaletteTouchStart(e, payload, e.currentTarget as HTMLElement);
};
return (
<span
className={`se-strat-status se-strat-status--drag${enabled ? ' se-strat-status--on' : ''}`}
draggable={!pointerMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onPointerDownCapture={onPointerDown}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onClick={e => e.stopPropagation()}
title="드래그하여 전략빌더에 이 전략의 조건을 추가/연결"
>
{enabled ? '활성' : '비활성'}
</span>
);
}
@@ -0,0 +1,70 @@
import React from 'react';
import {
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
type EdgeProps,
} from '@xyflow/react';
import type { StartCombineOp } from '../../utils/strategyStartNodes';
export type StartCombineEdgeData = {
op?: StartCombineOp;
onToggle?: (op: StartCombineOp) => void;
};
/**
* START AND/OR .
* startCombineOp . ( START 2 )
*/
export function StartCombineEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
}: EdgeProps) {
const op: StartCombineOp = (data as StartCombineEdgeData | undefined)?.op === 'OR' ? 'OR' : 'AND';
const onToggle = (data as StartCombineEdgeData | undefined)?.onToggle;
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 10,
});
return (
<>
<BaseEdge
id={id}
path={edgePath}
className={`se-flow-start-combine-edge se-flow-start-combine-edge--${op.toLowerCase()}`}
style={{ strokeWidth: 2, strokeDasharray: '6 4', pointerEvents: 'none' }}
/>
<EdgeLabelRenderer>
<button
type="button"
className={`se-start-combine-edge-badge se-start-combine-edge-badge--${op.toLowerCase()}`}
title={op === 'AND' ? 'START 간 AND (모두 충족) — 클릭하면 OR' : 'START 간 OR (하나만 충족) — 클릭하면 AND'}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
onClick={(ev) => {
ev.stopPropagation();
onToggle?.(op === 'AND' ? 'OR' : 'AND');
}}
>
{op}
</button>
</EdgeLabelRenderer>
</>
);
}
@@ -3,7 +3,6 @@ import {
ReactFlow, ReactFlow,
Background, Background,
Controls, Controls,
MiniMap,
Panel, Panel,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
@@ -20,6 +19,7 @@ import {
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type { Theme } from '../../types/index'; import type { Theme } from '../../types/index';
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes'; import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize'; import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
import { import {
addChild, addChild,
@@ -31,6 +31,7 @@ import {
type DefType, type DefType,
} from '../../utils/strategyEditorShared'; } from '../../utils/strategyEditorShared';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
import { import {
isPaletteHtmlDrag, isPaletteHtmlDrag,
isPaletteHtmlDragActive, isPaletteHtmlDragActive,
@@ -97,12 +98,13 @@ import {
DEFAULT_START_CANDLE, DEFAULT_START_CANDLE,
normalizeStartCandleType, normalizeStartCandleType,
type StartNodeMeta, type StartNodeMeta,
type StartCombineOp,
} from '../../utils/strategyStartNodes'; } from '../../utils/strategyStartNodes';
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes'; import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
import { StrategyFlowEdge } from './StrategyFlowEdge'; import { StrategyFlowEdge } from './StrategyFlowEdge';
import { StartCombineEdge } from './StartCombineEdge';
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks'; import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton'; import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
import { getMinimapColors } from './minimapTheme';
import { import {
loadCanvasInteractionMode, loadCanvasInteractionMode,
saveCanvasInteractionMode, saveCanvasInteractionMode,
@@ -124,6 +126,7 @@ const nodeTypes = {
const edgeTypes = { const edgeTypes = {
strategy: StrategyFlowEdge, strategy: StrategyFlowEdge,
startCombine: StartCombineEdge,
}; };
interface Props { interface Props {
@@ -146,6 +149,9 @@ interface Props {
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void; onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
onExtraStartIdsChange?: (ids: string[]) => void; onExtraStartIdsChange?: (ids: string[]) => void;
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void; onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
/** START 2개 이상일 때 분기 결합 (AND | OR) */
startCombineOp?: StartCombineOp;
onStartCombineOpChange?: (op: StartCombineOp) => void;
} }
export interface StrategyEditorCanvasHandle { export interface StrategyEditorCanvasHandle {
@@ -314,6 +320,8 @@ function StrategyEditorCanvasInner({
onStartCandleTypesChange, onStartCandleTypesChange,
onExtraStartIdsChange, onExtraStartIdsChange,
onExtraRootsChange, onExtraRootsChange,
startCombineOp = 'AND',
onStartCombineOpChange,
}: Props, ref: React.ForwardedRef<StrategyEditorCanvasHandle>) { }: Props, ref: React.ForwardedRef<StrategyEditorCanvasHandle>) {
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
const canvasWrapRef = useRef<HTMLDivElement>(null); const canvasWrapRef = useRef<HTMLDivElement>(null);
@@ -357,19 +365,6 @@ function StrategyEditorCanvasInner({
return ids; return ids;
}, [root, extraStartIds, extraRoots]); }, [root, extraStartIds, extraRoots]);
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
const minimapNodeColor = useCallback((node: Node) => {
if (isStartNodeId(node.id)) return minimapColors.start;
if (node.type === 'condition') return minimapColors.condition;
return minimapColors.logic;
}, [minimapColors]);
const minimapNodeStrokeColor = useCallback((node: Node) => {
if (isStartNodeId(node.id)) return minimapColors.start;
return minimapColors.stroke;
}, [minimapColors]);
const signalTabRef = useRef(signalTab); const signalTabRef = useRef(signalTab);
signalTabRef.current = signalTab; signalTabRef.current = signalTab;
@@ -608,11 +603,17 @@ function StrategyEditorCanvasInner({
}, [setNodes]); }, [setNodes]);
const resolvePaletteDropNode = useCallback(( const resolvePaletteDropNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
): LogicNode | null => { ): LogicNode | null => {
if (data.type === 'sidewaysFilter') { if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def); return buildSidewaysFilterNode(data.value, def);
} }
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
if (data.type === 'savedStrategy') {
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]); }, [signalTab, def]);
@@ -630,6 +631,20 @@ function StrategyEditorCanvasInner({
scheduleLayoutEmit(); scheduleLayoutEmit();
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]); }, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
/**
* ()
* AND ( ).
*/
const addSavedStrategyAtRoot = useCallback((
data: { type: string; value: string; label: string; condition?: LogicNode },
): boolean => {
const newNode = resolvePaletteDropNode(data);
if (!newNode) return false;
onChange(root ? mergeAtRoot(root, newNode, false) : newNode);
scheduleLayoutEmit();
return true;
}, [resolvePaletteDropNode, onChange, root, scheduleLayoutEmit]);
const applyDropWithAttach = useCallback(( const applyDropWithAttach = useCallback((
anchorId: string, anchorId: string,
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean },
@@ -713,17 +728,21 @@ function StrategyEditorCanvasInner({
const handleDropTarget = useCallback(( const handleDropTarget = useCallback((
anchorId: string, anchorId: string,
data: { type: string; value: string; label: string }, data: { type: string; value: string; label: string; condition?: LogicNode },
flowPos: { x: number; y: number }, flowPos: { x: number; y: number },
) => { ) => {
clearDropPreview(); clearDropPreview();
const fallback = () => {
if (data.type === 'savedStrategy') addSavedStrategyAtRoot(data);
else addOrphanAt(data, flowPos);
};
if (isPaletteConnectTarget(anchorId, root, orphans)) { if (isPaletteConnectTarget(anchorId, root, orphans)) {
const attached = applyDropWithAttach(anchorId, data, flowPos); const attached = applyDropWithAttach(anchorId, data, flowPos);
if (!attached) addOrphanAt(data, flowPos); if (!attached) fallback();
} else { } else {
addOrphanAt(data, flowPos); fallback();
} }
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]); }, [clearDropPreview, applyDropWithAttach, addOrphanAt, addSavedStrategyAtRoot, root, orphans]);
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => { const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
const next = normalizeConditionForPersistence(condition); const next = normalizeConditionForPersistence(condition);
@@ -938,7 +957,9 @@ function StrategyEditorCanvasInner({
startMeta, startMeta,
extraStartIds, extraStartIds,
extraRoots, extraRoots,
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots]); startCombineOp,
onStartCombineOpChange,
), [root, def, signalTab, flowCallbacks, orphans, startMeta, extraStartIds, extraRoots, startCombineOp, onStartCombineOpChange]);
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지) // 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
useEffect(() => { useEffect(() => {
@@ -1291,7 +1312,7 @@ function StrategyEditorCanvasInner({
); );
const applyPaletteDropAt = useCallback(( const applyPaletteDropAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean; condition?: LogicNode },
clientX: number, clientX: number,
clientY: number, clientY: number,
) => { ) => {
@@ -1308,12 +1329,16 @@ function StrategyEditorCanvasInner({
return; return;
} }
addOrphanAt(data, flowPos); if (data.type === 'savedStrategy') {
addSavedStrategyAtRoot(data);
} else {
addOrphanAt(data, flowPos);
}
requestAnimationFrame(() => { requestAnimationFrame(() => {
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 }); reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
}); });
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]); }, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt, addSavedStrategyAtRoot]);
const onPaneDrop = useCallback((e: React.DragEvent) => { const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1530,17 +1555,6 @@ function StrategyEditorCanvasInner({
> >
<Background gap={24} size={1} className="se-flow-bg" /> <Background gap={24} size={1} className="se-flow-bg" />
<Controls className="se-flow-controls" showInteractive={false} /> <Controls className="se-flow-controls" showInteractive={false} />
<MiniMap
className="se-flow-minimap"
zoomable
pannable
nodeStrokeWidth={2}
bgColor={minimapColors.bg}
maskColor={minimapColors.mask}
maskStrokeColor={minimapColors.maskStroke}
nodeColor={minimapNodeColor}
nodeStrokeColor={minimapNodeStrokeColor}
/>
<Panel position="top-right" className="se-canvas-auto-layout"> <Panel position="top-right" className="se-canvas-auto-layout">
<button <button
type="button" type="button"
@@ -4,7 +4,9 @@
import '../../styles/backtestDashboard.css'; import '../../styles/backtestDashboard.css';
import '../../styles/strategyEvaluation.css'; import '../../styles/strategyEvaluation.css';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import type { Theme, Timeframe } from '../../types'; import type { Theme, Timeframe, OHLCVBar } from '../../types';
import type { BacktestSignal } from '../../utils/backendApi';
import type { StrategyEvaluationWindowMeta } from '../strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationChart from '../strategyEvaluation/StrategyEvaluationChart'; import StrategyEvaluationChart from '../strategyEvaluation/StrategyEvaluationChart';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useMarketTicker } from '../../hooks/useMarketTicker'; import { useMarketTicker } from '../../hooks/useMarketTicker';
@@ -27,6 +29,11 @@ interface Props {
stratDesc: string; stratDesc: string;
buyEditorState: EditorConditionState; buyEditorState: EditorConditionState;
sellEditorState: EditorConditionState; sellEditorState: EditorConditionState;
onSignalsChange?: (signals: BacktestSignal[]) => void;
onBarsLoaded?: (bars: OHLCVBar[]) => void;
onEvalWindowMetaChange?: (meta: StrategyEvaluationWindowMeta) => void;
onBacktestRunningChange?: (running: boolean) => void;
onMarketChange?: (market: string) => void;
} }
const StrategyEditorEvaluationPanel: React.FC<Props> = ({ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
@@ -35,6 +42,11 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
stratDesc, stratDesc,
buyEditorState, buyEditorState,
sellEditorState, sellEditorState,
onSignalsChange,
onBarsLoaded,
onEvalWindowMetaChange,
onBacktestRunningChange,
onMarketChange,
}) => { }) => {
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings(); const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
const [market, setMarket] = useState('KRW-BTC'); const [market, setMarket] = useState('KRW-BTC');
@@ -42,6 +54,11 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
const [selectedBarIndex, setSelectedBarIndex] = useState(0); const [selectedBarIndex, setSelectedBarIndex] = useState(0);
const [paramsRevision, setParamsRevision] = useState(0); const [paramsRevision, setParamsRevision] = useState(0);
const editorState = useMemo(
() => ({ buy: buyEditorState, sell: sellEditorState }),
[buyEditorState, sellEditorState],
);
const draftStrategy = useMemo( const draftStrategy = useMemo(
() => buildDraftStrategyForEvaluation({ () => buildDraftStrategyForEvaluation({
name: stratName, name: stratName,
@@ -53,12 +70,12 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
); );
const strategyPrimaryTimeframe = useMemo( const strategyPrimaryTimeframe = useMemo(
() => (draftStrategy ? resolveStrategyPrimaryTimeframe(draftStrategy) : undefined), () => resolveStrategyPrimaryTimeframe(draftStrategy ?? undefined, editorState),
[draftStrategy], [draftStrategy, editorState],
); );
const chartTimeframe = useMemo((): Timeframe => { const chartTimeframe = useMemo((): Timeframe => {
return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe ?? '3m'); return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe);
}, [timeframeChoice, strategyPrimaryTimeframe]); }, [timeframeChoice, strategyPrimaryTimeframe]);
const tfOptions = useMemo( const tfOptions = useMemo(
@@ -67,10 +84,14 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
); );
const draftRevisionKey = useMemo( const draftRevisionKey = useMemo(
() => `${draftStrategy?.buyCondition ?? ''}|${draftStrategy?.sellCondition ?? ''}`, () => `${draftStrategy?.buyCondition ?? ''}|${draftStrategy?.sellCondition ?? ''}|${strategyPrimaryTimeframe}`,
[draftStrategy?.buyCondition, draftStrategy?.sellCondition], [draftStrategy?.buyCondition, draftStrategy?.sellCondition, strategyPrimaryTimeframe],
); );
useEffect(() => {
setTimeframeChoice(BACKTEST_STRATEGY_TIMEFRAME);
}, [strategyPrimaryTimeframe]);
useEffect(() => { useEffect(() => {
setParamsRevision(v => v + 1); setParamsRevision(v => v + 1);
}, [draftRevisionKey, settingsRevision]); }, [draftRevisionKey, settingsRevision]);
@@ -82,6 +103,11 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
refreshAllTickers: refreshMarketTickers, refreshAllTickers: refreshMarketTickers,
} = useMarketTicker(); } = useMarketTicker();
const handleMarketChange = (next: string) => {
setMarket(next);
onMarketChange?.(next);
};
if (!draftStrategy) { if (!draftStrategy) {
return ( return (
<div className="se-eval-panel se-eval-panel--empty"> <div className="se-eval-panel se-eval-panel--empty">
@@ -102,12 +128,16 @@ const StrategyEditorEvaluationPanel: React.FC<Props> = ({
strategies={chartStrategies} strategies={chartStrategies}
selectedStrategyId={DRAFT_EVAL_STRATEGY_ID} selectedStrategyId={DRAFT_EVAL_STRATEGY_ID}
onStrategySelect={() => {}} onStrategySelect={() => {}}
onMarketChange={setMarket} onMarketChange={handleMarketChange}
onTimeframeChoiceChange={setTimeframeChoice} onTimeframeChoiceChange={setTimeframeChoice}
theme={theme} theme={theme}
strategy={draftStrategy} strategy={draftStrategy}
selectedBarIndex={selectedBarIndex} selectedBarIndex={selectedBarIndex}
onSelectedBarIndexChange={setSelectedBarIndex} onSelectedBarIndexChange={setSelectedBarIndex}
onBarsLoaded={onBarsLoaded}
onEvalWindowMetaChange={onEvalWindowMetaChange}
onSignalsChange={onSignalsChange}
onBacktestRunningChange={onBacktestRunningChange}
paramsRevision={paramsRevision} paramsRevision={paramsRevision}
getParamsOverride={getParams} getParamsOverride={getParams}
getVisualConfigOverride={getVisualConfig} getVisualConfigOverride={getVisualConfig}
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { LogicNode } from '../../utils/strategyTypes'; import type { LogicNode } from '../../utils/strategyTypes';
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
import { import {
CondEditor, CondEditor,
addChild, addChild,
@@ -36,6 +37,7 @@ import LogicGateOpToggle from './LogicGateOpToggle';
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes'; import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
import { START_NODE_ID } from '../../utils/strategyFlowLayout'; import { START_NODE_ID } from '../../utils/strategyFlowLayout';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNodeFromDrag } from '../../utils/trendLinePalette';
import { import {
clearPaletteDropHighlights, clearPaletteDropHighlights,
findPaletteDropKeyAtPoint, findPaletteDropKeyAtPoint,
@@ -403,11 +405,17 @@ export default function StrategyListEditor({
}, [onEditorStateChange, onOrphansChange, orphans]); }, [onEditorStateChange, onOrphansChange, orphans]);
const resolvePaletteNode = useCallback(( const resolvePaletteNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
) => { ) => {
if (data.type === 'sidewaysFilter') { if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def); return buildSidewaysFilterNode(data.value, def);
} }
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
if (data.type === 'savedStrategy') {
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]); }, [signalTab, def]);
@@ -0,0 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import PaletteChip from './PaletteChip';
import { loadBacktestSettings } from '../../utils/backendApi';
import {
filterTrendLinePaletteItems,
type TrendLinePaletteItem,
} from '../../utils/trendLinePalette';
interface Props {
signalType: 'buy' | 'sell';
searchQuery: string;
selectedItemId: string | null;
onSelectItem: (id: string | null) => void;
onAddToCanvas: (item: TrendLinePaletteItem, lookback: number) => void;
}
function trendLinePeriodLabel(lookback: number, signalType: 'buy' | 'sell'): string {
const dir = signalType === 'buy' ? '상향' : '하향';
return `${lookback}봉 · ${dir}`;
}
export default function TrendLinePaletteTab({
signalType,
searchQuery,
selectedItemId,
onSelectItem,
onAddToCanvas,
}: Props) {
const [defaultLookback, setDefaultLookback] = useState(10);
useEffect(() => {
void loadBacktestSettings().then(s => {
if (s?.trendLineDefaultLookback != null && s.trendLineDefaultLookback > 1) {
setDefaultLookback(s.trendLineDefaultLookback);
}
});
}, []);
const items = useMemo(
() => filterTrendLinePaletteItems(searchQuery),
[searchQuery],
);
return (
<div className="se-palette-section se-palette-section--trendline se-palette-section--scroll">
<div className="se-palette-toolbar">
<span className="se-palette-toolbar-title"> </span>
<span className="se-palette-toolbar-hint">
{signalType === 'buy' ? '매수: 상향 돌파' : '매도: 하향 돌파'}
</span>
</div>
<div className="se-palette-grid se-palette-grid--3">
{items.map(item => (
<PaletteChip
key={item.id}
type="indicator"
value={item.id}
label={item.label}
desc={item.desc}
color="trendline"
trendLine
trendLineIndicatorType={item.indicatorType}
trendLineLeftField={item.leftField}
trendLineLookback={defaultLookback}
period={trendLinePeriodLabel(defaultLookback, signalType)}
selected={selectedItemId === item.id}
onSelect={() => onSelectItem(item.id)}
onAdd={() => onAddToCanvas(item, defaultLookback)}
/>
))}
</div>
{items.length === 0 && (
<p className="se-palette-empty"> .</p>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
import TradingChart from '../TradingChart'; import TradingChart from '../TradingChart';
import ChartCustomOverlaySettingsModal from '../ChartCustomOverlaySettingsModal'; import ChartCustomOverlaySettingsModal from '../ChartCustomOverlaySettingsModal';
import { MarketSearchPanel } from '../MarketSearchPanel'; import { MarketSearchPanel } from '../MarketSearchPanel';
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types'; import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe, Drawing } from '../../types';
import type { ChartManager } from '../../utils/ChartManager'; import type { ChartManager } from '../../utils/ChartManager';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useAppSettings } from '../../hooks/useAppSettings'; import { useAppSettings } from '../../hooks/useAppSettings';
@@ -14,8 +14,10 @@ import {
import { getIndicatorDef } from '../../utils/indicatorRegistry'; import { getIndicatorDef } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
import { getKoreanName } from '../../utils/marketNameCache'; import { getKoreanName } from '../../utils/marketNameCache';
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi'; import type { BacktestSettingsDto, BacktestSignal, StrategyDto } from '../../utils/backendApi';
import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals'; import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals';
import { buildSignalTrendLineDrawings } from '../../utils/buildSignalTrendLineDrawings';
import { setIndicatorChartContext } from '../../utils/indicatorRegistry';
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker'; import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
import { import {
BACKTEST_DISPLAY_BAR_COUNT, BACKTEST_DISPLAY_BAR_COUNT,
@@ -197,6 +199,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc'); const [marketSortDir, setMarketSortDir] = useState<MarketSortDir>('desc');
const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites())); const [marketFavs, setMarketFavs] = useState<Set<string>>(() => new Set(getFavorites()));
const [signals, setSignals] = useState<BacktestSignal[]>([]); const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [evalSettings, setEvalSettings] = useState<BacktestSettingsDto | null>(null);
const [signalDrawings, setSignalDrawings] = useState<Drawing[]>([]);
const [backtestRunning, setBacktestRunning] = useState(false); const [backtestRunning, setBacktestRunning] = useState(false);
const [backtestError, setBacktestError] = useState<string | null>(null); const [backtestError, setBacktestError] = useState<string | null>(null);
const [magnifierActive, setMagnifierActive] = useState(false); const [magnifierActive, setMagnifierActive] = useState(false);
@@ -415,7 +419,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
setBacktestRunning(true); setBacktestRunning(true);
setBacktestError(null); setBacktestError(null);
try { try {
const { signals: nextSignals } = await fetchStrategyEvaluationSignals({ const result = await fetchStrategyEvaluationSignals({
strategyId: strategy.id, strategyId: strategy.id,
strategy, strategy,
market, market,
@@ -426,7 +430,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
useDraftDsl, useDraftDsl,
}); });
if (gen !== backtestGenRef.current) return; if (gen !== backtestGenRef.current) return;
setSignals(nextSignals); setEvalSettings(result.settings);
setSignals(result.signals);
applyMarkers(); applyMarkers();
} catch (e) { } catch (e) {
if (gen !== backtestGenRef.current) return; if (gen !== backtestGenRef.current) return;
@@ -461,6 +466,29 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onSignalsChange?.(signals); onSignalsChange?.(signals);
}, [signals, onSignalsChange]); }, [signals, onSignalsChange]);
useEffect(() => {
setIndicatorChartContext(market, timeframe);
if (!strategy || bars.length === 0) {
setSignalDrawings([]);
return;
}
if (selectedBarIndex < 0 || selectedBarIndex >= bars.length) {
setSignalDrawings([]);
return;
}
let cancelled = false;
void buildSignalTrendLineDrawings({
bars,
barIndex: selectedBarIndex,
strategy,
settings: evalSettings,
getParams,
}).then(drawings => {
if (!cancelled) setSignalDrawings(drawings);
});
return () => { cancelled = true; };
}, [bars, strategy, evalSettings, getParams, market, timeframe, selectedBarIndex]);
useEffect(() => { useEffect(() => {
onBacktestRunningChange?.(backtestRunning); onBacktestRunningChange?.(backtestRunning);
}, [backtestRunning, onBacktestRunningChange]); }, [backtestRunning, onBacktestRunningChange]);
@@ -972,7 +1000,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
mode="chart" mode="chart"
indicators={indicators} indicators={indicators}
drawingTool="cursor" drawingTool="cursor"
drawings={[]} drawings={signalDrawings}
logScale={false} logScale={false}
onCrosshair={() => {}} onCrosshair={() => {}}
onManagerReady={onManagerReady} onManagerReady={onManagerReady}
@@ -5,6 +5,7 @@ import React, { useMemo, useState } from 'react';
import PaletteChip from '../strategyEditor/PaletteChip'; import PaletteChip from '../strategyEditor/PaletteChip';
import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab'; import IndicatorPaletteTab from '../strategyEditor/IndicatorPaletteTab';
import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab'; import SidewaysFilterPaletteTab from '../strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from '../strategyEditor/TrendLinePaletteTab';
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout'; import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
import { import {
BAND_PALETTE_CHIP_ITEMS, BAND_PALETTE_CHIP_ITEMS,
@@ -17,7 +18,7 @@ import type { useStrategyEvaluationEditor } from '../../hooks/useStrategyEvaluat
type EditorApi = Pick< type EditorApi = Pick<
ReturnType<typeof useStrategyEvaluationEditor>, ReturnType<typeof useStrategyEvaluationEditor>,
'def' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' 'def' | 'signalTab' | 'applyPalette' | 'applyPaletteItem' | 'applySidewaysFilter' | 'applyTrendLine'
>; >;
interface Props { interface Props {
@@ -35,10 +36,10 @@ function paletteKey(type: string, id: string) {
} }
const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) => { const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) => {
const { def, applyPalette, applyPaletteItem, applySidewaysFilter } = editor; const { def, signalTab, applyPalette, applyPaletteItem, applySidewaysFilter, applyTrendLine } = editor;
const [paletteSearch, setPaletteSearch] = useState(''); const [paletteSearch, setPaletteSearch] = useState('');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary'); const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary')); const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite')); const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
@@ -141,6 +142,16 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
> >
</button> </button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('trendline');
setSelectedPaletteKey(null);
}}
>
</button>
</div> </div>
{indicatorSubTab === 'auxiliary' ? ( {indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab <IndicatorPaletteTab
@@ -178,7 +189,7 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
applyPaletteItem(item); applyPaletteItem(item);
}} }}
/> />
) : ( ) : indicatorSubTab === 'range' ? (
<SidewaysFilterPaletteTab <SidewaysFilterPaletteTab
def={def as DefType} def={def as DefType}
items={sidewaysPalette} items={sidewaysPalette}
@@ -195,6 +206,21 @@ const StrategyEvaluationIndicatorPalettePanel: React.FC<Props> = ({ editor }) =>
applySidewaysFilter(item.id); applySidewaysFilter(item.id);
}} }}
/> />
) : (
<TrendLinePaletteTab
signalType={signalTab}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('trendline:')
? selectedPaletteKey.slice('trendline:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
onAddToCanvas={(item, lookback) => {
selectPalette('trendline', item.id);
applyTrendLine(item, lookback);
}}
/>
)} )}
</div> </div>
); );
@@ -59,6 +59,7 @@ import {
buildSidewaysFilterNode, buildSidewaysFilterNode,
loadSidewaysPaletteItems, loadSidewaysPaletteItems,
} from '../utils/sidewaysFilterPaletteStorage'; } from '../utils/sidewaysFilterPaletteStorage';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import type { IndicatorVisualConfig } from './useIndicatorSettings'; import type { IndicatorVisualConfig } from './useIndicatorSettings';
import type { PaletteItem } from '../utils/strategyPaletteStorage'; import type { PaletteItem } from '../utils/strategyPaletteStorage';
@@ -490,6 +491,18 @@ export function useStrategyEvaluationEditor({
}); });
}, [def, handleEditorStateChange]); }, [def, handleEditorStateChange]);
const applyTrendLine = useCallback((
item: { indicatorType: string; leftField?: string },
lookback: number,
) => {
const newNode = buildTrendLineConditionNode(item, signalTab, def, lookback);
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, def, handleEditorStateChange]);
const applyTemplateForEvaluation = useCallback(async (row: TemplatePaletteRow): Promise<StrategyDto | null> => { const applyTemplateForEvaluation = useCallback(async (row: TemplatePaletteRow): Promise<StrategyDto | null> => {
const payload = buildTemplateEvaluationPayload(row, def); const payload = buildTemplateEvaluationPayload(row, def);
if (!payload) { if (!payload) {
@@ -541,6 +554,7 @@ export function useStrategyEvaluationEditor({
applyPalette, applyPalette,
applyPaletteItem, applyPaletteItem,
applySidewaysFilter, applySidewaysFilter,
applyTrendLine,
applyTemplateForEvaluation, applyTemplateForEvaluation,
saving, saving,
saveNow, saveNow,
+229
View File
@@ -713,6 +713,19 @@
background: color-mix(in srgb, var(--se-gold) 15%, transparent); background: color-mix(in srgb, var(--se-gold) 15%, transparent);
border-color: color-mix(in srgb, var(--se-gold) 35%, transparent); border-color: color-mix(in srgb, var(--se-gold) 35%, transparent);
} }
.se-strat-status--drag {
cursor: grab;
touch-action: none;
user-select: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.se-strat-status--drag:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}
.se-strat-status--drag:active {
cursor: grabbing;
}
.se-empty { .se-empty {
font-size: 0.78rem; font-size: 0.78rem;
@@ -902,6 +915,10 @@
line-height: 1.5; line-height: 1.5;
} }
.se-eval-return-summary {
flex-shrink: 0;
}
.se-editing-name { .se-editing-name {
font-size: 0.72rem; font-size: 0.72rem;
color: color-mix(in srgb, var(--se-gold) 85%, transparent); color: color-mix(in srgb, var(--se-gold) 85%, transparent);
@@ -1111,6 +1128,56 @@
scale: 1.1; scale: 1.1;
} }
/* START 결합 엣지 — START끼리 자동 연결 (AND/OR) */
.se-flow-start-combine-edge {
stroke: var(--se-edge);
opacity: 0.85;
}
.se-flow-start-combine-edge--and {
stroke: color-mix(in srgb, var(--se-accent) 80%, transparent);
}
.se-flow-start-combine-edge--or {
stroke: #00d4ff;
}
.se-start-combine-edge-badge {
z-index: 11;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 38px;
height: 22px;
padding: 0 9px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--se-accent) 55%, transparent);
background: rgba(15, 23, 42, 0.96);
color: var(--se-accent);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
transition: background 0.15s, transform 0.12s, border-color 0.15s;
}
.se-start-combine-edge-badge:hover {
transform: scale(1.08);
}
.se-start-combine-edge-badge--or {
border-color: rgba(0, 212, 255, 0.6);
color: #00d4ff;
}
/* START 결합 전용 target 핸들 — 비대화형, 시각적으로 최소화 */
.se-flow-handle--start-combine-tgt {
width: 1px;
height: 1px;
min-width: 1px;
min-height: 1px;
background: transparent;
border: none;
opacity: 0;
pointer-events: none;
}
.se-flow-edge-hit { .se-flow-edge-hit {
pointer-events: stroke; pointer-events: stroke;
} }
@@ -1910,6 +1977,131 @@
overflow-x: hidden; overflow-x: hidden;
} }
/* ── AI 전략 에이전트 탭 ───────────────────────────────────── */
.se-ai-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.se-ai-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-bottom: 1px solid var(--se-border);
}
.se-ai-title { font-size: 0.78rem; font-weight: 600; color: var(--se-text); }
.se-ai-reset {
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--se-input-border);
background: var(--se-input-bg);
color: var(--se-text-muted);
cursor: pointer;
}
.se-ai-reset:disabled { opacity: 0.5; cursor: default; }
.se-ai-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.se-ai-empty { color: var(--se-text-muted); font-size: 0.76rem; }
.se-ai-empty p { margin: 0 0 6px; line-height: 1.5; }
.se-ai-empty-sub { opacity: 0.8; }
.se-ai-suggestions { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.se-ai-suggestion {
text-align: left;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--se-input-border);
background: var(--se-input-bg);
color: var(--se-text);
font-size: 0.73rem;
cursor: pointer;
}
.se-ai-suggestion:hover { border-color: var(--se-tab-active); }
.se-ai-suggestion:disabled { opacity: 0.5; cursor: default; }
.se-ai-msg { display: flex; flex-direction: column; max-width: 92%; }
.se-ai-msg--user { align-self: flex-end; align-items: flex-end; }
.se-ai-msg--assistant { align-self: flex-start; align-items: flex-start; }
.se-ai-bubble {
padding: 8px 11px;
border-radius: 12px;
font-size: 0.76rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.se-ai-bubble p { margin: 0; }
.se-ai-msg--user .se-ai-bubble {
background: var(--se-tab-active);
color: #fff;
border-bottom-right-radius: 4px;
}
.se-ai-msg--assistant .se-ai-bubble {
background: var(--se-input-bg);
border: 1px solid var(--se-input-border);
color: var(--se-text);
border-bottom-left-radius: 4px;
}
.se-ai-bubble--loading { opacity: 0.7; font-style: italic; }
.se-ai-apply-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.se-ai-tag { font-size: 0.68rem; padding: 2px 7px; border-radius: 999px; }
.se-ai-tag--applied { background: color-mix(in srgb, #16a34a 18%, transparent); color: #16a34a; }
.se-ai-tag--reverted { background: color-mix(in srgb, #d97706 18%, transparent); color: #d97706; }
.se-ai-mini-btn {
font-size: 0.68rem;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--se-input-border);
background: transparent;
color: var(--se-text-muted);
cursor: pointer;
}
.se-ai-mini-btn:disabled { opacity: 0.5; cursor: default; }
.se-ai-error {
margin: 0 10px 6px;
padding: 6px 9px;
border-radius: 8px;
font-size: 0.72rem;
background: color-mix(in srgb, #dc2626 14%, transparent);
color: #dc2626;
}
.se-ai-input-row {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--se-border);
}
.se-ai-input {
flex: 1;
resize: none;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--se-input-border);
background: var(--se-input-bg);
color: var(--se-text);
font-size: 0.76rem;
font-family: inherit;
}
.se-ai-send {
align-self: stretch;
padding: 0 14px;
border-radius: 8px;
border: none;
background: var(--se-tab-active);
color: #fff;
font-size: 0.76rem;
cursor: pointer;
}
.se-ai-send:disabled { opacity: 0.5; cursor: default; }
.se-palette-search { .se-palette-search {
margin: 10px 10px 6px; margin: 10px 10px 6px;
padding: 8px 10px; padding: 8px 10px;
@@ -1957,6 +2149,15 @@
color: var(--se-palette-section-range, #f59e0b); color: var(--se-palette-section-range, #f59e0b);
box-shadow: inset 0 -2px 0 var(--se-palette-section-range, #f59e0b); box-shadow: inset 0 -2px 0 var(--se-palette-section-range, #f59e0b);
} }
.se-palette-subtab--trendline.se-palette-subtab--on {
color: var(--se-palette-section-trendline, #38bdf8);
box-shadow: inset 0 -2px 0 var(--se-palette-section-trendline, #38bdf8);
}
.se-palette-section--trendline h3,
.se-palette-section--trendline .se-palette-toolbar-title {
color: var(--se-palette-section-trendline, #38bdf8);
}
.se-palette-section--range .se-palette-toolbar-title { .se-palette-section--range .se-palette-toolbar-title {
color: var(--se-palette-section-range, #f59e0b); color: var(--se-palette-section-range, #f59e0b);
@@ -2467,6 +2668,31 @@ body.se-palette-drag-armed .se-palette-section--scroll {
0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent); 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
} }
/* ── 추세선 돌파 ── */
.se-palette-card--trendline {
--se-palette-trendline-accent: var(--se-palette-section-trendline, #38bdf8);
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 42%, transparent);
background: color-mix(in srgb, var(--se-palette-trendline-accent) 10%, var(--bg2));
box-shadow:
inset 3px 0 0 var(--se-palette-trendline-accent),
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
--se-palette-chip-caption: color-mix(in srgb, var(--se-palette-trendline-accent) 34%, #fff 66%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-palette-trendline-accent) 18%, #fff 82%);
--se-palette-chip-period-bg: color-mix(in srgb, #000 34%, transparent);
--se-palette-chip-period-border: color-mix(in srgb, var(--se-palette-trendline-accent) 40%, transparent);
}
.se-palette-card--trendline .se-palette-card-icon {
background: color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
color: var(--se-palette-trendline-accent);
}
.se-palette-card--trendline:hover {
border-color: color-mix(in srgb, var(--se-palette-trendline-accent) 65%, transparent);
box-shadow:
inset 3px 0 0 var(--se-palette-trendline-accent),
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
0 4px 14px color-mix(in srgb, var(--se-palette-trendline-accent) 22%, transparent);
}
/* ── 복합지표 ── */ /* ── 복합지표 ── */
.se-palette-card--composite { .se-palette-card--composite {
border-color: color-mix(in srgb, #c084fc 42%, transparent); border-color: color-mix(in srgb, #c084fc 42%, transparent);
@@ -2577,6 +2803,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
/* 지표 탭 — 전략·보조 지표 컨트롤 이름 (오렌지) */ /* 지표 탭 — 전략·보조 지표 컨트롤 이름 (오렌지) */
.se-palette-panel .se-palette-card--band .se-palette-card-name, .se-palette-panel .se-palette-card--band .se-palette-card-name,
.se-palette-panel .se-palette-card--ind .se-palette-card-name, .se-palette-panel .se-palette-card--ind .se-palette-card-name,
.se-palette-panel .se-palette-card--trendline .se-palette-card-name,
.se-palette-panel .se-palette-card--composite .se-palette-card-name, .se-palette-panel .se-palette-card--composite .se-palette-card-name,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-name, .se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-name,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-name, .se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-name,
@@ -2588,6 +2815,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
/* 지표 탭 — 설명·기간 보조 텍스트 가독성 */ /* 지표 탭 — 설명·기간 보조 텍스트 가독성 */
.se-palette-panel .se-palette-card--band .se-palette-card-desc, .se-palette-panel .se-palette-card--band .se-palette-card-desc,
.se-palette-panel .se-palette-card--ind .se-palette-card-desc, .se-palette-panel .se-palette-card--ind .se-palette-card-desc,
.se-palette-panel .se-palette-card--trendline .se-palette-card-desc,
.se-palette-panel .se-palette-card--composite .se-palette-card-desc, .se-palette-panel .se-palette-card--composite .se-palette-card-desc,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-desc, .se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-desc,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-desc, .se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-desc,
@@ -2603,6 +2831,7 @@ body.se-palette-drag-armed .se-palette-section--scroll {
.se-palette-panel .se-palette-card--band .se-palette-card-period, .se-palette-panel .se-palette-card--band .se-palette-card-period,
.se-palette-panel .se-palette-card--ind .se-palette-card-period, .se-palette-panel .se-palette-card--ind .se-palette-card-period,
.se-palette-panel .se-palette-card--trendline .se-palette-card-period,
.se-palette-panel .se-palette-card--composite .se-palette-card-period, .se-palette-panel .se-palette-card--composite .se-palette-card-period,
.se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-period, .se-palette-panel .se-palette-card.se-palette-card--range .se-palette-card-period,
.se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-period, .se-palette-panel .se-palette-card.se-palette-card--range-include .se-palette-card-period,
@@ -77,6 +77,7 @@
--se-palette-section-band: #a78bfa; --se-palette-section-band: #a78bfa;
--se-palette-section-ind: #34d399; --se-palette-section-ind: #34d399;
--se-palette-section-range: #f59e0b; --se-palette-section-range: #f59e0b;
--se-palette-section-trendline: #38bdf8;
--se-palette-strategy-name: #f59e0b; --se-palette-strategy-name: #f59e0b;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 82%, #fff 18%); --se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 82%, #fff 18%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 90%, #fff 10%); --se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 90%, #fff 10%);
@@ -221,6 +222,7 @@
--se-palette-section-band: #7b1fa2; --se-palette-section-band: #7b1fa2;
--se-palette-section-ind: #2e7d32; --se-palette-section-ind: #2e7d32;
--se-palette-section-range: #e65100; --se-palette-section-range: #e65100;
--se-palette-section-trendline: #0288d1;
--se-palette-strategy-name: #e65100; --se-palette-strategy-name: #e65100;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text) 78%, #000 22%); --se-palette-chip-caption: color-mix(in srgb, var(--se-text) 78%, #000 22%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 92%, #000 8%); --se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 92%, #000 8%);
@@ -290,6 +292,7 @@
--se-palette-section-band: #ce93d8; --se-palette-section-band: #ce93d8;
--se-palette-section-ind: #69f0ae; --se-palette-section-ind: #69f0ae;
--se-palette-section-range: #ffb74d; --se-palette-section-range: #ffb74d;
--se-palette-section-trendline: #4fc3f7;
--se-palette-strategy-name: #ffb74d; --se-palette-strategy-name: #ffb74d;
--se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 80%, #fff 20%); --se-palette-chip-caption: color-mix(in srgb, var(--se-text-muted) 80%, #fff 20%);
--se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 88%, #fff 12%); --se-palette-chip-period-value: color-mix(in srgb, var(--se-text) 88%, #fff 12%);
+69
View File
@@ -1440,6 +1440,14 @@ export interface BacktestSettingsDto {
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY'; analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
/** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */ /** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */
tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE'; tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE';
/** 추세선 조건 — TWO_POINT | REGRESSION | SWING */
trendLineMode?: 'TWO_POINT' | 'REGRESSION' | 'SWING';
/** 조건 lookbackPeriod 미지정 시 기본 N봉 */
trendLineDefaultLookback?: number;
/** SWING 모드 — 스윙 저점 판정 좌측 봉 수 */
trendLineSwingPrecedingBars?: number;
/** SWING 모드 — 스윙 저점 판정 우측 봉 수 */
trendLineSwingFollowingBars?: number;
} }
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
@@ -1465,6 +1473,10 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
positionMode: 'LONG_ONLY', positionMode: 'LONG_ONLY',
analysisMethod: 'MARK_TO_MARKET', analysisMethod: 'MARK_TO_MARKET',
tradeExecutionMode: 'BACKTEST_ENGINE', tradeExecutionMode: 'BACKTEST_ENGINE',
trendLineMode: 'TWO_POINT',
trendLineDefaultLookback: 10,
trendLineSwingPrecedingBars: 1,
trendLineSwingFollowingBars: 1,
}; };
/** 백테스팅 설정 로드 */ /** 백테스팅 설정 로드 */
@@ -1823,6 +1835,63 @@ export async function fetchLlmConnectionTest(): Promise<LlmConnectionTestRespons
); );
} }
export interface LlmModelsQuery {
host: string;
port: number;
useHttps: boolean;
modelsPath?: string;
timeoutMs?: number;
}
export interface LlmModelsResult {
ok: boolean;
models: string[];
message?: string;
}
/** 설정 화면 — 입력한(미저장) 서버에서 사용 가능한 모델 목록 조회 */
export async function fetchLlmModels(query: LlmModelsQuery): Promise<LlmModelsResult> {
return requestOrThrowWithTimeout<LlmModelsResult>(
'/strategy/evaluation/llm-models',
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify(query),
},
60_000,
);
}
export interface StrategyAgentChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface StrategyAgentChatApiResponse {
reply: string;
action: 'ask' | 'build' | 'clear';
buyCondition?: unknown | null;
sellCondition?: unknown | null;
model?: string;
latencyMs?: number;
}
/** AI 전략 에이전트 — 멀티턴 대화로 전략 DSL 생성/수정 */
export async function postStrategyAgentChat(
messages: StrategyAgentChatMessage[],
context: unknown,
): Promise<StrategyAgentChatApiResponse> {
return requestOrThrowWithTimeout<StrategyAgentChatApiResponse>(
'/strategy/agent/chat',
{
method: 'POST',
cache: 'no-store',
body: JSON.stringify({ messages, context }),
},
620_000,
);
}
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */ /** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> { export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
if (!hasRegisteredUser()) return ['1m']; if (!hasRegisteredUser()) return ['1m'];
@@ -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;
}
+7
View File
@@ -13,6 +13,8 @@ export interface LlmAppSettings {
useHttps: boolean; useHttps: boolean;
/** OpenAI 호환 chat completions 경로 */ /** OpenAI 호환 chat completions 경로 */
chatPath: string; chatPath: string;
/** OpenAI 호환 모델 목록 경로 (드롭다운 조회용) */
modelsPath: string;
/** 모델 ID */ /** 모델 ID */
model: string; model: string;
/** 최대 생성 토큰 */ /** 최대 생성 토큰 */
@@ -29,6 +31,7 @@ export const DEFAULT_LLM_APP_SETTINGS: LlmAppSettings = {
port: 16000, port: 16000,
useHttps: false, useHttps: false,
chatPath: '/v1/chat/completions', chatPath: '/v1/chat/completions',
modelsPath: '/v1/models',
model: 'mlx-community/Qwen2.5-7B-Instruct-4bit', model: 'mlx-community/Qwen2.5-7B-Instruct-4bit',
maxTokens: 1024, maxTokens: 1024,
temperature: 0.1, temperature: 0.1,
@@ -56,6 +59,9 @@ export function resolveLlmAppSettings(
const chatPath = typeof raw.chatPath === 'string' && raw.chatPath.trim() const chatPath = typeof raw.chatPath === 'string' && raw.chatPath.trim()
? (raw.chatPath.startsWith('/') ? raw.chatPath.trim() : `/${raw.chatPath.trim()}`) ? (raw.chatPath.startsWith('/') ? raw.chatPath.trim() : `/${raw.chatPath.trim()}`)
: d.chatPath; : d.chatPath;
const modelsPath = typeof raw.modelsPath === 'string' && raw.modelsPath.trim()
? (raw.modelsPath.startsWith('/') ? raw.modelsPath.trim() : `/${raw.modelsPath.trim()}`)
: d.modelsPath;
const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : d.model; const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : d.model;
return { return {
enabled: raw.enabled === undefined ? d.enabled : Boolean(raw.enabled), enabled: raw.enabled === undefined ? d.enabled : Boolean(raw.enabled),
@@ -63,6 +69,7 @@ export function resolveLlmAppSettings(
port: clampInt(raw.port, 1, 65535, d.port), port: clampInt(raw.port, 1, 65535, d.port),
useHttps: raw.useHttps === undefined ? d.useHttps : Boolean(raw.useHttps), useHttps: raw.useHttps === undefined ? d.useHttps : Boolean(raw.useHttps),
chatPath, chatPath,
modelsPath,
model, model,
maxTokens: clampInt(raw.maxTokens, 64, 8192, d.maxTokens), maxTokens: clampInt(raw.maxTokens, 64, 8192, d.maxTokens),
temperature: clampFloat(raw.temperature, 0, 2, d.temperature), temperature: clampFloat(raw.temperature, 0, 2, d.temperature),
+109
View File
@@ -0,0 +1,109 @@
/**
* AI API + DSL ·.
*/
import {
postStrategyAgentChat,
type StrategyAgentChatMessage,
type StrategyAgentChatApiResponse,
} from './backendApi';
import { INDICATOR_CONDITIONS, generateNodeId, type ConditionDSL, type LogicNode, type LogicNodeType } from './strategyTypes';
export type { StrategyAgentChatMessage } from './backendApi';
export interface StrategyAgentResult {
reply: string;
action: 'ask' | 'build' | 'clear';
/** 검증·정규화된 매수 조건 (build이고 유효할 때만) */
buyCondition: LogicNode | null;
/** 검증·정규화된 매도 조건 */
sellCondition: LogicNode | null;
/** action=build인데 DSL이 모두 비어 검증 후 사라진 경우 */
hasStrategy: boolean;
model?: string;
latencyMs?: number;
}
const NODE_TYPES: ReadonlySet<LogicNodeType> = new Set<LogicNodeType>([
'AND', 'OR', 'NOT', 'CONDITION', 'TIMEFRAME',
]);
const WINDOW_MODES = new Set(['EXISTS_IN', 'NOT_EXISTS_IN']);
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/** LLM이 생성한 LogicNode 트리를 편집기/백엔드가 처리 가능한 형태로 검증·정규화. */
export function sanitizeAiLogicNode(raw: unknown): LogicNode | null {
const obj = asRecord(raw);
if (!obj) return null;
const type = obj.type as LogicNodeType;
if (!NODE_TYPES.has(type)) return null;
const id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : generateNodeId();
if (type === 'CONDITION') {
const cond = asRecord(obj.condition);
if (!cond) return null;
const indicatorType = typeof cond.indicatorType === 'string' ? cond.indicatorType.trim() : '';
const conditionType = typeof cond.conditionType === 'string' ? cond.conditionType.trim() : '';
if (!indicatorType || !conditionType) return null;
// 알려진 지표면 conditionType 매트릭스로 검증, 모르는 지표는 백엔드 폴백에 위임
const allowed = INDICATOR_CONDITIONS[indicatorType];
if (allowed && !allowed.includes(conditionType)) return null;
const condition = { ...cond, indicatorType, conditionType } as unknown as ConditionDSL;
// 윈도우 모드일 때 candleRange 보정
if (condition.candleRangeMode && WINDOW_MODES.has(condition.candleRangeMode)) {
const n = Number(condition.candleRange);
condition.candleRange = Number.isFinite(n) && n >= 2 ? Math.floor(n) : 4;
}
return { id, type, condition };
}
// AND / OR / NOT / TIMEFRAME
const children = Array.isArray(obj.children)
? (obj.children.map(sanitizeAiLogicNode).filter(Boolean) as LogicNode[])
: [];
if (type === 'NOT') {
if (children.length === 0) return null;
return { id, type, children: [children[0]] } as LogicNode;
}
if (type === 'TIMEFRAME') {
if (children.length === 0) return null;
const node: LogicNode = { id, type, children };
if (typeof obj.candleType === 'string') node.candleType = obj.candleType;
if (Array.isArray(obj.candleTypes)) {
node.candleTypes = obj.candleTypes.filter((c): c is string => typeof c === 'string');
}
return node;
}
// AND / OR — 자식이 없으면 무의미하므로 제거, 하나뿐이면 그대로 승격하지 않고 유지
if (children.length === 0) return null;
return { id, type, children } as LogicNode;
}
/** 멀티턴 대화 요청 → 응답 파싱·DSL 검증 */
export async function requestStrategyAgentChat(
messages: StrategyAgentChatMessage[],
context: unknown,
): Promise<StrategyAgentResult> {
const res: StrategyAgentChatApiResponse = await postStrategyAgentChat(messages, context);
const action: 'ask' | 'build' | 'clear' =
res.action === 'build' ? 'build' : res.action === 'clear' ? 'clear' : 'ask';
const buyCondition = action === 'build' ? sanitizeAiLogicNode(res.buyCondition) : null;
const sellCondition = action === 'build' ? sanitizeAiLogicNode(res.sellCondition) : null;
return {
reply: res.reply ?? '',
action,
buyCondition,
sellCondition,
hasStrategy: Boolean(buyCondition || sellCondition),
model: res.model,
latencyMs: res.latencyMs,
};
}
+15 -2
View File
@@ -418,6 +418,8 @@ const COND_OPTIONS = [
{ v: 'NEQ', l: '다름 (!=)' }, { v: 'NEQ', l: '다름 (!=)' },
{ v: 'CROSS_UP', l: '상향 돌파' }, { v: 'CROSS_UP', l: '상향 돌파' },
{ v: 'CROSS_DOWN', l: '하향 돌파' }, { v: 'CROSS_DOWN', l: '하향 돌파' },
{ v: 'TREND_LINE_CROSS_UP', l: '추세선 상향 돌파' },
{ v: 'TREND_LINE_CROSS_DOWN', l: '추세선 하향 돌파' },
{ v: 'SLOPE_UP', l: '상승 기울기' }, { v: 'SLOPE_UP', l: '상승 기울기' },
{ v: 'SLOPE_DOWN', l: '하락 기울기' }, { v: 'SLOPE_DOWN', l: '하락 기울기' },
{ v: 'DIFF_GT', l: '차이 > 값' }, { v: 'DIFF_GT', l: '차이 > 값' },
@@ -444,6 +446,7 @@ export const getCondOptionsForIndicator = (ind: string): CondTypeOpt[] => {
const COMMON_COND_KEYS = [ const COMMON_COND_KEYS = [
'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN',
'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN',
'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS',
]; ];
@@ -899,6 +902,10 @@ export const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, D
if (!isValid(updated.leftField)) updated.leftField = def.l; if (!isValid(updated.leftField)) updated.leftField = def.l;
updated.rightField = 'NONE'; updated.rightField = 'NONE';
updated.slopePeriod = updated.slopePeriod ?? 3; updated.slopePeriod = updated.slopePeriod ?? 3;
} else if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(newCondType)) {
if (!isValid(updated.leftField)) updated.leftField = def.l;
updated.rightField = 'NONE';
updated.lookbackPeriod = updated.lookbackPeriod ?? 10;
} else if (newCondType === 'HOLD_N_DAYS') { } else if (newCondType === 'HOLD_N_DAYS') {
updated.leftField = 'NONE'; updated.rightField = 'NONE'; updated.leftField = 'NONE'; updated.rightField = 'NONE';
updated.holdDays = updated.holdDays ?? 3; updated.holdDays = updated.holdDays ?? 3;
@@ -1004,6 +1011,11 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥'; const op = c.conditionType === 'LOWEST_LTE' ? '≤' : '≥';
return `${indName} - 최근 ${n}${L} 최저 ${op} ${R || '침체선'}`; return `${indName} - 최근 ${n}${L} 최저 ${op} ${R || '침체선'}`;
} }
if (['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(c.conditionType)) {
const n = c.lookbackPeriod ?? 10;
const dir = c.conditionType === 'TREND_LINE_CROSS_UP' ? '상향 돌파' : '하향 돌파';
return `${indName} - 이전 ${n}봉 추세선 ${dir} · ${L}`;
}
const rangeClause = formatCandleRangeClause(c); const rangeClause = formatCandleRangeClause(c);
if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`; if (R && R !== '선택안함') return `${indName} - ${rangeClause}${L} ${C} ${R}`;
return `${indName} - ${rangeClause}${L} ${C}`; return `${indName} - ${rangeClause}${L} ${C}`;
@@ -1215,11 +1227,12 @@ export const CondEditor: React.FC<CondEditorProps> = ({
}; };
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType); const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
const isTrendLineType = ['TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN'].includes(normalized.conditionType);
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS'; const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType); const isLookbackType = ['LOWEST_LTE', 'LOWEST_GTE'].includes(normalized.conditionType) || isTrendLineType;
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType); const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue(); const rightValue = isSlopeType || isTrendLineType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def)); const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
const handleRight = (v: string) => { const handleRight = (v: string) => {
+30
View File
@@ -10,11 +10,15 @@ import {
normalizeStartCandleType, normalizeStartCandleType,
STRATEGY_CANDLE_TYPES, STRATEGY_CANDLE_TYPES,
type StartNodeMeta, type StartNodeMeta,
type StartCombineOp,
} from './strategyStartNodes'; } from './strategyStartNodes';
export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES }; export { START_NODE_ID, isStartNodeId, STRATEGY_CANDLE_TYPES };
export type { StartNodeMeta }; export type { StartNodeMeta };
/** START 결합 엣지 식별 prefix */
export const START_COMBINE_EDGE_PREFIX = 'start-combine:';
const H_GAP = 280; const H_GAP = 280;
const V_GAP = 130; const V_GAP = 130;
@@ -436,6 +440,8 @@ export function applyEdgeHandles(
saved: Map<string, EdgeHandleBinding>, saved: Map<string, EdgeHandleBinding>,
): Edge[] { ): Edge[] {
return edges.map(edge => { return edges.map(edge => {
// START 결합 엣지는 고정 핸들(s-bottom→t-top) 유지 — 핸들 재계산/바인딩 제외
if (edge.type === 'startCombine') return edge;
const binding = saved.get(edge.id) ?? {}; const binding = saved.get(edge.id) ?? {};
const picked = pickHandleSides(edge.source, edge.target, positions); const picked = pickHandleSides(edge.source, edge.target, positions);
let sourceHandle = picked.sourceHandle; let sourceHandle = picked.sourceHandle;
@@ -716,6 +722,8 @@ export function logicNodeToFlow(
startMeta: Record<string, StartNodeMeta> = defaultStartMeta(), startMeta: Record<string, StartNodeMeta> = defaultStartMeta(),
extraStartIds: string[] = [], extraStartIds: string[] = [],
extraRoots: Record<string, LogicNode | null> = {}, extraRoots: Record<string, LogicNode | null> = {},
startCombineOp: StartCombineOp = 'AND',
onStartCombineOpChange?: (op: StartCombineOp) => void,
): { nodes: Node[]; edges: Edge[] } { ): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []; const nodes: Node[] = [];
const edges: Edge[] = []; const edges: Edge[] = [];
@@ -766,6 +774,28 @@ export function logicNodeToFlow(
appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140); appendStart(startId, extraRoots[startId] ?? null, 80 + index * 140);
}); });
// START 2개 이상이면 START끼리 자동 결합 엣지(체인)로 연결 — AND/OR 토글 배지
if (extraStartIds.length >= 1) {
const chain = [START_NODE_ID, ...extraStartIds];
for (let i = 1; i < chain.length; i += 1) {
const sourceId = chain[i - 1];
const targetId = chain[i];
edges.push({
id: `${START_COMBINE_EDGE_PREFIX}${sourceId}->${targetId}`,
source: sourceId,
sourceHandle: 's-bottom',
target: targetId,
targetHandle: 't-top',
type: 'startCombine',
selectable: false,
deletable: false,
reconnectable: false,
focusable: false,
data: { op: startCombineOp, onToggle: onStartCombineOpChange },
});
}
}
appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks); appendOrphanFlowNodes(orphans, nodes, positions, def, signalTab, callbacks);
return { nodes, edges }; return { nodes, edges };
@@ -25,7 +25,7 @@ import {
type StrategyIndicatorRef, type StrategyIndicatorRef,
} from './strategyIndicatorSync'; } from './strategyIndicatorSync';
const DSL_TO_REGISTRY: Record<string, string> = { export const DSL_TO_REGISTRY: Record<string, string> = {
RSI: 'RSI', RSI: 'RSI',
MACD: 'MACD', MACD: 'MACD',
MA: 'SMA', MA: 'SMA',
+17 -1
View File
@@ -104,6 +104,7 @@ export const CONDITION_LABEL: Record<string, string> = {
GT: '초과(>)', LT: '미만(<)', GTE: '이상(≥)', LTE: '이하(≤)', GT: '초과(>)', LT: '미만(<)', GTE: '이상(≥)', LTE: '이하(≤)',
EQ: '같음(=)', NEQ: '다름(≠)', EQ: '같음(=)', NEQ: '다름(≠)',
CROSS_UP: '상향 돌파', CROSS_DOWN: '하향 돌파', CROSS_UP: '상향 돌파', CROSS_DOWN: '하향 돌파',
TREND_LINE_CROSS_UP: '추세선 상향 돌파', TREND_LINE_CROSS_DOWN: '추세선 하향 돌파',
SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중', SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중',
DIFF_GT: '차이>값', DIFF_LT: '차이<값', DIFF_GT: '차이>값', DIFF_LT: '차이<값',
HOLD_N_DAYS: 'N일 유지', HOLD_N_DAYS: 'N일 유지',
@@ -118,7 +119,7 @@ export const CONDITION_LABEL: Record<string, string> = {
VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율', VOLUME_GT_MA_RATIO: '거래량 ≥ MA×비율',
}; };
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS']; const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'TREND_LINE_CROSS_UP', 'TREND_LINE_CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE']; const OSCILLATOR_LOOKBACK_CONDITIONS = ['LOWEST_LTE', 'LOWEST_GTE'];
const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS]; const OSCILLATOR_CONDITIONS = [...COMMON_CONDITIONS, ...OSCILLATOR_LOOKBACK_CONDITIONS];
@@ -357,6 +358,21 @@ export function generateNodeId(): string {
return `node_${++_nodeCounter}_${Date.now()}`; return `node_${++_nodeCounter}_${Date.now()}`;
} }
/** 서브트리 깊은 복제 + 모든 노드 id 재생성 (저장 전략 드롭 시 id 충돌 방지) */
export function cloneLogicNodeWithNewIds(node: LogicNode): LogicNode {
return {
...node,
id: generateNodeId(),
condition: node.condition
? {
...node.condition,
params: node.condition.params ? { ...node.condition.params } : node.condition.params,
}
: node.condition,
children: node.children ? node.children.map(cloneLogicNodeWithNewIds) : undefined,
};
}
/** 자연어 설명 변환 */ /** 자연어 설명 변환 */
export function nodeToNaturalLanguage(node: LogicNode): string { export function nodeToNaturalLanguage(node: LogicNode): string {
if (!node) return ''; if (!node) return '';
+305
View File
@@ -0,0 +1,305 @@
/**
* N봉 backend TrendLineMath ( ).
* fit [anchorN, anchor1], (UP)= ·(DOWN)= , anchor까지 .
*/
import type { BacktestSettingsDto } from './backendApi';
export type TrendLineMode = 'TWO_POINT' | 'REGRESSION' | 'SWING';
export type TrendLineKind = 'UP' | 'DOWN';
export interface TrendLineConfig {
mode: TrendLineMode;
defaultLookback: number;
swingPrecedingBars: number;
swingFollowingBars: number;
}
export interface TrendLineSegment {
startTimeSec: number;
startPrice: number;
endTimeSec: number;
endPrice: number;
}
interface PivotPair {
indexA: number;
yA: number;
indexB: number;
yB: number;
}
export function trendLineKindForCross(conditionType?: string, side?: 'buy' | 'sell'): TrendLineKind {
if (conditionType === 'TREND_LINE_CROSS_UP') return 'DOWN';
if (conditionType === 'TREND_LINE_CROSS_DOWN') return 'UP';
return side === 'buy' ? 'DOWN' : 'UP';
}
export function isTrendLinePriceScaleField(field?: string): boolean {
if (!field) return true;
const f = field.toUpperCase();
return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW')
|| f.includes('OPEN') || f.includes('MA') || f.includes('EMA')
|| f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER')
|| f.includes('BAND') || f.includes('DC_') || f.includes('VWAP')
|| f.includes('CONVERSION') || f.includes('BASE') || f.includes('SPAN')
|| f.includes('LAGGING') || f.includes('CLOUD');
}
export function trendLineConfigFromSettings(
settings?: Pick<
BacktestSettingsDto,
| 'trendLineMode'
| 'trendLineDefaultLookback'
| 'trendLineSwingPrecedingBars'
| 'trendLineSwingFollowingBars'
> | null,
): TrendLineConfig {
let mode = settings?.trendLineMode ?? 'SWING';
if (mode !== 'TWO_POINT' && mode !== 'REGRESSION' && mode !== 'SWING') {
mode = 'SWING';
}
const defaultLookback =
settings?.trendLineDefaultLookback != null && settings.trendLineDefaultLookback > 1
? settings.trendLineDefaultLookback
: 10;
return {
mode,
defaultLookback,
swingPrecedingBars: settings?.trendLineSwingPrecedingBars ?? 1,
swingFollowingBars: settings?.trendLineSwingFollowingBars ?? 1,
};
}
function numAt(values: number[], index: number): number | null {
if (index < 0 || index >= values.length) return null;
const v = values[index];
return Number.isFinite(v) ? v : null;
}
function yAtPivot(
bars: { low: number; high: number }[],
sourceValues: number[],
index: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (priceScale) {
return kind === 'UP' ? bars[index]!.low : bars[index]!.high;
}
return numAt(sourceValues, index) ?? NaN;
}
function isSwingLow(
bars: { low: number; high: number }[],
sourceValues: number[],
i: number,
prec: number,
fol: number,
priceScale: boolean,
): boolean {
const low = yAtPivot(bars, sourceValues, i, 'UP', priceScale);
if (!Number.isFinite(low)) return false;
for (let j = i - prec; j <= i + fol; j++) {
if (j === i) continue;
if (j < 0 || j >= bars.length) return false;
const v = yAtPivot(bars, sourceValues, j, 'UP', priceScale);
if (!Number.isFinite(v) || v < low) return false;
}
return true;
}
function isSwingHigh(
bars: { low: number; high: number }[],
sourceValues: number[],
i: number,
prec: number,
fol: number,
priceScale: boolean,
): boolean {
const high = yAtPivot(bars, sourceValues, i, 'DOWN', priceScale);
if (!Number.isFinite(high)) return false;
for (let j = i - prec; j <= i + fol; j++) {
if (j === i) continue;
if (j < 0 || j >= bars.length) return false;
const v = yAtPivot(bars, sourceValues, j, 'DOWN', priceScale);
if (!Number.isFinite(v) || v > high) return false;
}
return true;
}
function collectSwingIndices(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
kLeft: number,
kRight: number,
kind: TrendLineKind,
priceScale: boolean,
): number[] {
const out: number[] = [];
for (let i = start + kLeft; i <= end - kRight; i++) {
const isSwing = kind === 'UP'
? isSwingLow(bars, sourceValues, i, kLeft, kRight, priceScale)
: isSwingHigh(bars, sourceValues, i, kLeft, kRight, priceScale);
if (isSwing) out.push(i);
}
return out;
}
function resolvePivots(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): PivotPair | null {
const kLeft = Math.max(0, config.swingPrecedingBars);
const kRight = Math.max(0, config.swingFollowingBars);
const swings = collectSwingIndices(bars, sourceValues, start, end, kLeft, kRight, kind, priceScale);
if (swings.length < 2) return null;
const swingA = swings[0]!;
for (let j = 1; j < swings.length; j++) {
const swingB = swings[j]!;
if (swingB <= swingA) continue;
const yA = yAtPivot(bars, sourceValues, swingA, kind, priceScale);
const yB = yAtPivot(bars, sourceValues, swingB, kind, priceScale);
if (!Number.isFinite(yA) || !Number.isFinite(yB)) continue;
if (kind === 'UP' && yB > yA) return { indexA: swingA, yA, indexB: swingB, yB };
if (kind === 'DOWN' && yB < yA) return { indexA: swingA, yA, indexB: swingB, yB };
}
return null;
}
function lineAt(p: PivotPair, evalIndex: number): number {
if (p.indexB === p.indexA) return p.yA;
const t = (evalIndex - p.indexA) / (p.indexB - p.indexA);
return p.yA + (p.yB - p.yA) * t;
}
function twoPointAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
const y1 = yAtPivot(bars, sourceValues, end, kind, priceScale);
if (!Number.isFinite(y0) || !Number.isFinite(y1) || end === start) return NaN;
const t = (evalIndex - start) / (end - start);
return y0 + (y1 - y0) * t;
}
function regressionAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
kind: TrendLineKind,
priceScale: boolean,
): number {
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
let count = 0;
for (let i = start; i <= end; i++) {
const y = yAtPivot(bars, sourceValues, i, kind, priceScale);
if (!Number.isFinite(y)) continue;
const x = i - start;
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
count++;
}
if (count < 2) return NaN;
const denom = count * sumXX - sumX * sumX;
if (Math.abs(denom) < 1e-12) return NaN;
const b = (count * sumXY - sumX * sumY) / denom;
const a = (sumY - b * sumX) / count;
return a + b * (evalIndex - start);
}
function fallbackValueAt(
bars: { low: number; high: number }[],
sourceValues: number[],
start: number,
end: number,
evalIndex: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (config.mode === 'SWING') return NaN;
if (config.mode === 'REGRESSION') {
return regressionAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
}
return twoPointAt(bars, sourceValues, start, end, evalIndex, kind, priceScale);
}
export function trendLineValueAtEval(
sourceValues: number[],
bars: { low: number; high: number }[],
anchorIndex: number,
evalIndex: number,
lookback: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): number {
if (anchorIndex < 1 || lookback < 2) return NaN;
const start = anchorIndex - lookback;
const end = anchorIndex - 1;
if (start < 0 || end < start) return NaN;
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
if (pivots) return lineAt(pivots, evalIndex);
return fallbackValueAt(bars, sourceValues, start, end, evalIndex, config, kind, priceScale);
}
export function trendLineSegmentAt(
barTimes: number[],
sourceValues: number[],
bars: { low: number; high: number }[],
index: number,
lookback: number,
config: TrendLineConfig,
kind: TrendLineKind,
priceScale: boolean,
): TrendLineSegment | null {
if (index < 1 || lookback < 2 || sourceValues.length !== barTimes.length) return null;
const start = index - lookback;
const end = index - 1;
if (start < 0 || end < start) return null;
const pivots = resolvePivots(bars, sourceValues, start, end, config, kind, priceScale);
if (pivots) {
const yAt = lineAt(pivots, index);
if (!Number.isFinite(yAt)) return null;
return {
startTimeSec: barTimes[pivots.indexA]!,
startPrice: pivots.yA,
endTimeSec: barTimes[index]!,
endPrice: yAt,
};
}
if (config.mode === 'SWING') return null;
const y0 = yAtPivot(bars, sourceValues, start, kind, priceScale);
const yAt = fallbackValueAt(bars, sourceValues, start, end, index, config, kind, priceScale);
if (!Number.isFinite(y0) || !Number.isFinite(yAt)) return null;
return {
startTimeSec: barTimes[start]!,
startPrice: y0,
endTimeSec: barTimes[index]!,
endPrice: yAt,
};
}
+90
View File
@@ -0,0 +1,90 @@
/**
*
*/
import type { LogicNode } from './strategyTypes';
import {
applyCondTypeDefaults,
genId,
getDefaultConditionFields,
type DefType,
} from './strategyEditorShared';
import { initConditionPeriodsInherit } from './conditionPeriods';
export interface TrendLinePaletteItem {
id: string;
indicatorType: string;
label: string;
desc: string;
leftField?: string;
}
export const TREND_LINE_PALETTE_ITEMS: TrendLinePaletteItem[] = [
{ id: 'tl-close', indicatorType: 'MA', label: '종가', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
{ id: 'tl-ma', indicatorType: 'MA', label: 'MA', desc: '이동평균 vs N봉 추세선', leftField: 'MA20' },
{ id: 'tl-ema', indicatorType: 'EMA', label: 'EMA', desc: '지수이동평균 vs N봉 추세선', leftField: 'EMA20' },
{ id: 'tl-boll', indicatorType: 'BOLLINGER', label: '볼린저', desc: '종가 vs N봉 추세선', leftField: 'CLOSE_PRICE' },
{ id: 'tl-rsi', indicatorType: 'RSI', label: 'RSI', desc: 'RSI vs N봉 추세선' },
{ id: 'tl-macd', indicatorType: 'MACD', label: 'MACD', desc: 'MACD선 vs N봉 추세선', leftField: 'MACD_LINE' },
{ id: 'tl-stoch', indicatorType: 'STOCHASTIC', label: 'Stochastic', desc: '%K vs N봉 추세선', leftField: 'K' },
{ id: 'tl-cci', indicatorType: 'CCI', label: 'CCI', desc: 'CCI vs N봉 추세선' },
{ id: 'tl-adx', indicatorType: 'ADX', label: 'ADX', desc: 'ADX vs N봉 추세선' },
{ id: 'tl-obv', indicatorType: 'OBV', label: 'OBV', desc: 'OBV vs N봉 추세선' },
];
export function findTrendLinePaletteItem(id: string): TrendLinePaletteItem | undefined {
return TREND_LINE_PALETTE_ITEMS.find(i => i.id === id);
}
export function filterTrendLinePaletteItems(query: string): TrendLinePaletteItem[] {
const q = query.trim().toLowerCase();
if (!q) return TREND_LINE_PALETTE_ITEMS;
return TREND_LINE_PALETTE_ITEMS.filter(
i => i.label.toLowerCase().includes(q)
|| i.desc.toLowerCase().includes(q)
|| i.indicatorType.toLowerCase().includes(q),
);
}
export function buildTrendLineConditionNode(
item: Pick<TrendLinePaletteItem, 'indicatorType' | 'leftField'>,
signalType: 'buy' | 'sell',
def: DefType,
lookback = 10,
): LogicNode {
const defaults = getDefaultConditionFields(item.indicatorType, signalType, def);
const condType = signalType === 'buy' ? 'TREND_LINE_CROSS_UP' : 'TREND_LINE_CROSS_DOWN';
let condition = applyCondTypeDefaults(
{
indicatorType: item.indicatorType,
conditionType: condType,
leftField: item.leftField ?? defaults.l,
rightField: 'NONE',
lookbackPeriod: Math.max(2, lookback),
candleRange: 1,
valuePeriodOverride: false,
thresholdOverride: false,
rightPeriodOverride: false,
},
condType,
def,
);
condition = initConditionPeriodsInherit(item.indicatorType, condition, def);
return { id: genId(), type: 'CONDITION', condition };
}
export function buildTrendLineConditionNodeFromDrag(data: {
value: string;
indicatorType?: string;
leftField?: string;
lookback?: number;
}, signalType: 'buy' | 'sell', def: DefType): LogicNode | null {
const item = findTrendLinePaletteItem(data.value);
const indicatorType = item?.indicatorType ?? data.indicatorType;
if (!indicatorType) return null;
return buildTrendLineConditionNode(
{ indicatorType, leftField: item?.leftField ?? data.leftField },
signalType,
def,
data.lookback ?? 10,
);
}
+396
View File
@@ -0,0 +1,396 @@
# 드로잉선 → 전략 조건 검토
실시간 차트 **드로잉 툴바**(TradingView 스타일)에 제공되는 선·채널·피보나치 등 도구 중,
현재 구현된 **추세선 돌파(`TREND_LINE_CROSS_*`)** 와 같이 **봉마다 자동 계산 → Ta4j Rule → 실시간·전략평가 시그널**로 확장 가능한 항목을 정리한 문서입니다.
> **관련:** [매매전략조건.md](./매매전략조건.md) · [전략편집기 로직 및 동작흐름.md](./전략편집기%20로직%20및%20동작흐름.md)
> **코드 기준:** `DrawingToolbar.tsx` · `DrawingToolId` · `StrategyDslToTa4jAdapter` · `TrendLineMath`
---
## 1. 현재 추세선 전략 조건 (기준 구현)
| 항목 | 내용 |
|------|------|
| DSL | `TREND_LINE_CROSS_UP` / `TREND_LINE_CROSS_DOWN` |
| 드로잉 대응 | **추세줄**(`trendline`) · **익스텐디드 라인**(`extended_line`)과 개념적으로 동일 (2점 직선 + 연장) |
| 계산 | fit 구간 `[iN, i1]` · **스윙 피벗** (상승=저점↑ / 하락=고점↓) · 시그널 봉 `i`까지 연장 |
| 판정 | 직전 봉 ≤ 추세선 → 현재 봉 > 추세선 (상향) / 반대 (하향) |
| 설정 | 설정 → 전략 설정 → 추세선 (모드·N·스윙 k) |
| 파이프라인 | `StrategyDslToTa4jAdapter``BacktestingService` · `LiveConditionStatusService` · 전략평가 차트 |
**핵심 패턴:** 차트에 사용자가 그리는 **고정 좌표**가 아니라, 매 봉 `i`마다 **Indicator가 Y값(또는 시간)을 반환**하고, 기존 `CROSS_UP`/`CROSS_DOWN` Rule과 결합합니다.
---
## 2. 드로잉 툴바 선·채널 도구 목록
`DrawingToolbar` **라인 / 피보나치 / 채널 / 프로젝션** 그룹 기준 (측정·도형·텍스트·패턴 제외).
### 2.1 라인 그룹
| ID | UI 이름 | 점 수 | 기하 |
|----|---------|-------|------|
| `trendline` | 추세줄 | 2 | 2점 직선, **양방향** 연장 |
| `ray` | 빛(레이) | 2 | 2점 직선, **우측** 연장 |
| `extended_line` | 익스텐디드 라인 | 2 | 2점 직선, **양방향** 연장 (`trendline`과 동일 렌더) |
| `arrow` | 화살표 추세선 | 2 | 레이 + 화살표 |
| `trend_angle` | 추세각 | 2 | 추세선 + 각도 표시 |
| `info_line` | 인포 라인 | 2 | 2점 + 거리·각도 라벨 |
| `hline` | 가로줄 | 1 | **수평선** (가격 고정) |
| `hline_ray` | 가로빛 | 1 | 앵커 시점부터 **우측** 수평 |
| `vline` | 수직선 | 1 | **수직선** (시간 고정) |
| `cross_line` | 크로스 라인 | 1 | 수평+수직 교차 |
| `channel` | 추세 채널 | 3 | 기준 추세선 + 폭 |
| `parallel_channel` | 패러렐 채널 | 2 | 평행 2선 |
| `disjoint_channel` | 디스조인트 채널 | 4 | 비평행 2선 |
| `flat_top_bottom` | 플랫 탑/바텀 | 2+ | 상단 수평 + 하단 추세 |
### 2.2 피보나치·갠·피치포크
| ID | UI 이름 | 산출물 |
|----|---------|--------|
| `fib` | 피보나치 되돌림 | 2점 구간 **수평 레벨** 다수 (0, 23.6, …, 100%) |
| `fib_ext` | 추세기반 피보나치 확장 | 되돌림 + **확장** 수평 레벨 |
| `fib_channel` | 피보나치 채널 | 기울어진 채널 + 피보나치 분할 |
| `fibtz` | 피보나치 타임존 | 2점 시간 구간 **수직선** |
| `fib_speed_fan` | 스피드 리지스턴스 팬 | 1점에서 **다발 레이** |
| `fib_trend_time` | 추세기반 피보나치 시간 | 추세선 + 시간축 피보나치 |
| `fib_circles` / `fib_spiral` / `fib_speed_arc` / `fib_wedge` | 원·스파이럴·아크·웻지 | 곡선/영역 |
| `pitchfork` / `pitchfork_schiff` / `pitchfork_inside` | 피치포크 | 3점 → **중심선 + 평행 2선** |
| `gann_box` / `gann_square` / `gann_fan` | 갠 | 격자·팬 **다발 직선** |
### 2.3 프로젝션·기타 (선 성격)
| ID | UI 이름 | 비고 |
|----|---------|------|
| `anchored_vwap` | 앵커드 VWAP | 앵커 시점부터 **누적 VWAP 곡선** |
| `price_range` | 가격범위 | 구간 **상·하단 수평** |
| `date_range` | 날짜 범위 | **수직** 구간 |
| `guide_line` | 가이드선 | 보조 수평 (차트 UI) |
| `forecast` / `projection_tool` | 예측·프로젝션 | 2점+ **연장** |
---
## 3. 전략 조건화 평가 기준
드로잉 도구를 **실시간 매매 시그널**로 쓰려면 아래가 필요합니다.
| # | 요건 | 설명 |
|---|------|------|
| 1 | **봉 인덱스 결정론** | 사용자 클릭 없이 OHLCV·지표만으로 매 봉 동일 Y(또는 시간) 계산 |
| 2 | **교차·돌파 정의** | `CROSS_UP`/`DOWN` 또는 `GT`/`LT`로 표현 가능 |
| 3 | **Ta4j Indicator** | `getValue(i)` 또는 Rule 한 개로 encapsulation |
| 4 | **워밍업·NaN** | lookback·스윙 k만큼 불안정 구간 처리 |
| 5 | **멀티 TF·좌필드** | 종가/RSI 등 left 지표와 스케일 일치 |
**등급**
- **A — 즉시 확장 권장:** 추세선과 동일 패턴(동적 1~2선 + CROSS), 구현 난이도 중, 실전 활용도 높음
- **B — 부분 가능 / DSL已有:** 수평·채널·고저가 등 **이미 다른 필드로 구현**됐거나, 파라미터만 추가하면 됨
- **C — 전략 자동화 부적합:** 주관적 패턴·다점 수동 입력·시간만 조건·시각 주석 위주
---
## 4. 요약 매트릭스
| 드로잉 도구 | 등급 | 전략 활용 | 비고 |
|-------------|------|-----------|------|
| **추세줄 / 익스텐디드 / 레이** | **A** | ✅ 구현됨 (`TREND_LINE_*`) | 레이=연장 방향만 다름 → `extendMode` 파라미터로 통합 가능 |
| **가로줄** | **B** | ✅ 대체 가능 | `CROSS_*` + 고정값·`NH_PRIOR_N`·볼린저·MA |
| **가로빛** | **A** | ⚠️ 미구현 | 앵커 이후 구간 수평 = **구간 고점/저점** 또는 **앵커 VWAP** |
| **수직선 / 크로스 / 피보나치 TZ / 날짜범위** | **B~C** | △ 시간 조건 | 가격 돌파가 아닌 **봉 번호·세션·시간** 조건 (`TIMEFRAME`·`HOLD` 확장) |
| **추세·패러렐·플랫탑 채널** | **A** | ⚠️ 미구현 | **채널 상·하단 돌파** — Donchian과 유사하나 **기울기** 있음 |
| **디스조인트 채널** | **B** | △ | 4점 자유도 높음 → 자동화 시 규칙 고정 필요 |
| **피보나치 되돌림/확장** | **A** | ⚠️ 미구현 | 스윙 고저 + **수평 레벨** 돌파 (0.382, 0.5, 0.618…) |
| **피치포크 (중심선)** | **A** | ⚠️ 미구현 | 3 스윙점 → **중앙 추세선** 돌파 (추세선 일반화) |
| **피보나치 팬 / 갠 팬** | **B** | △ | 다중 레이 — **특정 각도 레이** 돌파로 축소 가능 |
| **앵커드 VWAP** | **B** | △ | VWAP 지표 + **앵커 lookback** 조건 (별도 지표 DSL) |
| **가격범위** | **A** | ⚠️ 미구현 | N봉 **Range 상·하단** 돌파 (Donchian과 동일 family) |
| **추세각 / 인포 / 화살표** | **C** | ✗ | 표시·측정용, 시그널 의미 없음 |
| **패턴(XABCD·H&S·엘리엇)** | **C** | ✗ | 형태 인식·주관적 — 별도 패턴 엔진 필요 |
| **원·호·브러시·텍스트** | **C** | ✗ | 시각 주석 |
---
## 5. A등급 — 구현 권장 (추세선과 동일 아키텍처)
### 5.1 레이 / 익스텐디드 (`ray`, `extended_line`)
**드로잉 의미:** 2점 직선, 연장 방향만 다름 (우측 only vs 양방향).
**전략 정의 (제안):**
- 기존 `TREND_LINE_*` + 파라미터 `extendMode: RIGHT | BOTH | LEFT`
- fit·피벗 로직은 **현재 `TrendLineMath`와 동일**
- `ray` = 피벗 선을 **평가 봉 i까지만** extrapolation (좌측 연장 없음)
**조건 예:**
| 조건 | 의미 |
|------|------|
| `TREND_LINE_CROSS_UP` + `extendMode=RIGHT` | 하락 저항 **레이** 상향 돌파 |
| `TREND_LINE_CROSS_DOWN` | 상승 지지선 하향 이탈 |
**구현:** `TrendLineMath`에 연장 방향만 추가 — **난이도: 낮음**.
---
### 5.2 추세 채널 / 패러렐 채널 (`channel`, `parallel_channel`)
**드로잉 의미:** 평행한 **상·하 2선**; 가격이 채널 밖으로 나가면 추세 가속·전환.
**전략 정의 (제안):**
| DSL (제안) | 계산 | 매수·매도 |
|------------|------|-----------|
| `CHANNEL_BREAK_UP` | 상단선 = 기준 추세선 + offset (또는 2번째 피벗 평행) | 종가 **상단** 상향 돌파 |
| `CHANNEL_BREAK_DOWN` | 하단선 = 기준 추세선 − offset | 종가 **하단** 하향 돌파 |
| `CHANNEL_BOUNCE_UP` | 하단선 터치 후 반등 | `CROSS_UP` on lower band |
**알고리즘 (패러렐 채널 자동화):**
1. `[iN, i1]`에서 **상승 추세선(저점)** 또는 **하락 추세선(고점)** 1본 계산 (현행과 동일)
2. offset = 구간 내 **최대 High 추세선값** (상단 채널) 또는 **추세선값 최소 Low** (하단)
3. 또는 **스윙 고점 2개 + 스윙 저점 2개**로 평행선 쌍 결정
**Ta4j:** `UpperChannelIndicator` / `LowerChannelIndicator` (커스텀) + `CROSS_*`.
**기존 DSL과 관계:** `DONCHIAN` `DC_UPPER_N` / `DC_LOWER_N`**수평 채널**(기울기 0). 본 조건은 **기울어진 채널** — 차별화됨.
**난이도: 중.**
---
### 5.3 플랫 탑/바텀 (`flat_top_bottom`)
**드로잉 의미:** 한쪽은 **수평**(저항/지지), 다른 쪽은 **기울어진** 변 연결 — 삼각 수렴·확장 패턴의 일부.
**전략 정의 (제안):**
| DSL (제안) | 구성 |
|------------|------|
| `FLAT_TOP_BREAK_UP` | `[iN,i1]` **최고가 수평선** (flat top) 상향 돌파 |
| `FLAT_BOTTOM_BREAK_DOWN` | **최저가 수평선** (flat bottom) 하향 돌파 |
| `WEDGE_BREAK_*` | flat + 대각 추세선 **수렴 후** 돌파 (확장) |
flat top = `NH_PRIOR` 구간 max(High)와 유사하나 **동일 구간** 명시.
**난이도: 중 (채널 특수 케이스).**
---
### 5.4 피보나치 되돌림 / 확장 (`fib`, `fib_ext`)
**드로잉 의미:** 스윙 A→B 구간에 **비율별 수평선** (0%, 38.2%, 50%, 61.8%, 100% …).
**전략 정의 (제안):**
```
FIB_LEVEL_CROSS_UP / DOWN
- swingHigh, swingLow: [iN, i1] 스윙 (또는 구간 고/저)
- level: 0.382 | 0.5 | 0.618 | 0.786 | 1.272 (확장)
- left: CLOSE_PRICE (또는 지표)
```
| 활용 | 조건 |
|------|------|
| 되돌림 매수 | 61.8% **지지**`CROSS_UP` |
| 되돌림 매도 | 38.2% **저항**`CROSS_DOWN` |
| 확장 목표 | 1.272 / 1.618 **돌파** 추적 |
**Indicator:** `FibLevelIndicator(swingA, swingB, ratio)` → bar i에서 해당 레벨 Y.
**차트 표시:** 전략평가 시 선택 봉 기준 **해당 레벨 1개** 또는 **주요 3선**만 overlay (추세선과 동일 UX).
**난이도: 중.**
---
### 5.5 피치포크 중심선 (`pitchfork` 계열)
**드로잉 의미:** 3점(스윙1, 스윙2, 스윙3) → **중앙 추세선** + 위·아래 평행선.
**전략 정의 (제안):**
| DSL (제안) | 의미 |
|------------|------|
| `PITCHFORK_MEDIAN_CROSS_UP/DOWN` | **중심선** 돌파 |
| `PITCHFORK_UPPER_TOUCH` | 상단 평행선 도달 |
| `PITCHFORK_LOWER_BOUNCE` | 하단 평행선 반등 |
**알고리즘:** 3 스윙 저점(상승) 또는 고점(하락) → 중점·기울기 → median line at i.
Andrews / Schiff / Inside는 **중심선 계산식**만 다름 — 설정에서 `pitchforkVariant`.
**난이도: 중~상.**
---
### 5.6 N봉 가격 레인지 (`price_range` 드로잉 ↔ Range 돌파)
**드로잉 의미:** 구간 **상단·하단** 박스.
**전략 정의:** Donchian과 동일 family.
| 기존 DSL | 드로잉 대응 |
|----------|-------------|
| `CLOSE CROSS_UP NH_PRIOR_20` | 20봉 **신고가(상단)** 돌파 |
| `CLOSE CROSS_DOWN NL_PRIOR_20` | 20봉 **신저가(하단)** 이탈 |
| `DONCHIAN` + `DC_UPPER_N` | 채널 상단 |
**추가 제안:** `RANGE_BREAK_UP/DOWN` — lookback N 명시적 alias (UI는 **가격범위** 드로잉과 매핑).
**난이도: 낮음 (이미 80% 구현).**
---
## 6. B등급 — 부분 가능 / 기존 DSL로 커버
### 6.1 가로줄 (`hline`)
| 드로잉 | 전략 DSL 대체 |
|--------|----------------|
| 임의 가격 수평선 | `GT`/`LT`/`CROSS_*` + `compareValue` 또는 지표 임계 |
| N봉 고가/저가 | `NH_PRIOR_N` / `NL_PRIOR_N` · `DC_UPPER_N` / `DC_LOWER_N` |
| 볼린저 상·중·하 | `BOLLINGER` + `UPPER`/`MIDDLE`/`LOWER` |
| 전고·전저 | `NEW_HIGH` / `NEW_LOW` 지표 |
**신규 DSL 필요성:** 낮음. 전략편집기 **「가로줄 = 수준 돌파」** 프리셋 UI만 추가해도 충분.
---
### 6.2 가로빛 (`hline_ray`)
**의미:** 특정 **시점 이후**에만 유효한 수평선 (예: 돌파봉 종가, 당일 시가).
**제안:**
| DSL (제안) | 정의 |
|------------|------|
| `SESSION_OPEN` | 당일/세션 시가 (수평) |
| `BAR_N_CLOSE` | N봉 전 종가 수평 (앵커) |
| `SWING_LEVEL` | 마지막 스윙 고/저 **수평 레이** |
**난이도: 중** (앵커 bar index 추적).
---
### 6.3 앵커드 VWAP (`anchored_vwap`)
**의미:** 특정 봉부터 누적 VWAP.
**전략:** `VWAP` 지표(또는 신규 `ANCHORED_VWAP`) + `CROSS_UP/DOWN`.
앵커 = `ianchorLookback` 또는 **세션 시작**.
**난이도: 중** (볼륨·누적 합).
---
### 6.4 피보나치 팬 / 갠 팬 (`fib_speed_fan`, `gann_fan`)
**의미:** 1점에서 **여러 각도**의 레이.
**전략 축소:** 팬 전체가 아닌 **특정 비율 1선**만 — 예: 1×1, 2×1 갠 각도 = 기울기 `k`인 **동적 추세선**.
| 제안 | 조건 |
|------|------|
| `GANN_FAN_CROSS` | 스윙 앵커 + `slopeRatio` (1/1, 1/2…) 레이 돌파 |
**난이도: 중** (추세선 + 고정 기울기).
---
### 6.5 수직선·시간 (`vline`, `fibtz`, `date_range`)
**의미:** **가격**이 아니라 **시간/봉** 조건.
| 활용 | DSL 방향 |
|------|----------|
| 특정 시각 이후만 매수 | `TIMEFRAME` + 세션 필터 |
| N봉 경과 후 | `HOLD_N_DAYS` · `BAR_INDEX ≥ anchor` |
| 피보나치 시간대 | `TIME_FIB_ZONE` (i가 [t0+φⁿ·Δt] 근처) |
실시간 **가격 돌파** 시그널과는 별도 — **필터/타이밍** 조건으로 AND 결합.
**난이도: 중** (가격 Indicator와 분리).
---
## 7. C등급 — 자동 전략 조건 부적합
| 유형 | 이유 |
|------|------|
| **추세각·인포라인·화살표** | 측정·주석; Y(t) 단일 함수 아님 |
| **harmonic 패턴** (XABCD, ABCD, Cypher, H&S) | 4~5점 **형태 매칭** — ML/규칙 트리 별도 |
| **엘리엇 파동** 드로잉 | 파동 카운트 주관적; ta4j Elliott는 분석용 |
| **고스트피드·봉패턴** | 미래 봉 투영 — 백테스트 **룩어헤드** 위험 |
| **long/short position** 박스 | RR 시각화; 진입·손절은 **별도 주문 DSL** |
| **브러시·텍스트·핀** | 사용자 주석 |
| **원·타원·삼각형** | 영역 포함 판정 — `IN_RECT`류는 가능하나 드로잉과 무관 |
---
## 8. 공통 구현 패턴 (추세선과 동일)
신규 드로잉型 조건 추가 시 **권장 스택:**
```
1. DSL conditionType (예: CHANNEL_BREAK_UP)
2. Math/Indicator (bar i, lookback, config → Y 또는 time)
3. StrategyDslToTa4jAdapter → buildXxxCrossRule (고정 창 [iN,i1])
4. BacktestSettings (전역 default N, k, ratio…)
5. 전략편집기 팔레트 (추세 탭과 동일 PaletteChip UX)
6. 전략평가 차트 (선택 봉 1개 overlay only)
7. LiveConditionStatusService 라벨
```
**교차 Rule 템플릿 (현행 추세선):**
```text
직전 봉: left[i1] ≤ line[i1]
현재 봉: left[i] > line[i] → CROSS_UP
line[i±k]는 동일 fit 창 [iN, i1]에서 extrapolation
```
---
## 9. 구현 우선순위 (로드맵 제안)
| 순위 | 기능 | 드로잉 대응 | 이유 |
|------|------|-------------|------|
| 1 | `extendMode` on `TREND_LINE` | ray / extended | 코드 변경 최소 |
| 2 | `CHANNEL_BREAK_*` | channel, parallel | 돈치안보다 실전 TA에 가까움 |
| 3 | `FIB_LEVEL_CROSS_*` | fib, fib_ext | 되돌림·확장 매매 빈도 높음 |
| 4 | `FLAT_TOP/BOTTOM_*` | flat_top_bottom | 삼각·깃발 패턴 기초 |
| 5 | `PITCHFORK_MEDIAN_*` | pitchfork | 중심선 트레이딩 |
| 6 | 가로줄 프리셋 UI | hline | 신규 엔진 없이 DSL 노출 |
| 7 | `ANCHORED_VWAP_CROSS` | anchored_vwap | VWAP 전략 수요 |
| 8 | 시간 조건 (`TIME_*`) | vline, fibtz | 필터로 활용 |
---
## 10. 실시간 매매 체크 연동
현재 파이프라인은 **드로잉 객체(JSON)를 읽지 않습니다.**
차트에 사용자가 그린 선과 **전략 시그널은 독립**이며, 전략은 **동일 수학 규칙**으로 매 봉 재계산합니다.
| 단계 | 서비스 |
|------|--------|
| 설정 로드 | `BacktestSettingsService``TrendLineConfig` |
| Rule 생성 | `StrategyDslToTa4jAdapter.toRule` |
| 백테스트·스캔 | `BacktestingService` |
| 실시간 | `LiveConditionStatusService` (조건별 충족/미충족) |
| UI | `StrategyEvaluationChart` · 가상/실전 차트 시그널 마커 |
신규 선 조건도 **드로잉 저장소와 연동할 필요 없음** — 추세선과 같이 **Indicator 기반**이면 실시간 체크에 바로 투입 가능.
---
## 11. 한 줄 결론
> **추세선(`TREND_LINE`)과 같이 “매 봉 Y값을 계산하는 1~2차원 직선·수평 레벨”** 이면 대부분 `CROSS_*` Rule로 실시간 전략에 넣을 수 있다.
> **우선순위는 채널(평행선)·피보나치 수준·레이 연장 방향**이며, **가로줄·Donchian·볼린저**는 이미 DSL로 커버된다.
> **패턴·팬 전체·브러시**는 드로잉 툴로는 유용하나 **자동 시그널**에는 부적합하거나 별도 엔진이 필요하다.
---
*문서 버전: 2026-06 — goldenChart 드로잉 툴바·TREND_LINE 구현 기준*
+505
View File
@@ -0,0 +1,505 @@
# 전략편집기 — 우측 패널 지표 컨트롤 ↔ 매매 시그널 감지
전략편집기 우측 **지표** 패널에 있는 컨트롤(칩)을 클릭·드래그했을 때 생성되는 DSL 조건이,
실제 차트·백테스트·실시간 전략 평가에서 **어떻게 매수/매도 시그널로 판정되는지**를 정리한 문서입니다.
> **평가 원천:** `StrategyDslToTa4jAdapter` → Ta4j `Rule` → 봉 `i`에서 `rule.isSatisfied(i)`
> **관련:** [매매전략조건.md](./매매전략조건.md) · [드로잉선_전략조건.md](./드로잉선_전략조건.md) · [전략편집기 로직 및 동작흐름.md](./전략편집기%20로직%20및%20동작흐름.md)
---
## 1. 전체 흐름
```text
[우측 PaletteChip 클릭/드래그]
makeNode / buildXxxConditionNode → LogicNode (CONDITION 또는 AND/OR/NOT 트리)
전략 저장 (buyConditionJson / sellConditionJson)
StrategyDslToTa4jAdapter.toRule() — leaf CONDITION → Ta4j Rule
봉 i마다 isSatisfied(i) — true면 해당 봉에 BUY 또는 SELL 시그널
전략평가 차트 마커 · LiveConditionStatusService · BacktestingService
```
### 1.1 매수/매도 탭과 시그널 방향
| 편집기 탭 | DSL 저장 위치 | 시그널 타입 |
|-----------|---------------|-------------|
| **매수** (`buySellTab = buy`) | `buyConditionJson` | `BUY` |
| **매도** (`buySellTab = sell`) | `sellConditionJson` | `SELL` |
같은 칩이라도 **어느 탭에서 추가했는지**에 따라 `conditionType` 기본값이 달라집니다.
예: 보조 RSI 칩 → 매수 탭 `CROSS_UP`, 매도 탭 `CROSS_DOWN`.
### 1.2 positionMode (설정 → 전략 설정)
| 모드 | 동작 |
|------|------|
| **SIGNAL_ONLY** | 매수·매도 Rule **독립** — 보유 여부와 무관하게 봉마다 시그널 |
| **LONG_ONLY** | **미보유**일 때만 entry(BUY), **보유**일 때만 exit(SELL) — `TradingRecord` 추적 |
---
## 2. 우측 패널 구조
**파일:** `StrategyEditorPage.tsx`, `StrategyEvaluationIndicatorPalettePanel.tsx`
| 영역 | 내용 |
|------|------|
| 검색 | `paletteSearch` — 라벨·설명 필터 |
| **시작·논리** | START, AND, OR, NOT |
| **밴드·추세** | MA, EMA, BOLLINGER, DONCHIAN, ICHIMOKU (고정 5칩) |
| **지표 서브탭** | 보조지표 · 복합지표 · 횡보필터 · 추세 |
| **템플릿 탭** | 내장·사용자 템플릿 (복합/횡보 항목 재노출) |
칩 추가 경로:
- **클릭**`applyPalette` / `applySidewaysFilter` / `applyTrendLine`
- **드래그**`PaletteChip``StrategyEditorCanvas.resolvePaletteDropNode`
---
## 3. 논리 노드 (START / AND / OR / NOT / TIMEFRAME)
### 3.1 START
- 캔버스 **시작 섹션** 추가용. 조건 노드가 아님.
- 실제 평가는 START 아래 **자식 AND/OR 트리**가 담당.
### 3.2 AND / OR
| 노드 | Ta4j | 시그널 조건 |
|------|------|-------------|
| **AND** | `AndRule(자식…)` | **모든** 자식 Rule이 같은 봉 `i`에서 true |
| **OR** | `OrRule(자식…)` | **하나라도** true면 true |
빈 AND/OR → 항상 false (시그널 없음).
### 3.3 NOT
- **첫 번째 자식만** `NotRule`로 감쌈.
- 예: 횡보필터 **exclude** 모드 → `NOT(횡보 조건)` = 추세 구간만 통과.
### 3.4 TIMEFRAME (분봉 분기)
```json
{ "type": "TIMEFRAME", "candleTypes": ["1h"], "children": [ … ] }
```
| 단계 | 동작 |
|------|------|
| 1 | `candleTypes[0]` 분봉으로 OHLCV **별도 BarSeries** 구성 |
| 2 | 자식 Rule을 그 분봉에서 평가 |
| 3 | 차트( primary ) 분봉 index `i`**시간 정렬** (`CrossSeriesRule`) |
**백테스트/전략평가:** primary 봉 시각에 맞는 **확정 분봉** index로 매핑.
**실시간:** `LiveStrategyEvaluator` + `StrategyTriggerBranchEvaluator` — 트리거 분봉 마감 시 분기 평가, AND 다중 TF는 **캐시** 결합.
---
## 4. 공통 CONDITION 구조
```typescript
interface ConditionDSL {
indicatorType: string; // RSI, MA, TREND_LINE, …
conditionType: string; // CROSS_UP, GT, TREND_LINE_CROSS_UP, …
leftField?: string; // RSI_VALUE, CLOSE_PRICE, MA20, …
rightField?: string; // HL_OVER, DC_UPPER_20, NONE, …
period?: number;
lookbackPeriod?: number; // 추세선 N, LOWEST N봉
compareValue?: number; // DIFF_GT, VOLUME_GT_MA_RATIO
slopePeriod?: number; // SLOPE_UP/DOWN
holdDays?: number; // HOLD_N_DAYS
candleRange?: number;
candleRangeMode?: 'CURRENT' | 'EXISTS_IN' | 'NOT_EXISTS_IN';
composite?: boolean;
leftPeriod / rightPeriod / leftCandleType / rightCandleType;
params?: Record<string, …>;
}
```
### 4.1 conditionType → 차트에서의 의미
| conditionType | 판정 방식 | 적용 봉 |
|---------------|-----------|---------|
| **GT / LT / GTE / LTE / EQ / NEQ** | `left[i]` vs `right[i]` **수준 비교** | 현재 봉 `i` |
| **CROSS_UP** | `left[i1] ≤ right[i1]` **그리고** `left[i] > right[i]` | 교차 **1봉** |
| **CROSS_DOWN** | `left[i1] ≥ right[i1]` **그리고** `left[i] < right[i]` | 교차 **1봉** |
| **TREND_LINE_CROSS_UP/DOWN** | CROSS와 동일 형식, **right = N봉 피벗 추세선** (fit `[iN,i1]`) | 교차 1봉 |
| **SLOPE_UP / SLOPE_DOWN** | `left``slopePeriod`**연속 상승/하락** | 현재 봉 |
| **DIFF_GT / DIFF_LT** | `|left right| >/< compareValue` | 현재 봉 |
| **HOLD_N_DAYS** | `left > right`**N봉 연속** | 현재 봉 |
| **LOWEST_LTE / LOWEST_GTE** | `min(left, iN..i)` vs `right` | 현재 봉 |
**NaN·미확정:** left/right NaN → 해당 봉 false. `CROSS_*``index < 1`이면 false.
### 4.2 leftField / rightField 해석
백엔드 `resolveField()`가 심볼 → Ta4j `Indicator`로 변환합니다.
| 심볼 예 | 의미 |
|---------|------|
| `CLOSE_PRICE` | 종가 |
| `RSI_VALUE` | RSI 본선 (period는 지표 설정·조건 상속) |
| `HL_OVER` / `HL_MID` / `HL_UNDER` | 과열/중립/침체 **임계값** (설정·지표별) |
| `K_80`, `K_20` | Stoch 직접 입력 임계 |
| `DC_UPPER_20` | 20봉 Donchian **상단** (직전 N봉 high max, 당일 제외 방식은 NH_PRIOR 참고) |
| `NH_PRIOR_9` | **직전 9봉** 최고가 (당일 봉 **미포함**) |
| `NL_PRIOR_9` | **직전 9봉** 최저가 |
| `NONE` | 일목 구름·추세선 등 **단항** 조건 |
---
## 5. 밴드·추세 (고정 5칩)
**출처:** `BAND_PALETTE_CHIP_ITEMS` (`strategyPaletteStorage.ts`)
### 5.1 이동평균 (MA)
| 항목 | 값 |
|------|-----|
| 기본 (매수) | `MA` · `CROSS_UP` · `MA1` vs `MA2` |
| 기본 (매도) | `MA` · `CROSS_DOWN` · `MA1` vs `MA2` |
| **시그널** | 단기 MA가 장기 MA **상향/하향 교차**한 **그 봉** |
MA1/MA2 기간 → 보조지표 설정 `period1`, `period2` (SMA 설정).
### 5.2 EMA
MA와 동일 구조. `EMA1` vs `EMA2`, `EMAIndicator` 사용.
### 5.3 볼린저밴드 (BOLLINGER)
| 항목 | 값 |
|------|-----|
| 기본 (매수) | `CLOSE_PRICE` `CROSS_UP` `UPPER_BAND` |
| 기본 (매도) | `CLOSE_PRICE` `CROSS_DOWN` `LOWER_BAND` (편집기에서 변경 가능) |
| **시그널** | 종가가 **밴드 경계**를 돌파한 봉 |
다른 조건: `GT`/`LT`로 밴드 **내부/외부** 체류 판정.
### 5.4 돈치안 (DONCHIAN)
| 항목 | 값 |
|------|-----|
| 기본 (매수) | `CLOSE_PRICE` `CROSS_UP` `DC_UPPER_20` |
| 기본 (매도) | `CLOSE_PRICE` `CROSS_DOWN` `DC_LOWER_20` |
| **시그널** | 종가가 N봉 채널 **상단/하단** 교차 |
`DC_UPPER_n` = Donchian 상단 Indicator. (신고가 `NH_PRIOR_n`과 계산 방식·당일 포함 여부 주의 — §7.4)
### 5.5 일목균형표 (ICHIMOKU)
| 항목 | 값 |
|------|-----|
| 기본 (매수) | `CONVERSION_LINE` `CROSS_UP` `BASE_LINE` |
| 기본 (매도) | `CONVERSION_LINE` `CROSS_DOWN` `BASE_LINE` |
| **시그널** | **전환선·기준선 골든/데드크로스** |
**추가 전용 conditionType** (left/right 자동 설정):
| conditionType | 시그널 의미 |
|---------------|-------------|
| `ABOVE_CLOUD` | 종가 > 구름 상단 |
| `BELOW_CLOUD` | 종가 < 구름 하단 |
| `IN_CLOUD` | 종가가 구름 **내부** |
| `CLOUD_BREAK_UP` | 종가 **구름 상단** 상향 돌파 (Ta4j CrossedUp) |
| `CLOUD_BREAK_DOWN` | 종가 **구름 하단** 하향 이탈 |
| `SPAN1_CROSS_UP_SPAN2` | 선행1 **선행2** 상향 교차 |
| `LAGGING_GT_PRICE` | 후행스팬 > **26봉 전** 종가 |
| `LAGGING_ABOVE_PRIOR_CLOUD` | 후행 > **26봉 전** 구름 상단 |
| `CHIKOU_CROSS_ABOVE_PRIOR_CLOUD` | 후행 **구름 상단** 1회 돌파 |
파라미터: 전환 9 · 기준 26 · 선행/후행 26 · 선행2 52 (설정 상속).
---
## 6. 보조지표 탭 (`auxiliary`)
**내장 17종** + 사용자 추가 (`DEFAULT_AUXILIARY_ITEMS`).
단일 칩 → **CONDITION 1개** (`makeNode``getDefaultConditionFields`).
### 6.1 오실레이터 (RSI, Stochastic, CCI, Williams %R, BWI)
| 지표 | 기본 left | 기본 right | 기본 condition (매수) |
|------|-----------|------------|------------------------|
| RSI | `RSI_VALUE` | `HL_OVER` (과열) | `CROSS_UP` → 침체→과열 **교차** 아님, **과열선 돌파** |
| Stochastic | `STOCH_K` | `STOCH_D` | `%K` vs `%D` 교차 |
| CCI | `CCI_VALUE` | `CCI_SIGNAL` | CCI vs 시그널선 교차 |
| Williams %R | `WILLIAMS_R_VALUE` | `HL_OVER` | 과매도 구간 **탈출** 등 (필드 변경 가능) |
**추가 (오실레이터 전용):**
| conditionType | 시그널 |
|---------------|--------|
| `LOWEST_LTE` | 최근 **N봉**(lookback, 기본 20) `left` **최저** ≤ 침체선 — “최근 N봉 안 침체 터치” |
| `LOWEST_GTE` | 최근 N봉 최저 ≥ 임계 — 침체 이탈 유지 |
**실전 예 (Stoch 재매수 억제):** `AND( CROSS_UP 80, LOWEST_LTE 20 )` — 80 돌파 **且** 최근 20봉 내 20 이하 경험.
### 6.2 추세·거래량 (ADX, DMI, MACD, OBV, TRIX, Volume OSC, VR)
| 지표 | 기본 left → right | 전형적 시그널 |
|------|-------------------|---------------|
| ADX | `ADX_VALUE``HL_MID` | ADX > 25 추세 강도 |
| DMI | `PDI``MDI` | +DI **DI** 교차 |
| MACD | `MACD_LINE``SIGNAL_LINE` | MACD **시그널** 골든크로스 |
| OBV | `OBV_LINE``OBV_SIGNAL` | OBV vs OBV 시그널 |
| TRIX | `TRIX_VALUE``TRIX_SIGNAL` | TRIX 교차 |
| Volume OSC | `VOLUME_OSC_VALUE``HL_MID` | 거래량 OSC 중립 돌파 |
| VR | `VR_VALUE``HL_OVER` | VR 과열 |
### 6.3 심리·이격 (DISPARITY, PSY*, INVEST_PSY)
- `DISPARITY{n}` vs `HL_MID` — 이격도 과열/침체.
- 심리도 계열 — `PSY_VALUE` vs `HL_OVER` 등.
### 6.4 거래량 (VOLUME)
| conditionType | 시그널 |
|---------------|--------|
| `GT` / `CROSS_UP` | 거래량 vs `VOLUME_MA` |
| **`VOLUME_GT_MA_RATIO`** | `volume[i] ≥ MA(length) × compareValue` (기본 MA 5, 비율 1.5) |
### 6.5 신고가 / 신저가 (NEW_HIGH / NEW_LOW)
| 항목 | 매수 (NEW_HIGH) | 매도 (NEW_LOW) |
|------|-----------------|----------------|
| 기본 | `CLOSE` `CROSS_UP` `NH_PRIOR_9` | `CLOSE` `CROSS_DOWN` `NL_PRIOR_9` |
| **right 의미** | **직전 9봉** 최고가 수평선 | **직전 9봉** 최저가 |
| **시그널** | 종가가 **신고가선** 상향 **교차** | 종가가 **신저가선** 하향 **교차** |
백엔드: `NH_PRIOR_n` = `max(high[tn] … high[t1])` (**현재 봉 high 미포함**).
`CROSS_UP` + right≠NH_PRIOR 시 **GTE**로 정규화 (Donchian 당일 포함 CROSS 방지).
---
## 7. 복합지표 탭 (`composite`)
### 7.1 동일 지표 2기간 교차 (`COMPOSITE_INDICATOR_ITEMS`)
**플래그:** `composite: true`, `leftPeriod`, `rightPeriod`, `leftField`, `rightField`, (선택) `leftCandleType` / `rightCandleType`.
| 칩 | left → right (예) | 시그널 |
|----|-------------------|--------|
| 돈치안 | `DC_UPPER_9` `CROSS_UP` `DC_UPPER_20` | **단기** 채널상단이 **장기** 상단 돌파 |
| RSI | `RSI_VALUE_9` `CROSS_UP` `RSI_VALUE_18` | 단기 RSI **장기** RSI 상향 교차 |
| CCI, ADX, Williams, TRIX, VR, PSY, 이격도 | 동일 패턴 | 기간 교차 |
**멀티 TF:** `leftCandleType ≠ rightCandleType``CrossTimeframeCompositeRule` (각 분봉에서 left/right 취한 뒤 교차 공식).
### 7.2 Stoch 과열×보조 (`STOCH_OVERBOUGHT_PAIR`)
**LogicNode OR 트리** (단일 CONDITION 아님):
```text
OR(
AND( Stoch %K ≥ 80, 보조지표 CROSS_UP 중앙 ),
AND( 보조지표 ≥ 0, Stoch %K CROSS_UP 80 )
)
```
- **시그널:** 과열 구간 Stoch×보조 **동시 충족** 또는 **역순** 동시 충족.
- 메타: `stochPair.secondaryIndicatorType` (기본 CCI).
### 7.3 9·20일 신고/신저 (`NEW_HIGH_9_20_BUY` / `NEW_LOW_9_20_SELL`)
**LogicNode AND:**
- `NEW_HIGH` `CROSS_UP` `NH_PRIOR_9` (또는 20)
- **필터** (filterLevel: balanced / strict) — ADX·거래량 등 추가 leaf
**시그널:** 단기 신고가 돌파 **且** 필터 통과.
### 7.4 33변곡 (`INFLECTION_33_BUY/SELL`)
- EMA33 **변곡** + Donchian/필터 AND.
- **시그널:** 33봉 EMA 기울기 전환 + 채널 확인.
### 7.5 일목×볼린저 (`ICHIMOKU_BB_BUY/SELL`)
단일 CONDITION:
```text
indicatorType: ICHIMOKU_BB
leftField: LAGGING_SPAN
rightField: UPPER_BAND | LOWER_BAND
conditionType: CROSS_UP | CROSS_DOWN
params.chikouDisplacement: 26 (기본)
```
**시그널:** **26봉 전** 후행스팬 vs **26봉 전** BB 밴드 교차 (후행·밴드 모두 lag 적용).
### 7.6 일목 상황별 (23종, `ICHIMOKU_SIT_*`)
| 카테고리 | 예 | 내부 구조 |
|----------|-----|-----------|
| 기본 매수 5 | 골든크로스, 기준선 돌파, 구름 돌파… | AND/OR **복수 CONDITION** |
| 호전/역전 | 호전(정배열), 역전(역배열) | 4~5 조건 AND |
| 엘리어트 10 | 1·2·3·4·5파, A·B·C | 파동 단계별 AND |
**시그널:** 해당 **상황 ID**에 정의된 leaf 조건이 **같은 봉**에서 모두(AND) 또는 일부(OR) 충족.
### 7.7 추천 안정형 (18종, `TURTLE_S1_BUY` 등)
| 전략 ID | 시그널 요지 |
|---------|-------------|
| TURTLE_S1 | 20일 **최고가** 돌파 매수 / 10일 **최저** 이탈 청산 |
| TURTLE_S2 | 55/20일 터틀 장기 |
| DONCHIAN_20 | 20일 채널 상·하단 |
| MA_TREND | MA 교차 + ADX |
| ICHIMOKU_TREND / SANYAKU / GOELEMENT | 일목 조합 |
| MACD_MOMENTUM / BOLLINGER_BOX | 모멘텀·박스 |
각 항목은 `stableStrategyPairs.ts`에 **고정 LogicNode**로 정의 — 편집기에서 leaf 펼치면 MA·DONCHIAN·ICHIMOKU CONDITION들.
---
## 8. 횡보필터 탭 (`range`)
**목적:** 매수/매도 **본 조건**과 AND로 묶어 **횡보 구간만** 또는 **횡보만 제외**하고 시그널.
| mode | LogicNode | 시그널 해석 |
|------|-----------|-------------|
| **include** | `buildInner()` 그대로 | **횡보일 때만** true인 leaf |
| **exclude** | `NOT(buildInner())` | **추세일 때만** true |
**카테고리 10종 · 내장 52필터** (`sidewaysFilterPalette.ts`):
| 카테고리 | 대표 필터 | leaf 조건 예 | true인 시장 |
|----------|-----------|--------------|-------------|
| **ADX** | ADX < 25 | `ADX LT HL_MID` | **약한 추세** (횡보) |
| | ADX ≥ 25 (exclude용) | `ADX GTE` | 강한 추세 |
| **볼린저** | 밴드 수축 | `DIFF_LT` UPPERLOWER `< 2%` | 변동성 **축소** |
| | 종가≈중심 | `CLOSE``MIDDLE` | 밴드 중앙 횡보 |
| **MA** | MA5≈MA20 | `DIFF_LT` MA5MA20 `< N` | 이평 **수렴** |
| **일목** | 구름 안 | `IN_CLOUD` | 구름 **내부** |
| | 구름 얇음 | `DIFF_LT` SPAN1SPAN2 | 얇은 구름 |
| **Donchian** | 9/20 채널 좁음 | `DIFF_LT` UPPERLOWER | **좁은 레인지** |
| **오실레이터** | RSI 40~60 | `GTE`+`LTE` 밴드 | **중립** |
| **cycle** | Stoch N봉 침체 | `LOWEST_LTE` `%K` | lookback 내 **20 이하** 경험 |
| **DMI** | +DI≈−DI | `DIFF_LT` PDIMDI | 방향성 **없음** |
| **volume** | 거래량 < MA | `VOLUME LT VOLUME_MA` | **저거래** |
| **combo** | ADX+구름+RSI | AND | **복합 횡보** |
**사용 예:**
```text
매수 시그널 = AND( 본래 매수 조건, 횡보필터 exclude「ADX≥25」 )
→ 추세가 있을 때만 매수
```
---
## 9. 추세 탭 (`trendline`)
**10칩** (`TREND_LINE_PALETTE_ITEMS`). 추세선 **정의**는 설정 → 전략 설정 (피벗·N·스윙 k).
| 칩 | indicatorType | leftField | conditionType |
|----|---------------|-----------|---------------|
| 종가 | MA | `CLOSE_PRICE` | buy: `TREND_LINE_CROSS_UP` |
| MA / EMA | MA / EMA | `MA20` / `EMA20` | sell: `TREND_LINE_CROSS_DOWN` |
| RSI, MACD, Stoch… | 해당 타입 | 지표선 | 동일 |
**공통 DSL:** `rightField: NONE`, `lookbackPeriod` (기본 10, 설정 `trendLineDefaultLookback`).
### 9.1 백엔드 시그널 알고리즘 (`TrendLineMath` + `buildTrendLineCrossUpRule`)
1. **fit 구간:** `[i N, i 1]` (현재 봉 **제외**)
2. **피벗 선정:**
- **상향 돌파 (매수):** **하락 저항선** — 스윙 **고점** 2개, `High₂ < High₁`, **High** 가격 연결
- **하향 돌파 (매도):** **상승 지지선** — 스윙 **저점** 2개, `Low₂ > Low₁`, **Low** 가격 연결
3. **동일 선**을 `i1`, `i`에서 extrapolation → `left`와 **CROSS** 판정
4. **오실레이터 left:** 스윙을 **지표값** 기준, 선도 지표 축
**시그널:** 해당 봉에서 종가(또는 left 지표)가 **N봉 피벗 추세선**을 **상향/하향 돌파**한 **그 봉 1개**.
전략평가 차트: **선택 봉** 위치의 추세선만 overlay (`buildSignalTrendLineDrawings`).
---
## 10. candleRange (N봉 윈도우) — 조건 편집기
팔레트 기본은 `candleRange: 1`, `candleRangeMode` 미설정 (= **CURRENT**).
| candleRangeMode | 의미 |
|-----------------|------|
| **CURRENT** | 해당 봉에서만 core Rule |
| **EXISTS_IN** | 최근 N봉 **중 하나라도** core true |
| **NOT_EXISTS_IN** | 최근 N봉 **동안 core true 없음** |
`SLOPE_*` + EXISTS_IN: 최근 N봉 **전 구간** 상승/하락 추세.
---
## 11. 시그널이 차트에 표시되는 경로
| 기능 | 서비스 | Rule 빌드 | 스캔 |
|------|--------|-----------|------|
| **전략평가** | `BacktestingService` / chart scan | `toRule(buy/sell)` | evalStart..end 각 `i` |
| **전략편집기 평가 탭** | 동일 (`StrategyEvaluationChart`) | 동일 | 동일 |
| **실시간 조건 패널** | `LiveConditionStatusService.evaluate` | leaf별 + overall | 현재/선택 봉 |
| **실시간 자동매매** | `LiveStrategyEvaluator` | TriggerBranch | 분봉 **마감** 시 |
**마커:** `isSatisfied(i) === true`인 봉 `i`의 **시각·종가**에 BUY/SELL 마커.
**LONG_ONLY:** BUY 후 exit Rule true 봉까지 **재진입 BUY 억제**.
---
## 12. 컨트롤 → 시그널 빠른 참조표
| 패널 영역 | 컨트롤 | 생성물 | 한 줄 시그널 요약 |
|-----------|--------|--------|-------------------|
| 논리 | AND | AND 노드 | 모든 하위 조건 **동시** 충족 봉 |
| 논리 | OR | OR 노드 | 하나라도 충족 봉 |
| 논리 | NOT | NOT 노드 | 자식 조건 **반대** |
| 밴드 | MA/EMA | 1 CONDITION | 이평 **골든/데드크로스** 봉 |
| 밴드 | BOLLINGER | 1 CONDITION | 종가 **밴드** 돌파 |
| 밴드 | DONCHIAN | 1 CONDITION | 종가 **채널** 돌파 |
| 밴드 | ICHIMOKU | 1 CONDITION | 전환/기준 **교차** 또는 구름/후행 **전용** 조건 |
| 보조 | RSI 등 | 1 CONDITION | 지표 vs 임계/시그널 **교차·수준** |
| 보조 | NEW_HIGH | 1 CONDITION | **직전 N봉 신고가** 교차 |
| 복합 | 2기간 RSI… | 1 composite CONDITION | **단기 vs 장기** 교차 |
| 복합 | Stoch×CCI | OR 트리 | 과열+보조 **복합** |
| 복합 | 9·20 신고가 | AND 트리 | 신고가+**필터** |
| 복합 | 일목 23종 | AND/OR 트리 | **상황별** 다중 조건 |
| 복합 | 안정형 18종 | AND/OR 트리 | **템플릿 전략** |
| 횡보 | ADX<25 등 | AND/NOT 트리 | **횡보/추세 필터** (타 조건과 AND) |
| 추세 | 종가/RSI… | 1 CONDITION | **N봉 피벗 추세선** 돌파 |
---
## 13. 편집기에서 시그널 확인 방법
1. **전략평가** 또는 편집기 **평가** 탭 실행 → 차트 **BUY/SELL 마커**
2. 하단 **봉 선택기**로 봉 이동 → `LiveConditionStatusService` 스타일로 **조건별 true/false** (평가 패널)
3. **추세** 조건: 선택 봉에서 **점선 추세선** overlay
4. DSL **CondEditor**에서 `conditionType`·`leftField`·`lookbackPeriod` 변경 → 위 표의 Rule **즉시 반영**
---
## 14. 코드 인덱스
| 영역 | 경로 |
|------|------|
| 팔레트 UI | `frontend/src/components/StrategyEditorPage.tsx` |
| 칩 | `frontend/src/components/strategyEditor/PaletteChip.tsx` |
| DSL 타입 | `frontend/src/utils/strategyTypes.ts` |
| 노드 생성 | `frontend/src/utils/strategyEditorShared.tsx` |
| 보조/복합 목록 | `frontend/src/utils/strategyPaletteStorage.ts` |
| 복합 2기간 | `frontend/src/utils/compositeIndicators.ts` |
| 횡보 47필터 | `frontend/src/utils/sidewaysFilterPalette.ts` |
| 추세 9칩 | `frontend/src/utils/trendLinePalette.ts` |
| 일목 23 | `frontend/src/utils/ichimokuSituationStrategy.ts` |
| 안정형 18 | `frontend/src/utils/stableStrategyPairs.ts` |
| Rule 변환 | `backend/.../StrategyDslToTa4jAdapter.java` |
| 추세선 수학 | `backend/.../TrendLineMath.java` |
| 백테스트 스캔 | `backend/.../BacktestingService.java` |
| 실시간 | `backend/.../LiveConditionStatusService.java` |
---
*문서 버전: 2026-06 — goldenChart 전략편집기·StrategyDslToTa4jAdapter 기준*
+382
View File
@@ -0,0 +1,382 @@
# 전략편집기 — 템플릿 탭 ↔ 매매 시그널 감지
전략편집기 우측 **템플릿** 탭에 나열된 각 전략을 적용했을 때 생성·저장되는 DSL이,
실제 차트·백테스트·실시간 평가에서 **어떻게 매수/매도 시그널로 판정되는지**를 정리한 문서입니다.
> **평가 원천:** `StrategyDslToTa4jAdapter` → Ta4j `Rule` → 봉 `i`에서 `rule.isSatisfied(i)`
> **관련:** [전략지표컨트롤.md](./전략지표컨트롤.md) · [매매전략조건.md](./매매전략조건.md)
---
## 1. 템플릿 탭 구조
**파일:** `StrategyEditorPage.tsx`, `strategyTemplateList.ts`, `TemplatePaletteTab.tsx`
템플릿 목록은 **4종 출처**를 하나의 리스트로 합칩니다 (`buildStrategyTemplatePaletteRows`).
| 출처 (`source`) | 배지 | 개수(기본) | 생성·적용 |
|-----------------|------|------------|-----------|
| **builtin** | (없음) | 28 | `getStrategyTemplates()` |
| **composite-palette** | 복합지표 | ~58 | 복합지표 팔레트와 동일 항목 |
| **sideways-palette** | 횡보지표 | 52 | 횡보필터 팔레트와 동일 항목 |
| **user** | 내 템플릿 | 사용자 저장 | `ui_preferences.strategyEditor.userTemplates` |
검색(`templateSearch`)은 **라벨·설명·매수/매도·배지** 문자열로 필터합니다.
---
## 2. 템플릿 적용 방식 (중요)
템플릿을 **더블클릭/적용**할 때 동작이 출처마다 다릅니다.
| 출처 | 적용 함수 | 캔버스 동작 |
|------|-----------|-------------|
| **내장 (builtin)** | `handleTemplate` | 해당 **매수 또는 매도 탭 전체를 교체** (반대 탭·고아 노드 초기화) |
| **내 템플릿 (user)** | `applyUserTemplate` | 저장된 **LogicNode 트리로 해당 탭 전체 교체** |
| **복합지표 (composite-palette)** | `applyPaletteItem` | **현재 탭 루트에 AND 병합** (기존 조건 유지 + 추가) |
| **횡보지표 (sideways-palette)** | `applySidewaysFilter` | **현재 탭 루트에 AND 병합** (필터 노드 추가) |
```text
[내장/내 템플릿 적용]
→ buyCondition 또는 sellCondition = 템플릿 루트 1개 (교체)
[복합·횡보 템플릿 적용]
→ mergeAtRoot(기존 루트, 새 노드, AND) — 다른 조건과 결합 가능
```
**전략평가 탭**에서는 `buildTemplateEvaluationPayload`가 내장/내 템플릿을 **새 평가 전략**으로 만들고, 복합·횡보는 동일 빌더로 DSL을 생성합니다.
---
## 3. 시그널 판정 공통 규칙
템플릿도 최종적으로 **LogicNode 트리**이므로, leaf `CONDITION`마다 아래 규칙이 적용됩니다.
| conditionType | 봉 `i`에서 true 조건 |
|---------------|----------------------|
| **CROSS_UP** | `left[i1] ≤ right[i1]` **且** `left[i] > right[i]` |
| **CROSS_DOWN** | `left[i1] ≥ right[i1]` **且** `left[i] < right[i]` |
| **GT / LT / GTE / LTE** | `left[i]` vs `right[i]` 수준 비교 |
| **AND** | 모든 자식 true |
| **OR** | 하나라도 true |
| **NOT** | 자식 false |
| **EXISTS_IN** (N봉) | 최근 N봉 중 core Rule **1회 이상** true |
**필터 강도:** 복합 전략(9·20 신고가, 33변곡, 안정형 등)은 기본 **`relaxed`(완화)** — ADX·거래량 등 보조 필터 **없음**이 기본값입니다. 편집기에서 **강함/보통**으로 변경 가능.
**positionMode:** 설정 → 전략 설정. `LONG_ONLY`면 BUY 후 exit(SELL) true 전까지 재진입 BUY 억제.
---
## 4. 내장 템플릿 (28종)
**출처:** `strategyPresets.ts``getStrategyTemplates(def)`
MA 기간은 설정의 `maLines[0]`, `maLines[1]` (기본 5·10일)을 사용합니다.
### 4.1 단순 템플릿 (`kind: 'simple'`) — 16종
CONDITION **1개**. `simpleTemplateToNode()`로 변환.
| 라벨 | signal | indicator | condition | left → right | 시그널 의미 |
|------|--------|-----------|-----------|--------------|-------------|
| MA 골든크로스 매수 | buy | MA | CROSS_UP | MA5 → MA10 | 단기 MA **장기 MA 상향 교차** 봉 |
| MA 데드크로스 매도 | sell | MA | CROSS_DOWN | MA5 → MA10 | 단기 MA **장기 MA 하향 교차** 봉 |
| RSI 과매도 매수 | buy | RSI | **LTE** | RSI_VALUE → OVERSOLD_30 | RSI **≤ 30** (과매도 구간 **체류**, 교차 아님) |
| RSI 과매수 매도 | sell | RSI | **GTE** | RSI_VALUE → OVERBOUGHT_70 | RSI **≥ 70** |
| MACD 상향 돌파 | buy | MACD | CROSS_UP | MACD_LINE → SIGNAL_LINE | MACD **시그널선 골든크로스** |
| MACD 하향 돌파 | sell | MACD | CROSS_DOWN | MACD_LINE → SIGNAL_LINE | MACD **데드크로스** |
| 볼린저 하단 돌파 매수 | buy | BOLLINGER | CROSS_UP | CLOSE → LOWER_BAND | 종가 **하단밴드 상향 돌파** (반등) |
| 볼린저 상단 돌파 매도 | sell | BOLLINGER | CROSS_UP | CLOSE → UPPER_BAND | 종가 **상단밴드 상향 돌파** (과열 이탈) |
| CCI 과매도 반등 | buy | CCI | CROSS_UP | CCI_VALUE → OVERSOLD_NEG100 | CCI **100선 상향 돌파** |
| 심리도 과매도 반등 매수 | buy | PSY | CROSS_UP | PSY_VALUE → OVERSOLD_25 | 심리도 **25선 상향 돌파** |
| 스토캐스틱 골든크로스 | buy | STOCH | CROSS_UP | STOCH_K → STOCH_D | %K **%D 상향 교차** |
| ADX 중앙선 상향 돌파 | buy | ADX | CROSS_UP | ADX_VALUE → ADX_25 | ADX **25선 상향 돌파** |
| 9일 신고가 돌파 | buy | NEW_HIGH | CROSS_UP | CLOSE → NH_PRIOR_9 | 종가 **직전 9봉 최고가** 교차 |
| 20일 신고가 돌파 | buy | NEW_HIGH | CROSS_UP | CLOSE → NH_PRIOR_20 | 종가 **직전 20봉 최고가** 교차 |
| 9일 신저가 이탈 | sell | NEW_LOW | CROSS_DOWN | CLOSE → NL_PRIOR_9 | 종가 **직전 9봉 최저가** 하향 교차 |
| 20일 신저가 이탈 | sell | NEW_LOW | CROSS_DOWN | CLOSE → NL_PRIOR_20 | 종가 **직전 20봉 최저가** 하향 교차 |
**NH_PRIOR_n / NL_PRIOR_n:** `max(high[tn..t1])` / `min(low[tn..t1])`**현재 봉 high/low 미포함**.
### 4.2 복합 내장 템플릿 (`kind: 'composite'`) — 12종
`build()` 함수가 **AND/OR LogicNode**를 반환. 적용 시 **해당 탭 전체**가 이 트리로 교체됩니다.
#### 4.2.1 9·20일 신고/신저가
| 라벨 | signal | 트리 | 기본(relaxed) 시그널 |
|------|--------|------|----------------------|
| 9·20일 신고가 매수 | buy | `AND` | `CLOSE CROSS_UP NH_PRIOR_20` **만** |
| 9·20일 신저가 매도 | sell | `AND` | `CLOSE CROSS_DOWN NL_PRIOR_20` **만** |
**필터 강도별 추가 leaf (편집기에서 변경 가능):**
| 강도 | 매수 추가 | 매도 추가 |
|------|-----------|-----------|
| **strict** | 9일 CROSS + ADX≥25 + 거래량>MA + 고가≥20일선 | ADX≥20 + 거래량>MA |
| **balanced** | ADX≥18 | ADX≥15 |
| **relaxed** | (없음) | (없음) |
#### 4.2.2 33변곡
| 라벨 | signal | 트리 | 기본(relaxed) 시그널 |
|------|--------|------|----------------------|
| 33변곡 매수 | buy | `AND` | EMA33 **1봉 SLOPE_UP** + CLOSE **CROSS_UP** CLOSE_MAX_33 |
| 33변곡 매도 | sell | `OR` | CLOSE **CROSS_DOWN** CLOSE_MIN_33 **만** (EMA 분기 생략) |
**balanced/strict** 시 매수: EMA 2봉 변곡, 종가>EMA33, ADX·거래량 필터 추가.
**매도 strict/balanced:** EMA 하락+종가<EMA33 **OR** 채널 이탈.
#### 4.2.3 돈천·터틀·일목 (Donchian / Ichimoku)
| 라벨 | signal | DSL 핵심 | 시그널 |
|------|--------|----------|--------|
| 돈천 채널(20일) 매수 | buy | DONCHIAN CROSS_UP CLOSE → DC_UPPER_20 | 20일 채널 **상단** 돌파 |
| 돈천 채널(20일) 매도 | sell | DONCHIAN CROSS_DOWN CLOSE → DC_LOWER_20 | 20일 채널 **하단** 이탈 |
| 터틀 S1 매수 | buy | DC_UPPER_**20** CROSS_UP | 20일 **최고가** 돌파 진입 |
| 터틀 S1 청산 | sell | DC_LOWER_**10** CROSS_DOWN | 10일 **최저가** 이탈 청산 |
| 터틀 S2 매수 | buy | DC_UPPER_**55** CROSS_UP | 55일 최고가 돌파 |
| 터틀 S2 청산 | sell | DC_LOWER_**20** CROSS_DOWN | 20일 최저가 이탈 |
| 일목 전환/기준선 매수 | buy | ICHIMOKU CROSS_UP CONVERSION → BASE | **전환·기준 골든크로스** |
| 일목 전환/기준선 매도 | sell | ICHIMOKU CROSS_DOWN CONVERSION → BASE | **데드크로스** |
> **참고:** 터틀 S1 매수 = 돈천 20일 매수와 **동일 DSL**. S1 청산만 10일 하단으로 다름.
---
## 5. 복합지표 템플릿 (~58종, 배지「복합지표」)
**출처:** `buildCompositePaletteTemplateRows``DEFAULT_COMPOSITE_ITEMS` (`strategyPaletteStorage.ts`)
지표 탭 **복합** 서브탭과 **동일 항목**이 템플릿 탭에도 노출됩니다.
적용 시 **현재 탭 조건에 AND 병합** (`applyPaletteItem`).
### 5.1 Stoch 과열×보조 (1종)
| 라벨 | signal | 트리 | 시그널 |
|------|--------|------|--------|
| Stoch 과열×보조 | buy | `OR` | `(Stoch≥80 ∧ 보조 CROSS_UP 중앙) (보조≥중앙 ∧ Stoch CROSS_UP 80)` |
기본 보조: **CCI**. 편집기에서 RSI·ADX 등 변경 가능.
### 5.2 9·20 신고/신저 · 33변곡 · 일목×BB (6종)
내장 §4.2와 **동일 빌더**. 템플릿 탭에서는 **추가(병합)** 방식만 다름.
| 항목 | 빌더 | 기본 시그널 요약 |
|------|------|------------------|
| 9·20일 신고가 매수 | `buildNewHigh920BuyTree` | 20일 신고가 CROSS + (필터) |
| 9·20일 신저가 매도 | `buildNewLow920SellTree` | 20일 신저가 CROSS + (필터) |
| 33변곡 매수/매도 | `buildInflection33Tree` | EMA33 변곡 + 33봉 종가 채널 |
| 일목×BB 매수 | `buildIchimokuBbBuyTree` | **26봉 전** 후행 vs **26봉 전** BB **상한** CROSS_UP |
| 일목×BB 매도 | `buildIchimokuBbSellTree` | **26봉 전** 후행 vs **26봉 전** BB **하한** CROSS_DOWN |
### 5.3 일목 상황별 (23종)
**빌더:** `ichimokuSituationStrategy.ts``BUILDERS[situationId][mode]`
| ID | 라벨(요) | signal | 논리 | leaf 조건 (동일 봉 AND/OR) |
|----|----------|--------|------|---------------------------|
| GOLDEN_CROSS | 골든크로스 매수 | buy | AND | 전환 **CROSS_UP** 기준 |
| BASE_BREAKUP | 기준선 돌파 매수 | buy | AND | 종가 **CROSS_UP** 기준선 |
| CLOUD_BREAKUP | 구름 돌파 매수 | buy | AND | **CLOUD_BREAK_UP** |
| LAGGING_BULL | 후행 호전 매수 | buy | AND | 후행 **>** 26봉 전 종가 |
| CLOUD_SUPPORT | 구름 지지 매수 | buy | AND | 종가>선행1 **且** 종가>기준선 |
| HOJEON | 호전(정배열) 매수 | buy | AND | 전환>기준 + 구름 위 + 양운 + 후행>종가 |
| DEAD_CROSS | 데드크로스 매도 | sell | OR | 전환 **CROSS_DOWN** 기준 |
| BASE_BREAKDOWN | 기준선 이탈 매도 | sell | OR | 종가 **CROSS_DOWN** 기준선 |
| CLOUD_BREAKDOWN | 구름 이탈 매도 | sell | OR | **CLOUD_BREAK_DOWN** |
| LAGGING_BEAR | 후행 역전 매도 | sell | OR | 후행 **<** 26봉 전 종가 |
| CLOUD_RESISTANCE | 구름 저항 매도 | sell | AND | 종가<선행1 **且** 종가<기준선 |
| SANYAKU_REVERSE | 삼역역전 매도 | sell | AND | 전환<기준 + 구름 아래 + 후행 역전 |
| GYEOKJEON | 역전(역배열) 매도 | sell | AND | 전환<기준 + 구름 아래 + 음운 + 후행 역전 |
| EW1 | 1파 매수 | buy | AND | 종가>기준 CROSS + 전환>기준 |
| EW2 | 2파 매수/손절 | buy/sell | AND/OR | 매수: 기준·선행2 지지+전환>기준 / 매도: 기준 이탈 OR 구름 이탈 |
| EW3 | 3파 매수 | buy | AND | 전환>기준 + 구름 위 + 후행 + 양운 |
| EW4 | 4파 재매수/익절 | buy/sell | AND/OR | 매수: 선행1·전환 지지 / 매도: 기준·구름 이탈 |
| EW5 | 5파 분할매도 | sell | OR | 종가<전환 OR 데드 OR 구름 이탈 |
| EW_A | A파 매도 | sell | OR | 기준·데드 이탈 |
| EW_B | B파 매도 | sell | AND | 기준 아래 + 구름 아래 + 전환<기준 |
| EW_C | C파 매도 | sell | AND | 구름 이탈 + 구름 아래 + 전환<기준 + 후행 역전 |
### 5.4 추천 안정형 (18종)
**빌더:** `stableStrategyPairs.ts` — 기본 필터 **`relaxed`**
| strategyId | 매수 핵심 | 매도 핵심 | relaxed 시그널 |
|------------|-----------|-----------|----------------|
| **TURTLE_S1** | DC_UPPER_20 CROSS_UP | DC_LOWER_10 CROSS_DOWN | §4.2.3과 동일 |
| **TURTLE_S2** | DC_UPPER_55 CROSS_UP | DC_LOWER_20 CROSS_DOWN | 장기 터틀 |
| **DONCHIAN_20** | DC_UPPER_20 CROSS_UP | DC_LOWER_20 CROSS_DOWN | 20일 채널 |
| **MA_TREND** | MA20 CROSS_UP MA60 | MA20 CROSS_DOWN MA60 **OR** 종가<MA20 | 이평 추세 |
| **ICHIMOKU_TREND** | 전환/기준 CROSS_UP | CROSS_DOWN **OR** 구름 아래(balanced+) | 일목 추세 |
| **ICHIMOKU_SANYAKU** | 구름 돌파 + (종가>전환 등) | 전환/기준/구름 **하향 CROSS** OR | 삼역호전 |
| **ICHIMOKU_PERFECT** | 전환>기준 + 구름 위 | 전환<기준 + 구름 아래 | 5원소(양운·후행은 strict) |
| **MACD_MOMENTUM** | MACD CROSS_UP | MACD CROSS_DOWN **OR** 종가<EMA60 | 모멘텀 |
| **BOLLINGER_RANGE** | RSI≤30 + ADX약 + 하단밴드 CROSS_UP | RSI≥70 **OR** 상단밴드 CROSS_UP | 박스권 |
**필터 강도 (strict/balanced) 추가 예:**
| 전략 | strict 추가 |
|------|-------------|
| TURTLE / DONCHIAN 매수 | ADX≥25 + 거래량>MA |
| MA_TREND 매수 | ADX≥25 + 거래량>MA |
| ICHIMOKU_SANYAKU 매수 | EXISTS_IN 윈도우(12~26봉) + MA60·거래량 |
| MACD_MOMENTUM 매수 | EMA60 위 + ADX·거래량 |
### 5.5 동일 지표 2기간 교차 (10종)
**적용:** `makeNode(..., { composite: true })` — 현재 **매수/매도 탭**에 따라 `CROSS_UP` / `CROSS_DOWN`.
| 칩 | left → right (기본 기간) | 시그널 |
|----|--------------------------|--------|
| 돈치안 | DC_UPPER_9 CROSS DC_UPPER_20 | **단기 채널상단**이 **장기 상단** 돌파 |
| RSI | RSI_VALUE_9 CROSS RSI_VALUE_18 | 단기 RSI **장기** RSI 교차 |
| CCI | CCI_VALUE_9 CROSS CCI_VALUE_26 | 동일 패턴 |
| ADX | ADX_VALUE_14 CROSS ADX_VALUE_28 | 동일 |
| Williams %R | WILLIAMS_R 14 vs 28 | 동일 |
| TRIX | TRIX 12 vs 24 | 동일 |
| VR | VR 26 vs 52 | 동일 |
| 심리도 / 투자심리도 | 설정 기간 ×2 | 동일 |
| 이격도 | dispShort vs dispMid | 동일 |
기간은 **보조지표 설정**(`DefType`)에서 상속.
---
## 6. 횡보지표 템플릿 (52종, 배지「횡보지표」)
**출처:** `buildSidewaysPaletteTemplateRows``DEFAULT_SIDEWAYS_FILTER_ITEMS`
적용: `buildSidewaysFilterLogic(templateId, mode, def)`**현재 탭에 AND 병합**.
| mode | LogicNode | 시그널 해석 |
|------|-----------|-------------|
| **include** | 필터 leaf 그대로 | **횡보일 때** true — 횡보 구간 **포함** 필터 |
| **exclude** | `NOT(필터 leaf)` | **추세일 때** true — 횡보 **제외** |
**템플릿 탭 signal 표시:** include → **buy**, exclude → **sell** (UI 라벨용; 실제로는 **필터 노드**이며 단독 매매 시그널이 아님).
### 6.1 카테고리별 시그널 조건
| 카테고리 | 대표 템플릿 | true = 횡보(include) |
|----------|-------------|----------------------|
| **ADX** | ADX < 25 | ADX **< 25** (비추세) |
| | ADX ≥ 25 (exclude) | 추세 — NOT으로 **횡보 제외** |
| **볼린저** | BB 수축 | 상·하단 간격 **DIFF_LT** (Squeeze) |
| | 종가 ≈ 중심 | 종가 **≈** MIDDLE_BAND |
| **MA** | MA5≈MA20 | MA 스프레드 **DIFF_LT** |
| **일목** | 구름 안 | **IN_CLOUD** |
| **Donchian** | 9/20일 채널 좁음 | DC_UPPER DC_LOWER **DIFF_LT** |
| **오실레이터** | RSI 40~60 | RSI **GTE 40 ∧ LTE 60** |
| **cycle** | Stoch 20봉 침체 | **LOWEST_LTE** %K ≤ 20 (lookback 20) |
| **DMI** | +DI ≈ DI | PDIMDI **DIFF_LT** |
| **volume** | 거래량 < MA | VOLUME **LT** VOLUME_MA |
| **combo** | ADX+구름+RSI | 위 조건 **AND** (3중 횡보) |
**실전 조합 예:**
```text
매수 = AND( MA 골든크로스, NOT「ADX<25」 ) ← exclude ADX≥25 = 추세에서만 매수
매수 = AND( RSI 과매도, 「RSI 40~60」 ) ← include = 횡보 확인 (드묾)
```
전체 52항목 ID·설명은 `sidewaysFilterPalette.ts` `DEFAULT_SIDEWAYS_FILTER_ITEMS` 참고.
---
## 7. 내 템플릿 (사용자 저장)
**저장:** 현재 **매수 또는 매도** 탭의 `EditorConditionState` + `orphans` 스냅샷.
| 필드 | 의미 |
|------|------|
| `signal` | buy / sell — 적용 시 **해당 탭만** 채움 |
| `editorState.root` | LogicNode 트리 |
| `orphans` | 캔버스 고아 노드 |
**시그널:** 저장 시점 DSL 그대로 `StrategyDslToTa4jAdapter` 평가.
내용은 사용자가 만든 **AND/OR/CONDITION** 조합이므로, leaf별로 §3 공통 규칙 적용.
**적용:** 반대 탭·고아 초기화 후 **전체 교체** (`applyUserTemplate`).
---
## 8. 내장 vs 복합지표 중복
다음 항목은 **내장 템플릿**과 **복합지표 템플릿**에 **둘 다** 나타날 수 있습니다.
| 항목 | 차이 |
|------|------|
| 9·20 신고/신저, 33변곡, 터틀, 돈천, 일목 전환/기준 | **DSL 동일** |
| 적용 방식 | 내장 = **탭 교체** / 복합 = **AND 추가** |
숨김: 템플릿 삭제 → `hiddenBuiltinTemplateIds`에 key 저장 (복원 가능).
---
## 9. 차트까지의 평가 경로
```text
[템플릿 적용]
buyConditionJson / sellConditionJson (전략 저장)
StrategyDslToTa4jAdapter.toRule(buy) / toRule(sell)
BacktestingService · StrategyEvaluationChart · LiveConditionStatusService
isSatisfied(i) === true → 봉 i에 BUY/SELL 마커
```
| 기능 | 템플릿 사용처 |
|------|---------------|
| **전략편집기** | 템플릿 탭 적용 → 편집 후 전략 저장 |
| **전략평가** | 템플릿 선택 → `[템플릿] {라벨}` 임시 전략 생성 |
| **실시간** | 저장된 전략 DSL → 분봉 마감 시 TriggerBranch 평가 |
---
## 10. 템플릿별 시그널 빠른 참조
| 템플릿 유형 | 대표 예 | 한 줄 시그널 |
|-------------|---------|--------------|
| 단순 내장 | MA 골든크로스 | 이평 **교차** 1봉 |
| 단순 내장 | RSI 과매도 | RSI **≤30** 수준 |
| 단순 내장 | 20일 신고가 | **직전 20봉 고가** CROSS_UP |
| 복합 내장 | 9·20 신고가 | 20일 CROSS + (선택 필터) AND |
| 복합 내장 | 터틀 S1 | 20일 돌파 매수 / 10일 이탈 매도 |
| 복합지표 | Stoch×CCI | 과열+보조 **OR** 조합 |
| 복합지표 | 일목 EW3 | 4~5 일목 leaf **AND** |
| 복합지표 | MA 추세 | MA20/60 + (ADX) |
| 복합지표 | RSI 2기간 | RSI 단기 **장기** CROSS |
| 횡보 | ADX<25 include | **횡보 필터** (타 조건과 AND) |
| 횡보 | ADX≥25 exclude | **추세만** 통과 NOT |
| 내 템플릿 | (사용자) | 저장 DSL 그대로 |
---
## 11. 코드 인덱스
| 영역 | 경로 |
|------|------|
| 템플릿 목록 병합 | `frontend/src/utils/strategyTemplateList.ts` |
| 내장 28종 정의 | `frontend/src/utils/strategyPresets.ts` |
| 복합·횡보 행 변환 | `frontend/src/utils/strategyEditorTemplatePaletteRows.ts` |
| 템플릿 UI | `frontend/src/components/strategyEditor/TemplatePaletteTab.tsx` |
| 적용 (편집기) | `frontend/src/components/StrategyEditorPage.tsx``handleTemplateApply` |
| 적용 (전략평가) | `frontend/src/utils/strategyEvaluationTemplateApply.ts` |
| 9·20 / 33변곡 | `priceExtremeBreakoutPair.ts`, `inflection33Strategy.ts` |
| Stoch×보조 | `stochOverboughtPair.ts` |
| 일목×BB | `ichimokuBbStrategy.ts` |
| 일목 23상황 | `ichimokuSituationStrategy.ts` |
| 안정형 18 | `stableStrategyPairs.ts` |
| 2기간 교차 | `compositeIndicators.ts` |
| 횡보 52 | `sidewaysFilterPalette.ts` |
| 사용자 템플릿 | `strategyTemplateStorage.ts` |
| Rule 변환 | `backend/.../StrategyDslToTa4jAdapter.java` |
---
*문서 버전: 2026-06 — goldenChart 전략편집기 템플릿 탭·strategyPresets 기준*