Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/ml4t/backtest/analytics/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ def to_trade_record(trade: Trade) -> dict[str, Any]:
"status": trade.status,
"mfe": trade.mfe,
"mae": trade.mae,
"entry_slippage": trade.entry_slippage,
"multiplier": trade.multiplier,
"metadata": trade.metadata,
# Computed cost decomposition fields
"gross_pnl": trade.gross_pnl,
"net_return": trade.net_return,
"total_slippage_cost": trade.total_slippage_cost,
"cost_drag": trade.cost_drag,
# Diagnostic-specific computed fields
"duration": trade.exit_time - trade.entry_time,
# Legacy field (diagnostic still expects this)
Expand Down
4 changes: 2 additions & 2 deletions src/ml4t/backtest/analytics/trades.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ def total_commission(self) -> float:

@property
def total_slippage(self) -> float:
"""Total slippage cost across all trades."""
return sum(t.slippage for t in self.trades)
"""Total slippage cost across all trades (entry + exit)."""
return sum(t.total_slippage_cost for t in self.trades)

def by_side(self, side: str) -> "TradeAnalyzer":
"""Filter trades by side ('long' or 'short')."""
Expand Down
13 changes: 10 additions & 3 deletions src/ml4t/backtest/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,14 @@ def _generate_results(self) -> BacktestResult:
# Get last known price for this asset
last_price = self.broker._current_prices.get(asset, pos.entry_price)

# Calculate mark-to-market PnL
pnl = (last_price - pos.entry_price) * pos.quantity - pos.entry_commission
pnl_pct = (last_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0
# Calculate mark-to-market PnL (include multiplier for futures)
pnl = (
last_price - pos.entry_price
) * pos.quantity * pos.multiplier - pos.entry_commission
raw_pct = (
(last_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0
)
pnl_pct = raw_pct if pos.quantity > 0 else -raw_pct

open_trade = Trade(
symbol=asset, # Asset identifier (Position.asset -> Trade.symbol)
Expand All @@ -250,6 +255,8 @@ def _generate_results(self) -> BacktestResult:
status="open",
mfe=pos.max_favorable_excursion,
mae=pos.max_adverse_excursion,
entry_slippage=pos.entry_slippage,
multiplier=pos.multiplier,
)
all_trades.append(open_trade)

Expand Down
12 changes: 10 additions & 2 deletions src/ml4t/backtest/execution/fill_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ def _create_position(self, ctx: FillContext) -> None:
context=context,
multiplier=broker.get_multiplier(order.asset),
entry_commission=ctx.commission,
entry_slippage=ctx.slippage,
high_water_mark=initial_hwm,
low_water_mark=initial_lwm,
)
Expand All @@ -342,7 +343,8 @@ def _close_position(self, ctx: FillContext, pos: Position, old_qty: float) -> No
# PnL includes both entry and exit commission, and multiplier for futures
total_commission = pos.entry_commission + ctx.commission
pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_commission
pnl_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0
raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0
pnl_pct = raw_pct if old_qty > 0 else -raw_pct

trade = Trade(
symbol=order.asset, # Order.asset -> Trade.symbol
Expand All @@ -359,6 +361,8 @@ def _close_position(self, ctx: FillContext, pos: Position, old_qty: float) -> No
exit_reason=_get_exit_reason(order),
mfe=pos.max_favorable_excursion,
mae=pos.max_adverse_excursion,
entry_slippage=pos.entry_slippage,
multiplier=pos.multiplier,
)
broker.trades.append(trade)
del broker.positions[order.asset]
Expand Down Expand Up @@ -394,7 +398,8 @@ def _flip_position(
# Close the old position (include multiplier for futures)
total_close_commission = pos.entry_commission + close_commission
pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_close_commission
pnl_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0
raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0
pnl_pct = raw_pct if old_qty > 0 else -raw_pct

trade = Trade(
symbol=order.asset, # Order.asset -> Trade.symbol
Expand All @@ -411,6 +416,8 @@ def _flip_position(
exit_reason=_get_exit_reason(order),
Comment thread
stefan-jansen marked this conversation as resolved.
mfe=pos.max_favorable_excursion,
mae=pos.max_adverse_excursion,
entry_slippage=pos.entry_slippage,
multiplier=pos.multiplier,
)
broker.trades.append(trade)

Expand All @@ -430,6 +437,7 @@ def _flip_position(
context=context,
multiplier=broker.get_multiplier(order.asset),
entry_commission=open_commission,
entry_slippage=ctx.slippage * (open_qty / ctx.fill_quantity),
Comment thread
stefan-jansen marked this conversation as resolved.
high_water_mark=initial_hwm,
low_water_mark=initial_lwm,
)
Expand Down
6 changes: 6 additions & 0 deletions src/ml4t/backtest/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ def to_trades_dataframe(self) -> pl.DataFrame:
"slippage": t.slippage,
"mfe": t.mfe,
"mae": t.mae,
"entry_slippage": t.entry_slippage,
"multiplier": t.multiplier,
"exit_reason": t.exit_reason,
Comment thread
stefan-jansen marked this conversation as resolved.
"status": t.status,
}
Expand Down Expand Up @@ -664,6 +666,8 @@ def from_parquet(cls, path: str | Path) -> BacktestResult:
exit_reason=row.get("exit_reason", "signal"),
mfe=row["mfe"],
mae=row["mae"],
entry_slippage=row.get("entry_slippage", 0.0),
multiplier=row.get("multiplier", 1.0),
)
)

Expand Down Expand Up @@ -731,6 +735,8 @@ def _trades_schema() -> dict[str, pl.DataType]:
"slippage": pl.Float64(),
"mfe": pl.Float64(),
"mae": pl.Float64(),
"entry_slippage": pl.Float64(),
"multiplier": pl.Float64(),
"exit_reason": pl.String(),
"status": pl.String(), # "closed" or "open"
}
Expand Down
48 changes: 46 additions & 2 deletions src/ml4t/backtest/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ class Position:
context: dict = field(default_factory=dict) # Strategy-provided context
multiplier: float = 1.0 # Contract multiplier (for futures)
entry_commission: float = 0.0 # Commission paid on entry (for Trade PnL)
entry_slippage: float = 0.0 # Per-unit slippage on entry (for cost decomposition)

def __post_init__(self):
# Initialize water marks to entry price
Expand Down Expand Up @@ -242,7 +243,10 @@ def unrealized_pnl(self, current_price: float | None = None) -> float:
return (price - self.entry_price) * self.quantity * self.multiplier

def pnl_percent(self, current_price: float | None = None) -> float:
"""Calculate percentage return on position.
"""Calculate direction-aware percentage return on position.

For long positions: (price - entry) / entry
For short positions: (entry - price) / entry

Args:
current_price: Price to calculate return at. If None, uses self.current_price.
Expand All @@ -252,7 +256,8 @@ def pnl_percent(self, current_price: float | None = None) -> float:
price = self.entry_price
if self.entry_price == 0:
return 0.0
return (price - self.entry_price) / self.entry_price
raw = (price - self.entry_price) / self.entry_price
return raw if self.quantity >= 0 else -raw

def notional_value(self, current_price: float | None = None) -> float:
"""Calculate notional value of position.
Expand Down Expand Up @@ -377,6 +382,9 @@ class Trade:
# MFE/MAE preserved from Position for trade analysis (shorter field names)
mfe: float = 0.0 # Max favorable excursion (best unrealized return)
mae: float = 0.0 # Max adverse excursion (worst unrealized return)
# Cost decomposition fields
entry_slippage: float = 0.0 # Per-unit slippage on entry
multiplier: float = 1.0 # Contract multiplier (for futures)
# Optional metadata extension point
metadata: dict[str, Any] | None = None

Expand All @@ -395,6 +403,42 @@ def commission(self) -> float:
"""Backward-compat alias for validation scripts expecting `commission`."""
return self.fees

@property
def gross_pnl(self) -> float:
"""Price-move P&L before fees: (exit - entry) * quantity * multiplier."""
return (self.exit_price - self.entry_price) * self.quantity * self.multiplier

@property
def net_pnl(self) -> float:
"""P&L after all costs. Alias for self.pnl."""
return self.pnl

@property
def gross_return(self) -> float:
"""Direction-aware gross return. Same as pnl_percent."""
return self.pnl_percent

@property
def net_return(self) -> float:
"""Direction-aware net return including fees."""
notional = self.entry_price * abs(self.quantity) * self.multiplier
if notional == 0:
return 0.0
return self.pnl / notional

@property
def total_slippage_cost(self) -> float:
"""Total slippage cost in dollars (entry + exit)."""
return (self.entry_slippage + self.slippage) * abs(self.quantity) * self.multiplier

@property
def cost_drag(self) -> float:
"""Total cost as fraction of notional: (fees + slippage) / notional."""
notional = self.entry_price * abs(self.quantity) * self.multiplier
if notional == 0:
return 0.0
return (self.fees + self.total_slippage_cost) / notional
Comment thread
stefan-jansen marked this conversation as resolved.


@dataclass
class PartialExit:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ def test_trades_dataframe_basic(self, backtest_result: BacktestResult):
"slippage",
"mfe",
"mae",
"entry_slippage",
"multiplier",
"exit_reason",
"status",
]
Expand Down
Loading