Files
ai-trade/modules/analysis/model_validator.py
gahusb 0aebca7ff0 v3.1 과매수 방지, 앙상블 학습, KRX 캘린더 기반 장중 전용 운영 구현
[잔고 관리]
- _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>
2026-03-29 05:21:23 +09:00

349 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
모델 검증 시스템 (Market-Regime Aware Model Validator)
- 백테스트 기반 현재 LSTM v3 성능 검증
- 코스피 레짐별 모델 적합성 평가
- 코스피 6300 강세장 시나리오 대응 점검
- 모델 교체 권고 보고서 생성
사용법:
validator = ModelValidator()
report = validator.validate(ticker, ohlcv_data, strategy_fn, kospi_price=2600)
print(report.summary())
validator.send_alert(report) # 텔레그램 알림 (심각한 경우만)
"""
import os
import json
import time
from dataclasses import dataclass, field
from typing import Optional, List
from modules.config import Config
from modules.analysis.backtest import Backtester, BacktestResult
from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis
# 모델 적합성 최소 기준
_MIN_SHARPE = 0.5
_MIN_WIN_RATE = 50.0 # %
_MAX_MDD = -20.0 # % (초과 시 문제)
_MIN_PROFIT_FACTOR = 1.2
_CACHE_TTL_SECONDS = 86400 # 24시간
@dataclass
class ValidationReport:
"""모델 검증 보고서"""
ticker: str
kospi_level: float
regime: str
regime_description: str
backtest_result: Optional[BacktestResult]
model_suitable: bool
suitability_score: float # 0~1
issues: List[str] = field(default_factory=list)
recommendations: List[str] = field(default_factory=list)
alternative_models: List[str] = field(default_factory=list)
regime_strategy_hint: str = ""
risk_level: str = "LOW"
def summary(self) -> str:
lines = [
"=" * 55,
f"🔍 모델 검증 보고서 [{self.ticker}]",
"=" * 55,
f"코스피 수준 : {self.kospi_level:.0f} ({self.regime_description})",
f"시장 레짐 : {self.regime} [리스크: {self.risk_level}]",
f"모델 적합성 : {'✅ 적합' if self.model_suitable else '⚠️ 부적합'} "
f"({self.suitability_score:.0%})",
]
if self.backtest_result:
bt = self.backtest_result
lines += [
"",
"📊 백테스트 성과",
f" 총 수익률 : {bt.total_return_pct:+.2f}%",
f" Sharpe Ratio : {bt.sharpe_ratio:.3f}",
f" Max Drawdown : {bt.max_drawdown_pct:.2f}%",
f" 승률 : {bt.win_rate:.1f}% ({bt.winning_trades}/{bt.total_trades})",
f" 손익비(PF) : {bt.profit_factor:.2f}",
]
if self.issues:
lines.append("")
lines.append(f"⚠️ 발견된 문제 ({len(self.issues)}건)")
for issue in self.issues:
lines.append(f" - {issue}")
if self.recommendations:
lines.append("")
lines.append("💡 권고사항")
for rec in self.recommendations:
lines.append(f"{rec}")
if self.alternative_models:
lines.append("")
lines.append("🔄 대안 모델 목록")
for model in self.alternative_models:
lines.append(f"{model}")
if self.regime_strategy_hint:
lines.append("")
lines.append(f"📌 레짐 전략: {self.regime_strategy_hint}")
lines.append("=" * 55)
return "\n".join(lines)
def is_critical(self) -> bool:
"""즉각적인 조치가 필요한 수준인지 (텔레그램 알림 기준)"""
if not self.model_suitable and self.suitability_score < 0.4:
return True
if self.backtest_result and self.backtest_result.sharpe_ratio < 0:
return True
if self.backtest_result and self.backtest_result.max_drawdown_pct < -30:
return True
return False
class ModelValidator:
"""
LSTM v3 모델 검증기
검증 흐름:
1. 시장 레짐 감지 (코스피 수준)
2. 백테스트 실행 (선택)
3. 레짐별 모델 적합성 평가
4. 종합 보고서 생성
5. 심각한 경우 텔레그램 알림
"""
_CACHE_FILE = "model_validation_cache.json"
def __init__(self):
self._cache_path = os.path.join(Config.DATA_DIR, self._CACHE_FILE)
self._cache: dict = self._load_cache()
def _load_cache(self) -> dict:
if os.path.exists(self._cache_path):
try:
with open(self._cache_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_cache(self):
try:
with open(self._cache_path, "w", encoding="utf-8") as f:
json.dump(self._cache, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[Validator] 캐시 저장 실패: {e}")
def validate(
self,
ticker: str,
ohlcv_data: dict,
strategy_fn=None,
kospi_price: float = 2500.0,
kospi_change_pct: float = 0.0,
run_backtest: bool = True,
) -> ValidationReport:
"""
모델 검증 실행
Args:
ticker: 종목 코드
ohlcv_data: OHLCV 딕셔너리
strategy_fn: 백테스트용 전략 함수 (None이면 백테스트 생략)
kospi_price: 현재 코스피 지수
kospi_change_pct: 코스피 당일 등락률
run_backtest: 백테스트 실행 여부
Returns:
ValidationReport
"""
issues: List[str] = []
recommendations: List[str] = []
# ── 1. 시장 레짐 감지 ────────────────────────────────
regime_analysis: RegimeAnalysis = MarketRegimeDetector.detect(
kospi_price, kospi_change_pct
)
# ── 2. 백테스트 (선택) ───────────────────────────────
backtest_result: Optional[BacktestResult] = None
if run_backtest and strategy_fn is not None:
try:
backtester = Backtester()
backtest_result = backtester.run(ohlcv_data, strategy_fn, ticker)
except Exception as e:
issues.append(f"백테스트 실행 오류: {e}")
# ── 3. 백테스트 결과 기준 위반 체크 ─────────────────
bt_sharpe = backtest_result.sharpe_ratio if backtest_result else None
bt_winrate = backtest_result.win_rate if backtest_result else None
bt_mdd = backtest_result.max_drawdown_pct if backtest_result else None
bt_pf = backtest_result.profit_factor if backtest_result else None
if backtest_result:
if bt_sharpe < _MIN_SHARPE:
issues.append(
f"Sharpe Ratio 미흡: {bt_sharpe:.3f} (최소 {_MIN_SHARPE})"
)
recommendations.append("LSTM 피처 확장 또는 모델 아키텍처 재검토")
if bt_winrate < _MIN_WIN_RATE:
issues.append(
f"승률 미흡: {bt_winrate:.1f}% (최소 {_MIN_WIN_RATE:.0f}%)"
)
recommendations.append("매수 진입 임계값 상향 조정 (+0.05)")
if bt_mdd < _MAX_MDD:
issues.append(
f"MDD 과다: {bt_mdd:.2f}% (허용 {_MAX_MDD:.0f}%)"
)
recommendations.append("ATR 손절 배수 축소 (ATR×2 → ATR×1.5)")
if bt_pf < _MIN_PROFIT_FACTOR:
issues.append(
f"손익비 미흡: {bt_pf:.2f} (최소 {_MIN_PROFIT_FACTOR})"
)
recommendations.append("익절 배수 확대 (ATR×3 → ATR×4)")
# ── 4. 레짐 기반 모델 적합성 평가 ───────────────────
regime_validation = MarketRegimeDetector.validate_model_for_regime(
regime_analysis.regime,
backtest_sharpe=bt_sharpe,
backtest_winrate=bt_winrate,
backtest_mdd=bt_mdd,
)
if not regime_validation["is_suitable"]:
issues.append(
f"레짐 부적합: {regime_analysis.regime.value} 환경에서 "
f"LSTM v3 한계 감지"
)
recommendations.append(regime_validation["recommendation"])
# 코스피 6300 특별 경고
if kospi_price >= 5000:
issues.append(
f"⚠️ 코스피 {kospi_price:.0f} - 역사적 극고점 수준 "
"LSTM 비선형 패턴 포착 한계 주의"
)
recommendations.append(
"Temporal Fusion Transformer(TFT) 또는 Mamba 모델 전환 검토"
)
# ── 5. 종합 적합성 점수 ──────────────────────────────
suitability_score = regime_validation["confidence_score"]
# 문제 건수에 따라 감점 (건당 10%, 최대 50% 감점)
penalty = min(len(issues) * 0.10, 0.50)
suitability_score = max(0.0, suitability_score - penalty)
suitability_score = round(suitability_score, 3)
# ── 6. 보고서 생성 ───────────────────────────────────
report = ValidationReport(
ticker=ticker,
kospi_level=kospi_price,
regime=regime_analysis.regime.value,
regime_description=regime_analysis.description,
backtest_result=backtest_result,
model_suitable=(suitability_score >= 0.5 and not regime_validation["should_replace"]),
suitability_score=suitability_score,
issues=issues,
recommendations=list(set(recommendations)), # 중복 제거
alternative_models=regime_validation.get("alternative_models", []),
regime_strategy_hint=regime_analysis.recommended_strategy,
risk_level=regime_analysis.risk_level,
)
# ── 7. 캐시 저장 ─────────────────────────────────────
self._cache[ticker] = {
"timestamp": time.time(),
"kospi_level": kospi_price,
"regime": regime_analysis.regime.value,
"suitability_score": suitability_score,
"should_replace": regime_validation["should_replace"],
"issue_count": len(issues),
}
self._save_cache()
return report
def get_cached(self, ticker: str) -> Optional[dict]:
"""캐시된 검증 결과 반환 (24시간 이내)"""
cached = self._cache.get(ticker)
if not cached:
return None
if time.time() - cached.get("timestamp", 0) > _CACHE_TTL_SECONDS:
return None
return cached
def send_alert(self, report: ValidationReport):
"""심각한 검증 결과 텔레그램 알림"""
if not report.is_critical():
return
try:
from modules.services.telegram import TelegramMessenger
msg = (
f"🚨 [모델 경고] {report.ticker}\n"
f"코스피 {report.kospi_level:.0f} | 레짐: {report.regime}\n"
f"적합성: {report.suitability_score:.0%}\n"
)
if report.issues:
msg += "문제:\n" + "\n".join(f"{i}" for i in report.issues[:3])
if report.alternative_models:
msg += f"\n권고 모델: {report.alternative_models[0]}"
TelegramMessenger().send_message(msg)
except Exception:
pass
def generate_regime_report(self, kospi_price: float) -> str:
"""코스피 수준만으로 빠른 레짐 보고서 생성 (백테스트 없음)"""
regime_analysis = MarketRegimeDetector.detect(kospi_price)
validation = MarketRegimeDetector.validate_model_for_regime(regime_analysis.regime)
lines = [
"=" * 55,
f"📈 코스피 {kospi_price:.0f} 레짐 분석",
"=" * 55,
f"레짐 : {regime_analysis.regime.value}",
f"설명 : {regime_analysis.description}",
f"리스크 수준 : {regime_analysis.risk_level}",
"",
"─ 전략 파라미터 조정 ─",
f"매수 임계값 : {'+' if regime_analysis.buy_threshold_adj >= 0 else ''}"
f"{regime_analysis.buy_threshold_adj:+.2f} 조정",
f"포지션 크기 : x{regime_analysis.position_size_adj:.2f}",
f"LSTM 가중치 : {'+' if regime_analysis.lstm_weight_adj >= 0 else ''}"
f"{regime_analysis.lstm_weight_adj:+.2f}",
"",
"─ 모델 평가 ─",
f"현재 모델 적합: {'' if validation['is_suitable'] else '⚠️'} "
f"(신뢰도 {validation['confidence_score']:.0%})",
f"교체 필요 : {'' if validation['should_replace'] else '아니오'}",
f"권고사항 : {validation['recommendation']}",
]
if validation["alternative_models"]:
lines.append("")
lines.append("대안 모델 목록:")
for model in validation["alternative_models"]:
lines.append(f"{model}")
lines.append("")
lines.append(f"📌 전략: {regime_analysis.recommended_strategy}")
lines.append("=" * 55)
return "\n".join(lines)
# 전역 싱글톤
_validator_instance: Optional[ModelValidator] = None
def get_validator() -> ModelValidator:
"""ModelValidator 싱글톤 반환"""
global _validator_instance
if _validator_instance is None:
_validator_instance = ModelValidator()
return _validator_instance