Registration & discovery

Bar types

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.

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) { /* … */ }
}
Ctor rules. Specs must be zero-arg-constructible (plugin discovery + codec). Builders are not — they take the paired spec (and optionally tick size). A builder with only a (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 BarSeedResult with LastSeriesBarIsInProgress = 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). The Cutoff tells 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 LineCount closes, so it implements IHistorySeedableBarBuilder and reconstructs its window from the historical IBarSeries (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"}}
Decode requirements. The plugin must still be loaded so 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.

Time bars and re-aggregation. For TIME bars specifically, a venue describes which periods it serves natively via 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.