주식자동매매 AI 프로그램 초기 모델

This commit is contained in:
2026-02-04 23:29:06 +09:00
parent 41df1a38d3
commit 7d5f62f844
20 changed files with 2987 additions and 0 deletions

2
modules/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# Initialize modules package

View File

@@ -0,0 +1,206 @@
import torch
import torch.nn as nn
import numpy as np
from sklearn.preprocessing import MinMaxScaler
class Attention(nn.Module):
"""Attention Mechanism for LSTM"""
def __init__(self, hidden_size):
super(Attention, self).__init__()
self.hidden_size = hidden_size
self.attn = nn.Linear(hidden_size, 1)
def forward(self, lstm_output):
# lstm_output: [batch_size, seq_len, hidden_size]
# attn_weights: [batch_size, seq_len, 1]
attn_weights = torch.softmax(self.attn(lstm_output), dim=1)
# context: [batch_size, hidden_size]
context = torch.sum(attn_weights * lstm_output, dim=1)
return context, attn_weights
class AdvancedLSTM(nn.Module):
"""
[RTX 5070 Ti Optimized] High-Capacity LSTM with Attention
- Hidden Size: 512 (Rich Feature Extraction)
- Layers: 4 (Deep Reasoning)
- Attention: Focus on critical time steps
"""
def __init__(self, input_size=1, hidden_size=512, num_layers=4, output_size=1, dropout=0.3):
super(AdvancedLSTM, self).__init__()
self.hidden_size = hidden_size
self.num_layers = num_layers
self.lstm = nn.LSTM(input_size, hidden_size, num_layers,
batch_first=True, dropout=dropout)
self.attention = Attention(hidden_size)
self.fc = nn.Sequential(
nn.Linear(hidden_size, hidden_size // 2),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(hidden_size // 2, hidden_size // 4),
nn.ReLU(),
nn.Linear(hidden_size // 4, output_size)
)
def forward(self, x):
# x: [batch, seq, feature]
h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(x.device)
c0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(x.device)
# LSTM Output
lstm_out, _ = self.lstm(x, (h0, c0)) # [batch, seq, hidden]
# Attention Mechanism
context, _ = self.attention(lstm_out) # [batch, hidden]
# Final Prediction
out = self.fc(context)
return out
class PricePredictor:
"""
주가 예측을 위한 고성능 Deep Learning 모델 (RTX 5070 Ti Edition)
"""
def __init__(self):
self.scaler = MinMaxScaler(feature_range=(0, 1))
# [Hardware Spec] RTX 5070 Ti (16GB VRAM) 맞춤 설정
self.hidden_size = 512
self.num_layers = 4
self.model = AdvancedLSTM(input_size=1, hidden_size=self.hidden_size,
num_layers=self.num_layers, dropout=0.3)
self.criterion = nn.MSELoss()
# CUDA 설정
self.device = torch.device('cpu')
if torch.cuda.is_available():
try:
gpu_name = torch.cuda.get_device_name(0)
vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
# GPU 할당
self.device = torch.device('cuda')
self.model.to(self.device)
# Warm-up (컴파일 최적화 유도)
dummy = torch.zeros(1, 60, 1).to(self.device)
_ = self.model(dummy)
print(f"🚀 [AI] Powered by {gpu_name} ({vram_gb:.1f}GB) - High Performance Mode On")
except Exception as e:
print(f"⚠️ [AI] GPU Init Failed: {e}")
self.device = torch.device('cpu')
else:
print("⚠️ [AI] Running on CPU (Low Performance)")
# Optimizer 설정 (AdamW가 일반화 성능이 좀 더 좋음)
self.optimizer = torch.optim.AdamW(self.model.parameters(), lr=0.0005, weight_decay=1e-4)
# 학습 파라미터 강화
self.batch_size = 64
self.epochs = 200 # 충분한 학습
self.seq_length = 60 # 60일(약 3개월) 패턴 분석
self.training_status = {
"is_training": False,
"loss": 0.0
}
@staticmethod
def verify_hardware():
"""서버 시작 시 하드웨어 가속 여부 점검 및 로그 출력"""
if torch.cuda.is_available():
try:
gpu_name = torch.cuda.get_device_name(0)
vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
print(f"🚀 [AI Check] Hardware Detected: {gpu_name} ({vram_gb:.1f}GB VRAM)")
print(f" ✅ High Performance Mode is READY.")
return True
except Exception as e:
print(f"⚠️ [AI Check] GPU Error: {e}")
return False
else:
print("⚠️ [AI Check] No GPU Detected. Running in CPU Mode.")
return False
def train_and_predict(self, prices, forecast_days=1):
"""
Online Learning & Prediction
"""
# 데이터가 최소 시퀀스 길이 + 여유분보다 적으면 예측 불가
if len(prices) < (self.seq_length + 10):
return None
# 1. 데이터 전처리
data = np.array(prices).reshape(-1, 1)
scaled_data = self.scaler.fit_transform(data)
x_train, y_train = [], []
for i in range(len(scaled_data) - self.seq_length):
x_train.append(scaled_data[i:i+self.seq_length])
y_train.append(scaled_data[i+self.seq_length])
x_train_t = torch.FloatTensor(np.array(x_train)).to(self.device)
y_train_t = torch.FloatTensor(np.array(y_train)).to(self.device)
# 2. 학습
self.model.train()
self.training_status["is_training"] = True
dataset_size = len(x_train_t)
final_loss = 0.0
for epoch in range(self.epochs):
perm = torch.randperm(dataset_size).to(self.device)
x_shuffled = x_train_t[perm]
y_shuffled = y_train_t[perm]
epoch_loss = 0.0
steps = 0
for i in range(0, dataset_size, self.batch_size):
batch_x = x_shuffled[i:min(i+self.batch_size, dataset_size)]
batch_y = y_shuffled[i:min(i+self.batch_size, dataset_size)]
self.optimizer.zero_grad()
outputs = self.model(batch_x)
loss = self.criterion(outputs, batch_y)
loss.backward()
self.optimizer.step()
epoch_loss += loss.item()
steps += 1
final_loss = epoch_loss / max(1, steps)
self.training_status["is_training"] = False
self.training_status["loss"] = final_loss
# 3. 예측
self.model.eval()
with torch.no_grad():
last_seq = torch.FloatTensor(scaled_data[-self.seq_length:]).unsqueeze(0).to(self.device)
predicted_scaled = self.model(last_seq)
predicted_price = self.scaler.inverse_transform(predicted_scaled.cpu().numpy())[0][0]
current_price = prices[-1]
trend = "UP" if predicted_price > current_price else "DOWN"
change_rate = ((predicted_price - current_price) / current_price) * 100
# 신뢰도 점수 (Loss가 낮을수록 높음, 0~1)
# Loss가 0.001이면 0.99, 0.01이면 0.9 정도 나오게 조정
confidence = 1.0 / (1.0 + (final_loss * 100))
return {
"current": current_price,
"predicted": float(predicted_price),
"change_rate": round(change_rate, 2),
"trend": trend,
"loss": final_loss,
"confidence": round(confidence, 2)
}

139
modules/analysis/macro.py Normal file
View File

@@ -0,0 +1,139 @@
from datetime import datetime
import os
from dotenv import load_dotenv
from modules.services.kis import KISClient
class MacroAnalyzer:
"""
KIS API를 활용한 거시경제(시장 지수) 분석 모듈
yfinance 대신 한국투자증권 API를 사용하여 안정적인 KOSPI, KOSDAQ 데이터를 수집함.
"""
@staticmethod
def get_macro_status(kis_client):
"""
시장 주요 지수(KOSPI, KOSDAQ)를 조회하여 시장 위험도를 평가함.
Args:
kis_client (KISClient): 인증된 KIS API 클라이언트 인스턴스
Returns:
dict: 시장 상태 (SAFE, CAUTION, DANGER) 및 지표 데이터
"""
indicators = {
"KOSPI": "0001",
"KOSDAQ": "1001"
}
results = {}
risk_score = 0
print("🌍 [Macro] Fetching market indices via KIS API...")
for name, code in indicators.items():
data = kis_client.get_current_index(code)
if data:
price = data['price']
change = data['change']
results[name] = {"price": price, "change": change}
print(f" - {name}: {price} ({change}%)")
# 리스크 평가 로직 (단순화: 2% 이상 폭락 장이면 위험)
if change <= -2.0:
risk_score += 2 # 패닉 상태
elif change <= -1.0:
risk_score += 1 # 주의 상태
else:
results[name] = {"price": 0, "change": 0}
# [신규] 시장 스트레스 지수(MSI) 추가
kospi_stress = MacroAnalyzer.calculate_stress_index(kis_client, "0001")
results['MSI'] = kospi_stress
print(f" - Market Stress Index: {kospi_stress}")
if kospi_stress >= 50:
risk_score += 2 # 매우 위험
elif kospi_stress >= 30:
risk_score += 1 # 위험
# 시장 상태 정의
status = "SAFE"
if risk_score >= 2:
status = "DANGER" # 매수 중단 권장
elif risk_score >= 1:
status = "CAUTION" # 보수적 매매
return {
"status": status,
"risk_score": risk_score,
"indicators": results
}
@staticmethod
def calculate_stress_index(kis_client, market_code="0001"):
"""
시장 스트레스 지수(MSI) 계산
- 0~100 사이의 값 (높을수록 위험)
- 요소: 변동성(Volatility), 추세 이격도(MA Divergence)
"""
import numpy as np
# 일봉 데이터 조회 (약 3개월치 = 60일 이상)
prices = kis_client.get_daily_index_price(market_code, period="D")
if not prices or len(prices) < 20:
return 0
prices = np.array(prices)
# 1. 역사적 변동성 (20일)
# 로그 수익률 계산
returns = np.diff(np.log(prices))
# 연환산 변동성 (Trading days = 252)
volatility = np.std(returns[-20:]) * np.sqrt(252) * 100
# 2. 이동평균 이격도
ma20 = np.mean(prices[-20:])
current_price = prices[-1]
disparity = (current_price - ma20) / ma20 * 100
# 3. 스트레스 점수 산출
# 변동성이 20% 넘어가면 위험, 이격도가 -5% 이하면 위험
stress_score = 0
# 변동성 기여 (평소 10~15%, 30% 이상 공포)
# 10 이하면 0점, 40 이상이면 60점 만점
v_score = min(max((volatility - 10) * 2, 0), 60)
# 하락 추세 기여 (-10% 이격이면 +40점)
d_score = 0
if disparity < 0:
d_score = min(abs(disparity) * 4, 40)
total_stress = v_score + d_score
return round(total_stress, 2)
if __name__ == "__main__":
# 테스트를 위한 코드
load_dotenv()
# 환경변수 로딩 및 클라이언트 초기화
if os.getenv("KIS_ENV_TYPE") == "real":
app_key = os.getenv("KIS_REAL_APP_KEY")
app_secret = os.getenv("KIS_REAL_APP_SECRET")
account = os.getenv("KIS_REAL_ACCOUNT")
is_virtual = False
else:
app_key = os.getenv("KIS_VIRTUAL_APP_KEY")
app_secret = os.getenv("KIS_VIRTUAL_APP_SECRET")
account = os.getenv("KIS_VIRTUAL_ACCOUNT")
is_virtual = True
kis = KISClient(app_key, app_secret, account, is_virtual)
# 토큰 발급 (필요 시)
kis.ensure_token()
# 분석 실행
report = MacroAnalyzer.get_macro_status(kis)
print("\n📊 [Macro Report]")
print(f"Status: {report['status']}")
print(f"Data: {report['indicators']}")

View File

@@ -0,0 +1,190 @@
import pandas as pd
import numpy as np
class TechnicalAnalyzer:
"""
Pandas를 활용한 기술적 지표 계산 모듈
CPU 멀티코어 성능(9800X3D)을 십분 활용하기 위해 복잡한 연산은 여기서 처리
"""
@staticmethod
def calculate_rsi(prices, period=14):
"""RSI(Relative Strength Index) 계산"""
if len(prices) < period:
return 50.0 # 데이터 부족 시 중립
delta = pd.Series(prices).diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
return rsi.iloc[-1]
@staticmethod
def calculate_ma(prices, period=20):
"""이동평균선(Moving Average) 계산"""
if len(prices) < period:
return prices[-1] if prices else 0
return pd.Series(prices).rolling(window=period).mean().iloc[-1]
@staticmethod
def calculate_macd(prices, fast=12, slow=26, signal=9):
"""MACD (Moving Average Convergence Divergence) 계산"""
if len(prices) < slow + signal:
return 0, 0, 0 # 데이터 부족
s = pd.Series(prices)
ema_fast = s.ewm(span=fast, adjust=False).mean()
ema_slow = s.ewm(span=slow, adjust=False).mean()
macd = ema_fast - ema_slow
signal_line = macd.ewm(span=signal, adjust=False).mean()
histogram = macd - signal_line
return macd.iloc[-1], signal_line.iloc[-1], histogram.iloc[-1]
@staticmethod
def calculate_bollinger_bands(prices, period=20, num_std=2):
"""Bollinger Bands 계산 (상단, 중단, 하단)"""
if len(prices) < period:
return 0, 0, 0
s = pd.Series(prices)
sma = s.rolling(window=period).mean()
std = s.rolling(window=period).std()
upper = sma + (std * num_std)
lower = sma - (std * num_std)
return upper.iloc[-1], sma.iloc[-1], lower.iloc[-1]
@staticmethod
def calculate_stochastic(prices, high_prices=None, low_prices=None, n=14, k=3, d=3):
"""Stochastic Oscillator (Fast/Slow)
고가/저가 데이터가 없으면 종가(prices)로 추정 계산
"""
if len(prices) < n:
return 50, 50
close = pd.Series(prices)
# 고가/저가 데이터가 별도로 없으면 종가로 대체 (정확도는 떨어짐)
high = pd.Series(high_prices) if high_prices else close
low = pd.Series(low_prices) if low_prices else close
# 최근 n일간 최고가/최저가
highest_high = high.rolling(window=n).max()
lowest_low = low.rolling(window=n).min()
# Fast %K
fast_k = ((close - lowest_low) / (highest_high - lowest_low + 1e-9)) * 100
# Slow %K (= Fast %D)
slow_k = fast_k.rolling(window=k).mean()
# Slow %D
slow_d = slow_k.rolling(window=d).mean()
return slow_k.iloc[-1], slow_d.iloc[-1]
@staticmethod
def get_technical_score(current_price, prices_history, volume_history=None):
"""
기술적 지표 통합 점수(0.0 ~ 1.0) 계산 (고도화됨)
- RSI, 이격도, MACD, Bollinger Bands, Stochastic 종합
- [New] Volume Analysis (Whale Activity)
"""
if not prices_history or len(prices_history) < 30:
return 0.5, 50.0 # 데이터 부족 시 중립
scores = []
# 1. RSI (비중 30%)
# 30 이하(과매도) -> 1.0, 70 이상(과매수) -> 0.0
rsi = TechnicalAnalyzer.calculate_rsi(prices_history)
if rsi <= 30: rsi_score = 1.0
elif rsi >= 70: rsi_score = 0.0
else: rsi_score = 1.0 - ((rsi - 30) / 40.0) # 선형 보간
scores.append(rsi_score * 0.3)
# 2. 이격도 (비중 20%)
ma20 = TechnicalAnalyzer.calculate_ma(prices_history, 20)
disparity = (current_price - ma20) / ma20
# 이격도가 마이너스일수록(저평가) 점수 높음
if disparity < -0.05: disp_score = 1.0 # -5% 이상 하락
elif disparity > 0.05: disp_score = 0.0 # +5% 이상 상승
else: disp_score = 0.5 - (disparity * 10) # -0.05~0.05 사이
scores.append(disp_score * 0.2)
# 3. MACD (비중 20%)
# MACD가 Signal선 위에 있으면 상승세 (매수)
macd, signal, hist = TechnicalAnalyzer.calculate_macd(prices_history)
if hist > 0 and macd > 0: macd_score = 0.8 # 상승 추세 가속
elif hist > 0 and macd <= 0: macd_score = 0.6 # 상승 반전 초기
elif hist < 0 and macd > 0: macd_score = 0.4 # 하락 반전 초기
else: macd_score = 0.2 # 하락 추세
scores.append(macd_score * 0.2)
# 4. Bollinger Bands (비중 15%)
# 하단 밴드 근처 -> 매수(1.0), 상단 밴드 근처 -> 매도(0.0)
up, mid, low = TechnicalAnalyzer.calculate_bollinger_bands(prices_history)
if current_price <= low: bb_score = 1.0
bb_score_base = 0.0
if current_price <= low: bb_score_base = 1.0
elif current_price >= up: bb_score_base = 0.0
else:
# 밴드 내 위치 비율 (Position %B) 유사 계산
# 하단(0) ~ 상단(1) -> 점수는 1 ~ 0 역순
pos = (current_price - low) / (up - low + 1e-9)
bb_score_base = 1.0 - pos
# 추가 점수 로직 (기존 tech_score += 0.2를 bb_score에 반영)
if current_price < low: # 과매도 (저점 매수 기회)
bb_score = min(1.0, bb_score_base + 0.2) # 최대 1.0
else:
bb_score = bb_score_base
scores.append(bb_score * 0.15)
# 5. Stochastic (비중 15%)
# K가 20 미만 -> 과매도(매수), 80 이상 -> 과매수(매도)
slow_k, slow_d = TechnicalAnalyzer.calculate_stochastic(prices_history)
st_score_base = 0.0
if slow_k < 20: st_score_base = 1.0
elif slow_k > 80: st_score_base = 0.0
else: st_score_base = 1.0 - (slow_k / 100.0)
# 추가 점수 로직 (기존 tech_score += 0.2 / -= 0.1를 st_score에 반영)
if slow_k < 20: # 과매도
st_score = min(1.0, st_score_base + 0.2)
elif slow_k > 80: # 과매수
st_score = max(0.0, st_score_base - 0.1)
else:
st_score = st_score_base
scores.append(st_score * 0.15)
total_score = sum(scores)
# [신규] 거래량 폭증 분석 (Whale Tracking)
volume_ratio = 1.0
if volume_history and len(volume_history) >= 5:
vol_s = pd.Series(volume_history)
avg_vol = vol_s.rolling(window=5).mean().iloc[-2] # 어제까지의 5일 평균
current_vol = volume_history[-1]
if avg_vol > 0:
volume_ratio = current_vol / avg_vol
# 평소 거래량의 3배(300%) 이상 터지면 세력 유입 가능성 높음 -> 가산점
if volume_ratio >= 3.0:
total_score += 0.1 # 강력한 매수 신호
# 0.0 ~ 1.0 클리핑
total_score = max(0.0, min(1.0, total_score))
# [신규] 변동성(Volatility) 계산
# 최근 20일간 일일 변동폭의 표준편차를 평균 가격으로 나눔
if len(prices_history) > 1:
# list 입력 대응
prices_np = np.array(prices_history)
changes = np.diff(prices_np) / prices_np[:-1]
volatility = np.std(changes) * 100 # 퍼센트 단위
else:
volatility = 0.0
return round(total_score, 4), round(rsi, 2), round(volatility, 2), round(volume_ratio, 1)

342
modules/bot.py Normal file
View File

@@ -0,0 +1,342 @@
import time
import os
import sys
import json
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime
# 모듈 임포트
from modules.config import Config
from modules.services.kis import KISClient
from modules.services.news import NewsCollector
from modules.services.ollama import OllamaManager
from modules.services.telegram import TelegramMessenger
from modules.analysis.macro import MacroAnalyzer
from modules.utils.monitor import SystemMonitor
from modules.strategy.process import analyze_stock_process
# 기존 코드와의 호환성을 위해 상대 경로나 절대 경로로 임포트
# (리팩토링 과도기에는 일부 파일은 그대로 있을 수 있음)
try:
from theme_manager import ThemeManager
except ImportError:
# 템플릿용 더미
class ThemeManager:
def get_themes(self, code): return []
class AutoTradingBot:
def __init__(self):
# 1. 서비스 초기화
self.kis = KISClient()
self.news = NewsCollector()
self.executor = ProcessPoolExecutor(max_workers=4)
self.messenger = TelegramMessenger()
self.theme_manager = ThemeManager()
self.ollama_monitor = OllamaManager() # GPU 모니터링용
# 2. 유틸리티 초기화
self.monitor = SystemMonitor(self.messenger, self.ollama_monitor)
# 3. 상태 변수
self.daily_trade_history = []
self.discovered_stocks = set()
self.is_macro_warning_sent = False
self.watchlist_updated_today = False
self.report_sent = False
# [IPC] BotIPC
try:
from modules.utils.ipc import BotIPC
self.ipc = BotIPC()
except ImportError:
print("⚠️ BotIPC module not found.")
self.ipc = None
# [Watchlist Manager]
try:
from watchlist_manager import WatchlistManager
self.watchlist_manager = WatchlistManager(self.kis, watchlist_file=Config.WATCHLIST_FILE)
except ImportError:
self.watchlist_manager = None
# 기록 로드
self.history_file = Config.HISTORY_FILE
self.load_trade_history()
# AI 및 하드웨어 점검
from modules.analysis.deep_learning import PricePredictor
PricePredictor.verify_hardware()
# 텔레그램 명령 서버 시작 (Server에서 관리하도록 변경)
# self.start_telegram_command_server()
pass
def load_trade_history(self):
"""파일에서 금일 매매 기록 로드"""
if os.path.exists(self.history_file):
try:
with open(self.history_file, "r", encoding="utf-8") as f:
self.daily_trade_history = json.load(f)
except Exception as e:
print(f"⚠️ Failed to load history: {e}")
self.daily_trade_history = []
else:
self.daily_trade_history = []
def save_trade_history(self):
"""매매 기록 저장"""
try:
with open(self.history_file, "w", encoding="utf-8") as f:
json.dump(self.daily_trade_history, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"⚠️ Failed to save history: {e}")
def load_watchlist(self):
try:
with open(Config.WATCHLIST_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except:
return {}
def send_daily_report(self):
"""장 마감 리포트"""
if self.report_sent: return
print("📝 [Bot] Generating Daily Report...")
balance = self.kis.get_balance()
total_eval = 0
if "total_eval" in balance:
total_eval = int(balance.get("total_eval", 0))
report = f"📅 **[Daily Closing Report]**\n" \
f"────────────────\n" \
f"💰 **총 자산**: `{total_eval:,}원`\n" \
f"📜 **오늘의 매매**: `{len(self.daily_trade_history)}건`\n\n"
if self.daily_trade_history:
for trade in self.daily_trade_history:
icon = "🔴" if trade['action'] == "BUY" else "🔵"
report += f"{icon} {trade['name']} {trade['qty']}\n"
if "holdings" in balance and balance["holdings"]:
report += "\n📊 **보유 현황**\n"
for stock in balance["holdings"]:
yld = stock.get('yield', 0)
icon = "🔺" if yld > 0 else "🔻"
report += f"{icon} {stock['name']}: `{yld}%`\n"
self.messenger.send_message(report)
self.report_sent = True
def run_cycle(self):
now = datetime.now()
# 1. 거시경제 분석 (우선 순위 상향)
macro_status = MacroAnalyzer.get_macro_status(self.kis)
is_crash = False
if macro_status['status'] == 'DANGER':
is_crash = True
if not self.is_macro_warning_sent:
self.messenger.send_message("🚨 **[MARKET CRASH ALERT]** 시장 급락 감지! 매수 중단.")
self.is_macro_warning_sent = True
else:
if self.is_macro_warning_sent:
self.messenger.send_message("🌤️ **[MARKET RECOVERY]** 시장 안정화.")
self.is_macro_warning_sent = False
# 2. IPC 상태 업데이트
if self.ipc:
try:
balance = self.kis.get_balance()
gpu_status = self.ollama_monitor.get_gpu_status()
watchlist = self.load_watchlist()
self.ipc.write_status({
'balance': balance,
'gpu': gpu_status,
'watchlist': watchlist,
'discovered_stocks': list(self.discovered_stocks),
'is_macro_warning': self.is_macro_warning_sent,
'macro_indices': macro_status['indicators'], # [수정] 거시경제 지표 전달
'themes': {}
})
except Exception:
pass
# 2. 아침 업데이트 (08:00)
if now.hour == 8 and 0 <= now.minute < 5:
if not self.watchlist_updated_today and self.watchlist_manager:
print("🌅 Morning Update...")
try:
summary = self.watchlist_manager.update_watchlist_daily()
self.messenger.send_message(summary)
self.watchlist_updated_today = True
except Exception as e:
self.messenger.send_message(f"Update Failed: {e}")
# 3. 리셋 (09:00)
if now.hour == 9 and now.minute < 5:
self.daily_trade_history = []
self.save_trade_history()
self.report_sent = False
self.discovered_stocks.clear()
self.watchlist_updated_today = False
# 4. 시스템 감시
self.monitor.check_health()
# 5. 장 운영 시간 체크
if not (9 <= now.hour < 15 or (now.hour == 15 and now.minute < 30)):
# 장 마감 리포트 (15:40)
if now.hour == 15 and now.minute >= 40:
self.send_daily_report()
print("💤 Market Closed. Waiting...")
return
print(f"⏰ [Bot] Cycle Start: {now.strftime('%H:%M:%S')}")
# 6. 거시경제 분석 (완료됨)
# macro_status = ... (Moved to top)
# 7. 종목 분석 및 매매
target_dict = self.load_watchlist()
# (Discovery 로직 생략 - 필요시 추가)
# 보유 종목 리스크 관리
balance = self.kis.get_balance()
current_holdings = {}
if balance and "holdings" in balance:
for stock in balance["holdings"]:
code = stock.get("code")
name = stock.get("name")
qty = int(stock.get("qty", 0))
yld = float(stock.get("yield", 0.0))
current_holdings[code] = stock
# 보유 수량이 0 이하라면 스킵 (오류 방지)
if qty <= 0:
continue
action = None
reason = ""
# 손절/익절 로직
if yld <= -5.0:
action = "SELL"
reason = "Stop Loss 📉"
elif yld >= 8.0:
action = "SELL"
reason = "Take Profit 🚀"
if action == "SELL":
print(f"🚨 Risk Management: {reason} - {name} (Qty: {qty}, Yield: {yld}%)")
# 전량 매도
res = self.kis.sell_stock(code, qty)
if res and res.get("status"):
self.messenger.send_message(f"🛡️ **[Risk SELL]** {name}\n"
f" • 사유: {reason}\n"
f" • 수량: {qty}\n"
f" • 수익률: {yld}%")
self.daily_trade_history.append({
"action": "SELL",
"name": name,
"qty": qty,
"price": stock.get('current_price'),
"yield": yld
})
self.save_trade_history()
else:
print(f"❌ Sell Failed for {name}: {res}")
# 분석 실행 (병렬 처리)
analysis_tasks = []
news_data = self.news.get_market_news()
# [수정] 실시간 잔고 추적용 변수 (매수 시 차감)
tracking_deposit = int(balance.get("deposit", 0))
for ticker, name in target_dict.items():
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)
# 결과 처리
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":
if is_crash: continue
# 매수 로직 (예수금 체크 추가)
current_qty = 0
if res['ticker'] in current_holdings:
current_qty = current_holdings[res['ticker']]['qty']
current_price = float(res['current_price'])
if current_price <= 0: continue
# 매수 수량 결정 (기본 1주, 추후 금액 기반으로 변경 가능)
qty = 1
required_amount = current_price * qty
# 예수금 확인
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",
"name": ticker_name,
"qty": qty,
"price": current_price
})
self.save_trade_history()
# [중요] 가상 잔고 차감 (연속 매수 시 초과 방지)
tracking_deposit -= required_amount
elif res['decision'] == "SELL":
print(f"📉 Selling {ticker_name} (Simulation)")
# 매도 로직 (필요 시 추가)
except Exception as e:
print(f"❌ Analysis Error: {e}")
def loop(self):
print(f"🤖 Bot Module Started (PID: {os.getpid()})")
self.messenger.send_message("🤖 **[Bot Started]** 리팩토링된 봇이 시작되었습니다.")
while True:
try:
self.run_cycle()
except Exception as e:
print(f"⚠️ Loop Error: {e}")
self.messenger.send_message(f"⚠️ Loop Error: {e}")
time.sleep(60)
def start_telegram_command_server(self):
"""텔레그램 봇 프로세스 실행 (독립 프로세스)"""
script = os.path.join(os.getcwd(), "modules", "services", "telegram_bot", "runner.py")
if os.path.exists(script):
import subprocess
subprocess.Popen([sys.executable, script], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
print("🚀 Telegram Command Server Started")
else:
print(f"⚠️ Telegram Bot Runner not found: {script}")

62
modules/config.py Normal file
View File

@@ -0,0 +1,62 @@
import os
import sys
from dotenv import load_dotenv
# .env 파일 로드
load_dotenv()
class Config:
# 1. 기본 설정
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 2. NAS 및 AI 서버
NAS_API_URL = os.getenv("NAS_API_URL", "http://192.168.45.54:18500")
OLLAMA_API_URL = os.getenv("OLLAMA_API_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1")
# 3. KIS 한국투자증권
KIS_ENV_TYPE = os.getenv("KIS_ENV_TYPE", "virtual").lower()
if KIS_ENV_TYPE == "real":
KIS_APP_KEY = os.getenv("KIS_REAL_APP_KEY")
KIS_APP_SECRET = os.getenv("KIS_REAL_APP_SECRET")
KIS_ACCOUNT = os.getenv("KIS_REAL_ACCOUNT")
KIS_IS_VIRTUAL = False
KIS_BASE_URL = "https://openapi.koreainvestment.com:9443"
else:
KIS_APP_KEY = os.getenv("KIS_VIRTUAL_APP_KEY")
KIS_APP_SECRET = os.getenv("KIS_VIRTUAL_APP_SECRET")
KIS_ACCOUNT = os.getenv("KIS_VIRTUAL_ACCOUNT")
KIS_IS_VIRTUAL = True
KIS_BASE_URL = "https://openapivts.koreainvestment.com:29443"
# 4. 텔레그램
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
# 5. 매매 설정 (상수)
MAX_INVESTMENT_PER_STOCK = 3000000 # 종목당 최대 300만원
# 6. 데이터 경로
DATA_DIR = os.path.join(BASE_DIR, "data")
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR, exist_ok=True)
IPC_FILE = os.path.join(DATA_DIR, "bot_ipc.json")
HISTORY_FILE = os.path.join(DATA_DIR, "daily_trade_history.json")
WATCHLIST_FILE = os.path.join(DATA_DIR, "watchlist.json")
# 7. 타임아웃 등
HTTP_TIMEOUT = 10
@staticmethod
def validate():
"""필수 설정 검증"""
missing = []
if not Config.KIS_APP_KEY: missing.append("KIS_APP_KEY")
if not Config.KIS_APP_SECRET: missing.append("KIS_APP_SECRET")
if missing:
print(f"⚠️ [Config] Missing Env Params: {', '.join(missing)}")
return False
return True

367
modules/services/kis.py Normal file
View File

@@ -0,0 +1,367 @@
import requests
import json
import time
import os
from modules.config import Config
class KISClient:
"""
한국투자증권 (Korea Investment & Securities) REST API Client
"""
def __init__(self, is_virtual=None):
# Config에서 설정 로드
self.app_key = Config.KIS_APP_KEY
self.app_secret = Config.KIS_APP_SECRET
self.cano = Config.KIS_ACCOUNT[:8]
self.acnt_prdt_cd = Config.KIS_ACCOUNT[-2:] # "01" 등
# 가상/실전 모드 설정
if is_virtual is None:
self.is_virtual = Config.KIS_IS_VIRTUAL
else:
self.is_virtual = is_virtual
self.base_url = Config.KIS_BASE_URL
self.access_token = None
self.token_expired = None
self.last_req_time = 0
def _throttle(self):
"""API 요청 속도 제한 (초당 5회 이하로 제한)"""
# 모의투자는 Rate Limit이 더 엄격할 수 있음
min_interval = 0.2 # 0.2초 대기
now = time.time()
elapsed = now - self.last_req_time
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
self.last_req_time = time.time()
def _get_headers(self, tr_id=None):
"""공통 헤더 생성"""
headers = {
"Content-Type": "application/json; charset=utf-8",
"authorization": f"Bearer {self.access_token}",
"appkey": self.app_key,
"appsecret": self.app_secret,
}
if tr_id:
headers["tr_id"] = tr_id
return headers
def ensure_token(self):
"""접근 토큰 발급 (OAuth 2.0)"""
# 토큰 유효성 체크 로직은 생략 (실제 운영 시 만료 시간 체크 필요)
if self.access_token:
return
url = f"{self.base_url}/oauth2/tokenP"
payload = {
"grant_type": "client_credentials",
"appkey": self.app_key,
"appsecret": self.app_secret
}
try:
print("🔑 [KIS] 토큰 발급 요청...")
res = requests.post(url, json=payload)
res.raise_for_status()
data = res.json()
self.access_token = data.get('access_token')
print("✅ [KIS] 토큰 발급 성공")
except Exception as e:
print(f"❌ [KIS] 토큰 발급 실패: {e}")
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
print(f"📄 [KIS Token Error Body]: {e.response.text}")
def get_hash_key(self, datas):
"""주문 시 필요한 Hash Key 생성 (Koreainvestment header 특화)"""
url = f"{self.base_url}/uapi/hashkey"
headers = {
"content-type": "application/json; charset=utf-8",
"appkey": self.app_key,
"appsecret": self.app_secret
}
try:
res = requests.post(url, headers=headers, json=datas)
return res.json()["HASH"]
except Exception as e:
print(f"❌ Hash Key 생성 실패: {e}")
return None
def get_balance(self):
"""주식 잔고 조회"""
self._throttle()
self.ensure_token()
# 국내주식 잔고조회 TR ID: VTTC8434R (모의), TTTC8434R (실전)
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R"
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance"
headers = self._get_headers(tr_id=tr_id)
# 쿼리 파라미터
params = {
"CANO": self.cano,
"ACNT_PRDT_CD": self.acnt_prdt_cd,
"AFHR_FLPR_YN": "N",
"OFL_YN": "",
"INQR_DVSN": "02",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "00",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": ""
}
try:
res = requests.get(url, headers=headers, params=params)
res.raise_for_status()
data = res.json()
# 응답 정리
if data['rt_cd'] != '0':
return {"error": data['msg1']}
holdings = []
for item in data['output1']:
if int(item['hldg_qty']) > 0:
holdings.append({
"code": item['pdno'],
"name": item['prdt_name'],
"qty": int(item['hldg_qty']),
"yield": float(item['evlu_pfls_rt']),
"purchase_price": float(item['pchs_avg_pric']), # 매입평균가
"current_price": float(item['prpr']), # 현재가
"profit_loss": int(item['evlu_pfls_amt']) # 평가손익
})
summary = data['output2'][0]
return {
"holdings": holdings,
"total_eval": int(summary['tot_evlu_amt']),
"deposit": int(summary['dnca_tot_amt'])
}
except Exception as e:
print(f"❌ [KIS] 잔고 조회 실패: {e}")
# If it's a requests error, verify if there's a response body
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
print(f"📄 [KIS Error Body]: {e.response.text}")
return {"error": str(e)}
def order(self, ticker, qty, buy_sell, price=0):
"""주문 (시장가)
buy_sell: 'BUY' or 'SELL'
"""
self._throttle()
self.ensure_token()
# 모의투자/실전 TR ID 구분
# 매수: VTTC0802U / TTTC0802U
# 매도: VTTC0801U / TTTC0801U
if buy_sell == 'BUY':
tr_id = "VTTC0802U" if self.is_virtual else "TTTC0802U"
else:
tr_id = "VTTC0801U" if self.is_virtual else "TTTC0801U"
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
# 주문 파라미터
datas = {
"CANO": self.cano,
"ACNT_PRDT_CD": self.acnt_prdt_cd,
"PDNO": ticker,
"ORD_DVSN": "01", # 01: 시장가
"ORD_QTY": str(qty),
"ORD_UNPR": "0" # 시장가는 0
}
# 헤더 준비
headers = self._get_headers(tr_id=tr_id)
# [중요] POST 요청(주문 등) 시 Hash Key 필수
# 단, 모의투자의 경우 일부 상황에서 생략 가능할 수 있으나, 정석대로 포함
hash_key = self.get_hash_key(datas)
if hash_key:
headers["hashkey"] = hash_key
else:
print("⚠️ [KIS] Hash Key 생성 실패 (주문 전송 시도)")
try:
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea (시장가)")
res = requests.post(url, headers=headers, json=datas)
res.raise_for_status()
data = res.json()
print(f"📥 [KIS] 주문 응답 코드(rt_cd): {data['rt_cd']}")
print(f"📥 [KIS] 주문 응답 메시지(msg1): {data['msg1']}")
if data['rt_cd'] != '0':
return {"status": False, "msg": data['msg1'], "rt_cd": data['rt_cd']}
return {"status": True, "msg": "주문 전송 완료", "order_no": data['output']['ODNO'], "rt_cd": data['rt_cd']}
except Exception as e:
return {"status": False, "msg": str(e), "rt_cd": "EXCEPTION"}
def get_current_price(self, ticker):
"""현재가 조회"""
self._throttle()
self.ensure_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
headers = self._get_headers(tr_id="FHKST01010100")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker
}
try:
res = requests.get(url, headers=headers, params=params)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
return None
return int(data['output']['stck_prpr']) # 현재가
except Exception as e:
print(f"❌ 현재가 조회 실패: {e}")
return None
def get_daily_price(self, ticker, period="D"):
"""일별 시세 조회 (기술적 분석용)"""
self._throttle()
self.ensure_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
headers = self._get_headers(tr_id="FHKST01010400")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker,
"FID_PERIOD_DIV_CODE": period,
"FID_ORG_ADJ_PRC": "1" # 수정주가
}
try:
res = requests.get(url, headers=headers, params=params)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
return []
# 과거 데이터부터 오도록 정렬 필요할 수 있음 (API는 최신순)
# output 리스트: [ {stck_clpr: 종가, ...}, ... ]
prices = [int(item['stck_clpr']) for item in data['output']]
prices.reverse() # 과거 -> 현재 순으로 정렬
return prices
except Exception as e:
print(f"❌ 일별 시세 조회 실패: {e}")
return []
def get_volume_rank(self, limit=5):
"""거래량 상위 종목 조회"""
self._throttle()
self.ensure_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
headers = self._get_headers(tr_id="FHPST01710000")
params = {
"FID_COND_MRKT_DIV_CODE": "J", # 주식, ETF, ETN 전체
"FID_COND_SCR_RSLT_GD_CD": "20171", # 전체
"FID_INPUT_ISCD": "0000", # 전체
"FID_DIV_CLS_CODE": "0", # 0: 전체
"FID_BLNG_CLS_CODE": "0", # 0: 전체
"FID_TRGT_CLS_CODE": "111111111", # 필터링 조건 (이대로 두면 됨)
"FID_TRGT_EXCLS_CLS_CODE": "0000000000", # 제외 조건
"FID_INPUT_PRICE_1": "",
"FID_INPUT_PRICE_2": "",
"FID_VOL_CNT": "",
"FID_INPUT_DATE_1": ""
}
try:
res = requests.get(url, headers=headers, params=params)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
return []
results = []
for item in data['output'][:limit]:
# 코드는 shtn_iscd, 이름은 hts_kor_isnm
results.append({
"code": item['mksc_shrn_iscd'],
"name": item['hts_kor_isnm'],
"volume": int(item['acml_vol']),
"price": int(item['stck_prpr'])
})
return results
except Exception as e:
print(f"❌ 거래량 순위 조회 실패: {e}")
return []
def buy_stock(self, ticker, qty):
return self.order(ticker, qty, 'BUY')
def get_current_index(self, ticker):
"""지수 현재가 조회 (업종/지수)
ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc.
"""
self._throttle()
self.ensure_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-index-price"
headers = self._get_headers(tr_id="FHKUP03500100")
params = {
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
"FID_INPUT_ISCD": ticker
}
try:
res = requests.get(url, headers=headers, params=params)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
return None
return {
"price": float(data['output']['bstp_nmix_prpr']), # 현재지수
"change": float(data['output']['bstp_nmix_prdy_ctrt']) # 등락률(%)
}
except Exception as e:
print(f"❌ 지수 조회 실패({ticker}): {e}")
return None
def sell_stock(self, ticker, qty):
return self.order(ticker, qty, 'SELL')
def get_daily_index_price(self, ticker, period="D"):
"""지수 일별 시세 조회 (Market Stress Index용)"""
self._throttle()
self.ensure_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
headers = self._get_headers(tr_id="FHKUP03500200")
params = {
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
"FID_INPUT_ISCD": ticker,
"FID_PERIOD_DIV_CODE": period,
"FID_ORG_ADJ_PRC": "1" # 수정주가
}
try:
res = requests.get(url, headers=headers, params=params)
res.raise_for_status()
data = res.json()
if data['rt_cd'] != '0':
return []
# output 리스트: [ {bstp_nmix_prpr: 지수, ...}, ... ]
prices = [float(item['bstp_nmix_prpr']) for item in data['output']]
prices.reverse() # 과거 -> 현재
return prices
except Exception as e:
print(f"❌ 지수 일별 시세 조회 실패({ticker}): {e}")
return []

22
modules/services/news.py Normal file
View File

@@ -0,0 +1,22 @@
import requests
import xml.etree.ElementTree as ET
class NewsCollector:
"""
NAS에서 뉴스를 받지 못할 경우, Windows 서버에서 직접 뉴스를 수집하는 모듈
(Google News RSS 활용)
"""
@staticmethod
def get_market_news(query="주식 시장"):
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
try:
resp = requests.get(url, timeout=5)
root = ET.fromstring(resp.content)
items = []
for item in root.findall(".//item")[:5]:
title = item.find("title").text
items.append({"title": title, "source": "Google News"})
return items
except Exception as e:
print(f"❌ 뉴스 수집 실패: {e}")
return []

114
modules/services/ollama.py Normal file
View File

@@ -0,0 +1,114 @@
import requests
import json
import psutil
try:
import pynvml
except ImportError:
pynvml = None
from modules.config import Config
class OllamaManager:
"""
Ollama API 세션 관리 및 메모리 누수 방지 래퍼
- GPU VRAM 사용량 모니터링
- keep_alive 파라미터를 통한 메모리 관리
"""
def __init__(self, model_name=None, base_url=None):
self.model_name = model_name or Config.OLLAMA_MODEL
self.base_url = base_url or Config.OLLAMA_API_URL
self.generate_url = f"{self.base_url}/api/generate"
self.gpu_available = False
try:
if pynvml:
pynvml.nvmlInit()
self.handle = pynvml.nvmlDeviceGetHandleByIndex(0) # 0번 GPU (3070 Ti)
self.gpu_available = True
print("✅ [OllamaManager] NVIDIA GPU Monitoring On")
else:
print("⚠️ [OllamaManager] 'nvidia-ml-py' not installed. GPU monitoring disabled.")
except Exception as e:
print(f"⚠️ [OllamaManager] GPU Init Failed: {e}")
def check_vram(self):
"""현재 GPU VRAM 사용량(GB) 반환"""
if not self.gpu_available:
return 0.0
try:
info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
used_gb = info.used / 1024**3
return used_gb
except Exception:
return 0.0
def get_gpu_status(self):
"""GPU 종합 상태 반환 (온도, 메모리, 사용률, 이름)"""
if not self.gpu_available:
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
try:
# GPU 이름
name = pynvml.nvmlDeviceGetName(self.handle)
if isinstance(name, bytes):
name = name.decode('utf-8')
# 온도
temp = pynvml.nvmlDeviceGetTemperature(self.handle, pynvml.NVML_TEMPERATURE_GPU)
# 메모리
mem_info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
vram_used = mem_info.used / 1024**3
vram_total = mem_info.total / 1024**3
# 사용률
util = pynvml.nvmlDeviceGetUtilizationRates(self.handle)
load = util.gpu
return {
"name": name,
"temp": temp,
"vram_used": round(vram_used, 1),
"vram_total": round(vram_total, 1),
"load": load
}
except Exception as e:
print(f"⚠️ GPU Status Check Failed: {e}")
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
def request_inference(self, prompt, context_data=None):
"""
Ollama에 추론 요청
:param prompt: 시스템 프롬프트 + 사용자 입력
:param context_data: (Optional) 이전 대화 컨텍스트
"""
# [5070Ti 최적화] VRAM이 14GB 이상이면 모델 언로드 시도 (16GB 중 여유분 확보)
vram = self.check_vram()
if vram > 14.0:
print(f"⚠️ [OllamaManager] High VRAM Usage ({vram:.1f}GB). Requesting unload.")
try:
# keep_alive=0으로 설정하여 모델 즉시 언로드
requests.post(self.generate_url,
json={"model": self.model_name, "keep_alive": 0}, timeout=5)
except Exception as e:
print(f"Warning: Failed to unload model: {e}")
payload = {
"model": self.model_name,
"prompt": prompt,
"stream": False,
"format": "json", # JSON 강제
"options": {
"num_ctx": 8192, # [5070Ti 최적화] 컨텍스트 크기 2배 증가 (4096 -> 8192)
"temperature": 0.2, # 분석 일관성 유지
"num_gpu": 1, # GPU 사용 명시
"num_thread": 8 # CPU 스레드 수 (9800X3D 활용)
},
"keep_alive": "10m" # [5070Ti 최적화] 10분간 유지 (메모리 여유 있음)
}
try:
response = requests.post(self.generate_url, json=payload, timeout=180) # 타임아웃 증가
response.raise_for_status()
return response.json().get('response')
except Exception as e:
print(f"❌ Inference Error: {e}")
return None

View File

@@ -0,0 +1,34 @@
import requests
import os
import threading
from modules.config import Config
class TelegramMessenger:
def __init__(self, token=None, chat_id=None):
# 환경 변수에서 로드하거나 인자로 받음
self.token = token or Config.TELEGRAM_BOT_TOKEN
self.chat_id = chat_id or Config.TELEGRAM_CHAT_ID
if not self.token or not self.chat_id:
print("⚠️ [Telegram] Token or Chat ID not found.")
def send_message(self, message):
"""별도 스레드로 메시지를 전송하여 메인 루프 블로킹 방지"""
if not self.token or not self.chat_id:
return
def _send():
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
payload = {
"chat_id": self.chat_id,
"text": message,
"parse_mode": "Markdown"
}
try:
requests.post(url, json=payload, timeout=5)
except Exception as e:
print(f"⚠️ [Telegram] Error: {e}")
# 스레드 실행 (Fire-and-forget)
threading.Thread(target=_send, daemon=True).start()

View File

@@ -0,0 +1,73 @@
"""
멀티프로세스 방식 - 텔레그램 봇 프로세스
트레이딩 봇과 완전히 분리된 독립 프로세스로 실행
"""
import os
import sys
import multiprocessing
from dotenv import load_dotenv
# 환경 변수 로드
load_dotenv()
def run_telegram_bot_standalone():
"""텔레그램 봇만 독립적으로 실행"""
# 경로 문제 해결을 위해 상위 디렉토리 추가
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../')))
from modules.services.telegram_bot.server import TelegramBotServer
from modules.utils.ipc import BotIPC
from modules.config import Config
token = os.getenv("TELEGRAM_BOT_TOKEN")
if not token:
print("❌ [Telegram] TELEGRAM_BOT_TOKEN not found in .env")
sys.exit(1)
print(f"🤖 [Telegram Bot Process] Starting... (PID: {os.getpid()})")
print(f"🔗 [Telegram Bot] Standalone Process Mode (IPC Enabled)")
# IPC 초기화
ipc = BotIPC()
# [최적화] 재시작 루프 구현
while True:
try:
# 봇 서버 생성 (매번 새로 생성)
bot_server = TelegramBotServer(token)
# IPC를 통해 메인 봇 데이터 가져오기
try:
# 초기 연결 시도
instance_data = ipc.get_bot_instance_data()
if instance_data:
bot_server.set_bot_instance(instance_data)
except Exception:
pass # 연결 실패해도 일단 봇은 띄움
# 봇 실행 (블로킹)
bot_server.run()
# 재시작 요청 확인
if bot_server.should_restart:
print("🔄 [Telegram Bot] Restarting instance...")
import time
time.sleep(1) # 잠시 대기
continue
else:
print("🛑 [Telegram Bot] Process exiting.")
break
except KeyboardInterrupt:
print("\n🛑 [Telegram Bot] Stopped by user")
break
except Exception as e:
if "Conflict" not in str(e):
print(f"❌ [Telegram Bot] Error: {e}")
import traceback
traceback.print_exc()
break
if __name__ == "__main__":
multiprocessing.freeze_support()
run_telegram_bot_standalone()

View File

@@ -0,0 +1,394 @@
"""
텔레그램 봇 최적화 버전
- Polling 최적화 (CPU 사용률 감소)
- 별도 프로세스로 분리
- 봇 재시작 명령어
- 원격 명령어 실행
"""
import os
import asyncio
import logging
import subprocess
import sys
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes, TypeHandler
from dotenv import load_dotenv
# 로깅 설정
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO
)
logging.getLogger("httpx").setLevel(logging.WARNING)
class TelegramBotServer:
def __init__(self, bot_token):
# [최적화] 연결 풀 설정 추가
self.application = Application.builder()\
.token(bot_token)\
.concurrent_updates(True)\
.build()
self.bot_instance = None
self.is_shutting_down = False
self.should_restart = False
def set_bot_instance(self, bot):
"""AutoTradingBot 인스턴스를 주입받음"""
self.bot_instance = bot
def refresh_bot_instance(self):
"""IPC에서 최신 봇 인스턴스 데이터 읽기"""
# [수정] 모듈 경로 변경
from modules.utils.ipc import BotIPC
ipc = BotIPC()
self.bot_instance = ipc.get_bot_instance_data()
return self.bot_instance is not None
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/start 명령어 핸들러"""
await update.message.reply_text(
"🤖 **AI Trading Bot Command Center**\n"
"명령어 목록:\n"
"/status - 현재 봇 및 시장 상태 조회\n"
"/portfolio - 현재 보유 종목 및 평가액\n"
"/watchlist - 현재 감시 중인 종목 리스트\n"
"/update_watchlist - Watchlist 즉시 업데이트\n"
"/macro - 거시경제 지표 및 시장 위험도\n"
"/system - PC 리소스(CPU/GPU) 상태\n"
"/ai - AI 모델 학습 상태 조회\n\n"
"**[관리 명령어]**\n"
"/restart - 봇 재시작\n"
"/exec <command> - 원격 명령어 실행\n"
"/stop - 봇 종료",
parse_mode="Markdown"
)
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/status: 종합 상태 브리핑"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
return
from datetime import datetime
now = datetime.now()
is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30)
status_msg = "✅ **System Status: ONLINE**\n"
status_msg += f"🕒 **Market:** {'OPEN 🟢' if is_market_open else 'CLOSED 🔴'}\n"
macro_warn = self.bot_instance.is_macro_warning_sent
status_msg += f"🌍 **Macro Filter:** {'DANGER 🚨 (Trading Halted)' if macro_warn else 'SAFE 🟢'}\n"
await update.message.reply_text(status_msg, parse_mode="Markdown")
async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/portfolio: 잔고 조회"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 봇 인스턴스가 연결되지 않았습니다.")
return
await update.message.reply_text("⏳ 잔고를 조회 중입니다...")
try:
balance = self.bot_instance.kis.get_balance()
if "error" in balance:
await update.message.reply_text(f"❌ 잔고 조회 실패: {balance['error']}")
return
msg = f"💰 **Total Asset:** `{int(balance['total_eval']):,} KRW`\n" \
f"💵 **Deposit:** `{int(balance['deposit']):,} KRW`\n\n"
if balance['holdings']:
msg += "**[Holdings]**\n"
for stock in balance['holdings']:
icon = "🔴" if stock['yield'] > 0 else "🔵"
msg += f"{icon} **{stock['name']}** `{stock['yield']}%`\n" \
f" (수량: {stock['qty']} / 평가손익: {stock['profit_loss']:,})\n"
else:
msg += "보유 중인 종목이 없습니다."
await update.message.reply_text(msg, parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ Error: {str(e)}")
async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/watchlist: 감시 대상 종목"""
if not self.refresh_bot_instance():
return
target_dict = self.bot_instance.load_watchlist()
discovered = list(self.bot_instance.discovered_stocks)
msg = f"👀 **Watchlist: {len(target_dict)} items**\n"
for code, name in target_dict.items():
themes = self.bot_instance.theme_manager.get_themes(code)
theme_str = f" ({', '.join(themes)})" if themes else ""
msg += f"- {name}{theme_str}\n"
if discovered:
msg += f"\n✨ **Discovered Today ({len(discovered)}):**\n"
for code in discovered:
msg += f"- {code}\n"
await update.message.reply_text(msg)
async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/update_watchlist: Watchlist 즉시 업데이트"""
await update.message.reply_text("🔄 Watchlist를 업데이트하고 있습니다... (30초 소요)")
try:
# [수정] IPC 모드에서도 직접 수행하기 위해 새로운 인스턴스 생성
from modules.services.kis import KISClient
from watchlist_manager import WatchlistManager
# 독립적인 KIS 클라이언트 생성
from modules.config import Config
temp_kis = KISClient()
mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE)
# 업데이트 수행 (파일 쓰기)
summary = mgr.update_watchlist_daily()
await update.message.reply_text(summary, parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ 업데이트 실패: {e}")
async def macro_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/macro: 거시경제 지표 조회 (IPC 데이터 사용)"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 메인 봇 연결 대기 중...")
return
await update.message.reply_text("⏳ 거시경제 데이터를 불러옵니다...")
try:
# [수정] IPC 데이터를 직접 사용하여 출력 (FakeKIS의 _macro_indices 활용)
# FakeKIS는 bot_instance.kis에 할당되어 있음
indices = getattr(self.bot_instance.kis, '_macro_indices', {})
if not indices:
await update.message.reply_text("⚠️ 데이터가 아직 수집되지 않았습니다. 잠시 후 다시 시도하세요.")
return
# 리스크 점수 계산 (간이)
status = "SAFE"
msi = indices.get('MSI', 0)
if msi >= 50: status = "DANGER"
elif msi >= 30: status = "CAUTION"
color = "🟢" if status == "SAFE" else "🔴" if status == "DANGER" else "🟡"
msg = f"{color} **Market Risk: {status}**\n\n"
if 'MSI' in indices:
msg += f"🌡️ **Stress Index:** `{indices['MSI']}`\n"
for k, v in indices.items():
if k != "MSI":
icon = "🔺" if v.get('change', 0) > 0 else "🔻"
msg += f"{icon} **{k}**: {v.get('price', 0)} ({v.get('change', 0)}%)\n"
await update.message.reply_text(msg, parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ Error: {e}")
async def system_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/system: 시스템 리소스 상태"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
return
import psutil
cpu = psutil.cpu_percent(interval=1)
ram = psutil.virtual_memory().percent
# CPU 점유율 상위 3개 프로세스 수집
top_processes = []
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
try:
proc_info = proc.info
if proc_info['name'] == 'System Idle Process':
continue
top_processes.append(proc_info)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
top_processes.sort(key=lambda x: x.get('cpu_percent', 0), reverse=True)
top_3 = top_processes[:3]
gpu_status = self.bot_instance.ollama_monitor.get_gpu_status()
gpu_msg = f"N/A"
if gpu_status and gpu_status.get('name') != 'N/A':
gpu_name = gpu_status.get('name', 'GPU')
gpu_msg = f"{gpu_name}\n Temp: {gpu_status.get('temp', 0)}°C / VRAM: {gpu_status.get('vram_used', 0)}GB / {gpu_status.get('vram_total', 0)}GB"
msg = "🖥️ **PC System Status**\n" \
f"🧠 **CPU:** `{cpu}%`\n" \
f"💾 **RAM:** `{ram}%`\n" \
f"🎮 **GPU:** {gpu_msg}\n\n"
if top_3:
msg += "⚙️ **Top CPU Processes:**\n"
for i, proc in enumerate(top_3, 1):
proc_name = proc.get('name', 'Unknown')
proc_cpu = proc.get('cpu_percent', 0)
msg += f" {i}. `{proc_name}` - {proc_cpu:.1f}%\n"
await update.message.reply_text(msg, parse_mode="Markdown")
async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/ai: AI 모델 학습 상태 조회"""
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
msg = "🧠 **AI Model Status**\n"
msg += f"• **LLM Engine:** Ollama (Llama 3.1)\n"
gpu_name = gpu.get('name', 'NVIDIA RTX 5070 Ti')
msg += f"• **Device:** {gpu_name}\n"
if gpu:
msg += f"• **GPU Load:** `{gpu.get('load', 0)}%`\n"
msg += f"• **VRAM Usage:** `{gpu.get('vram_used', 0)}GB` / {gpu.get('vram_total', 0)}GB"
await update.message.reply_text(msg, parse_mode="Markdown")
async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/restart: 텔레그램 봇 모듈만 재시작"""
await update.message.reply_text("🔄 **텔레그램 인터페이스를 재시작합니다...**")
# 재시작 플래그 설정 (runner.py에서 감지하여 재시작)
self.should_restart = True
self.application.stop_running()
async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/stop: 봇 종료"""
await update.message.reply_text("🛑 **텔레그램 봇을 종료합니다.**")
# 종료 플래그 설정 (runner.py에서 루프 탈출)
self.should_restart = False
self.application.stop_running()
async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/exec: 원격 명령어 실행"""
if not context.args:
await update.message.reply_text("❌ 사용법: /exec <command>")
return
command = " ".join(context.args)
await update.message.reply_text(f"⚙️ 실행 중: `{command}`", parse_mode="Markdown")
try:
# 보안: 위험한 명령어 차단
dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot']
if any(keyword in command.lower() for keyword in dangerous_keywords):
await update.message.reply_text("⛔ 위험한 명령어는 실행할 수 없습니다.")
return
# 명령어 실행 (타임아웃 30초)
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30,
cwd=os.getcwd()
)
output = result.stdout if result.stdout else result.stderr
if not output:
output = "명령어 실행 완료 (출력 없음)"
# 출력이 너무 길면 잘라내기
if len(output) > 3000:
output = output[:3000] + "\n... (출력이 너무 깁니다)"
await update.message.reply_text(f"```\n{output}\n```", parse_mode="Markdown")
except subprocess.TimeoutExpired:
await update.message.reply_text("⏱️ 명령어 실행 시간 초과 (30초)")
except Exception as e:
await update.message.reply_text(f"❌ 실행 오류: {str(e)}")
def run(self):
"""봇 실행 (비동기 polling)"""
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""에러 핸들러"""
import traceback
# Conflict 에러는 무시 (다른 봇 인스턴스 실행 중)
if "Conflict" in str(context.error):
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다. 이 인스턴스를 종료합니다.")
if self.application.running:
await self.application.stop()
return
tb_list = traceback.format_exception(None, context.error, context.error.__traceback__)
tb_string = ''.join(tb_list)
print(f"❌ [Telegram Error] {tb_string}")
if isinstance(update, Update) and update.effective_message:
try:
await update.effective_message.reply_text(f"⚠️ 오류 발생: {context.error}")
except:
pass
async def ultimate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message or not update.message.text:
return
text = update.message.text.strip()
print(f"📨 [Telegram] Command Received: {text}")
try:
if text.startswith("/start"):
await self.start_command(update, context)
elif text.startswith("/status"):
await self.status_command(update, context)
elif text.startswith("/portfolio"):
await self.portfolio_command(update, context)
elif text.startswith("/watchlist"):
await self.watchlist_command(update, context)
elif text.startswith("/update_watchlist"):
await self.update_watchlist_command(update, context)
elif text.startswith("/macro"):
await self.macro_command(update, context)
elif text.startswith("/system"):
await self.system_command(update, context)
elif text.startswith("/ai"):
await self.ai_status_command(update, context)
elif text.startswith("/restart"):
await self.restart_command(update, context)
elif text.startswith("/stop"):
await self.stop_command(update, context)
elif text.startswith("/exec"):
await self.exec_command(update, context)
except Exception as e:
print(f"❌ Handle Error: {e}")
await update.message.reply_text(f"⚠️ Error: {e}")
# 에러 핸들러 등록
self.application.add_error_handler(error_handler)
self.application.add_handler(TypeHandler(Update, ultimate_handler))
# [최적화] Polling 설정 개선
print("🤖 [Telegram] Command Server Started (Optimized Polling Mode).")
try:
self.application.run_polling(
allowed_updates=Update.ALL_TYPES,
stop_signals=None,
poll_interval=1.0, # 1초마다 폴링 (기본값 0.0)
timeout=10, # 타임아웃 10초
drop_pending_updates=True # 대기 중인 업데이트 무시
)
except Exception as e:
if "Conflict" in str(e):
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다.")
print(f"⚠️ [Telegram] 기존 봇을 종료하고 다시 시도하세요.")
else:
print(f"❌ [Telegram] 봇 실행 오류: {e}")
import traceback
traceback.print_exc()

145
modules/strategy/process.py Normal file
View File

@@ -0,0 +1,145 @@
import os
import json
import numpy as np
from modules.services.ollama import OllamaManager
from modules.analysis.technical import TechnicalAnalyzer
from modules.analysis.deep_learning import PricePredictor
# [최적화] 워커 프로세스별 전역 변수 (LSTM 모델 캐싱)
_lstm_predictor = None
def get_predictor():
"""워커 프로세스 내에서 PricePredictor 인스턴스를 싱글톤으로 관리"""
global _lstm_predictor
if _lstm_predictor is None:
print(f"🧩 [Worker {os.getpid()}] Initializing LSTM Predictor...")
_lstm_predictor = PricePredictor()
return _lstm_predictor
def analyze_stock_process(ticker, prices, news_items):
"""
[CPU Intensive] 기술적 분석 및 AI 판단을 수행하는 함수
(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:
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)
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"
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}(Conf:{ai_confidence:.2f}) → Total={total_score:.2f} [{decision}]")
# [신규] 변동성(Volatility) 계산
if len(prices) > 1:
prices_np = np.array(prices)
changes = np.diff(prices_np) / prices_np[:-1]
volatility = np.std(changes) * 100 # 퍼센트 단위
else:
volatility = 0.0
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
}

163
modules/utils/ipc.py Normal file
View File

@@ -0,0 +1,163 @@
"""
프로세스 간 통신 (IPC) - 파일 기반
텔레그램 봇과 메인 봇 간 데이터 공유
"""
import os
import json
import time
from datetime import datetime
from modules.config import Config
class BotIPC:
"""파일 기반 IPC (Inter-Process Communication)"""
def __init__(self, ipc_file=None):
self.ipc_file = ipc_file if ipc_file else Config.IPC_FILE
self.last_update = 0
def write_status(self, data):
"""메인 봇이 상태를 파일에 기록"""
try:
with open(self.ipc_file, 'w', encoding='utf-8') as f:
json.dump({
'timestamp': time.time(),
'data': data
}, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"⚠️ [IPC] Write failed: {e}")
def read_status(self):
"""텔레그램 봇이 상태를 파일에서 읽기"""
try:
if not os.path.exists(self.ipc_file):
print(f"⚠️ [IPC] File not found: {self.ipc_file}")
return None
with open(self.ipc_file, 'r', encoding='utf-8') as f:
ipc_data = json.load(f)
# 60초 이상 오래된 데이터는 무시 (10초 → 60초로 완화)
timestamp = ipc_data.get('timestamp', 0)
age = time.time() - timestamp
if age > 60:
print(f"⚠️ [IPC] Data too old: {age:.1f}s")
return None
print(f"✅ [IPC] Data loaded (age: {age:.1f}s)")
return ipc_data.get('data')
except Exception as e:
print(f"⚠️ [IPC] Read failed: {e}")
return None
def get_bot_instance_data(self):
"""봇 인스턴스 데이터 가져오기 (호환성 유지)"""
status = self.read_status()
if not status:
return None
# 가짜 봇 인스턴스 객체 생성 (기존 코드 호환)
class FakeBotInstance:
def __init__(self, data):
self.kis = FakeKIS(data.get('balance', {}), data.get('macro_indices', {}))
self.ollama_monitor = FakeOllama(data.get('gpu', {}))
self.theme_manager = FakeThemeManager(data.get('themes', {}))
self.discovered_stocks = set(data.get('discovered_stocks', []))
self.is_macro_warning_sent = data.get('is_macro_warning', False)
self.watchlist_manager = FakeWatchlistManager(data.get('watchlist', {}))
self.load_watchlist = lambda: data.get('watchlist', {})
class FakeKIS:
def __init__(self, balance_data, macro_indices):
self._balance = balance_data if balance_data else {
'total_eval': 0,
'deposit': 0,
'holdings': []
}
self._macro_indices = macro_indices if macro_indices else {}
def get_balance(self):
return self._balance
def get_current_index(self, ticker):
"""지수 조회 - IPC에서 저장된 데이터 반환"""
if ticker in self._macro_indices:
return self._macro_indices[ticker]
# 데이터 없으면 기본값
return {
'price': 2500.0,
'change': 0.0
}
def get_daily_index_price(self, ticker, period="D"):
"""지수 일별 시세 조회 - IPC 모드에서는 더미 데이터 반환"""
# MacroAnalyzer의 MSI 계산용
# 실제 데이터는 메인 봇에서만 조회 가능
# IPC 모드에서는 기본 더미 데이터 반환 (20일치)
base_price = 2500.0
if ticker in self._macro_indices:
base_price = self._macro_indices[ticker].get('price', 2500.0)
# 20일치 더미 데이터 (약간의 변동)
import random
prices = []
for i in range(20):
variation = random.uniform(-0.02, 0.02) # ±2% 변동
prices.append(base_price * (1 + variation))
return prices
def get_current_price(self, ticker):
"""현재가 조회 - IPC 모드에서는 사용 불가"""
return None
def get_daily_price(self, ticker, period="D"):
"""일별 시세 조회 - IPC 모드에서는 사용 불가"""
return []
def get_volume_rank(self, market="0"):
"""거래량 순위 조회 - IPC 모드에서는 사용 불가"""
return []
def buy_stock(self, ticker, qty):
"""매수 주문 - IPC 모드에서는 사용 불가"""
return {"success": False, "msg": "IPC mode: buy not available"}
def sell_stock(self, ticker, qty):
"""매도 주문 - IPC 모드에서는 사용 불가"""
return {"success": False, "msg": "IPC mode: sell not available"}
class FakeOllama:
def __init__(self, gpu_data):
self._gpu = gpu_data if gpu_data else {
'name': 'N/A',
'temp': 0,
'vram_used': 0,
'vram_total': 0,
'load': 0
}
def get_gpu_status(self):
return self._gpu
class FakeThemeManager:
def __init__(self, themes_data):
self._themes = themes_data if themes_data else {}
def get_themes(self, ticker):
return self._themes.get(ticker, [])
class FakeWatchlistManager:
def __init__(self, watchlist_data):
self._watchlist = watchlist_data if watchlist_data else {}
def update_watchlist_daily(self):
return "⚠️ Watchlist update not available in IPC mode"
return FakeBotInstance(status)
def load_watchlist(self):
"""Watchlist 로드"""
status = self.read_status()
if status:
return status.get('watchlist', {})
return {}

72
modules/utils/monitor.py Normal file
View File

@@ -0,0 +1,72 @@
import psutil
import time
from datetime import datetime
class SystemMonitor:
def __init__(self, messenger, ollama_manager):
self.messenger = messenger
self.ollama_monitor = ollama_manager
self.last_health_check = datetime.now()
def check_health(self):
"""시스템 상태 점검 및 알림 (CPU, RAM, GPU) - 5분마다 실행"""
now = datetime.now()
# 5분에 한 번씩만 체크
if (now - self.last_health_check).total_seconds() < 300:
return
self.last_health_check = now
alerts = []
# 1. CPU Check (Double Verify)
# 1초 간격으로 측정
cpu_usage = psutil.cpu_percent(interval=1)
if cpu_usage > 90:
# 일시적인 스파이크일 수 있으므로 3초 후 재측정
time.sleep(3)
cpu_usage_2nd = psutil.cpu_percent(interval=1)
if cpu_usage_2nd > 90:
# 과부하 시 원인 프로세스 추적
top_processes = []
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
try:
# Windows 유휴 프로세스 제외
if proc.info['name'] == 'System Idle Process':
continue
top_processes.append(proc.info)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# CPU 사용률 내림차순 정렬
top_processes.sort(key=lambda x: x['cpu_percent'], reverse=True)
# 상위 프로세스들의 CPU 합계 검증 (측정 오류 필터링)
total_top_cpu = sum(p['cpu_percent'] for p in top_processes[:3])
if total_top_cpu < 30.0:
print(f"⚠️ [Monitor] Ignored CPU Alert: usage={cpu_usage_2nd}% but top3_sum={total_top_cpu}%")
else:
top_3_str = ""
for p in top_processes[:3]:
top_3_str += f"\n- {p['name']} ({p['cpu_percent']}%)"
alerts.append(f"🔥 **[CPU Overload]** Usage: `{cpu_usage_2nd}%`\n**Top Processes:**{top_3_str}")
# 2. RAM Check
ram = psutil.virtual_memory()
if ram.percent > 90:
alerts.append(f"💾 **[RAM High]** Usage: `{ram.percent}%` (Free: {ram.available / 1024**3:.1f}GB)")
# 3. GPU Check
if self.ollama_monitor:
gpu_status = self.ollama_monitor.get_gpu_status()
temp = gpu_status.get('temp', 0)
if temp > 80:
alerts.append(f"♨️ **[GPU Overheat]** Temp: `{temp}°C`")
# 알림 전송
if alerts:
msg = "⚠️ **[System Health Alert]**\n" + "\n".join(alerts)
if self.messenger:
self.messenger.send_message(msg)