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.

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();
}
FillKey is identity. It starts from the venue-scoped account, so reconnecting a login keeps the same identity. With an 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.

Why 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);
}
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. 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
    }
}
What the host does with it. The host's backfill adapter fetches a window from your read-only source and replaces that window in the store, leaving it uncovered when the fetch was incomplete; 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.

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);
}
Native currency, never mixed. 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.