Skip to main content

brokapi_bybit/
ud.rs

1//! The user data connector: orders, stop orders, fills and the portfolio of one Bybit account.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, Mutex as StdMutex};
5use std::time::Instant;
6
7use async_trait::async_trait;
8use brokapi_bybit_protocol::http::client::BybitHttpClient;
9use brokapi_bybit_protocol::http::models::{
10    CancelOrderRequest, CreateOrderRequest, InstrumentInfo, OrderRecord,
11};
12use brokapi_bybit_protocol::ws::session::{WsSession, WsStream};
13use brokapi_core::{
14    Backpressure, Balance, ClientOrderId, Envelope, Error, Event, Execution, ExecutionSource,
15    InstrumentId, OrderAck, OrderExecutor, OrderSnapshot, OrderState, OrderType as CoreOrderType,
16    PlaceIdempotency, PlaceOrderRequest, PlaceStopOrderRequest, Portfolio, PortfolioSource,
17    Position, Price, Qty, ReconcileReport, ReplaceOrderRequest, ShutdownMode, ShutdownReport,
18    StopOrderExecutor, StopOrderRef, StopOrderSnapshot, SubscribeOpts, SubscriptionId, TimeRange,
19    UserDataCaps,
20};
21use brokapi_runtime::{
22    BrokerLimiter, CommandGate, Emitter, InstrumentLocks, OrderRuntime, PriceGrid,
23    ShutdownController,
24};
25use brokapi_sansio::{CoreEvent, OrderSignal};
26use tokio::sync::{Mutex, RwLock, mpsc};
27use tokio::task::JoinHandle;
28use tokio_util::sync::CancellationToken;
29
30use crate::cid::cid_from_link_id;
31use crate::config::UserDataConfig;
32use crate::core::UdCore;
33use crate::core::ud::{WALLET_TOPIC, execution_topic, order_topic, position_topic};
34use crate::error::{map_command_err, map_config_err, map_err};
35use crate::map::execution::execution;
36use crate::map::order::{is_final, is_untriggered, order_ack, order_snapshot};
37use crate::map::portfolio::portfolio as map_portfolio;
38use crate::map::{ms_to_nanos, side_word, stop as map_stop};
39use crate::pump::{drain_backlog, run_pump};
40use crate::raw::BybitRaw;
41use crate::subs::{ReqIdGen, SubIdGen, UdSubKind, UdSubscription, wait_ack};
42use crate::util::{nanos_to_ms, symbol_category};
43
44/// How many orders a page of the lists holds at most (a limit of the exchange).
45const ORDERS_PAGE: u32 = 50;
46/// How many fills a page holds at most.
47const EXECUTIONS_PAGE: u32 = 100;
48/// How many positions a page holds at most.
49const POSITIONS_PAGE: u32 = 200;
50/// The pages read before a list is given up as too long.
51const MAX_PAGES: u32 = 40;
52/// The fills of a window: the exchange answers a range of 7 days at most.
53const EXECUTION_WINDOW_MS: i64 = 7 * 24 * 3600 * 1000;
54/// The exchange keeps the fills for two years.
55const EXECUTION_HISTORY_MS: i64 = 2 * 365 * 24 * 3600 * 1000;
56
57/// Where the connector keeps what it knows about an order of its own.
58#[derive(Debug, Clone)]
59struct Tracked {
60    order_id: String,
61    symbol: String,
62}
63
64/// The trading rules of a symbol, read once.
65#[derive(Debug, Clone)]
66struct Rules {
67    qty_step: Qty,
68    min_qty: Qty,
69    tick: Price,
70}
71
72impl Rules {
73    fn of(info: &InstrumentInfo) -> Self {
74        Self {
75            qty_step: Qty::new(info.lot_size_filter.qty_step),
76            min_qty: Qty::new(info.lot_size_filter.min_order_qty),
77            tick: Price::new(info.price_filter.tick_size),
78        }
79    }
80
81    fn check_qty(&self, qty: Qty) -> Result<(), Error> {
82        let bad = |why: String| Error::Rejected {
83            code: "invalid_qty".into(),
84            message: why,
85        };
86        if !qty.is_positive() {
87            return Err(bad(format!("quantity must be positive, got {qty}")));
88        }
89        if self.qty_step.is_positive() && !qty.is_on_step(self.qty_step) {
90            return Err(bad(format!(
91                "quantity {qty} is not a whole number of steps of {}",
92                self.qty_step
93            )));
94        }
95        if qty < self.min_qty {
96            return Err(bad(format!(
97                "quantity {qty} is below the minimum {}",
98                self.min_qty
99            )));
100        }
101        Ok(())
102    }
103
104    fn check_price(&self, price: Price) -> Result<(), Error> {
105        brokapi_core::check_price_step(price, self.tick)
106    }
107}
108
109struct UdShared {
110    http: BybitHttpClient,
111    ws: WsSession,
112    limiter: Arc<BrokerLimiter>,
113    core: Arc<Mutex<UdCore>>,
114    emitter: Arc<Emitter>,
115    orders: Mutex<OrderRuntime>,
116    instrument_locks: InstrumentLocks,
117    price_grid: PriceGrid,
118    subs: RwLock<HashMap<SubscriptionId, UdSubscription>>,
119    sub_ids: SubIdGen,
120    req_ids: ReqIdGen,
121    shutdown: Mutex<ShutdownController>,
122    cancel: CancellationToken,
123    order_state_buffer: usize,
124    category: String,
125    settle_coin: String,
126    demo: bool,
127    ack_timeout: std::time::Duration,
128    /// cid -> the number of the order, for the orders this process placed or has seen.
129    tracked: StdMutex<HashMap<ClientOrderId, Tracked>>,
130    /// The rules of the symbols, read from the directory.
131    rules: StdMutex<HashMap<String, Rules>>,
132}
133
134impl UdShared {
135    fn track(&self, cid: ClientOrderId, order_id: &str, symbol: &str) {
136        if !order_id.is_empty()
137            && let Ok(mut t) = self.tracked.lock()
138        {
139            t.insert(
140                cid,
141                Tracked {
142                    order_id: order_id.to_string(),
143                    symbol: symbol.to_string(),
144                },
145            );
146        }
147    }
148
149    fn tracked_of(&self, cid: ClientOrderId) -> Option<Tracked> {
150        self.tracked.lock().ok().and_then(|t| t.get(&cid).cloned())
151    }
152}
153
154/// The user data connector of Bybit for one account: implements [`OrderExecutor`], [`StopOrderExecutor`],
155/// [`ExecutionSource`] and [`PortfolioSource`].
156///
157/// Commands (place, cancel) go by HTTP, one request each, never repeated: a lost answer is [`Error::Unknown`]. The
158/// streams (own orders, fills, positions, wallet) come over one private WebSocket session that logs in with the key,
159/// reconnects on its own and restores its subscriptions. The account must be in one-way position mode (the default);
160/// the unified trading account is the only kind V5 knows.
161///
162/// Quantities are in units of the base coin (`0.001` BTC). The connector reads the quantity step, the smallest order and
163/// the price step of a symbol once, and refuses an order off the grid before anything is sent.
164///
165/// The `cid` goes to the exchange as the `orderLinkId` of the order, which is how it comes back in the order list, the
166/// stream and the fills. A conditional order (a stop order) is an order with a trigger price: it is placed and cancelled
167/// through the same methods, and listed apart in [`StopOrderExecutor::active_stop_orders`].
168///
169/// # Examples
170///
171/// ```
172/// # #[tokio::main(flavor = "current_thread")]
173/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
174/// # let server = brokapi_mocks::bybit::MockServer::start(Default::default()).await;
175/// # let cfg = brokapi_bybit_protocol::config::BybitConfig::for_mock("mock-key", "mock-secret", &server.http_base);
176/// use brokapi_bybit::limits::default_limits;
177/// use brokapi_bybit::util::instrument_id;
178/// use brokapi_bybit::{UserDataConfig, UserDataConnector};
179/// use brokapi_core::{
180///     ClientOrderId, Decimal, OrderExecutor, OrderType, PlaceOrderRequest, Price, Qty, ShutdownMode, Side, dec,
181/// };
182/// use brokapi_runtime::BrokerLimiter;
183/// use tokio::sync::mpsc;
184///
185/// let (tx, _events) = mpsc::channel(256);
186/// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bybit-ud"), BrokerLimiter::new(&default_limits()), tx).await?;
187/// // Commands wait for the first reconcile.
188/// ud.reconcile(&[]).await?;
189/// let cid = ClientOrderId::new_v4();
190/// let ack = ud
191///     .place(PlaceOrderRequest {
192///         cid,
193///         instrument: instrument_id("MOCKUSDT", "linear"),
194///         side: Side::Buy,
195///         order_type: OrderType::Limit,
196///         qty: Qty::from(10), // one step of the mock symbol
197///         price: Some(Price::new(dec!(1.00))),
198///     })
199///     .await?;
200/// assert_eq!(ack.cid, cid);
201/// ud.cancel(cid).await?;
202/// ud.shutdown(ShutdownMode::Immediate).await;
203/// # Ok(())
204/// # }
205/// ```
206pub struct UserDataConnector {
207    inner: Arc<UdShared>,
208    pump: JoinHandle<()>,
209}
210
211impl UserDataConnector {
212    /// Starts the connector: reads the clock of the exchange, checks the key pair with a read of the wallet, and opens
213    /// the private WebSocket session in the background. Commands wait for the first
214    /// [`reconcile`](OrderExecutor::reconcile).
215    ///
216    /// # Errors
217    ///
218    /// [`Error::Config`] when the client cannot be built or the exchange refuses the key pair (a key of the demo account
219    /// used on the main host, a wrong secret, a missing right, an IP not on the list of the key);
220    /// [`Error::Transport`] when the exchange cannot be reached.
221    pub async fn start(
222        cfg: UserDataConfig,
223        limiter: Arc<BrokerLimiter>,
224        out: mpsc::Sender<Envelope>,
225    ) -> Result<Self, Error> {
226        let http = BybitHttpClient::new(cfg.bybit.clone()).map_err(map_config_err)?;
227        http.sync_time().await.map_err(map_err)?;
228        http.get_wallet_balance().await.map_err(map_err)?;
229
230        let (event_tx, event_rx) = kanal::bounded_async(512);
231        let ws = WsSession::spawn(http.clone(), WsStream::Private, event_tx, cfg.ws_options());
232        let cancel = CancellationToken::new();
233        let category = cfg.bybit.category.as_str().to_string();
234        let core = Arc::new(Mutex::new(UdCore::new(category.clone())));
235        let emitter = Emitter::new(Arc::clone(&cfg.source), out);
236        let gate = CommandGate::new();
237
238        let inner = Arc::new(UdShared {
239            http,
240            ws,
241            limiter,
242            core: Arc::clone(&core),
243            emitter: Arc::clone(&emitter),
244            orders: Mutex::new(OrderRuntime::new()),
245            instrument_locks: InstrumentLocks::new(),
246            price_grid: cfg.price_grid.clone(),
247            subs: RwLock::new(HashMap::new()),
248            sub_ids: SubIdGen::new(1000),
249            req_ids: ReqIdGen::default(),
250            shutdown: Mutex::new(ShutdownController::new(gate)),
251            cancel: cancel.clone(),
252            order_state_buffer: cfg.order_state_buffer,
253            category,
254            settle_coin: cfg.bybit.settle_coin.clone(),
255            demo: cfg.bybit.is_demo(),
256            ack_timeout: cfg.subscribe_ack_timeout,
257            tracked: StdMutex::new(HashMap::new()),
258            rules: StdMutex::new(HashMap::new()),
259        });
260        let pump = tokio::spawn(run_pump(core, emitter, event_rx, cancel, || async {}));
261        Ok(Self { inner, pump })
262    }
263
264    /// Direct access to any API method with this connector's key and rate limiter. See [`BybitRaw`].
265    pub fn raw(&self) -> BybitRaw {
266        BybitRaw::new(self.inner.http.clone(), Arc::clone(&self.inner.limiter))
267    }
268
269    /// The price steps and quantity steps the connector checks orders against, on top of the rules it reads from the
270    /// exchange. Fill it with `learn` to be stricter.
271    pub fn price_grid(&self) -> &PriceGrid {
272        &self.inner.price_grid
273    }
274
275    /// What the connector can do.
276    ///
277    /// - `place_idempotency`: `LabelOnly`. The documentation says an `orderLinkId` is unique and a second order with the
278    ///   same one is refused (code 110072), but how long the exchange remembers a closed order's id is not stated and
279    ///   not checked on a live account. The library never repeats a place whatever the value.
280    /// - `supports_replace`: no. An amendment of the exchange keeps the client id of the order, and the shared `replace`
281    ///   promises a new order under a new cid. The amendment is available through [`raw`](Self::raw) and
282    ///   `BybitHttpClient::amend_order`. `supports_stop_orders` and `streams_executions`: yes.
283    /// - `ws_rpc` and `ordered_command_channel`: no (commands go by HTTP; the exchange has a WebSocket trade channel
284    ///   that this connector does not use, and the demo contour has none). `sandbox`: yes on the demo contour (virtual
285    ///   money), no on the main one.
286    pub fn caps(&self) -> UserDataCaps {
287        UserDataCaps::new(
288            PlaceIdempotency::LabelOnly,
289            false,
290            false,
291            false,
292            true,
293            true,
294            self.inner.demo,
295        )
296    }
297
298    /// Stops the connector. Unless the mode is `Immediate` it closes the trading gate first; `Graceful` also lets the
299    /// bot's channel take what is still buffered. Orders at the exchange are not touched.
300    pub async fn shutdown(self, mode: ShutdownMode) -> ShutdownReport {
301        let now = brokapi_runtime::now();
302        if let ShutdownMode::Graceful { deadline } = mode {
303            drain_backlog(&self.inner.emitter, deadline).await;
304        }
305        let mut orders = self.inner.orders.lock().await;
306        let mut shutdown = self.inner.shutdown.lock().await;
307        let report = shutdown.shutdown_local(mode, now, &mut orders, || (0, 0));
308        self.inner.cancel.cancel();
309        let _ = self.pump.await;
310        report
311    }
312
313    async fn check_command(&self) -> Result<(), Error> {
314        let mut ctl = self.inner.shutdown.lock().await;
315        if self.inner.emitter.is_degraded() {
316            ctl.gate_mut().mark_degraded();
317        }
318        ctl.gate().check_command()
319    }
320
321    fn acquire(&self, method: &str) -> Result<(), Error> {
322        self.inner.limiter.acquire(method, brokapi_runtime::now())
323    }
324
325    fn symbol(&self, id: &InstrumentId) -> Result<String, Error> {
326        symbol_category(id, &self.inner.category)
327    }
328
329    /// The rules of a symbol: from the cache, else one read of the directory.
330    async fn rules_of(&self, symbol: &str) -> Result<Rules, Error> {
331        if let Some(r) = self
332            .inner
333            .rules
334            .lock()
335            .ok()
336            .and_then(|m| m.get(symbol).cloned())
337        {
338            return Ok(r);
339        }
340        self.acquire("GetInstruments")?;
341        let info = self
342            .inner
343            .http
344            .get_instrument(&self.inner.category, symbol)
345            .await
346            .map_err(map_err)?;
347        let rules = Rules::of(&info);
348        if let Ok(mut m) = self.inner.rules.lock() {
349            m.insert(symbol.to_string(), rules.clone());
350        }
351        Ok(rules)
352    }
353
354    /// Takes the pages of a list until the cursor ends.
355    async fn all_open_orders(&self) -> Result<Vec<OrderRecord>, Error> {
356        let mut all = Vec::new();
357        let mut cursor: Option<String> = None;
358        for page in 0..MAX_PAGES {
359            if page > 0 {
360                self.acquire("GetOrders")?;
361            }
362            let batch = self
363                .inner
364                .http
365                .get_open_orders(
366                    &self.inner.category,
367                    None,
368                    Some(&self.inner.settle_coin),
369                    None,
370                    None,
371                    ORDERS_PAGE,
372                    cursor.as_deref(),
373                )
374                .await
375                .map_err(map_err)?;
376            all.extend(batch.list);
377            if batch.next_page_cursor.is_empty() {
378                return Ok(all);
379            }
380            cursor = Some(batch.next_page_cursor);
381        }
382        Err(Error::Transport(brokapi_core::TransportError::Message(
383            format!(
384                "the list of open orders is longer than {MAX_PAGES} pages: not returned in part"
385            ),
386        )))
387    }
388
389    /// The order of a cid as the exchange shows it now: working, or closed (the exchange keeps the closed ones of the
390    /// last hours in the order list, and the history of the last days). `None` when it knows none.
391    async fn find_record(&self, cid: ClientOrderId) -> Result<Option<OrderRecord>, Error> {
392        let tracked = self.inner.tracked_of(cid);
393        let (order_id, link_id) = match &tracked {
394            Some(t) => (Some(t.order_id.clone()), None),
395            None => (None, Some(cid.hyphenated().to_string())),
396        };
397        self.acquire("GetOrder")?;
398        let open = self
399            .inner
400            .http
401            .get_open_orders(
402                &self.inner.category,
403                None,
404                Some(&self.inner.settle_coin),
405                order_id.as_deref(),
406                link_id.as_deref(),
407                ORDERS_PAGE,
408                None,
409            )
410            .await
411            .map_err(map_err)?;
412        if let Some(r) = open.list.into_iter().next() {
413            return Ok(Some(r));
414        }
415        self.acquire("GetOrderHistory")?;
416        let history = self
417            .inner
418            .http
419            .get_order_history(
420                &self.inner.category,
421                None,
422                order_id.as_deref(),
423                link_id.as_deref(),
424                ORDERS_PAGE,
425                None,
426            )
427            .await
428            .map_err(map_err)?;
429        if let Some(r) = history.list.into_iter().next() {
430            return Ok(Some(r));
431        }
432        if tracked.is_some() {
433            return Ok(None);
434        }
435        // An order of another program carries a made-up cid: look for it among the working orders.
436        let working = self.all_open_orders().await?;
437        Ok(working
438            .into_iter()
439            .find(|r| crate::cid::foreign_cid(&r.order_id) == cid))
440    }
441
442    /// The order of a cid and its place in what this process tracks.
443    async fn find_order(&self, cid: ClientOrderId) -> Result<Tracked, Error> {
444        if let Some(t) = self.inner.tracked_of(cid) {
445            return Ok(t);
446        }
447        match self.find_record(cid).await? {
448            Some(r) => {
449                self.inner.track(cid, &r.order_id, &r.symbol);
450                Ok(Tracked {
451                    order_id: r.order_id,
452                    symbol: r.symbol,
453                })
454            }
455            None => Err(Error::not_found(
456                "the exchange has no order with this client order id",
457            )),
458        }
459    }
460
461    async fn settle(&self, cid: ClientOrderId, outcome: &Result<OrderAck, Error>) {
462        let signal = match outcome {
463            Ok(ack) => OrderSignal::Ack {
464                broker_id: ack.broker_id.clone(),
465            },
466            Err(Error::Rejected { message, .. }) => OrderSignal::Rejected {
467                reason: message.clone(),
468            },
469            Err(_) => OrderSignal::Timeout,
470        };
471        self.inner
472            .orders
473            .lock()
474            .await
475            .apply_signal(cid, signal, brokapi_runtime::now());
476    }
477
478    /// The ack of a command from the answer of the exchange. An answer without the number of the order does not say what
479    /// happened: unknown.
480    fn ack_from(
481        &self,
482        cid: ClientOrderId,
483        order_id: &str,
484        symbol: &str,
485    ) -> Result<OrderAck, Error> {
486        if order_id.is_empty() {
487            return Err(Error::Unknown);
488        }
489        self.inner.track(cid, order_id, symbol);
490        Ok(order_ack(cid, order_id, brokapi_runtime::now()))
491    }
492
493    /// Marks the start of a place command for `cid`: once, with the clock of the order runtime.
494    async fn begin_place(&self, cid: ClientOrderId, what: &str) -> Result<(), Error> {
495        let now = brokapi_runtime::now();
496        let mut orders = self.inner.orders.lock().await;
497        if !orders.try_place_once(cid) {
498            return Err(Error::Rejected {
499                code: "duplicate_place".into(),
500                message: format!("auto-retry of {what} is forbidden"),
501            });
502        }
503        orders.register_place(cid, now);
504        Ok(())
505    }
506
507    fn snapshot_of(
508        &self,
509        cid: ClientOrderId,
510        record: &OrderRecord,
511    ) -> Result<OrderSnapshot, Error> {
512        order_snapshot(record, cid, &self.inner.category, brokapi_runtime::now()).ok_or_else(|| {
513            Error::Transport(brokapi_core::TransportError::Message(
514                "the order answer has a side the connector cannot read".into(),
515            ))
516        })
517    }
518
519    /// Sends a request for an order and settles it. Shared by `place` and `place_stop`.
520    async fn send_create(
521        &self,
522        cid: ClientOrderId,
523        symbol: &str,
524        body: CreateOrderRequest,
525    ) -> Result<OrderAck, Error> {
526        let result = self.inner.http.create_order(&body).await;
527        let outcome = match result {
528            Ok(r) => self.ack_from(cid, &r.order_id, symbol),
529            Err(e) => Err(map_command_err(e)),
530        };
531        self.settle(cid, &outcome).await;
532        let ack = outcome?;
533        self.inner
534            .core
535            .lock()
536            .await
537            .learn_order_id(ack.broker_id.as_str(), cid);
538        Ok(ack)
539    }
540
541    async fn register_ud_sub(
542        &self,
543        kind: UdSubKind,
544        opts: SubscribeOpts,
545        topics: Vec<String>,
546    ) -> Result<SubscriptionId, Error> {
547        let id = self.inner.sub_ids.next();
548        self.inner.emitter.register(id, None, opts.backpressure);
549        let reqs: Vec<(String, String)> = topics
550            .iter()
551            .map(|t| (self.inner.req_ids.next(), t.clone()))
552            .collect();
553        // The topics some other subscription already holds are not asked for again.
554        let (fresh, notify) = {
555            let mut core = self.inner.core.lock().await;
556            let fresh: Vec<(String, String)> = reqs
557                .iter()
558                .filter(|(_, t)| !core.has_topic(t))
559                .cloned()
560                .collect();
561            core.register(id, kind, &topics, &reqs);
562            (fresh, core.notifier())
563        };
564        for (req, topic) in &fresh {
565            if let Err(e) = self.inner.ws.subscribe(req, topic).await {
566                self.drop_sub(id).await;
567                return Err(map_err(e));
568            }
569        }
570        // Returns when the exchange has confirmed the streams: what happens after that is delivered.
571        let confirmed = wait_ack(
572            &self.inner.core,
573            notify,
574            self.inner.ack_timeout,
575            |c: &UdCore| c.sub_state(id),
576        )
577        .await;
578        if let Err(e) = confirmed {
579            self.drop_sub(id).await;
580            return Err(e);
581        }
582        self.inner.subs.write().await.insert(
583            id,
584            UdSubscription {
585                id,
586                kind,
587                opts,
588                topics,
589            },
590        );
591        Ok(id)
592    }
593
594    async fn drop_sub(&self, id: SubscriptionId) {
595        let orphans = self.inner.core.lock().await.forget(id);
596        self.inner.emitter.unregister(id);
597        for topic in orphans {
598            let _ = self.inner.ws.unsubscribe(&topic).await;
599        }
600    }
601
602    /// The whole portfolio, read now.
603    async fn read_portfolio(&self) -> Result<Portfolio, Error> {
604        self.acquire("GetWallet")?;
605        let wallets = self
606            .inner
607            .http
608            .get_wallet_balance()
609            .await
610            .map_err(map_err)?;
611        let mut records = Vec::new();
612        let mut cursor: Option<String> = None;
613        for page in 0..MAX_PAGES {
614            self.acquire("GetPositions")?;
615            let batch = self
616                .inner
617                .http
618                .get_positions(
619                    &self.inner.category,
620                    &self.inner.settle_coin,
621                    POSITIONS_PAGE,
622                    cursor.as_deref(),
623                )
624                .await
625                .map_err(map_err)?;
626            records.extend(batch.list);
627            if batch.next_page_cursor.is_empty() {
628                break;
629            }
630            cursor = Some(batch.next_page_cursor);
631            if page + 1 == MAX_PAGES {
632                return Err(Error::Transport(brokapi_core::TransportError::Message(
633                    "the list of positions is too long: not returned in part".into(),
634                )));
635            }
636        }
637        Ok(map_portfolio(
638            &wallets,
639            &records,
640            &self.inner.category,
641            brokapi_runtime::now(),
642        ))
643    }
644}
645
646#[async_trait]
647impl brokapi_core::Connector for UserDataConnector {
648    type Caps = brokapi_core::UserDataCaps;
649
650    fn caps(&self) -> brokapi_core::UserDataCaps {
651        UserDataConnector::caps(self)
652    }
653
654    async fn shutdown(self, mode: brokapi_core::ShutdownMode) -> brokapi_core::ShutdownReport {
655        UserDataConnector::shutdown(self, mode).await
656    }
657}
658
659#[async_trait]
660impl OrderExecutor for UserDataConnector {
661    async fn place(&self, req: PlaceOrderRequest) -> Result<OrderAck, Error> {
662        self.check_command().await?;
663        // Input first, then the rules (a read), then quota, then the cid: a refusal before the cid is marked (bad
664        // input, `RateLimited`) sends nothing, so the same cid can be used again.
665        let symbol = self.symbol(&req.instrument)?;
666        self.inner
667            .price_grid
668            .check_qty(&req.instrument.key, req.qty)?;
669        let price = match req.order_type {
670            CoreOrderType::Market => None,
671            CoreOrderType::Limit => {
672                let price = req.price.ok_or_else(|| Error::Rejected {
673                    code: "missing_price".into(),
674                    message: "limit order requires price".into(),
675                })?;
676                // The step is checked here, before a quota is spent or the cid is marked.
677                self.inner.price_grid.check(&req.instrument.key, price)?;
678                Some(price)
679            }
680        };
681        let rules = self.rules_of(&symbol).await?;
682        rules.check_qty(req.qty)?;
683        if let Some(p) = price {
684            rules.check_price(p)?;
685        }
686        self.acquire("PlaceOrder")?;
687
688        // Waits for earlier commands on this instrument, released on every exit path.
689        let _instrument = self.inner.instrument_locks.lock(&req.instrument).await;
690        // The gate may have closed while this command stood in the queue (degraded, shutdown): ask again before the
691        // cid is marked and anything is sent.
692        self.check_command().await?;
693        self.inner.instrument_locks.bind(req.cid, &req.instrument);
694        self.begin_place(req.cid, "place").await?;
695
696        let body = CreateOrderRequest {
697            category: self.inner.category.clone(),
698            symbol: symbol.clone(),
699            side: side_word(req.side).to_string(),
700            order_type: if price.is_some() { "Limit" } else { "Market" }.to_string(),
701            qty: req.qty.decimal(),
702            price: price.map(Price::decimal),
703            // A market order is always immediate-or-cancel at the exchange.
704            time_in_force: price.map(|_| "GTC".to_string()),
705            order_link_id: Some(req.cid.hyphenated().to_string()),
706            ..Default::default()
707        };
708        self.send_create(req.cid, &symbol, body).await
709    }
710
711    async fn cancel(&self, cid: ClientOrderId) -> Result<(), Error> {
712        self.check_command().await?;
713        self.acquire("CancelOrder")?;
714        // The instrument comes from the orders this process placed; an older order is not serialized.
715        let _instrument = self.inner.instrument_locks.lock_cid(cid).await;
716        // The gate may have closed while this command stood in the queue.
717        self.check_command().await?;
718        let t = self.find_order(cid).await.map_err(|e| match e {
719            e if e.is_not_found() => Error::Rejected {
720                code: brokapi_core::NOT_FOUND.into(),
721                message: "the exchange has no order with this client order id".into(),
722            },
723            e => e,
724        })?;
725        self.inner
726            .http
727            .cancel_order(&CancelOrderRequest {
728                category: self.inner.category.clone(),
729                symbol: t.symbol,
730                order_id: Some(t.order_id),
731                order_link_id: None,
732            })
733            .await
734            .map_err(map_command_err)?;
735        Ok(())
736    }
737
738    async fn replace(&self, _req: ReplaceOrderRequest) -> Result<OrderAck, Error> {
739        // The amendment of the exchange keeps the order and its client id; this call promises a new order under a new
740        // cid. Cancel and place, or amend through raw().
741        Err(Error::Unsupported {
742            capability: "replace".into(),
743        })
744    }
745
746    async fn order_state(&self, cid: ClientOrderId) -> Result<OrderSnapshot, Error> {
747        match self.find_record(cid).await? {
748            Some(rec) => {
749                self.inner.track(cid, &rec.order_id, &rec.symbol);
750                self.snapshot_of(cid, &rec)
751            }
752            None => Err(Error::not_found(
753                "the exchange has no order with this client order id",
754            )),
755        }
756    }
757
758    async fn active_orders(&self) -> Result<Vec<OrderSnapshot>, Error> {
759        let records = self.all_open_orders().await?;
760        let now = brokapi_runtime::now();
761        let mut out = Vec::new();
762        let mut core = self.inner.core.lock().await;
763        for r in &records {
764            if is_untriggered(r) || is_final(r) {
765                continue;
766            }
767            let cid = core.resolve_cid(&r.order_id, &r.order_link_id);
768            // Ours or not, the cid is remembered for the number: an order of another program can be cancelled by the
769            // made-up cid it was listed with.
770            self.inner.track(cid, &r.order_id, &r.symbol);
771            if let Some(snap) = order_snapshot(r, cid, &self.inner.category, now) {
772                out.push(snap);
773            }
774        }
775        Ok(out)
776    }
777
778    async fn subscribe_order_state(&self) -> Result<SubscriptionId, Error> {
779        let opts = lossless(self.inner.order_state_buffer);
780        self.register_ud_sub(
781            UdSubKind::OrderState,
782            opts,
783            vec![order_topic(&self.inner.category)],
784        )
785        .await
786    }
787
788    async fn reconcile(&self, ledger: &[ClientOrderId]) -> Result<ReconcileReport, Error> {
789        self.acquire("Reconcile")?;
790        let open = self.all_open_orders().await?;
791        let now = brokapi_runtime::now();
792        let mut report = ReconcileReport::new(Vec::new(), Vec::new(), Vec::new());
793        let known: HashSet<ClientOrderId> = ledger.iter().copied().collect();
794        for cid in ledger {
795            let tracked = self.inner.tracked_of(*cid);
796            let working = open.iter().find(|r| {
797                cid_from_link_id(Some(&r.order_link_id)) == Some(*cid)
798                    || tracked.as_ref().is_some_and(|t| t.order_id == r.order_id)
799            });
800            if let Some(r) = working {
801                self.inner.track(*cid, &r.order_id, &r.symbol);
802                let snap = if is_untriggered(r) {
803                    stop_as_order(r, *cid, &self.inner.category, now)
804                } else {
805                    order_snapshot(r, *cid, &self.inner.category, now)
806                };
807                match snap {
808                    Some(s) => report.confirmed.push(s),
809                    // Alive but unreadable: it is not closed.
810                    None => {
811                        return Err(Error::Transport(brokapi_core::TransportError::Message(
812                            "an order of the ledger cannot be read".into(),
813                        )));
814                    }
815                }
816                continue;
817            }
818            // Not among the working orders: closed while we were offline, or never there. Ask for its end.
819            match self.find_record(*cid).await? {
820                Some(r) if !is_final(&r) => {
821                    // Working after all (the list and the lookup disagree): not closed, so the bot must not place a
822                    // second order in its stead.
823                    self.inner.track(*cid, &r.order_id, &r.symbol);
824                    if let Some(s) = order_snapshot(&r, *cid, &self.inner.category, now) {
825                        report.confirmed.push(s);
826                    }
827                }
828                _ => report.closed_offline.push(*cid),
829            }
830        }
831        {
832            let mut core = self.inner.core.lock().await;
833            report.foreign = open
834                .iter()
835                .filter(|r| !is_untriggered(r) && !is_final(r))
836                .filter_map(|r| {
837                    let cid = core.resolve_cid(&r.order_id, &r.order_link_id);
838                    (!known.contains(&cid))
839                        .then(|| order_snapshot(r, cid, &self.inner.category, now))?
840                })
841                .collect();
842        }
843        // A reconcile that got this far leaves `Degraded`: the answers above are the repair of what the lost events may
844        // have cost.
845        self.inner.emitter.clear_degraded();
846        let mut ctl = self.inner.shutdown.lock().await;
847        ctl.gate_mut().clear_degraded();
848        ctl.gate_mut().mark_reconciled();
849        Ok(report)
850    }
851}
852
853/// A conditional order that waits, as a snapshot of an order (for `reconcile`, where the ledger holds stop orders too).
854fn stop_as_order(
855    r: &OrderRecord,
856    cid: ClientOrderId,
857    category: &str,
858    now: Instant,
859) -> Option<OrderSnapshot> {
860    let s = map_stop::snapshot(r, Some(cid), category, now)?;
861    Some(
862        OrderSnapshot::new(
863            cid,
864            s.instrument,
865            OrderState::Acked {
866                cid,
867                broker_id: s.broker_id.clone(),
868            },
869            s.side,
870            if s.limit_price.is_some() {
871                CoreOrderType::Limit
872            } else {
873                CoreOrderType::Market
874            },
875            s.qty,
876            s.ts,
877        )
878        .with_broker_id(Some(s.broker_id))
879        .with_price(s.limit_price),
880    )
881}
882
883fn lossless(cap: usize) -> SubscribeOpts {
884    SubscribeOpts {
885        backpressure: Backpressure::Lossless { cap },
886        depth: None,
887    }
888}
889
890#[async_trait]
891impl ExecutionSource for UserDataConnector {
892    async fn executions(&self, range: TimeRange) -> Result<Vec<Execution>, Error> {
893        let now_ms = chrono::Utc::now().timestamp_millis();
894        let end = nanos_to_ms(range.to).min(now_ms);
895        // The exchange keeps two years and answers a range of 7 days at most: the range is read in windows, the oldest
896        // first.
897        let start = nanos_to_ms(range.from).max(end - EXECUTION_HISTORY_MS);
898        let mut seen = HashSet::new();
899        let mut out = Vec::new();
900        let mut from = start;
901        while from <= end {
902            let to = (from + EXECUTION_WINDOW_MS).min(end);
903            let mut cursor: Option<String> = None;
904            for page in 0..MAX_PAGES {
905                self.acquire("GetExecutions")?;
906                let batch = self
907                    .inner
908                    .http
909                    .get_executions(
910                        &self.inner.category,
911                        None,
912                        Some(from),
913                        Some(to),
914                        EXECUTIONS_PAGE,
915                        cursor.as_deref(),
916                    )
917                    .await
918                    .map_err(map_err)?;
919                let nanos = brokapi_runtime::now();
920                let core = self.inner.core.lock().await;
921                for r in &batch.list {
922                    if !seen.insert(r.exec_id.clone()) {
923                        continue;
924                    }
925                    let at = ms_to_nanos(r.exec_time);
926                    if r.exec_time > 0 && (at < range.from || at >= range.to) {
927                        continue;
928                    }
929                    let cid = cid_from_link_id(Some(&r.order_link_id))
930                        .or_else(|| core.cid_of_order_id(&r.order_id));
931                    if let Some(e) = execution(r, cid, &self.inner.category, nanos) {
932                        out.push(e);
933                    }
934                }
935                drop(core);
936                if batch.next_page_cursor.is_empty() {
937                    break;
938                }
939                cursor = Some(batch.next_page_cursor);
940                if page + 1 == MAX_PAGES {
941                    return Err(Error::Rejected {
942                        code: "range_too_long".into(),
943                        message: "a window of 7 days holds more fills than the connector reads: read a shorter range".into(),
944                    });
945                }
946            }
947            if to >= end {
948                break;
949            }
950            from = to + 1;
951        }
952        out.sort_by_key(|e| e.ts.broker_ts.map(|t| t.as_nanos()).unwrap_or(0));
953        Ok(out)
954    }
955
956    async fn subscribe_executions(&self) -> Result<SubscriptionId, Error> {
957        let opts = lossless(self.inner.order_state_buffer);
958        self.register_ud_sub(
959            UdSubKind::Executions,
960            opts,
961            vec![execution_topic(&self.inner.category)],
962        )
963        .await
964    }
965}
966
967#[async_trait]
968impl PortfolioSource for UserDataConnector {
969    async fn portfolio(&self) -> Result<Portfolio, Error> {
970        self.read_portfolio().await
971    }
972
973    async fn positions(&self) -> Result<Vec<Position>, Error> {
974        Ok(self.read_portfolio().await?.positions)
975    }
976
977    async fn balances(&self) -> Result<Vec<Balance>, Error> {
978        Ok(self.read_portfolio().await?.balances)
979    }
980
981    async fn subscribe_portfolio(&self, opts: SubscribeOpts) -> Result<SubscriptionId, Error> {
982        let topics = vec![
983            position_topic(&self.inner.category),
984            WALLET_TOPIC.to_string(),
985        ];
986        let id = self
987            .register_ud_sub(UdSubKind::Portfolio, opts, topics)
988            .await?;
989        // The streams of the exchange send changes only, with no snapshot at the start. The first events are the
990        // positions and balances now, read once, so the reader starts from a whole picture.
991        match self.read_portfolio().await {
992            Ok(p) => {
993                let mut events = Vec::new();
994                if !p.positions.is_empty() {
995                    events.push(CoreEvent {
996                        sub: Some(id),
997                        payload: Event::Positions(p.positions),
998                    });
999                }
1000                if !p.balances.is_empty() {
1001                    events.push(CoreEvent {
1002                        sub: Some(id),
1003                        payload: Event::Balances(p.balances),
1004                    });
1005                }
1006                self.inner.emitter.emit(events);
1007                Ok(id)
1008            }
1009            Err(e) => {
1010                self.inner.subs.write().await.remove(&id);
1011                self.drop_sub(id).await;
1012                Err(e)
1013            }
1014        }
1015    }
1016}
1017
1018#[async_trait]
1019impl StopOrderExecutor for UserDataConnector {
1020    async fn place_stop(&self, req: PlaceStopOrderRequest) -> Result<OrderAck, Error> {
1021        self.check_command().await?;
1022        // Input is checked before anything is sent or any quota is spent.
1023        let symbol = self.symbol(&req.instrument)?;
1024        self.inner
1025            .price_grid
1026            .check_qty(&req.instrument.key, req.qty)?;
1027        self.inner
1028            .price_grid
1029            .check(&req.instrument.key, req.stop_price)?;
1030        if let Some(p) = req.limit_price {
1031            self.inner.price_grid.check(&req.instrument.key, p)?;
1032        }
1033        let body = map_stop::build(&req, &symbol, &self.inner.category, req.cid)?;
1034        let rules = self.rules_of(&symbol).await?;
1035        rules.check_qty(req.qty)?;
1036        rules.check_price(req.stop_price)?;
1037        if let Some(p) = req.limit_price {
1038            rules.check_price(p)?;
1039        }
1040        // Quota before the cid is marked: a `RateLimited` sent nothing, the cid stays usable.
1041        self.acquire("PlaceStopOrder")?;
1042        let _instrument = self.inner.instrument_locks.lock(&req.instrument).await;
1043        // The gate may have closed while this command stood in the queue.
1044        self.check_command().await?;
1045        self.inner.instrument_locks.bind(req.cid, &req.instrument);
1046        self.begin_place(req.cid, "place_stop").await?;
1047        self.send_create(req.cid, &symbol, body).await
1048    }
1049
1050    async fn cancel_stop(&self, stop: StopOrderRef) -> Result<(), Error> {
1051        self.check_command().await?;
1052        self.acquire("CancelStopOrder")?;
1053        let (known_cid, order_id, symbol) = match stop {
1054            StopOrderRef::Cid(cid) => {
1055                let t = self.find_order(cid).await.map_err(|e| {
1056                    if e.is_not_found() {
1057                        Error::Rejected {
1058                            code: brokapi_core::NOT_FOUND.into(),
1059                            message: "the exchange has no stop order with this client order id"
1060                                .into(),
1061                        }
1062                    } else {
1063                        e
1064                    }
1065                })?;
1066                (cid, t.order_id, t.symbol)
1067            }
1068            StopOrderRef::Broker(id) => {
1069                // The cancel needs the symbol: ask for the order by its number.
1070                self.acquire("GetOrder")?;
1071                let page = self
1072                    .inner
1073                    .http
1074                    .get_open_orders(
1075                        &self.inner.category,
1076                        None,
1077                        Some(&self.inner.settle_coin),
1078                        Some(id.as_str()),
1079                        None,
1080                        ORDERS_PAGE,
1081                        None,
1082                    )
1083                    .await
1084                    .map_err(map_err)?;
1085                let rec = page
1086                    .list
1087                    .into_iter()
1088                    .next()
1089                    .ok_or_else(|| Error::Rejected {
1090                        code: brokapi_core::NOT_FOUND.into(),
1091                        message: "the exchange has no stop order with this number".into(),
1092                    })?;
1093                let cid = self
1094                    .inner
1095                    .core
1096                    .lock()
1097                    .await
1098                    .resolve_cid(&rec.order_id, &rec.order_link_id);
1099                (cid, rec.order_id, rec.symbol)
1100            }
1101        };
1102        let _instrument = self.inner.instrument_locks.lock_cid(known_cid).await;
1103        // The gate may have closed while this command stood in the queue.
1104        self.check_command().await?;
1105        self.inner
1106            .http
1107            .cancel_order(&CancelOrderRequest {
1108                category: self.inner.category.clone(),
1109                symbol,
1110                order_id: Some(order_id),
1111                order_link_id: None,
1112            })
1113            .await
1114            .map_err(map_command_err)?;
1115        Ok(())
1116    }
1117
1118    async fn active_stop_orders(&self) -> Result<Vec<StopOrderSnapshot>, Error> {
1119        let records = self.all_open_orders().await?;
1120        let now = brokapi_runtime::now();
1121        let mut core = self.inner.core.lock().await;
1122        let mut out = Vec::new();
1123        for r in records.iter().filter(|r| is_untriggered(r)) {
1124            let cid = core.resolve_cid(&r.order_id, &r.order_link_id);
1125            self.inner.track(cid, &r.order_id, &r.symbol);
1126            if let Some(s) = map_stop::snapshot(r, Some(cid), &self.inner.category, now) {
1127                out.push(s);
1128            }
1129        }
1130        Ok(out)
1131    }
1132
1133    async fn subscribe_stop_orders(&self) -> Result<SubscriptionId, Error> {
1134        // The shared events have no stop order event; the list is `active_stop_orders`.
1135        Err(Error::Unsupported {
1136            capability: "stop_order_stream".into(),
1137        })
1138    }
1139}