A complete venue plugin

Venue plugins

A complete venue plugin WORKED EXAMPLE

Every file of a working market-data venue: plugin, descriptor, manifest, session, data port, historical source, tick source, catalog and tests.

What we are building

A complete, honest venue: one REST + WebSocket vendor exposed as a market-data venue with historical bars, live trades, instrument metadata and a typed catalog. Every file below is the real shape — nothing is elided into "…and wire it up".

File Role
ExampleVenuePlugin.cs The export + descriptor + manifest.
ExampleVenueSession.cs Lifecycle for one profile; publishes the data port.
ExampleDataProvider.cs IDataProvider + catalog capability.
ExampleHistoricalBars.cs IHistoricalTimeBarSource over the vendor REST API.
ExampleTickSource.cs ITickSource over the vendor socket, ref-counted.

The project

SabrTrader.Pipeline.Example.csproj<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <RootNamespace>SabrTrader.Pipeline.Example</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <!-- CONTRACTS ONLY. An engine reference is a conformance-kit failure. -->
    <PackageReference Include="SabrTrader.Sdk" Version="0.8.0" />
  </ItemGroup>
</Project>

Plugin & descriptor

ExampleVenuePlugin.csusing SabrTrader.Pipeline.Providers;
using SabrTrader.Pipeline.Venues;

namespace SabrTrader.Pipeline.Example;

public sealed class ExampleVenuePlugin : IVenuePlugin
{
    public IReadOnlyList<IVenueDescriptor> Venues { get; } =
        new IVenueDescriptor[] { new ExampleVenueDescriptor() };
}

internal sealed class ExampleVenueDescriptor : IVenueDescriptor
{
    internal const string TypeId       = "Example";
    internal const string FieldApiKey  = "ApiKey";
    internal const string FieldPaper   = "UsePaper";

    public VenueManifest Manifest { get; } = new(
        TypeId: TypeId,
        DisplayName: "Example Data",
        Category: VenueCategory.MarketData,
        AssetClasses: new[] { VenueAssetClass.Stock, VenueAssetClass.Future },
        Capabilities: VenueCapabilities.LiveTrades
                      | VenueCapabilities.HistoricalBars
                      | VenueCapabilities.InstrumentCatalog,
        SettingsSchema: new[]
        {
            new ProviderCredentialField(FieldApiKey, "API key",
                ProviderCredentialKind.Secret,
                HelpText: "Issued in the Example portal under Settings → API. " +
                          "Leave blank when editing to keep the saved key."),
            new ProviderCredentialField(FieldPaper, "Use sandbox",
                ProviderCredentialKind.Toggle,
                Required: false, DefaultValue: "false"),
        },
        SupportsPaper: true,
        StateStorageFolder: "example");

    public bool TryValidateSettings(IReadOnlyDictionary<string, string> values, out string? error)
    {
        if (values.TryGetValue(FieldApiKey, out var key) && key.Length is > 0 and < 20)
        {
            error = "That API key looks truncated — Example keys are 32 characters.";
            return false;
        }
        error = null;
        return true;
    }

    public IVenueSession CreateSession(VenueSessionContext context)
        => new ExampleVenueSession(context);

    public IVenueSymbolParser? CreateSymbolParser(ISharedSymbolParsers shared)
        => new ExampleSymbolParser(shared);
}

internal sealed class ExampleSymbolParser(ISharedSymbolParsers shared) : IVenueSymbolParser
{
    public VenueSymbolParse Parse(string rawSymbol)
        => rawSymbol.StartsWith("F:", StringComparison.Ordinal)
            ? shared.Futures.Parse(rawSymbol[2..])
            : shared.Equities.Parse(rawSymbol);
}

The session

ExampleVenueSession.csinternal sealed class ExampleVenueSession : IVenueSession
{
    private readonly VenueSessionContext _context;
    private ExampleDataProvider? _provider;
    private int _status;

    public ExampleVenueSession(VenueSessionContext context)
        => _context = context ?? throw new ArgumentNullException(nameof(context));

    public ProviderConnectionStatus Status => (ProviderConnectionStatus)Volatile.Read(ref _status);
    public event Action<ProviderConnectionStatus>? StatusChanged;
    public event Action<VenueStreams>? StreamsRestored;
    public VenueFailure? LastFailure { get; private set; }

    public IDataProvider? Data => _provider;
    public ITradingProvider? Trading => null;

    public async Task ConnectAsync(CancellationToken cancellationToken)
    {
        SetStatus(ProviderConnectionStatus.Connecting);
        _context.Notices.Log("Connecting…");

        bool paper = _context.Settings.TryGetValue(ExampleVenueDescriptor.FieldPaper, out var p)
                     && bool.TryParse(p, out var b) && b;

        var provider = new ExampleDataProvider(
            apiKey: _context.Settings[ExampleVenueDescriptor.FieldApiKey],
            paper: paper,
            context: _context);

        // The venue's own socket healed itself — tell the host to re-register feeds
        // and backfill the gap. No full session cycle needed.
        provider.SocketRestored += () => StreamsRestored?.Invoke(VenueStreams.MarketData);

        try
        {
            await provider.ConnectAsync(cancellationToken).ConfigureAwait(false);
            _provider = provider;
            SetStatus(ProviderConnectionStatus.Connected);
            _context.Notices.Log("Connected.");
        }
        catch (Exception ex)
        {
            try { provider.Dispose(); } catch { }
            bool permanent = ex is ExampleAuthException;
            LastFailure = new VenueFailure(
                permanent
                    ? "Example rejected the API key — verify it in the portal."
                    : $"Example connect failed: {ex.Message}",
                permanent);
            SetStatus(ProviderConnectionStatus.Failed);
            throw new InvalidOperationException(LastFailure.Message, ex);
        }
    }

    public async Task DisconnectAsync()
    {
        var provider = Interlocked.Exchange(ref _provider, null);
        if (provider is not null)
        {
            try { await provider.DisconnectAsync().ConfigureAwait(false); } catch { }
            try { provider.Dispose(); } catch { }
        }
        SetStatus(ProviderConnectionStatus.Disconnected);
    }

    public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);

    private void SetStatus(ProviderConnectionStatus next)
    {
        var prev = (ProviderConnectionStatus)Interlocked.Exchange(ref _status, (int)next);
        if (prev != next) StatusChanged?.Invoke(next);
    }
}

The data port

ExampleDataProvider.csinternal sealed class ExampleDataProvider : IDataProvider, IVenueInstrumentCatalog
{
    private readonly ExampleRestClient _rest;
    private readonly ExampleSocketClient _socket;
    private readonly InMemoryInstrumentMetadata _instruments = new();
    private readonly List<VenueCatalogEntry> _entries = new();
    private IBackfillProvider _backfill = null!;
    private bool _tickDerived;

    public ExampleDataProvider(string apiKey, bool paper, VenueSessionContext context)
    {
        _rest   = new ExampleRestClient(apiKey, paper);
        _socket = new ExampleSocketClient(apiKey, paper);
        LiveTicks = new ExampleTickSource(_socket);

        var composed = (context.BackfillComposer ?? DirectVenueBackfillComposer.Instance)
            .Compose(new VenueBackfillParts(
                ProviderKey:      "Example",
                VenueDisplayName: "Example Data",
                TimeBars:         new ExampleHistoricalBars(_rest),
                Ticks:            NoTickHistory.Instance,        // this venue serves no tape
                Instruments:      _instruments,
                DirectBackfill:   new ExampleDirectBackfill(_rest),
                PeriodSupport:    ExamplePeriods.Instance,
                DefaultLookback:  TimeSpan.FromDays(30),
                Log:              context.Notices.Log));

        _backfill    = composed.Backfill;
        _tickDerived = composed.SupportsTickDerived;
    }

    public string Key => "Example";
    public string DisplayName => "Example Data";

    public ProviderCapabilities Capabilities =>
        ProviderCapabilities.Backfill
        | ProviderCapabilities.LiveTicks
        | ProviderCapabilities.InstrumentMetadata;

    public IBackfillProvider? Backfill => _backfill;
    public ITickSource? LiveTicks { get; }
    public IInstrumentMetadata? Instruments => _instruments;

    // BarSpecification is an abstract record with one concrete type per bar kind —
    // match on the type, there is no Kind discriminator.
    public bool SupportsBarSpec(BarSpecification spec)
        => spec is TimeBarSpec || _tickDerived;

    public ProviderConnectionStatus Status { get; private set; }
        = ProviderConnectionStatus.Disconnected;
    public event Action<ProviderConnectionStatus>? ConnectionStatusChanged;
    public event Action? SocketRestored;

    public async Task ConnectAsync(CancellationToken cancellationToken = default)
    {
        Status = ProviderConnectionStatus.Connecting;
        ConnectionStatusChanged?.Invoke(Status);

        await _rest.AuthenticateAsync(cancellationToken).ConfigureAwait(false);
        await _socket.ConnectAsync(cancellationToken).ConfigureAwait(false);
        _socket.Restored += () => SocketRestored?.Invoke();

        await LoadCatalogAsync(cancellationToken).ConfigureAwait(false);

        Status = ProviderConnectionStatus.Connected;
        ConnectionStatusChanged?.Invoke(Status);
    }

    public async Task DisconnectAsync()
    {
        try { await _socket.DisconnectAsync().ConfigureAwait(false); } catch { }
        Status = ProviderConnectionStatus.Disconnected;
        ConnectionStatusChanged?.Invoke(Status);
    }

    public void Dispose() { _socket.Dispose(); _rest.Dispose(); }

    // ── IVenueInstrumentCatalog ────────────────────────────────────────────────
    public IReadOnlyList<VenueCatalogEntry> ListEntries()
    {
        lock (_entries) return _entries.ToArray();
    }

    public event Action? EntriesChanged;

    private async Task LoadCatalogAsync(CancellationToken ct)
    {
        var listing = await _rest.ListInstrumentsAsync(ct).ConfigureAwait(false);
        lock (_entries)
        {
            _entries.Clear();
            foreach (var i in listing)
            {
                // ONE call registers every facet; nulls stay unregistered.
                _instruments.Register(i.Symbol, i.TickSize,
                    pointValue: i.PointValue,
                    quoteCurrency: i.Currency,
                    category: i.IsFuture ? InstrumentCategory.Future : InstrumentCategory.Stock);

                _entries.Add(new VenueCatalogEntry(
                    InstrumentId: i.Symbol,
                    AssetClass:   i.IsFuture ? VenueAssetClass.Future : VenueAssetClass.Stock,
                    Exchange:     i.Mic,
                    Description:  i.Name,
                    ProductCode:  i.Root,
                    Expiration:   i.Expiry));
            }
        }
        EntriesChanged?.Invoke();
    }
}
No tape history? The composer takes a non-null IHistoricalTickSource, and the engine's empty implementation is not in the contracts package — so a venue without a tape ships its own four-line stub. Declaring ProvidesHistoricalTicks = false is what stops the chain asking for ticks it will never get:
NoTickHistory.csinternal sealed class NoTickHistory : IHistoricalTickSource
{
    public static readonly NoTickHistory Instance = new();

    public bool ProvidesHistoricalTicks => false;

    public Task<IReadOnlyList<Tick>> LoadTicks(
        string instrumentId, DateTime fromUtc, DateTime toUtcExclusive, CancellationToken ct = default)
        => Task.FromResult<IReadOnlyList<Tick>>(Array.Empty<Tick>());
}

The historical source

ExampleHistoricalBars.csinternal sealed class ExampleHistoricalBars(ExampleRestClient rest) : IHistoricalTimeBarSource
{
    public async Task<IReadOnlyList<Bar>> LoadTimeBars(
        string instrumentId, TimeSpan period,
        DateTime fromUtc, DateTime toUtcExclusive, CancellationToken ct = default)
    {
        if (!rest.IsAuthenticated)
            throw new VenueNotConnectedException("Example Data is not connected.");

        // Clamp to what the venue actually holds — venue knowledge stays in the venue.
        var floor = DateTime.UtcNow.AddYears(-5);
        if (fromUtc < floor) fromUtc = floor;

        var bars = new List<Bar>();
        string? cursor = null;
        do
        {
            var page = await rest.LoadBarsAsync(instrumentId, period, fromUtc, toUtcExclusive,
                                                cursor, ct).ConfigureAwait(false);
            foreach (var b in page.Bars)
                bars.Add(new Bar(
                    StartUtc: b.OpenUtc,
                    EndUtc:   b.OpenUtc + period,
                    Open: b.Open, High: b.High, Low: b.Low, Close: b.Close,
                    Volume: b.Volume, TickCount: b.Trades));
            cursor = page.NextCursor;
        }
        while (cursor is not null && !ct.IsCancellationRequested);

        return bars;
    }
}

The tick source

ExampleTickSource.csinternal sealed class ExampleTickSource(ExampleSocketClient socket) : ITickSource
{
    private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);

    public IDisposable Subscribe(string instrumentId, Action<Tick> onTick)
    {
        var sub = _subs.GetOrAdd(instrumentId, id => new Subscription(this, id));
        sub.Add(onTick);
        return new Handle(sub, onTick);
    }

    public void Dispose()
    {
        foreach (var sub in _subs.Values) sub.Dispose();
        _subs.Clear();
    }

    private sealed class Subscription : IDisposable
    {
        private readonly ExampleTickSource _owner;
        private readonly string _id;
        private readonly object _gate = new();
        private Action<Tick>[] _handlers = Array.Empty<Action<Tick>>();
        private IDisposable? _wire;

        public Subscription(ExampleTickSource owner, string id)
        {
            _owner = owner;
            _id = id;
        }

        public void Add(Action<Tick> handler)
        {
            lock (_gate)
            {
                _handlers = _handlers.Append(handler).ToArray();
                // First consumer opens the venue-side stream; the rest ride it.
                _wire ??= _owner.OpenWire(_id, Dispatch);
            }
        }

        public void Remove(Action<Tick> handler)
        {
            lock (_gate)
            {
                _handlers = _handlers.Where(h => h != handler).ToArray();
                if (_handlers.Length != 0) return;
                _wire?.Dispose();      // last consumer releases the venue stream
                _wire = null;
            }
        }

        // On the socket thread. No allocation, no locking across the callback.
        private void Dispatch(in Tick tick)
        {
            var handlers = Volatile.Read(ref _handlers);
            for (int i = 0; i < handlers.Length; i++) handlers[i](tick);
        }

        public void Dispose() { _wire?.Dispose(); _wire = null; }
    }

    private IDisposable OpenWire(string instrumentId, TickHandler onTick)
        => socket.SubscribeTrades(instrumentId, onTick);

    private sealed record Handle(Subscription Sub, Action<Tick> Handler) : IDisposable
    {
        public void Dispose() => Sub.Remove(Handler);
    }
}

The catalog

The catalog above is implemented directly on the data provider, so the host's AsCapability<IVenueInstrumentCatalog>() probe finds it with no extra wiring. Two details in that code carry weight:

  • The entry set is rebuilt under a lock and published by copyListEntries never hands out the live list.
  • EntriesChanged fires after the swap, so a consumer that re-reads on the event can never observe a half-built catalog.

The tests

ExampleVenuePluginConformanceTests.cspublic sealed class ExampleVenuePluginConformanceTests : VenuePluginConformanceKit
{
    protected override IVenuePlugin CreatePlugin() => new ExampleVenuePlugin();
    protected override IReadOnlyList<string> LegacyTypeIds => new[] { "Example" };
}

public sealed class ExampleSessionTests
{
    [Fact]
    public async Task Connect_Failure_On_Bad_Key_Is_Permanent()
    {
        var context = ConformanceContext.For(new ExampleVenueDescriptor());
        await using var session = new ExampleVenueSession(context);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => session.ConnectAsync(CancellationToken.None));

        Assert.True(session.LastFailure!.Permanent);
        Assert.Equal(ProviderConnectionStatus.Failed, session.Status);
    }

    [Fact]
    public async Task Dispose_Before_Connect_Is_Safe()
    {
        var context = ConformanceContext.For(new ExampleVenueDescriptor());
        var session = new ExampleVenueSession(context);
        await session.DisposeAsync();      // must not throw — the dialog's cancel path
    }
}
Filter your test runs. Run your own venue's classes, not the whole suite — venue test projects sit in a large solution and a filtered run is the difference between seconds and minutes.

Where to go next

If your venue… Read
serves an order book The hosted depth plane
routes orders Broker venues
serves option chains Options data & trading
is a levels vendor Options levels
rotates OAuth tokens Rotating credentials