FinaQuotes Feed: документация WebSocket API

Котировочный фид реального времени, доставляемый по одному WebSocket-соединению. Аутентифицируйтесь один раз, подпишитесь на нужные инструменты и получайте живой поток ценовых тиков.

Что это

Фид - это потоковый API: цены приходят уведомлениями в момент изменения, опрос (polling) не нужен. Котировки собираются из нескольких институциональных и биржевых источников данных, нормализуются FinaQuotes и доставляются вам по единому простому JSON-протоколу.

Что нужно для подключения

  • Токен доступа (WebApiId, WebApiKey и Secret) - предоставляется оператором.
  • URL точки входа: wss://feed.finaquotes.com/feed (только защищённый WebSocket).
  • Любой стандартный WebSocket-клиент на вашем языке.
Токен Учётные данные выдаёт оператор. Держите Secret в секрете - он никогда не покидает вашу сторону и используется только для вычисления подписи запросов.

Быстрый старт

Минимальный путь от нуля до живого потока цен - пять шагов:

  1. Откройте WebSocket-соединение к wss://feed.finaquotes.com/feed.
  2. Отправьте запрос Login с HMAC-подписью.
  3. Получите Login → Authenticated: true, затем автоматически придёт сообщение SessionInfo.
  4. Отправьте FeedSubscribe с нужными символами. В ответ сразу придёт снапшот последних цен.
  5. Принимайте уведомления FeedTick по мере обновления цен.

Все сообщения - JSON. Запросы несут выбранный вами Id; соответствующий ответ возвращает тот же Id. Уведомления (например FeedTick) поля Id не содержат.

Перейдите сразу к разделу Полный клиент - там готовый к запуску клиент на Node.js и Python, который уже делает всё перечисленное, включая heartbeat и реконнект.

Аутентификация

Аутентификация использует подпись HMAC-SHA256. Сам Secret никогда не передаётся по сети - вы подписываете им короткое сообщение, а сервер проверяет подпись своей копией вашего секрета.

Запрос Login

json
{
  "Id": "1",
  "Request": "Login",
  "Params": {
    "AuthType": "HMAC",
    "WebApiId": "YOUR_WEB_API_ID",
    "WebApiKey": "YOUR_WEB_API_KEY",
    "Timestamp": 1700000000000,
    "Signature": "<base64 HMAC-SHA256>"
  }
}

Как вычислить подпись

Подпись вычисляется пошагово из четырёх значений, которые у вас уже есть:

  1. Timestamp - текущее время в миллисекундах от Unix-эпохи (ms). Оно должно быть в пределах ±60 секунд от серверного времени, поэтому держите часы синхронизированными.
  2. Соберите строку сообщения, конкатенируя в строго таком порядке, без разделителей:
    message = Timestamp + Id + WebApiKey
    где Timestamp - значение миллисекунд как строка, Id - идентификатор вашего запроса, WebApiKey - ваш ключ.
  3. Подпишите сообщение алгоритмом HMAC-SHA256, используя ваш Secret в качестве ключа.
  4. Закодируйте в Base64 сырой дайджест HMAC. Эта строка и есть Signature.
Формула Signature = Base64( HMAC_SHA256( Timestamp + Id + WebApiKey, key = Secret ) )

Пример расчёта

Для входных значений:

Timestamp1700000000000
Id"1"
WebApiKey"YOUR_WEB_API_ID_KEY"
Secret"YOUR_SECRET"

Строка сообщения - 17000000000001YOUR_WEB_API_ID_KEY, а итоговая подпись:

signature
dwVxvaQwOI831BmW7OVKxcta3Q4WtYAIYOp+lTTV8u8=

Используйте этот вектор для проверки своей реализации до выхода в боевой режим.

Эталонные реализации

javascript
const crypto = require("crypto");

function sign(timestamp, id, webApiKey, secret) {
  const message = String(timestamp) + id + webApiKey;
  return crypto.createHmac("sha256", secret)
    .update(message)
    .digest("base64");
}

// Пример
const sig = sign(1700000000000, "1", "YOUR_WEB_API_ID_KEY", "YOUR_SECRET");
console.log(sig); // dwVxvaQwOI831BmW7OVKxcta3Q4WtYAIYOp+lTTV8u8=
python
import hmac, hashlib, base64

def sign(timestamp, id, web_api_key, secret):
    message = str(timestamp) + id + web_api_key
    digest = hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()
    return base64.b64encode(digest).decode()

# Пример
sig = sign(1700000000000, "1", "YOUR_WEB_API_ID_KEY", "YOUR_SECRET")
print(sig)  # dwVxvaQwOI831BmW7OVKxcta3Q4WtYAIYOp+lTTV8u8=

Успех и ошибка

При успехе сервер отвечает Authenticated: true и затем автоматически шлёт SessionInfo:

json
{ "Id": "1", "Response": "Login", "Result": { "Authenticated": true } }

При ошибке сервер возвращает ошибку и закрывает соединение:

json
{ "Id": "1", "Response": "Error", "Error": { "Code": "login_failed", "Message": "Authentication failed" } }
Ограничение попыток Неудачные попытки входа ограничены по частоте для защиты от перебора. Не зацикливайте Login при ошибке подписи - сначала исправьте параметры (см. Решение проблем).

Подключение

Подключайтесь к точке входа по защищённому WebSocket. Обычный ws:// не принимается.

Точка входаТранспорт
wss://feed.finaquotes.com/feedWebSocket (только wss)

Жизненный цикл соединения:

  1. open - WebSocket-соединение установлено.
  2. send Login - первым кадром отправьте запрос Login.
  3. придёт SessionInfo - сразу после успешного Login сервер сам присылает сообщение SessionInfo. Его также можно запросить в любой момент через {"Request":"SessionInfo"}.

SessionInfo

json
{
  "Response": "SessionInfo",
  "Result": {
    "PlatformName": "FinaQuotes Feed",
    "PlatformCompany": "FinaQuotes",
    "PlatformTimezoneOffset": 0,
    "SessionId": "550e8400-e29b-41d4-a716-446655440000",
    "SessionStatus": "Opened",
    "SessionStartTime": 1700000000000
  }
}

Получение символов

Запрос Symbols возвращает список доступных инструментов - все сразу либо один.

Запрос - все символы

json
{ "Id": "2", "Request": "Symbols" }

Запрос - один символ

json
{ "Request": "Symbols", "Params": { "Symbol": "AAPL" } }

Ответ

json
{
  "Id": "2",
  "Response": "Symbols",
  "Result": {
    "Symbols": [
      {
        "Symbol": "AAPL",
        "Precision": 2,
        "Description": "Apple Inc",
        "ContractSize": 1,
        "MarginCurrency": "USD",
        "ProfitCurrency": "USD",
        "TradeAmountStep": 1,
        "MinTradeAmount": 1
      }
    ]
  }
}

Поля

ПолеТипЗначение
SymbolstringТикер, например AAPL.
PrecisionintЧисло знаков после запятой в цене (центы = 2).
DescriptionstringЧитаемое название инструмента.
ContractSizenumberНоминальный дефолт.
MarginCurrencystringНоминальный дефолт.
ProfitCurrencystringНоминальный дефолт.
TradeAmountStepnumberНоминальный дефолт.
MinTradeAmountnumberНоминальный дефолт.
Поля ContractSize, MarginCurrency, ProfitCurrency, TradeAmountStep и MinTradeAmount несут номинальные значения по умолчанию. Для интеграции «только котировки» важны поля Symbol, Precision и Description.

Подписка

Подпишитесь на один или несколько символов через FeedSubscribe. В ответ сразу приходит снапшот текущей котировки по каждому распознанному символу плюс список нераспознанных. Далее по подписанным символам идут уведомления FeedTick по мере изменения цены.

Запрос

json
{
  "Id": "3",
  "Request": "FeedSubscribe",
  "Params": {
    "Subscribe": [ { "Symbol": "AAPL" }, { "Symbol": "MSFT" } ]
  }
}

Ответ - снапшот + нераспознанные

json
{
  "Id": "3",
  "Response": "FeedSubscribe",
  "Result": {
    "Snapshot": [
      {
        "Symbol": "AAPL",
        "Timestamp": 1700000000000,
        "BestBid": { "Type": "Bid", "Price": 307.36, "Volume": 100 },
        "BestAsk": { "Type": "Ask", "Price": 307.42, "Volume": 200 },
        "SessionOpen": true
      }
    ],
    "Fails": [ "UNKNOWN1" ]
  }
}
  • Snapshot - текущая котировка (лучшие bid/ask) по каждому распознанному символу, возвращается сразу.
  • SessionOpen - торгуется ли рынок инструмента в момент снапшота. Если false, это последняя котировка перед закрытием, её Timestamp может быть давним; потока FeedTick до открытия рынка не будет.
  • Fails - символы из запроса, которые не были распознаны.
В подписку можно передать поле BookDepth, но оно игнорируется - фид всегда отдаёт top-of-book (один уровень).

Уведомления FeedTick

По каждому подписанному символу при изменении цены приходит уведомление FeedTick. Поля Id в нём нет:

json
{
  "Response": "FeedTick",
  "Result": {
    "Symbol": "AAPL",
    "Timestamp": 1700000000500,
    "BestBid": { "Type": "Bid", "Price": 307.47, "Volume": 300 },
    "BestAsk": { "Type": "Ask", "Price": 307.53, "Volume": 100 }
  }
}

Расширенные часы (акции США)

Акции США торгуются и до, и после основной сессии. Если для вашего аккаунта включены расширенные часы, фид покрывает и их:

  • FeedTick по акциям США продолжают приходить в премаркет, 04:00-09:30, и в постмаркет, 16:00-20:00 по нью-йоркскому времени (с учётом перехода на летнее время). Формат сообщений тот же, что в основной сессии.
  • Снапшот при FeedSubscribe отдаёт последнюю котировку с учётом расширенной сессии, а SessionOpen в эти часы равен true.
  • История цен расширенной сессии доступна по REST с параметром session=extended - см. GET /api/v1/candles.

Если расширенные часы для аккаунта выключены, акции США идут только в основную сессию, 09:30-16:00 по нью-йоркскому времени, а вне её SessionOpen равен false. Расширенные часы есть только у акций США, остальные классы активов торгуются в свои обычные часы. Чтобы включить или выключить расширенные часы для аккаунта, напишите в поддержку через вашего менеджера.

Отписка

FeedUnsubscribe убирает символы. В ответе возвращается Symbols - оставшиеся активные подписки.

json
{ "Id": "4", "Request": "FeedUnsubscribe", "Params": { "Unsubscribe": [ "AAPL" ] } }
json
{ "Id": "4", "Response": "FeedUnsubscribe", "Result": { "Symbols": [ "MSFT" ] } }

Формат цены

Каждый тик несёт лучший bid и лучший ask - по одному ценовому уровню на сторону. Содержимое полей зависит от инструмента:

  • Реальный спред там, где рынок его котирует. Для акций, форекса и спот-металлов BestBid.Price и BestAsk.Price несут фактические лучшие bid и ask - спред между ними является реальными рыночными данными.
  • Реальные размеры уровней для акций. Для акций США и ряда международных листингов Volume несёт фактический размер уровня в единицах инструмента (штуках акций). Там, где размеров нет (форекс, индексы и другие), Volume равен 0.
  • Инструменты с одной ценой. Там, где рынок не даёт котировочных данных (криптовалюты, индексы, сырьё, облигации), обе стороны несут одну и ту же последнюю цену - bid = ask = last - и Volume равен 0. Если приложению нужен спред по таким инструментам - наложите его сами поверх последней цены.
  • Только top-of-book. Предоставляется один ценовой уровень - глубины стакана нет. Любой BookDepth в подписке игнорируется.
Среднюю цену всегда можно получить как среднее BestBid.Price и BestAsk.Price; у инструментов с одной ценой обе стороны равны. Volume: 0 означает, что размер недоступен, а не пустой уровень. Timestamp - время цены в миллисекундах от Unix-эпохи.

Список инструментов

  • Категории. Текущий набор охватывает акции, форекс, криптовалюты, металлы, сырьё, облигации и индексы.
  • Актуальный список. Всегда запрашивайте живой список запросом Symbols, а не зашивайте его в код - см. Получение символов.
  • Автоматическое расширение. Список инструментов растёт автоматически; новые символы появляются сами, без действий с вашей стороны.
  • Нормализация тикеров. Тикеры чистые (например AAPL). Тикеры с точкой передаются без неё - например BRK.B становится BRKB.

Полный список инструментов - что отдаём сейчас и какой каталог доступен дополнительно.

REST API

Помимо WebSocket-фида, справочные данные и история цен доступны по обычному HTTPS. Все эндпоинты живут под https://feed.finaquotes.com/api/v1/ и аутентифицируются одним заголовком:

заголовок
x-history-token: YOUR_HISTORY_TOKEN

Токен выдаёт оператор вместе с учётными данными фида. Запросы без валидного токена возвращают 404.

Полный список параметров, коды ошибок, лимиты и примеры на curl и Python - в справочнике REST API.

GET /api/v1/instruments

Каталог инструментов, раздаваемых фидом, - тот же набор, что возвращает запрос Symbols по WebSocket. Необязательный query-параметр category фильтрует по классу активов; неизвестная категория возвращает 400.

запрос
curl -H "x-history-token: YOUR_HISTORY_TOKEN" \
  "https://feed.finaquotes.com/api/v1/instruments?category=forex"
json
{
  "count": 70,
  "instruments": [
    {
      "symbol": "AUDCAD",
      "name": "Australian Dollar / Canadian Dollar",
      "category": "forex",
      "currency": "CAD",
      "digits": 5,
      "session": "forex_24_5"
    }
  ]
}

Поля

ПолеТипЗначение
symbolstringТикер в том виде, как его использует фид, например AAPL, EURUSD, BTCUSDT.
namestringЧитаемое название инструмента.
categorystringКласс актива: crypto, stocks, forex, metals, commodities, indices или bonds.
currencystringВалюта котировки.
digitsintЧисло знаков после запятой в цене - совпадает с Precision в фиде.
sessionstringТип торговой сессии, например 24_7, nasdaq, forex_24_5.
Без category возвращается полный список, отсортированный по категории и символу. Цен в ответе нет - живые цены идут из фида, история - из /api/v1/candles.

GET /api/v1/categories

Классы активов с количеством активных инструментов в каждом:

json
{
  "count": 7,
  "categories": [
    { "category": "crypto", "count": 292 },
    { "category": "stocks", "count": 819 },
    { "category": "forex", "count": 70 },
    { "category": "metals", "count": 19 },
    { "category": "commodities", "count": 38 },
    { "category": "indices", "count": 38 },
    { "category": "bonds", "count": 6 }
  ]
}
Счётчики растут по мере расширения списка инструментов - числа выше считайте примером на момент публикации, а не контрактом.

GET /api/v1/candles

История цен OHLCV. Параметры: symbol (обязателен), tf - один из 1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W, 1MN, from/to - миллисекунды от Unix-эпохи (необязательны), limit - число баров (по умолчанию 1000, максимум 5000), session - regular (по умолчанию) или extended, чтобы включить премаркет и постмаркет акций США (только если для аккаунта включены расширенные часы).

запрос
curl -H "x-history-token: YOUR_HISTORY_TOKEN" \
  "https://feed.finaquotes.com/api/v1/candles?symbol=AAPL&tf=1H&limit=2"
json
{
  "symbol": "AAPL",
  "tf": "1H",
  "candles": [
    { "ts": 1782928800000, "open": 293.725, "high": 295.53, "low": 292.7, "close": 294.72, "volume": 4190956, "trades": 43640 },
    { "ts": 1782932400000, "open": 294.73, "high": 295.55, "low": 293.7501, "close": 294.25, "volume": 5363332, "trades": 59130 }
  ]
}

ts - время открытия бара (UTC, мс). Неверный tf или отсутствующий symbol возвращают 400; неизвестный символ - 404.

Справочник сообщений

Все сообщения используют единый конверт.

НаправлениеФорма
запрос{ "Id", "Request", "Params" }
ответ{ "Id", "Response", "Result" }
ошибка{ "Id", "Response": "Error", "Error": { "Code", "Message" } }
уведомление{ "Response", "Result" }  (без Id)

Login

запрос
{ "Id": "1", "Request": "Login", "Params": { "AuthType": "HMAC", "WebApiId": "YOUR_WEB_API_ID", "WebApiKey": "YOUR_WEB_API_KEY", "Timestamp": 1700000000000, "Signature": "<base64>" } }
ответ
{ "Id": "1", "Response": "Login", "Result": { "Authenticated": true } }

SessionInfo

запрос (опц.)
{ "Request": "SessionInfo" }
ответ
{ "Response": "SessionInfo", "Result": { "PlatformName": "FinaQuotes Feed", "PlatformCompany": "FinaQuotes", "PlatformTimezoneOffset": 0, "SessionId": "<uuid>", "SessionStatus": "Opened", "SessionStartTime": 1700000000000 } }

Symbols

запрос
{ "Id": "2", "Request": "Symbols" }
ответ
{ "Id": "2", "Response": "Symbols", "Result": { "Symbols": [ { "Symbol": "AAPL", "Precision": 2, "Description": "Apple Inc", "ContractSize": 1, "MarginCurrency": "USD", "ProfitCurrency": "USD", "TradeAmountStep": 1, "MinTradeAmount": 1 } ] } }

FeedSubscribe

запрос
{ "Id": "3", "Request": "FeedSubscribe", "Params": { "Subscribe": [ { "Symbol": "AAPL" }, { "Symbol": "MSFT" } ] } }
ответ
{ "Id": "3", "Response": "FeedSubscribe", "Result": { "Snapshot": [ { "Symbol": "AAPL", "Timestamp": 1700000000000, "BestBid": { "Type": "Bid", "Price": 307.36, "Volume": 100 }, "BestAsk": { "Type": "Ask", "Price": 307.42, "Volume": 200 }, "SessionOpen": true } ], "Fails": [ "UNKNOWN1" ] } }

FeedTick (уведомление)

уведомление
{ "Response": "FeedTick", "Result": { "Symbol": "AAPL", "Timestamp": 1700000000500, "BestBid": { "Type": "Bid", "Price": 307.47, "Volume": 300 }, "BestAsk": { "Type": "Ask", "Price": 307.53, "Volume": 100 } } }

FeedUnsubscribe

запрос
{ "Id": "4", "Request": "FeedUnsubscribe", "Params": { "Unsubscribe": [ "AAPL" ] } }
ответ
{ "Id": "4", "Response": "FeedUnsubscribe", "Result": { "Symbols": [ "MSFT" ] } }

Ping / Pong

запрос
{ "Request": "Ping" }
ответ
{ "Response": "Pong" }

Error

ответ
{ "Id": "1", "Response": "Error", "Error": { "Code": "login_failed", "Message": "Authentication failed" } }

Правила

Одно подключение на токен

На токен допустимо одно активное соединение. Новое подключение тем же токеном разрывает предыдущее.

Heartbeat

  • Если сервер не видит активности от клиента дольше 60 секунд, он разрывает соединение.
  • Периодически шлите Ping (или WS-ping-кадр) - рекомендуется каждые 20-30 секунд.
  • Сервер также сам шлёт WebSocket-ping каждые 30 секунд.

Реконнект

При разрыве переподключайтесь с экспоненциальным backoff (например 1с, 2с, 4с … до 30с) и выполняйте Login повторно.

Вне торговой сессии

При закрытом рынке подписка всё равно возвращает снапшот (последнюю известную котировку), но потока FeedTick нет до открытия рынка. Это нормально. Для акций США с включёнными расширенными часами премаркет и постмаркет считаются открытым рынком - см. Расширенные часы.

Попытки входа

Неудачные попытки входа ограничены по частоте (защита от перебора). Не зацикливайте Login при ошибке подписи - сначала проверьте параметры.

Полный клиент

Полный, готовый к запуску клиент. Он подключается, входит с корректной HMAC-подписью, подписывается, печатает входящие тики, шлёт Ping каждые 25 секунд как heartbeat и переподключается с экспоненциальным backoff.

Нужен пакет ws: npm install ws. Модуль crypto встроенный.
client.js
const WebSocket = require("ws");
const crypto = require("crypto");

const URL = "wss://feed.finaquotes.com/feed";
const WEB_API_ID = "YOUR_WEB_API_ID";
const WEB_API_KEY = "YOUR_WEB_API_KEY";
const SECRET = "YOUR_SECRET";
const SYMBOLS = ["AAPL", "MSFT"];

let ws;
let pingTimer = null;
let backoff = 1000; // старт 1с, потолок 30с

function sign(timestamp, id, webApiKey, secret) {
  const message = String(timestamp) + id + webApiKey;
  return crypto.createHmac("sha256", secret).update(message).digest("base64");
}

function send(obj) {
  ws.send(JSON.stringify(obj));
}

function login() {
  const id = "1";
  const ts = Date.now();
  const signature = sign(ts, id, WEB_API_KEY, SECRET);
  send({
    Id: id,
    Request: "Login",
    Params: {
      AuthType: "HMAC",
      WebApiId: WEB_API_ID,
      WebApiKey: WEB_API_KEY,
      Timestamp: ts,
      Signature: signature
    }
  });
}

function subscribe() {
  send({
    Id: "3",
    Request: "FeedSubscribe",
    Params: { Subscribe: SYMBOLS.map((s) => ({ Symbol: s })) }
  });
}

function startHeartbeat() {
  stopHeartbeat();
  pingTimer = setInterval(() => send({ Request: "Ping" }), 25000);
}
function stopHeartbeat() {
  if (pingTimer) clearInterval(pingTimer);
  pingTimer = null;
}

function connect() {
  ws = new WebSocket(URL);

  ws.on("open", () => {
    console.log("connected, logging in");
    login();
  });

  ws.on("message", (data) => {
    let msg;
    try { msg = JSON.parse(data.toString()); } catch (e) { return; }

    if (msg.Response === "Login" && msg.Result && msg.Result.Authenticated) {
      console.log("authenticated");
      backoff = 1000; // сброс backoff при успехе
      startHeartbeat();
      subscribe();
    } else if (msg.Response === "SessionInfo") {
      console.log("session:", msg.Result.SessionId);
    } else if (msg.Response === "FeedSubscribe") {
      console.log("snapshot:", msg.Result.Snapshot);
      if (msg.Result.Fails && msg.Result.Fails.length) {
        console.log("unrecognized:", msg.Result.Fails);
      }
    } else if (msg.Response === "FeedTick") {
      const r = msg.Result;
      console.log(r.Symbol, r.BestBid.Price, "@", r.Timestamp);
    } else if (msg.Response === "Pong") {
      // heartbeat подтверждён
    } else if (msg.Response === "Error") {
      console.error("error:", msg.Error.Code, msg.Error.Message);
    }
  });

  ws.on("close", () => {
    console.log("disconnected, reconnecting in", backoff, "ms");
    stopHeartbeat();
    setTimeout(connect, backoff);
    backoff = Math.min(backoff * 2, 30000);
  });

  ws.on("error", (err) => {
    console.error("socket error:", err.message);
    // следом сработает close и запустит реконнект
  });
}

connect();
Нужен websocket-client: pip install websocket-client. Модули hmac, hashlib и base64 встроенные.
client.py
import json
import time
import hmac
import hashlib
import base64
import threading
import websocket  # pip install websocket-client

URL = "wss://feed.finaquotes.com/feed"
WEB_API_ID = "YOUR_WEB_API_ID"
WEB_API_KEY = "YOUR_WEB_API_KEY"
SECRET = "YOUR_SECRET"
SYMBOLS = ["AAPL", "MSFT"]

backoff = 1.0  # секунды, потолок 30
_ping_stop = None


def sign(timestamp, id, web_api_key, secret):
    message = str(timestamp) + id + web_api_key
    digest = hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()
    return base64.b64encode(digest).decode()


def send(ws, obj):
    ws.send(json.dumps(obj))


def login(ws):
    id = "1"
    ts = int(time.time() * 1000)
    signature = sign(ts, id, WEB_API_KEY, SECRET)
    send(ws, {
        "Id": id,
        "Request": "Login",
        "Params": {
            "AuthType": "HMAC",
            "WebApiId": WEB_API_ID,
            "WebApiKey": WEB_API_KEY,
            "Timestamp": ts,
            "Signature": signature,
        },
    })


def subscribe(ws):
    send(ws, {
        "Id": "3",
        "Request": "FeedSubscribe",
        "Params": {"Subscribe": [{"Symbol": s} for s in SYMBOLS]},
    })


def start_heartbeat(ws):
    global _ping_stop
    _ping_stop = threading.Event()

    def loop():
        while not _ping_stop.wait(25):
            try:
                send(ws, {"Request": "Ping"})
            except Exception:
                break

    threading.Thread(target=loop, daemon=True).start()


def stop_heartbeat():
    global _ping_stop
    if _ping_stop:
        _ping_stop.set()
        _ping_stop = None


def on_open(ws):
    print("connected, logging in")
    login(ws)


def on_message(ws, message):
    global backoff
    try:
        msg = json.loads(message)
    except ValueError:
        return

    resp = msg.get("Response")
    if resp == "Login" and msg.get("Result", {}).get("Authenticated"):
        print("authenticated")
        backoff = 1.0  # сброс backoff при успехе
        start_heartbeat(ws)
        subscribe(ws)
    elif resp == "SessionInfo":
        print("session:", msg["Result"]["SessionId"])
    elif resp == "FeedSubscribe":
        print("snapshot:", msg["Result"]["Snapshot"])
        fails = msg["Result"].get("Fails")
        if fails:
            print("unrecognized:", fails)
    elif resp == "FeedTick":
        r = msg["Result"]
        print(r["Symbol"], r["BestBid"]["Price"], "@", r["Timestamp"])
    elif resp == "Pong":
        pass  # heartbeat подтверждён
    elif resp == "Error":
        print("error:", msg["Error"]["Code"], msg["Error"]["Message"])


def on_close(ws, status, reason):
    print("disconnected:", status, reason)
    stop_heartbeat()


def run():
    global backoff
    while True:
        ws = websocket.WebSocketApp(
            URL,
            on_open=on_open,
            on_message=on_message,
            on_close=on_close,
        )
        # ping_interval держит WS-heartbeat на уровне протокола
        ws.run_forever(ping_interval=25)
        print("reconnecting in", backoff, "s")
        time.sleep(backoff)
        backoff = min(backoff * 2, 30.0)


if __name__ == "__main__":
    run()

FAQ / Решение проблем

Login падает с «Authentication failed»

Почти всегда это несовпадение подписи. Проверьте по порядку:

  • Сообщение собирается как Timestamp + Id + WebApiKey в строго таком порядке, конкатенацией без разделителей, Timestamp как строка.
  • Ключ HMAC - это ваш Secret, а не WebApiKey.
  • Дайджест кодируется в Base64 (не hex).
  • Сверьтесь с опубликованным тест-вектором - он должен дать dwVxvaQwOI831BmW7OVKxcta3Q4WtYAIYOp+lTTV8u8=.

Login отклоняется, хотя подпись выглядит верной

Скорее всего устарел Timestamp. Он должен быть в пределах ±60 секунд от серверного времени. Синхронизируйте часы машины (NTP) и формируйте timestamp заново в момент отправки.

Соединение рвётся после ~60 секунд тишины

Вы не шлёте heartbeat. Отправляйте Ping каждые 20-30 секунд (в примерах - 25с). Без активности сервер закрывает простаивающие соединения.

Подписался, но тиков нет

Вероятно, рынок закрыт. Снапшот (последнюю известную котировку) при подписке вы всё равно получите, но уведомления FeedTick идут только при открытом рынке. Они возобновятся автоматически при открытии.

Соединение рвётся сразу после второго Login

Это правило «одно подключение на токен». Новое подключение тем же токеном разрывает старое. Убедитесь, что не запущены два клиента с одними и теми же учётными данными.

Символ попадает в «Fails»

Он не был распознан. Проверьте написание и помните про нормализацию тикеров: тикеры с точкой передаются без неё (например BRK.B → BRKB). Запросите живой список через Symbols, чтобы уточнить точное имя.