2025-05-21 17:03:34 +08:00
|
|
|
import pandas as pd
|
|
|
|
|
import numpy as np
|
2025-05-29 11:04:03 +08:00
|
|
|
import time
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
from cycles.supertrend import Supertrends
|
|
|
|
|
from cycles.market_fees import MarketFees
|
|
|
|
|
|
|
|
|
|
class Backtest:
|
|
|
|
|
@staticmethod
|
2025-07-10 10:23:41 +08:00
|
|
|
def run(min1_df, df, initial_usd, stop_loss_pct, progress_callback=None, verbose=False):
|
2025-05-21 17:03:34 +08:00
|
|
|
"""
|
|
|
|
|
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)
|
2025-07-10 10:23:41 +08:00
|
|
|
- df: pandas DataFrame, main timeframe data for signals
|
2025-05-21 17:03:34 +08:00
|
|
|
- initial_usd: float, starting USD amount
|
|
|
|
|
- stop_loss_pct: float, stop loss as a fraction (e.g. 0.05 for 5%)
|
2025-07-10 10:23:41 +08:00
|
|
|
- progress_callback: callable, optional callback function to report progress (current_step)
|
|
|
|
|
- verbose: bool, enable debug logging for stop loss checks
|
2025-05-21 17:03:34 +08:00
|
|
|
"""
|
2025-07-10 10:23:41 +08:00
|
|
|
_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")
|
|
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
_df['timestamp'] = pd.to_datetime(_df['timestamp'])
|
|
|
|
|
|
2025-07-10 10:23:41 +08:00
|
|
|
supertrends = Supertrends(_df, verbose=False, close_column='predicted_close_price')
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
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)
|
2025-05-27 17:49:55 +08:00
|
|
|
# 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
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
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
|
2025-07-10 10:23:41 +08:00
|
|
|
stop_loss_count = 0 # Track number of stop losses
|
2025-05-21 17:03:34 +08:00
|
|
|
|
2025-07-10 10:23:41 +08:00
|
|
|
# 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)
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
for i in range(1, len(_df)):
|
2025-07-10 10:23:41 +08:00
|
|
|
# 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)
|
2025-05-29 11:04:03 +08:00
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
price_open = _df['open'].iloc[i]
|
|
|
|
|
price_close = _df['close'].iloc[i]
|
|
|
|
|
date = _df['timestamp'].iloc[i]
|
2025-05-27 17:49:55 +08:00
|
|
|
prev_mt = meta_trend_signal[i-1]
|
|
|
|
|
curr_mt = meta_trend_signal[i]
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
# 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,
|
2025-07-10 10:23:41 +08:00
|
|
|
verbose=verbose
|
2025-05-21 17:03:34 +08:00
|
|
|
)
|
|
|
|
|
if stop_loss_result is not None:
|
2025-07-10 10:23:41 +08:00
|
|
|
trade_log_entry, position, coin, entry_price, usd = stop_loss_result
|
2025-05-21 17:03:34 +08:00
|
|
|
trade_log.append(trade_log_entry)
|
2025-07-10 10:23:41 +08:00
|
|
|
stop_loss_count += 1
|
2025-05-21 17:03:34 +08:00
|
|
|
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)
|
|
|
|
|
|
2025-07-10 10:23:41 +08:00
|
|
|
# Report completion if callback is provided
|
|
|
|
|
if progress_callback:
|
|
|
|
|
progress_callback(len(_df) - 1)
|
2025-05-29 11:04:03 +08:00
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
# 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
|
2025-07-10 10:23:41 +08:00
|
|
|
|
|
|
|
|
# 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}")
|
|
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
trades.append({
|
|
|
|
|
'entry_time': trade['entry_time'],
|
|
|
|
|
'exit_time': trade['exit_time'],
|
|
|
|
|
'entry': trade['entry'],
|
|
|
|
|
'exit': trade['exit'],
|
|
|
|
|
'profit_pct': profit_pct,
|
2025-07-10 10:23:41 +08:00
|
|
|
'type': trade_type,
|
|
|
|
|
'fee_usd': fee_usd
|
2025-05-21 17:03:34 +08:00
|
|
|
})
|
|
|
|
|
total_fees_usd += fee_usd
|
|
|
|
|
|
|
|
|
|
results = {
|
|
|
|
|
"initial_usd": initial_usd,
|
|
|
|
|
"final_usd": final_balance,
|
|
|
|
|
"n_trades": n_trades,
|
2025-07-10 10:23:41 +08:00
|
|
|
"n_stop_loss": stop_loss_count, # Add stop loss count
|
2025-05-21 17:03:34 +08:00
|
|
|
"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
|
2025-07-10 10:23:41 +08:00
|
|
|
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
|
|
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
stop_price = entry_price * (1 - stop_loss_pct)
|
|
|
|
|
|
2025-07-10 10:23:41 +08:00
|
|
|
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
|
|
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
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
|
2025-05-21 17:06:16 +08:00
|
|
|
return usd, coin, position, entry_price, trade_log_entry
|