Market Analyzer columns

Market Analyzer columns

Overview INTERFACE

The Market Analyzer is SabrTrader's spreadsheet-style watchlist: one row per instrument, one column per fact you care about (last price, spread, session volume, an indicator value, your open P&L). A column plugin adds a column to that grid. Put the DLL in the plugin folder and your column shows up in the Columns… dialog, categorized and searchable, and persists across restarts, with no host changes.

What a column plugin is

A column plugin is a small, self-contained set of .NET types that teaches the Market Analyzer how to compute and display one fact per row. You provide two things:

  • An immutable definition: the column's identity, header and payload type (IColumnDefinition).
  • A per-row value source: a small worker that subscribes to the row's market data (or runs a one-shot backfill) and pushes cell values into the grid (IColumnValueSource).

The host does everything else: discovery, instantiation, workspace persistence, styling, conditional formatting, sorting and totals. You code against SabrTrader.Pipeline.Contracts (the SDK assembly) and never reference the engine.

Why it exists

Watchlists are where traders live. The analyzer ships a standard column set, but the facts a desk needs are open-ended: a venue-specific spread metric, a proprietary signal, a basket P&L, the output of an in-house indicator. Rebuilding the grid, the persistence codec and the conditional-formatting engine for each of those is not practical.

The column contract is an open/closed seam: a column kind is one IColumnDefinition implementation, and the engine, the grid and the workspace format stay as they are. Definitions are value-objects that hold no mutable state, never reference the engine and never depend on a specific data provider, so one definition instance is shared across every row of the panel.

When to build one

Live market metric

A derived quote the built-ins do not show: a weighted mid, an imbalance ratio, a custom spread. Subscribe to the row's quotes or ticks and publish on every update.

Session / daily aggregate

Anything anchored to the trading day: a custom VWAP variant, a session range stat. Use the ISessionResolver to find the session boundary instead of computing it yourself.

History-based statistic

A value computed from past bars, such as a 14-day average range. Load the bars through the row's feed (LoadBarsAsync) and publish once, then again on each session roll.

External / reference data

Pull from an optional host service resolved through GetService<T>(), such as the instrument description source. Publish a blank cell when the host does not offer it.

Use a different plugin kind for a chart overlay (an indicator) or for automated orders (a strategy). A column produces one cell per row; it observes the market and places no orders.

The model: definition → context → source

The contract is built from three collaborating roles:

Role Lifetime Job
IColumnDefinition One per column, shared across all rows Stateless identity (Id, Header, DataType) and a factory that spawns a value source for each row.
IColumnContext Handed in per CreateSource call The engine's service surface: clock, session resolver, the row's market-data feed (GetService<IInstrumentFeed>()) and other optional services.
IColumnValueSource One per (row, column) The worker. Wires up in StartAsync, pushes CellValues into the IColumnValueSink, tears down in DisposeAsync.

The definition holds no per-row state; that lives in the source it spawns. A source reads only its own data origin: cross-column composition runs through the engine's expression evaluator, never through references between sources. That loose coupling keeps each column kind independent.

Data flow

When a row is added to a panel, the engine calls the definition's CreateSource once for that row, hands the new source an IColumnValueSink via StartAsync, and from then on every value the source publishes flows into that row's cell.

graph TD
  Def["IColumnDefinition
(one per column)"] -->|CreateSource(instrument, context)| Src["IColumnValueSource
(one per row)"] Ctx["IColumnContext
(clock, sessions, row feed)"] -.->|injected| Src Src -->|StartAsync| Sub["subscribe / backfill"] Sub -->|tick / timer / event| Src Src -->|sink.Publish(CellValue)| Sink["IColumnValueSink"] Sink -->|marshalled onto row actor| Cell["Grid cell
(Initializing / Ready / Error)"]

A guided tour

A minimal but complete column, end to end, so the shape is concrete before the detail chapters. It shows the last traded price of each row, read from the row's shared feed.

LastTradeColumn.csusing System;
using System.Threading;
using System.Threading.Tasks;
using SabrTrader.Pipeline.MarketAnalyzer;

namespace MyPlugin.Columns;

// 1) The definition: stateless identity + a per-row source factory.
[MarketAnalyzerColumn("Last trade",
    Category    = "My plugin",
    Description = "Last traded price of the row's instrument.")]
public sealed class LastTradeColumn : IColumnDefinition
{
    public string Id => "myplugin.last-trade";          // stable and globally unique
    public string Header => "Last";
    public ColumnDataType DataType => ColumnDataType.Price;

    public IColumnValueSource CreateSource(InstrumentRef instrument, IColumnContext context)
        => new LastTradeSource(context);
}

// 2) The source: one per row. Listens to the row's quote, publishes on every Last change.
internal sealed class LastTradeSource : IColumnValueSource
{
    private readonly IColumnContext _context;
    private IDisposable? _subscription;

    public LastTradeSource(IColumnContext context) => _context = context;

    public Task StartAsync(IColumnValueSink sink, CancellationToken cancellationToken)
    {
        IInstrumentFeed? 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);
        // Runs on the feed thread; a known quote is replayed to the handler at once.
        _subscription = feed.OnQuote(change =>
        {
            if ((change.Changed & RowQuoteFields.Last) != 0 && change.Quote.HasLast)
                sink.Publish(CellValue.Ready(change.Quote.Last));   // safe from any thread
        });

        return Task.CompletedTask;
    }

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

That is the entire plugin. The host finds the public IColumnDefinition, lists it in the Columns dialog under the [MarketAnalyzerColumn] name, persists the user's choice and styles the cell. The next three chapters unpack each piece.

The contract surface

Everything in this chapter lives in the SabrTrader.Pipeline.MarketAnalyzer namespace inside the SabrTrader.Pipeline.Contracts SDK assembly:

Type Role Chapter
IColumnDefinition Identity + per-row source factory Defining a column
ColumnDataType Payload type-tag (drives formatting & sort) Defining a column
MarketAnalyzerColumnAttribute, ColumnParameterAttribute Picker metadata + user-configurable parameters Defining a column
IColumnValueSource Per-row worker Producing values
IColumnValueSink, CellValue, CellState Publishing values Producing values
IColumnContext, ColumnContextExtensions, IMarketAnalyzerClock, ISessionResolver Engine service surface Producing values
IInstrumentFeed, RowQuote, RowQuoteChange, RowQuoteFields, QuoteSource, RowBinding, RowBindingKind The row's shared market data Producing values
InstrumentRef Row key Producing values
IInstrumentDescriptionSource Optional host service Producing values