주식자동매매 AI 프로그램 초기 모델
This commit is contained in:
367
modules/services/kis.py
Normal file
367
modules/services/kis.py
Normal file
@@ -0,0 +1,367 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
|
||||
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
|
||||
|
||||
def _throttle(self):
|
||||
"""API 요청 속도 제한 (초당 5회 이하로 제한)"""
|
||||
# 모의투자는 Rate Limit이 더 엄격할 수 있음
|
||||
min_interval = 0.2 # 0.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 _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):
|
||||
"""접근 토큰 발급 (OAuth 2.0)"""
|
||||
# 토큰 유효성 체크 로직은 생략 (실제 운영 시 만료 시간 체크 필요)
|
||||
if self.access_token:
|
||||
return
|
||||
|
||||
url = f"{self.base_url}/oauth2/tokenP"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
|
||||
try:
|
||||
print("🔑 [KIS] 토큰 발급 요청...")
|
||||
res = requests.post(url, json=payload)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
self.access_token = data.get('access_token')
|
||||
print("✅ [KIS] 토큰 발급 성공")
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] 토큰 발급 실패: {e}")
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Token Error Body]: {e.response.text}")
|
||||
|
||||
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)
|
||||
return res.json()["HASH"]
|
||||
except Exception as e:
|
||||
print(f"❌ Hash Key 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
def get_balance(self):
|
||||
"""주식 잔고 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
# 국내주식 잔고조회 TR ID: VTTC8434R (모의), TTTC8434R (실전)
|
||||
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R"
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
headers = self._get_headers(tr_id=tr_id)
|
||||
|
||||
# 쿼리 파라미터
|
||||
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:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
# 응답 정리
|
||||
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'])
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] 잔고 조회 실패: {e}")
|
||||
# If it's a requests error, verify if there's a response body
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Error Body]: {e.response.text}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def order(self, ticker, qty, buy_sell, price=0):
|
||||
"""주문 (시장가)
|
||||
buy_sell: 'BUY' or 'SELL'
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
# 모의투자/실전 TR ID 구분
|
||||
# 매수: VTTC0802U / TTTC0802U
|
||||
# 매도: VTTC0801U / TTTC0801U
|
||||
if buy_sell == 'BUY':
|
||||
tr_id = "VTTC0802U" if self.is_virtual else "TTTC0802U"
|
||||
else:
|
||||
tr_id = "VTTC0801U" if self.is_virtual else "TTTC0801U"
|
||||
|
||||
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": "01", # 01: 시장가
|
||||
"ORD_QTY": str(qty),
|
||||
"ORD_UNPR": "0" # 시장가는 0
|
||||
}
|
||||
|
||||
# 헤더 준비
|
||||
headers = self._get_headers(tr_id=tr_id)
|
||||
|
||||
# [중요] POST 요청(주문 등) 시 Hash Key 필수
|
||||
# 단, 모의투자의 경우 일부 상황에서 생략 가능할 수 있으나, 정석대로 포함
|
||||
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 (시장가)")
|
||||
res = requests.post(url, headers=headers, json=datas)
|
||||
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)
|
||||
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_price(self, ticker, period="D"):
|
||||
"""일별 시세 조회 (기술적 분석용)"""
|
||||
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)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
# 과거 데이터부터 오도록 정렬 필요할 수 있음 (API는 최신순)
|
||||
# output 리스트: [ {stck_clpr: 종가, ...}, ... ]
|
||||
prices = [int(item['stck_clpr']) for item in data['output']]
|
||||
prices.reverse() # 과거 -> 현재 순으로 정렬
|
||||
return prices
|
||||
except Exception as e:
|
||||
print(f"❌ 일별 시세 조회 실패: {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)
|
||||
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.
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-index-price"
|
||||
headers = self._get_headers(tr_id="FHKUP03500100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
return {
|
||||
"price": float(data['output']['bstp_nmix_prpr']), # 현재지수
|
||||
"change": float(data['output']['bstp_nmix_prdy_ctrt']) # 등락률(%)
|
||||
}
|
||||
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용)"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
|
||||
headers = self._get_headers(tr_id="FHKUP03500200")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1" # 수정주가
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
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 []
|
||||
22
modules/services/news.py
Normal file
22
modules/services/news.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class NewsCollector:
|
||||
"""
|
||||
NAS에서 뉴스를 받지 못할 경우, Windows 서버에서 직접 뉴스를 수집하는 모듈
|
||||
(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)
|
||||
items = []
|
||||
for item in root.findall(".//item")[:5]:
|
||||
title = item.find("title").text
|
||||
items.append({"title": title, "source": "Google News"})
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"❌ 뉴스 수집 실패: {e}")
|
||||
return []
|
||||
114
modules/services/ollama.py
Normal file
114
modules/services/ollama.py
Normal file
@@ -0,0 +1,114 @@
|
||||
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 (3070 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 request_inference(self, prompt, context_data=None):
|
||||
"""
|
||||
Ollama에 추론 요청
|
||||
:param prompt: 시스템 프롬프트 + 사용자 입력
|
||||
:param context_data: (Optional) 이전 대화 컨텍스트
|
||||
"""
|
||||
# [5070Ti 최적화] VRAM이 14GB 이상이면 모델 언로드 시도 (16GB 중 여유분 확보)
|
||||
vram = self.check_vram()
|
||||
if vram > 14.0:
|
||||
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", # JSON 강제
|
||||
"options": {
|
||||
"num_ctx": 8192, # [5070Ti 최적화] 컨텍스트 크기 2배 증가 (4096 -> 8192)
|
||||
"temperature": 0.2, # 분석 일관성 유지
|
||||
"num_gpu": 1, # GPU 사용 명시
|
||||
"num_thread": 8 # CPU 스레드 수 (9800X3D 활용)
|
||||
},
|
||||
"keep_alive": "10m" # [5070Ti 최적화] 10분간 유지 (메모리 여유 있음)
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.generate_url, json=payload, timeout=180) # 타임아웃 증가
|
||||
response.raise_for_status()
|
||||
return response.json().get('response')
|
||||
except Exception as e:
|
||||
print(f"❌ Inference Error: {e}")
|
||||
return None
|
||||
34
modules/services/telegram.py
Normal file
34
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": "Markdown"
|
||||
}
|
||||
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()
|
||||
73
modules/services/telegram_bot/runner.py
Normal file
73
modules/services/telegram_bot/runner.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
멀티프로세스 방식 - 텔레그램 봇 프로세스
|
||||
트레이딩 봇과 완전히 분리된 독립 프로세스로 실행
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import multiprocessing
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 환경 변수 로드
|
||||
load_dotenv()
|
||||
|
||||
def run_telegram_bot_standalone():
|
||||
"""텔레그램 봇만 독립적으로 실행"""
|
||||
# 경로 문제 해결을 위해 상위 디렉토리 추가
|
||||
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 BotIPC
|
||||
from modules.config import Config
|
||||
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
print("❌ [Telegram] TELEGRAM_BOT_TOKEN not found in .env")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"🤖 [Telegram Bot Process] Starting... (PID: {os.getpid()})")
|
||||
print(f"🔗 [Telegram Bot] Standalone Process Mode (IPC Enabled)")
|
||||
|
||||
# IPC 초기화
|
||||
ipc = BotIPC()
|
||||
|
||||
# [최적화] 재시작 루프 구현
|
||||
while True:
|
||||
try:
|
||||
# 봇 서버 생성 (매번 새로 생성)
|
||||
bot_server = TelegramBotServer(token)
|
||||
|
||||
# IPC를 통해 메인 봇 데이터 가져오기
|
||||
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...")
|
||||
import time
|
||||
time.sleep(1) # 잠시 대기
|
||||
continue
|
||||
else:
|
||||
print("🛑 [Telegram Bot] Process exiting.")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 [Telegram Bot] Stopped by user")
|
||||
break
|
||||
except Exception as e:
|
||||
if "Conflict" not in str(e):
|
||||
print(f"❌ [Telegram Bot] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
run_telegram_bot_standalone()
|
||||
394
modules/services/telegram_bot/server.py
Normal file
394
modules/services/telegram_bot/server.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
텔레그램 봇 최적화 버전
|
||||
- Polling 최적화 (CPU 사용률 감소)
|
||||
- 별도 프로세스로 분리
|
||||
- 봇 재시작 명령어
|
||||
- 원격 명령어 실행
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes, TypeHandler
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
level=logging.INFO
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
class TelegramBotServer:
|
||||
def __init__(self, bot_token):
|
||||
# [최적화] 연결 풀 설정 추가
|
||||
self.application = Application.builder()\
|
||||
.token(bot_token)\
|
||||
.concurrent_updates(True)\
|
||||
.build()
|
||||
|
||||
self.bot_instance = None
|
||||
self.is_shutting_down = False
|
||||
self.should_restart = False
|
||||
|
||||
def set_bot_instance(self, bot):
|
||||
"""AutoTradingBot 인스턴스를 주입받음"""
|
||||
self.bot_instance = bot
|
||||
|
||||
def refresh_bot_instance(self):
|
||||
"""IPC에서 최신 봇 인스턴스 데이터 읽기"""
|
||||
# [수정] 모듈 경로 변경
|
||||
from modules.utils.ipc import BotIPC
|
||||
ipc = BotIPC()
|
||||
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):
|
||||
"""/start 명령어 핸들러"""
|
||||
await update.message.reply_text(
|
||||
"🤖 **AI Trading Bot Command Center**\n"
|
||||
"명령어 목록:\n"
|
||||
"/status - 현재 봇 및 시장 상태 조회\n"
|
||||
"/portfolio - 현재 보유 종목 및 평가액\n"
|
||||
"/watchlist - 현재 감시 중인 종목 리스트\n"
|
||||
"/update_watchlist - Watchlist 즉시 업데이트\n"
|
||||
"/macro - 거시경제 지표 및 시장 위험도\n"
|
||||
"/system - PC 리소스(CPU/GPU) 상태\n"
|
||||
"/ai - AI 모델 학습 상태 조회\n\n"
|
||||
"**[관리 명령어]**\n"
|
||||
"/restart - 봇 재시작\n"
|
||||
"/exec <command> - 원격 명령어 실행\n"
|
||||
"/stop - 봇 종료",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/status: 종합 상태 브리핑"""
|
||||
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 = "✅ **System Status: ONLINE**\n"
|
||||
status_msg += f"🕒 **Market:** {'OPEN 🟢' if is_market_open else 'CLOSED 🔴'}\n"
|
||||
|
||||
macro_warn = self.bot_instance.is_macro_warning_sent
|
||||
status_msg += f"🌍 **Macro Filter:** {'DANGER 🚨 (Trading Halted)' if macro_warn else 'SAFE 🟢'}\n"
|
||||
|
||||
await update.message.reply_text(status_msg, parse_mode="Markdown")
|
||||
|
||||
async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/portfolio: 잔고 조회"""
|
||||
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"💰 **Total Asset:** `{int(balance['total_eval']):,} KRW`\n" \
|
||||
f"💵 **Deposit:** `{int(balance['deposit']):,} KRW`\n\n"
|
||||
|
||||
if balance['holdings']:
|
||||
msg += "**[Holdings]**\n"
|
||||
for stock in balance['holdings']:
|
||||
icon = "🔴" if stock['yield'] > 0 else "🔵"
|
||||
msg += f"{icon} **{stock['name']}** `{stock['yield']}%`\n" \
|
||||
f" (수량: {stock['qty']} / 평가손익: {stock['profit_loss']:,})\n"
|
||||
else:
|
||||
msg += "보유 중인 종목이 없습니다."
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/watchlist: 감시 대상 종목"""
|
||||
if not self.refresh_bot_instance():
|
||||
return
|
||||
|
||||
target_dict = self.bot_instance.load_watchlist()
|
||||
discovered = list(self.bot_instance.discovered_stocks)
|
||||
|
||||
msg = f"👀 **Watchlist: {len(target_dict)} items**\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"- {name}{theme_str}\n"
|
||||
|
||||
if discovered:
|
||||
msg += f"\n✨ **Discovered Today ({len(discovered)}):**\n"
|
||||
for code in discovered:
|
||||
msg += f"- {code}\n"
|
||||
|
||||
await update.message.reply_text(msg)
|
||||
|
||||
async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/update_watchlist: Watchlist 즉시 업데이트"""
|
||||
await update.message.reply_text("🔄 Watchlist를 업데이트하고 있습니다... (30초 소요)")
|
||||
|
||||
try:
|
||||
# [수정] IPC 모드에서도 직접 수행하기 위해 새로운 인스턴스 생성
|
||||
from modules.services.kis import KISClient
|
||||
from watchlist_manager import WatchlistManager
|
||||
|
||||
# 독립적인 KIS 클라이언트 생성
|
||||
from modules.config import Config
|
||||
temp_kis = KISClient()
|
||||
mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE)
|
||||
|
||||
# 업데이트 수행 (파일 쓰기)
|
||||
summary = mgr.update_watchlist_daily()
|
||||
await update.message.reply_text(summary, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 업데이트 실패: {e}")
|
||||
|
||||
async def macro_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/macro: 거시경제 지표 조회 (IPC 데이터 사용)"""
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("⚠️ 메인 봇 연결 대기 중...")
|
||||
return
|
||||
|
||||
await update.message.reply_text("⏳ 거시경제 데이터를 불러옵니다...")
|
||||
|
||||
try:
|
||||
# [수정] IPC 데이터를 직접 사용하여 출력 (FakeKIS의 _macro_indices 활용)
|
||||
# FakeKIS는 bot_instance.kis에 할당되어 있음
|
||||
indices = getattr(self.bot_instance.kis, '_macro_indices', {})
|
||||
|
||||
if not indices:
|
||||
await update.message.reply_text("⚠️ 데이터가 아직 수집되지 않았습니다. 잠시 후 다시 시도하세요.")
|
||||
return
|
||||
|
||||
# 리스크 점수 계산 (간이)
|
||||
status = "SAFE"
|
||||
msi = indices.get('MSI', 0)
|
||||
if msi >= 50: status = "DANGER"
|
||||
elif msi >= 30: status = "CAUTION"
|
||||
|
||||
color = "🟢" if status == "SAFE" else "🔴" if status == "DANGER" else "🟡"
|
||||
msg = f"{color} **Market Risk: {status}**\n\n"
|
||||
|
||||
if 'MSI' in indices:
|
||||
msg += f"🌡️ **Stress Index:** `{indices['MSI']}`\n"
|
||||
|
||||
for k, v in indices.items():
|
||||
if k != "MSI":
|
||||
icon = "🔺" if v.get('change', 0) > 0 else "🔻"
|
||||
msg += f"{icon} **{k}**: {v.get('price', 0)} ({v.get('change', 0)}%)\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ Error: {e}")
|
||||
|
||||
async def system_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/system: 시스템 리소스 상태"""
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
import psutil
|
||||
|
||||
cpu = psutil.cpu_percent(interval=1)
|
||||
ram = psutil.virtual_memory().percent
|
||||
|
||||
# CPU 점유율 상위 3개 프로세스 수집
|
||||
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 = f"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 / VRAM: {gpu_status.get('vram_used', 0)}GB / {gpu_status.get('vram_total', 0)}GB"
|
||||
|
||||
msg = "🖥️ **PC System Status**\n" \
|
||||
f"🧠 **CPU:** `{cpu}%`\n" \
|
||||
f"💾 **RAM:** `{ram}%`\n" \
|
||||
f"🎮 **GPU:** {gpu_msg}\n\n"
|
||||
|
||||
if top_3:
|
||||
msg += "⚙️ **Top CPU Processes:**\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}. `{proc_name}` - {proc_cpu:.1f}%\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/ai: AI 모델 학습 상태 조회"""
|
||||
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
|
||||
|
||||
msg = "🧠 **AI Model Status**\n"
|
||||
msg += f"• **LLM Engine:** Ollama (Llama 3.1)\n"
|
||||
|
||||
gpu_name = gpu.get('name', 'NVIDIA RTX 5070 Ti')
|
||||
msg += f"• **Device:** {gpu_name}\n"
|
||||
|
||||
if gpu:
|
||||
msg += f"• **GPU Load:** `{gpu.get('load', 0)}%`\n"
|
||||
msg += f"• **VRAM Usage:** `{gpu.get('vram_used', 0)}GB` / {gpu.get('vram_total', 0)}GB"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/restart: 텔레그램 봇 모듈만 재시작"""
|
||||
await update.message.reply_text("🔄 **텔레그램 인터페이스를 재시작합니다...**")
|
||||
|
||||
# 재시작 플래그 설정 (runner.py에서 감지하여 재시작)
|
||||
self.should_restart = True
|
||||
self.application.stop_running()
|
||||
|
||||
async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/stop: 봇 종료"""
|
||||
await update.message.reply_text("🛑 **텔레그램 봇을 종료합니다.**")
|
||||
|
||||
# 종료 플래그 설정 (runner.py에서 루프 탈출)
|
||||
self.should_restart = False
|
||||
self.application.stop_running()
|
||||
|
||||
async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/exec: 원격 명령어 실행"""
|
||||
if not context.args:
|
||||
await update.message.reply_text("❌ 사용법: /exec <command>")
|
||||
return
|
||||
|
||||
command = " ".join(context.args)
|
||||
await update.message.reply_text(f"⚙️ 실행 중: `{command}`", parse_mode="Markdown")
|
||||
|
||||
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
|
||||
|
||||
# 명령어 실행 (타임아웃 30초)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=os.getcwd()
|
||||
)
|
||||
|
||||
output = result.stdout if result.stdout else result.stderr
|
||||
if not output:
|
||||
output = "명령어 실행 완료 (출력 없음)"
|
||||
|
||||
# 출력이 너무 길면 잘라내기
|
||||
if len(output) > 3000:
|
||||
output = output[:3000] + "\n... (출력이 너무 깁니다)"
|
||||
|
||||
await update.message.reply_text(f"```\n{output}\n```", parse_mode="Markdown")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
await update.message.reply_text("⏱️ 명령어 실행 시간 초과 (30초)")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 실행 오류: {str(e)}")
|
||||
|
||||
def run(self):
|
||||
"""봇 실행 (비동기 polling)"""
|
||||
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""에러 핸들러"""
|
||||
import traceback
|
||||
|
||||
# Conflict 에러는 무시 (다른 봇 인스턴스 실행 중)
|
||||
if "Conflict" in str(context.error):
|
||||
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다. 이 인스턴스를 종료합니다.")
|
||||
if self.application.running:
|
||||
await self.application.stop()
|
||||
return
|
||||
|
||||
tb_list = traceback.format_exception(None, context.error, context.error.__traceback__)
|
||||
tb_string = ''.join(tb_list)
|
||||
print(f"❌ [Telegram Error] {tb_string}")
|
||||
|
||||
if isinstance(update, Update) and update.effective_message:
|
||||
try:
|
||||
await update.effective_message.reply_text(f"⚠️ 오류 발생: {context.error}")
|
||||
except:
|
||||
pass
|
||||
|
||||
async def ultimate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
|
||||
text = update.message.text.strip()
|
||||
print(f"📨 [Telegram] Command Received: {text}")
|
||||
|
||||
try:
|
||||
if text.startswith("/start"):
|
||||
await self.start_command(update, context)
|
||||
elif text.startswith("/status"):
|
||||
await self.status_command(update, context)
|
||||
elif text.startswith("/portfolio"):
|
||||
await self.portfolio_command(update, context)
|
||||
elif text.startswith("/watchlist"):
|
||||
await self.watchlist_command(update, context)
|
||||
elif text.startswith("/update_watchlist"):
|
||||
await self.update_watchlist_command(update, context)
|
||||
elif text.startswith("/macro"):
|
||||
await self.macro_command(update, context)
|
||||
elif text.startswith("/system"):
|
||||
await self.system_command(update, context)
|
||||
elif text.startswith("/ai"):
|
||||
await self.ai_status_command(update, context)
|
||||
elif text.startswith("/restart"):
|
||||
await self.restart_command(update, context)
|
||||
elif text.startswith("/stop"):
|
||||
await self.stop_command(update, context)
|
||||
elif text.startswith("/exec"):
|
||||
await self.exec_command(update, context)
|
||||
except Exception as e:
|
||||
print(f"❌ Handle Error: {e}")
|
||||
await update.message.reply_text(f"⚠️ Error: {e}")
|
||||
|
||||
# 에러 핸들러 등록
|
||||
self.application.add_error_handler(error_handler)
|
||||
self.application.add_handler(TypeHandler(Update, ultimate_handler))
|
||||
|
||||
# [최적화] Polling 설정 개선
|
||||
print("🤖 [Telegram] Command Server Started (Optimized Polling Mode).")
|
||||
|
||||
try:
|
||||
self.application.run_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
stop_signals=None,
|
||||
poll_interval=1.0, # 1초마다 폴링 (기본값 0.0)
|
||||
timeout=10, # 타임아웃 10초
|
||||
drop_pending_updates=True # 대기 중인 업데이트 무시
|
||||
)
|
||||
except Exception as e:
|
||||
if "Conflict" in str(e):
|
||||
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다.")
|
||||
print(f"⚠️ [Telegram] 기존 봇을 종료하고 다시 시도하세요.")
|
||||
else:
|
||||
print(f"❌ [Telegram] 봇 실행 오류: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
Reference in New Issue
Block a user