Defining a column

Market Analyzer columns

Defining a column

The definition is the column's identity card: a stateless, immutable value-object that names the column, declares the shape of its cells, and spawns a worker for each row. One definition instance is shared across every row of the panel.

The IColumnDefinition interface

Every column kind implements this one interface. It carries no mutable state, never references the engine and never depends on a specific data provider; per-row runtime state lives in the IColumnValueSource the definition spawns.

IColumnDefinition.csnamespace SabrTrader.Pipeline.MarketAnalyzer;

public interface IColumnDefinition
{
    // Stable identifier. Used in snapshots, expressions (col:"<id>")
    // and condition rules. MUST be unique inside a panel.
    string Id { get; }

    // Human-readable header text shown in the grid.
    string Header { get; }

    // Type-tag of the cell payload this column produces.
    ColumnDataType DataType { get; }

    // Create a per-row value source. Called once per (column, row) when
    // the row is added. The source's lifetime is owned by the row's
    // actor and ends on row removal or panel dispose.
    IColumnValueSource CreateSource(InstrumentRef instrument, IColumnContext context);
}

Id, Header and the factory

Id is the stable, lowercase key for your column. It is what gets written into workspace snapshots, referenced from cell-condition rules, and used in expressions as col:"<id>". It must be globally unique: a second plugin type claiming an id that is already registered is refused at install with a descriptive error. The convention is "<vendor>.<column>", e.g. "myplugin.midprice".

Keep a shipped Id fixed. It is the persistence key. A workspace that references an id no installed column reports loses that one column on restore. Pick it once.

Header is the default header text. The user can override it per panel from the Columns dialog, so treat it as a sensible default rather than a fixed label.

CreateSource is the factory. The engine calls it once per row, passing the InstrumentRef for that row and the engine's IColumnContext. Return a fresh IColumnValueSource each time and keep each source to one row. The method only constructs the source; all wiring happens later in the source's StartAsync.

ColumnDataType: payload & formatting

DataType is a coarse type-tag for your cell payload. It drives the grid's default formatting, the default sort comparer, and the default condition-operator set in the UI. The runtime value type is fixed per column: every cell of one column carries the same CLR type.

Member Runtime payload Behaviour
Text string Free-form text. No numeric ops, no aggregates.
Integer long Integer. Supports numeric ops + aggregates.
Number double Real. Supports numeric ops + aggregates.
Price double Formatted as a price using the row's tick size.
Percent double Expressed as a percentage (0.05 = 5%).
Timestamp DateTime A UTC timestamp.
Boolean bool Boolean / flag.
ConditionStatus host condition status Untriggered / Triggered / Sticky status of the host's cell-condition columns.
Sparkline host sparkline snapshot Inline mini-chart of the built-in sparkline columns. Rendered as a polyline; treated like text for condition purposes.
Pick the narrowest tag. Price respects the instrument's tick size; Percent renders 0.05 as 5%; Integer keeps volumes free of decimals. The tag is what makes a column sortable and condition-aware for free, so publish a value whose CLR type matches it (a double for Number/Price/Percent, a long for Integer, and so on). The payloads of ConditionStatus and Sparkline are host types, so a plugin column uses one of the other tags.

Picker metadata: [MarketAnalyzerColumn]

Decorate your definition class with [MarketAnalyzerColumn] to control how the column appears in the Columns dialog's "Available" list. The constructor takes the required display name; Category and Description are optional init-only properties.

MarketAnalyzerColumnAttribute.csnamespace SabrTrader.Pipeline.MarketAnalyzer;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class MarketAnalyzerColumnAttribute : Attribute
{
    public MarketAnalyzerColumnAttribute(string displayName);   // required, non-empty (trimmed)

    public string DisplayName { get; }                  // shown in the Available list
    public string Category    { get; init; } = "";      // picker grouping; empty = ungrouped
    public string Description { get; init; } = "";      // one-line tooltip / detail text
}
The attribute enriches the listing. Discovery picks up any public, concrete IColumnDefinition with a zero-arg-callable constructor (a parameterless constructor, or one whose parameters all have default values). With the attribute, the picker shows its display name, category and description; without it, the picker shows the definition's Header with an empty category, so you almost always want it.

User parameters: [ColumnParameter]

To let users tune a column from the Columns dialog, mark a public read/write property with [ColumnParameter]. The dialog renders an editor driven by the property type, and the workspace codec saves and restores the value: the same convention indicator parameters use, applied to columns.

ColumnParameterAttribute.csnamespace SabrTrader.Pipeline.MarketAnalyzer;

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class ColumnParameterAttribute : Attribute
{
    public string DisplayName { get; init; } = "";          // label; empty = property name
    public string Description { get; init; } = "";          // one-line help
    public double MinValue    { get; init; } = double.NaN;  // inclusive min (NaN = unbounded)
    public double MaxValue    { get; init; } = double.NaN;  // inclusive max (NaN = unbounded)
}

Supported property types: bool, int, long, double, decimal, string, TimeSpan, DateTime and enums. The property needs a public getter AND setter. Definitions stay immutable per panel because the host sets every parameter once, before the definition is attached to the panel. Read the parameter inside your CreateSource and pass it into the source's constructor (see below).

Parameters validate at install time. A [ColumnParameter] property with no public setter, or of an unsupported type, fails when the plugin is loaded, so the mistake surfaces at once instead of during a workspace restore.

A complete configurable column

A "Bid ask spread" column with a user-tunable averaging window expressed as a [ColumnParameter]. It shows every piece of the definition (picker metadata, a typed parameter with bounds, the right DataType, reading the parameter in the factory) plus a compact source built on the row's feed. Producing values covers sources in depth.

SpreadColumn.csusing System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using SabrTrader.Pipeline.MarketAnalyzer;

namespace MyPlugin.Columns;

[MarketAnalyzerColumn("Bid ask spread",
    Category    = "My plugin",
    Description = "Best ask minus best bid, optionally averaged over recent quote updates.")]
public sealed class SpreadColumn : IColumnDefinition
{
    public string Id => "myplugin.spread";
    public string Header => "Spread";

    // A spread is a price-scale value: format with the row's tick size.
    public ColumnDataType DataType => ColumnDataType.Price;

    [ColumnParameter(
        DisplayName = "Average over (updates)",
        Description = "Number of quote updates to average; 1 = instantaneous.",
        MinValue = 1, MaxValue = 500)]
    public int WindowSize { get; set; } = 1;

    public IColumnValueSource CreateSource(InstrumentRef instrument, IColumnContext context)
        => new SpreadSource(context, WindowSize);   // pass the parameter through
}

internal sealed class SpreadSource : IColumnValueSource
{
    private readonly IColumnContext _context;
    private readonly int _window;
    private readonly Queue<double> _recent = new();
    private double _sum;
    private IDisposable? _subscription;

    public SpreadSource(IColumnContext context, int window)
    {
        _context = context;
        _window = Math.Max(1, window);
    }

    public Task StartAsync(IColumnValueSink sink, CancellationToken cancellationToken)
    {
        var feed = _context.GetService<IInstrumentFeed>();
        if (feed is null)
        {
            sink.Publish(CellValue.Error("No market data for this row."));
            return Task.CompletedTask;
        }

        sink.Publish(CellValue.Initializing);
        _subscription = feed.OnQuote(change =>
        {
            const RowQuoteFields sides = RowQuoteFields.Bid | RowQuoteFields.Ask;
            var q = change.Quote;
            if ((change.Changed & sides) == 0 || !q.HasBid || !q.HasAsk) return;

            double spread = q.Ask - q.Bid;
            _recent.Enqueue(spread);
            _sum += spread;
            if (_recent.Count > _window) _sum -= _recent.Dequeue();
            sink.Publish(CellValue.Ready(_sum / _recent.Count));
        });
        return Task.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        _subscription?.Dispose();
        return ValueTask.CompletedTask;
    }
}

When the user picks "Bid ask spread" in the Columns dialog they see a numeric editor labelled Average over (updates), limited to 1 to 500. The chosen value is saved with the workspace and re-applied on restore before CreateSource runs, so by the time a source is spawned WindowSize already holds the user's value.

Rules & common mistakes

  • Keep the definition stateless. No fields that change at runtime. State belongs in the source. The same definition instance serves every row simultaneously.
  • One DataType per column. Every cell carries the same CLR payload type: a Number column publishes double values only.
  • Keep CreateSource cheap. Construct and return. Subscriptions and backfill belong in the source's StartAsync.
  • Read parameters in the factory. Capture the parameter value and pass it to the source constructor, so the source is a snapshot of the configuration at attach time.