Costs are a strategy killer
Worked examples
Read the claim, then run it and check the machine agrees. One at a time — nothing here is taken on trust.
import numpy as np
import qadata
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
def net_backtest(prices, cost_bp, window=20):
rets = prices.pct_change().fillna(0.0)
held = (prices > prices.rolling(window).mean()).astype(float).shift(1).fillna(0.0)
turnover = held.diff().abs().fillna(0.0) # |Δ held|, 0 on day one
net = held * rets - cost_bp / 1e4 * turnover
equity = (1.0 + net).cumprod()
return equity.iloc[-1] - 1.0, net.mean() / net.std() * np.sqrt(252)
for bp in (0, 5, 10, 20):
total, sharpe = net_backtest(prices, bp)
print(f"{bp:>2}bp: total {total:+7.2%} Sharpe {sharpe:+.2f}")
The whole lesson is the turnover line and the subtraction after
it. Note which series gets diffed: held, the shifted position —
the toll is charged on the bar the trade actually happens, the same
bar whose return the new position starts earning. At 0bp the engine
reproduces m02-1 exactly (+27.26%, Sharpe 0.74); at 10bp a third of
the Sharpe is gone (0.52); at 20bp two thirds of the total return
(+9.30%). Nothing about the strategy changed — only the accounting
got honest.
import numpy as np
import qadata
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
years = len(prices) / 252
for window in (20, 5):
held = (prices > prices.rolling(window).mean()).astype(float).shift(1).fillna(0.0)
turnover = held.diff().abs().fillna(0.0)
gross_total = (1.0 + held * rets).cumprod().iloc[-1] - 1.0
gross_cagr = (1.0 + gross_total) ** (1 / years) - 1.0
bill = turnover.sum() / years * 10 / 1e4 # per year, at 10bp
net_total = (1.0 + held * rets - 10 / 1e4 * turnover).cumprod().iloc[-1] - 1.0
print(f"{window:>2}d filter: {turnover.sum():.0f} units "
f"({turnover.sum() / years:.1f}/yr) | gross edge {gross_cagr:+.2%}/yr "
f"| 10bp bill {bill:.2%}/yr | net total {net_total:+.2%}")
The doom of the 5d filter is visible before the backtest even runs: it earns 3.77%/yr gross and owes 7.13%/yr at 10bp — the bill exceeds the whole edge, so the net result (−9.82%) was never in doubt. The 20d filter earns 8.37%/yr against a 2.53%/yr bill and lives. Comparing a strategy's gross CAGR to its annual cost bill is the fastest cost sanity-check there is, and it needs no equity curve at all.
import numpy as np
import qadata
from scipy.optimize import brentq
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
def net_total(cost_bp, window):
held = (prices > prices.rolling(window).mean()).astype(float).shift(1).fillna(0.0)
turnover = held.diff().abs().fillna(0.0)
return (1.0 + held * rets - cost_bp / 1e4 * turnover).cumprod().iloc[-1] - 1.0
for window in (20, 5):
be = brentq(lambda bp: net_total(bp, window), 0.0, 200.0)
print(f"{window:>2}d filter breaks even at {be:.1f}bp per unit of turnover")
Solving net total return = 0 for cost_bp gives each rule a single robustness number: the 20d filter can survive costs up to 31.7bp, the 5d filter only 5.2bp. A strategy whose break-even sits inside the range of plausible real-world costs is dead on arrival, no statistics required. When m03 sweeps trend rules and m10 grades your capstone, this is one of the first numbers on the report card.
Check the concept
One question at a time. Unsure? Revisit the lecture, then answer.
The challenge
Pass the quiz to unlock the challenge — your code will still be waiting here.