Cycles/cycles/utils/results_processor.py

239 lines
9.0 KiB
Python
Raw Normal View History

2025-05-23 20:37:14 +08:00
"""
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}"
]