Trade history
Trade history & backfill
An append-only store of executed fills is the immutable ground truth of trade history. Every higher view (round-trip trades, the performance report, chart execution markers) is a projection re-derived from the ordered fill stream, never stored.
On this page
What it is & why it exists
Only fills are persisted. Round-trip trades and performance metrics are never stored; they are re-derived from the fill stream on demand. A fill is appended once and never mutated, which keeps the model robust to crashes, reconnects and re-aggregation. Appends are idempotent on a deterministic natural key, so a venue re-emitting a fill on reconnect is silently de-duplicated.
A venue plugin uses these contracts to supply history: it reports fills through
IVenueTradeHistorySource, orders through IOrderHistorySource and posted cash
activity through IVenueCashActivitySource, all as session ports. The host owns the fill and
cash-activity stores and everything written to them.
The contracts live in SabrTrader.Pipeline.Venues.TradeHistory. They reference a few trading-layer types
(AccountId, OrderSide, OrderType) from
SabrTrader.Pipeline.Venues.Trading; see the trading reference.
PersistedFill
One executed fill, captured and persisted as immutable ground truth. A trading-layer Fill alone
does not carry its instrument or side (only the originating order does), so the recorder joins each fill to
its order before projecting onto this record. Quantity and Price are always
positive; Side carries the direction. Commission is the cost of THIS fill in
account currency (a positive cost, not signed).
PersistedFill.csusing System;
using SabrTrader.Pipeline.Venues.Trading; // AccountId, OrderSide
using SabrTrader.Pipeline.Venues.TradeHistory;
public sealed record PersistedFill(
AccountId Account,
string Instrument,
OrderSide Side,
decimal Quantity, // positive magnitude
decimal Price, // positive
decimal Commission, // positive cost in account currency
DateTime TimestampUtc,
string OrderId, // originating order id
string? BrokerOrderId,
long Sequence)
{
// Optional role tag from the originating order's client tag (e.g. "bracket-sl" /
// "bracket-tp"); null for a plain entry. Metadata, NOT part of FillKey.
public string? Tag { get; init; }
// Currency Commission is denominated in. Null = the account currency (the normal case).
public string? FeeCurrency { get; init; }
// The quote→account FX rate in effect AT this fill. Null = no conversion applied
// (quote currency matched the account, was unknown, or no rate resolved at fill time).
public decimal? FxRateToAccount { get; init; }
// The account's currency (ISO-4217) AT this fill, so reporting knows what a null
// FxRateToAccount means for this row. Null when the recorder had no account snapshot.
public string? AccountCurrency { get; init; }
// The venue's own execution id, when it assigns one. Set, it becomes the fill's identity.
public string? ExecutionId { get; init; }
// Commission is the venue's own fee for this execution (a genuine 0 included). Decides the
// write on a FillKey collision; not stored, so fills read back carry false.
public bool CommissionIsAuthoritative { get; init; }
// Deterministic natural dedup key: stable across restarts, independent of the row id.
public string FillKey { get; }
// The formula behind FillKey.
public static string ComputeFillKey(AccountId account, string orderId, string? brokerOrderId,
DateTime timestampUtc, OrderSide side, decimal quantity, decimal price, string? executionId = null);
// Structural invariants; throws ArgumentException for a malformed fill so it never reaches the store.
public void Validate();
}
ExecutionId the key is that id plus the fill's UTC date (a venue fill id is
unique within a trading day, and some venues' ids wrap over a longer window). Without one it uses the
BrokerOrderId when present (the id live capture and history both see) with the time bucketed to
the UTC second, since live and history reports of one fill can differ by microseconds; otherwise the order id
with full timestamp ticks. Side, quantity and price complete it, in invariant culture. The store enforces it
as a UNIQUE column and ignores a second insert with the same key, which is what makes a replayed or
overlapping backfill harmless. The one field a duplicate may improve is Commission: an incoming
fill marked CommissionIsAuthoritative replaces the stored fee, so the broker's real charge wins
whether live capture or backfill landed first. Tag and FeeCurrency are metadata and
are not part of the key.
How the host stores fills
The host keeps an append-only fill store. It validates each fill and persists it once: a fill whose
FillKey is already stored is ignored, so a venue re-reporting a window never creates
duplicates. Reads come back chronologically, oldest first, which is the order round-trip reconstruction
needs. Account matching is venue-scoped, so a connection that is deleted and re-added keeps one history
for the same broker account. A venue never writes to the store; the host records live fills and stores
what your IVenueTradeHistorySource reports.
ExecutionId matters. Until the venue's blotter row arrives, a fill the platform saw
live is stored without an execution id. When the real row turns up describing the same execution, the
host replaces that synthesised twin with the venue's row and moves anything keyed on the old fill (journal
notes, tags, screenshots) to the new key. Setting PersistedFill.ExecutionId whenever the venue
has its own fill id is what lets the host recognise the pair.
Supplying history (IVenueTradeHistorySource)
The default capture path is live-forward: the recorder persists every fill the trading service emits from the
moment the app runs, since venues do not reliably replay full fill history on reconnect. A venue that DOES
expose an order/trade-history API seeds the older fills, and it does so
by reporting them: it implements IVenueTradeHistorySource, exposes it as a session port,
and never touches platform storage.
IVenueTradeHistorySource.csusing SabrTrader.Pipeline.Venues.Trading; // AccountId
using SabrTrader.Pipeline.Venues.TradeHistory;
public interface IVenueTradeHistorySource
{
// True only when this session can report history for `account`, i.e. the
// account belongs to THIS login. Answering true for an account you do not own
// makes the host mark another venue's gap covered with no rows.
bool Supports(AccountId account);
// The fills in [fromUtc, toUtc], oldest-first, UTC timestamps.
Task<VenueTradeHistoryPage> GetFillsAsync(
AccountId account, DateTime fromUtc, DateTime toUtc, CancellationToken ct = default);
}
public sealed record VenueTradeHistoryPage(
IReadOnlyList<PersistedFill> Fills,
bool IsComplete = true)
{
// The venue has nothing in this window, and says so authoritatively.
public static readonly VenueTradeHistoryPage Empty;
// The venue could not serve the WHOLE window. What it got is still usable,
// but the window stays uncovered so it is re-requested.
public static VenueTradeHistoryPage Partial(IReadOnlyList<PersistedFill> fills);
}
VenueTradeHistoryPage.Partial(...); the next refresh retries the window. An EMPTY page with
IsComplete true is a different and equally useful answer: it is what lets the host stop asking.
Why read-only. The host replaces a stored window by deleting it before inserting what the venue reported,
so "how a window is replaced" is a platform rule with real consequences: an empty list legitimately clears a
window. The host's backfill adapter owns that call, so the replace-window semantics and the coverage
bookkeeping live in exactly one place. IVenueCashActivitySource uses the same division for
posted cash activity.
A trade-history source end-to-end
Implement the port on your session (or on a separately-owned object returned from
GetPort<T>), declare VenueCapabilities.TradeHistoryBackfill on the manifest, and
the host's port registrar wires it while the session is connected.
AcmeVenueSession.csusing SabrTrader.Pipeline.Venues;
using SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.TradeHistory;
internal sealed class AcmeVenueSession : IVenueSession, IVenueTradeHistorySource
{
private readonly IAcmeHistoryApi _api; // your venue client
public bool Supports(AccountId account)
=> _api.OwnsAccount(account.ProviderAccountId); // only accounts behind THIS login
public async Task<VenueTradeHistoryPage> GetFillsAsync(
AccountId account, DateTime fromUtc, DateTime toUtc, CancellationToken ct = default)
{
var fills = new List<PersistedFill>();
string? cursor = null;
int pages = 0;
do
{
var page = await _api.GetExecutionsAsync(account.ProviderAccountId, fromUtc, toUtc, cursor, ct)
.ConfigureAwait(false);
foreach (var e in page.Executions)
fills.Add(new PersistedFill(
Account: account,
Instrument: e.Symbol,
Side: e.IsBuy ? OrderSide.Buy : OrderSide.Sell,
Quantity: e.Quantity,
Price: e.Price,
Commission: e.Fee,
TimestampUtc: e.FilledAtUtc,
OrderId: e.OrderId,
BrokerOrderId: e.OrderId,
Sequence: 0)
{
ExecutionId = e.Id, // venue-native id: the dedup identity
CommissionIsAuthoritative = true, // e.Fee is the venue's own charge
});
cursor = page.NextCursor;
// The vendor caps us at 20 pages per call. Say so rather than pretending
// the window is done: the next refresh picks the gap back up.
if (++pages == 20 && cursor is not null)
return VenueTradeHistoryPage.Partial(fills);
}
while (cursor is not null && !ct.IsCancellationRequested);
return new VenueTradeHistoryPage(fills); // IsComplete: true, the whole window
}
}
Order history (IOrderHistorySource)
Fills say what happened; orders say what was asked for. A venue that remembers the orders an account placed can expose them read-only, and the journal folds them into its order lifecycle for trades from before the live recorder ran.
IOrderHistorySource.csusing SabrTrader.Pipeline.Venues.Trading; // AccountId, OrderSide, OrderType
using SabrTrader.Pipeline.Venues.TradeHistory;
public interface IOrderHistorySource
{
bool Supports(AccountId account); // true only for accounts behind THIS session
// Every order confirmed inside the inclusive UTC window. Throw when the venue cannot
// answer - never return a partial page as if it were complete.
Task<IReadOnlyList<HistoricalOrder>> GetOrdersAsync(
AccountId account, DateTime fromUtc, DateTime toUtc, CancellationToken ct = default);
}
public sealed record HistoricalOrder(
AccountId Account, string OrderId, string Instrument,
OrderSide Side, OrderType Type,
decimal Quantity, decimal? LimitPrice, decimal? StopPrice,
decimal FilledQuantity, decimal? AvgFillPrice,
HistoricalOrderStatus Status,
DateTime PlacedUtc, DateTime? FilledUtc, DateTime? CancelledUtc,
string? Tag = null);
public enum HistoricalOrderStatus { Unknown, Working, Filled, Cancelled, Rejected }
Read-only by design: nothing on this seam touches the order path. Expose it as a session port next to
your IVenueTradeHistorySource; the host probes it with AsPort<IOrderHistorySource>()
while the session is connected. OrderId is the venue's stable id (the exchange order id when it
has one), the same id your live fills carry, and the record carries the order's LAST prices: a stop that was
trailed shows where it ended.
Cash activity
Broker-posted cash movements that are not trades (swap / rollover financing, interest,
dividends, crypto-perp funding) have their own append-only ledger, the twin of the fill store. The host
fills it from each connected venue's IVenueCashActivitySource (see
Session ports). Every derived figure (an account's
period financing) is a projection over the ordered stream of postings, never stored. The host stores each
VenueTransactionId once per account, so re-fetching an overlapping window is safe.
IVenueCashActivitySource.csusing SabrTrader.Pipeline.Venues.TradeHistory;
public enum CashActivityKind
{
Financing, // swap / rollover financing (forex)
Interest, // margin / credit interest
Other, // recognised but not yet classified
Dividend, // dividend posted on a holding
Funding, // crypto perpetual-futures funding (distinct from Financing)
}
public sealed record VenueCashActivity(
string AccountId, // the venue's raw account id; the host scopes it
CashActivityKind Kind,
decimal Amount, // native Currency; + collected, − paid (normalise the venue's sign)
string Currency,
DateTime TimestampUtc,
string VenueTransactionId); // stable venue-native id, required
public interface IVenueCashActivitySource
{
// Posted entries since sinceUtc, oldest first. Called on a low-frequency cadence;
// overlapping windows are expected. Clamp to the venue's lookback, never throw for it.
Task<IReadOnlyList<VenueCashActivity>> GetCashActivityAsync(
DateTime sinceUtc, CancellationToken ct = default);
}
Amount stays in the entry's own Currency as
the broker posted it; the host converts to the account currency when it builds a figure. Corrections are
reversal entries (a new id with the opposite amount), never edits, so the sum nets out naturally. A venue
without a stable per-entry id cannot expose this port.