Include Trading Fees
Model maker/taker fees, funding rates, and cumulative cost drag in your backtests to produce realistic net-return estimates.
Backtesting Realism: Incorporating Trading Fees
# 1. Install Dependencies
# Install required libraries
import sys
!{sys.executable} -m pip install pandas numpy plotlyRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1) Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4) Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
# 2. Library Imports
import warnings
warnings.filterwarnings("ignore") # Suppress warnings for cleaner output
import pandas as pd # Data manipulation and analysis
import numpy as np # Numerical operations
import plotly.graph_objects as go # Interactive plotting
from plotly.subplots import make_subplots # Creating subplots for visualizations3. Significance of Trading Fees
Trading fees frequently represent an underestimated factor in strategy performance degradation. Strategies appearing profitable pre-fee may become unprofitable post-fee, particularly those with high trading frequencies.
Fee Types in Cryptocurrency Markets
This section delineates common fee structures in cryptocurrency trading:
| Fee Type | Trigger | Typical Range | Rationale for Lower Rate |
|---|---|---|---|
| Maker Fee | Limit order (adds liquidity to order book) | 0.00–0.02% | Exchange benefits from increased liquidity |
| Taker Fee | Market order (removes liquidity from order book) | 0.03–0.06% | Exchange incurs a cost for liquidity removal |
| Funding Rate | Holding a perpetual futures position (charged every 8h) | ±0.01% per 8h | Equilibrates perpetual future price to spot price |
Impact of Fee Structures on Strategy Frequency
The following table demonstrates the compounding effect of fee drag across varying trading frequencies, based on a 0.05% taker fee applied to both entry and exit legs of a trade:
| Strategy Type | Trades per Day | Annual Fee Drag (0.05% Taker, Both Sides) |
|---|---|---|
| Low Frequency (daily signals) | 1 | ≈ 36.5% of Capital Annually |
| Medium Frequency (hourly) | 24 | ≈ 876% — Leads to Inevitable Ruin |
| High Frequency (per minute) | 1,440 | Instantly Unviable |
This analysis highlights the critical importance of fee awareness. A modest 0.05% taker fee per side can destructively compound, especially with high-frequency trading. Strategy profitability necessitates returns that significantly exceed the cumulative fee drag.
Round-Trip Cost
The total fee for a complete trade cycle (entry and exit) is termed the round-trip cost. For taker orders at a 0.05% rate, the round-trip cost is calculated as 0.05% (entry) + 0.05% (exit) = 0.10% per trade.
def generate_data(periods: int) -> pd.DataFrame:
"""
Generates synthetic price data for backtesting.
Parameters
----------
periods : int
The number of data points (minutes) to generate.
Returns
-------
pd.DataFrame
A DataFrame containing synthetic OHLCV (Open, High, Low, Close, Volume)
data with a datetime index.
"""
start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
price_data = []
last_close = 42000 # Initial price for simulation
volatility_scale = 0.005 # Factor for price volatility
wick_scale = 0.002 # Factor for wick (high/low) generation
for _ in range(periods):
# Simulate open price based on last close with minor normal distribution
open_price = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)
# Simulate close price based on open with larger normal distribution
close_price = open_price + np.random.normal(0, last_close * volatility_scale)
# Determine body high and low
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
# Simulate high price with a wick above the body
high_price = max(body_high + abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
# Simulate low price with a wick below the body
low_price = min(body_low - abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
# Ensure high is always greater than or equal to low
if high_price < low_price:
high_price, low_price = low_price, high_price
price_data.append({
"open": max(1, int(open_price)), # Ensure price is at least 1
"high": max(1, int(high_price)),
"low": max(1, int(low_price)),
"close": max(1, int(close_price)),
})
last_close = close_price # Update last close for the next iteration
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods) # Simulate random volume
df["datetime"] = df.index.to_series() # Add datetime as a column for easier plotting
return df.reset_index(drop=True)
# Generate 500 minutes of synthetic data
df = generate_data(500)
# Display the first 5 rows of the generated DataFrame
display(df.head())
# Print information about the DataFrame to check data types and nulls
df.info()| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 42010 | 42157 | 41773 | 41892 | 307.772930 | 2024-01-01 00:00:00+00:00 |
| 1 | 41878 | 42151 | 41785 | 42028 | 179.013242 | 2024-01-01 00:01:00+00:00 |
| 2 | 42022 | 42053 | 41888 | 41930 | 434.207709 | 2024-01-01 00:02:00+00:00 |
| 3 | 41948 | 42170 | 41910 | 41918 | 358.372107 | 2024-01-01 00:03:00+00:00 |
| 4 | 41918 | 41968 | 41719 | 41800 | 238.542288 | 2024-01-01 00:04:00+00:00 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 500 entries, 0 to 499 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 open 500 non-null int64 1 high 500 non-null int64 2 low 500 non-null int64 3 close 500 non-null int64 4 volume 500 non-null float64 5 datetime 500 non-null datetime64[ns, UTC] dtypes: datetime64[ns, UTC](1), float64(1), int64(4) memory usage: 23.6 KB
def backtest_with_fees(
df: pd.DataFrame,
fast_window: int = 10,
slow_window: int = 30,
initial_capital: float = 10_000.0,
maker_fee: float = 0.0002,
taker_fee: float = 0.0005,
use_market_orders: bool = True,
) -> pd.DataFrame:
"""
Executes a vectorized backtest, incorporating explicit trading fee accounting.
The strategy is based on a simple Moving Average Crossover.
Parameters
----------
df : pd.DataFrame
Input DataFrame containing price data (must include a 'close' column).
fast_window : int, default 10
The period for the fast moving average.
slow_window : int, default 30
The period for the slow moving average.
initial_capital : float, default 10_000.0
The starting capital for the backtest.
maker_fee : float, default 0.0002
Fee rate applied for limit orders (adding liquidity).
taker_fee : float, default 0.0005
Fee rate applied for market orders (removing liquidity).
use_market_orders : bool, default True
If True, `taker_fee` is applied. If False, `maker_fee` is applied.
Assumes market orders for guaranteed fills in most algorithmic strategies.
Returns
-------
pd.DataFrame
The input DataFrame augmented with backtesting results, including
gross/net returns, equity curves, and fee drag.
"""
df = df.copy().sort_values("datetime", ignore_index=True)
fee = taker_fee if use_market_orders else maker_fee
# Calculate Moving Averages
df["fast_ma"] = df["close"].rolling(fast_window).mean()
df["slow_ma"] = df["close"].rolling(slow_window).mean()
# Generate trading signal: 1 for long (fast_ma > slow_ma), 0 otherwise
df["signal"] = np.where(df["fast_ma"] > df["slow_ma"], 1, 0)
# Determine position: lagged signal to avoid look-ahead bias
df["position"] = df["signal"].shift(1).fillna(0)
# Identify trade events: 1 when position changes (entry or exit)
df["trade"] = df["position"].diff().abs()
# Calculate market returns
df["market_return"] = df["close"].pct_change()
# Calculate gross returns (before fees)
df["gross_return"] = df["position"] * df["market_return"]
# Calculate fee cost, applied only on candles where a trade event occurs
df["fee_cost"] = df["trade"] * fee
# Calculate net returns (after fees)
df["net_return"] = df["gross_return"] - df["fee_cost"]
# Calculate gross equity curve
df["gross_equity"] = initial_capital * (1 + df["gross_return"]).cumprod()
# Calculate net equity curve
df["net_equity"] = initial_capital * (1 + df["net_return"]).cumprod()
# Calculate cumulative fee drag in dollar terms
df["cumulative_fee_drag"] = df["gross_equity"] - df["net_equity"]
# --- Performance Metrics Calculation ---
gross_ret = (df["gross_equity"].iloc[-1] / initial_capital - 1) * 100
net_ret = (df["net_equity"].iloc[-1] / initial_capital - 1) * 100
total_fees = df["cumulative_fee_drag"].iloc[-1]
n_trades = int(df["trade"].sum()) # Total number of trade events (entries + exits)
# Output performance summary
print(f"Gross Return : {gross_ret:.2f}%")
print(f"Net Return : {net_ret:.2f}%")
print(f"Fee Drag : {gross_ret - net_ret:.2f}%")
print(f"Total Fees Paid ($) : ${total_fees:,.2f}")
print(f"Total Trade Events : {n_trades}")
print(f"Fee per Trade : {fee*100:.4f}% ({'Taker' if use_market_orders else 'Maker'})")
return df
# Execute backtest with specified parameters
df_fees = backtest_with_fees(
df,
fast_window = 10,
slow_window = 30,
initial_capital = 10_000.0,
maker_fee = 0.0002,
taker_fee = 0.0005,
use_market_orders = True,
)Gross Return : 9.60% Net Return : 8.83% Fee Drag : 0.76% Total Fees Paid ($) : $76.47 Total Trade Events : 14 Fee per Trade : 0.0500% (Taker)
Backtest Logic Explanation
df["trade"] = df["position"].diff().abs(): This expression identifies trade events. It evaluates to 1 only when a position transition occurs (entry or exit) and 0 during holding periods. This ensures that trading fees are applied precisely once per entry and once per exit, rather than on every candlestick.fee_cost = trade × fee: The fee is quantified as a fraction of the position value per identified trade event. This cost is then subtracted from the net return calculation for that specific candlestick.- Equity Curve Divergence: The
gross_equityandnet_equitycurves progressively diverge over time as trades accumulate. The vertical distance between these two curves at the conclusion of the backtest represents the total fee drag in absolute dollar terms. cumulative_fee_drag: This metric provides a running dollar difference between the gross and net equity curves, offering a direct, temporal measurement of the total fee costs incurred at any given point.
# Define a range of fee rates for analysis
fee_rates = [0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005]
results = []
# Iterate through each fee rate to perform a backtest and record net return
for fee in fee_rates:
df_temp = backtest_with_fees(
df, fast_window=10, slow_window=30,
initial_capital=10_000, taker_fee=fee, use_market_orders=True,
)
# Calculate net return percentage for the current fee rate
net_ret = (df_temp["net_equity"].iloc[-1] / 10_000 - 1) * 100
results.append({"fee_pct": fee * 100, "net_return_pct": round(net_ret, 2)})
print() # Add a newline for better readability between backtest summaries
# Convert results to a DataFrame for structured display
df_sensitivity = pd.DataFrame(results)
print("--- Fee Sensitivity Analysis Summary ---")
display(df_sensitivity)Gross Return : 9.60% Net Return : 9.44% Fee Drag : 0.15% Total Fees Paid ($) : $15.33 Total Trade Events : 14 Fee per Trade : 0.0100% (Taker) Gross Return : 9.60% Net Return : 9.29% Fee Drag : 0.31% Total Fees Paid ($) : $30.65 Total Trade Events : 14 Fee per Trade : 0.0200% (Taker) Gross Return : 9.60% Net Return : 8.83% Fee Drag : 0.76% Total Fees Paid ($) : $76.47 Total Trade Events : 14 Fee per Trade : 0.0500% (Taker) Gross Return : 9.60% Net Return : 8.07% Fee Drag : 1.52% Total Fees Paid ($) : $152.43 Total Trade Events : 14 Fee per Trade : 0.1000% (Taker) Gross Return : 9.60% Net Return : 6.57% Fee Drag : 3.03% Total Fees Paid ($) : $302.90 Total Trade Events : 14 Fee per Trade : 0.2000% (Taker) Gross Return : 9.60% Net Return : 2.17% Fee Drag : 7.43% Total Fees Paid ($) : $742.70 Total Trade Events : 14 Fee per Trade : 0.5000% (Taker) --- Fee Sensitivity Analysis Summary ---
| fee_pct | net_return_pct | |
|---|---|---|
| 0 | 0.01 | 9.44 |
| 1 | 0.02 | 9.29 |
| 2 | 0.05 | 8.83 |
| 3 | 0.10 | 8.07 |
| 4 | 0.20 | 6.57 |
| 5 | 0.50 | 2.17 |
Sensitivity Analysis Interpretation
This sensitivity analysis systematically evaluates the strategy's performance across six distinct fee rate levels, ranging from 0.01% (representative of institutional VIP tiers) to 0.5% (typical for retail exchanges without discount structures). Each fee level yields a unique net return, thereby directly quantifying the proportion of strategy returns surrendered to fees at each respective tier. This analytical approach is fundamental for establishing the minimum sustainable fee rate required for a strategy to maintain viability.
# Create subplots for gross/net equity and cumulative fee drag
fig = make_subplots(
rows=2, cols=1, shared_xaxes=True,
subplot_titles=[
"Gross vs Net Equity Curve (Fee Impact)",
"Cumulative Fee Drag (USD)",
],
row_heights=[0.6, 0.4], # Allocate more height to equity curve
)
# Add trace for Gross Equity (before fees)
fig.add_trace(go.Scatter(
x=df_fees["datetime"], y=df_fees["gross_equity"],
mode="lines", name="Gross Equity (before fees)",
line=dict(color="blue", width=2)), row=1, col=1)
# Add trace for Net Equity (after fees)
fig.add_trace(go.Scatter(
x=df_fees["datetime"], y=df_fees["net_equity"],
mode="lines", name="Net Equity (after fees)",
line=dict(color="green", width=2)), row=1, col=1)
# Add trace for Cumulative Fee Drag
fig.add_trace(go.Scatter(
x=df_fees["datetime"], y=df_fees["cumulative_fee_drag"],
mode="lines", name="Cumulative Fee Drag (USD)",
fill="tozeroy", line=dict(color="red", width=1)), row=2, col=1)
# Update layout for title, axis labels, and rangeslider visibility
fig.update_layout(
title_text="Trading Fee Impact on Strategy Performance",
xaxis_rangeslider_visible=False, # Hide the default range slider for cleaner look
height=700, # Set overall figure height
yaxis=dict(autorange=True), # Auto-scale y-axis
xaxis2_title="Datetime", # X-axis label for the second subplot
yaxis_title="Portfolio Value (USD)", # Y-axis label for the first subplot
yaxis2_title="Fee Drag (USD)", # Y-axis label for the second subplot
)
fig.show()Visualization Interpretation
The upper panel of the visualization presents an overlay of the gross and net equity curves. The vertical separation observed between these two lines at any given point directly quantifies the cumulative fee cost incurred up to that specific time. The lower panel explicitly depicts this difference as a filled area, rendering the trajectory of the fee drag both visible and quantifiable. A significant and rapidly increasing fee drag curve, particularly in relation to the gross equity curve, indicates that the strategy's per-trade returns are insufficient to offset its associated transaction costs.