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

@@ -4,6 +4,11 @@ import time
import os
from datetime import datetime, timedelta
try:
import aiohttp
except ImportError:
aiohttp = None
from modules.config import Config
class KISClient:
@@ -120,7 +125,7 @@ class KISClient:
try:
print(f"🔑 [KIS] 토큰 발급 요청: {url}")
res = requests.post(url, json=payload)
res = requests.post(url, json=payload, timeout=Config.HTTP_TIMEOUT)
res.raise_for_status()
data = res.json()
@@ -164,7 +169,7 @@ class KISClient:
"appsecret": self.app_secret
}
try:
res = requests.post(url, headers=headers, json=datas)
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
return res.json()["HASH"]
except Exception as e:
print(f"❌ Hash Key 생성 실패: {e}")
@@ -185,10 +190,12 @@ class KISClient:
try:
if method == "GET":
res = requests.get(url, headers=headers, params=params)
res = requests.get(url, headers=headers, params=params,
timeout=Config.HTTP_TIMEOUT)
else:
res = requests.post(url, headers=headers, json=data)
res = requests.post(url, headers=headers, json=data,
timeout=Config.HTTP_TIMEOUT)
# 토큰 만료 체크 (500 에러 or msg_cd 확인)
is_token_error = False
try:
@@ -200,18 +207,20 @@ class KISClient:
is_token_error = True
except:
pass
if is_token_error:
print("🔄 [KIS] Token expired (caught). Refreshing...")
self.ensure_token(force=True)
headers = self._get_headers(tr_id)
if use_hash and data and "hashkey" in headers:
pass # Hash 재활용
if method == "GET":
res = requests.get(url, headers=headers, params=params)
res = requests.get(url, headers=headers, params=params,
timeout=Config.HTTP_TIMEOUT)
else:
res = requests.post(url, headers=headers, json=data)
res = requests.post(url, headers=headers, json=data,
timeout=Config.HTTP_TIMEOUT)
res.raise_for_status()
return res.json()
@@ -266,7 +275,8 @@ class KISClient:
return {
"holdings": holdings,
"total_eval": int(summary['tot_evlu_amt']),
"deposit": int(summary['dnca_tot_amt'])
"deposit": int(summary['dnca_tot_amt']),
"today_buy_amt": int(summary.get('thdt_buy_amt', 0)), # 금일매수금액 (T+2 차감 전 당일 집행액)
}
except Exception as e:
return {"error": str(e)}
@@ -321,7 +331,7 @@ class KISClient:
try:
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea ({order_type_str})")
res = requests.post(url, headers=headers, json=datas)
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
res.raise_for_status()
data = res.json()
@@ -348,7 +358,8 @@ class KISClient:
}
try:
res = requests.get(url, headers=headers, params=params)
res = requests.get(url, headers=headers, params=params,
timeout=Config.HTTP_TIMEOUT)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
@@ -564,12 +575,13 @@ class KISClient:
}
try:
res = requests.get(url, headers=headers, params=params)
res = requests.get(url, headers=headers, params=params,
timeout=Config.HTTP_TIMEOUT)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
return []
results = []
for item in data['output'][:limit]:
# 코드는 shtn_iscd, 이름은 hts_kor_isnm
@@ -664,7 +676,8 @@ class KISClient:
}
try:
res = requests.get(url, headers=headers, params=params)
res = requests.get(url, headers=headers, params=params,
timeout=Config.HTTP_TIMEOUT)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
@@ -699,7 +712,9 @@ class KISAsyncClient:
async def _async_get(self, session, url, headers, params):
"""비동기 GET 요청"""
try:
async with session.get(url, headers=headers, params=params) as resp:
timeout = aiohttp.ClientTimeout(total=Config.HTTP_TIMEOUT) if aiohttp else None
async with session.get(url, headers=headers, params=params,
timeout=timeout) as resp:
return await resp.json()
except Exception as e:
print(f"[KIS Async] Request failed: {e}")

View File

@@ -0,0 +1,199 @@
"""
통합 LLM 클라이언트 — Gemini 2.5 Flash (Primary) + Ollama (Fallback)
설계 원칙:
- OllamaManager.request_inference(prompt) 와 동일한 인터페이스 유지
→ process.py, ai_council.py 코드 변경 최소화
- Gemini 실패(네트워크, Rate Limit) 시 자동으로 로컬 Ollama 폴백
- 15 RPM 제한 준수를 위한 자동 스로틀링
- VRAM 충돌 없음 (외부 API 호출이므로 LSTM 학습과 간섭 없음)
Rate Limit (Gemini 2.5 Flash 무료 티어):
- 15 RPM, 1,500 RPD (봇 필요량 ~240/일 → 여유 6배)
추가 패키지 불필요:
- requests (이미 설치됨) 기반 REST API 직접 호출
"""
import time
import requests
import json
from modules.config import Config
class GeminiLLMClient:
"""
Gemini API 클라이언트
사용법:
client = GeminiLLMClient()
result = client.request_inference(prompt) # str | None
"""
_GENERATE_URL = (
"https://generativelanguage.googleapis.com/v1beta/models"
"/{model}:generateContent?key={key}"
)
# 15 RPM → 최소 4초 간격 (여유 0.1초 추가)
_MIN_INTERVAL = 4.1
# 클래스 변수: 같은 프로세스 내 재생성 시에도 마지막 호출 시각 유지
# (워커 OOM 재시작 후 싱글톤 교체 시에도 스로틀 유효)
_class_last_call_ts: float = 0.0
def __init__(self):
self.api_key = Config.GEMINI_API_KEY
self.model = Config.GEMINI_MODEL
self._ollama = None # Ollama 폴백 (lazy init)
self._use_gemini = bool(self.api_key)
if self._use_gemini:
print(f"✅ [LLMClient] Primary: Gemini {self.model}")
else:
print("⚠️ [LLMClient] GEMINI_API_KEY 미설정 → Ollama 전용 모드")
# ── 내부 헬퍼 ────────────────────────────────────────────────────────────
def _throttle(self):
"""15 RPM 제한 준수 — 최소 호출 간격 강제 대기 (클래스 공유 타임스탬프)"""
elapsed = time.time() - GeminiLLMClient._class_last_call_ts
if elapsed < self._MIN_INTERVAL:
time.sleep(self._MIN_INTERVAL - elapsed)
def _call_gemini(self, prompt: str) -> str | None:
"""
Gemini REST API 단일 호출
설정:
- systemInstruction: JSON 전용 응답 강제
- thinkingBudget=0: 내부 추론 비활성 (속도 1.5초 / 토큰 절약)
- maxOutputTokens=512: 200은 thinking 소모로 잘리므로 여유 확보
"""
self._throttle()
url = self._GENERATE_URL.format(model=self.model, key=self.api_key)
payload = {
"system_instruction": {
"parts": [{"text": (
"You are a Korean stock market analyst. "
"Respond with valid JSON only. "
"No markdown, no code blocks, no explanations."
)}]
},
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"maxOutputTokens": 512, # 200→512 (thinking 비활성 후 실제 응답 공간 확보)
"temperature": 0.1, # 결정론적 출력
"thinkingConfig": {"thinkingBudget": 0}, # 내부 추론 끔 (속도↑, 토큰↓)
},
}
try:
resp = requests.post(url, json=payload, timeout=30)
GeminiLLMClient._class_last_call_ts = time.time()
# Rate Limit 초과
if resp.status_code == 429:
print("[LLMClient] Gemini Rate Limit (429) → Ollama 폴백")
return None
resp.raise_for_status()
data = resp.json()
# thinking 파트 제외, 실제 텍스트 파트만 결합
candidate = data.get("candidates", [{}])[0]
parts = candidate.get("content", {}).get("parts", [])
text = "".join(
p.get("text", "") for p in parts
if "text" in p and not p.get("thought")
).strip()
return text if text else None
except requests.exceptions.Timeout:
print("[LLMClient] Gemini Timeout (30s) → Ollama 폴백")
return None
except Exception as e:
print(f"[LLMClient] Gemini Error: {e} → Ollama 폴백")
return None
def _get_ollama(self):
"""Ollama 폴백 인스턴스 (lazy init — 필요할 때만 로드)"""
if self._ollama is None:
from modules.services.ollama import OllamaManager
self._ollama = OllamaManager()
# Ollama 실행 여부 사전 확인 (WinError 10061 조기 감지)
try:
requests.get(
f"{Config.OLLAMA_API_URL}/api/tags",
timeout=3,
)
except Exception:
print(
f"❌ [LLMClient] Ollama 미실행 (localhost:11434 연결 거부) — "
f"`ollama serve` 명령으로 Ollama를 시작하세요."
)
return self._ollama
# ── 공개 인터페이스 ───────────────────────────────────────────────────────
def request_inference(self, prompt: str, context_data=None) -> str | None:
"""
LLM 추론 요청 — OllamaManager.request_inference()와 동일한 시그니처
순서:
1) GEMINI_API_KEY 있음 → Gemini API 호출
2) Gemini 실패(에러/타임아웃/Rate Limit) → Ollama 로컬 폴백
3) GEMINI_API_KEY 없음 → 바로 Ollama 사용
"""
if self._use_gemini:
result = self._call_gemini(prompt)
if result is not None:
return result
# Gemini 실패 → Ollama 폴백
print("[LLMClient] Ollama 폴백 시도 중...")
return self._get_ollama().request_inference(prompt, context_data)
# ── OllamaManager 호환 메서드 (ai_council, evaluator 등에서 사용) ─────────
def check_vram(self) -> float:
"""VRAM 사용량 반환 (Ollama 측 정보, Gemini 호출 시엔 무관)"""
if self._ollama:
return self._ollama.check_vram()
return 0.0
def get_gpu_status(self) -> dict:
"""GPU 상태 반환 (OllamaManager 호환)"""
return self._get_ollama().get_gpu_status()
def unload_model(self):
"""Ollama 모델 언로드 (LSTM 학습 전 호출용, Gemini는 무작동)"""
if self._ollama:
try:
requests.post(
f"{Config.OLLAMA_API_URL}/api/generate",
json={"model": Config.OLLAMA_MODEL, "keep_alive": 0},
timeout=5,
)
except Exception:
pass
# ── 워커 프로세스 전역 싱글톤 ─────────────────────────────────────────────────
_llm_client: GeminiLLMClient | None = None
def get_llm_client() -> GeminiLLMClient:
"""
워커 프로세스 내 GeminiLLMClient 싱글톤 반환
process.py에서 기존 get_ollama() 대신 이 함수를 사용:
ollama = get_llm_client()
result = ollama.request_inference(prompt)
"""
global _llm_client
if _llm_client is None:
_llm_client = GeminiLLMClient()
return _llm_client

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)

View File

@@ -0,0 +1,463 @@
"""
Skill Runner — 텔레그램 봇에서 Claude Skills 스크립트를 실행하는 유틸리티
각 스킬 스크립트를 subprocess로 실행하고, 결과를 텔레그램 HTML 메시지로 포맷합니다.
Claude Code 없이도 텔레그램 명령어만으로 분석 리포트를 받을 수 있습니다.
"""
import asyncio
import json
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
# 봇 프로젝트 루트 (이 파일 기준 3단계 상위)
BOT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
SKILLS_DIR = BOT_ROOT / ".claude" / "skills"
PYTHON_EXE = sys.executable # 현재 봇과 동일한 Python 인터프리터 사용
def _skill_script(skill_name: str, script_name: str) -> Path:
return SKILLS_DIR / skill_name / "scripts" / script_name
async def _run_script(script_path: Path, extra_args: Optional[list] = None,
timeout: int = 60) -> dict:
"""
스킬 스크립트를 비동기 subprocess로 실행.
--bot-path, --json 플래그를 자동으로 추가.
반환: {"ok": bool, "output": str, "json_data": dict|None}
"""
if not script_path.exists():
return {"ok": False, "output": f"스크립트 없음: {script_path}", "json_data": None}
cmd = [PYTHON_EXE, str(script_path),
"--bot-path", str(BOT_ROOT),
"--json"]
if extra_args:
cmd.extend(extra_args)
try:
loop = asyncio.get_running_loop()
# PYTHONIOENCODING=utf-8: 서브프로세스 stdout에서 유니코드/이모지 출력 허용
_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
result = await loop.run_in_executor(
None,
lambda: subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
cwd=str(BOT_ROOT),
env=_env,
)
)
raw_out = result.stdout.strip()
raw_err = result.stderr.strip()
# JSON 파싱 시도
json_data = None
if raw_out:
try:
json_data = json.loads(raw_out)
except json.JSONDecodeError:
pass
if result.returncode != 0 and not raw_out:
return {"ok": False, "output": raw_err or "알 수 없는 오류", "json_data": None}
return {"ok": True, "output": raw_out, "json_data": json_data}
except subprocess.TimeoutExpired:
return {"ok": False, "output": f"실행 시간 초과 ({timeout}초)", "json_data": None}
except Exception as e:
return {"ok": False, "output": str(e), "json_data": None}
def _truncate(text: str, limit: int = 3800) -> str:
if len(text) <= limit:
return text
return text[:limit] + "\n<i>... (일부 생략)</i>"
def _escape_html(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# ─────────────────────────────────────────────
# 스킬별 포맷터
# ─────────────────────────────────────────────
def _fmt_syshealth(data: dict) -> str:
ipc = data.get("ipc", {})
gpu = data.get("gpu", {})
token = data.get("kis_token", {})
procs = data.get("processes", {})
ipc_status = ipc.get("status", "?")
ipc_emoji = {"FRESH": "", "NORMAL": "", "STALE": "⚠️",
"EXPIRED": "🔴", "EMPTY": "⚠️", "ERROR": "🔴"}.get(ipc_status, "")
age = ipc.get("age_seconds")
age_str = f"{age}초 전" if age is not None else "알 수 없음"
api_str = "✅ 실행 중" if procs.get("api_running") else "🔴 오프라인"
token_str = "✅ 유효" if token.get("status") == "VALID" else f"🔴 {token.get('status','?')}"
token_env = token.get("env", "?")
vram = gpu.get("vram_used_gb")
vram_str = f"{vram}GB / {gpu.get('vram_total_gb', 16)}GB" if vram else "측정 불가"
cuda_str = "" if gpu.get("cuda_available") else ""
# 로그 에러 집계
logs = data.get("logs", {})
all_errors = {}
for ld in logs.values():
for k, v in ld.get("errors", {}).items():
all_errors[k] = all_errors.get(k, 0) + v
err_lines = "\n".join(
f" ⚠️ {k}: {v}" for k, v in sorted(all_errors.items(), key=lambda x: x[1], reverse=True)
) or " ✅ 없음"
balance = ipc.get("balance")
balance_str = f"\n 잔고: <code>{int(balance):,}원</code>" if balance else ""
wl_count = ipc.get("watchlist_count", 0)
msg = (
f"<b>🔧 시스템 헬스 진단</b>\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"<b>API 서버:</b> {api_str}\n"
f"<b>IPC 상태:</b> {ipc_emoji} {ipc_status} ({age_str})"
f"{balance_str}\n"
f" 감시종목: {wl_count}\n"
f"<b>GPU/CUDA:</b> {cuda_str} VRAM: <code>{vram_str}</code>\n"
f"<b>KIS 토큰:</b> {token_str} ({token_env})\n\n"
f"<b>로그 에러 (최근):</b>\n{err_lines}"
)
return msg
def _fmt_risk(data: dict) -> str:
mdd = data.get("mdd", {})
dl = data.get("daily_loss", {})
cl = data.get("consecutive_losses", {})
cap = data.get("total_capital", 0)
mdd_val = mdd.get("mdd", 0) or 0
mdd_emoji = "" if mdd_val > -5 else ("⚠️" if mdd_val > -10 else "🔴")
dl_ratio = dl.get("ratio", 0) or 0
dl_emoji = "" if dl_ratio < 50 else ("⚠️" if dl_ratio < 75 else "🔴")
cl_count = cl.get("count", 0)
cl_active = cl.get("cooldown_active", False)
cl_emoji = "🚨" if cl_active else ("⚠️" if cl_count >= 2 else "")
msg = (
f"<b>🛡️ 리스크 대시보드</b>\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"<b>총 자산:</b> <code>{int(cap):,}원</code>\n\n"
f"<b>MDD:</b> {mdd_emoji} <code>{mdd_val:.1f}%</code> ({mdd.get('level','?')})\n"
f" 최고점: <code>{int(mdd.get('peak',0) or 0):,}원</code> ({mdd.get('peak_days_ago','?')}일 전)\n"
f" 복구 필요: <code>+{mdd.get('recovery_needed',0):.1f}%</code>\n\n"
f"<b>일일 손실한도:</b> {dl_emoji} {dl_ratio:.0f}% 소진\n"
f" 한도: <code>{int(dl.get('limit',0) or 0):,}원</code> "
f"사용: <code>{int(dl.get('used',0) or 0):,}원</code>\n\n"
f"<b>연속 손절:</b> {cl_emoji} {cl_count}"
)
if cl_active:
msg += f"\n 🚨 매수 중단 중 (재개: {cl.get('resume_time','?')})"
return msg
def _fmt_regime(data: dict) -> str:
regime = data.get("regime", "?")
msi = data.get("msi", {})
params = data.get("recommended_params", {})
ens = params.get("ensemble", {})
data_source = data.get("data_source", "ipc")
source_note = " <i>(IPC 데이터 없음 — 기본값 기반)</i>\n" if data_source == "default" else ""
regime_emoji = {
"BULL_EXTREME": "🔥", "BULL_STRONG": "📈",
"NORMAL": "➡️", "BEAR_WEAK": "📉", "BEAR_STRONG": "🚨"
}.get(regime, "")
status_emoji = {"SAFE": "", "CAUTION": "⚠️", "DANGER": "🚨"}.get(msi.get("status", ""), "")
flags = msi.get("flags", {})
flag_lines = "\n".join(f" {v}" for v in flags.values())
msg = (
f"<b>📊 시장 레짐 분석</b>\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"{source_note}"
f"<b>레짐:</b> {regime_emoji} {regime}\n"
f"<b>MSI:</b> {status_emoji} {msi.get('score','?')}/{msi.get('max','?')} ({msi.get('status','?')})\n\n"
f"<b>지표 현황:</b>\n{flag_lines}\n\n"
f"<b>권고 파라미터:</b>\n"
f" buy_threshold: <code>{params.get('buy_threshold','?')}</code>\n"
f" max_position: <code>{params.get('max_position_ratio','?')}</code>\n"
f" sl_atr_mult: <code>{params.get('sl_atr_multiplier','?')}</code>\n\n"
f"<b>앙상블 권고:</b>\n"
f" tech: <code>{ens.get('tech','?')}</code> "
f"lstm: <code>{ens.get('lstm','?')}</code> "
f"sent: <code>{ens.get('sentiment','?')}</code>\n"
f"<i>다음 점검: {params.get('next_check_days','?')}일 후</i>"
)
return msg
def _fmt_model_health(data: dict) -> str:
models = data.get("models", {})
missing = data.get("missing_models", [])
grade_emoji = {"HEALTHY": "🟢", "WARNING": "🟡", "DEGRADED": "🟠",
"CRITICAL": "🔴", "MISSING": ""}
grade_counts = {}
for info in models.values():
g = info.get("grade", "?")
grade_counts[g] = grade_counts.get(g, 0) + 1
# 우선순위 높은 종목 상위 5개
critical = [(t, i) for t, i in models.items() if i.get("grade") in ("CRITICAL", "DEGRADED")]
critical.sort(key=lambda x: {"CRITICAL": 0, "DEGRADED": 1}.get(x[1].get("grade"), 9))
summary_lines = "\n".join(
f" {grade_emoji.get(g,'?')} {g}: {cnt}"
for g, cnt in grade_counts.items()
)
critical_lines = ""
for t, info in critical[:5]:
critical_lines += f"\n {grade_emoji.get(info['grade'],'?')} {t}: {info.get('reason','?')}"
missing_str = ""
if missing:
missing_str = f"\n\n<b>모델 없는 감시종목:</b>\n " + ", ".join(missing[:5])
if len(missing) > 5:
missing_str += f"{len(missing)-5}"
msg = (
f"<b>🤖 LSTM 모델 건강도</b>\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"<b>체크포인트 {len(models)}개:</b>\n"
f"{summary_lines}"
)
if critical_lines:
msg += f"\n\n<b>조치 필요:</b>{critical_lines}"
msg += missing_str
if not critical and not missing:
msg += "\n\n✅ 모든 모델 정상"
return msg
def _fmt_weights(data: dict) -> str:
current = data.get("current_global", {})
optimal = data.get("optimal_global", {})
health = data.get("ema_health", {})
contribs = data.get("signal_contributions", {})
issues = "\n".join(f" {i}" for i in health.get("issues", []))
health_status = "" if health.get("status") == "OK" else "⚠️"
contrib_lines = ""
for sig, c in contribs.items():
if c.get("total_trades", 0) > 0:
acc = c.get("accuracy", 0)
contrib_lines += f"\n {sig}: 정확도 {acc:.1%} ({c['total_trades']}거래)"
delta_lines = ""
for sig in ["tech", "lstm", "sentiment"]:
cur = current.get(sig, 0)
opt = optimal.get(sig, cur)
diff = round(opt - cur, 3)
arrow = "" if diff > 0 else ("" if diff < 0 else "")
delta_lines += f"\n {sig:12s}: {cur} {arrow} <b>{opt}</b>"
msg = (
f"<b>⚖️ 앙상블 가중치</b>\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"<b>EMA 학습 상태:</b> {health_status}\n{issues}\n"
)
if contrib_lines:
msg += f"\n<b>신호 기여도:</b>{contrib_lines}\n"
msg += f"\n<b>권고 조정:</b>{delta_lines}"
return msg
def _fmt_postmortem(data: dict) -> str:
stats = data.get("basic_stats", {})
combos = data.get("signal_combinations", {})
suggestions = data.get("parameter_suggestions", {})
days = data.get("days", 30)
wr = stats.get("win_rate", 0)
pr = stats.get("profit_ratio", 0)
wr_emoji = "" if wr >= 55 else ("⚠️" if wr >= 50 else "🔴")
pr_emoji = "" if pr >= 2.0 else ("⚠️" if pr >= 1.5 else "🔴")
best_combos = list(combos.items())[:2]
worst_combos = list(combos.items())[-2:]
combo_lines = ""
for k, v in best_combos:
combo_lines += f"\n{k}: 승률 {v['win_rate']}% ({v['trades']}건)"
for k, v in worst_combos:
if v["win_rate"] < 50:
combo_lines += f"\n ⚠️ {k}: 승률 {v['win_rate']}% ({v['trades']}건)"
suggest_lines = ""
for param, s in suggestions.items():
suggest_lines += f"\n {param}: {s.get('current','?')} → <b>{s.get('recommended','?')}</b>"
msg = (
f"<b>📊 매매 사후분석</b> (최근 {days}일)\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"<b>총 거래:</b> {stats.get('total',0)}"
f"승률: {wr_emoji} <code>{wr}%</code>\n"
f"<b>손익비:</b> {pr_emoji} <code>{pr}</code> "
f"Sharpe: <code>{stats.get('sharpe',0)}</code>\n"
f"평균 수익: <code>+{stats.get('avg_win_pct',0)}%</code> "
f"평균 손실: <code>-{stats.get('avg_loss_pct',0)}%</code>"
)
if combo_lines:
msg += f"\n\n<b>신호 조합:</b>{combo_lines}"
if suggest_lines:
msg += f"\n\n<b>파라미터 권고:</b>{suggest_lines}"
return msg
def _fmt_watchlist(data: dict) -> str:
scored = data.get("scored", [])
current = data.get("current_watchlist", [])
r_min, r_max = data.get("recommended_range", (8, 15))
to_add = [s for s in scored if s.get("action") == "편입"]
to_remove = [s for s in scored if s.get("action") == "제거"]
to_keep = [s for s in scored if s.get("action") == "유지" and s.get("in_watchlist")]
to_keep.sort(key=lambda x: x.get("total_score", 0), reverse=True)
add_lines = ""
for s in to_add[:5]:
wr = f" ({s['win_rate']:.0%})" if s.get("win_rate") else ""
add_lines += f"\n{s['ticker']} {s['total_score']}점 — {s.get('theme','?')}{wr}"
remove_lines = ""
for s in to_remove:
remove_lines += f"\n{s['ticker']} {s['total_score']}"
keep_lines = ""
for s in to_keep[:3]:
keep_lines += f"\n{s['ticker']} {s['total_score']}"
final = len(current) - len(to_remove) + len(to_add)
size_ok = "" if r_min <= final <= r_max else "⚠️"
msg = (
f"<b>📋 Watchlist 분석</b>\n"
f"━━━━━━━━━━━━━━━━━━\n"
f"현재 {len(current)}종목 → 최종 {final}종목 {size_ok}\n"
f"권고 규모: {r_min}~{r_max}종목"
)
if add_lines:
msg += f"\n\n<b>편입 추천:</b>{add_lines}"
if remove_lines:
msg += f"\n\n<b>제거 추천:</b>{remove_lines}"
if keep_lines:
msg += f"\n\n<b>상위 유지 종목:</b>{keep_lines}"
return msg
# ─────────────────────────────────────────────
# 공개 API — 텔레그램 핸들러에서 호출
# ─────────────────────────────────────────────
def _to_chunks(text: str, limit: int = 3800) -> List[str]:
"""메시지가 Telegram 4096자 제한을 초과하면 청크로 분할"""
if len(text) <= limit:
return [text]
chunks = []
while text:
chunks.append(text[:limit])
text = text[limit:]
return chunks
async def run_syshealth() -> List[str]:
script = _skill_script("bot-system-health-diagnostics", "health_checker.py")
r = await _run_script(script, timeout=30)
if not r["ok"]:
return [f"⚠️ 시스템 헬스 실행 오류:\n<code>{_escape_html(r['output'])}</code>"]
if r["json_data"]:
return _to_chunks(_fmt_syshealth(r["json_data"]))
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
async def run_risk() -> List[str]:
script = _skill_script("auto-trade-risk-manager", "risk_dashboard.py")
r = await _run_script(script, timeout=30)
if not r["ok"]:
return [f"⚠️ 리스크 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
if r["json_data"]:
return _to_chunks(_fmt_risk(r["json_data"]))
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
async def run_regime() -> List[str]:
script = _skill_script("korean-market-regime-detector", "regime_calculator.py")
r = await _run_script(script, timeout=60)
if not r["ok"]:
return [f"⚠️ 레짐 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
if r["json_data"]:
return _to_chunks(_fmt_regime(r["json_data"]))
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
async def run_model_health() -> List[str]:
script = _skill_script("lstm-model-health-monitor", "model_health_report.py")
r = await _run_script(script, timeout=60)
if not r["ok"]:
return [f"⚠️ 모델 건강도 오류:\n<code>{_escape_html(r['output'])}</code>"]
if r["json_data"]:
return _to_chunks(_fmt_model_health(r["json_data"]))
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
async def run_weights() -> List[str]:
script = _skill_script("ensemble-weight-optimizer", "weight_optimizer.py")
r = await _run_script(script, timeout=30)
if not r["ok"]:
return [f"⚠️ 가중치 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
if r["json_data"]:
return _to_chunks(_fmt_weights(r["json_data"]))
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
async def run_postmortem(days: int = 30) -> List[str]:
script = _skill_script("trade-post-mortem-analyzer", "post_mortem_report.py")
r = await _run_script(script, extra_args=["--days", str(days)], timeout=30)
if not r["ok"]:
return [f"⚠️ 매매 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
if r["json_data"]:
return _to_chunks(_fmt_postmortem(r["json_data"]))
if not r["output"].strip():
return [f"<b>📊 매매 사후분석</b> (최근 {days}일)\n━━━━━━━━━━━━━━━━━━\n<i>분석 대상 매매 기록이 없습니다.</i>"]
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
async def run_watchlist_check(candidates: Optional[List[str]] = None) -> List[str]:
script = _skill_script("watchlist-intelligence-curator", "watchlist_scorer.py")
extra = []
if candidates:
extra = ["--candidates"] + candidates
r = await _run_script(script, extra_args=extra, timeout=30)
if not r["ok"]:
return [f"⚠️ Watchlist 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
if r["json_data"]:
return _to_chunks(_fmt_watchlist(r["json_data"]))
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")