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
OnInitializeon. 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);
}
}
}
Related articles
- Lifecycle and threads: when the indicator is attached and when it is disposed.
- Settings: what the platform saves for you.