Gateway interfaces

Brokerage gateways

Gateway interfaces

The three sub-gateways in detail — data, trading, account — with every venue-neutral DTO that crosses their ports, and a realistic gateway skeleton you can copy.

IBrokerageGateway — the root port

The root port owns the vendor SDK clients and the three sub-gateways. The sub-gateway properties are nullable: return a non-null instance only for a concern the broker actually serves (and whose BrokerCapabilities you advertise). A common pattern — shown in the skeleton — is to construct the sub-gateways eagerly (they describe capability) and have their accessors throw until ConnectAsync has run.

IBrokerageGateway.csusing TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Trading;

public interface IBrokerageGateway : IDisposable
{
    string BrokerKey { get; }                    // registration TypeId, lower-cased
    BrokerCapabilities Capabilities { get; }

    IBrokerageDataGateway?    Data { get; }
    IBrokerageTradingGateway? Trading { get; }
    IBrokerageAccountGateway? Account { get; }

    BrokerConnectionStatus Status { get; }
    event Action<BrokerConnectionStatus>? ConnectionStatusChanged;

    Task ConnectAsync(BrokerCredentials credentials, AccountMode mode, CancellationToken ct = default);
    Task DisconnectAsync();   // idempotent
}
ConnectAsync contract. Move Status to Connecting, authenticate, open the sessions you need, then move to Connected — firing ConnectionStatusChanged on each transition. On a hard failure, tear down and throw: the connection layer treats a thrown ConnectAsync as a failed connect. Live vs Paper picks the venue's endpoint; Sim never reaches a broker gateway (it routes to the built-in simulator).

BrokerCredentials

A venue-neutral key/value bag the gateway reads on connect. The Desktop connection provider builds it from the DPAPI-decrypted profile and the broker's declared credential schema (see Registration); the gateway pulls the keys it needs via Get / Require / Has. Keeping it a bag — not a typed-per-broker shape — is what lets one generic connection provider serve every broker.

BrokerCredentials.csusing TradeStrike.Pipeline.Brokerage;

public sealed class BrokerCredentials
{
    public BrokerCredentials(IReadOnlyDictionary<string, string> values);

    public static BrokerCredentials Empty { get; }   // for public-data-only connects

    public string? Get(string key);       // value, or null when absent / blank
    public string  Require(string key);   // value, or throws when absent / blank
    public bool    Has(string key);       // true when a non-blank value exists
}

IBrokerageDataGateway

The market-data half: historical bars, the live trade stream, and the tradable instrument universe. Speaks only venue-neutral DTOs — BrokerBar, BrokerTrade, BrokerInstrument. A broker that serves no market data exposes a null IBrokerageGateway.Data entirely (the generic provider then sources data from the asset-class fallback).

IBrokerageDataGateway.csusing TradeStrike.Pipeline.Brokerage;

public interface IBrokerageDataGateway
{
    // Historical bars over the half-open UTC range [fromUtc, toUtcExclusive), oldest-first.
    Task<IReadOnlyList<BrokerBar>> GetBarsAsync(
        string symbol, TimeSpan barInterval, DateTime fromUtc, DateTime toUtcExclusive,
        CancellationToken ct = default);

    // Live trade stream. Returns a token that unsubscribes on dispose,
    // or null when the venue exposes no live trade stream. Callbacks may fire on a background thread.
    IDisposable? SubscribeTrades(string symbol, Action<BrokerTrade> onTrade);

    // The broker's tradable instrument universe — called once per connect; implementations cache it.
    Task<IReadOnlyList<BrokerInstrument>> GetInstrumentsAsync(CancellationToken ct = default);

    // Opaque per-symbol corporate-action stamp (split date, …) used to invalidate the bar cache on a split.
    // Default null = no corporate-action versioning (forex / crypto / futures); MUST degrade to null on failure.
    Task<string?> GetAdjustmentVersionAsync(string symbol, CancellationToken ct = default)
        => Task.FromResult<string?>(null);
}

The two value DTOs it produces:

BrokerBar.cs & BrokerTrade.csusing TradeStrike.Pipeline.Brokerage;

// One OHLCV bar. StartUtc is the bar's OPEN time (normalise any close-stamped venue convention first).
public readonly record struct BrokerBar(
    DateTime StartUtc, double Open, double High, double Low, double Close, double Volume);

// One live trade print. The generic tick source assigns the global sequence number itself.
public readonly record struct BrokerTrade(
    string Symbol, double Price, double Size, DateTime TimestampUtc);

And the instrument universe DTO — the Desktop seeder maps a list of these onto the platform's instrument catalog. QuantityUnit is the baseline asset-class signal; Category is the authoritative override when the unit alone is ambiguous (a metal/index/commodity CFD sized in lots/units, indistinguishable from forex):

BrokerInstrument.csusing TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Bars;   // QuantityUnit, InstrumentCategory

public sealed record BrokerInstrument(
    string Symbol,                          // canonical id, e.g. "AAPL", "EUR/USD"
    string Description,                      // human name
    double TickSize,                        // minimum price increment (must be positive)
    double? PointValue,                     // currency value of one full point, or null
    QuantityUnit QuantityUnit,              // Share / Lot / Unit / Coin / Contract — the asset-class signal
    double? LotSize = null,                 // base-currency units per lot (forex standard 100 000)
    double? QuantityStep = null,            // smallest quantity increment; null = whole units
    double? MinQuantity = null,             // smallest order quantity the venue accepts
    string? QuoteCurrency = null,           // ISO-4217 (EUR/USD → "USD")
    string? AdjustmentVersion = null,       // corporate-action stamp; flows into the bar-cache key
    InstrumentCategory? Category = null);   // authoritative classification when the unit is ambiguous

IBrokerageTradingGateway

The order-routing half. A key contract rule: order operations never throw on a venue refusal. A placement maps to a rejected BrokerOrderAck, a cancel/modify to a failed ack — so an operation always becomes a definitive state. (Caller cancellation still surfaces as OperationCanceledException.)

IBrokerageTradingGateway.csusing TradeStrike.Pipeline.Brokerage;

public interface IBrokerageTradingGateway
{
    Task<BrokerOrderAck>  PlaceOrderAsync(BrokerOrderSpec spec, CancellationToken ct = default);
    Task<BrokerCancelAck> CancelOrderAsync(string accountId, string symbol, string venueOrderId, CancellationToken ct = default);

    // Native amend — only called when the broker advertises BrokerCapabilities.ModifyOrders;
    // otherwise the provider does cancel-replace.
    Task<BrokerModifyAck> ModifyOrderAsync(BrokerModifySpec spec, CancellationToken ct = default);

    // Flatten — only called when the broker advertises BrokerCapabilities.FlattenPositions.
    Task<bool> FlattenAsync(string accountId, string? symbol, CancellationToken ct = default);

    // Lot / tick / minimum rules so the provider can snap an order onto the grid before sending.
    // Null = no metadata / unknown symbol / fetch failed (the provider then sends unsnapped).
    Task<BrokerSymbolRules?> GetSymbolRulesAsync(string symbol, CancellationToken ct = default);

    event Action<BrokerOrderUpdate>? OrderUpdated;   // live order-state changes; background thread
    event Action? StreamsReconnected;                // after a drop + re-establish → provider reconciles

    Task StartStreamsAsync(CancellationToken ct = default);   // open the live order subscription
    Task StopStreamsAsync();                                  // idempotent
    Task<IReadOnlyList<BrokerOrderUpdate>> GetOpenOrdersAsync(CancellationToken ct = default);   // reconnect reconciliation
}

Placing an order: BrokerOrderSpec → BrokerOrderAck

The generic BrokerTradingProvider produces a BrokerOrderSpec from the platform-wide OrderRequest (snapping to the venue grid first). It carries the full broker order shape — stop price plus an optional protective BracketSpec the venue attaches natively when it advertises Brackets. ClientOrderId is the platform OrderId as a string, so order-stream updates correlate back.

BrokerOrderSpec.csusing TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Trading;   // OrderSide, OrderType, TimeInForce, BracketSpec

public sealed record BrokerOrderSpec(
    string AccountId,            // the venue account this order targets
    string Symbol,               // canonical, e.g. "AAPL", "EUR/USD"
    OrderSide Side,              // Buy / Sell
    OrderType Type,              // Market / Limit / Stop / StopLimit
    decimal Quantity,            // always positive
    decimal? LimitPrice,         // for Limit / StopLimit; null otherwise
    decimal? StopPrice,          // for Stop / StopLimit; null otherwise
    TimeInForce TimeInForce,     // Day / Gtc / Ioc / Fok
    BracketSpec? Bracket,        // native protective bracket, or null for a bare order
    string ClientOrderId);       // the platform OrderId as a string (correlation key)
BrokerOrderAck.csusing TradeStrike.Pipeline.Brokerage;

// The synchronous answer to PlaceOrderAsync. Construct via the factories — never throw on a venue refusal.
public sealed record BrokerOrderAck
{
    public bool    Success { get; }
    public string? VenueOrderId { get; }   // non-null only when Success
    public string? RejectReason { get; }   // non-null only when not Success

    public static BrokerOrderAck Accepted(string venueOrderId);   // throws if the id is blank
    public static BrokerOrderAck Rejected(string reason);
}

Cancel & modify acks

Both mirror the order ack's "definitive state, never throw" shape. For a REST cancel the response is the confirmation — Success means the order is cancelled.

BrokerCancelAck.cs & BrokerModify.csusing TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Trading;   // TimeInForce

public sealed record BrokerCancelAck
{
    public bool    Success { get; }
    public string? FailureReason { get; }
    public static BrokerCancelAck Ok { get; }
    public static BrokerCancelAck Failed(string reason);
}

// A native modify intent — null fields keep the order's current value.
public sealed record BrokerModifySpec(
    string AccountId, string VenueOrderId, string Symbol,
    decimal? NewQuantity, decimal? NewLimitPrice, decimal? NewStopPrice, TimeInForce? NewTimeInForce);

public sealed record BrokerModifyAck
{
    public bool    Success { get; }
    public string? FailureReason { get; }
    public static BrokerModifyAck Ok { get; }
    public static BrokerModifyAck Failed(string reason);
}

The live order stream: BrokerOrderUpdate

Order-state changes arrive on OrderUpdated (and are re-fetched by GetOpenOrdersAsync for reconnect reconciliation). The provider correlates each update back to a platform OrderId via ClientOrderId. A partially-filled order is Working with a non-zero FilledQuantity — the provider derives "partially filled" from that.

BrokerOrderUpdate.csusing TradeStrike.Pipeline.Brokerage;

public enum BrokerOrderStatus
{
    Working,    // on the book — possibly partially filled
    Filled,
    Cancelled,
    Rejected,
    Expired,
    Unknown,    // venue reported a state that could not be classified
}

public sealed record BrokerOrderUpdate(
    string AccountId,
    string ClientOrderId,          // the correlation key we stamped on the order
    string VenueOrderId,
    BrokerOrderStatus Status,
    decimal FilledQuantity,        // CUMULATIVE
    decimal AverageFillPrice,      // cumulative VWAP (0 when nothing has filled)
    decimal? LastFillQuantity,     // INCREMENTAL fill, or null when no new execution
    decimal? LastFillPrice,
    decimal LastFillCommission,    // 0 when none / not reported
    string? RejectReason,
    DateTime TimestampUtc);

Symbol rules

GetSymbolRulesAsync returns the venue's lot/tick grid so the provider can snap an order before sending. Every field is nullable — null means "no constraint of this kind". The generic provider projects it onto the shared OrderSizingRules and snaps via the shared OrderQuantitySnapper, so the snapping algorithm is reused, never reimplemented. Cache the symbol list; this is cheap to call per order.

BrokerSymbolRules.csusing TradeStrike.Pipeline.Brokerage;

public sealed record BrokerSymbolRules(
    decimal? PriceStep,     // a limit price must be a whole multiple of this
    decimal? QuantityStep,  // an order quantity must be a whole multiple of this
    decimal? MinQuantity,
    decimal? MaxQuantity,
    decimal? MinNotional)   // smallest order value (quantity × price)
{
    public static readonly BrokerSymbolRules None;   // no constraints — snapping is a no-op
}

IBrokerageAccountGateway

The account-state half: the accounts the credentials can reach, their open positions, and their per-asset cash balances, plus push events when any of those change. It speaks venue-neutral DTOs plus the platform's neutral AccountBalance. The generic provider turns these into the platform's Account / Position / balance stream.

IBrokerageAccountGateway.csusing TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Trading;   // AccountBalance

public interface IBrokerageAccountGateway
{
    Task<IReadOnlyList<BrokerAccount>>  GetAccountsAsync(CancellationToken ct = default);
    Task<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct = default);   // all accounts; empty when flat
    Task<IReadOnlyList<AccountBalance>> GetBalancesAsync(string accountId, CancellationToken ct = default);

    event Action<BrokerAccount>? AccountUpdated;      // fresh account snapshot (cash / PnL / margin)
    event Action<BrokerPosition>? PositionUpdated;    // fresh account-tagged signed position (flat = quantity 0)
    event Action<string, IReadOnlyList<AccountBalance>>? BalancesUpdated;   // account id + its full non-zero set
}

The two account DTOs (both reuse platform types under TradeStrike.Pipeline.Trading):

BrokerAccount.cs & BrokerPosition.csusing TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Trading;   // AccountMetrics

// The venue's OWN account id as a string — the generic provider composes the platform AccountId.
public sealed record BrokerAccount(
    string AccountId,
    string DisplayName,
    string Currency,            // ISO-4217, e.g. "USD"
    decimal CashValue,
    decimal RealizedPnL,
    decimal UnrealizedPnL,
    decimal MarginUsed,
    decimal BuyingPower,
    AccountMetrics? Metrics = null);   // optional extended figures; null = core only

// Quantity is SIGNED (positive long, negative short, zero flat).
public sealed record BrokerPosition(
    string AccountId,
    string Symbol,
    decimal Quantity,
    decimal AverageEntryPrice,
    decimal LastPrice,          // 0 when the broker reports none — the live feed re-marks
    decimal UnrealizedPnL,      // in the instrument's quote currency
    decimal Multiplier = 1m);   // currency-per-point (1 shares; pip-value forex; contract multiplier futures)

Per-asset balances reuse the platform's AccountBalance (build them with the validating factory). A forex/equity broker that reports a single cash figure returns an empty balance list — that cash sits on BrokerAccount.CashValue instead. BalancesUpdated carries the full non-zero set with snapshot semantics.

A realistic gateway skeleton

Here is the shape a real broker assembly takes — the root gateway owns the SDK clients, ConnectAsync builds them for the chosen AccountMode, and the same connected clients back all three sub-gateways (one session, no duplication). The sub-gateways are constructed eagerly (they describe capability) and their client accessors throw until connected. This mirrors the built-in Alpaca adapter.

AcmeBrokerageGateway.csusing TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Trading;

public sealed class AcmeBrokerageGateway : IBrokerageGateway
{
    // Declare once; the registration mirrors this and the providers gate the UI off it.
    public const BrokerCapabilities AcmeCapabilities =
        BrokerCapabilities.HistoricalBars | BrokerCapabilities.LiveTrades | BrokerCapabilities.Instruments |
        BrokerCapabilities.PlaceOrders | BrokerCapabilities.CancelOrders | BrokerCapabilities.ModifyOrders |
        BrokerCapabilities.FlattenPositions | BrokerCapabilities.Positions;

    private readonly object _gate = new();
    private AcmeSdkClient? _client;   // your vendor SDK client
    private int _disposed;

    public AcmeBrokerageGateway()
    {
        // Sub-gateways describe capability; their accessors throw until ConnectAsync runs.
        Data    = new AcmeDataGateway(RequireClient);
        Trading = new AcmeTradingGateway(RequireClient);
        Account = new AcmeAccountGateway(RequireClient);
    }

    public string BrokerKey => AcmeBroker.TypeId;           // "acme"
    public BrokerCapabilities Capabilities => AcmeCapabilities;
    public IBrokerageDataGateway?    Data { get; }
    public IBrokerageTradingGateway? Trading { get; }
    public IBrokerageAccountGateway? Account { get; }

    public BrokerConnectionStatus Status { get; private set; } = BrokerConnectionStatus.Disconnected;
    public event Action<BrokerConnectionStatus>? ConnectionStatusChanged;

    public async Task ConnectAsync(BrokerCredentials credentials, AccountMode mode, CancellationToken ct = default)
    {
        if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(AcmeBrokerageGateway));
        SetStatus(BrokerConnectionStatus.Connecting);
        try
        {
            var apiKey   = credentials.Require("ApiKey");        // throws if the field is blank
            var apiSecret = credentials.Require("ApiSecret");
            var endpoint = mode == AccountMode.Paper ? AcmeEndpoints.Paper : AcmeEndpoints.Live;

            var client = new AcmeSdkClient(endpoint, apiKey, apiSecret);
            await client.AuthenticateAsync(ct).ConfigureAwait(false);   // validate credentials up front

            lock (_gate) _client = client;
            SetStatus(BrokerConnectionStatus.Connected);
        }
        catch
        {
            await DisconnectAsync().ConfigureAwait(false);
            SetStatus(BrokerConnectionStatus.Disconnected);
            throw;   // a hard connect failure must surface to the connection layer
        }
    }

    public async Task DisconnectAsync()
    {
        AcmeSdkClient? client;
        lock (_gate) { client = _client; _client = null; }
        if (client != null) { try { await client.CloseAsync().ConfigureAwait(false); } catch { } client.Dispose(); }
        if (Status != BrokerConnectionStatus.Disconnected) SetStatus(BrokerConnectionStatus.Disconnected);
    }

    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
        try { DisconnectAsync().GetAwaiter().GetResult(); } catch { }
        ConnectionStatusChanged = null;
    }

    private void SetStatus(BrokerConnectionStatus status)
    {
        Status = status;
        ConnectionStatusChanged?.Invoke(status);
    }

    private AcmeSdkClient RequireClient()
        => _client ?? throw new InvalidOperationException("Acme gateway is not connected.");
}

A trading sub-gateway then maps each port call to the SDK and back to the venue-neutral DTOs — critically, turning a venue refusal into a Rejected / Failed ack rather than letting it throw:

AcmeTradingGateway.cs (excerpt)using TradeStrike.Pipeline.Brokerage;
using TradeStrike.Pipeline.Trading;

public async Task<BrokerOrderAck> PlaceOrderAsync(BrokerOrderSpec spec, CancellationToken ct = default)
{
    var client = _requireClient();
    try
    {
        var native = new AcmeOrderRequest
        {
            Account   = spec.AccountId,
            Symbol    = _mapper.ToNative(spec.Symbol),
            Side      = spec.Side == OrderSide.Buy ? AcmeSide.Buy : AcmeSide.Sell,
            Type      = MapType(spec.Type),
            Quantity  = spec.Quantity,
            Limit     = spec.LimitPrice,
            Stop      = spec.StopPrice,
            Tif       = MapTif(spec.TimeInForce),
            ClientId  = spec.ClientOrderId,         // so OrderUpdated correlates back to the platform OrderId
        };
        var result = await client.SubmitOrderAsync(native, ct).ConfigureAwait(false);
        return result.Accepted
            ? BrokerOrderAck.Accepted(result.OrderId)
            : BrokerOrderAck.Rejected(result.Message ?? "Order rejected by Acme.");
    }
    catch (OperationCanceledException) { throw; }            // caller cancellation still surfaces
    catch (AcmeApiException ex) { return BrokerOrderAck.Rejected(ex.Message); }   // venue refusal → definitive state
}