0005. Reservations ledger for order lifecycle

Status: Accepted Date: 2026-04-18

#Context

Pre-Phase-B, the engine updated the portfolio optimistically at the moment of order submission. The code path was:

signal → Risk.check(portfolio.cash) → Portfolio.fill → Broker.place_order

Portfolio.fill moved cash into a position before the broker had even acknowledged the order. This is the dominant pattern in retail broker and banking products, and it's the root cause of familiar failure modes:

In Backtest the problem didn't manifest because there's no broker — we are the reality and the optimistic fill is ground truth. In Live with Paper the problem was masked because Paper fills deterministically at open[T+1] with no rejections. Moving to real brokers (Finam, BCS), the problems would surface immediately.

The proposed mechanism: the cash required for a buy — the position value plus a slippage allowance (only for market orders, since limit orders fix the price) plus the commission estimate — should enter a pending state. The amount is reserved against the available cash but not debited. On the corresponding broker fill event, the actual debit replaces the reservation.

#Decision

Split portfolio accounting into two phases:

  1. Reserve at the moment of order intent. Cash and position quantity are earmarkedavailable_cash / available_qty drop, but cash and positions are unchanged. Risk checks consult available_cash, so back-to-back signals can't collectively overspend.
  2. Commit when the broker confirms a fill, with actual numbers. Removes the reservation, applies Portfolio.fill for that slice. Partial fills shrink the reservation and apply a fill for the slice; the reservation stays open until remaining quantity hits zero.

A reject / cancel calls release, which drops the reservation without any cash/position change.

Add these operations to Portfolio:

val reserve : t -> id:int -> side -> instrument -> quantity ->
              price -> slippage_buffer:float -> fee_rate:float -> t
val commit_fill : t -> id:int -> actual_* -> t
val commit_partial_fill : t -> id:int -> actual_* -> t
val release : t -> id:int -> t
val available_cash : t -> Decimal.t
val available_qty : t -> Instrument.t -> Decimal.t

Thread through Step.execute_pending:

let portfolio' = Portfolio.reserve state.portfolio
  ~id:reservation_id ~side ~instrument ~quantity ~price
  ~slippage_buffer ~fee_rate in
if config.auto_commit then
  Portfolio.commit_fill portfolio' ~id:reservation_id
    ~actual_quantity ~actual_price ~actual_fee
else
  portfolio'   (* reservation stays open *)

auto_commit = true for Backtest (reserve + commit atomic, no broker latency). auto_commit = false for Live (reserve only; wait for broker event).

Live_engine calls Step.commit_fill / Step.commit_partial_fill / Step.release from:

#Alternatives considered

#Status quo (optimistic fill)

Fails on reject, partial, and slippage. We started here; moving to real broker made it untenable.

#Separate "expected" vs. "actual" portfolios

Keep the optimistic fill in Live and additionally maintain a "broker-confirmed" portfolio updated by events. Reconcile drift between them.

This doesn't fix the strategy's decision-making — it still asks the optimistic portfolio for available_cash, which doesn't reflect pending orders. Adds complexity without solving the Risk gate problem.

#Pessimistic ledger: no portfolio update until broker

confirms

The cleanest correctness model: the engine's Portfolio is only the broker's confirmed reality. Reserve with a shadow record, check it against reality.

Works, but slower (strategy on bar T+1 doesn't know about its order from bar T until the broker round-trip completes). Doesn't matter for our bar-granular strategies (minutes to hours between decisions), but constrains higher-frequency trading.

Practically equivalent to the reserve+commit approach we picked — the reservation IS the "shadow record". The difference is just whether Risk checks against cash (pessimistic) or available_cash = cash - reserved (reserve+commit). We chose the latter because it's slightly more responsive: Risk sees the pending impact immediately.

#Event sourcing

Model the portfolio as a fold over an append-only event log. Replay on restart, distribute across nodes, etc.

Overkill for a single-engine, single-process deployment. Interesting if this grows to a distributed system, but we can retrofit event sourcing on top of the reservation model without disturbing the domain types.

#Consequences

Easier:

Harder:

To watch for:

#Consequences observed

After Phase B.4 (partial fill support) + auto-trigger of reconcile, all 238 OCaml tests pass, including the differential test and a new multi-bar partial-fill end-to-end test. The reservation model added no measurable overhead in Backtest (same lazy Seq.t drive loop). Live behavior on paper is indistinguishable from the pre-reservations version — same fills, same P&L, same order counts.

#References