Reservations ledger

The portfolio accounts for cash and positions in two phases: a trade is first reserved (cash/qty earmarked but not moved), then committed when the broker confirms the actual fill. This closes a class of bugs inherent in optimistic accounting, which dominates retail broker and banking products and leads to familiar problems: stale balances, double-spending windows, eventually-consistent disagreement between client and server.

See ADR 0005 for the motivation.

#Data model

Portfolio.t carries an additional field beside cash, positions, realized_pnl:

type reservation = {
  id : int;
  side : Side.t;
  instrument : Instrument.t;
  quantity : Decimal.t;         (* remaining — decreases on partial *)
  per_unit_cash : Decimal.t;    (* immutable after reserve *)
}

type t = private {
  cash : Decimal.t;
  positions : (Instrument.t * position) list;
  realized_pnl : Decimal.t;
  reservations : reservation list;
}

per_unit_cash captures the expected outlay per unit for a Buy (price × (1 + slippage_buffer) + price × fee_rate); it's zero for Sell since sells free cash. Stored per-reservation so a partial fill can shrink the reservation proportionally without recomputing from original parameters.

#Operations

val reserve :
  t -> id:int -> side -> instrument -> quantity:Decimal.t ->
  price:Decimal.t -> slippage_buffer:float -> fee_rate:float -> t

val commit_fill :
  t -> id:int ->
  actual_quantity:Decimal.t ->
  actual_price:Decimal.t ->
  actual_fee:Decimal.t ->
  (t * commit_fill_outcome, commit_fill_error) result

val release : t -> id:int -> (t * Reservation_released.t, release_error) result

val available_cash : t -> Decimal.t
val available_qty : t -> Instrument.t -> Decimal.t

#available_cash

let available_cash p =
  List.fold_left (fun acc r ->
    match r.side with
    | Buy  -> Decimal.sub acc (reserved_cash r)
    | Sell -> acc)
    p.cash p.reservations

This is what Risk.check uses instead of raw cash, so back-to-back signals on the same bar can't collectively overspend. A Buy reservation of 1000 rub reduces available_cash by 1000 immediately. If the bar also produces a second signal, Risk sees only 9000 left (assuming initial 10000) and sizes / rejects accordingly.

#available_qty

let available_qty p instrument =
  let base = match position p instrument with
    | Some pos -> pos.quantity
    | None -> Decimal.zero
  in
  List.fold_left ... subtract Sell reservations ...
    base p.reservations

Similarly for exits: a pending Exit_long locks the shares it's selling. A second Exit_long on the same bar wouldn't try to sell shares already earmarked.

#Two commit modes

Step.config.auto_commit controls whether the trade is committed immediately after reservation or left pending:

(* inside Step.execute_pending, after Risk.Accept *)
let portfolio_r = Portfolio.reserve ... in
let portfolio' =
  if config.auto_commit then
    Portfolio.commit_fill portfolio_r
      ~id:reservation_id
      ~actual_quantity:q ~actual_price:price ~actual_fee:fee
  else
    portfolio_r     (* reservation stays open *)
in

#End-to-end flow in Live mode

 Pipeline.run (Step.execute_pending)
  │
  ├─ Risk.check against available_cash  → Accept q
  ├─ Portfolio.reserve ~id (available_cash drops)
  └─ emit event.settled { side; q; price; fee; reservation_id }

 Live_engine.apply_event
  │
  ├─ map cid → reservation_id (Hashtbl)
  └─ Broker.place_order ~client_order_id:cid

  ⏸ broker round-trip ⏸

 Broker fills (Paper callback / WS event / reconcile poll)
  │
  ▼
 Live_engine.on_fill_event { cid; actual_qty; actual_price; actual_fee }
  │
  ├─ find cid in pending map
  ├─ Step.commit_fill (Drawn_down: shrink; Fully_committed: remove)
  └─ Portfolio.reservations shrinks, cash moves, position updates

#Two commit paths in Live

Live's post-Phase-B architecture at a glance: a reservation enters the system via one entry point (Step.execute_pending) and leaves through one of two converging paths — the primary path driven by real broker events, and the safety-net path driven by periodic polling.

                      ┌─ Step.execute_pending (reserve) ─┐
                      ▼                                   │
Pipeline ───► settled ─►│ Portfolio.reservations            │
                      │   available_cash = cash - Σ       │
                      │                                   │
                      │ submit_order:                     │
                      │   pending[cid] = {id, intended}   │
                      │   Broker.place_order              │
                      │                                   │
          ┌───────────┴───────────┐                       │
          │                       │                       │
┌─────────▼────────┐    ┌─────────▼────────┐             │
│ on_fill_event    │    │ reconcile        │             │
│ (primary path)   │    │ (safety net)     │             │
│                  │    │                  │             │
│ fires from:      │    │ polls get_orders │             │
│ - Paper callback │    │ for each cid in  │             │
│ - WS order_update│    │ pending:         │             │
│                  │    │   Filled → commit│             │
│ actual numbers   │    │   Rejected →     │             │
│                  │    │     release      │             │
└──────────────────┘    └──────────────────┘             │
          │                       │                       │
          └───────────┬───────────┘                       │
                      ▼                                   │
             Portfolio.commit_fill /                      │
             Portfolio.release                            │
                      │                                   │
                      └──────────────────────────────────┘

Both paths terminate in the same Portfolio.commit_fill / Portfolio.release operations — the only difference is where the actual numbers came from (broker event vs intended fallback) and when it happened (synchronously vs bounded by reconcile_every). Every reservation must exit through one of the two paths eventually, or it leaks — this is why reconcile exists as a safety net even when the primary path is reliable.

#Primary: on_fill_event

Synchronous in-process callback from Paper, or a WS order_update frame from a real broker. Carries actual fill numbers:

let on_fill_event t (fe : fill_event) =
  ...
  match Step.commit_fill t.state ~reservation_id
          ~actual_quantity:fe.actual_quantity
          ~actual_price:fe.actual_price ~actual_fee:fe.actual_fee with
  | Ok (state', Drawn_down _) ->
      t.state <- state';
      (* reservation still open; engine-side bookkeeping if any *)
  | Ok (state', Fully_committed _) ->
      t.state <- state';
      Hashtbl.remove t.pending cid
  | Error _ -> ()  (* Reservation_not_found | Overfill — log + drop *)

#Fallback: reconcile

A periodic poll of Broker.get_orders catches anything the primary path missed (network drop, WS reconnect, crash recovery):

let reconcile_unsafe t =
  let orders = Broker.get_orders t.cfg.broker in
  List.iter (fun o ->
    match Hashtbl.find_opt t.pending o.client_order_id, o.status with
    | Some p, Filled ->
      Step.commit_fill t.state ~reservation_id:p.reservation_id
        ~actual_quantity:p.intended_quantity
        ~actual_price:p.intended_price    (* ← fallback, see caveat *)
        ~actual_fee:p.intended_fee
    | Some p, (Cancelled | Rejected | Expired | Failed) ->
      Step.release t.state ~reservation_id:p.reservation_id
    | _ -> ())
    orders

Auto-trigger: Live_engine.config.reconcile_every runs it every N bars inside on_bar.

#Reconcile pulls actuals via get_executions

On a Filled status, reconcile calls Broker.get_executions ~client_order_id and commits each returned execution via Step.commit_fill with actual broker numbers. Paper returns its own fill history filtered by cid; real brokers (Finam, BCS) will return per-execution trade records (Finam's /v1/accounts/{id}/trades, BCS's Deal list) once their adapters are wired.

If get_executions returns empty (adapter stub, or broker that doesn't expose per-execution detail), reconcile falls back to the intended numbers from the pending map. This is a documented drift bounded by per-order slippage (typically < 0.5% for liquid instruments) and does not accumulate — but the ACL adapters for Finam/BCS should implement get_executions before going to production so the fallback never fires for real brokers.

#Invariants

#Testing

12 unit tests in test/unit/domain/engine/portfolio_test.ml cover reserve/commit/release, partial fills, available_cash / available_qty math, and error cases. Plus end-to-end tests in test/unit/application/live_engine/:

#See also