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: float640 NaN
1 1.0
2 0.0
3 -1.0
4 1.0
dtype: float64
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.
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.
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.
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.
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.
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.
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.
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.
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% 69window 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.
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%