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 TradeStrike.Pipeline.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 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; 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.

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 — float drift on cumulative PnL is unacceptable.

Account.csusing TradeStrike.Pipeline.Trading;

public sealed record Account(
    AccountId Id,
    string DisplayName,
    string Currency,
    decimal CashValue,
    decimal RealizedPnL,
    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 AccountMode Mode { get; }             // read-through from Id
    public decimal Equity { get; }               // CashValue + UnrealizedPnL

    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 — useful for a "resyncing…" indicator. The NewSim factory gives a freshly-created sim account: zero PnL, no margin, full buying power equal to starting cash.

Extended metrics

The base Account stays minimal — the figures every venue supplies and the UI binds hottest. Richer figures a venue may publish (margin decomposition, prop-firm risk, activity counts, options exposure) ride alongside in an optional AccountMetrics record, surfacing the NinjaTrader-parity Accounts-grid columns 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. Derived figures (total PnL, net liquidation) are intentionally absent — consumers compute them from the base Account fields, so a stored copy can never drift from the authoritative source.
AccountMetrics.csusing TradeStrike.Pipeline.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

    // 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 — exactly what the row view-model's diff relies on to repaint.

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 TradeStrike.Pipeline.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. On a closed/removed account, emit AccountRemovedEvent — no further events for that id will follow.