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

@@ -62,6 +62,14 @@ class TelegramBotServer:
"/system - PC 리소스(CPU/GPU) 상태\n"
"/ai - AI 모델 학습 상태 조회\n"
"/evaluate - 즉시 성과 평가 보고서 생성\n\n"
"<b>[AI 진단 스킬]</b>\n"
"/syshealth - 시스템 종합 건강 진단\n"
"/risk - 리스크 대시보드 (MDD, 연속손절)\n"
"/regime - 코스피 시장 레짐 감지\n"
"/model_health - LSTM 모델 건강 체크\n"
"/weights - 앙상블 가중치 분석\n"
"/postmortem [일수] - 매매 사후 분석 (기본 30일)\n"
"/watchlist_check - 감시 종목 스코어링\n\n"
"<b>[관리 명령어]</b>\n"
"/restart - 메인 봇 재시작 요청\n"
"/exec <code>명령어</code> - 원격 명령어 실행\n"
@@ -222,7 +230,11 @@ class TelegramBotServer:
volume = int(v.get('volume', 0))
if price == 0:
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
# 장 마감 후: prev_close(전일 종가)라도 표시
if prev_close > 0:
msg += f"⚫ <b>{k}:</b> <code>{prev_close:,.2f}</code> <i>(전일 종가 기준, 장 마감)</i>\n\n"
else:
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
continue
if change > 0:
@@ -303,9 +315,18 @@ class TelegramBotServer:
from modules.config import Config
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
if Config.GEMINI_API_KEY:
llm_primary = f"Gemini ({Config.GEMINI_MODEL})"
llm_fallback = f"Ollama ({Config.OLLAMA_MODEL})"
else:
llm_primary = f"Ollama ({Config.OLLAMA_MODEL})"
llm_fallback = None
msg = "<b>AI Model Status</b>\n"
msg += f"* <b>LLM Engine:</b> Ollama ({Config.OLLAMA_MODEL})\n"
msg += f"* <b>Device:</b> {gpu.get('name', 'GPU')}\n"
msg += f"* <b>LLM Engine:</b> {llm_primary}\n"
if llm_fallback:
msg += f"* <b>Fallback:</b> {llm_fallback}\n"
msg += f"* <b>LSTM Device:</b> {gpu.get('name', 'GPU')}\n"
if gpu:
msg += f"* <b>GPU Load:</b> <code>{gpu.get('load', 0)}%</code>\n"
@@ -417,6 +438,121 @@ class TelegramBotServer:
logging.error(f"[Command] /evaluate error: {e}")
await update.message.reply_text(f"평가 오류: {e}")
# ──────────────────────────────────────────────
# AI 진단 스킬 명령어 (skill_runner 기반)
# ──────────────────────────────────────────────
async def syshealth_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/syshealth: 시스템 종합 건강 진단"""
await update.message.reply_text("🔍 시스템 건강 진단 중... (최대 30초 소요)", parse_mode="HTML")
try:
from modules.services.telegram_bot import skill_runner
result = await skill_runner.run_syshealth()
for chunk in result:
await update.message.reply_text(chunk, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /syshealth error: {e}")
await update.message.reply_text(f"진단 오류: {e}")
async def risk_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/risk: 리스크 대시보드 (MDD, 연속손절, 포지션 집중도)"""
await update.message.reply_text("📊 리스크 데이터 분석 중...", parse_mode="HTML")
try:
from modules.services.telegram_bot import skill_runner
result = await skill_runner.run_risk()
for chunk in result:
await update.message.reply_text(chunk, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /risk error: {e}")
await update.message.reply_text(f"리스크 분석 오류: {e}")
async def regime_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/regime: 코스피 시장 레짐 감지"""
await update.message.reply_text("📈 시장 레짐 분석 중...", parse_mode="HTML")
try:
from modules.services.telegram_bot import skill_runner
result = await skill_runner.run_regime()
for chunk in result:
await update.message.reply_text(chunk, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /regime error: {e}")
await update.message.reply_text(f"레짐 분석 오류: {e}")
async def model_health_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/model_health: LSTM 모델 건강 체크"""
await update.message.reply_text("🧠 LSTM 모델 체크포인트 스캔 중...", parse_mode="HTML")
try:
from modules.services.telegram_bot import skill_runner
result = await skill_runner.run_model_health()
for chunk in result:
await update.message.reply_text(chunk, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /model_health error: {e}")
await update.message.reply_text(f"모델 건강 체크 오류: {e}")
async def weights_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/weights: 앙상블 가중치 분석"""
await update.message.reply_text("⚖️ 앙상블 가중치 분석 중...", parse_mode="HTML")
try:
from modules.services.telegram_bot import skill_runner
result = await skill_runner.run_weights()
for chunk in result:
await update.message.reply_text(chunk, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /weights error: {e}")
await update.message.reply_text(f"가중치 분석 오류: {e}")
async def postmortem_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/postmortem [days]: 매매 사후 분석 (기본 30일)"""
args = context.args
days = 30
if args:
try:
days = int(args[0])
days = max(7, min(days, 365))
except ValueError:
pass
await update.message.reply_text(
f"🔬 최근 {days}일 매매 사후 분석 중...", parse_mode="HTML")
try:
from modules.services.telegram_bot import skill_runner
result = await skill_runner.run_postmortem(days)
for chunk in result:
await update.message.reply_text(chunk, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /postmortem error: {e}")
await update.message.reply_text(f"사후 분석 오류: {e}")
async def watchlist_check_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/watchlist_check: 현재 감시 종목 스코어링"""
await update.message.reply_text("🔎 감시 종목 스코어링 중...", parse_mode="HTML")
try:
from modules.services.telegram_bot import skill_runner
# 현재 watchlist에서 종목 코드 목록 로드
candidates = []
try:
import json, os
from modules.config import Config
wl_path = Config.WATCHLIST_FILE
if os.path.exists(wl_path):
with open(wl_path, encoding="utf-8") as f:
wl_data = json.load(f)
if isinstance(wl_data, dict):
candidates = list(wl_data.keys())
elif isinstance(wl_data, list):
candidates = wl_data
except Exception:
pass
result = await skill_runner.run_watchlist_check(candidates)
for chunk in result:
await update.message.reply_text(chunk, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /watchlist_check error: {e}")
await update.message.reply_text(f"스코어링 오류: {e}")
def run(self):
handlers = [
("start", self.start_command),
@@ -428,6 +564,13 @@ class TelegramBotServer:
("system", self.system_command),
("ai", self.ai_status_command),
("evaluate", self.evaluate_command),
("syshealth", self.syshealth_command),
("risk", self.risk_command),
("regime", self.regime_command),
("model_health", self.model_health_command),
("weights", self.weights_command),
("postmortem", self.postmortem_command),
("watchlist_check", self.watchlist_check_command),
("restart", self.restart_command),
("stop", self.stop_command),
("exec", self.exec_command)