178 lines
6.4 KiB
Python
178 lines
6.4 KiB
Python
import logging
|
|
from datetime import datetime
|
|
from typing import Dict, List, Tuple
|
|
|
|
import pandas as pd
|
|
import torch
|
|
|
|
from config import config
|
|
from src.models.gnn_model import CorporateActionAwareGNN
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GNNBacktester:
|
|
def __init__(
|
|
self,
|
|
model: CorporateActionAwareGNN,
|
|
price_data: Dict,
|
|
initial_capital: float = config.INITIAL_CAPITAL,
|
|
):
|
|
self.model = model
|
|
self.price_data = price_data
|
|
self.initial_capital = initial_capital
|
|
self.portfolio_value = initial_capital
|
|
self.portfolio = {} # {ticker: shares}
|
|
self.trade_log = []
|
|
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
|
|
def run_backtest(self, dataset: List) -> Tuple[pd.Series, pd.DataFrame]:
|
|
"""
|
|
Run backtest on the given dataset
|
|
|
|
Parameters:
|
|
dataset: List of PyG Data objects
|
|
|
|
Returns:
|
|
Tuple of (portfolio_values, trade_log)
|
|
"""
|
|
portfolio_values = []
|
|
dates = []
|
|
|
|
for data in dataset:
|
|
date = data.date
|
|
current_tickers = data.tickers
|
|
|
|
# Get current prices
|
|
current_prices = {}
|
|
for ticker in current_tickers:
|
|
if ticker in self.price_data and date in self.price_data[ticker].index:
|
|
current_prices[ticker] = self.price_data[ticker].loc[date][
|
|
"Adj Close"
|
|
]
|
|
|
|
# Calculate current portfolio value
|
|
current_value = sum(
|
|
shares * current_prices[ticker]
|
|
for ticker, shares in self.portfolio.items()
|
|
if ticker in current_prices
|
|
)
|
|
cash = self.portfolio_value - current_value
|
|
current_portfolio_value = current_value + cash
|
|
|
|
# Store portfolio value
|
|
portfolio_values.append(current_portfolio_value)
|
|
dates.append(date)
|
|
|
|
# Get predictions from GNN
|
|
with torch.no_grad():
|
|
data = data.to(self.device)
|
|
predictions = self.model(data).squeeze().cpu().numpy()
|
|
|
|
# Create trading signals
|
|
signals = {}
|
|
for i, ticker in enumerate(current_tickers):
|
|
if ticker in current_prices:
|
|
# Get corporate action flag (last feature)
|
|
corporate_action_flag = data.x[i, -1].item()
|
|
|
|
# Buy if predicted return > threshold and no upcoming corporate action
|
|
if predictions[i] > 0.005 and corporate_action_flag == 0:
|
|
signals[ticker] = "buy"
|
|
# Sell if predicted return < threshold or upcoming corporate action
|
|
elif predictions[i] < -0.005 or corporate_action_flag > 0:
|
|
signals[ticker] = "sell"
|
|
|
|
# Execute trades
|
|
for ticker, signal in signals.items():
|
|
if signal == "buy" and cash > 0:
|
|
# Buy with 10% of available cash
|
|
price = current_prices[ticker]
|
|
shares_to_buy = int(
|
|
(cash * 0.1) / (price * (1 + config.TRANSACTION_COST))
|
|
)
|
|
if shares_to_buy > 0:
|
|
cost = shares_to_buy * price * (1 + config.TRANSACTION_COST)
|
|
self.portfolio[ticker] = (
|
|
self.portfolio.get(ticker, 0) + shares_to_buy
|
|
)
|
|
cash -= cost
|
|
self.trade_log.append(
|
|
(date, ticker, "buy", shares_to_buy, price)
|
|
)
|
|
logger.debug(
|
|
f"Bought {shares_to_buy} shares of {ticker} at {price:.2f}"
|
|
)
|
|
|
|
elif signal == "sell" and ticker in self.portfolio:
|
|
# Sell all shares
|
|
shares = self.portfolio.pop(ticker)
|
|
proceeds = (
|
|
shares * current_prices[ticker] * (1 - config.TRANSACTION_COST)
|
|
)
|
|
cash += proceeds
|
|
self.trade_log.append(
|
|
(date, ticker, "sell", shares, current_prices[ticker])
|
|
)
|
|
logger.debug(
|
|
f"Sold {shares} shares of {ticker} at {current_prices[ticker]:.2f}"
|
|
)
|
|
|
|
# Update portfolio value
|
|
new_value = sum(
|
|
shares * current_prices[ticker]
|
|
for ticker, shares in self.portfolio.items()
|
|
if ticker in current_prices
|
|
)
|
|
self.portfolio_value = new_value + cash
|
|
|
|
# Create portfolio value series
|
|
portfolio_series = pd.Series(portfolio_values, index=dates)
|
|
|
|
# Create trade log DataFrame
|
|
if self.trade_log:
|
|
trade_log_df = pd.DataFrame(self.trade_log)
|
|
trade_log_df.columns = ["date", "ticker", "action", "shares", "price"]
|
|
else:
|
|
trade_log_df = pd.DataFrame()
|
|
trade_log_df.columns = ["date", "ticker", "action", "shares", "price"]
|
|
|
|
return portfolio_series, trade_log_df
|
|
|
|
def get_portfolio_composition(self, date: datetime) -> Dict[str, float]:
|
|
"""
|
|
Get portfolio composition at a specific date
|
|
|
|
Parameters:
|
|
date: Date to get composition for
|
|
|
|
Returns:
|
|
Dictionary of {ticker: weight} where weight is the percentage of portfolio
|
|
"""
|
|
# Get current prices
|
|
current_prices = {}
|
|
for ticker in self.portfolio:
|
|
if ticker in self.price_data and date in self.price_data[ticker].index:
|
|
current_prices[ticker] = self.price_data[ticker].loc[date]["Adj Close"]
|
|
|
|
# Calculate current value
|
|
current_value = sum(
|
|
shares * current_prices[ticker]
|
|
for ticker, shares in self.portfolio.items()
|
|
if ticker in current_prices
|
|
)
|
|
cash = self.portfolio_value - current_value
|
|
|
|
# Calculate weights
|
|
composition = {}
|
|
for ticker, shares in self.portfolio.items():
|
|
if ticker in current_prices:
|
|
composition[ticker] = (
|
|
shares * current_prices[ticker]
|
|
) / self.portfolio_value
|
|
|
|
# Add cash
|
|
composition["cash"] = cash / self.portfolio_value
|
|
|
|
return composition
|