0011. Risk evacuation from Strategy; pre_trade_risk and execution_management BCs; Place_order saga

Status: Accepted Date: 2026-05-08

#Context

ADR 0009 introduced portfolio_management and explicitly deferred the relocation of strategy/lib/domain/engine/risk.{ml,mli} and the live-engine's portfolio state, noting that the strategy BC was at that point still importing account. ADR 0010 finalised the strategy → PM contract: Signal_detected_integration_event is a LEAN-style declarative alpha-mind, the only outbound the BC publishes. Both ADRs left the strategy library structurally incompatible with the architecture they described:

strategy/
└── lib/
    ├── domain/engine/{risk,step,pipeline,backtest}.ml
    │     # Engine.Risk hybrid: cash-buffer / gross-exposure / leverage
    │     # gate (consumed Account.Portfolio.t) + size_from_strength
    │     # construction-time sizing + Engine.Backtest portfolio loop.
    └── application/live_engine/live_engine.ml
          # Eio-backed orchestration: kill-switch (peak/drawdown),
          # rate-limit, reservation lifecycle, broker submission,
          # fill-event reconciliation, pending/placed Hashtbls.

Three classes of responsibility were entangled in this code:

  1. Construction-time sizing — turning signal.strength and equity into a target quantity. This is policy: how big a position the alpha is allowed to want. ADR 0009 already hosts the sister policy (Risk_policy.clip) in PM under domain/risk/. The two policies operate on the same kind of object (a target proposal) with different algebraic shapes — clip ⊆ identity, from_strength constructs from primitives.

  2. Pre-trade hard gate — refuse a trade intent that would breach cash buffer / gross-exposure / leverage caps. This is risk-as- gatekeeper, distinct from risk-as-policy: it acts on every order regardless of source, and its semantics are hard veto, not clip. ADR 0009 §«Risk-as-policy lives inside PM» named the gatekeeper as a separate future BC.

  3. Execution-layer defensive policies — kill-switch (peak-equity / drawdown halt), rate-limit (rolling order count), and the coordination of Reserve(Account) → Submit(Broker) → Release( Account) on rejection. These are applied between an approved trade intent and venue submission. They are not alpha policy and not pre-trade gating — they are the layer LEAN names ExecutionModel and Nautilus names ExecutionEngine.

Beyond the responsibility tangle, the cross-BC import graph was acyclic only on paper:

strategy.engine          → account            (Engine.Risk.check on Account.Portfolio)
strategy.live_engine     → account, broker    (reservation lifecycle, place_order)
strategy.factory         → account, broker    (factory wiring)

That violates the project rule that a BC must not import another's domain code. Microservice extraction of strategy from the present monolith would be infeasible without these imports being dissolved.

A fourth gap, orthogonal to risk, blocked end-to-end traffic. The saga that coordinates reserve → submit → release on compensation had not been built. The shared workflow_engine library (implementing Hohpe & Woolf's Process Manager template, EIP ch. 11) existed but had no concrete instance. Without it, no cross-BC flow could connect Strategy's signals to Account's ledger and Broker's venue, even after the BCs were untangled.

A fifth concern — strategy/lib/domain/engine/backtest.{ml,mli} and the corresponding CLI subcommand and HTTP endpoint — had been identified as dismantleable: a backtest is not a separate code path but the same composition with a synthetic broker adapter substituted. ADR 0009's Pipeline-unification thesis (one Pipeline.run for backtest and live) had already convinced the codebase that backtest-vs-live equivalence is structural; making it also a composition-level swap removes the last duplicated control path.

#Decision

Take six structural changes in sequence, each leaving the workspace green (dune build, dune runtest, dune build @fmt):

#1. correlation_id as first-class field on cross-BC saga DTOs

Introduce shared/lib/correlation_id/ (a private string newtype with make, generate : unit -> t via UUID v4 from uuidm). Thread correlation_id : string through eleven cross-BC DTOs that the Place_order saga touches: Trade_intents_planned_IE, Trade_intent_approved_IE, Trade_intent_rejected_IE, Reserve_command, Amount_reserved_IE, Reservation_rejected_IE, Submit_order_command, Order_accepted_IE, Order_rejected_IE, Order_unreachable_IE, Release_command. Wire-format DTOs carry the primitive string; only the originating site mints a fresh identifier — every other DTO echoes the inbound value.

The Process Manager runtime requires this field on every event it routes (see decision §4). Adding the field as a follow-up after the saga is in place would mean re-touching every DTO; the prerequisite checkpoint is the cheaper sequence.

#2. New BC pre_trade_risk (risk-as-gatekeeper)

Mirror Account's per-aggregate domain layout. Aggregate Risk_view keeps cash + per-instrument quantities per book_id, maintained from Account.Reservation_filled inbound IEs. The upstream IE carries cash and position together in one atomic payload, committed via Record_fill_command, so equity is never transiently broken between cash and positions. Aggregate Risk_limits is a private record (min_cash_buffer ≥ 0, max_gross_exposure ≥ 0, max_leverage > 0, Why3-checkable). Domain service Assessment consumes a Trade_intent plus Risk_view plus Risk_limits and emits Approve _ | Reject reason.

Subscribes to in-memory://pm.trade-intents-planned; publishes in-memory://pre-trade-risk.trade-intent-{approved,rejected}. The old Engine.Risk.check, default_limits, and decision move here verbatim, with Account.Portfolio.t replaced by Risk_view.t so the cross-BC import is dissolved at the same checkpoint.

#3. New BC execution_management (EMS) hosting Open_order_ticket_process

Two domain aggregates: Kill_switch (peak-equity tracking, max-drawdown halt) and Rate_limit (rolling timestamp window). Both have Why3-checkable invariants (peak_equity ≥ 0, halted ⇒ no submissions).

The saga Open_order_ticket_process is a Workflow_engine.WORKFLOW implementation. Its transition function is pure (state, event) → (state, command list). Five live transitions plus an idempotent fall-through:

state event next state commands
Awaiting_reservation Amount_reserved Submitted Submit_order
Awaiting_reservation Reservation_rejected Compensated
Submitted Order_accepted Done
Submitted Order_rejected Compensated Release (compensation)
Submitted Order_unreachable Compensated Release (compensation)
any duplicate / late event unchanged

Subscribes to seven inbound topics (Trade_intent_approved_IE starts a saga instance after kill-switch / rate-limit gating; Amount_reserved / Reservation_rejected / Order_accepted / Order_rejected / Order_unreachable advance instances; Reservation_filled feeds Kill_switch — its new_cash field serves as an equity proxy until a mark-to-market feed lands). Publishes Trade_submission_blocked_IE on a kill-switch / rate-limit halt and Kill_switch_tripped_IE on a fresh trip.

The kill-switch / rate-limit code that lived inside live_engine.ml is reimplemented as pure aggregates inside this BC.

#4. Process Manager over Routing Slip

Pick the Process Manager EIP template (Hohpe & Woolf, EIP ch. 11) for the saga runtime, not Routing Slip (the alternative template; a reference implementation in OCaml is krew-solutions/ascetic-ddd-ml/lib/saga).

The two templates differ on where saga state lives:

The deciding constraints are: events arrive from two BCs (Account, Broker) on independent buses, the saga can be paused for arbitrary time between steps (broker WS may take seconds to minutes to acknowledge), and the persistence story matters (Postgres-backed store eventually). Routing Slip cannot serve any of these; PM serves all three. The cost — correlation_id on every inbound DTO — is paid at decision §1.

#5. PM Sizing domain service

Move Engine.Risk.size_from_strength to portfolio_management/lib/domain/sizing.{ml,mli,mlw}, a flat triple at the domain root (mirrors the portfolio_construction.{ml,mli,mlw} precedent set in ADR 0009 — both are domain-layer abstractions, not aggregates with sub-state).

Why3 invariants: 0 ≤ strength ≤ 1 enforced by clamp, result ≥ 0, price = 0 → result = 0 (zero-price sentinel).

PM's Apply_proposed_targets_on_alpha_direction_changed handler extends to call Sizing.from_strength before Risk_policy.clip before Target_portfolio.apply_proposal. The new equity_provider and mark_provider ports are injected from the factory.

strategy/lib/domain/engine/risk.{ml,mli} is deleted at this checkpoint. The engine library no longer imports account; the strategy domain is self-contained.

#6. PM consumes Signal_detected_IE; Strategy collapses to alpha emitter

Add signal_detected_integration_event_handler in PM's inbound ACL: Signal_detected_IE → Define_alpha_view_command. PM's factory subscribes in-memory://strategy.signal-detected.

Strategy publishes Signal_detected_IE on in-memory://strategy.signal-detected, with a fresh Correlation_id.generate () per emission. live_engine.ml collapses from ~329 lines to ~50: on_bar calls Strategy.on_candle, builds Signal_detected_IE, publishes via the injected port. No more pending, placed, peak_equity, halted, recent_order_ts, mutex, submit_order, update_drawdown, reconcile, on_fill_event. The struct shrinks to { strategy_ref; publisher; instrument; strategy_id }.

strategy/lib/domain/engine/{step,pipeline,backtest}.{ml,mli,mlw} and the directory itself are deleted. strategy/lib/dune (strategy_factory) drops account, broker, engine from its (libraries …). Strategy becomes a pure alpha emitter at this checkpoint.

#7. Backtest via composition, not via a domain Backtest.run

Delete Engine.Backtest, Fill_view_model, Backtest_result_view_model, backtest_test. Re-implement the user-facing surface (trading backtest <strategy> CLI, POST /api/backtest) as composition: boot the same in-process trading host that serve boots, with the synthetic broker adapter as data source and paper-mode order interception, generate N synthetic candles, drive them through broker.bar-updated, tally outbound integration events into a structured summary.

To enable end-to-end traffic in this composition, add bus consumers in Account (account.reserve-command / account.release-command topics) and Broker (broker.submit-order-command topic). The wire-format DTOs the saga publishes on these topics are byte-equivalent to the existing command types, so the consumers parse straight into Reserve_command.t / Release_command.t / Submit_order_command.t and route through existing handlers.

#Bounded-context name: execution_management, not execution

Pick execution_management for symmetry with the existing portfolio_management. EMS — Execution Management System — is the canonical industry term, sister to OMS / PMS / RMS, alongside the LEAN ExecutionModel / Nautilus ExecutionEngine references. execution alone is rejected on three grounds: it has no symmetry with PM; it collides with core/order.ml's Order.execution (the record type for a fill); and it semantically conflates the act of executing (which lives in Broker via the venue port) with the policies and gates that govern that act (this BC's actual content).

#Alternatives considered

#Keep Engine.Risk in strategy

Rejected. Strategy then keeps importing account, the BC graph stays cyclic, microservice extraction stays infeasible, and risk- as-gatekeeper has no canonical home. ADR 0009 had already named this as a deferred follow-up; deferring further was not defensible.

#Combine pre_trade_risk and execution_management into one BC

Rejected. The two have different invariant classes: risk-as-gatekeeper is a hard veto (cash buffer, leverage); EMS defensive policies are throttles and halts (kill-switch, rate-limit). LEAN and Nautilus split them likewise (RiskManagementModel vs ExecutionModel, RiskEngine vs ExecutionEngine). One BC hosting both would also blur the saga shape: the gatekeeper publishes Approved / Rejected decisions; the EMS publishes Submission_blocked telemetry and runs the saga. Different command interfaces, different inbound subscriptions.

#Routing Slip for the saga

Rejected. Account's Amount_reserved and Broker's Order_accepted arrive on independent buses; the saga can be paused arbitrarily between steps; persistence will eventually matter. Routing Slip's state-in-message model fits orchestrated synchronous flows, not cross-BC asynchronous compensation. See decision §4 above for the full comparison.

#Order as a separate aggregate or BC

Rejected. Order lifecycle is saga state in Open_order_ticket_process: identity (correlation_id), status transitions (Awaiting_reservationSubmittedDone | Compensated), idempotency on duplicate broker IEs, and cancel-in-flight invariants are all Process-Manager-state properties. broker/lib/application/view_models/order_view_model remains as the broker-side wire-shape mirror of venue order responses, but no aggregate is created. The saga is the order-lifecycle owner.

#Delete the user-facing trading backtest CLI and POST /api/backtest

Rejected. The user-facing surface for historical replay is valuable and orthogonal to whether replay is a separate code path internally. The surface is preserved by routing both entry points through the same run_backtest_composition helper that swaps the synthetic broker in. The internal Engine.Backtest is gone; the external API stays.

#Strategy keeps the live engine for backtest determinism

Rejected. The Pipeline-unification ADR (0004) already established that backtest and live agree because they share Pipeline.run, not because they share an engine. Once Pipeline.run itself is gone (subsumed by the saga composition), the live engine has no more reason to host backtest semantics — the composition itself is the equivalence proof.

#Consequences

Easier:

Harder:

To watch for:

#References