Contracts
Portfolio envelope
Every output of a PortfolioWorkerBase is stamped by PortfolioEnvelope.Stamp:
| Extension | Value |
|---|---|
exchange |
the venue config, else the worker's default venue |
portfolioworld |
the world config: act (default) or hyp.<NAME> for a paper or testnet account |
clocktype |
the clocktype config, else wall for act and historical for hyp.<NAME>; a paper account on live time sets clocktype=wall |
eventtime, ce-time |
Outgoing.EventTime (the venue's time), else the input's eventtime, else its ce-time, else now |
walltime |
now |
ingesttime |
copied from the input (when the WebSocketManager received the frame) |
A portfolio event never carries scenarioid, runid or strategyworld: an
account is shared by every scenario whose portfolio leg uses it. It never
carries marketworld or marketuniverse either: an account is not market
data.
Config
| Key | Default | Meaning |
|---|---|---|
account |
required | The logical account name, lowercase (main, paper); one worker instance per account |
world |
act |
act or hyp.<NAME> (the portfolio world and topic prefix) |
venue |
the worker's default | Topic segment and exchange |
clocktype |
from the world | wall or historical |
Credentials are never config. They are worker secrets, referenced by name.
Topics
Every portfolio topic is per account, and the asset, symbol, entry or order travels in the payload:
| Entity | Topic | ce-type | State key |
|---|---|---|---|
| account | <world>.portfolio.<venue>.account.update.<account> |
com.virtufin.portfolio.<venue>.account.update |
|
| account | <world>.portfolio.<venue>.account.snapshot.<account> |
com.virtufin.portfolio.<venue>.account.snapshot |
<world>.portfolio.<venue>.account.<account> |
| balance | <world>.portfolio.<venue>.balance.changed.<account> |
com.virtufin.portfolio.<venue>.balance.changed |
<world>.portfolio.<venue>.balance.<account> |
| holding | <world>.portfolio.<venue>.holding.changed.<account> |
com.virtufin.portfolio.<venue>.holding.changed |
<world>.portfolio.<venue>.holding.<account> |
| ledger | <world>.portfolio.<venue>.ledger.posted.<account> |
com.virtufin.portfolio.<venue>.ledger.posted |
none: the history lives in the portfolio database |
| openorder | <world>.portfolio.<venue>.openorder.changed.<account> |
com.virtufin.portfolio.<venue>.openorder.changed |
<world>.portfolio.<venue>.openorder.<account> |
ce-subject is account/<account> throughout. <world> is act or
hyp.<NAME>. Build every name with the *Topics classes, never by hand.
The account sits last in the topic, not in the path, because the naming library renders every path segment into the ce-type: a per-account path would make the ce-type differ per account.
Payloads
All payloads are JSON objects with snake_case fields, decimals as JSON
numbers and times as RFC3339 UTC (2026-10-25T12:00:00.000Z). Optional
fields are omitted when unknown.
| Contract | Payload |
|---|---|
Balance |
{ asset, free, locked, total }; total = free + locked, derived on decode |
Holding |
{ symbol, qty, avg_price?, market_value?, unrealized_pnl?, currency? }; qty signed |
LedgerEntry |
{ entry_id, kind, asset, delta, time, reference? }; delta signed |
OpenOrder |
{ order_id, symbol, side, type, qty, filled_qty, status, time, price?, client_order_id? } |
AccountSnapshot |
{ account, time, balances: [...], holdings: [...], open_orders: [...] } |
Ledger kind is one of deposit, withdrawal, transfer, fill, fee,
dust, dividend, interest, other. Open-order status is one of new,
partially_filled, filled, canceled, expired, rejected, closed.
closed means the order vanished between two snapshots and its final status
is unknown. Decoding rejects any other value.
Assets and symbols are uppercase. A symbol is a canonical pair (BTC_USD) or
a ticker (AAPL). A venue that reports balances only, like Binance spot,
publishes no holdings.
Snapshot, then stream
A worker pulls the venue's REST snapshot on start and after every
WebSocketManager reconnect, then applies the push stream's deltas.
PortfolioDiff.Between(previous, next) returns what changed between two
snapshots:
- new or changed balances, holdings and orders;
- an asset gone from the snapshot, as a zero balance;
- a holding gone from the snapshot, as quantity zero;
- an order gone from the snapshot, with status
closed.
The worker publishes those changes and the new snapshot, never a replay of unchanged state.
Writing a venue worker
public sealed class MyPortfolioWorker : PortfolioWorkerBase
{
public MyPortfolioWorker() : base("myportfolio", "myvenue")
{
// AccountProcess folds venue frames into (Balance, venue time) changes.
Bind(new AccountProcess()
.FromCloudEvents((ce, payload) => DecodeVenueFrame(payload))
.ToCloudEvents((ce, change) =>
[
new Outgoing(
BalanceJson.Encode(change.Balance),
BalanceTopics.Type(Venue(ce)),
BalanceTopics.Topic(PortfolioConfig.World(ce), Venue(ce), PortfolioConfig.Account(ce)),
BalanceTopics.Subject(PortfolioConfig.Account(ce))) { EventTime = change.VenueTime },
]));
}
}
Keep the venue decoding and the account state pure and unit-tested, separate from the CloudEvent glue.