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.
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.803366mae = 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: float64trades.plot_mae_returns(column="BTC-USD").show()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:
| Records | One record per | Use it for |
|---|---|---|
pf.entry_trades | Order that opens or adds to a position | Judging entries |
pf.exit_trades (pf.trades) | Order that reduces or closes a position | Judging exits |
pf.positions | Position from open to flat | Judging whole positions |
With a single entry and exit they are identical. With scaling in or out they differ:
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.5pf.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.5pf.positions.readable[cols] Entry Index Exit Index Size PnL
0 2026-01-01 2026-01-04 2.0 4.0Entry 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:
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: float64Most 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:
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.5The 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!
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()✅ 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.
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()✅ 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.
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()✅ Trade history is a human-readable DataFrame listing orders, extended with useful details about entry trades, exit trades, and positions.
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✅ 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.
data = vbt.YFData.pull("BTC-USD")
pf = vbt.PF.from_random_signals(data, n=50, seed=42)
pf.trades.plot_mae_returns().show()pf = vbt.PF.from_random_signals(data, n=50, sl_stop=0.1, seed=42)
pf.trades.plot_mae_returns().show()Related pages
- Performance and risk metricsCompute returns, Sharpe, Sortino, benchmark, and rolling metrics on any backtest
- Backtesting › Signal backtestingTurn entry and exit signals into long, short, reversing, and pyramided positions
- Backtesting › Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Backtesting › Stop loss and take profitBacktest stop losses, trailing stops, take profits, time stops, and exit ladders
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.