165 lines
5.9 KiB
Python
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 |