Gateway interfaces
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.
On this page
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
}
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
}