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.
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 0print(turnover)# -> 0 0.0# -> 1 1.0# -> 2 0.0# -> 3 1.0# -> 4 1.0# -> dtype: float64print(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 pointscost = turnover * rate # charged only on the days that tradedprint(cost)# -> 0 0.000# -> 1 0.001# -> 2 0.000# -> 3 0.001# -> 4 0.001# -> dtype: float64print(f"{cost.sum():.2%}") # -> 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 datesaapl = prices[prices["Ticker"] =="AAA"].sort_values("Date") # one ticker, oldest day firstclose = aapl.set_index("Date")["Close"] # closes indexed by dateret = close.pct_change() # daily return, close to closesma = close.rolling(50).mean() # trailing 50-day mean of the closesignal = (close > sma).astype(float) # 1 while the close sits above the meanposition = signal.shift(1) # yesterday's signal is today's positionturnover = position.diff().abs().fillna(0) # 1 on a day the position changes, else 0print(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.
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 costsreturn (1+ g).prod() -1, (1+ n).prod() -1, trn.sum() # gross total, net total, tradesprint(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 pointsprint(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
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?
TipShow answer
import pandas as pdprices = pd.read_csv("prices.csv", parse_dates=["Date"])aapl = prices[prices["Ticker"] =="AAA"].sort_values("Date") # one ticker, oldest day firstclose = aapl.set_index("Date")["Close"]ret = close.pct_change()signal = (close > close.rolling(20).mean()).astype(float) # 1 while above the 20-day meanposition = signal.shift(1) # lagged a day before it earnsgross = (position * ret).dropna() # return earned while positionedturnover = position.diff().abs().fillna(0).reindex(gross.index) # trades, on the gross days onlyfor rate in (0.0000, 0.0010, 0.0025): # 0, 10 and 25 basis points net = gross - turnover * rate # same gross, three chargesprint(f"{rate *10000:>5.0f} bp {(1+ net).prod() -1:>10.2%}")# -> 0 bp 109.32%# -> 10 bp 76.06%# -> 25 bp 35.77%