Cycles/result_processor.py

354 lines
13 KiB
Python

import pandas as pd
import numpy as np
import os
import csv
import logging
from typing import List, Dict, Any, Optional, Tuple
from collections import defaultdict
from cycles.utils.storage import Storage
class ResultProcessor:
"""Handles processing, aggregation, and saving of backtest results"""
def __init__(self, storage: Storage, logging_instance: Optional[logging.Logger] = None):
"""
Initialize result processor
Args:
storage: Storage instance for file operations
logging_instance: Optional logging instance
"""
self.storage = storage
self.logging = logging_instance
def process_timeframe_results(
self,
min1_df: pd.DataFrame,
df: pd.DataFrame,
stop_loss_pcts: List[float],
timeframe_name: str,
initial_usd: float,
debug: bool = False
) -> Tuple[List[Dict], List[Dict]]:
"""
Process results for a single timeframe with multiple stop loss values
Args:
min1_df: 1-minute data DataFrame
df: Resampled timeframe DataFrame
stop_loss_pcts: List of stop loss percentages to test
timeframe_name: Name of the timeframe (e.g., '1D', '6h')
initial_usd: Initial USD amount
debug: Whether to enable debug output
Returns:
Tuple of (results_rows, trade_rows)
"""
from cycles.backtest import Backtest
df = df.copy().reset_index(drop=True)
results_rows = []
trade_rows = []
for stop_loss_pct in stop_loss_pcts:
try:
results = Backtest.run(
min1_df,
df,
initial_usd=initial_usd,
stop_loss_pct=stop_loss_pct,
debug=debug
)
# Calculate metrics
metrics = self._calculate_metrics(results, initial_usd, stop_loss_pct, timeframe_name)
results_rows.append(metrics)
# Process trades
trades = self._process_trades(results.get('trades', []), timeframe_name, stop_loss_pct)
trade_rows.extend(trades)
if self.logging:
self.logging.info(f"Timeframe: {timeframe_name}, Stop Loss: {stop_loss_pct}, Trades: {results['n_trades']}")
if debug:
self._debug_output(results)
except Exception as e:
error_msg = f"Error processing {timeframe_name} with stop loss {stop_loss_pct}: {e}"
if self.logging:
self.logging.error(error_msg)
raise RuntimeError(error_msg) from e
return results_rows, trade_rows
def _calculate_metrics(
self,
results: Dict[str, Any],
initial_usd: float,
stop_loss_pct: float,
timeframe_name: str
) -> Dict[str, Any]:
"""Calculate performance metrics from backtest results"""
trades = results.get('trades', [])
n_trades = results["n_trades"]
# Calculate win metrics
winning_trades = [t for t in trades if t.get('exit') is not None and t['exit'] > t['entry']]
n_winning_trades = len(winning_trades)
win_rate = n_winning_trades / n_trades if n_trades > 0 else 0
# Calculate profit metrics
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 drawdown
max_drawdown = self._calculate_max_drawdown(trades)
# Calculate final USD
final_usd = initial_usd
for trade in trades:
final_usd *= (1 + trade['profit_pct'])
# Calculate fees
total_fees_usd = sum(trade.get('fee_usd', 0) for trade in trades)
return {
"timeframe": timeframe_name,
"stop_loss_pct": stop_loss_pct,
"n_trades": n_trades,
"n_stop_loss": sum(1 for trade in trades if trade.get('type') == 'STOP'),
"win_rate": win_rate,
"max_drawdown": max_drawdown,
"avg_trade": avg_trade,
"total_profit": total_profit,
"total_loss": total_loss,
"profit_ratio": profit_ratio,
"initial_usd": initial_usd,
"final_usd": final_usd,
"total_fees_usd": total_fees_usd,
}
def _calculate_max_drawdown(self, trades: List[Dict]) -> float:
"""Calculate maximum drawdown from trade sequence"""
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 max_drawdown
def _process_trades(
self,
trades: List[Dict],
timeframe_name: str,
stop_loss_pct: float
) -> List[Dict]:
"""Process individual trades with metadata"""
processed_trades = []
for trade in trades:
processed_trade = {
"timeframe": timeframe_name,
"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"),
}
processed_trades.append(processed_trade)
return processed_trades
def _debug_output(self, results: Dict[str, Any]) -> None:
"""Output debug information for backtest results"""
trades = results.get('trades', [])
# Print stop loss trades
stop_loss_trades = [t for t in trades if t.get('type') == 'STOP']
if stop_loss_trades:
print("Stop Loss Trades:")
for trade in stop_loss_trades:
print(trade)
# Print large loss trades
large_loss_trades = [t for t in trades if t.get('profit_pct', 0) < -0.09]
if large_loss_trades:
print("Large Loss Trades:")
for trade in large_loss_trades:
print("Large loss trade:", trade)
def aggregate_results(self, all_results: List[Dict]) -> List[Dict]:
"""
Aggregate results per stop_loss_pct and timeframe
Args:
all_results: List of result dictionaries from all timeframes
Returns:
List of aggregated summary rows
"""
grouped = defaultdict(list)
for row in all_results:
key = (row['timeframe'], row['stop_loss_pct'])
grouped[key].append(row)
summary_rows = []
for (timeframe, stop_loss_pct), rows in grouped.items():
summary = self._aggregate_group(rows, timeframe, stop_loss_pct)
summary_rows.append(summary)
return summary_rows
def _aggregate_group(self, rows: List[Dict], timeframe: str, stop_loss_pct: float) -> Dict:
"""Aggregate a group of rows with the same timeframe and stop loss"""
total_trades = sum(r['n_trades'] for r in rows)
total_stop_loss = sum(r['n_stop_loss'] for r in rows)
# Calculate averages
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])
# Calculate final USD and fees
final_usd = np.mean([r.get('final_usd', r.get('initial_usd', 0)) for r in rows])
total_fees_usd = np.mean([r.get('total_fees_usd', 0) for r in rows])
initial_usd = rows[0].get('initial_usd', 0) if rows else 0
return {
"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": final_usd,
"total_fees_usd": total_fees_usd,
}
def save_trade_file(self, trades: List[Dict], timeframe: str, stop_loss_pct: float) -> None:
"""
Save individual trade file with summary header
Args:
trades: List of trades for this combination
timeframe: Timeframe name
stop_loss_pct: Stop loss percentage
"""
if not trades:
return
try:
# Generate filename
sl_percent = int(round(stop_loss_pct * 100))
trades_filename = os.path.join(self.storage.results_dir, f"trades_{timeframe}_ST{sl_percent}pct.csv")
# Prepare summary from first trade
sample_trade = trades[0]
summary_fields = ["timeframe", "stop_loss_pct", "n_trades", "win_rate"]
summary_values = [timeframe, stop_loss_pct, len(trades), "calculated_elsewhere"]
# Write file with header and trades
trades_fieldnames = ["entry_time", "exit_time", "entry_price", "exit_price", "profit_pct", "type", "fee_usd"]
with open(trades_filename, "w", newline="") as f:
# Write summary header
f.write("\t".join(summary_fields) + "\n")
f.write("\t".join(str(v) for v in summary_values) + "\n")
# Write trades
writer = csv.DictWriter(f, fieldnames=trades_fieldnames)
writer.writeheader()
for trade in trades:
writer.writerow({k: trade.get(k, "") for k in trades_fieldnames})
if self.logging:
self.logging.info(f"Trades saved to {trades_filename}")
except Exception as e:
error_msg = f"Failed to save trades file for {timeframe}_ST{int(round(stop_loss_pct * 100))}pct: {e}"
if self.logging:
self.logging.error(error_msg)
raise RuntimeError(error_msg) from e
def save_backtest_results(
self,
results: List[Dict],
metadata_lines: List[str],
timestamp: str
) -> str:
"""
Save aggregated backtest results to CSV file
Args:
results: List of aggregated result dictionaries
metadata_lines: List of metadata strings
timestamp: Timestamp for filename
Returns:
Path to saved file
"""
try:
filename = f"{timestamp}_backtest.csv"
fieldnames = [
"timeframe", "stop_loss_pct", "n_trades", "n_stop_loss", "win_rate",
"max_drawdown", "avg_trade", "profit_ratio", "final_usd", "total_fees_usd"
]
filepath = self.storage.write_backtest_results(filename, fieldnames, results, metadata_lines)
if self.logging:
self.logging.info(f"Backtest results saved to {filepath}")
return filepath
except Exception as e:
error_msg = f"Failed to save backtest results: {e}"
if self.logging:
self.logging.error(error_msg)
raise RuntimeError(error_msg) from e
def get_price_info(self, data_df: pd.DataFrame, date: str) -> Tuple[Optional[str], Optional[float]]:
"""
Get nearest price information for a given date
Args:
data_df: DataFrame with price data
date: Target date string
Returns:
Tuple of (nearest_time, price) or (None, None) if no data
"""
try:
if len(data_df) == 0:
return None, None
target_ts = pd.to_datetime(date)
nearest_idx = data_df.index.get_indexer([target_ts], method='nearest')[0]
nearest_time = data_df.index[nearest_idx]
price = data_df.iloc[nearest_idx]['close']
return str(nearest_time), float(price)
except Exception as e:
if self.logging:
self.logging.warning(f"Could not get price info for {date}: {e}")
return None, None