Start with three to five rows and compute the expected positions and P&L by hand. Print the timestamps for feature availability, decisions and fills beside the position actually held over each return interval. Then check how you compound equity and annualise it. A small, reproducible discrepancy is usually easier to diagnose than a full-history
equity curve.
Print the trade ledger before reading annual returns
Build a reconciliation table with signal timestamp, fill timestamp, interval start and end, starting exposure, asset return, costs and strategy return. Work forward from the initial state. Identify which price the signal first used and which later price the order could attain. If a signal requires the
closing auction price, a model that fills at that same price needs a justified pre-auction order rule.
Verify the accounting at each step. With daily fractional returns rt, wealth is WT/W0=∏t(1+rt). For elapsed time Y years, CAGR is (WT/W0)1/Y−1. On regularly spaced daily bars, Y≈T/252 can be a useful convention. On irregular data or when you have calendar endpoints, use elapsed years and state the convention.
Finally, examine joins, corporate actions and portfolio scale. A many-to-many join can duplicate returns. A stock split can generate a spurious return if adjusted and unadjusted prices get mixed.
Gross exposure exceeding one can be intentional, but
leverage and financing belong in the reported strategy.
Let three consecutive daily returns be +5%, −4% and +3%. A rule sets the next day's target to long when today's return is positive. Same-row multiplication produces +5%, 0 and +3%, or +8.15% compounded. If you held zero before the first decision, one-interval delayed weights are 0, 1 and 0, giving −4%. This toy case isolates alignment. If the input arrives at the close and the first feasible fill is at the next open, a realistic
backtest must further move the return intervals to open-to-open prices.
After fixing timing, re-run the same rows with costs set to zero and then with one known fee. The change in cash at the fill should equal that fee before any later compounding. Test a position of zero throughout, a single buy-and-hold trade and a single round trip. Those cases separate a return-unit or bookkeeping bug from a timing bug. Only when all three reconcile is it useful to inspect annualisation. A spectacular compounded result often starts with one ordinary-looking row.
Reproduce the timing bug with three rows
import pandas as pd
returns = pd.Series([0.05, -0.04, 0.03], name="asset_return")
signal = (returns > 0).astype(float)
held = signal.shift(1, fill_value=0.0)
audit = pd.DataFrame({"asset_return": returns, "signal": signal,
"held": held, "incorrect": signal * returns,
"lagged": held * returns})
print(audit)
print((1 + audit[["incorrect", "lagged"]]).prod() - 1)
Once your hand ledger reconciles, use a
Strategy Lab example to compare performance reporting conventions.