How do I charge trading costs?

Count trades with abs(diff(position)), charge a fee on each one, and compare the gross and net totals of a moving average rule.

You pay when the position changes, not when you hold it. Holding the same position for a month costs nothing extra; moving from flat to long and back charges you twice. So the fee is charged on turnover, abs(diff(position)), and never on the level of the position.

In this lesson I count the trades in a five-day position vector by hand, then charge 10 basis points per trade on the 50-day rule from Lesson 23 and print the total gross and net, and I finish by charging the same fee on a 20-day and a 200-day version of the rule.

Step 1. Count the trades on five days

Here is a position that is flat, long for two days, flat, then long again. Five days, and you can count the trades by eye: in on day 2, out on day 4, in on day 5. Three trades.

diff() gives the change from each element to the next. abs() throws away the direction, because getting out costs the same as getting in.

position <- c(0, 1, 1, 0, 1)   # flat, long, long, flat, long

print(position)                # -> [1] 0 1 1 0 1
[1] 0 1 1 0 1
print(diff(position))          # -> [1]  1  0 -1  1
[1]  1  0 -1  1
print(abs(diff(position)))     # -> [1] 1 0 1 1
[1] 1 0 1 1

diff() returns one element fewer than it was given, because four gaps sit between five days. To use it as a column next to the position I pad the front with the first position itself. That first element is the trade that opened the book: if the strategy starts long you paid to get there, and if it starts flat you paid nothing.

turnover <- c(position[1], abs(diff(position)))   # pad the front, then the changes

print(turnover)                                   # -> [1] 0 1 0 1 1
[1] 0 1 0 1 1
print(length(turnover))                           # -> [1] 5
[1] 5
print(sum(turnover))                              # -> [1] 3   <- three trades
[1] 3

The position starts at 0, so day 1 is free and the total is the three trades I counted by eye.

Now the fee. One basis point is 0.01 percent, which is 1/10000, or 0.0001 as a decimal. Ten basis points is 0.0010. Multiply the turnover column by that and you get the charge on each day.

fee  <- 0.0010                     # 10 basis points, 0.10 percent
cost <- turnover * fee             # charge only on the days that traded

print(cost)                        # -> [1] 0.000 0.001 0.000 0.001 0.001
[1] 0.000 0.001 0.000 0.001 0.001
print(sum(cost))                   # -> [1] 0.003
[1] 0.003
print(round(sum(cost) * 100, 2))   # -> [1] 0.3   <- percent, over all five days
[1] 0.3

Three trades at 10 basis points is 30 basis points, 0.3 percent. Days 1, 3 and 5 pay nothing at all: day 1 and day 3 because nothing moved, and the position on day 5 is only held.

Step 2. Charge the 50-day rule on AAA

The price file next to this lesson is simulated: six years of daily closes for four tickers. read.csv() returns Date as character, so as.Date() makes it a real date.

I rebuild the rule from Lesson 23 in one pipeline. Price above its 50-day average is the signal, lag() turns the signal into the position you can actually hold, and the two leading gaps go with one filter().

library(dplyr)                            # filter, mutate, lag
library(zoo)                              # rollmean

px      <- read.csv("prices.csv")         # simulated daily closes
px$Date <- as.Date(px$Date)               # character to date

aapl <- px |>
  filter(Ticker == "AAA") |>                                 # one ticker
  arrange(Date) |>                                            # oldest first
  mutate(ret      = Close / lag(Close) - 1,                   # daily return
         sma      = rollmean(Close, 50, fill = NA,
                             align = "right"),                # 50-day average
         signal   = as.numeric(Close > sma),                  # 1 when price is above
         position = lag(signal)) |>                           # yesterday's signal, held today
  filter(!is.na(position), !is.na(ret))                       # drop the warm-up rows

print(nrow(aapl))                                             # -> [1] 1516
[1] 1516
print(head(aapl[, c("Date", "Close", "sma", "signal", "position")], 3))
        Date Close     sma signal position
1 2020-03-11 79.65 76.2886      1        1
2 2020-03-12 78.64 76.3226      1        1
3 2020-03-13 75.83 76.2924      0        1
# ->         Date Close     sma signal position
# -> 1 2020-03-11 79.65 76.2886      1        1
# -> 2 2020-03-12 78.64 76.3226      1        1
# -> 3 2020-03-13 75.83 76.2924      0        1

Same three columns as Step 1, only 1516 rows long. The turnover line is identical.

aapl <- aapl |>
  mutate(turnover = c(position[1], abs(diff(position))),   # 1 on a day the position moved
         gross    = position * ret,                        # return earned before costs
         net      = gross - turnover * fee)                # same return, minus the charge

print(head(aapl[, c("Date", "position", "turnover", "gross", "net")], 6))
        Date position turnover        gross          net
1 2020-03-11        1        1 -0.016423808 -0.017423808
2 2020-03-12        1        0 -0.012680477 -0.012680477
3 2020-03-13        1        0 -0.035732452 -0.035732452
4 2020-03-16        0        1  0.000000000 -0.001000000
5 2020-03-17        0        0  0.000000000  0.000000000
6 2020-03-18        1        1 -0.004315982 -0.005315982
# ->         Date position turnover        gross          net
# -> 1 2020-03-11        1        1 -0.016423808 -0.017423808
# -> 2 2020-03-12        1        0 -0.012680477 -0.012680477
# -> 3 2020-03-13        1        0 -0.035732452 -0.035732452
# -> 4 2020-03-16        0        1  0.000000000 -0.001000000
# -> 5 2020-03-17        0        0  0.000000000  0.000000000
# -> 6 2020-03-18        1        1 -0.004315982 -0.005315982

Rows 2 and 3 hold the position and net equals gross. Row 4 is the one to look at: the position is 0, so nothing is earned, but the day still costs 0.0010 because that is the day the strategy sold out. You pay for the exit even though you are flat afterwards.

Count the trades and add up the charges.

print(sum(aapl$turnover))                           # -> [1] 113
[1] 113
print(sum(aapl$turnover != 0))                      # -> [1] 113   <- days that traded
[1] 113
print(round(sum(aapl$turnover) * fee * 100, 2))     # -> [1] 11.3
[1] 11.3

The two counts match because this position only ever moves between 0 and 1, so every change is exactly one unit of turnover. 113 trades at 10 basis points is 11.3 percent of capital paid away over six years.

Now the totals. Returns compound, so both go through prod(1 + r) - 1 and never through sum().

tot_gross <- prod(1 + aapl$gross) - 1               # total before costs
tot_net   <- prod(1 + aapl$net) - 1                 # total after costs

print(round(tot_gross * 100, 2))                    # -> [1] 58.61
[1] 58.61
print(round(tot_net * 100, 2))                      # -> [1] 41.65
[1] 41.65
print(round((tot_gross - tot_net) * 100, 2))        # -> [1] 16.96
[1] 16.96

58.61 percent becomes 41.65 percent. The gap is 16.96 points, larger than the 11.3 percent I charged, because every basis point taken out early is also a basis point that never compounds for the remaining six years.

Step 3. A faster rule pays more

Turnover is a property of the rule, not of the market. A short average tracks the price closely and crosses it often, so it trades often. I put the whole thing in a function so I can change the window and read off the bill.

charge <- function(w, fee = 0.0010) {
  d <- px |>
    filter(Ticker == "AAA") |>
    arrange(Date) |>
    mutate(ret      = Close / lag(Close) - 1,
           sma      = rollmean(Close, w, fill = NA, align = "right"),   # w-day average
           signal   = as.numeric(Close > sma),
           position = lag(signal)) |>
    filter(!is.na(position), !is.na(ret)) |>
    mutate(turnover = c(position[1], abs(diff(position))))              # trades per day

  gross <- prod(1 + d$position * d$ret) - 1                             # before costs
  net   <- prod(1 + d$position * d$ret - d$turnover * fee) - 1          # after costs

  data.frame(window   = w,
             turnover = sum(d$turnover),                                # total trades
             gross    = round(gross * 100, 2),
             net      = round(net * 100, 2))
}

print(rbind(charge(20), charge(50), charge(200)))
  window turnover  gross   net
1     20      173 109.32 76.06
2     50      113  58.61 41.65
3    200       69  18.26 10.37
# ->   window turnover  gross   net
# -> 1     20      173 109.32 76.06
# -> 2     50      113  58.61 41.65
# -> 3    200       69  18.26 10.37

The 20-day rule trades 173 times, the 200-day rule 69 times, and the 50-day version sits between them at the 113 I charged in Step 2. The fast rule pays 17.3 percent in fees against the slow rule’s 6.9 percent.

Costs do not reorder these three windows here: 20 beats 50 beats 200 before costs and after them. They do change the size of the answer. The 20-day rule keeps 76.06 of its 109.32, so a third of what it earned went to the broker. Buy and hold on the same AAA data returned 165.45 percent and traded once.

Your turn

Run the 50-day rule on CCC and charge 20 basis points per trade. How many trades, and what is the total before and after costs?

library(dplyr)
library(zoo)

px      <- read.csv("prices.csv")
px$Date <- as.Date(px$Date)

msft <- px |>
  filter(Ticker == "CCC") |>                                    # one ticker
  arrange(Date) |>
  mutate(ret      = Close / lag(Close) - 1,
         sma      = rollmean(Close, 50, fill = NA, align = "right"),
         signal   = as.numeric(Close > sma),
         position = lag(signal)) |>                              # signal held from the next day
  filter(!is.na(position), !is.na(ret)) |>
  mutate(turnover = c(position[1], abs(diff(position))),         # trades
         gross    = position * ret,
         net      = gross - turnover * 0.0020)                   # 20 basis points each

print(sum(msft$turnover))                        # -> [1] 75
print(round((prod(1 + msft$gross) - 1) * 100, 2))  # -> [1] 390.22
print(round((prod(1 + msft$net)   - 1) * 100, 2))  # -> [1] 321.93

75 trades at 20 basis points is 15 percent charged, and the total drops by 68 points once the missing compounding is counted.

Lesson 26 measures the worst fall an equity curve takes from a previous peak.