354 lines
13 KiB
Python
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
|