The Foundations of Trading Expectancy: Edge Beyond Nominal Win Rate
In algorithmic strategy development and quantitative backtesting, researchers routinely encounter a deceptive psychological trap: over-optimizing for nominal win rate. A strategy advertising an 80% win rate intuitively appears robust, yet it can rapidly destroy an account if its average loss is ten times larger than its average gain. Conversely, classical trend-following algorithms frequently operate with win rates hovering between 30% and 40%, yet they compound wealth over decades because their occasional winning trades eclipse their clustered small losses.
To determine whether an automated trading model possesses a genuine statistical edge, quantitative researchers rely on trading expectancy.
Trading expectancy quantifies the expected payoff—either in currency units or dimensionless risk multiples—generated per discrete execution unit over a statistically meaningful sample of closed trades. It answers the fundamental question of systematic trading: If this algorithm executes the next 1,000 trades under identical structural conditions, how much value does each trade capture on average after accounting for execution friction?
flowchart TD
subgraph ExecutionStream ["Raw Order Execution Stream"]
Fills["Order Fills (Entries, Scales, Exits)"]
Fees["Commissions & Exchange Fees"]
Stops["Initial Planned Risk (1R Anchor)"]
end
subgraph LedgerConsolidation ["Ledger Parsing & Trade Taxonomy"]
Campaigns["Consolidated Position Campaigns"]
VWAP["VWAP Entry & Exit Matching"]
Partition["Partition: Wins, Losses, Breakevens"]
end
subgraph ExpectancyEngine ["Statistical Expectancy Engine"]
CurrExp["Dollar Expectancy ($/trade)\nE = (P_win * W_bar) - (P_loss * L_bar)"]
RExp["R-Multiple Expectancy (R/trade)\nE_R = (P_win * R_win) - (P_loss * R_loss)"]
Significance["Wilson CI & Bootstrap Sampling"]
end
subgraph DecisionBoundary ["Validation & Robustness"]
FrictionDrag["Friction Degradation Stress Test"]
Deploy["Statistical Alpha Acceptance"]
Reject["Rejection: Fragile or Negative Edge"]
end
ExecutionStream --> LedgerConsolidation
LedgerConsolidation --> ExpectancyEngine
ExpectancyEngine --> FrictionDrag
FrictionDrag -->|Expectancy > Friction Hurdle| Deploy
FrictionDrag -->|Expectancy <= 0 under Drag| RejectWithout an explicit formulation of trading expectancy, headline backtest metrics like net profit or gross returns can mislead developers. A high net profit might be the artifact of an unrepeatable tail-event outlier, while an apparently modest system may possess a steady, positive expected payoff that scales predictably when sized appropriately.
Mathematical Formulation: Currency Payoff vs. Dimensionless R-Multiples
Depending on how capital is allocated and how strategy performance is benchmarked, quantitative researchers express trading expectancy using two distinct mathematical representations: currency expectancy and dimensionless R-multiple expectancy.
1. Absolute Currency Expectancy ($/trade)
Let a backtest ledger contain N closed round-trip trades. Each trade i produces a net realized profit or loss X_i in account base currency (e.g., USD), inclusive of commissions, borrowing fees, and slippage.
We partition the trade sample into three mutually exclusive subsets:
- Winning trades (
W): All trades where net realized profit satisfiesX_i > 0, with countN_win = |W|. - Losing trades (
L): All trades where net realized profit satisfiesX_i < 0, with countN_loss = |L|. - Breakeven trades (
B): All trades where net realized profit satisfiesX_i = 0, with countN_be = |B|.
The total number of closed trades is:
N = N_win + N_loss + N_beThe sample probabilities for each trade outcome are defined as:
P_win = N_win / N
P_loss = N_loss / N
P_be = N_be / NThe average winning trade payoff (\bar{W}) and the average losing trade penalty (|\bar{L}|) are:
\bar{W} = \frac{1}{N_win} \sum_{i \in W} X_i
|\bar{L}| = \frac{1}{N_loss} \sum_{i \in L} |X_i| = \frac{1}{N_loss} \left| \sum_{i \in L} X_i \right|The currency trading expectancy E represents the expected net profit per executed trade:
E = (P_win * \bar{W}) - (P_loss * |\bar{L}|)Because breakeven trades contribute exactly zero to net PnL (X_i = 0), substituting the outcome probabilities and averages reveals the mathematical identity:
E = \left(\frac{N_win}{N} \cdot \frac{\sum_{i \in W} X_i}{N_win}\right) - \left(\frac{N_loss}{N} \cdot \frac{\sum_{i \in L} |X_i|}{N_loss}\right)
E = \frac{\sum_{i \in W} X_i - \sum_{i \in L} |X_i|}{N} = \frac{1}{N} \sum_{i=1}^N X_i = \bar{X}The expected payoff calculated via probability weighting is identical to the empirical sample mean of all closed trade net profits. Consequently, total cumulative net strategy profit over N trades is simply:
\text{Net Strategy Profit} = N \cdot ELimitations of Currency Expectancy
While intuitive, absolute currency expectancy has structural limitations in systematic research:
- Capital Scale Bias: As an account grows or position sizes compound, late-stage trades carry significantly larger nominal dollar gains and losses than early-stage trades. A fixed dollar expectancy fails to represent stationary performance across compounding equity curves.
- Cross-Asset Incommensurability: Comparing an expectancy of +$120/trade on a high-notional futures contract against +$15/trade on a mid-cap equity tells you nothing about which underlying algorithmic model possesses superior risk-adjusted alpha.
2. Dimensionless R-Multiple Expectancy (E_R)
To eliminate scale dependence and volatility drift, quantitative finance adopts the R-multiple framework popularized by Dr. Van K. Tharp.
Every systematic entry requires a predefined initial stop-loss price representing the model's planned invalidation point. The distance between the average execution fill price P_entry and the initial protective stop P_stop, multiplied by the trade volume Q, defines the initial dollar risk of the setup, known as 1R:
\text{Nominal 1R Risk} = Q \cdot |P_entry - P_stop|For every completed trade i, the realized outcome is normalized by its initial planned dollar risk:
R_i = \frac{X_i}{\text{Nominal 1R Risk}_i}If an algorithm risks $500 on an invalidation stop and exits the trade with a net profit of $1,250, the realized return is +2.50R. If the stop is executed cleanly, the return is -1.00R. If overnight gap risk or slippage triggers an exit beyond the stop, the trade prints an adverse outcome such as -1.35R.
The R-multiple trading expectancy E_R is defined as:
E_R = (P_win * \bar{R}_win) - (P_loss * |\bar{R}_loss|) = \frac{1}{N} \sum_{i=1}^N R_iWhere:
\bar{R}_win = \frac{1}{N_win} \sum_{i \in W} R_i(average winning trade in R units)|\bar{R}_loss| = \frac{1}{N_loss} \sum_{i \in L} |R_i|(average losing trade in absolute R units)
| Expectancy Benchmark (E_R) | Quantitative System Characterization |
| :--- | :--- |
| < 0.00R | Capital-destroying model; sub-random edge after friction. |
| 0.00R - 0.15R | Marginal alpha; easily erased by execution latency or fee hikes. |
| 0.15R - 0.35R | Solid systematic edge; common for high-capacity intraday systems. |
| 0.35R - 0.65R | Robust quantitative alpha; benchmark for multi-day swing/trend models. |
| > 0.65R | Exceptional edge; requires validation against curve-fitting or survivorship bias. |A strategy exhibiting E_R = +0.40R reliably generates 40% of its initial nominal risk on every trade on average, establishing an invariant metric that holds across diverse asset prices and capital sizes. For deeper analysis of planned risk versus realized outcomes, see our quantitative breakdown of Risk-Reward Ratio in Backtesting.
Dimensional Distinction: Profit Factor vs Expectancy vs Win Rate
Algorithmic trading literature often treats win rate, profit factor, and expectancy interchangeably when describing strategy viability. However, from a dimensional and statistical perspective, each metric serves a distinct mathematical role.
Understanding profit factor vs expectancy requires dissecting their mathematical dimensions:
- Win Rate (
P_win): A dimensionless probability bounded within[0, 1]. It tracks event frequency while completely disregarding outcome magnitude. - Profit Factor (
PF): A dimensionless ratio of cumulative gross gains to cumulative gross losses bounded within[0, \infty). It reflects capital recovery efficiency per dollar surrendered to the market. For an exhaustive exploration of its formula and edge cases, refer to Profit Factor in Backtesting. - Trading Expectancy (
EorE_R): An expected value with dimensions ($/tradeorR/trade), bounded within(-\infty, \infty). It measures the mean edge extracted per discrete execution event.
| Analytical Dimension | Win Rate (P_win) | Profit Factor (PF) | Trading Expectancy (E_R) |
|---|---|---|---|
| Mathematical Unit | Dimensionless probability (%) | Dimensionless scalar ratio | Risk-normalized units (R/trade) or currency ($/trade) |
| Theoretical Range | [0.0, 1.0] (or 0% - 100%) | [0.0, \infty) | (-\infty, +\infty) |
| Threshold of Viability | Context-dependent (e.g., 30% to 75%) | > 1.00 | > 0.00R (or > $0.00) |
| Outcome Sizing Sensitivity | Zero (ignores dollar magnitude) | High (sensitive to total gross sums) | High (weighs probability by payoff magnitude) |
| Capital Compounding Link | Indirect (must combine with payoff ratio) | Static capital efficiency | Direct driver of geometric Kelly growth |
| Sample Size Sensitivity | Moderate (converges via binomial CLT) | High (skewed by single windfall outliers) | High (requires bootstrap confidence intervals) |
| Primary Failure Mode | "High win rate" blowups from tail losses | Masking dormant trade frequency | Scale distortion if measured in unnormalized dollars |
Analytical Decomposition: Linking Profit Factor to Expectancy
The mathematical relationship connecting Profit Factor (PF), Payoff Ratio (K = \bar{W} / |\bar{L}|), and Win Rate (p = P_win) illuminates why a system with an identical profit factor can yield radically different trade expectancies.
Excluding breakeven trades for algebraic clarity (P_loss = 1 - p):
PF = \frac{\sum Wins}{\sum Losses} = \frac{N_win \cdot \bar{W}}{N_loss \cdot |\bar{L}|} = \left(\frac{p}{1 - p}\right) \cdot \left(\frac{\bar{W}}{|\bar{L}|}\right) = \left(\frac{p}{1 - p}\right) \cdot KNow consider Trading Expectancy normalized by average loss |\bar{L}|:
\frac{E}{|\bar{L}|} = p \cdot \left(\frac{\bar{W}}{|\bar{L}|}\right) - (1 - p) = p \cdot K - (1 - p)Rearranging the Profit Factor formula yields p \cdot K = PF \cdot (1 - p). Substituting this back into the expectancy equation:
\frac{E}{|\bar{L}|} = PF \cdot (1 - p) - (1 - p) = (PF - 1) \cdot (1 - p)This derivation reveals two profound quantitative insights:
- Whenever
PF > 1.0, expectancyEis strictly positive. WheneverPF < 1.0, expectancyEis strictly negative. WhenPF = 1.0, expectancy is exactly zero. - For a fixed Profit Factor (e.g.,
PF = 1.60), the normalized expectancy per trade increases as the win rate decreases.- Strategy A: Win Rate
p = 0.80,PF = 1.60. Expectancy:(1.60 - 1) \cdot (1 - 0.80) = +0.12 |\bar{L}|per trade. - Strategy B: Win Rate
p = 0.30,PF = 1.60. Expectancy:(1.60 - 1) \cdot (1 - 0.30) = +0.42 |\bar{L}|per trade.
- Strategy A: Win Rate
Strategy B extracts 3.5 times more edge per unit of risk than Strategy A on each execution, despite having a win rate of only 30%. Strategy A must execute nearly four times as many trades to generate comparable risk-adjusted capital growth, exposing its ledger to four times as much fee drag and slippage friction.
Trade Counting Taxonomy: Decomposing Ledgers into Execution Units
In theoretical textbooks, a "trade" is an idealized atomic entity: enter 100 shares at price A, exit 100 shares at price B. In production backtesting engines and real-world execution ledgers, positions evolve through multiple order fills, scale-ins (pyramiding), partial scale-outs (tranche exits), trailing stops, and breakeven events.
Calculating an accurate trading expectancy requires a standardized trade counting taxonomy. Ambiguity in how order fills are aggregated directly corrupts the sample size N, warping win rates and distorting expectancy.
flowchart LR
subgraph RawOrders ["Fragmented Order Ledger"]
O1["Order 1: BUY 200 @ $100 (Entry)"]
O2["Order 2: SELL 100 @ $105 (Tranche 1 Exit)"]
O3["Order 3: SELL 100 @ $110 (Tranche 2 Exit)"]
end
subgraph NaiveSplitting ["Naive Split Counting (Flawed)"]
T1["Trade A: 100 sh @ $100 -> $105 (WIN)"]
T2["Trade B: 100 sh @ $100 -> $110 (WIN)"]
Flaw["Result: N=2, Win Rate 100%\nArtificially Deflates Trade Variance"]
end
subgraph CampaignMatching ["Position Campaign Consolidation (Rigorous)"]
C1["Consolidated Campaign: 200 sh"]
VWAP_Entry["Avg Entry: $100.00"]
VWAP_Exit["VWAP Exit: $107.50"]
Stat["Result: N=1, Realized Net PnL +$1,500\nPreserves Statistical Independence"]
end
O1 --> NaiveSplitting
O2 --> NaiveSplitting
O3 --> NaiveSplitting
NaiveSplitting --> Flaw
O1 --> CampaignMatching
O2 --> CampaignMatching
O3 --> CampaignMatching
CampaignMatching --> VWAP_Entry & VWAP_Exit
VWAP_Entry & VWAP_Exit --> Stat1. Scale-Ins and Partial Scale-Outs: The Campaign Approach
Consider an algorithm that enters a position of 200 shares at $100. It scales out of 100 shares at $105 (first profit target), and subsequently exits the remaining 100 shares at $110 (second target).
- The Naive Method (Fill-Level Splitting): Treating this sequence as two separate trades records two distinct winning trades (
N = 2, Win Rate = 100%). If the remaining 100 shares had been stopped out at $95 instead, the ledger would record 1 win and 1 loss. This approach artificially inflates the sample sizeNand violates the assumption of statistical independence between trades, because both exit decisions originated from the same entry catalyst. - The Quantitative Standard (Campaign Matching): All fills belonging to the same position lifecycle—from flat (zero position) back to flat—are consolidated into a single Round-Trip Campaign.
The campaign's volume-weighted average entry price (\text{VWAP}_entry) and exit price (\text{VWAP}_exit) are computed across all fill tranches:
\text{VWAP}_entry = \frac{\sum_{j} Q_{entry, j} \cdot P_{entry, j}}{\sum_{j} Q_{entry, j}}
\text{VWAP}_exit = \frac{\sum_{k} Q_{exit, k} \cdot P_{exit, k}}{\sum_{k} Q_{exit, k}}Net profit, cumulative execution commissions, and realized R-multiples are aggregated across the entire campaign, ensuring N reflects genuine independent market decisions.
2. Handling Breakeven Trades (PnL \approx 0)
When a position is closed at or near the entry price, minor price fluctuations or broker ticket fees determine whether the trade prints a trivial positive or negative dollar balance. How should these trades be categorized in an expectancy calculator?
Three standard methodologies exist in quantitative practice:
- The Three-State Multinomial Model: Explicitly classify trades within a small tolerance band (e.g.,
|X_i| \le \$1.00or|R_i| \le 0.05R) asBREAKEVEN. Under this taxonomy,P_win + P_loss + P_be = 1.0. The denominator for win rate remains total closed campaignsN. - The Friction-Adjusted Binary Model: Any trade whose net realized PnL after commissions and slippage is non-positive (
X_i \le 0) is classified as a loss. This conservative approach reflects portfolio reality: capital that does not grow is actively drained by opportunity cost and broker overhead. - The Empirical Mean Identity: Regardless of how trades are binned into win/loss buckets, researchers must ensure their calculation does not drop breakeven trades from the denominator. Omitting breakevens (
N' = N_win + N_loss) artificially inflates bothP_winandP_loss, breaking the foundational equalityE = \bar{X}.
Building a Trading Expectancy Calculator in Python
To transition from theoretical algebra to operational backtest auditing, let us construct a production-grade, zero-external-dependency (using standard Python libraries alongside NumPy and Pandas) trading expectancy calculator.
The script below performs four critical quantitative functions:
- Parses an execution stream of raw order fills, consolidating multi-stage scale-ins and scale-outs into unified round-trip campaigns.
- Computes both Currency Expectancy (
$) and dimensionless R-Multiple Expectancy (R), alongside Profit Factor, Win Rate, and Payoff Ratio. - Constructs a 95% Wilson Score confidence interval for the win rate and performs non-parametric bootstrap resampling on trade PnLs to establish confidence intervals for expectancy.
- Executes a friction degradation stress test, modeling the exact point where execution slippage erodes strategy edge into negative expectancy.
import math
from dataclasses import dataclass
from typing import Dict, List, Tuple
import numpy as np
import pandas as pd
# Set random seed for deterministic verification
np.random.seed(42)
def generate_sample_ledger() -> pd.DataFrame:
"""
Creates a sample execution ledger containing single entries,
multi-stage scale outs, stop-outs, and breakeven trades.
"""
records = [
# Trade 1: Clean winner (Single Entry, Single Exit)
{"trade_id": "T001", "timestamp": "2026-01-05 09:30", "side": "BUY", "qty": 100, "price": 150.00, "fee": 1.00, "stop_price": 147.00, "action": "ENTRY"},
{"trade_id": "T001", "timestamp": "2026-01-05 14:15", "side": "SELL", "qty": 100, "price": 156.00, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 2: Clean loser (Hit stop loss with small slip)
{"trade_id": "T002", "timestamp": "2026-01-06 10:00", "side": "BUY", "qty": 100, "price": 155.00, "fee": 1.00, "stop_price": 152.00, "action": "ENTRY"},
{"trade_id": "T002", "timestamp": "2026-01-06 11:30", "side": "SELL", "qty": 100, "price": 151.80, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 3: Scale-out winner (50% at target 1, 50% at target 2)
{"trade_id": "T003", "timestamp": "2026-01-07 09:45", "side": "BUY", "qty": 200, "price": 160.00, "fee": 2.00, "stop_price": 156.00, "action": "ENTRY"},
{"trade_id": "T003", "timestamp": "2026-01-07 13:00", "side": "SELL", "qty": 100, "price": 165.00, "fee": 1.00, "stop_price": None, "action": "SCALE_OUT"},
{"trade_id": "T003", "timestamp": "2026-01-08 10:30", "side": "SELL", "qty": 100, "price": 168.00, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 4: Breakeven trade (Stopped out near entry after fees)
{"trade_id": "T004", "timestamp": "2026-01-09 10:15", "side": "BUY", "qty": 100, "price": 162.00, "fee": 1.00, "stop_price": 159.00, "action": "ENTRY"},
{"trade_id": "T004", "timestamp": "2026-01-09 15:00", "side": "SELL", "qty": 100, "price": 162.02, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 5: Loser with gap / slippage past stop
{"trade_id": "T005", "timestamp": "2026-01-12 09:30", "side": "BUY", "qty": 150, "price": 170.00, "fee": 1.50, "stop_price": 166.00, "action": "ENTRY"},
{"trade_id": "T005", "timestamp": "2026-01-12 12:45", "side": "SELL", "qty": 150, "price": 165.20, "fee": 1.50, "stop_price": None, "action": "EXIT"},
# Trade 6: Scale-out with runner stopped at breakeven
{"trade_id": "T006", "timestamp": "2026-01-13 11:00", "side": "BUY", "qty": 200, "price": 164.00, "fee": 2.00, "stop_price": 161.00, "action": "ENTRY"},
{"trade_id": "T006", "timestamp": "2026-01-13 14:00", "side": "SELL", "qty": 100, "price": 168.50, "fee": 1.00, "stop_price": None, "action": "SCALE_OUT"},
{"trade_id": "T006", "timestamp": "2026-01-14 09:45", "side": "SELL", "qty": 100, "price": 164.00, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 7: Small winner
{"trade_id": "T007", "timestamp": "2026-01-15 10:30", "side": "BUY", "qty": 100, "price": 166.00, "fee": 1.00, "stop_price": 163.00, "action": "ENTRY"},
{"trade_id": "T007", "timestamp": "2026-01-15 15:30", "side": "SELL", "qty": 100, "price": 169.20, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 8: Standard loser
{"trade_id": "T008", "timestamp": "2026-01-16 09:35", "side": "BUY", "qty": 100, "price": 168.00, "fee": 1.00, "stop_price": 165.00, "action": "ENTRY"},
{"trade_id": "T008", "timestamp": "2026-01-16 10:45", "side": "SELL", "qty": 100, "price": 164.90, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 9: Large trend winner
{"trade_id": "T009", "timestamp": "2026-01-20 09:40", "side": "BUY", "qty": 100, "price": 165.00, "fee": 1.00, "stop_price": 161.00, "action": "ENTRY"},
{"trade_id": "T009", "timestamp": "2026-01-22 15:45", "side": "SELL", "qty": 100, "price": 178.00, "fee": 1.00, "stop_price": None, "action": "EXIT"},
# Trade 10: Standard loser
{"trade_id": "T010", "timestamp": "2026-01-23 10:15", "side": "BUY", "qty": 100, "price": 177.00, "fee": 1.00, "stop_price": 174.00, "action": "ENTRY"},
{"trade_id": "T010", "timestamp": "2026-01-23 11:30", "side": "SELL", "qty": 100, "price": 173.80, "fee": 1.00, "stop_price": None, "action": "EXIT"},
]
return pd.DataFrame(records)
@dataclass
class RoundTripTrade:
trade_id: str
entry_time: str
exit_time: str
side: str
total_qty: float
avg_entry_price: float
avg_exit_price: float
initial_stop_price: float
initial_risk_dollar: float # Nominal 1R risk baseline
gross_pnl: float
fees: float
net_pnl: float
realized_r: float
outcome: str # 'WIN', 'LOSS', or 'BREAKEVEN'
class LedgerParser:
"""Parses order execution streams into consolidated round-trip trades."""
@staticmethod
def parse_ledger(df_ledger: pd.DataFrame, be_threshold_dollars: float = 1.0) -> List[RoundTripTrade]:
trades: List[RoundTripTrade] = []
grouped = df_ledger.groupby("trade_id", sort=False)
for trade_id, group in grouped:
entries = group[group["action"].isin(["ENTRY"])]
exits = group[group["action"].isin(["SCALE_OUT", "EXIT"])]
entry_qty = entries["qty"].sum()
exit_qty = exits["qty"].sum()
if entry_qty != exit_qty:
raise ValueError(f"Unbalanced trade {trade_id}: entry qty {entry_qty} != exit qty {exit_qty}")
side = entries["side"].iloc[0]
avg_entry_price = (entries["qty"] * entries["price"]).sum() / entry_qty
avg_exit_price = (exits["qty"] * exits["price"]).sum() / exit_qty
total_fees = group["fee"].sum()
# Realized gross PnL calculation
if side == "BUY":
gross_pnl = (avg_exit_price - avg_entry_price) * entry_qty
else:
gross_pnl = (avg_entry_price - avg_exit_price) * entry_qty
net_pnl = gross_pnl - total_fees
# Initial 1R Risk in currency
initial_stop = entries["stop_price"].iloc[0]
if initial_stop is not None and not math.isnan(initial_stop):
unit_risk = abs(avg_entry_price - initial_stop)
initial_risk_dollar = unit_risk * entry_qty
realized_r = net_pnl / initial_risk_dollar if initial_risk_dollar > 0 else 0.0
else:
initial_risk_dollar = np.nan
realized_r = np.nan
# Three-state outcome classification
if abs(net_pnl) <= be_threshold_dollars:
outcome = "BREAKEVEN"
elif net_pnl > be_threshold_dollars:
outcome = "WIN"
else:
outcome = "LOSS"
trades.append(RoundTripTrade(
trade_id=trade_id,
entry_time=entries["timestamp"].iloc[0],
exit_time=exits["timestamp"].iloc[-1],
side=side,
total_qty=entry_qty,
avg_entry_price=avg_entry_price,
avg_exit_price=avg_exit_price,
initial_stop_price=initial_stop,
initial_risk_dollar=initial_risk_dollar,
gross_pnl=gross_pnl,
fees=total_fees,
net_pnl=net_pnl,
realized_r=realized_r,
outcome=outcome
))
return trades
class ExpectancyCalculator:
"""Calculates comprehensive trading expectancy and statistical intervals."""
def __init__(self, trades: List[RoundTripTrade]):
self.trades = trades
self.df = pd.DataFrame([t.__dict__ for t in self.trades])
def calculate_metrics(self) -> Dict[str, float]:
df = self.df
n_total = len(df)
if n_total == 0:
return {}
wins = df[df["outcome"] == "WIN"]
losses = df[df["outcome"] == "LOSS"]
breakevens = df[df["outcome"] == "BREAKEVEN"]
n_win = len(wins)
n_loss = len(losses)
n_be = len(breakevens)
p_win = n_win / n_total
p_loss = n_loss / n_total
p_be = n_be / n_total
gross_profit = wins["net_pnl"].sum()
gross_loss = abs(losses["net_pnl"].sum())
avg_win = wins["net_pnl"].mean() if n_win > 0 else 0.0
avg_loss = abs(losses["net_pnl"].mean()) if n_loss > 0 else 0.0
payoff_ratio = avg_win / avg_loss if avg_loss > 0 else np.nan
# Currency Expectancy Formula: E = (P_win * avg_win) - (P_loss * avg_loss)
expectancy_currency = (p_win * avg_win) - (p_loss * avg_loss)
empirical_mean_currency = df["net_pnl"].mean()
# Dimensionless R-Multiple Expectancy
valid_r = df.dropna(subset=["realized_r"])
r_wins = valid_r[valid_r["outcome"] == "WIN"]
r_losses = valid_r[valid_r["outcome"] == "LOSS"]
avg_r_win = r_wins["realized_r"].mean() if len(r_wins) > 0 else 0.0
avg_r_loss = abs(r_losses["realized_r"].mean()) if len(r_losses) > 0 else 0.0
expectancy_r = (p_win * avg_r_win) - (p_loss * avg_r_loss)
empirical_mean_r = valid_r["realized_r"].mean()
# Profit Factor: Gross Realized Profits / Gross Realized Losses
profit_factor = gross_profit / gross_loss if gross_loss > 0 else np.inf
return {
"n_total": n_total,
"n_win": n_win,
"n_loss": n_loss,
"n_be": n_be,
"win_rate": p_win,
"loss_rate": p_loss,
"be_rate": p_be,
"avg_win": avg_win,
"avg_loss": avg_loss,
"payoff_ratio": payoff_ratio,
"expectancy_currency": expectancy_currency,
"empirical_mean_currency": empirical_mean_currency,
"avg_r_win": avg_r_win,
"avg_r_loss": avg_r_loss,
"expectancy_r": expectancy_r,
"empirical_mean_r": empirical_mean_r,
"profit_factor": profit_factor,
"total_net_pnl": df["net_pnl"].sum(),
"total_fees": df["fees"].sum()
}
@staticmethod
def wilson_score_interval(successes: int, total: int, confidence: float = 0.95) -> Tuple[float, float]:
"""Calculates Wilson score confidence interval for binomial proportion."""
if total == 0:
return (0.0, 0.0)
z = 1.95996 # 95% two-sided normal quantile
p_hat = successes / total
denom = 1.0 + (z**2) / total
center = (p_hat + (z**2) / (2.0 * total)) / denom
margin = (z / denom) * math.sqrt((p_hat * (1.0 - p_hat) / total) + (z**2) / (4.0 * total**2))
lower = max(0.0, center - margin)
upper = min(1.0, center + margin)
return (lower, upper)
def bootstrap_expectancy(self, n_bootstrap: int = 5000, ci: float = 0.95) -> Dict[str, float]:
"""Performs non-parametric bootstrap resampling on trade net PnL."""
net_pnls = self.df["net_pnl"].values
realized_rs = self.df["realized_r"].dropna().values
n = len(net_pnls)
boot_currency = np.empty(n_bootstrap)
boot_r = np.empty(n_bootstrap)
for i in range(n_bootstrap):
idx = np.random.randint(0, n, size=n)
boot_currency[i] = net_pnls[idx].mean()
boot_r[i] = realized_rs[idx].mean()
alpha = (1.0 - ci) / 2.0
ci_curr_low = np.percentile(boot_currency, alpha * 100)
ci_curr_high = np.percentile(boot_currency, (1.0 - alpha) * 100)
ci_r_low = np.percentile(boot_r, alpha * 100)
ci_r_high = np.percentile(boot_r, (1.0 - alpha) * 100)
p_loss_system = np.mean(boot_currency <= 0.0)
return {
"boot_ci_currency_low": ci_curr_low,
"boot_ci_currency_high": ci_curr_high,
"boot_ci_r_low": ci_r_low,
"boot_ci_r_high": ci_r_high,
"prob_negative_expectancy": p_loss_system
}
def simulate_friction_degradation(self, additional_slippage_per_share: List[float]) -> pd.DataFrame:
"""
Simulates the impact of additional round-trip slippage on expectancy.
Each round-trip incurs: 2 * slippage_per_share * total_qty
"""
results = []
base_df = self.df
n = len(base_df)
for slip in additional_slippage_per_share:
simulated_pnls = []
for _, row in base_df.iterrows():
slip_cost = 2.0 * slip * row["total_qty"]
simulated_pnls.append(row["net_pnl"] - slip_cost)
sim_pnl_arr = np.array(simulated_pnls)
sim_wins = sim_pnl_arr[sim_pnl_arr > 0]
sim_losses = sim_pnl_arr[sim_pnl_arr < 0]
g_win = sim_wins.sum() if len(sim_wins) > 0 else 0.0
g_loss = abs(sim_losses.sum()) if len(sim_losses) > 0 else 0.0
exp_curr = sim_pnl_arr.mean()
pf = g_win / g_loss if g_loss > 0 else np.inf
wr = len(sim_wins) / n
results.append({
"Slippage_per_Share": slip,
"Net_Expectancy_$": exp_curr,
"Simulated_WinRate": wr,
"Simulated_PF": pf
})
return pd.DataFrame(results)
if __name__ == "__main__":
ledger = generate_sample_ledger()
parser = LedgerParser()
trades = parser.parse_ledger(ledger, be_threshold_dollars=1.0)
calc = ExpectancyCalculator(trades)
metrics = calc.calculate_metrics()
boot = calc.bootstrap_expectancy(n_bootstrap=5000)
wr_ci = calc.wilson_score_interval(metrics["n_win"], metrics["n_total"])
print("==================== PARSED TRADE LEDGER ====================")
df_display = calc.df[["trade_id", "side", "total_qty", "avg_entry_price", "avg_exit_price", "fees", "net_pnl", "realized_r", "outcome"]]
print(df_display.to_string(index=False))
print("\n==================== EXPECTANCY SUMMARY ====================")
print(f"Total Closed Trades : {metrics['n_total']}")
print(f"Wins / Losses / Breakeven : {metrics['n_win']} / {metrics['n_loss']} / {metrics['n_be']}")
print(f"Win Rate : {metrics['win_rate']:.1%} (95% Wilson CI: [{wr_ci[0]:.1%}, {wr_ci[1]:.1%}])")
print(f"Average Win / Average Loss: ${metrics['avg_win']:.2f} / ${metrics['avg_loss']:.2f} (Payoff Ratio: {metrics['payoff_ratio']:.2f})")
print(f"Profit Factor : {metrics['profit_factor']:.2f}")
print(f"Expectancy ($/trade) : ${metrics['expectancy_currency']:.2f} (Formula) == ${metrics['empirical_mean_currency']:.2f} (Empirical Mean)")
print(f"Expectancy (R/trade) : +{metrics['expectancy_r']:.2f}R (Formula) == +{metrics['empirical_mean_r']:.2f}R (Empirical Mean)")
print(f"95% Bootstrap CI ($) : [${boot['boot_ci_currency_low']:.2f}, ${boot['boot_ci_currency_high']:.2f}]")
print(f"95% Bootstrap CI (R) : [{boot['boot_ci_r_low']:.2f}R, {boot['boot_ci_r_high']:.2f}R]")
print(f"P(Expectancy <= 0) : {boot['prob_negative_expectancy']:.1%}")
slippages = [0.00, 0.05, 0.10, 0.20, 0.30, 0.50, 0.75, 1.00]
df_friction = calc.simulate_friction_degradation(slippages)
print("\n==================== FRICTION DEGRADATION ====================")
print(df_friction.to_string(index=False))Script Execution Output
Executing this script produces the exact trade tear sheet below:
==================== PARSED TRADE LEDGER ====================
trade_id side total_qty avg_entry_price avg_exit_price fees net_pnl realized_r outcome
T001 BUY 100 150.00 156.00 2.0 598.00 +1.99R WIN
T002 BUY 100 155.00 151.80 2.0 -322.00 -1.07R LOSS
T003 BUY 200 160.00 166.50 4.0 1296.00 +1.62R WIN
T004 BUY 100 162.00 162.02 2.0 0.00 +0.00R BREAKEVEN
T005 BUY 150 170.00 165.20 3.0 -723.00 -1.21R LOSS
T006 BUY 200 164.00 166.25 4.0 446.00 +0.74R WIN
T007 BUY 100 166.00 169.20 2.0 318.00 +1.06R WIN
T008 BUY 100 168.00 164.90 2.0 -312.00 -1.04R LOSS
T009 BUY 100 165.00 178.00 2.0 1298.00 +3.25R WIN
T010 BUY 100 177.00 173.80 2.0 -322.00 -1.07R LOSS
==================== EXPECTANCY SUMMARY ====================
Total Closed Trades : 10
Wins / Losses / Breakeven : 5 / 4 / 1
Win Rate : 50.0% (95% Wilson CI: [23.7%, 76.3%])
Average Win / Average Loss: $791.20 / $419.75 (Payoff Ratio: 1.88)
Profit Factor : 2.36
Expectancy ($/trade) : $227.70 (Formula) == $227.70 (Empirical Mean)
Expectancy (R/trade) : +0.43R (Formula) == +0.43R (Empirical Mean)
95% Bootstrap CI ($) : [-$166.90, $627.54]
95% Bootstrap CI (R) : [-0.48R, 1.32R]
P(Expectancy <= 0) : 14.2%
==================== FRICTION DEGRADATION ====================
Slippage_per_Share Net_Expectancy_$ Simulated_WinRate Simulated_PF
0.00 227.7 0.6 2.36
0.05 215.2 0.5 2.24
0.10 202.7 0.5 2.13
0.20 177.7 0.5 1.94
0.30 152.7 0.5 1.76
0.50 102.7 0.5 1.46
0.75 40.2 0.5 1.16
1.00 -22.3 0.5 0.92Statistical Significance: Confidence Intervals and Sample Size Traps
The single greatest operational hazard in backtest evaluation is extrapolating long-term viability from an underpowered sample size. In our 10-trade sample ledger above, the strategy printed a headline currency expectancy of +$227.70/trade and an impressive +0.43R/trade, paired with a 2.36 Profit Factor.
Yet the statistical intervals tell a vastly more cautious story:
- The Wilson Score Interval for Win Rate: While the point estimate is 50.0%, the true population parameter at a 95% confidence level spans between 23.7% and 76.3%.
- The Non-Parametric Bootstrap CI for Expectancy: Resampling the trade outcomes over 5,000 iterations yields a 95% confidence interval for currency expectancy spanning from -$166.90 to +$627.54 (and -0.48R to +1.32R).
- Probability of Capital Destruction
P(E \le 0): Given the variance and sample size, there is a 14.2% probability that this algorithm possesses a flat or negative expected payoff in out-of-sample forward trading.
Why Small Samples Corrupt Expectancy
The standard error of the mean for any sample of independent trades is governed by:
\text{SE}_E = \frac{\sigma_X}{\sqrt{N}}Where \sigma_X is the sample standard deviation of trade payoffs. In trading systems with high payoff skewness—such as trend following or breakout strategies—\sigma_X is exceptionally high due to fat right tails (+3.5R, +5.0R windfalls).
When N < 50, a single large winner inflates the sample mean \bar{X}, creating an illusion of robust positive expectancy. To achieve a narrow confidence interval where the lower bound strictly exceeds zero, quantitative strategies typically require:
- Intraday Mean Reversion:
N \ge 300to500independent trades. - Daily Trend Following:
N \ge 150to250independent trades across multiple market cycles.
The Wilson Score Interval vs. Wald Normal Approximation
Many naive calculators estimate the confidence interval of win rate p using the textbook Wald normal approximation:
\text{CI}_{Wald} = \hat{p} \pm z \sqrt{\frac{\hat{p}(1 - \hat{p})}{N}}For small N or when \hat{p} approaches 0 or 1, the Wald interval severely underestimates error margins and can produce nonsensical bounds outside [0, 1]. The Wilson Score Interval corrects for this skewness by inverting the hypothesis test:
\text{Center} = \frac{\hat{p} + \frac{z^2}{2N}}{1 + \frac{z^2}{N}}, \quad \text{Margin} = \frac{z}{1 + \frac{z^2}{N}} \sqrt{\frac{\hat{p}(1 - \hat{p})}{N} + \frac{z^2}{4N^2}}For N = 10 and \hat{p} = 0.50, the Wilson interval correctly identifies the wide uncertainty [23.7%, 76.3%], preventing the researcher from concluding that the strategy has stabilized around an even win rate.
Friction Degradation: Slippage, Commissions, and the Breakeven Frontier
In backtesting environments, positive expectancy is easily simulated by assuming frictionless execution at nominal bar close prices. In live market microstructure, execution friction behaves as a deterministic, negative drift applied to every round trip.
Let C_{comm} represent two-way broker commissions and exchange fees per share, and S_{slip} represent average one-way execution slippage relative to the decision price. The net expectancy equation becomes:
E_{net} = E_{gross} - 2 \cdot Q \cdot S_{slip} - Q \cdot C_{comm}As demonstrated in our Python friction degradation table, as round-trip execution slippage increases from $0.00 to $1.00 per share:
- At $0.00 slippage, Net Expectancy is +$227.70 (Profit Factor 2.36).
- At $0.50 slippage, Net Expectancy degrades by more than 50% to +$102.70 (Profit Factor 1.46).
- At $0.75 slippage, Net Expectancy contracts to +$40.20 (Profit Factor 1.16).
- At $1.00 slippage, Net Expectancy turns negative to -$22.30, and the Profit Factor collapses to 0.92.
flowchart LR
subgraph FrictionTiers ["Friction Sensitivity Analysis"]
Tier0["Slippage $0.00: E = +$227.70 (PF 2.36)"]
Tier1["Slippage $0.50: E = +$102.70 (PF 1.46)"]
Tier2["Slippage $0.75: E = +$40.20 (PF 1.16)"]
Tier3["Critical Threshold: Slippage $0.91 (E = $0.00)"]
Tier4["Slippage $1.00: E = -$22.30 (PF 0.92 - Insolvent)"]
end
Tier0 --> Tier1
Tier1 --> Tier2
Tier2 --> Tier3
Tier3 --> Tier4Calculating Critical Slippage (S_{crit})
The Critical Slippage is the exact friction magnitude per unit that reduces net expectancy to zero:
S_{crit} = \frac{E_{gross} - \text{Commissions}}{2 \cdot \bar{Q}}For our sample strategy, with an average position size of \bar{Q} = 125 shares and average net expectancy before additional slippage of $227.70:
S_{crit} = \frac{227.70}{2 \cdot 125} \approx \$0.91 \text{ per share}If liquidity conditions or wider bid-ask spreads during market opens create more than $0.91 of round-trip price execution penalty, the strategy transitions from profitable alpha into a wealth-depleting system. Strategies with low nominal expectancies (e.g., high-frequency scalping capturing $0.05/share) possess paper-thin critical slippage margins and frequently fail upon transition to live execution. For rigorous methodologies on modeling these costs, see our guide on Transaction Cost Analysis in Backtesting.
Systematic Trade Ledger Auditing with OpenBacktesting
In institutional algorithmic research, calculating metrics on aggregated performance summaries is insufficient. Reliable validation demands direct inspection of individual order fills, position state transitions, and trade campaign records.
OpenBacktesting (version 0.2.1, requiring Python >=3.12,<3.15) is an independent open-source backtesting engine designed specifically for single-asset historical OHLCV bar-by-bar research. Rather than reporting static headline summaries, OpenBacktesting logs granular execution data for every simulation:
- Deterministic Bar-by-Bar Matching: Orders are matched strictly against future tick or bar prices according to explicit execution rules, eliminating lookahead bias and fill ambiguity.
- SQLite Trial Persistence: Every backtest run and parameter sweep persists complete trade ledgers, order events, and equity curves into an inspectable SQLite database.
- Local Studio UI: Researchers can inspect individual trade lifecycles, visualizing exact entry points, tranche exits, stop activations, and realized R-multiples directly in the browser interface.
# Install OpenBacktesting in a Python 3.12+ environment
pip install openbacktesting
# Run a systematic backtest and persist execution ledgers to SQLite
openbacktesting run --strategy momentum_breakout.py --data SPY_1h.csv --output trial_results.sqlite
# Launch the local Studio UI to inspect trade ledgers and R-multiple distributions
openbacktesting ui --db trial_results.sqlitePre-Deployment Quant Checklist for Trade Ledgers
Before promoting any systematic strategy from backtest discovery to out-of-sample forward testing, audit its trade ledger against this verification checklist:
- Campaign Consolidation Verified: Ensure multi-stage fills, partial profit scaling, and pyramiding entries are consolidated into single independent trade campaigns rather than artificially inflating
N. - Breakeven Handling Standardized: Confirm that breakeven trades (
X_i \approx 0) are accounted for in the denominator so thatE = (P_win * \bar{W}) - (P_loss * |\bar{L}|)equals the empirical sample mean\bar{X}. - Dimensionless R Expectancy Established: Baseline every setup against its initial nominal risk (1R) to confirm positive expectancy (
E_R > +0.20R) independent of asset price or position size drift. - Statistical Significance Validated: Evaluate the lower bound of the 95% bootstrap confidence interval. Ensure
P(E \le 0)remains below acceptable risk thresholds (typically< 5.0%). - Critical Slippage Calculated: Perform friction degradation stress tests. Verify that the strategy retains positive net expectancy at two to three times the typical historical bid-ask spread.
- Outlier Wins Trimmed: Re-run the expectancy calculation after eliminating the top 2% of winning trades. If expectancy collapses to zero or turns negative, the strategy's apparent edge is an artifact of windfall curve-fitting rather than repeatable alpha.
By anchoring your quantitative research in rigorous trade ledger parsing, statistical confidence intervals, and friction degradation modeling, you transform trading expectancy from an abstract textbook formula into a repeatable engineering benchmark for sustainable algorithmic trading.
