Как собрать инфраструктуру на несколько брокеров и счетов (Rust)
Инструкция. Цель: один процесс держит несколько брокеров и счетов (БКС один, Т-Банк два на одном токене, Финам один, АЛОР один), все события
идут в один канал, заявки пишутся в журнал в файле, а после рестарта каждый счет сверяется с журналом. Правила: Контракт для
агентов, устройство: Несколько счетов и брокеров. Код ниже - куски программы
examples/infrastructure.rs. Она запускается тестом против имитаций всех брокеров, и тест проходит две жизни процесса.
Что получится
На схеме: коннекторы, общий канал, счетчики лимитов (у каждого брокера свой), журнал. Т-Банк делит один счетчик между рынком и обоими счетами, потому что считает лимит на пользователя.
flowchart TB
subgraph B[БКС: счетчик 1]
direction LR
BM[MD bcs-md] ~~~ BU[UD bcs]
end
subgraph T[Т-Банк: счетчик 2 на всех]
direction LR
TM[MD tbank-md] ~~~ TA[UD tbank-a] ~~~ TB2[UD tbank-b]
end
subgraph F[Финам: счетчик 3]
direction LR
FM[MD finam-md] ~~~ FU[UD finam]
end
subgraph A[АЛОР: счетчик 4]
direction LR
AM[MD alor-md] ~~~ AU[UD alor]
end
B ~~~ T ~~~ F ~~~ A
A -- события всех коннекторов --> Q[(Один канал событий)]
Q --> R[route: по env.source<br>в нужный счет]
R --> J[(Журнал в файле)]
J --> S[start: reconcile каждого счета<br>со своей частью журнала]
Из схемы следует: UD на счет, MD на брокера, счетчик на область квоты. Метка source у коннектора, которую вы задаете в
настройке, - единственное, по чему в общем канале видно, чье событие. Поэтому метки UD равны именам счетов.
Подключение
[package]
name = "my-bot"
edition = "2024" # нужен Rust 1.88 или новее: в коде книги цепочки let
[dependencies]
brokapi = { version = "0.1.0-alpha.5", features = ["bcs", "tbank", "finam", "alor"] }
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"
use brokapi::core::{
ClientOrderId, Envelope, Error, Event, ExecutionSource, InstrumentId, InstrumentQuery,
InstrumentSource, NativeId, OrderExecutor, OrderState, OrderType, PlaceOrderRequest,
PortfolioSource, Price, Qty, ReconcileReport, ShutdownMode, Side,
};
use brokapi::runtime::BrokerLimiter;
use tokio::sync::mpsc;
Настройки
Все, что нужно для подключения, в одной структуре. В боевом запуске она строится из окружения (BCS_REFRESH_TOKEN,
TBANK_TOKEN, TBANK_ACCOUNT_ID, TBANK_ACCOUNT_ID_2, FINAM_SECRET, FINAM_ACCOUNT_ID), в примере - из имитаций.
/// What the process needs to connect. In production it comes from the environment (`from_env`), in the example from
/// the imitations.
pub struct Settings {
pub bcs: brokapi::bcs::protocol::config::BcsConfig,
pub tbank: brokapi::tbank::protocol::TbankConfig,
/// Two accounts behind one T-Bank token.
pub tbank_accounts: [String; 2],
pub finam: brokapi::finam::FinamApiConfig,
pub finam_account: String,
pub alor: brokapi::alor::protocol::config::AlorConfig,
}
impl Settings {
/// BCS_REFRESH_TOKEN, BCS_IS_WRITE, TBANK_TOKEN, TBANK_SANDBOX, TBANK_ACCOUNT_ID, TBANK_ACCOUNT_ID_2, FINAM_SECRET,
/// FINAM_ACCOUNT_ID, ALOR_REFRESH_TOKEN, ALOR_PORTFOLIO.
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let (finam, account) = brokapi::finam::api_config_from_env()?;
Ok(Self {
bcs: brokapi::bcs::protocol::config::BcsConfig::from_env()?,
tbank: brokapi::tbank::protocol::TbankConfig::from_env()?,
tbank_accounts: [
std::env::var("TBANK_ACCOUNT_ID")?,
std::env::var("TBANK_ACCOUNT_ID_2")?,
],
finam,
finam_account: account.ok_or("FINAM_ACCOUNT_ID is not set")?,
alor: brokapi::alor::protocol::config::AlorConfig::from_env()?,
})
}
}
Для пробного запуска без брокеров те же настройки собираются из имитаций (запуск имитаций и их константы: Тесты против имитаций):
// The imitations live through both lives of the process, like the brokers do.
let bcs_server = mbcs::MockServer::start(Default::default()).await;
let tbank_server = mtbank::MockServer::start(Default::default()).await;
let finam_server = mfinam::MockServer::start().await;
let alor_server = malor::MockServer::start(Default::default()).await;
let settings = Settings {
bcs: brokapi::bcs::protocol::config::BcsConfig::for_mock(
"mock-refresh-token",
&bcs_server.http_base,
)
.with_write(true),
tbank: brokapi::tbank::protocol::TbankConfig::for_mock(
mtbank::TOKEN,
&tbank_server.endpoint,
),
tbank_accounts: [mtbank::ACCOUNT_A.into(), mtbank::ACCOUNT_B.into()],
finam: brokapi::finam::FinamApiConfig::new(mfinam::SECRET)
.with_endpoint(finam_server.url.clone()),
finam_account: "ACC1".into(),
alor: brokapi::alor::protocol::config::AlorConfig::for_mock(
"mock-refresh-token",
&alor_server.http_base,
),
};
MD Т-Банка не привязан к счету: его конфиг без account_id. Счета выбирают конфиги UD.
Журнал заявок в файле
Текстовый файл, одна строка на изменение: счет, cid, состояние, пояснение. Для каждой записи вызывается sync_data: журнал
должен быть на диске до вызова, который его требует. Побеждает последняя строка cid. Строка, оборванная падением,
пропускается при чтении.
/// What the journal knows about an order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
/// Written BEFORE `place`. After a crash the order may or may not exist at the broker.
Sending,
/// The broker has it and it works.
Working,
/// Final, with the text of how it ended.
Done(String),
}
/// The order journal: a text file, one line per change, `account TAB cid TAB state TAB detail`. The last line of a cid
/// wins. Every line is flushed to disk before the call that needs it: a journal that is written after `place` loses
/// the order that a crash cuts in between.
pub struct Journal {
path: PathBuf,
file: File,
orders: HashMap<ClientOrderId, (String, State)>,
}
impl Journal {
/// Opens the file (creates it if there is none) and reads what an earlier life wrote.
pub fn open(path: &Path) -> io::Result<Self> {
let mut orders = HashMap::new();
if path.exists() {
for line in BufReader::new(File::open(path)?).lines() {
let line = line?;
let mut parts = line.splitn(4, '\t');
let (Some(account), Some(cid), Some(state)) =
(parts.next(), parts.next(), parts.next())
else {
continue; // a line cut by a crash: it carries no complete change
};
let Ok(cid) = cid.parse::<ClientOrderId>() else {
continue;
};
let state = match state {
"sending" => State::Sending,
"working" => State::Working,
_ => State::Done(parts.next().unwrap_or("").to_string()),
};
orders.insert(cid, (account.to_string(), state));
}
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(Self {
path: path.to_path_buf(),
file,
orders,
})
}
/// Writes a change and waits until the disk has it.
pub fn set(&mut self, account: &str, cid: ClientOrderId, state: State) -> io::Result<()> {
let (word, detail) = match &state {
State::Sending => ("sending", String::new()),
State::Working => ("working", String::new()),
State::Done(text) => ("done", text.replace(['\t', '\n', '\r'], " ")),
};
writeln!(self.file, "{account}\t{cid}\t{word}\t{detail}")?;
self.file.sync_data()?;
self.orders.insert(cid, (account.to_string(), state));
Ok(())
}
pub fn state(&self, cid: ClientOrderId) -> Option<&State> {
self.orders.get(&cid).map(|(_, s)| s)
}
/// The ledger of one account for `reconcile`: orders it believes are alive.
pub fn live(&self, account: &str) -> Vec<ClientOrderId> {
self.orders
.iter()
.filter(|(_, (a, s))| a == account && !matches!(s, State::Done(_)))
.map(|(cid, _)| *cid)
.collect()
}
}
Что нужно процессу от счета
Три брокера - три разных типа коннекторов. Общий трейт Account (его реализуют все автоматически) дает единый вид, и
остальной код не знает, какой это брокер. Исход place сведен к четырем вариантам. Чтобы убрать счет, например оставить один
счет Т-Банка вместо двух, уберите его UD из Infra, из accounts() и из ACCOUNTS.
/// What the process needs from an account, whatever the broker is. A blanket impl makes every connector one.
pub trait Account: OrderExecutor + ExecutionSource + PortfolioSource {}
impl<T: OrderExecutor + ExecutionSource + PortfolioSource> Account for T {}
/// How a `place` ended, for the caller.
#[derive(Debug, PartialEq, Eq)]
pub enum Placed {
/// The broker accepted it.
Working,
/// The broker refused it. No order exists.
Refused(String),
/// The command did not leave the process (gate closed, quota). No order exists, the cid is not spent.
NotSent(String),
/// The outcome is unknown. The journal says `Sending`; the next reconcile or `order_state` settles it.
Unknown,
}
Запуск всех коннекторов
Порядок запуска: счетчики, канал, коннекторы, подписки на статусы и сделки каждого счета, и только потом reconcile каждого
счета со своей частью журнала. У Финама один JWT на MD и UD (shared_tokens).
/// All the connectors of the process and the journal.
pub struct Infra {
pub bcs_md: brokapi::bcs::MarketDataConnector,
pub tbank_md: brokapi::tbank::MarketDataConnector,
pub finam_md: brokapi::finam::MarketDataConnector,
pub alor_md: brokapi::alor::MarketDataConnector,
pub bcs: brokapi::bcs::UserDataConnector,
pub tbank_a: brokapi::tbank::UserDataConnector,
pub tbank_b: brokapi::tbank::UserDataConnector,
pub finam: brokapi::finam::UserDataConnector,
pub alor: brokapi::alor::UserDataConnector,
pub journal: Journal,
/// Accounts that lost events (`Overflow`): reconcile them before trading.
pub degraded: Vec<&'static str>,
}
/// The names of the accounts: the journal key and the `source` label of the envelopes.
pub const ACCOUNTS: [&str; 5] = ["bcs", "tbank-a", "tbank-b", "finam", "alor"];
impl Infra {
/// Connects everything, subscribes, reconciles every account with the journal, and only then returns.
pub async fn start(
settings: &Settings,
journal_path: &Path,
) -> Result<(Self, mpsc::UnboundedReceiver<Envelope>), Box<dyn std::error::Error>> {
use brokapi::{alor, bcs, finam, tbank};
// One limiter per area where the broker counts a limit. T-Bank counts per user: both accounts and the market
// data connector share one. BCS counts per token, Finam per user, ALOR per login. The brokers never share.
let bcs_limits = BrokerLimiter::new(&bcs::limits::default_limits());
let tbank_limits = BrokerLimiter::new(&tbank::limits::default_limits());
let finam_limits = BrokerLimiter::new(&finam::limits::default_limits());
let alor_limits = BrokerLimiter::new(&alor::limits::default_limits());
// One channel for everything: the `source` label of an envelope says whose it is. A small task moves the
// envelopes into the process's own queue at once, so the library's channel is always empty and the process
// may wait for the brokers as long as it needs (rule 5 of the contract).
let (tx, channel) = mpsc::channel::<Envelope>(8192);
let events = forward(channel);
// Finam: one secret, one JWT for the market data and the account.
let finam_api = settings
.finam
.clone()
.with_tokens(settings.finam.shared_tokens().await?);
let infra = Self {
bcs_md: bcs::MarketDataConnector::start(
bcs::MarketDataConfig::new(settings.bcs.clone(), "bcs-md"),
bcs_limits.clone(),
tx.clone(),
)
.await?,
tbank_md: tbank::MarketDataConnector::start(
tbank::MarketDataConfig::new(settings.tbank.clone(), "tbank-md"),
tbank_limits.clone(),
tx.clone(),
)
.await?,
finam_md: finam::MarketDataConnector::start(
finam::MarketDataConfig::new(finam_api.clone(), "finam-md"),
finam_limits.clone(),
tx.clone(),
)
.await?,
alor_md: alor::MarketDataConnector::start(
alor::MarketDataConfig::new(settings.alor.clone(), "alor-md"),
alor_limits.clone(),
tx.clone(),
)
.await?,
// The `source` of an account connector is the account name.
bcs: bcs::UserDataConnector::start(
bcs::UserDataConfig::new(settings.bcs.clone(), "bcs"),
bcs_limits,
tx.clone(),
)
.await?,
tbank_a: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[0]),
"tbank-a",
),
tbank_limits.clone(),
tx.clone(),
)
.await?,
tbank_b: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[1]),
"tbank-b",
),
tbank_limits,
tx.clone(),
)
.await?,
finam: finam::UserDataConnector::start(
finam::UserDataConfig::new(finam_api, &settings.finam_account, "finam"),
finam_limits,
tx.clone(),
)
.await?,
alor: alor::UserDataConnector::start(
alor::UserDataConfig::new(settings.alor.clone(), "alor"),
alor_limits,
tx,
)
.await?,
journal: Journal::open(journal_path)?,
degraded: Vec::new(),
};
// Subscribe to the statuses and fills of every account FIRST, then reconcile it with ITS part of the journal.
for (_, account) in infra.accounts() {
account.subscribe_order_state().await?;
account.subscribe_executions().await?;
}
let mut infra = infra;
for name in ACCOUNTS {
infra.reconcile_retry(name).await?;
}
Ok((infra, events))
}
/// The accounts by name, as the trait the process needs.
pub fn accounts(&self) -> [(&'static str, &dyn Account); 5] {
[
("bcs", &self.bcs),
("tbank-a", &self.tbank_a),
("tbank-b", &self.tbank_b),
("finam", &self.finam),
("alor", &self.alor),
]
}
pub fn account(&self, name: &str) -> &dyn Account {
self.accounts()
.into_iter()
.find(|(n, _)| *n == name)
.map(|(_, a)| a)
.unwrap_or_else(|| panic!("no account {name}"))
}
Вспомогательные функции
Читатель канала (forward), слова состояний для журнала и распознавание «брокер не знает заявку». Последнее у брокеров разное:
Все брокеры отвечают Error::Rejected с кодом not_found (проверка Error::is_not_found()).
Терпение NOT_FOUND_PATIENCE в примере полсекунды для имитаций. Для настоящего брокера берите секунды.
/// How long a restarted process asks about an order the broker does not know, before it closes the journal line.
const NOT_FOUND_PATIENCE: Duration = Duration::from_millis(500);
/// The connectors write into a bounded channel and never wait for it: this task moves every envelope into the
/// process's own queue. The queue is unbounded, so watch its length.
fn forward(mut events: mpsc::Receiver<Envelope>) -> mpsc::UnboundedReceiver<Envelope> {
let (queue_tx, queue) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(env) = events.recv().await {
if queue_tx.send(env).is_err() {
break;
}
}
});
queue
}
fn state_word(state: &OrderState) -> &'static str {
match state {
OrderState::InFlight { .. } => "in flight",
OrderState::Unknown { .. } => "unknown",
OrderState::Acked { .. } => "working",
OrderState::PartiallyFilled { .. } => "partly filled",
OrderState::Filled { .. } => "filled",
OrderState::Cancelled { .. } => "cancelled",
OrderState::Rejected { .. } => "rejected",
_ => "unknown",
}
}
/// "The broker does not know this order". All brokers say it as `Rejected` with the code `not_found`.
fn is_not_found(e: &Error) -> bool {
e.is_not_found()
}
Сверка при старте и после сбоя
Каждый счет сверяется со своей частью журнала. Из отчета: confirmed - заявка стоит, closed_offline - ее нет среди живых,
значит исполнилась, снялась, отклонилась или так и не была отправлена. Узнать итог можно запросом order_state. Если брокер
заявку не знает даже после ожидания, значит, процесс упал между записью в журнал и вызовом place: запись закрывается как
«не дошла». foreign показывается, но не трогается.
/// Reconciles one account with the journal and writes down what it found. The gate of the account opens only if
/// this succeeds; if it fails, repeat it later and do not trade on that account meanwhile.
pub async fn reconcile(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let ledger = self.journal.live(name);
let report = self.account(name).reconcile(&ledger).await?;
for order in &report.confirmed {
self.write(name, order.cid, State::Working);
}
for cid in &report.closed_offline {
// Not alive at the broker. Ask how it ended. "Not found" can also mean "never sent": a crash between the
// journal line and the call. Both are final for the journal; a new order gets a new cid.
let how = self.settle(name, *cid).await?;
self.write(name, *cid, State::Done(how));
}
// report.foreign: orders nobody here placed. Shown, never touched.
self.degraded.retain(|n| *n != name);
Ok(report)
}
/// The final state of an order that is not alive any more, as text for the journal.
async fn settle(&self, name: &str, cid: ClientOrderId) -> Result<String, Error> {
// How long "not found" may still change after a restart is not measured on a real broker: here half a second
// is enough for the imitations, take seconds for a real one.
let deadline = tokio::time::Instant::now() + NOT_FOUND_PATIENCE;
loop {
match self.account(name).order_state(cid).await {
Ok(order) => return Ok(state_word(&order.state).to_string()),
// The broker does not know it. Right after a cut that is not final, so wait a little.
Err(e) if is_not_found(&e) => {}
Err(Error::RateLimited { .. } | Error::Transport(_)) => {}
Err(other) => return Err(other),
}
if tokio::time::Instant::now() >= deadline {
return Ok("never reached the broker".to_string());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// The journal must not lose a line. If the disk fails, trading must stop: there is nothing to say here.
fn write(&mut self, name: &str, cid: ClientOrderId, state: State) {
self.journal
.set(name, cid, state)
.expect("the journal must be writable");
}
Сверка при старте идет через reconcile_retry: лимит или сбой сети не убивают процесс, а повторяются с паузой, остальные ошибки
возвращаются. После Overflow счета из degraded сверяются методом recover: сначала очередь читается до конца, потом
сверка.
/// A reconcile that does not give up on the first hiccup: a limit or a network error means "no sealed answer
/// yet", so wait and ask again, a few times. Any other error is returned: a retry would fail the same way.
pub async fn reconcile_retry(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let mut pause = Duration::from_millis(200);
for attempt in 1..=5 {
match self.reconcile(name).await {
Ok(report) => return Ok(report),
Err(Error::RateLimited { retry_after }) if attempt < 5 => {
tokio::time::sleep(retry_after).await
}
Err(Error::Transport(_)) if attempt < 5 => {
tokio::time::sleep(pause).await;
pause *= 2;
}
Err(other) => return Err(other),
}
}
unreachable!("the last attempt returns")
}
/// After `Overflow` (the accounts in `degraded`): read the queue to the end, THEN reconcile each such account.
/// The events that did arrive are the history up to the hole. The states after the hole come from the reconcile
/// and `order_state`; fills since the hole from `executions(range)` if the process keeps its own totals.
pub async fn recover(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
while let Ok(env) = events.try_recv() {
self.route(env);
}
for name in std::mem::take(&mut self.degraded) {
self.reconcile_retry(name).await?;
}
Ok(())
}
Заявка: журнал, вызов, журнал
/// Places an order on an account: journal first, then the command, then the journal again.
pub async fn place(&mut self, name: &'static str, req: PlaceOrderRequest) -> Placed {
if self.degraded.contains(&name) {
return Placed::NotSent("the account lost events, reconcile first".into());
}
let cid = req.cid;
self.write(name, cid, State::Sending);
match self.account(name).place(req).await {
Ok(_) => {
self.write(name, cid, State::Working);
Placed::Working
}
Err(Error::Rejected { code, message }) => {
self.write(name, cid, State::Done(format!("rejected {code}")));
Placed::Refused(message)
}
// Nothing left the process: a closed gate, a local limit, a wrong setup, a missing capability, a stop.
Err(
e @ (Error::NotReconciled
| Error::Degraded
| Error::ShuttingDown
| Error::RateLimited { .. }
| Error::Config(_)
| Error::Unsupported { .. }),
) => {
self.write(name, cid, State::Done("not sent".into()));
Placed::NotSent(e.to_string())
}
// Unknown or Transport: the order may exist. Leave `Sending`: it is in the ledger of the next reconcile.
// An error a later version adds is treated the same way: the safe reading is "may exist".
Err(_) => Placed::Unknown,
}
}
Вариант NotSent покрывает все ошибки, при которых ничего не ушло: закрытый шлюз, остановка, локальный лимит, неверная
настройка, неподдерживаемая возможность. Unknown и Transport оставляют заявку возможно существующей.
Unknown оставляет в журнале Sending. Эта запись попадет в список следующей сверки, и order_state скажет, чем кончилось.
Повторять place нельзя.
Разбор общего канала
/// One envelope of the shared channel: the label says whose it is, the payload what to do with it.
pub fn route(&mut self, env: Envelope) {
let Some(name) = ACCOUNTS.iter().copied().find(|n| **n == *env.source) else {
return; // a market data connector: the strategy reads those, not the infrastructure
};
match env.payload {
Event::Order(order) => {
let state = match &order.state {
OrderState::Acked { .. } | OrderState::PartiallyFilled { .. } => State::Working,
OrderState::InFlight { .. } | OrderState::Unknown { .. } => return,
final_state => State::Done(state_word(final_state).to_string()),
};
self.write(name, order.cid, state);
}
Event::Overflow { .. } => self.degraded.push(name),
_ => {}
}
}
Журнал не зависит от событий для правильности: place записывает Working по ответу, а сверка при старте исправит все остальное.
События лишь ускоряют запись итогов. Поэтому для примера не важно, что статусы БКС приходят после подключения сокета в фоне
(ловушка в тестах): потеряно только раннее событие, итог узнает сверка.
События маркетдаты (bcs-md, tbank-md, finam-md, alor-md) инфраструктура пропускает: их читает стратегия. Overflow счета
помечает его как «сверить до торговли».
Остановка
/// Stops everything. Accounts first, so no command is taken while the market data is still closing. Orders at the
/// brokers are not touched.
pub async fn stop(self, mode: ShutdownMode) {
self.bcs.shutdown(mode).await;
self.tbank_a.shutdown(mode).await;
self.tbank_b.shutdown(mode).await;
self.finam.shutdown(mode).await;
self.alor.shutdown(mode).await;
self.bcs_md.shutdown(mode).await;
self.tbank_md.shutdown(mode).await;
self.finam_md.shutdown(mode).await;
self.alor_md.shutdown(mode).await;
}
В отчете shutdown у БКС unsubscribed всегда 0: сессии WebSocket отписываются не отдельными сообщениями. У Т-Банка и Финама
счет подписок считается.
Вся программа целиком
Текст программы examples/infrastructure.rs
//! One process, several brokers and accounts: a shared event channel, one order journal in a file, and a restart
//! that reconciles every account against that journal.
//!
//! The code of the page "Как собрать инфраструктуру на несколько брокеров и счетов" of the book
//! (docs/src/rust/howto-infrastructure.md). The accounts are BCS (one), T-Bank (two, one token), Finam (one) and ALOR (one). `run` plays
//! two lives of the process against the in-process imitations: the first places an order on every account and goes away
//! without cancelling (and leaves a journal line for an order that was never sent), while the process is down one order
//! is filled, the second life reconciles every account with the journal and finds out what became of everything.
//! Nothing here touches the network or money.
//!
//! ```text
//! cargo run --example infrastructure
//! ```
#![allow(dead_code)]
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
// ANCHOR: imports
use brokapi::core::{
ClientOrderId, Envelope, Error, Event, ExecutionSource, InstrumentId, InstrumentQuery,
InstrumentSource, NativeId, OrderExecutor, OrderState, OrderType, PlaceOrderRequest,
PortfolioSource, Price, Qty, ReconcileReport, ShutdownMode, Side,
};
use brokapi::runtime::BrokerLimiter;
use tokio::sync::mpsc;
// ANCHOR_END: imports
// ANCHOR: journal
/// What the journal knows about an order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
/// Written BEFORE `place`. After a crash the order may or may not exist at the broker.
Sending,
/// The broker has it and it works.
Working,
/// Final, with the text of how it ended.
Done(String),
}
/// The order journal: a text file, one line per change, `account TAB cid TAB state TAB detail`. The last line of a cid
/// wins. Every line is flushed to disk before the call that needs it: a journal that is written after `place` loses
/// the order that a crash cuts in between.
pub struct Journal {
path: PathBuf,
file: File,
orders: HashMap<ClientOrderId, (String, State)>,
}
impl Journal {
/// Opens the file (creates it if there is none) and reads what an earlier life wrote.
pub fn open(path: &Path) -> io::Result<Self> {
let mut orders = HashMap::new();
if path.exists() {
for line in BufReader::new(File::open(path)?).lines() {
let line = line?;
let mut parts = line.splitn(4, '\t');
let (Some(account), Some(cid), Some(state)) =
(parts.next(), parts.next(), parts.next())
else {
continue; // a line cut by a crash: it carries no complete change
};
let Ok(cid) = cid.parse::<ClientOrderId>() else {
continue;
};
let state = match state {
"sending" => State::Sending,
"working" => State::Working,
_ => State::Done(parts.next().unwrap_or("").to_string()),
};
orders.insert(cid, (account.to_string(), state));
}
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(Self {
path: path.to_path_buf(),
file,
orders,
})
}
/// Writes a change and waits until the disk has it.
pub fn set(&mut self, account: &str, cid: ClientOrderId, state: State) -> io::Result<()> {
let (word, detail) = match &state {
State::Sending => ("sending", String::new()),
State::Working => ("working", String::new()),
State::Done(text) => ("done", text.replace(['\t', '\n', '\r'], " ")),
};
writeln!(self.file, "{account}\t{cid}\t{word}\t{detail}")?;
self.file.sync_data()?;
self.orders.insert(cid, (account.to_string(), state));
Ok(())
}
pub fn state(&self, cid: ClientOrderId) -> Option<&State> {
self.orders.get(&cid).map(|(_, s)| s)
}
/// The ledger of one account for `reconcile`: orders it believes are alive.
pub fn live(&self, account: &str) -> Vec<ClientOrderId> {
self.orders
.iter()
.filter(|(_, (a, s))| a == account && !matches!(s, State::Done(_)))
.map(|(cid, _)| *cid)
.collect()
}
}
// ANCHOR_END: journal
// ANCHOR: settings
/// What the process needs to connect. In production it comes from the environment (`from_env`), in the example from
/// the imitations.
pub struct Settings {
pub bcs: brokapi::bcs::protocol::config::BcsConfig,
pub tbank: brokapi::tbank::protocol::TbankConfig,
/// Two accounts behind one T-Bank token.
pub tbank_accounts: [String; 2],
pub finam: brokapi::finam::FinamApiConfig,
pub finam_account: String,
pub alor: brokapi::alor::protocol::config::AlorConfig,
}
impl Settings {
/// BCS_REFRESH_TOKEN, BCS_IS_WRITE, TBANK_TOKEN, TBANK_SANDBOX, TBANK_ACCOUNT_ID, TBANK_ACCOUNT_ID_2, FINAM_SECRET,
/// FINAM_ACCOUNT_ID, ALOR_REFRESH_TOKEN, ALOR_PORTFOLIO.
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let (finam, account) = brokapi::finam::api_config_from_env()?;
Ok(Self {
bcs: brokapi::bcs::protocol::config::BcsConfig::from_env()?,
tbank: brokapi::tbank::protocol::TbankConfig::from_env()?,
tbank_accounts: [
std::env::var("TBANK_ACCOUNT_ID")?,
std::env::var("TBANK_ACCOUNT_ID_2")?,
],
finam,
finam_account: account.ok_or("FINAM_ACCOUNT_ID is not set")?,
alor: brokapi::alor::protocol::config::AlorConfig::from_env()?,
})
}
}
// ANCHOR_END: settings
// ANCHOR: account
/// What the process needs from an account, whatever the broker is. A blanket impl makes every connector one.
pub trait Account: OrderExecutor + ExecutionSource + PortfolioSource {}
impl<T: OrderExecutor + ExecutionSource + PortfolioSource> Account for T {}
/// How a `place` ended, for the caller.
#[derive(Debug, PartialEq, Eq)]
pub enum Placed {
/// The broker accepted it.
Working,
/// The broker refused it. No order exists.
Refused(String),
/// The command did not leave the process (gate closed, quota). No order exists, the cid is not spent.
NotSent(String),
/// The outcome is unknown. The journal says `Sending`; the next reconcile or `order_state` settles it.
Unknown,
}
// ANCHOR_END: account
// ANCHOR: infra
/// All the connectors of the process and the journal.
pub struct Infra {
pub bcs_md: brokapi::bcs::MarketDataConnector,
pub tbank_md: brokapi::tbank::MarketDataConnector,
pub finam_md: brokapi::finam::MarketDataConnector,
pub alor_md: brokapi::alor::MarketDataConnector,
pub bcs: brokapi::bcs::UserDataConnector,
pub tbank_a: brokapi::tbank::UserDataConnector,
pub tbank_b: brokapi::tbank::UserDataConnector,
pub finam: brokapi::finam::UserDataConnector,
pub alor: brokapi::alor::UserDataConnector,
pub journal: Journal,
/// Accounts that lost events (`Overflow`): reconcile them before trading.
pub degraded: Vec<&'static str>,
}
/// The names of the accounts: the journal key and the `source` label of the envelopes.
pub const ACCOUNTS: [&str; 5] = ["bcs", "tbank-a", "tbank-b", "finam", "alor"];
impl Infra {
/// Connects everything, subscribes, reconciles every account with the journal, and only then returns.
pub async fn start(
settings: &Settings,
journal_path: &Path,
) -> Result<(Self, mpsc::UnboundedReceiver<Envelope>), Box<dyn std::error::Error>> {
use brokapi::{alor, bcs, finam, tbank};
// One limiter per area where the broker counts a limit. T-Bank counts per user: both accounts and the market
// data connector share one. BCS counts per token, Finam per user, ALOR per login. The brokers never share.
let bcs_limits = BrokerLimiter::new(&bcs::limits::default_limits());
let tbank_limits = BrokerLimiter::new(&tbank::limits::default_limits());
let finam_limits = BrokerLimiter::new(&finam::limits::default_limits());
let alor_limits = BrokerLimiter::new(&alor::limits::default_limits());
// One channel for everything: the `source` label of an envelope says whose it is. A small task moves the
// envelopes into the process's own queue at once, so the library's channel is always empty and the process
// may wait for the brokers as long as it needs (rule 5 of the contract).
let (tx, channel) = mpsc::channel::<Envelope>(8192);
let events = forward(channel);
// Finam: one secret, one JWT for the market data and the account.
let finam_api = settings
.finam
.clone()
.with_tokens(settings.finam.shared_tokens().await?);
let infra = Self {
bcs_md: bcs::MarketDataConnector::start(
bcs::MarketDataConfig::new(settings.bcs.clone(), "bcs-md"),
bcs_limits.clone(),
tx.clone(),
)
.await?,
tbank_md: tbank::MarketDataConnector::start(
tbank::MarketDataConfig::new(settings.tbank.clone(), "tbank-md"),
tbank_limits.clone(),
tx.clone(),
)
.await?,
finam_md: finam::MarketDataConnector::start(
finam::MarketDataConfig::new(finam_api.clone(), "finam-md"),
finam_limits.clone(),
tx.clone(),
)
.await?,
alor_md: alor::MarketDataConnector::start(
alor::MarketDataConfig::new(settings.alor.clone(), "alor-md"),
alor_limits.clone(),
tx.clone(),
)
.await?,
// The `source` of an account connector is the account name.
bcs: bcs::UserDataConnector::start(
bcs::UserDataConfig::new(settings.bcs.clone(), "bcs"),
bcs_limits,
tx.clone(),
)
.await?,
tbank_a: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[0]),
"tbank-a",
),
tbank_limits.clone(),
tx.clone(),
)
.await?,
tbank_b: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[1]),
"tbank-b",
),
tbank_limits,
tx.clone(),
)
.await?,
finam: finam::UserDataConnector::start(
finam::UserDataConfig::new(finam_api, &settings.finam_account, "finam"),
finam_limits,
tx.clone(),
)
.await?,
alor: alor::UserDataConnector::start(
alor::UserDataConfig::new(settings.alor.clone(), "alor"),
alor_limits,
tx,
)
.await?,
journal: Journal::open(journal_path)?,
degraded: Vec::new(),
};
// Subscribe to the statuses and fills of every account FIRST, then reconcile it with ITS part of the journal.
for (_, account) in infra.accounts() {
account.subscribe_order_state().await?;
account.subscribe_executions().await?;
}
let mut infra = infra;
for name in ACCOUNTS {
infra.reconcile_retry(name).await?;
}
Ok((infra, events))
}
/// The accounts by name, as the trait the process needs.
pub fn accounts(&self) -> [(&'static str, &dyn Account); 5] {
[
("bcs", &self.bcs),
("tbank-a", &self.tbank_a),
("tbank-b", &self.tbank_b),
("finam", &self.finam),
("alor", &self.alor),
]
}
pub fn account(&self, name: &str) -> &dyn Account {
self.accounts()
.into_iter()
.find(|(n, _)| *n == name)
.map(|(_, a)| a)
.unwrap_or_else(|| panic!("no account {name}"))
}
// ANCHOR_END: infra
// ANCHOR: recover
/// A reconcile that does not give up on the first hiccup: a limit or a network error means "no sealed answer
/// yet", so wait and ask again, a few times. Any other error is returned: a retry would fail the same way.
pub async fn reconcile_retry(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let mut pause = Duration::from_millis(200);
for attempt in 1..=5 {
match self.reconcile(name).await {
Ok(report) => return Ok(report),
Err(Error::RateLimited { retry_after }) if attempt < 5 => {
tokio::time::sleep(retry_after).await
}
Err(Error::Transport(_)) if attempt < 5 => {
tokio::time::sleep(pause).await;
pause *= 2;
}
Err(other) => return Err(other),
}
}
unreachable!("the last attempt returns")
}
/// After `Overflow` (the accounts in `degraded`): read the queue to the end, THEN reconcile each such account.
/// The events that did arrive are the history up to the hole. The states after the hole come from the reconcile
/// and `order_state`; fills since the hole from `executions(range)` if the process keeps its own totals.
pub async fn recover(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
while let Ok(env) = events.try_recv() {
self.route(env);
}
for name in std::mem::take(&mut self.degraded) {
self.reconcile_retry(name).await?;
}
Ok(())
}
// ANCHOR_END: recover
// ANCHOR: reconcile
/// Reconciles one account with the journal and writes down what it found. The gate of the account opens only if
/// this succeeds; if it fails, repeat it later and do not trade on that account meanwhile.
pub async fn reconcile(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let ledger = self.journal.live(name);
let report = self.account(name).reconcile(&ledger).await?;
for order in &report.confirmed {
self.write(name, order.cid, State::Working);
}
for cid in &report.closed_offline {
// Not alive at the broker. Ask how it ended. "Not found" can also mean "never sent": a crash between the
// journal line and the call. Both are final for the journal; a new order gets a new cid.
let how = self.settle(name, *cid).await?;
self.write(name, *cid, State::Done(how));
}
// report.foreign: orders nobody here placed. Shown, never touched.
self.degraded.retain(|n| *n != name);
Ok(report)
}
/// The final state of an order that is not alive any more, as text for the journal.
async fn settle(&self, name: &str, cid: ClientOrderId) -> Result<String, Error> {
// How long "not found" may still change after a restart is not measured on a real broker: here half a second
// is enough for the imitations, take seconds for a real one.
let deadline = tokio::time::Instant::now() + NOT_FOUND_PATIENCE;
loop {
match self.account(name).order_state(cid).await {
Ok(order) => return Ok(state_word(&order.state).to_string()),
// The broker does not know it. Right after a cut that is not final, so wait a little.
Err(e) if is_not_found(&e) => {}
Err(Error::RateLimited { .. } | Error::Transport(_)) => {}
Err(other) => return Err(other),
}
if tokio::time::Instant::now() >= deadline {
return Ok("never reached the broker".to_string());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// The journal must not lose a line. If the disk fails, trading must stop: there is nothing to say here.
fn write(&mut self, name: &str, cid: ClientOrderId, state: State) {
self.journal
.set(name, cid, state)
.expect("the journal must be writable");
}
// ANCHOR_END: reconcile
// ANCHOR: place
/// Places an order on an account: journal first, then the command, then the journal again.
pub async fn place(&mut self, name: &'static str, req: PlaceOrderRequest) -> Placed {
if self.degraded.contains(&name) {
return Placed::NotSent("the account lost events, reconcile first".into());
}
let cid = req.cid;
self.write(name, cid, State::Sending);
match self.account(name).place(req).await {
Ok(_) => {
self.write(name, cid, State::Working);
Placed::Working
}
Err(Error::Rejected { code, message }) => {
self.write(name, cid, State::Done(format!("rejected {code}")));
Placed::Refused(message)
}
// Nothing left the process: a closed gate, a local limit, a wrong setup, a missing capability, a stop.
Err(
e @ (Error::NotReconciled
| Error::Degraded
| Error::ShuttingDown
| Error::RateLimited { .. }
| Error::Config(_)
| Error::Unsupported { .. }),
) => {
self.write(name, cid, State::Done("not sent".into()));
Placed::NotSent(e.to_string())
}
// Unknown or Transport: the order may exist. Leave `Sending`: it is in the ledger of the next reconcile.
// An error a later version adds is treated the same way: the safe reading is "may exist".
Err(_) => Placed::Unknown,
}
}
// ANCHOR_END: place
// ANCHOR: route
/// One envelope of the shared channel: the label says whose it is, the payload what to do with it.
pub fn route(&mut self, env: Envelope) {
let Some(name) = ACCOUNTS.iter().copied().find(|n| **n == *env.source) else {
return; // a market data connector: the strategy reads those, not the infrastructure
};
match env.payload {
Event::Order(order) => {
let state = match &order.state {
OrderState::Acked { .. } | OrderState::PartiallyFilled { .. } => State::Working,
OrderState::InFlight { .. } | OrderState::Unknown { .. } => return,
final_state => State::Done(state_word(final_state).to_string()),
};
self.write(name, order.cid, state);
}
Event::Overflow { .. } => self.degraded.push(name),
_ => {}
}
}
// ANCHOR_END: route
// ANCHOR: stop
/// Stops everything. Accounts first, so no command is taken while the market data is still closing. Orders at the
/// brokers are not touched.
pub async fn stop(self, mode: ShutdownMode) {
self.bcs.shutdown(mode).await;
self.tbank_a.shutdown(mode).await;
self.tbank_b.shutdown(mode).await;
self.finam.shutdown(mode).await;
self.alor.shutdown(mode).await;
self.bcs_md.shutdown(mode).await;
self.tbank_md.shutdown(mode).await;
self.finam_md.shutdown(mode).await;
self.alor_md.shutdown(mode).await;
}
// ANCHOR_END: stop
}
// ANCHOR: helpers
/// How long a restarted process asks about an order the broker does not know, before it closes the journal line.
const NOT_FOUND_PATIENCE: Duration = Duration::from_millis(500);
/// The connectors write into a bounded channel and never wait for it: this task moves every envelope into the
/// process's own queue. The queue is unbounded, so watch its length.
fn forward(mut events: mpsc::Receiver<Envelope>) -> mpsc::UnboundedReceiver<Envelope> {
let (queue_tx, queue) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(env) = events.recv().await {
if queue_tx.send(env).is_err() {
break;
}
}
});
queue
}
fn state_word(state: &OrderState) -> &'static str {
match state {
OrderState::InFlight { .. } => "in flight",
OrderState::Unknown { .. } => "unknown",
OrderState::Acked { .. } => "working",
OrderState::PartiallyFilled { .. } => "partly filled",
OrderState::Filled { .. } => "filled",
OrderState::Cancelled { .. } => "cancelled",
OrderState::Rejected { .. } => "rejected",
_ => "unknown",
}
}
/// "The broker does not know this order". All brokers say it as `Rejected` with the code `not_found`.
fn is_not_found(e: &Error) -> bool {
e.is_not_found()
}
// ANCHOR_END: helpers
/// The instrument of each account in the form its broker wants. In real code every one comes from `find`.
pub async fn sber_for(
infra: &Infra,
name: &str,
) -> Result<InstrumentId, Box<dyn std::error::Error>> {
use brokapi::core::InstrumentKey;
Ok(match name {
"bcs" => InstrumentId::new(
InstrumentKey::new("SBER", "TQBR"),
NativeId::TickerClass {
ticker: "SBER".into(),
class_code: "TQBR".into(),
},
),
"tbank-a" | "tbank-b" => {
infra
.tbank_md
.find(InstrumentQuery::ByTicker {
ticker: "SBER".into(),
exchange: Some("TQBR".into()),
})
.await?
.into_iter()
.next()
.ok_or("SBER is not in the T-Bank directory")?
.id
}
"alor" => brokapi::alor::util::instrument_id("SBER", "TQBR"),
_ => brokapi::finam::util::instrument_from_symbol("SBER@MISX"),
})
}
fn buy(instrument: InstrumentId, cid: ClientOrderId) -> PlaceOrderRequest {
PlaceOrderRequest {
cid,
instrument,
side: Side::Buy,
order_type: OrderType::Limit,
qty: Qty::from(10),
price: Some(Price::from(200)),
}
}
/// What the test looks at after the two lives.
#[derive(Debug)]
pub struct Result2 {
pub cids: HashMap<&'static str, ClientOrderId>,
pub orphan: ClientOrderId,
pub journal: HashMap<ClientOrderId, State>,
pub reports: HashMap<&'static str, (usize, usize, usize)>,
}
/// Reads the channel into the journal until `done` holds or `how_long` passes.
async fn pump(
infra: &mut Infra,
events: &mut mpsc::UnboundedReceiver<Envelope>,
how_long: Duration,
done: impl Fn(&Infra) -> bool,
) {
let _ = tokio::time::timeout(how_long, async {
while !done(infra) {
match events.recv().await {
Some(env) => infra.route(env),
None => break,
}
}
})
.await;
}
/// The two lives of the process. `dir` holds the journal.
pub async fn run(dir: &Path) -> Result<Result2, Box<dyn std::error::Error>> {
use brokapi_mocks::{alor as malor, bcs as mbcs, finam as mfinam, tbank as mtbank};
// ANCHOR: mock_settings
// The imitations live through both lives of the process, like the brokers do.
let bcs_server = mbcs::MockServer::start(Default::default()).await;
let tbank_server = mtbank::MockServer::start(Default::default()).await;
let finam_server = mfinam::MockServer::start().await;
let alor_server = malor::MockServer::start(Default::default()).await;
let settings = Settings {
bcs: brokapi::bcs::protocol::config::BcsConfig::for_mock(
"mock-refresh-token",
&bcs_server.http_base,
)
.with_write(true),
tbank: brokapi::tbank::protocol::TbankConfig::for_mock(
mtbank::TOKEN,
&tbank_server.endpoint,
),
tbank_accounts: [mtbank::ACCOUNT_A.into(), mtbank::ACCOUNT_B.into()],
finam: brokapi::finam::FinamApiConfig::new(mfinam::SECRET)
.with_endpoint(finam_server.url.clone()),
finam_account: "ACC1".into(),
alor: brokapi::alor::protocol::config::AlorConfig::for_mock(
"mock-refresh-token",
&alor_server.http_base,
),
};
// ANCHOR_END: mock_settings
let journal_path = dir.join("orders.journal");
let _ = std::fs::remove_file(&journal_path);
// ---- first life: an order on every account, then the process goes away without cancelling anything.
let (mut infra, mut events) = Infra::start(&settings, &journal_path).await?;
let mut cids = HashMap::new();
for name in ACCOUNTS {
let cid = ClientOrderId::new_v4();
let instrument = sber_for(&infra, name).await?;
let placed = infra.place(name, buy(instrument, cid)).await;
assert_eq!(placed, Placed::Working, "{name}");
cids.insert(name, cid);
}
// The journal line of an order that a crash cut off before `place` was called.
let orphan = ClientOrderId::new_v4();
infra.journal.set("tbank-b", orphan, State::Sending)?;
pump(&mut infra, &mut events, Duration::from_secs(5), |i| {
ACCOUNTS
.iter()
.all(|n| i.journal.state(cids[n]) == Some(&State::Working))
})
.await;
infra.stop(ShutdownMode::Immediate).await;
println!("first life over: the orders stay on the exchanges");
// ---- while the process is down, the order of T-Bank account A is executed.
tbank_server.fill(mtbank::ACCOUNT_A, &cids["tbank-a"].hyphenated().to_string());
// ---- second life: the journal is read, every account is reconciled with its part of it.
let (mut infra, mut events) = Infra::start(&settings, &journal_path).await?;
let mut reports = HashMap::new();
for name in ACCOUNTS {
// `start` reconciled already; a second reconcile is cheap, safe, and shows the report.
let r = infra.reconcile(name).await?;
reports.insert(
name,
(r.confirmed.len(), r.closed_offline.len(), r.foreign.len()),
);
}
// Take the remaining orders off before leaving (a choice of this process; the library never does it).
for name in ["bcs", "tbank-b", "finam", "alor"] {
infra.account(name).cancel(cids[name]).await?;
}
pump(&mut infra, &mut events, Duration::from_secs(5), |i| {
["bcs", "tbank-b", "finam", "alor"]
.iter()
.all(|n| matches!(i.journal.state(cids[n]), Some(State::Done(_))))
})
.await;
let journal = ACCOUNTS
.iter()
.map(|n| cids[n])
.chain([orphan])
.filter_map(|c| infra.journal.state(c).cloned().map(|s| (c, s)))
.collect();
// An account that lost events is reconciled after the queue is read to the end.
infra.degraded.push("bcs");
infra.recover(&mut events).await?;
assert!(infra.degraded.is_empty());
infra
.stop(ShutdownMode::Graceful {
deadline: Duration::from_secs(5),
})
.await;
Ok(Result2 {
cids,
orphan,
journal,
reports,
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir();
let r = run(&dir).await?;
for (account, cid) in &r.cids {
println!("{account}: {cid} -> {:?}", r.journal[cid]);
}
Ok(())
}
Как проверить
cargo test --test examples infrastructure # в клоне репозитория
cargo run --example infrastructure # то же с выводом
Вне клона скопируйте файл программы выше в свой проект (зависимости как в начале страницы плюс brokapi-mocks) и вызовите
run(&каталог) из main.
Тест проходит две жизни процесса. Первая: заявка на каждом из четырех счетов, плюс строка журнала для заявки, которая так и не
была отправлена (падение между журналом и вызовом), процесс уходит, заявки остаются на бирже. Пока процесса нет, заявка
Т-Банка (счет A) исполняется. Вторая жизнь читает журнал и сверяет каждый счет. Проверяется: исполненная заявка закрыта как
filled, три живые найдены в confirmed и сняты процессом, неотправленная закрыта как «не дошла», чужих заявок нет.
Что менять под себя
- Состав счетов: добавьте UD и имя в
ACCOUNTSиaccounts(). Правило прежнее: UD на счет, MD на брокера, счетчик на область. - Журнал: вместо файла любая база. Условие одно: запись дошла до диска до вызова.
- Стратегию подключите к
route: рыночные события не обрабатываются инфраструктурой, их можно отдавать стратегии поenv.sourceиenv.sub. - Автосверку после
Overflow:degradedсодержит счета, которые надо сверить. Вызовитеreconcileдля каждого, предварительно дочитав канал (Если что-то пошло не так).
Листать можно клавишами Влево и Вправо.