Горячие клавиши

← и →: предыдущая и следующая глава

S или /: поиск по книге

?: показать эту подсказку

Esc: закрыть подсказку или поиск

Как тестировать код против имитаций брокеров (Rust)

Инструкция. Крейт brokapi-mocks запускает в вашем процессе имитации БКС, Т-Банка, Финама, АЛОРа и Bybit. Они говорят на настоящем протоколе брокера (HTTP и WebSocket у БКС, АЛОРа и Bybit, gRPC у Т-Банка и Финама), поэтому коннектор работает с ними так же, как с брокером: без сети, без токенов, без денег. Все примеры книги и все программы в examples/ запускаются против них.

Имитация проверяет, что ваш код правильно разбирает то, что брокер описал в документации. Она не доказывает, что настоящий брокер отвечает так же.

brokapi-mocks не входит в обещания библиотеки (publish = false): его интерфейс может меняться в любой версии. Он нужен тестам и примерам.

Подключение

# edition = "2024" в [package], Rust 1.88 или новее
[dev-dependencies]
brokapi       = { path = "../brokapi", features = ["bcs", "tbank", "finam", "alor"] }
brokapi-mocks = { path = "../brokapi/crates/mocks" }
tokio         = { version = "1", features = ["full", "test-util"] }

Путь указывает на клон репозитория. Тесты - обычные #[tokio::test] (среда в одном потоке). Как обращаться со временем в таких тестах, написано в разделе Время в тестах.

Запуск каждой имитации

Имитация живет, пока жива переменная MockServer, и слушает случайный порт на 127.0.0.1. Адрес и токен подставляются в настройку коннектора специальным конструктором for_mock.

БКСТ-БанкФинамАЛОР
Запускbrokapi_mocks::bcs::MockServer::start(Default::default()).awaitbrokapi_mocks::tbank::MockServer::start(Default::default()).awaitbrokapi_mocks::finam::MockServer::start().awaitbrokapi_mocks::alor::MockServer::start(Default::default()).await
Адресserver.http_baseserver.endpointserver.urlserver.http_base (сервер авторизации по тому же адресу)
Настройка клиентаBcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true)TbankConfig::for_mock(TOKEN, &server.endpoint).with_account(ACCOUNT_A)FinamApiConfig::new(SECRET).with_endpoint(server.url.clone())AlorConfig::for_mock("mock-refresh-token", &server.http_base) (портфель D00001, биржа MOEX)
Счетаодин счет на refresh-токен: другое имя после mock-refresh- дает отдельный счетACCOUNT_A и ACCOUNT_B с разными данными"ACC1" и "ACC2"один портфель D00001, другой refresh-токен дает отдельный счет с пустыми заявками
Инструментылюбой тикер, класс TQBRсправочник (find) знает SBER и GAZP (uid-sber, uid-gazp), лот 10. Для неизвестного тикера find отвечает пустым списком, не ошибкой. Свечи с synthetic_candles отдаются и для идентификатора TickerClass любого тикерасправочник знает SBER@MISX, GAZP@MISX. Свечи с synthetic_candles отдаются для любого символа-
use std::sync::Arc;
use brokapi::bcs::protocol::config::BcsConfig;
use brokapi::bcs::{MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::bcs::limits::default_limits;
use brokapi::runtime::BrokerLimiter;
use tokio::sync::mpsc;

let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
let limiter = BrokerLimiter::new(&default_limits());
let (tx, mut events) = mpsc::channel(1024);
let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, tx).await?;
ud.reconcile(&[]).await?;   // обязательно, как и с настоящим брокером

Чтобы читать счетчики и менять рычаги работающей имитации (event_sockets(), synthetic_candles и другие), создайте настройки сами и передайте Arc в start. Тип надо назвать: Arc::new(Default::default()) компилятор не выведет.

use std::sync::Arc;
use brokapi_mocks::bcs::{MockOptions, MockServer};

let opts: Arc<MockOptions> = Arc::default();
let server = MockServer::start(opts.clone()).await;
// ... рычаги и счетчики лежат в `opts`, например `opts.event_sockets()` - сколько сокетов событий открыто.

BrokerLimiter::new возвращает Arc<BrokerLimiter>: передавайте его дальше через .clone().

Готовые рабочие программы с запуском имитаций: examples/trading_bot.rs (БКС и Т-Банк), examples/infrastructure.rs и examples/research.rs (все три). Тесты на них: tests/examples.rs.

Время в тестах

Тесты без настоящих пауз: ни sleep, ни ожидания «на всякий случай». Часы коннекторов - часы tokio: все они берут текущее время из brokapi::runtime::now(), поэтому лимит запросов, таймаут команды и пауза переподключения идут по тем же часам, что и тест.

Нельзя только одного: останавливать часы на весь тест (#[tokio::test(start_paused = true)]), когда имитация слушает настоящий порт. Пока запрос идет по сокету, у среды нет задач, и остановленные часы сами перескакивают к ближайшему таймеру. Таймаут запроса срабатывает раньше, чем прочитан ответ. Поэтому у тестов с имитациями часы настоящие, а вместо пауз - два помощника в brokapi_mocks::time:

ПомощникЗачем
jump(d)сдвигает часы вперед на d сразу. Лимит запросов, который восстанавливается за секунду, полон после jump(1 с). Таймеры, которые ждали, срабатывают
jump_until("что ждем", шаг, || условие)сдвигает часы на шаг снова и снова, пока условие не станет истинным. Для ожидания того, что перед действием спит, например пауза 0,5 с перед переоткрытием потока. Вызывать, когда запросов в пути нет
wait_until("что ждем", || условие)ждет, пока условие станет истинным (счетчик имитации, событие), без пауз. Через 10 секунд роняет тест и называет, чего не дождался. wait_until_async - то же для условия, которое спрашивают вызовом коннектора
elapse_when("что ждем", || запрос дошел, d, вызов)выполняет вызов, а когда запрос дошел до имитации, сдвигает часы на d. Так проверяют Unknown по таймауту без ожидания таймаута: имитация держит ответ дольше (post_delay_ms), чем таймаут клиента, а часы перескакивают таймаут за ноль секунд

Про «ничего не произошло» (чужой счет не получил событие, повтора не было) вместо «подождали и не увидели» проверяют так: после действия делают что-то, что приходит заведомо позже (событие по своему счету, круг запроса к брокеру), и смотрят, что между ними лишнего нет.

В готовых тестах книги так и сделано: crates/*/tests/.

Константы и пути

ЧтоГдеЗначение
Токен Т-Банкаbrokapi_mocks::tbank::TOKEN"mock-token"
Счета Т-Банкаbrokapi_mocks::tbank::{ACCOUNT_A, ACCOUNT_B}"acc-a", "acc-b"
Секрет Финамаbrokapi_mocks::finam::SECRET"mock-secret"
Счета Финамастроки"ACC1", "ACC2"
Refresh-токен БКСстрока"mock-refresh-token" (токен доступа имитации brokapi_mocks::bcs::DEFAULT_ACCESS)
Refresh-токен и портфель АЛОРаbrokapi_mocks::alor::PORTFOLIO"mock-refresh-token", "D00001" (токен доступа brokapi_mocks::alor::DEFAULT_ACCESS, лот бумаг brokapi_mocks::alor::LOT = 10)
Цена, которую имитации отклоняютbrokapi_conformance::REJECT_PRICE13.37 (можно писать число)
Цена, отклоняемая в теле ответа (БКС)brokapi_mocks::bcs::REJECT_IN_BODY_PRICE13.38

Какие данные отдают имитации

  • Стакан. БКС (чтение по REST и поток): лучшая цена покупки 244.30, продажи 244.35. Подписка на стакан БКС дает один снимок и дальше тихо: бот, которому нужен повтор по таймеру, не дождется второго снимка. Т-Банк: покупка около 273.90, продажа около 274.00, при подписке приходит несколько снимков. Финам (чтение по REST): покупка 100.0, продажа 100.1, поток снимков с периодом tick_ms. Имитации принимают любую цену, кроме отклоняемой.
  • Свечи и бары. По умолчанию фиксированный короткий ответ. Включите synthetic_candles (опция имитации), и имитация отдаст ряд под заданный период по одному бару на шаг таймфрейма и отклонит период длиннее предела брокера (БКС: больше 1440 баров, Т-Банк: INVALID_ARGUMENT 30014 по таблице пределов). Так проверяется выгрузка окнами.
  • АЛОР. Стакан: покупка 244.30, продажа 244.31, подписка на стакан дает один снимок. Бумаги SBER, GAZP (режим TQBR, лот 10, шаг 0.01) и фьючерс SiZ6, тикер NOPE неизвестен. Портфель: рубли и 30 штук SBER. Свечи всегда рядом по сетке таймфрейма на весь запрошенный период. Повтор X-REQID получает копию первого ответа, как сказано в документации брокера.
  • Заявки. Имитации помнят заявки: их можно поставить, прочитать, снять, заменить (где брокер умеет). Данные счета отдельные.

Имитация Bybit

Таблицы выше про четырех российских брокеров. Имитация Bybit устроена так же, отличия коротко.

use brokapi::bybit::protocol::config::BybitConfig;

let server = brokapi_mocks::bybit::MockServer::start(Default::default()).await;
let cfg = BybitConfig::for_mock("mock-key", "mock-secret", &server.http_base);
// MarketDataConfig::new(cfg.clone(), "bybit-md"), UserDataConfig::new(cfg, "bybit-ud"): дальше как у остальных.
  • Подпись настоящая. Имитация проверяет подпись каждого приватного запроса и окно времени (recv_window), как биржа: клиент, который подписывает неверно, получает отказ. Ключ brokapi_mocks::bybit::DEFAULT_KEY ("mock-key"), секрет SECRET ("mock-secret"). Другой ключ - отдельный счет со своими заявками.
  • Инструменты. MOCKUSDT (бессрочный, шаг количества и самое малое количество 10, шаг цены 0.01, LOT = 10), BTCUSDT (числа настоящего контракта: шаг 0.001, цена 0.10) и срочный фьючерс BTC-26DEC26. Тикер NOPEUSDT (UNKNOWN_SYMBOL) неизвестен: чтение дает код 10001.
  • Заявки и рычаги. server.fill(&cid.hyphenated().to_string(), qty, price) исполняет заявку (статус, исполнение, события). server.cut_event_sockets() обрывает приватные сокеты (бот получает Event::Reconnected), server.push_event(текст) пишет сырое сообщение в приватные сокеты. Лимитная заявка по цене brokapi_conformance::REJECT_PRICE (13.37) отвергается кодом 110003.
  • Настройки (MockOptions): rate_limit_order_book (чтение стакана отвечает HTTP 429), order_delay_ms (команда ждет столько по часам теста, для Unknown по таймауту), stop_answer_lost (стоп принят, ответ потерян: HTTP 503), clock_offset_ms (часы биржи уходят вперед), refuse_ws_login, fail_subscribe_text, stream_gate (биржа медленно открывает потоки), disconnect_after_data_frames.
  • Свечи. Ряд под запрошенный период, по одной свече на шаг интервала (synthetic_candles не нужен).

Рычаги, которыми вызывают нужный исход

Что получитьБКСТ-БанкФинамАЛОР
Исполнить заявкуserver.fill(&cid.hyphenated().to_string(), qty, price)server.fill(ACCOUNT_A, &cid.hyphenated().to_string())вручную: server.state.push_order(счет, ...)server.fill(&cid.hyphenated().to_string(), qty, price): сначала событие заявки, потом сделка
Rejected на постановке (цена задается в тесте, не берется из стакана)лимитная цена brokapi_conformance::REJECT_PRICE (13.37), в теле ответа 200 - цена 13.38 (mbcs::REJECT_IN_BODY_PRICE)REJECT_PRICE (13.37)REJECT_PRICE (13.37)REJECT_PRICE (13.37), ответ HTTP 400
Unknown на постановкеopts.order_delay_ms больше, чем cfg.request_timeout клиента (имитация держит команду это время по часам теста). Пример: a_place_without_an_answer_is_unknown в crates/bcs/tests/conformance.rsopts.post_delay_ms больше, чем with_request_timeout(...) клиента. Либо opts.post_fail с кодом gRPCopts.place_delay_ms больше таймаута клиента, либо opts.place_erroropts.order_delay_ms больше, чем request_timeout клиента
Обрыв потока стаканаopts.disconnect_after_data_framesopts.md_cut_afteropts.book_drop_afteropts.disconnect_after_data_frames
Брокер медленно открывает поток (subscribe_* должен ждать, а не вернуться раньше)opts.stream_gate.hold() до старта коннектора: рукопожатие сокетов заявок и портфеля не отвечается. opts.stream_gate.release() отпускает. opts.stream_gate.waiting() - сколько запросов держится. Подписка рынка держится так жеopts.stream_gate.hold(): потоки заявок, исполнений и портфеля не открываются. Рынок: opts.md_silent = true, подтверждения подписок не приходятserver.state.options.stream_gate.hold(): открытие любого потока не возвращаетсяopts.stream_gate.hold(): подписки на сокете не обрабатываются и не подтверждаются
Обрыв потоков своих заявок и исполнений (Event::Reconnected)server.cut_event_sockets(), подключения считает opts.ws_connections()server.cut_user_streams(), открытия считает opts.user_stream_opensserver.state.cut_user_streams(), открытия считает server.state.order_stream_opensserver.cut_event_sockets(), подключения считает opts.ws_connections()
Статус 6 «Отменяется»opts.cancel_pending, закончить отмену server.finish_cancel(cid)нетнет-
Исполнения с накопленными итогамиserver.fill(cid, qty, price) шлет executedQuantity, executionValue, averagePrice как брокернетнет-
Длинная история сделокserver.add_history_trades(n, цена, количество) (список листается по page и size, не больше 1000 последних)opts.operations_pagesserver.state.push_trade(...), ответ режется по limit и интервалуserver.add_history_trades(n, цена, количество) (один ответ до 1000)
Живая заявка, которой нет в спискеserver.hide_from_list(cid)нетнет-
Заявка, поставленная кем-то другимserver.fill/ручной вызовserver.place_foreign(счет, request_id, инструмент, лоты)server.state.push_order(...)raw().call_command(...) с чужим комментарием
Лот инструмента нельзя прочитатьнетopts.instrument_by_fail, opts.omit_lot_sizeнет-
Overflowканал на одно место, никто не читает, cfg.order_state_buffer = 2 и несколько исполнений подрядто жето жето же
RateLimitedмалый бюджет: свои числа в BrokerLimiter::new (формат файла). «Один отказ, потом проходит»: burst = 1, длинный perто жето жето же
Чтение свечей падаетне предусмотреноopts.candle_fail: 1 - отказ навсегда (Rejected), 2 - сбой сети (Transport), 0 - норма. opts.candle_network_failures: сколько следующих чтений упадут по сети, потом снова норма. Только с synthetic_candlesне предусмотрено-
Свечи по заданному периодуopts.synthetic_candlesopts.synthetic_candlesserver.state.options.synthetic_candlesвсегда: ряд по сетке, opts.history_requests хранит запросы

opts - это Arc<MockOptions>, который передается в start. Для Финама рычаги лежат в server.state.options. Все поля - атомарные числа и флаги, их можно менять, пока имитация работает.

Ловушки тестов

  • subscribe_* возвращается, когда поток открыт. Ждать подключения сокета (event_sockets()) после subscribe_order_state и subscribe_executions не нужно: заявка, поставленная сразу после возврата, не пропадет. Событие, случившееся до подписки, теряется, его читают через order_state или active_orders. Подписка раньше заявки - правило и для имитации.
  • Имитация сервера должна жить столько же, сколько коннекторы. Положите ее в переменную, которая не уходит из области раньше.
  • Рестарт процесса проверяется двумя жизнями коннекторов при одной имитации: имитация помнит заявки между ними (инфраструктура).
  • Таймауты ожидания в тестах задавайте с запасом на медленный CI, но не бесконечные: зависший тест хуже упавшего. Они срабатывают только когда тест упал, поэтому на скорость прогона не влияют.
  • Имитации не знают реальных лимитов брокеров и реальной скорости. Лимиты запросов проверяйте своим малым бюджетом.

Чего имитации не покажут

Реальные форматы чисел и времени, реальные лимиты и ошибки, поведение брокера на повтор cid, единицы количества у БКС. Проверяйте эти места на минимальной заявке.

Листать можно клавишами Влево и Вправо.

brokapi by RunInfra