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.
On this page
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.
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 |