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 AAPL 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)

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)

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

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 AAPL 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"])

aapl  = prices[prices["Ticker"] == "AAPL"].sort_values("Date")
close = aapl.set_index("Date")["Close"]
ret   = close.pct_change()

sma      = close.rolling(50).mean()
signal   = (close > sma).astype(float)
position = signal.shift(1)
turnover = position.diff().abs().fillna(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()
turnover = turnover.reindex(gross.index)
net      = gross - turnover * rate

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)
    pos = sig.shift(1)
    g   = (pos * ret).dropna()
    trn = pos.diff().abs().fillna(0).reindex(g.index)
    n   = g - trn * rate
    return (1 + g).prod() - 1, (1 + n).prod() - 1, trn.sum()

print(f"{'window':>6} {'gross':>9} {'net':>9} {'turnover':>9}")
for w in (20, 200):
    g, n, t = totals(w, 0.0010)
    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 AAPL 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"] == "AAPL"].sort_values("Date")
close = aapl.set_index("Date")["Close"]
ret   = close.pct_change()

signal   = (close > close.rolling(20).mean()).astype(float)
position = signal.shift(1)
gross    = (position * ret).dropna()
turnover = position.diff().abs().fillna(0).reindex(gross.index)

for rate in (0.0000, 0.0010, 0.0025):
    net = gross - turnover * rate
    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%