2025-05-21 17:03:34 +08:00
|
|
|
import pandas as pd
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
|
|
from cycles.market_fees import MarketFees
|
|
|
|
|
|
|
|
|
|
class Backtest:
|
2025-05-22 20:02:14 +08:00
|
|
|
def __init__(self, initial_usd, df, min1_df, init_strategy_fields) -> None:
|
|
|
|
|
self.initial_usd = initial_usd
|
|
|
|
|
self.usd = initial_usd
|
|
|
|
|
self.max_balance = initial_usd
|
|
|
|
|
self.coin = 0
|
|
|
|
|
self.position = 0
|
|
|
|
|
self.entry_price = 0
|
|
|
|
|
self.entry_time = None
|
|
|
|
|
self.current_trade_min1_start_idx = None
|
|
|
|
|
self.current_min1_end_idx = None
|
|
|
|
|
self.price_open = None
|
|
|
|
|
self.price_close = None
|
|
|
|
|
self.current_date = None
|
|
|
|
|
self.strategies = {}
|
|
|
|
|
self.df = df
|
|
|
|
|
self.min1_df = min1_df
|
|
|
|
|
|
|
|
|
|
self.trade_log = []
|
|
|
|
|
self.drawdowns = []
|
|
|
|
|
self.trades = []
|
|
|
|
|
|
|
|
|
|
self = init_strategy_fields(self)
|
|
|
|
|
|
|
|
|
|
def run(self, entry_strategy, exit_strategy, debug=False):
|
2025-05-21 17:03:34 +08:00
|
|
|
"""
|
2025-05-22 20:02:14 +08:00
|
|
|
Runs the backtest using provided entry and exit strategy functions.
|
|
|
|
|
|
|
|
|
|
The method iterates over the main DataFrame (self.df), simulating trades based on the entry and exit strategies. It tracks balances, drawdowns, and logs each trade, including fees. At the end, it returns a dictionary of performance statistics.
|
|
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
Parameters:
|
2025-05-22 20:02:14 +08:00
|
|
|
- entry_strategy: function, determines when to enter a trade. Should accept (self, i) and return True to enter.
|
|
|
|
|
- exit_strategy: function, determines when to exit a trade. Should accept (self, i) and return (exit_reason, sell_price) or (None, None) to hold.
|
|
|
|
|
- debug: bool, whether to print debug info (default: False)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
- dict with keys: initial_usd, final_usd, n_trades, win_rate, max_drawdown, avg_trade, trade_log, trades, total_fees_usd, and optionally first_trade and last_trade.
|
2025-05-21 17:03:34 +08:00
|
|
|
"""
|
|
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
for i in range(1, len(self.df)):
|
|
|
|
|
self.price_open = self.df['open'].iloc[i]
|
|
|
|
|
self.price_close = self.df['close'].iloc[i]
|
2025-05-21 17:03:34 +08:00
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
self.current_date = self.df['timestamp'].iloc[i]
|
|
|
|
|
|
|
|
|
|
if self.position == 0:
|
|
|
|
|
if entry_strategy(self, i):
|
|
|
|
|
self.handle_entry()
|
|
|
|
|
elif self.position == 1:
|
|
|
|
|
exit_test_results, sell_price = exit_strategy(self, i)
|
2025-05-22 17:05:19 +08:00
|
|
|
|
|
|
|
|
if exit_test_results is not None:
|
2025-05-22 20:02:14 +08:00
|
|
|
self.handle_exit(exit_test_results, sell_price)
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
# Track drawdown
|
2025-05-22 20:02:14 +08:00
|
|
|
balance = self.usd if self.position == 0 else self.coin * self.price_close
|
2025-05-22 17:05:19 +08:00
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
if balance > self.max_balance:
|
|
|
|
|
self.max_balance = balance
|
2025-05-22 17:05:19 +08:00
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
drawdown = (self.max_balance - balance) / self.max_balance
|
|
|
|
|
self.drawdowns.append(drawdown)
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
# If still in position at end, sell at last close
|
2025-05-22 20:02:14 +08:00
|
|
|
if self.position == 1:
|
|
|
|
|
self.handle_exit("EOD", None)
|
|
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
# Calculate statistics
|
2025-05-22 20:02:14 +08:00
|
|
|
final_balance = self.usd
|
|
|
|
|
n_trades = len(self.trade_log)
|
|
|
|
|
wins = [1 for t in self.trade_log if t['exit'] is not None and t['exit'] > t['entry']]
|
2025-05-21 17:03:34 +08:00
|
|
|
win_rate = len(wins) / n_trades if n_trades > 0 else 0
|
2025-05-22 20:02:14 +08:00
|
|
|
max_drawdown = max(self.drawdowns) if self.drawdowns else 0
|
|
|
|
|
avg_trade = np.mean([t['exit']/t['entry']-1 for t in self.trade_log if t['exit'] is not None]) if self.trade_log else 0
|
2025-05-21 17:03:34 +08:00
|
|
|
|
|
|
|
|
trades = []
|
|
|
|
|
total_fees_usd = 0.0
|
2025-05-22 20:02:14 +08:00
|
|
|
|
|
|
|
|
for trade in self.trade_log:
|
2025-05-21 17:03:34 +08:00
|
|
|
if trade['exit'] is not None:
|
|
|
|
|
profit_pct = (trade['exit'] - trade['entry']) / trade['entry']
|
|
|
|
|
else:
|
|
|
|
|
profit_pct = 0.0
|
|
|
|
|
trades.append({
|
|
|
|
|
'entry_time': trade['entry_time'],
|
|
|
|
|
'exit_time': trade['exit_time'],
|
|
|
|
|
'entry': trade['entry'],
|
|
|
|
|
'exit': trade['exit'],
|
|
|
|
|
'profit_pct': profit_pct,
|
2025-05-22 17:05:19 +08:00
|
|
|
'type': trade['type'],
|
|
|
|
|
'fee_usd': trade['fee_usd']
|
2025-05-21 17:03:34 +08:00
|
|
|
})
|
|
|
|
|
fee_usd = trade.get('fee_usd')
|
|
|
|
|
total_fees_usd += fee_usd
|
|
|
|
|
|
|
|
|
|
results = {
|
2025-05-22 20:02:14 +08:00
|
|
|
"initial_usd": self.initial_usd,
|
2025-05-21 17:03:34 +08:00
|
|
|
"final_usd": final_balance,
|
|
|
|
|
"n_trades": n_trades,
|
|
|
|
|
"win_rate": win_rate,
|
|
|
|
|
"max_drawdown": max_drawdown,
|
|
|
|
|
"avg_trade": avg_trade,
|
2025-05-22 20:02:14 +08:00
|
|
|
"trade_log": self.trade_log,
|
2025-05-21 17:03:34 +08:00
|
|
|
"trades": trades,
|
|
|
|
|
"total_fees_usd": total_fees_usd,
|
|
|
|
|
}
|
|
|
|
|
if n_trades > 0:
|
|
|
|
|
results["first_trade"] = {
|
2025-05-22 20:02:14 +08:00
|
|
|
"entry_time": self.trade_log[0]['entry_time'],
|
|
|
|
|
"entry": self.trade_log[0]['entry']
|
2025-05-21 17:03:34 +08:00
|
|
|
}
|
|
|
|
|
results["last_trade"] = {
|
2025-05-22 20:02:14 +08:00
|
|
|
"exit_time": self.trade_log[-1]['exit_time'],
|
|
|
|
|
"exit": self.trade_log[-1]['exit']
|
2025-05-21 17:03:34 +08:00
|
|
|
}
|
|
|
|
|
return results
|
|
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
def handle_entry(self):
|
|
|
|
|
entry_fee = MarketFees.calculate_okx_taker_maker_fee(self.usd, is_maker=False)
|
|
|
|
|
usd_after_fee = self.usd - entry_fee
|
2025-05-22 17:05:19 +08:00
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
self.coin = usd_after_fee / self.price_open
|
|
|
|
|
self.entry_price = self.price_open
|
|
|
|
|
self.entry_time = self.current_date
|
|
|
|
|
self.usd = 0
|
|
|
|
|
self.position = 1
|
2025-05-22 17:05:19 +08:00
|
|
|
|
2025-05-21 17:03:34 +08:00
|
|
|
trade_log_entry = {
|
|
|
|
|
'type': 'BUY',
|
2025-05-22 20:02:14 +08:00
|
|
|
'entry': self.entry_price,
|
2025-05-21 17:03:34 +08:00
|
|
|
'exit': None,
|
2025-05-22 20:02:14 +08:00
|
|
|
'entry_time': self.entry_time,
|
2025-05-21 17:03:34 +08:00
|
|
|
'exit_time': None,
|
|
|
|
|
'fee_usd': entry_fee
|
|
|
|
|
}
|
2025-05-22 20:02:14 +08:00
|
|
|
self.trade_log.append(trade_log_entry)
|
2025-05-21 17:03:34 +08:00
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
def handle_exit(self, exit_reason, sell_price):
|
|
|
|
|
btc_to_sell = self.coin
|
|
|
|
|
exit_price = sell_price if sell_price is not None else self.price_open
|
2025-05-22 17:05:19 +08:00
|
|
|
usd_gross = btc_to_sell * exit_price
|
2025-05-21 17:03:34 +08:00
|
|
|
exit_fee = MarketFees.calculate_okx_taker_maker_fee(usd_gross, is_maker=False)
|
2025-05-22 17:05:19 +08:00
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
self.usd = usd_gross - exit_fee
|
2025-05-22 17:05:19 +08:00
|
|
|
|
|
|
|
|
exit_log_entry = {
|
|
|
|
|
'type': exit_reason,
|
2025-05-22 20:02:14 +08:00
|
|
|
'entry': self.entry_price,
|
2025-05-22 17:05:19 +08:00
|
|
|
'exit': exit_price,
|
2025-05-22 20:02:14 +08:00
|
|
|
'entry_time': self.entry_time,
|
|
|
|
|
'exit_time': self.current_date,
|
2025-05-21 17:03:34 +08:00
|
|
|
'fee_usd': exit_fee
|
|
|
|
|
}
|
2025-05-22 20:02:14 +08:00
|
|
|
self.coin = 0
|
|
|
|
|
self.position = 0
|
|
|
|
|
self.entry_price = 0
|
2025-05-22 17:05:19 +08:00
|
|
|
|
2025-05-22 20:02:14 +08:00
|
|
|
self.trade_log.append(exit_log_entry)
|
|
|
|
|
|