llm 분석기능

This commit is contained in:
Macbook
2026-06-17 13:40:52 +09:00
parent 3ed09cd966
commit 7a2509a045
28 changed files with 2783 additions and 3 deletions
+24
View File
@@ -33,7 +33,9 @@ import {
} from '../utils/tradeAlertPopupLayout';
import TradeAlertPopupPreview from './TradeAlertPopupPreview';
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
import LlmSettingsPanel from './settings/LlmSettingsPanel';
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
import type { LlmAppSettings } from '../utils/llmSettings';
import { isTradingSettingsCategory } from '../utils/tradingAccess';
import {
CHART_LEGEND_SETTING_ITEMS,
@@ -176,6 +178,8 @@ interface SettingsPageProps {
onVerificationIssueNotify?: (v: boolean) => void;
trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
llmSettings?: LlmAppSettings;
onLlmSettingsChange?: (s: LlmAppSettings) => void;
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
initialCategory?: SettingsCategoryId;
/** 정식 로그인 여부 (게스트는 매매·전략 설정 탭 차단) */
@@ -277,6 +281,13 @@ const IcTrendSearch = () => (
<circle cx="18" cy="6" r="2" fill="currentColor" stroke="none"/>
</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 = () => (
<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"/>
@@ -304,6 +315,7 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'trading', label: '매매설정', icon: <IcTrading />, desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' },
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
{ id: 'llm', label: 'LLM 설정', icon: <IcLlm />, desc: 'AI 전략 검증용 LLM 서버 주소·포트·모델·타임아웃' },
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
{ id: 'network', label: '네트워크', icon: <IcNetwork />, desc: 'API 서버 주소, WebSocket, 연결 상태' },
{ id: 'admin', label: '관리자 설정', icon: <IcAdmin />, desc: '사용자·역할·메뉴 접근 권한 관리 (관리자 비밀번호 필요)' },
@@ -2008,6 +2020,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onVerificationIssueNotify,
trendSearchSettings,
onTrendSearchSettingsChange,
llmSettings,
onLlmSettingsChange,
initialCategory,
isFormalLogin = true,
onRequireFormalLogin,
@@ -2180,6 +2194,16 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
<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 (
<StrategyPanel
liveMarket={liveMarket}
@@ -49,6 +49,13 @@ import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
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_OPEN_KEY = 'seval-left-open';
@@ -92,6 +99,13 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [reportOpen, setReportOpen] = useState(false);
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
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 reportGenRef = useRef(0);
const evalSessionRef = useRef(0);
@@ -369,6 +383,80 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading;
const aiVerifyDisabled = reportDisabled
|| evalLoading
|| chartLoading
|| selectedBarTimeSec == null
|| !snapshot;
const runAiVerify = useCallback(async () => {
if (aiVerifyDisabled || !selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) return;
const gen = ++aiVerifyGenRef.current;
setAiVerifyOpen(true);
setAiVerifyLoading(true);
setAiVerifyError(null);
setAiVerifyResult(null);
setAiVerifyContext(null);
try {
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
const context = buildStrategyEvaluationAiVerifyContext({
strategy: selectedStrategy,
strategyId: selectedStrategyId,
market,
timeframe: chartTimeframe,
bars,
selectedBarIndex,
snapshot,
evalLoading,
evalError,
chartSignals: backtestSignals,
barSignalHighlight,
indicatorParams,
evaluationBarCount: Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length),
});
setAiVerifySummary(summarizeVerifyContext(context));
setAiVerifyContext(context);
const result = await requestStrategyEvaluationAiVerify(context);
if (gen !== aiVerifyGenRef.current) return;
setAiVerifyResult(result);
} catch (e) {
if (gen !== aiVerifyGenRef.current) return;
setAiVerifyError(e instanceof Error ? e.message : 'AI 검증 요청 실패');
} finally {
if (gen === aiVerifyGenRef.current) {
setAiVerifyLoading(false);
}
}
}, [
aiVerifyDisabled,
selectedStrategyId,
selectedStrategy,
selectedBarTimeSec,
market,
chartTimeframe,
bars,
selectedBarIndex,
snapshot,
evalLoading,
evalError,
backtestSignals,
barSignalHighlight,
getEvalParams,
]);
const handleOpenAiVerify = useCallback(() => {
void runAiVerify();
}, [runAiVerify]);
const handleCloseAiVerify = useCallback(() => {
++aiVerifyGenRef.current;
setAiVerifyOpen(false);
setAiVerifyLoading(false);
}, []);
const handleOpenReport = useCallback(async () => {
if (reportDisabled || !selectedStrategyId || !selectedStrategy) return;
@@ -618,6 +706,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
onOpenReport={() => { void handleOpenReport(); }}
reportDisabled={reportDisabled}
reportLoading={reportLoading}
onOpenAiVerify={handleOpenAiVerify}
aiVerifyDisabled={aiVerifyDisabled}
aiVerifyLoading={aiVerifyLoading && aiVerifyOpen}
marketTickers={marketFeed.tickers}
marketInfos={marketFeed.marketInfos}
marketLoading={marketFeed.loading}
@@ -711,6 +802,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
onClose={() => setReportOpen(false)}
model={reportModel}
/>
<StrategyEvaluationAiVerifyModal
open={aiVerifyOpen}
onClose={handleCloseAiVerify}
loading={aiVerifyLoading}
error={aiVerifyError}
contextSummary={aiVerifySummary}
verifyContext={aiVerifyContext}
result={aiVerifyResult}
onRetry={() => { void runAiVerify(); }}
/>
</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;
reportDisabled?: boolean;
reportLoading?: boolean;
/** AI 전략평가 검증 (LLM) */
onOpenAiVerify?: () => void;
aiVerifyDisabled?: boolean;
aiVerifyLoading?: boolean;
/** StrategyEvaluationPage 공유 ticker (종목 탭·검색과 단일 useMarketTicker) */
marketTickers?: Map<string, TickerData>;
marketInfos?: MarketInfo[];
@@ -133,6 +137,9 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onOpenReport,
reportDisabled = false,
reportLoading = false,
onOpenAiVerify,
aiVerifyDisabled = false,
aiVerifyLoading = false,
marketTickers,
marketInfos,
marketLoading,
@@ -611,6 +618,26 @@ const StrategyEvaluationChart: React.FC<Props> = ({
)}
</div>
<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 && (
<button
type="button"