Separate the signal, order and held position
For a 20-day window ending at close , compute
A close-to-open gap changes the actual entry price. Including it through the open-based return prevents the simulation from buying at the price that generated the signal. Calculate cumulative performance by compounding daily returns and account for a terminal liquidation if the strategy still holds the stock.
Trace one trade from entry signal to exit
The move from the signal close of 95 to the entry open of 96 happens while you're flat. The same is true of any move after your exit fill. In a daily implementation with open-to-open returns, a trade at today's open changes the position that earns the interval from today's open to tomorrow's open. Charge entry costs at today's execution. Then inspect the first and last rows explicitly. An open position at the end needs either a mark-to-market policy or a stated liquidation rule.
For a cash account buying one share, an explicit proportional fee of 0.05% raises the purchase cash outflow to and lowers the sale proceeds to . Taking the ratio and subtracting one gives about 3.02%. This is a useful test because you can verify both fees without relying on the vectorised return calculation. Write down whether the model uses all-in share purchases, proportional deductions from portfolio returns, or both. A model that already embeds costs in fills needs to avoid charging the same component again.
A stateful next-open backtest
Supply a sorted daily CSV with columns named Date, Open and Close. Use open and close prices on the same corporate-action adjustment basis.
import pandas as pd
prices = pd.read_csv("daily_prices.csv", parse_dates=["Date"])
prices = prices.set_index("Date").sort_index()
close, opening = prices["Close"], prices["Open"]
window = close.rolling(20)
z = (close - window.mean()) / window.std()
target = pd.Series(float("nan"), index=prices.index)
target[z < -2] = 1.0
target[z >= 0] = 0.0
weight = target.ffill().shift(1, fill_value=0.0).fillna(0.0)
forward = opening.shift(-1).div(opening).sub(1)
weight = weight.loc[forward.notna()]
forward = forward.loc[weight.index]
turnover = weight.diff().abs().fillna(weight.abs())
net = weight * forward - 0.0005 * turnover
net.iloc[-1] -= 0.0005 * weight.iloc[-1] # liquidate at the final open
print(pd.DataFrame({"strategy": (1 + net).cumprod(),
"buy_hold_gross": (1 + forward).cumprod()}).tail())What to check
- Choosing the most attractive stock after inspecting its full return history creates selection bias. Record the universe and selection rule before evaluating performance.
- Recomputing thresholds on the final test segment turns it into another development sample. Select parameters earlier or follow a fixed rolling selection rule.
- A large price drop may reflect new information. Include trades that continue to fall when assessing the rule and inspect their contribution to drawdown.