Cycles/cycles/backtest.py
Simon Moisy e286dd881a - Refactored the Backtest class for strategy modularity
- Updated entry and exit strategy functions
2025-05-22 17:05:19 +08:00

165 lines
5.9 KiB
Python

import pandas as pd
import numpy as np
from cycles.market_fees import MarketFees
class Backtest:
class Data:
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 = init_strategy_fields(self)
@staticmethod
def run(data, entry_strategy, exit_strategy, debug=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)
- initial_usd: float, starting USD amount
- stop_loss_pct: float, stop loss as a fraction (e.g. 0.05 for 5%)
- debug: bool, whether to print debug info
"""
trade_log = []
drawdowns = []
trades = []
for i in range(1, len(data.df)):
data.price_open = data.df['open'].iloc[i]
data.price_close = data.df['close'].iloc[i]
data.current_date = data.df['timestamp'].iloc[i]
if data.position == 0:
if entry_strategy(data, i):
data, entry_log_entry = Backtest.handle_entry(data)
trade_log.append(entry_log_entry)
elif data.position == 1:
exit_test_results, data, sell_price = exit_strategy(data, i)
if exit_test_results is not None:
data, exit_log_entry = Backtest.handle_exit(data, exit_test_results, sell_price)
trade_log.append(exit_log_entry)
# Track drawdown
balance = data.usd if data.position == 0 else data.coin * data.price_close
if balance > data.max_balance:
data.max_balance = balance
drawdown = (data.max_balance - balance) / data.max_balance
drawdowns.append(drawdown)
# If still in position at end, sell at last close
if data.position == 1:
data, exit_log_entry = Backtest.handle_exit(data, "EOD", None)
trade_log.append(exit_log_entry)
# Calculate statistics
final_balance = data.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
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": data.initial_usd,
"final_usd": final_balance,
"n_trades": n_trades,
"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 handle_entry(data):
entry_fee = MarketFees.calculate_okx_taker_maker_fee(data.usd, is_maker=False)
usd_after_fee = data.usd - entry_fee
data.coin = usd_after_fee / data.price_open
data.entry_price = data.price_open
data.entry_time = data.current_date
data.usd = 0
data.position = 1
trade_log_entry = {
'type': 'BUY',
'entry': data.entry_price,
'exit': None,
'entry_time': data.entry_time,
'exit_time': None,
'fee_usd': entry_fee
}
return data, trade_log_entry
@staticmethod
def handle_exit(data, exit_reason, sell_price):
btc_to_sell = data.coin
exit_price = sell_price if sell_price is not None else data.price_open
usd_gross = btc_to_sell * exit_price
exit_fee = MarketFees.calculate_okx_taker_maker_fee(usd_gross, is_maker=False)
data.usd = usd_gross - exit_fee
exit_log_entry = {
'type': exit_reason,
'entry': data.entry_price,
'exit': exit_price,
'entry_time': data.entry_time,
'exit_time': data.current_date,
'fee_usd': exit_fee
}
data.coin = 0
data.position = 0
data.entry_price = 0
return data, exit_log_entry