Skip to main content

brokapi_alor/
ud.rs

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