Skip to main content

brokapi_bcs/
ud.rs

1//! The user data connector: orders, stop orders, portfolio and fills.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use async_trait::async_trait;
8use brokapi_bcs_protocol::error::BcsApiError;
9use brokapi_bcs_protocol::http::client::BcsHttpClient;
10use brokapi_bcs_protocol::http::models::{
11    CancelByIdRequest, CreateOrderRequest, EditOrderRequest, OrderRecord, OrderSearchRequest,
12    OrderStateListResponse, TradeSearchRequest,
13};
14use brokapi_bcs_protocol::models::enums::OrderSide as BcsOrderSide;
15use brokapi_bcs_protocol::ws::client::BcsWsClient;
16use brokapi_bcs_protocol::ws::session::WsSession;
17use brokapi_core::{
18    Backpressure, Balance, ClientOrderId, Envelope, Error, Execution, ExecutionSource, OrderAck,
19    OrderExecutor, OrderSnapshot, OrderType as CoreOrderType, PlaceIdempotency, PlaceOrderRequest,
20    PlaceStopOrderRequest, Portfolio, PortfolioSource, Position, ReconcileReport,
21    ReplaceOrderRequest, ShutdownMode, ShutdownReport, Side, StopOrderExecutor, StopOrderRef,
22    StopOrderSnapshot, SubscribeOpts, SubscriptionId, TimeRange, UserDataCaps,
23};
24use brokapi_runtime::{
25    BrokerLimiter, CommandGate, Emitter, InstrumentLocks, OrderRuntime, PriceGrid,
26    ShutdownController,
27};
28use brokapi_sansio::{
29    BrokerCore, DesiredSubscription, OrderSignal, SubscriptionKind, SubscriptionSet,
30};
31use chrono::{TimeZone, Utc};
32use tokio::sync::{Mutex, RwLock, mpsc};
33use tokio::task::JoinHandle;
34use tokio_util::sync::CancellationToken;
35
36use crate::cid::foreign_cid;
37use crate::config::UserDataConfig;
38use crate::core::UdCore;
39use crate::error::{
40    map_command_err, map_config_err, map_err, map_order_cmd_err, map_order_read_err,
41};
42use crate::map::execution::map_trade_record;
43use crate::map::order::{
44    LIST_STATUS_ACTIVE, PLAIN_LIST_TYPES, STOP_LIST_TYPES, map_order_ack, map_order_record,
45    map_state_list, map_stop_record,
46};
47use crate::map::portfolio::map_rest_portfolio;
48use crate::map::stop as map_stop;
49use crate::map::whole_pieces;
50use crate::pump::{drain_backlog, run_ud_pump};
51use crate::raw::BcsRaw;
52use crate::subs::{SubIdGen, UdSubKind, UdSubscription};
53use crate::util::{class_code, nanos_to_datetime, ticker};
54
55struct UdShared {
56    http: BcsHttpClient,
57    limiter: Arc<BrokerLimiter>,
58    core: Arc<Mutex<UdCore>>,
59    emitter: Arc<Emitter>,
60    orders: Mutex<OrderRuntime>,
61    instrument_locks: InstrumentLocks,
62    price_grid: PriceGrid,
63    subs: RwLock<HashMap<SubscriptionId, UdSubscription>>,
64    sub_ids: SubIdGen,
65    shutdown: Mutex<ShutdownController>,
66    cancel: CancellationToken,
67    order_state_buffer: usize,
68    /// How long a `subscribe_*` call waits for its WebSocket to be open.
69    ack_timeout: Duration,
70}
71
72/// BCS user data connector: orders, stop orders, portfolio and the history of fills for one account.
73///
74/// It implements [`OrderExecutor`], [`StopOrderExecutor`], [`ExecutionSource`] and
75/// [`PortfolioSource`]. The account is the one the refresh token in the config belongs to. One
76/// connector per account. Start it with [`start`](Self::start). Commands return their answer
77/// directly, and subscriptions deliver [`Envelope`]s into the channel given to `start`.
78///
79/// # Rules that protect the account
80///
81/// - **Write access.** The config must be built with `with_write(true)` (the environment switch
82///   `BCS_IS_WRITE=true` does the same for `BcsConfig::from_env`). Without it the connector asks for
83///   a read-only token. Whether the broker then refuses the orders with a definite error is not
84///   verified against the live API. Never rely on it as a safety lock, use a read-only config
85///   only for reading.
86/// - **Reconcile gate.** Trading commands (`place`, `replace`, `cancel`, `place_stop`, `cancel_stop`)
87///   are refused with [`Error::NotReconciled`] until [`reconcile`](OrderExecutor::reconcile) has
88///   succeeded once. This forces the bot to compare its own ledger of orders with the broker before
89///   it sends a new order, so a restart cannot double an order that was placed before the crash.
90///   Reads (`order_state`, `active_orders`, `portfolio`, `executions`) are not gated.
91/// - **Degraded.** If a lossless subscription overflows (the bot reads too slowly), the gate closes
92///   and commands return [`Error::Degraded`] until the next successful `reconcile`.
93/// - **No retry of `place`.** A cid that has been sent is spent. If `place` returns
94///   [`Error::Unknown`] (no answer, a break, HTTP 5xx), the order may exist. Ask `order_state` or
95///   run `reconcile`. Calling `place` again with the same cid returns [`Error::Rejected`] with code
96///   `duplicate_place`, because a retry could create a second order.
97/// - **The gate is checked twice.** Once at the start, and again when the command's turn in the queue of its
98///   instrument comes: a gate that closed while it waited stops it, and nothing is sent.
99/// - **A cut of the order events socket is told.** [`Event::Reconnected`](brokapi_core::Event::Reconnected) on the
100///   order state and executions subscriptions, the gate closes until `reconcile`.
101/// - **Quantity is in pieces**, a whole number, never rounded: `0.4` or `2.5` is `Rejected` with code `invalid_qty`.
102/// - **Rate limit.** Quota is taken from the shared limiter before the request. A refusal is
103///   [`Error::RateLimited`] and nothing is sent, so the same cid can be used again.
104///
105/// # Examples
106///
107/// The whole path against the BCS imitation: reconcile, place, cancel, and two kinds of refusal.
108///
109/// ```
110/// use brokapi_bcs::limits::default_limits;
111/// use brokapi_bcs::{UserDataConfig, UserDataConnector};
112/// use brokapi_conformance::REJECT_PRICE;
113/// use brokapi_core::{
114///     ClientOrderId, Error, InstrumentId, InstrumentKey, NativeId, OrderExecutor, OrderState, OrderType,
115///     PlaceOrderRequest, Price, Qty, ShutdownMode, Side,
116/// };
117/// use brokapi_mocks::bcs::REJECT_IN_BODY_PRICE;
118/// use brokapi_runtime::BrokerLimiter;
119/// use tokio::sync::mpsc;
120/// # use brokapi_bcs_protocol::config::BcsConfig;
121///
122/// # #[tokio::main(flavor = "current_thread")]
123/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
124/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
125/// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
126/// let (events_tx, _events) = mpsc::channel(256);
127/// let limiter = BrokerLimiter::new(&default_limits());
128/// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, events_tx).await?;
129///
130/// let sber = InstrumentId::new(
131///     InstrumentKey::new("SBER", "TQBR"),
132///     NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() },
133/// );
134/// let limit_buy = |price: Price| PlaceOrderRequest {
135///     cid: ClientOrderId::new_v4(),
136///     instrument: sber.clone(),
137///     side: Side::Buy,
138///     order_type: OrderType::Limit,
139///     qty: Qty::from(1),
140///     price: Some(price),
141/// };
142///
143/// // Before the first reconcile the gate is closed.
144/// assert!(matches!(ud.place(limit_buy(Price::from(100))).await, Err(Error::NotReconciled)));
145/// // An empty ledger: the bot has no orders of its own to compare. Opens the gate.
146/// ud.reconcile(&[]).await?;
147///
148/// // Place, then cancel.
149/// let req = limit_buy(Price::from(100));
150/// let cid = req.cid;
151/// let ack = ud.place(req).await?;
152/// println!("broker order id: {}", ack.broker_id.as_str());
153/// ud.cancel(cid).await?;
154/// assert!(matches!(ud.order_state(cid).await?.state, OrderState::Cancelled { .. }));
155///
156/// // The broker says no, way one: HTTP 400. Nothing was created.
157/// match ud.place(limit_buy(REJECT_PRICE)).await {
158///     Err(Error::Rejected { code, .. }) => assert_eq!(code, "validation"),
159///     other => panic!("expected a refusal, got {other:?}"),
160/// }
161/// // Way two: HTTP 200, but the report in the answer has orderStatus = 8.
162/// match ud.place(limit_buy(Price::new(REJECT_IN_BODY_PRICE))).await {
163///     Err(Error::Rejected { code, message }) => {
164///         assert_eq!(code, "rejected");
165///         println!("reason from the broker: {message}");
166///     }
167///     other => panic!("expected a refusal, got {other:?}"),
168/// }
169///
170/// ud.shutdown(ShutdownMode::Immediate).await;
171/// # Ok(())
172/// # }
173/// ```
174pub struct UserDataConnector {
175    inner: Arc<UdShared>,
176    pump: JoinHandle<()>,
177    /// `orders/events/ws`: status changes and fills of orders in one channel.
178    ws_events: WsSession,
179    /// `portfolio/ws`: the positions of the account.
180    ws_portfolio: WsSession,
181}
182
183impl UserDataConnector {
184    /// Starts the connector: gets the first access token and opens the order events and portfolio
185    /// WebSockets.
186    ///
187    /// The trading gate starts closed: call [`reconcile`](OrderExecutor::reconcile) before the first
188    /// command, or `place` returns [`Error::NotReconciled`]. The token refresh does not spend rate
189    /// limit quota. The two WebSocket sessions run in the background and reconnect on their own.
190    /// Order status changes and fills arrive on one channel, `orders/events/ws`.
191    ///
192    /// `limiter` should be the one shared by every connector of this BCS account, because the limits
193    /// belong to the token. `out` receives the envelopes of all subscriptions and must be read.
194    ///
195    /// # Errors
196    ///
197    /// - [`Error::Config`] when the HTTP or WebSocket client cannot be built from `cfg`.
198    /// - [`Error::Transport`] when the token refresh fails: no connection, a timeout, or the auth
199    ///   server refuses the refresh token. Exact auth server answers: not verified against the live
200    ///   API.
201    /// - [`Error::RateLimited`] when the auth server answers HTTP 429.
202    ///
203    /// # Examples
204    ///
205    /// ```
206    /// use brokapi_bcs::limits::default_limits;
207    /// use brokapi_bcs::{UserDataConfig, UserDataConnector};
208    /// use brokapi_core::{OrderExecutor, ShutdownMode};
209    /// use brokapi_runtime::BrokerLimiter;
210    /// use tokio::sync::mpsc;
211    /// # use brokapi_bcs_protocol::config::BcsConfig;
212    ///
213    /// # #[tokio::main(flavor = "current_thread")]
214    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
215    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
216    /// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
217    /// let (events_tx, _events) = mpsc::channel(256);
218    /// let limiter = BrokerLimiter::new(&default_limits());
219    /// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, events_tx).await?;
220    /// // The gate is closed until the first reconcile.
221    /// ud.reconcile(&[]).await?;
222    /// # ud.shutdown(ShutdownMode::Immediate).await;
223    /// # Ok(())
224    /// # }
225    /// ```
226    ///
227    /// A broker that cannot be reached is a [`Error::Transport`]:
228    ///
229    /// ```
230    /// use brokapi_bcs::limits::default_limits;
231    /// use brokapi_bcs::{UserDataConfig, UserDataConnector};
232    /// use brokapi_core::Error;
233    /// use brokapi_runtime::BrokerLimiter;
234    /// use tokio::sync::mpsc;
235    /// use brokapi_bcs_protocol::config::BcsConfig;
236    ///
237    /// # #[tokio::main(flavor = "current_thread")]
238    /// # async fn main() {
239    /// // Port 1 of the local host: nothing listens there.
240    /// let cfg = BcsConfig::for_mock("any-token", "http://127.0.0.1:1").with_write(true);
241    /// let (events_tx, _events) = mpsc::channel(16);
242    /// let limiter = BrokerLimiter::new(&default_limits());
243    /// let started = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, events_tx).await;
244    /// assert!(matches!(started, Err(Error::Transport(_))));
245    /// # }
246    /// ```
247    pub async fn start(
248        cfg: UserDataConfig,
249        limiter: Arc<BrokerLimiter>,
250        out: mpsc::Sender<Envelope>,
251    ) -> Result<Self, Error> {
252        let ws_client = BcsWsClient::new(cfg.bcs.clone())
253            .map_err(map_config_err)?
254            .with_options(cfg.ws_options());
255        ws_client
256            .http_client()
257            .authenticate()
258            .await
259            .map_err(map_err)?;
260
261        let (event_tx, event_rx) = kanal::bounded_async(512);
262
263        // One channel for order statuses AND fills: the current `orders/events/ws`. The deprecated
264        // execution-status and transaction-status are not opened (the protocol crate still has them,
265        // reachable through `raw().connect_ws`).
266        let ws_events = ws_client.connect_order_events(event_tx.clone());
267        // Only the order events session announces its connects (a lost connection there loses fills).
268        let ws_client = ws_client.with_options(cfg.portfolio_ws_options());
269        let ws_portfolio = ws_client.connect_portfolio(event_tx);
270
271        let cancel = CancellationToken::new();
272        let core = Arc::new(Mutex::new(UdCore::new()));
273        let emitter = Emitter::new(Arc::clone(&cfg.source), out);
274        let gate = CommandGate::new();
275
276        let inner = Arc::new(UdShared {
277            http: ws_client.http_client().clone(),
278            limiter,
279            core: Arc::clone(&core),
280            emitter: Arc::clone(&emitter),
281            orders: Mutex::new(OrderRuntime::new()),
282            instrument_locks: InstrumentLocks::new(),
283            price_grid: cfg.price_grid.clone(),
284            subs: RwLock::new(HashMap::new()),
285            sub_ids: SubIdGen::new(1000),
286            shutdown: Mutex::new(ShutdownController::new(gate)),
287            cancel: cancel.clone(),
288            order_state_buffer: cfg.order_state_buffer,
289            ack_timeout: cfg.subscribe_ack_timeout,
290        });
291
292        let pump = tokio::spawn(run_ud_pump(core, emitter, event_rx, cancel));
293
294        Ok(Self {
295            inner,
296            pump,
297            ws_events,
298            ws_portfolio,
299        })
300    }
301
302    /// Returns a handle for any other method of the BCS API, with this connector's token, endpoint
303    /// and rate limiter.
304    ///
305    /// Orders sent through the handle bypass this connector's order state: there are no events for
306    /// them, they are not in the ledger and `reconcile` does not know them. Use [`OrderExecutor`]
307    /// for orders, and the handle for reads and for methods the traits do not wrap. Use
308    /// [`call_command`](BcsRaw::call_command) for anything that changes the account. See [`BcsRaw`]
309    /// for the rules of a call.
310    ///
311    /// # Examples
312    ///
313    /// A raw read, spending the quota of the name `GetQuotes`:
314    ///
315    /// ```
316    /// use brokapi_bcs_protocol::http::models::{QuoteInstrument, QuotesRequest};
317    /// use brokapi_bcs::limits::default_limits;
318    /// use brokapi_bcs::{UserDataConfig, UserDataConnector};
319    /// use brokapi_core::ShutdownMode;
320    /// use brokapi_runtime::BrokerLimiter;
321    /// use tokio::sync::mpsc;
322    /// # use brokapi_bcs_protocol::config::BcsConfig;
323    ///
324    /// # #[tokio::main(flavor = "current_thread")]
325    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
326    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
327    /// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
328    /// # let (events_tx, _events) = mpsc::channel(256);
329    /// # let limiter = BrokerLimiter::new(&default_limits());
330    /// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, events_tx).await?;
331    /// let quotes = ud
332    ///     .raw()
333    ///     .call("GetQuotes", |http| async move {
334    ///         http.get_quotes(&QuotesRequest { instruments: vec![QuoteInstrument::new("SBER", "TQBR")] }).await
335    ///     })
336    ///     .await?;
337    /// assert!(!quotes.records.is_empty());
338    /// ud.shutdown(ShutdownMode::Immediate).await;
339    /// # Ok(())
340    /// # }
341    /// ```
342    pub fn raw(&self) -> BcsRaw {
343        BcsRaw::new(self.inner.http.clone(), Arc::clone(&self.inner.limiter))
344    }
345
346    /// The price steps this connector checks order prices against. Fill it from
347    /// [`TradingParams`](brokapi_core::TradingParams) (`grid.learn(&params)`) to have a price off the
348    /// step refused before anything is sent. See [`PriceGrid`].
349    pub fn price_grid(&self) -> &PriceGrid {
350        &self.inner.price_grid
351    }
352
353    /// Describes what this connector can do, without any request.
354    ///
355    /// The answer is fixed for BCS: a place request is deduplicated by label only
356    /// ([`PlaceIdempotency::LabelOnly`], so never repeat a place), replace and stop orders are
357    /// supported, fills are streamed, there is no command channel over WebSocket and no sandbox.
358    ///
359    /// # Examples
360    ///
361    /// ```
362    /// use brokapi_bcs::limits::default_limits;
363    /// use brokapi_bcs::{UserDataConfig, UserDataConnector};
364    /// use brokapi_core::ShutdownMode;
365    /// use brokapi_runtime::BrokerLimiter;
366    /// use tokio::sync::mpsc;
367    /// # use brokapi_bcs_protocol::config::BcsConfig;
368    ///
369    /// # #[tokio::main(flavor = "current_thread")]
370    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
371    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
372    /// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
373    /// let (events_tx, _events) = mpsc::channel(256);
374    /// let limiter = BrokerLimiter::new(&default_limits());
375    /// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, events_tx).await?;
376    /// let caps = ud.caps();
377    /// assert!(caps.supports_replace && caps.supports_stop_orders && caps.streams_executions);
378    /// assert!(!caps.sandbox);
379    /// # ud.shutdown(ShutdownMode::Immediate).await;
380    /// # Ok(())
381    /// # }
382    /// ```
383    pub fn caps(&self) -> UserDataCaps {
384        // Conditional orders (`orderType = 5`): see the `StopOrderExecutor` impl.
385        UserDataCaps::new(
386            PlaceIdempotency::LabelOnly,
387            false,
388            false,
389            true,
390            true,
391            true,
392            false,
393        )
394    }
395
396    /// Stops the connector and reports what was left unfinished. Consumes the connector.
397    ///
398    /// The command gate closes first, so nothing new is sent. Orders that were placed but have no
399    /// answer yet are listed in `inflight_abandoned` and marked unknown locally. The broker is not
400    /// asked to cancel anything: orders that exist on the exchange stay alive, and a later
401    /// `reconcile` finds them. With [`ShutdownMode::Graceful`] the events that wait in the
402    /// subscription buffers first get time to reach the bot's channel, until `deadline`. The
403    /// WebSocket sessions are not unsubscribed message by message, so `unsubscribed` is `0`.
404    ///
405    /// # Examples
406    ///
407    /// ```
408    /// use std::time::Duration;
409    /// use brokapi_bcs::limits::default_limits;
410    /// use brokapi_bcs::{UserDataConfig, UserDataConnector};
411    /// use brokapi_core::ShutdownMode;
412    /// use brokapi_runtime::BrokerLimiter;
413    /// use tokio::sync::mpsc;
414    /// # use brokapi_bcs_protocol::config::BcsConfig;
415    ///
416    /// # #[tokio::main(flavor = "current_thread")]
417    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
418    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
419    /// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
420    /// # let (events_tx, _events) = mpsc::channel(256);
421    /// # let limiter = BrokerLimiter::new(&default_limits());
422    /// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, events_tx).await?;
423    /// let report = ud.shutdown(ShutdownMode::Graceful { deadline: Duration::from_secs(1) }).await;
424    /// assert!(report.inflight_abandoned.is_empty());
425    /// # Ok(())
426    /// # }
427    /// ```
428    pub async fn shutdown(self, mode: ShutdownMode) -> ShutdownReport {
429        let now = brokapi_runtime::now();
430        if let ShutdownMode::Graceful { deadline } = mode {
431            drain_backlog(&self.inner.emitter, deadline).await;
432        }
433        let mut orders = self.inner.orders.lock().await;
434        let mut shutdown = self.inner.shutdown.lock().await;
435        let report = shutdown.shutdown_local(mode, now, &mut orders, || (0, 0));
436        self.inner.cancel.cancel();
437        let _ = self.pump.await;
438        report
439    }
440
441    /// The gate of trading commands. An `Overflow` of a lossless subscription (found by the emitter,
442    /// which runs in the pump task) closes it as `Degraded` until `reconcile` succeeds.
443    async fn check_command(&self) -> Result<(), Error> {
444        let mut ctl = self.inner.shutdown.lock().await;
445        if self.inner.emitter.is_degraded() {
446            ctl.gate_mut().mark_degraded();
447        }
448        ctl.gate().check_command()
449    }
450
451    async fn acquire(&self, method: &str) -> Result<(), Error> {
452        self.inner.limiter.acquire(method, brokapi_runtime::now())
453    }
454
455    /// Tells the core the whole set of subscriptions: applying one alone would drop the others.
456    async fn apply_desired(&self) {
457        let desired = SubscriptionSet::with_subscriptions(
458            self.inner
459                .subs
460                .read()
461                .await
462                .values()
463                .map(|s| DesiredSubscription {
464                    id: s.id,
465                    kind: match s.kind {
466                        UdSubKind::OrderState => SubscriptionKind::OrderState,
467                        UdSubKind::Portfolio => SubscriptionKind::Portfolio,
468                        UdSubKind::Executions => SubscriptionKind::Executions,
469                    },
470                })
471                .collect(),
472        );
473        self.inner.core.lock().await.apply(&desired);
474    }
475
476    async fn register_ud_sub(
477        &self,
478        kind: UdSubKind,
479        opts: SubscribeOpts,
480    ) -> Result<SubscriptionId, Error> {
481        let id = self.inner.sub_ids.next();
482        let instrument = brokapi_core::InstrumentId::new(
483            brokapi_core::InstrumentKey::new("", ""),
484            brokapi_core::NativeId::TickerClass {
485                ticker: String::new(),
486                class_code: String::new(),
487            },
488        );
489        self.inner
490            .emitter
491            .register(id, Some(instrument), opts.backpressure);
492        self.inner
493            .subs
494            .write()
495            .await
496            .insert(id, UdSubscription { id, kind, opts });
497
498        self.apply_desired().await;
499
500        // Returns when the WebSocket of the channel is open: the broker pushes order events and positions as soon
501        // as it is, there is nothing to send. Whatever happens at the broker after that is delivered. Before it,
502        // an order that is placed and filled would be missed.
503        let socket = match kind {
504            UdSubKind::Portfolio => &self.ws_portfolio,
505            UdSubKind::OrderState | UdSubKind::Executions => &self.ws_events,
506        };
507        let limit = self.inner.ack_timeout;
508        if tokio::time::timeout(limit, socket.wait_connected())
509            .await
510            .is_err()
511        {
512            // Not open in time: take the subscription back, nothing stays half open. The socket keeps
513            // connecting in the background, and a later subscribe finds it open.
514            self.inner.subs.write().await.remove(&id);
515            self.apply_desired().await;
516            self.inner.emitter.unregister(id);
517            return Err(brokapi_runtime::not_confirmed(limit));
518        }
519        Ok(id)
520    }
521}
522
523// ─── helpers ──────────────────────────────────────────────────────────────────
524
525/// The list of orders shows orders from 26.01.2026 (documented limit), so the search starts there.
526fn list_start() -> chrono::DateTime<Utc> {
527    Utc.with_ymd_and_hms(2026, 1, 26, 0, 0, 0)
528        .single()
529        .expect("valid date")
530}
531
532/// The order list pages are read until `totalPages`, at most this many (100 per page).
533const MAX_LIST_PAGES: i32 = 10;
534
535/// The biggest page `POST /trades/search` gives (`size`, 100), and the most pages read in one call.
536const TRADES_PAGE_SIZE: u32 = 100;
537const MAX_TRADE_PAGES: u32 = 20;
538/// The trades list holds at most this many of the most recent trades (docs of `POST /trades/search`).
539const TRADES_LIST_CAP: u64 = 1000;
540
541fn too_long() -> Error {
542    Error::Rejected {
543        code: "range_too_long".into(),
544        message: format!(
545            "the trade list holds {TRADES_LIST_CAP} trades at most and the range reaches that: read it in shorter ranges"
546        ),
547    }
548}
549
550/// How a broker order id is named in `orderId` / `orderIdType`: our cid is a UUID (`"1"`), anything
551/// else is the exchange id such as `20261019-TQBR-79628540663` (`"2"`).
552fn order_ref(id: &str) -> (&'static str, String) {
553    if uuid::Uuid::parse_str(id).is_ok() {
554        ("1", id.to_string())
555    } else {
556        ("2", id.to_string())
557    }
558}
559
560impl UserDataConnector {
561    /// Orders of the account from `POST /orders/search`, all pages.
562    async fn list_orders(
563        &self,
564        statuses: &[i32],
565        types: &[i32],
566    ) -> Result<Vec<OrderRecord>, Error> {
567        let mut out = Vec::new();
568        for page in 0..MAX_LIST_PAGES {
569            self.acquire("SearchOrders").await?;
570            let req = OrderSearchRequest {
571                start_date_time: list_start(),
572                // The end is a day ahead of the local clock: the clock of this machine may lag behind the
573                // broker, and an order of the last minutes must not fall out of the window.
574                end_date_time: Utc::now() + chrono::Duration::days(1),
575                side: None,
576                order_status: Some(statuses.to_vec()),
577                order_types: Some(types.to_vec()),
578                tickers: None,
579                class_codes: None,
580            };
581            let resp = self
582                .inner
583                .http
584                .search_orders(req, Some(page), Some(100))
585                .await
586                .map_err(map_err)?;
587            out.extend(resp.records);
588            if page + 1 >= resp.total_pages {
589                break;
590            }
591        }
592        Ok(out)
593    }
594
595    /// Our cid of a broker order id: what this process has seen, else one `GET /orders` by the
596    /// exchange id (the list carries no `clientOrderId`). `None` when the broker has no cid of ours
597    /// for it (an order from the app, or the lookup failed).
598    async fn cid_of_order_id(&self, order_id: &str) -> Option<ClientOrderId> {
599        if let Some(cid) = self.inner.core.lock().await.cid_of_order_id(order_id) {
600            return Some(cid);
601        }
602        self.acquire("GetOrder").await.ok()?;
603        let resp = self.inner.http.get_order("2", order_id).await.ok()?;
604        // The cid of an order is its own `clientOrderId`; `original...` names the order it replaced.
605        let cid = uuid::Uuid::parse_str(&resp.client_order_id)
606            .ok()
607            .or_else(|| {
608                resp.original_client_order_id
609                    .as_deref()
610                    .and_then(|s| uuid::Uuid::parse_str(s).ok())
611            })?;
612        self.inner.core.lock().await.learn_order_id(order_id, cid);
613        Some(cid)
614    }
615
616    async fn learn(&self, order_id: &str, cid: ClientOrderId) {
617        self.inner.core.lock().await.learn_order_id(order_id, cid);
618    }
619
620    /// The state of the order after a command that waits for the broker (`/sync` methods): the report
621    /// the broker returned, or `Unknown` when it answered without one (the order may exist).
622    fn state_of(
623        &self,
624        cid: ClientOrderId,
625        resp: &OrderStateListResponse,
626    ) -> Result<brokapi_core::OrderSnapshot, Error> {
627        map_state_list(Some(cid), resp, brokapi_runtime::now()).ok_or(Error::Unknown)
628    }
629
630    /// A cancel that waited for the broker: done when the order is cancelled, refused when the broker
631    /// says it is filled or rejected the request, `Unknown` when it is still alive in the answer
632    /// (the outcome is not decided: ask `order_state`).
633    fn cancel_outcome(
634        &self,
635        cid: ClientOrderId,
636        resp: &OrderStateListResponse,
637    ) -> Result<(), Error> {
638        use brokapi_core::OrderState as S;
639        let snap = self.state_of(cid, resp)?;
640        match snap.state {
641            S::Cancelled { .. } => Ok(()),
642            S::Filled { .. } => Err(Error::Rejected {
643                code: "already_filled".into(),
644                message: "the order was filled before the cancel".into(),
645            }),
646            S::Rejected { reason, .. } => Err(Error::Rejected {
647                code: "cancel_rejected".into(),
648                message: reason,
649            }),
650            _ => Err(Error::Unknown),
651        }
652    }
653
654    /// An order command that waited for the broker, as an ack: a refusal inside a 200 answer
655    /// (`orderStatus = 8`) is `Rejected`.
656    /// Moves the order out of `InFlight` when its command is answered, so a shutdown reports only commands
657    /// that really have no answer.
658    async fn settle(
659        &self,
660        cid: ClientOrderId,
661        outcome: &Result<(OrderAck, Option<String>), Error>,
662    ) {
663        let signal = match outcome {
664            Ok((ack, _)) => OrderSignal::Ack {
665                broker_id: ack.broker_id.clone(),
666            },
667            Err(Error::Rejected { message, .. }) => OrderSignal::Rejected {
668                reason: message.clone(),
669            },
670            Err(_) => OrderSignal::Timeout,
671        };
672        self.inner
673            .orders
674            .lock()
675            .await
676            .apply_signal(cid, signal, brokapi_runtime::now());
677    }
678
679    fn ack_of(
680        &self,
681        cid: ClientOrderId,
682        resp: &OrderStateListResponse,
683        now: Instant,
684    ) -> Result<(OrderAck, Option<String>), Error> {
685        let snap = self.state_of(cid, resp)?;
686        if let brokapi_core::OrderState::Rejected { reason, .. } = &snap.state {
687            return Err(Error::Rejected {
688                code: "rejected".into(),
689                message: reason.clone(),
690            });
691        }
692        let order_id = snap.broker_id.as_ref().map(|b| b.as_str().to_string());
693        let broker_id = order_id
694            .clone()
695            .unwrap_or_else(|| cid.hyphenated().to_string());
696        Ok((
697            map_order_ack(cid, brokapi_core::BrokerOrderId::new(broker_id), now),
698            order_id,
699        ))
700    }
701}
702
703#[async_trait]
704impl brokapi_core::Connector for UserDataConnector {
705    type Caps = brokapi_core::UserDataCaps;
706
707    fn caps(&self) -> brokapi_core::UserDataCaps {
708        UserDataConnector::caps(self)
709    }
710
711    async fn shutdown(self, mode: brokapi_core::ShutdownMode) -> brokapi_core::ShutdownReport {
712        UserDataConnector::shutdown(self, mode).await
713    }
714}
715
716#[async_trait]
717impl OrderExecutor for UserDataConnector {
718    /// `POST /orders/sync`, not `POST /orders`. The synchronous method waits for the broker's decision
719    /// and answers with the order's state, so (1) a refusal (`orderStatus = 8`, `rejectReason`) is
720    /// known right here as `Rejected`, not a minute later from the stream, and (2) the ack carries the
721    /// broker's real order id (`orderId`), the same one the order list and the stream use. The plain
722    /// method only says "request accepted".
723    async fn place(&self, req: PlaceOrderRequest) -> Result<OrderAck, Error> {
724        self.check_command().await?;
725        // Input first, then quota, then the cid: a refusal before the cid is marked (bad input,
726        // `RateLimited`) sends nothing, so the same cid can be used again. The order is the one of
727        // T-Bank and Finam.
728        let side = match req.side {
729            Side::Buy => BcsOrderSide::Buy,
730            Side::Sell => BcsOrderSide::Sell,
731        };
732        // Whole pieces, a multiple of the lot when the lot is known: refused as it is, never rounded.
733        whole_pieces(req.qty)?;
734        self.inner
735            .price_grid
736            .check_qty(&req.instrument.key, req.qty)?;
737        let http_req = match req.order_type {
738            CoreOrderType::Market => CreateOrderRequest::market(
739                req.cid,
740                ticker(&req.instrument)?,
741                class_code(&req.instrument)?,
742                side,
743                whole_pieces(req.qty)?,
744            ),
745            CoreOrderType::Limit => {
746                let price = req.price.ok_or_else(|| Error::Rejected {
747                    code: "missing_price".into(),
748                    message: "limit order requires price".into(),
749                })?;
750                // The step is checked here, before a quota is spent or the cid is marked.
751                self.inner.price_grid.check(&req.instrument.key, price)?;
752                CreateOrderRequest::limit(
753                    req.cid,
754                    ticker(&req.instrument)?,
755                    class_code(&req.instrument)?,
756                    side,
757                    whole_pieces(req.qty)?,
758                    price.decimal(),
759                )
760            }
761        };
762        self.acquire("PlaceOrder").await?;
763
764        // Waits for earlier commands on this instrument, released on every exit path.
765        let _instrument = self.inner.instrument_locks.lock(&req.instrument).await;
766        // The gate may have closed while this command stood in the queue (degraded, shutdown): ask again
767        // before the cid is marked and anything is sent.
768        self.check_command().await?;
769        self.inner.instrument_locks.bind(req.cid, &req.instrument);
770
771        let now = brokapi_runtime::now();
772        {
773            let mut orders = self.inner.orders.lock().await;
774            if !orders.try_place_once(req.cid) {
775                return Err(Error::Rejected {
776                    code: "duplicate_place".into(),
777                    message: "auto-retry of place is forbidden".into(),
778                });
779            }
780            orders.register_place(req.cid, now);
781        }
782
783        let resp = match self.inner.http.create_order_sync(&http_req).await {
784            Ok(resp) => resp,
785            Err(e) => {
786                let err = map_command_err(e);
787                self.settle(req.cid, &Err(err.clone())).await;
788                return Err(err);
789            }
790        };
791        let outcome = self.ack_of(req.cid, &resp, now);
792        self.settle(req.cid, &outcome).await;
793        let (ack, order_id) = outcome?;
794        if let Some(id) = order_id {
795            self.learn(&id, req.cid).await;
796        }
797        Ok(ack)
798    }
799
800    /// `POST /orders/cancel/sync` (the order is named in the body). Synchronous for the same reason as
801    /// `place`: "cancelled" means the broker says so, an order that was filled first is `Rejected`
802    /// (`already_filled`), and an answer that leaves the order alive is `Unknown`, never "ok".
803    async fn cancel(&self, cid: ClientOrderId) -> Result<(), Error> {
804        self.check_command().await?;
805        self.acquire("CancelOrder").await?;
806        // The instrument comes from the orders this process placed; an older order is not serialized.
807        let _instrument = self.inner.instrument_locks.lock_cid(cid).await;
808        // The gate may have closed while this command stood in the queue.
809        self.check_command().await?;
810        let req = CancelByIdRequest::by_client_order_id(cid, uuid::Uuid::new_v4());
811        let resp = self
812            .inner
813            .http
814            .cancel_order_by_id_sync(&req)
815            .await
816            .map_err(map_order_cmd_err)?;
817        self.cancel_outcome(cid, &resp)
818    }
819
820    /// `POST /orders/edit/sync`: the broker cancels the original and puts a new order under the new
821    /// cid. The quantity and price the caller leaves out are the order's current ones (one
822    /// `GET /orders`), because the request needs `orderQuantity` and a limit order needs its price. An
823    /// order that is partly filled needs an explicit quantity: whether `orderQuantity` of an edit is the
824    /// total or the rest is not documented (live question 7).
825    async fn replace(&self, req: ReplaceOrderRequest) -> Result<OrderAck, Error> {
826        self.check_command().await?;
827        // The request has no instrument: it is the one of the original order, if this process
828        // placed it. The new cid inherits it.
829        let (qty, price) = match (req.qty, req.price) {
830            (Some(q), Some(p)) => {
831                if let Some(i) = self.inner.instrument_locks.instrument_of(req.original_cid) {
832                    self.inner.price_grid.check(&i.key, p)?;
833                }
834                if let Some(i) = self.inner.instrument_locks.instrument_of(req.original_cid) {
835                    self.inner.price_grid.check_qty(&i.key, q)?;
836                }
837                (whole_pieces(q)?, Some(p.decimal()))
838            }
839            (q, p) => {
840                self.acquire("GetOrder").await?;
841                let cur = self
842                    .inner
843                    .http
844                    .get_order("1", &req.original_cid.hyphenated().to_string())
845                    .await
846                    .map_err(map_err)?;
847                let snap = map_state_list(Some(req.original_cid), &cur, brokapi_runtime::now())
848                    .ok_or(Error::Unknown)?;
849                if q.is_none() && snap.filled.is_positive() {
850                    return Err(Error::Rejected {
851                        code: "qty_required".into(),
852                        message: "the order is partly filled: give the quantity of the new order"
853                            .into(),
854                    });
855                }
856                if let Some(p) = p {
857                    self.inner.price_grid.check(&snap.instrument.key, p)?;
858                }
859                if let Some(q) = q {
860                    self.inner.price_grid.check_qty(&snap.instrument.key, q)?;
861                }
862                let qty = q.unwrap_or(snap.qty);
863                (
864                    whole_pieces(qty)?,
865                    p.or(snap.price).map(brokapi_core::Price::decimal),
866                )
867            }
868        };
869        self.acquire("ReplaceOrder").await?;
870        let _instrument = self.inner.instrument_locks.lock_cid(req.original_cid).await;
871        // The gate may have closed while this command stood in the queue.
872        self.check_command().await?;
873        if let Some(i) = self.inner.instrument_locks.instrument_of(req.original_cid) {
874            self.inner.instrument_locks.bind(req.cid, &i);
875        }
876        let http_req = EditOrderRequest {
877            order_id_type: Some("1".into()),
878            order_id: req.original_cid.hyphenated().to_string(),
879            client_order_id: req.cid,
880            order_type: None,
881            price,
882            order_quantity: qty,
883            extras: Default::default(),
884        };
885        let resp = self
886            .inner
887            .http
888            .edit_order_sync(&http_req)
889            .await
890            .map_err(map_order_cmd_err)?;
891        let (ack, order_id) = self.ack_of(req.cid, &resp, brokapi_runtime::now())?;
892        if let Some(id) = order_id {
893            self.learn(&id, req.cid).await;
894        }
895        Ok(ack)
896    }
897
898    /// `GET /orders?orderIdType=1&orderId=<cid>`. The answer is about the order asked for, so its cid is
899    /// the one asked.
900    async fn order_state(&self, cid: ClientOrderId) -> Result<OrderSnapshot, Error> {
901        self.acquire("GetOrder").await?;
902        let resp = self
903            .inner
904            .http
905            .get_order("1", &cid.hyphenated().to_string())
906            .await
907            .map_err(map_order_read_err)?;
908        let snap = map_state_list(Some(cid), &resp, brokapi_runtime::now()).ok_or_else(|| {
909            Error::Transport(brokapi_core::TransportError::Message(
910                "order status answer has no report".into(),
911            ))
912        })?;
913        if let Some(id) = &snap.broker_id {
914            self.learn(id.as_str(), cid).await;
915        }
916        Ok(snap)
917    }
918
919    /// Active orders (`orderStatus = 3`, plain order types) from `POST /orders/search`. The list carries
920    /// the broker's order id only: our cid comes from what this process has seen or from one lookup by
921    /// the exchange id; an order we cannot name gets a stable foreign cid ([`foreign_cid`]).
922    async fn active_orders(&self) -> Result<Vec<OrderSnapshot>, Error> {
923        let records = self
924            .list_orders(&[LIST_STATUS_ACTIVE], &PLAIN_LIST_TYPES)
925            .await?;
926        let now = brokapi_runtime::now();
927        let mut out = Vec::new();
928        for r in &records {
929            let cid = match self.cid_of_order_id(&r.order_id).await {
930                Some(c) => c,
931                None => foreign_cid(&r.order_id),
932            };
933            if let Some(snap) = map_order_record(r, cid, now) {
934                out.push(snap);
935            }
936        }
937        Ok(out)
938    }
939
940    async fn subscribe_order_state(&self) -> Result<SubscriptionId, Error> {
941        let opts = SubscribeOpts {
942            backpressure: Backpressure::Lossless {
943                cap: self.inner.order_state_buffer,
944            },
945            depth: None,
946        };
947        self.register_ud_sub(UdSubKind::OrderState, opts).await
948    }
949
950    /// Compares the ledger with the broker. The decision for each cid of the ledger is the answer of
951    /// `GET /orders` for that order, not the list of orders (the list is cut at the 1000 most recent
952    /// orders and is cut by a time window, so an order that is alive may be missing from it):
953    ///
954    /// - the order is alive (new, partly filled, being replaced, or being cancelled): `confirmed`, with
955    ///   the snapshot of that answer;
956    /// - the order is over (filled, cancelled, rejected), or the broker does not know the cid
957    ///   (`not_found`): `closed_offline`;
958    /// - the answer has no report, or any other error: the reconcile fails with that error and the gate
959    ///   stays as it was. Nothing is guessed.
960    ///
961    /// `foreign` are the active orders of the list that are not in the ledger. Success opens the gate
962    /// and ends `Degraded`.
963    async fn reconcile(&self, ledger: &[ClientOrderId]) -> Result<ReconcileReport, Error> {
964        use brokapi_core::OrderState as S;
965        self.acquire("Reconcile").await?;
966        let mut report = ReconcileReport::new(Vec::new(), Vec::new(), Vec::new());
967        for cid in ledger {
968            self.acquire("GetOrder").await?;
969            match self
970                .inner
971                .http
972                .get_order("1", &cid.hyphenated().to_string())
973                .await
974            {
975                Ok(resp) => {
976                    let snap = map_state_list(Some(*cid), &resp, brokapi_runtime::now())
977                        .ok_or_else(|| {
978                            Error::Transport(brokapi_core::TransportError::Message(format!(
979                                "reconcile: the answer for order {cid} has no report"
980                            )))
981                        })?;
982                    // Teaches the cid of this broker order, so the list below names it.
983                    if let Some(id) = &snap.broker_id {
984                        self.learn(id.as_str(), *cid).await;
985                    }
986                    match snap.state {
987                        S::Filled { .. } | S::Cancelled { .. } | S::Rejected { .. } => {
988                            report.closed_offline.push(*cid);
989                        }
990                        // Alive, or not clear (status 6 and the like): not closed, so the bot must not
991                        // place a second order in its stead.
992                        _ => report.confirmed.push(snap),
993                    }
994                }
995                // The broker never saw this cid (or forgot it): closed while we were offline.
996                Err(BcsApiError::NotFound { .. }) => report.closed_offline.push(*cid),
997                // Anything else is not an answer about the order: reconcile is not done.
998                Err(e) => return Err(map_err(e)),
999            }
1000        }
1001        let known: std::collections::HashSet<ClientOrderId> = ledger.iter().copied().collect();
1002        report.foreign = self
1003            .active_orders()
1004            .await?
1005            .into_iter()
1006            .filter(|o| !o.cid.is_nil() && !known.contains(&o.cid))
1007            .collect();
1008        // A reconcile that got this far leaves `Degraded`: the answers above are the repair of what the
1009        // lost events may have cost.
1010        self.inner.emitter.clear_degraded();
1011        let mut ctl = self.inner.shutdown.lock().await;
1012        ctl.gate_mut().clear_degraded();
1013        ctl.gate_mut().mark_reconciled();
1014        Ok(report)
1015    }
1016}
1017
1018#[async_trait]
1019impl ExecutionSource for UserDataConnector {
1020    /// Own trades in `range` (`POST /trades/search`), every page.
1021    ///
1022    /// The connector asks for the biggest page (`size = 100`) and reads page after page until
1023    /// `totalPages`. Every page spends quota of `SearchTrades`. BCS lists at most the 1000 most recent
1024    /// trades since 26.01.2026: when the answer says it holds that many (`totalRecords` of 1000 or more),
1025    /// older trades of the range may be missing, and the answer is [`Error::Rejected`] with the code
1026    /// `range_too_long` instead of a list that looks complete: read a shorter range.
1027    ///
1028    /// # Errors
1029    ///
1030    /// [`Error::RateLimited`], [`Error::Transport`] or [`Error::Rejected`] (including `range_too_long`),
1031    /// as for any read.
1032    async fn executions(&self, range: TimeRange) -> Result<Vec<Execution>, Error> {
1033        let req = TradeSearchRequest {
1034            start_date_time: nanos_to_datetime(range.from).to_rfc3339(),
1035            end_date_time: nanos_to_datetime(range.to).to_rfc3339(),
1036            side: None,
1037            tickers: None,
1038        };
1039        let mut out = Vec::new();
1040        for page in 0..MAX_TRADE_PAGES {
1041            self.acquire("SearchTrades").await?;
1042            let resp = self
1043                .inner
1044                .http
1045                .search_trades(&req, Some(TRADES_PAGE_SIZE), Some(page))
1046                .await
1047                .map_err(map_err)?;
1048            if resp.total_records >= TRADES_LIST_CAP {
1049                return Err(too_long());
1050            }
1051            let now = brokapi_runtime::now();
1052            out.extend(resp.records.iter().filter_map(|r| map_trade_record(r, now)));
1053            if u64::from(page) + 1 >= resp.total_pages {
1054                return Ok(out);
1055            }
1056        }
1057        Err(too_long())
1058    }
1059
1060    /// Fills pushed by the `orders/events/ws` channel: messages with `lastQuantity > 0`, each
1061    /// `executionId` once. Lossless: a fill is never dropped (an overflow is a visible `Overflow`).
1062    async fn subscribe_executions(&self) -> Result<SubscriptionId, Error> {
1063        let opts = SubscribeOpts {
1064            backpressure: Backpressure::Lossless {
1065                cap: self.inner.order_state_buffer,
1066            },
1067            depth: None,
1068        };
1069        self.register_ud_sub(UdSubKind::Executions, opts).await
1070    }
1071}
1072
1073#[async_trait]
1074impl PortfolioSource for UserDataConnector {
1075    async fn portfolio(&self) -> Result<Portfolio, Error> {
1076        self.acquire("GetPortfolio").await?;
1077        let resp = self.inner.http.get_portfolio().await.map_err(map_err)?;
1078        Ok(map_rest_portfolio(&resp, brokapi_runtime::now()))
1079    }
1080
1081    async fn positions(&self) -> Result<Vec<Position>, Error> {
1082        Ok(self.portfolio().await?.positions)
1083    }
1084
1085    async fn balances(&self) -> Result<Vec<Balance>, Error> {
1086        Ok(self.portfolio().await?.balances)
1087    }
1088
1089    async fn subscribe_portfolio(&self, opts: SubscribeOpts) -> Result<SubscriptionId, Error> {
1090        self.register_ud_sub(UdSubKind::Portfolio, opts).await
1091    }
1092}
1093
1094/// Stop orders are BCS conditional orders (`orderType = "5"`): `stopPrice` for a stop-loss and
1095/// stop-limit, `takePrice` for a take-profit, market or limit execution. How the request is built, and
1096/// every choice the docs leave open (lifetime, trigger direction, the meaning of `price`, TP+SL pairs),
1097/// is in [`crate::map::stop`]; each of them is a choice made from the docs. Both commands use the synchronous methods, for the reasons given at `place`.
1098///
1099/// There is no stream of stop order statuses: `orders/events/ws` documents only order types 1 and 2,
1100/// whether it carries conditional orders is a live question, so `subscribe_stop_orders` is `Unsupported`.
1101#[async_trait]
1102impl StopOrderExecutor for UserDataConnector {
1103    async fn place_stop(&self, req: PlaceStopOrderRequest) -> Result<OrderAck, Error> {
1104        self.check_command().await?;
1105        // Input is checked before anything is sent or any quota is spent.
1106        let wire = map_stop::build(&req)?;
1107        self.inner
1108            .price_grid
1109            .check_qty(&req.instrument.key, req.qty)?;
1110        self.inner
1111            .price_grid
1112            .check(&req.instrument.key, req.stop_price)?;
1113        if let Some(p) = req.limit_price {
1114            self.inner.price_grid.check(&req.instrument.key, p)?;
1115        }
1116        // Quota before the cid is marked: a `RateLimited` sent nothing, the cid stays usable.
1117        self.acquire("PlaceStopOrder").await?;
1118        let _instrument = self.inner.instrument_locks.lock(&req.instrument).await;
1119        // The gate may have closed while this command stood in the queue.
1120        self.check_command().await?;
1121        self.inner.instrument_locks.bind(req.cid, &req.instrument);
1122        let now = brokapi_runtime::now();
1123        {
1124            let mut orders = self.inner.orders.lock().await;
1125            if !orders.try_place_once(req.cid) {
1126                return Err(Error::Rejected {
1127                    code: "duplicate_place".into(),
1128                    message: "auto-retry of place_stop is forbidden".into(),
1129                });
1130            }
1131            orders.register_place(req.cid, now);
1132        }
1133
1134        let resp = match self.inner.http.create_order_sync(&wire).await {
1135            Ok(resp) => resp,
1136            Err(e) => {
1137                let err = map_command_err(e);
1138                self.settle(req.cid, &Err(err.clone())).await;
1139                return Err(err);
1140            }
1141        };
1142        let outcome = self.ack_of(req.cid, &resp, now);
1143        self.settle(req.cid, &outcome).await;
1144        let (ack, order_id) = outcome?;
1145        if let Some(id) = order_id {
1146            self.learn(&id, req.cid).await;
1147        }
1148        Ok(ack)
1149    }
1150
1151    async fn cancel_stop(&self, stop: StopOrderRef) -> Result<(), Error> {
1152        self.check_command().await?;
1153        self.acquire("CancelStopOrder").await?;
1154        let (known_cid, order_id_type, order_id) = match stop {
1155            StopOrderRef::Cid(cid) => (cid, "1", cid.hyphenated().to_string()),
1156            StopOrderRef::Broker(id) => {
1157                let (t, id) = order_ref(id.as_str());
1158                let cid = self
1159                    .inner
1160                    .core
1161                    .lock()
1162                    .await
1163                    .cid_of_order_id(&id)
1164                    .unwrap_or_else(|| foreign_cid(&id));
1165                (cid, t, id)
1166            }
1167        };
1168        let _instrument = self.inner.instrument_locks.lock_cid(known_cid).await;
1169        // The gate may have closed while this command stood in the queue.
1170        self.check_command().await?;
1171        let req = CancelByIdRequest {
1172            order_id_type: Some(order_id_type.into()),
1173            order_id,
1174            client_order_id: uuid::Uuid::new_v4(),
1175        };
1176        let resp = self
1177            .inner
1178            .http
1179            .cancel_order_by_id_sync(&req)
1180            .await
1181            .map_err(map_order_cmd_err)?;
1182        self.cancel_outcome(known_cid, &resp)
1183    }
1184
1185    /// Conditional orders waiting for their trigger: `POST /orders/search`, `orderStatus = 3`, types
1186    /// 4..7 and 11. A TP+SL pair (type 7) is shown as its two legs with the same ids. `cid` is `None`
1187    /// when the order is not one this connector can name.
1188    async fn active_stop_orders(&self) -> Result<Vec<StopOrderSnapshot>, Error> {
1189        let records = self
1190            .list_orders(&[LIST_STATUS_ACTIVE], &STOP_LIST_TYPES)
1191            .await?;
1192        let now = brokapi_runtime::now();
1193        let mut out = Vec::new();
1194        for r in &records {
1195            let cid = self.cid_of_order_id(&r.order_id).await;
1196            out.extend(map_stop_record(r, cid, now));
1197        }
1198        Ok(out)
1199    }
1200
1201    async fn subscribe_stop_orders(&self) -> Result<SubscriptionId, Error> {
1202        Err(Error::Unsupported {
1203            capability: "stop_order_stream".into(),
1204        })
1205    }
1206}