Appearance
События 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 события |
eventType | order_update, execution_update или position_update |
platform | фактическая зарегистрированная platform; сейчас ninjatrader |
instanceId | фактический instance зарегистрированного соединения |
accountName | account, на события которого подписан 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.
Ошибки
| Endpoint | HTTP | Code | Причина |
|---|---|---|---|
| оба | 401 | invalid_api_key | ключ отсутствует или недействителен |
| оба | 403 | insufficient_scope | нет events:read |
| stream | 400 | invalid_terminal_source | неверные, отсутствующие или повторённые query parameters |
| stream | 400 | invalid_event_cursor | повторённый или слишком длинный Last-Event-ID |
| stream | 404 | terminal_source_not_found | exact source сейчас не активен |
| stream | 429 | too_many_event_subscriptions | уже открыты 3 streams пользователя; Retry-After: 5 |
| оба | 429 | api_rate_limited | превышен API-key rate limit |
Если ошибка возникает до начала SSE response, backend возвращает обычный JSON error envelope. После 200 text/event-stream разрыв соединения обнаруживается как EOF/network exception и обрабатывается reconnect logic.