main_server.py가 중복 실행되면서 좀비 프로세스가 수행되는 오류 해결, process_tracker.py가 감시하면서 할당되지 않은 pid가 존재하면 좀비프로세스로 판단하여 kill
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
157
modules/bot.py
157
modules/bot.py
@@ -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):
|
||||||
"""텔레그램 봇 프로세스 실행 (독립 프로세스)"""
|
"""텔레그램 봇 프로세스 실행 (독립 프로세스)"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|
||||||
|
|||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
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()
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
78
modules/utils/process_tracker.py
Normal file
78
modules/utils/process_tracker.py
Normal 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
|
||||||
Reference in New Issue
Block a user