0029. Account Holdings To Virtual Fund Allocation Policy
Status
Accepted - first implementation landed
This ADR defines the business logic and implementation plan for allocating real
account holdings into virtual-fund holdings. The first implementation moves
virtual-fund identity and storage into core msm, adds a pure vector planner,
adds deterministic input resolution from PositionSetTable.uid, and adds an
apply step for feasible plans. The remaining schema follow-up is adding
VirtualFundTable.account_target_allocation_uid so virtual-fund identity is
relational, not only encoded in the deterministic business key.
Context
Account holdings are the canonical record of what the account actually holds:
AccountTable
<- AccountHoldingsSetTable
<- AccountHoldingsStorage
time_index
account_uid
asset_identifier
quantity
direction
Account target allocations describe mandate intent:
AccountTargetAllocationTable
<- PositionSetTable
<- TargetPositionsStorage
target_type = asset | portfolio
asset_uid
portfolio_uid
weight_notional_exposure
constant_notional_exposure
single_asset_quantity
Virtual funds are allocation views over real account holdings:
VirtualFundTable
account_uid
target_portfolio_uid
VirtualFundHoldingsSetTable
virtual_fund_uid
source_account_holdings_set_uid
VirtualFundHoldingsStorage
virtual_fund_uid
asset_identifier
allocated_quantity
direction
The allocation service is the business logic that turns:
real account holdings
+ account target allocation / PositionSet
+ portfolio weights
+ valuations / NAV
+ allocation policy
= virtual-fund allocation rows
The service must not invent virtual-fund holdings from hardcoded quantities. Portfolio construction does not imply how much of an account should be funded into a virtual fund. That funding decision belongs to an explicit account allocation policy derived from the account's target allocation.
Principle
AccountHoldingsStorage remains canonical custody state. Virtual-fund holdings
are a derived allocation view over those holdings. They must be reproducible
from a source holdings set, a target position set, portfolio weights,
valuation outputs, and an explicit allocation policy.
The allocation service must first produce a dry-run allocation plan. Publishing
VirtualFundHoldingsStorage rows is a separate apply step.
Core Example
Assume:
Account holds:
10 BTC
20 ETH
Valuation numeraire:
USD cash asset represented by AssetTable.uid = USD_ASSET_UID
Simple spot valuation inputs:
BTC = 60,000 units of USD_ASSET_UID
ETH = 2,000 units of USD_ASSET_UID
Account NAV:
BTC value = 10 * 60,000 = 600,000 units of USD_ASSET_UID
ETH value = 20 * 2,000 = 40,000 units of USD_ASSET_UID
account_nav = 640,000 units of USD_ASSET_UID
Account target row:
target_type = portfolio
weight_notional_exposure = 10%
constant_notional_exposure = null
portfolio_uid = P
Portfolio P weights:
BTC = 40%
ETH = 60%
The virtual fund should not receive arbitrary BTC or ETH quantities. In this weight-based case, its total notional is:
portfolio_sleeve_notional = account_nav * 10%
= 640,000 * 10%
= 64,000 USD
That sleeve notional is expanded through the portfolio weights:
BTC notional = 64,000 * 40% = 25,600 USD
ETH notional = 64,000 * 60% = 38,400 USD
BTC quantity = 25,600 / 60,000 = 0.4266666667 BTC
ETH quantity = 38,400 / 2,000 = 19.2 ETH
If the account target row uses a fixed notional instead:
target_type = portfolio
weight_notional_exposure = null
constant_notional_exposure = 50,000 USD
portfolio_uid = P
then the sleeve notional is independent of NAV:
portfolio_sleeve_notional = 50,000 USD
and the expanded asset demand is:
BTC notional = 50,000 * 40% = 20,000 USD
ETH notional = 50,000 * 60% = 30,000 USD
BTC quantity = 20,000 / 60,000 = 0.3333333333 BTC
ETH quantity = 30,000 / 2,000 = 15 ETH
For the weight-based example, the account has enough BTC and ETH to fund the virtual-fund target:
available BTC = 10 BTC
required BTC = 0.4266666667 BTC
residual BTC = 9.5733333333 BTC
available ETH = 20 ETH
required ETH = 19.2 ETH
residual ETH = 0.8 ETH
Those residual holdings remain real account holdings. They may stay unallocated, be used to satisfy direct asset targets, or be used by other virtual-fund targets depending on the full account target allocation and allocation policy.
Deterministic Resolution Paths
The planner intentionally starts from one authoritative row:
PositionSetTable.uid. It does not accept account_uid,
source_account_holdings_set_uid, or portfolio weight sources as primary
inputs.
Account and source holdings are resolved through one path:
PositionSetTable.uid = position_set_uid
-> PositionSetTable.account_target_allocation_uid
-> AccountTargetAllocationTable.uid
-> AccountTargetAllocationTable.account_uid
-> AccountTable.uid
-> AccountHoldingsSetTable rows for that account
The default holdings selection policy is exact:
AccountHoldingsSetTable.account_uid = derived account_uid
AccountHoldingsSetTable.time_index = valuation_time
AccountHoldingsSetTable already has a unique (account_uid, time_index) path,
so the default policy produces at most one source holdings set. If the selected
policy resolves zero or more than one source holdings set, the planner must fail
before loading holdings rows.
Portfolio weights are resolved from the portfolio target row:
TargetPositionsStorage.portfolio_uid
-> PortfolioTable.uid
-> PortfolioTable.portfolio_index_uid
-> IndexTable.uid
-> IndexTable.unique_identifier
-> PortfolioWeightsStorage rows
where portfolio_index_identifier = IndexTable.unique_identifier
and time_index = valuation_time
If PortfolioTable.portfolio_index_uid is missing, the referenced IndexTable
row is missing, or no portfolio weights exist for the selected time, the planner
fails by default. Latest-available weights may be accepted only when
allocation_policy explicitly allows that selection rule, and the resolved
weight timestamp must be recorded in diagnostics.
Inputs
The allocation planner requires:
| Input | Type | Required relationship / FK check | Meaning |
|---|---|---|---|
position_set_uid |
uuid.UUID |
Must exist as PositionSetTable.uid; PositionSetTable.account_target_allocation_uid -> AccountTargetAllocationTable.uid; AccountTargetAllocationTable.account_uid -> AccountTable.uid. |
Concrete target allocation snapshot to satisfy. This is the authoritative account context. |
valuation_time |
timezone-aware UTC datetime.datetime |
Used to select valuations, conversions, portfolio weights, and target/holdings snapshots. It must be normalized to UTC before planning. | Time at which NAV and target quantities are calculated. |
valuation_asset_uid |
uuid.UUID |
Must exist as AssetTable.uid; this asset is the canonical valuation numeraire. |
Asset used for account NAV, fixed notional targets, quantity conversion, and diagnostics. |
holdings_selection_policy |
typed policy object | Must resolve exactly one AccountHoldingsSetTable row for the account derived from position_set_uid; default is exact AccountHoldingsSetTable.time_index == valuation_time. |
Selects the canonical source holdings snapshot without accepting a raw holdings-set UID. |
valuation_resolver |
batch protocol / callable | Planner calls it with requested valuation metrics, resolved source holdings, target notional demands, valuation_time, valuation_asset_uid, and valuation rules from allocation_policy. For this allocation planner, nav is required. |
Owns instrument valuation, metric calculation, NAV contribution, and notional-to-quantity conversion. |
allocation_policy |
typed policy object, default proportional_attribution |
Must define attribution mode, shortage behavior, residual behavior, leverage permission, stale data tolerance, rounding tolerance, and idempotency mode. | Governs how real holdings are attributed when holdings and target demand do not match exactly. |
Resolver contracts:
valuation_resolver(
requested_metrics: collection[str],
source_holdings: collection[HoldingValuationInput],
target_notional_demands: collection[TargetNotionalDemand],
*,
valuation_time: datetime,
valuation_asset_uid: uuid.UUID,
valuation_policy: ValuationPolicy,
)
-> AllocationValuation
required metric for this planner:
"nav"
examples of future metrics using the same protocol:
"var"
"vol"
"duration"
"dv01"
HoldingValuationInput
asset_uid: uuid.UUID
asset_identifier: str
quantity: Decimal | float
direction: 1 | -1
source_row: mapping
TargetNotionalDemand
target_row_key: str
asset_uid: uuid.UUID
asset_identifier: str
notional_value: Decimal | float
direction: 1 | -1
AllocationValuation
metrics: dict[str, ValuationMetricResult]
valuation_asset_uid: uuid.UUID
valuation_asset_identifier: str
target_quantity_demands: list[TargetQuantityDemand]
diagnostics: list[ValuationDiagnostic]
ValuationMetricResult
metric: str
total: ValuationMetricValue
lines: list[ValuationMetricLine]
ValuationMetricValue
value: Decimal | float | mapping
valuation_asset_uid: uuid.UUID | None
as_of: datetime
source: str | None
ValuationMetricLine
line_key: str
asset_uid: uuid.UUID | None
asset_identifier: str | None
source_row_key: str | None
target_row_key: str | None
value: Decimal | float | mapping
valuation_asset_uid: uuid.UUID | None
as_of: datetime
source: str | None
TargetQuantityDemand
target_row_key: str
asset_uid: uuid.UUID
asset_identifier: str
requested_signed_quantity: Decimal | float
direction: 1 | -1
requested_notional: Decimal | float
asset_uid is the authoritative valuation subject. The planner may provide the
resolved AssetTable.unique_identifier as metadata because current
asset-indexed storage is keyed by asset_identifier, but the resolver contract
must not use ticker, ISIN, FIGI, or provider symbols as the primary asset key.
valuation_asset_uid is the authoritative numeraire. If a valuation provider
needs ISO-4217 currency codes or provider symbols, that mapping belongs inside
the resolver from AssetTable.uid to provider-specific identifiers. The planner
contract stays on canonical asset identity. ValuationPolicy is the valuation
sub-policy carried by allocation_policy; it is not another top-level planner
input.
The valuation protocol is metric-based. The allocation service currently
requires the nav metric because portfolio sleeve notional is defined from NAV
or fixed notional exposure. Other consumers can request metrics such as var,
vol, duration, or dv01 through the same resolver without changing the
resolver interface. A resolver may reject unsupported metrics explicitly.
Metric results must support both totals and line results. Totals are used for
account-level decisions such as account NAV. Lines are used for audit,
reconciliation, and per-asset allocation checks. A line can be tied to an
asset_uid, a source holding row, a target demand row, or any combination that
is meaningful for the requested metric.
The valuation resolver owns instrument-specific valuation. For a simple spot
asset, it may compute quantity * spot_price. For a derivative, structured
product, accrued-income instrument, or any other non-linear exposure, it may use
contract metadata, model configuration, curves, greeks, multipliers, accrued
interest, or other domain inputs. The allocation planner must not encode those
cases.
Weights are signed notional weights. A negative weight produces short demand.
Weights do not need to sum to 1.0; if they sum above 1.0, the target
portfolio is leveraged and must be allowed by policy.
The planner must load:
PositionSetTable row
where uid = position_set_uid
AccountTargetAllocationTable row
from PositionSetTable.account_target_allocation_uid
AccountHoldingsSetTable row
by derived account_uid and holdings_selection_policy
AccountHoldingsStorage rows
where holdings_set_uid = resolved AccountHoldingsSetTable.uid
TargetPositionsStorage rows
where position_set_uid = position_set_uid
Portfolio weights
for every portfolio target row
AssetTable rows
for every held asset_identifier and every target asset_uid
Valuations
for source holdings and target notional demands
NAV Calculation
Default account NAV is provided by the valuation resolver at valuation_time
in valuation_asset_uid:
valuation_result = valuation_resolver(requested_metrics=("nav",), ...)
account_nav = valuation_result.metrics["nav"].total.value
For auditability, the resolver must also return NAV line results whose sum
reconciles to account_nav within policy tolerance. For normal asset holdings,
those lines should be keyed by asset_uid and source row. For instruments that
cannot be naturally decomposed per asset, the resolver may return a model-level
line with asset_uid=None and explicit diagnostics:
account_nav = sum(valuation_result.metrics["nav"].lines[*].value)
Rules:
- Missing valuation data is an error by default.
- Missing conversion into
valuation_asset_uidis an error by default. - Negative or zero NAV is an error for weight-based allocation unless the caller explicitly allows it.
- Cash should be represented as a normal asset row or an explicit future cash balance input. The initial service must not silently invent cash.
- Stale valuations are accepted only when the caller provides a valuation policy that allows latest-available data.
Target Demand Expansion
Target rows produce demand against the account NAV or explicit quantity.
Direct asset rows:
target_type = asset
Direct asset rows describe the desired direct account exposure. They are not virtual-fund allocations, but they must be considered when deciding what source holdings remain available for virtual funds.
Portfolio rows:
target_type = portfolio
Portfolio rows produce virtual-fund demand. For each portfolio target row:
portfolio_sleeve_notional =
account_nav * weight_notional_exposure
or constant_notional_exposure
expanded_asset_notional =
portfolio_sleeve_notional * portfolio_weight(asset_uid)
expanded_signed_quantity =
valuation_result.target_quantity_demands[target_row_key].requested_signed_quantity
direction =
1 if expanded_asset_notional >= 0
-1 if expanded_asset_notional < 0
single_asset_quantity is valid for direct asset targets only. It is not a
valid portfolio target exposure unless a later ADR defines portfolio units.
If portfolio weights are leveraged or short, the sign of
portfolio_sleeve_notional * portfolio_weight(asset_uid) determines the
requested target direction. The sign is preserved on the claim. Under
proportional_attribution, opposite-signed virtual-fund claims do not net
against each other. Direct account targets are not fill competitors; the direct
account sleeve is the balancing residual after virtual-fund allocation.
Allocation Pools
The planner builds signed account holding vectors from real account holdings:
AccountHoldingsStorage.asset_identifier
-> AssetTable.unique_identifier
-> AssetTable.uid
holding key = asset_uid
signed_account_holding = sum(quantity * direction)
gross_source_capacity = sum(abs(quantity))
The planner builds a virtual-fund target matrix from portfolio target rows:
virtual_target[virtual_fund_uid, asset_uid] = requested_signed_quantity
It also builds the aggregate desired direct account target:
direct_target[asset_uid] = sum(requested_signed_quantity for direct asset rows)
The direct target is diagnostic intent. It does not enter the virtual-fund fill denominator. The direct sleeve is computed later as the residual required to make the account reconcile to real holdings.
The planner builds target demands:
direct demand:
from target_type = asset
virtual-fund demand:
from target_type = portfolio expanded through portfolio weights
The planner must compare virtual-fund demand against the asset-level gross source capacity. This matters because multiple virtual funds can compete for the same BTC, ETH, cash, or signed exposure budget. Direct account targets do not compete for that fill; they are measured against the residual direct sleeve.
The planner also builds virtual-fund allocation claims. A claim is a desired virtual-fund exposure against one asset-level capacity vector:
claim key = (claim_type, claim_uid, asset_uid)
claim_type:
virtual_fund_target
claim_uid:
virtual_fund_uid for virtual-fund targets
These claims are not custody. They are virtual-fund exposure lines carved from the account. After they are calculated, the direct account sleeve is whatever signed exposure remains:
direct_sleeve[asset_uid] =
signed_account_holding[asset_uid]
- sum(virtual_allocation[virtual_fund_uid, asset_uid])
All vectors are signed:
requested_signed_quantity > 0 long exposure
requested_signed_quantity < 0 short exposure
requested_abs_quantity = abs(requested_signed_quantity)
Allocation Policy Model
allocation_policy must be an explicit typed object. It should not be a loose
set of flags:
AllocationPolicy
mode: "proportional_attribution" | "strict_feasible"
quantity_tolerance: Decimal | float
valuation_tolerance: Decimal | float
residual_policy: "leave_as_account_residual"
leverage_policy: "attribute_without_borrow" | "reject"
shortage_policy: "proportional_target_gap" | "fail"
stale_valuation_policy: "reject" | "allow_latest"
stale_weight_policy: "reject" | "allow_latest"
rounding_policy: "none" | future named policy
idempotency_mode: "replace_same_allocation_run" | "fail_if_existing"
The default policy is:
mode = "proportional_attribution"
residual_policy = "leave_as_account_residual"
leverage_policy = "attribute_without_borrow"
shortage_policy = "proportional_target_gap"
stale_valuation_policy = "reject"
stale_weight_policy = "reject"
rounding_policy = "none"
idempotency_mode = "replace_same_allocation_run"
strict_feasible is the validation mode. It sets shortage_policy = "fail" and
requires every claim to be fully filled before apply.
Strict Feasible Policy
The strict validation policy is:
strict_feasible
For every asset_uid:
sum(abs(virtual_target[virtual_fund_uid, asset_uid]))
<= gross_source_capacity + tolerance
If this condition fails for any pool, the planner returns a failed allocation
plan with deficits and must not publish VirtualFundHoldingsStorage rows.
If the condition passes:
- Allocate each portfolio-expanded demand to its corresponding virtual fund.
- Compute the direct account sleeve as the residual balance.
- Report direct target gap and any residual diagnostics.
This policy avoids silently changing target weights, borrowing assets, selling residual assets, or allocating holdings without a documented policy.
strict_feasible is useful as a validation policy and for tests that require an
exact target fit. It is not sufficient as the practical attribution policy for
live accounts, because real accounts can be underfunded or drifted. In those
cases the virtual fund should carry a target gap instead of making the planner
unusable.
Proportional Attribution Policy
The default practical non-trading attribution policy is:
proportional_attribution
This policy never invents assets, trades, borrows, or changes custody. It allocates the virtual-fund target matrix first and computes the direct account sleeve as the residual balance. Direct account targets are not part of the fill ratio. They are only used to measure how far the balancing direct sleeve is from the desired direct exposure.
This must be implemented as vectorized pandas operations over asset_uid and
virtual_fund_uid, not line-by-line source matching. The planner should build
grouped holding vectors, grouped target matrices, per-asset scale factors, and
then merge/broadcast those vectors back to result frames.
For each asset_uid:
H = signed_account_holding
= sum(quantity * direction)
C = gross_source_capacity
= sum(abs(quantity))
V*_f = desired signed virtual-fund target for virtual fund f
G = sum(abs(V*_f) for every virtual fund f)
scale = min(1, C / G) if G > 0 else 0
V_f = V*_f * scale
D = H - sum(V_f)
virtual_fund_gap_f = V*_f - V_f
direct_gap = direct_target - D
balance invariant:
D + sum(V_f) = H
Example:
direct target requested = 7 BTC
virtual fund requested = 5 BTC
available = 10 BTC
H = 10
C = 10
G = abs(5) = 5
scale = 1
virtual fund holding = 5 BTC
direct account sleeve = H - 5 = 5 BTC
direct target gap = 7 - 5 = 2 BTC
virtual fund gap = 5 - 5 = 0 BTC
Opposite-signed direct and virtual targets do not net and do not share a fill ratio, because the direct target is not a fill competitor:
direct target requested = -7 BTC
virtual fund requested = 5 BTC
available = 10 BTC
H = 10
C = 10
G = abs(5) = 5
scale = 1
virtual fund holding = 5 BTC
direct account sleeve = H - 5 = 5 BTC
direct target gap = -7 - 5 = -12 BTC
virtual fund gap = 5 - 5 = 0 BTC
The policy must never compute abs(-7 + 5) or otherwise let opposite-signed
direct and virtual targets offset each other. It must also never include direct
targets in the virtual-fund fill denominator. The direct sleeve is always the
balancing residual.
The result must be reported as account-virtual holding lines:
claim_type
claim_uid
asset_uid
asset_identifier
requested_direction
requested_signed_quantity
allocated_signed_quantity
target_gap_signed_quantity
requested_abs_quantity
allocated_abs_quantity
target_gap_abs_quantity
requested_notional
allocated_notional
target_gap_notional
scale
For claim_type = virtual_fund_target, allocated_abs_quantity is the quantity
published into VirtualFundHoldingsStorage, and requested_direction
determines the storage direction.
VirtualFundHoldingsStorage.allocation_strategy records the allocation policy
mode used to create the row as first-class storage state, not as nested
extra_details metadata.
target_gap_signed_quantity remains diagnostic tracking error for that virtual
fund target. For claim_type = direct_account_residual, no virtual-fund row is
published; the line records the account's balancing direct sleeve and target
gap.
Current VirtualFundHoldingsStorage is indexed by (time_index,
virtual_fund_uid, asset_identifier), not by direction. If the virtual target
matrix produces both long and short rows for the same virtual fund, asset, and
time, the apply step must fail or require a storage-contract change. It must
not silently net opposite target directions into one published row.
More Assets Than Needed
When the account has more of an asset than the virtual-fund allocation needs:
gross_source_capacity > virtual_gross_demand
The extra quantity stays in the direct account sleeve through the balance equation. It is not pushed into a virtual fund unless a future policy explicitly says how residuals should be assigned.
The plan must report residuals:
asset_uid
asset_identifier
signed_account_holding
gross_source_capacity
virtual_gross_demand
virtual_allocated_signed_quantity
direct_sleeve_signed_quantity
direct_target_signed_quantity
direct_target_gap_signed_quantity
residual_notional
Residuals are expected when virtual-fund target exposures consume less than the account's holdings, when the account holds non-target assets, or when holdings drift away from the target.
Not Enough Assets
When virtual funds request more gross exposure for an asset than the account can
support, behavior depends on allocation_policy:
gross_source_capacity < virtual_gross_demand
Under strict_feasible, the plan fails and reports deficits. Under
proportional_attribution, the plan remains usable: each virtual-fund claim
receives its proportional share of the available gross source capacity, and the
target gap is carried as tracking error on that virtual-fund claim. The direct
account sleeve is still computed afterward as the residual balance.
The plan must report target gaps:
asset_uid
asset_identifier
gross_source_capacity
virtual_gross_demand
scale
target_gap_abs_quantity
target_gap_notional
affected_virtual_funds
virtual_fund_holding_lines
direct_sleeve_line
The service must not create virtual-fund holdings for assets the account does not hold unless a future execution workflow explicitly models trades, cash, borrow, or financing.
Optional future policies may include:
priority_waterfall
allow_cash_purchase
allow_short_borrow
reject_short_virtual_exposure
Those policies must be named and documented before implementation. They must return tracking-error diagnostics because they intentionally deviate from the target allocation.
Multiple Virtual Funds
One account target allocation can contain multiple portfolio target rows. Each portfolio target row should resolve or create a virtual fund for the account and target portfolio.
Demand is a matrix:
virtual_fund_uid x asset_uid x asset_identifier -> requested_signed_quantity
When multiple virtual funds require the same asset pool, strict_feasible
requires aggregate feasibility:
sum(abs(requested_signed_quantity) for all virtual funds)
<= gross_source_capacity
If strict feasibility passes, each virtual fund receives its full requested
quantity. If it fails under strict_feasible, the plan fails and reports which
funds are competing for the constrained asset. Under proportional_attribution,
each competing virtual fund receives its proportional share of the constrained
gross source capacity, and each fund line carries its own target gap. The direct
account sleeve is the balancing residual after those virtual-fund allocations.
VirtualFund Identity Gap
The current virtual-fund table records:
VirtualFundTable
unique_identifier
account_uid
target_portfolio_uid
That is enough to publish holdings, but it does not make the account allocation mandate explicit. The canonical virtual-fund creation path should create or reuse one virtual fund per:
account_target_allocation_uid
target_portfolio_uid
So the follow-up schema migration should add:
VirtualFundTable.account_target_allocation_uid
-> AccountTargetAllocationTable.uid
and enforce uniqueness on (account_target_allocation_uid, target_portfolio_uid).
No allocation-run table is required.
Package Boundary
This ADR changes the intended ownership of virtual funds.
VirtualFundTable, VirtualFundHoldingsSetTable, and
VirtualFundHoldingsStorage should become core msm account-allocation
models. A virtual fund is an account allocation view over real account holdings;
it is not portfolio construction state. Keeping it in core lets account
allocation, direct sleeve residuals, target gaps, and virtual-fund holdings be
implemented in one account-domain service without making core msm import
msm_portfolios.
Target core locations:
src/msm/models/accounts/virtual_funds.py
VirtualFundTable
VirtualFundHoldingsSetTable
src/msm/data_nodes/accounts/virtual_funds/storage.py
VirtualFundHoldingsStorage
src/msm/services/accounts/account_virtual_allocations.py
plan_account_virtual_fund_allocations(...)
apply_account_virtual_fund_allocation_plan(...)
Today src/msm/services/accounts.py is a module, not a package. Implementing
the target service path requires converting it into:
src/msm/services/accounts/__init__.py
src/msm/services/accounts/core.py
src/msm/services/accounts/account_virtual_allocations.py
That import-layout change should be done directly, without compatibility helper modules, unless a later compatibility ADR explicitly requires them.
msm_portfolios remains the portfolio construction engine. It should continue
to own portfolio calculation DataNodes, portfolio weights, signals, rebalance
strategies, contributed price/signal nodes, and portfolio examples. The account
allocation planner may use a portfolio-target expansion boundary supplied by
msm_portfolios, but the vector allocation equation and virtual-fund apply
step belong to core msm.
The dependency direction remains:
msm_portfolios -> msm
Core msm must not import msm_portfolios. If the planner needs portfolio
weights, it should receive or call an explicit portfolio target expansion
protocol that can be implemented by msm_portfolios at the workflow boundary.
This supersedes the part of ADR 0019 that placed virtual funds under
msm_portfolios. msm_portfolios should be treated as the construction engine;
virtual funds should be treated as core account allocation state.
Service Shape
The service is split into a canonical planner and an apply step.
Planner:
plan_account_virtual_fund_allocations(
*,
position_set_uid,
valuation_time,
valuation_asset_uid,
holdings_selection_policy,
valuation_resolver,
allocation_policy,
) -> AccountVirtualFundAllocationPlan
These are the service inputs. Raw holdings, raw target demands, account UID,
holdings-set UID, account NAV, repository context, scan limits, and custom
input resolvers are not public planner inputs. The service resolves them from
position_set_uid.
The service resolves:
PositionSetTable.uid
-> AccountTargetAllocationTable.uid
-> AccountHoldingsSetTable(account_uid, valuation_time)
-> AccountHoldingsStorage rows
-> TargetPositionsStorage rows
-> AssetTable identity rows
-> portfolio-target expansion boundary
-> valuation resolver
The public planner resolves the account/target/asset relationship graph from
the required inputs. Portfolio target rows are expanded internally through the
registered portfolio index and PortfolioWeightsStorage at valuation_time.
The planner performs no writes. It returns:
AccountVirtualFundAllocationPlan
status = feasible | attributed_with_target_gap | infeasible
account_uid
source_account_holdings_set_uid
account_nav
source_holdings
direct_target_demands
virtual_fund_demands
account_virtual_holding_lines
virtual_fund_allocations
residuals
target_gaps
deficits
diagnostics
Apply step:
apply_account_virtual_fund_allocation_plan(
plan,
*,
data_node=None,
run=False,
)
The apply step may:
- Create or reuse
VirtualFundTablerows. - Create holdings-set records.
- Build a
VirtualFundHoldingsStorageframe. - Attach and optionally publish through
VirtualFundHoldings.
The existing VirtualFund.allocate_from_account_holdings_set(...) should remain
a low-level explicit-allocation helper. It must not be the policy engine.
Validation Rules
The planner must validate:
position_set_uidresolves throughAccountTargetAllocationTableto exactly one account;holdings_selection_policyresolves exactly oneAccountHoldingsSetTablerow for the account derived fromposition_set_uid;- exactly one exposure field is present on each target row;
- portfolio target rows do not use
single_asset_quantity; - every portfolio target resolves to portfolio weights at
valuation_time; - every held and target asset resolves to an
AssetTablerow; - every held and target asset has valuation output;
- source holdings quantities are positive and direction is
1or-1; - requested signed target quantities are non-zero;
proportional_attributioncomputes per-asset scale factors from virtual-fund gross demand only and never includes direct account targets in the fill denominator;- published virtual-fund storage quantities are positive and derive from
abs(allocated_signed_quantity)with storagedirectioncarrying the requested target sign; - strict feasibility holds before apply only when
allocation_policy.mode == "strict_feasible"; - no virtual-fund output allocation exceeds the per-asset gross source capacity after scaling;
- direct account sleeve plus virtual-fund allocations reconciles to signed account holdings for every asset;
- plan output is deterministic for the same inputs.
Edge Cases
Target Exposures Sum To Less Than 100%
The unallocated portion remains residual account holdings. It is reported but not assigned to a virtual fund.
Target Exposures Sum To More Than 100%
This implies leverage in the desired target allocation. Under
proportional_attribution, the planner may still attribute only available real
holdings and report the resulting target gaps. Actual borrowing or financing is
not allowed unless an explicit execution policy exists.
Account Holds Non-Target Assets
Non-target holdings remain residual. They do not fund portfolio targets unless an execution/rebalance workflow converts them.
Portfolio Needs Asset Not Held By Account
Under proportional_attribution, allocate zero for that claim and report a full
target gap. Under strict_feasible, fail with a deficit. Do not synthesize a
holding or assume the account can trade into the asset.
Portfolio Weight Is Negative
The desired virtual-fund allocation is short. Under proportional_attribution,
the claim can receive a negative virtual holding as long as the virtual-fund
gross demand fits inside the asset's gross source capacity after scaling. For
example, if the account has 10 BTC and the short virtual-fund target is
-5 BTC, the planner can attribute -5 BTC to that virtual fund. The direct
account sleeve becomes +15 BTC so that +15 + (-5) = +10. Under
strict_feasible, the absolute virtual-fund desired quantity must fit inside
the available gross source capacity unless a later policy rejects short virtual
exposures explicitly.
Account Has Short Holdings But Long Target
For attribution, virtual-fund target claims are signed rows in the virtual target matrix. The direct account target does not compete with them. The direct account sleeve is the residual balance against the signed account holding. Execution or borrow workflows may later decide how to trade or finance the mismatch; the allocation planner only attributes available virtual-fund capacity and reports target gaps.
Missing Portfolio Weights
Reject by default. Latest-available weights can be used only under an explicit valuation policy.
Missing Valuations Or Conversion
Reject by default. The planner cannot compute NAV, target notionals, or target
quantities without valuation output in valuation_asset_uid.
Rounding And Lot Sizes
The initial planner should use exact floating quantities and expose optional rounding as a later policy. Rounding must report residuals and tracking error.
Existing Virtual-Fund Allocations
The planner must account for existing allocation rows from the same source holdings set. Replacing the same allocation run should be idempotent; creating a second allocation over the same source and target must not double allocate the same source holdings.
Documentation Requirements
The implementation is incomplete unless the following documents are updated:
docs/ADR/0019-msm-portfolios-package-boundary.mddocs/knowledge/msm/accounts/virtual_funds.mddocs/knowledge/msm_portfolios/portfolios/index.mddocs/knowledge/msm/accounts/index.md- account and virtual-fund examples under
examples/msm/andexamples/msm_portfolios/ - packaged skills for account, portfolio, and valuation-resolver workflows
The virtual-fund docs must show that allocated quantities come from the planner, not from arbitrary hardcoded example payloads.
Implementation Plan
- [x] Define the pure allocation-plan data models: source holdings, target demand, expanded portfolio demand, account-virtual holding lines, residuals, target gaps, deficits, and diagnostics.
- [x] Define the allocation policy model with
proportional_attributionas the default andstrict_feasibleas a validation mode. - [x] Add implementation support for account holdings, target position sets, deterministic portfolio expansion, and valuation behind the canonical planner inputs.
- [x] Move virtual-fund identity and holdings storage into core
msmaccount modules:src/msm/models/accounts/virtual_funds.pyandsrc/msm/data_nodes/accounts/virtual_funds/storage.py. - [x] Convert
src/msm/services/accounts.pyinto a package and place the planner insrc/msm/services/accounts/account_virtual_allocations.py. - [x] Keep
msm_portfoliosas the portfolio construction engine and expose a portfolio-target expansion boundary instead of making coremsmimport portfolio construction modules. - [x] Implement
plan_account_virtual_fund_allocations(...)with no writes. - [x] Add deterministic virtual-fund identity rules for
account/portfolio/target-allocation combinations through
virtual_fund_unique_identifier_for_target(...). - [ ] Add
VirtualFundTable.account_target_allocation_uidand uniqueness on(account_target_allocation_uid, target_portfolio_uid)in the next schema migration. No allocation-run table is required. - [x] Implement the apply step that converts a feasible plan into
VirtualFundHoldingsStoragerows. - [x] Keep
VirtualFund.allocate_from_account_holdings_set(...)as a low-level explicit allocation publisher, not the policy engine. - [x] Add focused planner tests for exact fit, excess/direct residual, insufficient holdings, multiple funds competing for the same asset, opposite-signed direct targets, and short virtual-fund targets.
- [x] Add resolver-level tests for deterministic virtual-fund identity, portfolio target expansion, notional-to-quantity valuation conversion, missing portfolio expansion, and planner execution through an input resolver.
- [x] Add a virtual-fund allocation example that starts with account holdings and account target allocation, runs the planner, displays the plan, and only then applies it as an extension of the full account workflow.
- [x] Update account, portfolio, and virtual-fund documentation with the documented planner flow and edge-case behavior.
Non-Goals
- Do not execute trades.
- Do not rebalance account holdings.
- Do not infer cash purchases.
- Do not borrow assets for short allocation.
- Do not allocate residual holdings without an explicit policy.
- Do not make portfolio construction create virtual funds.
- Do not make virtual funds canonical custody.