Update
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user