Trade-by-Trade Realized P&L Engine With FIFO Matching
You are building a real-time P&L engine for a trading desk. A stream of $m$ timestamped trades arrives, each represented as a pair $(p_j, q_j)$ where $p_j$ is the execution price and $q_j$ is the signed quantity ($q_j > 0$ for buys, $q_j < 0$ for sells). Your engine must process the stream in a single pass using $O(m)$ time and $O(1)$ extra space (beyond the inventory queue), and return three things:
- Realized P&L -- computed using FIFO (first-in, first-out) inventory matching. When a trade offsets an existing position, match it against the oldest inventory lot first.
- Ending inventory and average cost -- the remaining open position after all trades, along with its weighted-average cost basis.
- Maximum drawdown of cumulative realized P&L -- the largest peak-to-trough decline in the running realized P&L over the trade sequence.
Your implementation must handle the following edge cases:
- Partial fills: a single trade may only partially offset the oldest lot, leaving a residual.
- Zero-quantity records: trades with $q_j = 0$ (no-ops that should be skipped).
- Fees: each trade may carry an optional fee that reduces realized P&L.
- NaN prices or quantities: invalid records that should be skipped with a warning.
Constraints: - $1 \le m \le 10^6$ - Prices and quantities are floating-point numbers. - Fees are non-negative floats (default 0).
Example:
Input trades: [(100, +10), (102, +5), (105, -8), (103, -4), (101, +3)]
Using FIFO matching: - Sell 8 at 105: matched against the first lot (bought 10 at 100). Realized P&L on 8 units = $8 \times (105 - 100) = +40$. Remaining from lot 1: 2 units at 100. - Sell 4 at 103: matched against remaining 2 from lot 1 at 100, then 2 from lot 2 at 102. Realized P&L = $2 \times (103 - 100) + 2 \times (103 - 102) = 6 + 2 = +8$. - Final realized P&L: $+48$. - Ending inventory: 3 units from lot 2 at 102, plus 3 units from the new buy at 101. Total 6 long, average cost $(3 \times 102 + 3 \times 101)/6 = 101.5$. - Cumulative realized P&L series: $[0, 0, +40, +48, +48]$. Peak = 48, no drawdown occurs, so max drawdown = 0.
Hints
- Think about what data structure naturally supports FIFO order with efficient insertion at the back and removal from the front.
- When a trade offsets the current position, you may need to partially consume the oldest lot and leave a residual -- handle the bookkeeping for both the lot and the incoming trade quantity.
- For max drawdown, you only need to track the running peak of cumulative realized P&L and the largest gap between that peak and the current value -- no need to store the full P&L series.
Worked Solution
How to Think About It: This is a bread-and-butter systems problem on any trading desk. Every desk has a P&L engine, and the FIFO matching logic is how most firms (and tax authorities) track cost basis. The core data structure is a deque (double-ended queue) of inventory lots. When a new trade comes in on the same side as the current position, you just append a new lot. When it comes in on the opposite side, you peel off the oldest lots first (FIFO), booking realized P&L on each matched portion. The max drawdown is a simple running-max tracker on the cumulative realized P&L -- no need for anything fancy.
The $O(1)$ extra space claim needs a caveat: the inventory deque itself can grow up to $O(m)$ in the worst case (if every trade is on the same side). The problem means $O(1)$ space *beyond* the inventory, which is standard phrasing for this type of question.
Algorithm:
1. Maintain a deque of lots, where each lot is (price, remaining_qty). 2. For each incoming trade $(p_j, q_j)$: - Skip if $q_j = 0$ or if price/quantity is NaN. - If the trade is on the *same side* as the current inventory (or inventory is empty), push a new lot onto the back of the deque. - If the trade is on the *opposite side*, match against the front of the deque: - Take match_qty = min(|q_j|, front.remaining_qty). - Book realized P&L: match_qty * (p_j - front.price) if closing a long, or match_qty * (front.price - p_j) if closing a short. - Subtract any fee pro-rated to the matched portion. - Reduce front.remaining_qty by match_qty. If it hits zero, pop the front. - Reduce the remaining trade quantity by match_qty. If there is still quantity left, continue matching the next lot. - If you exhaust the entire inventory and still have leftover trade quantity, the position has flipped sides -- push the residual as a new lot. 3. Track cumulative realized P&L. After each trade, update peak = max(peak, cumulative_rpnl) and max_drawdown = max(max_drawdown, peak - cumulative_rpnl). 4. At the end, compute average cost from the remaining lots.
Code:
```python from collections import deque from math import isnan from typing import List, Tuple, Optional
def pnl_engine( trades: List[Tuple[float, float, float]], # (price, signed_qty, fee) ) -> dict: """ Single-pass FIFO P&L engine. Each trade is (price, signed_quantity, fee). fee defaults to 0. Returns realized_pnl, ending_position, avg_cost, max_drawdown. """ inventory: deque = deque() # each element: [price, remaining_qty (signed)] realized_pnl = 0.0 peak_pnl = 0.0 max_dd = 0.0
def same_side(a: float, b: float) -> bool: return (a > 0 and b > 0) or (a < 0 and b < 0)
for trade in trades: price, qty = trade[0], trade[1] fee = trade[2] if len(trade) > 2 else 0.0
# -- skip invalid records -- if isnan(price) or isnan(qty): continue if qty == 0.0: continue if isnan(fee): fee = 0.0
remaining = qty trade_rpnl = 0.0
# -- match against opposite-side inventory -- while remaining != 0.0 and inventory: front = inventory[0] if same_side(front[1], remaining): break # same side, no offset
match_qty = min(abs(remaining), abs(front[1])) # P&L: if we were long (front[1]>0) and selling, gain = sell - cost # if we were short (front[1]<0) and buying, gain = cost - buy if front[1] > 0: trade_rpnl += match_qty * (price - front[0]) else: trade_rpnl += match_qty * (front[0] - price)
# reduce inventory lot if abs(front[1]) <= match_qty + 1e-12: # lot fully consumed match_qty = abs(front[1]) inventory.popleft() else: if front[1] > 0: front[1] -= match_qty else: front[1] += match_qty
# reduce remaining trade qty if remaining > 0: remaining -= match_qty else: remaining += match_qty
# -- any residual becomes a new inventory lot -- if abs(remaining) > 1e-12: inventory.append([price, remaining])
# -- apply fee -- trade_rpnl -= fee
# -- update running P&L and drawdown -- realized_pnl += trade_rpnl if realized_pnl > peak_pnl: peak_pnl = realized_pnl dd = peak_pnl - realized_pnl if dd > max_dd: max_dd = dd
# -- compute ending position and average cost -- total_pos = 0.0 cost_sum = 0.0 for lot in inventory: total_pos += lot[1] cost_sum += lot[0] * abs(lot[1]) avg_cost = cost_sum / abs(total_pos) if abs(total_pos) > 1e-12 else 0.0
return { "realized_pnl": round(realized_pnl, 6), "ending_position": round(total_pos, 6), "average_cost": round(avg_cost, 6), "max_drawdown": round(max_dd, 6), } ```
Complexity:
- Time: $O(m)$ amortized. Each unit of quantity enters the deque at most once and leaves at most once, so the total work across all matching operations is bounded by the total quantity traded, which is $O(m)$ in terms of the number of trades.
- Space: $O(k)$ for the inventory deque where $k$ is the number of open lots, plus $O(1)$ for the running P&L and drawdown trackers. In the worst case $k = m$ (all trades on the same side), but this is inherent to the problem -- you must store the inventory.
Answer: The key idea is to maintain a FIFO deque of inventory lots, matching incoming offsetting trades against the oldest lots first. Realized P&L, ending position, and max drawdown are all tracked in a single pass. Time complexity is $O(m)$ amortized and extra space beyond the inventory is $O(1)$.
Intuition
FIFO matching is how most trading desks and tax systems (like the IRS default method) track cost basis. The reason it matters in practice is that the order you match trades against inventory changes your realized P&L timing, even though the total P&L over the life of the position is the same. FIFO front-loads the oldest cost basis, which is usually what regulators and risk systems expect. LIFO or average-cost matching would give different intermediate P&L paths but identical terminal P&L.
The max drawdown tracker is a classic streaming algorithm pattern: maintain a running maximum and compare each new value against it. This same pattern appears in risk systems (tracking portfolio drawdown), in algorithmic problems (maximum subarray variants), and in performance attribution. The broader lesson is that many quantities that seem like they require storing the full history can actually be computed in $O(1)$ space with the right running statistics.