저는 HolySheep AI에서 2년간 금융 AI 시스템을 구축하며 수십억 건의 시장 데이터를 처리해온 엔지니어입니다. 이번 튜토리얼에서는 Tardis의 실시간 주문서(Oder Book) 데이터를 활용하여 암호화폐 가격 움직임을 예측하는(end-to-end ML 파이프라인)를 상세히 다룹니다. Tardis는 Binance, Bybit, OKX 등 30개 이상의 거래소에서 마이크로초 단위의 호가창 데이터를 제공하는 전문 데이터 공급자입니다.
아키텍처 개요: 왜 주문서 데이터인가?
주문서는 특정 시점에서 특정 가격에 대기 중인 매수/매도 주문의 누적량을 보여줍니다. 이 데이터는 거래소 내 유동성 분포를 실시간으로 반영하며, 시장 미세 구조(Market Microstructure) 분석의 핵심입니다. 전통적인 시계열 모델(ARIMA, LSTM)보다 주문서 기반 피처 엔지니어링이 단기 가격 변동 예측에서 통계적으로 우월한 결과를 보여줍니다.
# 프로젝트 구조
orderbook-prediction/
├── config/
│ ├── __init__.py
│ └── settings.py # 하이퍼파라미터 및 API 설정
├── data/
│ ├── fetcher.py # Tardis 데이터 수집기
│ └── processor.py # 실시간 전처리 파이프라인
├── features/
│ ├── orderbook_features.py # 주문서 파생 피처
│ └── momentum_features.py # 모멘텀 및 변동성 피처
├── models/
│ ├── lstm_model.py # LSTM 기반 예측기
│ └── transformer_model.py # Transformer 기반 예측기
├── training/
│ ├── trainer.py # 학습 루프
│ └── evaluator.py # 모델 평가
├── inference/
│ └── predictor.py # 실시간 추론 서버
├── tests/
│ └── test_pipeline.py
├── main.py
├── requirements.txt
└── docker-compose.yml
1. 환경 설정 및 의존성
먼저 필수 패키지를 설치합니다. Tardis SDK는 WebSocket을 통해 실시간 데이터를 스트리밍하며, PyTorch로 모델을 구현합니다.
# requirements.txt
tardis-dev==1.8.0
torch==2.1.0
torchvision==0.16.0
numpy==1.24.3
pandas==2.0.3
scikit-learn==1.3.2
ta==0.10.2
redis==5.0.1
fastapi==0.104.1
uvicorn==0.24.0
python-dotenv==1.0.0
optuna==3.5.0
# config/settings.py
import os
from dataclasses import dataclass
from typing import List
@dataclass
class ModelConfig:
sequence_length: int = 60 # 60초 윈도우 (1분봉 기준)
prediction_horizon: int = 5 # 5초 후 예측
hidden_dim: int = 256
num_layers: int = 3
dropout: float = 0.2
learning_rate: float = 1e-4
batch_size: int = 128
epochs: int = 100
@dataclass
class DataConfig:
exchanges: List[str] = None # ["binance", "bybit", "okx"]
symbols: List[str] = None # ["BTCUSDT", "ETHUSDT"]
channels: List[str] = None # ["orderbook", "trade"]
def __post_init__(self):
self.exchanges = self.exchanges or ["binance"]
self.symbols = self.symbols or ["BTCUSDT"]
self.channels = self.channels or ["orderbook_snapshot"]
@dataclass
class HolySheepConfig:
base_url: str = "https://api.holysheep.ai/v1"
api_key: str = os.getenv("HOLYSHEEP_API_KEY", "YOUR_HOLYSHEEP_API_KEY")
model_name: str = "gpt-4.1" # 피처 생성용 LLM
max_tokens: int = 500
temperature: float = 0.7
HolySheep AI를 사용한 강화 학습 에이전트용 설정
@dataclass
class RLConfig:
use_holysheep: bool = True # HolySheep AI Inference API 사용
inference_endpoint: str = HolySheepConfig.base_url + "/chat/completions"
reinforcement_model: str = "gpt-4.1"
cache_enabled: bool = True
cache_ttl: int = 300 # 5분 캐시
환경 변수 로드
from dotenv import load_dotenv
load_dotenv()
2. Tardis 데이터 수집기 구현
Tardis는 고성능 WebSocket 스트리밍을 제공합니다. Binance의 경우 100ms 간격으로 주문서 스냅샷이 전송되며, 이를 버퍼링하여 배치 처리합니다.
# data/fetcher.py
import asyncio
import json
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass, field
from tardis import Tardis
from tardis.channels.exchange import Binance
import pandas as pd
from datetime import datetime
import redis.asyncio as redis
@dataclass
class OrderBookSnapshot:
exchange: str
symbol: str
timestamp: int
bids: List[List[float]] # [[price, quantity], ...]
asks: List[List[float]] # [[price, quantity], ...]
@property
def mid_price(self) -> float:
if not self.bids or not self.asks:
return 0.0
return (float(self.bids[0][0]) + float(self.asks[0][0])) / 2
def to_dataframe(self) -> pd.DataFrame:
return pd.DataFrame({
'timestamp': [self.timestamp] * len(self.bids + self.asks),
'price': [float(x[0]) for x in self.bids + self.asks],
'quantity': [float(x[1]) for x in self.bids + self.asks],
'side': ['bid'] * len(self.bids) + ['ask'] * len(self.asks),
'exchange': [self.exchange] * (len(self.bids) + len(self.asks)),
'symbol': [self.symbol] * (len(self.bids) + len(self.asks))
})
class TardisCollector:
"""Tardis 실시간 주문서 데이터 수집기"""
def __init__(
self,
exchanges: List[str],
symbols: List[str],
buffer_size: int = 1000,
redis_url: str = "redis://localhost:6379"
):
self.exchanges = exchanges
self.symbols = symbols
self.buffer_size = buffer_size
self.buffer: Dict[str, List[OrderBookSnapshot]] = {}
self.redis_client: Optional[redis.Redis] = None
self.redis_url = redis_url
self._running = False
# 버퍼 초기화
for symbol in symbols:
self.buffer[symbol] = []
async def connect_redis(self):
"""Redis 연결 (시계열 캐싱용)"""
self.redis_client = await redis.from_url(
self.redis_url,
encoding="utf-8",
decode_responses=True
)
print(f"✅ Redis 연결 완료: {self.redis_url}")
def _get_exchange_channel(self, exchange: str, symbol: str):
"""거래소별 채널 매핑"""
exchange_map = {
"binance": Binance(
exchange="binance",
channels=[f"orderbook_{symbol.replace('USDT', '').lower()}@depth20"
for symbol in self.symbols]
)
}
return exchange_map.get(exchange)
async def collect(
self,
duration_seconds: Optional[int] = None,
callback: Optional[Callable] = None
):
"""
지정된 시간 동안 데이터 수집
Args:
duration_seconds: 수집 시간 (None이면 무한 수집)
callback: 각 스냅샷마다 호출될 콜백 함수
"""
self._running = True
async with Tardis() as tardis:
# Binance WebSocket 구독
channels = [
Binance(
exchange="binance",
channels=[f"orderbook_{symbol.replace('USDT', '').lower()}@depth20@100ms"
for symbol in self.symbols]
)
]
await tardis.subscribe(channels=channels)
print(f"📡 Tardis 스트리밍 시작: {self.exchanges} - {self.symbols}")
start_time = asyncio.get_event_loop().time()
async for site_name, exchange_name, channel_name, message in tardis.stream():
if not self._running:
break
try:
# 주문서 데이터 파싱
snapshot = self._parse_orderbook_message(
exchange_name, channel_name, message
)
if snapshot:
# 버퍼에 저장
self.buffer[snapshot.symbol].append(snapshot)
# Redis 캐싱 (TTL: 5분)
if self.redis_client:
await self._cache_snapshot(snapshot)
# 버퍼 크기 제한
if len(self.buffer[snapshot.symbol]) > self.buffer_size:
self.buffer[snapshot.symbol].pop(0)
# 콜백 실행
if callback:
await callback(snapshot)
except Exception as e:
print(f"⚠️ 메시지 파싱 오류: {e}")
# 시간 제한 체크
if duration_seconds:
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed >= duration_seconds:
break
def _parse_orderbook_message(
self,
exchange: str,
channel: str,
message: dict
) -> Optional[OrderBookSnapshot]:
"""Tardis 메시지를 OrderBookSnapshot으로 변환"""
try:
symbol = message.get('s', '')
return OrderBookSnapshot(
exchange=exchange,
symbol=symbol,
timestamp=message.get('E', 0), # EventTime
bids=[[float(p), float(q)] for p, q in message.get('b', [])],
asks=[[float(p), float(q)] for p, q in message.get('a', [])]
)
except (KeyError, ValueError, TypeError) as e:
return None
async def _cache_snapshot(self, snapshot: OrderBookSnapshot):
"""Redis에 스냅샷 캐싱"""
key = f"orderbook:{snapshot.exchange}:{snapshot.symbol}:{snapshot.timestamp}"
data = json.dumps({
'bids': snapshot.bids,
'asks': snapshot.asks,
'mid_price': snapshot.mid_price
})
await self.redis_client.setex(key, 300, data) # 5분 TTL
async def get_historical(
self,
exchange: str,
symbol: str,
start: datetime,
end: datetime
) -> pd.DataFrame:
"""과거 데이터 조회 (Tardis Replay API)"""
async with Tardis() as tardis:
channels = [
Binance(
exchange=exchange,
channels=[f"orderbook_{symbol.replace('USDT', '').lower()}@depth20"]
)
]
await tardis.subscribe(channels=channels)
dfs = []
async for site_name, exchange_name, channel_name, message in tardis.stream():
snapshot = self._parse_orderbook_message(
exchange_name, channel_name, message
)
if snapshot:
dfs.append(snapshot.to_dataframe())
# 지정 시간 범위 체크
timestamp = datetime.fromtimestamp(snapshot.timestamp / 1000)
if timestamp >= end:
break
return pd.concat(dfs, ignore_index=True) if dfs else pd.DataFrame()
def stop(self):
"""수집 중지"""
self._running = False
print("⏹️ 데이터 수집 중지됨")
async def close(self):
"""리소스 정리"""
if self.redis_client:
await self.redis_client.close()
self.stop()
3. 주문서 파생 피처 엔지니어링
원시 주문서 데이터에서 예측에 유용한 피처를 추출합니다. 핵심 지표들은 시장 심리와 유동성 상황을 정량화합니다.
# features/orderbook_features.py
import numpy as np
import pandas as pd
from typing import List, Tuple
from dataclasses import dataclass
from numba import jit
@dataclass
class OrderBookFeatures:
"""주문서에서 추출한 피처 벡터"""
# 유동성 지표
bid_volume: float # 매수측 총 물량
ask_volume: float # 매도측 총 물량
volume_imbalance: float # 물량 불균형 (bid - ask) / (bid + ask)
bid_volume_depth10: float # 최상위 10단계 물량 합계
ask_volume_depth10: float
# 스프레드 지표
spread: float # 최우선 매도 - 최우선 매수
spread_pct: float # 스프레드 / mid_price
spread_depth5: float # 5단계 스프레드
# 가격 압박 지표
weighted_mid_price: float # 물량 가중 중립가
price_pressure: float # 가격 압박 지수
microprice: float # 마이크로프라이스
# 모멘텀 지표
mid_price_return: float # 중립가 수익률
price_acceleration: float # 가격 가속도
volume_weighted_price: float
# 변동성 지표
volatility: float # 최근 변동성
bid_ask_ratio: float # 매수/매도 비율
@classmethod
def from_snapshot(cls, snapshot, prev_snapshot=None) -> 'OrderBookFeatures':
"""OrderBookSnapshot에서 피처 생성"""
bids = np.array(snapshot.bids, dtype=np.float64)
asks = np.array(snapshot.asks, dtype=np.float64)
if len(bids) == 0 or len(asks) == 0:
return cls(*([0.0] * 15))
bid_prices = bids[:, 0]
bid_qtys = bids[:, 1]
ask_prices = asks[:, 0]
ask_qtys = asks[:, 1]
# 기본 유동성 지표
bid_volume = np.sum(bid_qtys)
ask_volume = np.sum(ask_qtys)
volume_imbalance = (bid_volume - ask_volume) / (bid_volume + ask_volume + 1e-10)
# 10단계 깊이
bid_volume_depth10 = np.sum(bid_qtys[:10])
ask_volume_depth10 = np.sum(ask_qtys[:10])
# 스프레드
best_bid = bid_prices[0]
best_ask = ask_prices[0]
mid_price = (best_bid + best_ask) / 2
spread = best_ask - best_bid
spread_pct = spread / mid_price if mid_price > 0 else 0
# 5단계 스프레드
spread_depth5 = (ask_prices[4] - bid_prices[4]) / mid_price if len(bid_prices) >= 5 else spread_pct
# 가중 중립가 및 마이크로프라이스
total_volume = bid_volume + ask_volume
weighted_mid_price = (
np.sum(bid_prices * bid_qtys) / (bid_volume + 1e-10) * ask_volume +
np.sum(ask_prices * ask_qtys) / (ask_volume + 1e-10) * bid_volume
) / (total_volume + 1e-10)
# 마이크로프라이스 (VWAP 기반 가격 압박)
microprice = (best_bid * ask_volume + best_ask * bid_volume) / (bid_volume + ask_volume + 1e-10)
# 가격 압박 지수
price_pressure = (bid_volume - ask_volume) * (best_ask - best_bid) / mid_price
# 변동성 및 모멘텀
return_rate = 0.0
price_acceleration = 0.0
if prev_snapshot and prev_snapshot.mid_price > 0:
return_rate = (mid_price - prev_snapshot.mid_price) / prev_snapshot.mid_price
price_acceleration = return_rate * 10 # 시간 정규화
# VWAP
vwap = (np.sum(bid_prices * bid_qtys) + np.sum(ask_prices * ask_qtys)) / total_volume
return cls(
bid_volume=float(bid_volume),
ask_volume=float(ask_volume),
volume_imbalance=float(volume_imbalance),
bid_volume_depth10=float(bid_volume_depth10),
ask_volume_depth10=float(ask_volume_depth10),
spread=float(spread),
spread_pct=float(spread_pct),
spread_depth5=float(spread_depth5),
weighted_mid_price=float(weighted_mid_price),
price_pressure=float(price_pressure),
microprice=float(microprice),
mid_price_return=float(return_rate),
price_acceleration=float(price_acceleration),
volume_weighted_price=float(vwap)
)
def to_array(self) -> np.ndarray:
"""피처 벡터를 numpy 배열로 변환"""
return np.array([
self.bid_volume,
self.ask_volume,
self.volume_imbalance,
self.bid_volume_depth10,
self.ask_volume_depth10,
self.spread,
self.spread_pct,
self.spread_depth5,
self.weighted_mid_price,
self.price_pressure,
self.microprice,
self.mid_price_return,
self.price_acceleration,
self.volume_weighted_price
], dtype=np.float32)
class FeatureProcessor:
"""피처 전처리 및 정규화 파이프라인"""
def __init__(self, feature_dim: int = 14):
self.feature_dim = feature_dim
self.scaler_mean: np.ndarray = None
self.scaler_std: np.ndarray = None
self.is_fitted = False
def fit(self, features_list: List[OrderBookFeatures]):
"""학습 데이터로 정규화 파라미터 계산"""
X = np.array([f.to_array() for f in features_list])
self.scaler_mean = np.mean(X, axis=0)
self.scaler_std = np.std(X, axis=0) + 1e-8
self.is_fitted = True
print(f"✅ 피처 정규화 파라미터 학습 완료: mean.shape={self.scaler_mean.shape}")
def transform(self, features: OrderBookFeatures) -> np.ndarray:
"""정규화 변환"""
if not self.is_fitted:
raise ValueError("FeatureProcessor가 먼저 fit()되어야 합니다")
x = features.to_array()
return (x - self.scaler_mean) / self.scaler_std
def fit_transform(self, features_list: List[OrderBookFeatures]) -> np.ndarray:
"""fit + transform"""
self.fit(features_list)
return np.array([self.transform(f) for f in features_list])
def inverse_transform(self, X_normalized: np.ndarray) -> np.ndarray:
"""역정규화 (예측 결과 복원)"""
return X_normalized * self.scaler_std + self.scaler_mean
@jit(nopython=True)
def calculate_rolling_features(
prices: np.ndarray,
volumes: np.ndarray,
window: int = 60
) -> np.ndarray:
"""Numba JIT 가속 롤링 통계량 계산"""
n = len(prices)
result = np.zeros((n, 5), dtype=np.float64)
for i in range(window, n):
price_window = prices[i-window:i]
volume_window = volumes[i-window:i]
# 롤링 평균
result[i, 0] = np.mean(price_window)
# 롤링 표준편차
result[i, 1] = np.std(price_window)
# VWAP
result[i, 2] = np.sum(price_window * volume_window) / np.sum(volume_window)
# 변동성 ( Parkinson volatility )
high = np.max(price_window)
low = np.min(price_window)
result[i, 3] = np.log(high / low) / np.sqrt(2) if high > low else 0
# 모멘텀
result[i, 4] = (prices[i] - prices[i-window]) / prices[i-window]
return result
4. LSTM + Attention 기반 예측 모델
시계열 패턴 학습에 최적화된 LSTM과 Self-Attention 메커니즘을 결합합니다. HolySheep AI Inference API를 활용하면 추가적인 시장 분석 LLM을 쉽게 통합할 수 있습니다.
# models/lstm_attention_model.py
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Tuple, Optional
import math
class PositionalEncoding(nn.Module):
"""시퀀스 위치 정보 인코딩"""
def __init__(self, d_model: int, max_len: int = 5000, dropout: float = 0.1):
super().__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0) # (1, max_len, d_model)
self.register_buffer('pe', pe)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x + self.pe[:, :x.size(1), :]
return self.dropout(x)
class MultiHeadSelfAttention(nn.Module):
"""멀티헤드 셀프 어텐션"""
def __init__(self, d_model: int, num_heads: int = 8, dropout: float = 0.1):
super().__init__()
assert d_model % num_heads == 0
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads
self.q_linear = nn.Linear(d_model, d_model)
self.k_linear = nn.Linear(d_model, d_model)
self.v_linear = nn.Linear(d_model, d_model)
self.out_linear = nn.Linear(d_model, d_model)
self.dropout = nn.Dropout(dropout)
self.scale = math.sqrt(self.d_k)
def forward(
self,
x: torch.Tensor,
mask: Optional[torch.Tensor] = None
) -> torch.Tensor:
batch_size, seq_len, _ = x.size()
# Q, K, V 선형 변환
Q = self.q_linear(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
K = self.k_linear(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
V = self.v_linear(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
# 어텐션 스코어
scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attention_weights = F.softmax(scores, dim=-1)
attention_weights = self.dropout(attention_weights)
# 어텐션 결과
context = torch.matmul(attention_weights, V)
context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
return self.out_linear(context)
class TransformerEncoderLayer(nn.Module):
"""트랜스포머 인코더 레이어"""
def __init__(self, d_model: int, num_heads: int = 8, ff_dim: int = 512, dropout: float = 0.1):
super().__init__()
self.self_attn = MultiHeadSelfAttention(d_model, num_heads, dropout)
self.ffn = nn.Sequential(
nn.Linear(d_model, ff_dim),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(ff_dim, d_model),
nn.Dropout(dropout)
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> torch.Tensor:
# 셀프 어텐션 + 잔차 연결
attn_out = self.self_attn(x, mask)
x = self.norm1(x + self.dropout(attn_out))
# 피드포워드 + 잔차 연결
ffn_out = self.ffn(x)
x = self.norm2(x + ffn_out)
return x
class LSTMAttentionModel(nn.Module):
"""LSTM + Multi-Head Attention 기반 가격 예측 모델"""
def __init__(
self,
input_dim: int = 14,
hidden_dim: int = 256,
num_layers: int = 3,
dropout: float = 0.2,
prediction_horizon: int = 5,
use_transformer: bool = True
):
super().__init__()
self.hidden_dim = hidden_dim
self.num_layers = num_layers
self.prediction_horizon = prediction_horizon
# 입력 투영
self.input_projection = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.GELU(),
nn.Dropout(dropout)
)
# LSTM 레이어
self.lstm = nn.LSTM(
input_size=hidden_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
bidirectional=True
)
# 트랜스포머 인코더 (어텐션 메커니즘)
self.use_transformer = use_transformer
if use_transformer:
self.positional_encoding = PositionalEncoding(hidden_dim * 2, dropout=dropout)
self.transformer_encoder = nn.ModuleList([
TransformerEncoderLayer(
d_model=hidden_dim * 2,
num_heads=8,
ff_dim=hidden_dim * 4,
dropout=dropout
)
for _ in range(2)
])
# 어텐션 pooling
self.attention = nn.Sequential(
nn.Linear(hidden_dim * 2, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, 1)
)
# 예측 헤드
self.prediction_head = nn.Sequential(
nn.Linear(hidden_dim * 2, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, prediction_horizon) # 다중 스텝 예측
)
# 방향 분류기 (상승/하락/변화없음)
self.direction_head = nn.Sequential(
nn.Linear(hidden_dim * 2, hidden_dim // 2),
nn.GELU(),
nn.Linear(hidden_dim // 2, 3) # 3 클래스 분류
)
self._init_weights()
def _init_weights(self):
"""가중치 초기화 (Xavier)"""
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.xavier_uniform_(module.weight)
if module.bias is not None:
nn.init.zeros_(module.bias)
def forward(
self,
x: torch.Tensor,
return_attention: bool = False
) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
"""
Args:
x: (batch_size, seq_len, input_dim)
Returns:
price_prediction: (batch_size, prediction_horizon)
direction_logits: (batch_size, 3)
attention_weights: Optional[(batch_size, seq_len)]
"""
batch_size, seq_len, _ = x.size()
# 입력 투영
x = self.input_projection(x) # (B, L, hidden)
# Bidirectional LSTM
lstm_out, _ = self.lstm(x) # (B, L, hidden*2)
# 트랜스포머 인코더 (선택적)
if self.use_transformer:
encoded = self.positional_encoding(lstm_out)
for layer in self.transformer_encoder:
encoded = layer(encoded)
else:
encoded = lstm_out
# 어텐션 기반 시퀀스 pooling
attn_weights = self.attention(encoded) # (B, L, 1)
attn_weights = F.softmax(attn_weights, dim=1) # (B, L, 1)
# 가중합으로 단일 벡터 생성
context = torch.sum(encoded * attn_weights, dim=1) # (B, hidden*2)
# 예측
price_pred = self.prediction_head(context) # (B, horizon)
direction_logits = self.direction_head(context) # (B, 3)
if return_attention:
return price_pred, direction_logits, attn_weights.squeeze(-1)
return price_pred, direction_logits, None
class PricePredictionLoss(nn.Module):
"""복합 손실 함수"""
def __init__(
self,
price_weight: float = 1.0,
direction_weight: float = 0.3,
use_focal: bool = True
):
super().__init__()
self.price_weight = price_weight
self.direction_weight = direction_weight
self.use_focal = use_focal
self.mse = nn.MSELoss()
self.ce = nn.CrossEntropyLoss()
def forward(
self,
price_pred: torch.Tensor,
direction_logits: torch.Tensor,
price_target: torch.Tensor,
direction_target: torch.Tensor
) -> Tuple[torch.Tensor, dict]:
# 가격 예측 손실 (MSE)
price_loss = self.mse(price_pred, price_target)
# 방향 분류 손실 (Focal Loss)
if self.use_focal:
ce_loss = F.cross_entropy(direction_logits, direction_target, reduction='none')
pt = torch.exp(-ce_loss)
focal_loss = ((1 - pt) ** 2 * ce_loss).mean()
direction_loss = focal_loss
else:
direction_loss = self.ce(direction_logits, direction_target)
# 총 손실
total_loss = (
self.price_weight * price_loss +
self.direction_weight * direction_loss
)
return total_loss, {
'price_loss': price_loss.item(),
'direction_loss': direction_loss.item(),
'total_loss': total_loss.item()
}
5. 학습 파이프라인 및 평가
# training/trainer.py
import torch
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
import numpy as np
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass
import json
from pathlib import Path
from datetime import datetime
from tqdm import tqdm
import pandas as pd
from models.lstm_attention_model import LSTMAttentionModel, PricePredictionLoss
from features.orderbook_features import OrderBookFeatures, FeatureProcessor
@dataclass
class TrainingMetrics:
epoch: int
train_loss: float
val_loss: float
price_mae: float
direction_accuracy: float
directional_accuracy: float # 상승/하락 정확도
sharpe_ratio: float
max_drawdown: float
class OrderBookDataset(Dataset):
"""시퀀스 데이터셋"""
def __init__(
self,
sequences: np.ndarray,
price_targets: np.ndarray,
direction_targets: np.ndarray,
seq_len: int = 60
):
self.sequences = torch.FloatTensor(sequences)
self.price_targets = torch.FloatTensor(price_targets)
self.direction_targets = torch.LongTensor(direction_targets)
self.seq_len = seq_len
def __len__(self):
return len(self.sequences) - self.seq_len
def __getitem__(self, idx):
return (
self.sequences[idx:idx + self.seq_len],
self.price_targets[idx:idx + self.seq_len].mean(dim=0), # 타겟 기간 평균
self.direction_targets[idx + self.seq_len]
)
class ModelTrainer:
"""모델 학습 및 평가"""
def __init__(
self,
model: LSTMAttentionModel,
config,
device: str = "cuda" if torch.cuda.is_available() else "cpu"
):
self.model = model.to(device)
self.config = config
self.device = device
# 옵티마이저
self.optimizer = optim.AdamW(
model.parameters(),
lr=config.learning_rate,
weight_decay=1e-4
)
# 스케줄러
self.scheduler = CosineAnnealingWarmRestarts(
self.optimizer,
T_0=10,
T_mult=2,
eta_min=1e-6
)
# 손실 함수
self.criterion = PricePredictionLoss(
price_weight=1.0,
direction_weight=0.3
)
# 메트릭 기록
self.history: List[TrainingMetrics] = []
self.best_val_loss = float('inf')
def create_sequences(
self,
features: List[OrderBookFeatures],
prices: np.ndarray,
seq_len: int = 60,
horizon: int = 5
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""시퀀스 데이터 생성"""
feature_processor = FeatureProcessor()
X_normalized = feature_processor.fit_transform(features)
sequences = []
price_targets = []
direction_targets = []
for i in range(len(X_normalized) - seq_len - horizon):
# 입력 시퀀스
seq = X_normalized[i:i + seq_len]
sequences.append(seq)
# 가격 타겟 (horizon 후 수익률)
future_price = prices[i + seq_len + horizon]
current_price = prices[i + seq_len]
price_return = (future_price - current_price) / current_price
price_targets.append(price_return