Trade history

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.

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();
}
FillKey is identity. It is composed from the account, order id, exact timestamp ticks, side, quantity and price in invariant culture. The store enforces it as a UNIQUE column and ignores a second insert with the same key — that is what makes a replayed or overlapping backfill harmless. 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);
}
Live overlays. Subscribe to 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);
}
Completeness is a contract, not a detail. The host marks a window covered only when the venue reported it FULLY. A truncated answer returned as complete permanently hides every fill past the cut, because the gap is never re-requested. If a paging cap, a vendor limit or a partial outage stopped you short, return 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
    }
}
What the host does with it. A 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;
}
Native currency, never mixed. 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.