- Refactored the Backtest class to encapsulate state and behavior, enhancing clarity and maintainability. - Updated strategy functions to accept the Backtest instance, streamlining data access and manipulation. - Introduced a new plotting method in BacktestCharts for visualizing close prices with trend indicators. - Improved handling of meta_trend data to ensure proper visualization and trend representation. - Adjusted main execution logic to support the new Backtest structure and enhanced debugging capabilities.
165 lines
6.0 KiB
Python
165 lines
6.0 KiB
Python
import pandas as pd
|
|
import numpy as np
|
|
|
|
from cycles.market_fees import MarketFees
|
|
|
|
class Backtest:
|
|
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):
|
|
"""
|
|
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.
|
|
|
|
Parameters:
|
|
- 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.
|
|
"""
|
|
|
|
for i in range(1, len(self.df)):
|
|
self.price_open = self.df['open'].iloc[i]
|
|
self.price_close = self.df['close'].iloc[i]
|
|
|
|
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)
|
|
|
|
if exit_test_results is not None:
|
|
self.handle_exit(exit_test_results, sell_price)
|
|
|
|
# Track drawdown
|
|
balance = self.usd if self.position == 0 else self.coin * self.price_close
|
|
|
|
if balance > self.max_balance:
|
|
self.max_balance = balance
|
|
|
|
drawdown = (self.max_balance - balance) / self.max_balance
|
|
self.drawdowns.append(drawdown)
|
|
|
|
# If still in position at end, sell at last close
|
|
if self.position == 1:
|
|
self.handle_exit("EOD", None)
|
|
|
|
|
|
# Calculate statistics
|
|
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']]
|
|
win_rate = len(wins) / n_trades if n_trades > 0 else 0
|
|
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
|
|
|
|
trades = []
|
|
total_fees_usd = 0.0
|
|
|
|
for trade in self.trade_log:
|
|
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,
|
|
'type': trade['type'],
|
|
'fee_usd': trade['fee_usd']
|
|
})
|
|
fee_usd = trade.get('fee_usd')
|
|
total_fees_usd += fee_usd
|
|
|
|
results = {
|
|
"initial_usd": self.initial_usd,
|
|
"final_usd": final_balance,
|
|
"n_trades": n_trades,
|
|
"win_rate": win_rate,
|
|
"max_drawdown": max_drawdown,
|
|
"avg_trade": avg_trade,
|
|
"trade_log": self.trade_log,
|
|
"trades": trades,
|
|
"total_fees_usd": total_fees_usd,
|
|
}
|
|
if n_trades > 0:
|
|
results["first_trade"] = {
|
|
"entry_time": self.trade_log[0]['entry_time'],
|
|
"entry": self.trade_log[0]['entry']
|
|
}
|
|
results["last_trade"] = {
|
|
"exit_time": self.trade_log[-1]['exit_time'],
|
|
"exit": self.trade_log[-1]['exit']
|
|
}
|
|
return results
|
|
|
|
def handle_entry(self):
|
|
entry_fee = MarketFees.calculate_okx_taker_maker_fee(self.usd, is_maker=False)
|
|
usd_after_fee = self.usd - entry_fee
|
|
|
|
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
|
|
|
|
trade_log_entry = {
|
|
'type': 'BUY',
|
|
'entry': self.entry_price,
|
|
'exit': None,
|
|
'entry_time': self.entry_time,
|
|
'exit_time': None,
|
|
'fee_usd': entry_fee
|
|
}
|
|
self.trade_log.append(trade_log_entry)
|
|
|
|
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
|
|
usd_gross = btc_to_sell * exit_price
|
|
exit_fee = MarketFees.calculate_okx_taker_maker_fee(usd_gross, is_maker=False)
|
|
|
|
self.usd = usd_gross - exit_fee
|
|
|
|
exit_log_entry = {
|
|
'type': exit_reason,
|
|
'entry': self.entry_price,
|
|
'exit': exit_price,
|
|
'entry_time': self.entry_time,
|
|
'exit_time': self.current_date,
|
|
'fee_usd': exit_fee
|
|
}
|
|
self.coin = 0
|
|
self.position = 0
|
|
self.entry_price = 0
|
|
|
|
self.trade_log.append(exit_log_entry)
|
|
|