Accounts & balances
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.
On this page
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.
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
- On
StartAsync, expose discovered accounts via theAccountssnapshot and emit anAccountSnapshotEventper account (declareAccountDiscovery). - On every financial change, emit a fresh
Accountinstance. SetIsSnapshoton a broker snapshot frame; leave it false for live deltas. - Populate
Metricswith whatever your venue reports; leave unknown fields null. - For multi-asset venues, declare
AssetBalancesand emitBalanceSnapshotEventwith the full non-zero set whenever a wallet changes. - For an account that comes up after the connect's
BooksSyncedEvent(created or enabled while connected), emit itsAccountSnapshotEvent, publish every open order and position it holds, then raise oneAccountBookSyncedEventfor it. Only then does a position you do not state on that account count as a position it does not hold. - On a closed/removed account, emit
AccountRemovedEventand raise nothing further for that id.