refactor: web-ai V1 assets → signal_v1/ (graduation prep)
Atomic mv of root V1 assets (main_server.py + modules/ + data/ + tests/ + entry scripts + docs + logs) into signal_v1/ subdirectory. load_dotenv() updated to load web-ai/.env explicitly via Path. Adds web-ai/CLAUDE.md (workspace guide) and web-ai/start.bat (signal_v1 entry wrapper). Prepares for signal_v2/ Phase 2. Tests: signal_v1/tests/unit baseline preserved (no regression). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
950
signal_v1/modules/services/kis.py
Normal file
950
signal_v1/modules/services/kis.py
Normal file
@@ -0,0 +1,950 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
aiohttp = None
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class KISClient:
|
||||
"""
|
||||
한국투자증권 (Korea Investment & Securities) REST API Client
|
||||
"""
|
||||
def __init__(self, is_virtual=None):
|
||||
# Config에서 설정 로드
|
||||
self.app_key = Config.KIS_APP_KEY
|
||||
self.app_secret = Config.KIS_APP_SECRET
|
||||
self.cano = Config.KIS_ACCOUNT[:8]
|
||||
self.acnt_prdt_cd = Config.KIS_ACCOUNT[-2:] # "01" 등
|
||||
|
||||
# 가상/실전 모드 설정
|
||||
if is_virtual is None:
|
||||
self.is_virtual = Config.KIS_IS_VIRTUAL
|
||||
else:
|
||||
self.is_virtual = is_virtual
|
||||
|
||||
self.base_url = Config.KIS_BASE_URL
|
||||
|
||||
self.access_token = None
|
||||
self.token_expired = None
|
||||
self.last_req_time = 0
|
||||
|
||||
# 토큰 파일 경로 (영구 저장용)
|
||||
self.token_file = os.path.join(Config.DATA_DIR, "kis_token.json")
|
||||
self.load_token() # 초기화 시 토큰 로드 시도
|
||||
|
||||
def _safe_int(self, val):
|
||||
"""안전한 int 변환"""
|
||||
try:
|
||||
if not val:
|
||||
return 0
|
||||
return int(str(val).strip())
|
||||
except:
|
||||
return 0
|
||||
|
||||
def _throttle(self):
|
||||
"""API 요청 속도 제한 (초당 2회 이하로 제한)"""
|
||||
# 모의투자는 Rate Limit이 매우 엄격함 (초당 2~3회 권장)
|
||||
min_interval = 0.5 # 0.5초 대기 (초당 2회)
|
||||
now = time.time()
|
||||
elapsed = now - self.last_req_time
|
||||
|
||||
if elapsed < min_interval:
|
||||
time.sleep(min_interval - elapsed)
|
||||
|
||||
self.last_req_time = time.time()
|
||||
|
||||
def load_token(self):
|
||||
"""파일에서 토큰 로드"""
|
||||
if os.path.exists(self.token_file):
|
||||
try:
|
||||
with open(self.token_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# 만료 시간 체크
|
||||
expire_str = data.get("expired_at")
|
||||
if expire_str:
|
||||
expire_dt = datetime.strptime(expire_str, "%Y-%m-%d %H:%M:%S")
|
||||
if datetime.now() < expire_dt:
|
||||
self.access_token = data.get("access_token")
|
||||
self.token_expired = expire_dt
|
||||
print(f"📂 [KIS] Saved Token Loaded (Expires: {expire_str})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to load token file: {e}")
|
||||
|
||||
def save_token(self):
|
||||
"""토큰 파일 저장"""
|
||||
if not self.access_token or not self.token_expired:
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
"access_token": self.access_token,
|
||||
"expired_at": self.token_expired.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
with open(self.token_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to save token file: {e}")
|
||||
|
||||
def _get_headers(self, tr_id=None):
|
||||
"""공통 헤더 생성"""
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"authorization": f"Bearer {self.access_token}",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret,
|
||||
}
|
||||
if tr_id:
|
||||
headers["tr_id"] = tr_id
|
||||
|
||||
return headers
|
||||
|
||||
def ensure_token(self, force=False):
|
||||
"""접근 토큰 발급 (OAuth 2.0) 및 유효성 관리"""
|
||||
# 토큰이 있고, 만료 시간이 아직 안 지났으면 재사용
|
||||
if not force and self.access_token and self.token_expired:
|
||||
if datetime.now() < self.token_expired:
|
||||
return
|
||||
|
||||
# 앱키 확인
|
||||
if not self.app_key or not self.app_secret:
|
||||
print("❌ [KIS] App Key or Secret is missing!")
|
||||
return
|
||||
|
||||
url = f"{self.base_url}/oauth2/tokenP"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"🔑 [KIS] 토큰 발급 요청: {url}")
|
||||
res = requests.post(url, json=payload, timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
self.access_token = data.get('access_token')
|
||||
|
||||
# 만료 시간 설정
|
||||
expires_in = int(data.get('expires_in', 86400))
|
||||
self.token_expired = datetime.now() + timedelta(seconds=expires_in - 60)
|
||||
|
||||
# 파일 저장
|
||||
self.save_token()
|
||||
|
||||
print(f"✅ [KIS] 토큰 발급 성공 (만료: {self.token_expired.strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
|
||||
except Exception as e:
|
||||
# 1분 제한 에러 핸들링 (EGW00133)
|
||||
retry = False
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
err_text = e.response.text
|
||||
print(f"📄 [KIS Error]: {err_text}")
|
||||
if "EGW00133" in err_text:
|
||||
print("⏳ [KIS] Rate Limit Hit (1 min). Waiting 65s...")
|
||||
time.sleep(65) # 1분 대기
|
||||
retry = True
|
||||
|
||||
if retry:
|
||||
# 재귀 호출 (한 번만)
|
||||
self.ensure_token()
|
||||
return
|
||||
|
||||
print(f"❌ [KIS] 토큰 발급 실패: {e}")
|
||||
self.access_token = None
|
||||
raise e
|
||||
|
||||
def get_hash_key(self, datas):
|
||||
"""주문 시 필요한 Hash Key 생성 (Koreainvestment header 특화)"""
|
||||
url = f"{self.base_url}/uapi/hashkey"
|
||||
headers = {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
try:
|
||||
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}")
|
||||
return None
|
||||
|
||||
def _request_api(self, method, endpoint, tr_id, params=None, data=None, use_hash=False):
|
||||
"""API 요청 공통 핸들러 (토큰 만료 시 자동 갱신)"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
headers = self._get_headers(tr_id)
|
||||
|
||||
if use_hash and data:
|
||||
hash_key = self.get_hash_key(data)
|
||||
if hash_key:
|
||||
headers["hashkey"] = hash_key
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
else:
|
||||
res = requests.post(url, headers=headers, json=data,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
|
||||
# 토큰 만료 체크 (500 에러 or msg_cd 확인)
|
||||
is_token_error = False
|
||||
try:
|
||||
# KIS는 토큰 만료 시 500을 주거나 200/403 등과 함께 msg_cd로 알려줌
|
||||
if res.status_code == 500 or res.status_code == 401 or res.status_code == 403:
|
||||
err_data = res.json()
|
||||
# EGW00121: 유효하지 않은 토큰, EGW00123: 만료된 토큰
|
||||
if err_data.get('msg_cd') in ['EGW00121', 'EGW00123']:
|
||||
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,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
else:
|
||||
res = requests.post(url, headers=headers, json=data,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] API Request Failed: {url} | {e}")
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Error Body]: {e.response.text}")
|
||||
raise e
|
||||
|
||||
def get_balance(self):
|
||||
"""주식 잔고 조회"""
|
||||
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R"
|
||||
endpoint = "uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
# 쿼리 파라미터
|
||||
params = {
|
||||
"CANO": self.cano,
|
||||
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "00",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
|
||||
try:
|
||||
data = self._request_api("GET", endpoint, tr_id, params=params)
|
||||
|
||||
# 응답 정리
|
||||
if data['rt_cd'] != '0':
|
||||
return {"error": data['msg1']}
|
||||
|
||||
holdings = []
|
||||
for item in data['output1']:
|
||||
if int(item['hldg_qty']) > 0:
|
||||
holdings.append({
|
||||
"code": item['pdno'],
|
||||
"name": item['prdt_name'],
|
||||
"qty": int(item['hldg_qty']),
|
||||
"yield": float(item['evlu_pfls_rt']),
|
||||
"purchase_price": float(item['pchs_avg_pric']), # 매입평균가
|
||||
"current_price": float(item['prpr']), # 현재가
|
||||
"profit_loss": int(item['evlu_pfls_amt']) # 평가손익
|
||||
})
|
||||
|
||||
summary = data['output2'][0]
|
||||
return {
|
||||
"holdings": holdings,
|
||||
"total_eval": int(summary['tot_evlu_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)}
|
||||
|
||||
def order(self, ticker, qty, buy_sell, price=0, order_type="market"):
|
||||
"""주문
|
||||
buy_sell: 'BUY' or 'SELL'
|
||||
order_type: 'market'(시장가), 'limit'(지정가), 'conditional'(조건부지정가)
|
||||
price: 지정가일 때 주문 가격 (market이면 무시)
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
# 모의투자/실전 TR ID 구분
|
||||
if buy_sell == 'BUY':
|
||||
tr_id = "VTTC0802U" if self.is_virtual else "TTTC0802U"
|
||||
else:
|
||||
tr_id = "VTTC0801U" if self.is_virtual else "TTTC0801U"
|
||||
|
||||
# 주문 구분 코드
|
||||
# 00: 지정가, 01: 시장가, 03: 최유리지정가, 05: 장전시간외, 06: 장후시간외
|
||||
if order_type == "limit" and price > 0:
|
||||
ord_dvsn = "00"
|
||||
ord_unpr = str(int(price))
|
||||
order_type_str = f"지정가({price:,.0f})"
|
||||
elif order_type == "conditional" and price > 0:
|
||||
ord_dvsn = "03" # 최유리지정가
|
||||
ord_unpr = str(int(price))
|
||||
order_type_str = f"조건부({price:,.0f})"
|
||||
else:
|
||||
ord_dvsn = "01" # 시장가
|
||||
ord_unpr = "0"
|
||||
order_type_str = "시장가"
|
||||
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
|
||||
|
||||
datas = {
|
||||
"CANO": self.cano,
|
||||
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
||||
"PDNO": ticker,
|
||||
"ORD_DVSN": ord_dvsn,
|
||||
"ORD_QTY": str(qty),
|
||||
"ORD_UNPR": ord_unpr
|
||||
}
|
||||
|
||||
headers = self._get_headers(tr_id=tr_id)
|
||||
hash_key = self.get_hash_key(datas)
|
||||
if hash_key:
|
||||
headers["hashkey"] = hash_key
|
||||
else:
|
||||
print("⚠️ [KIS] Hash Key 생성 실패 (주문 전송 시도)")
|
||||
|
||||
try:
|
||||
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea ({order_type_str})")
|
||||
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
print(f"📥 [KIS] 주문 응답 코드(rt_cd): {data['rt_cd']}")
|
||||
print(f"📥 [KIS] 주문 응답 메시지(msg1): {data['msg1']}")
|
||||
|
||||
if data['rt_cd'] != '0':
|
||||
return {"status": False, "msg": data['msg1'], "rt_cd": data['rt_cd']}
|
||||
|
||||
return {"status": True, "msg": "주문 전송 완료", "order_no": data['output']['ODNO'], "rt_cd": data['rt_cd']}
|
||||
except Exception as e:
|
||||
return {"status": False, "msg": str(e), "rt_cd": "EXCEPTION"}
|
||||
|
||||
def get_current_price(self, ticker):
|
||||
"""현재가 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
|
||||
headers = self._get_headers(tr_id="FHKST01010100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
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 None
|
||||
return int(data['output']['stck_prpr']) # 현재가
|
||||
except Exception as e:
|
||||
print(f"❌ 현재가 조회 실패: {e}")
|
||||
return None
|
||||
|
||||
def _get_daily_ohlcv_by_range(self, ticker, period="D", count=100):
|
||||
"""기간별시세 API (FHKST03010100) - OHLCV 전체 반환
|
||||
output2에서 stck_oprc, stck_hgpr, stck_lwpr, stck_clpr, acml_vol 파싱
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self._get_headers(tr_id="FHKST03010100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
if data.get('rt_cd') != '0':
|
||||
return None
|
||||
|
||||
output = data.get('output2', [])
|
||||
if not output:
|
||||
return None
|
||||
|
||||
opens, highs, lows, closes, volumes = [], [], [], [], []
|
||||
for item in output:
|
||||
try:
|
||||
c = int(item.get('stck_clpr', 0) or 0)
|
||||
o = int(item.get('stck_oprc', 0) or 0)
|
||||
h = int(item.get('stck_hgpr', 0) or 0)
|
||||
l = int(item.get('stck_lwpr', 0) or 0)
|
||||
v = int(item.get('acml_vol', 0) or 0)
|
||||
if c > 0:
|
||||
opens.append(o if o > 0 else c)
|
||||
highs.append(h if h > 0 else c)
|
||||
lows.append(l if l > 0 else c)
|
||||
closes.append(c)
|
||||
volumes.append(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if not closes:
|
||||
return None
|
||||
|
||||
# API는 최신순 → 과거→현재 순으로 변환
|
||||
opens.reverse(); highs.reverse(); lows.reverse()
|
||||
closes.reverse(); volumes.reverse()
|
||||
|
||||
result = {
|
||||
'open': opens[-count:],
|
||||
'high': highs[-count:],
|
||||
'low': lows[-count:],
|
||||
'close': closes[-count:],
|
||||
'volume': volumes[-count:]
|
||||
}
|
||||
print(f"[KIS] {ticker} OHLCV: {len(result['close'])}개 ({start_date}~{end_date})")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [KIS] OHLCV 조회 실패 ({ticker}): {e}")
|
||||
return None
|
||||
|
||||
def get_daily_ohlcv(self, ticker, period="D", count=100):
|
||||
"""일별 OHLCV 시세 조회 (기술적 분석 + LSTM 7차원 입력용)
|
||||
1차: 기간별시세 API OHLCV 파싱 (100일)
|
||||
2차: 기존 close-only fallback
|
||||
"""
|
||||
ohlcv = self._get_daily_ohlcv_by_range(ticker, period, count)
|
||||
if ohlcv and len(ohlcv['close']) >= 30:
|
||||
return ohlcv
|
||||
|
||||
# fallback: close만 반환 (가짜 OHLCV)
|
||||
print(f"[KIS] {ticker} OHLCV 실패 → close-only fallback")
|
||||
prices = self._get_daily_price_by_range(ticker, period, count)
|
||||
if not prices:
|
||||
return None
|
||||
return {
|
||||
'open': prices, 'high': prices, 'low': prices,
|
||||
'close': prices, 'volume': []
|
||||
}
|
||||
|
||||
def _get_daily_price_by_range(self, ticker, period="D", count=100):
|
||||
"""기간별시세 API (FHKST03010100) - 날짜 범위로 최대 100일 데이터 반환
|
||||
inquire-daily-price(FHKST01010400)가 30일만 반환하는 한계 극복"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
# 영업일 count개 확보를 위해 역일 1.6배 요청 (주말/공휴일 여유)
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self._get_headers(tr_id="FHKST03010100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
if data.get('rt_cd') != '0':
|
||||
return []
|
||||
|
||||
# 기간별시세는 output2에 배열로 반환
|
||||
output = data.get('output2', [])
|
||||
if not output:
|
||||
return []
|
||||
|
||||
prices = []
|
||||
for item in output:
|
||||
clpr = item.get('stck_clpr', '')
|
||||
if clpr and clpr != '0':
|
||||
try:
|
||||
prices.append(int(clpr))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
prices.reverse() # API는 최신순 → 과거→현재 순으로 변환
|
||||
result = prices[-count:]
|
||||
print(f"[KIS] {ticker} 기간별시세: {len(result)}개 "
|
||||
f"({start_date}~{end_date})")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [KIS] 기간별시세 조회 실패 ({ticker}): {e}")
|
||||
return []
|
||||
|
||||
def get_daily_price(self, ticker, period="D", count=100):
|
||||
"""일별 시세 조회 (기술적 분석 + LSTM용)
|
||||
1차: 기간별시세 API (100일, LSTM 학습 가능)
|
||||
2차: 구형 API fallback (30일)
|
||||
"""
|
||||
# 1차: 기간별시세 API (FHKST03010100) - 100일
|
||||
prices = self._get_daily_price_by_range(ticker, period, count)
|
||||
if prices and len(prices) >= 30:
|
||||
return prices
|
||||
|
||||
# 2차: 구형 API fallback (FHKST01010400) - 30일
|
||||
print(f"[KIS] {ticker} 기간별시세 실패 → 구형 API(30일) fallback")
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
||||
headers = self._get_headers(tr_id="FHKST01010400")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data.get('rt_cd') != '0':
|
||||
return []
|
||||
prices = [int(item['stck_clpr']) for item in data['output']
|
||||
if item.get('stck_clpr')]
|
||||
prices.reverse()
|
||||
return prices
|
||||
except Exception as e:
|
||||
print(f"❌ 일별 시세 조회 실패 ({ticker}): {e}")
|
||||
return []
|
||||
|
||||
def get_volume_rank(self, limit=5):
|
||||
"""거래량 상위 종목 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||
headers = self._get_headers(tr_id="FHPST01710000")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J", # 주식, ETF, ETN 전체
|
||||
"FID_COND_SCR_RSLT_GD_CD": "20171", # 전체
|
||||
"FID_INPUT_ISCD": "0000", # 전체
|
||||
"FID_DIV_CLS_CODE": "0", # 0: 전체
|
||||
"FID_BLNG_CLS_CODE": "0", # 0: 전체
|
||||
"FID_TRGT_CLS_CODE": "111111111", # 필터링 조건 (이대로 두면 됨)
|
||||
"FID_TRGT_EXCLS_CLS_CODE": "0000000000", # 제외 조건
|
||||
"FID_INPUT_PRICE_1": "",
|
||||
"FID_INPUT_PRICE_2": "",
|
||||
"FID_VOL_CNT": "",
|
||||
"FID_INPUT_DATE_1": ""
|
||||
}
|
||||
|
||||
try:
|
||||
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
|
||||
results.append({
|
||||
"code": item['mksc_shrn_iscd'],
|
||||
"name": item['hts_kor_isnm'],
|
||||
"volume": int(item['acml_vol']),
|
||||
"price": int(item['stck_prpr'])
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"❌ 거래량 순위 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
def buy_stock(self, ticker, qty):
|
||||
return self.order(ticker, qty, 'BUY')
|
||||
|
||||
def get_current_index(self, ticker):
|
||||
"""지수 현재가 조회 (업종/지수)
|
||||
ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc.
|
||||
"""
|
||||
endpoint = "uapi/domestic-stock/v1/quotations/inquire-index-price"
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
data = self._request_api("GET", endpoint, "FHKUP03500100", params=params)
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
o = data['output']
|
||||
def _f(val): return float(val) if val else 0.0
|
||||
def _i(val): return int(float(val)) if val else 0
|
||||
return {
|
||||
"price": _f(o.get('bstp_nmix_prpr')), # 현재지수
|
||||
"change": _f(o.get('bstp_nmix_prdy_ctrt')), # 등락률(%)
|
||||
"change_val": _f(o.get('bstp_nmix_prdy_vrss')), # 전일 대비 포인트
|
||||
"high": _f(o.get('bstp_nmix_hgpr')), # 장중 고가
|
||||
"low": _f(o.get('bstp_nmix_lwpr')), # 장중 저가
|
||||
"prev_close": _f(o.get('prdy_nmix')), # 전일 종가
|
||||
"volume": _i(o.get('acml_vol')), # 누적 거래량(천주)
|
||||
"trade_value": _i(o.get('acml_tr_pbmn')), # 누적 거래대금(백만원)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"❌ 지수 조회 실패({ticker}): {e}")
|
||||
return None
|
||||
|
||||
def sell_stock(self, ticker, qty):
|
||||
return self.order(ticker, qty, 'SELL')
|
||||
|
||||
def get_daily_index_price(self, ticker, period="D"):
|
||||
"""지수 일별 시세 조회 (Market Stress Index용)"""
|
||||
endpoint = "uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
|
||||
|
||||
# 날짜 계산 (최근 100일)
|
||||
end_dt = datetime.now().strftime("%Y%m%d")
|
||||
start_dt = (datetime.now() - timedelta(days=100)).strftime("%Y%m%d")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_dt, # 시작일
|
||||
"FID_INPUT_DATE_2": end_dt, # 종료일
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "0" # 수정주가 반영 여부
|
||||
}
|
||||
|
||||
try:
|
||||
data = self._request_api("GET", endpoint, "FHKUP03500200", params=params)
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
# output 리스트: [ {bstp_nmix_prpr: 지수, ...}, ... ]
|
||||
prices = [float(item['bstp_nmix_prpr']) for item in data['output']]
|
||||
prices.reverse() # 과거 -> 현재
|
||||
return prices
|
||||
except Exception as e:
|
||||
print(f"❌ 지수 일별 시세 조회 실패({ticker}): {e}")
|
||||
return []
|
||||
|
||||
def get_investor_trend(self, ticker):
|
||||
"""종목별 투자자(외인/기관) 매매동향 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
||||
headers = self._get_headers(tr_id="FHKST01010900")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
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 None
|
||||
|
||||
trends = []
|
||||
for item in data['output'][:5]:
|
||||
trends.append({
|
||||
"date": item['stck_bsop_date'],
|
||||
"foreigner": self._safe_int(item.get('frgn_ntby_qty')),
|
||||
"institutional": self._safe_int(item.get('orgn_ntby_qty')),
|
||||
"price_change": float(item['prdy_vrss'])
|
||||
})
|
||||
|
||||
return trends
|
||||
except Exception as e:
|
||||
print(f"[KIS] 투자자 동향 조회 실패({ticker}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
class KISAsyncClient:
|
||||
"""
|
||||
비동기 KIS API 클라이언트
|
||||
- aiohttp 기반 HTTP 호출
|
||||
- 동기 KISClient의 토큰/설정을 공유
|
||||
- 다중 종목 병렬 수집용
|
||||
"""
|
||||
def __init__(self, sync_client):
|
||||
self.sync = sync_client
|
||||
self.min_interval = 0.5 # 초당 2회 제한
|
||||
|
||||
async def _async_get(self, session, url, headers, params):
|
||||
"""비동기 GET 요청"""
|
||||
try:
|
||||
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}")
|
||||
return None
|
||||
|
||||
async def get_daily_price_async(self, ticker):
|
||||
"""비동기 일별 시세 조회 (close only, 하위 호환)"""
|
||||
import aiohttp
|
||||
|
||||
self.sync.ensure_token()
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010400")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = await self._async_get(session, url, headers, params)
|
||||
if data and data.get('rt_cd') == '0':
|
||||
prices = [int(item['stck_clpr']) for item in data['output']]
|
||||
prices.reverse()
|
||||
return prices
|
||||
return []
|
||||
|
||||
async def get_daily_ohlcv_async(self, ticker, count=100):
|
||||
"""비동기 OHLCV 조회 (기간별시세 API 사용)"""
|
||||
import aiohttp
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
self.sync.ensure_token()
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self.sync._get_headers(tr_id="FHKST03010100")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = await self._async_get(session, url, headers, params)
|
||||
if data and data.get('rt_cd') == '0':
|
||||
output = data.get('output2', [])
|
||||
opens, highs, lows, closes, volumes = [], [], [], [], []
|
||||
for item in output:
|
||||
try:
|
||||
c = int(item.get('stck_clpr', 0) or 0)
|
||||
if c > 0:
|
||||
opens.append(int(item.get('stck_oprc', 0) or c))
|
||||
highs.append(int(item.get('stck_hgpr', 0) or c))
|
||||
lows.append(int(item.get('stck_lwpr', 0) or c))
|
||||
closes.append(c)
|
||||
volumes.append(int(item.get('acml_vol', 0) or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if closes:
|
||||
opens.reverse(); highs.reverse(); lows.reverse()
|
||||
closes.reverse(); volumes.reverse()
|
||||
return {
|
||||
'open': opens[-count:], 'high': highs[-count:],
|
||||
'low': lows[-count:], 'close': closes[-count:],
|
||||
'volume': volumes[-count:]
|
||||
}
|
||||
return None
|
||||
|
||||
async def get_investor_trend_async(self, ticker):
|
||||
"""비동기 투자자 동향 조회"""
|
||||
import aiohttp
|
||||
|
||||
self.sync.ensure_token()
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010900")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = await self._async_get(session, url, headers, params)
|
||||
if data and data.get('rt_cd') == '0':
|
||||
trends = []
|
||||
for item in data['output'][:5]:
|
||||
trends.append({
|
||||
"date": item['stck_bsop_date'],
|
||||
"foreigner": self.sync._safe_int(item.get('frgn_ntby_qty')),
|
||||
"institutional": self.sync._safe_int(item.get('orgn_ntby_qty')),
|
||||
"price_change": float(item['prdy_vrss'])
|
||||
})
|
||||
return trends
|
||||
return None
|
||||
|
||||
async def get_daily_prices_batch(self, tickers):
|
||||
"""여러 종목의 일별 시세(close only)를 병렬로 조회 (하위 호환)"""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
self.sync.ensure_token()
|
||||
results = {}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = []
|
||||
for i, ticker in enumerate(tickers):
|
||||
if i > 0:
|
||||
await asyncio.sleep(self.min_interval)
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010400")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
||||
|
||||
for ticker, task in tasks:
|
||||
data = await task
|
||||
if data and data.get('rt_cd') == '0':
|
||||
prices = [int(item['stck_clpr']) for item in data['output']]
|
||||
prices.reverse()
|
||||
results[ticker] = prices
|
||||
else:
|
||||
results[ticker] = []
|
||||
|
||||
return results
|
||||
|
||||
async def get_daily_ohlcv_batch(self, tickers, count=100):
|
||||
"""여러 종목의 OHLCV를 병렬로 조회 (기간별시세 API)"""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
self.sync.ensure_token()
|
||||
results = {}
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = []
|
||||
for i, ticker in enumerate(tickers):
|
||||
if i > 0:
|
||||
await asyncio.sleep(self.min_interval)
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self.sync._get_headers(tr_id="FHKST03010100")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
||||
|
||||
for ticker, task in tasks:
|
||||
data = await task
|
||||
if data and data.get('rt_cd') == '0':
|
||||
output = data.get('output2', [])
|
||||
opens, highs, lows, closes, volumes = [], [], [], [], []
|
||||
for item in output:
|
||||
try:
|
||||
c = int(item.get('stck_clpr', 0) or 0)
|
||||
if c > 0:
|
||||
opens.append(int(item.get('stck_oprc', 0) or c))
|
||||
highs.append(int(item.get('stck_hgpr', 0) or c))
|
||||
lows.append(int(item.get('stck_lwpr', 0) or c))
|
||||
closes.append(c)
|
||||
volumes.append(int(item.get('acml_vol', 0) or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if closes:
|
||||
opens.reverse(); highs.reverse(); lows.reverse()
|
||||
closes.reverse(); volumes.reverse()
|
||||
results[ticker] = {
|
||||
'open': opens[-count:], 'high': highs[-count:],
|
||||
'low': lows[-count:], 'close': closes[-count:],
|
||||
'volume': volumes[-count:]
|
||||
}
|
||||
else:
|
||||
results[ticker] = None
|
||||
else:
|
||||
results[ticker] = None
|
||||
|
||||
return results
|
||||
|
||||
async def get_investor_trends_batch(self, tickers):
|
||||
"""여러 종목의 투자자 동향을 병렬로 조회"""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
self.sync.ensure_token()
|
||||
results = {}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = []
|
||||
for i, ticker in enumerate(tickers):
|
||||
if i > 0:
|
||||
await asyncio.sleep(self.min_interval)
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010900")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
||||
|
||||
for ticker, task in tasks:
|
||||
data = await task
|
||||
if data and data.get('rt_cd') == '0':
|
||||
trends = []
|
||||
for item in data['output'][:5]:
|
||||
trends.append({
|
||||
"date": item['stck_bsop_date'],
|
||||
"foreigner": self.sync._safe_int(item.get('frgn_ntby_qty')),
|
||||
"institutional": self.sync._safe_int(item.get('orgn_ntby_qty')),
|
||||
"price_change": float(item['prdy_vrss'])
|
||||
})
|
||||
results[ticker] = trends
|
||||
else:
|
||||
results[ticker] = None
|
||||
|
||||
return results
|
||||
199
signal_v1/modules/services/llm_client.py
Normal file
199
signal_v1/modules/services/llm_client.py
Normal 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
|
||||
122
signal_v1/modules/services/news.py
Normal file
122
signal_v1/modules/services/news.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import time
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _parse_items(root, max_items):
|
||||
"""RSS item → [{title, url, pub_date, source}]"""
|
||||
out = []
|
||||
for item in root.findall(".//item")[:max_items]:
|
||||
t = item.find("title")
|
||||
l = item.find("link")
|
||||
p = item.find("pubDate")
|
||||
title = (t.text or "").strip() if t is not None else ""
|
||||
url = (l.text or "").strip() if l is not None else ""
|
||||
pub = (p.text or "").strip() if p is not None else ""
|
||||
if not title:
|
||||
continue
|
||||
out.append({"title": title, "url": url, "pub_date": pub, "source": "Google News"})
|
||||
return out
|
||||
|
||||
|
||||
class NewsCollector:
|
||||
"""동기 뉴스 수집 (Google News RSS)"""
|
||||
@staticmethod
|
||||
def get_market_news(query="주식 시장"):
|
||||
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
root = ET.fromstring(resp.content)
|
||||
return _parse_items(root, 5)
|
||||
except Exception as e:
|
||||
print(f"[News] Collection failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
class AsyncNewsCollector:
|
||||
"""비동기 뉴스 수집 + 5분 캐싱 + (옵션) 스냅샷 저장"""
|
||||
|
||||
def __init__(self, snapshot_store=None):
|
||||
self._cache = None
|
||||
self._cache_time = 0
|
||||
self._cache_ttl = 300 # 5분
|
||||
self._stock_cache = {} # {stock_name: (items, timestamp)}
|
||||
self._snap = snapshot_store # NewsSnapshotStore | None
|
||||
|
||||
def _save_snapshot(self, items, query: str, ticker: Optional[str] = None):
|
||||
if not self._snap or not items:
|
||||
return
|
||||
try:
|
||||
self._snap.save_many(items, query=query, ticker=ticker)
|
||||
except Exception as e:
|
||||
print(f"[News] snapshot 저장 실패: {e}")
|
||||
|
||||
def get_market_news(self, query="주식 시장"):
|
||||
"""동기 인터페이스 (하위 호환)"""
|
||||
now = time.time()
|
||||
if self._cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return self._cache
|
||||
|
||||
result = NewsCollector.get_market_news(query)
|
||||
self._cache = result
|
||||
self._cache_time = now
|
||||
self._save_snapshot(result, query=query)
|
||||
return result
|
||||
|
||||
async def get_market_news_async(self, query="주식 시장"):
|
||||
"""비동기 뉴스 수집 (aiohttp + 캐싱)"""
|
||||
now = time.time()
|
||||
if self._cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return self._cache
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
content = await resp.read()
|
||||
root = ET.fromstring(content)
|
||||
items = _parse_items(root, 5)
|
||||
self._cache = items
|
||||
self._cache_time = now
|
||||
self._save_snapshot(items, query=query)
|
||||
return items
|
||||
except ImportError:
|
||||
return self.get_market_news(query)
|
||||
except Exception as e:
|
||||
print(f"[News Async] Collection failed: {e}")
|
||||
if self._cache:
|
||||
return self._cache
|
||||
return self.get_market_news(query)
|
||||
|
||||
async def get_stock_news_async(self, stock_name, max_items=3, ticker: Optional[str] = None):
|
||||
"""종목별 뉴스 수집 (5분 캐싱)
|
||||
stock_name: 종목 이름 (e.g. '삼성전자', 'SK하이닉스')
|
||||
ticker: 스냅샷 저장 시 종목코드 (옵션)
|
||||
"""
|
||||
now = time.time()
|
||||
cached = self._stock_cache.get(stock_name)
|
||||
if cached and (now - cached[1]) < self._cache_ttl:
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
import urllib.parse
|
||||
query = urllib.parse.quote(f"{stock_name} 주가")
|
||||
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
content = await resp.read()
|
||||
root = ET.fromstring(content)
|
||||
items = _parse_items(root, max_items)
|
||||
self._stock_cache[stock_name] = (items, now)
|
||||
self._save_snapshot(items, query=f"{stock_name} 주가", ticker=ticker)
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[News] 종목 뉴스 수집 실패 ({stock_name}): {e}")
|
||||
return []
|
||||
|
||||
def clear_stock_cache(self):
|
||||
"""종목 뉴스 캐시 전체 초기화"""
|
||||
self._stock_cache.clear()
|
||||
189
signal_v1/modules/services/news_snapshot.py
Normal file
189
signal_v1/modules/services/news_snapshot.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
뉴스 스냅샷 인프라 (v3.2)
|
||||
|
||||
목적:
|
||||
- 수집한 뉴스를 SQLite에 타임스탬프와 함께 영구 저장
|
||||
- 사후 감성 신호 재검증 (LLM 재호출 / 모델 비교) 가능하게
|
||||
- 백테스트에서 '그 시점에 실제로 알 수 있던 뉴스'만 사용
|
||||
|
||||
스키마:
|
||||
news_snapshots(
|
||||
id INTEGER PK,
|
||||
captured_at TEXT, # ISO8601 (KST) — 수집 시점
|
||||
query TEXT, # 수집 쿼리 (예: '주식 시장', '삼성전자')
|
||||
ticker TEXT, # 종목 코드 (종목 뉴스일 때, else NULL)
|
||||
title TEXT,
|
||||
url TEXT UNIQUE,
|
||||
pub_date TEXT, # RSS pubDate 원본
|
||||
source TEXT DEFAULT 'google_news'
|
||||
)
|
||||
sentiment_scores( # 야간 배치로 사후 생성
|
||||
news_id INTEGER PK,
|
||||
scored_at TEXT,
|
||||
model TEXT,
|
||||
sentiment REAL, # -1.0 ~ 1.0
|
||||
confidence REAL,
|
||||
raw_json TEXT,
|
||||
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
|
||||
)
|
||||
|
||||
순수 I/O 모듈 — 네트워크 의존성 없음 → unit 테스트 가능.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Iterable, List, Optional, Dict
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
class NewsSnapshotStore:
|
||||
"""
|
||||
SQLite 기반 뉴스 스냅샷 저장소.
|
||||
|
||||
사용 예:
|
||||
store = NewsSnapshotStore("data/news_snapshots.db")
|
||||
store.save_many(items, query="삼성전자", ticker="005930")
|
||||
rows = store.query_between(start, end, ticker="005930")
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 스키마
|
||||
# ──────────────────────────────────────────────
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self):
|
||||
with self._connect() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS news_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
captured_at TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
ticker TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
pub_date TEXT,
|
||||
source TEXT DEFAULT 'google_news'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_captured
|
||||
ON news_snapshots(captured_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_ticker
|
||||
ON news_snapshots(ticker, captured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sentiment_scores (
|
||||
news_id INTEGER PRIMARY KEY,
|
||||
scored_at TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
sentiment REAL NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
raw_json TEXT,
|
||||
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
|
||||
);
|
||||
""")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 쓰기
|
||||
# ──────────────────────────────────────────────
|
||||
def save_many(self, items: Iterable[Dict], query: str,
|
||||
ticker: Optional[str] = None,
|
||||
captured_at: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
뉴스 다건 저장. URL 기준 중복 자동 무시.
|
||||
|
||||
Args:
|
||||
items: [{"title": str, "url": str, "pub_date": str?}, ...]
|
||||
|
||||
Returns:
|
||||
실제로 삽입된 행 수
|
||||
"""
|
||||
if captured_at is None:
|
||||
captured_at = datetime.now(KST)
|
||||
ts = captured_at.isoformat()
|
||||
|
||||
rows = []
|
||||
for it in items:
|
||||
title = (it.get("title") or "").strip()
|
||||
url = (it.get("url") or "").strip()
|
||||
if not title or not url:
|
||||
continue
|
||||
rows.append((ts, query, ticker, title, url, it.get("pub_date")))
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
with self._connect() as conn:
|
||||
before = conn.total_changes
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO news_snapshots "
|
||||
"(captured_at, query, ticker, title, url, pub_date) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
inserted = conn.total_changes - before
|
||||
return inserted
|
||||
|
||||
def save_sentiment(self, news_id: int, model: str,
|
||||
sentiment: float, confidence: float,
|
||||
raw_json: str = "",
|
||||
scored_at: Optional[datetime] = None) -> None:
|
||||
if scored_at is None:
|
||||
scored_at = datetime.now(KST)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sentiment_scores "
|
||||
"(news_id, scored_at, model, sentiment, confidence, raw_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(news_id, scored_at.isoformat(), model,
|
||||
float(sentiment), float(confidence), raw_json),
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 읽기
|
||||
# ──────────────────────────────────────────────
|
||||
def query_between(self, start: datetime, end: datetime,
|
||||
ticker: Optional[str] = None,
|
||||
query: Optional[str] = None) -> List[sqlite3.Row]:
|
||||
"""특정 기간 내 수집된 뉴스 조회."""
|
||||
sql = "SELECT * FROM news_snapshots WHERE captured_at >= ? AND captured_at < ?"
|
||||
args = [start.isoformat(), end.isoformat()]
|
||||
if ticker is not None:
|
||||
sql += " AND ticker = ?"
|
||||
args.append(ticker)
|
||||
if query is not None:
|
||||
sql += " AND query = ?"
|
||||
args.append(query)
|
||||
sql += " ORDER BY captured_at ASC"
|
||||
with self._connect() as conn:
|
||||
return list(conn.execute(sql, args))
|
||||
|
||||
def pending_sentiment(self, limit: int = 100) -> List[sqlite3.Row]:
|
||||
"""아직 감성 점수가 없는 뉴스 반환 (야간 배치용)."""
|
||||
with self._connect() as conn:
|
||||
return list(conn.execute(
|
||||
"""SELECT n.* FROM news_snapshots n
|
||||
LEFT JOIN sentiment_scores s ON s.news_id = n.id
|
||||
WHERE s.news_id IS NULL
|
||||
ORDER BY n.captured_at DESC
|
||||
LIMIT ?""",
|
||||
(limit,)
|
||||
))
|
||||
|
||||
def stats(self) -> Dict:
|
||||
"""DB 통계 (row 수, 감성 커버리지)."""
|
||||
with self._connect() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM news_snapshots").fetchone()[0]
|
||||
scored = conn.execute("SELECT COUNT(*) FROM sentiment_scores").fetchone()[0]
|
||||
return {
|
||||
"total_news": total,
|
||||
"scored": scored,
|
||||
"pending": total - scored,
|
||||
"coverage_pct": (scored / total * 100) if total else 0.0,
|
||||
}
|
||||
136
signal_v1/modules/services/ollama.py
Normal file
136
signal_v1/modules/services/ollama.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import requests
|
||||
import json
|
||||
import psutil
|
||||
try:
|
||||
import pynvml
|
||||
except ImportError:
|
||||
pynvml = None
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class OllamaManager:
|
||||
"""
|
||||
Ollama API 세션 관리 및 메모리 누수 방지 래퍼
|
||||
- GPU VRAM 사용량 모니터링
|
||||
- keep_alive 파라미터를 통한 메모리 관리
|
||||
"""
|
||||
def __init__(self, model_name=None, base_url=None):
|
||||
self.model_name = model_name or Config.OLLAMA_MODEL
|
||||
self.base_url = base_url or Config.OLLAMA_API_URL
|
||||
self.generate_url = f"{self.base_url}/api/generate"
|
||||
|
||||
self.gpu_available = False
|
||||
try:
|
||||
if pynvml:
|
||||
pynvml.nvmlInit()
|
||||
self.handle = pynvml.nvmlDeviceGetHandleByIndex(0) # 0번 GPU (5070 Ti)
|
||||
self.gpu_available = True
|
||||
print("✅ [OllamaManager] NVIDIA GPU Monitoring On")
|
||||
else:
|
||||
print("⚠️ [OllamaManager] 'nvidia-ml-py' not installed. GPU monitoring disabled.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [OllamaManager] GPU Init Failed: {e}")
|
||||
|
||||
def check_vram(self):
|
||||
"""현재 GPU VRAM 사용량(GB) 반환"""
|
||||
if not self.gpu_available:
|
||||
return 0.0
|
||||
try:
|
||||
info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
|
||||
used_gb = info.used / 1024**3
|
||||
return used_gb
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_gpu_status(self):
|
||||
"""GPU 종합 상태 반환 (온도, 메모리, 사용률, 이름)"""
|
||||
if not self.gpu_available:
|
||||
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
|
||||
|
||||
try:
|
||||
# GPU 이름
|
||||
name = pynvml.nvmlDeviceGetName(self.handle)
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode('utf-8')
|
||||
|
||||
# 온도
|
||||
temp = pynvml.nvmlDeviceGetTemperature(self.handle, pynvml.NVML_TEMPERATURE_GPU)
|
||||
# 메모리
|
||||
mem_info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
|
||||
vram_used = mem_info.used / 1024**3
|
||||
vram_total = mem_info.total / 1024**3
|
||||
# 사용률
|
||||
util = pynvml.nvmlDeviceGetUtilizationRates(self.handle)
|
||||
load = util.gpu
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"temp": temp,
|
||||
"vram_used": round(vram_used, 1),
|
||||
"vram_total": round(vram_total, 1),
|
||||
"load": load
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⚠️ GPU Status Check Failed: {e}")
|
||||
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
|
||||
|
||||
def is_training_active(self):
|
||||
"""LSTM 학습 중인지 확인 (GPU 메모리 충돌 방지)"""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
# VRAM 사용량으로 학습 여부 추정
|
||||
vram = self.check_vram()
|
||||
return vram > Config.VRAM_WARNING_THRESHOLD
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def request_inference(self, prompt, context_data=None):
|
||||
"""
|
||||
Ollama에 추론 요청
|
||||
- LSTM 학습 중이면 대기 (GPU 메모리 충돌 방지)
|
||||
"""
|
||||
# LSTM 학습 중이면 최대 60초 대기
|
||||
import time as _time
|
||||
for _ in range(12):
|
||||
if not self.is_training_active():
|
||||
break
|
||||
print("[Ollama] Waiting for LSTM training to finish...")
|
||||
_time.sleep(5)
|
||||
|
||||
vram = self.check_vram()
|
||||
if vram > Config.VRAM_WARNING_THRESHOLD:
|
||||
print(f"[OllamaManager] High VRAM Usage ({vram:.1f}GB). Requesting unload.")
|
||||
try:
|
||||
# keep_alive=0으로 설정하여 모델 즉시 언로드
|
||||
requests.post(self.generate_url,
|
||||
json={"model": self.model_name, "keep_alive": 0}, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unload model: {e}")
|
||||
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"num_ctx": Config.OLLAMA_NUM_CTX, # 4096 (속도 2배)
|
||||
"num_predict": Config.OLLAMA_NUM_PREDICT, # 응답 토큰 제한
|
||||
"temperature": 0.1, # 더 결정론적 (JSON 파싱 안정성)
|
||||
"num_gpu": 1,
|
||||
"num_thread": Config.OLLAMA_NUM_THREAD # Config 설정값 (기본 8)
|
||||
},
|
||||
"keep_alive": "5m" # 5분 유지 (불필요한 VRAM 점유 줄임)
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.generate_url, json=payload, timeout=90) # 180→90초
|
||||
response.raise_for_status()
|
||||
return response.json().get('response')
|
||||
except requests.exceptions.Timeout:
|
||||
print(f"❌ Inference Timeout (90s): {self.model_name}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ Inference Error: {e}")
|
||||
return None
|
||||
34
signal_v1/modules/services/telegram.py
Normal file
34
signal_v1/modules/services/telegram.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import requests
|
||||
import os
|
||||
import threading
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class TelegramMessenger:
|
||||
def __init__(self, token=None, chat_id=None):
|
||||
# 환경 변수에서 로드하거나 인자로 받음
|
||||
self.token = token or Config.TELEGRAM_BOT_TOKEN
|
||||
self.chat_id = chat_id or Config.TELEGRAM_CHAT_ID
|
||||
|
||||
if not self.token or not self.chat_id:
|
||||
print("⚠️ [Telegram] Token or Chat ID not found.")
|
||||
|
||||
def send_message(self, message):
|
||||
"""별도 스레드로 메시지를 전송하여 메인 루프 블로킹 방지"""
|
||||
if not self.token or not self.chat_id:
|
||||
return
|
||||
|
||||
def _send():
|
||||
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Telegram] Error: {e}")
|
||||
|
||||
# 스레드 실행 (Fire-and-forget)
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
91
signal_v1/modules/services/telegram_bot/runner.py
Normal file
91
signal_v1/modules/services/telegram_bot/runner.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
멀티프로세스 방식 - 텔레그램 봇 프로세스
|
||||
트레이딩 봇과 완전히 분리된 독립 프로세스로 실행
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
|
||||
|
||||
|
||||
def run_telegram_bot_standalone(ipc_lock=None, command_queue=None, shutdown_event=None):
|
||||
"""텔레그램 봇만 독립적으로 실행"""
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../')))
|
||||
|
||||
from modules.services.telegram_bot.server import TelegramBotServer
|
||||
from modules.utils.ipc import SharedIPC
|
||||
from modules.utils.process_tracker import ProcessTracker
|
||||
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
print("[Telegram] TELEGRAM_BOT_TOKEN not found in .env")
|
||||
sys.exit(1)
|
||||
|
||||
ProcessTracker.register("Telegram Bot Standalone")
|
||||
print(f"[Telegram Bot Process] Starting... (PID: {os.getpid()})")
|
||||
|
||||
# IPC 초기화 (shared memory + command queue)
|
||||
ipc = SharedIPC(lock=ipc_lock, command_queue=command_queue)
|
||||
|
||||
conflict_retries = 0
|
||||
MAX_CONFLICT_RETRIES = 10
|
||||
|
||||
while True:
|
||||
# shutdown 체크
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("[Telegram Bot] Shutdown signal received.")
|
||||
break
|
||||
|
||||
try:
|
||||
bot_server = TelegramBotServer(token, ipc=ipc, shutdown_event=shutdown_event)
|
||||
|
||||
# 초기 데이터 로드
|
||||
try:
|
||||
instance_data = ipc.get_bot_instance_data()
|
||||
if instance_data:
|
||||
bot_server.set_bot_instance(instance_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bot_server.run()
|
||||
|
||||
if bot_server.should_restart:
|
||||
print("[Telegram Bot] Restarting instance...")
|
||||
conflict_retries = 0 # 정상 재시작 시 카운터 리셋
|
||||
time.sleep(1)
|
||||
continue
|
||||
else:
|
||||
print("[Telegram Bot] Process exiting.")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("[Telegram Bot] Stopped by user")
|
||||
break
|
||||
except Exception as e:
|
||||
if "Conflict" in str(e):
|
||||
conflict_retries += 1
|
||||
if conflict_retries >= MAX_CONFLICT_RETRIES:
|
||||
print(f"[Telegram Bot] Conflict max retries ({MAX_CONFLICT_RETRIES}) reached. Exiting.")
|
||||
break
|
||||
wait_secs = min(5 * conflict_retries, 30)
|
||||
print(f"[Telegram Bot] Conflict detected. Waiting {wait_secs}s before retry "
|
||||
f"({conflict_retries}/{MAX_CONFLICT_RETRIES})...")
|
||||
time.sleep(wait_secs)
|
||||
continue
|
||||
else:
|
||||
print(f"[Telegram Bot] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
# 정리
|
||||
ipc.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
run_telegram_bot_standalone()
|
||||
601
signal_v1/modules/services/telegram_bot/server.py
Normal file
601
signal_v1/modules/services/telegram_bot/server.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
텔레그램 봇 - Shared Memory IPC + 양방향 명령 채널
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes
|
||||
|
||||
# [디버깅] 파일 로깅 추가
|
||||
log_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
|
||||
"telegram_bot.log")
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
|
||||
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
level=logging.INFO,
|
||||
handlers=[logging.StreamHandler(), file_handler]
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class TelegramBotServer:
|
||||
def __init__(self, bot_token, ipc=None, shutdown_event=None):
|
||||
self.application = Application.builder()\
|
||||
.token(bot_token)\
|
||||
.concurrent_updates(True)\
|
||||
.build()
|
||||
|
||||
self.bot_instance = None
|
||||
self.ipc = ipc
|
||||
self.shutdown_event = shutdown_event
|
||||
self.is_shutting_down = False
|
||||
self.should_restart = False
|
||||
|
||||
def set_bot_instance(self, bot):
|
||||
self.bot_instance = bot
|
||||
|
||||
def refresh_bot_instance(self):
|
||||
"""IPC에서 최신 봇 인스턴스 데이터 읽기"""
|
||||
if self.ipc:
|
||||
self.bot_instance = self.ipc.get_bot_instance_data()
|
||||
else:
|
||||
# fallback: 새 IPC 인스턴스 생성
|
||||
from modules.utils.ipc import SharedIPC
|
||||
ipc = SharedIPC()
|
||||
self.bot_instance = ipc.get_bot_instance_data()
|
||||
return self.bot_instance is not None
|
||||
|
||||
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
logging.info(f"[Command] /start from user {update.effective_user.id}")
|
||||
await update.message.reply_text(
|
||||
"<b>AI Trading Bot Command Center</b>\n"
|
||||
"명령어 목록:\n"
|
||||
"/status - 현재 봇 및 시장 상태 조회\n"
|
||||
"/portfolio - 현재 보유 종목 및 평가액\n"
|
||||
"/watchlist - 현재 감시 중인 종목 리스트\n"
|
||||
"/update_watchlist - Watchlist 즉시 업데이트\n"
|
||||
"/macro - 거시경제 지표 및 시장 위험도\n"
|
||||
"/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"
|
||||
"/stop - 봇 종료",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
logging.info(f"[Command] /status from user {update.effective_user.id}")
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30)
|
||||
|
||||
status_msg = "<b>System Status: ONLINE</b>\n"
|
||||
status_msg += f"<b>Market:</b> {'OPEN' if is_market_open else 'CLOSED'}\n"
|
||||
|
||||
macro_warn = self.bot_instance.is_macro_warning_sent
|
||||
status_msg += f"<b>Macro Filter:</b> {'DANGER (Trading Halted)' if macro_warn else 'SAFE'}\n"
|
||||
|
||||
await update.message.reply_text(status_msg, parse_mode="HTML")
|
||||
|
||||
async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("봇 인스턴스가 연결되지 않았습니다.")
|
||||
return
|
||||
|
||||
await update.message.reply_text("잔고를 조회 중입니다...")
|
||||
|
||||
try:
|
||||
balance = self.bot_instance.kis.get_balance()
|
||||
if "error" in balance:
|
||||
await update.message.reply_text(f"잔고 조회 실패: {balance['error']}")
|
||||
return
|
||||
|
||||
msg = f"<b>Total Asset:</b> <code>{int(balance['total_eval']):,} KRW</code>\n" \
|
||||
f"<b>Deposit:</b> <code>{int(balance['deposit']):,} KRW</code>\n\n"
|
||||
|
||||
if balance['holdings']:
|
||||
msg += "<b>[Holdings]</b>\n"
|
||||
for stock in balance['holdings']:
|
||||
yld = float(stock.get('yield', 0))
|
||||
# 상승(빨강), 하락(파랑) 이모지 적용
|
||||
if yld > 0:
|
||||
icon = "🔴"
|
||||
yld_str = f"+{yld}"
|
||||
elif yld < 0:
|
||||
icon = "🔵"
|
||||
yld_str = f"{yld}"
|
||||
else:
|
||||
icon = "⚪"
|
||||
yld_str = f"{yld}"
|
||||
|
||||
msg += f"{icon} <b>{stock['name']}</b>: <code>{yld_str}%</code>\n" \
|
||||
f" (수량: {stock['qty']} / 손익: {stock['profit_loss']:,})\n"
|
||||
else:
|
||||
msg += "보유 중인 종목이 없습니다."
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Error: {str(e)}")
|
||||
|
||||
async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("봇 인스턴스가 연결되지 않았습니다.")
|
||||
return
|
||||
|
||||
target_dict = self.bot_instance.load_watchlist()
|
||||
discovered = self.bot_instance.discovered_stocks
|
||||
|
||||
msg = f"<b>Watchlist: {len(target_dict)} items</b>\n"
|
||||
for code, name in target_dict.items():
|
||||
themes = self.bot_instance.theme_manager.get_themes(code)
|
||||
theme_str = f" ({', '.join(themes)})" if themes else ""
|
||||
msg += f"• <b>{name}</b>{theme_str}\n"
|
||||
|
||||
if discovered:
|
||||
msg += f"\n<b>Discovered Today ({len(discovered)}):</b>\n"
|
||||
for code in discovered:
|
||||
msg += f"- {code}\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Watchlist 업데이트 - command queue를 통해 메인 봇에 요청"""
|
||||
if self.ipc and self.ipc.send_command('update_watchlist'):
|
||||
await update.message.reply_text("Watchlist 업데이트를 메인 봇에 요청했습니다.")
|
||||
else:
|
||||
# fallback: 직접 업데이트
|
||||
await update.message.reply_text("Watchlist를 업데이트하고 있습니다... (30초 소요)")
|
||||
try:
|
||||
from modules.services.kis import KISClient
|
||||
from watchlist_manager import WatchlistManager
|
||||
from modules.config import Config
|
||||
|
||||
temp_kis = KISClient()
|
||||
mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE)
|
||||
summary = mgr.update_watchlist_daily()
|
||||
summary = summary.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
await update.message.reply_text(summary)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"업데이트 실패: {e}")
|
||||
|
||||
async def macro_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇 연결 대기 중...")
|
||||
return
|
||||
|
||||
await update.message.reply_text("거시경제 데이터를 불러옵니다...")
|
||||
|
||||
try:
|
||||
indices = getattr(self.bot_instance.kis, '_macro_indices', {})
|
||||
|
||||
if not indices:
|
||||
await update.message.reply_text("데이터가 아직 수집되지 않았습니다.")
|
||||
return
|
||||
|
||||
msi = float(indices.get('MSI', 0))
|
||||
if msi >= 50:
|
||||
risk_status = "🔴 DANGER"
|
||||
risk_desc = "시장 극도 불안정 - 매수 중단 권고"
|
||||
elif msi >= 30:
|
||||
risk_status = "🟡 CAUTION"
|
||||
risk_desc = "시장 불안정 - 보수적 매매 권고"
|
||||
else:
|
||||
risk_status = "🟢 SAFE"
|
||||
risk_desc = "시장 안정 - 정상 매매 가능"
|
||||
|
||||
from datetime import datetime
|
||||
now_str = datetime.now().strftime("%m/%d %H:%M")
|
||||
|
||||
msg = f"<b>거시경제 지표</b> <code>{now_str}</code>\n"
|
||||
msg += f"━━━━━━━━━━━━━━━━━━\n"
|
||||
msg += f"<b>Market Risk:</b> {risk_status}\n"
|
||||
msg += f"<i>{risk_desc}</i>\n\n"
|
||||
|
||||
# MSI 상세
|
||||
msi_bar = "█" * int(msi / 10) + "░" * (10 - int(msi / 10))
|
||||
msg += f"<b>Stress Index (MSI):</b> <code>{msi:.1f}/100</code>\n"
|
||||
msg += f"<code>[{msi_bar}]</code>\n\n"
|
||||
|
||||
# 지수 상세
|
||||
index_order = ["KOSPI", "KOSDAQ", "KOSPI200"]
|
||||
for k in index_order:
|
||||
if k not in indices:
|
||||
continue
|
||||
v = indices[k]
|
||||
price = float(v.get('price', 0))
|
||||
change = float(v.get('change', 0))
|
||||
change_val = float(v.get('change_val', 0))
|
||||
high = float(v.get('high', 0))
|
||||
low = float(v.get('low', 0))
|
||||
prev_close = float(v.get('prev_close', 0))
|
||||
volume = int(v.get('volume', 0))
|
||||
|
||||
if price == 0:
|
||||
# 장 마감 후: 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:
|
||||
icon = "🔴"
|
||||
chg_str = f"+{change:.2f}% (+{change_val:.2f}pt)"
|
||||
elif change < 0:
|
||||
icon = "🔵"
|
||||
chg_str = f"{change:.2f}% ({change_val:.2f}pt)"
|
||||
else:
|
||||
icon = "⚪"
|
||||
chg_str = f"{change:.2f}%"
|
||||
|
||||
msg += f"{icon} <b>{k}:</b> <code>{price:,.2f}</code> {chg_str}\n"
|
||||
if high and low:
|
||||
msg += f" 고: <code>{high:,.2f}</code> 저: <code>{low:,.2f}</code>"
|
||||
if prev_close:
|
||||
msg += f" 전일종가: <code>{prev_close:,.2f}</code>"
|
||||
msg += "\n"
|
||||
if volume:
|
||||
msg += f" 거래량: <code>{volume:,}천주</code>\n"
|
||||
msg += "\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Error: {e}")
|
||||
|
||||
async def system_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
import psutil
|
||||
|
||||
# non-blocking CPU 측정
|
||||
cpu = psutil.cpu_percent(interval=0)
|
||||
ram = psutil.virtual_memory().percent
|
||||
|
||||
top_processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
|
||||
try:
|
||||
proc_info = proc.info
|
||||
if proc_info['name'] == 'System Idle Process':
|
||||
continue
|
||||
top_processes.append(proc_info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
pass
|
||||
|
||||
top_processes.sort(key=lambda x: x.get('cpu_percent', 0), reverse=True)
|
||||
top_3 = top_processes[:3]
|
||||
|
||||
gpu_status = self.bot_instance.ollama_monitor.get_gpu_status()
|
||||
gpu_msg = "N/A"
|
||||
if gpu_status and gpu_status.get('name') != 'N/A':
|
||||
gpu_name = gpu_status.get('name', 'GPU')
|
||||
gpu_msg = f"{gpu_name}\n Temp: {gpu_status.get('temp', 0)}C / " \
|
||||
f"VRAM: {gpu_status.get('vram_used', 0)}GB / {gpu_status.get('vram_total', 0)}GB"
|
||||
|
||||
msg = "<b>PC System Status</b>\n" \
|
||||
f"<b>CPU:</b> <code>{cpu}%</code>\n" \
|
||||
f"<b>RAM:</b> <code>{ram}%</code>\n" \
|
||||
f"<b>GPU:</b> {gpu_msg}\n\n"
|
||||
|
||||
if top_3:
|
||||
msg += "<b>Top CPU Processes:</b>\n"
|
||||
for i, proc in enumerate(top_3, 1):
|
||||
proc_name = proc.get('name', 'Unknown')
|
||||
proc_cpu = proc.get('cpu_percent', 0)
|
||||
msg += f" {i}. <code>{proc_name}</code> - {proc_cpu:.1f}%\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
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> {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"
|
||||
msg += f"* <b>VRAM Usage:</b> <code>{gpu.get('vram_used', 0)}GB</code> / {gpu.get('vram_total', 0)}GB"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/restart: 메인 봇에 재시작 명령 전달"""
|
||||
if self.ipc and self.ipc.send_command('restart'):
|
||||
await update.message.reply_text(
|
||||
"<b>메인 봇에 재시작 요청을 전송했습니다.</b>", parse_mode="HTML")
|
||||
else:
|
||||
# IPC 명령 실패 시 텔레그램 봇만 재시작
|
||||
await update.message.reply_text(
|
||||
"<b>텔레그램 인터페이스를 재시작합니다...</b>", parse_mode="HTML")
|
||||
self.should_restart = True
|
||||
self.application.stop_running()
|
||||
|
||||
async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(
|
||||
"<b>텔레그램 봇을 종료합니다.</b>", parse_mode="HTML")
|
||||
self.should_restart = False
|
||||
self.application.stop_running()
|
||||
|
||||
async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
text = update.message.text.strip()
|
||||
parts = text.split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2:
|
||||
await update.message.reply_text("사용법: /exec 명령어")
|
||||
return
|
||||
|
||||
command = parts[1]
|
||||
await update.message.reply_text(f"실행 중: <code>{command}</code>", parse_mode="HTML")
|
||||
|
||||
try:
|
||||
dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot']
|
||||
if any(keyword in command.lower() for keyword in dangerous_keywords):
|
||||
await update.message.reply_text("위험한 명령어는 실행할 수 없습니다.")
|
||||
return
|
||||
|
||||
import platform
|
||||
is_windows = platform.system() == 'Windows'
|
||||
|
||||
if is_windows:
|
||||
exec_cmd = ['powershell', '-Command', command]
|
||||
else:
|
||||
exec_cmd = command
|
||||
|
||||
def run_subprocess():
|
||||
return subprocess.run(
|
||||
exec_cmd,
|
||||
shell=not is_windows,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=30,
|
||||
cwd=os.getcwd()
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, run_subprocess)
|
||||
|
||||
output = result.stdout.strip() if result.stdout else ""
|
||||
error_output = result.stderr.strip() if result.stderr else ""
|
||||
|
||||
if output and error_output:
|
||||
combined = f"[STDOUT]\n{output}\n\n[STDERR]\n{error_output}"
|
||||
elif output:
|
||||
combined = output
|
||||
elif error_output:
|
||||
combined = f"[ERROR]\n{error_output}"
|
||||
else:
|
||||
combined = "명령어 실행 완료 (출력 없음)"
|
||||
|
||||
if len(combined) > 3000:
|
||||
combined = combined[:3000] + "\n... (Truncated)"
|
||||
|
||||
combined = combined.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
await update.message.reply_text(f"<pre>{combined}</pre>", parse_mode="HTML")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await update.message.reply_text("명령어 실행 시간 초과 (30초)")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"실행 오류: {e}")
|
||||
|
||||
async def evaluate_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/evaluate: 즉시 성과 평가 보고서 생성 (LLM 분석 포함)"""
|
||||
await update.message.reply_text(
|
||||
"📊 성과 평가를 실행합니다...\n"
|
||||
"<i>LLM 전문가 패널 분석 포함 시 30초~1분 소요됩니다.</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
try:
|
||||
from modules.utils.performance_db import PerformanceDB
|
||||
from modules.analysis.evaluator import PerformanceEvaluator
|
||||
|
||||
evaluator = PerformanceEvaluator()
|
||||
loop = asyncio.get_running_loop()
|
||||
report = await loop.run_in_executor(None, evaluator.generate_weekly_report)
|
||||
|
||||
if len(report) > 4000:
|
||||
report = report[:4000] + "\n... (일부 생략)"
|
||||
|
||||
await update.message.reply_text(report, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
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),
|
||||
("status", self.status_command),
|
||||
("portfolio", self.portfolio_command),
|
||||
("watchlist", self.watchlist_command),
|
||||
("update_watchlist", self.update_watchlist_command),
|
||||
("macro", self.macro_command),
|
||||
("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)
|
||||
]
|
||||
|
||||
for cmd, func in handlers:
|
||||
self.application.add_handler(CommandHandler(cmd, func))
|
||||
|
||||
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if "Conflict" in str(context.error):
|
||||
print(f"[Telegram] Conflict detected. Stopping...")
|
||||
if self.application.running:
|
||||
await self.application.stop()
|
||||
return
|
||||
print(f"[Telegram Error] {context.error}")
|
||||
|
||||
self.application.add_error_handler(error_handler)
|
||||
|
||||
logging.info("[Telegram] Command Server Started (Shared Memory IPC Mode).")
|
||||
print("[Telegram] Command Server Started (Shared Memory IPC Mode).")
|
||||
|
||||
try:
|
||||
self.application.run_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Telegram] Polling Error: {e}")
|
||||
463
signal_v1/modules/services/telegram_bot/skill_runner.py
Normal file
463
signal_v1/modules/services/telegram_bot/skill_runner.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 스킬별 포맷터
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
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>")
|
||||
Reference in New Issue
Block a user