ai 전략 agent 수정

This commit is contained in:
Macbook
2026-06-30 22:29:14 +09:00
parent 25cbadbffb
commit a3f65c26d0
5 changed files with 173 additions and 24 deletions
@@ -24,8 +24,8 @@ import java.util.regex.Pattern;
/**
* AI 전략 에이전트 — 자연어 멀티턴 대화로 전략 DSL(LogicNode 트리)을 생성/수정한다.
*
* <p>설정에 저장된 LLM(OpenAI 호환)을 사용하며, 정보가 부족하면 되묻고(action=ask),
* 충분하면 전략을 생성/수정한다(action=build).
* <p>설정에 저장된 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": <LogicNode 또는 null>, "sellCondition": <LogicNode 또는 null> }
```
- 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처럼 기간을 알려주세요. 캔들은 어떤 분봉인가요?"
+46 -11
View File
@@ -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);
}, []);
@@ -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<Props> = ({ getContext, onApplyStrategy }) => {
const [turns, setTurns] = useState<Turn[]>([]);
const [input, setInput] = useState('');
@@ -61,11 +75,37 @@ const AiStrategyPanel: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 => {
+1 -1
View File
@@ -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;
+3 -2
View File
@@ -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<StrategyAgentResult> {
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 {