Skip to content

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.