239 lines
9.0 KiB
Python
239 lines
9.0 KiB
Python
"""
|
|
Results Processor
|
|
|
|
This module handles processing, aggregation, and analysis of backtest results.
|
|
It provides utilities for calculating metrics, aggregating results across
|
|
timeframes, and formatting output data.
|
|
"""
|
|
|
|
import pandas as pd
|
|
import numpy as np
|
|
import logging
|
|
from typing import Dict, List, Tuple, Any, Optional
|
|
from collections import defaultdict
|
|
|
|
|
|
class BacktestMetrics:
|
|
"""Container for backtest metrics calculation."""
|
|
|
|
@staticmethod
|
|
def calculate_trade_metrics(trades: List[Dict[str, Any]]) -> Dict[str, float]:
|
|
"""Calculate trade-level metrics."""
|
|
if not trades:
|
|
return {
|
|
"n_trades": 0,
|
|
"n_winning_trades": 0,
|
|
"win_rate": 0.0,
|
|
"total_profit": 0.0,
|
|
"total_loss": 0.0,
|
|
"avg_trade": 0.0,
|
|
"profit_ratio": 0.0,
|
|
"max_drawdown": 0.0
|
|
}
|
|
|
|
n_trades = len(trades)
|
|
wins = [t for t in trades if t.get('exit') and t['exit'] > t['entry']]
|
|
n_winning_trades = len(wins)
|
|
win_rate = n_winning_trades / n_trades if n_trades > 0 else 0
|
|
|
|
total_profit = sum(trade['profit_pct'] for trade in trades)
|
|
total_loss = sum(-trade['profit_pct'] for trade in trades if trade['profit_pct'] < 0)
|
|
avg_trade = total_profit / n_trades if n_trades > 0 else 0
|
|
profit_ratio = total_profit / total_loss if total_loss > 0 else float('inf')
|
|
|
|
# Calculate max drawdown
|
|
cumulative_profit = 0
|
|
max_drawdown = 0
|
|
peak = 0
|
|
|
|
for trade in trades:
|
|
cumulative_profit += trade['profit_pct']
|
|
if cumulative_profit > peak:
|
|
peak = cumulative_profit
|
|
drawdown = peak - cumulative_profit
|
|
if drawdown > max_drawdown:
|
|
max_drawdown = drawdown
|
|
|
|
return {
|
|
"n_trades": n_trades,
|
|
"n_winning_trades": n_winning_trades,
|
|
"win_rate": win_rate,
|
|
"total_profit": total_profit,
|
|
"total_loss": total_loss,
|
|
"avg_trade": avg_trade,
|
|
"profit_ratio": profit_ratio,
|
|
"max_drawdown": max_drawdown
|
|
}
|
|
|
|
@staticmethod
|
|
def calculate_portfolio_metrics(trades: List[Dict[str, Any]], initial_usd: float) -> Dict[str, float]:
|
|
"""Calculate portfolio-level metrics."""
|
|
final_usd = initial_usd
|
|
for trade in trades:
|
|
final_usd *= (1 + trade['profit_pct'])
|
|
|
|
total_fees_usd = sum(trade.get('fee_usd', 0.0) for trade in trades)
|
|
|
|
return {
|
|
"initial_usd": initial_usd,
|
|
"final_usd": final_usd,
|
|
"total_fees_usd": total_fees_usd,
|
|
"total_return": (final_usd - initial_usd) / initial_usd
|
|
}
|
|
|
|
|
|
class ResultsProcessor:
|
|
"""
|
|
Processes and aggregates backtest results.
|
|
|
|
Handles result processing, metric calculation, and aggregation across
|
|
multiple timeframes and configurations.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize results processor."""
|
|
self.logger = logging.getLogger(__name__)
|
|
|
|
def process_backtest_results(self, results: Dict[str, Any], timeframe: str,
|
|
config: Dict[str, Any], strategy_summary: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
"""
|
|
Process results from a single backtest run.
|
|
|
|
Args:
|
|
results: Raw backtest results
|
|
timeframe: Timeframe identifier
|
|
config: Configuration used for the test
|
|
strategy_summary: Summary of strategies used
|
|
|
|
Returns:
|
|
Tuple[Dict, List]: (summary_row, trade_rows)
|
|
"""
|
|
trades = results.get('trades', [])
|
|
initial_usd = config['initial_usd']
|
|
|
|
# Calculate metrics
|
|
trade_metrics = BacktestMetrics.calculate_trade_metrics(trades)
|
|
portfolio_metrics = BacktestMetrics.calculate_portfolio_metrics(trades, initial_usd)
|
|
|
|
# Get primary strategy info for reporting
|
|
primary_strategy = strategy_summary['strategies'][0] if strategy_summary['strategies'] else {}
|
|
primary_timeframe = primary_strategy.get('timeframes', ['unknown'])[0]
|
|
stop_loss_pct = primary_strategy.get('params', {}).get('stop_loss_pct', 'N/A')
|
|
|
|
# Create summary row
|
|
summary_row = {
|
|
"timeframe": f"{timeframe}({primary_timeframe})",
|
|
"stop_loss_pct": stop_loss_pct,
|
|
"n_stop_loss": sum(1 for trade in trades if trade.get('type') == 'STOP_LOSS'),
|
|
**trade_metrics,
|
|
**portfolio_metrics
|
|
}
|
|
|
|
# Create trade rows
|
|
trade_rows = []
|
|
for trade in trades:
|
|
trade_rows.append({
|
|
"timeframe": f"{timeframe}({primary_timeframe})",
|
|
"stop_loss_pct": stop_loss_pct,
|
|
"entry_time": trade.get("entry_time"),
|
|
"exit_time": trade.get("exit_time"),
|
|
"entry_price": trade.get("entry"),
|
|
"exit_price": trade.get("exit"),
|
|
"profit_pct": trade.get("profit_pct"),
|
|
"type": trade.get("type"),
|
|
"fee_usd": trade.get("fee_usd"),
|
|
})
|
|
|
|
# Log results
|
|
strategy_names = [s['name'] for s in strategy_summary['strategies']]
|
|
self.logger.info(
|
|
f"Timeframe: {timeframe}({primary_timeframe}), Stop Loss: {stop_loss_pct}, "
|
|
f"Trades: {trade_metrics['n_trades']}, Strategies: {strategy_names}"
|
|
)
|
|
|
|
return summary_row, trade_rows
|
|
|
|
def aggregate_results(self, all_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""
|
|
Aggregate results per timeframe and stop_loss_pct.
|
|
|
|
Args:
|
|
all_rows: List of result rows to aggregate
|
|
|
|
Returns:
|
|
List[Dict]: Aggregated summary rows
|
|
"""
|
|
grouped = defaultdict(list)
|
|
for row in all_rows:
|
|
key = (row['timeframe'], row['stop_loss_pct'])
|
|
grouped[key].append(row)
|
|
|
|
summary_rows = []
|
|
for (timeframe, stop_loss_pct), rows in grouped.items():
|
|
if not rows:
|
|
continue
|
|
|
|
# Aggregate metrics
|
|
total_trades = sum(r['n_trades'] for r in rows)
|
|
total_stop_loss = sum(r['n_stop_loss'] for r in rows)
|
|
|
|
# Average metrics across runs
|
|
avg_win_rate = np.mean([r['win_rate'] for r in rows])
|
|
avg_max_drawdown = np.mean([r['max_drawdown'] for r in rows])
|
|
avg_avg_trade = np.mean([r['avg_trade'] for r in rows])
|
|
avg_profit_ratio = np.mean([r['profit_ratio'] for r in rows if r['profit_ratio'] != float('inf')])
|
|
|
|
# Portfolio metrics
|
|
initial_usd = rows[0]['initial_usd'] # Should be same for all
|
|
avg_final_usd = np.mean([r['final_usd'] for r in rows])
|
|
avg_total_fees_usd = np.mean([r['total_fees_usd'] for r in rows])
|
|
|
|
summary_rows.append({
|
|
"timeframe": timeframe,
|
|
"stop_loss_pct": stop_loss_pct,
|
|
"n_trades": total_trades,
|
|
"n_stop_loss": total_stop_loss,
|
|
"win_rate": avg_win_rate,
|
|
"max_drawdown": avg_max_drawdown,
|
|
"avg_trade": avg_avg_trade,
|
|
"profit_ratio": avg_profit_ratio,
|
|
"initial_usd": initial_usd,
|
|
"final_usd": avg_final_usd,
|
|
"total_fees_usd": avg_total_fees_usd,
|
|
})
|
|
|
|
return summary_rows
|
|
|
|
def create_metadata_lines(self, config_manager, data_1min: pd.DataFrame) -> List[str]:
|
|
"""
|
|
Create metadata lines for result files.
|
|
|
|
Args:
|
|
config_manager: Configuration manager instance
|
|
data_1min: 1-minute data for price lookups
|
|
|
|
Returns:
|
|
List[str]: Metadata lines
|
|
"""
|
|
start_date = config_manager.start_date
|
|
stop_date = config_manager.stop_date
|
|
initial_usd = config_manager.initial_usd
|
|
|
|
def get_nearest_price(df: pd.DataFrame, target_date: str) -> Tuple[Optional[str], Optional[float]]:
|
|
"""Get nearest price for a given date."""
|
|
if len(df) == 0:
|
|
return None, None
|
|
target_ts = pd.to_datetime(target_date)
|
|
nearest_idx = df.index.get_indexer([target_ts], method='nearest')[0]
|
|
nearest_time = df.index[nearest_idx]
|
|
price = df.iloc[nearest_idx]['close']
|
|
return str(nearest_time), price
|
|
|
|
nearest_start_time, start_price = get_nearest_price(data_1min, start_date)
|
|
nearest_stop_time, stop_price = get_nearest_price(data_1min, stop_date)
|
|
|
|
return [
|
|
f"Start date\t{start_date}\tPrice\t{start_price}",
|
|
f"Stop date\t{stop_date}\tPrice\t{stop_price}",
|
|
f"Initial USD\t{initial_usd}"
|
|
] |