Trade-level stats from a vectorized backtest
Lesson 17 · about 12 min
Bar-level metrics describe the equity curve. Trade-level metrics describe the behaviour: how often the rule wins, how big the wins are compared with the losses, how long trades last. A vectorized backtest never builds a trade list, but it contains one implicitly, and a few lines of groupby recover it. This lesson extracts the list, computes the standard statistics, and connects them to the expectancy arithmetic from the Risk Management course.
Recovering trades from the position column
A trade starts when the position goes from zero to non-zero and ends when it returns to zero (or flips sign). Number each run:
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma
def ma_crossover_signal(close, fast=20, slow=50):
f, s = sma(close, fast), sma(close, slow)
signal = (f > s).astype(float)
signal[s.isna()] = np.nan
return signal
def trade_ids(position: pd.Series) -> pd.Series:
"""Integer id per trade; 0 while flat. A sign flip starts a new trade."""
side = np.sign(position.fillna(0.0))
new_trade = (side != side.shift(1)) & (side != 0)
ids = new_trade.cumsum()
return ids.where(side != 0, 0).astype(int)
bars = synthetic_ohlcv(1500, seed=42)
asset_ret = bars["close"].pct_change()
position = ma_crossover_signal(bars["close"]).shift(1).fillna(0.0)
turnover = position.diff().abs().fillna(0.0)
net = (position * asset_ret).fillna(0.0) - turnover * 5 / 10_000
ids = trade_ids(position)
print(f"{ids.max()} trades found")
Build the ids from position, not signal. The position is what was actually held on each bar, already lagged, so its runs line up with the bars whose returns belong to each trade.
The trade list
For each id, collect entry date, exit date, bars held, and the compounded net return.
def trade_list(bars: pd.DataFrame, position: pd.Series, net: pd.Series) -> pd.DataFrame:
ids = trade_ids(position)
in_trade = ids > 0
g = net[in_trade].groupby(ids[in_trade])
trades = pd.DataFrame({
"entry": g.apply(lambda s: s.index[0]),
"exit": g.apply(lambda s: s.index[-1]),
"bars": g.size(),
"side": position[in_trade].groupby(ids[in_trade]).first().apply(np.sign),
"ret": g.apply(lambda s: (1 + s).prod() - 1),
})
return trades
trades = trade_list(bars, position, net)
print(trades.head())
print(trades["ret"].describe().round(4))
The per-trade return compounds the bars inside the trade, net of costs, and includes the exit bar. One subtlety: the entry cost is charged on the first bar of the trade (where turnover jumped), but the exit cost is charged on the first flat bar, which is outside the trade's group. So the trade returns above are slightly too generous by one side of costs. To be exact, shift the cost of exits back one bar before grouping, or accept the small overstatement and mention it. For a course project, accept and mention; for anything you will trade, be exact:
exit_cost = (turnover * 5 / 10_000).where(position == 0, 0.0).shift(-1).fillna(0.0)
net_exact = net - exit_cost + (turnover * 5 / 10_000).where(position == 0, 0.0)
That moves each exit's cost onto the last in-trade bar. (Yes, it uses shift(-1). This is accounting on realised returns after the fact, not a signal; look-ahead applies to decisions, not to bookkeeping.)
The standard statistics
def trade_stats(trades: pd.DataFrame) -> dict:
r = trades["ret"]
wins, losses = r[r > 0], r[r <= 0]
gross_win, gross_loss = wins.sum(), -losses.sum()
return {
"n_trades": int(len(r)),
"win_rate": round(float((r > 0).mean()), 3),
"avg_win": round(float(wins.mean()), 4) if len(wins) else float("nan"),
"avg_loss": round(float(losses.mean()), 4) if len(losses) else float("nan"),
"payoff_ratio": round(float(wins.mean() / -losses.mean()), 3) if len(wins) and len(losses) else float("nan"),
"profit_factor": round(float(gross_win / gross_loss), 3) if gross_loss > 0 else float("inf"),
"expectancy_per_trade": round(float(r.mean()), 4),
"avg_bars_held": round(float(trades["bars"].mean()), 1),
"largest_win": round(float(r.max()), 4),
"largest_loss": round(float(r.min()), 4),
}
for k, v in trade_stats(trades).items():
print(f"{k:>22}: {v}")
Win rate and payoff ratio (average win ÷ average loss) together determine expectancy, exactly as in the Risk Management course: expectancy = win_rate × avg_win − (1 − win_rate) × avg_loss. A trend-following rule like the crossover typically has a win rate below 50% and a payoff ratio well above 1; a mean-reversion rule is the reverse. Neither profile is better; what matters is that the expectancy is positive after costs and that you can tolerate the shape.
Profit factor (gross wins ÷ gross losses) is the same information in a ratio. Below 1 the strategy loses; 1.3 to 1.8 is typical for real systems on daily bars; above 3 over hundreds of trades is, again, a bug until proven otherwise.
Largest loss relative to average loss tells you how fat the left tail is, which is what stops (Module 7) are meant to cut.
Key idea: A vectorized backtest contains a trade list; recover it by numbering runs of non-zero position and grouping. Win rate and payoff ratio combine into expectancy, and that, not either one alone, is what you are testing.
How many trades is enough
Expectancy is an average, and averages from small samples are noise. The standard error of the mean trade return is roughly std(ret) / √n. With 30 trades and a per-trade standard deviation of 5%, the standard error is about 0.9%, so an expectancy of +0.5% per trade is indistinguishable from zero. Compute it:
def expectancy_t_stat(trades: pd.DataFrame) -> float:
r = trades["ret"]
return float(r.mean() / (r.std(ddof=1) / np.sqrt(len(r))))
print(f"t-stat of expectancy: {expectancy_t_stat(trades):.2f}")
A t-stat below 2 means the sample cannot distinguish the strategy from a coin flip. Most simple daily-bar strategies on one instrument over a few years produce 30 to 100 trades and t-stats near 1. This is not a reason to despair; it is the reason Module 8 is about walk-forward across time and Module 10 suggests multiple instruments. More trades, more evidence.
Holding-time distribution
print(trades.groupby("side")["bars"].describe().round(1))
If the median hold is 3 bars and the mean is 25, a few very long trends are carrying the strategy. That is normal for trend-following and a warning for mean reversion (a "mean reversion" trade held 60 bars is a trade that never reverted). The distribution of holding times is a cheap check that the rule is doing what you think it does.
Try it: Compute the trade list for the crossover on five different seeds and stack the trade returns into one series. Compare the pooled win rate and payoff ratio against the per-seed values. Then compute the t-stat of the pooled expectancy. On synthetic data with no real edge it should be near zero; if it is consistently positive, something in the pipeline is leaking.
Recap
- Number trades by runs of non-zero position (from the lagged position, not the signal) and group bar returns by id.
- Per-trade return compounds the bars in the trade; move the exit cost onto the last in-trade bar for exact accounting.
- Win rate and payoff ratio combine into expectancy; profit factor is the same idea as a ratio.
- The t-stat of expectancy tells you whether the sample can distinguish the edge from noise; 30 trades usually cannot.
- Check the holding-time distribution to confirm the rule behaves as designed.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.