0009. Portfolio Management bounded context

Status: Accepted Date: 2026-05-03

#Context

Existing BCs in the project:

A class of strategies that the project intends to demonstrate — cointegrated-pair mean reversion, β-hedging, vol-targeting, risk-parity overlays — does not fit Strategy.S. They are inherently multi-instrument: the decision is about the spread of two legs, or about the joint allocation across a basket. The output is not a single Signal.t but a vector — a desired position per instrument, with hedge-ratio-driven sizing across legs.

Forcing such strategies into Strategy.S either requires generalising the contract to a multi-bar stream and a multi-leg signal type (invasive, touches all eleven existing strategies and the live engine and the backtester), or producing a series of asymmetric workarounds (one strategy decides, others are "slaves"; or pair logic is bolted onto the execution side as a saga). Both lose the textbook canonical pattern that the reference application is meant to show.

A second issue: even with a generalised strategy contract, the execution side of a multi-leg strategy is not a sequence of single-instrument signals. It is a target → diff(actual, target) → trade-list flow, where the diff has to be computed atomically per book. Account is the wrong place to host that — its ubiquitous language is holdings (what I have), not targets (what I want); its invariants are accounting invariants (cash ≥ 0, reservations ≤ buying power), not portfolio- construction invariants (target weights well-formed, hedge symmetry preserved, risk caps respected).

The canonical decomposition for systematic trading platforms (LEAN's Algorithm Framework, Nautilus' Portfolio / RiskEngine, the buy-side middle-office / front-office split) is a four-stage pipeline:

Alpha / Signal       →  Portfolio Construction  →  Risk          →  Execution
single-instrument        multi-instrument target    pre-trade        order
forecast / signal        portfolio (vector)         clipping         routing

The current codebase has Alpha (strategy) and a primitive Execution (broker + the live engine), but no Portfolio Construction layer at all. This ADR introduces it as its own bounded context.

#Decision

Add a new bounded context portfolio_management as a peer to account and broker. The BC owns the target-portfolio lifecycle and the construction-time risk-shaping that goes with it. Its boundaries are:

The BC's domain language has two first-class portfolio models, not target plus view:

Both are domain models with behavioural invariants. Read-side projections of either are view-models living in the BC's application/view_models/ layer; the view-models are the CQRS-shaped DTOs, not the actual portfolio model itself. account.Portfolio and portfolio_management.{Target,Actual}_portfolio are distinct concepts — the BC boundary isolates the vocabulary. account knows nothing about targets; PM never mutates account's ledger.

The BC follows the project's hexagonal + per-aggregate layout (ADR 0001, 0006). Layer libraries are split per feedback_layer_libraries_enforced-style enforcement, with unidirectional compile-time deps:

portfolio_management/
├── lib/
│   ├── domain/                                # library `portfolio_management`
│   │   ├── shared/                            # cross-aggregate VOs
│   │   │   ├── book_id.{ml,mli,mlw}
│   │   │   ├── pair.{ml,mli,mlw}
│   │   │   ├── hedge_ratio.{ml,mli,mlw}
│   │   │   ├── z_score.{ml,mli,mlw}
│   │   │   ├── target_position.{ml,mli,mlw}
│   │   │   ├── target_proposal.{ml,mli,mlw}
│   │   │   └── trade_intent.{ml,mli,mlw}
│   │   ├── target_portfolio/
│   │   │   ├── target_portfolio.{ml,mli,mlw}  # aggregate root
│   │   │   └── events/target_set.{ml,mli,mlw}
│   │   ├── actual_portfolio/
│   │   │   ├── actual_portfolio.{ml,mli,mlw}  # aggregate root
│   │   │   ├── values/actual_position.{ml,mli,mlw}
│   │   │   └── events/actual_fill_committed.{ml,mli,mlw}
│   │   ├── reconciliation/
│   │   │   ├── reconciliation.{ml,mli,mlw}    # pure domain service
│   │   │   └── events/trades_planned.{ml,mli,mlw}
│   │   ├── risk/
│   │   │   ├── values/risk_limits.{ml,mli,mlw}
│   │   │   └── risk_policy.{ml,mli,mlw}       # construction-time clipping
│   │   ├── portfolio_construction.{ml,mli,mlw} # module type S
│   │   └── pair_mean_reversion/                # one S impl
│   │       ├── pair_mean_reversion.{ml,mli,mlw}
│   │       ├── values/{pair_mr_config,pair_mr_state}.{ml,mli,mlw}
│   │       └── events/target_proposed.{ml,mli,mlw}
│   ├── application/
│   │   ├── view_models/                       # library .view_models
│   │   ├── integration_events/                # library .integration_events
│   │   ├── domain_event_handlers/             # library .domain_event_handlers
│   │   └── commands/                          # library .commands
│   │       (set_target / commit_actual_fill / reconcile,
│   │        each as the wire-format DTO + handler +
│   │        workflow triplet)
│   └── infrastructure/
│       └── acl/external_integration_events/    # library .acl.external_integration_events
│           (mirror of Account's Reservation_filled IE —
│            atomic fill fact; cash and position
│            advance together preserving
│            equity = cash + Σ qty × mark)
└── test/
    ├── unit/
    └── component/                             # Gherkin BDD scenarios

Key choices that fall out of this decision are:

#Construction-policy abstraction introduced from day one

Portfolio_construction.S is a module type defined in portfolio_construction.{ml,mli,mlw} at the domain root. pair_mean_reversion includes it. The abstraction is introduced at v1, with one concrete implementation, by direct analogy with Strategy.S in the strategy BC: every Portfolio Construction policy will be a state machine consuming bars and occasionally emitting Target_proposal.t, and the reference application demonstrates the canonical pattern even before the second implementation lands.

#Book_id partitioning from day one

Every PM command, event, and aggregate carries Shared.Book_id.t. A book is a logical partition for an independently-managed portfolio (one book per running strategy instance, or one per trading account in a future multi-account setup). The aggregates are per-book; Target_portfolio.apply_proposal rejects cross-book mismatch; the reconciler is strictly per-book. Adding the partition later would mean rewriting every command and event, so it is in from the start.

#Risk-as-policy lives inside PM

Construction-time risk clipping (per-instrument notional cap, gross-exposure cap with proportional scaling that preserves hedge symmetry, future drawdown haircut) is part of building the target proposal. It lives in domain/risk/, applied as a transformation Target_proposal → Target_proposal between the policy's emission and the aggregate's apply.

This is risk-as-policy: how big a position the policy is allowed to want. It is distinct from risk-as-gatekeeper: pre-trade order validation, fat-finger thresholds, kill switches, real-time drawdown breach. Risk-as-gatekeeper applies to every order regardless of source — manual trades, future strategies, debug paths — and belongs to a separate future BC. PM's risk sub-system has soft-clipping semantics; the gatekeeper has hard-veto semantics. Mixing them would conflate two different failure-mode classes.

#Actual_portfolio is a domain model, not a CQRS read-model

The observed state of the book within PM is built from inbound integration events, but it bears its own invariants and is the input to Reconciliation.diff — a domain operation, not a read-side query. Calling it a "view" or "projection" would mis-classify it: views are what application/view_models/ holds; domain models are what enforces invariants and participates in business operations. Both Target_portfolio and Actual_portfolio are aggregate roots; the asymmetry that one is fed by external events does not change its model status.

#shared/ instead of core/ for cross-aggregate VOs

The BC depends on the external core library (Instrument, Side, Candle from the strategy BC's domain). With dune (include_subdirs qualified), a sibling sub-directory named core/ would be wrapped as a local Core module that shadows the external library inside any file that references both. The sub-directory holding the cross-aggregate VOs is therefore named shared/ — same role (Eric Evans' "shared kernel" within a BC), no name collision.

#portfolio_construction.{ml,mli,mlw} flat at the domain root

The module type definition lives as a flat triple at portfolio_management/lib/domain/, alongside the aggregate sub-directories (target_portfolio/, actual_portfolio/, pair_mean_reversion/, reconciliation/, risk/). Placing it inside a portfolio_construction/ parent sub-directory next to a pair_mean_reversion/ child sub-directory would trigger dune's sub-directory main-module collapse rule (ADR 0006): a child referencing the parent's main file is rejected as a cycle. The flat placement is the simplest correct shape — the module type is a contract definition, not an aggregate — and it follows the strategy BC's strategies/strategy.{ml,mli} precedent of placing the abstraction next to its implementations rather than wrapping them.

#Alternatives considered

#Merge target portfolio into the existing account BC

Reuse account.Portfolio and add target-tracking fields beside its existing cash / positions / reservations.

Rejected because the two halves have different ubiquitous languages (Portfolio = "what I have" vs Portfolio = "what I want

#Generalise Strategy.S to multi-instrument

Change the strategy contract so on_bar accepts a multi-bar context and returns a multi-leg target.

Rejected because it touches all eleven existing strategies, the backtester, the live engine, the indicator pipeline, the registry, and the CLI — and ends with the same target-vs-actual reconciliation problem that this BC solves anyway. The multi-instrument path also conflates the alpha-generation role (per-instrument signals) with the portfolio-construction role (allocation across signals), which the LEAN-style canonical decomposition keeps separate. Strategy stays single-instrument, PM is the new layer.

#Composite-over-strategies inside strategy

Introduce a new combinator under strategy/lib/domain/strategies/ that wraps two child strategies operating on different instruments and "coordinates" them.

Rejected because for pair mean reversion the legs are not independent decision-makers — each leg is a deterministic decomposition of one decision (the spread's z-score crossing). Calling them "two strategies" inflates the meaning of strategy to include "position holder", which weakens the abstraction. For genuine multi-policy overlays (dual momentum, risk-parity across independent alphas), the construct needed is a portfolio strategy emitting a target vector — exactly what Portfolio_construction.S is. The composite framing therefore converges on the same shape, just under a different name; PM introduces the right shape directly.

#Actual_portfolio as a CQRS read-model

Treat the observed state inside PM as a pure read-side projection with no behaviour, used only as input to a stateless reconciler.

Rejected because the projection enforces invariants (delta accumulation, single-valuedness per instrument, idempotent apply); a read-model that enforces invariants is by definition a domain model. Treating it as a view would also mis-classify Reconciliation.diff from a domain operation into a read-side query, blurring the layer boundaries the BC is meant to make explicit.

#Defer the Portfolio_construction.S abstraction until a second policy lands

Ship pair_mean_reversion as a concrete module with no abstract interface; introduce the module type when β-hedging or vol-targeting arrives.

Rejected for the reference-application case: the project's role is to demonstrate canonical patterns, and the Strategy.S-with-eleven-implementations precedent makes the abstraction the natural shape from day one. The cost of introducing the module type now is one short file; the cost of retrofitting it across an established pair_mean_reversion public surface later is a noticeable refactor of every caller.

#Consequences

Easier:

Harder:

To watch for:

#References