How do I charge trading costs?

Charge a cost every time the position changes, using turnover = position.diff().abs() times a rate.

You pay when the position changes, not when you hold it. The size of the change is the turnover, position.diff().abs(), and the cost of the day is that turnover times a rate.

In this lesson I count the trades in a five-day position by hand, charge 10 basis points for each one, then charge the same rate on a price-above-SMA rule on AAA and compare a fast window with a slow one.

Step 1. Five days, three trades

Here is a position that goes flat, long, long, flat, long. Read it as: I am out, then in, still in, out again, back in. That is three trades, and the middle day is a hold.

diff() is today minus yesterday. Row 0 has nothing before it, so it comes out NaN.

import pandas as pd

position = pd.Series([0, 1, 1, 0, 1], dtype=float)    # flat, long, long, flat, long

print(position.diff())
# -> 0    NaN
# -> 1    1.0
# -> 2    0.0
# -> 3   -1.0
# -> 4    1.0
# -> dtype: float64
0    NaN
1    1.0
2    0.0
3   -1.0
4    1.0
dtype: float64

Selling costs the same as buying, so take the absolute value. fillna(0) puts a zero in row 0, which is right here because the position starts flat: there is nothing to sell on day 0.

turnover = position.diff().abs().fillna(0)    # 1 on a day the position changes, else 0

print(turnover)
# -> 0    0.0
# -> 1    1.0
# -> 2    0.0
# -> 3    1.0
# -> 4    1.0
# -> dtype: float64

print(turnover.sum())     # -> 3.0
0    0.0
1    1.0
2    0.0
3    1.0
4    1.0
dtype: float64
3.0

Three units of turnover, matching the three changes counted by hand. Day 2 held the position and paid nothing.

Now the rate. One basis point is 0.01%, which is 1/10000, so 10 basis points is 0.0010.

rate = 0.0010                 # 10 basis points

cost = turnover * rate        # charged only on the days that traded

print(cost)
# -> 0    0.000
# -> 1    0.001
# -> 2    0.000
# -> 3    0.001
# -> 4    0.001
# -> dtype: float64

print(f"{cost.sum():.2%}")    # -> 0.30%
0    0.000
1    0.001
2    0.000
3    0.001
4    0.001
dtype: float64
0.30%

Three round trips at 10 basis points cost 0.30% in total.

Step 2. Turnover on the AAA rule

prices.csv sits next to this lesson and holds simulated daily closes for four tickers from 2020 to 2025. The rule is the one from Lesson 29: long when the close is above its 50-day mean, flat otherwise, and the signal is lagged a day before it earns anything.

prices = pd.read_csv("prices.csv", parse_dates=["Date"])          # Date column parsed as real dates

aapl  = prices[prices["Ticker"] == "AAA"].sort_values("Date")    # one ticker, oldest day first
close = aapl.set_index("Date")["Close"]                           # closes indexed by date
ret   = close.pct_change()                                        # daily return, close to close

sma      = close.rolling(50).mean()            # trailing 50-day mean of the close
signal   = (close > sma).astype(float)         # 1 while the close sits above the mean
position = signal.shift(1)                     # yesterday's signal is today's position
turnover = position.diff().abs().fillna(0)     # 1 on a day the position changes, else 0

print(pd.DataFrame({"close": close, "sma": sma,
                    "position": position, "turnover": turnover}).loc["2020-03-09":"2020-03-13"].round(2))
# ->             close    sma  position  turnover
# -> Date
# -> 2020-03-09  78.50    NaN       0.0       0.0
# -> 2020-03-10  80.98  76.20       0.0       0.0
# -> 2020-03-11  79.65  76.29       1.0       1.0
# -> 2020-03-12  78.64  76.32       1.0       0.0
# -> 2020-03-13  75.83  76.29       1.0       0.0
            close    sma  position  turnover
Date                                        
2020-03-09  78.50    NaN       0.0       0.0
2020-03-10  80.98  76.20       0.0       0.0
2020-03-11  79.65  76.29       1.0       1.0
2020-03-12  78.64  76.32       1.0       0.0
2020-03-13  75.83  76.29       1.0       0.0

The close crossed above the mean on 10 March, so the position turns to 1 on 11 March and the turnover of that day is 1. The two days that follow hold the position and their turnover is 0.

Because the position is only ever 0 or 1, each change is worth exactly 1 unit of turnover, so the sum of the turnover is also the number of trades.

print(turnover.sum())              # -> 113.0
print(int((turnover > 0).sum()))   # -> 113
113.0
113

Step 3. Gross and net

The gross return is the position times the return. The net return subtracts the day’s cost from it.

gross    = (position * ret).dropna()        # return earned only while positioned
turnover = turnover.reindex(gross.index)    # drop the days gross has no value for
net      = gross - turnover * rate          # the day's cost taken off the return

print(pd.DataFrame({"ret": ret.reindex(gross.index), "position": position.reindex(gross.index),
                    "turnover": turnover, "gross": gross, "net": net}).loc["2020-03-10":"2020-03-13"].round(4))
# ->                ret  position  turnover   gross     net
# -> Date
# -> 2020-03-10  0.0316       0.0       0.0  0.0000  0.0000
# -> 2020-03-11 -0.0164       1.0       1.0 -0.0164 -0.0174
# -> 2020-03-12 -0.0127       1.0       0.0 -0.0127 -0.0127
# -> 2020-03-13 -0.0357       1.0       0.0 -0.0357 -0.0357
               ret  position  turnover   gross     net
Date                                                  
2020-03-10  0.0316       0.0       0.0  0.0000  0.0000
2020-03-11 -0.0164       1.0       1.0 -0.0164 -0.0174
2020-03-12 -0.0127       1.0       0.0 -0.0127 -0.0127
2020-03-13 -0.0357       1.0       0.0 -0.0357 -0.0357

Only 11 March differs, by 0.0010, and that is the day the trade happened.

Compound each series to get the total for the sample.

print(f"gross: {(1 + gross).prod() - 1:.2%}")     # -> gross: 58.61%
print(f"net:   {(1 + net).prod() - 1:.2%}")       # -> net:   41.65%

print(f"{turnover.sum():.0f} trades")             # -> 113 trades
print(f"charged: {turnover.sum() * rate:.2%}")    # -> charged: 11.30%
gross: 58.61%
net:   41.65%
113 trades
charged: 11.30%

The charges add up to 11.30%, and the two totals are 16.96 percentage points apart. The gap is wider than the charges because the costs come out of the money that then compounds.

Step 4. A faster rule pays more

A 20-day mean crosses the price more often than a 200-day mean, so it trades more and pays more. Same rule, same rate, two windows.

def totals(window, rate):
    sig = (close > close.rolling(window).mean()).astype(float)     # same rule, window left open
    pos = sig.shift(1)                                             # lag it a day before it earns
    g   = (pos * ret).dropna()                                     # gross return of this window
    trn = pos.diff().abs().fillna(0).reindex(g.index)              # trades, on the gross days only
    n   = g - trn * rate                                           # same series after costs
    return (1 + g).prod() - 1, (1 + n).prod() - 1, trn.sum()       # gross total, net total, trades

print(f"{'window':>6} {'gross':>9} {'net':>9} {'turnover':>9}")
for w in (20, 200):                                                # one fast window, one slow
    g, n, t = totals(w, 0.0010)                                    # both charged 10 basis points
    print(f"{w:>6} {g:>9.2%} {n:>9.2%} {t:>9.0f}")
# -> window     gross       net  turnover
# ->     20   109.32%    76.06%       173
# ->    200    18.26%    10.37%        69
window     gross       net  turnover
    20   109.32%    76.06%       173
   200    18.26%    10.37%        69

The 20-day rule trades 173 times and gives up 33.26 points of total return. The 200-day rule trades 69 times and gives up 7.89 points. The faster rule earns more gross and pays more, because every extra crossing is another trade.

Your turn

Take the 20-day rule on AAA and charge it at three rates: 0, 10 and 25 basis points. What is the net total at each?

import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])

aapl  = prices[prices["Ticker"] == "AAA"].sort_values("Date")     # one ticker, oldest day first
close = aapl.set_index("Date")["Close"]
ret   = close.pct_change()

signal   = (close > close.rolling(20).mean()).astype(float)        # 1 while above the 20-day mean
position = signal.shift(1)                                         # lagged a day before it earns
gross    = (position * ret).dropna()                               # return earned while positioned
turnover = position.diff().abs().fillna(0).reindex(gross.index)    # trades, on the gross days only

for rate in (0.0000, 0.0010, 0.0025):                              # 0, 10 and 25 basis points
    net = gross - turnover * rate                                  # same gross, three charges
    print(f"{rate * 10000:>5.0f} bp {(1 + net).prod() - 1:>10.2%}")
# ->     0 bp    109.32%
# ->    10 bp     76.06%
# ->    25 bp     35.77%

Further reading: I put this turnover and cost arithmetic to work on real prices in Investing in high-yield dividend stocks?.