Defining a column
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.
On this page
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".
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. |
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
}
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).
[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
DataTypeper column. Every cell carries the same CLR payload type: aNumbercolumn publishesdoublevalues only. -
Keep
CreateSourcecheap. Construct and return. Subscriptions and backfill belong in the source'sStartAsync. - 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.