Slippage is a fee nobody prints
The exchange prints its fee. It does not print the spread, the thin book or the stop that fills worse than its trigger. A test that leaves them out is flattering itself.
A fee is easy to model because it is printed. A percentage per side, written on the exchange's page. The costs that are not printed are the ones that quietly turn a backtest into a story.
Three costs with no line on the invoice#
The spread. There is a price you can buy at and a lower one you can sell at. A market buy pays the higher one and a market sell receives the lower one, so a round trip of two market orders gives up the whole gap between them, on top of the fees.
The depth. The displayed price is for a small size. A bigger order walks up the order book and pays a worse average. On a thin market the effect of your size is a cost.
The stop. A stop is a trigger, not a promise of a price. A stop-market order fires when the price touches the line, and fills wherever the market is by then. In a fast move that is worse than the line, sometimes much worse.
Where backtests flatter themselves#
- Filling at the close, or at the bar's best price. Real fills happen at the price available when the order arrives.
- A limit order that fills because price touched it. Touching is not trading through. When price merely reaches your limit, you are somewhere in a queue and may never fill.
- Costs as a constant. The spread is wider when the market is moving, which is exactly when a stop-out happens.
def fill_price(side: str, bar_open: float, spread: float, slip: float) -> float:
"""Pessimistic market fill: pay half the spread plus a slippage allowance, always against you."""
half = spread / 2
return bar_open * (1 + half + slip) if side == "buy" else bar_open * (1 - half - slip)
def limit_filled(side: str, limit: float, bar_low: float, bar_high: float, tick: float) -> bool:
"""A limit fills only if price trades through it by a tick, not if it merely touches it."""
return bar_low <= limit - tick if side == "buy" else bar_high >= limit + tickThe inputs here are made up. What matters is the direction: every approximation is chosen to hurt, so that when the strategy survives the test, it survives a version of the market that is mean to it.
Measure it, then feed it back#
- Make the costs parameters, and run the test again with them doubled. A strategy that dies at double the assumed cost had no margin.
- Record the expected price when an order goes out, and the actual fill when it comes back. The difference, per order, is your real slippage, and it is data rather than an assumption.
- Add a cost check to the pre-trade gate. A gate that already refuses orders and says why, like the one in start from the task, is the natural place to refuse a trade whose expected costs eat too much of its expected gain.
- Remember that the fee line is only the start. The fee line eats the grid works through the printed costs. This post is the rest.
You can try the printed half in the lab.
This is how trading software can be built. It is not investment advice. Trading crypto assets, especially with leverage, carries a risk of losing money, and results from tests or simulations do not guarantee anything in live markets. The studio builds trading software and tooling. It does not manage anyone's money, hold client funds or trade on anyone's behalf.
The best of a hundred coin flips looks like talent
Try enough strategies and one of them will look brilliant, even when every one is a coin toss. The number of tries is part of the result, and it is the part nobody prints.
Start from the task, not the strategy
A trading bot project begins with what the bot is for, what it may lose and what it must never do. The signal comes last, and it is the smallest part of the work.
A stop that lives in your bot is not a stop
When the process crashes, the server reboots or the connection drops, a stop that exists only in the bot's memory does not exist. The stop belongs on the exchange, at entry.