From 7a2509a0455c544a37302deb84f4c0655aaaddb3 Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 17 Jun 2026 13:40:52 +0900 Subject: [PATCH] =?UTF-8?q?llm=20=EB=B6=84=EC=84=9D=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/goldenchart/auth/MenuIds.java | 2 +- .../StrategyEvaluationController.java | 66 ++ .../com/goldenchart/dto/LlmTestResponse.java | 13 + .../StrategyEvaluationAiVerifyRequest.java | 11 + .../StrategyEvaluationAiVerifyResponse.java | 19 + .../com/goldenchart/entity/GcAppSettings.java | 5 + .../service/AppSettingsService.java | 4 + .../service/LiveConditionStatusService.java | 16 + .../service/LlmSettingsResolver.java | 169 +++++ .../service/StrategyEvaluationLlmService.java | 188 +++++ ...ategyEvaluationTa4jDiagnosticsService.java | 273 +++++++ .../service/Ta4jRuleTraceCapture.java | 82 ++ .../src/main/resources/application-docker.yml | 2 + backend/src/main/resources/application.yml | 8 + .../db/migration/V67__llm_settings_json.sql | 11 + frontend/src/App.css | 20 + frontend/src/App.tsx | 5 + frontend/src/components/SettingsPage.tsx | 24 + .../src/components/StrategyEvaluationPage.tsx | 102 +++ .../components/settings/LlmSettingsPanel.tsx | 187 +++++ .../StrategyEvaluationAiVerifyModal.tsx | 347 +++++++++ .../StrategyEvaluationChart.tsx | 27 + frontend/src/hooks/useAppSettings.ts | 7 + frontend/src/styles/strategyEvaluation.css | 375 ++++++++++ frontend/src/utils/backendApi.ts | 36 + frontend/src/utils/llmSettings.ts | 84 +++ frontend/src/utils/permissions.ts | 5 +- .../src/utils/strategyEvaluationAiVerify.ts | 698 ++++++++++++++++++ 28 files changed, 2783 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java create mode 100644 backend/src/main/java/com/goldenchart/dto/LlmTestResponse.java create mode 100644 backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyRequest.java create mode 100644 backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyResponse.java create mode 100644 backend/src/main/java/com/goldenchart/service/LlmSettingsResolver.java create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java create mode 100644 backend/src/main/java/com/goldenchart/service/StrategyEvaluationTa4jDiagnosticsService.java create mode 100644 backend/src/main/java/com/goldenchart/service/Ta4jRuleTraceCapture.java create mode 100644 backend/src/main/resources/db/migration/V67__llm_settings_json.sql create mode 100644 frontend/src/components/settings/LlmSettingsPanel.tsx create mode 100644 frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx create mode 100644 frontend/src/utils/llmSettings.ts create mode 100644 frontend/src/utils/strategyEvaluationAiVerify.ts diff --git a/backend/src/main/java/com/goldenchart/auth/MenuIds.java b/backend/src/main/java/com/goldenchart/auth/MenuIds.java index 2c82181..b3a82ac 100644 --- a/backend/src/main/java/com/goldenchart/auth/MenuIds.java +++ b/backend/src/main/java/com/goldenchart/auth/MenuIds.java @@ -10,7 +10,7 @@ public final class MenuIds { "dashboard", "widget-dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "strategy-evaluation", "backtest", "analysis-report", "notifications", "settings", "settings_general", "settings_chart", "settings_indicators", "settings_backtest", "settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live", - "settings_trend-search", + "settings_trend-search", "settings_llm", "settings_alert", "settings_network", "settings_admin" ); } diff --git a/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java b/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java new file mode 100644 index 0000000..3c131ad --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/StrategyEvaluationController.java @@ -0,0 +1,66 @@ +package com.goldenchart.controller; + +import com.goldenchart.dto.LlmTestResponse; +import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest; +import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse; +import com.goldenchart.service.StrategyEvaluationLlmService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 전략 평가 부가 기능. + * + * POST /api/strategy/evaluation/ai-verify — LLM 기반 평가·시그널 일치 검증 + * POST /api/strategy/evaluation/llm-test — LLM 연결 테스트 + */ +@RestController +@RequestMapping("/strategy/evaluation") +@RequiredArgsConstructor +public class StrategyEvaluationController { + + private final StrategyEvaluationLlmService llmService; + + @PostMapping("/ai-verify") + public ResponseEntity aiVerify( + @RequestBody StrategyEvaluationAiVerifyRequest body, + @RequestHeader Map headers, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + TradingControllerSupport.requireRegisteredUser(headers); + if (body == null || body.getContext() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "context 가 필요합니다.")); + } + try { + Long userId = TradingControllerSupport.parseUserId(headers); + StrategyEvaluationAiVerifyResponse res = llmService.verify(body.getContext(), 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) { + return ResponseEntity.internalServerError().body(Map.of("message", "AI 검증 중 오류가 발생했습니다.")); + } + } + + @PostMapping("/llm-test") + public ResponseEntity llmTest( + @RequestHeader Map headers, + @RequestHeader(value = "X-Device-Id", required = false) String deviceId) { + TradingControllerSupport.requireRegisteredUser(headers); + try { + Long userId = TradingControllerSupport.parseUserId(headers); + LlmTestResponse res = llmService.testConnection(userId, deviceId); + if (res.isOk()) { + return ResponseEntity.ok(res); + } + return ResponseEntity.status(503).body(res); + } catch (IllegalStateException e) { + return ResponseEntity.status(503).body(Map.of("ok", false, "message", e.getMessage())); + } catch (Exception e) { + return ResponseEntity.internalServerError().body(Map.of("ok", false, "message", "LLM 연결 테스트 중 오류가 발생했습니다.")); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/dto/LlmTestResponse.java b/backend/src/main/java/com/goldenchart/dto/LlmTestResponse.java new file mode 100644 index 0000000..ec30a6a --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/LlmTestResponse.java @@ -0,0 +1,13 @@ +package com.goldenchart.dto; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class LlmTestResponse { + boolean ok; + String message; + String model; + long latencyMs; +} diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyRequest.java b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyRequest.java new file mode 100644 index 0000000..4f59219 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyRequest.java @@ -0,0 +1,11 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +/** 전략 평가 AI 검증 — 프론트가 수집한 평가·시그널 컨텍스트 */ +@Data +public class StrategyEvaluationAiVerifyRequest { + /** LLM 분석용 구조화 컨텍스트 (JSON) */ + private JsonNode context; +} diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyResponse.java b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyResponse.java new file mode 100644 index 0000000..9300862 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/dto/StrategyEvaluationAiVerifyResponse.java @@ -0,0 +1,19 @@ +package com.goldenchart.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StrategyEvaluationAiVerifyResponse { + private String analysis; + private String model; + private long latencyMs; + /** 서버 Ta4j 재평가·Rule descriptor·trace (LLM 컨텍스트와 동일) */ + private JsonNode ta4jDiagnostics; +} diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index e628993..f351454 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -273,6 +273,11 @@ public class GcAppSettings { @JdbcTypeCode(SqlTypes.JSON) private String trendSearchSettingsJson; + /** LLM(AI 검증) 연결·모델 설정 JSON */ + @Column(name = "llm_settings_json", columnDefinition = "JSON") + @JdbcTypeCode(SqlTypes.JSON) + private String llmSettingsJson; + /** 편집기·팔레트·패널 크기·가상투자 목록 등 UI 설정 통합 JSON */ @Column(name = "ui_preferences_json", columnDefinition = "JSON") @JdbcTypeCode(SqlTypes.JSON) diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java index ac77418..4825916 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -191,6 +191,9 @@ public class AppSettingsService { if (d.containsKey("trendSearchSettings")) { s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings"))); } + if (d.containsKey("llmSettings")) { + s.setLlmSettingsJson(toJson(d.get("llmSettings"))); + } if (d.containsKey("uiPreferences")) { s.setUiPreferencesJson(toJson(d.get("uiPreferences"))); } @@ -254,6 +257,7 @@ public class AppSettingsService { ? s.getLiveAutoTradeBudgetPct().doubleValue() : 95); m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false); m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson())); + m.put("llmSettings", parseJson(s.getLlmSettingsJson())); m.put("uiPreferences", parseJson(s.getUiPreferencesJson())); return m; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 7561fa6..2db82ea 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -671,6 +671,22 @@ public class LiveConditionStatusService { return merged; } + /** AI 검증 Ta4j 진단 — DB 지표 파라미터 + 오버라이드 병합 */ + @Transactional(readOnly = true) + public Map> mergeIndicatorParamsForDevice( + Long userId, String deviceId, + Map> override) { + return mergeIndicatorParams( + indicatorSettingsService.getAll(userId, deviceId), + override); + } + + /** AI 검증 Ta4j 진단 — 지표 visual 설정 */ + @Transactional(readOnly = true) + public Map> getAllVisualForDevice(Long userId, String deviceId) { + return indicatorSettingsService.getAllVisual(userId, deviceId); + } + /** * 차트 봉 구간을 스캔하여 매수/매도 시그널 목록을 반환한다. * {@link #evaluateOnChartBars} 의 {@code overallEntryMet}/{@code overallExitMet} 와 diff --git a/backend/src/main/java/com/goldenchart/service/LlmSettingsResolver.java b/backend/src/main/java/com/goldenchart/service/LlmSettingsResolver.java new file mode 100644 index 0000000..12ea3b0 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/LlmSettingsResolver.java @@ -0,0 +1,169 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcAppSettings; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.util.Map; + +/** + * 사용자 DB 설정(goldenchart.llm yml 기본값)을 병합해 LLM 호출에 사용할 값을 결정한다. + */ +@Service +@RequiredArgsConstructor +public class LlmSettingsResolver { + + public record ResolvedLlmSettings( + boolean enabled, + String baseUrl, + String chatPath, + String model, + int maxTokens, + double temperature, + long timeoutMs + ) {} + + private final AppSettingsService appSettingsService; + private final ObjectMapper objectMapper; + + @Value("${goldenchart.llm.base-url:http://127.0.0.1:16000}") + private String defaultBaseUrl; + + @Value("${goldenchart.llm.chat-path:/v1/chat/completions}") + private String defaultChatPath; + + @Value("${goldenchart.llm.model:mlx-community/Qwen2.5-7B-Instruct-4bit}") + private String defaultModel; + + @Value("${goldenchart.llm.max-tokens:1024}") + private int defaultMaxTokens; + + @Value("${goldenchart.llm.temperature:0.1}") + private double defaultTemperature; + + @Value("${goldenchart.llm.timeout-ms:120000}") + private long defaultTimeoutMs; + + public ResolvedLlmSettings resolve(Long userId, String deviceId) { + GcAppSettings entity = appSettingsService.getEntity(userId, deviceId); + Map user = parseUserSettings(entity.getLlmSettingsJson()); + return merge(user); + } + + private Map parseUserSettings(String json) { + if (json == null || json.isBlank()) return Map.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + private ResolvedLlmSettings merge(Map user) { + URI defaultUri = parseUri(defaultBaseUrl); + String defaultHost = defaultUri.getHost() != null ? defaultUri.getHost() : "127.0.0.1"; + int defaultPort = defaultUri.getPort() > 0 ? defaultUri.getPort() : 16000; + boolean defaultHttps = "https".equalsIgnoreCase(defaultUri.getScheme()); + + boolean enabled = user.containsKey("enabled") + ? Boolean.parseBoolean(String.valueOf(user.get("enabled"))) + : true; + + String host = stringOr(user.get("host"), defaultHost); + int port = clampPort(intOr(user.get("port"), defaultPort)); + boolean useHttps = user.containsKey("useHttps") + ? Boolean.parseBoolean(String.valueOf(user.get("useHttps"))) + : defaultHttps; + + String baseUrl = buildBaseUrl(host, port, useHttps); + String chatPath = stringOr(user.get("chatPath"), defaultChatPath); + String model = stringOr(user.get("model"), defaultModel); + int maxTokens = clampMaxTokens(intOr(user.get("maxTokens"), defaultMaxTokens)); + double temperature = clampTemperature(doubleOr(user.get("temperature"), defaultTemperature)); + long timeoutMs = clampTimeout(longOr(user.get("timeoutMs"), defaultTimeoutMs)); + + return new ResolvedLlmSettings( + enabled, trimTrailingSlash(baseUrl), normalizePath(chatPath), + model, maxTokens, temperature, timeoutMs); + } + + private static URI parseUri(String url) { + try { + return URI.create(trimTrailingSlash(url)); + } catch (Exception e) { + return URI.create("http://127.0.0.1:16000"); + } + } + + static String buildBaseUrl(String host, int port, boolean useHttps) { + String scheme = useHttps ? "https" : "http"; + String h = (host == null || host.isBlank()) ? "127.0.0.1" : host.trim(); + if ((useHttps && port == 443) || (!useHttps && port == 80)) { + return scheme + "://" + h; + } + return scheme + "://" + h + ":" + port; + } + + private static String normalizePath(String path) { + if (path == null || path.isBlank()) return "/v1/chat/completions"; + return path.startsWith("/") ? path : "/" + path; + } + + private static String trimTrailingSlash(String url) { + if (url == null || url.isBlank()) return url; + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private static String stringOr(Object v, String fallback) { + if (v == null) return fallback; + String s = v.toString().trim(); + return s.isEmpty() ? fallback : s; + } + + private static int intOr(Object v, int fallback) { + if (v == null) return fallback; + try { + return Integer.parseInt(v.toString()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private static long longOr(Object v, long fallback) { + if (v == null) return fallback; + try { + return Long.parseLong(v.toString()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private static double doubleOr(Object v, double fallback) { + if (v == null) return fallback; + try { + return Double.parseDouble(v.toString()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private static int clampPort(int port) { + return Math.max(1, Math.min(65535, port)); + } + + private static int clampMaxTokens(int v) { + return Math.max(64, Math.min(8192, v)); + } + + private static double clampTemperature(double v) { + return Math.max(0.0, Math.min(2.0, v)); + } + + private static long clampTimeout(long v) { + return Math.max(5_000L, Math.min(600_000L, v)); + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java new file mode 100644 index 0000000..df33d50 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationLlmService.java @@ -0,0 +1,188 @@ +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.LlmTestResponse; +import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse; +import com.goldenchart.service.LlmSettingsResolver.ResolvedLlmSettings; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; + +/** + * 전략 평가 AI 검증 — 챗봇 MLX LLM(OpenAI 호환) 프록시. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class StrategyEvaluationLlmService { + + private static final String SYSTEM_PROMPT = """ + 당신은 GoldenChart 전략 평가(Ta4j Rule) 검증 전문가입니다. + 사용자 JSON에는 선택 봉 시점의 조건 평가 결과, 차트 매매 시그널, 전략 DSL 요약이 포함됩니다. + ta4jDiagnostics 섹션은 서버가 동일 경로로 Ta4j Rule 을 재빌드·재평가한 결과(Rule descriptor, trace 로그, frontend vs backend 비교)입니다. + + 다음을 한국어로 분석하세요: + 1) 선택 봉에서 매수/매도 전체 Rule(overallEntryMet/overallExitMet)과 개별 조건(satisfied)이 논리적으로 일치하는지 + 2) ta4jDiagnostics.reEvaluation·entryRule/exitRule.satisfiedAtEvalIndex 와 프론트 evaluation 이 일치하는지 + 3) entryRule/exitRule.evaluationTraceLog 로 Rule 평가 경로를 추적해 불일치 원인을 설명 + 4) 차트에 표시된 매매 시그널(selectedBarSignals, barSignalHighlight)이 평가 결과와 맞는지 + 5) 시그널이 없을 때: 실제로 조건 미충족이라 시그널이 없는 것인지, 로직·데이터 불일치 가능성이 있는지 + 6) 의심 지점이 있으면 구체적 조건 ID·지표·수치·Rule 타입을 인용 + + 출력 형식(마크다운): + ## 종합 판정 + (정상 / 주의 / 오류 의심 — 한 줄 요약) + + ## 조건 평가 검증 + (매수·매도 각각 bullet) + + ## 차트 시그널 검증 + (선택 봉 시그널 vs Rule 결과) + + ## 결론 및 권장 조치 + (1~3문장) + + 로직 문제(종합 판정이 주의·오류 의심)인 경우, 위 분석에서 불일치 원인·조건 ID·지표를 구체적으로 명시하세요. + """; + + private static final String TEST_USER_PROMPT = "ping — 연결 테스트입니다. 한 문장으로 응답해 주세요."; + + private final WebClient.Builder webClientBuilder; + private final ObjectMapper objectMapper; + private final LlmSettingsResolver llmSettingsResolver; + private final StrategyEvaluationTa4jDiagnosticsService ta4jDiagnosticsService; + + public StrategyEvaluationAiVerifyResponse verify(JsonNode context, Long userId, String deviceId) throws Exception { + ResolvedLlmSettings cfg = llmSettingsResolver.resolve(userId, deviceId); + ensureEnabled(cfg); + if (context == null || context.isNull()) { + throw new IllegalArgumentException("context 가 필요합니다."); + } + + ObjectNode enrichedContext = ta4jDiagnosticsService.enrichContext(context, userId, deviceId); + JsonNode ta4jDiagnostics = enrichedContext.path("ta4jDiagnostics"); + + long started = System.currentTimeMillis(); + ObjectNode body = buildChatBody(cfg, SYSTEM_PROMPT, + "전략 평가 검증 데이터(JSON):\n" + + objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(enrichedContext)); + + String responseJson = postChat(cfg, body); + JsonNode root = objectMapper.readTree(responseJson); + String content = extractAssistantContent(root); + if (content == null || content.isBlank()) { + throw new IllegalStateException("LLM 응답에서 분석 텍스트를 찾을 수 없습니다."); + } + + return StrategyEvaluationAiVerifyResponse.builder() + .analysis(content.trim()) + .model(root.path("model").asText(cfg.model())) + .latencyMs(System.currentTimeMillis() - started) + .ta4jDiagnostics(ta4jDiagnostics.isMissingNode() || ta4jDiagnostics.isNull() + ? null : ta4jDiagnostics) + .build(); + } + + public LlmTestResponse testConnection(Long userId, String deviceId) { + ResolvedLlmSettings cfg = llmSettingsResolver.resolve(userId, deviceId); + ensureEnabled(cfg); + long started = System.currentTimeMillis(); + try { + ObjectNode body = buildChatBody(cfg, "You are a helpful assistant.", TEST_USER_PROMPT); + body.put("max_tokens", 32); + String responseJson = postChat(cfg, body); + JsonNode root = objectMapper.readTree(responseJson); + String content = extractAssistantContent(root); + if (content == null || content.isBlank()) { + return LlmTestResponse.builder() + .ok(false) + .message("LLM 응답에서 텍스트를 찾을 수 없습니다.") + .model(root.path("model").asText(cfg.model())) + .latencyMs(System.currentTimeMillis() - started) + .build(); + } + return LlmTestResponse.builder() + .ok(true) + .message("연결 성공: " + content.trim()) + .model(root.path("model").asText(cfg.model())) + .latencyMs(System.currentTimeMillis() - started) + .build(); + } catch (IllegalStateException e) { + return LlmTestResponse.builder() + .ok(false) + .message(e.getMessage()) + .model(cfg.model()) + .latencyMs(System.currentTimeMillis() - started) + .build(); + } catch (Exception e) { + log.warn("[StrategyEvaluationLlm] 연결 테스트 실패: {}", e.getMessage()); + return LlmTestResponse.builder() + .ok(false) + .message("연결 테스트 실패: " + e.getMessage()) + .model(cfg.model()) + .latencyMs(System.currentTimeMillis() - started) + .build(); + } + } + + private void ensureEnabled(ResolvedLlmSettings cfg) { + if (!cfg.enabled()) { + throw new IllegalStateException("LLM AI 검증이 비활성화되어 있습니다. 설정 > LLM에서 사용을 켜 주세요."); + } + } + + private ObjectNode buildChatBody(ResolvedLlmSettings cfg, String systemPrompt, String userPrompt) { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", cfg.model()); + body.put("max_tokens", cfg.maxTokens()); + body.put("temperature", cfg.temperature()); + ArrayNode messages = body.putArray("messages"); + messages.addObject().put("role", "system").put("content", systemPrompt); + messages.addObject().put("role", "user").put("content", userPrompt); + 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("[StrategyEvaluationLlm] LLM HTTP {} — {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new IllegalStateException("LLM 서버 응답 오류: " + e.getStatusCode().value()); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + log.warn("[StrategyEvaluationLlm] 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; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/StrategyEvaluationTa4jDiagnosticsService.java b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationTa4jDiagnosticsService.java new file mode 100644 index 0000000..c2bb155 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/StrategyEvaluationTa4jDiagnosticsService.java @@ -0,0 +1,273 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.core.type.TypeReference; +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.LiveConditionStatusDto; +import com.goldenchart.dto.OhlcvBar; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcStrategyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ta4j.core.BarSeries; +import org.ta4j.core.Rule; +import org.ta4j.core.serialization.ComponentDescriptor; +import org.ta4j.core.serialization.ComponentSerialization; +import org.ta4j.core.serialization.RuleSerialization; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * AI 검증 요청 시 전략평가와 동일 경로로 Ta4j Rule 을 재빌드·재평가하고 + * Rule 트리·trace 로그·평가 결과를 LLM 컨텍스트에 병합한다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class StrategyEvaluationTa4jDiagnosticsService { + + private static final int MAX_RULE_JSON_CHARS = 8_000; + private static final int MAX_TRACE_LINES = 100; + + private final LiveConditionStatusService liveConditionStatusService; + private final GcStrategyRepository strategyRepo; + private final StrategyDslToTa4jAdapter adapter; + private final StrategyDslTimeframeNormalizer timeframeNormalizer; + private final ObjectMapper objectMapper; + + /** + * context JSON 에 ta4jDiagnostics 섹션을 추가한다 (원본 context 는 유지). + */ + public ObjectNode enrichContext(JsonNode context, Long userId, String deviceId) { + ObjectNode enriched = context != null && context.isObject() + ? ((ObjectNode) context).deepCopy() + : objectMapper.createObjectNode(); + + ObjectNode diagnostics = buildDiagnostics(context, userId, deviceId); + enriched.set("ta4jDiagnostics", diagnostics); + return enriched; + } + + public ObjectNode buildDiagnostics(JsonNode context, Long userId, String deviceId) { + ObjectNode out = objectMapper.createObjectNode(); + out.put("generatedAt", java.time.Instant.now().toString()); + out.put("source", "backend-re-evaluate"); + + if (context == null || context.isNull()) { + out.put("error", "context 가 없습니다."); + return out; + } + + JsonNode chart = context.path("chart"); + JsonNode strategyNode = context.path("strategy"); + long strategyId = strategyNode.path("id").asLong(0); + String market = chart.path("market").asText(null); + String timeframe = chart.path("timeframe").asText(null); + Long barTimeSec = parseBarTimeSec(chart); + + if (strategyId <= 0 || market == null || market.isBlank()) { + out.put("error", "strategy.id 또는 chart.market 이 필요합니다."); + return out; + } + + List chartBars = parseChartBars(chart); + Map> indicatorParams = parseIndicatorParams(context.path("indicatorParams")); + + boolean usedChartBars = chartBars != null && !chartBars.isEmpty() && timeframe != null && !timeframe.isBlank(); + out.put("usedChartBars", usedChartBars); + if (!usedChartBars) { + ArrayNode warnings = out.putArray("warnings"); + warnings.add("chart.barsForEvaluation·timeframe 없음 — Ta4jStorage 기반 평가로 폴백 (차트와 불일치 가능)"); + } + + try { + LiveConditionStatusDto status = liveConditionStatusService.evaluate( + userId, + deviceId, + market, + strategyId, + barTimeSec, + indicatorParams.isEmpty() ? null : indicatorParams, + usedChartBars ? chartBars : null, + usedChartBars ? timeframe : null); + + ObjectNode statusNode = objectMapper.valueToTree(status); + out.set("reEvaluation", statusNode); + + Optional strategyOpt = strategyRepo.findById(strategyId); + if (strategyOpt.isEmpty()) { + out.put("ruleBuildError", "전략을 찾을 수 없습니다."); + return out; + } + + if (!usedChartBars) { + compareWithFrontendEvaluation(out, context, status); + return out; + } + + GcStrategy strategy = strategyOpt.get(); + JsonNode buyDsl = timeframeNormalizer.remapForChartTimeframe( + timeframeNormalizer.normalize( + objectMapper.readTree( + strategy.getBuyConditionJson() != null ? strategy.getBuyConditionJson() : "null"), + strategy.getName()), + OhlcvBarSeriesSupport.normalizeTf(timeframe)); + JsonNode sellDsl = timeframeNormalizer.remapForChartTimeframe( + timeframeNormalizer.normalize( + objectMapper.readTree( + strategy.getSellConditionJson() != null ? strategy.getSellConditionJson() : "null"), + strategy.getName()), + OhlcvBarSeriesSupport.normalizeTf(timeframe)); + + Map> params = mergeParamsFromStatus(userId, deviceId, indicatorParams); + Map> visual = liveConditionStatusService.getAllVisualForDevice(userId, deviceId); + + BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, OhlcvBarSeriesSupport.normalizeTf(timeframe)); + Map seriesOverrides = OhlcvBarSeriesSupport.buildSeriesOverrides( + primarySeries, OhlcvBarSeriesSupport.normalizeTf(timeframe), buyDsl, sellDsl); + int evalIndex = OhlcvBarSeriesSupport.resolveBarIndex(primarySeries, barTimeSec); + + out.put("evalBarIndex", evalIndex); + out.put("seriesBarCount", primarySeries.getBarCount()); + out.put("primaryTimeframe", OhlcvBarSeriesSupport.normalizeTf(timeframe)); + if (barTimeSec != null) out.put("barTimeSec", barTimeSec); + + StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + primarySeries, params, visual, market, null, false, seriesOverrides); + + attachRuleDiagnostics(out, "entryRule", buyDsl, ruleCtx, evalIndex); + attachRuleDiagnostics(out, "exitRule", sellDsl, ruleCtx, evalIndex); + + compareWithFrontendEvaluation(out, context, status); + } catch (Exception e) { + log.warn("[Ta4jDiagnostics] fail strategyId={} market={}: {}", strategyId, market, e.getMessage()); + out.put("error", e.getMessage()); + } + + return out; + } + + private void attachRuleDiagnostics(ObjectNode out, String fieldPrefix, JsonNode dsl, + StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx, int evalIndex) { + ObjectNode block = objectMapper.createObjectNode(); + if (dsl == null || dsl.isNull()) { + block.put("present", false); + out.set(fieldPrefix, block); + return; + } + block.put("present", true); + try { + Rule rule = adapter.toRule(dsl, ruleCtx); + block.put("ruleClass", rule.getClass().getSimpleName()); + block.put("ruleName", truncate(rule.getName(), 2000)); + + boolean serializationSupported = RuleSerialization.isSerializationSupported(rule); + block.put("serializationSupported", serializationSupported); + if (serializationSupported) { + ComponentDescriptor descriptor = RuleSerialization.describe(rule); + block.put("descriptorJson", truncate(ComponentSerialization.toJson(descriptor), MAX_RULE_JSON_CHARS)); + } + + java.util.List traceLines; + boolean satisfied; + try (Ta4jRuleTraceCapture capture = Ta4jRuleTraceCapture.open()) { + satisfied = capture.evaluateWithTrace(rule, evalIndex, null); + traceLines = capture.lines(); + } + block.put("satisfiedAtEvalIndex", satisfied); + ArrayNode traceArr = block.putArray("evaluationTraceLog"); + int n = Math.min(traceLines.size(), MAX_TRACE_LINES); + for (int i = 0; i < n; i++) { + traceArr.add(traceLines.get(i)); + } + if (traceLines.isEmpty()) { + block.put("traceNote", "TRACE 로그 없음 — logback TRACE 미활성 또는 Rule trace 미지원"); + } + } catch (Exception e) { + block.put("buildError", e.getMessage()); + } + out.set(fieldPrefix, block); + } + + private void compareWithFrontendEvaluation(ObjectNode out, JsonNode context, LiveConditionStatusDto status) { + ObjectNode cmp = objectMapper.createObjectNode(); + JsonNode fe = context.path("evaluation"); + putNullableBool(cmp, "frontendOverallEntryMet", fe.path("overallEntryMet")); + putNullableBool(cmp, "frontendOverallExitMet", fe.path("overallExitMet")); + if (fe.path("matchRate").isNull()) cmp.putNull("frontendMatchRate"); + else cmp.put("frontendMatchRate", fe.path("matchRate").asInt()); + putNullableBool(cmp, "backendOverallEntryMet", status.getOverallEntryMet()); + putNullableBool(cmp, "backendOverallExitMet", status.getOverallExitMet()); + cmp.put("backendMatchRate", status.getMatchRate()); + if (status.getEvalBarIndex() != null) cmp.put("backendEvalBarIndex", status.getEvalBarIndex()); + if (status.getBarTimeSec() != null) cmp.put("backendBarTimeSec", status.getBarTimeSec()); + out.set("frontendVsBackend", cmp); + } + + private static void putNullableBool(ObjectNode node, String field, JsonNode value) { + if (value == null || value.isNull() || value.isMissingNode()) node.putNull(field); + else node.put(field, value.asBoolean()); + } + + private static void putNullableBool(ObjectNode node, String field, Boolean value) { + if (value == null) node.putNull(field); + else node.put(field, value); + } + + private static Long parseBarTimeSec(JsonNode chart) { + JsonNode bar = chart.path("selectedBar"); + if (bar.isMissingNode() || bar.isNull()) return null; + long t = bar.path("time").asLong(0); + return t > 0 ? t : null; + } + + private List parseChartBars(JsonNode chart) { + JsonNode barsNode = chart.path("barsForEvaluation"); + if (!barsNode.isArray() || barsNode.isEmpty()) { + barsNode = chart.path("bars"); + } + if (!barsNode.isArray() || barsNode.isEmpty()) return List.of(); + + List bars = new ArrayList<>(); + for (JsonNode b : barsNode) { + bars.add(OhlcvBar.builder() + .time(b.path("time").asLong()) + .open(b.path("open").asDouble()) + .high(b.path("high").asDouble()) + .low(b.path("low").asDouble()) + .close(b.path("close").asDouble()) + .volume(b.path("volume").asDouble(0)) + .build()); + } + return bars; + } + + private Map> parseIndicatorParams(JsonNode node) { + if (node == null || node.isNull() || !node.isObject()) return Map.of(); + try { + return objectMapper.convertValue(node, new TypeReference<>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + private Map> mergeParamsFromStatus( + Long userId, String deviceId, + Map> override) { + return liveConditionStatusService.mergeIndicatorParamsForDevice(userId, deviceId, override); + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + if (s.length() <= max) return s; + return s.substring(0, max) + "\n…(truncated)"; + } +} diff --git a/backend/src/main/java/com/goldenchart/service/Ta4jRuleTraceCapture.java b/backend/src/main/java/com/goldenchart/service/Ta4jRuleTraceCapture.java new file mode 100644 index 0000000..60cc0d6 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/Ta4jRuleTraceCapture.java @@ -0,0 +1,82 @@ +package com.goldenchart.service; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.filter.Filter; +import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.spi.FilterReply; +import org.slf4j.LoggerFactory; +import org.ta4j.core.Rule; +import org.ta4j.core.TradingRecord; + +/** + * Ta4j Rule 평가 시 TRACE 로그를 메모리에 수집 (AI 검증용). + */ +final class Ta4jRuleTraceCapture implements AutoCloseable { + + private static final int MAX_LINES = 100; + private static final int MAX_LINE_LEN = 600; + + private final ListAppender appender; + private final Logger rulesLogger; + private final Level previousLevel; + + private Ta4jRuleTraceCapture(ListAppender appender, Logger rulesLogger, Level previousLevel) { + this.appender = appender; + this.rulesLogger = rulesLogger; + this.previousLevel = previousLevel; + } + + static Ta4jRuleTraceCapture open() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger rulesLogger = ctx.getLogger("org.ta4j.core.rules"); + Level prev = rulesLogger.getLevel(); + + ListAppender appender = new ListAppender<>(); + appender.addFilter(new Filter<>() { + @Override + public FilterReply decide(ILoggingEvent event) { + if (event == null || event.getLoggerName() == null) return FilterReply.DENY; + return event.getLoggerName().startsWith("org.ta4j.core.rules") + ? FilterReply.ACCEPT + : FilterReply.DENY; + } + }); + appender.setContext(ctx); + appender.start(); + + rulesLogger.setLevel(Level.TRACE); + rulesLogger.setAdditive(true); + rulesLogger.addAppender(appender); + + return new Ta4jRuleTraceCapture(appender, rulesLogger, prev); + } + + boolean evaluateWithTrace(Rule rule, int index, TradingRecord tradingRecord) { + return rule.isSatisfiedWithTraceMode(index, tradingRecord, Rule.TraceMode.VERBOSE); + } + + java.util.List lines() { + return appender.list.stream() + .filter(e -> e.getLevel().isGreaterOrEqual(Level.TRACE)) + .map(ILoggingEvent::getFormattedMessage) + .map(Ta4jRuleTraceCapture::truncate) + .limit(MAX_LINES) + .toList(); + } + + @Override + public void close() { + rulesLogger.detachAppender(appender); + appender.stop(); + rulesLogger.setLevel(previousLevel); + } + + private static String truncate(String s) { + if (s == null) return ""; + if (s.length() <= MAX_LINE_LEN) return s; + return s.substring(0, MAX_LINE_LEN) + "…"; + } +} diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 13175d2..9976d90 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -23,6 +23,8 @@ server: include-message: always goldenchart: + llm: + base-url: ${GC_LLM_BASE_URL:http://host.docker.internal:16000} cors: allowed-origins: - "*" diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 1577cd3..606e7b5 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -133,6 +133,14 @@ goldenchart: user: ${GC_DESKTOP_JENKINS_USER:} token: ${GC_DESKTOP_JENKINS_TOKEN:} trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:goldenchart-desktop-build} + llm: + # 챗봇 MLX LLM (OpenAI 호환 chat/completions) + base-url: ${GC_LLM_BASE_URL:http://127.0.0.1:16000} + chat-path: ${GC_LLM_CHAT_PATH:/v1/chat/completions} + model: ${GC_LLM_MODEL:mlx-community/Qwen2.5-7B-Instruct-4bit} + max-tokens: ${GC_LLM_MAX_TOKENS:1024} + temperature: ${GC_LLM_TEMPERATURE:0.1} + timeout-ms: ${GC_LLM_TIMEOUT_MS:120000} cors: allowed-origins: - http://localhost:5173 diff --git a/backend/src/main/resources/db/migration/V67__llm_settings_json.sql b/backend/src/main/resources/db/migration/V67__llm_settings_json.sql new file mode 100644 index 0000000..54dcd68 --- /dev/null +++ b/backend/src/main/resources/db/migration/V67__llm_settings_json.sql @@ -0,0 +1,11 @@ +-- LLM(AI 검증) 사용자별 설정 JSON +ALTER TABLE gc_app_settings + ADD COLUMN llm_settings_json JSON NULL + COMMENT 'LLM 연결·모델 설정 (host, port, model 등)' + AFTER trend_search_settings_json; + +-- 설정 화면 LLM 카테고리 권한 (네트워크 설정과 동일) +INSERT INTO gc_role_menu_permission (role, menu_id, allowed) +SELECT role, 'settings_llm', allowed +FROM gc_role_menu_permission +WHERE menu_id = 'settings_network'; diff --git a/frontend/src/App.css b/frontend/src/App.css index 643ce33..0113a37 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -10238,6 +10238,26 @@ html.desktop-client .tmb-logo-version { transition: border-color 0.15s; } .stg-btn-test:hover { border-color: var(--accent); color: var(--accent); } +.stg-btn-test:disabled { opacity: 0.5; cursor: not-allowed; } + +.stg-row-inline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.stg-code-preview { + display: block; + max-width: 100%; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text-muted, var(--text)); + font-size: 11px; + word-break: break-all; +} /* ── 하단 푸터 (저장/초기화) ── */ .stg-footer { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b6a82f0..a8f4889 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useIndicatorSettings } from './hooks/useIndicatorSettings'; import { getAppSettingsCache, useAppSettings } from './hooks/useAppSettings'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings'; +import { resolveLlmAppSettings } from './utils/llmSettings'; import TopMenuBar, { type MenuPage } from './components/TopMenuBar'; import { TradeNotificationProvider } from './contexts/TradeNotificationContext'; import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext'; @@ -436,6 +437,10 @@ function AppMainContent({ onTrendSearchSettingsChange={s => saveAppDef({ trendSearchSettings: resolveTrendSearchAppSettings(s), })} + llmSettings={appDefaults.llmSettings} + onLlmSettingsChange={s => saveAppDef({ + llmSettings: resolveLlmAppSettings(s), + })} tradingMode={appDefaults.tradingMode} onTradingMode={v => saveAppDef({ tradingMode: v })} liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 2f85b69..46d3101 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -33,7 +33,9 @@ import { } from '../utils/tradeAlertPopupLayout'; import TradeAlertPopupPreview from './TradeAlertPopupPreview'; import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel'; +import LlmSettingsPanel from './settings/LlmSettingsPanel'; import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings'; +import type { LlmAppSettings } from '../utils/llmSettings'; import { isTradingSettingsCategory } from '../utils/tradingAccess'; import { CHART_LEGEND_SETTING_ITEMS, @@ -176,6 +178,8 @@ interface SettingsPageProps { onVerificationIssueNotify?: (v: boolean) => void; trendSearchSettings?: TrendSearchAppSettings; onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void; + llmSettings?: LlmAppSettings; + onLlmSettingsChange?: (s: LlmAppSettings) => void; /** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */ initialCategory?: SettingsCategoryId; /** 정식 로그인 여부 (게스트는 매매·전략 설정 탭 차단) */ @@ -277,6 +281,13 @@ const IcTrendSearch = () => ( ); +const IcLlm = () => ( + + + + + +); const IcBacktest = () => ( @@ -304,6 +315,7 @@ const ALL_CATEGORIES: Category[] = [ { id: 'trading', label: '매매설정', icon: , desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' }, { id: 'live', label: '실거래', icon: , desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' }, { id: 'trend-search', label: '추세검색', icon: , desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' }, + { id: 'llm', label: 'LLM 설정', icon: , desc: 'AI 전략 검증용 LLM 서버 주소·포트·모델·타임아웃' }, { id: 'alert', label: '알림 설정', icon: , desc: '가격 알림, 신호 알림, 알림 방식 설정' }, { id: 'network', label: '네트워크', icon: , desc: 'API 서버 주소, WebSocket, 연결 상태' }, { id: 'admin', label: '관리자 설정', icon: , desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' }, @@ -2008,6 +2020,8 @@ const SettingsPage: React.FC = ({ onVerificationIssueNotify, trendSearchSettings, onTrendSearchSettingsChange, + llmSettings, + onLlmSettingsChange, initialCategory, isFormalLogin = true, onRequireFormalLogin, @@ -2180,6 +2194,16 @@ const SettingsPage: React.FC = ({

추세검색 설정을 불러올 수 없습니다.

) ); + case 'llm': return ( + llmSettings && onLlmSettingsChange ? ( + + ) : ( +

LLM 설정을 불러올 수 없습니다.

+ ) + ); case 'strategy': return ( (null); const [reportLoading, setReportLoading] = useState(false); + const [aiVerifyOpen, setAiVerifyOpen] = useState(false); + const [aiVerifyLoading, setAiVerifyLoading] = useState(false); + const [aiVerifyError, setAiVerifyError] = useState(null); + const [aiVerifyResult, setAiVerifyResult] = useState(null); + const [aiVerifyContext, setAiVerifyContext] = useState(null); + const [aiVerifySummary, setAiVerifySummary] = useState(null); + const aiVerifyGenRef = useRef(0); const reportCacheKeyRef = useRef(null); const reportGenRef = useRef(0); const evalSessionRef = useRef(0); @@ -369,6 +383,80 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading; + const aiVerifyDisabled = reportDisabled + || evalLoading + || chartLoading + || selectedBarTimeSec == null + || !snapshot; + + const runAiVerify = useCallback(async () => { + if (aiVerifyDisabled || !selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) return; + + const gen = ++aiVerifyGenRef.current; + setAiVerifyOpen(true); + setAiVerifyLoading(true); + setAiVerifyError(null); + setAiVerifyResult(null); + setAiVerifyContext(null); + + try { + const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); + const context = buildStrategyEvaluationAiVerifyContext({ + strategy: selectedStrategy, + strategyId: selectedStrategyId, + market, + timeframe: chartTimeframe, + bars, + selectedBarIndex, + snapshot, + evalLoading, + evalError, + chartSignals: backtestSignals, + barSignalHighlight, + indicatorParams, + evaluationBarCount: Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length), + }); + setAiVerifySummary(summarizeVerifyContext(context)); + setAiVerifyContext(context); + + const result = await requestStrategyEvaluationAiVerify(context); + if (gen !== aiVerifyGenRef.current) return; + setAiVerifyResult(result); + } catch (e) { + if (gen !== aiVerifyGenRef.current) return; + setAiVerifyError(e instanceof Error ? e.message : 'AI 검증 요청 실패'); + } finally { + if (gen === aiVerifyGenRef.current) { + setAiVerifyLoading(false); + } + } + }, [ + aiVerifyDisabled, + selectedStrategyId, + selectedStrategy, + selectedBarTimeSec, + market, + chartTimeframe, + bars, + selectedBarIndex, + snapshot, + evalLoading, + evalError, + backtestSignals, + barSignalHighlight, + getEvalParams, + ]); + + const handleOpenAiVerify = useCallback(() => { + void runAiVerify(); + }, [runAiVerify]); + + const handleCloseAiVerify = useCallback(() => { + ++aiVerifyGenRef.current; + setAiVerifyOpen(false); + setAiVerifyLoading(false); + }, []); + const handleOpenReport = useCallback(async () => { if (reportDisabled || !selectedStrategyId || !selectedStrategy) return; @@ -618,6 +706,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { onOpenReport={() => { void handleOpenReport(); }} reportDisabled={reportDisabled} reportLoading={reportLoading} + onOpenAiVerify={handleOpenAiVerify} + aiVerifyDisabled={aiVerifyDisabled} + aiVerifyLoading={aiVerifyLoading && aiVerifyOpen} marketTickers={marketFeed.tickers} marketInfos={marketFeed.marketInfos} marketLoading={marketFeed.loading} @@ -711,6 +802,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { onClose={() => setReportOpen(false)} model={reportModel} /> + + { void runAiVerify(); }} + /> ); } diff --git a/frontend/src/components/settings/LlmSettingsPanel.tsx b/frontend/src/components/settings/LlmSettingsPanel.tsx new file mode 100644 index 0000000..d133892 --- /dev/null +++ b/frontend/src/components/settings/LlmSettingsPanel.tsx @@ -0,0 +1,187 @@ +import React, { useMemo, useState } from 'react'; +import type { LlmAppSettings } from '../../utils/llmSettings'; +import { buildLlmEndpointUrl, buildLlmBaseUrl } from '../../utils/llmSettings'; +import { fetchLlmConnectionTest } from '../../utils/backendApi'; + +interface Props { + settings: LlmAppSettings; + onChange: (next: LlmAppSettings) => void; +} + +const SettingRow: React.FC<{ + label: string; + desc?: string; + children: React.ReactNode; +}> = ({ label, desc, children }) => ( +
+
+ {label} + {desc && {desc}} +
+
{children}
+
+); + +const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = ({ + title, children, +}) => ( +
+
{title}
+
{children}
+
+); + +const LlmSettingsPanel: React.FC = ({ settings, onChange }) => { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + const patch = (p: Partial) => { + setTestResult(null); + onChange({ ...settings, ...p }); + }; + + const endpointPreview = useMemo(() => buildLlmEndpointUrl(settings), [settings]); + const baseUrlPreview = useMemo(() => buildLlmBaseUrl(settings), [settings]); + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const res = await fetchLlmConnectionTest(); + setTestResult({ ok: res.ok, message: res.message ?? (res.ok ? '연결 성공' : '연결 실패') }); + } catch (e) { + const msg = e instanceof Error ? e.message : '연결 테스트 실패'; + setTestResult({ ok: false, message: msg }); + } finally { + setTesting(false); + } + }; + + return ( +
+ + + + + + + + + patch({ host: e.target.value })} + placeholder="127.0.0.1" + /> + + + patch({ port: Number(e.target.value) })} + /> + + + + + + patch({ chatPath: e.target.value })} + placeholder="/v1/chat/completions" + /> + + + {endpointPreview} + + +
+ + {testResult && ( + + {testResult.ok ? '● ' : '● '}{testResult.message} + + )} +
+
+
+ + + + patch({ model: e.target.value })} + placeholder="mlx-community/Qwen2.5-7B-Instruct-4bit" + /> + + + patch({ maxTokens: Number(e.target.value) })} + /> + + + patch({ temperature: Number(e.target.value) })} + /> + + + patch({ timeoutMs: Number(e.target.value) })} + /> + + +
+ ); +}; + +export default LlmSettingsPanel; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx new file mode 100644 index 0000000..d754f5c --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationAiVerifyModal.tsx @@ -0,0 +1,347 @@ +/** + * 전략 평가 — AI 검증 결과 모달 + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { + AiVerifyFixPointsBundle, + StrategyEvaluationAiVerifyContext, +} from '../../utils/strategyEvaluationAiVerify'; +import { buildAiVerifyFixPoints } from '../../utils/strategyEvaluationAiVerify'; +import type { StrategyEvaluationAiVerifyResponse } from '../../utils/strategyEvaluationAiVerify'; + +interface Props { + open: boolean; + onClose: () => void; + loading: boolean; + error: string | null; + contextSummary: string | null; + verifyContext: StrategyEvaluationAiVerifyContext | null; + result: StrategyEvaluationAiVerifyResponse | null; + onRetry?: () => void; +} + +function renderAnalysisMarkdown(text: string): React.ReactNode { + const lines = text.split('\n'); + const nodes: React.ReactNode[] = []; + let listItems: string[] = []; + + const flushList = () => { + if (listItems.length === 0) return; + nodes.push( +
    + {listItems.map((item, i) => ( +
  • {item}
  • + ))} +
, + ); + listItems = []; + }; + + for (const raw of lines) { + const line = raw.trimEnd(); + if (line.startsWith('## ')) { + flushList(); + nodes.push( +

+ {line.slice(3).trim()} +

, + ); + continue; + } + if (line.startsWith('- ') || line.startsWith('* ')) { + listItems.push(line.slice(2).trim()); + continue; + } + if (line === '') { + flushList(); + continue; + } + flushList(); + nodes.push( +

+ {line} +

, + ); + } + flushList(); + return nodes; +} + +function renderPreBlock(text: string, key: string): React.ReactNode { + return ( +
{text}
+ ); +} + +function asRecord(v: unknown): Record | null { + return v != null && typeof v === 'object' && !Array.isArray(v) + ? v as Record + : null; +} + +const Ta4jDiagnosticsSummary: React.FC<{ diagnostics: Record }> = ({ diagnostics }) => { + const [open, setOpen] = useState(false); + const entry = asRecord(diagnostics.entryRule); + const exit = asRecord(diagnostics.exitRule); + const cmp = asRecord(diagnostics.frontendVsBackend); + const traceEntry = Array.isArray(entry?.evaluationTraceLog) + ? (entry.evaluationTraceLog as string[]).slice(0, 8) + : []; + const traceExit = Array.isArray(exit?.evaluationTraceLog) + ? (exit.evaluationTraceLog as string[]).slice(0, 8) + : []; + + return ( +
+ + {open && ( +
+ {typeof diagnostics.error === 'string' && ( +

{diagnostics.error}

+ )} +
+ evalBarIndex: {String(diagnostics.evalBarIndex ?? '—')} + 매수 Rule: {entry?.satisfiedAtEvalIndex != null ? String(entry.satisfiedAtEvalIndex) : '—'} + 매도 Rule: {exit?.satisfiedAtEvalIndex != null ? String(exit.satisfiedAtEvalIndex) : '—'} +
+ {cmp && ( +

+ FE 매수/매도: {String(cmp.frontendOverallEntryMet)} / {String(cmp.frontendOverallExitMet)} + {' · '} + BE 매수/매도: {String(cmp.backendOverallEntryMet)} / {String(cmp.backendOverallExitMet)} +

+ )} + {traceEntry.length > 0 && ( + <> +

매수 Rule trace

+ {renderPreBlock(traceEntry.join('\n'), 'trace-entry')} + + )} + {traceExit.length > 0 && ( + <> +

매도 Rule trace

+ {renderPreBlock(traceExit.join('\n'), 'trace-exit')} + + )} +
+ )} +
+ ); +}; + +const FixPointsPanel: React.FC<{ + bundle: AiVerifyFixPointsBundle; +}> = ({ bundle }) => { + const [copied, setCopied] = useState(false); + const [expandedId, setExpandedId] = useState( + bundle.points[0]?.id ?? null, + ); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(bundle.cursorPrompt); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + window.prompt('아래 내용을 복사해 Cursor에 붙여넣으세요:', bundle.cursorPrompt); + } + }, [bundle.cursorPrompt]); + + return ( +
+
+
+

+ 수정 포인트 (Cursor AI) +

+

+ 로직 불일치가 감지되었습니다. 각 항목의 평가 상황·설정·결과를 Cursor에 붙여넣어 원인 분석 및 수정을 요청할 수 있습니다. +

+ {bundle.verdictHint && ( +

AI 판정: {bundle.verdictHint}

+ )} +
+ +
+ +
+ {bundle.points.map((point, idx) => { + const open = expandedId === point.id; + return ( +
+ + {open && ( +
+
+

현재 평가 상황

+ {renderPreBlock(point.situation, `${point.id}-sit`)} +
+
+

설정 및 예상 결과

+ {renderPreBlock(point.settingsAndExpected, `${point.id}-exp`)} +
+
+

현재 결과

+ {renderPreBlock(point.actualResult, `${point.id}-act`)} +
+
+ )} +
+ ); + })} +
+
+ ); +}; + +const StrategyEvaluationAiVerifyModal: React.FC = ({ + open, + onClose, + loading, + error, + contextSummary, + verifyContext, + result, + onRetry, +}) => { + const bodyRef = useRef(null); + + const fixPoints = useMemo( + () => (verifyContext && result + ? buildAiVerifyFixPoints(verifyContext, result.analysis) + : null), + [verifyContext, result], + ); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + useEffect(() => { + if (open && result && bodyRef.current) { + bodyRef.current.scrollTop = 0; + } + }, [open, result]); + + const handleBackdrop = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }, [onClose]); + + if (!open) return null; + + const content = ( +
+
e.stopPropagation()} + > +
+
+

+ AI 전략평가 검증 +

+ {contextSummary && ( +

{contextSummary}

+ )} +
+ +
+ +
+ {loading && ( +
+ +

LLM이 평가 결과와 차트 시그널을 분석 중입니다…

+

최대 2분 정도 걸릴 수 있습니다.

+
+ )} + + {!loading && error && ( +
+

{error}

+ {onRetry && ( + + )} +
+ )} + + {!loading && !error && result && ( + <> +
+ {result.model} + {(result.latencyMs / 1000).toFixed(1)}초 +
+
+ {renderAnalysisMarkdown(result.analysis)} +
+ {result.ta4jDiagnostics && ( + + )} + {fixPoints && fixPoints.hasIssue && ( + + )} + + )} +
+ +
+

+ ※ AI 분석은 참고용입니다. 수정 포인트는 Cursor 등에 붙여넣어 코드 수정을 요청할 수 있습니다. +

+ +
+
+
+ ); + + return createPortal(content, document.body); +}; + +export default StrategyEvaluationAiVerifyModal; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index db503dc..851ff49 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -91,6 +91,10 @@ interface Props { onOpenReport?: () => void; reportDisabled?: boolean; reportLoading?: boolean; + /** AI 전략평가 검증 (LLM) */ + onOpenAiVerify?: () => void; + aiVerifyDisabled?: boolean; + aiVerifyLoading?: boolean; /** StrategyEvaluationPage 공유 ticker (종목 탭·검색과 단일 useMarketTicker) */ marketTickers?: Map; marketInfos?: MarketInfo[]; @@ -133,6 +137,9 @@ const StrategyEvaluationChart: React.FC = ({ onOpenReport, reportDisabled = false, reportLoading = false, + onOpenAiVerify, + aiVerifyDisabled = false, + aiVerifyLoading = false, marketTickers, marketInfos, marketLoading, @@ -611,6 +618,26 @@ const StrategyEvaluationChart: React.FC = ({ )}
+ {onOpenAiVerify && ( + + )} {onOpenReport && (