Refactor intraday trading system for correctness and maintainability
Extract timestamp generation to shared helper, fix attention gating from broken softmax to sigmoid per-feature gates, and detach LSTM hidden state to prevent gradient accumulation. Backtester now uses real prices, proper position sizing, and sell logic instead of placeholder buys. Alpha calculation uses consistent geometric annualization. Add optional mark-to-market pricing to paper broker and close-position safety in real- time trader. Add sqlite3 import and fix pandas view warning in live data.
This commit is contained in:
+3
-37
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -15,6 +15,7 @@ from src.data.live_data import LiveDataService
|
|||||||
from src.data.pipeline import StockDataPipeline
|
from src.data.pipeline import StockDataPipeline
|
||||||
from src.models.intraday_gnn import IntradayGNN
|
from src.models.intraday_gnn import IntradayGNN
|
||||||
from src.models.trainer import GNNTrainer
|
from src.models.trainer import GNNTrainer
|
||||||
|
from src.utils.helpers import generate_intraday_timestamps
|
||||||
from src.utils.memory_manager import MemoryManager
|
from src.utils.memory_manager import MemoryManager
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -166,7 +167,7 @@ class LiveTradingSystem:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Get sequence of features
|
# Get sequence of features
|
||||||
timestamps = self._generate_timestamps_for_date(current_date)
|
timestamps = generate_intraday_timestamps(current_date)
|
||||||
current_idx = (
|
current_idx = (
|
||||||
timestamps.index(timestamp)
|
timestamps.index(timestamp)
|
||||||
if timestamp in timestamps
|
if timestamp in timestamps
|
||||||
@@ -278,41 +279,6 @@ class LiveTradingSystem:
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _generate_timestamps_for_date(self, date: str) -> List[str]:
|
|
||||||
"""Generate timestamps for a given date"""
|
|
||||||
# Get market open and close times
|
|
||||||
market_open = datetime.strptime(config.TRADING_HOURS["start"], "%H:%M").time()
|
|
||||||
market_close = datetime.strptime(config.TRADING_HOURS["end"], "%H:%M").time()
|
|
||||||
|
|
||||||
# Create datetime objects for open and close
|
|
||||||
open_datetime = datetime.strptime(f"{date} {market_open}", "%Y-%m-%d %H:%M:%S")
|
|
||||||
close_datetime = datetime.strptime(
|
|
||||||
f"{date} {market_close}", "%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate timestamps based on trading frequency
|
|
||||||
if config.TRADING_FREQUENCY == "1min":
|
|
||||||
delta = timedelta(minutes=1)
|
|
||||||
elif config.TRADING_FREQUENCY == "5min":
|
|
||||||
delta = timedelta(minutes=5)
|
|
||||||
elif config.TRADING_FREQUENCY == "15min":
|
|
||||||
delta = timedelta(minutes=15)
|
|
||||||
elif config.TRADING_FREQUENCY == "30min":
|
|
||||||
delta = timedelta(minutes=30)
|
|
||||||
elif config.TRADING_FREQUENCY == "1h":
|
|
||||||
delta = timedelta(hours=1)
|
|
||||||
else: # Default to 1 minute
|
|
||||||
delta = timedelta(minutes=1)
|
|
||||||
|
|
||||||
timestamps = []
|
|
||||||
current = open_datetime
|
|
||||||
|
|
||||||
while current <= close_datetime:
|
|
||||||
timestamps.append(current.strftime("%Y-%m-%d %H:%M:%S"))
|
|
||||||
current += delta
|
|
||||||
|
|
||||||
return timestamps
|
|
||||||
|
|
||||||
def _get_feature_sequence(self, ticker: str, start: str, end: str):
|
def _get_feature_sequence(self, ticker: str, start: str, end: str):
|
||||||
"""Get feature sequence for a ticker between start and end timestamps"""
|
"""Get feature sequence for a ticker between start and end timestamps"""
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Callable, Dict, List, Optional
|
from typing import Callable, Dict, List, Optional
|
||||||
@@ -390,10 +391,11 @@ class LiveDataService:
|
|||||||
if ticker not in self.price_bars or self.price_bars[ticker].empty:
|
if ticker not in self.price_bars or self.price_bars[ticker].empty:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the most recent bars
|
# Get the most recent bars (.copy() prevents SettingWithCopyWarning
|
||||||
|
# and ensures assignments below write to this local frame, not a view)
|
||||||
recent_bars = self.price_bars[ticker].iloc[
|
recent_bars = self.price_bars[ticker].iloc[
|
||||||
-config.REALTIME_FEATURE_WINDOW :
|
-config.REALTIME_FEATURE_WINDOW :
|
||||||
]
|
].copy()
|
||||||
|
|
||||||
if len(recent_bars) < 5: # Need at least 5 bars for meaningful features
|
if len(recent_bars) < 5: # Need at least 5 bars for meaningful features
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ Intraday backtesting framework.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import torch
|
||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
from src.trading.paper_broker import PaperTradingBroker
|
from src.trading.paper_broker import PaperTradingBroker
|
||||||
@@ -21,6 +22,30 @@ class IntradayBacktester:
|
|||||||
self.pipeline = pipeline
|
self.pipeline = pipeline
|
||||||
self.broker = PaperTradingBroker(initial_cash=config.INITIAL_CAPITAL)
|
self.broker = PaperTradingBroker(initial_cash=config.INITIAL_CAPITAL)
|
||||||
|
|
||||||
|
def _get_current_price(self, ticker: str, date) -> Optional[float]:
|
||||||
|
"""Look up the adjusted close price for a ticker on or before the given date."""
|
||||||
|
price_df = self.pipeline.price_data.get(ticker)
|
||||||
|
if price_df is None or price_df.empty:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if date in price_df.index:
|
||||||
|
return float(price_df.loc[date]["Close"])
|
||||||
|
idx = price_df.index.get_indexer([date], method="ffill")[0]
|
||||||
|
if idx >= 0:
|
||||||
|
return float(price_df.iloc[idx]["Close"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _portfolio_value(self, date) -> float:
|
||||||
|
"""Compute total portfolio value using current market prices."""
|
||||||
|
total = self.broker.cash
|
||||||
|
for ticker, qty in self.broker.positions.items():
|
||||||
|
price = self._get_current_price(ticker, date)
|
||||||
|
if price:
|
||||||
|
total += qty * price
|
||||||
|
return total
|
||||||
|
|
||||||
def run_backtest(self, dataset: List) -> Tuple[pd.Series, List]:
|
def run_backtest(self, dataset: List) -> Tuple[pd.Series, List]:
|
||||||
"""Run an intraday backtest."""
|
"""Run an intraday backtest."""
|
||||||
logger.info(f"Starting intraday backtest with {len(dataset)} samples")
|
logger.info(f"Starting intraday backtest with {len(dataset)} samples")
|
||||||
@@ -30,17 +55,46 @@ class IntradayBacktester:
|
|||||||
trade_log = []
|
trade_log = []
|
||||||
|
|
||||||
for data in dataset:
|
for data in dataset:
|
||||||
predictions = self.model(data)
|
date = getattr(data, "date", None)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
predictions = self.model(data.to(config.DEVICE))
|
||||||
|
|
||||||
|
portfolio_val = self._portfolio_value(date)
|
||||||
|
|
||||||
for i, ticker in enumerate(getattr(data, "tickers", [])):
|
for i, ticker in enumerate(getattr(data, "tickers", [])):
|
||||||
pred = predictions[i].item()
|
pred = predictions[i].item()
|
||||||
|
price = self._get_current_price(ticker, date)
|
||||||
|
|
||||||
|
if price is None or price <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
held_qty = self.broker.positions.get(ticker, 0)
|
||||||
|
|
||||||
|
if held_qty > 0:
|
||||||
|
if pred < -0.002:
|
||||||
|
order = {
|
||||||
|
"ticker": ticker,
|
||||||
|
"action": "sell",
|
||||||
|
"quantity": held_qty,
|
||||||
|
"price": price,
|
||||||
|
"timestamp": getattr(data, "timestamp", ""),
|
||||||
|
"type": "market",
|
||||||
|
}
|
||||||
|
order_id = self.broker.submit_order(order)
|
||||||
|
if order_id:
|
||||||
|
trade_log.append({**order, "order_id": order_id})
|
||||||
|
else:
|
||||||
if pred > 0.002:
|
if pred > 0.002:
|
||||||
|
max_spend = portfolio_val * config.MAX_POSITION_SIZE
|
||||||
|
quantity = int(max_spend / price)
|
||||||
|
if quantity <= 0:
|
||||||
|
continue
|
||||||
order = {
|
order = {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"action": "buy",
|
"action": "buy",
|
||||||
"quantity": 100,
|
"quantity": quantity,
|
||||||
"price": 100,
|
"price": price,
|
||||||
"timestamp": getattr(data, "timestamp", ""),
|
"timestamp": getattr(data, "timestamp", ""),
|
||||||
"type": "market",
|
"type": "market",
|
||||||
}
|
}
|
||||||
@@ -48,8 +102,7 @@ class IntradayBacktester:
|
|||||||
if order_id:
|
if order_id:
|
||||||
trade_log.append({**order, "order_id": order_id})
|
trade_log.append({**order, "order_id": order_id})
|
||||||
|
|
||||||
account = self.broker.get_account_summary()
|
portfolio_values.append(self._portfolio_value(date))
|
||||||
portfolio_values.append(account["total_value"])
|
|
||||||
timestamps.append(getattr(data, "timestamp", None))
|
timestamps.append(getattr(data, "timestamp", None))
|
||||||
|
|
||||||
portfolio_series = pd.Series(portfolio_values, index=timestamps)
|
portfolio_series = pd.Series(portfolio_values, index=timestamps)
|
||||||
|
|||||||
@@ -70,10 +70,11 @@ def calculate_performance_metrics(
|
|||||||
benchmark_variance = benchmark_returns.var()
|
benchmark_variance = benchmark_returns.var()
|
||||||
metrics["beta"] = covariance / benchmark_variance if benchmark_variance > 0 else 0
|
metrics["beta"] = covariance / benchmark_variance if benchmark_variance > 0 else 0
|
||||||
|
|
||||||
# Alpha
|
# Alpha — use same geometric annualization as portfolio so both are comparable
|
||||||
metrics["alpha"] = (
|
benchmark_annualized = (1 + benchmark_returns).prod() ** (
|
||||||
metrics["annualized_return"] - metrics["beta"] * benchmark_returns.mean() * 252
|
252 / len(benchmark_returns)
|
||||||
)
|
) - 1
|
||||||
|
metrics["alpha"] = metrics["annualized_return"] - metrics["beta"] * benchmark_annualized
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
|
|||||||
+16
-21
@@ -86,12 +86,13 @@ class IntradayGNN(nn.Module):
|
|||||||
# Initialize LSTM weights
|
# Initialize LSTM weights
|
||||||
self._init_lstm_weights()
|
self._init_lstm_weights()
|
||||||
|
|
||||||
# Attention mechanism for final prediction
|
# Per-feature gating after GNN+LSTM (Softmax over a single element is always 1,
|
||||||
|
# so the original design collapsed to a scalar no-op; Sigmoid gives proper gates)
|
||||||
self.attention = nn.Sequential(
|
self.attention = nn.Sequential(
|
||||||
nn.Linear(config.HIDDEN_CHANNELS, config.HIDDEN_CHANNELS),
|
nn.Linear(config.HIDDEN_CHANNELS, config.HIDDEN_CHANNELS),
|
||||||
nn.SiLU(),
|
nn.SiLU(),
|
||||||
nn.Linear(config.HIDDEN_CHANNELS, 1),
|
nn.Linear(config.HIDDEN_CHANNELS, config.HIDDEN_CHANNELS),
|
||||||
nn.Softmax(dim=1),
|
nn.Sigmoid(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Final prediction layer
|
# Final prediction layer
|
||||||
@@ -151,14 +152,18 @@ class IntradayGNN(nn.Module):
|
|||||||
else:
|
else:
|
||||||
lstm_out, self.hidden_state = self.lstm(lstm_input)
|
lstm_out, self.hidden_state = self.lstm(lstm_input)
|
||||||
|
|
||||||
|
# Detach hidden state so gradients don't accumulate across inference calls
|
||||||
|
if self.hidden_state is not None:
|
||||||
|
h, c = self.hidden_state
|
||||||
|
self.hidden_state = (h.detach(), c.detach())
|
||||||
|
|
||||||
# Remove sequence dimension
|
# Remove sequence dimension
|
||||||
lstm_out = lstm_out.squeeze(1)
|
lstm_out = lstm_out.squeeze(1) # (N, HIDDEN_CHANNELS)
|
||||||
|
|
||||||
# Apply attention to LSTM outputs
|
# Per-feature gating: sigmoid weights scale each channel independently
|
||||||
attention_weights = self.attention(lstm_out)
|
attention_weights = self.attention(lstm_out) # (N, HIDDEN_CHANNELS)
|
||||||
attended = (lstm_out * attention_weights).sum(dim=1, keepdim=True)
|
attended = lstm_out * attention_weights # (N, HIDDEN_CHANNELS)
|
||||||
|
|
||||||
# Final prediction
|
|
||||||
return self.linear(attended)
|
return self.linear(attended)
|
||||||
|
|
||||||
def _gnn_forward(self, x, edge_index, edge_attr):
|
def _gnn_forward(self, x, edge_index, edge_attr):
|
||||||
@@ -184,16 +189,8 @@ class IntradayGNN(nn.Module):
|
|||||||
"""Get attention weights for interpretability"""
|
"""Get attention weights for interpretability"""
|
||||||
x, edge_index, edge_attr = data.x, data.edge_index, data.edge_attr
|
x, edge_index, edge_attr = data.x, data.edge_index, data.edge_attr
|
||||||
|
|
||||||
# Process through the model up to attention
|
# TemporalAttention already handles batched input — no need to loop
|
||||||
batch_size, seq_len, num_features = x.size()
|
temporal_features = self.temporal_attention(x)
|
||||||
temporal_features = []
|
|
||||||
|
|
||||||
for i in range(batch_size):
|
|
||||||
stock_sequence = x[i].unsqueeze(0)
|
|
||||||
temporal_feature = self.temporal_attention(stock_sequence)
|
|
||||||
temporal_features.append(temporal_feature)
|
|
||||||
|
|
||||||
temporal_features = torch.cat(temporal_features, dim=0)
|
|
||||||
processed_features = self.feature_processor(temporal_features)
|
processed_features = self.feature_processor(temporal_features)
|
||||||
|
|
||||||
x = self.conv1(processed_features, edge_index, edge_attr)
|
x = self.conv1(processed_features, edge_index, edge_attr)
|
||||||
@@ -204,6 +201,4 @@ class IntradayGNN(nn.Module):
|
|||||||
lstm_out, _ = self.lstm(lstm_input)
|
lstm_out, _ = self.lstm(lstm_input)
|
||||||
lstm_out = lstm_out.squeeze(1)
|
lstm_out = lstm_out.squeeze(1)
|
||||||
|
|
||||||
attention_weights = self.attention(lstm_out)
|
return self.attention(lstm_out)
|
||||||
|
|
||||||
return attention_weights
|
|
||||||
|
|||||||
@@ -72,10 +72,16 @@ class PaperTradingBroker(Broker):
|
|||||||
"""Get current simulated positions."""
|
"""Get current simulated positions."""
|
||||||
return self.positions.copy()
|
return self.positions.copy()
|
||||||
|
|
||||||
def get_account_summary(self) -> Dict:
|
def get_account_summary(self, prices: Optional[Dict[str, float]] = None) -> Dict:
|
||||||
"""Get simulated account summary using fill prices for position valuation."""
|
"""Get simulated account summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prices: Current market prices for mark-to-market valuation.
|
||||||
|
Falls back to fill price for any ticker not in prices.
|
||||||
|
"""
|
||||||
|
effective_prices = prices or {}
|
||||||
position_value = sum(
|
position_value = sum(
|
||||||
qty * self.position_prices.get(ticker, 0)
|
qty * effective_prices.get(ticker, self.position_prices.get(ticker, 0))
|
||||||
for ticker, qty in self.positions.items()
|
for ticker, qty in self.positions.items()
|
||||||
)
|
)
|
||||||
total_value = self.cash + position_value
|
total_value = self.cash + position_value
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Real-time trader for live trading execution.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
|
|
||||||
@@ -24,9 +24,17 @@ class RealTimeTrader:
|
|||||||
self.max_drawdown = 0.0
|
self.max_drawdown = 0.0
|
||||||
self.entry_times = {}
|
self.entry_times = {}
|
||||||
|
|
||||||
|
def _get_last_known_price(self, ticker: str) -> Optional[float]:
|
||||||
|
"""Look up the most recent daily close for a ticker from the pipeline."""
|
||||||
|
price_df = self.pipeline.price_data.get(ticker)
|
||||||
|
if price_df is not None and not price_df.empty:
|
||||||
|
return float(price_df.iloc[-1]["Close"])
|
||||||
|
return None
|
||||||
|
|
||||||
def _calculate_position_size(self, ticker: str, price: float) -> int:
|
def _calculate_position_size(self, ticker: str, price: float) -> int:
|
||||||
"""Calculate position size based on risk management rules."""
|
"""Calculate position size based on risk management rules."""
|
||||||
account_summary = self.broker.get_account_summary()
|
# Pass current price so the broker marks this position to market
|
||||||
|
account_summary = self.broker.get_account_summary(prices={ticker: price})
|
||||||
total_value = account_summary.get("total_value", config.INITIAL_CAPITAL)
|
total_value = account_summary.get("total_value", config.INITIAL_CAPITAL)
|
||||||
max_position_value = total_value * config.MAX_POSITION_SIZE
|
max_position_value = total_value * config.MAX_POSITION_SIZE
|
||||||
position_size = int(max_position_value / price)
|
position_size = int(max_position_value / price)
|
||||||
@@ -75,13 +83,17 @@ class RealTimeTrader:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _close_all_positions(self):
|
def _close_all_positions(self):
|
||||||
"""Close all open positions."""
|
"""Close all open positions using last known price from the pipeline."""
|
||||||
for ticker in list(self.current_positions.keys()):
|
for ticker in list(self.current_positions.keys()):
|
||||||
|
price = self._get_last_known_price(ticker)
|
||||||
|
if price is None or price <= 0:
|
||||||
|
logger.warning(f"Cannot close {ticker}: no price available, skipping")
|
||||||
|
continue
|
||||||
order = {
|
order = {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
"quantity": self.current_positions[ticker],
|
"quantity": self.current_positions[ticker],
|
||||||
"price": 0, # market order
|
"price": price,
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"type": "market",
|
"type": "market",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user