Skip to content
GetProfitable
Search

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.

The spread of outcomes behind an expectancyA histogram of forty trades: a tall block of small losses on the left, a low spread of larger wins on the right, and a line marking the average outcome.NUMBER OF TRADES051024 LOSSES, AVG −$20016 WINS, AVG +$600EXPECTANCY +$120−$400−$200$0+$200+$400+$600+$800PROFIT OR LOSS PER TRADEexpectancy = (40% × $600) − (60% × $200) = +$120 per trade
Expectancy: the average trade. Forty trades sorted by outcome: 24 small losses and 16 larger wins. Weighting each side by how often it happens gives the average result per trade, marked here by the dashed line at +$120.
The win rate needed to break evenA falling curve: the more a winning trade pays relative to the amount risked, the smaller the share of trades that must win to break even.BREAKEVEN WIN RATE0%20%40%60%80%1:11:21:31:41:5REWARD-TO-RISK RATIO1:1 needs 50%1:2 needs 33.3%1:3 needs 25%breakeven win rate = 1 ÷ (1 + reward-to-risk)above the curve, wins more than cover losses
The win rate needed to break even. How often a method must win just to stay level, for each reward-to-risk ratio. At 1:1 half the trades must win, at 1:2 a third, and at 1:3 a quarter, because each win covers more losses.
A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.