Accounts & balances

Trading providers

Accounts & balances

What a provider must publish for an account: a core financial snapshot, an optional extended metrics record, and, for multi-asset venues, per-wallet balances.

AccountId & mode

An account is identified by a composite AccountId with three axes: the provider that hosts it, the provider's own opaque account identifier, and the operational mode. All three participate in equality and hash, so the SAME provider account-id used across modes never aliases.

AccountId.csusing SabrTrader.Pipeline.Venues.Trading;

public readonly record struct AccountId(string ProviderKey, string ProviderAccountId, AccountMode Mode)
{
    public static readonly AccountId None;                 // sentinel "no account"
    public bool IsEmpty { get; }
    public string VenueKey { get; }                        // ProviderKey without its ":qualifier"
    public AccountId ToVenueScoped();                      // same account, connection-independent
    public bool SameVenueAccount(AccountId other);         // same venue + account id + mode
    public override string ToString();                    // "providerKey/accountId/mode"
    public static bool TryParse(string? text, out AccountId id);
}

public enum AccountMode
{
    Live,   // real broker, real money — UI badges red, adds a confirm step over a threshold
    Paper,  // broker-side sandbox (same adapter as Live) — UI badge amber
    Sim,    // local in-process simulation, persisted on disk — UI badge blue
}

ToString renders the stable "providerKey/accountId/mode" form used in log lines and workspace references ("(no account)" for None); TryParse round-trips it so a selected account survives restart. The mode is load-bearing: routing sends Sim accounts to the built-in simulator and Live/Paper to the broker adapter, and the UI colour-codes for visual safety.

A provider key can carry a per-connection qualifier ("rithmic:83f8..."). VenueKey drops it, and ToVenueScoped() returns the same account keyed by venue alone. Store the venue-scoped id for anything the trader configures and expects to outlive a connection (copier groups, saved selections): deleting and re-adding a connection mints a new qualifier. Resolve it back to a live, connection-scoped id before trading with it. SameVenueAccount compares two ids across connections.

The Account snapshot

Account is an immutable record. Providers emit a fresh instance on every meaningful change (deposit, realized-PnL crystallization, unrealized mark-to-market) via an AccountSnapshotEvent; the UI binds to whichever instance is currently in the service's cache. Every money axis is decimal, so cumulative PnL carries no floating-point drift.

Account.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record Account(
    AccountId Id,
    string DisplayName,
    string Currency,
    decimal CashValue,
    decimal? RealizedPnL,   // null when the venue has not reported session realized PnL
    decimal UnrealizedPnL,
    decimal MarginUsed,
    decimal BuyingPower,
    DateTime UpdatedUtc)
{
    public bool IsSnapshot { get; init; }       // true when projected from a broker SNAPSHOT frame
    public AccountMetrics? Metrics { get; init; } // optional extended metrics (null when not supplied)
    public int UnrealizedExcludedCount { get; init; }      // open positions left out of UnrealizedPnL
    public bool PrefersDisplayNameInSelector { get; init; } // account id is synthetic: show DisplayName

    public AccountMode Mode { get; }             // read-through from Id
    public decimal Equity { get; }               // CashValue + UnrealizedPnL
    public bool IsUnrealizedPartial { get; }     // UnrealizedExcludedCount > 0
    public string SelectorLabel { get; }         // the label account pickers show

    public static Account NewSim(AccountId id, string displayName, string currency, decimal startingCash);
}

Equity (= CashValue + UnrealizedPnL) and Mode are computed conveniences. IsSnapshot distinguishes a broker-side snapshot frame (the first PnL frame after subscribe, or a fresh one after reconnect) from a live delta, which drives a "resyncing" indicator. The NewSim factory gives a freshly-created sim account: zero PnL, no margin, full buying power equal to starting cash.

UnrealizedExcludedCount is how many open positions could not be valued in the account's currency; when it is above zero, IsUnrealizedPartial is true, UnrealizedPnL and Equity are incomplete, and the MaxTotalLoss risk rule refuses new risk with UnrealizedPnLPartial instead of trusting the figure. SelectorLabel is the text account pickers show: the provider account id for Live and Paper accounts (brokers often repeat one display name across sub-accounts), and DisplayName for Sim accounts or when the provider sets PrefersDisplayNameInSelector because its account id is a synthetic token such as "spot".

RealizedPnL is today's realized PnL as the broker reports it. Whether fees are already deducted is a venue property, declared on the provider as bool? RealizedPnLIncludesCommissions: true means net (the built-in simulator), false means gross price PnL (Rithmic), and null means undeclared, in which case consumers show no net figure derived from it. See Venue-reported fees.

Extended metrics

The base Account holds the figures every venue supplies and the UI binds most often. Richer figures a venue may publish (margin decomposition, prop-firm risk, activity counts, options exposure) ride alongside in an optional AccountMetrics record, which feeds the extended columns of the Accounts grid without coupling the model to any one broker.

Every field is nullable and defaults to null. Populate only what your venue actually reports; a column bound to a field you don't supply renders blank. Consumers compute derived figures (total PnL, net liquidation) from the base Account fields, so they always agree with the authoritative source.
AccountMetrics.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record AccountMetrics
{
    // Cash / balances
    public decimal? TotalCashBalance { get; init; }
    public decimal? GrossRealizedPnL { get; init; }   // gross closed PnL BEFORE commissions
    public decimal? TotalCommissions { get; init; }   // cumulative commission today
    public decimal? AccruedInterest { get; init; }    // margin/credit interest accrued
    public decimal? Financing { get; init; }          // swap / rollover financing (forex)

    // Margin / buying-power decomposition
    public decimal? MaintenanceMargin { get; init; }
    public decimal? ExcessBuyMargin { get; init; }
    public decimal? ExcessSellMargin { get; init; }
    public decimal? UsedBuyingPower { get; init; }
    public decimal? ReservedBuyingPower { get; init; }

    // Risk / prop-firm eval
    public decimal? DailyLossLimit { get; init; }
    public decimal? DrawdownPercentUsed { get; init; }   // 0–100
    public decimal? TrailingDrawdownFloor { get; init; }
    public string?  AutoLiquidate { get; init; }         // broker passthrough ("yes"/"no"/empty)

    // Activity counts
    public int? NetQuantity { get; init; }
    public int? OpenPositions { get; init; }
    public int? FilledBuys { get; init; }
    public int? FilledSells { get; init; }
    public int? WorkingBuys { get; init; }
    public int? WorkingSells { get; init; }

    // Options exposure
    public decimal? LongOptionValue { get; init; }
    public decimal? ShortOptionValue { get; init; }
}
publishing metricsvar acct = baseAccount with
{
    Metrics = new AccountMetrics
    {
        MaintenanceMargin = 1_320m,
        UsedBuyingPower   = 6_600m,
        NetQuantity       = -2,
        OpenPositions     = 1,
        DailyLossLimit    = 1_000m,
        DrawdownPercentUsed = 42.5m,
    }
};
Emit(new AccountSnapshotEvent(DateTime.UtcNow, acct));

Because AccountMetrics is an immutable, value-equal record, an Account whose only change is a metric flip is != the previous one, which is what a row view-model's diff relies on to repaint.

Position metrics (derivatives & options)

The Account-level AccountMetrics has a per-position twin: an optional PositionMetrics on Position.Metrics. It carries figures that only make sense per instrument: position-level financing, an options detail block, and (for perpetual/leveraged venues) a nested DerivativeMetrics. Every field is nullable; populate only what your venue reports.

PositionMetrics.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record PositionMetrics
{
    public decimal? Financing { get; init; }              // position-level swap / financing
    public DerivativeMetrics? Derivative { get; init; }   // perp / leverage detail (below)

    // Options detail (null on non-option positions)
    public bool?     OptionIsCall { get; init; }
    public decimal?  OptionStrike { get; init; }
    public DateOnly? OptionExpiry { get; init; }
    public string?   OptionUnderlyingSymbol { get; init; }
    public bool?     OptionCanExerciseEarly { get; init; }
}

public sealed record DerivativeMetrics
{
    public decimal?     MarkPrice { get; init; }
    public decimal?     LiquidationPrice { get; init; }
    public decimal?     Leverage { get; init; }
    public MarginMode?  MarginMode { get; init; }         // Cross | Isolated
    public decimal?     PositionMarginUsed { get; init; }
    public decimal?     FundingRate { get; init; }
    public DateTime?    NextFundingTime { get; init; }
    public decimal?     UnrealizedFunding { get; init; }
    public decimal?     PaidFunding { get; init; }
}

public enum MarginMode { Cross, Isolated }

Per-asset balances

A futures account holds one cash figure (Account.CashValue). A crypto-spot account holds many wallets (BTC, ETH, USDT), each an AccountBalance. Providers with multi-asset wallets declare the AssetBalances capability and emit a BalanceSnapshotEvent carrying the full set of non-zero balances (snapshot semantics, so a consumer rebinds without merging deltas). Futures providers simply never declare it.

AccountBalance.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record AccountBalance
{
    public string  Asset     { get; }   // upper-cased, e.g. "BTC", "USDT"
    public decimal Total     { get; }   // available + reserved
    public decimal Available { get; }   // free — spendable now
    public decimal Reserved  { get; }   // derived: Total − Available

    // asset is trimmed + upper-cased; throws on blank asset, negative figures,
    // or available > total (reserved can never be negative).
    public static AccountBalance Create(string asset, decimal total, decimal available);
}
emitting balancesvar balances = new[]
{
    AccountBalance.Create("BTC",  total: 0.75m, available: 0.50m),  // 0.25 reserved in orders
    AccountBalance.Create("USDT", total: 5_000m, available: 5_000m),
};
Emit(new BalanceSnapshotEvent(DateTime.UtcNow, accountId, balances));

What a provider must publish

  1. On StartAsync, expose discovered accounts via the Accounts snapshot and emit an AccountSnapshotEvent per account (declare AccountDiscovery).
  2. On every financial change, emit a fresh Account instance. Set IsSnapshot on a broker snapshot frame; leave it false for live deltas.
  3. Populate Metrics with whatever your venue reports; leave unknown fields null.
  4. For multi-asset venues, declare AssetBalances and emit BalanceSnapshotEvent with the full non-zero set whenever a wallet changes.
  5. For an account that comes up after the connect's BooksSyncedEvent (created or enabled while connected), emit its AccountSnapshotEvent, publish every open order and position it holds, then raise one AccountBookSyncedEvent for it. Only then does a position you do not state on that account count as a position it does not hold.
  6. On a closed/removed account, emit AccountRemovedEvent and raise nothing further for that id.