main_server.py가 중복 실행되면서 좀비 프로세스가 수행되는 오류 해결, process_tracker.py가 감시하면서 할당되지 않은 pid가 존재하면 좀비프로세스로 판단하여 kill

This commit is contained in:
2026-02-11 07:48:06 +09:00
parent 7f2f575ec8
commit 4fd0aa91bc
8 changed files with 689 additions and 371 deletions

View File

@@ -24,11 +24,12 @@ news_collector = None
import multiprocessing import multiprocessing
from modules.bot import AutoTradingBot from modules.bot import AutoTradingBot
from modules.utils.process_tracker import ProcessTracker
from modules.services.telegram_bot.runner import run_telegram_bot_standalone from modules.services.telegram_bot.runner import run_telegram_bot_standalone
# 봇 실행 래퍼 함수 # 봇 실행 래퍼 함수
def run_trading_bot(): def run_trading_bot():
ProcessTracker.register("Trading Bot Main")
bot = AutoTradingBot() bot = AutoTradingBot()
bot.loop() bot.loop()
@@ -41,6 +42,12 @@ async def lifespan(app: FastAPI):
Config.validate() Config.validate()
# 2. 전역 객체 초기화 (서버용) # 2. 전역 객체 초기화 (서버용)
# [Process Tracker] 초기화
try:
ProcessTracker.clear()
ProcessTracker.register("Main Server (Uvicorn Worker)")
except: pass
ai_agent = OllamaManager() ai_agent = OllamaManager()
kis_client = KISClient() kis_client = KISClient()
news_collector = NewsCollector() news_collector = NewsCollector()
@@ -56,6 +63,13 @@ async def lifespan(app: FastAPI):
telegram_process = multiprocessing.Process(target=run_telegram_bot_standalone) telegram_process = multiprocessing.Process(target=run_telegram_bot_standalone)
telegram_process.start() telegram_process.start()
# [Process Tracker] 자식 프로세스 PID 기록 (부모 관점)
try:
with open(ProcessTracker.FILE_PATH, "a", encoding="utf-8") as f:
f.write(f"{bot_process.pid}: Trading Bot Process (Parent View)\n")
f.write(f"{telegram_process.pid}: Telegram Bot Process (Parent View)\n")
except: pass
messenger.send_message("🖥️ **[Server Started]** Windows AI Server (Refactored) Online.") messenger.send_message("🖥️ **[Server Started]** Windows AI Server (Refactored) Online.")
yield yield
@@ -65,13 +79,17 @@ async def lifespan(app: FastAPI):
if telegram_process and telegram_process.is_alive(): if telegram_process and telegram_process.is_alive():
print(" - Stopping Telegram Bot...") print(" - Stopping Telegram Bot...")
telegram_process.terminate() telegram_process.join(timeout=5)
telegram_process.join() if telegram_process.is_alive():
telegram_process.terminate()
telegram_process.join()
if bot_process and bot_process.is_alive(): if bot_process and bot_process.is_alive():
print(" - Stopping Trading Bot...") print(" - Stopping Trading Bot...")
bot_process.terminate() bot_process.join(timeout=5)
bot_process.join() if bot_process.is_alive():
bot_process.terminate()
bot_process.join()
messenger.send_message("🛑 **[Server Stopped]** Server Shutting Down.") messenger.send_message("🛑 **[Server Stopped]** Server Shutting Down.")
@@ -139,4 +157,12 @@ async def analyze_portfolio():
return {"analysis": analysis} return {"analysis": analysis}
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main_server:app", host="0.0.0.0", port=8000, reload=True) # [안정성] 서버 시작 시 이전 좀비 프로세스 정리
try:
from modules.utils.process_tracker import ProcessTracker
ProcessTracker.check_and_kill_zombies()
except: pass
# Reload=True는 멀티프로세싱 자식 프로세스 관리에 취약하므로 비활성화 권장
print("🚀 Starting Windows AI Server...")
uvicorn.run("main_server:app", host="0.0.0.0", port=8000, reload=False)

View File

@@ -187,4 +187,26 @@ class TechnicalAnalyzer:
else: else:
volatility = 0.0 volatility = 0.0
return round(total_score, 4), round(rsi, 2), round(volatility, 2), round(volume_ratio, 1) # [신규] 이동평균선 분석 (20일, 114일)
ma114 = TechnicalAnalyzer.calculate_ma(prices_history, 114)
ma_trend = "Unknown"
if ma20 > ma114:
ma_trend = "Bullish (Golden Alignment)" # 정배열
else:
ma_trend = "Bearish (Dead Alignment)" # 역배열
price_pos = "Unknown"
if current_price > ma20:
price_pos = "Above MA20"
else:
price_pos = "Below MA20"
ma_info = {
"ma20": ma20,
"ma114": ma114,
"trend": ma_trend,
"position": price_pos
}
return round(total_score, 4), round(rsi, 2), round(volatility, 2), round(volume_ratio, 1), ma_info

View File

@@ -3,6 +3,7 @@ import os
import sys import sys
import json import json
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from concurrent.futures.process import BrokenProcessPool
from datetime import datetime from datetime import datetime
# 모듈 임포트 # 모듈 임포트
@@ -24,12 +25,26 @@ except ImportError:
class ThemeManager: class ThemeManager:
def get_themes(self, code): return [] def get_themes(self, code): return []
# 워커 프로세스 초기화 함수
def init_worker():
try:
from modules.utils.process_tracker import ProcessTracker
ProcessTracker.register("Trading Bot Worker")
except: pass
class AutoTradingBot: class AutoTradingBot:
def __init__(self): def __init__(self):
# 1. 서비스 초기화 # 1. 서비스 초기화
self.kis = KISClient() self.kis = KISClient()
self.news = NewsCollector() self.news = NewsCollector()
self.executor = ProcessPoolExecutor(max_workers=4) # [안정성] GPU OOM 방지를 위해 워커 수 축소 (4 -> 2)
# [식별] 워커 프로세스 이름 등록
self.executor = ProcessPoolExecutor(max_workers=2, initializer=init_worker)
# 워커 프로세스 워밍업 (PID 등록 유도)
try:
list(self.executor.map(lambda x: x, range(2)))
except: pass
self.messenger = TelegramMessenger() self.messenger = TelegramMessenger()
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.ollama_monitor = OllamaManager() # GPU 모니터링용 self.ollama_monitor = OllamaManager() # GPU 모니터링용
@@ -67,8 +82,6 @@ class AutoTradingBot:
from modules.analysis.deep_learning import PricePredictor from modules.analysis.deep_learning import PricePredictor
PricePredictor.verify_hardware() PricePredictor.verify_hardware()
# 텔레그램 명령 서버 시작 (Server에서 관리하도록 변경)
# self.start_telegram_command_server()
pass pass
def load_trade_history(self): def load_trade_history(self):
@@ -129,6 +142,17 @@ class AutoTradingBot:
self.messenger.send_message(report) self.messenger.send_message(report)
self.report_sent = True self.report_sent = True
def restart_executor(self):
"""프로세스 풀 복구"""
print("🔄 [Bot] Restarting Process Executor...")
try:
self.executor.shutdown(wait=False)
except:
pass
# 워커 재시작
self.executor = ProcessPoolExecutor(max_workers=2, initializer=init_worker)
print("✅ [Bot] Process Executor Restarted.")
def run_cycle(self): def run_cycle(self):
now = datetime.now() now = datetime.now()
@@ -261,75 +285,96 @@ class AutoTradingBot:
# [수정] 실시간 잔고 추적용 변수 (매수 시 차감) # [수정] 실시간 잔고 추적용 변수 (매수 시 차감)
tracking_deposit = int(balance.get("deposit", 0)) tracking_deposit = int(balance.get("deposit", 0))
for ticker, name in target_dict.items(): try:
prices = self.kis.get_daily_price(ticker) for ticker, name in target_dict.items():
if not prices: continue prices = self.kis.get_daily_price(ticker)
if not prices: continue
future = self.executor.submit(analyze_stock_process, ticker, prices, news_data) # [신규] 외인 수급 분석
analysis_tasks.append(future) investor_trend = self.kis.get_investor_trend(ticker)
# 결과 처리 future = self.executor.submit(analyze_stock_process, ticker, prices, news_data, investor_trend)
for future in analysis_tasks: analysis_tasks.append(future)
try:
res = future.result()
ticker_name = target_dict.get(res['ticker'], 'Unknown')
print(f"📊 [{ticker_name}] Score: {res['score']:.2f} ({res['decision']})")
if res['decision'] == "BUY": # 결과 처리
if is_crash: continue for future in analysis_tasks:
try:
res = future.result()
ticker_name = target_dict.get(res['ticker'], 'Unknown')
print(f"📊 [{ticker_name}] Score: {res['score']:.2f} ({res['decision']})")
# 매수 로직 (예수금 체크 추가) if res['decision'] == "BUY":
current_qty = 0 if is_crash: continue
if res['ticker'] in current_holdings:
current_qty = current_holdings[res['ticker']]['qty']
current_price = float(res['current_price']) # 매수 로직 (예수금 체크 추가)
if current_price <= 0: continue current_qty = 0
if res['ticker'] in current_holdings:
current_qty = current_holdings[res['ticker']]['qty']
# 매수 수량 결정 (기본 1주, 추후 금액 기반으로 변경 가능) current_price = float(res['current_price'])
qty = 1 if current_price <= 0: continue
required_amount = current_price * qty
# 예수금 확인 # 매수 수량 결정 (기본 1주, 추후 금액 기반으로 변경 가능)
if tracking_deposit < required_amount: qty = 1
print(f"💰 [Skip Buy] 예수금 부족 ({ticker_name}): 필요 {required_amount:,.0f} > 잔고 {tracking_deposit:,.0f}") required_amount = current_price * qty
continue
print(f"🚀 Buying {ticker_name} {qty}ea") # 예수금 확인
if tracking_deposit < required_amount:
print(f"💰 [Skip Buy] 예수금 부족 ({ticker_name}): 필요 {required_amount:,.0f} > 잔고 {tracking_deposit:,.0f}")
continue
# 실제 주문 print(f"🚀 Buying {ticker_name} {qty}ea")
order = self.kis.buy_stock(res['ticker'], qty)
if order.get("status"):
self.messenger.send_message(f"🚀 **[BUY]** {ticker_name} {qty}\n"
f" • 가격: {current_price:,.0f}")
self.daily_trade_history.append({ # 실제 주문
"action": "BUY", order = self.kis.buy_stock(res['ticker'], qty)
"name": ticker_name, if order.get("status"):
"qty": qty, self.messenger.send_message(f"🚀 **[BUY]** {ticker_name} {qty}\n"
"price": current_price f" • 가격: {current_price:,.0f}")
})
self.save_trade_history()
# [중요] 가상 잔고 차감 (연속 매수 시 초과 방지) self.daily_trade_history.append({
tracking_deposit -= required_amount "action": "BUY",
"name": ticker_name,
"qty": qty,
"price": current_price
})
self.save_trade_history()
elif res['decision'] == "SELL": # [중요] 가상 잔고 차감 (연속 매수 시 초과 방지)
print(f"📉 Selling {ticker_name} (Simulation)") tracking_deposit -= required_amount
# 매도 로직 (필요 시 추가)
except Exception as e: elif res['decision'] == "SELL":
print(f"❌ Analysis Error: {e}") print(f"📉 Selling {ticker_name} (Simulation)")
# 매도 로직 (필요 시 추가)
except BrokenProcessPool:
raise # 상위 레벨에서 처리
except Exception as e:
print(f"❌ Analysis Worker Error: {e}")
except BrokenProcessPool:
print("⚠️ [Bot] Worker Process Crashed (OOM, CUDA Error?). Restarting Executor...")
self.restart_executor()
except KeyboardInterrupt:
raise
except Exception as e:
print(f"❌ Cycle Loop Error: {e}")
def loop(self): def loop(self):
print(f"🤖 Bot Module Started (PID: {os.getpid()})") print(f"🤖 Bot Module Started (PID: {os.getpid()})")
self.messenger.send_message("🤖 **[Bot Started]** 리팩토링된 봇이 시작되었습니다.") self.messenger.send_message("🤖 **[Bot Started]** 리팩토링된 봇이 시작되었습니다.")
while True: try:
try: while True:
self.run_cycle() try:
except Exception as e: self.run_cycle()
print(f"⚠️ Loop Error: {e}") except Exception as e:
self.messenger.send_message(f"⚠️ Loop Error: {e}") print(f"⚠️ Loop Error: {e}")
time.sleep(60) self.messenger.send_message(f"⚠️ Loop Error: {e}")
time.sleep(60)
except KeyboardInterrupt:
print("🛑 [Bot] Stopped by User.")
finally:
print("🛑 [Bot] Shutting down executor...")
self.executor.shutdown(wait=False)
print("✅ [Bot] Executor shutdown complete.")
def start_telegram_command_server(self): def start_telegram_command_server(self):
"""텔레그램 봇 프로세스 실행 (독립 프로세스)""" """텔레그램 봇 프로세스 실행 (독립 프로세스)"""

View File

@@ -29,6 +29,19 @@ class KISClient:
self.token_expired = None self.token_expired = None
self.last_req_time = 0 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): def _throttle(self):
"""API 요청 속도 제한 (초당 2회 이하로 제한)""" """API 요청 속도 제한 (초당 2회 이하로 제한)"""
# 모의투자는 Rate Limit이 매우 엄격함 (초당 2~3회 권장) # 모의투자는 Rate Limit이 매우 엄격함 (초당 2~3회 권장)
@@ -41,6 +54,38 @@ class KISClient:
self.last_req_time = time.time() 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): def _get_headers(self, tr_id=None):
"""공통 헤더 생성""" """공통 헤더 생성"""
headers = { headers = {
@@ -54,10 +99,16 @@ class KISClient:
return headers return headers
def ensure_token(self): def ensure_token(self, force=False):
"""접근 토큰 발급 (OAuth 2.0)""" """접근 토큰 발급 (OAuth 2.0) 및 유효성 관리"""
# 토큰 유효성 체크 로직은 생략 (실제 운영 시 만료 시간 체크 필요) # 토큰이 있고, 만료 시간이 아직 안 지났으면 재사용
if self.access_token: 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 return
url = f"{self.base_url}/oauth2/tokenP" url = f"{self.base_url}/oauth2/tokenP"
@@ -68,16 +119,41 @@ class KISClient:
} }
try: try:
print("🔑 [KIS] 토큰 발급 요청...") print(f"🔑 [KIS] 토큰 발급 요청: {url}")
res = requests.post(url, json=payload) res = requests.post(url, json=payload)
res.raise_for_status() res.raise_for_status()
data = res.json() data = res.json()
self.access_token = data.get('access_token') self.access_token = data.get('access_token')
print("✅ [KIS] 토큰 발급 성공")
# 만료 시간 설정
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: except Exception as e:
print(f"❌ [KIS] 토큰 발급 실패: {e}") # 1분 제한 에러 핸들링 (EGW00133)
retry = False
if isinstance(e, requests.exceptions.RequestException) and e.response is not None: if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
print(f"📄 [KIS Token Error Body]: {e.response.text}") 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): def get_hash_key(self, datas):
"""주문 시 필요한 Hash Key 생성 (Koreainvestment header 특화)""" """주문 시 필요한 Hash Key 생성 (Koreainvestment header 특화)"""
@@ -94,16 +170,62 @@ class KISClient:
print(f"❌ Hash Key 생성 실패: {e}") print(f"❌ Hash Key 생성 실패: {e}")
return None return None
def get_balance(self): def _request_api(self, method, endpoint, tr_id, params=None, data=None, use_hash=False):
"""주식 잔고 조회""" """API 요청 공통 핸들러 (토큰 만료 시 자동 갱신)"""
self._throttle() self._throttle()
self.ensure_token() self.ensure_token()
# 국내주식 잔고조회 TR ID: VTTC8434R (모의), TTTC8434R (실전) url = f"{self.base_url}/{endpoint}"
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R" headers = self._get_headers(tr_id)
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance"
headers = self._get_headers(tr_id=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)
else:
res = requests.post(url, headers=headers, json=data)
# 토큰 만료 체크 (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)
else:
res = requests.post(url, headers=headers, json=data)
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 = { params = {
@@ -121,9 +243,7 @@ class KISClient:
} }
try: try:
res = requests.get(url, headers=headers, params=params) data = self._request_api("GET", endpoint, tr_id, params=params)
res.raise_for_status()
data = res.json()
# 응답 정리 # 응답 정리
if data['rt_cd'] != '0': if data['rt_cd'] != '0':
@@ -149,10 +269,6 @@ class KISClient:
"deposit": int(summary['dnca_tot_amt']) "deposit": int(summary['dnca_tot_amt'])
} }
except Exception as e: 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)} return {"error": str(e)}
def order(self, ticker, qty, buy_sell, price=0): def order(self, ticker, qty, buy_sell, price=0):
@@ -311,20 +427,14 @@ class KISClient:
"""지수 현재가 조회 (업종/지수) """지수 현재가 조회 (업종/지수)
ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc. ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc.
""" """
self._throttle() endpoint = "uapi/domestic-stock/v1/quotations/inquire-index-price"
self.ensure_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-index-price"
headers = self._get_headers(tr_id="FHKUP03500100")
params = { params = {
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수 "FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
"FID_INPUT_ISCD": ticker "FID_INPUT_ISCD": ticker
} }
try: try:
res = requests.get(url, headers=headers, params=params) data = self._request_api("GET", endpoint, "FHKUP03500100", params=params)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0': if data['rt_cd'] != '0':
return None return None
return { return {
@@ -340,10 +450,7 @@ class KISClient:
def get_daily_index_price(self, ticker, period="D"): def get_daily_index_price(self, ticker, period="D"):
"""지수 일별 시세 조회 (Market Stress Index용)""" """지수 일별 시세 조회 (Market Stress Index용)"""
self._throttle() endpoint = "uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
self.ensure_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
headers = self._get_headers(tr_id="FHKUP03500200")
# 날짜 계산 (최근 100일) # 날짜 계산 (최근 100일)
end_dt = datetime.now().strftime("%Y%m%d") end_dt = datetime.now().strftime("%Y%m%d")
@@ -358,11 +465,8 @@ class KISClient:
"FID_ORG_ADJ_PRC": "0" # 수정주가 반영 여부 "FID_ORG_ADJ_PRC": "0" # 수정주가 반영 여부
} }
try: try:
res = requests.get(url, headers=headers, params=params) data = self._request_api("GET", endpoint, "FHKUP03500200", params=params)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0': if data['rt_cd'] != '0':
return [] return []
@@ -373,3 +477,38 @@ class KISClient:
except Exception as e: except Exception as e:
print(f"❌ 지수 일별 시세 조회 실패({ticker}): {e}") print(f"❌ 지수 일별 시세 조회 실패({ticker}): {e}")
return [] 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)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
return None
# output 리스트: [ {stck_bsop_date: 날짜, frgn_ntby_qty: 외인순매수, orgn_ntby_qty: 기관순매수, ...}, ... ]
trends = []
for item in data['output'][:5]: # 최근 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']) # 전일대비 등락금액
})
# 최근일이 0번 인덱스임
return trends
except Exception as e:
print(f"❌ 투자자 동향 조회 실패({ticker}): {e}")
return None

View File

@@ -18,12 +18,14 @@ def run_telegram_bot_standalone():
from modules.services.telegram_bot.server import TelegramBotServer from modules.services.telegram_bot.server import TelegramBotServer
from modules.utils.ipc import BotIPC from modules.utils.ipc import BotIPC
from modules.config import Config from modules.config import Config
from modules.utils.process_tracker import ProcessTracker
token = os.getenv("TELEGRAM_BOT_TOKEN") token = os.getenv("TELEGRAM_BOT_TOKEN")
if not token: if not token:
print("❌ [Telegram] TELEGRAM_BOT_TOKEN not found in .env") print("❌ [Telegram] TELEGRAM_BOT_TOKEN not found in .env")
sys.exit(1) sys.exit(1)
ProcessTracker.register("Telegram Bot Standalone")
print(f"🤖 [Telegram Bot Process] Starting... (PID: {os.getpid()})") print(f"🤖 [Telegram Bot Process] Starting... (PID: {os.getpid()})")
print(f"🔗 [Telegram Bot] Standalone Process Mode (IPC Enabled)") print(f"🔗 [Telegram Bot] Standalone Process Mode (IPC Enabled)")

View File

@@ -11,7 +11,7 @@ import logging
import subprocess import subprocess
import sys import sys
from telegram import Update from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes, TypeHandler from telegram.ext import Application, CommandHandler, ContextTypes
from dotenv import load_dotenv from dotenv import load_dotenv
# 로깅 설정 # 로깅 설정
@@ -39,7 +39,6 @@ class TelegramBotServer:
def refresh_bot_instance(self): def refresh_bot_instance(self):
"""IPC에서 최신 봇 인스턴스 데이터 읽기""" """IPC에서 최신 봇 인스턴스 데이터 읽기"""
# [수정] 모듈 경로 변경
from modules.utils.ipc import BotIPC from modules.utils.ipc import BotIPC
ipc = BotIPC() ipc = BotIPC()
self.bot_instance = ipc.get_bot_instance_data() self.bot_instance = ipc.get_bot_instance_data()
@@ -47,8 +46,9 @@ class TelegramBotServer:
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/start 명령어 핸들러""" """/start 명령어 핸들러"""
print(f"📨 [Telegram] /start received from user {update.effective_user.id}")
await update.message.reply_text( await update.message.reply_text(
"🤖 **AI Trading Bot Command Center**\n" "🤖 <b>AI Trading Bot Command Center</b>\n"
"명령어 목록:\n" "명령어 목록:\n"
"/status - 현재 봇 및 시장 상태 조회\n" "/status - 현재 봇 및 시장 상태 조회\n"
"/portfolio - 현재 보유 종목 및 평가액\n" "/portfolio - 현재 보유 종목 및 평가액\n"
@@ -57,11 +57,11 @@ class TelegramBotServer:
"/macro - 거시경제 지표 및 시장 위험도\n" "/macro - 거시경제 지표 및 시장 위험도\n"
"/system - PC 리소스(CPU/GPU) 상태\n" "/system - PC 리소스(CPU/GPU) 상태\n"
"/ai - AI 모델 학습 상태 조회\n\n" "/ai - AI 모델 학습 상태 조회\n\n"
"**[관리 명령어]**\n" "<b>[관리 명령어]</b>\n"
"/restart - 봇 재시작\n" "/restart - 봇 재시작\n"
"/exec <command> - 원격 명령어 실행\n" "/exec <code>명령어</code> - 원격 명령어 실행\n"
"/stop - 봇 종료", "/stop - 봇 종료",
parse_mode="Markdown" parse_mode="HTML"
) )
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -74,13 +74,13 @@ class TelegramBotServer:
now = datetime.now() now = datetime.now()
is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30) is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30)
status_msg = "**System Status: ONLINE**\n" status_msg = "<b>System Status: ONLINE</b>\n"
status_msg += f"🕒 **Market:** {'OPEN 🟢' if is_market_open else 'CLOSED 🔴'}\n" status_msg += f"🕒 <b>Market:</b> {'OPEN 🟢' if is_market_open else 'CLOSED 🔴'}\n"
macro_warn = self.bot_instance.is_macro_warning_sent macro_warn = self.bot_instance.is_macro_warning_sent
status_msg += f"🌍 **Macro Filter:** {'DANGER 🚨 (Trading Halted)' if macro_warn else 'SAFE 🟢'}\n" 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="Markdown") await update.message.reply_text(status_msg, parse_mode="HTML")
async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/portfolio: 잔고 조회""" """/portfolio: 잔고 조회"""
@@ -96,19 +96,19 @@ class TelegramBotServer:
await update.message.reply_text(f"❌ 잔고 조회 실패: {balance['error']}") await update.message.reply_text(f"❌ 잔고 조회 실패: {balance['error']}")
return return
msg = f"💰 **Total Asset:** `{int(balance['total_eval']):,} KRW`\n" \ msg = f"💰 <b>Total Asset:</b> <code>{int(balance['total_eval']):,} KRW</code>\n" \
f"💵 **Deposit:** `{int(balance['deposit']):,} KRW`\n\n" f"💵 <b>Deposit:</b> <code>{int(balance['deposit']):,} KRW</code>\n\n"
if balance['holdings']: if balance['holdings']:
msg += "**[Holdings]**\n" msg += "<b>[Holdings]</b>\n"
for stock in balance['holdings']: for stock in balance['holdings']:
icon = "🔴" if stock['yield'] > 0 else "🔵" icon = "🔴" if stock['yield'] > 0 else "🔵"
msg += f"{icon} **{stock['name']}** `{stock['yield']}%`\n" \ msg += f"{icon} <b>{stock['name']}</b> <code>{stock['yield']}%</code>\n" \
f" (수량: {stock['qty']} / 평가손익: {stock['profit_loss']:,})\n" f" (수량: {stock['qty']} / 평가손익: {stock['profit_loss']:,})\n"
else: else:
msg += "보유 중인 종목이 없습니다." msg += "보유 중인 종목이 없습니다."
await update.message.reply_text(msg, parse_mode="Markdown") await update.message.reply_text(msg, parse_mode="HTML")
except Exception as e: except Exception as e:
await update.message.reply_text(f"❌ Error: {str(e)}") await update.message.reply_text(f"❌ Error: {str(e)}")
@@ -116,41 +116,41 @@ class TelegramBotServer:
async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/watchlist: 감시 대상 종목""" """/watchlist: 감시 대상 종목"""
if not self.refresh_bot_instance(): if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 봇 인스턴스가 연결되지 않았습니다.")
return return
target_dict = self.bot_instance.load_watchlist() target_dict = self.bot_instance.load_watchlist()
discovered = list(self.bot_instance.discovered_stocks) discovered = list(self.bot_instance.discovered_stocks)
msg = f"👀 **Watchlist: {len(target_dict)} items**\n" msg = f"👀 <b>Watchlist: {len(target_dict)} items</b>\n"
for code, name in target_dict.items(): for code, name in target_dict.items():
themes = self.bot_instance.theme_manager.get_themes(code) themes = self.bot_instance.theme_manager.get_themes(code)
theme_str = f" ({', '.join(themes)})" if themes else "" theme_str = f" ({', '.join(themes)})" if themes else ""
msg += f"- {name}{theme_str}\n" msg += f"- {name}{theme_str}\n"
if discovered: if discovered:
msg += f"\n**Discovered Today ({len(discovered)}):**\n" msg += f"\n<b>Discovered Today ({len(discovered)}):</b>\n"
for code in discovered: for code in discovered:
msg += f"- {code}\n" msg += f"- {code}\n"
await update.message.reply_text(msg) await update.message.reply_text(msg, parse_mode="HTML")
async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/update_watchlist: Watchlist 즉시 업데이트""" """/update_watchlist: Watchlist 즉시 업데이트"""
await update.message.reply_text("🔄 Watchlist를 업데이트하고 있습니다... (30초 소요)") await update.message.reply_text("🔄 Watchlist를 업데이트하고 있습니다... (30초 소요)")
try: try:
# [수정] IPC 모드에서도 직접 수행하기 위해 새로운 인스턴스 생성
from modules.services.kis import KISClient from modules.services.kis import KISClient
from watchlist_manager import WatchlistManager from watchlist_manager import WatchlistManager
# 독립적인 KIS 클라이언트 생성
from modules.config import Config from modules.config import Config
temp_kis = KISClient() temp_kis = KISClient()
mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE) mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE)
# 업데이트 수행 (파일 쓰기)
summary = mgr.update_watchlist_daily() summary = mgr.update_watchlist_daily()
await update.message.reply_text(summary, parse_mode="Markdown") # HTML 특수문자 이스케이프
summary = summary.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
await update.message.reply_text(summary)
except Exception as e: except Exception as e:
await update.message.reply_text(f"❌ 업데이트 실패: {e}") await update.message.reply_text(f"❌ 업데이트 실패: {e}")
@@ -164,32 +164,29 @@ class TelegramBotServer:
await update.message.reply_text("⏳ 거시경제 데이터를 불러옵니다...") await update.message.reply_text("⏳ 거시경제 데이터를 불러옵니다...")
try: try:
# [수정] IPC 데이터를 직접 사용하여 출력 (FakeKIS의 _macro_indices 활용)
# FakeKIS는 bot_instance.kis에 할당되어 있음
indices = getattr(self.bot_instance.kis, '_macro_indices', {}) indices = getattr(self.bot_instance.kis, '_macro_indices', {})
if not indices: if not indices:
await update.message.reply_text("⚠️ 데이터가 아직 수집되지 않았습니다. 잠시 후 다시 시도하세요.") await update.message.reply_text("⚠️ 데이터가 아직 수집되지 않았습니다. 잠시 후 다시 시도하세요.")
return return
# 리스크 점수 계산 (간이)
status = "SAFE" status = "SAFE"
msi = indices.get('MSI', 0) msi = indices.get('MSI', 0)
if msi >= 50: status = "DANGER" if msi >= 50: status = "DANGER"
elif msi >= 30: status = "CAUTION" elif msi >= 30: status = "CAUTION"
color = "🟢" if status == "SAFE" else "🔴" if status == "DANGER" else "🟡" color = "🟢" if status == "SAFE" else "🔴" if status == "DANGER" else "🟡"
msg = f"{color} **Market Risk: {status}**\n\n" msg = f"{color} <b>Market Risk: {status}</b>\n\n"
if 'MSI' in indices: if 'MSI' in indices:
msg += f"🌡️ **Stress Index:** `{indices['MSI']}`\n" msg += f"🌡️ <b>Stress Index:</b> <code>{indices['MSI']}</code>\n"
for k, v in indices.items(): for k, v in indices.items():
if k != "MSI": if k != "MSI":
icon = "🔺" if v.get('change', 0) > 0 else "🔻" icon = "🔺" if v.get('change', 0) > 0 else "🔻"
msg += f"{icon} **{k}**: {v.get('price', 0)} ({v.get('change', 0)}%)\n" msg += f"{icon} <b>{k}</b>: {v.get('price', 0)} ({v.get('change', 0)}%)\n"
await update.message.reply_text(msg, parse_mode="Markdown") await update.message.reply_text(msg, parse_mode="HTML")
except Exception as e: except Exception as e:
await update.message.reply_text(f"❌ Error: {e}") await update.message.reply_text(f"❌ Error: {e}")
@@ -220,97 +217,101 @@ class TelegramBotServer:
top_3 = top_processes[:3] top_3 = top_processes[:3]
gpu_status = self.bot_instance.ollama_monitor.get_gpu_status() gpu_status = self.bot_instance.ollama_monitor.get_gpu_status()
gpu_msg = f"N/A" gpu_msg = "N/A"
if gpu_status and gpu_status.get('name') != 'N/A': if gpu_status and gpu_status.get('name') != 'N/A':
gpu_name = gpu_status.get('name', 'GPU') 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" 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" \ msg = "🖥️ <b>PC System Status</b>\n" \
f"🧠 **CPU:** `{cpu}%`\n" \ f"🧠 <b>CPU:</b> <code>{cpu}%</code>\n" \
f"💾 **RAM:** `{ram}%`\n" \ f"💾 <b>RAM:</b> <code>{ram}%</code>\n" \
f"🎮 **GPU:** {gpu_msg}\n\n" f"🎮 <b>GPU:</b> {gpu_msg}\n\n"
if top_3: if top_3:
msg += "⚙️ **Top CPU Processes:**\n" msg += "⚙️ <b>Top CPU Processes:</b>\n"
for i, proc in enumerate(top_3, 1): for i, proc in enumerate(top_3, 1):
proc_name = proc.get('name', 'Unknown') proc_name = proc.get('name', 'Unknown')
proc_cpu = proc.get('cpu_percent', 0) proc_cpu = proc.get('cpu_percent', 0)
msg += f" {i}. `{proc_name}` - {proc_cpu:.1f}%\n" msg += f" {i}. <code>{proc_name}</code> - {proc_cpu:.1f}%\n"
await update.message.reply_text(msg, parse_mode="Markdown") await update.message.reply_text(msg, parse_mode="HTML")
async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/ai: AI 모델 학습 상태 조회""" """/ai: AI 모델 학습 상태 조회"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
return
gpu = self.bot_instance.ollama_monitor.get_gpu_status() gpu = self.bot_instance.ollama_monitor.get_gpu_status()
msg = "🧠 **AI Model Status**\n" msg = "🧠 <b>AI Model Status</b>\n"
msg += f"**LLM Engine:** Ollama (Llama 3.1)\n" msg += "<b>LLM Engine:</b> Ollama (Llama 3.1)\n"
gpu_name = gpu.get('name', 'NVIDIA RTX 5070 Ti') gpu_name = gpu.get('name', 'NVIDIA RTX 5070 Ti')
msg += f"**Device:** {gpu_name}\n" msg += f"<b>Device:</b> {gpu_name}\n"
if gpu: if gpu:
msg += f"**GPU Load:** `{gpu.get('load', 0)}%`\n" msg += f"<b>GPU Load:</b> <code>{gpu.get('load', 0)}%</code>\n"
msg += f"**VRAM Usage:** `{gpu.get('vram_used', 0)}GB` / {gpu.get('vram_total', 0)}GB" 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="Markdown") await update.message.reply_text(msg, parse_mode="HTML")
async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/restart: 텔레그램 봇 모듈만 재시작""" """/restart: 텔레그램 봇 모듈만 재시작"""
await update.message.reply_text("🔄 **텔레그램 인터페이스를 재시작합니다...**") await update.message.reply_text("🔄 <b>텔레그램 인터페이스를 재시작합니다...</b>", parse_mode="HTML")
# 재시작 플래그 설정 (runner.py에서 감지하여 재시작)
self.should_restart = True self.should_restart = True
self.application.stop_running() self.application.stop_running()
async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/stop: 봇 종료""" """/stop: 봇 종료"""
await update.message.reply_text("🛑 **텔레그램 봇을 종료합니다.**") await update.message.reply_text("🛑 <b>텔레그램 봇을 종료합니다.</b>", parse_mode="HTML")
# 종료 플래그 설정 (runner.py에서 루프 탈출)
self.should_restart = False self.should_restart = False
self.application.stop_running() self.application.stop_running()
async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/exec: 원격 명령어 실행""" """/exec: 원격 명령어 실행 (Non-blocking)"""
# ultimate_handler에서는 context.args가 비어있으므로 직접 파싱
text = update.message.text.strip() text = update.message.text.strip()
parts = text.split(maxsplit=1) # "/exec" 와 나머지 명령어로 분리 parts = text.split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2:
await update.message.reply_text("❌ 사용법: /exec <command>") await update.message.reply_text("❌ 사용법: /exec 명령어")
return return
command = parts[1] # "/exec" 이후의 모든 텍스트 command = parts[1]
await update.message.reply_text(f"⚙️ 실행 중: `{command}`", parse_mode="Markdown") await update.message.reply_text(f"⚙️ 실행 중: <code>{command}</code>", parse_mode="HTML")
try: try:
# 보안: 위험한 명령어 차단 # 보안: 위험한 명령어 차단
dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot', 'ipconfig'] dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot']
if any(keyword in command.lower() for keyword in dangerous_keywords): if any(keyword in command.lower() for keyword in dangerous_keywords):
await update.message.reply_text("⛔ 위험한 명령어는 실행할 수 없습니다.") await update.message.reply_text("⛔ 위험한 명령어는 실행할 수 없습니다.")
return return
# Windows에서는 PowerShell을 명시적으로 사용
import platform import platform
if platform.system() == 'Windows': is_windows = platform.system() == 'Windows'
exec_command = ['powershell', '-Command', command]
if is_windows:
exec_cmd = ['powershell', '-Command', command]
else: else:
exec_command = command exec_cmd = command
# 명령어 실행 (타임아웃 30초) def run_subprocess():
result = subprocess.run( return subprocess.run(
exec_command, exec_cmd,
shell=False if platform.system() == 'Windows' else True, shell=not is_windows,
capture_output=True, capture_output=True,
text=True, text=True,
encoding='utf-8', encoding='utf-8',
errors='replace', # 인코딩 오류 무시 errors='replace',
timeout=30, timeout=30,
cwd=os.getcwd() cwd=os.getcwd()
) )
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, run_subprocess)
# stdout와 stderr 모두 확인
output = result.stdout.strip() if result.stdout else "" output = result.stdout.strip() if result.stdout else ""
error_output = result.stderr.strip() if result.stderr else "" error_output = result.stderr.strip() if result.stderr else ""
@@ -323,97 +324,53 @@ class TelegramBotServer:
else: else:
combined = "명령어 실행 완료 (출력 없음)" combined = "명령어 실행 완료 (출력 없음)"
# 출력이 너무 길면 잘라내기
if len(combined) > 3000: if len(combined) > 3000:
combined = combined[:3000] + "\n... (출력이 너무 깁니다)" combined = combined[:3000] + "\n... (Truncated)"
await update.message.reply_text(f"```\n{combined}\n```", parse_mode="Markdown") # HTML 특수문자 이스케이프
combined = combined.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
await update.message.reply_text(f"<pre>{combined}</pre>", parse_mode="HTML")
except subprocess.TimeoutExpired: except asyncio.TimeoutError:
await update.message.reply_text("⏱️ 명령어 실행 시간 초과 (30초)") await update.message.reply_text("⏱️ 명령어 실행 시간 초과 (30초)")
except Exception as e: except Exception as e:
print(f"❌ [Telegram /exec] Error: {e}") await update.message.reply_text(f"❌ 실행 오류: {e}")
import traceback
traceback.print_exc()
await update.message.reply_text(f"❌ 실행 오류: {str(e)}")
def run(self): def run(self):
"""봇 실행 (비동기 polling)""" """봇 실행 (Handler 등록 및 Polling)"""
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: handlers = [
"""에러 핸들러""" ("start", self.start_command),
import traceback ("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),
("restart", self.restart_command),
("stop", self.stop_command),
("exec", self.exec_command)
]
# Conflict 에러는 무시 (다른 봇 인스턴스 실행 중) 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): if "Conflict" in str(context.error):
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다. 이 인스턴스를 종료합니다.") print(f"⚠️ [Telegram] Conflict detected. Stopping...")
if self.application.running: if self.application.running:
await self.application.stop() await self.application.stop()
return return
print(f"❌ [Telegram Error] {context.error}")
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_error_handler(error_handler)
self.application.add_handler(TypeHandler(Update, ultimate_handler))
# [최적화] Polling 설정 개선 print("🤖 [Telegram] Command Server Started (Standard Polling Mode).")
print("🤖 [Telegram] Command Server Started (Optimized Polling Mode).")
try: try:
self.application.run_polling( self.application.run_polling(
allowed_updates=Update.ALL_TYPES, allowed_updates=Update.ALL_TYPES,
stop_signals=None, drop_pending_updates=True
poll_interval=1.0, # 1초마다 폴링 (기본값 0.0)
timeout=10, # 타임아웃 10초
drop_pending_updates=True # 대기 중인 업데이트 무시
) )
except Exception as e: except Exception as e:
if "Conflict" in str(e): print(f"❌ [Telegram] Polling Error: {e}")
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다.")
print(f"⚠️ [Telegram] 기존 봇을 종료하고 다시 시도하세요.")
else:
print(f"❌ [Telegram] 봇 실행 오류: {e}")
import traceback
traceback.print_exc()

View File

@@ -16,130 +16,179 @@ def get_predictor():
_lstm_predictor = PricePredictor() _lstm_predictor = PricePredictor()
return _lstm_predictor return _lstm_predictor
def analyze_stock_process(ticker, prices, news_items): def analyze_stock_process(ticker, prices, news_items, investor_trend=None):
""" """
[CPU Intensive] 기술적 분석 및 AI 판단을 수행하는 함수 [CPU Intensive] 기술적 분석 및 AI 판단을 수행하는 함수
(ProcessPoolExecutor에서 실행됨) (ProcessPoolExecutor에서 실행됨)
""" """
print(f"⚙️ [Bot Process] Analyzing {ticker} ({len(prices)} candles)...")
# 1. 기술적 지표 계산
current_price = prices[-1] if prices else 0
# [수정] 변동성, 거래량 비율 반환 (거래량 데이터가 없으면 None 전달)
tech_score, rsi, volatility, vol_ratio = TechnicalAnalyzer.get_technical_score(current_price, prices, volume_history=None)
# 2. LSTM 주가 예측
# [최적화] 전역 캐시된 Predictor 사용
lstm_predictor = get_predictor()
if lstm_predictor:
lstm_predictor.training_status['current_ticker'] = ticker
pred_result = lstm_predictor.train_and_predict(prices)
lstm_score = 0.5 # 중립
ai_confidence = 0.5
ai_loss = 1.0
if pred_result:
ai_confidence = pred_result.get('confidence', 0.5)
ai_loss = pred_result.get('loss', 1.0)
# 상승/하락 예측에 따라 점수 조정 (신뢰도 반영)
# 최대 5% 변동폭까지 반영
change_magnitude = min(abs(pred_result['change_rate']), 5.0) / 5.0
if pred_result['trend'] == 'UP':
# 상승 예측 시: 기본 0.5 + (강도 * 신뢰도 * 0.4) -> 최대 0.9
lstm_score = 0.5 + (change_magnitude * ai_confidence * 0.4)
else:
# 하락 예측 시: 기본 0.5 - (강도 * 신뢰도 * 0.4) -> 최소 0.1
lstm_score = 0.5 - (change_magnitude * ai_confidence * 0.4)
lstm_score = max(0.0, min(1.0, lstm_score))
# 3. AI 뉴스 분석
ollama = OllamaManager()
prompt = f"""
[System Instruction]
1. Role: You are a Expert Quant Trader with 20 years of experience.
2. Market Data:
- Technical Score: {tech_score:.2f} (RSI: {rsi:.2f})
- AI Prediction: {pred_result['predicted']:.0f} KRW ({pred_result['change_rate']}%)
- AI Confidence: {ai_confidence:.2f} (Loss: {ai_loss:.4f})
3. Strategy:
- If AI Confidence > 0.8 and Trend is UP -> Strong BUY signal.
- If Tech Score > 0.7 -> BUY signal.
- If Trend is DOWN -> SELL/AVOID.
4. Task: Analyze the news and combine with market data to decide sentiment.
News Data: {json.dumps(news_items, ensure_ascii=False)}
Response (JSON):
{{
"sentiment_score": 0.8,
"reason": "AI confidence is high and news supports the uptrend."
}}
"""
ai_resp = ollama.request_inference(prompt)
sentiment_score = 0.5
try: try:
data = json.loads(ai_resp) print(f"⚙️ [Bot Process] Analyzing {ticker} ({len(prices)} candles)...")
sentiment_score = float(data.get("sentiment_score", 0.5))
except:
pass
# 4. 통합 점수 (동적 가중치) # 1. 기술적 지표 계산
# AI 신뢰도가 높으면 AI 비중을 대폭 상향 current_price = prices[-1] if prices else 0
if ai_confidence >= 0.85: # [수정] 변동성, 거래량 비율, MA 정보 반환
w_tech, w_news, w_ai = 0.2, 0.2, 0.6 tech_score, rsi, volatility, vol_ratio, ma_info = TechnicalAnalyzer.get_technical_score(current_price, prices, volume_history=None)
print(f" 🤖 [High Confidence] AI Weight Boosted to 60%")
else:
w_tech, w_news, w_ai = 0.4, 0.3, 0.3
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score) # 2. LSTM 주가 예측
# [최적화] 전역 캐시된 Predictor 사용
lstm_predictor = get_predictor()
if lstm_predictor:
lstm_predictor.training_status['current_ticker'] = ticker
pred_result = lstm_predictor.train_and_predict(prices)
decision = "HOLD" lstm_score = 0.5 # 중립
ai_confidence = 0.5
ai_loss = 1.0
# [신규] 강한 단일 신호 매수 로직 (기준 강화) if pred_result:
strong_signal = False ai_confidence = pred_result.get('confidence', 0.5)
strong_reason = "" ai_loss = pred_result.get('loss', 1.0)
if tech_score >= 0.80: # 상승/하락 예측에 따라 점수 조정 (신뢰도 반영)
strong_signal = True # 최대 5% 변동폭까지 반영
strong_reason = "Super Strong Technical" change_magnitude = min(abs(pred_result['change_rate']), 5.0) / 5.0
elif lstm_score >= 0.80 and ai_confidence >= 0.8:
strong_signal = True
strong_reason = f"High Confidence AI Buy (Conf: {ai_confidence})"
elif sentiment_score >= 0.85:
strong_signal = True
strong_reason = "Strong News Sentiment"
if strong_signal: if pred_result['trend'] == 'UP':
decision = "BUY" # 상승 예측 시: 기본 0.5 + (강도 * 신뢰도 * 0.4) -> 최대 0.9
print(f" 🎯 [{strong_reason}] Overriding to BUY!") lstm_score = 0.5 + (change_magnitude * ai_confidence * 0.4)
elif total_score >= 0.60: # (0.5 -> 0.6 상향 조정으로 보수적 접근) else:
decision = "BUY" # 하락 예측 시: 기본 0.5 - (강도 * 신뢰도 * 0.4) -> 최소 0.1
elif total_score <= 0.30: lstm_score = 0.5 - (change_magnitude * ai_confidence * 0.4)
decision = "SELL"
print(f" └─ Scores: Tech={tech_score:.2f} News={sentiment_score:.2f} LSTM={lstm_score:.2f}(Conf:{ai_confidence:.2f}) → Total={total_score:.2f} [{decision}]") lstm_score = max(0.0, min(1.0, lstm_score))
# [신규] 변동성(Volatility) 계산 # [신규] 수급 분석 (외인/기관)
if len(prices) > 1: investor_score = 0.0
prices_np = np.array(prices) frgn_net_buy = 0
changes = np.diff(prices_np) / prices_np[:-1] orgn_net_buy = 0
volatility = np.std(changes) * 100 # 퍼센트 단위 consecutive_frgn_buy = 0
else:
volatility = 0.0
return { if investor_trend:
"ticker": ticker, # 최근 5일 합산
"score": total_score, for day in investor_trend:
"tech": tech_score, frgn_net_buy += day['foreigner']
"sentiment": sentiment_score, orgn_net_buy += day['institutional']
"lstm_score": lstm_score, if day['foreigner'] > 0:
"volatility": volatility, consecutive_frgn_buy += 1
"volume_ratio": vol_ratio,
"prediction": pred_result, # 외인 수급 점수 (단순화)
"decision": decision, if frgn_net_buy > 0:
"current_price": current_price investor_score += 0.05
} if consecutive_frgn_buy >= 3:
investor_score += 0.05
if investor_score > 0:
print(f" 💰 [Investor] Foreign Buy Detected (Net: {frgn_net_buy})")
# 3. AI 뉴스 분석
# pred_result가 None일 경우 기본값 사용
if pred_result:
pred_price = pred_result.get('predicted', 0)
pred_change = pred_result.get('change_rate', 0)
else:
pred_price = current_price
pred_change = 0.0
ollama = OllamaManager()
prompt = f"""
[System Instruction]
1. Role: You are a Expert Quant Trader with 20 years of experience.
2. Market Data:
- Technical Score: {tech_score:.2f} (RSI: {rsi:.2f})
- Moving Average: {ma_info['trend']} (Price is {ma_info['position']})
- AI Prediction: {pred_price:.0f} KRW ({pred_change}%)
- AI Confidence: {ai_confidence:.2f} (Loss: {ai_loss:.4f})
- Investor Trend (5 Days): Foreigner Net Buy {frgn_net_buy}, Institutional Net Buy {orgn_net_buy}
3. Strategy:
- If Foreigners are buying AND Trend is UP -> Strong BUY.
- If AI Confidence > 0.8 and Trend is UP -> Strong BUY.
- If MA is Bullish (Golden Alignment) -> Positive Signal.
- If Price is above MA20 -> Support Uptrend.
- If Trend is DOWN -> SELL/AVOID.
4. Task: Analyze the news and combine with market data to decide sentiment.
News Data: {json.dumps(news_items, ensure_ascii=False)}
Response (JSON):
{{
"sentiment_score": 0.8,
"reason": "Foreigners buying and Golden Cross detected."
}}
"""
ai_resp = ollama.request_inference(prompt)
sentiment_score = 0.5
try:
data = json.loads(ai_resp)
sentiment_score = float(data.get("sentiment_score", 0.5))
except:
pass
# 4. 통합 점수 (동적 가중치)
# AI 신뢰도가 높으면 AI 비중을 대폭 상향
if ai_confidence >= 0.85:
w_tech, w_news, w_ai = 0.2, 0.2, 0.6
print(f" 🤖 [High Confidence] AI Weight Boosted to 60%")
else:
w_tech, w_news, w_ai = 0.4, 0.3, 0.3
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score)
# [수신] 수급 가산점 추가 (최대 +0.1)
total_score += investor_score
total_score = min(total_score, 1.0)
decision = "HOLD"
# [신규] 강한 단일 신호 매수 로직 (기준 강화)
strong_signal = False
strong_reason = ""
if tech_score >= 0.80:
strong_signal = True
strong_reason = "Super Strong Technical"
elif lstm_score >= 0.80 and ai_confidence >= 0.8:
strong_signal = True
strong_reason = f"High Confidence AI Buy (Conf: {ai_confidence})"
elif sentiment_score >= 0.85:
strong_signal = True
strong_reason = "Strong News Sentiment"
elif investor_score >= 0.1 and total_score >= 0.6: # 외인 수급이 좋고 전체 점수 양호
strong_signal = True
strong_reason = "Strong Foreigner Buying"
if strong_signal:
decision = "BUY"
print(f" 🎯 [{strong_reason}] Overriding to BUY!")
elif total_score >= 0.60: # (0.5 -> 0.6 상향 조정으로 보수적 접근)
decision = "BUY"
elif total_score <= 0.30:
decision = "SELL"
print(f" └─ Scores: Tech={tech_score:.2f} News={sentiment_score:.2f} LSTM={lstm_score:.2f} → Total={total_score:.2f} [{decision}]")
return {
"ticker": ticker,
"score": total_score,
"tech": tech_score,
"sentiment": sentiment_score,
"lstm_score": lstm_score,
"volatility": volatility,
"volume_ratio": vol_ratio,
"prediction": pred_result,
"decision": decision,
"current_price": current_price,
"ma_info": ma_info
}
except Exception as e:
print(f"❌ [Worker Error] Failed to analyze {ticker}: {e}")
import traceback
traceback.print_exc()
# 기본 실패 응답 반환 (프로세스 크래시 방지)
return {
"ticker": ticker,
"score": 0.0,
"decision": "HOLD",
"current_price": 0,
"error": str(e)
}

View File

@@ -0,0 +1,78 @@
import os
import time
class ProcessTracker:
FILE_PATH = "pids.txt"
@staticmethod
def register(name):
"""현재 프로세스의 PID와 이름을 기록"""
pid = os.getpid()
entry = f"{pid}: {name} (Started: {time.strftime('%Y-%m-%d %H:%M:%S')})\n"
try:
# 파일이 없으면 생성, 있으면 추가
# 단, main_server 시작 시 초기화하는 것이 좋음
with open(ProcessTracker.FILE_PATH, "a", encoding="utf-8") as f:
f.write(entry)
print(f"📌 Process Registered: {name} (PID: {pid})")
except Exception as e:
print(f"⚠️ Failed to register process: {e}")
@staticmethod
def check_and_kill_zombies():
"""
pids.txt에 기록된 이전 프로세스들이 구동 중이라면 강제 종료.
서버 시작 시 1회 호출하여 좀비 프로세스를 정리함.
"""
if not os.path.exists(ProcessTracker.FILE_PATH):
return
print("🔍 Checking for zombie processes...")
try:
import psutil
current_pid = os.getpid()
with open(ProcessTracker.FILE_PATH, "r", encoding="utf-8") as f:
lines = f.readlines()
killed_count = 0
for line in lines:
if ":" not in line or "Running Processes" in line:
continue
try:
pid_str = line.split(":")[0].strip()
pid = int(pid_str)
if pid == current_pid:
continue
if psutil.pid_exists(pid):
proc = psutil.Process(pid)
proc_name = proc.name()
# Python 프로세스만 타겟
if "python" in proc_name.lower():
print(f"💀 Killing Zombie Process: {pid} ({line.strip()})")
proc.kill()
killed_count += 1
except (ValueError, psutil.NoSuchProcess, psutil.AccessDenied):
continue
if killed_count > 0:
print(f"✅ Cleaned up {killed_count} zombie processes.")
# 파일 초기화
ProcessTracker.clear()
except Exception as e:
print(f"⚠️ Failed to kill zombies: {e}")
@staticmethod
def clear():
"""PID 파일 초기화"""
try:
with open(ProcessTracker.FILE_PATH, "w", encoding="utf-8") as f:
f.write(f"--- Running Processes (Last Update: {time.strftime('%Y-%m-%d %H:%M:%S')}) ---\n")
except:
pass