Skip to main content

brokapi_finam/
ud.rs

1//! Finam user data connector: [`UserDataConnector`]. One connector = one account.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use async_trait::async_trait;
8use brokapi_core::{
9    Backpressure, Balance, ClientOrderId, Envelope, Error, Execution, ExecutionSource, OrderAck,
10    OrderExecutor, OrderSnapshot, PlaceIdempotency, PlaceOrderRequest, PlaceStopOrderRequest,
11    Portfolio, PortfolioSource, Position, ReconcileReport, ReplaceOrderRequest, ShutdownMode,
12    ShutdownReport, StopOrderExecutor, StopOrderRef, StopOrderSnapshot, StopOrderState,
13    SubscribeOpts, SubscriptionId, TimeRange, UserDataCaps,
14};
15use brokapi_finam_protocol::pb::google::r#type::Interval;
16use brokapi_finam_protocol::pb::grpc::tradeapi::v1::accounts as pa;
17use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders as po;
18use brokapi_finam_protocol::{FinamApi, FinamError};
19use brokapi_runtime::{
20    BackpressureManager, BrokerLimiter, CommandGate, InstrumentLocks, PriceGrid,
21    ShutdownController, reconcile_ledger,
22};
23use brokapi_sansio::{BrokerCore, CoreEvent, EventSink};
24use tokio::sync::{mpsc, oneshot};
25use tokio::task::JoinHandle;
26use tracing::warn;
27
28use crate::cid::FinamCidCodec;
29use crate::config::UserDataConfig;
30use crate::core::ud::UdSubKind;
31use crate::core::{StreamEvent, SubStatus, UdCore, UdEvent, UdFrame};
32use crate::error::{map_command_err, map_protocol_err, unsupported};
33use crate::index::OrderIndex;
34use crate::map::order::{build_place_order, is_active, rejection_of};
35use crate::map::portfolio::{map_account, map_trade};
36use crate::map::stop::{self as map_stop, StopWire};
37use crate::pump::{PumpIo, SharedBackpressure, run_pump};
38use crate::raw::FinamRaw;
39use crate::rpc::Rpc;
40use crate::stream::{OpenFn, StreamHandle, StreamParams, spawn_stream};
41use crate::subs::SubIdGen;
42use crate::util::{no_instrument, timestamps, to_timestamp};
43use brokapi_sansio::OrderSignal;
44
45type UdWrap<M> = Arc<dyn Fn(StreamEvent<M>) -> UdFrame + Send + Sync>;
46
47/// The cids of stop orders already sent, bounded: the oldest are forgotten after [`SENT_CIDS_CAP`] newer
48/// ones, so the guarantee "a cid is sent once" holds inside that window.
49#[derive(Default)]
50struct SentCids {
51    set: std::collections::HashSet<ClientOrderId>,
52    order: std::collections::VecDeque<ClientOrderId>,
53}
54
55/// How many stop-order cids the connector remembers as sent.
56const SENT_CIDS_CAP: usize = 10_000;
57
58impl SentCids {
59    /// `false` when the cid is there already.
60    fn insert(&mut self, cid: ClientOrderId) -> bool {
61        if !self.set.insert(cid) {
62            return false;
63        }
64        self.order.push_back(cid);
65        while self.order.len() > SENT_CIDS_CAP {
66            if let Some(old) = self.order.pop_front() {
67                self.set.remove(&old);
68            }
69        }
70        true
71    }
72
73    fn remove(&mut self, cid: &ClientOrderId) {
74        self.set.remove(cid);
75    }
76}
77
78struct UdShared {
79    rpc: Rpc,
80    account_id: String,
81    core: Arc<Mutex<UdCore>>,
82    index: Arc<OrderIndex>,
83    /// Commands on one instrument go one at a time, in arrival order.
84    locks: InstrumentLocks,
85    price_grid: PriceGrid,
86    /// Cids of stop orders already sent: the library never sends the same one twice.
87    stops_sent: Mutex<SentCids>,
88    backpressure: SharedBackpressure,
89    streams: Arc<Mutex<HashMap<SubscriptionId, StreamHandle>>>,
90    frames: mpsc::Sender<UdFrame>,
91    sub_ids: SubIdGen,
92    shutdown: Arc<Mutex<ShutdownController>>,
93    order_state_buffer: usize,
94    executions_limit: i32,
95    ack_timeout: Duration,
96}
97
98impl UdShared {
99    fn signal(&self, cid: ClientOrderId, signal: OrderSignal) {
100        self.core
101            .lock()
102            .expect("core poisoned")
103            .orders()
104            .apply_signal(cid, signal, brokapi_runtime::now());
105    }
106
107    /// Folds a failed command into the public error and the local order lifecycle.
108    fn command_failed(&self, cid: ClientOrderId, e: &FinamError) -> Error {
109        let err = map_command_err(e);
110        match &err {
111            Error::Unknown => self.signal(cid, OrderSignal::Timeout),
112            other => self.signal(
113                cid,
114                OrderSignal::Rejected {
115                    reason: other.to_string(),
116                },
117            ),
118        }
119        if matches!(err, Error::Unknown) {
120            warn!(?cid, error = %e, "place outcome unknown");
121        }
122        err
123    }
124}
125
126/// Finam user data connector for ONE account: orders, stop orders, own trades and the portfolio.
127///
128/// It implements [`OrderExecutor`], [`StopOrderExecutor`], [`ExecutionSource`] and
129/// [`PortfolioSource`]. To trade two accounts, start two connectors (they may share one token
130/// manager and must share one rate limiter).
131///
132/// Rules that protect your money:
133///
134/// - **Call `reconcile` first.** `place`, `cancel`, `place_stop` and `cancel_stop` return
135///   `Error::NotReconciled` until `reconcile` has succeeded once. Pass the cids you know from before the
136///   restart (an empty list at a clean start).
137/// - **A place command is sent once.** If it gets no answer in time, the result is `Error::Unknown`
138///   and the library does not repeat it, because a repeat could create a second order. Ask
139///   `order_state` or `active_orders` to see what happened. Sending the same cid again is refused
140///   locally with `Error::Rejected` and code `duplicate_place`.
141/// - **A refusal is final.** `Error::Rejected` means the broker took nothing. Use a NEW cid for the
142///   next try.
143/// - **The gate is checked twice.** Once at the start, and again when the command's turn in the queue of its
144///   instrument comes: a gate that closed while it waited (`Degraded`, shutdown) stops it, and nothing is sent.
145/// - **A cut of the order or trade stream is told.** `Event::Reconnected` on the subscription, the gate closes
146///   until `reconcile`.
147/// - **The cid travels in the order `comment`** (`bc1:` and 32 hex digits), so it survives a restart.
148///   See [`FinamCidCodec`]. That the broker echoes the comment unchanged is not verified against the
149///   live API.
150/// - **No replace.** Finam has no amend call. `replace` returns `Error::Unsupported`. Cancel plus a new
151///   order is not offered, because it is not atomic.
152/// - **Quantity is in pieces** (units of the instrument, like at every broker), price in the instrument currency. A quantity
153///   that is not a whole number of lots is refused with `invalid_qty` before anything is sent, when the lot is known
154///   (`price_grid().set_lot(..)` or `.learn(&params)`).
155/// - **Finam has no sandbox.** Test against the mock, then with a tiny order far from the market.
156///
157/// # Examples
158///
159/// Place, look at the order and cancel it. Then a refusal and a stop order. The broker is the
160/// in-process imitation, which refuses a limit order at `REJECT_PRICE`:
161///
162/// ```
163/// use brokapi_conformance::REJECT_PRICE;
164/// use brokapi_core::{
165///     ClientOrderId, Error, InstrumentId, InstrumentKey, NativeId, OrderExecutor, OrderState, OrderType,
166///     PlaceOrderRequest, PlaceStopOrderRequest, Price, Qty, ShutdownMode, Side, StopExpiry,
167///     StopOrderExecutor, StopOrderKind, StopOrderRef,
168/// };
169/// use brokapi_finam::limits::default_limits;
170/// use brokapi_finam::{UserDataConfig, UserDataConnector};
171/// use brokapi_runtime::BrokerLimiter;
172/// use tokio::sync::mpsc;
173/// # use brokapi_finam::FinamApiConfig;
174/// # use brokapi_mocks::finam::{MockServer, SECRET};
175///
176/// # #[tokio::main(flavor = "current_thread")]
177/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
178/// # let server = MockServer::start().await;
179/// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
180/// # let account = "ACC1";
181/// // `api`: your FinamApiConfig. `account`: the account id, as Finam writes it.
182/// let (events_tx, _events) = mpsc::channel(256);
183/// let limiter = BrokerLimiter::new(&default_limits());
184/// let ud = UserDataConnector::start(UserDataConfig::new(api, account, "finam-ud"), limiter, events_tx).await?;
185///
186/// // Before reconcile every command is refused and nothing is sent.
187/// let sber = InstrumentId::new(
188///     InstrumentKey::new("SBER", "MISX"),
189///     NativeId::SymbolMic { symbol: "SBER".into(), mic: "MISX".into() },
190/// );
191/// let limit_buy = |price: Price| PlaceOrderRequest {
192///     cid: ClientOrderId::new_v4(),
193///     instrument: sber.clone(),
194///     side: Side::Buy,
195///     order_type: OrderType::Limit,
196///     qty: Qty::from(1),
197///     price: Some(price),
198/// };
199/// assert!(matches!(ud.place(limit_buy(Price::from(1))).await, Err(Error::NotReconciled)));
200///
201/// ud.reconcile(&[]).await?;
202///
203/// // Place, read back, cancel.
204/// let req = limit_buy(Price::from(1));
205/// let cid = req.cid;
206/// let ack = ud.place(req).await?;
207/// assert_eq!(ack.cid, cid);
208/// let snapshot = ud.order_state(cid).await?;
209/// assert!(!matches!(snapshot.state, OrderState::Rejected { .. }));
210/// ud.cancel(cid).await?;
211///
212/// // The broker says no: a clean refusal. Nothing is open at the broker. Use a new cid to try again.
213/// match ud.place(limit_buy(REJECT_PRICE)).await {
214///     Err(Error::Rejected { code, .. }) => assert_eq!(code, "invalid_argument"),
215///     other => panic!("expected a refusal, got {other:?}"),
216/// }
217///
218/// // A stop-loss: a SELL that triggers when the price falls to 100.
219/// let stop = PlaceStopOrderRequest {
220///     cid: ClientOrderId::new_v4(),
221///     instrument: sber,
222///     side: Side::Sell,
223///     kind: StopOrderKind::StopLoss,
224///     qty: Qty::from(5),
225///     stop_price: Price::from(100),
226///     limit_price: None,
227///     expiry: StopExpiry::GoodTillCancel,
228/// };
229/// let stop_cid = stop.cid;
230/// ud.place_stop(stop).await?;
231/// assert_eq!(ud.active_stop_orders().await?.len(), 1);
232/// ud.cancel_stop(StopOrderRef::Cid(stop_cid)).await?;
233/// assert!(ud.active_stop_orders().await?.is_empty());
234///
235/// ud.shutdown(ShutdownMode::Immediate).await;
236/// # Ok(())
237/// # }
238/// ```
239///
240/// Own orders as events. The connector keeps a lossless buffer for this subscription, so no order
241/// event is lost. When the buffer overflows, `Event::Overflow` arrives and trading is refused until
242/// `reconcile`:
243///
244/// ```
245/// use std::time::Duration;
246/// use brokapi_core::{
247///     ClientOrderId, Event, InstrumentId, InstrumentKey, NativeId, OrderExecutor, OrderType,
248///     PlaceOrderRequest, Price, Qty, ShutdownMode, Side,
249/// };
250/// use brokapi_finam::limits::default_limits;
251/// use brokapi_finam::{UserDataConfig, UserDataConnector};
252/// use brokapi_runtime::BrokerLimiter;
253/// use tokio::sync::mpsc;
254/// # use brokapi_finam::FinamApiConfig;
255/// # use brokapi_mocks::finam::{MockServer, SECRET};
256///
257/// # #[tokio::main(flavor = "current_thread")]
258/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
259/// # let server = MockServer::start().await;
260/// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
261/// let (events_tx, mut events) = mpsc::channel(256);
262/// let ud = UserDataConnector::start(
263///     UserDataConfig::new(api, "ACC1", "finam-ud"),
264///     BrokerLimiter::new(&default_limits()),
265///     events_tx,
266/// )
267/// .await?;
268/// ud.reconcile(&[]).await?;
269/// let sub = ud.subscribe_order_state().await?;
270///
271/// let cid = ClientOrderId::new_v4();
272/// ud.place(PlaceOrderRequest {
273///     cid,
274///     instrument: InstrumentId::new(
275///         InstrumentKey::new("SBER", "MISX"),
276///         NativeId::SymbolMic { symbol: "SBER".into(), mic: "MISX".into() },
277///     ),
278///     side: Side::Buy,
279///     order_type: OrderType::Limit,
280///     qty: Qty::from(1),
281///     price: Some(Price::from(1)),
282/// })
283/// .await?;
284///
285/// // Wait for the event of OUR order, with a timeout, never a sleep.
286/// let ours = tokio::time::timeout(Duration::from_secs(5), async {
287///     while let Some(envelope) = events.recv().await {
288///         if let Event::Order(order) = envelope.payload {
289///             if order.cid == cid {
290///                 return Some(envelope.sub);
291///             }
292///         }
293///     }
294///     None
295/// })
296/// .await?;
297/// assert_eq!(ours, Some(Some(sub)));
298///
299/// ud.cancel(cid).await?;
300/// ud.shutdown(ShutdownMode::Immediate).await;
301/// # Ok(())
302/// # }
303/// ```
304pub struct UserDataConnector {
305    inner: Arc<UdShared>,
306    pump: JoinHandle<()>,
307}
308
309impl UserDataConnector {
310    /// Checks the config and the cid codec, logs in, and starts the event pump. Streams are opened later,
311    /// by the `subscribe_*` methods.
312    ///
313    /// The connector starts NOT reconciled: commands are refused until you call
314    /// [`reconcile`](OrderExecutor::reconcile). `limiter` is the rate limiter of your Finam user,
315    /// shared by every connector. Events go to `out`.
316    ///
317    /// When the token lists its accounts and `cfg.account_id` is not one of them, start fails.
318    ///
319    /// # Errors
320    ///
321    /// - [`Error::Config`]: empty account id, empty secret, invalid endpoint URL, the cid codec does
322    ///   not fit the broker limits, or the account is not available to this secret.
323    /// - [`Error::Config`] also: the broker refused the secret (a wrong token is a configuration problem at every broker).
324    /// - [`Error::RateLimited`]: the broker answered `RESOURCE_EXHAUSTED` to the login.
325    /// - [`Error::Transport`]: the broker could not be reached.
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// use brokapi_core::{Error, ShutdownMode};
331    /// use brokapi_finam::limits::default_limits;
332    /// use brokapi_finam::{FinamApiConfig, UserDataConfig, UserDataConnector};
333    /// use brokapi_runtime::BrokerLimiter;
334    /// use tokio::sync::mpsc;
335    /// # use brokapi_mocks::finam::{MockServer, SECRET};
336    ///
337    /// # #[tokio::main(flavor = "current_thread")]
338    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
339    /// # let server = MockServer::start().await;
340    /// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
341    /// let limiter = BrokerLimiter::new(&default_limits());
342    /// let (events, _rx) = mpsc::channel(256);
343    ///
344    /// let ud = UserDataConnector::start(UserDataConfig::new(api.clone(), "ACC1", "finam-ud"), limiter.clone(), events.clone()).await?;
345    /// assert_eq!(ud.account_id(), "ACC1");
346    /// ud.shutdown(ShutdownMode::Immediate).await;
347    ///
348    /// // An account that the secret cannot reach is a configuration error.
349    /// let err = UserDataConnector::start(UserDataConfig::new(api, "NOT-MINE", "finam-ud"), limiter, events).await.err().unwrap();
350    /// assert!(matches!(err, Error::Config(_)));
351    /// # Ok(())
352    /// # }
353    /// ```
354    pub async fn start(
355        cfg: UserDataConfig,
356        limiter: Arc<BrokerLimiter>,
357        out: mpsc::Sender<Envelope>,
358    ) -> Result<Self, Error> {
359        cfg.check()?;
360        // A lossy cid format is a configuration error at startup, not a runtime degradation.
361        FinamCidCodec.validate()?;
362
363        let tokens = cfg.api.resolve_tokens().await?;
364        let known = tokens.account_ids();
365        if !known.is_empty() && !known.iter().any(|a| a == &cfg.account_id) {
366            return Err(Error::Config(brokapi_core::ConfigError::Message(format!(
367                "account {} is not available to this secret (token lists {} account(s))",
368                cfg.account_id,
369                known.len()
370            ))));
371        }
372        let api = FinamApi::connect(&cfg.api.endpoint, tokens, cfg.api.bearer)
373            .map_err(|e| map_protocol_err(&e))?;
374
375        let (frames, rx) = mpsc::channel::<UdFrame>(1024);
376        let index = Arc::new(OrderIndex::new());
377        let core = Arc::new(Mutex::new(UdCore::new(Arc::clone(&index))));
378        // A placement is given up after its request has certainly ended.
379        core.lock()
380            .expect("core poisoned")
381            .orders()
382            .set_command_timeout(cfg.api.request_timeout + Duration::from_secs(10));
383        let backpressure: SharedBackpressure = Arc::new(Mutex::new(BackpressureManager::new()));
384        let shutdown = Arc::new(Mutex::new(ShutdownController::new(CommandGate::new())));
385        let streams: Arc<Mutex<HashMap<SubscriptionId, StreamHandle>>> = Arc::default();
386
387        let handler = {
388            let core = Arc::clone(&core);
389            let shutdown = Arc::clone(&shutdown);
390            move |frame: UdFrame, now: Instant| -> Vec<CoreEvent> {
391                let mut sink = EventSink::new();
392                core.lock()
393                    .expect("core poisoned")
394                    .on_frame(frame, now, &mut sink);
395                let events = sink.drain();
396                // A stream of orders or fills was cut and reopened: events were lost in the gap. Close
397                // the trading gate before the notice reaches the consumer, so a command that follows the
398                // notice is already refused.
399                if events
400                    .iter()
401                    .any(|e| matches!(e.payload, brokapi_core::Event::Reconnected { .. }))
402                {
403                    shutdown
404                        .lock()
405                        .expect("shutdown poisoned")
406                        .gate_mut()
407                        .mark_degraded();
408                }
409                events
410            }
411        };
412        // Spec 6: a Lossless overflow degrades the connector: trading commands are refused until
413        // `reconcile` succeeds.
414        let on_overflow = {
415            let shutdown = Arc::clone(&shutdown);
416            Arc::new(move |_sub: SubscriptionId| {
417                shutdown
418                    .lock()
419                    .expect("shutdown poisoned")
420                    .gate_mut()
421                    .mark_degraded();
422            })
423        };
424        let cancel = shutdown.lock().expect("shutdown poisoned").child_token();
425        let pump = tokio::spawn(run_pump(
426            PumpIo {
427                rx,
428                out,
429                source: Arc::clone(&cfg.source),
430                backpressure: Arc::clone(&backpressure),
431                cancel,
432            },
433            handler,
434            on_overflow,
435        ));
436
437        let inner = Arc::new(UdShared {
438            rpc: Rpc {
439                api,
440                limiter,
441                timeout: cfg.api.request_timeout,
442            },
443            account_id: cfg.account_id,
444            core,
445            index,
446            locks: InstrumentLocks::new(),
447            price_grid: cfg.price_grid.clone(),
448            stops_sent: Mutex::default(),
449            backpressure,
450            streams,
451            frames,
452            sub_ids: SubIdGen::new(1000),
453            shutdown,
454            order_state_buffer: cfg.order_state_buffer,
455            executions_limit: cfg.executions_limit,
456            ack_timeout: cfg.subscribe_ack_timeout,
457        });
458        // The safety net for a placement whose task ended without an answer (it normally cannot: see
459        // `place`), and the pruning of what is kept for orders that finished long ago.
460        {
461            let (weak, cancel) = (
462                Arc::downgrade(&inner),
463                inner
464                    .shutdown
465                    .lock()
466                    .expect("shutdown poisoned")
467                    .child_token(),
468            );
469            tokio::spawn(async move {
470                let mut tick = tokio::time::interval(Duration::from_secs(5));
471                tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
472                loop {
473                    tokio::select! {
474                        _ = cancel.cancelled() => return,
475                        _ = tick.tick() => {}
476                    }
477                    let Some(inner) = weak.upgrade() else { return };
478                    let now = brokapi_runtime::now();
479                    let forgotten = {
480                        let mut core = inner.core.lock().expect("core poisoned");
481                        core.orders().sweep_timeouts(now);
482                        core.take_forgotten()
483                    };
484                    for cid in forgotten {
485                        inner.locks.forget(cid);
486                    }
487                }
488            });
489        }
490        Ok(Self { inner, pump })
491    }
492
493    /// How many commands are in flight: sent, and the answer has not been taken yet. Zero when all of them
494    /// are settled. A `place` runs to its end even if the caller drops its future, so this number goes back
495    /// to zero by itself. For health checks and tests.
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// use brokapi_finam::limits::default_limits;
501    /// use brokapi_finam::{FinamApiConfig, UserDataConfig, UserDataConnector};
502    /// use brokapi_core::ShutdownMode;
503    /// use brokapi_runtime::BrokerLimiter;
504    /// # use brokapi_mocks::finam::{MockServer, SECRET};
505    ///
506    /// # #[tokio::main(flavor = "current_thread")]
507    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
508    /// # let server = MockServer::start().await;
509    /// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
510    /// let (events_tx, _events) = tokio::sync::mpsc::channel(16);
511    /// let ud = UserDataConnector::start(UserDataConfig::new(api, "ACC1", "finam-ud"), BrokerLimiter::new(&default_limits()), events_tx).await?;
512    /// assert_eq!(ud.in_flight_orders(), 0);
513    /// ud.shutdown(ShutdownMode::Immediate).await;
514    /// # Ok(())
515    /// # }
516    /// ```
517    pub fn in_flight_orders(&self) -> usize {
518        self.inner
519            .core
520            .lock()
521            .expect("core poisoned")
522            .orders()
523            .in_flight_count()
524    }
525
526    /// Account this connector trades and reads.
527    ///
528    /// # Examples
529    ///
530    /// See [`start`](Self::start).
531    pub fn account_id(&self) -> &str {
532        &self.inner.account_id
533    }
534
535    /// Any other method of the Finam API with this connector's token, endpoint and rate limiter.
536    /// `account_id()` of the result is this connector's account. See [`FinamRaw`].
537    ///
538    /// Orders placed through `raw()` are not tracked by this connector: no events, no `reconcile`.
539    /// Use the shared traits for orders.
540    ///
541    /// # Examples
542    ///
543    /// ```
544    /// use brokapi_core::ShutdownMode;
545    /// use brokapi_finam::limits::default_limits;
546    /// use brokapi_finam::{FinamApiConfig, UserDataConfig, UserDataConnector};
547    /// use brokapi_runtime::BrokerLimiter;
548    /// use tokio::sync::mpsc;
549    /// # use brokapi_mocks::finam::{MockServer, SECRET};
550    ///
551    /// # #[tokio::main(flavor = "current_thread")]
552    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
553    /// # let server = MockServer::start().await;
554    /// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
555    /// # let (events, _rx) = mpsc::channel(16);
556    /// let ud = UserDataConnector::start(UserDataConfig::new(api, "ACC1", "finam-ud"), BrokerLimiter::new(&default_limits()), events).await?;
557    /// assert_eq!(ud.raw().account_id(), Some("ACC1"));
558    /// # ud.shutdown(ShutdownMode::Immediate).await;
559    /// # Ok(())
560    /// # }
561    /// ```
562    pub fn raw(&self) -> FinamRaw {
563        FinamRaw::new(self.inner.rpc.clone(), Some(self.inner.account_id.clone()))
564    }
565
566    /// The price steps this connector checks order prices against. Fill it from
567    /// [`TradingParams`](brokapi_core::TradingParams) (`grid.learn(&params)`) to have a price off the
568    /// step refused before anything is sent. See [`PriceGrid`].
569    pub fn price_grid(&self) -> &PriceGrid {
570        &self.inner.price_grid
571    }
572
573    /// Capabilities.
574    ///
575    /// - `place_idempotency` is `LabelOnly` until a live experiment proves otherwise. The `client_order_id` is a label for us, nothing says Finam rejects or merges a
576    ///   repeat of it.
577    /// - No replace call exists in the Finam API.
578    /// - `supports_stop_orders` is true: stop-loss and stop-limit go through `PlaceOrder` (STOP,
579    ///   STOP_LIMIT), take-profit through `PlaceSLTPOrder`, see [`StopOrderExecutor`]. Not verified
580    ///   against the live API.
581    /// - Executions stream (`SubscribeTrades`) exists. Portfolio also streams (`SubscribeAccount`).
582    /// - Finam Trade API has no sandbox.
583    ///
584    /// # Examples
585    ///
586    /// ```
587    /// use brokapi_core::{PlaceIdempotency, ShutdownMode};
588    /// use brokapi_finam::limits::default_limits;
589    /// use brokapi_finam::{FinamApiConfig, UserDataConfig, UserDataConnector};
590    /// use brokapi_runtime::BrokerLimiter;
591    /// use tokio::sync::mpsc;
592    /// # use brokapi_mocks::finam::{MockServer, SECRET};
593    ///
594    /// # #[tokio::main(flavor = "current_thread")]
595    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
596    /// # let server = MockServer::start().await;
597    /// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
598    /// # let (events, _rx) = mpsc::channel(16);
599    /// # let ud = UserDataConnector::start(UserDataConfig::new(api, "ACC1", "finam-ud"), BrokerLimiter::new(&default_limits()), events).await?;
600    /// let caps = ud.caps();
601    /// assert!(!caps.supports_replace);
602    /// assert!(caps.supports_stop_orders);
603    /// assert_eq!(caps.place_idempotency, PlaceIdempotency::LabelOnly);
604    /// # ud.shutdown(ShutdownMode::Immediate).await;
605    /// # Ok(())
606    /// # }
607    /// ```
608    pub fn caps(&self) -> UserDataCaps {
609        // `supports_replace` is false: Finam has no amend call. Cancel + new order is not an emulation we
610        // offer: it is not atomic (the old order can fill between the two, or the new one be refused
611        // after the cancel).
612        UserDataCaps::new(
613            PlaceIdempotency::LabelOnly,
614            false,
615            false,
616            false,
617            true,
618            true,
619            false,
620        )
621    }
622
623    /// State of a subscription's stream (connecting, active, reconnecting, failed).
624    ///
625    /// `None` for an id this connector does not know. Use it after `subscribe_*` returned and the
626    /// stream may still be opening (Finam may send the headers of a quiet stream only with its first message).
627    /// A stream that the broker refuses (no permission) shows as
628    /// [`SubStatus::Failed`] and is retried slowly.
629    ///
630    /// # Examples
631    ///
632    /// ```
633    /// use std::time::Duration;
634    /// use brokapi_core::{OrderExecutor, ShutdownMode};
635    /// use brokapi_finam::limits::default_limits;
636    /// use brokapi_finam::{FinamApiConfig, SubStatus, UserDataConfig, UserDataConnector};
637    /// use brokapi_runtime::BrokerLimiter;
638    /// use tokio::sync::mpsc;
639    /// # use brokapi_mocks::finam::{MockServer, SECRET};
640    ///
641    /// # #[tokio::main(flavor = "current_thread")]
642    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
643    /// # let server = MockServer::start().await;
644    /// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
645    /// # let (events, _rx) = mpsc::channel(16);
646    /// # let ud = UserDataConnector::start(UserDataConfig::new(api, "ACC1", "finam-ud"), BrokerLimiter::new(&default_limits()), events).await?;
647    /// let sub = ud.subscribe_order_state().await?;
648    /// // Registered at once. Active when the stream is open.
649    /// let status = tokio::time::timeout(Duration::from_secs(5), async {
650    ///     loop {
651    ///         match ud.sub_status(sub) {
652    ///             Some(SubStatus::Active) => return SubStatus::Active,
653    ///             _ => tokio::time::sleep(Duration::from_millis(20)).await,
654    ///         }
655    ///     }
656    /// })
657    /// .await?;
658    /// assert_eq!(status, SubStatus::Active);
659    /// # ud.shutdown(ShutdownMode::Immediate).await;
660    /// # Ok(())
661    /// # }
662    /// ```
663    pub fn sub_status(&self, sub: SubscriptionId) -> Option<SubStatus> {
664        self.inner.core.lock().expect("core poisoned").status(sub)
665    }
666
667    /// Stops the connector in the order that protects your orders.
668    ///
669    /// 1. New commands are refused (`Error::ShuttingDown`).
670    /// 2. Every stream is cancelled (the gRPC way of unsubscribing). `Graceful` waits for each up to
671    ///    `deadline`, `Immediate` aborts them.
672    /// 3. Commands still in flight are abandoned and listed in the report (`inflight_abandoned`).
673    ///    Their orders may exist at the broker. Read them with `active_orders` after you start again.
674    /// 4. The event pump stops.
675    ///
676    /// Orders already on the exchange stay there. Shutdown does NOT cancel them.
677    ///
678    /// # Examples
679    ///
680    /// ```
681    /// use std::time::Duration;
682    /// use brokapi_core::{Error, OrderExecutor, ShutdownMode};
683    /// use brokapi_finam::limits::default_limits;
684    /// use brokapi_finam::{FinamApiConfig, UserDataConfig, UserDataConnector};
685    /// use brokapi_runtime::BrokerLimiter;
686    /// use tokio::sync::mpsc;
687    /// # use brokapi_mocks::finam::{MockServer, SECRET};
688    ///
689    /// # #[tokio::main(flavor = "current_thread")]
690    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
691    /// # let server = MockServer::start().await;
692    /// # let api = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
693    /// # let (events, _rx) = mpsc::channel(16);
694    /// let ud = UserDataConnector::start(UserDataConfig::new(api, "ACC1", "finam-ud"), BrokerLimiter::new(&default_limits()), events).await?;
695    /// ud.reconcile(&[]).await?;
696    /// ud.subscribe_order_state().await?;
697    ///
698    /// let report = ud.shutdown(ShutdownMode::Graceful { deadline: Duration::from_secs(2) }).await;
699    /// assert!(report.inflight_abandoned.is_empty());
700    /// # Ok(())
701    /// # }
702    /// ```
703    pub async fn shutdown(self, mode: ShutdownMode) -> ShutdownReport {
704        // Step 1: no new commands.
705        self.inner
706            .shutdown
707            .lock()
708            .expect("shutdown poisoned")
709            .gate_mut()
710            .begin_shutdown();
711        // Steps 2-3: unsubscribe = cancel the gRPC streams.
712        let handles: Vec<StreamHandle> = self
713            .inner
714            .streams
715            .lock()
716            .expect("streams poisoned")
717            .drain()
718            .map(|(_, h)| h)
719            .collect();
720        let (mut ok, mut failed) = (0usize, 0usize);
721        match mode {
722            ShutdownMode::Graceful { deadline } => {
723                let started = brokapi_runtime::now();
724                for h in handles {
725                    let left = deadline.saturating_sub(started.elapsed());
726                    if h.stop(left).await {
727                        ok += 1;
728                    } else {
729                        failed += 1;
730                    }
731                }
732            }
733            _ => handles.into_iter().for_each(StreamHandle::abort),
734        }
735        // Step 4-6: abandon in-flight commands (exchange orders stay), cancel the root token.
736        let report = {
737            let mut shutdown = self.inner.shutdown.lock().expect("shutdown poisoned");
738            let mut core = self.inner.core.lock().expect("core poisoned");
739            shutdown.shutdown_local(mode, brokapi_runtime::now(), core.orders(), || (ok, failed))
740        };
741        let _ = self.pump.await;
742        report
743    }
744
745    fn check_command(&self) -> Result<(), Error> {
746        self.inner
747            .shutdown
748            .lock()
749            .expect("shutdown poisoned")
750            .gate()
751            .check_command()
752    }
753
754    fn account(&self) -> String {
755        self.inner.account_id.clone()
756    }
757
758    /// Orders of the account (all statuses Finam returns), cids resolved, local lifecycle updated.
759    async fn fetch_orders(&self) -> Result<Vec<(po::OrderState, OrderSnapshot)>, Error> {
760        let account = self.account();
761        let resp = self
762            .inner
763            .rpc
764            .read("GetOrders", |api| {
765                let req = po::OrdersRequest {
766                    account_id: account.clone(),
767                };
768                async move { api.orders().get_orders(req).await }
769            })
770            .await?;
771        let now = brokapi_runtime::now();
772        let mut core = self.inner.core.lock().expect("core poisoned");
773        Ok(resp
774            .orders
775            .into_iter()
776            .map(|os| {
777                let snap = core.ingest_order(&os, now);
778                (os, snap)
779            })
780            .collect())
781    }
782
783    /// Finam order id of a cid: from the index, else from the account's order list.
784    async fn order_id_of(&self, cid: ClientOrderId) -> Result<String, Error> {
785        if let Some(id) = self.inner.index.order_id(cid) {
786            return Ok(id);
787        }
788        self.inner.index.know(cid);
789        self.fetch_orders().await?;
790        self.inner
791            .index
792            .order_id(cid)
793            .ok_or_else(|| Error::not_found(format!("order {cid} not found at the broker")))
794    }
795
796    async fn register_sub<M: Send + 'static>(
797        &self,
798        kind: UdSubKind,
799        opts: SubscribeOpts,
800        method: &'static str,
801        build: impl FnOnce(SubscriptionId) -> (OpenFn<M>, UdWrap<M>),
802    ) -> Result<SubscriptionId, Error> {
803        let max = self.inner.rpc.limiter.conn_quota().max_subs_per_conn;
804        if self.inner.streams.lock().expect("streams poisoned").len() >= max {
805            return Err(Error::Rejected {
806                code: "subscription_quota".into(),
807                message: format!("at most {max} streams per connection"),
808            });
809        }
810        let id = self.inner.sub_ids.next();
811        let (open, wrap) = build(id);
812        self.inner
813            .backpressure
814            .lock()
815            .expect("backpressure poisoned")
816            .register(id, no_instrument(), opts.backpressure);
817        self.inner
818            .core
819            .lock()
820            .expect("core poisoned")
821            .register(id, kind);
822
823        let (first_tx, first_rx) = oneshot::channel();
824        let cancel = self
825            .inner
826            .shutdown
827            .lock()
828            .expect("shutdown poisoned")
829            .child_token();
830        let handle = spawn_stream(
831            StreamParams {
832                sub: id,
833                method,
834                limiter: Arc::clone(&self.inner.rpc.limiter),
835                tokens: Arc::clone(self.inner.rpc.api.tokens()),
836                open,
837                open_timeout: self.inner.rpc.timeout,
838                wrap,
839                tx: self.inner.frames.clone(),
840                first: Some(first_tx),
841            },
842            cancel,
843        );
844        self.inner
845            .streams
846            .lock()
847            .expect("streams poisoned")
848            .insert(id, handle);
849
850        // Returns when the broker has opened the stream: what happens after that is delivered. A refusal takes the
851        // subscription back. No answer in time does not: the stream goes on opening in the background (see the
852        // config), because a server may send the headers of a quiet stream only with its first message.
853        let failure = match tokio::time::timeout(self.inner.ack_timeout, first_rx).await {
854            Ok(Ok(Err(e))) => map_protocol_err(&e),
855            _ => return Ok(id),
856        };
857        let h = self
858            .inner
859            .streams
860            .lock()
861            .expect("streams poisoned")
862            .remove(&id);
863        if let Some(h) = h {
864            h.stop(Duration::from_secs(2)).await;
865        }
866        self.inner
867            .core
868            .lock()
869            .expect("core poisoned")
870            .unregister(id);
871        self.inner
872            .backpressure
873            .lock()
874            .expect("backpressure poisoned")
875            .unregister(id);
876        Err(failure)
877    }
878
879    fn wrap<M: Send + 'static>(sub: SubscriptionId, data: fn(M) -> UdEvent) -> UdWrap<M> {
880        Arc::new(move |ev| {
881            let event = match ev {
882                StreamEvent::Msg(m) => data(m),
883                StreamEvent::Connected => UdEvent::Connected,
884                StreamEvent::Disconnected(r) => UdEvent::Disconnected(r),
885                StreamEvent::Failed(r) => UdEvent::Failed(r),
886            };
887            UdFrame { sub, event }
888        })
889    }
890
891    fn lossless(&self) -> SubscribeOpts {
892        SubscribeOpts {
893            backpressure: Backpressure::Lossless {
894                cap: self.inner.order_state_buffer,
895            },
896            depth: None,
897        }
898    }
899}
900
901#[async_trait]
902impl brokapi_core::Connector for UserDataConnector {
903    type Caps = brokapi_core::UserDataCaps;
904
905    fn caps(&self) -> brokapi_core::UserDataCaps {
906        UserDataConnector::caps(self)
907    }
908
909    async fn shutdown(self, mode: brokapi_core::ShutdownMode) -> brokapi_core::ShutdownReport {
910        UserDataConnector::shutdown(self, mode).await
911    }
912}
913
914#[async_trait]
915impl OrderExecutor for UserDataConnector {
916    /// Sends a `PlaceOrder`. The request is checked locally first, then the quota is taken, then the
917    /// command goes out once.
918    ///
919    /// The cid goes into the order `comment` (lossless) and into a 20 character `client_order_id`
920    /// tag. Quantity is in pieces. A market order ignores `price`, a limit order needs it.
921    ///
922    /// Commands on one instrument go one at a time, in arrival order. A refusal does not block the
923    /// instrument.
924    ///
925    /// # Errors
926    ///
927    /// - [`Error::NotReconciled`], [`Error::Degraded`], [`Error::ShuttingDown`]: the command gate is
928    ///   closed, at the start or when the command's turn in the queue came. Nothing was sent. Call `reconcile`.
929    /// - [`Error::Rejected`]: a local check failed (code `invalid_order`, `missing_price`, `invalid_qty`), the same cid was
930    ///   sent before (code `duplicate_place`), or the broker refused (a gRPC status such as
931    ///   `invalid_argument`, or an order status such as `rejected`). The broker holds no order.
932    /// - [`Error::RateLimited`]: the local quota is spent or the broker said `RESOURCE_EXHAUSTED`.
933    ///   Nothing was sent. The cid is still unused: send the same request later.
934    /// - [`Error::Config`]: the broker refused the token (`UNAUTHENTICATED`). Nothing was done, fix the secret.
935    /// - [`Error::Unknown`]: timeout, broken connection or server fault, or the answer carried no
936    ///   order id. The order MAY exist. It is never repeated by the library. Look it up with
937    ///   `order_state` or `active_orders`.
938    ///
939    /// See the [type docs](UserDataConnector) for an example with a refusal.
940    async fn place(&self, req: PlaceOrderRequest) -> Result<OrderAck, Error> {
941        self.check_command()?;
942        let body = build_place_order(&self.inner.account_id, &req)?;
943        // Before a quota is spent or the cid is marked.
944        self.inner
945            .price_grid
946            .check_qty(&req.instrument.key, req.qty)?;
947        if let Some(p) = req.price {
948            self.inner.price_grid.check(&req.instrument.key, p)?;
949        }
950        // Quota first: a refusal here means nothing was registered and the cid stays usable.
951        self.inner.rpc.acquire("PlaceOrder")?;
952        // Waits for earlier commands on this instrument, released on every exit path.
953        let _instrument = self.inner.locks.lock(&req.instrument).await;
954        // The gate may have closed while this command stood in the queue (degraded, shutdown): ask again
955        // before the cid is marked and anything is sent.
956        self.check_command()?;
957        self.inner.locks.bind(req.cid, &req.instrument);
958        {
959            let mut core = self.inner.core.lock().expect("core poisoned");
960            // Spec 7.2: the library never sends the same place twice.
961            if !core.orders().try_place_once(req.cid) {
962                return Err(Error::Rejected {
963                    code: "duplicate_place".into(),
964                    message: "auto-retry of place is forbidden".into(),
965                });
966            }
967            core.orders()
968                .register_place(req.cid, brokapi_runtime::now());
969        }
970        self.inner.index.know(req.cid);
971
972        let cid = req.cid;
973        let inner = Arc::clone(&self.inner);
974        // From here the cid is marked and the order is in flight. The request, the answer and the update of
975        // the order run as a task of their own: if the caller drops this future, the task still ends and
976        // the order does not stay in flight for ever.
977        let work = async move {
978            let _instrument = _instrument;
979            let sent = inner
980                .rpc
981                .send_command(|api| {
982                    let body = body.clone();
983                    async move { api.orders().place_order(body).await }
984                })
985                .await;
986            match sent {
987                Ok(os) => {
988                    if let Some(rej) = rejection_of(&os) {
989                        inner.signal(
990                            cid,
991                            OrderSignal::Rejected {
992                                reason: rej.to_string(),
993                            },
994                        );
995                        return Err(rej);
996                    }
997                    if os.order_id.is_empty() {
998                        inner.signal(cid, OrderSignal::Timeout);
999                        return Err(Error::Unknown);
1000                    }
1001                    inner.index.bind(cid, &os.order_id);
1002                    let broker_id = brokapi_core::BrokerOrderId::new(os.order_id.clone());
1003                    inner.signal(
1004                        cid,
1005                        OrderSignal::Ack {
1006                            broker_id: broker_id.clone(),
1007                        },
1008                    );
1009                    Ok(OrderAck {
1010                        cid,
1011                        broker_id,
1012                        ts: timestamps(
1013                            os.accept_at.as_ref().or(os.transact_at.as_ref()),
1014                            brokapi_runtime::now(),
1015                        ),
1016                    })
1017                }
1018                Err(e) => Err(inner.command_failed(cid, &e)),
1019            }
1020        };
1021        match brokapi_runtime::run_detached(work).await {
1022            Some(result) => result,
1023            // The task was torn down with the runtime: the order may exist.
1024            None => Err(Error::Unknown),
1025        }
1026    }
1027
1028    /// Sends a `CancelOrder` for the order of `cid`.
1029    ///
1030    /// Finam cancels by its own order id. The connector knows the id of orders it placed or has seen
1031    /// (stream, `reconcile`). For any other cid it reads the order list once (`GetOrders`, one more
1032    /// request against the quota) and looks the cid up in the comments.
1033    ///
1034    /// A cancel is a request, not a result: the order may fill before the cancel reaches the
1035    /// exchange. Watch the order state.
1036    ///
1037    /// # Errors
1038    ///
1039    /// - [`Error::NotReconciled`], [`Error::Degraded`], [`Error::ShuttingDown`]: the gate is closed.
1040    /// - [`Error::RateLimited`]: the local quota is spent. Nothing was sent.
1041    /// - [`Error::Rejected`]: the broker refused, or no order with this cid exists at the broker
1042    ///   (code `not_found`).
1043    /// - [`Error::Unknown`]: no answer. The cancel may or may not have been applied. Read the order state.
1044    async fn cancel(&self, cid: ClientOrderId) -> Result<(), Error> {
1045        self.check_command()?;
1046        self.inner.rpc.acquire("CancelOrder")?;
1047        // The instrument comes from the orders this process placed; an older order is not serialized.
1048        let _instrument = self.inner.locks.lock_cid(cid).await;
1049        // The gate may have closed while this command stood in the queue.
1050        self.check_command()?;
1051        let order_id = self.order_id_of(cid).await?;
1052        let account = self.account();
1053        let sent = self
1054            .inner
1055            .rpc
1056            .send_command(|api| {
1057                let req = po::CancelOrderRequest {
1058                    account_id: account.clone(),
1059                    order_id: order_id.clone(),
1060                };
1061                async move { api.orders().cancel_order(req).await }
1062            })
1063            .await;
1064        match sent {
1065            Ok(_) => Ok(()),
1066            // The cancel may or may not have been applied: Unknown, resolved by order_state.
1067            Err(e) => Err(map_command_err(&e)),
1068        }
1069    }
1070
1071    /// Finam has no replace/amend call, so this is `Unsupported` (and `caps().supports_replace` is false).
1072    /// Cancel + place is not emulated here: it is not atomic. The client decides whether to risk it.
1073    ///
1074    /// # Errors
1075    ///
1076    /// Always [`Error::Unsupported`] (capability `replace_order`). Nothing is sent.
1077    async fn replace(&self, _req: ReplaceOrderRequest) -> Result<OrderAck, Error> {
1078        Err(unsupported("replace_order"))
1079    }
1080
1081    /// Reads one order (`GetOrder`) and reports its state under `cid`. This is the way to resolve an
1082    /// [`Error::Unknown`] from `place` or `cancel`.
1083    ///
1084    /// The order id is found as in `cancel`: a cid that was never seen costs one `GetOrders`.
1085    ///
1086    /// # Errors
1087    ///
1088    /// - [`Error::Rejected`] with code `not_found`: no order with this cid at the broker. After an
1089    ///   `Unknown` place this means the order did not arrive (or is not visible yet, not verified).
1090    /// - [`Error::RateLimited`], [`Error::Transport`]: as for any read.
1091    async fn order_state(&self, cid: ClientOrderId) -> Result<OrderSnapshot, Error> {
1092        let order_id = self.order_id_of(cid).await?;
1093        let account = self.account();
1094        let os = self
1095            .inner
1096            .rpc
1097            .read("GetOrder", |api| {
1098                let req = po::GetOrderRequest {
1099                    account_id: account.clone(),
1100                    order_id: order_id.clone(),
1101                };
1102                async move { api.orders().get_order(req).await }
1103            })
1104            .await?;
1105        let mut core = self.inner.core.lock().expect("core poisoned");
1106        Ok(core.ingest_order(&os, brokapi_runtime::now()))
1107    }
1108
1109    /// Orders of the account that are still working (`GetOrders`, filtered). Stop orders are not in
1110    /// this list, they have [`active_stop_orders`](StopOrderExecutor::active_stop_orders).
1111    ///
1112    /// An order placed by someone else (the web terminal, another program) is listed under a made-up
1113    /// cid. A status the library does not know counts as working, so an order is never hidden.
1114    ///
1115    /// # Errors
1116    ///
1117    /// [`Error::RateLimited`], [`Error::Rejected`] or [`Error::Transport`], as for any read.
1118    async fn active_orders(&self) -> Result<Vec<OrderSnapshot>, Error> {
1119        // Stop orders have their own list (`active_stop_orders`).
1120        Ok(self
1121            .fetch_orders()
1122            .await?
1123            .into_iter()
1124            .filter(|(os, _)| is_active(os.status) && !map_stop::is_stop(os))
1125            .map(|(_, snap)| snap)
1126            .collect())
1127    }
1128
1129    /// Opens a `SubscribeOrders` stream for the account. Envelopes carry `Event::Order`.
1130    ///
1131    /// The buffer is lossless (size `order_state_buffer`): when it overflows the connector turns
1132    /// degraded and refuses commands until `reconcile`. Errors as in `subscribe_*` of the market data
1133    /// connector: [`Error::Rejected`] for a refusal or for too many streams (code `subscription_quota`).
1134    async fn subscribe_order_state(&self) -> Result<SubscriptionId, Error> {
1135        let account = self.account();
1136        let opts = self.lossless();
1137        self.register_sub(UdSubKind::Orders, opts, "SubscribeOrders", |sub| {
1138            let api = self.inner.rpc.api.clone();
1139            let open: OpenFn<po::SubscribeOrdersResponse> = Arc::new(move || {
1140                let api = api.clone();
1141                let req = po::SubscribeOrdersRequest {
1142                    account_id: account.clone(),
1143                };
1144                Box::pin(async move {
1145                    api.orders()
1146                        .subscribe_orders(req)
1147                        .await
1148                        .map(|r| r.into_inner())
1149                })
1150            });
1151            (open, Self::wrap(sub, UdEvent::Orders))
1152        })
1153        .await
1154    }
1155
1156    /// Ledger cids are matched against the account's order list. A ledger cid the broker does
1157    /// not list is reported in `closed_offline`. An order the broker lists that the
1158    /// ledger does not know is `foreign` (only orders still working are looked at for this).
1159    /// Success opens the command gate and clears `Degraded`.
1160    ///
1161    /// Stop orders are not part of it: restore them with `active_stop_orders`, and keep their cids out
1162    /// of `ledger`.
1163    ///
1164    /// Call it once after `start` (an empty ledger at a clean start), and again after
1165    /// `Event::Overflow` or after an `Unknown` outcome you could not resolve. It fills the cid index,
1166    /// so later cancels of old orders find their broker ids.
1167    ///
1168    /// # Errors
1169    ///
1170    /// [`Error::RateLimited`], [`Error::Rejected`] or [`Error::Transport`] from the order list
1171    /// request. On error the gate stays as it was.
1172    async fn reconcile(&self, ledger: &[ClientOrderId]) -> Result<ReconcileReport, Error> {
1173        for cid in ledger {
1174            self.inner.index.know(*cid);
1175        }
1176        // Reconcile is about plain orders: stop orders are restored with `active_stop_orders`, and
1177        // their cids do not belong in this ledger.
1178        let all: Vec<_> = self
1179            .fetch_orders()
1180            .await?
1181            .into_iter()
1182            .filter(|(os, _)| !map_stop::is_stop(os))
1183            .collect();
1184        let wanted: std::collections::HashSet<_> = ledger.iter().copied().collect();
1185        let ledger_snaps: Vec<OrderSnapshot> = all
1186            .iter()
1187            .filter(|(_, s)| wanted.contains(&s.cid))
1188            .map(|(_, s)| s.clone())
1189            .collect();
1190        let active: Vec<OrderSnapshot> = all
1191            .iter()
1192            .filter(|(os, _)| is_active(os.status))
1193            .map(|(_, s)| s.clone())
1194            .collect();
1195        let mut report = reconcile_ledger(&ledger_snaps, &active);
1196        // Cids the broker does not list at all.
1197        let listed: std::collections::HashSet<_> = all.iter().map(|(_, s)| s.cid).collect();
1198        report
1199            .closed_offline
1200            .extend(ledger.iter().filter(|c| !listed.contains(c)).copied());
1201        report.closed_offline.sort();
1202        report.closed_offline.dedup();
1203
1204        {
1205            let mut shutdown = self.inner.shutdown.lock().expect("shutdown poisoned");
1206            shutdown.gate_mut().mark_reconciled();
1207            shutdown.gate_mut().clear_degraded();
1208        }
1209        self.inner
1210            .backpressure
1211            .lock()
1212            .expect("backpressure poisoned")
1213            .clear_degraded();
1214        Ok(report)
1215    }
1216}
1217
1218/// Pages of `Trades` read at most per `executions` call.
1219const MAX_TRADE_PAGES: usize = 100;
1220
1221fn too_long(limit: i32) -> Error {
1222    Error::Rejected {
1223        code: "range_too_long".into(),
1224        message: format!(
1225            "the range holds more trades than one answer of {limit} can page through: read it in shorter ranges"
1226        ),
1227    }
1228}
1229
1230impl UserDataConnector {
1231    fn map_trades(
1232        &self,
1233        trades: Vec<brokapi_finam_protocol::pb::grpc::tradeapi::v1::AccountTrade>,
1234    ) -> Vec<Execution> {
1235        let now = brokapi_runtime::now();
1236        let index = &self.inner.index;
1237        trades
1238            .iter()
1239            .map(|t| map_trade(t, index.lookup(&t.order_id, &t.comment), now))
1240            .collect()
1241    }
1242}
1243
1244#[async_trait]
1245impl ExecutionSource for UserDataConnector {
1246    /// Own trades in `range` (`Trades`), read page by page until the period is complete.
1247    ///
1248    /// One `Trades` request returns at most `executions_limit` trades (1000 by default). When an answer
1249    /// is full, the connector asks again for the part of the period that is still open (from the time of
1250    /// the last trade it got, in the direction the answer is sorted) and keeps each trade once by its
1251    /// trade id. Every page spends quota of `Trades`. When the period still does not fit (more than
1252    /// `executions_limit` trades with the same timestamp, or more than 100 pages) the answer is
1253    /// [`Error::Rejected`] with the code `range_too_long`, never a silently cut list: read a shorter
1254    /// range. That Finam sorts the answer by time and cuts it at the limit is not verified against the
1255    /// live API, the paging works for both directions of sort.
1256    ///
1257    /// The commission is in the account currency, its currency code is not part of `Execution`. Only
1258    /// historical trades carry a commission.
1259    ///
1260    /// # Errors
1261    ///
1262    /// [`Error::RateLimited`], [`Error::Rejected`] (including `range_too_long`) or [`Error::Transport`],
1263    /// as for any read.
1264    async fn executions(&self, range: TimeRange) -> Result<Vec<Execution>, Error> {
1265        let account = self.account();
1266        let limit = self.inner.executions_limit.max(1);
1267        let (mut from, mut to) = (range.from, range.to);
1268        let mut seen = std::collections::HashSet::new();
1269        let mut trades = Vec::new();
1270        for _ in 0..MAX_TRADE_PAGES {
1271            let resp = self
1272                .inner
1273                .rpc
1274                .read("Trades", |api| {
1275                    let req = pa::TradesRequest {
1276                        account_id: account.clone(),
1277                        limit,
1278                        interval: Some(Interval {
1279                            start_time: Some(to_timestamp(from)),
1280                            end_time: Some(to_timestamp(to)),
1281                        }),
1282                    };
1283                    async move { api.accounts().trades(req).await }
1284                })
1285                .await?;
1286            let full = resp.trades.len() >= limit as usize;
1287            let times: Vec<i64> = resp
1288                .trades
1289                .iter()
1290                .filter_map(|t| t.timestamp.as_ref())
1291                .map(|t| {
1292                    t.seconds
1293                        .saturating_mul(1_000_000_000)
1294                        .saturating_add(i64::from(t.nanos))
1295                })
1296                .collect();
1297            let mut fresh = 0usize;
1298            for t in resp.trades {
1299                if seen.insert(t.trade_id.clone()) {
1300                    fresh += 1;
1301                    trades.push(t);
1302                }
1303            }
1304            if !full {
1305                return Ok(self.map_trades(trades));
1306            }
1307            // A full answer: the period may hold more. Narrow it to what was not read yet.
1308            let (Some(&first), Some(&last)) = (times.first(), times.last()) else {
1309                return Err(too_long(limit));
1310            };
1311            if fresh == 0 || times.len() < limit as usize {
1312                return Err(too_long(limit));
1313            }
1314            let (min, max) = (
1315                times.iter().copied().min().unwrap_or(first),
1316                times.iter().copied().max().unwrap_or(last),
1317            );
1318            if first <= last {
1319                from = brokapi_core::UnixNanos::from_nanos(max);
1320            } else {
1321                to = brokapi_core::UnixNanos::from_nanos(min.saturating_add(1));
1322            }
1323        }
1324        Err(too_long(limit))
1325    }
1326
1327    /// Opens a `SubscribeTrades` stream for the account. Envelopes carry `Event::Execution`, with the cid
1328    /// when the order is ours. Lossless buffer, errors as in `subscribe_order_state`.
1329    async fn subscribe_executions(&self) -> Result<SubscriptionId, Error> {
1330        let account = self.account();
1331        let opts = self.lossless();
1332        self.register_sub(UdSubKind::Executions, opts, "SubscribeTrades", |sub| {
1333            let api = self.inner.rpc.api.clone();
1334            let open: OpenFn<po::SubscribeTradesResponse> = Arc::new(move || {
1335                let api = api.clone();
1336                let req = po::SubscribeTradesRequest {
1337                    account_id: account.clone(),
1338                };
1339                Box::pin(async move {
1340                    api.orders()
1341                        .subscribe_trades(req)
1342                        .await
1343                        .map(|r| r.into_inner())
1344                })
1345            });
1346            (open, Self::wrap(sub, UdEvent::Trades))
1347        })
1348        .await
1349    }
1350}
1351
1352#[async_trait]
1353impl PortfolioSource for UserDataConnector {
1354    /// The account state (`GetAccount`): one balance per currency and one position per holding.
1355    ///
1356    /// Quantity is signed (short is negative). Finam reports no blocked amount per currency, so
1357    /// `blocked` is 0. The average price is missing for futures positions.
1358    ///
1359    /// **Theoretical positions.** The broker's list of positions holds the open ones **plus the
1360    /// theoretical ones** that active orders would create ("открытые, плюс теоретические (по неисполненным
1361    /// активным заявкам)" in its description of `GetAccount`). The answer has no field that tells the two
1362    /// apart, so the connector cannot filter them out or mark them. A working buy order for 10 shares you do
1363    /// not hold can show as a position of 10. Do not size an order from this list while orders are
1364    /// working: take the position from your own fills ([`ExecutionSource`]) or look at it when
1365    /// [`active_orders`](OrderExecutor::active_orders) is empty. Not verified against the live API how the
1366    /// broker prices and prints such a position.
1367    ///
1368    /// # Errors
1369    ///
1370    /// [`Error::Rejected`] (unknown account), [`Error::RateLimited`], [`Error::Transport`].
1371    async fn portfolio(&self) -> Result<Portfolio, Error> {
1372        let account = self.account();
1373        let resp = self
1374            .inner
1375            .rpc
1376            .read("GetAccount", |api| {
1377                let req = pa::GetAccountRequest {
1378                    account_id: account.clone(),
1379                };
1380                async move { api.accounts().get_account(req).await }
1381            })
1382            .await?;
1383        Ok(map_account(&resp, brokapi_runtime::now()))
1384    }
1385
1386    /// The positions of [`portfolio`](PortfolioSource::portfolio). One `GetAccount` request.
1387    async fn positions(&self) -> Result<Vec<Position>, Error> {
1388        Ok(self.portfolio().await?.positions)
1389    }
1390
1391    /// The balances of [`portfolio`](PortfolioSource::portfolio). One `GetAccount` request.
1392    async fn balances(&self) -> Result<Vec<Balance>, Error> {
1393        Ok(self.portfolio().await?.balances)
1394    }
1395
1396    /// Streams `SubscribeAccount`: every message is a full account state (`Event::Portfolio`).
1397    ///
1398    /// Each event replaces the previous one. `opts` is your choice of buffer policy: `Conflate` keeps
1399    /// only the latest state, which is what you usually want here.
1400    async fn subscribe_portfolio(&self, opts: SubscribeOpts) -> Result<SubscriptionId, Error> {
1401        let account = self.account();
1402        self.register_sub(UdSubKind::Portfolio, opts, "SubscribeAccount", |sub| {
1403            let api = self.inner.rpc.api.clone();
1404            let open: OpenFn<pa::GetAccountResponse> = Arc::new(move || {
1405                let api = api.clone();
1406                let req = pa::GetAccountRequest {
1407                    account_id: account.clone(),
1408                };
1409                Box::pin(async move {
1410                    api.accounts()
1411                        .subscribe_account(req)
1412                        .await
1413                        .map(|r| r.into_inner())
1414                })
1415            });
1416            (open, Self::wrap(sub, UdEvent::Account))
1417        })
1418        .await
1419    }
1420}
1421
1422#[async_trait]
1423impl StopOrderExecutor for UserDataConnector {
1424    /// Stop-loss and stop-limit: `PlaceOrder` (STOP / STOP_LIMIT). Take-profit: `PlaceSLTPOrder`
1425    /// with the take-profit leg only. The cid travels in `comment` (lossless) and in the
1426    /// `client_order_id` tag, like for plain orders.
1427    ///
1428    /// The trigger direction follows the side and the kind: a sell stop-loss triggers when the price
1429    /// falls, a buy stop-limit when it rises. Checks that need no network run first. The cid is sent
1430    /// once: after a definite refusal it is free again, after `Unknown` it stays used.
1431    ///
1432    /// # Errors
1433    ///
1434    /// - [`Error::NotReconciled`], [`Error::Degraded`], [`Error::ShuttingDown`]: the gate is closed.
1435    /// - [`Error::Rejected`]: a local check failed (`invalid_price`, `invalid_order`,
1436    ///   `missing_limit_price` for a stop-limit without a limit price, `unsupported_expiry` for a
1437    ///   date on a stop-loss, `unsupported_limit_price` for a take-profit with a limit price), the cid
1438    ///   was sent before (`duplicate_place`), or the broker refused.
1439    /// - [`Error::RateLimited`]: the local quota is spent or the broker said `RESOURCE_EXHAUSTED`.
1440    /// - [`Error::Unknown`]: no answer. Look with `active_stop_orders`.
1441    ///
1442    /// That the live broker accepts these orders the way the mock does is not verified against the
1443    /// live API.
1444    ///
1445    /// See the [type docs](UserDataConnector) for an example.
1446    async fn place_stop(&self, req: PlaceStopOrderRequest) -> Result<OrderAck, Error> {
1447        self.check_command()?;
1448        let wire = map_stop::build(&self.inner.account_id, &req)?;
1449        self.inner
1450            .price_grid
1451            .check_qty(&req.instrument.key, req.qty)?;
1452        self.inner
1453            .price_grid
1454            .check(&req.instrument.key, req.stop_price)?;
1455        if let Some(p) = req.limit_price {
1456            self.inner.price_grid.check(&req.instrument.key, p)?;
1457        }
1458        self.inner.rpc.acquire(match &wire {
1459            StopWire::Order(_) => "PlaceOrder",
1460            StopWire::TakeProfit(_) => "PlaceSLTPOrder",
1461        })?;
1462        let _instrument = self.inner.locks.lock(&req.instrument).await;
1463        // The gate may have closed while this command stood in the queue.
1464        self.check_command()?;
1465        self.inner.locks.bind(req.cid, &req.instrument);
1466        if !self
1467            .inner
1468            .stops_sent
1469            .lock()
1470            .expect("stops poisoned")
1471            .insert(req.cid)
1472        {
1473            return Err(Error::Rejected {
1474                code: "duplicate_place".into(),
1475                message: "auto-retry of place_stop is forbidden".into(),
1476            });
1477        }
1478        self.inner.index.know(req.cid);
1479        let sent = match wire {
1480            StopWire::Order(body) => {
1481                self.inner
1482                    .rpc
1483                    .send_command(|api| {
1484                        let body = body.clone();
1485                        async move { api.orders().place_order(body).await }
1486                    })
1487                    .await
1488            }
1489            StopWire::TakeProfit(body) => {
1490                self.inner
1491                    .rpc
1492                    .send_command(|api| {
1493                        let body = body.clone();
1494                        async move { api.orders().place_sltp_order(body).await }
1495                    })
1496                    .await
1497            }
1498        };
1499        match sent {
1500            Ok(os) => {
1501                if let Some(rej) = rejection_of(&os) {
1502                    self.inner
1503                        .stops_sent
1504                        .lock()
1505                        .expect("stops poisoned")
1506                        .remove(&req.cid);
1507                    return Err(rej);
1508                }
1509                if os.order_id.is_empty() {
1510                    return Err(Error::Unknown);
1511                }
1512                self.inner.index.bind(req.cid, &os.order_id);
1513                Ok(OrderAck {
1514                    cid: req.cid,
1515                    broker_id: brokapi_core::BrokerOrderId::new(os.order_id.clone()),
1516                    ts: timestamps(
1517                        os.accept_at.as_ref().or(os.transact_at.as_ref()),
1518                        brokapi_runtime::now(),
1519                    ),
1520                })
1521            }
1522            Err(e) => {
1523                let err = map_command_err(&e);
1524                if !matches!(err, Error::Unknown) {
1525                    // A definite refusal: the broker holds nothing, the cid is clean again.
1526                    self.inner
1527                        .stops_sent
1528                        .lock()
1529                        .expect("stops poisoned")
1530                        .remove(&req.cid);
1531                }
1532                Err(err)
1533            }
1534        }
1535    }
1536
1537    /// `CancelOrder` with the stop order's id. That Finam cancels stop orders through the same call
1538    /// is an assumption from the API layout, not verified against the live API.
1539    ///
1540    /// `StopOrderRef::Broker` uses the id as given. `StopOrderRef::Cid` looks the id up like
1541    /// `cancel` does (one extra `GetOrders` for a cid not seen yet).
1542    ///
1543    /// # Errors
1544    ///
1545    /// As for `cancel`: [`Error::NotReconciled`] and its siblings, [`Error::RateLimited`],
1546    /// [`Error::Rejected`] (including code `not_found`), [`Error::Unknown`].
1547    async fn cancel_stop(&self, stop: StopOrderRef) -> Result<(), Error> {
1548        self.check_command()?;
1549        self.inner.rpc.acquire("CancelOrder")?;
1550        let order_id = match stop {
1551            StopOrderRef::Broker(id) => id.as_str().to_string(),
1552            StopOrderRef::Cid(cid) => self.order_id_of(cid).await?,
1553        };
1554        let account = self.account();
1555        let sent = self
1556            .inner
1557            .rpc
1558            .send_command(|api| {
1559                let req = po::CancelOrderRequest {
1560                    account_id: account.clone(),
1561                    order_id: order_id.clone(),
1562                };
1563                async move { api.orders().cancel_order(req).await }
1564            })
1565            .await;
1566        match sent {
1567            Ok(_) => Ok(()),
1568            Err(e) => Err(map_command_err(&e)),
1569        }
1570    }
1571
1572    /// Stop orders that are waiting for their trigger (`GetOrders`, filtered to stop orders in the
1573    /// active state).
1574    ///
1575    /// `cid` is `Some` only for orders this library placed. A stop order set by hand has none, and can
1576    /// still be cancelled by its broker id.
1577    ///
1578    /// # Errors
1579    ///
1580    /// [`Error::RateLimited`], [`Error::Rejected`] or [`Error::Transport`], as for any read.
1581    async fn active_stop_orders(&self) -> Result<Vec<StopOrderSnapshot>, Error> {
1582        let account = self.account();
1583        let resp = self
1584            .inner
1585            .rpc
1586            .read("GetOrders", |api| {
1587                let req = po::OrdersRequest {
1588                    account_id: account.clone(),
1589                };
1590                async move { api.orders().get_orders(req).await }
1591            })
1592            .await?;
1593        let now = brokapi_runtime::now();
1594        let mut out = Vec::new();
1595        for os in resp.orders.iter().filter(|os| map_stop::is_stop(os)) {
1596            // Keep the id <-> cid index current, then show the cid only when it is really ours.
1597            let (client_order_id, comment) = map_stop::echo_keys(os);
1598            let cid = self
1599                .inner
1600                .index
1601                .resolve(&os.order_id, &client_order_id, &comment);
1602            let ours = (cid != crate::index::foreign_cid(&os.order_id)).then_some(cid);
1603            let snap = map_stop::snapshot(os, ours, now);
1604            if snap.state == StopOrderState::Active {
1605                out.push(snap);
1606            }
1607        }
1608        Ok(out)
1609    }
1610
1611    /// No separate stream: `SubscribeOrders` delivers orders, and whether stop orders are among them
1612    /// is not verified against the live API. Use `subscribe_order_state`.
1613    ///
1614    /// # Errors
1615    ///
1616    /// Always [`Error::Unsupported`] (capability `stop_order_stream`).
1617    async fn subscribe_stop_orders(&self) -> Result<SubscriptionId, Error> {
1618        Err(unsupported("stop_order_stream"))
1619    }
1620}