Table of Contents

Storage

Note

Not released yet: the storage API comes in the ATAS version after 8.0.15.

An indicator that keeps data between runs of the platform (a cache of downloaded data, levels the user marked, the state of a long calculation) writes it to its own storage: files that belong to the indicator, addressed by logical names instead of paths. The platform decides where they are kept, isolates them from other indicators and limits their size.

Settings are a different thing: the public properties of the indicator are saved with the chart, the template and the workspace by the platform itself (see Settings). Use the storage for data that is too big for the settings, that is shared by all charts, or that grows while the indicator runs.

What you build

Storage is an IIndicatorStorage:

Member What it does
GetSubGroup(name) A logical folder inside the storage; groups nest up to 8 levels
ExistsAsync(name), GetInfoAsync(name) Whether a file or a group exists, its length and the time of the last write
OpenReadAsync(name) A read-only, seekable snapshot of a file
CreateOrReplaceAsync(name) A write session that creates or replaces a file when it is committed
OpenAppendAsync(name) A write session that appends to a file; each FlushAsync publishes what was written
EnumerateAsync() The files and groups of the current group
DeleteAsync(name) Deletes a file; a group is not deleted recursively

Names are logical names, not paths: "BTC/USDT" is one name, not a folder and a file, so instrument codes and dates can be used as they are. Empty names, ., .., control characters and names longer than 200 characters are rejected with StorageInvalidNameException.

Lifecycle

  • The storage is available once the indicator is attached to a chart, that is from OnInitialize on. Before that, and in a host that does not provide one, every call fails with StorageUnavailableException.
  • All the instances of one indicator type share one storage, on every chart, in every workspace: keep the data of different instruments apart with groups.
  • The storage stays when the indicator is removed from a chart or the platform is closed; delete the files the indicator no longer needs.
  • An indicator class in another assembly, or the same class installed from a DLL file with another name, gets a different storage. Renaming the class, its namespace or the assembly therefore starts from an empty storage.

Threads

The methods are asynchronous: call them from a task of your own, not from OnCalculate, which must not wait. Read in the background after OnInitialize and give the result to the calculation, for example by calling RecalculateValues through DoActionInGuiThread:

private async Task LoadAsync(IIndicatorStorage storage)
{
    try
    {
        if (!await storage.ExistsAsync(FileName))
            return;

        await using var stream = await storage.OpenReadAsync(FileName);
        using var reader = new StreamReader(stream, Encoding.UTF8);
        var text = await reader.ReadToEndAsync();

        if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var high))
            return;

        lock (_sync)
            _storedHigh = high;

        // The history is calculated already: calculate it again with the stored value
        DoActionInGuiThread(RecalculateValues);
    }
    catch (IndicatorStorageException e)
    {
        this.LogError("Cannot read the remembered high.", e);
    }
}

The storage itself is safe to use from several threads, with one rule: a file has at most one writer at a time. A second CreateOrReplaceAsync or OpenAppendAsync of a file that is being written fails with StorageConflictException. Different files can be written at the same time, and the instances of the indicator on other charts write to the same storage.

Writing

CreateOrReplaceAsync writes into a staging file: the old content stays visible until CommitAsync, which replaces it at once. Disposing the session without CommitAsync discards what was written, so an exception or a crash in the middle never leaves half a file.

private void SaveIfHigher(decimal high)
{
    // One write at a time: a logical file has one writer, a second one gets StorageConflictException
    if (high <= _savedHigh || !_saving.IsCompleted || InstrumentStorage is not { } storage)
        return;

    _savedHigh = high;
    _saving = SaveAsync(storage, high);
}

private async Task SaveAsync(IIndicatorStorage storage, decimal high)
{
    try
    {
        // The new content replaces the file only on CommitAsync; disposing the session without it discards it
        await using var session = await storage.CreateOrReplaceAsync(FileName);

        await using (var writer = new StreamWriter(session.Stream, Encoding.UTF8, leaveOpen: true))
            await writer.WriteAsync(high.ToString(CultureInfo.InvariantCulture));

        await session.CommitAsync();
    }
    catch (IndicatorStorageException e)
    {
        this.LogError("Cannot save the remembered high.", e);
    }
}

OpenAppendAsync appends to the end of a file; FlushAsync publishes the bytes written since the previous flush. A reader opened earlier keeps seeing the length the file had when it opened it. After a crash of the computer the last appended bytes may be incomplete: write records that the reader can check.

Limits

The limits apply to the whole storage of the indicator type, all groups included:

Limit Value
One file 1 GiB
All files together 2 GiB
Number of files 4096
Depth of groups 8
Length of a name 200 characters
Streams and sessions open at the same time 64

A write that would exceed a limit fails with StorageQuotaExceededException.

Errors

All storage exceptions derive from IndicatorStorageException, which is an IOException. OpenReadAsync of a missing file throws StorageNotFoundException; to read many files that may be missing, enumerate the group once instead of trying every name. Catch the exceptions in your task: an exception left in a task nobody awaits is lost, and the indicator just shows no data.

Direct file access

The DataPath property of the indicator, the path of the platform folder, is obsolete (a compiler warning): an indicator that builds paths from it depends on the folder layout of the platform, writes where other indicators write, and has no quotas or protection against a half-written file. Use the storage instead.

Example

The indicator remembers, per instrument, the highest price it has ever seen, so that the line keeps the value after a restart even when the chart loads a shorter history.

using System;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading.Tasks;

using ATAS.Indicators;
using Utils.Common.Logging;

namespace ATAS.Docs.Samples.ChartIndicators;

[DisplayName("Remembered high")]
public class RememberedHighIndicator : Indicator
{
    private const string FileName = "high.txt";

    private readonly object _sync = new();

    private decimal _storedHigh;  // the highest price saved by earlier runs, under _sync
    private decimal _runningHigh; // the highest price of the loaded history
    private decimal _savedHigh;   // the last value written to the storage
    private Task _saving = Task.CompletedTask;

    // One group per instrument; the name is a logical name, not a path ("BTC/USDT" is fine)
    private IIndicatorStorage? InstrumentStorage =>
        InstrumentInfo?.Instrument is { Length: > 0 } instrument
            ? Storage.GetSubGroup(instrument)
            : null;

    protected override void OnInitialize()
    {
        if (InstrumentStorage is { } storage)
            _ = LoadAsync(storage);
    }

    protected override void OnRecalculate()
    {
        _runningHigh = 0;
    }

    protected override void OnCalculate(int bar, decimal value)
    {
        _runningHigh = Math.Max(_runningHigh, GetCandle(bar).High);

        decimal storedHigh;

        lock (_sync)
            storedHigh = _storedHigh;

        this[bar] = Math.Max(_runningHigh, storedHigh);

        if (bar == CurrentBar - 1)
            SaveIfHigher(this[bar]);
    }

    private async Task LoadAsync(IIndicatorStorage storage)
    {
        try
        {
            if (!await storage.ExistsAsync(FileName))
                return;

            await using var stream = await storage.OpenReadAsync(FileName);
            using var reader = new StreamReader(stream, Encoding.UTF8);
            var text = await reader.ReadToEndAsync();

            if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var high))
                return;

            lock (_sync)
                _storedHigh = high;

            // The history is calculated already: calculate it again with the stored value
            DoActionInGuiThread(RecalculateValues);
        }
        catch (IndicatorStorageException e)
        {
            this.LogError("Cannot read the remembered high.", e);
        }
    }

    private void SaveIfHigher(decimal high)
    {
        // One write at a time: a logical file has one writer, a second one gets StorageConflictException
        if (high <= _savedHigh || !_saving.IsCompleted || InstrumentStorage is not { } storage)
            return;

        _savedHigh = high;
        _saving = SaveAsync(storage, high);
    }

    private async Task SaveAsync(IIndicatorStorage storage, decimal high)
    {
        try
        {
            // The new content replaces the file only on CommitAsync; disposing the session without it discards it
            await using var session = await storage.CreateOrReplaceAsync(FileName);

            await using (var writer = new StreamWriter(session.Stream, Encoding.UTF8, leaveOpen: true))
                await writer.WriteAsync(high.ToString(CultureInfo.InvariantCulture));

            await session.CommitAsync();
        }
        catch (IndicatorStorageException e)
        {
            this.LogError("Cannot save the remembered high.", e);
        }
    }
}