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(¶ms)`) 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}