From 25cbadbffb863b3c3fa6445341b5d773513e412d Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 30 Jun 2026 21:59:13 +0900 Subject: [PATCH] =?UTF-8?q?ai=20=EC=A0=84=EB=9E=B5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/StrategyAgentController.java | 46 ++ .../StrategyEvaluationController.java | 18 + .../com/goldenchart/dto/LlmModelsRequest.java | 14 + .../goldenchart/dto/LlmModelsResponse.java | 19 + .../dto/StrategyAgentChatRequest.java | 23 + .../dto/StrategyAgentChatResponse.java | 25 + .../service/StrategyAgentChatService.java | 334 ++++++++++++ frontend/nginx-locations.inc | 28 + .../src/components/StrategyEditorPage.tsx | 49 +- .../components/settings/LlmSettingsPanel.tsx | 75 ++- .../strategyEditor/AiStrategyPanel.tsx | 238 +++++++++ frontend/src/styles/strategyEditor.css | 125 +++++ frontend/src/utils/backendApi.ts | 57 ++ frontend/src/utils/llmSettings.ts | 7 + frontend/src/utils/strategyAgentChat.ts | 108 ++++ 드로잉선_전략조건.md | 396 ++++++++++++++ 전략지표컨트롤.md | 505 ++++++++++++++++++ 전략템플릿.md | 382 +++++++++++++ 18 files changed, 2446 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/controller/StrategyAgentController.java create mode 100644 backend/src/main/java/com/goldenchart/dto/LlmModelsRequest.java create mode 100644 backend/src/main/java/com/goldenchart/dto/LlmModelsResponse.java create mode 100644 backend/src/main/java/com/goldenchart/dto/StrategyAgentChatRequest.java create mode 100644 backend/src/main/java/com/goldenchart/dto/StrategyAgentChatResponse.java create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java create mode 100644 frontend/src/components/strategyEditor/AiStrategyPanel.tsx create mode 100644 frontend/src/utils/strategyAgentChat.ts create mode 100644 드로잉선_전략조건.md create mode 100644 전략지표컨트롤.md create mode 100644 전략템플릿.md diff --git a/backend/src/main/java/com/goldenchart/controller/StrategyAgentController.java b/backend/src/main/java/com/goldenchart/controller/StrategyAgentController.java new file mode 100644 index 0000000..2c66142 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/StrategyAgentController.java @@ -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 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 전략 생성 중 오류가 발생했습니다.")); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java b/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java index 014faa9..35b22dd 100644 --- a/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java +++ b/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java @@ -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 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 headers, diff --git a/backend/src/main/java/com/goldenchart/dto/LlmModelsRequest.java b/backend/src/main/java/com/goldenchart/dto/LlmModelsRequest.java new file mode 100644 index 0000000..c17283c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LlmModelsRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/goldenchart/dto/LlmModelsResponse.java b/backend/src/main/java/com/goldenchart/dto/LlmModelsResponse.java new file mode 100644 index 0000000..8e36e71 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LlmModelsResponse.java @@ -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 models; + private String message; +} diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyAgentChatRequest.java b/backend/src/main/java/com/goldenchart/dto/StrategyAgentChatRequest.java new file mode 100644 index 0000000..2f0d351 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/StrategyAgentChatRequest.java @@ -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 messages; + + /** 현재 편집기 상태 컨텍스트 (buy/sell DSL, candleType, 지표 설정 등) */ + private JsonNode context; + + @Data + public static class ChatMessage { + private String role; + private String content; + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyAgentChatResponse.java b/backend/src/main/java/com/goldenchart/dto/StrategyAgentChatResponse.java new file mode 100644 index 0000000..303ea31 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/StrategyAgentChatResponse.java @@ -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; +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java b/backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java new file mode 100644 index 0000000..750670c --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java @@ -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 트리)을 생성/수정한다. + * + *

설정에 저장된 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": , "sellCondition": } + ``` + - 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 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 parseModelIds(JsonNode root) { + List 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; + } +} diff --git a/frontend/nginx-locations.inc b/frontend/nginx-locations.inc index adde0af..0b05d5b 100644 --- a/frontend/nginx-locations.inc +++ b/frontend/nginx-locations.inc @@ -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; diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 603cc00..820c1d9 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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(null); const [sellCondition, setSellCondition] = useState(null); const [signalTab, setSignalTab] = useState('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(() => loadPaletteItems('auxiliary')); const [compositePalette, setCompositePalette] = useState(() => 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({

+
{rightTab === 'indicators' && ( @@ -2593,6 +2634,12 @@ export default function StrategyEditorPage({ onDelete={handleTemplateToolbarDelete} /> )} + {rightTab === 'ai' && ( + + )}
diff --git a/frontend/src/components/settings/LlmSettingsPanel.tsx b/frontend/src/components/settings/LlmSettingsPanel.tsx index d133892..c91e067 100644 --- a/frontend/src/components/settings/LlmSettingsPanel.tsx +++ b/frontend/src/components/settings/LlmSettingsPanel.tsx @@ -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 = ({ 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([]); + const [modelsMessage, setModelsMessage] = useState<{ ok: boolean; message: string } | null>(null); const patch = (p: Partial) => { setTestResult(null); @@ -57,6 +60,36 @@ const LlmSettingsPanel: React.FC = ({ 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 (
@@ -114,6 +147,15 @@ const LlmSettingsPanel: React.FC = ({ settings, onChange }) => { placeholder="/v1/chat/completions" /> + + patch({ modelsPath: e.target.value })} + placeholder="/v1/models" + /> + {endpointPreview} @@ -137,7 +179,36 @@ const LlmSettingsPanel: React.FC = ({ settings, onChange }) => { - + +
+ + +
+
+ {modelsMessage && ( + + + ● {modelsMessage.message} + + + )} + 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 = ({ getContext, onApplyStrategy }) => { + const [turns, setTurns] = useState([]); + const [input, setInput] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const idRef = useRef(0); + const scrollRef = useRef(null); + + const nextId = () => ++idRef.current; + + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }); + }, []); + + const history = useMemo( + () => 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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void send(input); + } + }; + + return ( +
+
+ AI 전략 에이전트 + +
+ +
+ {turns.length === 0 && ( +
+

자연어로 매매 전략을 설명하면 조건을 자동 구성해 편집기에 적용합니다.

+

정보가 부족하면 먼저 질문하고, 이어지는 대화로 전략을 다듬습니다.

+
+ {SUGGESTIONS.map(s => ( + + ))} +
+
+ )} + + {turns.map(t => ( +
+
+ {t.content.split('\n').map((line, i) =>

{line || '\u00A0'}

)} +
+ {t.action === 'build' && t.produced && ( +
+ {t.reverted ? ( + <> + 되돌림 + + + ) : ( + <> + 편집기에 적용됨 + {t.snapshot && ( + + )} + + )} +
+ )} +
+ ))} + + {busy && ( +
+
생성 중…
+
+ )} +
+ + {error &&
{error}
} + +
+