LSTM v3 멀티피처, KIS OHLCV 배치, 동적 전략 강화
- deep_learning.py: INPUT_SIZE=7 (close/open/high/low/volume/rsi/macd), feature_scaler/target_scaler 분리, ModelRegistry LRU 종목별 격리 (v3 체크포인트) - kis.py: get_daily_ohlcv() OHLCV 전체 반환, KISAsyncClient 비동기 배치 조회 추가, order() 지정가/조건부 주문 지원 - strategy/process.py: ATR/ADX 기반 동적 손절익절, 트레일링 스탑, 포지션 사이징 강화 - config.py: OLLAMA_NUM_THREAD=8 (9800X3D 최적화), LSTM_COOLDOWN/FAST_EPOCHS 환경변수화 - macro.py: 거시경제 지표 계산 개선 - ollama.py: VRAM 여유량 기반 선택적 언로드 - monitor.py: CPU 서킷 브레이커 연속 횟수 조건 추가 - ipc.py: IPC_STALENESS 600초로 확대 - news.py: 비동기 뉴스 수집 개선 - telegram.py, runner.py: 안정성 개선 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import os
|
||||
import time
|
||||
import pickle
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import numpy as np
|
||||
from collections import OrderedDict
|
||||
from sklearn.preprocessing import MinMaxScaler
|
||||
|
||||
from modules.config import Config
|
||||
@@ -10,6 +12,10 @@ from modules.config import Config
|
||||
# cuDNN 벤치마크 활성화 (고정 입력 크기에 대해 최적 커널 자동 선택)
|
||||
torch.backends.cudnn.benchmark = True
|
||||
|
||||
# 체크포인트 버전 (피처 수 변경 시 기존 모델 자동 재학습)
|
||||
CHECKPOINT_VERSION = "v3"
|
||||
INPUT_SIZE = 7 # close, open, high, low, volume_norm, rsi_14, macd_hist
|
||||
|
||||
|
||||
class Attention(nn.Module):
|
||||
def __init__(self, hidden_size):
|
||||
@@ -23,7 +29,7 @@ class Attention(nn.Module):
|
||||
|
||||
|
||||
class AdvancedLSTM(nn.Module):
|
||||
def __init__(self, input_size=1, hidden_size=512, num_layers=4, output_size=1, dropout=0.3):
|
||||
def __init__(self, input_size=INPUT_SIZE, 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
|
||||
@@ -49,8 +55,24 @@ class AdvancedLSTM(nn.Module):
|
||||
return out
|
||||
|
||||
|
||||
def _get_free_vram_gb():
|
||||
"""현재 GPU VRAM 여유량(GB) 반환"""
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
total = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
reserved = torch.cuda.memory_reserved(0) / 1024**3
|
||||
return total - reserved
|
||||
except Exception:
|
||||
pass
|
||||
return 99.0 # CUDA 없으면 언로드 불필요
|
||||
|
||||
|
||||
def _unload_ollama():
|
||||
"""LSTM 학습 전 Ollama 모델 언로드하여 GPU 메모리 확보"""
|
||||
"""LSTM 학습 전 Ollama 모델 언로드 (VRAM < 2GB 여유일 때만)"""
|
||||
free_vram = _get_free_vram_gb()
|
||||
if free_vram >= 2.0:
|
||||
print(f"[AI] Ollama 언로드 생략 (VRAM 여유 {free_vram:.1f}GB >= 2GB)")
|
||||
return
|
||||
try:
|
||||
import requests
|
||||
url = f"{Config.OLLAMA_API_URL}/api/generate"
|
||||
@@ -58,14 +80,17 @@ def _unload_ollama():
|
||||
"model": Config.OLLAMA_MODEL,
|
||||
"keep_alive": 0
|
||||
}, timeout=5)
|
||||
print("[AI] Ollama model unloaded (GPU memory freed)")
|
||||
time.sleep(1) # 메모리 해제 대기
|
||||
print(f"[AI] Ollama 언로드 (VRAM 여유 {free_vram:.1f}GB)")
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _preload_ollama():
|
||||
"""LSTM 학습 후 Ollama 모델 다시 로드"""
|
||||
"""LSTM 학습 후 Ollama 모델 리로드 (언로드했던 경우만)"""
|
||||
free_vram = _get_free_vram_gb()
|
||||
if free_vram >= 2.0:
|
||||
return # 언로드하지 않았으니 리로드도 불필요
|
||||
try:
|
||||
import requests
|
||||
url = f"{Config.OLLAMA_API_URL}/api/generate"
|
||||
@@ -86,25 +111,99 @@ def _log_gpu_memory(tag=""):
|
||||
print(f"[AI GPU {tag}] Allocated: {allocated:.2f}GB / Reserved: {reserved:.2f}GB")
|
||||
|
||||
|
||||
def _compute_rsi(close_arr, period=14):
|
||||
"""RSI 계산 (numpy 기반)"""
|
||||
if len(close_arr) < period + 1:
|
||||
return np.full(len(close_arr), 50.0)
|
||||
delta = np.diff(close_arr, prepend=close_arr[0])
|
||||
gain = np.where(delta > 0, delta, 0.0)
|
||||
loss = np.where(delta < 0, -delta, 0.0)
|
||||
alpha = 1.0 / period
|
||||
rsi_arr = np.zeros(len(close_arr))
|
||||
avg_gain = gain[0]
|
||||
avg_loss = loss[0]
|
||||
for i in range(1, len(close_arr)):
|
||||
avg_gain = alpha * gain[i] + (1 - alpha) * avg_gain
|
||||
avg_loss = alpha * loss[i] + (1 - alpha) * avg_loss
|
||||
rs = avg_gain / (avg_loss + 1e-9)
|
||||
rsi_arr[i] = 100 - (100 / (1 + rs))
|
||||
return rsi_arr
|
||||
|
||||
|
||||
def _compute_macd_hist(close_arr, fast=12, slow=26, signal=9):
|
||||
"""MACD Histogram 계산 (numpy 기반)"""
|
||||
if len(close_arr) < slow + signal:
|
||||
return np.zeros(len(close_arr))
|
||||
ema_fast = np.zeros(len(close_arr))
|
||||
ema_slow = np.zeros(len(close_arr))
|
||||
alpha_f = 2 / (fast + 1)
|
||||
alpha_s = 2 / (slow + 1)
|
||||
ema_fast[0] = close_arr[0]
|
||||
ema_slow[0] = close_arr[0]
|
||||
for i in range(1, len(close_arr)):
|
||||
ema_fast[i] = alpha_f * close_arr[i] + (1 - alpha_f) * ema_fast[i - 1]
|
||||
ema_slow[i] = alpha_s * close_arr[i] + (1 - alpha_s) * ema_slow[i - 1]
|
||||
macd = ema_fast - ema_slow
|
||||
sig = np.zeros(len(close_arr))
|
||||
alpha_sig = 2 / (signal + 1)
|
||||
sig[0] = macd[0]
|
||||
for i in range(1, len(close_arr)):
|
||||
sig[i] = alpha_sig * macd[i] + (1 - alpha_sig) * sig[i - 1]
|
||||
return macd - sig
|
||||
|
||||
|
||||
def _build_feature_matrix(ohlcv_data):
|
||||
"""
|
||||
OHLCV 딕셔너리 → 7차원 numpy 피처 행렬 생성
|
||||
피처: [close, open, high, low, volume_norm, rsi_14, macd_hist]
|
||||
"""
|
||||
close = np.array(ohlcv_data.get('close', []), dtype=np.float64)
|
||||
open_ = np.array(ohlcv_data.get('open', close), dtype=np.float64)
|
||||
high = np.array(ohlcv_data.get('high', close), dtype=np.float64)
|
||||
low = np.array(ohlcv_data.get('low', close), dtype=np.float64)
|
||||
volume = np.array(ohlcv_data.get('volume', []), dtype=np.float64)
|
||||
|
||||
n = len(close)
|
||||
if len(open_) != n: open_ = close.copy()
|
||||
if len(high) != n: high = close.copy()
|
||||
if len(low) != n: low = close.copy()
|
||||
|
||||
# 거래량 정규화 (최대값 기준, 0이면 0)
|
||||
if len(volume) == n and volume.max() > 0:
|
||||
volume_norm = volume / (volume.max() + 1e-9)
|
||||
else:
|
||||
volume_norm = np.zeros(n)
|
||||
|
||||
rsi = _compute_rsi(close, period=14)
|
||||
rsi_norm = rsi / 100.0 # 0~1 정규화
|
||||
|
||||
macd_hist = _compute_macd_hist(close)
|
||||
|
||||
# 7차원 피처 스택 (n x 7)
|
||||
features = np.column_stack([close, open_, high, low, volume_norm, rsi_norm, macd_hist])
|
||||
return features # shape: (n, 7)
|
||||
|
||||
|
||||
class PricePredictor:
|
||||
"""
|
||||
주가 예측 Deep Learning 모델 (GPU 최적화)
|
||||
- 전체 학습 데이터를 GPU에 상주 (CPU↔GPU 전송 최소화)
|
||||
- Ollama 모델 언로드/리로드로 GPU 메모리 확보
|
||||
- Early Stopping + Mixed Precision (FP16)
|
||||
- 종목별 모델 체크포인트
|
||||
[v3.0] 주가 예측 Deep Learning 모델 (GPU 최적화)
|
||||
- 7차원 멀티피처 LSTM (close/open/high/low/vol_norm/rsi/macd_hist)
|
||||
- feature_scaler(6개) + target_scaler(1개) 분리
|
||||
- 데이터 누수 수정: train 데이터로만 fit
|
||||
- 체크포인트에 scaler 상태 저장/로드
|
||||
- VRAM 여유량 기반 Ollama 언로드 (충분하면 생략)
|
||||
"""
|
||||
def __init__(self):
|
||||
self.scaler = MinMaxScaler(feature_range=(0, 1))
|
||||
self.feature_scaler = MinMaxScaler(feature_range=(0, 1)) # 입력 6개 피처
|
||||
self.target_scaler = MinMaxScaler(feature_range=(0, 1)) # 타겟: close 가격
|
||||
|
||||
self.hidden_size = 512
|
||||
self.num_layers = 4
|
||||
|
||||
self.model = AdvancedLSTM(input_size=1, hidden_size=self.hidden_size,
|
||||
self.model = AdvancedLSTM(input_size=INPUT_SIZE, hidden_size=self.hidden_size,
|
||||
num_layers=self.num_layers, dropout=0.3)
|
||||
self.criterion = nn.MSELoss()
|
||||
|
||||
# CUDA 설정
|
||||
self.device = torch.device('cpu')
|
||||
self.use_amp = False
|
||||
|
||||
@@ -116,19 +215,18 @@ class PricePredictor:
|
||||
self.device = torch.device('cuda')
|
||||
self.model.to(self.device)
|
||||
|
||||
# Mixed Precision (Compute Capability >= 7.0: Volta 이상)
|
||||
if torch.cuda.get_device_capability(0)[0] >= 7:
|
||||
self.use_amp = True
|
||||
|
||||
# Warm-up: CUDA 커널 컴파일 유도
|
||||
dummy = torch.zeros(1, 60, 1, device=self.device)
|
||||
# Warm-up
|
||||
dummy = torch.zeros(1, 60, INPUT_SIZE, device=self.device)
|
||||
with torch.no_grad():
|
||||
_ = self.model(dummy)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
print(f"[AI] GPU Mode: {gpu_name} ({vram_gb:.1f}GB)"
|
||||
f" | FP16={'ON' if self.use_amp else 'OFF'}"
|
||||
f" | cuDNN Benchmark=ON")
|
||||
f" | Features={INPUT_SIZE} | cuDNN Benchmark=ON")
|
||||
_log_gpu_memory("init")
|
||||
|
||||
except Exception as e:
|
||||
@@ -139,9 +237,8 @@ class PricePredictor:
|
||||
print("[AI] No CUDA GPU detected. Running on CPU.")
|
||||
|
||||
self.optimizer = torch.optim.AdamW(self.model.parameters(), lr=0.001, weight_decay=1e-4)
|
||||
# [v2.0] Learning Rate Scheduler (ReduceLROnPlateau: val_loss 정체 시 lr 감소)
|
||||
self.lr_scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
||||
self.optimizer, mode='min', factor=0.5, patience=7, min_lr=1e-6, verbose=False
|
||||
self.optimizer, mode='min', factor=0.5, patience=7, min_lr=1e-6
|
||||
)
|
||||
self.scaler_amp = torch.amp.GradScaler('cuda') if self.use_amp else None
|
||||
|
||||
@@ -149,7 +246,6 @@ class PricePredictor:
|
||||
self.max_epochs = 200
|
||||
self.seq_length = 60
|
||||
self.patience = 15
|
||||
# [v2.0] Gradient Clipping 값 (exploding gradient 방지)
|
||||
self.max_grad_norm = 1.0
|
||||
|
||||
self.training_status = {
|
||||
@@ -164,7 +260,8 @@ class PricePredictor:
|
||||
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] {gpu_name} ({vram_gb:.1f}GB VRAM) | cuDNN={torch.backends.cudnn.is_available()}")
|
||||
print(f"[AI Check] {gpu_name} ({vram_gb:.1f}GB VRAM) | cuDNN={torch.backends.cudnn.is_available()}"
|
||||
f" | Features={INPUT_SIZE}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[AI Check] GPU Error: {e}")
|
||||
@@ -173,16 +270,25 @@ class PricePredictor:
|
||||
return False
|
||||
|
||||
def _get_checkpoint_path(self, ticker):
|
||||
return os.path.join(Config.MODEL_DIR, f"{ticker}_lstm.pt")
|
||||
return os.path.join(Config.MODEL_DIR, f"{ticker}_lstm_{CHECKPOINT_VERSION}.pt")
|
||||
|
||||
def _load_checkpoint(self, ticker):
|
||||
path = self._get_checkpoint_path(ticker)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
checkpoint = torch.load(path, map_location=self.device, weights_only=True)
|
||||
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
|
||||
# 버전 체크 (v3 이전 체크포인트는 재학습)
|
||||
if checkpoint.get('version', '') != CHECKPOINT_VERSION:
|
||||
print(f"[AI] Checkpoint version mismatch ({ticker}): 재학습 필요")
|
||||
return False
|
||||
self.model.load_state_dict(checkpoint['model_state_dict'])
|
||||
self.optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
|
||||
print(f"[AI] Checkpoint loaded: {ticker}")
|
||||
# scaler 복원
|
||||
if 'feature_scaler' in checkpoint:
|
||||
self.feature_scaler = pickle.loads(checkpoint['feature_scaler'])
|
||||
if 'target_scaler' in checkpoint:
|
||||
self.target_scaler = pickle.loads(checkpoint['target_scaler'])
|
||||
print(f"[AI] Checkpoint loaded: {ticker} (v3, 7-features)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[AI] Checkpoint load failed ({ticker}): {e}")
|
||||
@@ -192,21 +298,127 @@ class PricePredictor:
|
||||
path = self._get_checkpoint_path(ticker)
|
||||
try:
|
||||
torch.save({
|
||||
'version': CHECKPOINT_VERSION,
|
||||
'model_state_dict': self.model.state_dict(),
|
||||
'optimizer_state_dict': self.optimizer.state_dict(),
|
||||
'epoch': epoch,
|
||||
'loss': loss
|
||||
'loss': loss,
|
||||
'feature_scaler': pickle.dumps(self.feature_scaler),
|
||||
'target_scaler': pickle.dumps(self.target_scaler)
|
||||
}, path)
|
||||
except Exception as e:
|
||||
print(f"[AI] Checkpoint save failed ({ticker}): {e}")
|
||||
|
||||
def train_and_predict(self, prices, forecast_days=1, ticker=None):
|
||||
def _is_checkpoint_fresh(self, ticker, max_age=None):
|
||||
"""체크포인트가 최근에 학습된 것인지 확인 (쿨다운 판단)"""
|
||||
if not ticker:
|
||||
return False
|
||||
path = self._get_checkpoint_path(ticker)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
age = time.time() - os.path.getmtime(path)
|
||||
threshold = max_age if max_age is not None else Config.LSTM_COOLDOWN
|
||||
return age < threshold
|
||||
|
||||
def _prepare_scaled_features(self, features, split_point):
|
||||
"""
|
||||
피처 스케일링 (누수 방지: train split으로만 fit)
|
||||
features: (n, 7) numpy array
|
||||
split_point: train/val 분리 인덱스
|
||||
|
||||
Returns:
|
||||
scaled_features: (n, 7) 스케일된 전체 피처
|
||||
scaled_close: (n, 1) 스케일된 close (타겟용)
|
||||
"""
|
||||
# 6개 입력 피처 (close 포함 open/high/low/vol_norm/rsi/macd_hist)
|
||||
# + 타겟은 close만 별도 scaler
|
||||
input_features = features[:, :] # (n, 7) 전체 7개 피처 입력용
|
||||
target_close = features[:, 0:1] # (n, 1) close만 타겟용
|
||||
|
||||
# train 데이터로만 fit (데이터 누수 방지)
|
||||
self.feature_scaler.fit(input_features[:split_point])
|
||||
self.target_scaler.fit(target_close[:split_point])
|
||||
|
||||
scaled_features = self.feature_scaler.transform(input_features)
|
||||
scaled_close = self.target_scaler.transform(target_close)
|
||||
|
||||
return scaled_features, scaled_close
|
||||
|
||||
def _predict_only(self, ohlcv_data, ticker=None):
|
||||
"""학습 없이 현재 체크포인트로만 빠른 예측 (쿨다운 중 사용)"""
|
||||
prices = ohlcv_data.get('close', []) if isinstance(ohlcv_data, dict) else ohlcv_data
|
||||
if len(prices) < self.seq_length:
|
||||
return None
|
||||
try:
|
||||
features = _build_feature_matrix(
|
||||
ohlcv_data if isinstance(ohlcv_data, dict) else {'close': prices}
|
||||
)
|
||||
if len(features) < self.seq_length:
|
||||
return None
|
||||
|
||||
scaled = self.feature_scaler.transform(features)
|
||||
last_seq = torch.FloatTensor(scaled[-self.seq_length:]).unsqueeze(0).to(self.device)
|
||||
|
||||
self.model.eval()
|
||||
with torch.no_grad():
|
||||
if self.use_amp:
|
||||
with torch.amp.autocast('cuda'):
|
||||
pred_scaled = self.model(last_seq)
|
||||
else:
|
||||
pred_scaled = self.model(last_seq)
|
||||
|
||||
predicted_price = self.target_scaler.inverse_transform(
|
||||
pred_scaled.cpu().float().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
|
||||
|
||||
cached_loss = self.training_status.get("loss", 0.5)
|
||||
print(f"[AI] {ticker or '?'}: 쿨다운 중 → 캐시 예측 사용 "
|
||||
f"({predicted_price:.0f} / {change_rate:+.2f}%)")
|
||||
return {
|
||||
"current": current_price,
|
||||
"predicted": float(predicted_price),
|
||||
"change_rate": round(change_rate, 2),
|
||||
"trend": trend,
|
||||
"loss": cached_loss,
|
||||
"val_loss": cached_loss,
|
||||
"confidence": 0.62,
|
||||
"epochs": 0,
|
||||
"device": str(self.device),
|
||||
"lr": self.optimizer.param_groups[0]['lr'],
|
||||
"cached": True
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[AI] _predict_only 실패 ({ticker}): {e}")
|
||||
return None
|
||||
|
||||
def train_and_predict(self, ohlcv_data, forecast_days=1, ticker=None):
|
||||
"""
|
||||
[v3.0] 7차원 멀티피처 LSTM 학습 + 예측
|
||||
ohlcv_data: dict {'close':[], 'open':[], 'high':[], 'low':[], 'volume':[]}
|
||||
또는 list (하위 호환: close 리스트)
|
||||
"""
|
||||
# 하위 호환: list 형태
|
||||
if isinstance(ohlcv_data, list):
|
||||
ohlcv_data = {'close': ohlcv_data}
|
||||
|
||||
prices = ohlcv_data.get('close', [])
|
||||
if len(prices) < (self.seq_length + 10):
|
||||
return None
|
||||
|
||||
# ===== 쿨다운 체크 =====
|
||||
if self._is_checkpoint_fresh(ticker):
|
||||
has_ckpt = self._load_checkpoint(ticker)
|
||||
if has_ckpt:
|
||||
result = self._predict_only(ohlcv_data, ticker)
|
||||
if result:
|
||||
return result
|
||||
|
||||
is_gpu = self.device.type == 'cuda'
|
||||
|
||||
# --- Ollama 모델 언로드 (GPU 메모리 확보) ---
|
||||
# VRAM 여유량 기반 Ollama 언로드
|
||||
if is_gpu:
|
||||
_unload_ollama()
|
||||
torch.cuda.empty_cache()
|
||||
@@ -214,54 +426,54 @@ class PricePredictor:
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
# 1. 데이터 전처리 (CPU에서 numpy 작업)
|
||||
data = np.array(prices).reshape(-1, 1)
|
||||
scaled_data = self.scaler.fit_transform(data)
|
||||
# 1. 피처 행렬 구성 (n, 7)
|
||||
features = _build_feature_matrix(ohlcv_data)
|
||||
if len(features) < (self.seq_length + 10):
|
||||
return None
|
||||
|
||||
n = len(features)
|
||||
split_point = int(n * 0.8)
|
||||
|
||||
# 2. 스케일링 (train 데이터로만 fit → 누수 방지)
|
||||
scaled_features, scaled_close = self._prepare_scaled_features(features, split_point)
|
||||
|
||||
# 3. 시퀀스 생성
|
||||
x_seqs, y_seqs = [], []
|
||||
for i in range(len(scaled_data) - self.seq_length):
|
||||
x_seqs.append(scaled_data[i:i + self.seq_length])
|
||||
y_seqs.append(scaled_data[i + self.seq_length])
|
||||
for i in range(n - self.seq_length):
|
||||
x_seqs.append(scaled_features[i:i + self.seq_length]) # (seq, 7)
|
||||
y_seqs.append(scaled_close[i + self.seq_length]) # (1,)
|
||||
|
||||
# 2. 텐서 생성 → 즉시 GPU로 이동 (이후 CPU↔GPU 전송 없음)
|
||||
x_all = torch.FloatTensor(np.array(x_seqs)).to(self.device)
|
||||
y_all = torch.FloatTensor(np.array(y_seqs)).to(self.device)
|
||||
|
||||
# Validation split (80/20)
|
||||
split_idx = int(len(x_all) * 0.8)
|
||||
x_train = x_all[:split_idx]
|
||||
y_train = y_all[:split_idx]
|
||||
x_val = x_all[split_idx:]
|
||||
y_val = y_all[split_idx:]
|
||||
|
||||
# validation split (80/20)
|
||||
seq_split = int(len(x_all) * 0.8)
|
||||
x_train, y_train = x_all[:seq_split], y_all[:seq_split]
|
||||
x_val, y_val = x_all[seq_split:], y_all[seq_split:]
|
||||
dataset_size = len(x_train)
|
||||
|
||||
# 3. 체크포인트 로드
|
||||
has_checkpoint = False
|
||||
if ticker:
|
||||
has_checkpoint = self._load_checkpoint(ticker)
|
||||
max_epochs = 50 if has_checkpoint else self.max_epochs
|
||||
# 4. 체크포인트 로드
|
||||
has_checkpoint = self._load_checkpoint(ticker) if ticker else False
|
||||
max_epochs = Config.LSTM_FAST_EPOCHS if has_checkpoint else self.max_epochs
|
||||
|
||||
# 4. 학습 (전체 데이터 GPU 상주, DataLoader 미사용)
|
||||
# [v2.0] LR Scheduler 리셋
|
||||
self.optimizer.param_groups[0]['lr'] = 0.001 if not has_checkpoint else 0.0005
|
||||
self.lr_scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
||||
self.optimizer, mode='min', factor=0.5, patience=7, min_lr=1e-6, verbose=False
|
||||
self.optimizer, mode='min', factor=0.5, patience=7, min_lr=1e-6
|
||||
)
|
||||
|
||||
# 5. 학습
|
||||
self.model.train()
|
||||
self.training_status["is_training"] = True
|
||||
if ticker:
|
||||
self.training_status["current_ticker"] = ticker
|
||||
|
||||
best_val_loss = float('inf')
|
||||
best_model_state = None # [v2.0] Best Model 저장
|
||||
best_model_state = None
|
||||
patience_counter = 0
|
||||
final_loss = 0.0
|
||||
actual_epochs = 0
|
||||
|
||||
for epoch in range(max_epochs):
|
||||
# --- Training (GPU 내에서 셔플 + 미니배치) ---
|
||||
perm = torch.randperm(dataset_size, device=self.device)
|
||||
x_shuffled = x_train[perm]
|
||||
y_shuffled = y_train[perm]
|
||||
@@ -281,7 +493,6 @@ class PricePredictor:
|
||||
outputs = self.model(batch_x)
|
||||
loss = self.criterion(outputs, batch_y)
|
||||
self.scaler_amp.scale(loss).backward()
|
||||
# [v2.0] Gradient Clipping (AMP 호환)
|
||||
self.scaler_amp.unscale_(self.optimizer)
|
||||
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.max_grad_norm)
|
||||
self.scaler_amp.step(self.optimizer)
|
||||
@@ -290,7 +501,6 @@ class PricePredictor:
|
||||
outputs = self.model(batch_x)
|
||||
loss = self.criterion(outputs, batch_y)
|
||||
loss.backward()
|
||||
# [v2.0] Gradient Clipping
|
||||
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.max_grad_norm)
|
||||
self.optimizer.step()
|
||||
|
||||
@@ -299,7 +509,6 @@ class PricePredictor:
|
||||
|
||||
train_loss = epoch_loss / max(1, steps)
|
||||
|
||||
# --- Validation (GPU에서 직접 수행) ---
|
||||
self.model.eval()
|
||||
with torch.no_grad():
|
||||
if self.use_amp:
|
||||
@@ -311,23 +520,19 @@ class PricePredictor:
|
||||
val_loss = self.criterion(val_out, y_val).item()
|
||||
self.model.train()
|
||||
|
||||
# [v2.0] LR Scheduler step (val_loss 기반)
|
||||
self.lr_scheduler.step(val_loss)
|
||||
|
||||
final_loss = train_loss
|
||||
actual_epochs = epoch + 1
|
||||
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
patience_counter = 0
|
||||
# [v2.0] Best model 상태 저장 (overfitting 방지)
|
||||
best_model_state = {k: v.clone() for k, v in self.model.state_dict().items()}
|
||||
else:
|
||||
patience_counter += 1
|
||||
if patience_counter >= self.patience:
|
||||
break
|
||||
|
||||
# [v2.0] Best model 복원 (early stopping 후 최적 상태로 복구)
|
||||
if best_model_state:
|
||||
self.model.load_state_dict(best_model_state)
|
||||
|
||||
@@ -340,17 +545,17 @@ class PricePredictor:
|
||||
elapsed = time.time() - t_start
|
||||
print(f"[AI] {ticker or '?'}: {actual_epochs} epochs in {elapsed:.1f}s"
|
||||
f" | loss={final_loss:.6f} val={best_val_loss:.6f}"
|
||||
f" | device={self.device}")
|
||||
f" | device={self.device} | features={INPUT_SIZE}")
|
||||
|
||||
# 5. 체크포인트 저장
|
||||
# 6. 체크포인트 저장 (scaler 포함)
|
||||
if ticker:
|
||||
self._save_checkpoint(ticker, actual_epochs, final_loss)
|
||||
|
||||
# 6. 예측
|
||||
# 7. 예측
|
||||
self.model.eval()
|
||||
with torch.no_grad():
|
||||
last_seq = torch.FloatTensor(
|
||||
scaled_data[-self.seq_length:]
|
||||
scaled_features[-self.seq_length:]
|
||||
).unsqueeze(0).to(self.device)
|
||||
|
||||
if self.use_amp:
|
||||
@@ -359,12 +564,11 @@ class PricePredictor:
|
||||
else:
|
||||
predicted_scaled = self.model(last_seq)
|
||||
|
||||
predicted_price = self.scaler.inverse_transform(
|
||||
predicted_price = self.target_scaler.inverse_transform(
|
||||
predicted_scaled.cpu().float().numpy())[0][0]
|
||||
|
||||
# 7. GPU 메모리 정리 + Ollama 리로드
|
||||
# 8. GPU 정리 + Ollama 리로드
|
||||
if is_gpu:
|
||||
# 학습 중간 텐서 해제
|
||||
del x_all, y_all, x_train, y_train, x_val, y_val
|
||||
torch.cuda.empty_cache()
|
||||
_log_gpu_memory("post-train")
|
||||
@@ -374,27 +578,22 @@ class PricePredictor:
|
||||
trend = "UP" if predicted_price > current_price else "DOWN"
|
||||
change_rate = ((predicted_price - current_price) / current_price) * 100
|
||||
|
||||
# [v2.0] 개선된 신뢰도 계산
|
||||
# 1. 학습 손실 기반 (낮을수록 좋음)
|
||||
# 신뢰도 계산
|
||||
loss_confidence = 1.0 / (1.0 + (best_val_loss * 50))
|
||||
|
||||
# 2. Train/Val 괴리도 (overfitting 감지)
|
||||
overfit_ratio = final_loss / (best_val_loss + 1e-9)
|
||||
if overfit_ratio < 0.5:
|
||||
# Train loss가 Val loss보다 훨씬 낮음 = overfitting
|
||||
overfit_penalty = 0.7
|
||||
elif overfit_ratio > 2.0:
|
||||
# Train loss가 Val loss보다 훨씬 높음 = underfitting
|
||||
overfit_penalty = 0.8
|
||||
else:
|
||||
overfit_penalty = 1.0
|
||||
|
||||
# 3. 에포크 수 기반 (너무 적거나 많으면 불신)
|
||||
epoch_factor = 1.0
|
||||
if actual_epochs < 10:
|
||||
epoch_factor = 0.6 # 학습 부족
|
||||
epoch_factor = 0.6
|
||||
elif actual_epochs >= max_epochs:
|
||||
epoch_factor = 0.8 # 수렴 실패
|
||||
epoch_factor = 0.8
|
||||
|
||||
confidence = min(0.95, loss_confidence * overfit_penalty * epoch_factor)
|
||||
|
||||
@@ -411,28 +610,32 @@ class PricePredictor:
|
||||
"lr": self.optimizer.param_groups[0]['lr']
|
||||
}
|
||||
|
||||
def batch_predict(self, prices_dict):
|
||||
def batch_predict(self, ohlcv_dict):
|
||||
"""여러 종목을 배치로 예측 (체크포인트 있는 종목만)"""
|
||||
results = {}
|
||||
seqs = []
|
||||
metas = []
|
||||
|
||||
for ticker, prices in prices_dict.items():
|
||||
for ticker, ohlcv_data in ohlcv_dict.items():
|
||||
if isinstance(ohlcv_data, list):
|
||||
ohlcv_data = {'close': ohlcv_data}
|
||||
prices = ohlcv_data.get('close', [])
|
||||
if len(prices) < (self.seq_length + 10):
|
||||
results[ticker] = None
|
||||
continue
|
||||
|
||||
data = np.array(prices).reshape(-1, 1)
|
||||
scaler = MinMaxScaler(feature_range=(0, 1))
|
||||
scaled_data = scaler.fit_transform(data)
|
||||
|
||||
seq = torch.FloatTensor(scaled_data[-self.seq_length:]).unsqueeze(0)
|
||||
seqs.append(seq)
|
||||
metas.append((ticker, scaler, prices[-1]))
|
||||
try:
|
||||
features = _build_feature_matrix(ohlcv_data)
|
||||
scaled = self.feature_scaler.transform(features)
|
||||
seq = torch.FloatTensor(scaled[-self.seq_length:]).unsqueeze(0)
|
||||
seqs.append(seq)
|
||||
metas.append((ticker, prices[-1]))
|
||||
except Exception:
|
||||
results[ticker] = None
|
||||
|
||||
if not seqs:
|
||||
return results
|
||||
|
||||
# 배치로 합쳐서 한번에 GPU 추론
|
||||
batch = torch.cat(seqs, dim=0).to(self.device)
|
||||
|
||||
self.model.eval()
|
||||
@@ -445,8 +648,8 @@ class PricePredictor:
|
||||
|
||||
preds_cpu = preds.cpu().float().numpy()
|
||||
|
||||
for i, (ticker, scaler, current_price) in enumerate(metas):
|
||||
predicted_price = scaler.inverse_transform(preds_cpu[i:i+1])[0][0]
|
||||
for i, (ticker, current_price) in enumerate(metas):
|
||||
predicted_price = self.target_scaler.inverse_transform(preds_cpu[i:i+1])[0][0]
|
||||
trend = "UP" if predicted_price > current_price else "DOWN"
|
||||
change_rate = ((predicted_price - current_price) / current_price) * 100
|
||||
|
||||
@@ -461,3 +664,52 @@ class PricePredictor:
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""
|
||||
[v3.0] 종목별 LSTM 모델 격리 (LRU 퇴출, max_models=5)
|
||||
- 싱글톤 패턴: 워커 프로세스마다 하나의 Registry 유지
|
||||
- 16GB VRAM에서 LSTM 5개(~250MB) + Ollama 7B(~4GB) 동시 적재 가능
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, max_models=5):
|
||||
self.max_models = max_models
|
||||
self._predictors = OrderedDict() # ticker -> PricePredictor (LRU 순서)
|
||||
print(f"[ModelRegistry] Initialized (max_models={max_models})")
|
||||
|
||||
def get_predictor(self, ticker):
|
||||
"""종목별 PricePredictor 반환 (없으면 생성, LRU 관리)"""
|
||||
if ticker in self._predictors:
|
||||
# LRU: 접근 시 맨 뒤로 이동
|
||||
self._predictors.move_to_end(ticker)
|
||||
return self._predictors[ticker]
|
||||
|
||||
# 용량 초과 시 가장 오래된 것 퇴출
|
||||
if len(self._predictors) >= self.max_models:
|
||||
oldest_ticker, oldest_pred = self._predictors.popitem(last=False)
|
||||
print(f"[ModelRegistry] Evicted {oldest_ticker} (LRU, {len(self._predictors)}/{self.max_models})")
|
||||
del oldest_pred
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
predictor = PricePredictor()
|
||||
self._predictors[ticker] = predictor
|
||||
print(f"[ModelRegistry] Created predictor for {ticker} ({len(self._predictors)}/{self.max_models})")
|
||||
return predictor
|
||||
|
||||
def has_predictor(self, ticker):
|
||||
return ticker in self._predictors
|
||||
|
||||
def clear(self):
|
||||
"""모든 모델 해제"""
|
||||
self._predictors.clear()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
@@ -22,31 +22,32 @@ class MacroAnalyzer:
|
||||
"""
|
||||
indicators = {
|
||||
"KOSPI": "0001",
|
||||
"KOSDAQ": "1001"
|
||||
"KOSDAQ": "1001",
|
||||
"KOSPI200": "0028",
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
time.sleep(0.6) # Rate Limit 방지 (초당 2회 제한)
|
||||
|
||||
if data:
|
||||
price = data['price']
|
||||
|
||||
if data and data.get('price', 0) != 0:
|
||||
results[name] = data
|
||||
print(f" - {name}: {data['price']} ({data['change']}%)")
|
||||
|
||||
# 리스크 평가 로직 (2% 이상 폭락 장이면 위험)
|
||||
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}
|
||||
results[name] = {"price": 0, "change": 0, "high": 0, "low": 0,
|
||||
"prev_close": 0, "volume": 0, "trade_value": 0}
|
||||
|
||||
# [신규] 시장 스트레스 지수(MSI) 추가
|
||||
time.sleep(0.6)
|
||||
|
||||
Reference in New Issue
Block a user