llm 분석기능
This commit is contained in:
@@ -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",
|
"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_general", "settings_chart", "settings_indicators", "settings_backtest",
|
||||||
"settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live",
|
"settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live",
|
||||||
"settings_trend-search",
|
"settings_trend-search", "settings_llm",
|
||||||
"settings_alert", "settings_network", "settings_admin"
|
"settings_alert", "settings_network", "settings_admin"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, String> 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<String, String> 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 연결 테스트 중 오류가 발생했습니다."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -273,6 +273,11 @@ public class GcAppSettings {
|
|||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
private String trendSearchSettingsJson;
|
private String trendSearchSettingsJson;
|
||||||
|
|
||||||
|
/** LLM(AI 검증) 연결·모델 설정 JSON */
|
||||||
|
@Column(name = "llm_settings_json", columnDefinition = "JSON")
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
private String llmSettingsJson;
|
||||||
|
|
||||||
/** 편집기·팔레트·패널 크기·가상투자 목록 등 UI 설정 통합 JSON */
|
/** 편집기·팔레트·패널 크기·가상투자 목록 등 UI 설정 통합 JSON */
|
||||||
@Column(name = "ui_preferences_json", columnDefinition = "JSON")
|
@Column(name = "ui_preferences_json", columnDefinition = "JSON")
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ public class AppSettingsService {
|
|||||||
if (d.containsKey("trendSearchSettings")) {
|
if (d.containsKey("trendSearchSettings")) {
|
||||||
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
|
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
|
||||||
}
|
}
|
||||||
|
if (d.containsKey("llmSettings")) {
|
||||||
|
s.setLlmSettingsJson(toJson(d.get("llmSettings")));
|
||||||
|
}
|
||||||
if (d.containsKey("uiPreferences")) {
|
if (d.containsKey("uiPreferences")) {
|
||||||
s.setUiPreferencesJson(toJson(d.get("uiPreferences")));
|
s.setUiPreferencesJson(toJson(d.get("uiPreferences")));
|
||||||
}
|
}
|
||||||
@@ -254,6 +257,7 @@ public class AppSettingsService {
|
|||||||
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
||||||
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
|
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
|
||||||
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
|
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
|
||||||
|
m.put("llmSettings", parseJson(s.getLlmSettingsJson()));
|
||||||
m.put("uiPreferences", parseJson(s.getUiPreferencesJson()));
|
m.put("uiPreferences", parseJson(s.getUiPreferencesJson()));
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -671,6 +671,22 @@ public class LiveConditionStatusService {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AI 검증 Ta4j 진단 — DB 지표 파라미터 + 오버라이드 병합 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<String, Map<String, Object>> mergeIndicatorParamsForDevice(
|
||||||
|
Long userId, String deviceId,
|
||||||
|
Map<String, Map<String, Object>> override) {
|
||||||
|
return mergeIndicatorParams(
|
||||||
|
indicatorSettingsService.getAll(userId, deviceId),
|
||||||
|
override);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 검증 Ta4j 진단 — 지표 visual 설정 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<String, Map<String, Object>> getAllVisualForDevice(Long userId, String deviceId) {
|
||||||
|
return indicatorSettingsService.getAllVisual(userId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 차트 봉 구간을 스캔하여 매수/매도 시그널 목록을 반환한다.
|
* 차트 봉 구간을 스캔하여 매수/매도 시그널 목록을 반환한다.
|
||||||
* {@link #evaluateOnChartBars} 의 {@code overallEntryMet}/{@code overallExitMet} 와
|
* {@link #evaluateOnChartBars} 의 {@code overallEntryMet}/{@code overallExitMet} 와
|
||||||
|
|||||||
@@ -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<String, Object> user = parseUserSettings(entity.getLlmSettingsJson());
|
||||||
|
return merge(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> 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<String, Object> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+273
@@ -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<OhlcvBar> chartBars = parseChartBars(chart);
|
||||||
|
Map<String, Map<String, Object>> 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<GcStrategy> 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<String, Map<String, Object>> params = mergeParamsFromStatus(userId, deviceId, indicatorParams);
|
||||||
|
Map<String, Map<String, Object>> visual = liveConditionStatusService.getAllVisualForDevice(userId, deviceId);
|
||||||
|
|
||||||
|
BarSeries primarySeries = OhlcvBarSeriesSupport.buildSeries(chartBars, OhlcvBarSeriesSupport.normalizeTf(timeframe));
|
||||||
|
Map<String, BarSeries> 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<String> 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<OhlcvBar> 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<OhlcvBar> 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<String, Map<String, Object>> 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<String, Map<String, Object>> mergeParamsFromStatus(
|
||||||
|
Long userId, String deviceId,
|
||||||
|
Map<String, Map<String, Object>> 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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ILoggingEvent> appender;
|
||||||
|
private final Logger rulesLogger;
|
||||||
|
private final Level previousLevel;
|
||||||
|
|
||||||
|
private Ta4jRuleTraceCapture(ListAppender<ILoggingEvent> 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<ILoggingEvent> 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<String> 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) + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ server:
|
|||||||
include-message: always
|
include-message: always
|
||||||
|
|
||||||
goldenchart:
|
goldenchart:
|
||||||
|
llm:
|
||||||
|
base-url: ${GC_LLM_BASE_URL:http://host.docker.internal:16000}
|
||||||
cors:
|
cors:
|
||||||
allowed-origins:
|
allowed-origins:
|
||||||
- "*"
|
- "*"
|
||||||
|
|||||||
@@ -133,6 +133,14 @@ goldenchart:
|
|||||||
user: ${GC_DESKTOP_JENKINS_USER:}
|
user: ${GC_DESKTOP_JENKINS_USER:}
|
||||||
token: ${GC_DESKTOP_JENKINS_TOKEN:}
|
token: ${GC_DESKTOP_JENKINS_TOKEN:}
|
||||||
trigger-token: ${GC_DESKTOP_JENKINS_TRIGGER_TOKEN:goldenchart-desktop-build}
|
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:
|
cors:
|
||||||
allowed-origins:
|
allowed-origins:
|
||||||
- http://localhost:5173
|
- http://localhost:5173
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -10238,6 +10238,26 @@ html.desktop-client .tmb-logo-version {
|
|||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.stg-btn-test:hover { border-color: var(--accent); color: var(--accent); }
|
.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 {
|
.stg-footer {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useIndicatorSettings } from './hooks/useIndicatorSettings';
|
|||||||
import { getAppSettingsCache, useAppSettings } from './hooks/useAppSettings';
|
import { getAppSettingsCache, useAppSettings } from './hooks/useAppSettings';
|
||||||
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
||||||
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
|
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
|
||||||
|
import { resolveLlmAppSettings } from './utils/llmSettings';
|
||||||
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
||||||
import { TradeNotificationProvider } from './contexts/TradeNotificationContext';
|
import { TradeNotificationProvider } from './contexts/TradeNotificationContext';
|
||||||
import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext';
|
import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext';
|
||||||
@@ -436,6 +437,10 @@ function AppMainContent({
|
|||||||
onTrendSearchSettingsChange={s => saveAppDef({
|
onTrendSearchSettingsChange={s => saveAppDef({
|
||||||
trendSearchSettings: resolveTrendSearchAppSettings(s),
|
trendSearchSettings: resolveTrendSearchAppSettings(s),
|
||||||
})}
|
})}
|
||||||
|
llmSettings={appDefaults.llmSettings}
|
||||||
|
onLlmSettingsChange={s => saveAppDef({
|
||||||
|
llmSettings: resolveLlmAppSettings(s),
|
||||||
|
})}
|
||||||
tradingMode={appDefaults.tradingMode}
|
tradingMode={appDefaults.tradingMode}
|
||||||
onTradingMode={v => saveAppDef({ tradingMode: v })}
|
onTradingMode={v => saveAppDef({ tradingMode: v })}
|
||||||
liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled}
|
liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ import {
|
|||||||
} from '../utils/tradeAlertPopupLayout';
|
} from '../utils/tradeAlertPopupLayout';
|
||||||
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
|
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
|
||||||
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
|
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
|
||||||
|
import LlmSettingsPanel from './settings/LlmSettingsPanel';
|
||||||
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
|
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
|
||||||
|
import type { LlmAppSettings } from '../utils/llmSettings';
|
||||||
import { isTradingSettingsCategory } from '../utils/tradingAccess';
|
import { isTradingSettingsCategory } from '../utils/tradingAccess';
|
||||||
import {
|
import {
|
||||||
CHART_LEGEND_SETTING_ITEMS,
|
CHART_LEGEND_SETTING_ITEMS,
|
||||||
@@ -176,6 +178,8 @@ interface SettingsPageProps {
|
|||||||
onVerificationIssueNotify?: (v: boolean) => void;
|
onVerificationIssueNotify?: (v: boolean) => void;
|
||||||
trendSearchSettings?: TrendSearchAppSettings;
|
trendSearchSettings?: TrendSearchAppSettings;
|
||||||
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
|
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
|
||||||
|
llmSettings?: LlmAppSettings;
|
||||||
|
onLlmSettingsChange?: (s: LlmAppSettings) => void;
|
||||||
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
|
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
|
||||||
initialCategory?: SettingsCategoryId;
|
initialCategory?: SettingsCategoryId;
|
||||||
/** 정식 로그인 여부 (게스트는 매매·전략 설정 탭 차단) */
|
/** 정식 로그인 여부 (게스트는 매매·전략 설정 탭 차단) */
|
||||||
@@ -277,6 +281,13 @@ const IcTrendSearch = () => (
|
|||||||
<circle cx="18" cy="6" r="2" fill="currentColor" stroke="none"/>
|
<circle cx="18" cy="6" r="2" fill="currentColor" stroke="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
const IcLlm = () => (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 3l1.2 3.6L16 8l-3.8 1.4L11 13l-1.2-3.6L6 8l3.8-1.4L11 3z"/>
|
||||||
|
<path d="M17 14l.8 2.2L20 17l-2.2.8L17 20l-.8-2.2L14 17l2.2-.8L17 14z"/>
|
||||||
|
<rect x="3" y="15" width="6" height="4" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
const IcBacktest = () => (
|
const IcBacktest = () => (
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polyline points="2,17 6,11 9,14 13,7 16,10 20,4"/>
|
<polyline points="2,17 6,11 9,14 13,7 16,10 20,4"/>
|
||||||
@@ -304,6 +315,7 @@ const ALL_CATEGORIES: Category[] = [
|
|||||||
{ id: 'trading', label: '매매설정', icon: <IcTrading />, desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' },
|
{ id: 'trading', label: '매매설정', icon: <IcTrading />, desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' },
|
||||||
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
|
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
|
||||||
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
|
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
|
||||||
|
{ id: 'llm', label: 'LLM 설정', icon: <IcLlm />, desc: 'AI 전략 검증용 LLM 서버 주소·포트·모델·타임아웃' },
|
||||||
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
|
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
|
||||||
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
|
||||||
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
|
||||||
@@ -2008,6 +2020,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onVerificationIssueNotify,
|
onVerificationIssueNotify,
|
||||||
trendSearchSettings,
|
trendSearchSettings,
|
||||||
onTrendSearchSettingsChange,
|
onTrendSearchSettingsChange,
|
||||||
|
llmSettings,
|
||||||
|
onLlmSettingsChange,
|
||||||
initialCategory,
|
initialCategory,
|
||||||
isFormalLogin = true,
|
isFormalLogin = true,
|
||||||
onRequireFormalLogin,
|
onRequireFormalLogin,
|
||||||
@@ -2180,6 +2194,16 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
<p className="stg-ind-unavailable">추세검색 설정을 불러올 수 없습니다.</p>
|
<p className="stg-ind-unavailable">추세검색 설정을 불러올 수 없습니다.</p>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
case 'llm': return (
|
||||||
|
llmSettings && onLlmSettingsChange ? (
|
||||||
|
<LlmSettingsPanel
|
||||||
|
settings={llmSettings}
|
||||||
|
onChange={onLlmSettingsChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="stg-ind-unavailable">LLM 설정을 불러올 수 없습니다.</p>
|
||||||
|
)
|
||||||
|
);
|
||||||
case 'strategy': return (
|
case 'strategy': return (
|
||||||
<StrategyPanel
|
<StrategyPanel
|
||||||
liveMarket={liveMarket}
|
liveMarket={liveMarket}
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'
|
|||||||
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
|
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
|
||||||
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
|
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
|
||||||
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
|
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
|
||||||
|
import StrategyEvaluationAiVerifyModal from './strategyEvaluation/StrategyEvaluationAiVerifyModal';
|
||||||
|
import {
|
||||||
|
buildStrategyEvaluationAiVerifyContext,
|
||||||
|
requestStrategyEvaluationAiVerify,
|
||||||
|
summarizeVerifyContext,
|
||||||
|
} from '../utils/strategyEvaluationAiVerify';
|
||||||
|
import type { StrategyEvaluationAiVerifyResponse } from '../utils/backendApi';
|
||||||
|
|
||||||
const LEFT_KEY = 'seval-left-width';
|
const LEFT_KEY = 'seval-left-width';
|
||||||
const LEFT_OPEN_KEY = 'seval-left-open';
|
const LEFT_OPEN_KEY = 'seval-left-open';
|
||||||
@@ -92,6 +99,13 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
|
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
|
||||||
const [reportLoading, setReportLoading] = useState(false);
|
const [reportLoading, setReportLoading] = useState(false);
|
||||||
|
const [aiVerifyOpen, setAiVerifyOpen] = useState(false);
|
||||||
|
const [aiVerifyLoading, setAiVerifyLoading] = useState(false);
|
||||||
|
const [aiVerifyError, setAiVerifyError] = useState<string | null>(null);
|
||||||
|
const [aiVerifyResult, setAiVerifyResult] = useState<StrategyEvaluationAiVerifyResponse | null>(null);
|
||||||
|
const [aiVerifyContext, setAiVerifyContext] = useState<import('../utils/strategyEvaluationAiVerify').StrategyEvaluationAiVerifyContext | null>(null);
|
||||||
|
const [aiVerifySummary, setAiVerifySummary] = useState<string | null>(null);
|
||||||
|
const aiVerifyGenRef = useRef(0);
|
||||||
const reportCacheKeyRef = useRef<string | null>(null);
|
const reportCacheKeyRef = useRef<string | null>(null);
|
||||||
const reportGenRef = useRef(0);
|
const reportGenRef = useRef(0);
|
||||||
const evalSessionRef = 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 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 () => {
|
const handleOpenReport = useCallback(async () => {
|
||||||
if (reportDisabled || !selectedStrategyId || !selectedStrategy) return;
|
if (reportDisabled || !selectedStrategyId || !selectedStrategy) return;
|
||||||
|
|
||||||
@@ -618,6 +706,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
onOpenReport={() => { void handleOpenReport(); }}
|
onOpenReport={() => { void handleOpenReport(); }}
|
||||||
reportDisabled={reportDisabled}
|
reportDisabled={reportDisabled}
|
||||||
reportLoading={reportLoading}
|
reportLoading={reportLoading}
|
||||||
|
onOpenAiVerify={handleOpenAiVerify}
|
||||||
|
aiVerifyDisabled={aiVerifyDisabled}
|
||||||
|
aiVerifyLoading={aiVerifyLoading && aiVerifyOpen}
|
||||||
marketTickers={marketFeed.tickers}
|
marketTickers={marketFeed.tickers}
|
||||||
marketInfos={marketFeed.marketInfos}
|
marketInfos={marketFeed.marketInfos}
|
||||||
marketLoading={marketFeed.loading}
|
marketLoading={marketFeed.loading}
|
||||||
@@ -711,6 +802,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
onClose={() => setReportOpen(false)}
|
onClose={() => setReportOpen(false)}
|
||||||
model={reportModel}
|
model={reportModel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StrategyEvaluationAiVerifyModal
|
||||||
|
open={aiVerifyOpen}
|
||||||
|
onClose={handleCloseAiVerify}
|
||||||
|
loading={aiVerifyLoading}
|
||||||
|
error={aiVerifyError}
|
||||||
|
contextSummary={aiVerifySummary}
|
||||||
|
verifyContext={aiVerifyContext}
|
||||||
|
result={aiVerifyResult}
|
||||||
|
onRetry={() => { void runAiVerify(); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<div className="stg-row">
|
||||||
|
<div className="stg-row-label">
|
||||||
|
<span className="stg-row-title">{label}</span>
|
||||||
|
{desc && <span className="stg-row-desc">{desc}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="stg-row-ctrl">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||||
|
title, children,
|
||||||
|
}) => (
|
||||||
|
<div className="stg-section">
|
||||||
|
<div className="stg-section-title">{title}</div>
|
||||||
|
<div className="stg-section-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LlmSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
const patch = (p: Partial<LlmAppSettings>) => {
|
||||||
|
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 (
|
||||||
|
<div className="llm-settings-panel">
|
||||||
|
<SettingSection title="LLM 사용">
|
||||||
|
<SettingRow
|
||||||
|
label="AI 검증 사용"
|
||||||
|
desc="전략 평가 화면의 AI 검증 기능에서 LLM을 사용합니다. 끄면 AI 검증 버튼이 동작하지 않습니다."
|
||||||
|
>
|
||||||
|
<label className="stg-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled}
|
||||||
|
onChange={e => patch({ enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="stg-toggle-slider" />
|
||||||
|
</label>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection title="서버 연결">
|
||||||
|
<SettingRow label="호스트" desc="LLM API 서버 IP 또는 도메인 (Docker 환경에서는 host.docker.internal 등)">
|
||||||
|
<input
|
||||||
|
className="stg-input stg-input--wide"
|
||||||
|
type="text"
|
||||||
|
value={settings.host}
|
||||||
|
onChange={e => patch({ host: e.target.value })}
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="포트" desc="LLM API 서버 포트 (MLX 기본 16000)">
|
||||||
|
<input
|
||||||
|
className="stg-input stg-input--num"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={settings.port}
|
||||||
|
onChange={e => patch({ port: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="HTTPS" desc="보안 연결(HTTPS) 사용 여부">
|
||||||
|
<label className="stg-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.useHttps}
|
||||||
|
onChange={e => patch({ useHttps: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="stg-toggle-slider" />
|
||||||
|
</label>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="API 경로" desc="OpenAI 호환 chat completions 엔드포인트 경로">
|
||||||
|
<input
|
||||||
|
className="stg-input stg-input--wide"
|
||||||
|
type="text"
|
||||||
|
value={settings.chatPath}
|
||||||
|
onChange={e => patch({ chatPath: e.target.value })}
|
||||||
|
placeholder="/v1/chat/completions"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="연결 URL 미리보기" desc="저장 후 백엔드가 이 주소로 LLM에 요청합니다.">
|
||||||
|
<code className="stg-code-preview">{endpointPreview}</code>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="" desc="">
|
||||||
|
<div className="stg-row-inline">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stg-btn-test"
|
||||||
|
disabled={testing || !settings.enabled}
|
||||||
|
onClick={handleTest}
|
||||||
|
>
|
||||||
|
{testing ? '테스트 중…' : '연결 테스트'}
|
||||||
|
</button>
|
||||||
|
{testResult && (
|
||||||
|
<span className={`stg-badge ${testResult.ok ? 'stg-badge--ok' : 'stg-badge--err'}`}>
|
||||||
|
{testResult.ok ? '● ' : '● '}{testResult.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection title="모델·생성 옵션">
|
||||||
|
<SettingRow label="모델" desc="LLM 서버에 등록된 모델 ID">
|
||||||
|
<input
|
||||||
|
className="stg-input stg-input--wide"
|
||||||
|
type="text"
|
||||||
|
value={settings.model}
|
||||||
|
onChange={e => patch({ model: e.target.value })}
|
||||||
|
placeholder="mlx-community/Qwen2.5-7B-Instruct-4bit"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="최대 토큰" desc="한 번에 생성할 최대 토큰 수 (64~8192)">
|
||||||
|
<input
|
||||||
|
className="stg-input stg-input--num"
|
||||||
|
type="number"
|
||||||
|
min={64}
|
||||||
|
max={8192}
|
||||||
|
step={64}
|
||||||
|
value={settings.maxTokens}
|
||||||
|
onChange={e => patch({ maxTokens: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Temperature" desc="낮을수록 일관된 답변 (0~2, 권장 0.1)">
|
||||||
|
<input
|
||||||
|
className="stg-input stg-input--num"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.05}
|
||||||
|
value={settings.temperature}
|
||||||
|
onChange={e => patch({ temperature: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="타임아웃 (ms)" desc={`요청 대기 시간 (5초~10분). Base URL: ${baseUrlPreview}`}>
|
||||||
|
<input
|
||||||
|
className="stg-input stg-input--num"
|
||||||
|
type="number"
|
||||||
|
min={5000}
|
||||||
|
max={600000}
|
||||||
|
step={1000}
|
||||||
|
value={settings.timeoutMs}
|
||||||
|
onChange={e => patch({ timeoutMs: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LlmSettingsPanel;
|
||||||
@@ -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(
|
||||||
|
<ul key={`ul-${nodes.length}`} className="seval-ai-verify-list">
|
||||||
|
{listItems.map((item, i) => (
|
||||||
|
<li key={i}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
listItems = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trimEnd();
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
flushList();
|
||||||
|
nodes.push(
|
||||||
|
<h3 key={`h-${nodes.length}`} className="seval-ai-verify-h3">
|
||||||
|
{line.slice(3).trim()}
|
||||||
|
</h3>,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
|
listItems.push(line.slice(2).trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === '') {
|
||||||
|
flushList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushList();
|
||||||
|
nodes.push(
|
||||||
|
<p key={`p-${nodes.length}`} className="seval-ai-verify-p">
|
||||||
|
{line}
|
||||||
|
</p>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
flushList();
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreBlock(text: string, key: string): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<pre key={key} className="seval-ai-verify-pre">{text}</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||||
|
return v != null && typeof v === 'object' && !Array.isArray(v)
|
||||||
|
? v as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ta4jDiagnosticsSummary: React.FC<{ diagnostics: Record<string, unknown> }> = ({ 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 (
|
||||||
|
<section className="seval-ai-verify-ta4j">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-ta4j-toggle"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
>
|
||||||
|
<span>Ta4j 서버 재평가 (Rule·trace)</span>
|
||||||
|
<span aria-hidden>{open ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="seval-ai-verify-ta4j-body">
|
||||||
|
{typeof diagnostics.error === 'string' && (
|
||||||
|
<p className="seval-ai-verify-ta4j-err">{diagnostics.error}</p>
|
||||||
|
)}
|
||||||
|
<div className="seval-ai-verify-ta4j-grid">
|
||||||
|
<span>evalBarIndex: {String(diagnostics.evalBarIndex ?? '—')}</span>
|
||||||
|
<span>매수 Rule: {entry?.satisfiedAtEvalIndex != null ? String(entry.satisfiedAtEvalIndex) : '—'}</span>
|
||||||
|
<span>매도 Rule: {exit?.satisfiedAtEvalIndex != null ? String(exit.satisfiedAtEvalIndex) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
{cmp && (
|
||||||
|
<p className="seval-ai-verify-ta4j-cmp">
|
||||||
|
FE 매수/매도: {String(cmp.frontendOverallEntryMet)} / {String(cmp.frontendOverallExitMet)}
|
||||||
|
{' · '}
|
||||||
|
BE 매수/매도: {String(cmp.backendOverallEntryMet)} / {String(cmp.backendOverallExitMet)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{traceEntry.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h4 className="seval-ai-verify-ta4j-trace-title">매수 Rule trace</h4>
|
||||||
|
{renderPreBlock(traceEntry.join('\n'), 'trace-entry')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{traceExit.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h4 className="seval-ai-verify-ta4j-trace-title">매도 Rule trace</h4>
|
||||||
|
{renderPreBlock(traceExit.join('\n'), 'trace-exit')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FixPointsPanel: React.FC<{
|
||||||
|
bundle: AiVerifyFixPointsBundle;
|
||||||
|
}> = ({ bundle }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(
|
||||||
|
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 (
|
||||||
|
<section className="seval-ai-verify-fixpoints" aria-labelledby="seval-ai-fixpoints-title">
|
||||||
|
<div className="seval-ai-verify-fixpoints-head">
|
||||||
|
<div>
|
||||||
|
<h3 id="seval-ai-fixpoints-title" className="seval-ai-verify-fixpoints-title">
|
||||||
|
수정 포인트 (Cursor AI)
|
||||||
|
</h3>
|
||||||
|
<p className="seval-ai-verify-fixpoints-desc">
|
||||||
|
로직 불일치가 감지되었습니다. 각 항목의 평가 상황·설정·결과를 Cursor에 붙여넣어 원인 분석 및 수정을 요청할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
{bundle.verdictHint && (
|
||||||
|
<p className="seval-ai-verify-fixpoints-verdict">AI 판정: {bundle.verdictHint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-copy-btn"
|
||||||
|
onClick={() => { void handleCopy(); }}
|
||||||
|
>
|
||||||
|
{copied ? '복사됨 ✓' : '전체 복사'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="seval-ai-verify-fixpoints-list">
|
||||||
|
{bundle.points.map((point, idx) => {
|
||||||
|
const open = expandedId === point.id;
|
||||||
|
return (
|
||||||
|
<article key={point.id} className="seval-ai-verify-fixpoint">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-fixpoint-toggle"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setExpandedId(open ? null : point.id)}
|
||||||
|
>
|
||||||
|
<span className="seval-ai-verify-fixpoint-num">{idx + 1}</span>
|
||||||
|
<span className="seval-ai-verify-fixpoint-title">{point.title}</span>
|
||||||
|
<span className="seval-ai-verify-fixpoint-chevron" aria-hidden>{open ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="seval-ai-verify-fixpoint-body">
|
||||||
|
<div className="seval-ai-verify-fixpoint-block">
|
||||||
|
<h4>현재 평가 상황</h4>
|
||||||
|
{renderPreBlock(point.situation, `${point.id}-sit`)}
|
||||||
|
</div>
|
||||||
|
<div className="seval-ai-verify-fixpoint-block">
|
||||||
|
<h4>설정 및 예상 결과</h4>
|
||||||
|
{renderPreBlock(point.settingsAndExpected, `${point.id}-exp`)}
|
||||||
|
</div>
|
||||||
|
<div className="seval-ai-verify-fixpoint-block">
|
||||||
|
<h4>현재 결과</h4>
|
||||||
|
{renderPreBlock(point.actualResult, `${point.id}-act`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StrategyEvaluationAiVerifyModal: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
contextSummary,
|
||||||
|
verifyContext,
|
||||||
|
result,
|
||||||
|
onRetry,
|
||||||
|
}) => {
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(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 = (
|
||||||
|
<div
|
||||||
|
className="seval-ai-verify-overlay"
|
||||||
|
role="presentation"
|
||||||
|
onClick={handleBackdrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="seval-ai-verify-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="seval-ai-verify-title"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="seval-ai-verify-head">
|
||||||
|
<div className="seval-ai-verify-head-main">
|
||||||
|
<h2 id="seval-ai-verify-title" className="seval-ai-verify-title">
|
||||||
|
AI 전략평가 검증
|
||||||
|
</h2>
|
||||||
|
{contextSummary && (
|
||||||
|
<p className="seval-ai-verify-summary">{contextSummary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-close"
|
||||||
|
aria-label="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div ref={bodyRef} className="seval-ai-verify-body">
|
||||||
|
{loading && (
|
||||||
|
<div className="seval-ai-verify-loading" aria-live="polite">
|
||||||
|
<span className="btd-run-spinner seval-ai-verify-spinner" aria-hidden />
|
||||||
|
<p>LLM이 평가 결과와 차트 시그널을 분석 중입니다…</p>
|
||||||
|
<p className="seval-ai-verify-loading-hint">최대 2분 정도 걸릴 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<div className="seval-ai-verify-error" role="alert">
|
||||||
|
<p>{error}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button type="button" className="seval-ai-verify-retry" onClick={onRetry}>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && result && (
|
||||||
|
<>
|
||||||
|
<div className="seval-ai-verify-meta">
|
||||||
|
<span>{result.model}</span>
|
||||||
|
<span>{(result.latencyMs / 1000).toFixed(1)}초</span>
|
||||||
|
</div>
|
||||||
|
<div className="seval-ai-verify-analysis">
|
||||||
|
{renderAnalysisMarkdown(result.analysis)}
|
||||||
|
</div>
|
||||||
|
{result.ta4jDiagnostics && (
|
||||||
|
<Ta4jDiagnosticsSummary diagnostics={result.ta4jDiagnostics} />
|
||||||
|
)}
|
||||||
|
{fixPoints && fixPoints.hasIssue && (
|
||||||
|
<FixPointsPanel bundle={fixPoints} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="seval-ai-verify-foot">
|
||||||
|
<p className="seval-ai-verify-footnote">
|
||||||
|
※ AI 분석은 참고용입니다. 수정 포인트는 Cursor 등에 붙여넣어 코드 수정을 요청할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="seval-ai-verify-close-btn" onClick={onClose}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StrategyEvaluationAiVerifyModal;
|
||||||
@@ -91,6 +91,10 @@ interface Props {
|
|||||||
onOpenReport?: () => void;
|
onOpenReport?: () => void;
|
||||||
reportDisabled?: boolean;
|
reportDisabled?: boolean;
|
||||||
reportLoading?: boolean;
|
reportLoading?: boolean;
|
||||||
|
/** AI 전략평가 검증 (LLM) */
|
||||||
|
onOpenAiVerify?: () => void;
|
||||||
|
aiVerifyDisabled?: boolean;
|
||||||
|
aiVerifyLoading?: boolean;
|
||||||
/** StrategyEvaluationPage 공유 ticker (종목 탭·검색과 단일 useMarketTicker) */
|
/** StrategyEvaluationPage 공유 ticker (종목 탭·검색과 단일 useMarketTicker) */
|
||||||
marketTickers?: Map<string, TickerData>;
|
marketTickers?: Map<string, TickerData>;
|
||||||
marketInfos?: MarketInfo[];
|
marketInfos?: MarketInfo[];
|
||||||
@@ -133,6 +137,9 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
onOpenReport,
|
onOpenReport,
|
||||||
reportDisabled = false,
|
reportDisabled = false,
|
||||||
reportLoading = false,
|
reportLoading = false,
|
||||||
|
onOpenAiVerify,
|
||||||
|
aiVerifyDisabled = false,
|
||||||
|
aiVerifyLoading = false,
|
||||||
marketTickers,
|
marketTickers,
|
||||||
marketInfos,
|
marketInfos,
|
||||||
marketLoading,
|
marketLoading,
|
||||||
@@ -611,6 +618,26 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="seval-chart-toolbar-actions">
|
<div className="seval-chart-toolbar-actions">
|
||||||
|
{onOpenAiVerify && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btd-analysis-tool seval-chart-ai-verify-btn"
|
||||||
|
title={aiVerifyDisabled ? '전략·차트·봉 평가 데이터를 준비하세요' : 'AI 전략평가·시그널 검증'}
|
||||||
|
aria-label="AI 검증"
|
||||||
|
disabled={aiVerifyDisabled || aiVerifyLoading}
|
||||||
|
onClick={onOpenAiVerify}
|
||||||
|
>
|
||||||
|
{aiVerifyLoading ? (
|
||||||
|
<span className="btd-run-spinner seval-chart-ai-verify-spinner" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||||
|
<path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z" />
|
||||||
|
<path d="M5 19l1 2 2 1-2 1-1 2-1-2-2-1 1-2z" />
|
||||||
|
<path d="M19 13l.75 1.5 1.5.75-1.5.75-.75 1.5-.75-1.5L17.5 15l1.5-.75.75-1.5z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onOpenReport && (
|
{onOpenReport && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ import {
|
|||||||
resolveTrendSearchAppSettings,
|
resolveTrendSearchAppSettings,
|
||||||
type TrendSearchAppSettings,
|
type TrendSearchAppSettings,
|
||||||
} from '../utils/trendSearchAppSettings';
|
} from '../utils/trendSearchAppSettings';
|
||||||
|
import {
|
||||||
|
resolveLlmAppSettings,
|
||||||
|
type LlmAppSettings,
|
||||||
|
} from '../utils/llmSettings';
|
||||||
import {
|
import {
|
||||||
resolveChartPaneSeparatorOptions,
|
resolveChartPaneSeparatorOptions,
|
||||||
type ChartPaneSeparatorOptions,
|
type ChartPaneSeparatorOptions,
|
||||||
@@ -215,6 +219,9 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
|||||||
trendSearchSettings: resolveTrendSearchAppSettings(
|
trendSearchSettings: resolveTrendSearchAppSettings(
|
||||||
s.trendSearchSettings as Partial<TrendSearchAppSettings> | null | undefined,
|
s.trendSearchSettings as Partial<TrendSearchAppSettings> | null | undefined,
|
||||||
),
|
),
|
||||||
|
llmSettings: resolveLlmAppSettings(
|
||||||
|
s.llmSettings as Partial<LlmAppSettings> | null | undefined,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,381 @@
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-chart-ai-verify-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 85%, var(--se-text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-ai-verify-btn:hover:not(:disabled) {
|
||||||
|
color: var(--btd-gold, var(--se-gold));
|
||||||
|
background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 12%, var(--se-input-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-ai-verify-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AI 검증 모달 ─────────────────────────────────────────────── */
|
||||||
|
.seval-ai-verify-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 12000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-modal {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
max-height: min(88vh, 820px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
background: var(--se-panel-bg, #1a1d24);
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
border-bottom: 1px solid var(--se-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--se-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-summary {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px 18px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 32px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-loading-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-error {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, #ef5350 12%, transparent);
|
||||||
|
color: #ffb4ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-retry {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
background: var(--se-input-bg);
|
||||||
|
color: var(--se-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-h3 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--btd-gold, var(--se-gold));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--se-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-list {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--se-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px 16px;
|
||||||
|
border-top: 1px solid var(--se-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-footnote {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-close-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
background: var(--se-input-bg);
|
||||||
|
color: var(--se-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AI 검증 수정 포인트 (Cursor) ── */
|
||||||
|
.seval-ai-verify-fixpoints {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed var(--se-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoints-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoints-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoints-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoints-verdict {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--se-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-copy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, #ffb74d 45%, var(--se-border));
|
||||||
|
background: color-mix(in srgb, #ffb74d 10%, transparent);
|
||||||
|
color: #ffb74d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-copy-btn:hover {
|
||||||
|
background: color-mix(in srgb, #ffb74d 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoints-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint {
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--se-input-bg) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--se-text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint-toggle:hover {
|
||||||
|
background: color-mix(in srgb, var(--se-border) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint-num {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, #ffb74d 20%, transparent);
|
||||||
|
color: #ffb74d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint-title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint-body {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-fixpoint-block h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
background: var(--se-panel-bg, #14161a);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--se-text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-ta4j {
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-ta4j-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in srgb, var(--se-input-bg) 90%, transparent);
|
||||||
|
color: var(--se-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-ta4j-body {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border-top: 1px solid var(--se-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-ta4j-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-ta4j-cmp {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--se-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-ta4j-err {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #ffb4ab;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-ta4j-trace-title {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.seval-chart-magnifier-btn {
|
.seval-chart-magnifier-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -556,6 +556,8 @@ export interface AppSettingsDto {
|
|||||||
tradeAlertTimeFormat?: string;
|
tradeAlertTimeFormat?: string;
|
||||||
/** 추세검색 기본 설정 */
|
/** 추세검색 기본 설정 */
|
||||||
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||||
|
/** LLM(AI 검증) 연결·모델 설정 */
|
||||||
|
llmSettings?: import('./llmSettings').LlmAppSettings | null;
|
||||||
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
||||||
uiPreferences?: import('../types/uiPreferences').UiPreferences | null;
|
uiPreferences?: import('../types/uiPreferences').UiPreferences | null;
|
||||||
}
|
}
|
||||||
@@ -1676,6 +1678,40 @@ export async function fetchLiveConditionScanSignals(
|
|||||||
return list ?? [];
|
return list ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StrategyEvaluationAiVerifyResponse {
|
||||||
|
analysis: string;
|
||||||
|
model: string;
|
||||||
|
latencyMs: number;
|
||||||
|
/** 서버 Ta4j 재평가·Rule·trace (백엔드 ai-verify 재평가) */
|
||||||
|
ta4jDiagnostics?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전략 평가 — LLM 기반 Rule·시그널 일치 검증 */
|
||||||
|
export async function fetchStrategyEvaluationAiVerify(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): Promise<StrategyEvaluationAiVerifyResponse> {
|
||||||
|
return requestOrThrow<StrategyEvaluationAiVerifyResponse>('/strategy/evaluation/ai-verify', {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
body: JSON.stringify({ context }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmConnectionTestResponse {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
model?: string;
|
||||||
|
latencyMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 설정 화면 — LLM 서버 연결 테스트 (저장된 설정 기준) */
|
||||||
|
export async function fetchLlmConnectionTest(): Promise<LlmConnectionTestResponse> {
|
||||||
|
return requestOrThrow<LlmConnectionTestResponse>('/strategy/evaluation/llm-test', {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
|
/** 전략 DSL에 포함된 평가 시간봉 목록 (실시간 체크·STOMP 구독용) */
|
||||||
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
|
export async function loadStrategyTimeframes(strategyId: number): Promise<string[]> {
|
||||||
if (!hasRegisteredUser()) return ['1m'];
|
if (!hasRegisteredUser()) return ['1m'];
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* LLM(AI 검증) 앱 설정 (gc_app_settings.llm_settings_json)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LlmAppSettings {
|
||||||
|
/** LLM AI 검증 사용 여부 */
|
||||||
|
enabled: boolean;
|
||||||
|
/** LLM 서버 호스트 (IP 또는 도메인) */
|
||||||
|
host: string;
|
||||||
|
/** LLM 서버 포트 */
|
||||||
|
port: number;
|
||||||
|
/** HTTPS 사용 여부 */
|
||||||
|
useHttps: boolean;
|
||||||
|
/** OpenAI 호환 chat completions 경로 */
|
||||||
|
chatPath: string;
|
||||||
|
/** 모델 ID */
|
||||||
|
model: string;
|
||||||
|
/** 최대 생성 토큰 */
|
||||||
|
maxTokens: number;
|
||||||
|
/** temperature (0~2) */
|
||||||
|
temperature: number;
|
||||||
|
/** 요청 타임아웃 (ms) */
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LLM_APP_SETTINGS: LlmAppSettings = {
|
||||||
|
enabled: true,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 16000,
|
||||||
|
useHttps: false,
|
||||||
|
chatPath: '/v1/chat/completions',
|
||||||
|
model: 'mlx-community/Qwen2.5-7B-Instruct-4bit',
|
||||||
|
maxTokens: 1024,
|
||||||
|
temperature: 0.1,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampInt(v: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampFloat(v: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLlmAppSettings(
|
||||||
|
raw?: Partial<LlmAppSettings> | null,
|
||||||
|
): LlmAppSettings {
|
||||||
|
const d = DEFAULT_LLM_APP_SETTINGS;
|
||||||
|
if (!raw || typeof raw !== 'object') return { ...d };
|
||||||
|
const host = typeof raw.host === 'string' && raw.host.trim() ? raw.host.trim() : d.host;
|
||||||
|
const chatPath = typeof raw.chatPath === 'string' && raw.chatPath.trim()
|
||||||
|
? (raw.chatPath.startsWith('/') ? raw.chatPath.trim() : `/${raw.chatPath.trim()}`)
|
||||||
|
: d.chatPath;
|
||||||
|
const model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : d.model;
|
||||||
|
return {
|
||||||
|
enabled: raw.enabled === undefined ? d.enabled : Boolean(raw.enabled),
|
||||||
|
host,
|
||||||
|
port: clampInt(raw.port, 1, 65535, d.port),
|
||||||
|
useHttps: raw.useHttps === undefined ? d.useHttps : Boolean(raw.useHttps),
|
||||||
|
chatPath,
|
||||||
|
model,
|
||||||
|
maxTokens: clampInt(raw.maxTokens, 64, 8192, d.maxTokens),
|
||||||
|
temperature: clampFloat(raw.temperature, 0, 2, d.temperature),
|
||||||
|
timeoutMs: clampInt(raw.timeoutMs, 5_000, 600_000, d.timeoutMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 설정 UI용 — host·port·프로토콜로 base URL 미리보기 */
|
||||||
|
export function buildLlmBaseUrl(settings: LlmAppSettings): string {
|
||||||
|
const scheme = settings.useHttps ? 'https' : 'http';
|
||||||
|
const omitPort = (settings.useHttps && settings.port === 443)
|
||||||
|
|| (!settings.useHttps && settings.port === 80);
|
||||||
|
if (omitPort) return `${scheme}://${settings.host}`;
|
||||||
|
return `${scheme}://${settings.host}:${settings.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLlmEndpointUrl(settings: LlmAppSettings): string {
|
||||||
|
return `${buildLlmBaseUrl(settings)}${settings.chatPath}`;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export type TopMenuId = MenuPage;
|
|||||||
|
|
||||||
export type SettingsCategoryId =
|
export type SettingsCategoryId =
|
||||||
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
|
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
|
||||||
| 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
|
| 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'llm' | 'alert' | 'network' | 'admin';
|
||||||
|
|
||||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||||
'dashboard', 'widget-dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
|
'dashboard', 'widget-dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'strategy-evaluation', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
|
||||||
@@ -17,7 +17,7 @@ export const TOP_MENU_IDS: TopMenuId[] = [
|
|||||||
|
|
||||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||||
'general', 'chart', 'indicators', 'backtest', 'strategy', 'trading', 'live',
|
'general', 'chart', 'indicators', 'backtest', 'strategy', 'trading', 'live',
|
||||||
'trend-search', 'alert', 'network', 'admin',
|
'trend-search', 'llm', 'alert', 'network', 'admin',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ALL_MENU_IDS = [
|
export const ALL_MENU_IDS = [
|
||||||
@@ -52,6 +52,7 @@ export const MENU_LABELS: Record<string, string> = {
|
|||||||
settings_virtual: '설정 · 가상매매',
|
settings_virtual: '설정 · 가상매매',
|
||||||
settings_live: '설정 · 실거래',
|
settings_live: '설정 · 실거래',
|
||||||
'settings_trend-search': '설정 · 추세검색',
|
'settings_trend-search': '설정 · 추세검색',
|
||||||
|
settings_llm: '설정 · LLM',
|
||||||
settings_alert: '설정 · 알림',
|
settings_alert: '설정 · 알림',
|
||||||
settings_network: '설정 · 네트워크',
|
settings_network: '설정 · 네트워크',
|
||||||
settings_admin: '설정 · 관리자',
|
settings_admin: '설정 · 관리자',
|
||||||
|
|||||||
@@ -0,0 +1,698 @@
|
|||||||
|
/**
|
||||||
|
* 전략 평가 AI 검증 — LLM 전달용 컨텍스트 구성 및 API 호출
|
||||||
|
*/
|
||||||
|
import type { BacktestSignal, StrategyDto, StrategyEvaluationAiVerifyResponse } from './backendApi';
|
||||||
|
import { fetchStrategyEvaluationAiVerify } from './backendApi';
|
||||||
|
import type { OHLCVBar } from '../types';
|
||||||
|
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import type { BarSignalHighlight } from './strategyEvaluationBarSignals';
|
||||||
|
import { resolveBarSignalHighlight } from './strategyEvaluationBarSignals';
|
||||||
|
import { formatIndicatorValue } from './virtualStrategyConditions';
|
||||||
|
import { formatVirtualConditionListLabel } from './virtualStrategyConditions';
|
||||||
|
import { buildConditionMetrics } from './virtualSignalMetrics';
|
||||||
|
import { repairUtf8Mojibake } from './textEncoding';
|
||||||
|
import { normalizeEpochSecOptional } from './backtestUiUtils';
|
||||||
|
|
||||||
|
export interface StrategyEvaluationAiVerifyContext {
|
||||||
|
meta: {
|
||||||
|
generatedAt: string;
|
||||||
|
purpose: string;
|
||||||
|
};
|
||||||
|
strategy: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
buyCondition: unknown;
|
||||||
|
sellCondition: unknown;
|
||||||
|
};
|
||||||
|
chart: {
|
||||||
|
market: string;
|
||||||
|
timeframe: string;
|
||||||
|
barCount: number;
|
||||||
|
evaluationBarCount?: number;
|
||||||
|
/** AI 검증 시 서버 Ta4j 재평가용 — 차트와 동일 OHLCV (평가 구간) */
|
||||||
|
barsForEvaluation?: Array<{
|
||||||
|
time: number;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
}>;
|
||||||
|
selectedBarIndex: number;
|
||||||
|
selectedBar: {
|
||||||
|
time: number;
|
||||||
|
timeLabel: string;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
evaluation: {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
matchRate: number | null;
|
||||||
|
overallEntryMet: boolean | null;
|
||||||
|
overallExitMet: boolean | null;
|
||||||
|
buyConditions: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
side: string;
|
||||||
|
timeframe: string;
|
||||||
|
indicatorType: string;
|
||||||
|
conditionType: string;
|
||||||
|
targetValue: number | null;
|
||||||
|
currentValue: number | null;
|
||||||
|
satisfied: boolean | null;
|
||||||
|
thresholdLabel?: string;
|
||||||
|
}>;
|
||||||
|
sellConditions: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
side: string;
|
||||||
|
timeframe: string;
|
||||||
|
indicatorType: string;
|
||||||
|
conditionType: string;
|
||||||
|
targetValue: number | null;
|
||||||
|
currentValue: number | null;
|
||||||
|
satisfied: boolean | null;
|
||||||
|
thresholdLabel?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
signals: {
|
||||||
|
totalOnChart: number;
|
||||||
|
selectedBarHighlight: BarSignalHighlight;
|
||||||
|
selectedBarSignals: Array<{
|
||||||
|
type: string;
|
||||||
|
time: number;
|
||||||
|
price: number;
|
||||||
|
barIndex: number;
|
||||||
|
}>;
|
||||||
|
nearbySignals: Array<{
|
||||||
|
type: string;
|
||||||
|
time: number;
|
||||||
|
barIndex: number;
|
||||||
|
offsetFromSelected: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
indicatorParams?: Record<string, Record<string, unknown>> | null;
|
||||||
|
deterministicHints: {
|
||||||
|
expectedBuySignalAtBar: boolean;
|
||||||
|
expectedSellSignalAtBar: boolean;
|
||||||
|
signalMatchesOverallRule: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { StrategyEvaluationAiVerifyResponse };
|
||||||
|
|
||||||
|
export function buildStrategyEvaluationAiVerifyContext(opts: {
|
||||||
|
strategy: StrategyDto;
|
||||||
|
strategyId: number;
|
||||||
|
market: string;
|
||||||
|
timeframe: string;
|
||||||
|
bars: OHLCVBar[];
|
||||||
|
selectedBarIndex: number;
|
||||||
|
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||||
|
evalLoading: boolean;
|
||||||
|
evalError: string | null;
|
||||||
|
chartSignals: BacktestSignal[];
|
||||||
|
barSignalHighlight: BarSignalHighlight;
|
||||||
|
indicatorParams?: Record<string, Record<string, unknown>> | null;
|
||||||
|
evaluationBarCount?: number;
|
||||||
|
}): StrategyEvaluationAiVerifyContext {
|
||||||
|
const {
|
||||||
|
strategy,
|
||||||
|
strategyId,
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
bars,
|
||||||
|
selectedBarIndex,
|
||||||
|
snapshot,
|
||||||
|
evalLoading,
|
||||||
|
evalError,
|
||||||
|
chartSignals,
|
||||||
|
barSignalHighlight,
|
||||||
|
indicatorParams,
|
||||||
|
evaluationBarCount,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const selectedBar = bars[selectedBarIndex] ?? null;
|
||||||
|
const barTimeSec = selectedBar?.time ?? null;
|
||||||
|
|
||||||
|
const buyMetrics = snapshot?.rows
|
||||||
|
? buildConditionMetrics(snapshot.rows.filter(r => r.side === 'buy'))
|
||||||
|
: [];
|
||||||
|
const sellMetrics = snapshot?.rows
|
||||||
|
? buildConditionMetrics(snapshot.rows.filter(r => r.side === 'sell'))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const mapCondition = (m: typeof buyMetrics[number]) => ({
|
||||||
|
id: m.row.id,
|
||||||
|
label: formatVirtualConditionListLabel(m.row),
|
||||||
|
side: m.row.side,
|
||||||
|
timeframe: m.row.timeframe,
|
||||||
|
indicatorType: m.row.indicatorType,
|
||||||
|
conditionType: m.row.conditionType,
|
||||||
|
targetValue: m.row.targetValue,
|
||||||
|
currentValue: m.row.currentValue,
|
||||||
|
satisfied: m.row.satisfied ?? null,
|
||||||
|
thresholdLabel: m.row.thresholdLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedBarSignals = chartSignals.filter(s =>
|
||||||
|
signalMatchesBar(s, selectedBarIndex, barTimeSec ?? 0),
|
||||||
|
).map(s => ({
|
||||||
|
type: s.type,
|
||||||
|
time: s.time,
|
||||||
|
price: s.price,
|
||||||
|
barIndex: s.barIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nearbySignals = chartSignals
|
||||||
|
.map(s => ({
|
||||||
|
signal: s,
|
||||||
|
offset: s.barIndex - selectedBarIndex,
|
||||||
|
}))
|
||||||
|
.filter(x => Math.abs(x.offset) <= 5 && x.offset !== 0)
|
||||||
|
.sort((a, b) => a.offset - b.offset)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(x => ({
|
||||||
|
type: x.signal.type,
|
||||||
|
time: x.signal.time,
|
||||||
|
barIndex: x.signal.barIndex,
|
||||||
|
offsetFromSelected: x.offset,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const overallEntryMet = snapshot?.overallEntryMet ?? null;
|
||||||
|
const overallExitMet = snapshot?.overallExitMet ?? null;
|
||||||
|
|
||||||
|
const evalCount = evaluationBarCount ?? bars.length;
|
||||||
|
const barsForEvaluation = bars.slice(Math.max(0, bars.length - evalCount)).map(b => ({
|
||||||
|
time: b.time,
|
||||||
|
open: b.open,
|
||||||
|
high: b.high,
|
||||||
|
low: b.low,
|
||||||
|
close: b.close,
|
||||||
|
volume: b.volume,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let signalMatchesOverallRule = '평가 데이터 없음';
|
||||||
|
if (overallEntryMet != null || overallExitMet != null) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (overallEntryMet === true && !barSignalHighlight.buy) {
|
||||||
|
parts.push('매수 Rule 충족인데 선택 봉 BUY 시그널 없음 — 포지션 모드·중복 방지·스캔 범위 확인 필요');
|
||||||
|
}
|
||||||
|
if (overallEntryMet === false && barSignalHighlight.buy) {
|
||||||
|
parts.push('매수 Rule 미충족인데 선택 봉 BUY 시그널 있음 — 불일치 의심');
|
||||||
|
}
|
||||||
|
if (overallExitMet === true && !barSignalHighlight.sell) {
|
||||||
|
parts.push('매도 Rule 충족인데 선택 봉 SELL 시그널 없음 — 포지션·스캔 범위 확인 필요');
|
||||||
|
}
|
||||||
|
if (overallExitMet === false && barSignalHighlight.sell) {
|
||||||
|
parts.push('매도 Rule 미충족인데 선택 봉 SELL 시그널 있음 — 불일치 의심');
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push('전체 Rule과 선택 봉 시그널 표시가 대체로 일치');
|
||||||
|
}
|
||||||
|
signalMatchesOverallRule = parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
purpose: '전략평가 Ta4j Rule 결과와 차트 매매 시그널 일치 여부 검증',
|
||||||
|
},
|
||||||
|
strategy: {
|
||||||
|
id: strategyId,
|
||||||
|
name: repairUtf8Mojibake(strategy.name ?? `전략 #${strategyId}`),
|
||||||
|
buyCondition: strategy.buyCondition ?? null,
|
||||||
|
sellCondition: strategy.sellCondition ?? null,
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
barCount: bars.length,
|
||||||
|
evaluationBarCount,
|
||||||
|
barsForEvaluation,
|
||||||
|
selectedBarIndex,
|
||||||
|
selectedBar: selectedBar
|
||||||
|
? {
|
||||||
|
time: selectedBar.time,
|
||||||
|
timeLabel: new Date(selectedBar.time * 1000).toLocaleString('ko-KR'),
|
||||||
|
open: selectedBar.open,
|
||||||
|
high: selectedBar.high,
|
||||||
|
low: selectedBar.low,
|
||||||
|
close: selectedBar.close,
|
||||||
|
volume: selectedBar.volume,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
evaluation: {
|
||||||
|
loading: evalLoading,
|
||||||
|
error: evalError,
|
||||||
|
matchRate: snapshot?.matchRate ?? null,
|
||||||
|
overallEntryMet,
|
||||||
|
overallExitMet,
|
||||||
|
buyConditions: buyMetrics.map(mapCondition),
|
||||||
|
sellConditions: sellMetrics.map(mapCondition),
|
||||||
|
},
|
||||||
|
signals: {
|
||||||
|
totalOnChart: chartSignals.length,
|
||||||
|
selectedBarHighlight: barSignalHighlight,
|
||||||
|
selectedBarSignals,
|
||||||
|
nearbySignals,
|
||||||
|
},
|
||||||
|
indicatorParams: indicatorParams ?? null,
|
||||||
|
deterministicHints: {
|
||||||
|
expectedBuySignalAtBar: overallEntryMet === true,
|
||||||
|
expectedSellSignalAtBar: overallExitMet === true,
|
||||||
|
signalMatchesOverallRule,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalMatchesBar(
|
||||||
|
signal: BacktestSignal,
|
||||||
|
barIndex: number,
|
||||||
|
barTimeSec: number,
|
||||||
|
): boolean {
|
||||||
|
if (signal.barIndex === barIndex) return true;
|
||||||
|
const signalTime = normalizeEpochSecOptional(signal.time);
|
||||||
|
const barTime = normalizeEpochSecOptional(barTimeSec);
|
||||||
|
return signalTime > 0 && barTime > 0 && signalTime === barTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestStrategyEvaluationAiVerify(
|
||||||
|
context: StrategyEvaluationAiVerifyContext,
|
||||||
|
): Promise<StrategyEvaluationAiVerifyResponse> {
|
||||||
|
return fetchStrategyEvaluationAiVerify(context as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI 요약 — 컨텍스트 미리보기 */
|
||||||
|
export function summarizeVerifyContext(ctx: StrategyEvaluationAiVerifyContext): string {
|
||||||
|
const bar = ctx.chart.selectedBar;
|
||||||
|
const buyOk = ctx.evaluation.overallEntryMet;
|
||||||
|
const sellOk = ctx.evaluation.overallExitMet;
|
||||||
|
return [
|
||||||
|
bar ? `봉 ${bar.timeLabel}` : '봉 미선택',
|
||||||
|
buyOk != null ? `매수 ${buyOk ? '충족' : '미충족'}` : '매수 평가 없음',
|
||||||
|
sellOk != null ? `매도 ${sellOk ? '충족' : '미충족'}` : '매도 평가 없음',
|
||||||
|
`차트 시그널 ${ctx.signals.totalOnChart}건`,
|
||||||
|
ctx.signals.selectedBarSignals.length > 0
|
||||||
|
? `선택 봉 시그널 ${ctx.signals.selectedBarSignals.map(s => s.type).join(', ')}`
|
||||||
|
: '선택 봉 시그널 없음',
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cursor AI에 붙여넣을 수정 포인트 항목 */
|
||||||
|
export interface AiVerifyFixPoint {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** 현재 평가 상황 */
|
||||||
|
situation: string;
|
||||||
|
/** 설정 및 예상 결과 */
|
||||||
|
settingsAndExpected: string;
|
||||||
|
/** 현재 결과 */
|
||||||
|
actualResult: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiVerifyFixPointsBundle {
|
||||||
|
hasIssue: boolean;
|
||||||
|
verdictHint: string | null;
|
||||||
|
points: AiVerifyFixPoint[];
|
||||||
|
cursorPrompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConditionLines(
|
||||||
|
conditions: StrategyEvaluationAiVerifyContext['evaluation']['buyConditions'],
|
||||||
|
): string {
|
||||||
|
if (conditions.length === 0) return '(조건 없음)';
|
||||||
|
return conditions.map(c => {
|
||||||
|
const status = c.satisfied === true ? '충족' : c.satisfied === false ? '미충족' : '평가불가';
|
||||||
|
const cur = c.currentValue != null ? String(c.currentValue) : '-';
|
||||||
|
const target = c.thresholdLabel ?? (c.targetValue != null ? String(c.targetValue) : '-');
|
||||||
|
return `- [${c.id}] ${c.label} (${c.timeframe}/${c.indicatorType}): ${status} · 현재=${cur} · 기준=${target}`;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDslSummary(condition: unknown): string {
|
||||||
|
if (condition == null) return '(DSL 없음)';
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(condition, null, 2);
|
||||||
|
return s.length > 1200 ? `${s.slice(0, 1200)}\n…(생략)` : s;
|
||||||
|
} catch {
|
||||||
|
return String(condition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIndicatorParamsSummary(
|
||||||
|
params: Record<string, Record<string, unknown>> | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!params || Object.keys(params).length === 0) return '(지표 파라미터 없음)';
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(params, null, 2);
|
||||||
|
return s.length > 800 ? `${s.slice(0, 800)}\n…(생략)` : s;
|
||||||
|
} catch {
|
||||||
|
return '(직렬화 실패)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartSituationSummary(ctx: StrategyEvaluationAiVerifyContext): string {
|
||||||
|
const bar = ctx.chart.selectedBar;
|
||||||
|
const lines = [
|
||||||
|
`전략: ${ctx.strategy.name} (#${ctx.strategy.id})`,
|
||||||
|
`종목/타임프레임: ${ctx.chart.market} / ${ctx.chart.timeframe}`,
|
||||||
|
bar
|
||||||
|
? `선택 봉: index=${ctx.chart.selectedBarIndex}, ${bar.timeLabel}, O=${bar.open} H=${bar.high} L=${bar.low} C=${bar.close}`
|
||||||
|
: '선택 봉: 없음',
|
||||||
|
`평가 범위: 차트 ${ctx.chart.barCount}봉` + (ctx.chart.evaluationBarCount != null
|
||||||
|
? ` · Rule 평가 ${ctx.chart.evaluationBarCount}봉`
|
||||||
|
: ''),
|
||||||
|
ctx.evaluation.matchRate != null ? `조건 일치율: ${ctx.evaluation.matchRate}%` : null,
|
||||||
|
ctx.evaluation.error ? `평가 오류: ${ctx.evaluation.error}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LLM 종합 판정에서 로직 문제 여부 추정 */
|
||||||
|
export function detectLogicIssueFromAnalysis(analysis: string): boolean {
|
||||||
|
const text = analysis.replace(/\r/g, '');
|
||||||
|
const verdict = text.match(/##\s*종합\s*판정\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||||
|
if (verdict) {
|
||||||
|
const body = verdict[1].trim();
|
||||||
|
const head = body.split('\n').slice(0, 4).join(' ');
|
||||||
|
if (/오류\s*의심|주의|불일치|로직\s*문제|의심/.test(head)) return true;
|
||||||
|
if (/정상/.test(head) && !/주의|오류/.test(head)) return false;
|
||||||
|
}
|
||||||
|
return /\b(오류\s*의심|불일치|로직\s*문제)\b/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVerdictHint(analysis: string | null | undefined): string | null {
|
||||||
|
if (!analysis) return null;
|
||||||
|
const m = analysis.replace(/\r/g, '').match(/##\s*종합\s*판정\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const line = m[1].trim().split('\n').find(l => l.trim())?.trim();
|
||||||
|
return line ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushBuySignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
|
const { overallEntryMet, buyConditions } = ctx.evaluation;
|
||||||
|
const buyHighlight = ctx.signals.selectedBarHighlight.buy;
|
||||||
|
if (overallEntryMet !== true || buyHighlight) return;
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
id: 'buy-rule-no-signal',
|
||||||
|
title: '매수 Rule 충족 ↔ 차트 BUY 시그널 없음',
|
||||||
|
situation: [
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
'【매수 조건 평가】',
|
||||||
|
`overallEntryMet: true (전체 매수 Rule 충족)`,
|
||||||
|
formatConditionLines(buyConditions),
|
||||||
|
'',
|
||||||
|
'【선택 봉 차트 시그널】',
|
||||||
|
`BUY: 없음 · SELL: ${ctx.signals.selectedBarHighlight.sell ? '있음' : '없음'}`,
|
||||||
|
].join('\n'),
|
||||||
|
settingsAndExpected: [
|
||||||
|
'전략 매수 DSL:',
|
||||||
|
formatDslSummary(ctx.strategy.buyCondition),
|
||||||
|
'',
|
||||||
|
'지표 파라미터:',
|
||||||
|
formatIndicatorParamsSummary(ctx.indicatorParams),
|
||||||
|
'',
|
||||||
|
'예상 결과:',
|
||||||
|
'- Ta4j overallEntryMet=true 이면 해당 봉(또는 Rule 실행 시점)에 BUY 시그널이 차트에 표시되어야 함',
|
||||||
|
'- 포지션 모드(LONG_ONLY 등)·이미 보유·중복 방지·스캔 범위(evaluationBarCount)에 따라 시그널이 생략될 수 있음',
|
||||||
|
`- deterministicHints.expectedBuySignalAtBar: ${ctx.deterministicHints.expectedBuySignalAtBar}`,
|
||||||
|
].join('\n'),
|
||||||
|
actualResult: [
|
||||||
|
`선택 봉 BUY 시그널: 없음`,
|
||||||
|
`overallEntryMet: true`,
|
||||||
|
`힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
||||||
|
ctx.signals.nearbySignals.length > 0
|
||||||
|
? `인접 봉 시그널: ${ctx.signals.nearbySignals.map(s => `${s.type}@${s.offsetFromSelected > 0 ? '+' : ''}${s.offsetFromSelected}`).join(', ')}`
|
||||||
|
: '인접 봉(±5) 시그널: 없음',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSellSignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
|
const { overallExitMet, sellConditions } = ctx.evaluation;
|
||||||
|
const sellHighlight = ctx.signals.selectedBarHighlight.sell;
|
||||||
|
if (overallExitMet !== true || sellHighlight) return;
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
id: 'sell-rule-no-signal',
|
||||||
|
title: '매도 Rule 충족 ↔ 차트 SELL 시그널 없음',
|
||||||
|
situation: [
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
'【매도 조건 평가】',
|
||||||
|
`overallExitMet: true (전체 매도 Rule 충족)`,
|
||||||
|
formatConditionLines(sellConditions),
|
||||||
|
'',
|
||||||
|
'【선택 봉 차트 시그널】',
|
||||||
|
`BUY: ${ctx.signals.selectedBarHighlight.buy ? '있음' : '없음'} · SELL: 없음`,
|
||||||
|
].join('\n'),
|
||||||
|
settingsAndExpected: [
|
||||||
|
'전략 매도 DSL:',
|
||||||
|
formatDslSummary(ctx.strategy.sellCondition),
|
||||||
|
'',
|
||||||
|
'지표 파라미터:',
|
||||||
|
formatIndicatorParamsSummary(ctx.indicatorParams),
|
||||||
|
'',
|
||||||
|
'예상 결과:',
|
||||||
|
'- Ta4j overallExitMet=true 이면 해당 봉에 SELL 시그널이 차트에 표시되어야 함',
|
||||||
|
'- 미보유·포지션 모드·스캔 범위에 따라 시그널이 생략될 수 있음',
|
||||||
|
`- deterministicHints.expectedSellSignalAtBar: ${ctx.deterministicHints.expectedSellSignalAtBar}`,
|
||||||
|
].join('\n'),
|
||||||
|
actualResult: [
|
||||||
|
'선택 봉 SELL 시그널: 없음',
|
||||||
|
'overallExitMet: true',
|
||||||
|
`힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUnexpectedBuySignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
|
const { overallEntryMet, buyConditions } = ctx.evaluation;
|
||||||
|
if (overallEntryMet !== false || !ctx.signals.selectedBarHighlight.buy) return;
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
id: 'buy-signal-no-rule',
|
||||||
|
title: '매수 Rule 미충족 ↔ 차트 BUY 시그널 있음',
|
||||||
|
situation: [
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
'【매수 조건 평가】',
|
||||||
|
'overallEntryMet: false',
|
||||||
|
formatConditionLines(buyConditions),
|
||||||
|
'',
|
||||||
|
'【선택 봉 시그널】',
|
||||||
|
ctx.signals.selectedBarSignals.filter(s => s.type === 'BUY').map(s =>
|
||||||
|
`BUY @ barIndex=${s.barIndex}, price=${s.price}, time=${s.time}`,
|
||||||
|
).join('\n') || 'BUY 시그널 있음',
|
||||||
|
].join('\n'),
|
||||||
|
settingsAndExpected: [
|
||||||
|
'전략 매수 DSL:',
|
||||||
|
formatDslSummary(ctx.strategy.buyCondition),
|
||||||
|
'',
|
||||||
|
'예상 결과: overallEntryMet=false 이면 선택 봉에 BUY 시그널이 없어야 함',
|
||||||
|
].join('\n'),
|
||||||
|
actualResult: [
|
||||||
|
'overallEntryMet: false',
|
||||||
|
'선택 봉 BUY 시그널: 있음 → Rule 평가와 백테스트 시그널 생성 로직 불일치 의심',
|
||||||
|
`힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUnexpectedSellSignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
|
const { overallExitMet, sellConditions } = ctx.evaluation;
|
||||||
|
if (overallExitMet !== false || !ctx.signals.selectedBarHighlight.sell) return;
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
id: 'sell-signal-no-rule',
|
||||||
|
title: '매도 Rule 미충족 ↔ 차트 SELL 시그널 있음',
|
||||||
|
situation: [
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
'【매도 조건 평가】',
|
||||||
|
'overallExitMet: false',
|
||||||
|
formatConditionLines(sellConditions),
|
||||||
|
].join('\n'),
|
||||||
|
settingsAndExpected: [
|
||||||
|
'전략 매도 DSL:',
|
||||||
|
formatDslSummary(ctx.strategy.sellCondition),
|
||||||
|
'',
|
||||||
|
'예상 결과: overallExitMet=false 이면 선택 봉에 SELL 시그널이 없어야 함',
|
||||||
|
].join('\n'),
|
||||||
|
actualResult: [
|
||||||
|
'overallExitMet: false',
|
||||||
|
'선택 봉 SELL 시그널: 있음 → Rule 평가와 시그널 생성 불일치 의심',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFailedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
|
const failed = [
|
||||||
|
...ctx.evaluation.buyConditions.filter(c => c.satisfied === false),
|
||||||
|
...ctx.evaluation.sellConditions.filter(c => c.satisfied === false),
|
||||||
|
];
|
||||||
|
if (failed.length === 0) return;
|
||||||
|
|
||||||
|
const entryMet = ctx.evaluation.overallEntryMet;
|
||||||
|
const exitMet = ctx.evaluation.overallExitMet;
|
||||||
|
if (entryMet !== true && exitMet !== true) return;
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
id: 'partial-condition-fail',
|
||||||
|
title: '개별 조건 미충족 vs 전체 Rule 충족',
|
||||||
|
situation: [
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
`overallEntryMet: ${entryMet} · overallExitMet: ${exitMet}`,
|
||||||
|
'',
|
||||||
|
'【미충족 개별 조건】',
|
||||||
|
formatConditionLines(failed),
|
||||||
|
].join('\n'),
|
||||||
|
settingsAndExpected: [
|
||||||
|
'전략 DSL (매수/매도):',
|
||||||
|
formatDslSummary({ buy: ctx.strategy.buyCondition, sell: ctx.strategy.sellCondition }),
|
||||||
|
'',
|
||||||
|
'예상 결과: AND/OR 그룹 논리에 따라 개별 조건과 overallEntryMet/overallExitMet가 일치해야 함',
|
||||||
|
].join('\n'),
|
||||||
|
actualResult: [
|
||||||
|
'일부 leaf 조건은 satisfied=false 이지만 전체 Rule은 충족으로 표시됨',
|
||||||
|
'→ DSL 그룹(AND/OR/NOT) 해석, Ta4j Rule 빌더, 조건 ID 매핑을 확인 필요',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUnevaluatedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
|
const unknown = [
|
||||||
|
...ctx.evaluation.buyConditions.filter(c => c.satisfied == null),
|
||||||
|
...ctx.evaluation.sellConditions.filter(c => c.satisfied == null),
|
||||||
|
];
|
||||||
|
if (unknown.length === 0) return;
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
id: 'condition-unevaluated',
|
||||||
|
title: '조건 평가 불가 (지표·데이터 누락)',
|
||||||
|
situation: [
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
'【평가 불가 조건】',
|
||||||
|
formatConditionLines(unknown),
|
||||||
|
].join('\n'),
|
||||||
|
settingsAndExpected: [
|
||||||
|
'지표 파라미터:',
|
||||||
|
formatIndicatorParamsSummary(ctx.indicatorParams),
|
||||||
|
'',
|
||||||
|
'예상 결과: 모든 leaf 조건에 currentValue·satisfied가 채워져야 Rule 판정 가능',
|
||||||
|
].join('\n'),
|
||||||
|
actualResult: [
|
||||||
|
`${unknown.length}개 조건 satisfied=null — 지표 스냅샷·봉 데이터·타임프레임 정렬 문제 가능`,
|
||||||
|
ctx.evaluation.error ? `평가 API 오류: ${ctx.evaluation.error}` : '',
|
||||||
|
].filter(Boolean).join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCursorFixPrompt(
|
||||||
|
ctx: StrategyEvaluationAiVerifyContext,
|
||||||
|
points: AiVerifyFixPoint[],
|
||||||
|
verdictHint: string | null,
|
||||||
|
): string {
|
||||||
|
const header = [
|
||||||
|
'# GoldenChart 전략평가 — 로직 불일치 수정 요청',
|
||||||
|
'',
|
||||||
|
'아래 전략평가(Ta4j Rule vs 차트 시그널) 불일치를 분석하고, 코드 수정 포인트와 해결 방안을 제안해 주세요.',
|
||||||
|
'',
|
||||||
|
verdictHint ? `## AI 종합 판정\n${verdictHint}\n` : '',
|
||||||
|
'## 공통 컨텍스트',
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const body = points.map((p, i) => [
|
||||||
|
`## 수정 포인트 ${i + 1}: ${p.title}`,
|
||||||
|
'',
|
||||||
|
'### 현재 평가 상황',
|
||||||
|
p.situation,
|
||||||
|
'',
|
||||||
|
'### 설정 및 예상 결과',
|
||||||
|
p.settingsAndExpected,
|
||||||
|
'',
|
||||||
|
'### 현재 결과',
|
||||||
|
p.actualResult,
|
||||||
|
'',
|
||||||
|
].join('\n')).join('\n');
|
||||||
|
|
||||||
|
const footer = [
|
||||||
|
'## 요청',
|
||||||
|
'- 불일치 원인을 frontend/backend/Ta4j Rule 빌더 관점에서 추적',
|
||||||
|
'- 수정해야 할 파일·함수와 구체적 변경안 제시',
|
||||||
|
'- 재현 방법(종목, 타임프레임, 선택 봉 index) 포함',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return `${header}${body}${footer}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로직 문제 시 Cursor AI용 수정 포인트 번들 생성 */
|
||||||
|
export function buildAiVerifyFixPoints(
|
||||||
|
ctx: StrategyEvaluationAiVerifyContext,
|
||||||
|
analysis?: string | null,
|
||||||
|
): AiVerifyFixPointsBundle | null {
|
||||||
|
const points: AiVerifyFixPoint[] = [];
|
||||||
|
pushBuySignalMismatch(ctx, points);
|
||||||
|
pushUnexpectedBuySignal(ctx, points);
|
||||||
|
pushSellSignalMismatch(ctx, points);
|
||||||
|
pushUnexpectedSellSignal(ctx, points);
|
||||||
|
pushFailedConditions(ctx, points);
|
||||||
|
pushUnevaluatedConditions(ctx, points);
|
||||||
|
|
||||||
|
const verdictHint = extractVerdictHint(analysis);
|
||||||
|
const issueFromAnalysis = analysis ? detectLogicIssueFromAnalysis(analysis) : false;
|
||||||
|
const issueFromHints = ctx.deterministicHints.signalMatchesOverallRule
|
||||||
|
!== '전체 Rule과 선택 봉 시그널 표시가 대체로 일치';
|
||||||
|
|
||||||
|
if (points.length === 0 && issueFromAnalysis) {
|
||||||
|
points.push({
|
||||||
|
id: 'llm-verdict-only',
|
||||||
|
title: 'AI가 로직 문제로 판단',
|
||||||
|
situation: [
|
||||||
|
chartSituationSummary(ctx),
|
||||||
|
'',
|
||||||
|
'【조건 요약】',
|
||||||
|
`매수 overallEntryMet: ${ctx.evaluation.overallEntryMet}`,
|
||||||
|
formatConditionLines(ctx.evaluation.buyConditions),
|
||||||
|
'',
|
||||||
|
`매도 overallExitMet: ${ctx.evaluation.overallExitMet}`,
|
||||||
|
formatConditionLines(ctx.evaluation.sellConditions),
|
||||||
|
'',
|
||||||
|
`시그널 힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
||||||
|
].join('\n'),
|
||||||
|
settingsAndExpected: [
|
||||||
|
'전략 DSL:',
|
||||||
|
formatDslSummary({ buy: ctx.strategy.buyCondition, sell: ctx.strategy.sellCondition }),
|
||||||
|
'',
|
||||||
|
'예상: Ta4j Rule 결과와 차트 시그널·개별 조건 satisfied가 논리적으로 일치',
|
||||||
|
].join('\n'),
|
||||||
|
actualResult: verdictHint
|
||||||
|
? `AI 종합 판정: ${verdictHint}`
|
||||||
|
: (analysis?.slice(0, 400) ?? '분석 텍스트 없음'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDeterministicIssue = points.some(p => p.id !== 'llm-verdict-only');
|
||||||
|
const shouldShow = issueFromAnalysis || issueFromHints || hasDeterministicIssue;
|
||||||
|
if (!shouldShow || points.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasIssue: true,
|
||||||
|
verdictHint,
|
||||||
|
points,
|
||||||
|
cursorPrompt: buildCursorFixPrompt(ctx, points, verdictHint),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user