Why Do Trading Costs Matter? Add Fees and Slippage to a Python Backtest

Phase 4 has introduced several new ideas. So this lesson deliberately slows down.

We will change only one thing:

Phase 4-07
Gross Backtest

↓

Phase 4-08
Add Trading Costs

↓

Net Backtest

The trading rule stays exactly the same. We will only ask what happens when buying and selling are not free.

1. Start with One Simple Question

Suppose a backtest says a trade made $10.

Did we really keep all $10?

Usually, a trade can lose a little value through execution costs.

In this lesson, we model only two:

Fee
+
Slippage

2. Gross and Net Mean Different Things

A gross backtest ignores trading costs.

Gross Result
=
Result before costs

A net backtest subtracts the costs we choose to model.

Net Result
=
Gross Result
-
Trading Costs

Therefore:

Gross Backtest
≠
Net Backtest

3. What Is a Fee?

A fee is an explicit charge attached to a trade.

For this lesson, we use a simple percentage of trade value.

Fee
=
Trade Value
×
Fee Rate

We use:

fee_rate = 0.0005

= 0.05%

This is a teaching assumption. It is not a claim about any specific broker.

4. What Is Slippage?

Slippage means the price we actually receive is a little worse than the reference price.

For a BUY:

Reference Open
=
100.00

Actual Fill
=
slightly higher

For a SELL:

Reference Open
=
110.00

Actual Fill
=
slightly lower

So:

Reference Price
≠
Fill Price

5. See One Share Before We Use a Full Backtest

Use one share only. That keeps the arithmetic easy.

Imagine:

BUY reference Open = 100
SELL reference Open = 110

Without costs:

Gross PnL
=
110 - 100
=
10

Now use a deliberately visible teaching example:

fee = 0.10%
slippage = 0.10%

The BUY becomes:

Reference = 100.00
Fill      = 100.10
Fee       ≈ 0.10

The SELL becomes:

Reference = 110.00
Fill      = 109.89
Fee       ≈ 0.11

Now the same trade keeps about:

Net PnL
≈
9.58

The trade direction did not change. The strategy rule did not change. Only the execution costs changed.

One-share trading-cost example showing a buy reference price of 100, a worse buy fill after slippage, a sell reference price of 110, a worse sell fill after slippage, fees, gross PnL, total cost, and net PnL.
Figure 1. One-share cost example. Fees and slippage reduce the gross PnL even though the trading rule itself does not change.

6. Small Costs Can Add Up

One cost may look small.

But a strategy can trade many times.

small cost
×
many trades
=
larger total effect

This is why we should not judge a strategy only from a frictionless result.

7. Keep the Phase 4-07 Strategy Frozen

We do not change the trading logic.

AAPL

SMA20 Cross Above
after Close(t)
↓
BUY at Open(t+1)

SMA20 Cross Below
after Close(t)
↓
SELL at Open(t+1)

This is important. We want to measure the effect of costs—not the effect of a new strategy.

8. Use Simple Cost Assumptions

The market-data backtest uses:

fee_rate
=
0.05%

slippage_rate
=
0.05%

These are teaching values only.

Later research can test different cost assumptions. For now, one fixed setting is easier to understand.

9. How Does a BUY Change?

Phase 4-07 bought at the next Open.

Phase 4-08 still uses that Open as the reference price. But the simulated fill is slightly worse.

BUY Fill
=
Reference Open
×
(1 + Slippage Rate)

Then we pay the BUY fee.

Cash after BUY
=
Old Cash
-
Trade Value
-
BUY Fee

10. How Does a SELL Change?

The same idea applies in the other direction.

SELL Fill
=
Reference Open
×
(1 - Slippage Rate)

Then we subtract the SELL fee from the proceeds.

Net Sale Proceeds
=
Trade Value
-
SELL Fee

11. Position Size Must Include the BUY Fee

There is one small accounting detail.

Suppose we spend almost all available cash on shares. We still need enough cash to pay the BUY fee.

So the program calculates:

Cost per Share
=
BUY Fill
×
(1 + Fee Rate)

Then it buys only the number of whole shares the account can truly afford.

12. Run the Same Backtest Twice

This is the easiest way to see the effect of costs.

Run 1

fee = 0
slippage = 0

→ Gross Backtest
Run 2

fee > 0
slippage > 0

→ Net Backtest

Everything else stays the same.

13. Compare the Two Equity Curves

The program draws:

Gross Equity
vs
Net Equity

At the beginning, the lines start at the same account value.

After trades occur, the net curve can fall behind because it pays fees and receives slightly worse fill prices.

AAPL backtest comparing gross equity and net equity for the same SMA20 strategy, with the net curve reflecting fees and slippage.
Figure 2. Gross vs. net equity for the same strategy. The growing gap shows the cumulative effect of trading costs.

14. The Gap Is the Lesson

Do not focus only on whether the strategy finished higher or lower.

First ask:

How much did costs change
the same strategy?

That question is more useful than trying to decide whether one cost assumption is universally correct.

15. What Does the Program Save?

The program saves three CSV files:

gross_vs_net_equity.csv

net_backtest_trade_ledger.csv

trading_cost_comparison_summary.csv

The first file lets us compare the two equity curves bar by bar.

The trade ledger stores the fees and slippage cost for each completed net trade.

The summary file compares the final gross and net results.

16. Read One Net Trade

Each completed net trade stores:

  • Entry Reference Open,
  • Entry Fill Price,
  • Entry Fee,
  • Exit Reference Open,
  • Exit Fill Price,
  • Exit Fee,
  • Total Fees,
  • Total Slippage Cost,
  • Total Friction Cost,
  • Net PnL.

This makes the cost model visible instead of hiding it inside one final number.

17. Run the Self-Test First

Save the program as:

phase4_08_trading_costs.py

Then run:

python phase4_08_trading_costs.py --self-test-only

Confirm:

Self-test: PASS

The self-test checks the cost arithmetic before we use market data.

18. Run the Full Program

python phase4_08_trading_costs.py

The program downloads AAPL data, rebuilds the frozen SMA20 rule, and runs the same strategy twice.

Gross
vs
Net

19. The Complete Python Program

from pathlib import Path
from datetime import date, timedelta
import argparse
import os

import matplotlib.pyplot as plt
import pandas as pd


# ============================================================
# Phase 4-08 — Trading Costs
#
# One new idea only:
#
# Gross Backtest
#       ↓
# Add Fee
#       +
# Add Slippage
#       ↓
# Net Backtest
#
# Frozen from Phase 4-07:
#
# Symbol:
#     AAPL
#
# Indicator:
#     SMA(20)
#
# Entry:
#     Cross Above after Close(t)
#     BUY at Open(t+1)
#
# Exit:
#     Cross Below after Close(t)
#     SELL at Open(t+1)
#
# Account:
#     starting capital = 10,000 USD
#     long only
#     one position at a time
#     whole shares only
#
# New in this lesson:
#
# Fee:
#     explicit trading charge
#
# Slippage:
#     BUY fills slightly above the reference Open
#     SELL fills slightly below the reference Open
#
# Teaching assumptions:
#
# fee_rate = 0.0005
# slippage_rate = 0.0005
#
# These are teaching values.
# They are NOT claims about a specific broker or market.
#
# Important:
#
# Gross Backtest ≠ Net Backtest
# Reference Price ≠ Fill Price
# Small Cost × Many Trades = Meaningful Difference
# ============================================================


# ------------------------------------------------------------
# 1. Settings
# ------------------------------------------------------------

symbol = "AAPL"
recent_trading_days = 800
sma_period = 20
starting_capital = 10_000.0

fee_rate = 0.0005
slippage_rate = 0.0005

SCRIPT_DIR = Path(__file__).resolve().parent
os.chdir(SCRIPT_DIR)


# ------------------------------------------------------------
# 2. SMA
# ------------------------------------------------------------

def simple_moving_average(
    values,
    period,
):
    result = [
        None
    ] * len(values)

    if period <= 0:
        raise ValueError(
            "period must be positive"
        )

    for i in range(
        period - 1,
        len(values),
    ):
        window = values[
            i - period + 1
            :
            i + 1
        ]

        result[i] = (
            sum(
                float(value)
                for value
                in window
            )
            / period
        )

    return result


# ------------------------------------------------------------
# 3. State: Close above SMA?
# ------------------------------------------------------------

def above_sma_state(
    close_values,
    sma_values,
):
    result = [
        None
    ] * len(close_values)

    for i in range(
        len(close_values)
    ):
        if sma_values[i] is None:
            continue

        result[i] = (
            float(
                close_values[i]
            )
            >
            float(
                sma_values[i]
            )
        )

    return result


# ------------------------------------------------------------
# 4. Events: Cross Above / Cross Below
# ------------------------------------------------------------

def crossover_events(
    above_state,
):
    cross_above = [
        False
    ] * len(above_state)

    cross_below = [
        False
    ] * len(above_state)

    for i in range(
        1,
        len(above_state),
    ):
        previous_state = (
            above_state[i - 1]
        )

        current_state = (
            above_state[i]
        )

        if (
            previous_state is None
            or
            current_state is None
        ):
            continue

        cross_above[i] = (
            previous_state is False
            and
            current_state is True
        )

        cross_below[i] = (
            previous_state is True
            and
            current_state is False
        )

    return (
        cross_above,
        cross_below,
    )


# ------------------------------------------------------------
# 5. Cost helpers
# ------------------------------------------------------------

def buy_fill_price(
    reference_price,
    slip_rate,
):
    """
    A BUY is slightly more expensive
    than the reference Open.
    """

    return (
        float(reference_price)
        *
        (
            1.0
            +
            float(slip_rate)
        )
    )


def sell_fill_price(
    reference_price,
    slip_rate,
):
    """
    A SELL is slightly cheaper
    than the reference Open.
    """

    return (
        float(reference_price)
        *
        (
            1.0
            -
            float(slip_rate)
        )
    )


def trade_fee(
    trade_value,
    rate,
):
    """
    Explicit fee charged on trade value.
    """

    return (
        abs(
            float(trade_value)
        )
        *
        float(rate)
    )


def max_affordable_shares(
    cash,
    buy_price,
    rate,
):
    """
    Include the BUY fee when deciding
    how many whole shares we can afford.
    """

    cash = float(cash)
    buy_price = float(buy_price)
    rate = float(rate)

    cost_per_share = (
        buy_price
        *
        (
            1.0
            +
            rate
        )
    )

    if cost_per_share <= 0:
        raise ValueError(
            "cost_per_share must be positive"
        )

    return int(
        cash
        //
        cost_per_share
    )


# ------------------------------------------------------------
# 6. One-share cost example
# ------------------------------------------------------------

def one_share_cost_example(
    buy_reference=100.0,
    sell_reference=110.0,
    fee=0.001,
    slip=0.001,
):
    """
    A tiny example with exactly one share.

    This keeps the arithmetic easy to inspect.
    """

    buy_fill = buy_fill_price(
        buy_reference,
        slip,
    )

    sell_fill = sell_fill_price(
        sell_reference,
        slip,
    )

    buy_fee = trade_fee(
        buy_fill,
        fee,
    )

    sell_fee = trade_fee(
        sell_fill,
        fee,
    )

    gross_pnl = (
        sell_reference
        -
        buy_reference
    )

    net_pnl = (
        (
            sell_fill
            -
            sell_fee
        )
        -
        (
            buy_fill
            +
            buy_fee
        )
    )

    buy_slippage_cost = (
        buy_fill
        -
        buy_reference
    )

    sell_slippage_cost = (
        sell_reference
        -
        sell_fill
    )

    total_fee = (
        buy_fee
        +
        sell_fee
    )

    total_slippage_cost = (
        buy_slippage_cost
        +
        sell_slippage_cost
    )

    total_cost = (
        total_fee
        +
        total_slippage_cost
    )

    return {
        "Buy Reference":
            buy_reference,
        "Buy Fill":
            buy_fill,
        "Buy Fee":
            buy_fee,
        "Sell Reference":
            sell_reference,
        "Sell Fill":
            sell_fill,
        "Sell Fee":
            sell_fee,
        "Gross PnL":
            gross_pnl,
        "Net PnL":
            net_pnl,
        "Total Fee":
            total_fee,
        "Total Slippage":
            total_slippage_cost,
        "Total Cost":
            total_cost,
    }


# ------------------------------------------------------------
# 7. Backtest engine with optional costs
# ------------------------------------------------------------

def run_backtest(
    market_df,
    starting_cash,
    fee_rate_value=0.0,
    slippage_rate_value=0.0,
):
    """
    Run the same Phase 4-07 strategy.

    Set both cost rates to zero:
        gross backtest

    Set positive cost rates:
        net backtest
    """

    if starting_cash <= 0:
        raise ValueError(
            "starting_cash must be positive"
        )

    if fee_rate_value < 0:
        raise ValueError(
            "fee_rate_value cannot be negative"
        )

    if slippage_rate_value < 0:
        raise ValueError(
            "slippage_rate_value cannot be negative"
        )

    n = len(market_df)

    if n == 0:
        raise ValueError(
            "market_df is empty"
        )

    cash = float(
        starting_cash
    )

    shares = 0
    state = "CASH"

    pending_action = None
    pending_signal_index = None

    open_trade = None
    closed_trades = []

    position_state = [
        "CASH"
    ] * n

    shares_held = [
        0
    ] * n

    cash_series = [
        0.0
    ] * n

    position_value = [
        0.0
    ] * n

    equity = [
        0.0
    ] * n

    execution_action = [
        ""
    ] * n

    reference_open_series = [
        None
    ] * n

    fill_price_series = [
        None
    ] * n

    fee_series = [
        0.0
    ] * n

    slippage_cost_series = [
        0.0
    ] * n

    cumulative_fee = [
        0.0
    ] * n

    cumulative_slippage = [
        0.0
    ] * n

    triggering_signal_date = [
        None
    ] * n

    fee_total = 0.0
    slippage_total = 0.0

    for i in range(n):

        # ------------------------------------
        # A. Execute pending action at Open.
        # ------------------------------------

        if pending_action is not None:

            reference_open = float(
                market_df.iloc[i][
                    "Open"
                ]
            )

            signal_date = (
                market_df.index[
                    pending_signal_index
                ]
            )

            reference_open_series[i] = (
                reference_open
            )

            if pending_action == "BUY":

                fill_price = (
                    buy_fill_price(
                        reference_open,
                        slippage_rate_value,
                    )
                )

                quantity = (
                    max_affordable_shares(
                        cash,
                        fill_price,
                        fee_rate_value,
                    )
                )

                if quantity > 0:

                    trade_value = (
                        quantity
                        *
                        fill_price
                    )

                    fee_paid = (
                        trade_fee(
                            trade_value,
                            fee_rate_value,
                        )
                    )

                    slip_cost = (
                        quantity
                        *
                        (
                            fill_price
                            -
                            reference_open
                        )
                    )

                    cash = (
                        cash
                        -
                        trade_value
                        -
                        fee_paid
                    )

                    fee_total += (
                        fee_paid
                    )

                    slippage_total += (
                        slip_cost
                    )

                    shares = quantity
                    state = "LONG"

                    execution_action[i] = (
                        "BUY"
                    )

                    fill_price_series[i] = (
                        fill_price
                    )

                    fee_series[i] = (
                        fee_paid
                    )

                    slippage_cost_series[i] = (
                        slip_cost
                    )

                    triggering_signal_date[i] = (
                        signal_date
                    )

                    open_trade = {
                        "Entry Signal Date":
                            signal_date,
                        "Entry Date":
                            market_df.index[i],
                        "Entry Reference Open":
                            reference_open,
                        "Entry Fill Price":
                            fill_price,
                        "Entry Fee":
                            fee_paid,
                        "Entry Slippage Cost":
                            slip_cost,
                        "Shares":
                            quantity,
                        "Entry Cash After":
                            cash,
                        "Entry Index":
                            i,
                    }

                else:

                    execution_action[i] = (
                        "BUY SKIPPED"
                    )

                    fill_price_series[i] = (
                        fill_price
                    )

                    triggering_signal_date[i] = (
                        signal_date
                    )

            elif pending_action == "SELL":

                if (
                    shares <= 0
                    or
                    open_trade is None
                ):
                    raise RuntimeError(
                        "SELL found without "
                        "an open position."
                    )

                fill_price = (
                    sell_fill_price(
                        reference_open,
                        slippage_rate_value,
                    )
                )

                quantity = shares

                trade_value = (
                    quantity
                    *
                    fill_price
                )

                fee_paid = (
                    trade_fee(
                        trade_value,
                        fee_rate_value,
                    )
                )

                slip_cost = (
                    quantity
                    *
                    (
                        reference_open
                        -
                        fill_price
                    )
                )

                net_proceeds = (
                    trade_value
                    -
                    fee_paid
                )

                cash = (
                    cash
                    +
                    net_proceeds
                )

                fee_total += (
                    fee_paid
                )

                slippage_total += (
                    slip_cost
                )

                gross_pnl_same_shares = (
                    quantity
                    *
                    (
                        reference_open
                        -
                        open_trade[
                            "Entry Reference Open"
                        ]
                    )
                )

                net_pnl = (
                    (
                        fill_price
                        *
                        quantity
                        -
                        fee_paid
                    )
                    -
                    (
                        open_trade[
                            "Entry Fill Price"
                        ]
                        *
                        quantity
                        +
                        open_trade[
                            "Entry Fee"
                        ]
                    )
                )

                total_trade_fee = (
                    open_trade[
                        "Entry Fee"
                    ]
                    +
                    fee_paid
                )

                total_trade_slippage = (
                    open_trade[
                        "Entry Slippage Cost"
                    ]
                    +
                    slip_cost
                )

                total_trade_cost = (
                    total_trade_fee
                    +
                    total_trade_slippage
                )

                bars_held = (
                    i
                    -
                    open_trade[
                        "Entry Index"
                    ]
                )

                execution_action[i] = (
                    "SELL"
                )

                fill_price_series[i] = (
                    fill_price
                )

                fee_series[i] = (
                    fee_paid
                )

                slippage_cost_series[i] = (
                    slip_cost
                )

                triggering_signal_date[i] = (
                    signal_date
                )

                closed_trades.append(
                    {
                        "Entry Signal Date":
                            open_trade[
                                "Entry Signal Date"
                            ],
                        "Entry Date":
                            open_trade[
                                "Entry Date"
                            ],
                        "Entry Reference Open":
                            open_trade[
                                "Entry Reference Open"
                            ],
                        "Entry Fill Price":
                            open_trade[
                                "Entry Fill Price"
                            ],
                        "Entry Fee":
                            open_trade[
                                "Entry Fee"
                            ],
                        "Exit Signal Date":
                            signal_date,
                        "Exit Date":
                            market_df.index[i],
                        "Exit Reference Open":
                            reference_open,
                        "Exit Fill Price":
                            fill_price,
                        "Exit Fee":
                            fee_paid,
                        "Shares":
                            quantity,
                        "Bars Held":
                            bars_held,
                        "Gross PnL Same Shares":
                            gross_pnl_same_shares,
                        "Net PnL":
                            net_pnl,
                        "Total Fees":
                            total_trade_fee,
                        "Total Slippage Cost":
                            total_trade_slippage,
                        "Total Friction Cost":
                            total_trade_cost,
                        "Exit Cash After":
                            cash,
                    }
                )

                shares = 0
                state = "CASH"
                open_trade = None

            else:
                raise ValueError(
                    "Unknown pending action."
                )

            pending_action = None
            pending_signal_index = None

        # ------------------------------------
        # B. Mark account at Close.
        # ------------------------------------

        close_price = float(
            market_df.iloc[i][
                "Close"
            ]
        )

        current_position_value = (
            shares
            *
            close_price
        )

        current_equity = (
            cash
            +
            current_position_value
        )

        position_state[i] = state
        shares_held[i] = shares
        cash_series[i] = cash

        position_value[i] = (
            current_position_value
        )

        equity[i] = (
            current_equity
        )

        cumulative_fee[i] = (
            fee_total
        )

        cumulative_slippage[i] = (
            slippage_total
        )

        # ------------------------------------
        # C. Evaluate event after Close.
        # ------------------------------------

        if i >= n - 1:
            continue

        cross_above = bool(
            market_df.iloc[i][
                "Cross Above"
            ]
        )

        cross_below = bool(
            market_df.iloc[i][
                "Cross Below"
            ]
        )

        if (
            state == "CASH"
            and
            cross_above
        ):
            pending_action = "BUY"
            pending_signal_index = i

        elif (
            state == "LONG"
            and
            cross_below
        ):
            pending_action = "SELL"
            pending_signal_index = i

    out = market_df.copy()

    out[
        "Position State"
    ] = position_state

    out[
        "Shares"
    ] = shares_held

    out[
        "Cash"
    ] = cash_series

    out[
        "Position Value"
    ] = position_value

    out[
        "Equity"
    ] = equity

    out[
        "Execution Action"
    ] = execution_action

    out[
        "Reference Open"
    ] = reference_open_series

    out[
        "Fill Price"
    ] = fill_price_series

    out[
        "Fee"
    ] = fee_series

    out[
        "Slippage Cost"
    ] = slippage_cost_series

    out[
        "Cumulative Fees"
    ] = cumulative_fee

    out[
        "Cumulative Slippage"
    ] = cumulative_slippage

    out[
        "Triggering Signal Date"
    ] = triggering_signal_date

    trade_df = pd.DataFrame(
        closed_trades
    )

    summary = {
        "Starting Capital":
            float(
                starting_cash
            ),
        "Ending Cash":
            float(
                cash_series[-1]
            ),
        "Ending Equity":
            float(
                equity[-1]
            ),
        "Total Return":
            float(
                equity[-1]
                /
                starting_cash
                - 1.0
            ),
        "Closed Trades":
            int(
                len(trade_df)
            ),
        "Total Fees":
            float(
                fee_total
            ),
        "Total Slippage Cost":
            float(
                slippage_total
            ),
        "Total Friction Cost":
            float(
                fee_total
                +
                slippage_total
            ),
        "Current Position":
            str(
                position_state[-1]
            ),
        "Current Shares":
            int(
                shares_held[-1]
            ),
    }

    return (
        out,
        trade_df,
        summary,
    )


# ------------------------------------------------------------
# 8. Deterministic self-test
# ------------------------------------------------------------

def run_self_test():

    example = one_share_cost_example()

    assert abs(
        example[
            "Buy Fill"
        ]
        -
        100.10
    ) < 1e-12

    assert abs(
        example[
            "Sell Fill"
        ]
        -
        109.89
    ) < 1e-12

    assert abs(
        example[
            "Gross PnL"
        ]
        -
        10.0
    ) < 1e-12

    assert abs(
        example[
            "Total Cost"
        ]
        -
        0.41999
    ) < 1e-10

    assert abs(
        example[
            "Net PnL"
        ]
        -
        9.58001
    ) < 1e-10

    toy_index = pd.date_range(
        "2026-01-01",
        periods=6,
        freq="D",
    )

    toy_df = pd.DataFrame(
        {
            "Open": [
                95,
                96,
                100,
                105,
                110,
                111,
            ],
            "High": [
                97,
                99,
                103,
                109,
                112,
                114,
            ],
            "Low": [
                94,
                95,
                99,
                104,
                108,
                110,
            ],
            "Close": [
                96,
                98,
                101,
                108,
                109,
                113,
            ],
            "SMA": [
                96,
                97,
                98,
                102,
                107,
                110,
            ],
            "Cross Above": [
                False,
                True,
                False,
                False,
                False,
                False,
            ],
            "Cross Below": [
                False,
                False,
                False,
                True,
                False,
                False,
            ],
        },
        index=toy_index,
    )

    (
        gross_df,
        gross_trades,
        gross_summary,
    ) = run_backtest(
        toy_df,
        starting_cash=1_000.0,
        fee_rate_value=0.0,
        slippage_rate_value=0.0,
    )

    (
        net_df,
        net_trades,
        net_summary,
    ) = run_backtest(
        toy_df,
        starting_cash=1_000.0,
        fee_rate_value=0.001,
        slippage_rate_value=0.001,
    )

    assert abs(
        gross_summary[
            "Ending Equity"
        ]
        -
        1_100.0
    ) < 1e-12

    assert (
        net_summary[
            "Ending Equity"
        ]
        <
        gross_summary[
            "Ending Equity"
        ]
    )

    assert (
        net_summary[
            "Total Friction Cost"
        ]
        >
        0.0
    )

    print(
        "Self-test"
    )
    print(
        "========="
    )
    print()

    print(
        "One-share example"
    )
    print(
        "-----------------"
    )
    print(
        f"Gross PnL: "
        f"{example['Gross PnL']:.5f}"
    )
    print(
        f"Total Cost: "
        f"{example['Total Cost']:.5f}"
    )
    print(
        f"Net PnL: "
        f"{example['Net PnL']:.5f}"
    )

    print()
    print(
        "Toy account"
    )
    print(
        "-----------"
    )
    print(
        f"Gross ending equity: "
        f"{gross_summary['Ending Equity']:.2f}"
    )
    print(
        f"Net ending equity: "
        f"{net_summary['Ending Equity']:.2f}"
    )

    print()
    print(
        "Self-test: PASS"
    )
    print()

    return (
        example,
        gross_df,
        net_df,
    )


# ------------------------------------------------------------
# 9. Cost mechanics figure
# ------------------------------------------------------------

def plot_cost_mechanics(
    output_file,
):
    example = (
        one_share_cost_example()
    )

    fig, ax = plt.subplots(
        figsize=(12, 6)
    )

    ax.axis(
        "off"
    )

    ax.set_title(
        "One Share: Gross PnL vs Net PnL"
    )

    left_text = (
        "BUY\n\n"
        "Reference Open = 100.00\n"
        "Slippage → Fill = 100.10\n"
        "Fee = 0.10"
    )

    right_text = (
        "SELL\n\n"
        "Reference Open = 110.00\n"
        "Slippage → Fill = 109.89\n"
        "Fee = 0.11"
    )

    bottom_text = (
        "Gross PnL = 10.00\n"
        f"Total Cost = "
        f"{example['Total Cost']:.2f}\n"
        f"Net PnL = "
        f"{example['Net PnL']:.2f}"
    )

    ax.text(
        0.25,
        0.62,
        left_text,
        ha="center",
        va="center",
        transform=ax.transAxes,
        bbox={
            "boxstyle":
                "round,pad=0.7",
            "facecolor":
                "white",
            "edgecolor":
                "black",
        },
    )

    ax.text(
        0.75,
        0.62,
        right_text,
        ha="center",
        va="center",
        transform=ax.transAxes,
        bbox={
            "boxstyle":
                "round,pad=0.7",
            "facecolor":
                "white",
            "edgecolor":
                "black",
        },
    )

    ax.annotate(
        "",
        xy=(
            0.60,
            0.62,
        ),
        xytext=(
            0.40,
            0.62,
        ),
        xycoords=ax.transAxes,
        textcoords=ax.transAxes,
        arrowprops={
            "arrowstyle":
                "->",
        },
    )

    ax.text(
        0.50,
        0.22,
        bottom_text,
        ha="center",
        va="center",
        transform=ax.transAxes,
        bbox={
            "boxstyle":
                "round,pad=0.7",
            "facecolor":
                "white",
            "edgecolor":
                "black",
        },
    )

    fig.tight_layout()

    fig.savefig(
        output_file,
        dpi=160,
        bbox_inches="tight",
    )

    plt.close(fig)


# ------------------------------------------------------------
# 10. Gross vs net equity figure
# ------------------------------------------------------------

def plot_gross_vs_net_equity(
    gross_df,
    net_df,
    output_file,
):
    fig, ax = plt.subplots(
        figsize=(12, 6.8)
    )

    ax.plot(
        gross_df.index,
        gross_df[
            "Equity"
        ],
        linewidth=1.7,
        label="Gross Equity",
    )

    ax.plot(
        net_df.index,
        net_df[
            "Equity"
        ],
        linewidth=1.7,
        label="Net Equity",
    )

    ax.set_title(
        (
            f"{symbol} — Same Strategy, "
            "Before and After Trading Costs"
        )
    )

    ax.set_ylabel(
        "Account Value"
    )

    ax.grid(
        axis="y",
        alpha=0.20,
    )

    ax.legend()

    fig.autofmt_xdate()
    fig.tight_layout()

    fig.savefig(
        output_file,
        dpi=160,
        bbox_inches="tight",
    )

    plt.close(fig)


# ------------------------------------------------------------
# 11. Download market data
# ------------------------------------------------------------

def download_market_data():

    try:
        import FinanceDataReader as fdr

    except ImportError as exc:

        raise RuntimeError(
            "FinanceDataReader is not installed. "
            "Run: pip install finance-datareader"
        ) from exc

    today = date.today()

    start_date = (
        today
        -
        timedelta(
            days=1800
        )
    ).strftime(
        "%Y-%m-%d"
    )

    end_date = (
        today.strftime(
            "%Y-%m-%d"
        )
    )

    df = fdr.DataReader(
        symbol,
        start_date,
        end_date,
    )

    df = df.tail(
        recent_trading_days
    ).copy()

    if len(df) < (
        sma_period + 30
    ):
        raise ValueError(
            "Not enough market data."
        )

    return df


# ------------------------------------------------------------
# 12. Build the frozen SMA20 rule
# ------------------------------------------------------------

def add_rule_columns(
    df,
):
    close_values = [
        float(value)
        for value
        in df[
            "Close"
        ]
    ]

    sma_values = (
        simple_moving_average(
            close_values,
            sma_period,
        )
    )

    state = (
        above_sma_state(
            close_values,
            sma_values,
        )
    )

    (
        cross_above,
        cross_below,
    ) = crossover_events(
        state
    )

    out = df.copy()

    out[
        "SMA"
    ] = sma_values

    out[
        "Above SMA"
    ] = state

    out[
        "Cross Above"
    ] = cross_above

    out[
        "Cross Below"
    ] = cross_below

    return out


# ------------------------------------------------------------
# 13. Save outputs
# ------------------------------------------------------------

def save_outputs(
    gross_df,
    gross_trades,
    gross_summary,
    net_df,
    net_trades,
    net_summary,
):

    comparison_file = (
        SCRIPT_DIR
        /
        "gross_vs_net_equity.csv"
    )

    trade_file = (
        SCRIPT_DIR
        /
        "net_backtest_trade_ledger.csv"
    )

    summary_file = (
        SCRIPT_DIR
        /
        "trading_cost_comparison_summary.csv"
    )

    mechanics_file = (
        SCRIPT_DIR
        /
        "trading_cost_mechanics.png"
    )

    equity_file = (
        SCRIPT_DIR
        /
        "gross_vs_net_equity_curve.png"
    )

    comparison_df = pd.DataFrame(
        {
            "Gross Equity":
                gross_df[
                    "Equity"
                ],
            "Net Equity":
                net_df[
                    "Equity"
                ],
            "Net Cumulative Fees":
                net_df[
                    "Cumulative Fees"
                ],
            "Net Cumulative Slippage":
                net_df[
                    "Cumulative Slippage"
                ],
        },
        index=gross_df.index,
    )

    comparison_df.to_csv(
        comparison_file
    )

    net_trades.to_csv(
        trade_file,
        index=False,
    )

    summary_rows = [
        {
            "Metric":
                "Starting Capital",
            "Gross":
                gross_summary[
                    "Starting Capital"
                ],
            "Net":
                net_summary[
                    "Starting Capital"
                ],
        },
        {
            "Metric":
                "Ending Equity",
            "Gross":
                gross_summary[
                    "Ending Equity"
                ],
            "Net":
                net_summary[
                    "Ending Equity"
                ],
        },
        {
            "Metric":
                "Total Return",
            "Gross":
                gross_summary[
                    "Total Return"
                ],
            "Net":
                net_summary[
                    "Total Return"
                ],
        },
        {
            "Metric":
                "Closed Trades",
            "Gross":
                gross_summary[
                    "Closed Trades"
                ],
            "Net":
                net_summary[
                    "Closed Trades"
                ],
        },
        {
            "Metric":
                "Total Fees",
            "Gross":
                gross_summary[
                    "Total Fees"
                ],
            "Net":
                net_summary[
                    "Total Fees"
                ],
        },
        {
            "Metric":
                "Total Slippage Cost",
            "Gross":
                gross_summary[
                    "Total Slippage Cost"
                ],
            "Net":
                net_summary[
                    "Total Slippage Cost"
                ],
        },
        {
            "Metric":
                "Total Friction Cost",
            "Gross":
                gross_summary[
                    "Total Friction Cost"
                ],
            "Net":
                net_summary[
                    "Total Friction Cost"
                ],
        },
    ]

    pd.DataFrame(
        summary_rows
    ).to_csv(
        summary_file,
        index=False,
    )

    plot_cost_mechanics(
        mechanics_file
    )

    plot_gross_vs_net_equity(
        gross_df,
        net_df,
        equity_file,
    )

    return {
        "Comparison CSV":
            comparison_file,
        "Net Trade Ledger":
            trade_file,
        "Summary CSV":
            summary_file,
        "Cost Mechanics Figure":
            mechanics_file,
        "Gross vs Net Figure":
            equity_file,
    }


# ------------------------------------------------------------
# 14. Main
# ------------------------------------------------------------

def parse_args():

    parser = argparse.ArgumentParser(
        description=(
            "Alphesta Phase 4-08 "
            "trading costs"
        )
    )

    parser.add_argument(
        "--self-test-only",
        action="store_true",
        help=(
            "Run only the deterministic "
            "cost self-test."
        ),
    )

    return parser.parse_args()


def main():

    args = parse_args()

    run_self_test()

    if args.self_test_only:
        return 0

    print(
        "Downloading market data..."
    )

    df = download_market_data()

    df = add_rule_columns(
        df
    )

    (
        gross_df,
        gross_trades,
        gross_summary,
    ) = run_backtest(
        df,
        starting_cash=starting_capital,
        fee_rate_value=0.0,
        slippage_rate_value=0.0,
    )

    (
        net_df,
        net_trades,
        net_summary,
    ) = run_backtest(
        df,
        starting_cash=starting_capital,
        fee_rate_value=fee_rate,
        slippage_rate_value=slippage_rate,
    )

    files = save_outputs(
        gross_df,
        gross_trades,
        gross_summary,
        net_df,
        net_trades,
        net_summary,
    )

    print()
    print(
        "Gross vs Net"
    )
    print(
        "============"
    )
    print()

    print(
        f"Fee rate: "
        f"{100.0 * fee_rate:.3f}%"
    )

    print(
        f"Slippage rate: "
        f"{100.0 * slippage_rate:.3f}%"
    )

    print()

    print(
        f"Gross ending equity: "
        f"{gross_summary['Ending Equity']:,.2f}"
    )

    print(
        f"Net ending equity: "
        f"{net_summary['Ending Equity']:,.2f}"
    )

    print(
        f"Net total fees: "
        f"{net_summary['Total Fees']:,.2f}"
    )

    print(
        f"Net slippage cost: "
        f"{net_summary['Total Slippage Cost']:,.2f}"
    )

    print(
        f"Net total friction cost: "
        f"{net_summary['Total Friction Cost']:,.2f}"
    )

    print()
    print(
        "Important:"
    )

    print(
        "These cost rates are teaching assumptions."
    )

    print(
        "They are not claims about a specific broker."
    )

    print()

    print(
        "Files saved:"
    )

    for name, path in (
        files.items()
    ):
        print(
            f"{name}: {path}"
        )

    return 0


if __name__ == "__main__":
    raise SystemExit(
        main()
    )

20. Check Your Understanding

  • A gross backtest ignores the costs modeled in this lesson.
  • A net backtest includes them.
  • A fee is an explicit charge.
  • Slippage changes the simulated fill price.
  • A BUY fill is modeled above the reference Open.
  • A SELL fill is modeled below the reference Open.
  • The trading rule stays unchanged.
  • Small costs can become important after many trades.

21. What You Just Learned

Same Trading Rule
↓
Gross Backtest
↓
Add Fee
+
Add Slippage
↓
Net Backtest
↓
Compare the Difference

That is the whole lesson.

We changed only one layer: execution cost.

22. Where Do We Go Next?

Now we have a net backtest.

The next question is:

How should we measure
the strategy itself?

Phase 4-09 will introduce a small set of performance measures, including return and drawdown.

Sources and Further Reading