llm 동작 수정
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
package com.goldenchart.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전략평가 AI 검증 — LLM·Ta4j 재평가를 HTTP 요청 스레드와 분리.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class AiVerifyAsyncConfig {
|
||||||
|
|
||||||
|
@Bean(name = "aiVerifyExecutor")
|
||||||
|
public Executor aiVerifyExecutor(
|
||||||
|
@Value("${goldenchart.llm.ai-verify.pool-size:2}") int poolSize,
|
||||||
|
@Value("${goldenchart.llm.ai-verify.queue-capacity:8}") int queueCapacity) {
|
||||||
|
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
|
||||||
|
ex.setThreadNamePrefix("ai-verify-");
|
||||||
|
ex.setCorePoolSize(Math.max(1, poolSize));
|
||||||
|
ex.setMaxPoolSize(Math.max(1, poolSize));
|
||||||
|
ex.setQueueCapacity(queueCapacity);
|
||||||
|
ex.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
ex.setAwaitTerminationSeconds(120);
|
||||||
|
ex.initialize();
|
||||||
|
return ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.goldenchart.controller;
|
package com.goldenchart.controller;
|
||||||
|
|
||||||
import com.goldenchart.dto.LlmTestResponse;
|
import com.goldenchart.dto.LlmTestResponse;
|
||||||
|
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse;
|
||||||
|
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse;
|
||||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest;
|
import com.goldenchart.dto.StrategyEvaluationAiVerifyRequest;
|
||||||
import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse;
|
import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse;
|
||||||
|
import com.goldenchart.service.StrategyEvaluationAiVerifyJobService;
|
||||||
import com.goldenchart.service.StrategyEvaluationLlmService;
|
import com.goldenchart.service.StrategyEvaluationLlmService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -13,7 +16,9 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* 전략 평가 부가 기능.
|
* 전략 평가 부가 기능.
|
||||||
*
|
*
|
||||||
* POST /api/strategy/evaluation/ai-verify — LLM 기반 평가·시그널 일치 검증
|
* POST /api/strategy/evaluation/ai-verify/jobs — AI 검증 비동기 시작 (jobId 즉시 반환)
|
||||||
|
* GET /api/strategy/evaluation/ai-verify/jobs/{jobId} — job 상태·결과 조회
|
||||||
|
* POST /api/strategy/evaluation/ai-verify — (레거시) 동기 검증
|
||||||
* POST /api/strategy/evaluation/llm-test — LLM 연결 테스트
|
* POST /api/strategy/evaluation/llm-test — LLM 연결 테스트
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@@ -22,6 +27,45 @@ import java.util.Map;
|
|||||||
public class StrategyEvaluationController {
|
public class StrategyEvaluationController {
|
||||||
|
|
||||||
private final StrategyEvaluationLlmService llmService;
|
private final StrategyEvaluationLlmService llmService;
|
||||||
|
private final StrategyEvaluationAiVerifyJobService aiVerifyJobService;
|
||||||
|
|
||||||
|
@PostMapping("/ai-verify/jobs")
|
||||||
|
public ResponseEntity<?> startAiVerifyJob(
|
||||||
|
@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);
|
||||||
|
StrategyEvaluationAiVerifyJobStartResponse res =
|
||||||
|
aiVerifyJobService.startJob(body.getContext(), userId, deviceId);
|
||||||
|
return ResponseEntity.accepted().body(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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ai-verify/jobs/{jobId}")
|
||||||
|
public ResponseEntity<?> getAiVerifyJob(
|
||||||
|
@PathVariable String jobId,
|
||||||
|
@RequestHeader Map<String, String> headers) {
|
||||||
|
TradingControllerSupport.requireRegisteredUser(headers);
|
||||||
|
try {
|
||||||
|
Long userId = TradingControllerSupport.parseUserId(headers);
|
||||||
|
StrategyEvaluationAiVerifyJobStatusResponse res = aiVerifyJobService.getJob(jobId, userId);
|
||||||
|
if (res == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(res);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/ai-verify")
|
@PostMapping("/ai-verify")
|
||||||
public ResponseEntity<?> aiVerify(
|
public ResponseEntity<?> aiVerify(
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.goldenchart.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class StrategyEvaluationAiVerifyJobStartResponse {
|
||||||
|
private String jobId;
|
||||||
|
/** QUEUED | RUNNING */
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.goldenchart.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class StrategyEvaluationAiVerifyJobStatusResponse {
|
||||||
|
private String jobId;
|
||||||
|
/** QUEUED | RUNNING | COMPLETED | FAILED */
|
||||||
|
private String status;
|
||||||
|
private String error;
|
||||||
|
private StrategyEvaluationAiVerifyResponse result;
|
||||||
|
private long createdAtMs;
|
||||||
|
private Long completedAtMs;
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class StrategyEvaluationAiVerifyJobRunner {
|
||||||
|
|
||||||
|
private final StrategyEvaluationLlmService llmService;
|
||||||
|
private final StrategyEvaluationAiVerifyJobService jobService;
|
||||||
|
|
||||||
|
@Async("aiVerifyExecutor")
|
||||||
|
public void execute(String jobId, JsonNode context, Long userId, String deviceId) {
|
||||||
|
jobService.markRunning(jobId);
|
||||||
|
try {
|
||||||
|
var result = llmService.verify(context, userId, deviceId);
|
||||||
|
jobService.markCompleted(jobId, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[AiVerifyJob] fail jobId={}: {}", jobId, e.getMessage());
|
||||||
|
String msg = e.getMessage() != null && !e.getMessage().isBlank()
|
||||||
|
? e.getMessage()
|
||||||
|
: "AI 검증 중 오류가 발생했습니다.";
|
||||||
|
jobService.markFailed(jobId, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStartResponse;
|
||||||
|
import com.goldenchart.dto.StrategyEvaluationAiVerifyJobStatusResponse;
|
||||||
|
import com.goldenchart.dto.StrategyEvaluationAiVerifyResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StrategyEvaluationAiVerifyJobService {
|
||||||
|
|
||||||
|
private static final long JOB_TTL_MS = 60L * 60 * 1000;
|
||||||
|
|
||||||
|
private final StrategyEvaluationAiVerifyJobRunner jobRunner;
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, JobRecord> jobs = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public StrategyEvaluationAiVerifyJobStartResponse startJob(JsonNode context, Long userId, String deviceId) {
|
||||||
|
purgeExpired();
|
||||||
|
String jobId = UUID.randomUUID().toString();
|
||||||
|
JobRecord record = new JobRecord(jobId, userId, System.currentTimeMillis());
|
||||||
|
jobs.put(jobId, record);
|
||||||
|
jobRunner.execute(jobId, context, userId, deviceId);
|
||||||
|
return StrategyEvaluationAiVerifyJobStartResponse.builder()
|
||||||
|
.jobId(jobId)
|
||||||
|
.status(record.status)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public StrategyEvaluationAiVerifyJobStatusResponse getJob(String jobId, Long userId) {
|
||||||
|
purgeExpired();
|
||||||
|
JobRecord record = jobs.get(jobId);
|
||||||
|
if (record == null || !record.userId.equals(userId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return toResponse(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
void markRunning(String jobId) {
|
||||||
|
JobRecord record = jobs.get(jobId);
|
||||||
|
if (record != null) {
|
||||||
|
record.status = "RUNNING";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void markCompleted(String jobId, StrategyEvaluationAiVerifyResponse result) {
|
||||||
|
JobRecord record = jobs.get(jobId);
|
||||||
|
if (record != null) {
|
||||||
|
record.status = "COMPLETED";
|
||||||
|
record.result = result;
|
||||||
|
record.error = null;
|
||||||
|
record.completedAtMs = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void markFailed(String jobId, String error) {
|
||||||
|
JobRecord record = jobs.get(jobId);
|
||||||
|
if (record != null) {
|
||||||
|
record.status = "FAILED";
|
||||||
|
record.error = error;
|
||||||
|
record.completedAtMs = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StrategyEvaluationAiVerifyJobStatusResponse toResponse(JobRecord record) {
|
||||||
|
return StrategyEvaluationAiVerifyJobStatusResponse.builder()
|
||||||
|
.jobId(record.jobId)
|
||||||
|
.status(record.status)
|
||||||
|
.error(record.error)
|
||||||
|
.result(record.result)
|
||||||
|
.createdAtMs(record.createdAtMs)
|
||||||
|
.completedAtMs(record.completedAtMs)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void purgeExpired() {
|
||||||
|
long cutoff = System.currentTimeMillis() - JOB_TTL_MS;
|
||||||
|
Iterator<Map.Entry<String, JobRecord>> it = jobs.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<String, JobRecord> e = it.next();
|
||||||
|
long ts = e.getValue().completedAtMs != null
|
||||||
|
? e.getValue().completedAtMs
|
||||||
|
: e.getValue().createdAtMs;
|
||||||
|
if (ts < cutoff) {
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class JobRecord {
|
||||||
|
private final String jobId;
|
||||||
|
private final Long userId;
|
||||||
|
private final long createdAtMs;
|
||||||
|
private String status = "QUEUED";
|
||||||
|
private String error;
|
||||||
|
private StrategyEvaluationAiVerifyResponse result;
|
||||||
|
private Long completedAtMs;
|
||||||
|
|
||||||
|
private JobRecord(String jobId, Long userId, long createdAtMs) {
|
||||||
|
this.jobId = jobId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.createdAtMs = createdAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,31 +26,36 @@ public class StrategyEvaluationLlmService {
|
|||||||
|
|
||||||
private static final String SYSTEM_PROMPT = """
|
private static final String SYSTEM_PROMPT = """
|
||||||
당신은 GoldenChart 전략 평가(Ta4j Rule) 검증 전문가입니다.
|
당신은 GoldenChart 전략 평가(Ta4j Rule) 검증 전문가입니다.
|
||||||
사용자 JSON에는 선택 봉 시점의 조건 평가 결과, 차트 매매 시그널, 전략 DSL 요약이 포함됩니다.
|
사용자 JSON에는 선택 봉 시점의 조건 평가, 차트 시그널, 전략 DSL, llmGuidance가 포함됩니다.
|
||||||
ta4jDiagnostics 섹션은 서버가 동일 경로로 Ta4j Rule 을 재빌드·재평가한 결과(Rule descriptor, trace 로그, frontend vs backend 비교)입니다.
|
ta4jDiagnostics는 서버 Ta4j 재평가 결과(frontend vs backend 비교)입니다.
|
||||||
|
|
||||||
|
【중요 — 오판 방지】
|
||||||
|
- evaluation.buyConditions/sellConditions 각 항목의 dslCondition·dslExpression·evaluationSemantics를
|
||||||
|
판단의 1차 근거로 사용하세요. UI용 thresholdLabel(예: "> 20")은 컨텍스트에 없습니다.
|
||||||
|
- CROSS_UP/CROSS_DOWN은 GT/LT(수준 비교)와 다릅니다. 교차는 직전봉·현재봉 2봉 이벤트입니다.
|
||||||
|
현재값>임계값인데 CROSS_UP 미충족은 정상일 수 있습니다(이미 돌파 후 유지).
|
||||||
|
- matchRate는 leaf 조건 satisfied=true 비율입니다. 0%%는 leaf 전부 미충족이지,
|
||||||
|
Rule↔시그널 불일치를 뜻하지 않습니다.
|
||||||
|
- deterministicHints.signalMatchesOverallRule이 "대체로 일치"이면 overall Rule과 차트 시그널은 일치합니다.
|
||||||
|
|
||||||
다음을 한국어로 분석하세요:
|
다음을 한국어로 분석하세요:
|
||||||
1) 선택 봉에서 매수/매도 전체 Rule(overallEntryMet/overallExitMet)과 개별 조건(satisfied)이 논리적으로 일치하는지
|
1) dslExpression·evaluationSemantics 기준으로 satisfied·overallEntryMet/overallExitMet 논리 일치
|
||||||
2) ta4jDiagnostics.reEvaluation·entryRule/exitRule.satisfiedAtEvalIndex 와 프론트 evaluation 이 일치하는지
|
2) ta4jDiagnostics.reEvaluation·entryRule/exitRule.satisfiedAtEvalIndex 와 evaluation 일치
|
||||||
3) entryRule/exitRule.evaluationTraceLog 로 Rule 평가 경로를 추적해 불일치 원인을 설명
|
3) 차트 시그널(selectedBarSignals, barSignalHighlight)과 overall Rule 일치
|
||||||
4) 차트에 표시된 매매 시그널(selectedBarSignals, barSignalHighlight)이 평가 결과와 맞는지
|
4) 실제 불일치만 "오류 의심" — CROSS 조건의 정상 미충족은 "정상"
|
||||||
5) 시그널이 없을 때: 실제로 조건 미충족이라 시그널이 없는 것인지, 로직·데이터 불일치 가능성이 있는지
|
|
||||||
6) 의심 지점이 있으면 구체적 조건 ID·지표·수치·Rule 타입을 인용
|
|
||||||
|
|
||||||
출력 형식(마크다운):
|
출력 형식(마크다운):
|
||||||
## 종합 판정
|
## 종합 판정
|
||||||
(정상 / 주의 / 오류 의심 — 한 줄 요약)
|
(정상 / 주의 / 오류 의심 — 한 줄 요약)
|
||||||
|
|
||||||
## 조건 평가 검증
|
## 조건 평가 검증
|
||||||
(매수·매도 각각 bullet)
|
(매수·매도 각각 bullet — dslExpression 인용)
|
||||||
|
|
||||||
## 차트 시그널 검증
|
## 차트 시그널 검증
|
||||||
(선택 봉 시그널 vs Rule 결과)
|
(선택 봉 시그널 vs Rule 결과)
|
||||||
|
|
||||||
## 결론 및 권장 조치
|
## 결론 및 권장 조치
|
||||||
(1~3문장)
|
(1~3문장)
|
||||||
|
|
||||||
로직 문제(종합 판정이 주의·오류 의심)인 경우, 위 분석에서 불일치 원인·조건 ID·지표를 구체적으로 명시하세요.
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String TEST_USER_PROMPT = "ping — 연결 테스트입니다. 한 문장으로 응답해 주세요.";
|
private static final String TEST_USER_PROMPT = "ping — 연결 테스트입니다. 한 문장으로 응답해 주세요.";
|
||||||
|
|||||||
@@ -24,6 +24,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ── LLM / AI 검증 (Ta4j 재평가 + LLM — exact match, 일반 /api/ 60s 회피) ───
|
# ── LLM / AI 검증 (Ta4j 재평가 + LLM — exact match, 일반 /api/ 60s 회피) ───
|
||||||
|
location = /api/strategy/evaluation/ai-verify/jobs {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_buffering off;
|
||||||
|
client_max_body_size 16m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/api/strategy/evaluation/ai-verify/jobs/[^/]+$ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
location = /api/strategy/evaluation/ai-verify {
|
location = /api/strategy/evaluation/ai-verify {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -100,17 +100,35 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
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 [aiVerifyOpen, setAiVerifyOpen] = useState(false);
|
||||||
const [aiVerifyLoading, setAiVerifyLoading] = useState(false);
|
const [aiVerifyRunning, setAiVerifyRunning] = useState(false);
|
||||||
const [aiVerifyError, setAiVerifyError] = useState<string | null>(null);
|
const [aiVerifyError, setAiVerifyError] = useState<string | null>(null);
|
||||||
const [aiVerifyResult, setAiVerifyResult] = useState<StrategyEvaluationAiVerifyResponse | null>(null);
|
const [aiVerifyResult, setAiVerifyResult] = useState<StrategyEvaluationAiVerifyResponse | null>(null);
|
||||||
const [aiVerifyContext, setAiVerifyContext] = useState<import('../utils/strategyEvaluationAiVerify').StrategyEvaluationAiVerifyContext | null>(null);
|
const [aiVerifyContext, setAiVerifyContext] = useState<import('../utils/strategyEvaluationAiVerify').StrategyEvaluationAiVerifyContext | null>(null);
|
||||||
const [aiVerifySummary, setAiVerifySummary] = useState<string | null>(null);
|
const [aiVerifySummary, setAiVerifySummary] = useState<string | null>(null);
|
||||||
|
const [aiVerifyCompleteNotice, setAiVerifyCompleteNotice] = useState(false);
|
||||||
|
const [aiVerifyErrorNotice, setAiVerifyErrorNotice] = useState<string | null>(null);
|
||||||
const aiVerifyGenRef = useRef(0);
|
const aiVerifyGenRef = useRef(0);
|
||||||
|
const aiVerifyOpenRef = useRef(aiVerifyOpen);
|
||||||
|
const aiVerifyPollAbortRef = useRef<AbortController | null>(null);
|
||||||
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);
|
||||||
const paramsRevisionRef = useRef(paramsRevision);
|
const paramsRevisionRef = useRef(paramsRevision);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
aiVerifyPollAbortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
aiVerifyOpenRef.current = aiVerifyOpen;
|
||||||
|
if (aiVerifyOpen) {
|
||||||
|
setAiVerifyCompleteNotice(false);
|
||||||
|
setAiVerifyErrorNotice(null);
|
||||||
|
}
|
||||||
|
}, [aiVerifyOpen]);
|
||||||
|
|
||||||
const getEvalParams = useMemo(
|
const getEvalParams = useMemo(
|
||||||
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
|
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
|
||||||
[baseGetParams, appliedIndicatorParams],
|
[baseGetParams, appliedIndicatorParams],
|
||||||
@@ -392,12 +410,18 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
const runAiVerify = useCallback(async () => {
|
const runAiVerify = useCallback(async () => {
|
||||||
if (aiVerifyDisabled || !selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) return;
|
if (aiVerifyDisabled || !selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) return;
|
||||||
|
|
||||||
const gen = ++aiVerifyGenRef.current;
|
aiVerifyPollAbortRef.current?.abort();
|
||||||
|
const pollAbort = new AbortController();
|
||||||
|
aiVerifyPollAbortRef.current = pollAbort;
|
||||||
|
|
||||||
|
const runId = ++aiVerifyGenRef.current;
|
||||||
setAiVerifyOpen(true);
|
setAiVerifyOpen(true);
|
||||||
setAiVerifyLoading(true);
|
setAiVerifyRunning(true);
|
||||||
setAiVerifyError(null);
|
setAiVerifyError(null);
|
||||||
setAiVerifyResult(null);
|
setAiVerifyResult(null);
|
||||||
setAiVerifyContext(null);
|
setAiVerifyContext(null);
|
||||||
|
setAiVerifyCompleteNotice(false);
|
||||||
|
setAiVerifyErrorNotice(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
|
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
|
||||||
@@ -419,15 +443,25 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
setAiVerifySummary(summarizeVerifyContext(context));
|
setAiVerifySummary(summarizeVerifyContext(context));
|
||||||
setAiVerifyContext(context);
|
setAiVerifyContext(context);
|
||||||
|
|
||||||
const result = await requestStrategyEvaluationAiVerify(context);
|
const result = await requestStrategyEvaluationAiVerify(context, {
|
||||||
if (gen !== aiVerifyGenRef.current) return;
|
signal: pollAbort.signal,
|
||||||
|
});
|
||||||
|
if (runId !== aiVerifyGenRef.current) return;
|
||||||
setAiVerifyResult(result);
|
setAiVerifyResult(result);
|
||||||
|
if (!aiVerifyOpenRef.current) {
|
||||||
|
setAiVerifyCompleteNotice(true);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen !== aiVerifyGenRef.current) return;
|
if (runId !== aiVerifyGenRef.current) return;
|
||||||
setAiVerifyError(e instanceof Error ? e.message : 'AI 검증 요청 실패');
|
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||||
|
const msg = e instanceof Error ? e.message : 'AI 검증 요청 실패';
|
||||||
|
setAiVerifyError(msg);
|
||||||
|
if (!aiVerifyOpenRef.current) {
|
||||||
|
setAiVerifyErrorNotice(msg);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === aiVerifyGenRef.current) {
|
if (runId === aiVerifyGenRef.current) {
|
||||||
setAiVerifyLoading(false);
|
setAiVerifyRunning(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -448,13 +482,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleOpenAiVerify = useCallback(() => {
|
const handleOpenAiVerify = useCallback(() => {
|
||||||
|
if (aiVerifyRunning) {
|
||||||
|
setAiVerifyOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void runAiVerify();
|
void runAiVerify();
|
||||||
}, [runAiVerify]);
|
}, [aiVerifyRunning, runAiVerify]);
|
||||||
|
|
||||||
const handleCloseAiVerify = useCallback(() => {
|
const handleCloseAiVerify = useCallback(() => {
|
||||||
++aiVerifyGenRef.current;
|
|
||||||
setAiVerifyOpen(false);
|
setAiVerifyOpen(false);
|
||||||
setAiVerifyLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenReport = useCallback(async () => {
|
const handleOpenReport = useCallback(async () => {
|
||||||
@@ -708,7 +744,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
reportLoading={reportLoading}
|
reportLoading={reportLoading}
|
||||||
onOpenAiVerify={handleOpenAiVerify}
|
onOpenAiVerify={handleOpenAiVerify}
|
||||||
aiVerifyDisabled={aiVerifyDisabled}
|
aiVerifyDisabled={aiVerifyDisabled}
|
||||||
aiVerifyLoading={aiVerifyLoading && aiVerifyOpen}
|
aiVerifyLoading={aiVerifyRunning}
|
||||||
marketTickers={marketFeed.tickers}
|
marketTickers={marketFeed.tickers}
|
||||||
marketInfos={marketFeed.marketInfos}
|
marketInfos={marketFeed.marketInfos}
|
||||||
marketLoading={marketFeed.loading}
|
marketLoading={marketFeed.loading}
|
||||||
@@ -806,7 +842,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
<StrategyEvaluationAiVerifyModal
|
<StrategyEvaluationAiVerifyModal
|
||||||
open={aiVerifyOpen}
|
open={aiVerifyOpen}
|
||||||
onClose={handleCloseAiVerify}
|
onClose={handleCloseAiVerify}
|
||||||
loading={aiVerifyLoading}
|
loading={aiVerifyRunning}
|
||||||
error={aiVerifyError}
|
error={aiVerifyError}
|
||||||
contextSummary={aiVerifySummary}
|
contextSummary={aiVerifySummary}
|
||||||
verifyContext={aiVerifyContext}
|
verifyContext={aiVerifyContext}
|
||||||
@@ -814,6 +850,67 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
onRetry={() => { void runAiVerify(); }}
|
onRetry={() => { void runAiVerify(); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{aiVerifyRunning && !aiVerifyOpen && (
|
||||||
|
<div className="seval-ai-verify-bg-pill" role="status" aria-live="polite">
|
||||||
|
<span className="btd-run-spinner seval-ai-verify-bg-pill-spinner" aria-hidden />
|
||||||
|
<span>AI 분석 진행 중…</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-bg-pill-btn"
|
||||||
|
onClick={() => setAiVerifyOpen(true)}
|
||||||
|
>
|
||||||
|
진행 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(aiVerifyCompleteNotice || aiVerifyErrorNotice) && !aiVerifyOpen && (
|
||||||
|
<div
|
||||||
|
className={`seval-ai-verify-notice${aiVerifyErrorNotice ? ' seval-ai-verify-notice--error' : ''}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="seval-ai-verify-notice-text">
|
||||||
|
{aiVerifyErrorNotice ?? 'AI 분석이 완료되었습니다.'}
|
||||||
|
</span>
|
||||||
|
<div className="seval-ai-verify-notice-actions">
|
||||||
|
{!aiVerifyErrorNotice && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-notice-btn seval-ai-verify-notice-btn--primary"
|
||||||
|
onClick={() => {
|
||||||
|
setAiVerifyCompleteNotice(false);
|
||||||
|
setAiVerifyOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
결과 보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-notice-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setAiVerifyCompleteNotice(false);
|
||||||
|
setAiVerifyErrorNotice(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
{aiVerifyErrorNotice && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="seval-ai-verify-notice-btn seval-ai-verify-notice-btn--primary"
|
||||||
|
onClick={() => {
|
||||||
|
setAiVerifyErrorNotice(null);
|
||||||
|
setAiVerifyOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,10 @@ const StrategyEvaluationAiVerifyModal: React.FC<Props> = ({
|
|||||||
<div className="seval-ai-verify-loading" aria-live="polite">
|
<div className="seval-ai-verify-loading" aria-live="polite">
|
||||||
<span className="btd-run-spinner seval-ai-verify-spinner" aria-hidden />
|
<span className="btd-run-spinner seval-ai-verify-spinner" aria-hidden />
|
||||||
<p>LLM이 평가 결과와 차트 시그널을 분석 중입니다…</p>
|
<p>LLM이 평가 결과와 차트 시그널을 분석 중입니다…</p>
|
||||||
<p className="seval-ai-verify-loading-hint">Ta4j 재평가와 LLM 분석에 최대 2~3분 걸릴 수 있습니다.</p>
|
<p className="seval-ai-verify-loading-hint">
|
||||||
|
Ta4j 재평가와 LLM 분석에 최대 2~3분 걸릴 수 있습니다.
|
||||||
|
모달을 닫아도 백그라운드에서 분석이 계속됩니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -335,7 +338,9 @@ const StrategyEvaluationAiVerifyModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
<footer className="seval-ai-verify-foot">
|
<footer className="seval-ai-verify-foot">
|
||||||
<p className="seval-ai-verify-footnote">
|
<p className="seval-ai-verify-footnote">
|
||||||
※ AI 분석은 참고용입니다. 수정 포인트는 Cursor 등에 붙여넣어 코드 수정을 요청할 수 있습니다.
|
{loading
|
||||||
|
? '※ 분석 중 닫기를 눌러도 작업은 백그라운드에서 계속됩니다.'
|
||||||
|
: '※ AI 분석은 참고용입니다. 수정 포인트는 Cursor 등에 붙여넣어 코드 수정을 요청할 수 있습니다.'}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="seval-ai-verify-close-btn" onClick={onClose}>
|
<button type="button" className="seval-ai-verify-close-btn" onClick={onClose}>
|
||||||
닫기
|
닫기
|
||||||
|
|||||||
@@ -617,6 +617,95 @@
|
|||||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--border));
|
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── AI 검증 백그라운드 pill·완료 알림 ── */
|
||||||
|
.seval-ai-verify-bg-pill {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 11990;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 40%, var(--se-border, #2a3148));
|
||||||
|
background: color-mix(in srgb, var(--bg2, #151929) 92%, var(--accent, #3f7ef5) 8%);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text, var(--se-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-bg-pill-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-bg-pill-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 45%, var(--se-border));
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent, #3f7ef5);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-bg-pill-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-notice {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 11990;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: min(360px, calc(100vw - 32px));
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 35%, var(--se-border));
|
||||||
|
background: var(--bg2, #151929);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-notice--error {
|
||||||
|
border-color: color-mix(in srgb, #ef5350 45%, var(--se-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-notice-text {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text, var(--se-text));
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-notice-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-notice-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
background: var(--bg3, #1f2335);
|
||||||
|
color: var(--text, var(--se-text));
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-notice-btn--primary {
|
||||||
|
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 45%, var(--se-border));
|
||||||
|
color: var(--accent, #3f7ef5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-ai-verify-notice-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, var(--bg3));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── AI 검증 수정 포인트 (Cursor) ── */
|
/* ── AI 검증 수정 포인트 (Cursor) ── */
|
||||||
.seval-ai-verify-fixpoints {
|
.seval-ai-verify-fixpoints {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|||||||
@@ -1738,7 +1738,51 @@ export interface StrategyEvaluationAiVerifyResponse {
|
|||||||
ta4jDiagnostics?: Record<string, unknown> | null;
|
ta4jDiagnostics?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 전략 평가 — LLM 기반 Rule·시그널 일치 검증 */
|
export type StrategyEvaluationAiVerifyJobStatus =
|
||||||
|
| 'QUEUED'
|
||||||
|
| 'RUNNING'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'FAILED';
|
||||||
|
|
||||||
|
export interface StrategyEvaluationAiVerifyJobStartResponse {
|
||||||
|
jobId: string;
|
||||||
|
status: StrategyEvaluationAiVerifyJobStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyEvaluationAiVerifyJobStatusResponse {
|
||||||
|
jobId: string;
|
||||||
|
status: StrategyEvaluationAiVerifyJobStatus;
|
||||||
|
error?: string | null;
|
||||||
|
result?: StrategyEvaluationAiVerifyResponse | null;
|
||||||
|
createdAtMs: number;
|
||||||
|
completedAtMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전략 평가 — AI 검증 비동기 job 시작 (즉시 jobId 반환) */
|
||||||
|
export async function startStrategyEvaluationAiVerifyJob(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
): Promise<StrategyEvaluationAiVerifyJobStartResponse> {
|
||||||
|
return requestOrThrow<StrategyEvaluationAiVerifyJobStartResponse>(
|
||||||
|
'/strategy/evaluation/ai-verify/jobs',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
body: JSON.stringify({ context }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 검증 job 상태 조회 */
|
||||||
|
export async function fetchStrategyEvaluationAiVerifyJob(
|
||||||
|
jobId: string,
|
||||||
|
): Promise<StrategyEvaluationAiVerifyJobStatusResponse> {
|
||||||
|
return requestOrThrow<StrategyEvaluationAiVerifyJobStatusResponse>(
|
||||||
|
`/strategy/evaluation/ai-verify/jobs/${encodeURIComponent(jobId)}`,
|
||||||
|
{ cache: 'no-store' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전략 평가 — LLM 기반 Rule·시그널 일치 검증 (레거시 동기) */
|
||||||
export async function fetchStrategyEvaluationAiVerify(
|
export async function fetchStrategyEvaluationAiVerify(
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
): Promise<StrategyEvaluationAiVerifyResponse> {
|
): Promise<StrategyEvaluationAiVerifyResponse> {
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
* 전략 평가 AI 검증 — LLM 전달용 컨텍스트 구성 및 API 호출
|
* 전략 평가 AI 검증 — LLM 전달용 컨텍스트 구성 및 API 호출
|
||||||
*/
|
*/
|
||||||
import type { BacktestSignal, StrategyDto, StrategyEvaluationAiVerifyResponse } from './backendApi';
|
import type { BacktestSignal, StrategyDto, StrategyEvaluationAiVerifyResponse } from './backendApi';
|
||||||
import { fetchStrategyEvaluationAiVerify } from './backendApi';
|
import {
|
||||||
|
fetchStrategyEvaluationAiVerifyJob,
|
||||||
|
startStrategyEvaluationAiVerifyJob,
|
||||||
|
type StrategyEvaluationAiVerifyJobStatusResponse,
|
||||||
|
} from './backendApi';
|
||||||
import type { OHLCVBar } from '../types';
|
import type { OHLCVBar } from '../types';
|
||||||
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||||||
import type { BarSignalHighlight } from './strategyEvaluationBarSignals';
|
import type { BarSignalHighlight } from './strategyEvaluationBarSignals';
|
||||||
@@ -12,6 +16,13 @@ import { formatVirtualConditionListLabel } from './virtualStrategyConditions';
|
|||||||
import { buildConditionMetrics } from './virtualSignalMetrics';
|
import { buildConditionMetrics } from './virtualSignalMetrics';
|
||||||
import { repairUtf8Mojibake } from './textEncoding';
|
import { repairUtf8Mojibake } from './textEncoding';
|
||||||
import { normalizeEpochSecOptional } from './backtestUiUtils';
|
import { normalizeEpochSecOptional } from './backtestUiUtils';
|
||||||
|
import {
|
||||||
|
buildLlmVerifyPayload,
|
||||||
|
formatLlmConditionLines,
|
||||||
|
indexStrategyConditions,
|
||||||
|
enrichConditionRow,
|
||||||
|
type LlmConditionEvaluationRow,
|
||||||
|
} from './strategyEvaluationLlmContext';
|
||||||
|
|
||||||
export interface StrategyEvaluationAiVerifyContext {
|
export interface StrategyEvaluationAiVerifyContext {
|
||||||
meta: {
|
meta: {
|
||||||
@@ -285,8 +296,71 @@ function signalMatchesBar(
|
|||||||
|
|
||||||
export async function requestStrategyEvaluationAiVerify(
|
export async function requestStrategyEvaluationAiVerify(
|
||||||
context: StrategyEvaluationAiVerifyContext,
|
context: StrategyEvaluationAiVerifyContext,
|
||||||
|
opts?: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onJobStarted?: (jobId: string) => void;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
maxWaitMs?: number;
|
||||||
|
},
|
||||||
): Promise<StrategyEvaluationAiVerifyResponse> {
|
): Promise<StrategyEvaluationAiVerifyResponse> {
|
||||||
return fetchStrategyEvaluationAiVerify(context as unknown as Record<string, unknown>);
|
const payload = buildLlmVerifyPayload(context);
|
||||||
|
const { jobId } = await startStrategyEvaluationAiVerifyJob(
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
opts?.onJobStarted?.(jobId);
|
||||||
|
const job = await pollStrategyEvaluationAiVerifyJob(jobId, {
|
||||||
|
signal: opts?.signal,
|
||||||
|
pollIntervalMs: opts?.pollIntervalMs ?? 2_500,
|
||||||
|
maxWaitMs: opts?.maxWaitMs ?? 620_000,
|
||||||
|
});
|
||||||
|
if (!job.result) {
|
||||||
|
throw new Error(job.error ?? 'AI 검증 결과가 없습니다.');
|
||||||
|
}
|
||||||
|
return job.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(resolve, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
};
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 백그라운드 폴링 — job 완료까지 주기적으로 상태 조회 */
|
||||||
|
export async function pollStrategyEvaluationAiVerifyJob(
|
||||||
|
jobId: string,
|
||||||
|
opts?: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
maxWaitMs?: number;
|
||||||
|
onStatus?: (job: StrategyEvaluationAiVerifyJobStatusResponse) => void;
|
||||||
|
},
|
||||||
|
): Promise<StrategyEvaluationAiVerifyJobStatusResponse> {
|
||||||
|
const interval = opts?.pollIntervalMs ?? 2_500;
|
||||||
|
const maxWait = opts?.maxWaitMs ?? 620_000;
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - started < maxWait) {
|
||||||
|
if (opts?.signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
const job = await fetchStrategyEvaluationAiVerifyJob(jobId);
|
||||||
|
opts?.onStatus?.(job);
|
||||||
|
if (job.status === 'COMPLETED') return job;
|
||||||
|
if (job.status === 'FAILED') {
|
||||||
|
throw new Error(job.error ?? 'AI 검증 실패');
|
||||||
|
}
|
||||||
|
await sleep(interval, opts?.signal);
|
||||||
|
}
|
||||||
|
throw new Error('AI 검증 시간 초과. 잠시 후 다시 시도해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI 요약 — 컨텍스트 미리보기 */
|
/** UI 요약 — 컨텍스트 미리보기 */
|
||||||
@@ -324,16 +398,28 @@ export interface AiVerifyFixPointsBundle {
|
|||||||
cursorPrompt: string;
|
cursorPrompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatConditionLines(
|
function llmConditionRows(ctx: StrategyEvaluationAiVerifyContext): {
|
||||||
conditions: StrategyEvaluationAiVerifyContext['evaluation']['buyConditions'],
|
buy: LlmConditionEvaluationRow[];
|
||||||
): string {
|
sell: LlmConditionEvaluationRow[];
|
||||||
if (conditions.length === 0) return '(조건 없음)';
|
} {
|
||||||
return conditions.map(c => {
|
const dslById = indexStrategyConditions(ctx.strategy.buyCondition, ctx.strategy.sellCondition);
|
||||||
const status = c.satisfied === true ? '충족' : c.satisfied === false ? '미충족' : '평가불가';
|
return {
|
||||||
const cur = c.currentValue != null ? String(c.currentValue) : '-';
|
buy: ctx.evaluation.buyConditions.map(r => enrichConditionRow(r, dslById)),
|
||||||
const target = c.thresholdLabel ?? (c.targetValue != null ? String(c.targetValue) : '-');
|
sell: ctx.evaluation.sellConditions.map(r => enrichConditionRow(r, dslById)),
|
||||||
return `- [${c.id}] ${c.label} (${c.timeframe}/${c.indicatorType}): ${status} · 현재=${cur} · 기준=${target}`;
|
};
|
||||||
}).join('\n');
|
}
|
||||||
|
|
||||||
|
function formatConditionLines(ctx: StrategyEvaluationAiVerifyContext): {
|
||||||
|
buy: string;
|
||||||
|
sell: string;
|
||||||
|
all: string;
|
||||||
|
} {
|
||||||
|
const { buy, sell } = llmConditionRows(ctx);
|
||||||
|
return {
|
||||||
|
buy: formatLlmConditionLines(buy),
|
||||||
|
sell: formatLlmConditionLines(sell),
|
||||||
|
all: formatLlmConditionLines([...buy, ...sell]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDslSummary(condition: unknown): string {
|
function formatDslSummary(condition: unknown): string {
|
||||||
@@ -397,7 +483,7 @@ function extractVerdictHint(analysis: string | null | undefined): string | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushBuySignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
function pushBuySignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
const { overallEntryMet, buyConditions } = ctx.evaluation;
|
const { overallEntryMet } = ctx.evaluation;
|
||||||
const buyHighlight = ctx.signals.selectedBarHighlight.buy;
|
const buyHighlight = ctx.signals.selectedBarHighlight.buy;
|
||||||
if (overallEntryMet !== true || buyHighlight) return;
|
if (overallEntryMet !== true || buyHighlight) return;
|
||||||
|
|
||||||
@@ -409,7 +495,7 @@ function pushBuySignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: A
|
|||||||
'',
|
'',
|
||||||
'【매수 조건 평가】',
|
'【매수 조건 평가】',
|
||||||
`overallEntryMet: true (전체 매수 Rule 충족)`,
|
`overallEntryMet: true (전체 매수 Rule 충족)`,
|
||||||
formatConditionLines(buyConditions),
|
formatConditionLines(ctx).buy,
|
||||||
'',
|
'',
|
||||||
'【선택 봉 차트 시그널】',
|
'【선택 봉 차트 시그널】',
|
||||||
`BUY: 없음 · SELL: ${ctx.signals.selectedBarHighlight.sell ? '있음' : '없음'}`,
|
`BUY: 없음 · SELL: ${ctx.signals.selectedBarHighlight.sell ? '있음' : '없음'}`,
|
||||||
@@ -438,7 +524,7 @@ function pushBuySignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: A
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushSellSignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
function pushSellSignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
const { overallExitMet, sellConditions } = ctx.evaluation;
|
const { overallExitMet } = ctx.evaluation;
|
||||||
const sellHighlight = ctx.signals.selectedBarHighlight.sell;
|
const sellHighlight = ctx.signals.selectedBarHighlight.sell;
|
||||||
if (overallExitMet !== true || sellHighlight) return;
|
if (overallExitMet !== true || sellHighlight) return;
|
||||||
|
|
||||||
@@ -450,7 +536,7 @@ function pushSellSignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points:
|
|||||||
'',
|
'',
|
||||||
'【매도 조건 평가】',
|
'【매도 조건 평가】',
|
||||||
`overallExitMet: true (전체 매도 Rule 충족)`,
|
`overallExitMet: true (전체 매도 Rule 충족)`,
|
||||||
formatConditionLines(sellConditions),
|
formatConditionLines(ctx).sell,
|
||||||
'',
|
'',
|
||||||
'【선택 봉 차트 시그널】',
|
'【선택 봉 차트 시그널】',
|
||||||
`BUY: ${ctx.signals.selectedBarHighlight.buy ? '있음' : '없음'} · SELL: 없음`,
|
`BUY: ${ctx.signals.selectedBarHighlight.buy ? '있음' : '없음'} · SELL: 없음`,
|
||||||
@@ -476,7 +562,7 @@ function pushSellSignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushUnexpectedBuySignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
function pushUnexpectedBuySignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
const { overallEntryMet, buyConditions } = ctx.evaluation;
|
const { overallEntryMet } = ctx.evaluation;
|
||||||
if (overallEntryMet !== false || !ctx.signals.selectedBarHighlight.buy) return;
|
if (overallEntryMet !== false || !ctx.signals.selectedBarHighlight.buy) return;
|
||||||
|
|
||||||
points.push({
|
points.push({
|
||||||
@@ -487,7 +573,7 @@ function pushUnexpectedBuySignal(ctx: StrategyEvaluationAiVerifyContext, points:
|
|||||||
'',
|
'',
|
||||||
'【매수 조건 평가】',
|
'【매수 조건 평가】',
|
||||||
'overallEntryMet: false',
|
'overallEntryMet: false',
|
||||||
formatConditionLines(buyConditions),
|
formatConditionLines(ctx).buy,
|
||||||
'',
|
'',
|
||||||
'【선택 봉 시그널】',
|
'【선택 봉 시그널】',
|
||||||
ctx.signals.selectedBarSignals.filter(s => s.type === 'BUY').map(s =>
|
ctx.signals.selectedBarSignals.filter(s => s.type === 'BUY').map(s =>
|
||||||
@@ -509,7 +595,7 @@ function pushUnexpectedBuySignal(ctx: StrategyEvaluationAiVerifyContext, points:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushUnexpectedSellSignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
function pushUnexpectedSellSignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
const { overallExitMet, sellConditions } = ctx.evaluation;
|
const { overallExitMet } = ctx.evaluation;
|
||||||
if (overallExitMet !== false || !ctx.signals.selectedBarHighlight.sell) return;
|
if (overallExitMet !== false || !ctx.signals.selectedBarHighlight.sell) return;
|
||||||
|
|
||||||
points.push({
|
points.push({
|
||||||
@@ -520,7 +606,7 @@ function pushUnexpectedSellSignal(ctx: StrategyEvaluationAiVerifyContext, points
|
|||||||
'',
|
'',
|
||||||
'【매도 조건 평가】',
|
'【매도 조건 평가】',
|
||||||
'overallExitMet: false',
|
'overallExitMet: false',
|
||||||
formatConditionLines(sellConditions),
|
formatConditionLines(ctx).sell,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
settingsAndExpected: [
|
settingsAndExpected: [
|
||||||
'전략 매도 DSL:',
|
'전략 매도 DSL:',
|
||||||
@@ -536,10 +622,8 @@ function pushUnexpectedSellSignal(ctx: StrategyEvaluationAiVerifyContext, points
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushFailedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
function pushFailedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
const failed = [
|
const { buy, sell } = llmConditionRows(ctx);
|
||||||
...ctx.evaluation.buyConditions.filter(c => c.satisfied === false),
|
const failed = [...buy, ...sell].filter(c => c.satisfied === false);
|
||||||
...ctx.evaluation.sellConditions.filter(c => c.satisfied === false),
|
|
||||||
];
|
|
||||||
if (failed.length === 0) return;
|
if (failed.length === 0) return;
|
||||||
|
|
||||||
const entryMet = ctx.evaluation.overallEntryMet;
|
const entryMet = ctx.evaluation.overallEntryMet;
|
||||||
@@ -555,7 +639,7 @@ function pushFailedConditions(ctx: StrategyEvaluationAiVerifyContext, points: Ai
|
|||||||
`overallEntryMet: ${entryMet} · overallExitMet: ${exitMet}`,
|
`overallEntryMet: ${entryMet} · overallExitMet: ${exitMet}`,
|
||||||
'',
|
'',
|
||||||
'【미충족 개별 조건】',
|
'【미충족 개별 조건】',
|
||||||
formatConditionLines(failed),
|
formatLlmConditionLines(failed),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
settingsAndExpected: [
|
settingsAndExpected: [
|
||||||
'전략 DSL (매수/매도):',
|
'전략 DSL (매수/매도):',
|
||||||
@@ -571,10 +655,8 @@ function pushFailedConditions(ctx: StrategyEvaluationAiVerifyContext, points: Ai
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushUnevaluatedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
function pushUnevaluatedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
||||||
const unknown = [
|
const { buy, sell } = llmConditionRows(ctx);
|
||||||
...ctx.evaluation.buyConditions.filter(c => c.satisfied == null),
|
const unknown = [...buy, ...sell].filter(c => c.satisfied == null);
|
||||||
...ctx.evaluation.sellConditions.filter(c => c.satisfied == null),
|
|
||||||
];
|
|
||||||
if (unknown.length === 0) return;
|
if (unknown.length === 0) return;
|
||||||
|
|
||||||
points.push({
|
points.push({
|
||||||
@@ -584,7 +666,7 @@ function pushUnevaluatedConditions(ctx: StrategyEvaluationAiVerifyContext, point
|
|||||||
chartSituationSummary(ctx),
|
chartSituationSummary(ctx),
|
||||||
'',
|
'',
|
||||||
'【평가 불가 조건】',
|
'【평가 불가 조건】',
|
||||||
formatConditionLines(unknown),
|
formatLlmConditionLines(unknown),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
settingsAndExpected: [
|
settingsAndExpected: [
|
||||||
'지표 파라미터:',
|
'지표 파라미터:',
|
||||||
@@ -657,19 +739,20 @@ export function buildAiVerifyFixPoints(
|
|||||||
const issueFromHints = ctx.deterministicHints.signalMatchesOverallRule
|
const issueFromHints = ctx.deterministicHints.signalMatchesOverallRule
|
||||||
!== '전체 Rule과 선택 봉 시그널 표시가 대체로 일치';
|
!== '전체 Rule과 선택 봉 시그널 표시가 대체로 일치';
|
||||||
|
|
||||||
if (points.length === 0 && issueFromAnalysis) {
|
if (points.length === 0 && issueFromAnalysis && issueFromHints) {
|
||||||
|
const condLines = formatConditionLines(ctx);
|
||||||
points.push({
|
points.push({
|
||||||
id: 'llm-verdict-only',
|
id: 'llm-verdict-only',
|
||||||
title: 'AI가 로직 문제로 판단',
|
title: 'AI가 로직 문제로 판단',
|
||||||
situation: [
|
situation: [
|
||||||
chartSituationSummary(ctx),
|
chartSituationSummary(ctx),
|
||||||
'',
|
'',
|
||||||
'【조건 요약】',
|
'【조건 요약 — DSL·Ta4j 기준】',
|
||||||
`매수 overallEntryMet: ${ctx.evaluation.overallEntryMet}`,
|
`매수 overallEntryMet: ${ctx.evaluation.overallEntryMet}`,
|
||||||
formatConditionLines(ctx.evaluation.buyConditions),
|
condLines.buy,
|
||||||
'',
|
'',
|
||||||
`매도 overallExitMet: ${ctx.evaluation.overallExitMet}`,
|
`매도 overallExitMet: ${ctx.evaluation.overallExitMet}`,
|
||||||
formatConditionLines(ctx.evaluation.sellConditions),
|
condLines.sell,
|
||||||
'',
|
'',
|
||||||
`시그널 힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
`시그널 힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* LLM AI 검증 — 오해 소지 UI 라벨 대신 실제 DSL·Ta4j 평가 의미를 전달
|
||||||
|
*/
|
||||||
|
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
||||||
|
import { CONDITION_LABEL } from './strategyTypes';
|
||||||
|
import { asLogicNode } from './strategyHydrate';
|
||||||
|
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||||
|
import { formatIndicatorValue } from './virtualStrategyConditions';
|
||||||
|
import { isThresholdSymbol } from './thresholdSymbols';
|
||||||
|
import type { StrategyEvaluationAiVerifyContext } from './strategyEvaluationAiVerify';
|
||||||
|
|
||||||
|
const HL_ROLE_KO: Record<string, string> = {
|
||||||
|
HL_OVER: '과열선',
|
||||||
|
HL_MID: '중앙선',
|
||||||
|
HL_UNDER: '침체선',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ta4j Rule.isSatisfied 기준 — conditionType별 의미 */
|
||||||
|
const EVALUATION_SEMANTICS: Record<string, string> = {
|
||||||
|
GT: '현재봉 left > right',
|
||||||
|
LT: '현재봉 left < right',
|
||||||
|
GTE: '현재봉 left ≥ right',
|
||||||
|
LTE: '현재봉 left ≤ right',
|
||||||
|
EQ: '현재봉 left = right',
|
||||||
|
NEQ: '현재봉 left ≠ right',
|
||||||
|
CROSS_UP:
|
||||||
|
'직전봉 left≤right 이고 현재봉 left>right (교차 이벤트). 현재값이 임계값보다 커도 이미 돌파된 상태면 미충족.',
|
||||||
|
CROSS_DOWN:
|
||||||
|
'직전봉 left≥right 이고 현재봉 left<right (교차 이벤트). 현재값이 임계값보다 작지 않으면 미충족.',
|
||||||
|
SLOPE_UP: '최근 N봉 기울기 상승',
|
||||||
|
SLOPE_DOWN: '최근 N봉 기울기 하락',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LlmConditionEvaluationRow {
|
||||||
|
id: string;
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
timeframe: string;
|
||||||
|
indicatorType: string;
|
||||||
|
conditionType: string;
|
||||||
|
satisfied: boolean | null;
|
||||||
|
currentValue: number | null;
|
||||||
|
/** leaf condition DSL (strategy JSON 그대로) */
|
||||||
|
dslCondition: ConditionDSL;
|
||||||
|
/** Ta4j 평가식 요약 — GT/LT와 혼동되지 않도록 conditionType 명시 */
|
||||||
|
dslExpression: string;
|
||||||
|
/** Ta4j isSatisfied 판정 기준 설명 */
|
||||||
|
evaluationSemantics: string;
|
||||||
|
/** right operand 해석 (HL_UNDER → 20 등) */
|
||||||
|
rightOperand: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmVerifyPayload extends Omit<StrategyEvaluationAiVerifyContext, 'evaluation'> {
|
||||||
|
llmGuidance: {
|
||||||
|
matchRateNote: string;
|
||||||
|
conditionTypeNote: string;
|
||||||
|
doNotMisinterpret: string[];
|
||||||
|
};
|
||||||
|
evaluation: Omit<StrategyEvaluationAiVerifyContext['evaluation'], 'buyConditions' | 'sellConditions'> & {
|
||||||
|
buyConditions: LlmConditionEvaluationRow[];
|
||||||
|
sellConditions: LlmConditionEvaluationRow[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkConditionIndex(
|
||||||
|
node: LogicNode | null | undefined,
|
||||||
|
timeframe: string,
|
||||||
|
side: 'buy' | 'sell',
|
||||||
|
out: Map<string, ConditionDSL>,
|
||||||
|
seen: Set<string>,
|
||||||
|
): void {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
if (node.type === 'TIMEFRAME') {
|
||||||
|
const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe);
|
||||||
|
node.children?.forEach(c => walkConditionIndex(c, tf, side, out, seen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'NOT') {
|
||||||
|
const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
|
||||||
|
if (child) walkConditionIndex(child, timeframe, side, out, seen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'AND' || node.type === 'OR') {
|
||||||
|
node.children?.forEach(c => walkConditionIndex(c, timeframe, side, out, seen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!node.type || node.type === 'CONDITION') && node.condition && node.id) {
|
||||||
|
const rowId = `${node.id}-${side}`;
|
||||||
|
const key = `${rowId}:${JSON.stringify(node.condition)}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.set(rowId, node.condition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexStrategyConditions(
|
||||||
|
buyRoot: unknown,
|
||||||
|
sellRoot: unknown,
|
||||||
|
): Map<string, ConditionDSL> {
|
||||||
|
const map = new Map<string, ConditionDSL>();
|
||||||
|
walkConditionIndex(asLogicNode(buyRoot), '1m', 'buy', map, new Set());
|
||||||
|
walkConditionIndex(asLogicNode(sellRoot), '1m', 'sell', map, new Set());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRightOperand(cond: ConditionDSL, targetValue: number | null): string {
|
||||||
|
const rf = cond.rightField ?? '';
|
||||||
|
if (!rf || rf === 'NONE') return '—';
|
||||||
|
if (rf.startsWith('K_')) return rf;
|
||||||
|
if (isThresholdSymbol(rf)) {
|
||||||
|
const role = HL_ROLE_KO[rf] ?? rf;
|
||||||
|
const v = targetValue != null ? formatIndicatorValue(targetValue) : '?';
|
||||||
|
return `${rf}(${v}, ${role})`;
|
||||||
|
}
|
||||||
|
return rf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LLM·로그용 — conditionType을 GT/LT로 축약하지 않음 */
|
||||||
|
export function formatConditionDslExpression(
|
||||||
|
cond: ConditionDSL,
|
||||||
|
timeframe: string,
|
||||||
|
targetValue: number | null,
|
||||||
|
): string {
|
||||||
|
const left = cond.leftField && cond.leftField !== 'none' ? cond.leftField : cond.indicatorType;
|
||||||
|
const right = formatRightOperand(cond, targetValue);
|
||||||
|
const tf = normalizeStartCandleType(cond.leftCandleType ?? cond.rightCandleType ?? timeframe);
|
||||||
|
const ctLabel = CONDITION_LABEL[cond.conditionType] ?? cond.conditionType;
|
||||||
|
return `[${tf}] ${left} ${cond.conditionType}(${ctLabel}) ${right}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConditionEvaluationSemantics(conditionType: string): string {
|
||||||
|
return EVALUATION_SEMANTICS[conditionType]
|
||||||
|
?? `Ta4j Rule.isSatisfied — ${CONDITION_LABEL[conditionType] ?? conditionType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichConditionRow(
|
||||||
|
row: StrategyEvaluationAiVerifyContext['evaluation']['buyConditions'][number],
|
||||||
|
dslById: Map<string, ConditionDSL>,
|
||||||
|
): LlmConditionEvaluationRow {
|
||||||
|
const dsl = dslById.get(row.id);
|
||||||
|
if (!dsl) {
|
||||||
|
const fallback: ConditionDSL = {
|
||||||
|
indicatorType: row.indicatorType,
|
||||||
|
conditionType: row.conditionType,
|
||||||
|
leftField: row.indicatorType,
|
||||||
|
rightField: row.targetValue != null ? `K_${row.targetValue}` : undefined,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
side: row.side as 'buy' | 'sell',
|
||||||
|
timeframe: row.timeframe,
|
||||||
|
indicatorType: row.indicatorType,
|
||||||
|
conditionType: row.conditionType,
|
||||||
|
satisfied: row.satisfied,
|
||||||
|
currentValue: row.currentValue,
|
||||||
|
dslCondition: fallback,
|
||||||
|
dslExpression: formatConditionDslExpression(fallback, row.timeframe, row.targetValue),
|
||||||
|
evaluationSemantics: formatConditionEvaluationSemantics(row.conditionType),
|
||||||
|
rightOperand: formatRightOperand(fallback, row.targetValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
side: row.side as 'buy' | 'sell',
|
||||||
|
timeframe: row.timeframe,
|
||||||
|
indicatorType: row.indicatorType,
|
||||||
|
conditionType: row.conditionType,
|
||||||
|
satisfied: row.satisfied,
|
||||||
|
currentValue: row.currentValue,
|
||||||
|
dslCondition: dsl,
|
||||||
|
dslExpression: formatConditionDslExpression(dsl, row.timeframe, row.targetValue),
|
||||||
|
evaluationSemantics: formatConditionEvaluationSemantics(dsl.conditionType),
|
||||||
|
rightOperand: formatRightOperand(dsl, row.targetValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI 컨텍스트 → LLM API 전송용 (오해 유발 thresholdLabel 제외) */
|
||||||
|
export function buildLlmVerifyPayload(ctx: StrategyEvaluationAiVerifyContext): LlmVerifyPayload {
|
||||||
|
const dslById = indexStrategyConditions(ctx.strategy.buyCondition, ctx.strategy.sellCondition);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: ctx.meta,
|
||||||
|
strategy: {
|
||||||
|
id: ctx.strategy.id,
|
||||||
|
name: ctx.strategy.name,
|
||||||
|
buyCondition: ctx.strategy.buyCondition,
|
||||||
|
sellCondition: ctx.strategy.sellCondition,
|
||||||
|
},
|
||||||
|
chart: ctx.chart,
|
||||||
|
evaluation: {
|
||||||
|
loading: ctx.evaluation.loading,
|
||||||
|
error: ctx.evaluation.error,
|
||||||
|
matchRate: ctx.evaluation.matchRate,
|
||||||
|
overallEntryMet: ctx.evaluation.overallEntryMet,
|
||||||
|
overallExitMet: ctx.evaluation.overallExitMet,
|
||||||
|
buyConditions: ctx.evaluation.buyConditions.map(r => enrichConditionRow(r, dslById)),
|
||||||
|
sellConditions: ctx.evaluation.sellConditions.map(r => enrichConditionRow(r, dslById)),
|
||||||
|
},
|
||||||
|
signals: ctx.signals,
|
||||||
|
indicatorParams: ctx.indicatorParams,
|
||||||
|
deterministicHints: ctx.deterministicHints,
|
||||||
|
llmGuidance: {
|
||||||
|
matchRateNote:
|
||||||
|
'matchRate는 leaf 조건 satisfied=true 비율(0~100%)입니다. 0%는 모든 leaf가 미충족이라는 뜻이며, Rule↔시그널 불일치를 의미하지 않습니다.',
|
||||||
|
conditionTypeNote:
|
||||||
|
'CROSS_UP/CROSS_DOWN은 GT/LT(수준 비교)와 다릅니다. 교차는 직전봉·현재봉 2봉 비교 이벤트입니다.',
|
||||||
|
doNotMisinterpret: [
|
||||||
|
'evaluation.buyConditions/sellConditions의 dslExpression·evaluationSemantics를 기준으로 판단하세요.',
|
||||||
|
'thresholdLabel·"> 20" 형태 UI 표기는 LLM 컨텍스트에 포함하지 않았습니다.',
|
||||||
|
'deterministicHints.signalMatchesOverallRule이 "대체로 일치"이면 overall Rule과 차트 시그널은 일치합니다.',
|
||||||
|
'현재값>임계값인데 CROSS_UP 미충족은 정상일 수 있습니다(이미 돌파 후 유지).',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수정 포인트·Cursor 프롬프트용 조건 한 줄 (DSL 기준) */
|
||||||
|
export function formatLlmConditionLine(row: LlmConditionEvaluationRow): string {
|
||||||
|
const status = row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '평가불가';
|
||||||
|
const cur = row.currentValue != null ? formatIndicatorValue(row.currentValue) : '-';
|
||||||
|
return [
|
||||||
|
`- [${row.id}] ${row.dslExpression}`,
|
||||||
|
` Ta4j: ${row.evaluationSemantics}`,
|
||||||
|
` 결과: ${status} · 현재=${cur} · right=${row.rightOperand}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLlmConditionLines(rows: LlmConditionEvaluationRow[]): string {
|
||||||
|
if (rows.length === 0) return '(조건 없음)';
|
||||||
|
return rows.map(formatLlmConditionLine).join('\n');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user