| import logging |
| import pandas as pd |
| import numpy as np |
| import plotly.graph_objects as go |
| from typing import Dict, Any |
|
|
| logger = logging.getLogger(__name__) |
|
|
| class BacktestEngine: |
| def __init__(self, initial_capital: float = 10000.0): |
| self.initial_capital = initial_capital |
| self.capital = initial_capital |
| self.positions = 0.0 |
| self.trades = [] |
| self.equity_curve = [] |
|
|
| def load_data(self, data: pd.DataFrame): |
| """ |
| Expects a DataFrame with ['timestamp', 'bid', 'ask', 'mid', 'volume'] |
| and external features like ['sentiment'] if testing the momentum strategy. |
| """ |
| self.df = data.sort_values("timestamp").reset_index(drop=True) |
| |
| self.slippage_bps = 5 |
|
|
| def run_macrostem_simulation(self, strategy_func): |
| """ |
| Iterate through tick/candle data, evaluating the strategy function. |
| strategy_func takes a dict row and returns a trade action dict or None. |
| """ |
| logger.info(f"Starting backtest with {len(self.df)} ticks. Capital: ${self.capital}") |
| |
| for idx, row in self.df.iterrows(): |
| |
| mtm_value = self.capital + (self.positions * row['mid']) |
| self.equity_curve.append({'timestamp': row['timestamp'], 'equity': mtm_value}) |
| |
| |
| signal = strategy_func(row) |
| if not signal: |
| continue |
| |
| action = signal.get("action") |
| |
| |
| if action == "BUY_YES": |
| |
| execute_price = row['ask'] + (row['ask'] * self.slippage_bps / 10000) |
| cost = execute_price * 100 |
| |
| if self.capital >= cost: |
| self.capital -= cost |
| self.positions += 100 |
| self.trades.append({ |
| "timestamp": row['timestamp'], "action": action, |
| "price": execute_price, "size": 100, "pnl": 0 |
| }) |
| |
| elif action == "SELL_YES" and self.positions > 0: |
| |
| execute_price = row['bid'] - (row['bid'] * self.slippage_bps / 10000) |
| revenue = execute_price * self.positions |
| pnl = revenue - sum([t['price']*t['size'] for t in self.trades if t['action']=='BUY_YES']) |
| |
| self.capital += revenue |
| self.trades.append({ |
| "timestamp": row['timestamp'], "action": action, |
| "price": execute_price, "size": self.positions, "pnl": pnl |
| }) |
| self.positions = 0.0 |
| |
| |
| final_equity = self.capital + (self.positions * self.df.iloc[-1]['mid']) |
| logger.info(f"Backtest Complete. Final Equity: ${final_equity:.2f}") |
|
|
| def calculate_metrics(self) -> Dict[str, Any]: |
| """Calculate Sharpe, Sortino, Max Drawdown, etc.""" |
| if not self.equity_curve: |
| return {} |
| |
| equity_df = pd.DataFrame(self.equity_curve) |
| equity_df.set_index('timestamp', inplace=True) |
| |
| |
| returns = equity_df['equity'].pct_change().dropna() |
| |
| |
| |
| annualization = 365 * 24 |
| |
| mean_ret = returns.mean() * annualization |
| std_ret = returns.std() * np.sqrt(annualization) |
| |
| sharpe = mean_ret / std_ret if std_ret != 0 else 0 |
| |
| |
| cum_ret = (1 + returns).cumprod() |
| rolling_max = cum_ret.cummax() |
| drawdowns = (cum_ret - rolling_max) / rolling_max |
| max_dd = drawdowns.min() |
| |
| |
| winning_trades = len([t for t in self.trades if t.get('pnl', 0) > 0]) |
| total_closed = len([t for t in self.trades if t['action'] == 'SELL_YES']) |
| win_rate = winning_trades / total_closed if total_closed > 0 else 0 |
| |
| return { |
| "Total Return (%)": ((equity_df['equity'].iloc[-1] / self.initial_capital) - 1) * 100, |
| "Sharpe Ratio": sharpe, |
| "Max Drawdown (%)": max_dd * 100, |
| "Win Rate (%)": win_rate * 100, |
| "Total Trades": len(self.trades) |
| } |
|
|
| def generate_report(self): |
| """Optional: Output Plotly charts or console summary.""" |
| metrics = self.calculate_metrics() |
| logger.info("=== Backtest Performance Report ===") |
| for k, v in metrics.items(): |
| logger.info(f"{k}: {v:.2f}") |
|
|