backtester/portfolio/calc/round_trips.py round_trips.py:
Canonical FIFO trade matching for trade-level analytics.
This file is the single source of truth for completed round trips. It converts a blotter into matched trades, PnL, holding periods, win/loss statistics, turnover and consistency checks.
from backtester.portfolio.calc.round_trips import RoundTripAnalyzer When To Read This
- 01You need to explain why trade stats, turnover and round-trip counts agree.
- 02You are debugging order-based strategies and want the exact FIFO matching behavior.
- 03You are adding a trading analytics section to a tear-sheet.
File Anatomy
- RoundTrip dataclass: immutable record of one completed long or short round trip.
- FIFO matcher: internal signed-quantity engine that handles position reductions and zero-crossing trades.
- RoundTripAnalyzer: report-facing API built from one raw blotter and one returns series.
- Consistency checks: trade-to-round-trip ratio, volume consistency and position overlap.
Data Contract
Inputs
- trades_df with Timestamp, Instrument, Side, Quantity, Price and optional TransactionCost / TradeValue.
- returns: Series used to reconstruct NAV-based trade analytics context.
- initial_capital for NAV and percentage calculations.
Outputs
- round_trips DataFrame with entry/exit, direction, costs, gross/net PnL and holding days.
- summary dict consumed by portfolio_perf.py dot-path metrics.
- holding period lists and PnL series for plots.
Invariants
- Signed quantity: buy is positive, sell is negative.
- FIFO closes oldest lots first.
- Costs are allocated proportionally when a fill partially closes a lot.
Public API And Key Internals
RoundTrip
dataclassRoundTrip(instrument, direction, quantity, entry_price, exit_price, entry_time, exit_time, entry_cost, exit_cost, pnl_gross, pnl_net, holding_days, return_pct) Frozen record for one completed round trip.
_fifo_match
helper_fifo_match(trades_df) -> list[RoundTrip] Internal matching engine using signed quantities and FIFO open lots.
Returns
list[RoundTrip].
RoundTripAnalyzer
classRoundTripAnalyzer(trades_df, returns, initial_capital=100_000.0) Main report-facing class that owns raw trades, NAV and matched round trips.
round_trips
method@property round_trips -> pd.DataFrame Lazily materializes completed round trips as a DataFrame.
summary
methodsummary() -> dict[str, Any] Combines NAV metrics, trade counts, volume, commissions, round trips, holding periods and checks.
holding_periods_list / pnl_series / pnl_with_timestamps
methodpnl_with_timestamps() -> pd.DataFrame Plot-friendly accessors for distribution and time-series visualizations.
Implementation Notes
- This is the right place for order-based analytics because it starts from actual fills, not target weights.
- The analyzer writes keys that portfolio_perf.py reads through paths like compute_trade_analytics.net_profit.
- Cross-checks intentionally expose suspicious output rather than hiding it with formatting.
Code Walkthrough
Analyze an order-based strategy blotter
The result dict feeds report tables; round_trips feeds detailed trade review.
from backtester.portfolio.calc.round_trips import RoundTripAnalyzer
analyzer = RoundTripAnalyzer(
trades_df=trades,
returns=strategy_returns,
initial_capital=100_000,
)
trade_summary = analyzer.summary()
round_trips = analyzer.round_trips
pnl_by_exit = analyzer.pnl_with_timestamps() Key implementation: signed FIFO matching
Positive lots are long, negative lots are short. A trade with opposite sign closes the oldest lot first.
signed_qty = raw_qty if side == "buy" else -raw_qty
remaining = signed_qty
while remaining != 0 and open_lots:
lot = open_lots[0]
lot_qty, lot_price, lot_ts, lot_cost = lot
if (lot_qty > 0 and remaining > 0) or (lot_qty < 0 and remaining < 0):
break
close_qty = min(abs(remaining), abs(lot_qty))
direction = "long" if lot_qty > 0 else "short"
direction_sign = 1.0 if lot_qty > 0 else -1.0
pnl_gross = close_qty * (price - lot_price) * direction_sign Key implementation: summary is the report contract
Every report field comes from the same matched trade set.
def summary(self) -> Dict[str, Any]:
result: Dict[str, Any] = {}
result.update(self._nav_metrics())
result.update(self._trade_counts())
result.update(self._volume_and_turnover())
result.update(self._commission_stats())
result.update(self._round_trip_stats())
result.update(self._holding_period_stats())
result.update(self._cross_checks())
return result