ai 전략 기능 추가

This commit is contained in:
Macbook
2026-06-30 21:59:13 +09:00
parent f9447ea52f
commit 25cbadbffb
18 changed files with 2446 additions and 3 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;
import com.goldenchart.dto.LlmModelsRequest;
import com.goldenchart.dto.LlmModelsResponse;
import com.goldenchart.dto.LlmTestResponse;
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse;
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse;
import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest;
import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse;
import com.goldenchart.service.StrategyAgentChatService;
import com.goldenchart.service.StrategyEvaluationAiVerifyJobService;
import com.goldenchart.service.StrategyEvaluationLlmService;
import lombok.RequiredArgsConstructor;
@@ -28,6 +31,7 @@ public class StrategyEvaluationController {
private final StrategyEvaluationLlmService llmService;
private final StrategyEvaluationAiVerifyJobService aiVerifyJobService;
private final StrategyAgentChatService agentChatService;
@PostMapping("/ai-verify/jobs")
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")
public ResponseEntity<?> llmTest(
@RequestHeader Map<String, String> headers,
@@ -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,334 @@
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).
*/
@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)로 구성/수정합니다.
【행동 규칙 — 멀티턴 에이전트】
- 요청이 모호하거나 핵심 정보가 빠졌으면, 전략을 만들지 말고 한국어로 짧고 구체적으로 되물으세요. (action=ask)
핵심 정보 예: 매수/매도 구분, 지표 기간(예: 일목 9/26/52), 임계값(예: RSI 30), 캔들 종류(예: 5분봉),
"N봉 이내" 같은 윈도우 조건 여부.
- 정보가 충분하면 전략을 생성하거나, 이미 전략이 있으면 그것을 수정합니다. (action=build)
- 현재 편집기 상태(【현재 편집기 상태】의 buyCondition/sellCondition)가 비어있지 않으면, 처음부터 새로
만들지 말고 기존 DSL을 최소 변경으로 수정하세요.
- 한 번에 한두 가지만 묻고, 합리적인 기본값이 있으면 가정하고 진행하되 가정한 내용을 설명에 명시하세요.
【출력 형식】
- 항상 한국어 설명을 먼저 작성합니다.
- action=build일 때만, 설명 뒤에 단 하나의 ```json 코드블록을 추가합니다. 형식:
```json
{ "action": "build", "buyCondition": <LogicNode 또는 null>, "sellCondition": <LogicNode 또는 null> }
```
- 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).
【예시 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;
}
}
+28
View File
@@ -78,6 +78,34 @@
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 프록시 ────────────────────────
location /api/ {
proxy_pass http://backend:8080;
+48 -1
View File
@@ -47,6 +47,7 @@ import {
} from '../utils/strategyStartNodes';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
@@ -320,7 +321,7 @@ export default function StrategyEditorPage({
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
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' | 'trendline'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
@@ -1463,6 +1464,45 @@ export default function StrategyEditorPage({
[templateRows, templateSearch],
);
const buildAiContext = useCallback((): AiStrategyContext => {
const buyEncoded = encodeConditionForSave(buyEditorState);
const sellEncoded = encodeConditionForSave(sellEditorState);
return {
buyCondition: buyEncoded,
sellCondition: sellEncoded,
signalTab: buySellTab(signalTab),
candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType,
};
}, [buyEditorState, sellEditorState, signalTab, currentEditorState]);
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) => {
if (editorMode === 'graph') layoutFlushRef.current?.();
@@ -2418,6 +2458,7 @@ export default function StrategyEditorPage({
<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 === '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 className="se-right-body">
{rightTab === 'indicators' && (
@@ -2593,6 +2634,12 @@ export default function StrategyEditorPage({
onDelete={handleTemplateToolbarDelete}
/>
)}
{rightTab === 'ai' && (
<AiStrategyPanel
getContext={buildAiContext}
onApplyStrategy={applyAiStrategy}
/>
)}
</div>
</div>
</aside>
@@ -1,7 +1,7 @@
import React, { useMemo, useState } from 'react';
import type { LlmAppSettings } from '../../utils/llmSettings';
import { buildLlmEndpointUrl, buildLlmBaseUrl } from '../../utils/llmSettings';
import { fetchLlmConnectionTest } from '../../utils/backendApi';
import { fetchLlmConnectionTest, fetchLlmModels } from '../../utils/backendApi';
interface Props {
settings: LlmAppSettings;
@@ -34,6 +34,9 @@ const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = (
const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
const [testing, setTesting] = useState(false);
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>) => {
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 (
<div className="llm-settings-panel">
<SettingSection title="LLM 사용">
@@ -114,6 +147,15 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
placeholder="/v1/chat/completions"
/>
</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에 요청합니다.">
<code className="stg-code-preview">{endpointPreview}</code>
</SettingRow>
@@ -137,7 +179,36 @@ const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
</SettingSection>
<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
className="stg-input stg-input--wide"
type="text"
@@ -0,0 +1,238 @@
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;
[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 = [
'전환선이 기준선을 상향돌파하면 매수',
'26봉 전 후행스팬이 종가를 상향돌파하고 전환선이 기준선을 상향돌파하면 매수',
'RSI 30 이하에서 상향돌파하면 매수, 70 이상이면 매도',
'지금 전략을 더 엄격하게 만들어줘',
];
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 send = useCallback(async (text: string) => {
const trimmed = text.trim();
if (!trimmed || busy) return;
setError(null);
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();
const ctx = getContext();
try {
const res = await requestStrategyAgentChat(outgoing, ctx);
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]);
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;
+125
View File
@@ -1914,6 +1914,131 @@
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 {
margin: 10px 10px 6px;
padding: 8px 10px;
+57
View File
@@ -1835,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';
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 구독용) */
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
if (!hasRegisteredUser()) return ['1m'];
+7
View File
@@ -13,6 +13,8 @@ export interface LlmAppSettings {
useHttps: boolean;
/** OpenAI 호환 chat completions 경로 */
chatPath: string;
/** OpenAI 호환 모델 목록 경로 (드롭다운 조회용) */
modelsPath: string;
/** 모델 ID */
model: string;
/** 최대 생성 토큰 */
@@ -29,6 +31,7 @@ export const DEFAULT_LLM_APP_SETTINGS: LlmAppSettings = {
port: 16000,
useHttps: false,
chatPath: '/v1/chat/completions',
modelsPath: '/v1/models',
model: 'mlx-community/Qwen2.5-7B-Instruct-4bit',
maxTokens: 1024,
temperature: 0.1,
@@ -56,6 +59,9 @@ export function resolveLlmAppSettings(
const chatPath = typeof raw.chatPath === 'string' && raw.chatPath.trim()
? (raw.chatPath.startsWith('/') ? raw.chatPath.trim() : `/${raw.chatPath.trim()}`)
: 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;
return {
enabled: raw.enabled === undefined ? d.enabled : Boolean(raw.enabled),
@@ -63,6 +69,7 @@ export function resolveLlmAppSettings(
port: clampInt(raw.port, 1, 65535, d.port),
useHttps: raw.useHttps === undefined ? d.useHttps : Boolean(raw.useHttps),
chatPath,
modelsPath,
model,
maxTokens: clampInt(raw.maxTokens, 64, 8192, d.maxTokens),
temperature: clampFloat(raw.temperature, 0, 2, d.temperature),
+108
View File
@@ -0,0 +1,108 @@
/**
* 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';
/** 검증·정규화된 매수 조건 (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 = res.action === 'build' ? 'build' : '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,
};
}
+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 기준*