Files
ai-trade/modules/analysis/ensemble.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

417 lines
17 KiB
Python

"""
앙상블 예측 모듈 (Phase 3-3)
- LSTM + 기술지표 + LLM 감성 → 적응형 가중치
- 과거 매매 결과 기반 가중치 자동 조정
- Kelly Criterion 기반 포지션 비중 계산
- process.py의 하드코딩된 w_tech/w_news/w_ai 대체
- 파일 mtime 기반 cross-process 동기화 (워커 ↔ 메인 프로세스)
"""
import os
import json
import time
import numpy as np
from dataclasses import dataclass
from typing import Dict, Optional
from modules.config import Config
@dataclass
class SignalWeights:
"""앙상블 가중치"""
tech: float = 0.35
sentiment: float = 0.30
lstm: float = 0.35
# 각 신호의 허용 범위
MIN_WEIGHT = 0.10
MAX_WEIGHT = 0.65
def normalize(self):
"""
경계 보존 정규화 (합=1, MIN≤각값≤MAX 동시 보장)
단순 1/2차 정규화는 경계 위반을 반복 유발하므로
반복 배분 알고리즘(Water-Filling) 사용:
1. 단순 정규화 (비율 유지)
2. 경계 위반 값 → 경계에 고정, 나머지에 잔여 비중 비례 배분
3. 모든 값이 경계 내에 들 때까지 반복 (최대 10회)
"""
MIN, MAX = self.MIN_WEIGHT, self.MAX_WEIGHT
vals = [max(MIN * 0.1, self.tech),
max(MIN * 0.1, self.sentiment),
max(MIN * 0.1, self.lstm)]
for _ in range(10):
total = sum(vals)
if total > 0:
vals = [v / total for v in vals]
fixed = [None, None, None]
has_violation = False
for i, v in enumerate(vals):
if v < MIN:
fixed[i] = MIN
has_violation = True
elif v > MAX:
fixed[i] = MAX
has_violation = True
if not has_violation:
break
fixed_sum = sum(f for f in fixed if f is not None)
remaining = 1.0 - fixed_sum
free = [(i, vals[i]) for i, f in enumerate(fixed) if f is None]
free_sum = sum(v for _, v in free)
new_vals = list(fixed)
if free and free_sum > 0:
factor = remaining / free_sum
for i, v in free:
new_vals[i] = v * factor
elif free:
per = remaining / len(free)
for i, _ in free:
new_vals[i] = per
vals = [v if v is not None else 0.0 for v in new_vals]
self.tech, self.sentiment, self.lstm = vals
return self
def to_dict(self):
return {"tech": self.tech, "sentiment": self.sentiment, "lstm": self.lstm}
@classmethod
def from_dict(cls, d):
return cls(tech=d.get("tech", 0.35),
sentiment=d.get("sentiment", 0.30),
lstm=d.get("lstm", 0.35))
class AdaptiveEnsemble:
"""
적응형 앙상블 가중치 관리자
핵심 로직:
1. 종목별 최근 N 매매의 결과를 추적
2. 어떤 신호가 정확했는지 소급 평가 (크기 가중 정확도)
3. 정확도가 높은 신호의 가중치를 점진적으로 증가
4. 시장 상황(ADX, 거시경제) 반영한 컨텍스트별 가중치 분리
5. Kelly Criterion 기반 최적 포지션 비중 제공
6. 파일 mtime 기반 cross-process 동기화 (워커 프로세스 갱신)
"""
def __init__(self, history_file=None, max_history=50):
self.max_history = max_history
self.history_file = history_file or os.path.join(
Config.DATA_DIR, "ensemble_history.json"
)
# {ticker: [{"tech_score": f, "sentiment_score": f, "lstm_score": f,
# "decision": str, "outcome": float}, ...]}
self._trade_history: Dict[str, list] = {}
# {context: SignalWeights} - context: "strong_trend" | "sideways" | "danger" | "default"
self._context_weights: Dict[str, SignalWeights] = {
"strong_trend": SignalWeights(tech=0.50, sentiment=0.20, lstm=0.30),
"sideways": SignalWeights(tech=0.30, sentiment=0.40, lstm=0.30),
"danger": SignalWeights(tech=0.20, sentiment=0.50, lstm=0.30),
"default": SignalWeights(tech=0.35, sentiment=0.30, lstm=0.35),
}
self._load_mtime: float = 0.0 # 마지막 파일 로드 시각
self._load()
# ──────────────────────────────────────────────
# 파일 I/O
# ──────────────────────────────────────────────
def _load(self):
if os.path.exists(self.history_file):
try:
with open(self.history_file, "r", encoding="utf-8") as f:
data = json.load(f)
self._trade_history = data.get("history", {})
weights_raw = data.get("weights", {})
for ctx, w in weights_raw.items():
self._context_weights[ctx] = SignalWeights.from_dict(w)
self._load_mtime = os.path.getmtime(self.history_file)
except Exception as e:
print(f"[Ensemble] Load failed: {e}")
def _save(self):
try:
data = {
"history": {k: v[-self.max_history:] for k, v in self._trade_history.items()},
"weights": {ctx: w.to_dict() for ctx, w in self._context_weights.items()}
}
with open(self.history_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
self._load_mtime = os.path.getmtime(self.history_file)
except Exception as e:
print(f"[Ensemble] Save failed: {e}")
def reload_if_stale(self):
"""
파일이 마지막 로드 이후 수정되었으면 재로드.
워커 프로세스가 메인 프로세스의 record_trade 결과를 반영하기 위해 사용.
"""
if not os.path.exists(self.history_file):
return
try:
mtime = os.path.getmtime(self.history_file)
if mtime > self._load_mtime:
self._load()
print("[Ensemble] 파일 변경 감지, 가중치 재로드")
except Exception:
pass
# ──────────────────────────────────────────────
# 컨텍스트 & 가중치
# ──────────────────────────────────────────────
def get_context(self, adx: float, macro_state: str) -> str:
"""현재 시장 컨텍스트 결정"""
if macro_state == "DANGER":
return "danger"
if adx >= 25:
return "strong_trend"
if adx < 20:
return "sideways"
return "default"
def get_weights(self, ticker: str, adx: float = 20.0,
macro_state: str = "SAFE",
ai_confidence: float = 0.5) -> SignalWeights:
"""
종목 + 시장 컨텍스트에 맞는 가중치 반환
1. 컨텍스트별 기준 가중치 선택
2. AI 신뢰도 높으면 lstm 가중치 보정
3. 종목별 학습 결과 반영 (크기 가중 정확도 사용)
"""
context = self.get_context(adx, macro_state)
base = self._context_weights.get(context, self._context_weights["default"])
ticker_history = self._trade_history.get(ticker, [])
adjusted = SignalWeights(tech=base.tech, sentiment=base.sentiment, lstm=base.lstm)
if len(ticker_history) >= 5:
recent = ticker_history[-10:]
# _accuracy_weighted: 방향 일치 + 수익 크기 가중 반영 (단순 binary X)
tech_acc = self._accuracy_weighted(
[h.get("tech_score", 0.5) for h in recent],
[h["outcome"] for h in recent])
news_acc = self._accuracy_weighted(
[h.get("sentiment_score", 0.5) for h in recent],
[h["outcome"] for h in recent])
lstm_acc = self._accuracy_weighted(
[h.get("lstm_score", 0.5) for h in recent],
[h["outcome"] for h in recent])
alpha = 0.05 # 미세 조정폭 (±0.1 범위)
adjusted.tech = max(0.10, min(0.60, base.tech + alpha * (tech_acc - 0.5)))
adjusted.sentiment = max(0.10, min(0.60, base.sentiment + alpha * (news_acc - 0.5)))
adjusted.lstm = max(0.10, min(0.60, base.lstm + alpha * (lstm_acc - 0.5)))
# AI 신뢰도 보정 (LSTM confidence 상한 0.80 기준 조정)
if ai_confidence >= 0.75:
adjusted.lstm = min(0.65, adjusted.lstm * 1.25)
elif ai_confidence < 0.5:
adjusted.lstm = max(0.10, adjusted.lstm * 0.75)
return adjusted.normalize()
# ──────────────────────────────────────────────
# 앙상블 점수
# ──────────────────────────────────────────────
def compute_ensemble_score(self, tech_score: float, sentiment_score: float,
lstm_score: float, investor_score: float = 0.0,
weights: Optional[SignalWeights] = None) -> float:
"""
앙상블 통합 점수 계산
Args:
weights: 가중치 (None이면 기본값 사용)
"""
if weights is None:
weights = SignalWeights()
total = (weights.tech * tech_score
+ weights.sentiment * sentiment_score
+ weights.lstm * lstm_score)
# 수급 가산점 (최대 +0.15)
total += min(investor_score, 0.15)
return min(1.0, max(0.0, total))
# ──────────────────────────────────────────────
# Kelly Criterion
# ──────────────────────────────────────────────
def get_kelly_fraction(self, ticker: str = None, half_kelly: bool = True) -> float:
"""
Modified Kelly Criterion 기반 최적 투자 비중 계산
f* = (p * b - q) / b
where:
p = 과거 승리 거래 비율 (win rate)
q = 1 - p
b = 평균이익 / 평균손실 비율 (avg profit / avg loss, Risk-Reward)
Returns:
0.03 ~ 0.25 범위의 Kelly 분수
- half_kelly=True: 변동성 과대추정 보완을 위해 1/2 적용
- 거래 데이터 < 10건: 보수적 기본값 0.08 반환
"""
# 해당 종목 우선, 없으면 전체 통합 히스토리 사용
if ticker and ticker in self._trade_history:
outcomes = [h["outcome"] for h in self._trade_history[ticker]
if h.get("outcome") is not None]
else:
# 전체 종목 결과 통합 (시장 전반 win rate)
outcomes = [
h["outcome"]
for records in self._trade_history.values()
for h in records
if h.get("outcome") is not None
]
if len(outcomes) < 10:
return 0.08 # 데이터 부족 → 보수적 8%
wins = [o for o in outcomes if o > 0]
losses = [abs(o) for o in outcomes if o <= 0]
if not wins:
return 0.03 # 승리 거래 없음 → 최소 비중
if not losses:
return 0.20 # 손실 거래 없음 → 낙관적이나 상한 제한
p = len(wins) / len(outcomes)
q = 1.0 - p
avg_win = sum(wins) / len(wins)
avg_loss = sum(losses) / len(losses)
if avg_loss == 0:
return 0.20
b = avg_win / avg_loss # Risk-Reward ratio
kelly = (p * b - q) / b
if half_kelly:
kelly /= 2.0 # Half-Kelly: 실제 활용 시 표준
result = max(0.03, min(0.25, kelly)) # 3% ~ 25% 범위 제한
return result
# ──────────────────────────────────────────────
# 거래 결과 기록 & 가중치 학습
# ──────────────────────────────────────────────
def record_trade(self, ticker: str, tech_score: float, sentiment_score: float,
lstm_score: float, decision: str, outcome_pct: float):
"""
매매 결과 기록 → 가중치 학습 데이터 축적
Args:
outcome_pct: 실현 수익률 (%). 양수=이익, 음수=손실
"""
if ticker not in self._trade_history:
self._trade_history[ticker] = []
record = {
"tech_score": tech_score,
"sentiment_score": sentiment_score,
"lstm_score": lstm_score,
"decision": decision,
"outcome": outcome_pct
}
self._trade_history[ticker].append(record)
if len(self._trade_history[ticker]) > self.max_history:
self._trade_history[ticker] = self._trade_history[ticker][-self.max_history:]
self._update_weights(ticker)
self._save()
def _update_weights(self, ticker: str):
"""
종목별 성과를 반영해 컨텍스트 가중치 점진적 업데이트.
- 크기 가중 정확도(accuracy_weighted) 사용 → 큰 손실에 강한 패널티
- 지수이동평균(alpha=0.10)으로 점진 반영 → 급격한 가중치 전환 방지
- normalize() 후 재경계 적용 → 경계값 위반 방지
"""
history = self._trade_history.get(ticker, [])
if len(history) < 5:
return
recent = history[-10:]
outcomes = [h["outcome"] for h in recent]
tech_acc = self._accuracy_weighted(
[h.get("tech_score", 0.5) for h in recent], outcomes)
news_acc = self._accuracy_weighted(
[h.get("sentiment_score", 0.5) for h in recent], outcomes)
lstm_acc = self._accuracy_weighted(
[h.get("lstm_score", 0.5) for h in recent], outcomes)
alpha = 0.10 # EMA 계수 (10회 거래 후 완전 반영)
for ctx, w in self._context_weights.items():
delta_tech = alpha * (tech_acc - 0.5) * 0.4 # 최대 ±0.02
delta_news = alpha * (news_acc - 0.5) * 0.4
delta_lstm = alpha * (lstm_acc - 0.5) * 0.4
# 경계 적용 → normalize (경계 재반영) → normalize (합=1 보장)
w.tech = max(0.10, min(0.65, w.tech + delta_tech))
w.sentiment = max(0.10, min(0.65, w.sentiment + delta_news))
w.lstm = max(0.10, min(0.65, w.lstm + delta_lstm))
w.normalize() # normalize() 내부에서 경계 재클램핑 + 2차 정규화 수행
print(f"[Ensemble] {ctx} tech={w.tech:.2f} news={w.sentiment:.2f} lstm={w.lstm:.2f} "
f"(acc T={tech_acc:.2f} N={news_acc:.2f} L={lstm_acc:.2f})")
# ──────────────────────────────────────────────
# 정확도 지표
# ──────────────────────────────────────────────
@staticmethod
def _accuracy_weighted(scores: list, outcomes: list) -> float:
"""
신호-결과 크기 가중 정확도 (0.0~1.0, 0.5=무관)
- 단순 방향 일치(0/1)가 아닌 수익률 절댓값으로 가중
- 큰 손실 예측 실패는 작은 이익 예측 성공보다 강하게 패널티
"""
if len(scores) < 3:
return 0.5
total_weight = 0.0
weighted_correct = 0.0
for s, o in zip(scores, outcomes):
weight = max(1.0, abs(o)) # 수익률 절댓값 기반 가중치 (최소 1.0)
total_weight += weight
if (s >= 0.5 and o > 0) or (s < 0.5 and o <= 0):
weighted_correct += weight
if total_weight == 0:
return 0.5
return weighted_correct / total_weight
# ──────────────────────────────────────────────
# 전역 싱글톤 (프로세스별)
# ──────────────────────────────────────────────
_ensemble_instance: Optional[AdaptiveEnsemble] = None
def get_ensemble() -> AdaptiveEnsemble:
"""프로세스 내 싱글톤 앙상블 관리자 반환 (워커/메인 각각 독립 인스턴스)"""
global _ensemble_instance
if _ensemble_instance is None:
_ensemble_instance = AdaptiveEnsemble()
return _ensemble_instance