Applying Transaction Fees to Compounded Backtest Returns
Summary
The document explains how to include trading fees in a backtest represented by a return series and Boolean position signals. It identifies signal changes as trade events, then applies each fee as a multiplicative reduction to that period’s growth factor before compounding. This reflects a proportional charge on traded notional and avoids treating the cost as a fixed subtraction from the bar’s return.
The explanation notes that a trade count can account for multiple signal changes at one timestamp. It also contrasts multiplicative and additive adjustments with an example, while observing that the difference may be small for ordinary bar returns but can accumulate over many trades. The approach assumes a one-way fee rate and leaves an execution timing decision open: charging on the entry bar bundles cost with that bar’s return, which may overstate captured performance if the fill occurred after the bar’s move. Real results also depend on turnover, fill prices, and the fee schedule.
Key ideas
- Flag entries and exits by detecting changes in the position signal.
- Apply proportional transaction costs to growth factors before compounding returns.
- Count signal changes at each timestamp to charge for multiple trade legs when needed.
- Decide whether the entry bar’s return is earned before combining it with the fee.
- Fees modeled as a single proportional rate do not capture every execution cost.
Tags
Full text
# How to take into account transaction fee of a backtest from a list of returns? # How to take into account transaction fee of a backtest from a list of returns? I have a list of booleans that correspond to buy and sell signals that I would like to backtest. To achieve this, I calculated the return `ret` of a security and when the signal is `False` I modify the corresponding return to `0` (corresponds to a cash position), and when the signal is `True` I kept the return. The result is a Pandas series like this: ``` > signal 2018-01-01 00:00:00+00:00 NaN 2018-01-01 00:05:00+00:00 True 2018-01-01 00:10:00+00:00 False 2018-01-01 00:15:00+00:00 False 2018-01-01 00:20:00+00:00 True ... > ret 2018-01-01 00:00:00+00:00 NaN 2018-01-01 00:05:00+00:00 -0.003664 2018-01-01 00:10:00+00:00 -0.002735 2018-01-01 00:15:00+00:00 -0.005104 2018-01-01 00:20:00+00:00 0.000366 ... > ret_backtest = ret.loc[signal[~signal].index] = 0 > ret_backtest 2018-01-01 00:00:00+00:00 NaN 2018-01-01 00:05:00+00:00 -0.003664 2018-01-01 00:10:00+00:00 0 2018-01-01 00:15:00+00:00 0 2018-01-01 00:20:00+00:00 0.000366 ... ``` Then I reconstruct a price from `ret_backtest`, which give me a simplified result of the backtest. ``` result = ret_backtest.add(1).cumprod().mul(100) ``` My question concerns the trading fees. Usually, these fees are calculated based on the volumes bought or sold. But how can I take into account these transaction costs from a list of returns? for example, by selecting the periods when signal have changed, and applying the fees on the performance of these periods? ``` t = signal.shift(1) != signal trades_timestamp = (t.loc[t]).index ``` ## Answer by Valerii Sakara (score -1) https://quant.stackexchange.com/a/85765 Your `trades_timestamp` construction is exactly right — `signal.shift(1) != signal` correctly flags both entries and exits, one row per trade. The part worth being careful about is how you fold the cost into the return series, because the naive way (subtracting a flat amount from `ret_backtest` at those timestamps) quietly breaks compounding. Treat the fee as a multiplicative haircut on the growth factor, not an additive adjustment to the return: ``` fee = 0.001 # 10 bps, one-way n_trades = pd.Series(0, index=ret_backtest.index) n_trades.loc[trades_timestamp] = 1 growth = ret_backtest.add(1) * ((1 - fee) ** n_trades) result = growth.cumprod().mul(100) ``` Why multiply rather than subtract: transaction cost is quoted as a percentage of traded notional, so it scales the price you actually get filled at, not the raw percentage-point return of that bar. Subtracting `fee` from `ret_backtest` is only a first-order approximation and gets visibly wrong on large moves — e.g. a +50% bar with a 10bps fee should become 1.50 × 0.999 ≈ 1.4985 (fee scales with the size of the move), not 1.50 − 0.001 = 1.499 treated as if it were a small linear adjustment. The gap is tiny for typical daily-bar returns, but it compounds over thousands of trades and will quietly bias a long backtest, which is exactly the kind of thing worth getting right rather than approximating. The `n_trades` formulation (rather than just multiplying by `(1-fee)` once) is there for the edge case where an entry and an exit land on the same row — e.g. if you resample and two signal flips collapse into one timestamp. In that case you genuinely paid two round-trip legs on that bar, and `(1-fee)**2` reflects that instead of silently only charging once. One modeling question this doesn't resolve, and it's worth deciding deliberately rather than by default: is the fee meant to apply on top of that bar's own price return, or should the bar where you enter be treated as "no return yet, cost only" (since you're paying to establish the position, not profiting from a move you weren't in yet)? Bundling them (as above) is the common convention and fine as long as your bars are short enough that "the move during the bar you entered on" isn't doing most of the work — but if you're backtesting on daily bars with intraday entries, you're implicitly assuming your fill happened at a price that lets you capture that whole bar's return, which is its own honesty question separate from the fee itself.
Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.