A complete venue plugin
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.
On this page
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; only the vendor's own REST and socket clients are left out.
| 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 breaks type identity under the plugin load context. -->
<PackageReference Include="SabrTrader.Sdk" />
</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: NoHistoricalTicks.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.
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();
}
}
NoHistoricalTicks.Instance. It declares
ProvidesHistoricalTicks = false, which is what stops the chain asking for ticks the venue
will never have.The historical source
ExampleHistoricalBars.csinternal sealed class ExampleHistoricalBars(ExampleRestClient rest) : IHistoricalTimeBarSource
{
// The venue keeps five years of bars and serves at most this many pages per request.
private static readonly TimeSpan HistoryDepth = TimeSpan.FromDays(5 * 365);
private const int MaxPages = 50;
public async Task<HistoryServed<Bar>> LoadTimeBars(
HistoryRequest request, TimeSpan period, CancellationToken ct = default)
{
if (!rest.IsAuthenticated)
throw new BackfillSourceUnavailableException("Example Data is not connected.");
// Clamp to what the venue actually holds; venue knowledge stays in the venue.
var floor = DateTime.UtcNow - HistoryDepth;
if (request.ToUtcExclusive <= floor)
return HistoryServed<Bar>.None(request); // older than anything the venue keeps
var fromUtc = request.FromUtc < floor ? floor : request.FromUtc;
var bars = new List<Bar>();
string? cursor = null;
for (int pages = 0; pages < MaxPages; pages++)
{
var page = await rest.LoadBarsAsync(request.InstrumentId, period, fromUtc,
request.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;
if (cursor is null)
return HistoryServed<Bar>.Span(bars, request, fromUtc, request.ToUtcExclusive);
}
// The page budget ran out: vouch only for what arrived, so the rest is asked for again.
var servedTo = bars.Count > 0 ? bars[^1].EndUtc : fromUtc;
return HistoryServed<Bar>.Span(bars, request, fromUtc, servedTo);
}
}
The tick source
ExampleTickSource.csinternal sealed class ExampleTickSource(ExampleSocketClient socket) : ITickSource
{
private readonly ConcurrentDictionary<string, WireSubscription> _subs = new(StringComparer.Ordinal);
public IDisposable Subscribe(string instrumentId, Action<Tick> onTick)
{
var sub = _subs.GetOrAdd(instrumentId, id => new WireSubscription(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 WireSubscription : 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 WireSubscription(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);
// The vendor socket client's callback shape: ticks by reference, no copy per event.
internal delegate void TickHandler(in Tick tick);
private sealed record Handle(WireSubscription 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 copy:
ListEntriesreturns a snapshot array, never the live list. -
EntriesChangedfires after the swap, so a consumer that re-reads on the event can never observe a half-built catalog.
The tests
A session test needs a VenueSessionContext. Its required services are two small
interfaces, so a test builds the context from in-memory fakes:
ExampleSessionTests.cspublic sealed class ExampleSessionTests
{
private static VenueSessionContext Context(string apiKey) => new(
profile: new VenueProfileInfo(Guid.NewGuid(), "test"),
settings: new Dictionary<string, string> { [ExampleVenueDescriptor.FieldApiKey] = apiKey },
notices: new NullNotices(),
storage: new MemoryStorage());
[Fact]
public async Task Connect_Failure_On_Bad_Key_Is_Permanent()
{
await using var session = new ExampleVenueSession(Context("rejected-key"));
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 session = new ExampleVenueSession(Context(""));
await session.DisposeAsync(); // must not throw: the dialog's cancel path relies on it
}
private sealed class NullNotices : IVenueNoticeSink
{
public void Post(VenueNotice notice) { }
public void Log(string message) { }
}
private sealed class MemoryStorage : IVenueStateStorage
{
private readonly Dictionary<string, string> _files = new();
public string? ReadText(string name) => _files.GetValueOrDefault(name);
public void WriteText(string name, string content) => _files[name] = content;
}
}
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 |