Skip to main content

brokapi_tbank/core/
ud.rs

1//! User-data core: order lifecycle, own trades, portfolio of ONE account.
2//!
3//! The runtime owns the streams (`OrderStateStream`, `TradesStream`, `PortfolioStream`,
4//! `PositionsStream`) and the unary calls; this core keeps the order state machine, links ids
5//! (our cid <-> exchange order id <-> instrument) and turns everything into events.
6//!
7//! One account: every message with another `account_id` is dropped even if the broker
8//! sends it on our stream.
9
10use std::collections::{BTreeMap, HashMap, VecDeque};
11use std::sync::Arc;
12use std::time::Instant;
13
14use brokapi_core::{
15    Balance, BrokerOrderId, ClientOrderId, Error, Event, InstrumentId, InstrumentKey, Money,
16    OrderSnapshot, OrderState, OrderType, PlaceOrderRequest, Price, Qty, Side, SubscriptionId,
17    Timestamps,
18};
19use brokapi_runtime::{FinishedWindow, OrderRuntime};
20use brokapi_sansio::{
21    BrokerCore, EventSink, OrderSignal, Outbound, SubscriptionKind, SubscriptionSet,
22};
23use brokapi_tbank_protocol::money::money_to_decimal;
24use brokapi_tbank_protocol::v1 as pb;
25use brokapi_tbank_protocol::v1::order_state_stream_response::Payload as OrderStreamPayload;
26use brokapi_tbank_protocol::v1::portfolio_stream_response::Payload as PortfolioPayload;
27use brokapi_tbank_protocol::v1::positions_stream_response::Payload as PositionsPayload;
28use brokapi_tbank_protocol::v1::trades_stream_response::Payload as TradesPayload;
29
30use crate::core::md::SubUpdate;
31use crate::ids::{id_from_parts, stamps};
32use crate::lots::LotBook;
33use crate::map::order::{self, OrderParts};
34use crate::map::{execution, portfolio};
35
36/// The user-data streams the runtime keeps open.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum StreamKind {
39    /// `OrdersStreamService/OrderStateStream`: state changes of own orders.
40    OrderState,
41    /// `OrdersStreamService/TradesStream`: own trades.
42    Trades,
43    /// `OperationsStreamService/PortfolioStream`: the portfolio with prices.
44    Portfolio,
45    /// `OperationsStreamService/PositionsStream`: cash and securities balances.
46    Positions,
47}
48
49/// User-data subscriptions. `brokapi-sansio`'s `SubscriptionKind` has no executions variant yet, so
50/// this core takes them through [`UdCore::add_sub`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum UdSubKind {
53    /// State changes of own orders. Fed by [`StreamKind::OrderState`].
54    OrderState,
55    /// Own trades. Fed by [`StreamKind::Trades`].
56    Executions,
57    /// Portfolio, positions and cash. Fed by [`StreamKind::Portfolio`] and [`StreamKind::Positions`].
58    Portfolio,
59}
60
61/// One inbound item for [`UdCore`]: a message from one of the user data streams.
62#[derive(Debug, Clone)]
63pub enum UdFrame {
64    /// A stream was (re)opened.
65    Connected(StreamKind),
66    /// A message of `OrderStateStream`.
67    OrderState(pb::OrderStateStreamResponse),
68    /// A message of `TradesStream`.
69    Trades(pb::TradesStreamResponse),
70    /// A message of `PortfolioStream`.
71    Portfolio(pb::PortfolioStreamResponse),
72    /// A message of `PositionsStream`.
73    Positions(pb::PositionsStreamResponse),
74    /// `GetOrders` result used to refresh order state after a stream reconnect.
75    ActiveOrders(pb::GetOrdersResponse),
76    /// The broker refused to open the stream for good (a rejected token, no permission, the limit of open
77    /// streams): the subscription fed by it fails with this reason, and the stream is not reopened.
78    StreamFailed(StreamKind, String),
79}
80
81#[derive(Debug, Clone)]
82struct OrderMeta {
83    instrument: InstrumentId,
84    side: Side,
85    order_type: OrderType,
86    qty: Qty,
87    filled: Qty,
88    price: Option<Price>,
89    exchange_id: Option<String>,
90}
91
92/// Sans-io core of the user data of ONE account: order state machine, own trades, portfolio.
93///
94/// It links our cid to the exchange order id and the instrument, drops every message that belongs to
95/// another account, and turns the rest into events. The connector calls the command methods
96/// ([`begin_place`](Self::begin_place), [`place_response`](Self::place_response), ...) around its
97/// requests, and feeds stream messages through [`BrokerCore::on_frame`] as [`UdFrame`]s.
98///
99/// # Examples
100///
101/// ```
102/// use std::time::Instant;
103/// use brokapi_core::{ClientOrderId, InstrumentId, InstrumentKey, NativeId, OrderType, PlaceOrderRequest, Price, Qty, Side};
104/// use brokapi_tbank::core::UdCore;
105///
106/// let mut core = UdCore::new("acc-main");
107/// let sber = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::Uid("uid-sber".into()));
108/// let req = PlaceOrderRequest {
109///     cid: ClientOrderId::new_v4(),
110///     instrument: sber,
111///     side: Side::Buy,
112///     order_type: OrderType::Limit,
113///     qty: Qty::from(1),
114///     price: Some(Price::from(250)),
115/// };
116///
117/// // A cid is registered for one placement at most. The second try is refused before any send.
118/// assert!(core.begin_place(&req, Instant::now()).is_ok());
119/// assert!(core.begin_place(&req, Instant::now()).is_err());
120/// assert!(core.order_state(req.cid).is_some());
121/// ```
122#[derive(Debug)]
123pub struct UdCore {
124    account_id: String,
125    orders: OrderRuntime,
126    meta: HashMap<ClientOrderId, OrderMeta>,
127    by_exchange: HashMap<String, ClientOrderId>,
128    /// Own trades that arrived while a placement was still in flight and whose order is not known yet,
129    /// by exchange order id. They wait for the answer to the placement, which gives the cid.
130    pending_trades: HashMap<String, Vec<brokapi_core::Execution>>,
131    /// Finished orders, to forget their ids after the memory window.
132    done: FinishedWindow<ClientOrderId>,
133    /// Cids forgotten since the last call of `take_forgotten`.
134    forgotten: Vec<ClientOrderId>,
135    known: HashMap<String, InstrumentKey>,
136    subs: HashMap<UdSubKind, SubscriptionId>,
137    cash: BTreeMap<String, (Money, Money)>,
138    portfolio_parts: (bool, bool),
139    updates: Vec<SubUpdate>,
140    connections: HashMap<StreamKind, u32>,
141    reconnected: VecDeque<StreamKind>,
142    lots: Arc<LotBook>,
143}
144
145impl UdCore {
146    /// A core for the account `account_id`. Messages with another account id are dropped.
147    pub fn new(account_id: impl Into<String>) -> Self {
148        Self {
149            account_id: account_id.into(),
150            orders: OrderRuntime::new(),
151            meta: HashMap::new(),
152            by_exchange: HashMap::new(),
153            pending_trades: HashMap::new(),
154            done: FinishedWindow::new(),
155            forgotten: Vec::new(),
156            known: HashMap::new(),
157            subs: HashMap::new(),
158            cash: BTreeMap::new(),
159            portfolio_parts: (false, false),
160            updates: Vec::new(),
161            connections: HashMap::new(),
162            reconnected: VecDeque::new(),
163            lots: Arc::new(LotBook::new()),
164        }
165    }
166
167    /// The lot sizes this core knows. The connector fills the book before it feeds a message of an
168    /// instrument it has not met (the messages count in lots, the events in units).
169    pub fn lots(&self) -> &Arc<LotBook> {
170        &self.lots
171    }
172
173    /// The lot size of an instrument by the names of a message. 1 when it is not known, which would
174    /// make the quantities lots: the connector fills the book first, so this is the safety net only.
175    fn lot_of(&self, uid: &str, figi: &str, ticker: &str, class_code: &str) -> i64 {
176        match self.lots.get_parts(uid, figi, ticker, class_code) {
177            Some(lot) => lot,
178            None => {
179                tracing::warn!(
180                    uid,
181                    ticker,
182                    "lot size of the instrument is not known, quantities stay in lots"
183                );
184                1
185            }
186        }
187    }
188
189    /// The account this core serves.
190    pub fn account_id(&self) -> &str {
191        &self.account_id
192    }
193
194    /// The order runtime, for the shutdown sequence.
195    pub fn orders_mut(&mut self) -> &mut OrderRuntime {
196        &mut self.orders
197    }
198
199    /// The state this core holds for `cid`, or `None` if it never saw that cid.
200    pub fn order_state(&self, cid: ClientOrderId) -> Option<&OrderState> {
201        self.orders.state(cid)
202    }
203
204    /// Takes the cids the core forgot since the last call (orders finished long ago, see the memory window
205    /// of [`OrderRuntime`]). The connector drops what else it keeps for them.
206    pub fn take_forgotten(&mut self) -> Vec<ClientOrderId> {
207        std::mem::take(&mut self.forgotten)
208    }
209
210    /// Takes the subscription confirmations and refusals since the last call.
211    pub fn take_updates(&mut self) -> Vec<SubUpdate> {
212        std::mem::take(&mut self.updates)
213    }
214
215    /// Streams that were reopened after a break (not the first open). The runtime refreshes order
216    /// state from `GetOrders` for `OrderState`.
217    pub fn take_reconnected(&mut self) -> Vec<StreamKind> {
218        self.reconnected.drain(..).collect()
219    }
220
221    /// Registers a subscription of `kind` under `id`. A later one of the same kind replaces it.
222    pub fn add_sub(&mut self, id: SubscriptionId, kind: UdSubKind) {
223        self.subs.insert(kind, id);
224    }
225
226    /// Forgets the subscription of `kind`, if any.
227    pub fn remove_sub(&mut self, kind: UdSubKind) {
228        self.subs.remove(&kind);
229    }
230
231    /// The id of the active subscription of `kind`, if any.
232    pub fn sub(&self, kind: UdSubKind) -> Option<SubscriptionId> {
233        self.subs.get(&kind).copied()
234    }
235
236    /// Teach the core an instrument (uid/figi -> ticker@class), e.g. the one an order is placed on.
237    pub fn learn(&mut self, id: &InstrumentId) {
238        match &id.native {
239            brokapi_core::NativeId::Uid(u) | brokapi_core::NativeId::Figi(u) => {
240                self.known.insert(u.clone(), id.key.clone());
241            }
242            _ => {}
243        }
244    }
245
246    fn learn_parts(&mut self, uid: &str, figi: &str, ticker: &str, class_code: &str) {
247        if ticker.is_empty() {
248            return;
249        }
250        let key = InstrumentKey::new(ticker, class_code);
251        for k in [uid, figi] {
252            if !k.is_empty() {
253                self.known.insert(k.to_string(), key.clone());
254            }
255        }
256    }
257
258    /// Instrument id from message fields; the ticker is filled from earlier messages when missing.
259    pub fn resolve(&self, uid: &str, figi: &str, ticker: &str, class_code: &str) -> InstrumentId {
260        if ticker.is_empty() {
261            let key = self.known.get(uid).or_else(|| self.known.get(figi));
262            if let Some(key) = key {
263                return id_from_parts(uid, figi, &key.symbol, &key.mic);
264            }
265        }
266        id_from_parts(uid, figi, ticker, class_code)
267    }
268
269    fn other_account(&self, account_id: &str) -> bool {
270        !account_id.is_empty() && account_id != self.account_id
271    }
272
273    // ----- commands (called by the connector around its RPCs) -----
274
275    /// Registers a placement before the request is sent. At most one placement per cid, ever
276    /// (no automatic retry of `place`).
277    pub fn begin_place(&mut self, req: &PlaceOrderRequest, now: Instant) -> Result<(), Error> {
278        if !self.orders.try_place_once(req.cid) {
279            return Err(Error::Rejected {
280                code: "duplicate_place".into(),
281                message: "a cid is placed at most once; a repeated place is never sent".into(),
282            });
283        }
284        self.orders.register_place(req.cid, now);
285        self.learn(&req.instrument);
286        self.meta.insert(
287            req.cid,
288            OrderMeta {
289                instrument: req.instrument.clone(),
290                side: req.side,
291                order_type: req.order_type,
292                qty: req.qty,
293                filled: Qty::ZERO,
294                price: req.price,
295                exchange_id: None,
296            },
297        );
298        Ok(())
299    }
300
301    /// The placement response arrived.
302    pub fn place_response(
303        &mut self,
304        cid: ClientOrderId,
305        resp: &pb::PostOrderResponse,
306        now: Instant,
307        out: &mut EventSink,
308    ) {
309        let lot = self.lot_of(
310            &resp.instrument_uid,
311            &resp.figi,
312            &resp.ticker,
313            &resp.class_code,
314        );
315        let parts = order::parts_from_post(resp).with_lot(lot);
316        self.learn_parts(
317            &resp.instrument_uid,
318            &resp.figi,
319            &resp.ticker,
320            &resp.class_code,
321        );
322        if !parts.exchange_id.is_empty() {
323            self.by_exchange.insert(parts.exchange_id.clone(), cid);
324            if let Some(m) = self.meta.get_mut(&cid) {
325                m.exchange_id = Some(parts.exchange_id.clone());
326            }
327        }
328        self.apply_parts(cid, &parts, now, out);
329    }
330
331    /// The broker refused the placement: nothing exists at the broker.
332    pub fn place_rejected(
333        &mut self,
334        cid: ClientOrderId,
335        reason: String,
336        now: Instant,
337        out: &mut EventSink,
338    ) {
339        self.signal(cid, OrderSignal::Rejected { reason }, now, out);
340    }
341
342    /// Timeout or break during `place`: the outcome is unknown, never `Rejected`.
343    pub fn place_unknown(&mut self, cid: ClientOrderId, now: Instant, out: &mut EventSink) {
344        self.signal(cid, OrderSignal::Timeout, now, out);
345    }
346
347    /// The broker accepted a cancel.
348    pub fn cancel_ok(&mut self, cid: ClientOrderId, now: Instant, out: &mut EventSink) {
349        if self.orders.state(cid).is_none() {
350            return;
351        }
352        self.signal(cid, OrderSignal::Cancelled, now, out);
353    }
354
355    /// Registers a replace before the request is sent: the new cid is placed at most once, and it
356    /// inherits the instrument and side of the original when we know them.
357    pub fn begin_replace(
358        &mut self,
359        new_cid: ClientOrderId,
360        original: ClientOrderId,
361        now: Instant,
362    ) -> Result<(), Error> {
363        if !self.orders.try_place_once(new_cid) {
364            return Err(Error::Rejected {
365                code: "duplicate_place".into(),
366                message: "a cid is used for a placement or replace at most once".into(),
367            });
368        }
369        self.orders.register_place(new_cid, now);
370        if let Some(m) = self.meta.get(&original).cloned() {
371            self.meta.insert(
372                new_cid,
373                OrderMeta {
374                    exchange_id: None,
375                    filled: Qty::ZERO,
376                    ..m
377                },
378            );
379        }
380        Ok(())
381    }
382
383    /// The broker accepted a replace: the new cid is a new live order.
384    pub fn replace_response(
385        &mut self,
386        new_cid: ClientOrderId,
387        resp: &pb::PostOrderResponse,
388        now: Instant,
389        out: &mut EventSink,
390    ) {
391        let instrument = self.resolve(
392            &resp.instrument_uid,
393            &resp.figi,
394            &resp.ticker,
395            &resp.class_code,
396        );
397        let lot = self.lot_of(
398            &resp.instrument_uid,
399            &resp.figi,
400            &resp.ticker,
401            &resp.class_code,
402        );
403        let parts = order::parts_from_post(resp).with_lot(lot);
404        let meta = self.meta.entry(new_cid).or_insert_with(|| OrderMeta {
405            instrument,
406            side: order::side(resp.direction()),
407            order_type: order::order_type(resp.order_type()),
408            qty: parts.qty_requested(),
409            filled: Qty::ZERO,
410            price: None,
411            exchange_id: None,
412        });
413        meta.qty = parts.qty_requested();
414        meta.filled = parts.qty_executed();
415        if let Some(p) = resp
416            .initial_security_price
417            .as_ref()
418            .map(|m| Price::new(money_to_decimal(m)))
419            .filter(|p| !p.is_zero())
420        {
421            meta.price = Some(p);
422        }
423        self.place_response(new_cid, resp, now, out);
424    }
425
426    /// The replace failed with a definite refusal or an unknown outcome.
427    pub fn replace_failed(
428        &mut self,
429        new_cid: ClientOrderId,
430        unknown: bool,
431        reason: String,
432        now: Instant,
433        out: &mut EventSink,
434    ) {
435        let signal = if unknown {
436            OrderSignal::Timeout
437        } else {
438            OrderSignal::Rejected { reason }
439        };
440        self.signal(new_cid, signal, now, out);
441    }
442
443    /// Moves the placements that have had no answer for the command timeout to `Unknown`, one event each.
444    /// The connector calls it on a timer. It is the safety net: a command normally ends by itself, within
445    /// the request timeout.
446    pub fn sweep_timeouts(&mut self, now: Instant, out: &mut EventSink) {
447        for (cid, _) in self.orders.sweep_timeouts(now) {
448            self.emit_order(cid, now, out);
449        }
450        self.settle_pending_trades(out);
451    }
452
453    /// Sets how long a placement may wait for its answer before [`sweep_timeouts`](Self::sweep_timeouts)
454    /// gives it up. Keep it above the request timeout of the connector.
455    pub fn set_command_timeout(&mut self, timeout: std::time::Duration) {
456        self.orders.set_command_timeout(timeout);
457    }
458
459    /// In-flight placements become `Unknown` at shutdown, one event each.
460    pub fn abandon_inflight(&mut self, now: Instant, out: &mut EventSink) -> Vec<ClientOrderId> {
461        let abandoned = self.orders.abandon_inflight(now);
462        for cid in &abandoned {
463            self.emit_order(*cid, now, out);
464        }
465        self.settle_pending_trades(out);
466        abandoned
467    }
468
469    /// Exchange order id of a cid we know (placed here or seen on the stream).
470    pub fn exchange_id(&self, cid: ClientOrderId) -> Option<&str> {
471        self.meta.get(&cid).and_then(|m| m.exchange_id.as_deref())
472    }
473
474    /// Instrument of an order we know, for ordering commands per instrument.
475    pub fn instrument_of(&self, cid: ClientOrderId) -> Option<InstrumentId> {
476        self.meta.get(&cid).map(|m| m.instrument.clone())
477    }
478
479    /// Lots and price of an order we know, for building a replace without a round trip.
480    pub fn order_basis(&self, cid: ClientOrderId) -> Option<(Qty, Option<Price>)> {
481        self.meta.get(&cid).map(|m| (m.qty, m.price))
482    }
483
484    /// Learns ids from a `GetOrderState` / `GetOrders` answer.
485    pub fn note_order(&mut self, parts: &OrderParts) {
486        let cid = parts.cid();
487        self.learn_parts(
488            &instrument_uid(&parts.instrument),
489            "",
490            &parts.instrument.key.symbol,
491            &parts.instrument.key.mic,
492        );
493        if !parts.exchange_id.is_empty() {
494            self.by_exchange.insert(parts.exchange_id.clone(), cid);
495        }
496        let entry = self.meta.entry(cid).or_insert_with(|| OrderMeta {
497            instrument: parts.instrument.clone(),
498            side: order::side(parts.direction),
499            order_type: order::order_type(parts.order_type),
500            qty: parts.qty_requested(),
501            filled: parts.qty_executed(),
502            price: parts.unit_price,
503            exchange_id: None,
504        });
505        if !parts.exchange_id.is_empty() {
506            entry.exchange_id = Some(parts.exchange_id.clone());
507        }
508    }
509
510    // ----- own trades that came before their order was known -----
511
512    /// Sends the waiting trades of `order_id` with the cid that is known for it now.
513    fn release_trades(&mut self, order_id: &str, out: &mut EventSink) {
514        let Some(list) = self.pending_trades.remove(order_id) else {
515            return;
516        };
517        let cid = self.by_exchange.get(order_id).copied();
518        let sub = self.sub(UdSubKind::Executions);
519        for mut e in list {
520            e.cid = cid;
521            out.push_payload(sub, Event::Execution(e));
522        }
523    }
524
525    /// When no placement is in flight any more, nothing can claim the waiting trades: send them with
526    /// whatever cid is known (none for an order that is not ours).
527    fn settle_pending_trades(&mut self, out: &mut EventSink) {
528        if self.pending_trades.is_empty() || self.orders.in_flight_count() > 0 {
529            return;
530        }
531        let ids: Vec<String> = self.pending_trades.keys().cloned().collect();
532        for id in ids {
533            self.release_trades(&id, out);
534        }
535    }
536
537    // ----- order state machine -----
538
539    fn signal(
540        &mut self,
541        cid: ClientOrderId,
542        signal: OrderSignal,
543        now: Instant,
544        out: &mut EventSink,
545    ) {
546        let before = self.orders.state(cid).cloned();
547        if before.is_none() {
548            self.orders.register_place(cid, now);
549        }
550        self.orders.apply_signal(cid, signal, now);
551        if self.orders.state(cid) != before.as_ref() {
552            self.emit_order(cid, now, out);
553        }
554        self.settle_pending_trades(out);
555    }
556
557    fn emit_order(&mut self, cid: ClientOrderId, now: Instant, out: &mut EventSink) {
558        let (Some(state), Some(meta)) = (self.orders.state(cid).cloned(), self.meta.get(&cid))
559        else {
560            return;
561        };
562        let snap = OrderSnapshot::new(
563            cid,
564            meta.instrument.clone(),
565            state,
566            meta.side,
567            meta.order_type,
568            meta.qty,
569            Timestamps::new(crate::ids::wall_nanos(), now),
570        )
571        .with_broker_id(meta.exchange_id.clone().map(BrokerOrderId::new))
572        .with_filled(meta.filled)
573        .with_price(meta.price);
574        if snap.state.is_final() {
575            self.done.push(now, cid);
576            for old in self.done.expired(now) {
577                if let Some(m) = self.meta.remove(&old)
578                    && let Some(id) = m.exchange_id
579                {
580                    self.by_exchange.remove(&id);
581                }
582                self.forgotten.push(old);
583            }
584        }
585        out.push_payload(self.sub(UdSubKind::OrderState), Event::Order(snap));
586    }
587
588    /// Applies a status report (placement response, stream update, refresh) to the order.
589    fn apply_parts(
590        &mut self,
591        cid: ClientOrderId,
592        p: &OrderParts,
593        now: Instant,
594        out: &mut EventSink,
595    ) {
596        use pb::OrderExecutionReportStatus as S;
597        self.note_order(p);
598        if !p.exchange_id.is_empty() {
599            self.release_trades(&p.exchange_id, out);
600        }
601        if let Some(m) = self.meta.get_mut(&cid) {
602            m.filled = p.qty_executed();
603            if m.qty.is_zero() {
604                m.qty = p.qty_requested();
605            }
606            // For orders we placed, the price we sent is the truth (the stream's price field is unspecified).
607            if m.price.is_none() {
608                m.price = p.unit_price;
609            }
610        }
611        let before = self.orders.state(cid).cloned();
612        if before.is_none() {
613            self.orders.register_place(cid, now);
614        }
615        let broker_id = BrokerOrderId::new(p.exchange_id.clone());
616        let ack = OrderSignal::Ack { broker_id };
617        let signals: Vec<OrderSignal> = match p.status {
618            S::ExecutionReportStatusNew => vec![ack],
619            S::ExecutionReportStatusPartiallyfill => vec![
620                ack,
621                OrderSignal::PartialFill {
622                    filled: p.qty_executed(),
623                    left: p.qty_left(),
624                },
625            ],
626            S::ExecutionReportStatusFill => vec![ack, OrderSignal::Filled],
627            S::ExecutionReportStatusCancelled => vec![ack, OrderSignal::Cancelled],
628            S::ExecutionReportStatusRejected => vec![OrderSignal::Rejected {
629                reason: if p.reason.is_empty() {
630                    "REJECTED".into()
631                } else {
632                    p.reason.clone()
633                },
634            }],
635            S::ExecutionReportStatusUnspecified => vec![],
636        };
637        for s in signals {
638            // Invalid transitions (late duplicates) are ignored: the state machine keeps the newer state.
639            self.orders.apply_signal(cid, s, now);
640        }
641        if self.orders.state(cid) != before.as_ref() {
642            self.emit_order(cid, now, out);
643        }
644        self.settle_pending_trades(out);
645    }
646
647    // ----- inbound messages -----
648
649    fn on_order_state_stream(
650        &mut self,
651        r: pb::OrderStateStreamResponse,
652        now: Instant,
653        out: &mut EventSink,
654    ) {
655        match r.payload {
656            Some(OrderStreamPayload::OrderState(o)) => {
657                if self.other_account(&o.account_id) {
658                    return;
659                }
660                self.learn_parts(&o.instrument_uid, "", &o.ticker, &o.class_code);
661                // The stream message carries the lot size of the instrument: remember it.
662                self.lots.put(
663                    &o.instrument_uid,
664                    "",
665                    &o.ticker,
666                    &o.class_code,
667                    i64::from(o.lot_size),
668                );
669                let lot = self.lot_of(&o.instrument_uid, "", &o.ticker, &o.class_code);
670                let parts = order::parts_from_stream(&o).with_lot(lot);
671                let cid = parts.cid();
672                self.apply_parts(cid, &parts, now, out);
673            }
674            Some(OrderStreamPayload::Subscription(s)) => {
675                let ok = s.status() == pb::ResultSubscriptionStatus::Ok;
676                let reason = s
677                    .error
678                    .map(|e| format!("{}: {}", e.code, e.message))
679                    .unwrap_or_else(|| "subscription refused".into());
680                self.resolve_sub(UdSubKind::OrderState, ok, reason);
681            }
682            _ => {}
683        }
684    }
685
686    fn on_trades_stream(&mut self, r: pb::TradesStreamResponse, now: Instant, out: &mut EventSink) {
687        match r.payload {
688            Some(TradesPayload::OrderTrades(t)) => {
689                if self.other_account(&t.account_id) {
690                    return;
691                }
692                let instrument = self.resolve(&t.instrument_uid, &t.figi, "", "");
693                let cid = self.by_exchange.get(&t.order_id).copied();
694                let sub = self.sub(UdSubKind::Executions);
695                for e in execution::from_order_trades(&t, instrument, cid, now) {
696                    // The stream can be faster than the answer to `PostOrder`: the exchange order id is not
697                    // bound to a cid yet. While a placement is in flight such a fill waits for it, and gets
698                    // its cid when the answer comes (or none, when no placement claims it).
699                    if e.cid.is_none() && self.orders.in_flight_count() > 0 {
700                        self.pending_trades
701                            .entry(t.order_id.clone())
702                            .or_default()
703                            .push(e);
704                    } else {
705                        out.push_payload(sub, Event::Execution(e));
706                    }
707                }
708            }
709            Some(TradesPayload::Subscription(s)) => {
710                let ok = s.status() == pb::ResultSubscriptionStatus::Ok;
711                let reason = s
712                    .error
713                    .map(|e| format!("{}: {}", e.code, e.message))
714                    .unwrap_or_else(|| "subscription refused".into());
715                self.resolve_sub(UdSubKind::Executions, ok, reason);
716            }
717            _ => {}
718        }
719    }
720
721    fn cash_balances(&self, now: Instant) -> Vec<Balance> {
722        self.cash
723            .iter()
724            .map(|(currency, (available, blocked))| Balance {
725                currency: currency.clone(),
726                available: *available,
727                blocked: *blocked,
728                ts: stamps(now, None),
729            })
730            .collect()
731    }
732
733    fn set_cash(&mut self, balances: Vec<Balance>) {
734        for b in balances {
735            self.cash.insert(b.currency, (b.available, b.blocked));
736        }
737    }
738
739    fn on_portfolio_stream(
740        &mut self,
741        r: pb::PortfolioStreamResponse,
742        now: Instant,
743        out: &mut EventSink,
744    ) {
745        match r.payload {
746            Some(PortfolioPayload::Portfolio(p)) => {
747                if self.other_account(&p.account_id) {
748                    return;
749                }
750                for pos in &p.positions {
751                    self.learn_parts(&pos.instrument_uid, &pos.figi, &pos.ticker, &pos.class_code);
752                }
753                let snap = portfolio::portfolio(&p, self.cash_balances(now), now);
754                out.push_payload(self.sub(UdSubKind::Portfolio), Event::Portfolio(snap));
755            }
756            Some(PortfolioPayload::Subscriptions(s)) => {
757                let st = s
758                    .accounts
759                    .iter()
760                    .find(|a| a.account_id == self.account_id)
761                    .map(|a| a.subscription_status());
762                self.portfolio_parts.0 = st == Some(pb::PortfolioSubscriptionStatus::Success);
763                self.check_portfolio_ack(st.is_some(), "portfolio stream: account not subscribed");
764            }
765            _ => {}
766        }
767    }
768
769    fn on_positions_stream(
770        &mut self,
771        r: pb::PositionsStreamResponse,
772        now: Instant,
773        out: &mut EventSink,
774    ) {
775        let sub = self.sub(UdSubKind::Portfolio);
776        match r.payload {
777            Some(PositionsPayload::InitialPositions(p)) => {
778                if self.other_account(&p.account_id) {
779                    return;
780                }
781                self.set_cash(portfolio::balances_from_positions(&p, now));
782                out.push_payload(sub, Event::Balances(self.cash_balances(now)));
783            }
784            Some(PositionsPayload::Position(p)) => {
785                if self.other_account(&p.account_id) {
786                    return;
787                }
788                if !p.money.is_empty() {
789                    self.set_cash(portfolio::balances_from_stream(&p.money, now));
790                    out.push_payload(sub, Event::Balances(self.cash_balances(now)));
791                }
792                if !p.securities.is_empty() {
793                    let resolved: Vec<InstrumentId> = p
794                        .securities
795                        .iter()
796                        .map(|s| self.resolve(&s.instrument_uid, &s.figi, &s.ticker, &s.class_code))
797                        .collect();
798                    let positions =
799                        portfolio::positions_from_securities(&p.securities, resolved, now);
800                    out.push_payload(sub, Event::Positions(positions));
801                }
802            }
803            Some(PositionsPayload::Subscriptions(s)) => {
804                let st = s
805                    .accounts
806                    .iter()
807                    .find(|a| a.account_id == self.account_id)
808                    .map(|a| a.subscription_status());
809                self.portfolio_parts.1 = st
810                    == Some(
811                        pb::PositionsAccountSubscriptionStatus::PositionsSubscriptionStatusSuccess,
812                    );
813                self.check_portfolio_ack(st.is_some(), "positions stream: account not subscribed");
814            }
815            _ => {}
816        }
817    }
818
819    /// The portfolio subscription is two streams; it is confirmed when both accept the account.
820    fn check_portfolio_ack(&mut self, answered: bool, reason: &str) {
821        if !answered {
822            self.resolve_sub(UdSubKind::Portfolio, false, reason.to_string());
823        } else if self.portfolio_parts == (true, true) {
824            self.resolve_sub(UdSubKind::Portfolio, true, String::new());
825        }
826    }
827
828    fn resolve_sub(&mut self, kind: UdSubKind, ok: bool, reason: String) {
829        if let Some(id) = self.sub(kind) {
830            self.updates.push(SubUpdate {
831                sub: id,
832                result: if ok { Ok(()) } else { Err(reason) },
833            });
834        }
835    }
836
837    fn on_active_orders(&mut self, r: pb::GetOrdersResponse, now: Instant, out: &mut EventSink) {
838        for o in &r.orders {
839            self.learn_parts(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code);
840            let lot = self.lot_of(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code);
841            let parts = order::parts_from_state(o).with_lot(lot);
842            let cid = parts.cid();
843            self.apply_parts(cid, &parts, now, out);
844        }
845    }
846}
847
848fn instrument_uid(id: &InstrumentId) -> String {
849    match &id.native {
850        brokapi_core::NativeId::Uid(u) => u.clone(),
851        _ => String::new(),
852    }
853}
854
855impl BrokerCore for UdCore {
856    type Frame = UdFrame;
857
858    fn on_frame(&mut self, frame: UdFrame, now: Instant, out: &mut EventSink) {
859        match frame {
860            UdFrame::Connected(kind) => {
861                let n = self.connections.entry(kind).or_insert(0);
862                *n += 1;
863                if *n > 1 {
864                    self.reconnected.push_back(kind);
865                    // The streams of orders and fills carry money: whatever happened in the gap never
866                    // reached us. Tell the consumer (the emitter closes the trading gate on this event).
867                    let owner = match kind {
868                        StreamKind::OrderState => Some(UdSubKind::OrderState),
869                        StreamKind::Trades => Some(UdSubKind::Executions),
870                        _ => None,
871                    };
872                    if let Some(sub) = owner.and_then(|k| self.sub(k)) {
873                        out.push_payload(
874                            Some(sub),
875                            Event::Reconnected {
876                                sub,
877                                gap_from: crate::ids::wall_nanos(),
878                            },
879                        );
880                    }
881                }
882                match kind {
883                    StreamKind::Portfolio => self.portfolio_parts.0 = false,
884                    StreamKind::Positions => self.portfolio_parts.1 = false,
885                    _ => {}
886                }
887            }
888            UdFrame::OrderState(r) => self.on_order_state_stream(r, now, out),
889            UdFrame::Trades(r) => self.on_trades_stream(r, now, out),
890            UdFrame::Portfolio(r) => self.on_portfolio_stream(r, now, out),
891            UdFrame::Positions(r) => self.on_positions_stream(r, now, out),
892            UdFrame::ActiveOrders(r) => self.on_active_orders(r, now, out),
893            UdFrame::StreamFailed(kind, reason) => {
894                let sub = match kind {
895                    StreamKind::OrderState => UdSubKind::OrderState,
896                    StreamKind::Trades => UdSubKind::Executions,
897                    StreamKind::Portfolio | StreamKind::Positions => UdSubKind::Portfolio,
898                };
899                self.resolve_sub(sub, false, reason);
900            }
901        }
902    }
903
904    /// Streams are server-side and their requests are fixed: nothing to transmit.
905    fn poll_transmit(&mut self) -> Option<Outbound> {
906        None
907    }
908
909    fn poll_timeout(&self) -> Option<Instant> {
910        None
911    }
912
913    fn on_timeout(&mut self, _now: Instant, _out: &mut EventSink) {}
914
915    /// Takes the order-state and portfolio subscriptions from the set (executions: [`UdCore::add_sub`]).
916    fn apply(&mut self, desired: &SubscriptionSet) {
917        self.subs.remove(&UdSubKind::OrderState);
918        self.subs.remove(&UdSubKind::Portfolio);
919        for d in desired.iter() {
920            match d.kind {
921                SubscriptionKind::OrderState => {
922                    self.subs.insert(UdSubKind::OrderState, d.id);
923                }
924                SubscriptionKind::Portfolio => {
925                    self.subs.insert(UdSubKind::Portfolio, d.id);
926                }
927                _ => {}
928            }
929        }
930    }
931}