Registration & discovery
Registration & discovery
How your spec and builder are wired together at runtime — including
[BarBuilderFor] plugin pairing, JSON persistence for plugin parameters, and
availability probing for built-ins and plugins.
On this page
The factory: spec → builder
The runtime only knows it needs a builder for a spec; the mapping lives behind
IBarBuilderFactory. It is two methods:
IBarBuilderFactory.csnamespace SabrTrader.Pipeline.Bars;
public interface IBarBuilderFactory
{
IBarBuilder Create(BarSpecification spec);
bool Supports(BarSpecification spec);
}
In a deployed plugin you do not register bar types by hand — dropping the DLL is enough, because the
host scans it, files your BarSpecification subclass into the bar-type catalog, and registers
every ITickBarBuilder annotated with [BarBuilderFor] into the process-wide
BarBuilderRegistry. DefaultBarBuilderFactory.Create falls through to that
registry for unknown specs.
When you need explicit control (most often in a unit test, or when composing a factory yourself),
register the mapping from a spec type to a builder factory delegate on
DefaultBarBuilderFactory:
Registrationusing SabrTrader.Pipeline.Bars;
var factory = new DefaultBarBuilderFactory(instruments); // instruments: IInstrumentMetadata
// A tick-count bar needs no tick size, so the delegate just passes the spec.
factory.Register<ThresholdTickBarSpec>(spec => new NTickBarBuilder(spec));
// A price-distance bar gets its tick size resolved HERE, at registration,
// and passed into the builder's constructor (the built-in range bar does exactly this):
factory.Register<RangeBarSpec>(spec =>
new NRangeBarBuilder(spec, instruments.GetTickSize(spec.InstrumentId)));
if (factory.Supports(spec))
{
IBarBuilder builder = factory.Create(spec);
}
Alternatively, supply your own IBarBuilderFactory implementation entirely — the runtime
depends only on the interface.
Plugin pairing: [BarBuilderFor]
Annotate the builder class with the spec type it constructs. The host installer reads the attribute and
binds a factory that prefers (TSpec, double tickSize), then (TSpec).
SampleFixedTickBarBuilder.csusing SabrTrader.Pipeline.Bars;
using SabrTrader.Pipeline.Ticks;
[BarBuilderFor(typeof(SampleFixedTickBarSpec))]
public sealed class SampleFixedTickBarBuilder : ITickBarBuilder
{
public SampleFixedTickBarBuilder(SampleFixedTickBarSpec spec) { /* … */ }
// ITickBarBuilder members…
}
// Price-distance builders that need tick size:
[BarBuilderFor(typeof(MyRangeSpec), NeedsTickSize = true)]
public sealed class MyRangeBuilder : ITickBarBuilder
{
public MyRangeBuilder(MyRangeSpec spec, double tickSize) { /* … */ }
}
(TSpec) ctor is still discovered and registered.Tick size & instrument metadata
Any price-distance bar type (range, Renko, Kagi, Point&Figure, …) needs the instrument's tick
size to turn a setting like "4 ticks" into a price distance. That is the job of
IInstrumentMetadata, which the factory is constructed with. Resolution happens once, at
registration time, inside the delegate — the factory calls
GetTickSize(instrumentId) and passes the resulting double into the builder's
constructor. Your builder receives a ready tick size; it never holds an IInstrumentMetadata
reference or looks anything up at run time.
IInstrumentMetadata.cs (excerpt)namespace SabrTrader.Pipeline.Bars;
public interface IInstrumentMetadata
{
double GetTickSize(string instrumentId); // throws if unregistered — fail loud
bool IsRegistered(string instrumentId);
// Optional facets (default null / Contract) — sizing, currency, classification:
double? TryGetPointValue(string instrumentId) => null;
QuantityUnit GetQuantityUnit(string instrumentId) => QuantityUnit.Contract;
double? TryGetLotSize(string instrumentId) => null;
double? TryGetQuantityStep(string instrumentId) => null;
double? TryGetMinQuantity(string instrumentId) => null;
string? TryGetQuoteCurrency(string instrumentId) => null;
string? TryGetAdjustmentVersion(string instrumentId) => null;
InstrumentCategory? TryGetCategory(string instrumentId) => null;
}
GetTickSize throws when the instrument is not registered — deliberately, so a range bar
is never built on a fabricated tick size. The SDK ships InMemoryInstrumentMetadata (a
thread-safe registry with a fluent Register(...)) for tests and host wiring. The optional
facets describe sizing and classification — QuantityUnit (Contract / Share / Lot / Unit
/ Coin) and InstrumentCategory (Forex / Equity / Crypto / Index / Commodity / Metal / Bond)
— and feed order-entry surfaces rather than bar building. The
GetQuantityModel(instrumentId) extension resolves unit + step + min into one
QuantityModel for those surfaces.
Seeding the live handoff
When a chart switches from historical backfill to the live feed, chain-from-close bar types must continue the series or they leave a phantom gap. A builder opts into this by implementing one of two seam interfaces; the handoff checks for them and applies the result uniformly, so any builder — built-in or plugin — participates without bespoke handoff code.
ISeedableBarBuilder.csnamespace SabrTrader.Pipeline.Bars;
public readonly record struct BarSeedResult(bool Seeded, DateTime Cutoff, bool LastSeriesBarIsInProgress)
{
public static readonly BarSeedResult NotSeeded = new(false, default, false);
}
// Single-bar seed: continue from the last historical bar.
public interface ISeedableBarBuilder
{
BarSeedResult SeedFromLastBar(in Bar lastBar, DateTime nowUtc);
}
// Multi-bar seed: builders whose rule looks back N bars (e.g. Line-Break).
// Preferred over ISeedableBarBuilder when both are present.
public interface IHistorySeedableBarBuilder
{
BarSeedResult SeedFromHistory(IBarSeries series, DateTime nowUtc);
}
-
Time bars re-open the last (possibly still-forming) period bar so live ticks extend it. They
return a
BarSeedResultwithLastSeriesBarIsInProgress = true. -
Range / Renko / Kagi / Median-Renko chain — the next bar opens at the previous close, so
seeding opens a fresh in-progress bar off the last closed bar (
LastSeriesBarIsInProgress = false). TheCutofftells the runtime which live ticks the seed already accounts for. - Volume / tick bars do NOT chain — the next bar opens at the next trade's own price — so they are not seedable; the unseeded behaviour is already correct.
-
Line-Break needs the last
LineCountcloses, so it implementsIHistorySeedableBarBuilderand reconstructs its window from the historicalIBarSeries(barsAgo-indexed,[0]= newest).
Derived builders use the separate ISeedableDerivedBarBuilder seam covered in
A derived bar.
BarSeedResult fields. Seeded — whether a bar was
seeded. Cutoff — live ticks at or before this instant are already in the seeded bar and
must be dropped. LastSeriesBarIsInProgress — true when the seed re-opened the last series
bar (time-bar partial period), false when it opened a new bar and the last series bar stays final
(range-bar chain-from-close).Persistence: BarSpecificationCodec
Chart templates and workspaces persist the exact bar spec across restarts. BarSpecificationCodec
is the bidirectional spec ↔ JSON codec — the template and workspace layers never hand-roll spec
JSON. The wire format is a single object with a string "type" discriminator plus the type's
own fields; "instrumentId" is always present, and continuous-futures + trading-hours intent
are written only when set (so a literal series round-trips byte-identically to the pre-feature format).
BarSpecificationCodec.csusing SabrTrader.Pipeline.Bars;
string json = BarSpecificationCodec.Encode(new TimeBarSpec("MNQ", TimeSpan.FromMinutes(5)));
// {"type":"time","instrumentId":"MNQ","periodTicks":3000000000}
BarSpecification spec = BarSpecificationCodec.Decode(json);
Built-in discriminators include time, tick, range,
volume, renko, medianrenko, kagi,
pointandfigure, linebreak, ha. Decoding is case-insensitive and
forgiving (missing optional keys fall back to safe defaults); an unknown built-in discriminator throws
NotSupportedException with the offending string.
Plugin specs use discriminator "plugin" plus the CLR type full name and a
params map of every [BarParameter] property:
plugin wire format// Encode(new SampleFixedTickBarSpec { InstrumentId = "ES", Ticks = 50 })
// → {"type":"plugin",
// "specType":"SabrTrader.Examples.SampleBarTypePlugin.SampleFixedTickBarSpec",
// "instrumentId":"ES",
// "params":{"Ticks":"50"}}
BarSpecTypeResolverAmbient can resolve specType. The type must honour
WithInstrumentId, and parameter values are reapplied via
BarParameterReflector. A missing plugin surfaces a clear
NotSupportedException rather than silently restoring nothing.Availability probing
Which bar types a chart may offer for an instrument depends on the data provider. The
venue-agnostic helper BarTypeAvailability answers "which bar types can THIS venue produce?"
without any UI or venue knowledge — the caller passes a supports probe (in practice
IDataProvider.SupportsBarSpec) and either the built-in representative set or an explicit
candidate list (built-ins + plugin defaults from the catalog).
BarTypeAvailability.csnamespace SabrTrader.Pipeline.Bars;
public static class BarTypeAvailability
{
// One representative spec per built-in bar type, in chart-picker order.
public static IReadOnlyList<BarSpecification> RepresentativeSpecs(string instrumentId);
// Probe an explicit candidate list (use this when the host catalog includes plugins).
public static IReadOnlyList<BarSpecification> SupportedSpecs(
Func<BarSpecification, bool> supports,
IReadOnlyList<BarSpecification> candidates);
// Built-in-only seed — same as SupportedSpecs(supports, RepresentativeSpecs(instrumentId)).
public static IReadOnlyList<BarSpecification> SupportedSpecs(
Func<BarSpecification, bool> supports, string instrumentId);
}
A provider accepts a spec when it can fulfil it either natively or by building it client-side from its historical ticks — both collapse into one boolean, so the UI only needs "offerable or not". The consumer (New Chart dialog, Data Series dialog, chart toolbar, a future web client) maps each returned spec's runtime type onto its own picker and hides the rest. The representative specs use canonical, strictly-positive parameters because the probe asks a type-level question — only the spec's runtime type (and parameter positivity, for venues that validate it) affects the verdict.
ITimeBarPeriodSupport (in SabrTrader.Pipeline.Runtime):
SupportsNatively(period) and LargestNativeDivisor(period). The generic
re-aggregating backfill provider uses it to fetch a divisor period and roll it up. Tick / volume / range /
Renko bypass this entirely.