Skip to content

События NinjaTrader через SSE

Terminal Events API позволяет внешнему клиенту читать диагностические события выбранного NinjaTrader account в реальном времени:

  • order_update — изменение состояния ордера;
  • execution_update — исполнение, включая partial fill;
  • position_update — изменение позиции.

Поток односторонний: NinjaTrader передаёт события backend через существующее SignalR-соединение, а Public API client читает их через Server-Sent Events (SSE). Обычная отправка сигналов и ReportApiSignalStatus работают независимо от этого потока.

Оба endpoint требуют scope events:read.

GET /api/v1/terminal-events/sources

Возвращает доступные сейчас NinjaTrader sources текущего пользователя.

Source включается в список, если:

  • terminal подключён к аккаунту этого пользователя;
  • terminal объявил capability terminal-events-v1;
  • последний heartbeat получен не более 120 секунд назад.

Один source — это точная пара instanceId + accountName. Несколько accounts или компьютеров отображаются отдельными строками, если каждый indicator connection зарегистрирован со своим уникальным instanceId. Повторное подключение с тем же instanceId, включая пустой, заменяет прежнее terminal connection этого пользователя.

Примеры

bash
curl 'https://tradingmonitor.pro/api/v1/terminal-events/sources' \
  --header 'Authorization: Bearer tmpro_api_REPLACE_ME'
csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;

public sealed record TerminalSource(
    string Platform,
    string InstanceId,
    string AccountName,
    string? ClientVersion,
    DateTimeOffset LastHeartbeat);

using var http = new HttpClient
{
    BaseAddress = new Uri("https://tradingmonitor.pro")
};
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "tmpro_api_REPLACE_ME");

var sources = await http.GetFromJsonAsync<List<TerminalSource>>(
    "/api/v1/terminal-events/sources");

foreach (var source in sources ?? new List<TerminalSource>())
{
    var instance = source.InstanceId.Length == 0
        ? "(default / empty)"
        : source.InstanceId;
    Console.WriteLine(
        $"{source.Platform} | {instance} | {source.AccountName} | {source.LastHeartbeat:O}");
}
python
import requests

response = requests.get(
    "https://tradingmonitor.pro/api/v1/terminal-events/sources",
    headers={"Authorization": "Bearer tmpro_api_REPLACE_ME"},
    timeout=30,
)
response.raise_for_status()

for source in response.json():
    instance = source["instanceId"] or "(default / empty)"
    print(
        source["platform"],
        instance,
        source["accountName"],
        source["lastHeartbeat"],
    )

Пример ответа:

json
[
  {
    "platform": "ninjatrader",
    "instanceId": "",
    "accountName": "Sim101",
    "clientVersion": "1.4.0",
    "lastHeartbeat": "2026-07-16T12:00:00Z"
  },
  {
    "platform": "ninjatrader",
    "instanceId": "VPS-NT8-01",
    "accountName": "Live01",
    "clientVersion": "1.4.0",
    "lastHeartbeat": "2026-07-16T12:00:03Z"
  }
]
ПолеОписание
platformсейчас всегда ninjatrader
instanceIdточное значение из настроек индикатора; пустая строка является допустимым default instance
accountNameимя account, выбранного в настройках индикатора, например Sim101
clientVersionверсия terminal client или null
lastHeartbeatвремя последнего подтверждённого heartbeat

Список является моментальным снимком. Source может отключиться после получения ответа, поэтому stream endpoint повторно проверяет его доступность.

GET /api/v1/terminal-events/stream

Открывает SSE-подписку для одной точной пары instanceId + accountName.

Query parameters

ПараметрОбязательныйОграничения
platformдаровно одно значение ninjatrader
instanceIdдаровно одно значение; пустая строка разрешена; до 128 символов
accountNameдаровно одно непустое значение; до 128 символов

Совпадение instanceId и accountName чувствительно к регистру. Не вводите их вручную, если можно выбрать значения из ответа /sources.

Пустой instanceId

Для default terminal query parameter всё равно должен присутствовать: instanceId=. Отсутствующий параметр и пустое значение — разные случаи.

Дополнительный request header:

HeaderОбязательныйНазначение
Last-Event-IDнетпродолжить поток после последнего полностью обработанного eventId; не более 80 символов

curl

-N отключает output buffering в curl. Вариант с пустым default instance:

bash
curl --no-buffer --get \
  'https://tradingmonitor.pro/api/v1/terminal-events/stream' \
  --header 'Authorization: Bearer tmpro_api_REPLACE_ME' \
  --data-urlencode 'platform=ninjatrader' \
  --data-urlencode 'instanceId=' \
  --data-urlencode 'accountName=Sim101'

Повторное подключение после события evt_...:

bash
curl --no-buffer --get \
  'https://tradingmonitor.pro/api/v1/terminal-events/stream' \
  --header 'Authorization: Bearer tmpro_api_REPLACE_ME' \
  --header 'Last-Event-ID: evt_REPLACE_WITH_LAST_PROCESSED_ID' \
  --data-urlencode 'platform=ninjatrader' \
  --data-urlencode 'instanceId=VPS-NT8-01' \
  --data-urlencode 'accountName=Live01'

C#

Пример читает SSE records последовательно. Для production-клиента оберните ReadStreamAsync в reconnect loop и передавайте последний полностью обработанный eventId.

csharp
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static async Task ReadStreamAsync(
    string apiKey,
    string instanceId,
    string accountName,
    string? lastEventId,
    CancellationToken cancellationToken)
{
    using var http = new HttpClient
    {
        BaseAddress = new Uri("https://tradingmonitor.pro"),
        Timeout = Timeout.InfiniteTimeSpan
    };

    var query =
        "?platform=ninjatrader" +
        $"&instanceId={Uri.EscapeDataString(instanceId)}" +
        $"&accountName={Uri.EscapeDataString(accountName)}";

    using var request = new HttpRequestMessage(
        HttpMethod.Get,
        "/api/v1/terminal-events/stream" + query);
    request.Headers.Authorization =
        new AuthenticationHeaderValue("Bearer", apiKey);
    request.Headers.Accept.Add(
        new MediaTypeWithQualityHeaderValue("text/event-stream"));
    if (!string.IsNullOrEmpty(lastEventId))
    {
        request.Headers.TryAddWithoutValidation("Last-Event-ID", lastEventId);
    }

    using var response = await http.SendAsync(
        request,
        HttpCompletionOption.ResponseHeadersRead,
        cancellationToken);
    response.EnsureSuccessStatusCode();

    await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
    using var reader = new StreamReader(stream, Encoding.UTF8);

    string? id = null;
    string? eventType = null;
    var data = new StringBuilder();

    while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
    {
        var line = await reader.ReadLineAsync(cancellationToken);
        if (line is null)
        {
            break;
        }

        if (line.Length == 0)
        {
            if (eventType is not null)
            {
                var json = data.ToString();
                Console.WriteLine($"{eventType} id={id ?? "-"} data={json}");

                // Persist id only after the event has been handled successfully.
                if (id is not null)
                {
                    lastEventId = id;
                }
            }

            id = null;
            eventType = null;
            data.Clear();
            continue;
        }

        if (line.StartsWith(':'))
        {
            continue; // keepalive comment
        }
        if (line.StartsWith("id:", StringComparison.Ordinal))
        {
            id = line[3..].TrimStart();
        }
        else if (line.StartsWith("event:", StringComparison.Ordinal))
        {
            eventType = line[6..].TrimStart();
        }
        else if (line.StartsWith("data:", StringComparison.Ordinal))
        {
            if (data.Length > 0)
            {
                data.Append('\n');
            }
            data.Append(line[5..].TrimStart());
        }
    }
}

using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, args) =>
{
    args.Cancel = true;
    stop.Cancel();
};

await ReadStreamAsync(
    "tmpro_api_REPLACE_ME",
    instanceId: "",       // default / empty instance
    accountName: "Sim101",
    lastEventId: null,
    stop.Token);

Python

python
import json
import requests


def read_sse(api_key, instance_id, account_name, last_event_id=None):
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "text/event-stream",
    }
    if last_event_id:
        headers["Last-Event-ID"] = last_event_id

    with requests.get(
        "https://tradingmonitor.pro/api/v1/terminal-events/stream",
        headers=headers,
        params={
            "platform": "ninjatrader",
            "instanceId": instance_id,  # "" is a valid exact value
            "accountName": account_name,
        },
        stream=True,
        timeout=(10, None),
    ) as response:
        response.raise_for_status()

        event_id = None
        event_type = None
        data_lines = []

        for line in response.iter_lines(decode_unicode=True):
            if line == "":
                if event_type:
                    payload_text = "\n".join(data_lines)
                    payload = json.loads(payload_text)
                    print(event_type, event_id, payload)

                    # Save only after successful processing.
                    if event_id:
                        last_event_id = event_id

                event_id = None
                event_type = None
                data_lines = []
                continue

            if line.startswith(":"):
                continue  # keepalive comment
            if line.startswith("id:"):
                event_id = line[3:].lstrip()
            elif line.startswith("event:"):
                event_type = line[6:].lstrip()
            elif line.startswith("data:"):
                data_lines.append(line[5:].lstrip())

    return last_event_id


last_id = read_sse(
    "tmpro_api_REPLACE_ME",
    instance_id="",
    account_name="Sim101",
)

Формат событий

Пример SSE record:

text
id: evt_16338ff754da40f5a32e1e986b98e8b5
event: order_update
data: {"eventId":"evt_16338ff754da40f5a32e1e986b98e8b5","eventType":"order_update","platform":"ninjatrader","instanceId":"","accountName":"Sim101","occurredAt":"2026-07-16T12:00:00.200Z","receivedAt":"2026-07-16T12:00:00.240Z","rawText":"..."}

JSON в data:

ПолеОписание
eventIdуникальный cursor события
eventTypeorder_update, execution_update или position_update
platformфактическая зарегистрированная platform; сейчас ninjatrader
instanceIdфактический instance зарегистрированного соединения
accountNameaccount, на события которого подписан indicator
occurredAtвремя события на terminal
receivedAtвремя приёма backend
rawTextдиагностический текст с фактическими NinjaTrader fields

Backend не доверяет значениям platform/instance/account, переданным в event report: source identity берётся из зарегистрированного SignalR connection.

Correlation в rawText

  • API order/execution классифицируется как api_tracked и содержит requestId, executionId, orderRef, когда correlation известна;
  • ручные, ATM и сторонние ордера классифицируются как untracked;
  • position events классифицируются как unknown, потому что NinjaTrader position не всегда позволяет достоверно определить происхождение изменения.

rawText предназначен для диагностики и отображения. Не используйте его как стабильный машинный контракт; для надёжного статуса API-команды используйте GET /api/v1/signals/{requestId}.

Keepalive, replay и переподключение

Backend отправляет comment : keepalive примерно каждые 15 секунд при отсутствии событий. Клиент должен игнорировать comments, но считать их признаком живого соединения.

События хранятся в process-local memory:

  • не более 1000 событий на пользователя;
  • не более 10 минут;
  • буфер очищается при перезапуске backend;
  • replay выполняется только при наличии Last-Event-ID.

Если cursor отсутствует в текущем буфере, backend сначала отправляет:

text
event: replay_gap
data: {"reason":"cursor_not_available"}

После replay_gap backend передаёт доступные buffered events для выбранного source, затем продолжает live stream. Это означает, что между сохранённым cursor и первым replayed event могли быть потеряны события.

Рекомендуемый reconnect backoff: 1, 2, 5 и затем 10 секунд. На каждом reconnect передавайте последний eventId, обработка которого завершилась успешно.

Лимиты и изоляция

  • одновременно разрешено не более 3 SSE streams на пользователя;
  • лимит общий для всех API keys пользователя и всех instanceId + accountName;
  • SSE subscriptions не уменьшают и не изменяют лимит устройств тарифного плана;
  • события разных пользователей изолированы;
  • stream фильтруется по точной паре instance/account;
  • каждый HTTP-запрос к Public API, включая открытие stream, проходит общий API-key rate limiter;
  • очередь live events одного subscriber ограничена 256 элементами; слишком медленный клиент отключается.

Каждая SSE subscription удерживает один HTTP connection. Всегда закрывайте response/stream при остановке приложения, смене source или revoke credentials.

Ошибки

EndpointHTTPCodeПричина
оба401invalid_api_keyключ отсутствует или недействителен
оба403insufficient_scopeнет events:read
stream400invalid_terminal_sourceневерные, отсутствующие или повторённые query parameters
stream400invalid_event_cursorповторённый или слишком длинный Last-Event-ID
stream404terminal_source_not_foundexact source сейчас не активен
stream429too_many_event_subscriptionsуже открыты 3 streams пользователя; Retry-After: 5
оба429api_rate_limitedпревышен API-key rate limit

Если ошибка возникает до начала SSE response, backend возвращает обычный JSON error envelope. После 200 text/event-stream разрыв соединения обнаруживается как EOF/network exception и обрабатывается reconnect logic.

TradingMonitor.Pro documentation.