Trade Metrics
Compute granular trade-level performance statistics including win rate, profit factor, average winning and losing trade sizes, profit expectancy per trade, and maximum consecutive win and loss streak analysis for deeper strategy diagnostic insights.
Performance Metrics — Trade-Level Metrics
1. Dependency Installation
!pip install pandas numpy plotlyRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1) Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4) Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
2. Library Imports
import warnings
warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots3. Trade-Level Metric Definitions
Trade-level metrics evaluate the quality of individual completed trades rather than the aggregate equity curve. They answer the question: does this strategy have a genuine statistical edge on a per-trade basis?
| Metric | Formula | Interpretation |
|---|---|---|
| Win Rate | Winning Trades / Total Trades | Above 50% means more wins than losses. A strategy can be profitable with a win rate below 50% if the average win is larger than the average loss. |
| Average Win | Mean PnL of winning trades | Average profit on trades that closed positively. |
| Average Loss | Mean PnL of losing trades (negative) | Average loss on trades that closed negatively. |
| Reward:Risk Ratio | |Avg Win| / |Avg Loss| | Must exceed (1 − Win Rate) / Win Rate for positive expectancy. |
| Profit Factor | Gross Profit / Gross Loss | Above 1.0 means the strategy makes more than it loses in aggregate. Above 1.5 is considered solid. |
| Expectancy | Win Rate × Avg Win + Loss Rate × Avg Loss | Expected dollar PnL per trade. Positive expectancy is the fundamental requirement for a viable strategy. |
| Consecutive Losses | Max run of losing trades | The longest losing streak — used to size capital drawdown buffer. |
The minimum viability equation:
Expectancy > 0 ⟺ Win Rate × |Avg Win| > (1 − Win Rate) × |Avg Loss|
A strategy is viable if and only if the expected profit from winning trades exceeds the expected loss from losing trades.
4. Data Generation
This section details the generation of synthetic market data. This data facilitates the demonstration and evaluation of the trading strategy without reliance on external datasets. The generate_data function produces a DataFrame containing open, high, low, close prices, volume, and datetime for a specified number of periods, simulating minute-level candlestick data.
def generate_data(periods: int) -> pd.DataFrame:
start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
price_data = []; last_close = 42000
volatility_scale = 0.005; wick_scale = 0.002
for _ in range(periods):
open_price = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)
close_price = open_price + np.random.normal(0, last_close * volatility_scale)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
high_price = max(body_high + abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
low_price = min(body_low - abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
if high_price < low_price:
high_price, low_price = low_price, high_price
price_data.append({
"open": max(1, int(open_price)),
"high": max(1, int(high_price)),
"low": max(1, int(low_price)),
"close": max(1, int(close_price)),
})
last_close = close_price
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods)
df["datetime"] = df.index.to_series()
return df.reset_index(drop=True)
df = generate_data(500)
display(df.head())| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 42019 | 42051 | 41838 | 41913 | 316.799771 | 2024-01-01 00:00:00+00:00 |
| 1 | 41893 | 42079 | 41859 | 42073 | 100.563092 | 2024-01-01 00:01:00+00:00 |
| 2 | 42072 | 42127 | 41836 | 41893 | 413.048895 | 2024-01-01 00:02:00+00:00 |
| 3 | 41886 | 42262 | 41826 | 42180 | 190.237807 | 2024-01-01 00:03:00+00:00 |
| 4 | 42171 | 42394 | 42044 | 42322 | 117.994367 | 2024-01-01 00:04:00+00:00 |
5. Trade Extraction Function
This section outlines the logic for extracting individual trades from the generated price data. The strategy employed is a Moving Average (MA) crossover, a common technical analysis indicator:
- Signal Generation: A short-period (fast) Moving Average (MA) is calculated alongside a long-period (slow) MA.
- Entry Condition: A long trade signal (
1) is generated when the fast MA crosses above the slow MA. - Exit Condition: A long trade is exited (
-1) when the fast MA crosses below the slow MA. - Trade Completion: Only completed round-trip trades (entry followed by an exit) are recorded. Open positions at the end of the data series are excluded from analysis.
Transaction fees are applied to both entry and exit points to reflect real-world trading costs.
def extract_trades(
df: pd.DataFrame,
fast_window: int = 10,
slow_window: int = 30,
fee_pct: float = 0.0005,
) -> pd.DataFrame:
"""
Simulate MA crossover entries and exits and return a trade-level
DataFrame with one row per completed round-trip trade.
"""
df = df.copy().sort_values("datetime", ignore_index=True)
df["fast_ma"] = df["close"].rolling(fast_window).mean()
df["slow_ma"] = df["close"].rolling(slow_window).mean()
df["signal"] = np.where(df["fast_ma"] > df["slow_ma"], 1, 0)
df["trade"] = df["signal"].diff()
trades = []
entry_price = None
entry_dt = None
entry_bar = None
for i, row in df.iterrows():
if row["trade"] == 1:
entry_price = row["close"]
entry_dt = row["datetime"]
entry_bar = i
elif row["trade"] == -1 and entry_price is not None:
exit_price = row["close"]
gross_pnl = (exit_price - entry_price) / entry_price * 100
net_pnl = gross_pnl - 2 * fee_pct * 100 # Round-trip fee
hold_bars = i - entry_bar
trades.append({
"entry_datetime": entry_dt,
"exit_datetime": row["datetime"],
"entry_price": entry_price,
"exit_price": exit_price,
"gross_pnl_pct": round(gross_pnl, 4),
"net_pnl_pct": round(net_pnl, 4),
"holding_bars": hold_bars,
"win": net_pnl > 0,
})
entry_price = None
return pd.DataFrame(trades)
trades = extract_trades(df, fast_window=10, slow_window=30, fee_pct=0.0005)
print(f"Total Completed Trades: {len(trades)}")
display(trades.head(10))
Total Completed Trades: 13
| entry_datetime | exit_datetime | entry_price | exit_price | gross_pnl_pct | net_pnl_pct | holding_bars | win | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2024-01-01 00:29:00+00:00 | 2024-01-01 00:32:00+00:00 | 43027 | 42616 | -0.9552 | -1.0552 | 3 | False |
| 1 | 2024-01-01 01:14:00+00:00 | 2024-01-01 01:30:00+00:00 | 42320 | 41080 | -2.9301 | -3.0301 | 16 | False |
| 2 | 2024-01-01 01:51:00+00:00 | 2024-01-01 01:58:00+00:00 | 41245 | 40515 | -1.7699 | -1.8699 | 7 | False |
| 3 | 2024-01-01 02:12:00+00:00 | 2024-01-01 02:30:00+00:00 | 41597 | 40901 | -1.6732 | -1.7732 | 18 | False |
| 4 | 2024-01-01 02:36:00+00:00 | 2024-01-01 03:12:00+00:00 | 42220 | 42878 | 1.5585 | 1.4585 | 36 | True |
| 5 | 2024-01-01 03:50:00+00:00 | 2024-01-01 04:13:00+00:00 | 42599 | 42390 | -0.4906 | -0.5906 | 23 | False |
| 6 | 2024-01-01 04:17:00+00:00 | 2024-01-01 04:29:00+00:00 | 43061 | 42632 | -0.9963 | -1.0963 | 12 | False |
| 7 | 2024-01-01 04:42:00+00:00 | 2024-01-01 05:29:00+00:00 | 42733 | 43917 | 2.7707 | 2.6707 | 47 | True |
| 8 | 2024-01-01 05:31:00+00:00 | 2024-01-01 05:42:00+00:00 | 44474 | 43340 | -2.5498 | -2.6498 | 11 | False |
| 9 | 2024-01-01 06:04:00+00:00 | 2024-01-01 06:26:00+00:00 | 44218 | 43482 | -1.6645 | -1.7645 | 22 | False |
Explanation:
- A trade event is recorded on every
signal.diff() == 1(entry) andsignal.diff() == -1(exit). This ensures only completed round-trip trades appear in the trade log — open positions at the end of the series are excluded. 2 × fee_pct × 100: The round-trip fee is the sum of entry and exit fees. Expressed in percentage points to matchgross_pnl_pct.holding_bars: The number of bars between entry and exit — a proxy for trade duration at the candle frequency.
6. Trade Metrics Function
This section defines the compute_trade_metrics function, which calculates a comprehensive set of performance metrics for the extracted trades. These metrics quantify various aspects of the trading strategy's effectiveness, including profitability, risk, and consistency. The function processes a DataFrame of completed trades and returns a dictionary containing key performance indicators.
def compute_trade_metrics(trades: pd.DataFrame) -> dict:
"""
Compute comprehensive trade-level performance metrics from
a completed trade log.
"""
if len(trades) == 0:
return {"error": "No completed trades."}
wins = trades[trades["win"]]
losses = trades[~trades["win"]]
gross_profit = wins["net_pnl_pct"].sum()
gross_loss = losses["net_pnl_pct"].abs().sum()
profit_factor= gross_profit / gross_loss if gross_loss > 0 else np.inf
rr_ratio = abs(wins["net_pnl_pct"].mean() / losses["net_pnl_pct"].mean()) if len(losses) > 0 else np.inf
win_rate = len(wins) / len(trades)
expectancy = trades["net_pnl_pct"].mean()
# Consecutive losses
run = 0; max_run = 0
for w in trades["win"]:
if not w:
run += 1; max_run = max(max_run, run)
else:
run = 0
return {
"total_trades": len(trades),
"winning_trades": len(wins),
"losing_trades": len(losses),
"win_rate_pct": round(win_rate * 100, 2),
"avg_win_pct": round(wins["net_pnl_pct"].mean(), 4) if len(wins) > 0 else 0,
"avg_loss_pct": round(losses["net_pnl_pct"].mean(), 4) if len(losses) > 0 else 0,
"reward_risk_ratio": round(rr_ratio, 4),
"profit_factor": round(profit_factor, 4),
"expectancy_pct": round(expectancy, 4),
"max_consecutive_loss":max_run,
"total_gross_profit": round(gross_profit, 4),
"total_gross_loss": round(gross_loss, 4),
"avg_holding_bars": round(trades["holding_bars"].mean(), 1),
"max_holding_bars": int(trades["holding_bars"].max()),
"min_holding_bars": int(trades["holding_bars"].min()),
}
metrics = compute_trade_metrics(trades)
print("--- Trade-Level Metrics ---")
for k, v in metrics.items():
print(f" {k:<28}: {v}")--- Trade-Level Metrics --- total_trades : 13 winning_trades : 3 losing_trades : 10 win_rate_pct : 23.08 avg_win_pct : 1.6382 avg_loss_pct : -1.7375 reward_risk_ratio : 0.9428 profit_factor : 0.2829 expectancy_pct : -0.9585 max_consecutive_loss : 4 total_gross_profit : 4.9145 total_gross_loss : 17.3748 avg_holding_bars : 19.2 max_holding_bars : 47 min_holding_bars : 3
7. Visualization
This section provides a visual analysis of the trading strategy's performance, utilizing plotly to generate interactive charts. The visualizations offer insights into individual trade outcomes, profit and loss distribution, cumulative strategy performance, and trade holding durations. This graphical representation complements the quantitative metrics by highlighting patterns and trends in the strategy's behavior.
fig = make_subplots(
rows=2, cols=2,
subplot_titles=[
"Per-Trade Net PnL (%)",
"PnL Distribution",
"Cumulative PnL (%)",
"Holding Duration (bars)",
],
)
colors = ["green" if w else "red" for w in trades["win"]]
# Per-trade bar chart
fig.add_trace(go.Bar(
x=list(range(len(trades))),
y=trades["net_pnl_pct"],
marker_color=colors,
name="Net PnL (%)"), row=1, col=1)
# PnL histogram
fig.add_trace(go.Histogram(
x=trades["net_pnl_pct"],
nbinsx=30,
marker_color="steelblue",
name="PnL Distribution"), row=1, col=2)
fig.add_vline(x=0, line_dash="dash", line_color="red", row=1, col=2)
fig.add_vline(x=metrics["expectancy_pct"], line_dash="dash", line_color="green",
annotation_text=f"Expectancy: {metrics['expectancy_pct']:.4f}%",
row=1, col=2)
# Cumulative PnL
fig.add_trace(go.Scatter(
x=list(range(len(trades))),
y=trades["net_pnl_pct"].cumsum(),
mode="lines",
line=dict(color="green", width=2),
name="Cumulative PnL (%)"), row=2, col=1)
fig.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1)
# Holding duration
fig.add_trace(go.Bar(
x=list(range(len(trades))),
y=trades["holding_bars"],
marker_color="steelblue",
name="Holding Bars"), row=2, col=2)
fig.update_layout(
title_text="Trade-Level Performance Analysis",
height=700,
showlegend=False,
)
fig.show()Conclusion
This notebook demonstrates how to generate synthetic market data, extract trades based on a Moving Average crossover strategy, compute key trade-level performance metrics, and visualize the results. The trade-level metrics provide a detailed understanding of the strategy's effectiveness on a per-trade basis, highlighting profitability, risk, and consistency. The visualizations offer an intuitive way to interpret these metrics and identify potential areas for improvement in the trading strategy.