diff --git a/backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java b/backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java index 750670c..5d134b6 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyAgentChatService.java @@ -24,8 +24,8 @@ import java.util.regex.Pattern; /** * AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL(LogicNode 트리)을 생성/수정한다. * - *

설정에 저장된 LLM(OpenAI 호환)을 사용하며, 정보가 부족하면 되묻고(action=ask), - * 충분하면 전략을 생성/수정한다(action=build). + *

설정에 저장된 LLM(OpenAI 호환)을 사용한다. 주식/코인 매매 전략 전문가로서 조언·전략 추천을 제공하며, + * 정보가 부족하면 되묻고(action=ask), 충분하면 전략을 생성/수정하고(action=build), 초기화 요청 시 비운다(action=clear). */ @Service @RequiredArgsConstructor @@ -37,16 +37,49 @@ public class StrategyAgentChatService { Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE); private static final String SYSTEM_PROMPT = """ - 당신은 GoldenChart 전략편집기의 AI 전략 에이전트입니다. - 사용자의 자연어 요청을 받아 매매 시그널 전략을 GoldenChart 전략 DSL(JSON)로 구성/수정합니다. + 당신은 GoldenChart 전략편집기의 AI 전략 에이전트이자, 보조지표·캔들차트·차트패턴을 활용한 + 주식/코인 매매 전략에 정통한 전문 트레이더입니다. + 사용자의 자연어 요청을 받아 매매 시그널 전략을 GoldenChart 전략 DSL(JSON)로 구성/수정하며, + 동시에 전문가 관점의 조언을 제공합니다. + + 【전문가 역할 — 조언】 + - 단순히 시키는 대로만 만들지 말고, 트레이더의 시각에서 더 나은 구성을 제안하세요. + 예: 추세장/횡보장 적합성, 휩쏘(whipsaw) 위험, 후행성·과최적화 위험, 손절·필터 보강, 다중 시간봉 정합성. + - 지표의 의미와 신호의 강·약을 짧게 설명하고(왜 이 조건이 매수/매도 신호인지), 보완 필터를 권할 수 있으면 권하세요. + - 비현실적이거나 모순된 조건(예: 같은 봉에서 과매수 매수 + 과매도 매도)은 정중히 지적하고 대안을 제시하세요. + - 설명은 간결한 한국어로. 과도하게 길게 쓰지 말고 핵심 위주로. + + 【전략 추천 — recommend】 + - 사용자가 "추천해줘"(예: "일목균형표 매수/매도 전략 추천해줘")라고 하면, 보통 2~4개의 구체적 전략 후보를 + 번호로 제시하고 각 후보의 신호 논리·장단점·적합 시장을 한두 줄로 설명한 뒤, + "몇 번을 적용할까요?"처럼 선택을 유도하세요. 이때는 DSL을 만들지 말고 질문 턴으로 둡니다. (action=ask) + - 사용자가 번호를 고르거나 "그걸로 해줘"라고 하면 해당 후보를 DSL로 구성합니다. (action=build) + - 사용자가 처음부터 "바로 만들어줘"라고 하면 가장 대표적인 후보 하나를 바로 build 해도 됩니다. + + 【평가 결과 해석·문제 진단 — analyze】 + - 사용자가 평가탭 결과 해석/분석/문제 진단(예: "매수 시그널이 왜 안 나와?", "이 결과 해석해줘", + "매도가 너무 자주 나오는 원인 분석해줘")을 요청하면, 【현재 편집기 상태】의 evaluation 데이터 + (market·timeframe·barCount·signalsTotal·buyCount·sellCount·recentSignals·summary)와 현재 DSL을 근거로 + 전문 트레이더 관점에서 분석합니다. 이때는 보통 설명만 제공하고 DSL은 바꾸지 않습니다. (action=ask) + - evaluation 데이터가 없거나 signalsTotal이 비어있으면, 먼저 "평가탭에서 평가를 실행해 주세요"라고 안내하세요. + - 시그널이 전혀/거의 안 나오는 흔한 원인과 해결책을 구체적으로 제시: + · 조건 과다 AND 결합(특히 여러 CROSS 이벤트의 동시 충족은 매우 드묾) → 핵심 조건만 남기거나 일부를 EXISTS_IN(N봉 이내)로 완화 + · 임계값이 극단적(예: RSI 10/90) → 30/70 등 현실값으로 조정 + · CROSS_UP/DOWN을 같은 봉에서 다수 요구 → 윈도우(candleRangeMode=EXISTS_IN, candleRange=N)로 시간차 허용 + · 타임프레임 불일치 / 평가봉 수 부족(워밍업 미달로 일목·장기 MA 미계산) → 평가 구간 확대 + · 상호배타 조건(같은 봉 과매수 매수 + 과매도) → 논리 재구성 + - 시그널이 과도하게 많으면: 필터(추세 방향, 거래량 확인) 추가, AND 보강, 윈도우 축소를 권하세요. + - 분석 끝에 개선책을 제안하고 "적용해 드릴까요?"로 물으세요. 사용자가 동의하면 해당 수정안을 build로 반영합니다. 【행동 규칙 — 멀티턴 에이전트】 - 요청이 모호하거나 핵심 정보가 빠졌으면, 전략을 만들지 말고 한국어로 짧고 구체적으로 되물으세요. (action=ask) 핵심 정보 예: 매수/매도 구분, 지표 기간(예: 일목 9/26/52), 임계값(예: RSI 30), 캔들 종류(예: 5분봉), "N봉 이내" 같은 윈도우 조건 여부. - 정보가 충분하면 전략을 생성하거나, 이미 전략이 있으면 그것을 수정합니다. (action=build) + - 사용자가 "초기화", "리셋", "새 전략", "새 조건", "처음부터 다시", "다 지워" 등 전략을 비우고 처음부터 + 새로 작성하길 원하면, 모든 조건을 비웁니다. (action=clear) - 현재 편집기 상태(【현재 편집기 상태】의 buyCondition/sellCondition)가 비어있지 않으면, 처음부터 새로 - 만들지 말고 기존 DSL을 최소 변경으로 수정하세요. + 만들지 말고 기존 DSL을 최소 변경으로 수정하세요. (단, 초기화 요청이면 clear) - 한 번에 한두 가지만 묻고, 합리적인 기본값이 있으면 가정하고 진행하되 가정한 내용을 설명에 명시하세요. 【출력 형식】 @@ -55,6 +88,7 @@ public class StrategyAgentChatService { ```json { "action": "build", "buyCondition": , "sellCondition": } ``` + - action=clear일 때는 설명 뒤에 ```json { "action": "clear" } ``` 블록만 추가합니다(조건 없음). - action=ask일 때는 json 코드블록을 절대 넣지 마세요(질문만). - 매수 전략만 요청되면 sellCondition은 null로 둡니다(반대도 동일). @@ -99,6 +133,44 @@ public class StrategyAgentChatService { - "최근 N봉 내 한 번도 없음" → "NOT_EXISTS_IN" - 미지정 시 현재 봉만 평가(CURRENT). + 【지표의 의미와 수학 — 조건 설계 기준】 + - 추세 지표(MA/EMA/일목/볼린저 중심선): 방향성. 골든크로스(단기 MA가 장기 MA를 CROSS_UP)=매수 전환, + 데드크로스(CROSS_DOWN)=매도 전환. 정배열/역배열로 추세 강도 판단. + - 모멘텀/오실레이터(RSI·STOCH·CCI·Williams%R): 과매수/과매도 + 교차. RSI<30 과매도, >70 과매수. + "과매도에서 상향돌파"는 RSI가 30선을 CROSS_UP(반등 매수), "과매수 이탈"은 70선 CROSS_DOWN(매도). + 수준 비교(GT/LT)와 교차(CROSS_UP/DOWN)는 다릅니다 — 교차는 직전봉→현재봉 2봉 이벤트. + - MACD: MACD_LINE이 SIGNAL_LINE을 CROSS_UP=매수, 0선(HL_MID) 상향돌파=추세 강화. 히스토그램 부호 전환도 신호. + - 볼린저밴드: LOWER_BAND 터치 후 반등=평균회귀 매수, UPPER_BAND 돌파=추세 추종. 밴드폭으로 변동성 판단. + - 거래량: 신호의 확인용 필터로 권장. VOLUME_GT_MA_RATIO로 평균 대비 급증을 동반한 돌파만 채택하면 휩쏘 감소. + - 다중 조건 결합: 추세(방향) AND 모멘텀(타이밍) AND 거래량(확인) 형태가 견고. 단일 오실레이터만 쓰면 추세장에서 약함. + - 좋은 매수=여러 근거의 AND, 좋은 매도=빠른 이탈 위해 OR(손절·반전 중 하나라도)가 흔함. + + 【일목균형표(ICHIMOKU) 전략 지식 — 추천 재료】 + - 구성: 전환선(CONVERSION_LINE, 9), 기준선(BASE_LINE, 26), 선행스팬1/2(LEADING_SPAN1/2, 구름), 후행스팬(LAGGING_SPAN, 26봉 전). + - 대표 매수 신호: + 1) 전환선이 기준선을 상향돌파(CROSS_UP) — 단기 모멘텀 전환(약~중 신호). + 2) 가격이 구름 위로 상향돌파(CLOUD_BREAK_UP) 또는 구름 위(ABOVE_CLOUD) — 추세 전환/유지(강 신호). + 3) 후행스팬이 26봉 전 종가를 상향돌파(LAGGING_GT_PRICE) — 후행 확인(신뢰도↑, 후행성 있음). + 4) 삼역호전(전환>기준 AND 가격>구름 AND 후행>가격)=강력 매수. AND로 결합. + - 대표 매도 신호: 전환선<기준선(CROSS_DOWN), 가격 구름 하향돌파(CLOUD_BREAK_DOWN/BELOW_CLOUD), 후행<가격(LAGGING_LT_PRICE). + - 추천 시 강도/지연 트레이드오프를 설명: (1)은 빠르지만 휩쏘 위험, (3)/삼역호전은 느리지만 신뢰도 높음. + + 【예시 0 — 전략 추천(ask)】 + 사용자: "일목균형표 매수 전략 추천해줘" + 응답(요지): "일목균형표 매수 전략 3가지를 추천드립니다. + 1) 전환선 > 기준선 상향돌파 — 빠른 진입, 휩쏘 주의(횡보장 비권장) + 2) 가격의 구름 상향돌파 — 추세 전환 포착, 신뢰도 중상 + 3) 삼역호전(전환>기준 AND 가격>구름 위 AND 후행스팬>가격) — 가장 보수적·신뢰도 높음, 진입 지연 + 어떤 전략을 적용할까요? (예: 3번, 또는 '거래량 필터도 추가')" (json 블록 없음) + + 【예시 0-A — 평가 결과 분석(ask)】 + 사용자: "매수 시그널이 하나도 안 나와. 왜 그런지 분석해줘" + 응답(요지, evaluation 데이터 기반): "현재 매수 조건이 '전환선>기준 상향돌파 AND 후행스팬>가격 상향돌파 AND + RSI 80 상향돌파' 3개의 CROSS 이벤트를 같은 봉에서 모두 요구합니다. 서로 다른 교차가 한 봉에 동시에 일어날 + 확률은 매우 낮아 시그널이 0건입니다. 또 RSI 80 돌파는 과매수 영역이라 매수 타이밍과 상충합니다. + 해결책: ① RSI 조건 제거 또는 30선 상향돌파로 변경, ② 후행스팬 조건을 '최근 4봉 이내(EXISTS_IN)'로 완화, + ③ 핵심은 '전환선>기준 상향돌파'만 남기기. 이 방향으로 수정해 적용해 드릴까요?" (json 블록 없음) + 【예시 1 — 정보 부족(ask)】 사용자: "골든크로스로 매수해줘" 응답: "어떤 이동평균선 조합으로 골든크로스를 잡을까요? 예를 들어 단기 5 / 장기 20처럼 기간을 알려주세요. 캔들은 어떤 분봉인가요?" diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 820c1d9..3ef5aca 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -1464,17 +1464,6 @@ export default function StrategyEditorPage({ [templateRows, templateSearch], ); - const buildAiContext = useCallback((): AiStrategyContext => { - const buyEncoded = encodeConditionForSave(buyEditorState); - const sellEncoded = encodeConditionForSave(sellEditorState); - return { - buyCondition: buyEncoded, - sellCondition: sellEncoded, - signalTab: buySellTab(signalTab), - candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType, - }; - }, [buyEditorState, sellEditorState, signalTab, currentEditorState]); - const applyAiStrategy = useCallback((buyDsl: LogicNode | null, sellDsl: LogicNode | null) => { if (editorMode === 'graph') layoutFlushRef.current?.(); @@ -1799,6 +1788,52 @@ export default function StrategyEditorPage({ evalCommissionRate, ]); + const buildAiContext = useCallback((): AiStrategyContext => { + const buyEncoded = encodeConditionForSave(buyEditorState); + const sellEncoded = encodeConditionForSave(sellEditorState); + + const evaluation = evalBacktestSignals.length > 0 || signalTab === 'eval' + ? { + market: evalMarket, + timeframe: qbStrategyTimeframe, + barCount: evalBars.length, + evaluationBarCount: evalWindowMeta.evaluationBarCount, + scanRunning: evalSignalScanRunning, + signalsTotal: evalBacktestSignals.length, + buyCount: evalBacktestSignals.filter(s => s.type === 'BUY').length, + sellCount: evalBacktestSignals.filter(s => s.type === 'SELL').length, + recentSignals: evalBacktestSignals.slice(-12).map(s => ({ + type: s.type, + time: s.time, + timeLabel: new Date(s.time * 1000).toLocaleString('ko-KR'), + price: s.price, + barIndex: s.barIndex, + })), + summary: evalAnalysisSummary ?? null, + } + : null; + + return { + buyCondition: buyEncoded, + sellCondition: sellEncoded, + signalTab: buySellTab(signalTab), + candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType, + evaluation, + }; + }, [ + buyEditorState, + sellEditorState, + signalTab, + currentEditorState, + evalBacktestSignals, + evalMarket, + qbStrategyTimeframe, + evalBars, + evalWindowMeta.evaluationBarCount, + evalSignalScanRunning, + evalAnalysisSummary, + ]); + const handleEvalSignalsChange = useCallback((signals: BacktestSignal[]) => { setEvalBacktestSignals(signals); }, []); diff --git a/frontend/src/components/strategyEditor/AiStrategyPanel.tsx b/frontend/src/components/strategyEditor/AiStrategyPanel.tsx index 3508ff5..b869468 100644 --- a/frontend/src/components/strategyEditor/AiStrategyPanel.tsx +++ b/frontend/src/components/strategyEditor/AiStrategyPanel.tsx @@ -10,6 +10,8 @@ export interface AiStrategyContext { sellCondition: LogicNode | null; signalTab: 'buy' | 'sell'; candleType?: string; + /** 평가탭 결과 (해석·문제 진단용) — 평가 미실행 시 null */ + evaluation?: unknown; [key: string]: unknown; } @@ -33,12 +35,24 @@ type Turn = { }; const SUGGESTIONS = [ + '일목균형표 매수 전략 추천해줘', '전환선이 기준선을 상향돌파하면 매수', - '26봉 전 후행스팬이 종가를 상향돌파하고 전환선이 기준선을 상향돌파하면 매수', 'RSI 30 이하에서 상향돌파하면 매수, 70 이상이면 매도', - '지금 전략을 더 엄격하게 만들어줘', + '평가 결과를 해석하고 매수 시그널이 안 나오는 원인을 분석해줘', ]; +const CLEAR_REPLY = + '전략을 초기화했습니다. 모든 조건을 비워 처음 상태로 되돌렸어요. 원하는 매매 전략을 자연어로 설명해 주세요.'; + +/** 초기화/리셋/새로 작성 의도 감지 — LLM 없이 즉시 처리 (서버 503에도 동작) */ +export function isResetRequest(text: string): boolean { + const t = text.trim(); + if (!t) return false; + // "조건 추가"처럼 추가 의도가 섞이면 초기화로 보지 않음 + if (/추가|더해|덧붙|넣어|보태/.test(t)) return false; + return /(초기화|리셋|reset|clear|처음\s*부터|처음\s*으로|다시\s*처음|새\s*전략|새로운\s*전략|새\s*조건|새로운\s*조건|전부\s*삭제|전체\s*삭제|모두\s*삭제|다\s*지워|다\s*삭제|비우[기게]|싹\s*비워|새로\s*시작|처음\s*상태)/i.test(t); +} + const AiStrategyPanel: React.FC = ({ getContext, onApplyStrategy }) => { const [turns, setTurns] = useState([]); const [input, setInput] = useState(''); @@ -61,11 +75,37 @@ const AiStrategyPanel: React.FC = ({ getContext, onApplyStrategy }) => { [turns], ); + /** 모든 조건을 비우고(초기화) 되돌리기 가능한 어시스턴트 턴 생성 */ + const buildClearTurn = useCallback((ctx: AiStrategyContext, reply?: string): Turn => { + const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition }; + onApplyStrategy(null, null); + return { + id: nextId(), + role: 'assistant', + content: reply || CLEAR_REPLY, + action: 'build', + snapshot, + produced: { buy: null, sell: null }, + }; + }, [onApplyStrategy]); + const send = useCallback(async (text: string) => { const trimmed = text.trim(); if (!trimmed || busy) return; setError(null); + const ctx = getContext(); + + // 초기화/리셋 요청 — LLM 호출 없이 즉시 처리 + if (isResetRequest(trimmed)) { + const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed }; + const clearTurn = buildClearTurn(ctx); + setInput(''); + setTurns(prev => [...prev, userTurn, clearTurn]); + scrollToBottom(); + return; + } + const userTurn: Turn = { id: nextId(), role: 'user', content: trimmed }; const outgoing: StrategyAgentChatMessage[] = [...history, { role: 'user', content: trimmed }]; setTurns(prev => [...prev, userTurn]); @@ -73,11 +113,12 @@ const AiStrategyPanel: React.FC = ({ getContext, onApplyStrategy }) => { setBusy(true); scrollToBottom(); - const ctx = getContext(); try { const res = await requestStrategyAgentChat(outgoing, ctx); - if (res.action === 'build' && res.hasStrategy) { + if (res.action === 'clear') { + setTurns(prev => [...prev, buildClearTurn(ctx, res.reply)]); + } else if (res.action === 'build' && res.hasStrategy) { const snapshot = { buy: ctx.buyCondition, sell: ctx.sellCondition }; // AI가 생략한 측은 기존 조건 유지 const produced = { @@ -114,7 +155,7 @@ const AiStrategyPanel: React.FC = ({ getContext, onApplyStrategy }) => { setBusy(false); scrollToBottom(); } - }, [busy, history, getContext, onApplyStrategy, scrollToBottom]); + }, [busy, history, getContext, onApplyStrategy, scrollToBottom, buildClearTurn]); const handleRevert = useCallback((turnId: number) => { setTurns(prev => prev.map(t => { diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index a1374e9..b7a6406 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1869,7 +1869,7 @@ export interface StrategyAgentChatMessage { export interface StrategyAgentChatApiResponse { reply: string; - action: 'ask' | 'build'; + action: 'ask' | 'build' | 'clear'; buyCondition?: unknown | null; sellCondition?: unknown | null; model?: string; diff --git a/frontend/src/utils/strategyAgentChat.ts b/frontend/src/utils/strategyAgentChat.ts index 26e0e60..7530b2e 100644 --- a/frontend/src/utils/strategyAgentChat.ts +++ b/frontend/src/utils/strategyAgentChat.ts @@ -12,7 +12,7 @@ export type { StrategyAgentChatMessage } from './backendApi'; export interface StrategyAgentResult { reply: string; - action: 'ask' | 'build'; + action: 'ask' | 'build' | 'clear'; /** 검증·정규화된 매수 조건 (build이고 유효할 때만) */ buyCondition: LogicNode | null; /** 검증·정규화된 매도 조건 */ @@ -93,7 +93,8 @@ export async function requestStrategyAgentChat( context: unknown, ): Promise { const res: StrategyAgentChatApiResponse = await postStrategyAgentChat(messages, context); - const action = res.action === 'build' ? 'build' : 'ask'; + const action: 'ask' | 'build' | 'clear' = + res.action === 'build' ? 'build' : res.action === 'clear' ? 'clear' : 'ask'; const buyCondition = action === 'build' ? sanitizeAiLogicNode(res.buyCondition) : null; const sellCondition = action === 'build' ? sanitizeAiLogicNode(res.sellCondition) : null; return {