Table of Contents

Lifecycle and threads

A chart indicator is a passive object: the platform creates it, gives it the settings, attaches it to a chart, calls it for the history and for every tick, draws it and finally disposes it. This article lists those steps in order, the thread each one runs on, and what the platform does when your code throws. The same rules apply to chart strategies, which are indicators too; the calls of the other extension kinds are summarized in Call model.

The steps

Step What the platform calls Thread
1. Create The constructor Any thread; when a chart loads its template, a background thread
2. Apply the settings The setters of the saved public properties The same thread, right after the constructor
3. Attach OnDataProviderChanged; the indicator subscribes to the market data and the trading events of the chart The same thread
4. Calculate the history OnRecalculate, then for every bar from 0: OnInitialize (once per instance, before the first bar) and OnCalculate, then OnFinishRecalculate The same thread
5. Live data OnCalculate for the last bar on every change of it; OnNewTrade, MarketDepthChanged, OnBestBidAskChanged, OnCumulativeTrade Data threads of the platform
6. Trading events OnPositionChanged, OnOrderChanged, OnNewMyTrade and the others Threads of the trading connection
7. Draw OnRender for the layouts subscribed with SubscribeToDrawingEvents The drawing thread of the chart
8. Dispose OnDispose The thread that removes the indicator or reloads the chart

The steps 5 to 7 run at the same time, each on its own thread; step 4 is repeated whenever the indicator is recalculated (see below).

Create and apply the settings

When a chart is opened, or reloads its data, every indicator is created again from the saved template: the constructor runs, then the saved values are written through the property setters. At this moment the indicator is not attached yet: DataProvider, InstrumentInfo, ChartInfo and the data providers are null, and there are no bars. A setter that calls RecalculateValues then only calls OnRecalculate; nothing is calculated until the indicator is attached. So:

  • create the data series and set the defaults in the constructor, and do nothing that needs the chart there;
  • in a setter, check for null before using the chart, the instrument or a data provider;
  • start subscriptions (timers, other timeframes, options, files) in OnInitialize, when the indicator is attached.

Calculate the history

When the indicator gets its source data series, the platform calculates it completely: OnRecalculate, then OnCalculate for every bar from 0 to CurrentBar - 1, then OnFinishRecalculate. Before the first bar of the first full calculation the platform calls OnInitialize, once per instance.

Note

OnInitialize is called inside a full calculation that has at least one bar. An indicator attached to a chart without bars (a new instrument that waits for its first trade) gets its bars live, through OnCalculate alone, and OnInitialize is not called until the next full calculation of the same instance.

A full calculation clears the data series and the drawing objects of the indicator (TrendLines, Rectangles, Labels, the horizontal lines till touch) and the indicators added with Add, but not your fields: reset them in OnRecalculate.

protected override void OnRecalculate()
{
    // Before every full calculation: the platform clears the data series, your fields are yours to reset
    _sessionHigh = 0;
    _sessionLow = 0;
    _label = "";
}

Live data

On the last bar OnCalculate is called for every change of the bar: a trade that updates it, or a new bar. The same bar is therefore calculated many times, so the update must give the same result when it is repeated:

protected override void OnCalculate(int bar, decimal value)
{
    var candle = GetCandle(bar);

    // The current bar is calculated again on every tick: the update must give the same result
    // when it is repeated for the same bar, which a running maximum and minimum do
    if (IsNewSession(bar))
    {
        _sessionHigh = candle.High;
        _sessionLow = candle.Low;
    }
    else
    {
        _sessionHigh = Math.Max(_sessionHigh, candle.High);
        _sessionLow = Math.Min(_sessionLow, candle.Low);
    }

    _high[bar] = _sessionHigh;
    _low[bar] = _sessionLow;

    if (bar == CurrentBar - 1)
        _label = $"Session range: {_sessionHigh - _sessionLow}";
}

When the calls for the same bar come faster than ten times the duration of the previous calculation, the platform skips some of them; before it calculates the next bar it calculates the skipped bar once more, so the last values of every bar are taken into account.

When the indicator is calculated again

The platform does not call RecalculateValues on an attached indicator by itself. Instead:

  • A setting changes: nothing happens unless the setter calls RecalculateValues (see Settings). The calculation then runs on the thread of the setter, usually the UI thread.
  • The user changes the source of the indicator: a full calculation.
  • The chart reloads its data, changes the instrument, the time frame or the trading session, or applies a template: the indicator is disposed and created again from its saved settings, and the new instance goes through all the steps from the beginning. Nothing survives in your fields; keep what must survive in the settings or in the storage.

Call RecalculateValues yourself when data your calculation depends on arrives from elsewhere, for example after an asynchronous load. Call it through DoActionInGuiThread, as the setters do, rather than from a data thread.

Dispose

OnDispose is called once, when the indicator is removed from the chart, when its chart is closed and when the chart reloads (see above). After it the platform clears the data series, detaches the source, removes the timer subscriptions of the indicator and detaches it from the data: the market data, the candle series of other timeframes and instruments, the options and the live data of other instruments that the indicator subscribed to are released. Release there what the platform does not know about: your own tasks, cancellation tokens, event handlers.

protected override void OnDispose()
{
    // The indicator was removed, or the chart reloaded its data: in that case a new instance takes over
    this.LogInfo("Session extremes: disposed");
}

Threads

  • OnCalculate never runs concurrently with itself: the full calculation and the live calls for one instance take the same lock. Fields used only by OnCalculate need no locks.
  • Everything else may run at the same time as OnCalculate: the market data and trading callbacks, the timers, OnRender, the setters (the settings window, on the UI thread) and the continuations of your own tasks. Share data between them with immutable snapshots, volatile references, Interlocked or a lock of your own, and never write to the data series from them.
  • OnRender runs on the drawing thread while the calculation goes on. Compute in OnCalculate, publish the result, and only draw in OnRender:
protected override void OnRender(RenderContext context, DrawingLayouts layout)
{
    // Read what the calculation published; do not calculate here
    var label = _label;

    if (label.Length == 0)
        return;

    context.DrawString(label, _font, Color.Gray, ChartArea.X + 8, ChartArea.Y + 8);
}
  • UI work goes through DoActionInGuiThread. The action is queued to the UI thread of the chart window and the method returns at once, without waiting for it. Before the indicator is attached the call does nothing.
  • Background tasks are yours to stop. A task started from the indicator keeps running after OnDispose unless you cancel it; check IsDisposed or a cancellation token before you touch the indicator from it.

Errors

The platform catches what your code throws, writes it to the log (see Diagnostics) and keeps the indicator on the chart:

Where What happens
OnCalculate Logged as OnCalculate error, at most once per 10 seconds per instance. The bar keeps the values written before the exception; the next call is made as usual
OnRender Logged at most once per 10 seconds per instance; the other indicators of the panel are drawn
Market data callbacks Logged (Process ticks error., Process best prices error.), the next data is delivered as usual
Trading callbacks Logged (Process order changed error. and similar)
Timer callbacks Logged (Failed to call timer callback.)
Constructor, setters The indicator is not created: it is missing from the chart after the load, and the log has Loading indicator error

So an exception never stops an indicator, but an indicator that throws on every tick still loses that data: treat the log message as a bug to fix, not as a recovery mechanism. A slow OnCalculate is not stopped either; it makes the platform skip live calls for the same bar (see above).

Example

The complete indicator used above: the high and the low of the current session, a field state reset in OnRecalculate, and a label that the calculation publishes and OnRender draws.

using System;
using System.ComponentModel;
using System.Drawing;

using ATAS.Indicators;
using OFT.Rendering.Context;
using OFT.Rendering.Tools;
using Utils.Common.Logging;

namespace ATAS.Docs.Samples.ChartIndicators;

[DisplayName("Session extremes")]
public class SessionExtremesIndicator : Indicator
{
    private readonly ValueDataSeries _high = new("SessionHigh", "Session high");
    private readonly ValueDataSeries _low = new("SessionLow", "Session low");
    private readonly RenderFont _font = new("Arial", 10);

    // The calculation state: used only by OnCalculate, which never runs concurrently with itself
    private decimal _sessionHigh;
    private decimal _sessionLow;

    // Published for OnRender, which runs on the drawing thread while the calculation goes on
    private volatile string _label = "";

    public SessionExtremesIndicator()
    {
        _high.Color = Color.SeaGreen.Convert();
        _low.Color = Color.IndianRed.Convert();

        DataSeries[0] = _high;
        DataSeries.Add(_low);

        EnableCustomDrawing = true;
        SubscribeToDrawingEvents(DrawingLayouts.Final);
    }

    protected override void OnInitialize()
    {
        // Once per instance, right before the first bar of the first full calculation
        this.LogInfo("Session extremes: calculating {0} bars", CurrentBar);
    }

    protected override void OnRecalculate()
    {
        // Before every full calculation: the platform clears the data series, your fields are yours to reset
        _sessionHigh = 0;
        _sessionLow = 0;
        _label = "";
    }

    protected override void OnCalculate(int bar, decimal value)
    {
        var candle = GetCandle(bar);

        // The current bar is calculated again on every tick: the update must give the same result
        // when it is repeated for the same bar, which a running maximum and minimum do
        if (IsNewSession(bar))
        {
            _sessionHigh = candle.High;
            _sessionLow = candle.Low;
        }
        else
        {
            _sessionHigh = Math.Max(_sessionHigh, candle.High);
            _sessionLow = Math.Min(_sessionLow, candle.Low);
        }

        _high[bar] = _sessionHigh;
        _low[bar] = _sessionLow;

        if (bar == CurrentBar - 1)
            _label = $"Session range: {_sessionHigh - _sessionLow}";
    }

    protected override void OnRender(RenderContext context, DrawingLayouts layout)
    {
        // Read what the calculation published; do not calculate here
        var label = _label;

        if (label.Length == 0)
            return;

        context.DrawString(label, _font, Color.Gray, ChartArea.X + 8, ChartArea.Y + 8);
    }

    protected override void OnDispose()
    {
        // The indicator was removed, or the chart reloaded its data: in that case a new instance takes over
        this.LogInfo("Session extremes: disposed");
    }
}