주식자동매매 AI 프로그램 초기 모델
This commit is contained in:
2
modules/__init__.py
Normal file
2
modules/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
# Initialize modules package
|
||||
206
modules/analysis/deep_learning.py
Normal file
206
modules/analysis/deep_learning.py
Normal 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
139
modules/analysis/macro.py
Normal 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']}")
|
||||
190
modules/analysis/technical.py
Normal file
190
modules/analysis/technical.py
Normal 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
342
modules/bot.py
Normal 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
62
modules/config.py
Normal 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
367
modules/services/kis.py
Normal file
@@ -0,0 +1,367 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class KISClient:
|
||||
"""
|
||||
한국투자증권 (Korea Investment & Securities) REST API Client
|
||||
"""
|
||||
def __init__(self, is_virtual=None):
|
||||
# Config에서 설정 로드
|
||||
self.app_key = Config.KIS_APP_KEY
|
||||
self.app_secret = Config.KIS_APP_SECRET
|
||||
self.cano = Config.KIS_ACCOUNT[:8]
|
||||
self.acnt_prdt_cd = Config.KIS_ACCOUNT[-2:] # "01" 등
|
||||
|
||||
# 가상/실전 모드 설정
|
||||
if is_virtual is None:
|
||||
self.is_virtual = Config.KIS_IS_VIRTUAL
|
||||
else:
|
||||
self.is_virtual = is_virtual
|
||||
|
||||
self.base_url = Config.KIS_BASE_URL
|
||||
|
||||
self.access_token = None
|
||||
self.token_expired = None
|
||||
self.last_req_time = 0
|
||||
|
||||
def _throttle(self):
|
||||
"""API 요청 속도 제한 (초당 5회 이하로 제한)"""
|
||||
# 모의투자는 Rate Limit이 더 엄격할 수 있음
|
||||
min_interval = 0.2 # 0.2초 대기
|
||||
now = time.time()
|
||||
elapsed = now - self.last_req_time
|
||||
|
||||
if elapsed < min_interval:
|
||||
time.sleep(min_interval - elapsed)
|
||||
|
||||
self.last_req_time = time.time()
|
||||
|
||||
def _get_headers(self, tr_id=None):
|
||||
"""공통 헤더 생성"""
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"authorization": f"Bearer {self.access_token}",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret,
|
||||
}
|
||||
if tr_id:
|
||||
headers["tr_id"] = tr_id
|
||||
|
||||
return headers
|
||||
|
||||
def ensure_token(self):
|
||||
"""접근 토큰 발급 (OAuth 2.0)"""
|
||||
# 토큰 유효성 체크 로직은 생략 (실제 운영 시 만료 시간 체크 필요)
|
||||
if self.access_token:
|
||||
return
|
||||
|
||||
url = f"{self.base_url}/oauth2/tokenP"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
|
||||
try:
|
||||
print("🔑 [KIS] 토큰 발급 요청...")
|
||||
res = requests.post(url, json=payload)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
self.access_token = data.get('access_token')
|
||||
print("✅ [KIS] 토큰 발급 성공")
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] 토큰 발급 실패: {e}")
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Token Error Body]: {e.response.text}")
|
||||
|
||||
def get_hash_key(self, datas):
|
||||
"""주문 시 필요한 Hash Key 생성 (Koreainvestment header 특화)"""
|
||||
url = f"{self.base_url}/uapi/hashkey"
|
||||
headers = {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
try:
|
||||
res = requests.post(url, headers=headers, json=datas)
|
||||
return res.json()["HASH"]
|
||||
except Exception as e:
|
||||
print(f"❌ Hash Key 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
def get_balance(self):
|
||||
"""주식 잔고 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
# 국내주식 잔고조회 TR ID: VTTC8434R (모의), TTTC8434R (실전)
|
||||
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R"
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
headers = self._get_headers(tr_id=tr_id)
|
||||
|
||||
# 쿼리 파라미터
|
||||
params = {
|
||||
"CANO": self.cano,
|
||||
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "00",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
# 응답 정리
|
||||
if data['rt_cd'] != '0':
|
||||
return {"error": data['msg1']}
|
||||
|
||||
holdings = []
|
||||
for item in data['output1']:
|
||||
if int(item['hldg_qty']) > 0:
|
||||
holdings.append({
|
||||
"code": item['pdno'],
|
||||
"name": item['prdt_name'],
|
||||
"qty": int(item['hldg_qty']),
|
||||
"yield": float(item['evlu_pfls_rt']),
|
||||
"purchase_price": float(item['pchs_avg_pric']), # 매입평균가
|
||||
"current_price": float(item['prpr']), # 현재가
|
||||
"profit_loss": int(item['evlu_pfls_amt']) # 평가손익
|
||||
})
|
||||
|
||||
summary = data['output2'][0]
|
||||
return {
|
||||
"holdings": holdings,
|
||||
"total_eval": int(summary['tot_evlu_amt']),
|
||||
"deposit": int(summary['dnca_tot_amt'])
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] 잔고 조회 실패: {e}")
|
||||
# If it's a requests error, verify if there's a response body
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Error Body]: {e.response.text}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def order(self, ticker, qty, buy_sell, price=0):
|
||||
"""주문 (시장가)
|
||||
buy_sell: 'BUY' or 'SELL'
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
# 모의투자/실전 TR ID 구분
|
||||
# 매수: VTTC0802U / TTTC0802U
|
||||
# 매도: VTTC0801U / TTTC0801U
|
||||
if buy_sell == 'BUY':
|
||||
tr_id = "VTTC0802U" if self.is_virtual else "TTTC0802U"
|
||||
else:
|
||||
tr_id = "VTTC0801U" if self.is_virtual else "TTTC0801U"
|
||||
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
|
||||
|
||||
# 주문 파라미터
|
||||
datas = {
|
||||
"CANO": self.cano,
|
||||
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
||||
"PDNO": ticker,
|
||||
"ORD_DVSN": "01", # 01: 시장가
|
||||
"ORD_QTY": str(qty),
|
||||
"ORD_UNPR": "0" # 시장가는 0
|
||||
}
|
||||
|
||||
# 헤더 준비
|
||||
headers = self._get_headers(tr_id=tr_id)
|
||||
|
||||
# [중요] POST 요청(주문 등) 시 Hash Key 필수
|
||||
# 단, 모의투자의 경우 일부 상황에서 생략 가능할 수 있으나, 정석대로 포함
|
||||
hash_key = self.get_hash_key(datas)
|
||||
if hash_key:
|
||||
headers["hashkey"] = hash_key
|
||||
else:
|
||||
print("⚠️ [KIS] Hash Key 생성 실패 (주문 전송 시도)")
|
||||
|
||||
try:
|
||||
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea (시장가)")
|
||||
res = requests.post(url, headers=headers, json=datas)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
print(f"📥 [KIS] 주문 응답 코드(rt_cd): {data['rt_cd']}")
|
||||
print(f"📥 [KIS] 주문 응답 메시지(msg1): {data['msg1']}")
|
||||
|
||||
if data['rt_cd'] != '0':
|
||||
return {"status": False, "msg": data['msg1'], "rt_cd": data['rt_cd']}
|
||||
|
||||
return {"status": True, "msg": "주문 전송 완료", "order_no": data['output']['ODNO'], "rt_cd": data['rt_cd']}
|
||||
except Exception as e:
|
||||
return {"status": False, "msg": str(e), "rt_cd": "EXCEPTION"}
|
||||
|
||||
def get_current_price(self, ticker):
|
||||
"""현재가 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
|
||||
headers = self._get_headers(tr_id="FHKST01010100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
return int(data['output']['stck_prpr']) # 현재가
|
||||
except Exception as e:
|
||||
print(f"❌ 현재가 조회 실패: {e}")
|
||||
return None
|
||||
|
||||
def get_daily_price(self, ticker, period="D"):
|
||||
"""일별 시세 조회 (기술적 분석용)"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
||||
headers = self._get_headers(tr_id="FHKST01010400")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1" # 수정주가
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
# 과거 데이터부터 오도록 정렬 필요할 수 있음 (API는 최신순)
|
||||
# output 리스트: [ {stck_clpr: 종가, ...}, ... ]
|
||||
prices = [int(item['stck_clpr']) for item in data['output']]
|
||||
prices.reverse() # 과거 -> 현재 순으로 정렬
|
||||
return prices
|
||||
except Exception as e:
|
||||
print(f"❌ 일별 시세 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
def get_volume_rank(self, limit=5):
|
||||
"""거래량 상위 종목 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||
headers = self._get_headers(tr_id="FHPST01710000")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J", # 주식, ETF, ETN 전체
|
||||
"FID_COND_SCR_RSLT_GD_CD": "20171", # 전체
|
||||
"FID_INPUT_ISCD": "0000", # 전체
|
||||
"FID_DIV_CLS_CODE": "0", # 0: 전체
|
||||
"FID_BLNG_CLS_CODE": "0", # 0: 전체
|
||||
"FID_TRGT_CLS_CODE": "111111111", # 필터링 조건 (이대로 두면 됨)
|
||||
"FID_TRGT_EXCLS_CLS_CODE": "0000000000", # 제외 조건
|
||||
"FID_INPUT_PRICE_1": "",
|
||||
"FID_INPUT_PRICE_2": "",
|
||||
"FID_VOL_CNT": "",
|
||||
"FID_INPUT_DATE_1": ""
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
results = []
|
||||
for item in data['output'][:limit]:
|
||||
# 코드는 shtn_iscd, 이름은 hts_kor_isnm
|
||||
results.append({
|
||||
"code": item['mksc_shrn_iscd'],
|
||||
"name": item['hts_kor_isnm'],
|
||||
"volume": int(item['acml_vol']),
|
||||
"price": int(item['stck_prpr'])
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"❌ 거래량 순위 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
def buy_stock(self, ticker, qty):
|
||||
return self.order(ticker, qty, 'BUY')
|
||||
|
||||
def get_current_index(self, ticker):
|
||||
"""지수 현재가 조회 (업종/지수)
|
||||
ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc.
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-index-price"
|
||||
headers = self._get_headers(tr_id="FHKUP03500100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
return {
|
||||
"price": float(data['output']['bstp_nmix_prpr']), # 현재지수
|
||||
"change": float(data['output']['bstp_nmix_prdy_ctrt']) # 등락률(%)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"❌ 지수 조회 실패({ticker}): {e}")
|
||||
return None
|
||||
|
||||
def sell_stock(self, ticker, qty):
|
||||
return self.order(ticker, qty, 'SELL')
|
||||
|
||||
def get_daily_index_price(self, ticker, period="D"):
|
||||
"""지수 일별 시세 조회 (Market Stress Index용)"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
|
||||
headers = self._get_headers(tr_id="FHKUP03500200")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1" # 수정주가
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
# output 리스트: [ {bstp_nmix_prpr: 지수, ...}, ... ]
|
||||
prices = [float(item['bstp_nmix_prpr']) for item in data['output']]
|
||||
prices.reverse() # 과거 -> 현재
|
||||
return prices
|
||||
except Exception as e:
|
||||
print(f"❌ 지수 일별 시세 조회 실패({ticker}): {e}")
|
||||
return []
|
||||
22
modules/services/news.py
Normal file
22
modules/services/news.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class NewsCollector:
|
||||
"""
|
||||
NAS에서 뉴스를 받지 못할 경우, Windows 서버에서 직접 뉴스를 수집하는 모듈
|
||||
(Google News RSS 활용)
|
||||
"""
|
||||
@staticmethod
|
||||
def get_market_news(query="주식 시장"):
|
||||
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
root = ET.fromstring(resp.content)
|
||||
items = []
|
||||
for item in root.findall(".//item")[:5]:
|
||||
title = item.find("title").text
|
||||
items.append({"title": title, "source": "Google News"})
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"❌ 뉴스 수집 실패: {e}")
|
||||
return []
|
||||
114
modules/services/ollama.py
Normal file
114
modules/services/ollama.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import requests
|
||||
import json
|
||||
import psutil
|
||||
try:
|
||||
import pynvml
|
||||
except ImportError:
|
||||
pynvml = None
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class OllamaManager:
|
||||
"""
|
||||
Ollama API 세션 관리 및 메모리 누수 방지 래퍼
|
||||
- GPU VRAM 사용량 모니터링
|
||||
- keep_alive 파라미터를 통한 메모리 관리
|
||||
"""
|
||||
def __init__(self, model_name=None, base_url=None):
|
||||
self.model_name = model_name or Config.OLLAMA_MODEL
|
||||
self.base_url = base_url or Config.OLLAMA_API_URL
|
||||
self.generate_url = f"{self.base_url}/api/generate"
|
||||
|
||||
self.gpu_available = False
|
||||
try:
|
||||
if pynvml:
|
||||
pynvml.nvmlInit()
|
||||
self.handle = pynvml.nvmlDeviceGetHandleByIndex(0) # 0번 GPU (3070 Ti)
|
||||
self.gpu_available = True
|
||||
print("✅ [OllamaManager] NVIDIA GPU Monitoring On")
|
||||
else:
|
||||
print("⚠️ [OllamaManager] 'nvidia-ml-py' not installed. GPU monitoring disabled.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [OllamaManager] GPU Init Failed: {e}")
|
||||
|
||||
def check_vram(self):
|
||||
"""현재 GPU VRAM 사용량(GB) 반환"""
|
||||
if not self.gpu_available:
|
||||
return 0.0
|
||||
try:
|
||||
info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
|
||||
used_gb = info.used / 1024**3
|
||||
return used_gb
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_gpu_status(self):
|
||||
"""GPU 종합 상태 반환 (온도, 메모리, 사용률, 이름)"""
|
||||
if not self.gpu_available:
|
||||
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
|
||||
|
||||
try:
|
||||
# GPU 이름
|
||||
name = pynvml.nvmlDeviceGetName(self.handle)
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode('utf-8')
|
||||
|
||||
# 온도
|
||||
temp = pynvml.nvmlDeviceGetTemperature(self.handle, pynvml.NVML_TEMPERATURE_GPU)
|
||||
# 메모리
|
||||
mem_info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
|
||||
vram_used = mem_info.used / 1024**3
|
||||
vram_total = mem_info.total / 1024**3
|
||||
# 사용률
|
||||
util = pynvml.nvmlDeviceGetUtilizationRates(self.handle)
|
||||
load = util.gpu
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"temp": temp,
|
||||
"vram_used": round(vram_used, 1),
|
||||
"vram_total": round(vram_total, 1),
|
||||
"load": load
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⚠️ GPU Status Check Failed: {e}")
|
||||
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
|
||||
|
||||
def request_inference(self, prompt, context_data=None):
|
||||
"""
|
||||
Ollama에 추론 요청
|
||||
:param prompt: 시스템 프롬프트 + 사용자 입력
|
||||
:param context_data: (Optional) 이전 대화 컨텍스트
|
||||
"""
|
||||
# [5070Ti 최적화] VRAM이 14GB 이상이면 모델 언로드 시도 (16GB 중 여유분 확보)
|
||||
vram = self.check_vram()
|
||||
if vram > 14.0:
|
||||
print(f"⚠️ [OllamaManager] High VRAM Usage ({vram:.1f}GB). Requesting unload.")
|
||||
try:
|
||||
# keep_alive=0으로 설정하여 모델 즉시 언로드
|
||||
requests.post(self.generate_url,
|
||||
json={"model": self.model_name, "keep_alive": 0}, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unload model: {e}")
|
||||
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json", # JSON 강제
|
||||
"options": {
|
||||
"num_ctx": 8192, # [5070Ti 최적화] 컨텍스트 크기 2배 증가 (4096 -> 8192)
|
||||
"temperature": 0.2, # 분석 일관성 유지
|
||||
"num_gpu": 1, # GPU 사용 명시
|
||||
"num_thread": 8 # CPU 스레드 수 (9800X3D 활용)
|
||||
},
|
||||
"keep_alive": "10m" # [5070Ti 최적화] 10분간 유지 (메모리 여유 있음)
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.generate_url, json=payload, timeout=180) # 타임아웃 증가
|
||||
response.raise_for_status()
|
||||
return response.json().get('response')
|
||||
except Exception as e:
|
||||
print(f"❌ Inference Error: {e}")
|
||||
return None
|
||||
34
modules/services/telegram.py
Normal file
34
modules/services/telegram.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import requests
|
||||
import os
|
||||
import threading
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class TelegramMessenger:
|
||||
def __init__(self, token=None, chat_id=None):
|
||||
# 환경 변수에서 로드하거나 인자로 받음
|
||||
self.token = token or Config.TELEGRAM_BOT_TOKEN
|
||||
self.chat_id = chat_id or Config.TELEGRAM_CHAT_ID
|
||||
|
||||
if not self.token or not self.chat_id:
|
||||
print("⚠️ [Telegram] Token or Chat ID not found.")
|
||||
|
||||
def send_message(self, message):
|
||||
"""별도 스레드로 메시지를 전송하여 메인 루프 블로킹 방지"""
|
||||
if not self.token or not self.chat_id:
|
||||
return
|
||||
|
||||
def _send():
|
||||
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "Markdown"
|
||||
}
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Telegram] Error: {e}")
|
||||
|
||||
# 스레드 실행 (Fire-and-forget)
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
73
modules/services/telegram_bot/runner.py
Normal file
73
modules/services/telegram_bot/runner.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
멀티프로세스 방식 - 텔레그램 봇 프로세스
|
||||
트레이딩 봇과 완전히 분리된 독립 프로세스로 실행
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import multiprocessing
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 환경 변수 로드
|
||||
load_dotenv()
|
||||
|
||||
def run_telegram_bot_standalone():
|
||||
"""텔레그램 봇만 독립적으로 실행"""
|
||||
# 경로 문제 해결을 위해 상위 디렉토리 추가
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../')))
|
||||
|
||||
from modules.services.telegram_bot.server import TelegramBotServer
|
||||
from modules.utils.ipc import BotIPC
|
||||
from modules.config import Config
|
||||
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
print("❌ [Telegram] TELEGRAM_BOT_TOKEN not found in .env")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"🤖 [Telegram Bot Process] Starting... (PID: {os.getpid()})")
|
||||
print(f"🔗 [Telegram Bot] Standalone Process Mode (IPC Enabled)")
|
||||
|
||||
# IPC 초기화
|
||||
ipc = BotIPC()
|
||||
|
||||
# [최적화] 재시작 루프 구현
|
||||
while True:
|
||||
try:
|
||||
# 봇 서버 생성 (매번 새로 생성)
|
||||
bot_server = TelegramBotServer(token)
|
||||
|
||||
# IPC를 통해 메인 봇 데이터 가져오기
|
||||
try:
|
||||
# 초기 연결 시도
|
||||
instance_data = ipc.get_bot_instance_data()
|
||||
if instance_data:
|
||||
bot_server.set_bot_instance(instance_data)
|
||||
except Exception:
|
||||
pass # 연결 실패해도 일단 봇은 띄움
|
||||
|
||||
# 봇 실행 (블로킹)
|
||||
bot_server.run()
|
||||
|
||||
# 재시작 요청 확인
|
||||
if bot_server.should_restart:
|
||||
print("🔄 [Telegram Bot] Restarting instance...")
|
||||
import time
|
||||
time.sleep(1) # 잠시 대기
|
||||
continue
|
||||
else:
|
||||
print("🛑 [Telegram Bot] Process exiting.")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 [Telegram Bot] Stopped by user")
|
||||
break
|
||||
except Exception as e:
|
||||
if "Conflict" not in str(e):
|
||||
print(f"❌ [Telegram Bot] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
run_telegram_bot_standalone()
|
||||
394
modules/services/telegram_bot/server.py
Normal file
394
modules/services/telegram_bot/server.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
텔레그램 봇 최적화 버전
|
||||
- Polling 최적화 (CPU 사용률 감소)
|
||||
- 별도 프로세스로 분리
|
||||
- 봇 재시작 명령어
|
||||
- 원격 명령어 실행
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes, TypeHandler
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
level=logging.INFO
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
class TelegramBotServer:
|
||||
def __init__(self, bot_token):
|
||||
# [최적화] 연결 풀 설정 추가
|
||||
self.application = Application.builder()\
|
||||
.token(bot_token)\
|
||||
.concurrent_updates(True)\
|
||||
.build()
|
||||
|
||||
self.bot_instance = None
|
||||
self.is_shutting_down = False
|
||||
self.should_restart = False
|
||||
|
||||
def set_bot_instance(self, bot):
|
||||
"""AutoTradingBot 인스턴스를 주입받음"""
|
||||
self.bot_instance = bot
|
||||
|
||||
def refresh_bot_instance(self):
|
||||
"""IPC에서 최신 봇 인스턴스 데이터 읽기"""
|
||||
# [수정] 모듈 경로 변경
|
||||
from modules.utils.ipc import BotIPC
|
||||
ipc = BotIPC()
|
||||
self.bot_instance = ipc.get_bot_instance_data()
|
||||
return self.bot_instance is not None
|
||||
|
||||
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/start 명령어 핸들러"""
|
||||
await update.message.reply_text(
|
||||
"🤖 **AI Trading Bot Command Center**\n"
|
||||
"명령어 목록:\n"
|
||||
"/status - 현재 봇 및 시장 상태 조회\n"
|
||||
"/portfolio - 현재 보유 종목 및 평가액\n"
|
||||
"/watchlist - 현재 감시 중인 종목 리스트\n"
|
||||
"/update_watchlist - Watchlist 즉시 업데이트\n"
|
||||
"/macro - 거시경제 지표 및 시장 위험도\n"
|
||||
"/system - PC 리소스(CPU/GPU) 상태\n"
|
||||
"/ai - AI 모델 학습 상태 조회\n\n"
|
||||
"**[관리 명령어]**\n"
|
||||
"/restart - 봇 재시작\n"
|
||||
"/exec <command> - 원격 명령어 실행\n"
|
||||
"/stop - 봇 종료",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/status: 종합 상태 브리핑"""
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30)
|
||||
|
||||
status_msg = "✅ **System Status: ONLINE**\n"
|
||||
status_msg += f"🕒 **Market:** {'OPEN 🟢' if is_market_open else 'CLOSED 🔴'}\n"
|
||||
|
||||
macro_warn = self.bot_instance.is_macro_warning_sent
|
||||
status_msg += f"🌍 **Macro Filter:** {'DANGER 🚨 (Trading Halted)' if macro_warn else 'SAFE 🟢'}\n"
|
||||
|
||||
await update.message.reply_text(status_msg, parse_mode="Markdown")
|
||||
|
||||
async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/portfolio: 잔고 조회"""
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("⚠️ 봇 인스턴스가 연결되지 않았습니다.")
|
||||
return
|
||||
|
||||
await update.message.reply_text("⏳ 잔고를 조회 중입니다...")
|
||||
|
||||
try:
|
||||
balance = self.bot_instance.kis.get_balance()
|
||||
if "error" in balance:
|
||||
await update.message.reply_text(f"❌ 잔고 조회 실패: {balance['error']}")
|
||||
return
|
||||
|
||||
msg = f"💰 **Total Asset:** `{int(balance['total_eval']):,} KRW`\n" \
|
||||
f"💵 **Deposit:** `{int(balance['deposit']):,} KRW`\n\n"
|
||||
|
||||
if balance['holdings']:
|
||||
msg += "**[Holdings]**\n"
|
||||
for stock in balance['holdings']:
|
||||
icon = "🔴" if stock['yield'] > 0 else "🔵"
|
||||
msg += f"{icon} **{stock['name']}** `{stock['yield']}%`\n" \
|
||||
f" (수량: {stock['qty']} / 평가손익: {stock['profit_loss']:,})\n"
|
||||
else:
|
||||
msg += "보유 중인 종목이 없습니다."
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/watchlist: 감시 대상 종목"""
|
||||
if not self.refresh_bot_instance():
|
||||
return
|
||||
|
||||
target_dict = self.bot_instance.load_watchlist()
|
||||
discovered = list(self.bot_instance.discovered_stocks)
|
||||
|
||||
msg = f"👀 **Watchlist: {len(target_dict)} items**\n"
|
||||
for code, name in target_dict.items():
|
||||
themes = self.bot_instance.theme_manager.get_themes(code)
|
||||
theme_str = f" ({', '.join(themes)})" if themes else ""
|
||||
msg += f"- {name}{theme_str}\n"
|
||||
|
||||
if discovered:
|
||||
msg += f"\n✨ **Discovered Today ({len(discovered)}):**\n"
|
||||
for code in discovered:
|
||||
msg += f"- {code}\n"
|
||||
|
||||
await update.message.reply_text(msg)
|
||||
|
||||
async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/update_watchlist: Watchlist 즉시 업데이트"""
|
||||
await update.message.reply_text("🔄 Watchlist를 업데이트하고 있습니다... (30초 소요)")
|
||||
|
||||
try:
|
||||
# [수정] IPC 모드에서도 직접 수행하기 위해 새로운 인스턴스 생성
|
||||
from modules.services.kis import KISClient
|
||||
from watchlist_manager import WatchlistManager
|
||||
|
||||
# 독립적인 KIS 클라이언트 생성
|
||||
from modules.config import Config
|
||||
temp_kis = KISClient()
|
||||
mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE)
|
||||
|
||||
# 업데이트 수행 (파일 쓰기)
|
||||
summary = mgr.update_watchlist_daily()
|
||||
await update.message.reply_text(summary, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 업데이트 실패: {e}")
|
||||
|
||||
async def macro_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/macro: 거시경제 지표 조회 (IPC 데이터 사용)"""
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("⚠️ 메인 봇 연결 대기 중...")
|
||||
return
|
||||
|
||||
await update.message.reply_text("⏳ 거시경제 데이터를 불러옵니다...")
|
||||
|
||||
try:
|
||||
# [수정] IPC 데이터를 직접 사용하여 출력 (FakeKIS의 _macro_indices 활용)
|
||||
# FakeKIS는 bot_instance.kis에 할당되어 있음
|
||||
indices = getattr(self.bot_instance.kis, '_macro_indices', {})
|
||||
|
||||
if not indices:
|
||||
await update.message.reply_text("⚠️ 데이터가 아직 수집되지 않았습니다. 잠시 후 다시 시도하세요.")
|
||||
return
|
||||
|
||||
# 리스크 점수 계산 (간이)
|
||||
status = "SAFE"
|
||||
msi = indices.get('MSI', 0)
|
||||
if msi >= 50: status = "DANGER"
|
||||
elif msi >= 30: status = "CAUTION"
|
||||
|
||||
color = "🟢" if status == "SAFE" else "🔴" if status == "DANGER" else "🟡"
|
||||
msg = f"{color} **Market Risk: {status}**\n\n"
|
||||
|
||||
if 'MSI' in indices:
|
||||
msg += f"🌡️ **Stress Index:** `{indices['MSI']}`\n"
|
||||
|
||||
for k, v in indices.items():
|
||||
if k != "MSI":
|
||||
icon = "🔺" if v.get('change', 0) > 0 else "🔻"
|
||||
msg += f"{icon} **{k}**: {v.get('price', 0)} ({v.get('change', 0)}%)\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ Error: {e}")
|
||||
|
||||
async def system_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/system: 시스템 리소스 상태"""
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
import psutil
|
||||
|
||||
cpu = psutil.cpu_percent(interval=1)
|
||||
ram = psutil.virtual_memory().percent
|
||||
|
||||
# CPU 점유율 상위 3개 프로세스 수집
|
||||
top_processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
|
||||
try:
|
||||
proc_info = proc.info
|
||||
if proc_info['name'] == 'System Idle Process':
|
||||
continue
|
||||
top_processes.append(proc_info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
pass
|
||||
|
||||
top_processes.sort(key=lambda x: x.get('cpu_percent', 0), reverse=True)
|
||||
top_3 = top_processes[:3]
|
||||
|
||||
gpu_status = self.bot_instance.ollama_monitor.get_gpu_status()
|
||||
gpu_msg = f"N/A"
|
||||
if gpu_status and gpu_status.get('name') != 'N/A':
|
||||
gpu_name = gpu_status.get('name', 'GPU')
|
||||
gpu_msg = f"{gpu_name}\n Temp: {gpu_status.get('temp', 0)}°C / VRAM: {gpu_status.get('vram_used', 0)}GB / {gpu_status.get('vram_total', 0)}GB"
|
||||
|
||||
msg = "🖥️ **PC System Status**\n" \
|
||||
f"🧠 **CPU:** `{cpu}%`\n" \
|
||||
f"💾 **RAM:** `{ram}%`\n" \
|
||||
f"🎮 **GPU:** {gpu_msg}\n\n"
|
||||
|
||||
if top_3:
|
||||
msg += "⚙️ **Top CPU Processes:**\n"
|
||||
for i, proc in enumerate(top_3, 1):
|
||||
proc_name = proc.get('name', 'Unknown')
|
||||
proc_cpu = proc.get('cpu_percent', 0)
|
||||
msg += f" {i}. `{proc_name}` - {proc_cpu:.1f}%\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/ai: AI 모델 학습 상태 조회"""
|
||||
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
|
||||
|
||||
msg = "🧠 **AI Model Status**\n"
|
||||
msg += f"• **LLM Engine:** Ollama (Llama 3.1)\n"
|
||||
|
||||
gpu_name = gpu.get('name', 'NVIDIA RTX 5070 Ti')
|
||||
msg += f"• **Device:** {gpu_name}\n"
|
||||
|
||||
if gpu:
|
||||
msg += f"• **GPU Load:** `{gpu.get('load', 0)}%`\n"
|
||||
msg += f"• **VRAM Usage:** `{gpu.get('vram_used', 0)}GB` / {gpu.get('vram_total', 0)}GB"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||||
|
||||
async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/restart: 텔레그램 봇 모듈만 재시작"""
|
||||
await update.message.reply_text("🔄 **텔레그램 인터페이스를 재시작합니다...**")
|
||||
|
||||
# 재시작 플래그 설정 (runner.py에서 감지하여 재시작)
|
||||
self.should_restart = True
|
||||
self.application.stop_running()
|
||||
|
||||
async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/stop: 봇 종료"""
|
||||
await update.message.reply_text("🛑 **텔레그램 봇을 종료합니다.**")
|
||||
|
||||
# 종료 플래그 설정 (runner.py에서 루프 탈출)
|
||||
self.should_restart = False
|
||||
self.application.stop_running()
|
||||
|
||||
async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/exec: 원격 명령어 실행"""
|
||||
if not context.args:
|
||||
await update.message.reply_text("❌ 사용법: /exec <command>")
|
||||
return
|
||||
|
||||
command = " ".join(context.args)
|
||||
await update.message.reply_text(f"⚙️ 실행 중: `{command}`", parse_mode="Markdown")
|
||||
|
||||
try:
|
||||
# 보안: 위험한 명령어 차단
|
||||
dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot']
|
||||
if any(keyword in command.lower() for keyword in dangerous_keywords):
|
||||
await update.message.reply_text("⛔ 위험한 명령어는 실행할 수 없습니다.")
|
||||
return
|
||||
|
||||
# 명령어 실행 (타임아웃 30초)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=os.getcwd()
|
||||
)
|
||||
|
||||
output = result.stdout if result.stdout else result.stderr
|
||||
if not output:
|
||||
output = "명령어 실행 완료 (출력 없음)"
|
||||
|
||||
# 출력이 너무 길면 잘라내기
|
||||
if len(output) > 3000:
|
||||
output = output[:3000] + "\n... (출력이 너무 깁니다)"
|
||||
|
||||
await update.message.reply_text(f"```\n{output}\n```", parse_mode="Markdown")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
await update.message.reply_text("⏱️ 명령어 실행 시간 초과 (30초)")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"❌ 실행 오류: {str(e)}")
|
||||
|
||||
def run(self):
|
||||
"""봇 실행 (비동기 polling)"""
|
||||
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""에러 핸들러"""
|
||||
import traceback
|
||||
|
||||
# Conflict 에러는 무시 (다른 봇 인스턴스 실행 중)
|
||||
if "Conflict" in str(context.error):
|
||||
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다. 이 인스턴스를 종료합니다.")
|
||||
if self.application.running:
|
||||
await self.application.stop()
|
||||
return
|
||||
|
||||
tb_list = traceback.format_exception(None, context.error, context.error.__traceback__)
|
||||
tb_string = ''.join(tb_list)
|
||||
print(f"❌ [Telegram Error] {tb_string}")
|
||||
|
||||
if isinstance(update, Update) and update.effective_message:
|
||||
try:
|
||||
await update.effective_message.reply_text(f"⚠️ 오류 발생: {context.error}")
|
||||
except:
|
||||
pass
|
||||
|
||||
async def ultimate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
|
||||
text = update.message.text.strip()
|
||||
print(f"📨 [Telegram] Command Received: {text}")
|
||||
|
||||
try:
|
||||
if text.startswith("/start"):
|
||||
await self.start_command(update, context)
|
||||
elif text.startswith("/status"):
|
||||
await self.status_command(update, context)
|
||||
elif text.startswith("/portfolio"):
|
||||
await self.portfolio_command(update, context)
|
||||
elif text.startswith("/watchlist"):
|
||||
await self.watchlist_command(update, context)
|
||||
elif text.startswith("/update_watchlist"):
|
||||
await self.update_watchlist_command(update, context)
|
||||
elif text.startswith("/macro"):
|
||||
await self.macro_command(update, context)
|
||||
elif text.startswith("/system"):
|
||||
await self.system_command(update, context)
|
||||
elif text.startswith("/ai"):
|
||||
await self.ai_status_command(update, context)
|
||||
elif text.startswith("/restart"):
|
||||
await self.restart_command(update, context)
|
||||
elif text.startswith("/stop"):
|
||||
await self.stop_command(update, context)
|
||||
elif text.startswith("/exec"):
|
||||
await self.exec_command(update, context)
|
||||
except Exception as e:
|
||||
print(f"❌ Handle Error: {e}")
|
||||
await update.message.reply_text(f"⚠️ Error: {e}")
|
||||
|
||||
# 에러 핸들러 등록
|
||||
self.application.add_error_handler(error_handler)
|
||||
self.application.add_handler(TypeHandler(Update, ultimate_handler))
|
||||
|
||||
# [최적화] Polling 설정 개선
|
||||
print("🤖 [Telegram] Command Server Started (Optimized Polling Mode).")
|
||||
|
||||
try:
|
||||
self.application.run_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
stop_signals=None,
|
||||
poll_interval=1.0, # 1초마다 폴링 (기본값 0.0)
|
||||
timeout=10, # 타임아웃 10초
|
||||
drop_pending_updates=True # 대기 중인 업데이트 무시
|
||||
)
|
||||
except Exception as e:
|
||||
if "Conflict" in str(e):
|
||||
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다.")
|
||||
print(f"⚠️ [Telegram] 기존 봇을 종료하고 다시 시도하세요.")
|
||||
else:
|
||||
print(f"❌ [Telegram] 봇 실행 오류: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
145
modules/strategy/process.py
Normal file
145
modules/strategy/process.py
Normal 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
163
modules/utils/ipc.py
Normal 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
72
modules/utils/monitor.py
Normal 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)
|
||||
Reference in New Issue
Block a user