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>
This commit is contained in:
2026-03-29 05:21:23 +09:00
parent 760d1906ed
commit 0aebca7ff0
17 changed files with 3816 additions and 200 deletions

View File

@@ -1,12 +1,17 @@
import os
import json
import time
import numpy as np
from modules.services.ollama import OllamaManager
from modules.services.llm_client import get_llm_client
from modules.analysis.technical import TechnicalAnalyzer
from modules.analysis.deep_learning import ModelRegistry
from modules.analysis.market_regime import MarketRegimeDetector
from modules.analysis.ai_council import get_council
from modules.analysis.ensemble import get_ensemble
from modules.config import Config
# [최적화] 워커 프로세스별 전역 변수 (Ollama 캐싱)
_ollama_manager = None
# AI Council 마지막 호출 시각 캐시 (종목별, 과다 호출 방지)
_council_last_call: dict = {}
def get_predictor(ticker=None):
@@ -16,24 +21,23 @@ def get_predictor(ticker=None):
def get_ollama():
"""워커 프로세스 내에서 OllamaManager 인스턴스를 싱글톤으로 관리
- 종목마다 새 인스턴스를 만들면 Ollama에 동시 요청이 폭주해 데드락 발생"""
global _ollama_manager
if _ollama_manager is None:
_ollama_manager = OllamaManager()
return _ollama_manager
"""LLMClient 싱글톤 반환 (Gemini 우선, Ollama 폴백)"""
return get_llm_client()
def calculate_position_size(total_capital, current_price, volatility, score, ai_confidence,
max_per_stock=3000000):
max_per_stock=3000000, ticker=None):
"""
[v2.0] 변동성 기반 포지션 사이징 (Modified Kelly Criterion)
[v3.1] Modified Kelly Criterion 기반 포지션 사이징
핵심 원칙:
1. 변동성이 높으면 → 적은 수량 (리스크 관리)
2. 확신도(score)가 높으면 → 많은 수량 (기회 포착)
3. AI 신뢰도가 높으면 → 가산 비중
4. 절대 한 종목에 전체 자산의 15% 이상 투자하지 않음
1. Kelly Fraction: f* = (p*b - q) / b (과거 실전 승률 + 손익비 기반)
- 데이터 부족 시 보수적 기본값 8% 사용
- Half-Kelly 적용으로 변동성 과대추정 보완
2. 변동성 조절: ATR 기반 변동성에 따라 Kelly 비중 추가 조절
3. 확신도 조절: 앙상블 score에 따른 최종 배수
4. AI 신뢰도 가산: LSTM confidence 기반 (상한 0.80 반영)
5. 상한: min(종목당 최대, 자산의 20%, 실제 자산)
Returns:
int: 매수 수량 (0이면 매수 안 함)
@@ -41,10 +45,12 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
if current_price <= 0 or total_capital <= 0:
return 0
# 1. 기본 투자금 (전체 자산의 10%)
base_invest = total_capital * 0.10
# 1. Kelly Fraction 기반 기본 투자 비중
ensemble = get_ensemble()
kelly_f = ensemble.get_kelly_fraction(ticker=ticker, half_kelly=True)
base_invest = total_capital * kelly_f
# 2. 변동성 조절 계수 (변동성 높을수록 투자금 감소)
# 2. 변동성 조절 계수 (ATR% 기반, 변동성 높을수록 소)
if volatility <= 1.0:
vol_factor = 1.2
elif volatility <= 2.0:
@@ -56,7 +62,7 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
else:
vol_factor = 0.3
# 3. 확신도 조절 계수
# 3. 앙상블 확신도 조절 계수 (score 기반)
if score >= 0.85:
conf_factor = 2.0
elif score >= 0.75:
@@ -66,35 +72,43 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
else:
conf_factor = 0.5
# 4. AI 신뢰도 가산
# 4. AI 신뢰도 가산 (LSTM confidence 상한 0.80 반영)
ai_bonus = 1.0
if ai_confidence >= 0.85:
ai_bonus = 1.3
elif ai_confidence >= 0.7:
if ai_confidence >= 0.75:
ai_bonus = 1.2
elif ai_confidence >= 0.65:
ai_bonus = 1.1
# 5. 최종 투자금 계산
invest_amount = base_invest * vol_factor * conf_factor * ai_bonus
invest_amount = min(invest_amount, max_per_stock)
invest_amount = min(invest_amount, total_capital * 0.15)
invest_amount = min(invest_amount, max_per_stock) # 종목당 최대
invest_amount = min(invest_amount, total_capital * 0.20) # 자산 20% 상한
invest_amount = min(invest_amount, total_capital)
qty = int(invest_amount / current_price)
kelly_pct = invest_amount / total_capital * 100 if total_capital > 0 else 0
print(f" [Kelly] f={kelly_f:.2%} invest={invest_amount:,.0f}won ({kelly_pct:.1f}%) qty={qty}")
return max(0, qty)
def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
macro_status=None, holding_info=None):
macro_status=None, holding_info=None, total_capital=None):
"""
[v3.0] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
[v3.1] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
[v3.0 개선사항]
1. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
2. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
3. 강화된 LLM 프롬프트 (거시경제 상태, 볼린저밴드, 거래량 급증, 보유 수익률)
[v3.1 개선사항]
1. AdaptiveEnsemble 연동: 하드코딩 가중치 → 학습 기반 동적 가중치
2. Kelly Criterion 기반 포지션 사이징 (calculate_position_size)
3. 파일 mtime 동기화: 메인 프로세스의 record_trade 결과를 워커에 반영
[v3.0 기능 유지]
4. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
5. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
6. 강화된 LLM 프롬프트
"""
try:
# [v3.1] 메인 프로세스가 갱신한 앙상블 가중치 파일 감지 → 재로드
get_ensemble().reload_if_stale()
# OHLCV 데이터 분리 (하위호환: list 형태도 허용)
if isinstance(ohlcv_data, dict):
prices = ohlcv_data.get('close', [])
@@ -184,10 +198,18 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
for day in investor_trend:
frgn_net_buy += day['foreigner']
orgn_net_buy += day['institutional']
# 연속 매수일 수: 가장 최근부터 역순으로 연속된 양수 일수만 카운트
for day in reversed(investor_trend):
if day['foreigner'] > 0:
consecutive_frgn_buy += 1
else:
break
for day in reversed(investor_trend):
if day['institutional'] > 0:
consecutive_orgn_buy += 1
else:
break
if frgn_net_buy > 0:
investor_score += 0.03
@@ -253,47 +275,82 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
except Exception:
print(f" ⚠️ AI response parse failed, using neutral (0.5)")
# ===== 7. 통합 점수 (동적 가중치 v2.0) =====
# ===== 7. 통합 점수 (AdaptiveEnsemble v3.1) =====
# 하드코딩 가중치 → 학습 기반 동적 가중치 (과거 매매 결과 반영)
adx_val = ma_info.get('adx', 20)
if ai_confidence >= 0.85 and adx_val >= 25:
w_tech, w_news, w_ai = 0.15, 0.15, 0.70
print(f" 🤖 [Ultra High Confidence + Strong Trend] AI Weight 70%")
elif ai_confidence >= 0.85:
w_tech, w_news, w_ai = 0.20, 0.20, 0.60
print(f" 🤖 [High Confidence] AI Weight 60%")
elif adx_val >= 30:
w_tech, w_news, w_ai = 0.50, 0.20, 0.30
print(f" 📊 [Very Strong Trend ADX={adx_val:.0f}] Tech Weight 50%")
elif adx_val < 20:
w_tech, w_news, w_ai = 0.30, 0.40, 0.30
print(f" 📰 [Sideways ADX={adx_val:.0f}] News Weight 40%")
else:
w_tech, w_news, w_ai = 0.35, 0.30, 0.35
ensemble = get_ensemble()
weights = ensemble.get_weights(
ticker=ticker,
adx=adx_val,
macro_state=macro_state,
ai_confidence=ai_confidence
)
print(f" [Ensemble] tech={weights.tech:.2f} news={weights.sentiment:.2f} "
f"lstm={weights.lstm:.2f} (adx={adx_val:.0f} conf={ai_confidence:.2f})")
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score)
total_score = ensemble.compute_ensemble_score(
tech_score=tech_score,
sentiment_score=sentiment_score,
lstm_score=lstm_score,
investor_score=investor_score,
weights=weights
)
total_score += min(investor_score, 0.15)
total_score = min(total_score, 1.0)
# ===== 7.5. 시장 레짐 감지 (코스피 수준 기반) =====
kospi_price = 0.0
kospi_change_val = 0.0
regime_analysis = None
if macro_status:
kospi_info = macro_status.get('indicators', {}).get('KOSPI', {})
kospi_price = float(kospi_info.get('price', 0) or 0)
kospi_change_val = float(kospi_info.get('change', 0) or 0)
if Config.MARKET_REGIME_ENABLED and kospi_price > 0:
regime_analysis = MarketRegimeDetector.detect(kospi_price, kospi_change_val)
print(
f" 📈 [Regime] {MarketRegimeDetector.get_regime_label(kospi_price)} "
f"risk={regime_analysis.risk_level} "
f"buy_adj={regime_analysis.buy_threshold_adj:+.2f} "
f"pos=x{regime_analysis.position_size_adj:.2f}"
)
# ===== 8. 시장 상황별 동적 임계값 =====
buy_threshold = 0.60
sell_threshold = 0.30
danger_force_sell = False # DANGER 긴급 매도 플래그
if macro_status:
if macro_state == 'DANGER':
buy_threshold = 999.0
sell_threshold = 0.45
print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold raised to 0.45")
sell_threshold = 0.35 # 이전 0.45에서 하향 (더 적극적 손절)
print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold lowered to 0.35")
# 보유 중이고 손실이면 즉시 매도 플래그
if holding_info and holding_info.get('qty', 0) > 0:
hy = holding_info.get('yield', 0.0)
if hy < -3.0:
danger_force_sell = True
print(f" 🚨 [DANGER + Loss {hy:.1f}%] Emergency Sell Triggered")
elif macro_state == 'CAUTION':
buy_threshold = 0.72
sell_threshold = 0.38
print(f" ⚠️ [CAUTION Market] Buy threshold raised to 0.72")
# 레짐 기반 임계값 추가 조정 (거시경제 판단 이후 적용)
if regime_analysis and macro_state != 'DANGER':
buy_threshold = round(
max(0.55, buy_threshold + regime_analysis.buy_threshold_adj), 3
)
# ===== 9. 매매 결정 =====
decision = "HOLD"
decision_reason = ""
# DANGER 긴급 매도 (손실 보유종목)
if danger_force_sell:
decision = "SELL"
decision_reason = f"Emergency DANGER Market + Loss ({holding_info.get('yield', 0.0):.1f}%)"
if holding_info:
holding_yield = holding_info.get('yield', 0.0)
holding_qty = holding_info.get('qty', 0)
@@ -333,7 +390,7 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
if tech_score >= 0.75 and lstm_score >= 0.6 and sentiment_score >= 0.6:
strong_signal = True
strong_reason = "Triple Confirmation (Tech+AI+News)"
elif lstm_score >= 0.80 and ai_confidence >= 0.85 and adx_val >= 25:
elif lstm_score >= 0.78 and ai_confidence >= 0.75 and adx_val >= 25:
strong_signal = True
strong_reason = f"High Confidence AI + Strong Trend (ADX={adx_val:.0f})"
elif investor_score >= 0.10 and tech_score >= 0.60 and total_score >= 0.60:
@@ -352,24 +409,115 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
decision_reason = f"Score {total_score:.2f} >= threshold {buy_threshold:.2f}"
# ===== 10. 포지션 사이징 =====
# total_capital: 호출 측에서 실제 잔고 전달 (없으면 보수적 기본값 5M)
_capital = total_capital if (total_capital and total_capital > 0) else 5_000_000
suggested_qty = 0
if decision == "BUY":
suggested_qty = calculate_position_size(
total_capital=10000000,
total_capital=_capital,
current_price=current_price,
volatility=volatility,
score=total_score,
ai_confidence=ai_confidence
ai_confidence=ai_confidence,
ticker=ticker
)
if suggested_qty == 0:
decision = "HOLD"
decision_reason = "Position size too small"
# 레짐 기반 포지션 크기 조정 (이미 계산된 수량에 배수 적용)
if regime_analysis and suggested_qty > 0:
adjusted_qty = int(suggested_qty * regime_analysis.position_size_adj)
if adjusted_qty != suggested_qty:
print(f" 📐 [Regime] 포지션 조정: {suggested_qty}{adjusted_qty}"
f"(x{regime_analysis.position_size_adj:.2f})")
suggested_qty = max(0, adjusted_qty)
if suggested_qty == 0:
decision = "HOLD"
decision_reason = "Regime position size adjustment → 0"
print(f" └─ Scores: Tech={tech_score:.2f} News={sentiment_score:.2f} "
f"LSTM={lstm_score:.2f} Inv={investor_score:.2f}"
f"Total={total_score:.2f} [{decision}]"
f"{f' ({decision_reason})' if decision_reason else ''}")
# ===== 11. AI 전문가 회의 (선택적, Config.AI_COUNCIL_ENABLED) =====
council_decision = None
if Config.AI_COUNCIL_ENABLED:
now = time.time()
last_call = _council_last_call.get(ticker, 0)
if now - last_call >= Config.AI_COUNCIL_MIN_INTERVAL:
_council_last_call[ticker] = now
council_data = {
"current_price": current_price,
"kospi_price": kospi_price,
"macro_state": macro_state,
"tech_score": tech_score,
"rsi": rsi,
"adx": adx_val,
"volatility": volatility,
"bb_zone": bb_zone,
"mtf_alignment": ma_info.get('mtf_alignment', 'N/A'),
"lstm_predicted": (
pred_result.get('predicted', current_price)
if pred_result else current_price
),
"lstm_change_rate": (
pred_result.get('change_rate', 0) if pred_result else 0
),
"ai_confidence": ai_confidence,
"lstm_score": lstm_score,
"sentiment_score": sentiment_score,
"investor_score": investor_score,
"frgn_net_buy": frgn_net_buy,
"consecutive_frgn_buy": consecutive_frgn_buy,
"is_holding": (
holding_info.get('qty', 0) > 0 if holding_info else False
),
"holding_yield": (
holding_info.get('yield', 0.0) if holding_info else 0.0
),
"total_score": total_score,
}
try:
council = get_council(get_ollama())
council_decision = council.convene(
ticker, council_data,
regime_analysis=regime_analysis,
fast_mode=Config.AI_COUNCIL_FAST_MODE,
)
# 모델 교체 권고 경고 출력
if council_decision.model_replacement_recommended:
print(
f" ⚠️ [Council] 모델 교체 권고: "
f"{council_decision.recommended_model}"
)
# 회의 결정이 기존 결정과 다르고 신뢰도 높으면 우선 적용
if council_decision.confidence >= 0.75:
council_final = council_decision.final_decision.upper()
if council_final != decision:
print(
f" 🔄 [Council Override] {decision}{council_final} "
f"(conf={council_decision.confidence:.2f})"
)
decision = council_final
decision_reason = (
f"AI Council ({council_decision.confidence:.0%}): "
f"{council_decision.majority_reasoning[:80]}"
)
# BUY로 전환된 경우 수량 재계산
if decision == "BUY" and suggested_qty == 0:
suggested_qty = calculate_position_size(
total_capital=_capital,
current_price=current_price,
volatility=volatility,
score=council_decision.confidence,
ai_confidence=ai_confidence,
ticker=ticker,
)
except Exception as _ce:
print(f" [Council] 회의 오류: {_ce}")
return {
"ticker": ticker,
"score": total_score,
@@ -387,7 +535,24 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
"sl_tp": sl_tp,
"suggested_qty": suggested_qty,
"ai_confidence": ai_confidence,
"ai_reason": ai_reason
"ai_reason": ai_reason,
"regime": {
"kospi_level": kospi_price,
"regime": regime_analysis.regime.value if regime_analysis else "unknown",
"description": regime_analysis.description if regime_analysis else "",
"risk_level": regime_analysis.risk_level if regime_analysis else "LOW",
"model_recommendation": (
regime_analysis.model_recommendation if regime_analysis else ""
),
} if regime_analysis else None,
"council": {
"final": council_decision.final_decision,
"confidence": council_decision.confidence,
"model_health": council_decision.model_health_score,
"replace_recommended": council_decision.model_replacement_recommended,
"recommended_model": council_decision.recommended_model,
"summary": council_decision.council_summary,
} if council_decision else None,
}
except Exception as e: