import pandas as pd import numpy as np import time from cycles.supertrend import Supertrends from cycles.market_fees import MarketFees class Backtest: @staticmethod def run(min1_df, df, initial_usd, stop_loss_pct, progress_callback=None, verbose=False): """ Backtest a simple strategy using the meta supertrend (all three supertrends agree). Buys when meta supertrend is positive, sells when negative, applies a percentage stop loss. Parameters: - min1_df: pandas DataFrame, 1-minute timeframe data for more accurate stop loss checking (optional) - df: pandas DataFrame, main timeframe data for signals - initial_usd: float, starting USD amount - stop_loss_pct: float, stop loss as a fraction (e.g. 0.05 for 5%) - progress_callback: callable, optional callback function to report progress (current_step) - verbose: bool, enable debug logging for stop loss checks """ _df = df.copy().reset_index() # Ensure we have a timestamp column regardless of original index name if 'timestamp' not in _df.columns: # If reset_index() created a column with the original index name, rename it if len(_df.columns) > 0 and _df.columns[0] not in ['open', 'high', 'low', 'close', 'volume', 'predicted_close_price']: _df = _df.rename(columns={_df.columns[0]: 'timestamp'}) else: raise ValueError("Unable to identify timestamp column in DataFrame") _df['timestamp'] = pd.to_datetime(_df['timestamp']) supertrends = Supertrends(_df, verbose=False, close_column='predicted_close_price') supertrend_results_list = supertrends.calculate_supertrend_indicators() trends = [st['results']['trend'] for st in supertrend_results_list] trends_arr = np.stack(trends, axis=1) meta_trend = np.where((trends_arr[:,0] == trends_arr[:,1]) & (trends_arr[:,1] == trends_arr[:,2]), trends_arr[:,0], 0) # Shift meta_trend by one to avoid lookahead bias meta_trend_signal = np.roll(meta_trend, 1) meta_trend_signal[0] = 0 # or np.nan, but 0 means 'no signal' for first bar position = 0 # 0 = no position, 1 = long entry_price = 0 usd = initial_usd coin = 0 trade_log = [] max_balance = initial_usd drawdowns = [] trades = [] entry_time = None stop_loss_count = 0 # Track number of stop losses # Ensure min1_df has proper DatetimeIndex if min1_df is not None and not min1_df.empty: min1_df.index = pd.to_datetime(min1_df.index) for i in range(1, len(_df)): # Report progress if callback is provided if progress_callback: # Update more frequently for better responsiveness update_frequency = max(1, len(_df) // 50) # Update every 2% of dataset (50 updates total) if i % update_frequency == 0 or i == len(_df) - 1: # Always update on last iteration if verbose: # Only print in verbose mode to avoid spam print(f"DEBUG: Progress callback called with i={i}, total={len(_df)-1}") progress_callback(i) price_open = _df['open'].iloc[i] price_close = _df['close'].iloc[i] date = _df['timestamp'].iloc[i] prev_mt = meta_trend_signal[i-1] curr_mt = meta_trend_signal[i] # Check stop loss if in position if position == 1: stop_loss_result = Backtest.check_stop_loss( min1_df, entry_time, date, entry_price, stop_loss_pct, coin, verbose=verbose ) if stop_loss_result is not None: trade_log_entry, position, coin, entry_price, usd = stop_loss_result trade_log.append(trade_log_entry) stop_loss_count += 1 continue # Entry: only if not in position and signal changes to 1 if position == 0 and prev_mt != 1 and curr_mt == 1: entry_result = Backtest.handle_entry(usd, price_open, date) coin, entry_price, entry_time, usd, position, trade_log_entry = entry_result trade_log.append(trade_log_entry) # Exit: only if in position and signal changes from 1 to -1 elif position == 1 and prev_mt == 1 and curr_mt == -1: exit_result = Backtest.handle_exit(coin, price_open, entry_price, entry_time, date) usd, coin, position, entry_price, trade_log_entry = exit_result trade_log.append(trade_log_entry) # Track drawdown balance = usd if position == 0 else coin * price_close if balance > max_balance: max_balance = balance drawdown = (max_balance - balance) / max_balance drawdowns.append(drawdown) # Report completion if callback is provided if progress_callback: progress_callback(len(_df) - 1) # If still in position at end, sell at last close if position == 1: exit_result = Backtest.handle_exit(coin, _df['close'].iloc[-1], entry_price, entry_time, _df['timestamp'].iloc[-1]) usd, coin, position, entry_price, trade_log_entry = exit_result trade_log.append(trade_log_entry) # Calculate statistics final_balance = usd n_trades = len(trade_log) wins = [1 for t in trade_log if t['exit'] is not None and t['exit'] > t['entry']] win_rate = len(wins) / n_trades if n_trades > 0 else 0 max_drawdown = max(drawdowns) if drawdowns else 0 avg_trade = np.mean([t['exit']/t['entry']-1 for t in trade_log if t['exit'] is not None]) if trade_log else 0 trades = [] total_fees_usd = 0.0 for trade in trade_log: if trade['exit'] is not None: profit_pct = (trade['exit'] - trade['entry']) / trade['entry'] else: profit_pct = 0.0 # Validate fee_usd field if 'fee_usd' not in trade: raise ValueError(f"Trade missing required field 'fee_usd': {trade}") fee_usd = trade['fee_usd'] if fee_usd is None: raise ValueError(f"Trade fee_usd is None: {trade}") # Validate trade type field if 'type' not in trade: raise ValueError(f"Trade missing required field 'type': {trade}") trade_type = trade['type'] if trade_type is None: raise ValueError(f"Trade type is None: {trade}") trades.append({ 'entry_time': trade['entry_time'], 'exit_time': trade['exit_time'], 'entry': trade['entry'], 'exit': trade['exit'], 'profit_pct': profit_pct, 'type': trade_type, 'fee_usd': fee_usd }) total_fees_usd += fee_usd results = { "initial_usd": initial_usd, "final_usd": final_balance, "n_trades": n_trades, "n_stop_loss": stop_loss_count, # Add stop loss count "win_rate": win_rate, "max_drawdown": max_drawdown, "avg_trade": avg_trade, "trade_log": trade_log, "trades": trades, "total_fees_usd": total_fees_usd, } if n_trades > 0: results["first_trade"] = { "entry_time": trade_log[0]['entry_time'], "entry": trade_log[0]['entry'] } results["last_trade"] = { "exit_time": trade_log[-1]['exit_time'], "exit": trade_log[-1]['exit'] } return results @staticmethod def check_stop_loss(min1_df, entry_time, current_time, entry_price, stop_loss_pct, coin, verbose=False): """ Check if stop loss should be triggered based on 1-minute data Args: min1_df: 1-minute DataFrame with DatetimeIndex entry_time: Entry timestamp current_time: Current timestamp entry_price: Entry price stop_loss_pct: Stop loss percentage (e.g. 0.05 for 5%) coin: Current coin position verbose: Enable debug logging Returns: Tuple of (trade_log_entry, position, coin, entry_price, usd) if stop loss triggered, None otherwise """ if min1_df is None or min1_df.empty: if verbose: print("Warning: No 1-minute data available for stop loss checking") return None stop_price = entry_price * (1 - stop_loss_pct) try: # Ensure min1_df has a DatetimeIndex if not isinstance(min1_df.index, pd.DatetimeIndex): if verbose: print("Warning: min1_df does not have DatetimeIndex") return None # Convert entry_time and current_time to pandas Timestamps for comparison entry_ts = pd.to_datetime(entry_time) current_ts = pd.to_datetime(current_time) if verbose: print(f"Checking stop loss from {entry_ts} to {current_ts}, stop_price: {stop_price:.2f}") # Handle edge case where entry and current time are the same (1-minute timeframe) if entry_ts == current_ts: if verbose: print("Entry and current time are the same, no range to check") return None # Find the range of 1-minute data to check (exclusive of entry time, inclusive of current time) # We start from the candle AFTER entry to avoid checking the entry candle itself start_check_time = entry_ts + pd.Timedelta(minutes=1) # Get the slice of data to check for stop loss mask = (min1_df.index > entry_ts) & (min1_df.index <= current_ts) min1_slice = min1_df.loc[mask] if len(min1_slice) == 0: if verbose: print(f"No 1-minute data found between {start_check_time} and {current_ts}") return None if verbose: print(f"Checking {len(min1_slice)} candles for stop loss") # Check if any low price in the slice hits the stop loss stop_triggered = (min1_slice['low'] <= stop_price).any() if stop_triggered: # Find the exact candle where stop loss was triggered stop_candle = min1_slice[min1_slice['low'] <= stop_price].iloc[0] if verbose: print(f"Stop loss triggered at {stop_candle.name}, low: {stop_candle['low']:.2f}") # More realistic fill: if open < stop, fill at open, else at stop if stop_candle['open'] < stop_price: sell_price = stop_candle['open'] if verbose: print(f"Filled at open price: {sell_price:.2f}") else: sell_price = stop_price if verbose: print(f"Filled at stop price: {sell_price:.2f}") btc_to_sell = coin usd_gross = btc_to_sell * sell_price exit_fee = MarketFees.calculate_okx_taker_maker_fee(usd_gross, is_maker=False) usd_after_stop = usd_gross - exit_fee trade_log_entry = { 'type': 'STOP', 'entry': entry_price, 'exit': sell_price, 'entry_time': entry_time, 'exit_time': stop_candle.name, 'fee_usd': exit_fee } # After stop loss, reset position and entry, return USD balance return trade_log_entry, 0, 0, 0, usd_after_stop elif verbose: print(f"No stop loss triggered, min low in range: {min1_slice['low'].min():.2f}") except Exception as e: # In case of any error, don't trigger stop loss but log the issue error_msg = f"Warning: Stop loss check failed: {e}" print(error_msg) if verbose: import traceback print(traceback.format_exc()) return None return None @staticmethod def handle_entry(usd, price_open, date): entry_fee = MarketFees.calculate_okx_taker_maker_fee(usd, is_maker=False) usd_after_fee = usd - entry_fee coin = usd_after_fee / price_open entry_price = price_open entry_time = date usd = 0 position = 1 trade_log_entry = { 'type': 'BUY', 'entry': entry_price, 'exit': None, 'entry_time': entry_time, 'exit_time': None, 'fee_usd': entry_fee } return coin, entry_price, entry_time, usd, position, trade_log_entry @staticmethod def handle_exit(coin, price_open, entry_price, entry_time, date): btc_to_sell = coin usd_gross = btc_to_sell * price_open exit_fee = MarketFees.calculate_okx_taker_maker_fee(usd_gross, is_maker=False) usd = usd_gross - exit_fee trade_log_entry = { 'type': 'SELL', 'entry': entry_price, 'exit': price_open, 'entry_time': entry_time, 'exit_time': date, 'fee_usd': exit_fee } coin = 0 position = 0 entry_price = 0 return usd, coin, position, entry_price, trade_log_entry