[잔고 관리] - _today_buy_total 인스턴스 변수로 당일 누적 매수 추적 (KIS T+2 미차감 보완) - MAX_BUY_PER_CYCLE, MAX_DAILY_BUY_RATIO 설정 추가 - available_deposit = max_daily_buy - effective_today_buy 계산 [앙상블 & 포지션 사이징] - AdaptiveEnsemble 실제 연동 (하드코딩 가중치 제거) - Kelly Criterion Half-Kelly 포지션 비중 계산 - SignalWeights.normalize() Water-Filling 알고리즘으로 경계 위반 해결 - _accuracy_weighted() 크기 가중 정확도로 통일 - ensemble_weights.json → ensemble_history.json 통합 [LLM 클라이언트] - GeminiLLMClient 추가 (Gemini → Ollama 폴백 체인) - _class_last_call_ts 클래스 변수로 워커 재시작 후에도 스로틀 유지 - Ollama 미실행 조기 감지 및 명확한 오류 메시지 [KIS API] - 모든 requests.get/post에 timeout=Config.HTTP_TIMEOUT 적용 - get_balance()에 today_buy_amt 필드 추가 [장중 전용 운영] - KRXCalendar: exchange_calendars 기반, 2024~2026 공휴일 하드코딩 폴백 - EOD 셧다운: 15:35에 전체 상태 저장 후 서버 자동 종료 - Watchdog: .eod_date 마커로 EOD 후 재시작 차단 - daily_launcher.py: 매일 08:30 실행, 휴장일 감지 후 봇 미시작 - Windows 작업 스케줄러 WebAI_DailyLauncher 등록 [텔레그램 스킬 수정] - PYTHONIOENCODING=utf-8 서브프로세스 환경 설정 (cp949 이모지 오류 해결) - /regime: IPC macro_indices 파싱 구현, --json 모드 input() 블로킹 제거 - /weights: ensemble_history.json 형식 파싱 업데이트 - /model_health: glob 패턴 *_v3.pt 수정 - /postmortem: 거래 없을 때 빈 JSON 출력으로 Telegram 오류 해결 - /macro: price=0 시 prev_close 폴백 표시 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
446 lines
17 KiB
Python
446 lines
17 KiB
Python
"""
|
|
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
|