""" AI 전문가 회의 시스템 (Multi-Agent Council) - 4명의 전문가 에이전트가 독립 분석 후 의장 AI가 최종 결정 - 코스피 레짐 기반 모델 교체 권고 - process.py 분석 결과를 입력받아 심층 검토 수행 흐름: 전문가 1~4 (각 역할별 Ollama 호출) ↓ 의장 AI (전문가 의견 취합 + 최종 결정 + 모델 건전성 평가) ↓ CouncilDecision (결정 + 모델 교체 권고 + 회의록) """ import json import time from dataclasses import dataclass, field from typing import Optional, List, Any from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis @dataclass class ExpertOpinion: """개별 전문가 의견""" expert_name: str role: str decision: str # BUY / SELL / HOLD confidence: float # 0~1 reasoning: str key_concern: str model_feedback: str # 현재 AI 모델 적합성 평가 @dataclass class CouncilDecision: """회의 최종 결정""" final_decision: str # BUY / SELL / HOLD consensus_score: float # 0~1 (1 = 만장일치) confidence: float # 0~1 majority_reasoning: str # 주요 결정 근거 dissenting_views: str # 소수 의견 model_health_score: float # 0~1 (현재 모델 신뢰도) model_replacement_recommended: bool # 모델 교체 필요 여부 recommended_model: str # 교체 권고 모델명 council_summary: str # 회의 전체 요약 expert_opinions: List[dict] = field(default_factory=list) # 전문가 페르소나 정의 _EXPERTS = [ { "name": "기술분석가", "role": "technical", "persona": ( "20년 경력의 코스피 전문 기술분석가. " "RSI, MACD, 볼린저밴드, 추세선, 거래량 분석을 주로 사용. " "단기 가격 모멘텀과 지지/저항 구간을 중시함." ), "focus": ( "RSI 과매수/과매도, 볼린저밴드 위치, ADX 추세 강도, " "거래량 급증, 멀티타임프레임 정렬 여부를 핵심 근거로 사용하세요." ), }, { "name": "퀀트전문가", "role": "quant", "persona": ( "AI/ML 기반 퀀트 투자 전문가. " "LSTM 예측 신뢰도, 통계적 유의성, 백테스트 성과를 중시. " "모델의 현재 시장 환경 적합성을 항상 평가함." ), "focus": ( "LSTM 신뢰도와 예측 방향을 중심으로 분석하세요. " "현재 코스피 레짐에서 LSTM v3 모델이 적합한지 반드시 평가하고, " "더 나은 대안 모델이 있으면 구체적으로 제안하세요." ), }, { "name": "리스크관리자", "role": "risk", "persona": ( "글로벌 헤지펀드 리스크 관리 전문가. " "포지션 사이징, 최대 낙폭(MDD), VaR, 손절 기준을 최우선으로 고려. " "수익보다 손실 방어를 먼저 생각함." ), "focus": ( "변동성 대비 포지션 크기 적절성, 손절 기준 타당성, " "현재 보유 중이라면 추가 하락 리스크를 집중 평가하세요." ), }, { "name": "거시경제분석가", "role": "macro", "persona": ( "글로벌 매크로 및 한국 증시 전문가. " "코스피 지수 수준, 원/달러 환율, 미국 금리, 외국인 수급을 중시. " "현재 시장이 역사적으로 어떤 위치인지 판단함." ), "focus": ( "코스피 지수 현재 수준이 역사적으로 어떤 의미인지, " "이 가격대에서 매수/보유가 타당한지 거시경제 관점에서 평가하세요." ), }, ] def _build_expert_prompt(expert: dict, ticker: str, data: dict) -> str: """전문가 역할에 맞는 분석 프롬프트 생성""" kospi = data.get("kospi_price", 2500) regime_label = MarketRegimeDetector.get_regime_label(kospi) base = ( f"종목: {ticker} | 현재가: {data.get('current_price', 0):,.0f}원\n" f"코스피: {kospi:.0f} [{regime_label}]\n" f"시장상태: {data.get('macro_state', 'SAFE')}\n" f"---기술지표---\n" f"기술점수: {data.get('tech_score', 0.5):.3f} | " f"RSI: {data.get('rsi', 50):.1f} | ADX: {data.get('adx', 20):.1f}\n" f"변동성: {data.get('volatility', 2.0):.2f}% | BB위치: {data.get('bb_zone', '중간')}\n" f"MTF정렬: {data.get('mtf_alignment', 'N/A')}\n" f"---AI모델---\n" f"LSTM예측: {data.get('lstm_predicted', 0):,.0f}원 " f"(변화율: {data.get('lstm_change_rate', 0):+.2f}%)\n" f"LSTM신뢰도: {data.get('ai_confidence', 0.5):.2f} | " f"LSTM점수: {data.get('lstm_score', 0.5):.3f}\n" f"---수급/감성---\n" f"감성점수: {data.get('sentiment_score', 0.5):.3f} | " f"수급점수: {data.get('investor_score', 0):.3f}\n" f"외인순매수: {data.get('frgn_net_buy', 0):+,} " f"({data.get('consecutive_frgn_buy', 0)}일 연속)\n" f"---포지션---\n" f"보유중: {data.get('is_holding', False)} | " f"보유수익률: {data.get('holding_yield', 0):+.2f}%\n" f"통합점수: {data.get('total_score', 0.5):.3f}\n" ) role_addition = ( f"\n당신은 {expert['persona']}\n" f"분석 초점: {expert['focus']}\n" ) output_format = ( "\n반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 금지):\n" "{\n" ' "decision": "BUY" 또는 "SELL" 또는 "HOLD",\n' ' "confidence": 0.0~1.0,\n' ' "reasoning": "주요 판단 근거 (1~2문장, 한국어)",\n' ' "key_concern": "가장 우려되는 리스크 (1문장, 한국어)",\n' ' "model_feedback": "현재 LSTM v3 모델이 이 시장 환경에서 적합한지 평가 (1문장)"\n' "}" ) return base + role_addition + output_format def _build_chairman_prompt( ticker: str, opinions: List[ExpertOpinion], data: dict, regime: RegimeAnalysis, ) -> str: """의장 AI 최종 결정 프롬프트""" opinions_text = "\n".join([ f"[{op.expert_name}] {op.decision} (확신도: {op.confidence:.2f})\n" f" 근거: {op.reasoning}\n" f" 우려: {op.key_concern}\n" f" 모델평가: {op.model_feedback}" for op in opinions ]) votes = [op.decision for op in opinions] buy_n = votes.count("BUY") sell_n = votes.count("SELL") hold_n = votes.count("HOLD") avg_conf = sum(op.confidence for op in opinions) / max(len(opinions), 1) return ( "당신은 AI 투자 전문가 회의를 주재하는 의장입니다.\n\n" f"=== 종목: {ticker} ===\n" f"현재가: {data.get('current_price', 0):,.0f}원 | " f"코스피: {data.get('kospi_price', 2500):.0f}\n" f"시장 레짐: {regime.regime.value} ({regime.description})\n" f"레짐 권고: {regime.model_recommendation}\n\n" f"=== 전문가 의견 ===\n{opinions_text}\n\n" f"=== 투표: 매수 {buy_n} / 매도 {sell_n} / 보유 {hold_n} " f"(평균 확신도: {avg_conf:.2f}) ===\n\n" "당신의 임무:\n" "1. 4명 의견을 종합하여 최종 매매 결정\n" f"2. LSTM v3 모델이 코스피 {data.get('kospi_price', 2500):.0f} 레짐에서 적합한지 평가\n" "3. 필요 시 대안 모델 구체적으로 권고\n\n" "반드시 아래 JSON 형식으로만 응답하세요:\n" "{\n" ' "final_decision": "BUY" 또는 "SELL" 또는 "HOLD",\n' ' "consensus_score": 0.0~1.0,\n' ' "confidence": 0.0~1.0,\n' ' "majority_reasoning": "최종 결정 근거 2~3문장 (한국어)",\n' ' "dissenting_views": "소수 의견 요약 (없으면 빈 문자열)",\n' ' "model_health_score": 0.0~1.0,\n' ' "model_replacement_recommended": true 또는 false,\n' ' "recommended_model": "교체 권고 모델명 (없으면 \'현재 모델 유지\')",\n' ' "council_summary": "회의 전체 요약 3~4문장 (한국어)"\n' "}" ) def _parse_json_response(raw: Optional[str]) -> Optional[dict]: """LLM 응답에서 JSON 추출 (폴백 포함)""" if not raw: return None try: return json.loads(raw) except json.JSONDecodeError: import re match = re.search(r'\{[\s\S]*\}', raw) if match: try: return json.loads(match.group()) except json.JSONDecodeError: pass return None def _vote_fallback(opinions: List[ExpertOpinion]) -> CouncilDecision: """의장 AI 실패 시 단순 다수결 폴백""" from collections import Counter if not opinions: return CouncilDecision( final_decision="HOLD", consensus_score=0.5, confidence=0.5, majority_reasoning="분석 데이터 부족", dissenting_views="", model_health_score=0.5, model_replacement_recommended=False, recommended_model="현재 모델 유지", council_summary="전문가 의견 수집 실패로 HOLD 처리", ) votes = [op.decision for op in opinions] final = Counter(votes).most_common(1)[0][0] avg_conf = sum(op.confidence for op in opinions) / len(opinions) vote_counts = Counter(votes) consensus = vote_counts[final] / len(votes) return CouncilDecision( final_decision=final, consensus_score=round(consensus, 3), confidence=round(avg_conf, 3), majority_reasoning=f"전문가 {vote_counts[final]}/{len(votes)} 다수결 결과", dissenting_views="", model_health_score=0.5, model_replacement_recommended=False, recommended_model="현재 모델 유지", council_summary="의장 AI 오류 - 전문가 투표로 대체", expert_opinions=[ {"name": op.expert_name, "decision": op.decision, "confidence": op.confidence, "reasoning": op.reasoning} for op in opinions ], ) class AICouncil: """ AI 전문가 회의 시스템 사용 방법: council = AICouncil(llm_client) decision = council.convene(ticker, analysis_data, regime_analysis) fast_mode=True 시 전문가 생략, 의장 AI 단독 판단 (속도 약 4배 향상) llm_client: GeminiLLMClient 또는 OllamaManager (request_inference 인터페이스 공용) """ def __init__(self, llm_client: Any = None): self._ollama = llm_client # 내부 변수명 유지 (하위호환) def _get_ollama(self) -> Any: if self._ollama is None: from modules.services.llm_client import get_llm_client self._ollama = get_llm_client() return self._ollama def _ask_expert(self, expert: dict, ticker: str, data: dict) -> ExpertOpinion: """단일 전문가 의견 수집""" prompt = _build_expert_prompt(expert, ticker, data) raw = self._get_ollama().request_inference(prompt) parsed = _parse_json_response(raw) if parsed: return ExpertOpinion( expert_name=expert["name"], role=expert["role"], decision=str(parsed.get("decision", "HOLD")).upper(), confidence=float(parsed.get("confidence", 0.5)), reasoning=str(parsed.get("reasoning", "")), key_concern=str(parsed.get("key_concern", "")), model_feedback=str(parsed.get("model_feedback", "")), ) # 파싱 실패 → 중립 print(f"[Council] {expert['name']} 응답 파싱 실패 → HOLD 처리") return ExpertOpinion( expert_name=expert["name"], role=expert["role"], decision="HOLD", confidence=0.5, reasoning="응답 파싱 실패", key_concern="", model_feedback="", ) def convene( self, ticker: str, analysis_data: dict, regime_analysis: Optional[RegimeAnalysis] = None, fast_mode: bool = True, ) -> CouncilDecision: """ 전문가 회의 소집 및 최종 결정 Args: ticker: 종목 코드 analysis_data: process.py 분석 결과 딕셔너리 regime_analysis: MarketRegimeDetector.detect() 결과 fast_mode: True=의장 AI 단독(빠름), False=전문가 4명+의장(심층) Returns: CouncilDecision """ # 레짐 기본값 if regime_analysis is None: kospi = analysis_data.get("kospi_price", 2500) regime_analysis = MarketRegimeDetector.detect(kospi) expert_opinions: List[ExpertOpinion] = [] if not fast_mode: print(f"[Council] {ticker} - 전문가 회의 시작 (4명)") for expert in _EXPERTS: print(f"[Council] {expert['name']} 분석 중...") opinion = self._ask_expert(expert, ticker, analysis_data) expert_opinions.append(opinion) time.sleep(0.3) # Ollama 연속 요청 간격 else: print(f"[Council] {ticker} - Fast mode (의장 단독)") # 의장 AI 취합 chairman_prompt = _build_chairman_prompt( ticker, expert_opinions, analysis_data, regime_analysis ) raw_chairman = self._get_ollama().request_inference(chairman_prompt) parsed_chairman = _parse_json_response(raw_chairman) if parsed_chairman: decision = CouncilDecision( final_decision=str(parsed_chairman.get("final_decision", "HOLD")).upper(), consensus_score=float(parsed_chairman.get("consensus_score", 0.5)), confidence=float(parsed_chairman.get("confidence", 0.5)), majority_reasoning=str(parsed_chairman.get("majority_reasoning", "")), dissenting_views=str(parsed_chairman.get("dissenting_views", "")), model_health_score=float(parsed_chairman.get("model_health_score", 0.7)), model_replacement_recommended=bool( parsed_chairman.get("model_replacement_recommended", False) ), recommended_model=str( parsed_chairman.get("recommended_model", "현재 모델 유지") ), council_summary=str(parsed_chairman.get("council_summary", "")), expert_opinions=[ { "name": op.expert_name, "decision": op.decision, "confidence": op.confidence, "reasoning": op.reasoning, } for op in expert_opinions ], ) status_icon = "⚠️" if decision.model_replacement_recommended else "✅" print( f"[Council] {ticker} → {decision.final_decision} " f"(합의율: {decision.consensus_score:.0%}, " f"모델건전성: {decision.model_health_score:.0%}) " f"{status_icon}" ) if decision.model_replacement_recommended: print(f"[Council] 모델 교체 권고: {decision.recommended_model}") return decision # 의장 실패 → 투표 폴백 print(f"[Council] {ticker} - 의장 AI 실패, 투표 폴백 사용") return _vote_fallback(expert_opinions) def quick_validate( self, ticker: str, kospi_price: float, ai_confidence: float, backtest_sharpe: Optional[float] = None, ) -> dict: """ LLM 호출 없이 규칙 기반 빠른 모델 검증 Returns: { "regime": str, "model_ok": bool, "score": float, "recommendation": str, "should_replace": bool, } """ regime_analysis = MarketRegimeDetector.detect(kospi_price) validation = MarketRegimeDetector.validate_model_for_regime( regime_analysis.regime, backtest_sharpe=backtest_sharpe, ) # AI 신뢰도 하락 시 추가 감점 score = validation["confidence_score"] if ai_confidence < 0.4: score *= 0.8 return { "regime": regime_analysis.regime.value, "regime_description": regime_analysis.description, "model_ok": score >= 0.5 and not validation["should_replace"], "score": round(score, 3), "recommendation": validation["recommendation"], "should_replace": validation["should_replace"], "alternative_models": validation["alternative_models"], } # 전역 싱글톤 _council_instance: Optional[AICouncil] = None def get_council(llm_client: Any = None) -> AICouncil: """워커 프로세스 내 AICouncil 싱글톤 반환 (GeminiLLMClient 또는 OllamaManager 수용)""" global _council_instance if _council_instance is None: _council_instance = AICouncil(llm_client) return _council_instance