Market Analyzer columns

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.

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.

Not the right tool when… you need a chart overlay (write an indicator) or an automated order (write a strategy). A column produces one scalar cell per row — it observes, it doesn't trade.

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