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 TradeStrike.Pipeline.TradeHistory. They reference a few trading-layer types (AccountId, OrderSide) from TradeStrike.Pipeline.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 TradeStrike.Pipeline.Trading;       // AccountId, OrderSide
using TradeStrike.Pipeline.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; }

    // 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 TradeStrike.Pipeline.Trading;       // AccountId
using TradeStrike.Pipeline.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 TradeStrike.Pipeline.Trading;       // AccountId
using TradeStrike.Pipeline.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 TradeStrike.Pipeline.Trading;
using TradeStrike.Pipeline.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 (ITradeHistoryBackfillSource)

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 can implement ITradeHistoryBackfillSource to seed older fills into the store, with no change to the store, the recorder or any consumer.

ITradeHistoryBackfillSource.csusing System;
using System.Threading;
using System.Threading.Tasks;
using TradeStrike.Pipeline.Trading;       // AccountId
using TradeStrike.Pipeline.TradeHistory;

public interface ITradeHistoryBackfillSource
{
    // Fetch and persist any fills for the account on/after sinceUtc that the store doesn't already hold.
    Task BackfillAsync(AccountId account, DateTime sinceUtc, CancellationToken ct = default);
}
Funnel through Append. Implementations must route every discovered fill through the same ITradeHistoryStore.Append path and rely on FillKey de-duplication. A backfill that overlaps the live-captured range is then harmless — the overlap is silently ignored.

A backfill source end-to-end

A complete venue backfill source. It pulls executions from a venue's history API, projects each onto a PersistedFill (validating it), and appends through the store — overlapping fills de-duplicate on FillKey, so it is safe to re-run.

AcmeTradeHistoryBackfillSource.csusing System;
using System.Threading;
using System.Threading.Tasks;
using TradeStrike.Pipeline.Trading;
using TradeStrike.Pipeline.TradeHistory;

public sealed class AcmeTradeHistoryBackfillSource : ITradeHistoryBackfillSource
{
    private readonly ITradeHistoryStore _store;
    private readonly IAcmeHistoryApi _api;   // your venue client

    public AcmeTradeHistoryBackfillSource(ITradeHistoryStore store, IAcmeHistoryApi api)
    {
        _store = store;
        _api = api;
    }

    public async Task BackfillAsync(AccountId account, DateTime sinceUtc, CancellationToken ct = default)
    {
        // Pull the venue's executions for this account from sinceUtc forward.
        var executions = await _api.GetExecutionsAsync(account.ToString(), sinceUtc, ct).ConfigureAwait(false);

        long seq = 0;
        foreach (var ex in executions)
        {
            ct.ThrowIfCancellationRequested();

            var fill = new PersistedFill(
                Account: account,
                Instrument: ex.Symbol,
                Side: ex.IsBuy ? OrderSide.Buy : OrderSide.Sell,
                Quantity: Math.Abs(ex.Quantity),     // always a positive magnitude
                Price: ex.Price,                     // always positive
                Commission: Math.Max(0m, ex.Fee),    // positive cost; never negative
                TimestampUtc: ex.ExecutedUtc,
                OrderId: ex.OrderId,
                BrokerOrderId: ex.BrokerOrderId,
                Sequence: seq++)
            {
                Tag = ex.BracketRole,                // e.g. "bracket-sl" / "bracket-tp", or null
                FeeCurrency = ex.FeeCurrency,        // null = account currency
            };

            try
            {
                fill.Validate();                     // reject a malformed execution before it reaches the store
            }
            catch (ArgumentException)
            {
                continue;                            // skip a bad row rather than failing the whole backfill
            }

            // Idempotent: an overlap with already-captured fills de-duplicates on FillKey.
            _store.Append(fill);
        }
    }
}
Registration. How the host discovers and runs a backfill source on reconnect is a host concern — the contract you implement is ITradeHistoryBackfillSource. Keep BackfillAsync cancellation-aware and idempotent, and it composes cleanly with the live-forward recorder.