Features

Trade analytics

Analyze trades and positions with MAE, MFE, edge ratio, profit factor, and plots

Which trades made the money, and were the exits any good? VBT keeps every trade as a record with its entry, exit, profit, and excursions, so trade analysis in Python covers win rate, profit factor, expectancy, MAE and MFE, and the edge ratio for one backtest or thousands.

Check whether a 5% stop would have cut the winners
data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], start="2018-01-01", end="2025-01-01")
fast = data.close.rolling(20).mean()
slow = data.close.rolling(50).mean()
pf = vbt.PF.from_signals(
    data,
    fast.vbt.crossed_above(slow),
    fast.vbt.crossed_below(slow),
    fees=0.001,
)
trades = pf.trades.status_closed  
metrics = ["total_records", "win_rate", "profit_factor", "expectancy", "sqn"]
trades.stats(metrics, agg_func=None)
         Total Records  Win Rate [%]  Profit Factor  Expectancy       SQN
symbol
BTC-USD             26     30.769231       1.632437   13.422132  0.669763
ETH-USD             23     52.173913       1.867036   33.244891  0.803366
mae = trades.mae_returns.values  
returns = trades.returns.values
pnl = trades.pnl.values
cut = (returns > 0) & (mae < -0.05)
pd.Series({
    "winners": (returns > 0).sum(),
    "winners that first fell over 5%": cut.sum(),
    "their profit": pnl[cut].sum().round(2),
    "profit of all winners": pnl[returns > 0].sum().round(2),
})
winners                              20.00
winners that first fell over 5%       9.00
their profit                       1306.13
profit of all winners              2547.29
dtype: float64
trades.plot_mae_returns(column="BTC-USD").show()
Scatter plot of maximum adverse excursion against final return for Bitcoin moving average crossover trades Figure data (JSON)

Nine of the 20 winning trades first fell more than 5% below their entry, and they brought in half of the winning profit. A 5% stop would have closed them at a loss. The scatter shows the same thing for every Bitcoin trade: how far it went against the position compared with how it ended.

Trades and positions

The same orders can be grouped into trades in three ways, and each answers a different question:

RecordsOne record perUse it for
pf.entry_tradesOrder that opens or adds to a positionJudging entries
pf.exit_trades (pf.trades)Order that reduces or closes a positionJudging exits
pf.positionsPosition from open to flatJudging whole positions

With a single entry and exit they are identical. With scaling in or out they differ:

Split two buys and two sells into trades and positions
index = pd.date_range("2026-01-01", periods=4)
close = pd.Series([10.0, 11.0, 12.0, 13.0], index=index)
pf = vbt.PF.from_orders(close, size=[1, 1, -1, -1])
cols = ["Entry Index", "Exit Index", "Size", "PnL"]

pf.entry_trades.readable[cols]
  Entry Index Exit Index  Size  PnL
0  2026-01-01 2026-01-04   1.0  2.5
1  2026-01-02 2026-01-04   1.0  1.5
pf.exit_trades.readable[cols]
  Entry Index Exit Index  Size  PnL
0  2026-01-01 2026-01-03   1.0  1.5
1  2026-01-01 2026-01-04   1.0  2.5
pf.positions.readable[cols]
  Entry Index Exit Index  Size  PnL
0  2026-01-01 2026-01-04   2.0  4.0

Entry trades measure each entry against the average exit price, and exit trades measure each exit against the average entry price. Records carry the column labels of your inputs, so trades of a parameter grid can be grouped by parameter, and direction_long or direction_short selects one side. pf.trade_history joins orders with all three views, as the Trade history highlight below shows. It has one row per order, and each row carries the profit of that order's entry trade or exit trade, so in the example above its PnL column adds up to 8, twice the real profit. Compute statistics from one of the three record types instead.

Trade metrics

trades.stats() reports win rate, best and worst trade, average win and loss, winning and losing streaks, profit factor, expectancy, and the system quality number (SQN). Every metric is also a property, such as trades.profit_factor.

A trade's return is its profit relative to the money committed at entry, not to the account. A $5 profit on a $100 position is 5%, whatever the account size. Profit factor, expectancy, and SQN are computed from profit by default, and use_returns=True computes them from returns instead, which removes the effect of position size.

Profit factor divides the total profit of winning trades by the total loss of losing trades. A value of 2 means the winners made twice what the losers lost. Expectancy is the average profit per trade in the account's currency, so an expectancy of 10 means an average gain of 10 per trade in the selected records. Read these alongside win rate to see whether the size of the winners makes up for the losers.

Measures that need information the records do not store, such as R-multiples, are a few lines on top of them. With a stop two ATRs away, a trade's profit divided by its initial risk gives its R-multiple:

Express trade results as multiples of the initial risk
stop_dist = 2 * data.run("atr", window=14).atr
pf = vbt.PF.from_signals(
    data,
    fast.vbt.crossed_above(slow),
    fast.vbt.crossed_below(slow),
    sl_stop=stop_dist,
    delta_format="absolute",
    fees=0.001,
)
trades = pf.trades.status_closed
entry_risk = stop_dist.values[trades.entry_idx.values, trades.col.values]
risk = entry_risk * trades.size.values
pd.Series(trades.pnl.values / risk).describe().round(2)
count    50.00
mean      3.19
std      14.88
min      -1.05
25%      -1.02
50%      -1.01
75%       1.05
max      77.89
dtype: float64

Most losers stop out close to one unit of risk, while a few winners return many times it, the typical profile of a trend-following system.

Compare long and short trades

You can run the same analysis on a subset of trades without rerunning the backtest. Here four trades give the long and short sides the same win rate, but very different results:

Compare both sides of a strategy from one simulation
side_close = pd.Series([100.0, 120.0, 100.0, 105.0, 100.0, 95.0, 100.0, 98.0], index=pd.date_range("2026-01-01", periods=8))
side_pf = vbt.PF.from_orders(side_close, size=[1, -1, -1, 1, 1, -1, -1, 1], init_cash=1000)
side_trades = side_pf.trades.status_closed
side_metrics = ["total_records", "win_rate", "profit_factor", "expectancy"]
pd.DataFrame({
    "Long": side_trades.direction_long.stats(side_metrics),
    "Short": side_trades.direction_short.stats(side_metrics),
}).round(2)
               Long Short
Total Records     2     2
Win Rate [%]   50.0  50.0
Profit Factor   4.0   0.4
Expectancy      7.5  -1.5

The long trades made 20 and lost 5, while the short trades lost 5 and made 2. Separating the sides shows where the profit came from. The filtered records still support the same trade plots and excursion analysis. For other ways to filter and group records, see Records and mapped arrays.

Excursions

Maximum adverse excursion (MAE) is the worst point a trade reached, and maximum favorable excursion (MFE) the best. Both are available in price units and as returns, with the bar where they occurred. Comparing them with the final result shows whether stops are too tight and whether exits leave profit behind. Expanding versions track both bar by bar from the entry, as the Expanding trade metrics highlight below shows, and the MAE and MFE highlight compares a portfolio with and without a stop.

Use these results to choose stop or target levels to test, then simulate those exits with Stop loss and take profit. Compare the new trade distribution and portfolio results to see what the change did to both winners and losers.

Entry quality

The edge ratio divides the average volatility-adjusted MFE by the average volatility-adjusted MAE. Above one, entries tend to move in the trade's favor more than against it. The running edge ratio computes it for every holding period, so you can see how many bars an entry needs to show its edge, and compare it with random entries, as in the Edge ratio highlight.

Trade plots

trades.plot() draws each trade from entry to exit, shaded by profit or loss. pf.plot_trade_signals() marks long and short entries and exits separately, and plot_pnl, plot_returns, plot_mae, and plot_mfe show trade-level results as scatter plots. The figures are regular Plotly figures, so hover labels can carry any extra information, such as indicator values at entry.

✅ Regular metrics like MAE and MFE only represent the final point of each trade. But what if you want to see how these metrics develop during the trade? You can now analyze expanding trade metrics as DataFrames!

Visualize the expanding MFE using projections
data = vbt.YFData.pull("BTC-USD")
pf = vbt.PF.from_random_signals(data, n=50, tp_stop=0.5, seed=42)
pf.trades.plot_expanding_mfe_returns().show()
Expanding maximum favorable excursion return projections for BTC-USD trades Figure data (JSON)

Trade signals

✅ New trade plotting method that separates entry and exit trades into long entries, long exits, short entries, and short exits. It supports different styles for positions.

Plot trade signals of a Bollinger Bands strategy
data = vbt.YFData.pull("BTC-USD")
bb = data.run("bbands")
long_entries = data.hlc3.vbt.crossed_above(bb.upper) & (bb.bandwidth < 0.1)
long_exits = data.hlc3.vbt.crossed_below(bb.upper) & (bb.bandwidth > 0.5)
short_entries = data.hlc3.vbt.crossed_below(bb.lower) & (bb.bandwidth < 0.1)
short_exits = data.hlc3.vbt.crossed_above(bb.lower) & (bb.bandwidth > 0.5)
pf = vbt.PF.from_signals(
    data,
    long_entries=long_entries,
    long_exits=long_exits,
    short_entries=short_entries,
    short_exits=short_exits
)
pf.plot_trade_signals().show()
BTC-USD OHLC with long and short Bollinger Bands strategy trade signals Figure data (JSON)

Edge ratio

✅ Edge ratio is a unique metric for quantifying entry profitability. Unlike most performance metrics, the edge ratio accounts for both open profits and losses. This can help you find better trade exits.

Compare the edge ratio of an EMA crossover to a random strategy
data = vbt.YFData.pull("BTC-USD")
fast_ema = data.run("ema", 10, hide_params=True)
slow_ema = data.run("ema", 20, hide_params=True)
entries = fast_ema.real_crossed_above(slow_ema)
exits = fast_ema.real_crossed_below(slow_ema)
pf = vbt.PF.from_signals(data, entries, exits, direction="both")
rand_pf = vbt.PF.from_random_signals(data, n=pf.orders.count() // 2, seed=42)  
fig = pf.trades.plot_running_edge_ratio(
    trace_kwargs=dict(line_color="limegreen", name="Edge Ratio (S)")
)
fig = rand_pf.trades.plot_running_edge_ratio(
    trace_kwargs=dict(line_color="mediumslateblue", name="Edge Ratio (R)"),
    fig=fig
)
fig.show()
Running edge ratios for EMA crossover and random BTC-USD strategies Figure data (JSON)

Trade history

✅ Trade history is a human-readable DataFrame listing orders, extended with useful details about entry trades, exit trades, and positions.

Get the trade history of a random portfolio with one signal
data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], missing_index="drop")
pf = vbt.PF.from_random_signals(
    data,
    n=1,
    seed=42,
    run_kwargs=dict(hide_params=True),
    tp_stop=0.5,
    sl_stop=0.1
)
pf.trade_history
   Order Id   Column              Signal Index            Creation Index  \
0         0  BTC-USD 2016-02-20 00:00:00+00:00 2016-02-20 00:00:00+00:00
1         1  BTC-USD 2016-02-20 00:00:00+00:00 2016-06-12 00:00:00+00:00
2         0  ETH-USD 2019-05-25 00:00:00+00:00 2019-05-25 00:00:00+00:00
3         1  ETH-USD 2019-05-25 00:00:00+00:00 2019-07-15 00:00:00+00:00

                 Fill Index  Side    Type Stop Type      Size       Price  \
0 2016-02-20 00:00:00+00:00   Buy  Market      None  0.228747  437.164001
1 2016-06-12 00:00:00+00:00  Sell  Market        TP  0.228747  655.746002
2 2019-05-25 00:00:00+00:00   Buy  Market      None  0.397204  251.759872
3 2019-07-15 00:00:00+00:00  Sell  Market        SL  0.397204  226.583885

   Fees   PnL  Return Direction  Status  Entry Trade Id  Exit Trade Id  \
0   0.0  50.0     0.5      Long  Closed               0             -1
1   0.0  50.0     0.5      Long  Closed              -1              0
2   0.0 -10.0    -0.1      Long  Closed               0             -1
3   0.0 -10.0    -0.1      Long  Closed              -1              0

   Position Id
0            0
1            0
2            0
3            0

MAE and MFE

✅ Maximum Adverse Excursion (MAE) helps you see the maximum loss taken during a trade, also known as the maximum drawdown of the position. Maximum Favorable Excursion (MFE) shows the highest profit reached during a trade. Analyzing MAE and MFE statistics can help you improve your exit strategies.

Analyze the MAE of a random portfolio without SL
data = vbt.YFData.pull("BTC-USD")
pf = vbt.PF.from_random_signals(data, n=50, seed=42)
pf.trades.plot_mae_returns().show()
Maximum adverse excursion versus return for BTC-USD trades without stop loss Figure data (JSON)
Analyze the MAE of a random portfolio with SL
pf = vbt.PF.from_random_signals(data, n=50, sl_stop=0.1, seed=42)
pf.trades.plot_mae_returns().show()
Maximum adverse excursion versus return for BTC-USD trades with stop loss Figure data (JSON)

Copyright © 2021–2026 Oleg Polakow. All rights reserved.

Site content and documentation are provided for using and evaluating VectorBT PRO and for educational purposes. Any other use, including building or supporting competing products or services, requires prior written consent.