Performance·Performance Metrics·Intermediate

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.

performanceperformance-metrics

Performance Metrics — Trade-Level Metrics


1. Dependency Installation

[ ]
!pip install pandas numpy plotly
Requirement 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_subplots

3. 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?

MetricFormulaInterpretation
Win RateWinning Trades / Total TradesAbove 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 WinMean PnL of winning tradesAverage profit on trades that closed positively.
Average LossMean 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 FactorGross Profit / Gross LossAbove 1.0 means the strategy makes more than it loses in aggregate. Above 1.5 is considered solid.
ExpectancyWin Rate × Avg Win + Loss Rate × Avg LossExpected dollar PnL per trade. Positive expectancy is the fundamental requirement for a viable strategy.
Consecutive LossesMax run of losing tradesThe 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:

  1. Signal Generation: A short-period (fast) Moving Average (MA) is calculated alongside a long-period (slow) MA.
  2. Entry Condition: A long trade signal (1) is generated when the fast MA crosses above the slow MA.
  3. Exit Condition: A long trade is exited (-1) when the fast MA crosses below the slow MA.
  4. 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) and signal.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 match gross_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.