llm 분석기능
This commit is contained in:
@@ -191,6 +191,9 @@ public class AppSettingsService {
|
||||
if (d.containsKey("trendSearchSettings")) {
|
||||
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
|
||||
}
|
||||
if (d.containsKey("llmSettings")) {
|
||||
s.setLlmSettingsJson(toJson(d.get("llmSettings")));
|
||||
}
|
||||
if (d.containsKey("uiPreferences")) {
|
||||
s.setUiPreferencesJson(toJson(d.get("uiPreferences")));
|
||||
}
|
||||
@@ -254,6 +257,7 @@ public class AppSettingsService {
|
||||
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
||||
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
|
||||
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
|
||||
m.put("llmSettings", parseJson(s.getLlmSettingsJson()));
|
||||
m.put("uiPreferences", parseJson(s.getUiPreferencesJson()));
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -671,6 +671,22 @@ public class LiveConditionStatusService {
|
||||
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} 와
|
||||
|
||||
@@ -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) + "…";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user