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 knows how to spawn 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 TradeStrike.Pipeline.MarketAnalyzer;

public interface IColumnDefinition
{
    // Stable identifier, kebab-case. Used in snapshots, expressions
    // (col:"") 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, kebab-case 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 collision with another plugin or a built-in refuses registration. The convention is "<vendor>.<column>", e.g. "myplugin.midprice".

Never change a shipped Id. It is the persistence key. Renaming it orphans every saved workspace that referenced the old id (the host drops the unknown 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 this as a sensible default, not 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 — never share one source across rows. This method should do nothing but construct 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 actual runtime value type is fixed per column — heterogeneous cells inside one column are forbidden.

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 tri-state Untriggered / Triggered / Sticky condition status.
Sparkline engine snapshot Inline mini-chart. 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 you choose is what makes a column sortable and condition-aware for free — publish a value whose CLR type matches the tag (a double for Number/Price/Percent, a long for Integer, and so on).

Discovery metadata: [MarketAnalyzerColumn]

Decorate your definition class with [MarketAnalyzerColumn] to enrich 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 TradeStrike.Pipeline.MarketAnalyzer;

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

    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; it does not gate. Discovery itself does not require the attribute — a plugin column participates by being a public, concrete IColumnDefinition with a zero-arg-callable constructor (the same convention indicators, bar builders and drawing tools use). Without the attribute the picker falls back to the definition's Header and 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 round-trips the value automatically — the same convention indicator parameters use, applied to columns.

ColumnParameterAttribute.csnamespace TradeStrike.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 must have 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, not at restore. A [ColumnParameter] property with no public setter, or of an unsupported type, fails when the plugin is loaded — not silently later. Fix it once and every workspace restore is safe.

A complete configurable column

A "Bid–ask spread" column with a user-tunable smoothing window expressed as a [ColumnParameter]. It demonstrates every piece of the definition: discovery metadata, a typed parameter with bounds, the right DataType, and reading the parameter in the factory. (The source's internals are covered in Producing values.)

SpreadColumn.csusing System;
using TradeStrike.Pipeline.MarketAnalyzer;

namespace MyPlugin.Columns;

[MarketAnalyzerColumn("Bid ask spread",
    Category    = "My plugin",
    Description = "Best-ask minus best-bid, optionally averaged over a tick window.")]
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 (ticks)",
        Description = "Number of quote updates to average; 1 = instantaneous.",
        MinValue = 1, MaxValue = 500)]
    public int WindowTicks { get; set; } = 1;

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

When the user picks "Bid ask spread" in the Columns dialog they see a numeric editor labelled Average over (ticks), clamped to 1–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, WindowTicks 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 must carry the same CLR payload type. Don't publish a string from a Number column.
  • Make CreateSource cheap. Construct and return; do not subscribe or backfill here. Heavy work goes in the source's StartAsync, off the calling thread.
  • Read parameters in the factory, not the source. Capture the parameter value into a local and pass it to the source constructor, so the source is a snapshot of the configuration at attach time.