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.
You reach for these contracts in two situations: to read trade history (a journal, a custom report,
a chart overlay) via ITradeHistoryStore, and to supply history for a venue that exposes
an order/trade-history API via ITradeHistoryBackfillSource. The storage technology (SQLite today)
lives entirely behind the store interface, so no consumer is coupled to it.
The contracts live in SabrTrader.Pipeline.Venues.TradeHistory. They reference a few trading-layer types
(AccountId, OrderSide) 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 on pre-schema-v4 rows.
public string? AccountCurrency { get; init; }
// Deterministic natural dedup key — stable across restarts, independent of the row id.
public string FillKey { get; }
// Structural invariants; throws ArgumentException for a malformed fill so it never reaches the store.
public void Validate();
}
Tag and
FeeCurrency are additive metadata and are NOT part of the key.
The store (ITradeHistoryStore)
The persistent, append-only store is the single seam every consumer talks to. Append validates
and idempotently persists a fill; QueryFills reads them back chronologically. It is thread-safe:
appends may arrive on venue/provider threads while queries run on the UI thread.
ITradeHistoryStore.csusing System;
using System.Collections.Generic;
using SabrTrader.Pipeline.Venues.Trading; // AccountId
using SabrTrader.Pipeline.Venues.TradeHistory;
public interface ITradeHistoryStore : IDisposable
{
// Persist a fill. Idempotent on FillKey (a duplicate is ignored, with no FillsChanged).
// Validates before writing; raises FillsChanged exactly once when a NEW fill is stored.
void Append(PersistedFill fill);
// Every stored fill matching the query, ordered chronologically (oldest first).
IReadOnlyList<PersistedFill> QueryFills(TradeHistoryQuery query);
// Total quantity already persisted for an originating order id (0 for an unknown order).
// Used by reconnect reconciliation to detect fills that happened while the app was closed.
decimal RecordedQuantityForOrder(string orderId);
IReadOnlyList<AccountId> KnownAccounts(); // distinct accounts with ≥ 1 stored fill
IReadOnlyList<string> KnownInstruments(); // distinct instruments with ≥ 1 stored fill
// Raised after a new fill is persisted (fires on the appending thread; marshal yourself).
event Action? FillsChanged;
}
Querying (TradeHistoryQuery)
TradeHistoryQuery is the filter for reading fills back. Every axis is optional and ANDed
together; an empty collection or null bound means "no restriction on that axis". The time bounds are
inclusive UTC instants compared against TimestampUtc; the account and instrument sets are
membership filters. TradeHistoryQuery.All matches every stored fill.
TradeHistoryQuery.csusing System;
using System.Collections.Generic;
using SabrTrader.Pipeline.Venues.Trading; // AccountId
using SabrTrader.Pipeline.Venues.TradeHistory;
public sealed record TradeHistoryQuery
{
public DateTime? FromUtc { get; init; } // inclusive lower bound (null = unbounded)
public DateTime? ToUtc { get; init; } // inclusive upper bound (null = unbounded)
public IReadOnlyCollection<AccountId> Accounts { get; init; } // empty = all accounts
public IReadOnlyCollection<string> Instruments { get; init; } // empty = all instruments (exact match)
public static TradeHistoryQuery All { get; } // every stored fill
}
A date-range report over one account and symbol stays a single indexed query:
DailyPnlReport.csusing System;
using System.Linq;
using SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.TradeHistory;
public static decimal NetCommission(ITradeHistoryStore store, AccountId account, string symbol, DateTime dayUtc)
{
var query = new TradeHistoryQuery
{
FromUtc = dayUtc.Date,
ToUtc = dayUtc.Date.AddDays(1).AddTicks(-1),
Accounts = new[] { account },
Instruments = new[] { symbol },
};
var fills = store.QueryFills(query); // chronological, oldest first
return fills.Sum(f => f.Commission);
}
FillsChanged to refresh a chart execution overlay or a
running report without polling. It fires on the appending thread, so marshal to your own dispatcher before
touching UI.
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 — the model NinjaTrader and Sierra Chart use, 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. ITradeHistoryStore.ReplaceFillsInRange deletes a window before inserting it,
so "how a window is replaced" is a platform rule with real consequences — an empty list legitimately clears a
window. Every legacy venue re-implemented that call. On the seam the host's backfill adapter owns it, so the
replace-window semantics and the coverage bookkeeping live in exactly one place. It is the same division
IVenueCashActivitySource already uses 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.Id); // 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.Id, fromUtc, toUtc, cursor, ct)
.ConfigureAwait(false);
foreach (var e in page.Executions)
fills.Add(new PersistedFill(
AccountId: account,
Instrument: e.Symbol,
Side: e.IsBuy ? OrderSide.Buy : OrderSide.Sell,
Quantity: e.Quantity,
Price: e.Price,
Commission: e.Fee,
TimestampUtc: e.FilledAtUtc,
ExecutionId: e.Id)); // venue-native id — dedup identity
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
}
}
VenueTradeHistoryBackfillAdapter bridges your read-only
source onto the platform's ITradeHistoryBackfillSource and performs the store write; a live
composite registry holds one entry per connected session, so your venue's blotter backfill appears on connect
and disappears on disconnect without the backfill service being rebuilt. Your side of the contract is
reporting fills and answering honestly about completeness.
Cash activity (ICashActivityStore)
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.
A CashActivityEntry captures one posting in its native currency; every derived figure (an
account's period financing) is a projection over the ordered stream, never stored. Appends are idempotent
on ActivityKey (the broker's own transaction id scoped by account), so a re-fetched window is
silently de-duplicated.
CashActivityEntry.csusing System;
using SabrTrader.Pipeline.Venues.Trading; // AccountId
using 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 CashActivityEntry(
AccountId Account,
CashActivityKind Kind,
decimal Amount, // native Currency; sign follows the broker (+ collected, − paid)
string Currency,
DateTime TimestampUtc,
string BrokerTxnId) // stable broker-native id — required
{
public string ActivityKey { get; } // "{Account}|{BrokerTxnId}" — idempotent dedup key
public void Validate(); // throws ArgumentException on a blank id/currency or default timestamp
}
ICashActivityStore.csusing System;
using System.Collections.Generic;
using SabrTrader.Pipeline.Venues.Trading; // AccountId
using SabrTrader.Pipeline.Venues.TradeHistory;
public interface ICashActivityStore : IDisposable
{
// Persist an entry. Idempotent on ActivityKey; validates; raises ActivityChanged once on a NEW entry.
void Append(CashActivityEntry entry);
// All stored entries for one account in [fromUtc, toUtc], oldest first.
IReadOnlyList<CashActivityEntry> Entries(AccountId account, DateTime fromUtc, DateTime toUtc);
// Sum of one kind's amounts per NATIVE currency since fromUtc (the projector converts each bucket).
IReadOnlyList<(string Currency, decimal Amount)> SumByCurrency(AccountId account, CashActivityKind kind, DateTime fromUtc);
// Timestamp of the newest stored entry (the recorder's incremental-fetch cursor), or null when none.
DateTime? LastTimestampUtc(AccountId account);
// Raised after a NEW entry is persisted (fires on the appending thread; marshal yourself).
event Action? ActivityChanged;
}
Amount stays in the entry's own Currency as
the broker posted it; converting to the account currency and summing across currencies is the read-model's
job, never this record's. Corrections are reversal entries (a new id with the opposite amount), never
mutations — the sum nets out naturally.