Backtesting·Realism·Beginner

Include Trading Fees

Model maker/taker fees, funding rates, and cumulative cost drag in your backtests to produce realistic net-return estimates.

feescommissionsfunding rates

Backtesting Realism: Incorporating Trading Fees

[8]
# 1. Install Dependencies
# Install required libraries
import sys
!{sys.executable} -m pip install pandas numpy plotly
Requirement 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)
[9]
# 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 visualizations

3. 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 TypeTriggerTypical RangeRationale for Lower Rate
Maker FeeLimit order (adds liquidity to order book)0.00–0.02%Exchange benefits from increased liquidity
Taker FeeMarket order (removes liquidity from order book)0.03–0.06%Exchange incurs a cost for liquidity removal
Funding RateHolding a perpetual futures position (charged every 8h)±0.01% per 8hEquilibrates 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 TypeTrades per DayAnnual 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,440Instantly 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.

[10]
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
[11]
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_equity and net_equity curves 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.
[12]
# 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.

[14]
# 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.