500 lines
19 KiB
Python
500 lines
19 KiB
Python
"""
|
|
Backtesting Engine for Polymarket
|
|
|
|
Simulates trading on historical market data.
|
|
"""
|
|
|
|
from typing import Dict, List, Optional, Any
|
|
from datetime import datetime, timedelta
|
|
import pandas as pd
|
|
import numpy as np
|
|
|
|
# Configure numpy to handle division by zero gracefully
|
|
np.seterr(divide='ignore', invalid='ignore')
|
|
from ..strategies.base_strategy import BaseStrategy, MarketSignal, Position
|
|
from ..api.gamma_client import GammaClient
|
|
from ..api.clob_client import ClobClient
|
|
import time
|
|
|
|
|
|
class BacktestEngine:
|
|
"""
|
|
Main backtesting engine for Polymarket strategies.
|
|
|
|
Simulates trading on historical data with realistic execution.
|
|
"""
|
|
|
|
def __init__(self,
|
|
strategy: BaseStrategy,
|
|
start_date: datetime,
|
|
end_date: datetime,
|
|
initial_balance: float = 1000.0):
|
|
"""
|
|
Initialize backtesting engine.
|
|
|
|
Args:
|
|
strategy: Strategy instance to backtest
|
|
start_date: Start date for backtesting
|
|
end_date: End date for backtesting
|
|
initial_balance: Starting USDC balance
|
|
"""
|
|
self.strategy = strategy
|
|
self.start_date = start_date
|
|
self.end_date = end_date
|
|
self.initial_balance = initial_balance
|
|
|
|
# Initialize API clients (for data fetching)
|
|
self.gamma_client = GammaClient()
|
|
self.clob_client = ClobClient()
|
|
|
|
# Backtest state
|
|
self.current_date = start_date
|
|
self.market_snapshots: List[Dict] = []
|
|
self.trades: List[Dict] = []
|
|
|
|
# Performance tracking
|
|
self.equity_curve: List[Dict] = []
|
|
self.daily_returns: List[float] = []
|
|
|
|
def fetch_historical_markets(self, tag_id: Optional[int] = None) -> List[Dict]:
|
|
"""
|
|
Fetch markets that were active during backtest period.
|
|
|
|
Note: Polymarket API may not provide full historical data.
|
|
This is a simplified implementation.
|
|
|
|
Args:
|
|
tag_id: Optional tag ID to filter markets
|
|
|
|
Returns:
|
|
List of market dictionaries
|
|
"""
|
|
# Get current active markets (as proxy for historical)
|
|
# In production, you'd need to store historical snapshots
|
|
events = self.gamma_client.get_events(
|
|
active=True,
|
|
closed=False,
|
|
limit=100,
|
|
tag_id=tag_id
|
|
)
|
|
|
|
markets = []
|
|
for event in events:
|
|
for market in event.get('markets', []):
|
|
markets.append({
|
|
'event': event,
|
|
'market': market,
|
|
'timestamp': datetime.now() # Would be historical in real implementation
|
|
})
|
|
|
|
return markets
|
|
|
|
def simulate_price_evolution(self,
|
|
initial_price: float,
|
|
days: int,
|
|
volatility: float = 0.05) -> List[float]:
|
|
"""
|
|
Simulate price evolution for backtesting.
|
|
|
|
In production, use actual historical price data.
|
|
|
|
Args:
|
|
initial_price: Starting price
|
|
days: Number of days to simulate
|
|
volatility: Daily volatility
|
|
|
|
Returns:
|
|
List of prices over time
|
|
"""
|
|
prices = [initial_price]
|
|
for _ in range(days):
|
|
# Random walk with mean reversion
|
|
change = np.random.normal(0, volatility)
|
|
new_price = prices[-1] + change
|
|
new_price = max(0.01, min(0.99, new_price)) # Bound between 0 and 1
|
|
prices.append(new_price)
|
|
|
|
return prices
|
|
|
|
def execute_signal(self,
|
|
signal: MarketSignal,
|
|
market_data: Dict,
|
|
timestamp: datetime) -> Optional[Dict]:
|
|
"""
|
|
Execute a trading signal.
|
|
|
|
Args:
|
|
signal: Trading signal from strategy
|
|
market_data: Current market data
|
|
timestamp: Current timestamp
|
|
|
|
Returns:
|
|
Trade dictionary or None if execution failed
|
|
"""
|
|
# Get market from market_data first (needed for prices)
|
|
market = market_data.get('market', {})
|
|
|
|
# Use token_id from signal if available, otherwise get from market
|
|
if signal.token_id:
|
|
token_id = signal.token_id
|
|
else:
|
|
token_ids = market.get('clobTokenIds', [])
|
|
if not token_ids:
|
|
return None
|
|
token_id = token_ids[0]
|
|
|
|
outcome = 'Yes' # Default outcome
|
|
|
|
# Get current price from market data
|
|
import json
|
|
prices = json.loads(market.get('outcomePrices', '[0.5, 0.5]'))
|
|
if signal.action == 'BUY':
|
|
current_price = float(prices[0]) # Yes price
|
|
else:
|
|
current_price = float(prices[0]) # Use Yes price for exit too
|
|
|
|
# Validate price
|
|
if current_price <= 0 or current_price >= 1:
|
|
return None # Invalid price
|
|
|
|
# Calculate position_size based on action
|
|
if signal.action == 'SELL':
|
|
if token_id not in self.strategy.positions:
|
|
return None # No position to close
|
|
# For SELL, position_size represents the value we'll get back
|
|
pos = self.strategy.positions[token_id]
|
|
# Validate position data
|
|
if not (pos.size > 0 and np.isfinite(pos.size) and
|
|
current_price > 0 and current_price < 1 and
|
|
np.isfinite(current_price)):
|
|
return None # Invalid position or price data
|
|
position_size = pos.size * current_price * signal.size # signal.size = 1.0 for full close
|
|
if not np.isfinite(position_size) or position_size <= 0:
|
|
return None
|
|
else:
|
|
# For BUY, calculate position size and check limits
|
|
# Validate balance
|
|
if not (np.isfinite(self.strategy.current_balance) and self.strategy.current_balance > 0):
|
|
return None
|
|
|
|
position_size = min(
|
|
signal.size * self.strategy.current_balance,
|
|
self.strategy.current_balance * self.strategy.max_position_size
|
|
)
|
|
|
|
# Validate position_size
|
|
if not (np.isfinite(position_size) and position_size > 0):
|
|
return None
|
|
|
|
# Check if can open position
|
|
if not self.strategy.can_open_position(position_size, token_id):
|
|
return None
|
|
|
|
# Ensure we have enough balance
|
|
if position_size > self.strategy.current_balance:
|
|
return None
|
|
|
|
# Execute trade
|
|
if signal.action == 'BUY':
|
|
# Buy tokens - safe division
|
|
if current_price > 0 and current_price < 1 and np.isfinite(current_price):
|
|
tokens_bought = position_size / current_price
|
|
# Validate tokens_bought
|
|
if not (np.isfinite(tokens_bought) and tokens_bought > 0):
|
|
return None
|
|
else:
|
|
return None # Invalid price, skip trade
|
|
|
|
# Validate balance before subtraction
|
|
if not (np.isfinite(self.strategy.current_balance) and
|
|
self.strategy.current_balance >= position_size):
|
|
return None
|
|
|
|
self.strategy.current_balance -= position_size
|
|
|
|
# Ensure balance is still finite
|
|
if not np.isfinite(self.strategy.current_balance):
|
|
self.strategy.current_balance = 0.0
|
|
return None
|
|
|
|
# Create position
|
|
position = Position(
|
|
token_id=token_id,
|
|
outcome=outcome,
|
|
size=tokens_bought,
|
|
entry_price=current_price,
|
|
entry_time=timestamp,
|
|
current_price=current_price,
|
|
unrealized_pnl=0.0
|
|
)
|
|
self.strategy.positions[token_id] = position
|
|
|
|
elif signal.action == 'SELL':
|
|
# Close existing position
|
|
if token_id in self.strategy.positions:
|
|
pos = self.strategy.positions[token_id]
|
|
# Validate position data
|
|
if not (np.isfinite(pos.size) and pos.size > 0 and
|
|
np.isfinite(pos.entry_price) and pos.entry_price > 0):
|
|
return None
|
|
|
|
# Close fraction of position (signal.size = 1.0 means close all)
|
|
close_size = pos.size * signal.size
|
|
if not (np.isfinite(close_size) and close_size > 0):
|
|
return None
|
|
|
|
exit_value = close_size * current_price
|
|
entry_cost = close_size * pos.entry_price
|
|
|
|
# Validate calculations
|
|
if not (np.isfinite(exit_value) and np.isfinite(entry_cost)):
|
|
return None
|
|
|
|
pnl = exit_value - entry_cost
|
|
if not np.isfinite(pnl):
|
|
pnl = 0.0
|
|
|
|
# Validate balance before addition
|
|
if not np.isfinite(self.strategy.current_balance):
|
|
self.strategy.current_balance = 0.0
|
|
|
|
self.strategy.current_balance += exit_value
|
|
|
|
# Ensure balance is still finite
|
|
if not np.isfinite(self.strategy.current_balance):
|
|
self.strategy.current_balance = 0.0
|
|
return None
|
|
|
|
self.strategy.total_trades += 1
|
|
|
|
if pnl > 0:
|
|
self.strategy.winning_trades += 1
|
|
self.strategy.total_profit += pnl if np.isfinite(pnl) else 0.0
|
|
else:
|
|
self.strategy.losing_trades += 1
|
|
self.strategy.total_loss += abs(pnl) if np.isfinite(pnl) else 0.0
|
|
|
|
# Update or remove position
|
|
if signal.size >= 1.0:
|
|
# Close entire position
|
|
pos.realized_pnl = pnl if np.isfinite(pnl) else 0.0
|
|
self.strategy.closed_positions.append(pos)
|
|
del self.strategy.positions[token_id]
|
|
else:
|
|
# Partial close
|
|
pos.size -= close_size
|
|
if not (np.isfinite(pos.size) and pos.size >= 0):
|
|
pos.size = 0.0
|
|
pos.realized_pnl += pnl if np.isfinite(pnl) else 0.0
|
|
if not np.isfinite(pos.realized_pnl):
|
|
pos.realized_pnl = 0.0
|
|
|
|
trade = {
|
|
'timestamp': timestamp,
|
|
'action': signal.action,
|
|
'token_id': token_id,
|
|
'outcome': outcome,
|
|
'price': current_price,
|
|
'size': position_size,
|
|
'reason': signal.reason,
|
|
'confidence': signal.confidence
|
|
}
|
|
|
|
self.trades.append(trade)
|
|
return trade
|
|
|
|
def run(self, markets: Optional[List[Dict]] = None) -> Dict[str, Any]:
|
|
"""
|
|
Run the backtest.
|
|
|
|
Args:
|
|
markets: Optional list of markets to backtest. If None, fetches markets.
|
|
|
|
Returns:
|
|
Dictionary with backtest results
|
|
"""
|
|
print(f"Starting backtest from {self.start_date} to {self.end_date}")
|
|
|
|
# Fetch markets if not provided
|
|
if markets is None:
|
|
markets = self.fetch_historical_markets()
|
|
|
|
if not markets:
|
|
raise ValueError("No markets found for backtesting")
|
|
|
|
print(f"Found {len(markets)} markets to backtest")
|
|
|
|
# Simulate time progression
|
|
current_date = self.start_date
|
|
day_count = 0
|
|
|
|
while current_date <= self.end_date:
|
|
# Update positions with current prices
|
|
for token_id, position in self.strategy.positions.items():
|
|
# Simulate price movement
|
|
# In production, use actual historical prices
|
|
price_change = np.random.normal(0, 0.02)
|
|
new_price = max(0.01, min(0.99, position.current_price + price_change))
|
|
self.strategy.update_position(token_id, new_price)
|
|
|
|
# Process each market
|
|
for market_snapshot in markets:
|
|
market_data = {
|
|
'event': market_snapshot['event'],
|
|
'market': market_snapshot['market'],
|
|
'timestamp': current_date
|
|
}
|
|
|
|
# Get current prices
|
|
market = market_snapshot['market']
|
|
import json
|
|
outcomes = json.loads(market.get('outcomes', '["Yes", "No"]'))
|
|
prices = json.loads(market.get('outcomePrices', '[0.5, 0.5]'))
|
|
|
|
market_data['prices'] = {
|
|
outcome: float(price)
|
|
for outcome, price in zip(outcomes, prices)
|
|
}
|
|
|
|
# Get strategy signal
|
|
signal = self.strategy.analyze_market(market_data)
|
|
|
|
if signal and signal.confidence >= self.strategy.min_confidence:
|
|
self.execute_signal(signal, market_data, current_date)
|
|
|
|
# Update equity curve
|
|
self.strategy.update_drawdown()
|
|
equity = self.strategy.calculate_equity()
|
|
|
|
self.equity_curve.append({
|
|
'date': current_date,
|
|
'equity': equity,
|
|
'balance': self.strategy.current_balance,
|
|
'unrealized_pnl': sum(pos.unrealized_pnl for pos in self.strategy.positions.values())
|
|
})
|
|
|
|
# Calculate daily return
|
|
if len(self.equity_curve) > 1:
|
|
prev_equity = self.equity_curve[-2]['equity']
|
|
daily_return = (equity - prev_equity) / prev_equity if prev_equity > 0 else 0.0
|
|
self.daily_returns.append(daily_return)
|
|
|
|
# Advance to next day
|
|
current_date += timedelta(days=1)
|
|
day_count += 1
|
|
|
|
if day_count % 10 == 0:
|
|
print(f"Progress: {day_count} days, Equity: ${equity:.2f}")
|
|
|
|
# Close all open positions at end
|
|
final_equity = self.strategy.calculate_equity()
|
|
for token_id, position in list(self.strategy.positions.items()):
|
|
# Assume final price is entry price (or use last known price)
|
|
exit_value = position.size * position.current_price
|
|
pnl = exit_value - (position.size * position.entry_price)
|
|
|
|
self.strategy.current_balance += exit_value
|
|
self.strategy.total_trades += 1
|
|
|
|
if pnl > 0:
|
|
self.strategy.winning_trades += 1
|
|
self.strategy.total_profit += pnl
|
|
else:
|
|
self.strategy.losing_trades += 1
|
|
self.strategy.total_loss += abs(pnl)
|
|
|
|
del self.strategy.positions[token_id]
|
|
|
|
# Calculate final metrics with safe division
|
|
if self.initial_balance > 0:
|
|
total_return = (final_equity - self.initial_balance) / self.initial_balance * 100
|
|
else:
|
|
total_return = 0.0
|
|
|
|
sharpe_ratio = self._calculate_sharpe_ratio()
|
|
|
|
# Safe win rate calculation
|
|
if self.strategy.total_trades > 0:
|
|
win_rate = (self.strategy.winning_trades / self.strategy.total_trades * 100)
|
|
else:
|
|
win_rate = 0.0
|
|
|
|
# Safe profit factor calculation
|
|
if abs(self.strategy.total_loss) > 1e-10:
|
|
profit_factor = abs(self.strategy.total_profit / self.strategy.total_loss)
|
|
else:
|
|
profit_factor = 0.0 if abs(self.strategy.total_profit) < 1e-10 else float('inf')
|
|
|
|
# Ensure all values are finite
|
|
total_return = total_return if np.isfinite(total_return) else 0.0
|
|
win_rate = win_rate if np.isfinite(win_rate) else 0.0
|
|
profit_factor = profit_factor if (np.isfinite(profit_factor) and profit_factor != float('inf')) else 0.0
|
|
sharpe_ratio = sharpe_ratio if np.isfinite(sharpe_ratio) else 0.0
|
|
max_dd = self.strategy.max_drawdown * 100 if np.isfinite(self.strategy.max_drawdown) else 0.0
|
|
|
|
results = {
|
|
'strategy': self.strategy.name,
|
|
'start_date': self.start_date,
|
|
'end_date': self.end_date,
|
|
'initial_balance': self.initial_balance,
|
|
'final_balance': self.strategy.current_balance,
|
|
'final_equity': final_equity if np.isfinite(final_equity) else self.initial_balance,
|
|
'total_return': total_return,
|
|
'total_trades': self.strategy.total_trades,
|
|
'winning_trades': self.strategy.winning_trades,
|
|
'losing_trades': self.strategy.losing_trades,
|
|
'win_rate': win_rate,
|
|
'total_profit': self.strategy.total_profit if np.isfinite(self.strategy.total_profit) else 0.0,
|
|
'total_loss': self.strategy.total_loss if np.isfinite(self.strategy.total_loss) else 0.0,
|
|
'net_profit': (self.strategy.total_profit + self.strategy.total_loss) if np.isfinite(self.strategy.total_profit + self.strategy.total_loss) else 0.0,
|
|
'profit_factor': profit_factor,
|
|
'max_drawdown': max_dd,
|
|
'sharpe_ratio': sharpe_ratio,
|
|
'trades': self.trades,
|
|
'equity_curve': self.equity_curve
|
|
}
|
|
|
|
return results
|
|
|
|
def _calculate_sharpe_ratio(self, risk_free_rate: float = 0.0) -> float:
|
|
"""Calculate Sharpe ratio from daily returns"""
|
|
if not self.daily_returns:
|
|
return 0.0
|
|
|
|
returns = np.array(self.daily_returns)
|
|
if len(returns) == 0:
|
|
return 0.0
|
|
|
|
excess_returns = returns - (risk_free_rate / 365) # Daily risk-free rate
|
|
|
|
std_dev = returns.std()
|
|
if std_dev == 0 or np.isnan(std_dev) or not np.isfinite(std_dev):
|
|
return 0.0
|
|
|
|
mean_return = excess_returns.mean()
|
|
if not np.isfinite(mean_return):
|
|
return 0.0
|
|
|
|
sharpe = np.sqrt(365) * mean_return / std_dev
|
|
return sharpe if np.isfinite(sharpe) else 0.0
|
|
|
|
def generate_report(self, output_file: Optional[str] = None) -> None:
|
|
"""Generate backtest report"""
|
|
results = {
|
|
'strategy': self.strategy.name,
|
|
'performance': self.strategy.get_performance_metrics()
|
|
}
|
|
|
|
print("\n" + "="*60)
|
|
print("BACKTEST RESULTS")
|
|
print("="*60)
|
|
print(f"Strategy: {results['strategy']}")
|
|
print(f"Period: {self.start_date.date()} to {self.end_date.date()}")
|
|
print(f"Initial Balance: ${self.initial_balance:.2f}")
|
|
print(f"Final Equity: ${self.strategy.equity:.2f}")
|
|
print(f"Total Return: {((self.strategy.equity - self.initial_balance) / self.initial_balance * 100):.2f}%")
|
|
print(f"Total Trades: {self.strategy.total_trades}")
|
|
print(f"Win Rate: {(self.strategy.winning_trades / self.strategy.total_trades * 100) if self.strategy.total_trades > 0 else 0:.2f}%")
|
|
print(f"Max Drawdown: {self.strategy.max_drawdown * 100:.2f}%")
|
|
print("="*60)
|