import pandas as pd import numpy as np import logging import concurrent.futures import os import datetime import argparse import json from cycles.utils.storage import Storage from cycles.utils.system import SystemUtils from cycles.backtest import Backtest from cycles.charts import BacktestCharts from cycles.strategies import create_strategy_manager logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler("backtest.log"), logging.StreamHandler() ] ) def strategy_manager_init(backtester: Backtest): """Strategy Manager initialization function""" # This will be called by Backtest.__init__, but actual initialization # happens in strategy_manager.initialize() pass def strategy_manager_entry(backtester: Backtest, df_index: int): """Strategy Manager entry function""" return backtester.strategy_manager.get_entry_signal(backtester, df_index) def strategy_manager_exit(backtester: Backtest, df_index: int): """Strategy Manager exit function""" return backtester.strategy_manager.get_exit_signal(backtester, df_index) def process_timeframe_data(data_1min, timeframe, config, debug=False): """Process a timeframe using Strategy Manager with configuration""" results_rows = [] trade_rows = [] # Extract values from config initial_usd = config['initial_usd'] strategy_config = { "strategies": config['strategies'], "combination_rules": config['combination_rules'] } # Create and initialize strategy manager if not strategy_config: logging.error("No strategy configuration provided") return results_rows, trade_rows strategy_manager = create_strategy_manager(strategy_config) # Get the primary timeframe from the first strategy for backtester setup primary_strategy = strategy_manager.strategies[0] primary_timeframe = primary_strategy.get_timeframes()[0] # For BBRS strategy, it works with 1-minute data directly and handles internal resampling # For other strategies, use their preferred timeframe if primary_strategy.name == "bbrs": # BBRS strategy processes 1-minute data and outputs signals on its internal timeframes # Use 1-minute data for backtester working dataframe working_df = data_1min.copy() else: # Other strategies specify their preferred timeframe # Let the primary strategy resample the data to get the working dataframe primary_strategy._resample_data(data_1min) working_df = primary_strategy.get_primary_timeframe_data() # Prepare working dataframe for backtester (ensure timestamp column) working_df_for_backtest = working_df.copy().reset_index() if 'index' in working_df_for_backtest.columns: working_df_for_backtest = working_df_for_backtest.rename(columns={'index': 'timestamp'}) # Initialize backtest with strategy manager initialization backtester = Backtest(initial_usd, working_df_for_backtest, working_df_for_backtest, strategy_manager_init) # Store original min1_df for strategy processing backtester.original_df = data_1min # Attach strategy manager to backtester and initialize backtester.strategy_manager = strategy_manager strategy_manager.initialize(backtester) # Run backtest with strategy manager functions results = backtester.run( strategy_manager_entry, strategy_manager_exit, debug ) n_trades = results["n_trades"] trades = results.get('trades', []) wins = [1 for t in trades if t['exit'] is not None and t['exit'] > t['entry']] n_winning_trades = len(wins) 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) win_rate = n_winning_trades / n_trades if n_trades > 0 else 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') 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 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) # Get stop_loss_pct from the first strategy for reporting # In multi-strategy setups, strategies can have different stop_loss_pct values stop_loss_pct = primary_strategy.params.get("stop_loss_pct", "N/A") # Update row to include timeframe information row = { "timeframe": f"{timeframe}({primary_timeframe})", # Show actual timeframe used "stop_loss_pct": stop_loss_pct, "n_trades": n_trades, "n_stop_loss": sum(1 for trade in trades if 'type' in trade and trade['type'] == 'STOP_LOSS'), "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, } results_rows.append(row) 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 strategy summary strategy_summary = strategy_manager.get_strategy_summary() logging.info(f"Timeframe: {timeframe}({primary_timeframe}), Stop Loss: {stop_loss_pct}, " f"Trades: {n_trades}, Strategies: {[s['name'] for s in strategy_summary['strategies']]}") if debug: # Plot after each backtest run try: # Check if any strategy has processed_data for universal plotting processed_data = None for strategy in strategy_manager.strategies: if hasattr(backtester, 'processed_data') and backtester.processed_data is not None: processed_data = backtester.processed_data break if processed_data is not None and not processed_data.empty: # Format strategy data with actual executed trades for universal plotting formatted_data = BacktestCharts.format_strategy_data_with_trades(processed_data, results) # Plot using universal function BacktestCharts.plot_data(formatted_data) else: # Fallback to meta_trend plot if available if "meta_trend" in backtester.strategies: meta_trend = backtester.strategies["meta_trend"] # Use the working dataframe for plotting BacktestCharts.plot(working_df, meta_trend) else: print("No plotting data available") except Exception as e: print(f"Plotting failed: {e}") return results_rows, trade_rows def process(timeframe_info, debug=False): """Process a single timeframe with strategy config""" timeframe, data_1min, config = timeframe_info # Pass the essential data and full config results_rows, all_trade_rows = process_timeframe_data( data_1min, timeframe, config, debug=debug ) return results_rows, all_trade_rows def aggregate_results(all_rows): """Aggregate results per stop_loss_pct and per rule (timeframe)""" from collections import defaultdict grouped = defaultdict(list) for row in all_rows: key = (row['timeframe'], row['stop_loss_pct']) grouped[key].append(row) summary_rows = [] for (rule, stop_loss_pct), rows in grouped.items(): n_months = len(rows) total_trades = sum(r['n_trades'] for r in rows) total_stop_loss = sum(r['n_stop_loss'] for r in rows) 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 final_usd = np.mean([r.get('final_usd', initial_usd) for r in rows]) total_fees_usd = np.mean([r.get('total_fees_usd') for r in rows]) summary_rows.append({ "timeframe": rule, "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, }) return summary_rows def get_nearest_price(df, target_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 nearest_time, price if __name__ == "__main__": debug = True parser = argparse.ArgumentParser(description="Run backtest with config file.") parser.add_argument("config", type=str, nargs="?", help="Path to config JSON file.") args = parser.parse_args() # Use config_default.json as fallback if no config provided config_file = args.config or "configs/config_default.json" try: with open(config_file, 'r') as f: config = json.load(f) print(f"Using config: {config_file}") except FileNotFoundError: print(f"Error: Config file '{config_file}' not found.") print("Available configs: configs/config_default.json, configs/config_bbrs.json, configs/config_combined.json") exit(1) except json.JSONDecodeError as e: print(f"Error: Invalid JSON in config file '{config_file}': {e}") exit(1) start_date = config['start_date'] if config['stop_date'] is None: stop_date = datetime.datetime.now().strftime("%Y-%m-%d") else: stop_date = config['stop_date'] initial_usd = config['initial_usd'] timeframes = config['timeframes'] timestamp = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M") storage = Storage(logging=logging) system_utils = SystemUtils(logging=logging) data_1min = storage.load_data('btcusd_1-min_data.csv', start_date, stop_date) nearest_start_time, start_price = get_nearest_price(data_1min, start_date) nearest_stop_time, stop_price = get_nearest_price(data_1min, stop_date) metadata_lines = [ 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}" ] # Create tasks for each timeframe tasks = [ (name, data_1min, config) for name in timeframes ] if debug: all_results_rows = [] all_trade_rows = [] for task in tasks: results, trades = process(task, debug) if results or trades: all_results_rows.extend(results) all_trade_rows.extend(trades) else: workers = system_utils.get_optimal_workers() with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor: futures = {executor.submit(process, task, debug): task for task in tasks} all_results_rows = [] all_trade_rows = [] for future in concurrent.futures.as_completed(futures): results, trades = future.result() if results or trades: all_results_rows.extend(results) all_trade_rows.extend(trades) backtest_filename = os.path.join(f"{timestamp}_backtest.csv") backtest_fieldnames = [ "timeframe", "stop_loss_pct", "n_trades", "n_stop_loss", "win_rate", "max_drawdown", "avg_trade", "profit_ratio", "final_usd", "total_fees_usd" ] storage.write_backtest_results(backtest_filename, backtest_fieldnames, all_results_rows, metadata_lines) trades_fieldnames = ["entry_time", "exit_time", "entry_price", "exit_price", "profit_pct", "type", "fee_usd"] storage.write_trades(all_trade_rows, trades_fieldnames)