Trading is not free
A backtest that trades for free overstates every strategy, and the more it trades, the more it lies. Real costs are the broker's commission, exchange and regulatory fees, and above all the bid-ask spread you cross each time. They are usually quoted in basis points of the value traded: 1 bp is 0.01%. A round trip at 5 bps each way costs 10 bps, which a strategy earning 3 bps a day does not survive if it flips daily.
def trade_cost(notional, bps):
return abs(notional) * bps / 10000
print(trade_cost(25000, 5)) # 12.5 buying 25,000 at 5 bps
print(trade_cost(-25000, 5)) # 12.5 selling costs the same
print(trade_cost(1_000_000, 1)) # 100.0 one basis point on a millionTurnover: how much you trade
Cost depends on how much the position changes, not on its size. With the position as a fraction of the account, turnover on a day is the absolute change from the previous position: abs(position[t] - position[t - 1]), starting from flat on day 0. Staying long costs nothing after the first purchase; flipping from long to short trades the account twice. Summed over a year, turnover is the first number to look at in any strategy.
import numpy as np
positions = np.array([1.0, 1.0, -1.0, -1.0, 0.0]) # long, long, flip to short, short, flat
turnover = np.abs(np.diff(positions, prepend=0.0)) # day 0 starts from a flat position
print(turnover) # [1. 0. 2. 0. 1.]
print(turnover.sum()) # 4.0 accounts traded over the five daysNet returns and the break-even cost
The day's cost, as a fraction of the account, is turnover * bps / 10000, and the net return is the gross return minus that. A useful single number is the break-even cost: the cost per unit traded at which the total profit is exactly zero, sum(gross) / sum(turnover) * 10000. Compare it with what you really pay. A strategy with a break-even of 2 bps is dead in practice, one with 40 bps has room.
# Gross and net returns of a rule that flips often, at 5 bps per unit traded
import numpy as np
rng = np.random.default_rng(11)
positions = np.where(rng.random(250) > 0.5, 1.0, -1.0) # a coin-flip position, flipping often
gross = rng.normal(0.0015, 0.005, size=250) # a strategy with a real daily edge before costs
turnover = np.abs(np.diff(positions, prepend=0.0))
net = gross - turnover * 5 / 10000
print(round(float(gross.sum()), 4), round(float(net.sum()), 4))
print(round(float(turnover.sum()), 1), "accounts traded;", round(float(gross.sum() / turnover.sum() * 10000), 2), "bps break-even")# The break-even cost of a small example
import numpy as np
gross = np.array([0.004, 0.002, 0.003, 0.001, 0.002])
positions = np.array([1.0, 1.0, -1.0, -1.0, 0.0])
turnover = np.abs(np.diff(positions, prepend=0.0))
breakeven = gross.sum() / turnover.sum() * 10000
print(round(float(breakeven), 2)) # 30.0 bps per unit traded
print(abs(round(float((gross - turnover * breakeven / 10000).sum()), 10))) # 0.0: the whole profit is gone- Costs are quoted in basis points of the value traded:
abs(notional) * bps / 10000, the same for a buy and a sell. - Turnover is
abs(change in position)(a flip trades 2); net return = gross - turnover * bps / 10000. - The break-even cost
sum(gross) / sum(turnover) * 10000tells how much cost a strategy can take; compare it with the real one.
Next up: slippage, the gap between the price you expected and the price you got.
Lab · what's graded
- Price one trade with
trade_cost25 pts - Count what is traded with
turnover25 pts - Subtract the bill with
net_returns25 pts - Find the cost that kills it with
breakeven_bps25 pts