Entries and exits, as a state machine
A signal is a boolean per bar. A position is something you hold across bars. Converting one into the other is where the second big class of bug lives.
Signal is not position
signal: false false true true true false false
position: 0 0 0 1 1 1 0
Look at bar 3. The signal turns true, and the position is still zero. That single column shift is the honest part: the signal was computed from that bar's close, so the earliest you could act is the next bar.
Line them up instead — position 1 on the same bar the signal fires — and you have bought at a price you only knew about after the close. It is the same look-ahead leak as lesson 1, wearing different clothes, and it is much harder to see because both columns look plausible.
Signals are computed on bar i; positions change on bar i+1. Write that as a comment above the loop, because it is the line people delete during a refactor.
Three states, not two
Model it explicitly:
type Position = { units: 0 | 1; entryBar: number | null };
Four transitions, and each deserves a named branch: flat and no signal (do nothing), flat and signal (enter next bar), held and signal (hold), held and no signal (exit next bar). Writing them as four branches rather than one clever ternary makes the diff readable and the edge cases obvious.
The edges that get missed, in order of how often:
- The last bar. An open position at the end of the series needs closing at the final price, or the return is never realised.
- The first bar. No previous bar, so no signal, so no position.
- A gap in the data. A missing day is not a signal change, and treating it as one produces phantom trades.
Count the trades and look at them
Your backtester must record every trade: entry date, entry price, exit date, exit price, return. Not for the report — for you, now.
Then read the first ten by eye against the price series. This takes five minutes and it catches things no summary statistic will: entries at prices that never traded, exits before entries, two positions open at once, a trade held for zero days.
A curve is a summary. A trade list is evidence. When the two disagree, the trade list is right.
Ask, then read
Generated state machines are usually correct on the main path and wrong at the ends. Read the diff for: does the loop start at index 1 or 0; is the position shifted by one bar; is the final open position closed; what happens when two signals fire on consecutive bars.
Try it now
Print your first ten trades as a table and check each entry price against the actual bar. Then deliberately remove the one-bar shift and watch what happens to the total return. The size of that jump is how much your backtest would have lied by, and it is usually larger than any edge you were hoping to find.