Position-Aware Portfolio Accounting
Position-aware accounting is the opt-in msm_portfolios workflow for portfolios
that must track instrument quantities, currency balances, receivables, payables,
and position-driven cash flows. It is appropriate for dividends, funding,
coupons, exercise, expiry, settlement, and user-defined lifecycle economics that
cannot be represented truthfully as weighted asset returns.
The existing PortfoliosDataNode remains the default weight-only engine. Enabling
accounting creates a separate configuration identity and output history; it never
converts or appends to a legacy weight-only portfolio.
Current implementation status
The canonical ledger, reference reducer, coordinated signal-to-strategy
execution simulation, explicit instrument sizing and settlement terms,
strategy-owned execution costs, dividend recognition and settlement,
explicit price/FX valuation, ledger restart, read projections, storage
schemas, and migration 0017 are implemented. Issue #11 removed the
erroneous external execution ingress; there is no replay lane or fallback.
Correction-tail publication, optimized reducer parity, projection publishers,
and option/bond acceptance fixtures remain implementation work inside
msm_portfolios. There is no mainsequence-sdk blocker.
The architectural contract is recorded in ADR 0042.
Architecture And Ownership
flowchart LR
SW["Signal weights
TimeIndexTableUpdater or ref"]
MO["Execution-market observations
prices, liquidity, terms"]
RS["RebalanceStrategy
pure simulated sizing + costs"]
LF["Lifecycle observations
TimeIndexTableUpdater(s) or refs"]
VM["Price and FX observations
TimeIndexTableUpdater(s) or refs"]
LM["LifecycleEventModel(s)
pure, directly injected"]
PA["PortfolioAccounting
pure state reducer"]
PE["PortfolioEngine
TimeIndexTableUpdater"]
EL["PortfolioEventLedgerStorage
authoritative TimeIndexMetaTable"]
RP["Ledger-derived projections
state, cash flows, NAV/returns"]
SW --> RS
MO --> RS
RS -->|internal simulated execution facts| PE
LF --> LM --> PE
VM --> PE
PE --> PA --> PE
PE --> EL
EL -. validated restart .-> PA
EL --> RP
The responsibilities are strict:
PortfolioEngineowns dependencies, source loading, orchestration, canonical validation, and publication of one authoritative table.- A Portfolio is a backtest model with no Account, custody, broker orders, trades, fills, or actual account holdings.
PortfolioAccountingis an in-memory deterministic reducer. It is not aTimeIndexMetaTable, updater, or extension point.LifecycleEventModelimplementations turn declared observations into typed, columnar event records. They do not write storage or mutate the reducer.PositionValuationModelvalues the complete position/cash/obligation state at an economic timestamp using explicit observations.PortfolioEventLedgerStorageis restart authority. State, cash-flow, weight, and value tables are rebuildable projections.- All simulated accounting and deterministic restart behavior belongs to
msm_portfolios. The existing SDK updater, MetaTable, and migration contracts are sufficient.
Selecting The Correct Portfolio Path
| Requirement | Engine |
|---|---|
| Weighted asset-return portfolio with turnover commission | Existing PortfoliosDataNode |
| Existing saved portfolio with no accounting configuration | Existing PortfoliosDataNode |
| Quantities, currency cash, receivables, payables, or lifecycle cash flows | PortfolioEngine |
| Signal targets converted into simulated quantities and costs | PortfolioEngine coordinated RebalanceStrategy path |
| User-defined position-dependent economics | Inject a LifecycleEventModel into PortfolioEngine |
Broker executions, account trades, custody holdings, and actual cash are not a Portfolio path. They belong to the account domain even when an Account uses a Portfolio as allocation intent.
An omitted PortfolioBuildConfiguration.accounting_configuration and an
explicit None both preserve the legacy serialized configuration and hash. An
enabled accounting configuration passed to PortfoliosDataNode is rejected;
there is no silent downgrade to weight-only valuation.
Target Configuration
PortfolioAccountingConfiguration is hash-bearing. Every value that can change
economic events, state, or valuation belongs in this configuration or in a
versioned model configuration.
PortfolioBuildConfiguration
backtesting_weights_configuration
signal_weights_instance
rebalance_strategy_instance
accounting_configuration
valuation_asset_identifier
initial_nav
initial_state_time_index
position_valuation_model_instance
lifecycle_event_model_instances
The signal and RebalanceStrategy already owned by
BacktestingWeightsConfig are the only execution decision path. Accounting does
not accept an execution source, Account, broker, order, trade, fill, or holdings
dependency. The removed external-execution field is rejected as an unknown
configuration key; it has no alias or compatibility fallback.
Configure position sizing, settlement, and execution-time costs on the existing rebalance strategy:
from msm_portfolios.rebalance_strategy import (
ImmediateSignal,
InstrumentExecutionSpec,
ProportionalExecutionCostModel,
TargetWeightExecutionModel,
)
strategy = ImmediateSignal(
execution_model_instance=TargetWeightExecutionModel(
instrument_specs=(
InstrumentExecutionSpec(
asset_identifier="STOCK-EUR",
quantity_unit="shares",
target_measure="market_value_weight",
contract_multiplier=1.0,
quantity_step=1.0,
price_asset_identifier="EUR",
settlement_style="cash",
terms_version="stock-v1",
),
)
),
execution_cost_model_instances=(
ProportionalExecutionCostModel(rate=0.001),
),
)
TargetWeightExecutionModel sizes every compatible Asset in one vectorized
timestamp batch from the immutable post-lifecycle state and NAV. Every Asset
with nonzero desired exposure or a position to close requires an
InstrumentExecutionSpec; an unchanged zero target is not economically
required. The engine never guesses its unit,
multiplier, lot step, quote Asset, target measure, or settlement style.
For each rebalance timestamp the strategy receives the signal targets, desired
and current exposures, immutable positions, free settled balances by currency,
post-lifecycle NAV, price/FX observations, canonical valuation references, and
the prior execution-progress state. Assets are partitioned by a stable
vectorization signature—target measure, unit, quote Asset, multiplier, lot step,
terms version, and settlement style—then each compatible partition is sized and
costed as one vector.
settlement_style="cash" posts trade consideration. For a linear perpetual
whose position has zero fair value between variation-margin events,
settlement_style="variation_margin" changes contract quantity without
deducting full notional cash. Funding remains a lifecycle event.
Before publishing, the referenced PortfolioTable row and every Asset used by
positions, cash, prices, or FX must already exist. Persisted Asset dimensions use
canonical asset_identifier values that reference
AssetTable.unique_identifier; there is no unique_identifier input fallback.
Initial State
The current opening-state contract is deliberately narrow:
initial_navmust be finite and strictly positive.initial_state_time_indexmust be timezone-aware.- The opening book is exactly
initial_navunits of settled cash invaluation_asset_identifier. - Restart does not create another opening contribution. It reconstructs state from the canonical ledger.
Supplying arbitrary opening positions or obligations is not currently a public contract.
Required Input Contracts
All timestamps are economic UTC timestamps. Inputs retain their truthful grains; the engine does not reshape unrelated sources into one artificial index.
Internal Simulated Execution Facts
The configured RebalanceStrategy produces a batch of deterministic simulated
execution facts from signal targets, market observations, and the
post-lifecycle/pre-execution accounting state. This batch is an internal typed
boundary to PortfolioAccounting; it is not an upstream table or broker feed.
One row represents one simulated signed execution fact.
| Field | Required | Meaning |
|---|---|---|
time_index |
Yes | Economic execution time |
execution_identifier |
Yes | Stable simulated execution identity |
source_revision |
Yes | Deterministic revision of the signal, observations, terms, strategy, and input state |
asset_identifier |
Yes | Executed instrument Asset |
quantity_delta |
Yes | Signed executed quantity; buy positive, sell negative |
quantity_unit |
Yes | Explicit unit such as shares or contracts |
execution_price |
Yes | Price per declared quantity unit |
price_asset_identifier |
Yes | Asset in which the execution price is quoted |
position_identifier |
No | Stable position state identity; defaults to the Asset identifier |
observed_at |
Yes | Availability time of the selected execution mark |
The economic identity (execution_identifier, source_revision) must be unique.
Settlement and cost legs are explicit. The engine must not infer spot cash
consideration, futures settlement, multipliers, or inverse-contract behavior
from an Asset type or ticker.
Price Observations
MarketPriceValuationModel expects grain
(time_index, asset_identifier) with:
| Field | Meaning |
|---|---|
price |
Finite price selected by price_column |
price_asset_identifier |
Quote Asset of that price |
source_revision |
Reproducible observation revision |
The most recent observation at or before the accounting timestamp is selected
only within maximum_staleness. Missing or stale marks for held positions fail
the event.
FX Observations
FX grain is
(time_index, base_asset_identifier, quote_asset_identifier) with:
| Field | Meaning |
|---|---|
rate |
Units of quote Asset per one unit of base Asset |
source_revision |
Reproducible observation revision |
For a USD portfolio, (EUR, USD, 1.10) means one EUR is worth 1.10 USD. The
rate must be finite and strictly positive. The engine requires the exact
base-to-valuation pair; it does not infer or invert another pair. Valuation-Asset
cash uses a rate of one and needs no FX row.
Dividend Observations
The built-in DividendCashFlowModel accepts grain
(time_index, source_event_identifier, source_revision) with:
| Field | Meaning |
|---|---|
observed_at |
When this source revision became available |
asset_identifier |
Instrument whose holders may be entitled |
entitlement_time_index |
Economic cutoff used to snapshot eligible quantity |
payment_time_index |
Economic settlement time |
amount_per_unit |
Signed dividend amount per eligible instrument unit |
settlement_asset_identifier |
Asset delivered at payment |
One observation expands into two linked transitions:
- At entitlement, eligible quantity creates a receivable and recognizes income.
- At payment, the receivable is reduced and settled cash increases.
The receivable survives sale of the originating shares. A buyer entering after the entitlement cutoff receives nothing. Payment exchanges one balance for another and therefore does not recognize the income a second time.
Small EUR Dividend / USD Valuation Example
Run the complete offline fixture without backend writes:
uv run --extra portfolios python \
examples/msm_portfolios/portfolio_cashflows_and_fx_valuation_example.py
The fixture produces this economic sequence:
| Time | Event | Result |
|---|---|---|
| 2026-01-01 | Opening state | USD 1,000 settled cash |
| 2026-01-02 | Buy | +10 STOCK-EUR, EUR -500 cash |
| 2026-01-03 | Entitlement | EUR +10 receivable; USD +11 recognized P&L at EUR/USD 1.10 |
| 2026-01-04 | Sell | Position closes; receivable remains |
| 2026-01-05 | Settlement | EUR +10 cash and EUR -10 receivable; zero additional P&L |
The example prints event-level USD NAV/P&L, ending positions, obligations and
currency balances, completed cash-flow rows, and normalized USD portfolio
values. It also exposes the frames through build_example() for tests.
Linear Perpetual / Funding Example
Run the variation-margin fixture:
uv run --extra portfolios python \
examples/msm_portfolios/portfolio_perpetual_funding_example.py
The first signal targets 100% notional exposure at USD 100 and creates ten
contracts without a USD 1,000 trade-consideration debit; its one-percent
commission reduces NAV to USD 990 exactly once. At the next timestamp, a
vectorized PositionCashFlowModel applies USD 100 of funding in
pre_execution. The same-time signal is sized from the resulting USD 890 NAV,
changes the position to eight contracts, and applies a USD 2 commission once. A
second funding event changes cash and NAV with no rebalance. The fixture uses a
custom PositionValuationModel because a variation-margined contract has
different value semantics from a cash-settled share; settlement style and
valuation model must agree.
Economic Ordering
The reducer scans actual economic timestamps from signal/strategy observations, lifecycle events, and requested valuation observations. It never creates a generic frequency grid or uses job time as an economic timestamp.
Within one timestamp, current processing is:
- lifecycle events in
pre_execution; - the configured rebalance strategy emits and accounting applies internal
simulated execution facts in
execution; - lifecycle events in
post_execution; and - an optional valuation marker.
Lifecycle models are ordered by explicit economic_ordering_priority and then
stable model identifier. Events whose results depend on each other require an
explicit causal ordering contract; the complete causal-DAG/atomic-group engine
is still pending and must not be represented as already implemented.
historical_information_policy.mode="as_known" is the implemented mode. A
candidate whose observed_at is later than its economic time_index is rejected
to prevent look-ahead. Corrected-history calculation is intentionally rejected
until deterministic correction-tail publication exists.
Canonical Event Ledger
PortfolioEventLedgerStorage is a PlatformTimeIndexMetaTable with grain:
(time_index, portfolio_identifier, event_identifier,
event_revision, record_identifier)
An event is one indivisible state transition represented by one or more typed
economic records plus exactly one valuation_summary record.
Supported record kinds are:
| Record kind | Meaning |
|---|---|
position_delta |
Signed change to an instrument position |
cash_delta |
Signed settled or restricted cash movement |
obligation_delta |
Signed receivable, payable, or deliverable change |
cost |
Execution-time cost posting |
lifecycle_state |
Versioned model-owned state transition |
execution_progress |
Execution simulation progress fact |
event_marker |
Economic event with no direct balance posting |
valuation_summary |
One event-level NAV-before, NAV-after, and recognized-P&L summary |
Important envelope and integrity fields include:
event_identifier: stable economic identity derived from portfolio, producer, source, and local event identity;event_revision: deterministic revision derived from source/model revision and input state;event_sequenceandrecord_sequence: resolved processing order;event_record_countandevent_digest: proof that the complete event group is present and unchanged;input_state_identifierandledger_state_identifier: state-chain lineage;source_identifier,source_revision,model_identifier, andmodel_version: reproducibility lineage; andvaluation_references: canonical references to price and FX observations.
recognized_pnl, nav_before, and nav_after belong to the summary record.
A cash movement is not automatically P&L: a trade exchanges cash for an
instrument, and dividend payment exchanges a receivable for cash.
Retry, Restart, And Corrections
Exact retries are idempotent. Reprocessing the same economic event with the same source revision produces the same identity and does not apply the balance change twice. Before publication, existing and calculated event digests are compared.
PortfolioAccounting.from_ledger(...) reconstructs positions, cash,
obligations, execution progress, applied revisions, latest NAV, and event
sequence from a complete active ledger. It validates:
- one portfolio identity;
- contiguous event and record sequences;
- record counts and event digests;
- exactly one valuation summary per event;
- the input/output state-identifier chain; and
- the configured opening timestamp, currency, and NAV.
Changed or deleted source revisions currently stop with an explicit tail-replay
error. They are never ignored or applied on top of the old state. Appending
superseding/cancellation revisions and publishing the corrected portfolio tail
remain msm_portfolios work; no SDK feature is needed.
Read Projections
The public pure projections consume only the ledger:
from msm_portfolios.accounting import (
project_cash_flows,
project_portfolio_values,
project_state,
)
state = project_state(ledger)
cash_flows = project_cash_flows(ledger)
values = project_portfolio_values(ledger, initial_nav=1_000_000.0)
project_statereturns end-of-timestamp position, cash, and obligation quantities by stablestate_identifier. It preserves explicit zero rows withis_closed=True.project_cash_flowsreturns completedcash_deltaandcostrecords only. Future payments remain obligations and do not appear as cash.project_portfolio_valuesselects the final summary per economic timestamp and derives normalizedclose = nav_after / initial_navand linked returns.
PortfolioStateStorage and PortfolioCashFlowsStorage are additive schemas
installed by migration 0017. Dedicated projection publisher updaters are not
yet implemented; callers must not describe the schemas as independent accounting
authorities.
User-Defined Cash Flows And Lifecycle Events
Users extend lifecycle economics by directly injecting a module-level,
importable LifecycleEventModel. There is no string registry or built-in
fallback.
Use PositionCashFlowModel when every candidate produces one settled cash
posting. Override its vectorized calculate_cash_deltas(...) method. Use the
full LifecycleEventModel when an event also changes positions, obligations,
deliverables, or model-owned state.
The supported full-model hooks are:
declared_dependencies()
required_input_contracts()
dependency_window(...)
alignment_contracts()
lifecycle_state_contract()
select_event_candidates(...)
vectorization_keys(...)
build_event_batch(...)
Models receive validated source batches and an immutable accounting-state view.
They return a flat EventBatch with NumPy-compatible columns and event offsets.
They must not query hidden data, read wall-clock time, mutate reducer state, or
write any ledger/projection table.
Run the custom vectorized example:
uv run --extra portfolios python \
examples/msm_portfolios/portfolio_custom_cashflow_model_example.py
It defines a user-owned EUR usage royalty outside msm_portfolios, injects it
directly, processes two compatible source events in one vectorized model call,
and values their effect in USD using the shared FX observations.
Failure Semantics
Accounting mode is strict. Common failures mean:
| Error | Cause |
|---|---|
| Missing fresh valuation | A held position has no mark within maximum_staleness |
| Missing explicit FX | A non-valuation Asset has no direct FX observation into the valuation Asset |
| Duplicate grain coordinates | A declared source contains more than one row at its stated grain |
| Observation would cause look-ahead | observed_at is later than the economic event in as_known mode |
| Broken event digest or state chain | Ledger records are incomplete, modified, or incorrectly ordered |
| Corrected source revision requires tail replay | A source changed and correction publication is not yet implemented |
Enabled accounting rejected by PortfoliosDataNode |
The accounting configuration was sent to the legacy weight-only engine |
The engine does not convert any of these conditions into a zero cash flow, stale valuation, inverse FX guess, or weight-only fallback.
Migration And Validation
Migration 0017 adds the canonical ledger and rebuildable state/cash-flow
schemas. Apply it through the repository's existing SDK-managed migration
deployment workflow before runtime attachment. This is an ordinary deployment
step, not an SDK development dependency.
For changes to this accounting surface, run at least:
uv run --extra portfolios --extra dev ruff check \
src/msm_portfolios/accounting \
src/msm_portfolios/data_nodes/portfolios \
examples/msm_portfolios \
tests/msm_portfolios/accounting
uv run --extra portfolios --extra dev pytest \
tests/msm_portfolios/accounting/test_accounting.py
uv run --extra portfolios --extra dev mkdocs build --strict \
--site-dir /private/tmp/msmarkets-docs-site
Before a release, also run the complete repository test suite and the backward-compatibility acceptance matrix defined in ADR 0042.