Market Analyzer columns
Overview INTERFACE
The Market Analyzer is TradeStrike'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 new column to that grid. Drop a DLL next to the host, and your column shows up in the Columns… dialog, categorized and searchable, persisting across restarts — with zero host changes.
On this page
What a column plugin is
A column plugin is a small, self-contained .NET type set that teaches the Market Analyzer how to compute and display one new 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 tiny actor that subscribes to live data (or runs a one-shot
backfill) and pushes cell values into the grid
(
IColumnValueSource).
Everything else — discovery, instantiation, workspace persistence, styling, conditional formatting,
sorting and totals — the host does for you. You code against
TradeStrike.Pipeline.Contracts (the SDK assembly) and never reference the engine.
Why it exists
Watchlists are where traders live. Out of the box the analyzer ships the NinjaTrader-parity 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 would be untenable.
The column contract is an open/closed seam: the engine is closed against modification but open for
extension. A new column kind is a new IColumnDefinition implementation — nothing in the
engine, the grid or the workspace format changes. Definitions are pure value-objects that hold no mutable
state, never reference the engine and never depend on a specific data provider, so the same definition is
shared across every row of the panel.
When to build one
Live market metric
A derived quote your venue exposes but the built-ins don't — a weighted mid, an imbalance ratio, a custom spread. Subscribe to 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; never compute it yourself.
External / fundamental data
Pull from an optional host service resolved through
GetService<T>() — an instrument description, a calendar, your own feed. Degrade to a
blank cell when the host doesn't offer it.
Editable note
A per-instrument text column the user types into. Mark the
descriptor IsEditable and republish on change.
The model: definition → context → source
The contract is built from three collaborating roles. Understanding the split is the whole mental model:
| 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, data-provider resolution, session resolver, symbol translation,
and optional services via GetService. |
IColumnValueSource |
One per (row, column) | The worker. Wires up in StartAsync, pushes CellValues into the
IColumnValueSink, tears down in DisposeAsync. |
The definition never holds per-row state — all of that lives in the source it spawns. The source never reads other columns' state directly — cross-column composition runs through the engine's expression evaluator, not source-to-source references. That loose coupling is what 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 back into that row's cell.
graph TD Def["IColumnDefinition
(one per column)"] -->|CreateSource(instrument, context)| Src["IColumnValueSource
(one per row)"] Ctx["IColumnContext
(clock, providers, sessions)"] -.->|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
Here is a minimal but complete column, end-to-end, so the shape is concrete before the detail chapters. It reports the live last-trade price for each row.
LastTradeColumn.csusing System;
using System.Threading;
using System.Threading.Tasks;
using TradeStrike.Pipeline.MarketAnalyzer;
using TradeStrike.Pipeline.Providers;
using TradeStrike.Pipeline.Ticks;
namespace MyPlugin.Columns;
// 1) The definition: stateless identity + a per-row source factory.
[MarketAnalyzerColumn("Last trade",
Category = "My plugin",
Description = "Live last-trade price from the venue's tick stream.")]
public sealed class LastTradeColumn : IColumnDefinition
{
public string Id => "myplugin.last-trade"; // stable, kebab-case, globally unique
public string Header => "Last";
public ColumnDataType DataType => ColumnDataType.Price;
public IColumnValueSource CreateSource(InstrumentRef instrument, IColumnContext context)
=> new LastTradeSource(instrument, context);
}
// 2) The source: one per row. Subscribes to ticks, publishes on every trade print.
internal sealed class LastTradeSource : IColumnValueSource
{
private readonly InstrumentRef _instrument;
private readonly IColumnContext _context;
private IDisposable? _subscription;
public LastTradeSource(InstrumentRef instrument, IColumnContext context)
{
_instrument = instrument;
_context = context;
}
public Task StartAsync(IColumnValueSink sink, CancellationToken cancellationToken)
{
IDataProvider? provider = _context.ResolveDataProvider(_instrument);
if (provider?.LiveTicks is null)
{
sink.Publish(CellValue.Error("No live tick feed for this instrument."));
return Task.CompletedTask;
}
string symbol = _context.ResolveBackfillSymbol(_instrument);
_subscription = provider.LiveTicks.Subscribe(symbol, tick =>
{
if ((tick.Flags & TickFlags.Trade) != 0)
sink.Publish(CellValue.Ready(tick.Price)); // safe to call from any thread
});
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
_subscription?.Dispose();
return ValueTask.CompletedTask;
}
}
That is the entire plugin. The [MarketAnalyzerColumn] attribute makes it discoverable; the host
finds it, lists it in the Columns dialog, 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 TradeStrike.Pipeline.MarketAnalyzer namespace inside the
TradeStrike.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
|
Discovery + user-configurable parameters | Defining a column |
IColumnValueSource |
Per-row worker | Producing values |
IColumnValueSink, CellValue, CellState
|
Publishing values | Producing values |
IColumnContext, IMarketAnalyzerClock, ISessionResolver
|
Engine service surface | Producing values |
InstrumentRef |
Row key | Producing values |
IColumnDefinitionCatalog, ColumnDescriptor
|
Discovery & the picker dialog | Registration & discovery |