Skip to main content

brokapi_finam/
md.rs

1//! Finam market data connector: [`MarketDataConnector`].
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    Candle, ConfigError, Envelope, Error, Instrument, InstrumentId, InstrumentQuery,
10    InstrumentSource, MarketDataCaps, MarketDataSource, MarketTrade, OrderBook, OrderBookSource,
11    Schedule, ShutdownMode, ShutdownReport, StreamingCaps, SubscribeOpts, SubscriptionId,
12    TimeRange, Timeframe, TradingParams, TradingStatus,
13};
14use brokapi_finam_protocol::FinamApi;
15use brokapi_finam_protocol::pb::google::r#type::Interval;
16use brokapi_finam_protocol::pb::grpc::tradeapi::v1::assets as pa;
17use brokapi_finam_protocol::pb::grpc::tradeapi::v1::marketdata as pm;
18use brokapi_runtime::{BackpressureManager, BrokerLimiter};
19use brokapi_sansio::{
20    BrokerCore, CoreEvent, DesiredSubscription, EventSink, SubscriptionKind, SubscriptionSet,
21};
22use tokio::sync::{mpsc, oneshot};
23use tokio::task::JoinHandle;
24use tokio_util::sync::CancellationToken;
25
26use crate::config::MarketDataConfig;
27use crate::core::md::DEFAULT_DEPTH;
28use crate::core::{MdCore, MdEvent, MdFrame, StreamEvent, SubStatus};
29use crate::error::{map_protocol_err, transport};
30use crate::map::instrument::{
31    apply_trade_lot_size, availability_at, kind_from_type, map_asset, map_get_asset, map_schedule,
32    map_trading_params,
33};
34use crate::map::m10::{floor_m10, merge_m5};
35use crate::map::market::{map_bar, map_rest_order_book, map_trade, wire_timeframe};
36use crate::pump::{PumpIo, SharedBackpressure, run_pump};
37use crate::raw::FinamRaw;
38use crate::rpc::Rpc;
39use crate::stream::{OpenFn, StreamHandle, StreamParams, spawn_stream};
40use crate::subs::SubIdGen;
41use crate::util::{now_nanos, symbol_of, to_timestamp};
42
43/// Finam does not document a book depth limit; the server decides what it sends and we truncate
44/// to the requested depth.
45const MAX_DEPTH: u32 = DEFAULT_DEPTH;
46/// Safety bound on pages when listing the whole directory.
47const MAX_ASSET_PAGES: usize = 200;
48
49type MdWrap<M> = Arc<dyn Fn(StreamEvent<M>) -> MdFrame + Send + Sync>;
50
51struct MdShared {
52    rpc: Rpc,
53    core: Arc<Mutex<MdCore>>,
54    backpressure: SharedBackpressure,
55    streams: Arc<Mutex<HashMap<SubscriptionId, StreamHandle>>>,
56    frames: mpsc::Sender<MdFrame>,
57    sub_ids: SubIdGen,
58    root: CancellationToken,
59    ack_timeout: Duration,
60}
61
62/// Finam market data connector: order books, trades, quotes, candles and the instrument directory.
63///
64/// One connector is enough for the whole broker, it is not tied to an account. It implements
65/// [`OrderBookSource`], [`MarketDataSource`] and [`InstrumentSource`]. Queries return their answer
66/// directly. Subscriptions deliver [`Envelope`]s into the channel given to
67/// [`start`](Self::start).
68///
69/// What is specific to Finam:
70///
71/// - **Instruments are `SYMBOL@MIC`.** Pass `NativeId::SymbolMic`. Any other native id gives
72///   `Error::Config` without a network call.
73/// - **Order books come as full snapshots.** Finam sends rows with ADD, UPDATE and REMOVE actions. The
74///   core folds them into a local book, so you never see increments (`incremental_order_book` is
75///   false). If an increment arrives with no snapshot under it, the stream is reopened and you get an
76///   `Event::Resync`.
77/// - **Depth.** Finam documents no depth limit. The book is cut to the depth you ask for, at most
78///   [`caps().max_order_book_depth`](Self::caps).
79/// - **Every call spends quota.** Opening a stream counts as a request too. A refusal is
80///   `Error::RateLimited`, and nothing is sent.
81/// - **Streams reconnect by themselves** with a growing pause (0.5 s up to 30 s). After a reconnect
82///   of an order book stream you get `Event::Resync` and then a new snapshot. Trades, quotes and
83///   candle streams resume without a notice (trades already seen are not repeated).
84///   [`sub_status`](Self::sub_status) says what a stream is doing.
85/// - **M10 candles** are built by the connector from M5 candles, because the wire has no M10.
86/// - **Schedules are per instrument**: pass `SBER@MISX` as the `exchange` argument of `schedule`.
87///
88/// # Examples
89///
90/// Order book by request, then as a stream, with the instrument directory and an error path:
91///
92/// ```
93/// use std::time::Duration;
94/// use brokapi_core::{
95///     Error, Event, InstrumentId, InstrumentKey, InstrumentQuery, InstrumentSource, NativeId, OrderBook,
96///     OrderBookSource, ShutdownMode, SubscribeOpts,
97/// };
98/// use brokapi_finam::limits::default_limits;
99/// use brokapi_finam::{MarketDataConfig, MarketDataConnector};
100/// use brokapi_runtime::BrokerLimiter;
101/// use tokio::sync::mpsc;
102/// # use brokapi_finam::FinamApiConfig;
103/// # use brokapi_mocks::finam::{MockServer, SECRET};
104///
105/// # #[tokio::main(flavor = "current_thread")]
106/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
107/// # let server = MockServer::start().await;
108/// # let cfg = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
109/// // `cfg` is your `FinamApiConfig`: the secret, and the endpoint of the real broker by default.
110/// let (events_tx, mut events) = mpsc::channel(256);
111/// let limiter = BrokerLimiter::new(&default_limits());
112/// let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "finam-md"), limiter, events_tx).await?;
113///
114/// let sber = InstrumentId::new(
115///     InstrumentKey::new("SBER", "MISX"),
116///     NativeId::SymbolMic { symbol: "SBER".into(), mic: "MISX".into() },
117/// );
118///
119/// // By request: a snapshot.
120/// let OrderBook::Snapshot { bids, asks, .. } = md.order_book(&sber, 10).await? else {
121///     panic!("a request answers a snapshot")
122/// };
123/// assert!(!bids.is_empty() && !asks.is_empty());
124///
125/// // As a stream: envelopes arrive in `events`.
126/// let sub = md.subscribe_order_book(&sber, SubscribeOpts::conflate()).await?;
127/// let envelope = tokio::time::timeout(Duration::from_secs(5), events.recv()).await?.expect("channel open");
128/// assert_eq!(envelope.sub, Some(sub));
129/// assert!(matches!(envelope.payload, Event::OrderBook(_) | Event::Resync { .. }));
130/// md.unsubscribe(sub).await?;
131///
132/// // The directory: one `GetAsset` when the exchange is known.
133/// let found = md
134///     .find(InstrumentQuery::ByTicker { ticker: "SBER".into(), exchange: Some("MISX".into()) })
135///     .await?;
136/// assert_eq!(found[0].id.key.symbol, "SBER");
137///
138/// // An instrument that is not a Finam symbol fails before anything is sent.
139/// let foreign = InstrumentId::new(InstrumentKey::new("SBER", "TQBR"), NativeId::Figi("BBG004730N88".into()));
140/// assert!(matches!(md.order_book(&foreign, 5).await, Err(Error::Config(_))));
141///
142/// md.shutdown(ShutdownMode::Immediate).await;
143/// # Ok(())
144/// # }
145/// ```
146pub struct MarketDataConnector {
147    inner: Arc<MdShared>,
148    pump: JoinHandle<()>,
149}
150
151impl MarketDataConnector {
152    /// Logs in, opens a lazy channel and starts the event pump. Streams are opened later, by `subscribe_*`.
153    ///
154    /// `limiter` is the rate limiter of your Finam user: create one per user and share it with every
155    /// connector. Events and subscription results go to `out`. Give it room, because a full channel makes
156    /// a `Lossless` subscription overflow and a `Conflate` one merge events.
157    ///
158    /// If `cfg.api.tokens` is set, that token manager is used and no login happens here.
159    ///
160    /// # Errors
161    ///
162    /// - [`Error::Config`]: empty secret, or an invalid endpoint URL.
163    /// - [`Error::Config`] also: the broker refused the secret (a wrong token is a configuration problem at every broker).
164    /// - [`Error::RateLimited`]: the broker answered `RESOURCE_EXHAUSTED` to the login.
165    /// - [`Error::Transport`]: the broker could not be reached.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use brokapi_core::{Error, ShutdownMode};
171    /// use brokapi_finam::limits::default_limits;
172    /// use brokapi_finam::{FinamApiConfig, MarketDataConfig, MarketDataConnector};
173    /// use brokapi_runtime::BrokerLimiter;
174    /// use tokio::sync::mpsc;
175    /// # use brokapi_mocks::finam::{MockServer, SECRET};
176    ///
177    /// # #[tokio::main(flavor = "current_thread")]
178    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
179    /// # let server = MockServer::start().await;
180    /// # let cfg = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
181    /// let limiter = BrokerLimiter::new(&default_limits());
182    /// let (events, _rx) = mpsc::channel(256);
183    /// let md = MarketDataConnector::start(MarketDataConfig::new(cfg.clone(), "finam-md"), limiter.clone(), events.clone()).await?;
184    /// md.shutdown(ShutdownMode::Immediate).await;
185    ///
186    /// // A wrong secret is refused at start.
187    /// let wrong = FinamApiConfig { secret: "wrong".into(), ..cfg };
188    /// let err = MarketDataConnector::start(MarketDataConfig::new(wrong, "finam-md"), limiter, events).await.err().unwrap();
189    /// assert!(matches!(err, Error::Config(_)));
190    /// # Ok(())
191    /// # }
192    /// ```
193    pub async fn start(
194        cfg: MarketDataConfig,
195        limiter: Arc<BrokerLimiter>,
196        out: mpsc::Sender<Envelope>,
197    ) -> Result<Self, Error> {
198        let tokens = cfg.api.resolve_tokens().await?;
199        let api = FinamApi::connect(&cfg.api.endpoint, tokens, cfg.api.bearer)
200            .map_err(|e| map_protocol_err(&e))?;
201
202        let (frames, rx) = mpsc::channel::<MdFrame>(1024);
203        let core = Arc::new(Mutex::new(MdCore::new()));
204        let backpressure: SharedBackpressure = Arc::new(Mutex::new(BackpressureManager::new()));
205        let streams: Arc<Mutex<HashMap<SubscriptionId, StreamHandle>>> = Arc::default();
206        let root = CancellationToken::new();
207
208        let handler = {
209            let core = Arc::clone(&core);
210            let streams = Arc::clone(&streams);
211            move |frame: MdFrame, now: Instant| -> Vec<CoreEvent> {
212                let mut sink = EventSink::new();
213                let restarts = {
214                    let mut c = core.lock().expect("core poisoned");
215                    c.on_frame(frame, now, &mut sink);
216                    c.take_restarts()
217                };
218                if !restarts.is_empty() {
219                    let map = streams.lock().expect("streams poisoned");
220                    for sub in restarts {
221                        if let Some(h) = map.get(&sub) {
222                            h.restart();
223                        }
224                    }
225                }
226                sink.drain()
227            }
228        };
229        // Market-data buffers never gate commands, so overflow needs no further reaction.
230        let pump = tokio::spawn(run_pump(
231            PumpIo {
232                rx,
233                out,
234                source: Arc::clone(&cfg.source),
235                backpressure: Arc::clone(&backpressure),
236                cancel: root.child_token(),
237            },
238            handler,
239            Arc::new(|_| {}),
240        ));
241
242        let inner = Arc::new(MdShared {
243            rpc: Rpc {
244                api,
245                limiter,
246                timeout: cfg.api.request_timeout,
247            },
248            core,
249            backpressure,
250            streams,
251            frames,
252            sub_ids: SubIdGen::new(0),
253            root,
254            ack_timeout: cfg.subscribe_ack_timeout,
255        });
256        Ok(Self { inner, pump })
257    }
258
259    /// Any other method of the Finam API with this connector's token, endpoint and rate limiter.
260    /// See [`FinamRaw`].
261    ///
262    /// # Examples
263    ///
264    /// See the [`raw` module](crate::raw) for a complete call.
265    pub fn raw(&self) -> FinamRaw {
266        FinamRaw::new(self.inner.rpc.clone(), None)
267    }
268
269    /// What this connector can do. Order books are streamed as full snapshots: Finam sends rows
270    /// with ADD/UPDATE/REMOVE actions, which the core folds into a local book, so the consumer
271    /// never sees increments (and `Conflate` is safe).
272    ///
273    /// All four streams (order book, market trades, quotes, candles) are on. The maximum depth is
274    /// the cut this connector applies, not a limit Finam documents.
275    ///
276    /// # Examples
277    ///
278    /// ```
279    /// use brokapi_finam::limits::default_limits;
280    /// use brokapi_finam::{FinamApiConfig, MarketDataConfig, MarketDataConnector};
281    /// use brokapi_core::ShutdownMode;
282    /// use brokapi_runtime::BrokerLimiter;
283    /// use tokio::sync::mpsc;
284    /// # use brokapi_mocks::finam::{MockServer, SECRET};
285    ///
286    /// # #[tokio::main(flavor = "current_thread")]
287    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
288    /// # let server = MockServer::start().await;
289    /// # let cfg = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
290    /// # let (events, _rx) = mpsc::channel(16);
291    /// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "finam-md"), BrokerLimiter::new(&default_limits()), events).await?;
292    /// let caps = md.caps();
293    /// assert!(caps.streaming.order_book && caps.streaming.candles);
294    /// assert!(!caps.incremental_order_book);
295    /// # md.shutdown(ShutdownMode::Immediate).await;
296    /// # Ok(())
297    /// # }
298    /// ```
299    pub fn caps(&self) -> MarketDataCaps {
300        MarketDataCaps::new(MAX_DEPTH, false, StreamingCaps::new(true, true, true, true))
301    }
302
303    /// State of a subscription's stream (connecting, active, reconnecting, failed).
304    ///
305    /// `None` for an id this connector does not know (never issued, or already unsubscribed).
306    /// Use it after `subscribe_*` returned and the stream may still be opening: Finam may send the headers of a
307    /// quiet stream only with its first message, so the call can return before the stream is open.
308    ///
309    /// # Examples
310    ///
311    /// ```
312    /// use std::time::Duration;
313    /// use brokapi_core::{InstrumentId, InstrumentKey, NativeId, OrderBookSource, ShutdownMode, SubscribeOpts};
314    /// use brokapi_finam::limits::default_limits;
315    /// use brokapi_finam::{FinamApiConfig, MarketDataConfig, MarketDataConnector, SubStatus};
316    /// use brokapi_runtime::BrokerLimiter;
317    /// use tokio::sync::mpsc;
318    /// # use brokapi_mocks::finam::{MockServer, SECRET};
319    ///
320    /// # #[tokio::main(flavor = "current_thread")]
321    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
322    /// # let server = MockServer::start().await;
323    /// # let cfg = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
324    /// # let (events, mut rx) = mpsc::channel(256);
325    /// # let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "finam-md"), BrokerLimiter::new(&default_limits()), events).await?;
326    /// # let sber = InstrumentId::new(InstrumentKey::new("SBER", "MISX"), NativeId::SymbolMic { symbol: "SBER".into(), mic: "MISX".into() });
327    /// let sub = md.subscribe_order_book(&sber, SubscribeOpts::conflate()).await?;
328    /// // Wait for the first event: by then the stream is open.
329    /// tokio::time::timeout(Duration::from_secs(5), rx.recv()).await?;
330    /// assert_eq!(md.sub_status(sub), Some(SubStatus::Active));
331    ///
332    /// md.unsubscribe(sub).await?;
333    /// assert_eq!(md.sub_status(sub), None);
334    /// # md.shutdown(ShutdownMode::Immediate).await;
335    /// # Ok(())
336    /// # }
337    /// ```
338    pub fn sub_status(&self, sub: SubscriptionId) -> Option<SubStatus> {
339        self.inner.core.lock().expect("core poisoned").status(sub)
340    }
341
342    /// Graceful: every stream is cancelled (the protocol-level unsubscribe of gRPC) and awaited
343    /// within `deadline`; `Immediate` aborts them.
344    ///
345    /// Consumes the connector and stops the event pump. The pump makes one last pass and hands over
346    /// what fits into your channel without waiting. Events that do not fit are dropped. There are no commands here, so nothing is abandoned in flight
347    /// (`inflight_abandoned` is always empty).
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use std::time::Duration;
353    /// use brokapi_core::ShutdownMode;
354    /// use brokapi_finam::limits::default_limits;
355    /// use brokapi_finam::{FinamApiConfig, MarketDataConfig, MarketDataConnector};
356    /// use brokapi_runtime::BrokerLimiter;
357    /// use tokio::sync::mpsc;
358    /// # use brokapi_mocks::finam::{MockServer, SECRET};
359    ///
360    /// # #[tokio::main(flavor = "current_thread")]
361    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
362    /// # let server = MockServer::start().await;
363    /// # let cfg = FinamApiConfig::new(SECRET).with_endpoint(server.url.clone());
364    /// # let (events, _rx) = mpsc::channel(16);
365    /// let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "finam-md"), BrokerLimiter::new(&default_limits()), events).await?;
366    /// let report = md.shutdown(ShutdownMode::Graceful { deadline: Duration::from_secs(2) }).await;
367    /// assert_eq!(report.unsubscribe_failed, 0);
368    /// # Ok(())
369    /// # }
370    /// ```
371    pub async fn shutdown(self, mode: ShutdownMode) -> ShutdownReport {
372        let handles: Vec<StreamHandle> = self
373            .inner
374            .streams
375            .lock()
376            .expect("streams poisoned")
377            .drain()
378            .map(|(_, h)| h)
379            .collect();
380        let (mut ok, mut failed) = (0usize, 0usize);
381        match mode {
382            ShutdownMode::Graceful { deadline } => {
383                let started = brokapi_runtime::now();
384                for h in handles {
385                    let left = deadline.saturating_sub(started.elapsed());
386                    if h.stop(left).await {
387                        ok += 1;
388                    } else {
389                        failed += 1;
390                    }
391                }
392            }
393            _ => handles.into_iter().for_each(StreamHandle::abort),
394        }
395        self.inner.root.cancel();
396        let _ = self.pump.await;
397        ShutdownReport::new(ok, failed, Vec::new())
398    }
399
400    fn quota_check(&self) -> Result<(), Error> {
401        let max = self.inner.rpc.limiter.conn_quota().max_subs_per_conn;
402        if self.inner.streams.lock().expect("streams poisoned").len() >= max {
403            return Err(Error::Rejected {
404                code: "subscription_quota".into(),
405                message: format!("at most {max} streams per connection"),
406            });
407        }
408        Ok(())
409    }
410
411    /// Registers the subscription in the core and buffers, opens the stream, and waits for the first
412    /// outcome: a refusal (bad symbol, no permission) is returned as an error and the subscription is taken
413    /// back. A stream that has not opened within `subscribe_ack_timeout` leaves the subscription connecting.
414    async fn register_sub<M: Send + 'static>(
415        &self,
416        instrument: InstrumentId,
417        core_kind: SubscriptionKind,
418        opts: SubscribeOpts,
419        method: &'static str,
420        build: impl FnOnce(SubscriptionId) -> (OpenFn<M>, MdWrap<M>),
421    ) -> Result<SubscriptionId, Error> {
422        self.quota_check()?;
423        let id = self.inner.sub_ids.next();
424        let (open, wrap) = build(id);
425        self.inner
426            .backpressure
427            .lock()
428            .expect("backpressure poisoned")
429            .register(id, instrument, opts.backpressure);
430        self.set_core_sub(id, Some(core_kind));
431
432        let (first_tx, first_rx) = oneshot::channel();
433        let handle = spawn_stream(
434            StreamParams {
435                sub: id,
436                method,
437                limiter: Arc::clone(&self.inner.rpc.limiter),
438                tokens: Arc::clone(self.inner.rpc.api.tokens()),
439                open,
440                open_timeout: self.inner.rpc.timeout,
441                wrap,
442                tx: self.inner.frames.clone(),
443                first: Some(first_tx),
444            },
445            self.inner.root.child_token(),
446        );
447        self.inner
448            .streams
449            .lock()
450            .expect("streams poisoned")
451            .insert(id, handle);
452
453        // Returns when the broker has opened the stream: what happens after that is delivered. A refusal takes the
454        // subscription back. No answer in time does not: the stream goes on opening in the background (see the
455        // config), because a server may send the headers of a quiet stream only with its first message.
456        match tokio::time::timeout(self.inner.ack_timeout, first_rx).await {
457            Ok(Ok(Err(e))) => {
458                self.drop_sub(id).await;
459                Err(map_protocol_err(&e))
460            }
461            _ => Ok(id),
462        }
463    }
464
465    fn set_core_sub(&self, id: SubscriptionId, kind: Option<SubscriptionKind>) {
466        let mut core = self.inner.core.lock().expect("core poisoned");
467        let mut subs: Vec<DesiredSubscription> = core
468            .desired()
469            .iter()
470            .filter(|s| s.id != id)
471            .cloned()
472            .collect();
473        if let Some(kind) = kind {
474            subs.push(DesiredSubscription { id, kind });
475        }
476        core.apply(&SubscriptionSet::with_subscriptions(subs));
477    }
478
479    async fn drop_sub(&self, id: SubscriptionId) -> bool {
480        let handle = self
481            .inner
482            .streams
483            .lock()
484            .expect("streams poisoned")
485            .remove(&id);
486        let had = handle.is_some();
487        if let Some(h) = handle {
488            h.stop(Duration::from_secs(2)).await;
489        }
490        self.set_core_sub(id, None);
491        self.inner
492            .backpressure
493            .lock()
494            .expect("backpressure poisoned")
495            .unregister(id);
496        had
497    }
498
499    fn wrap<M: Send + 'static>(sub: SubscriptionId, data: fn(M) -> MdEvent) -> MdWrap<M> {
500        Arc::new(move |ev| {
501            let event = match ev {
502                StreamEvent::Msg(m) => data(m),
503                StreamEvent::Connected => MdEvent::Connected,
504                StreamEvent::Disconnected(r) => MdEvent::Disconnected(r),
505                StreamEvent::Failed(r) => MdEvent::Failed(r),
506            };
507            MdFrame { sub, event }
508        })
509    }
510}
511
512fn history_interval(range: TimeRange) -> Interval {
513    Interval {
514        start_time: Some(to_timestamp(range.from)),
515        end_time: Some(to_timestamp(range.to)),
516    }
517}
518
519impl MarketDataConnector {
520    async fn all_assets(&self) -> Result<Vec<pa::Asset>, Error> {
521        let mut out = Vec::new();
522        let mut cursor = 0i64;
523        for _ in 0..MAX_ASSET_PAGES {
524            let resp = self
525                .inner
526                .rpc
527                .read("Assets", |api| async move {
528                    api.assets()
529                        .all_assets(pa::AllAssetsRequest {
530                            cursor,
531                            only_active: true,
532                            only_disabled: false,
533                        })
534                        .await
535                })
536                .await?;
537            out.extend(resp.assets);
538            if resp.next_cursor == 0 || resp.next_cursor == cursor {
539                return Ok(out);
540            }
541            cursor = resp.next_cursor;
542        }
543        Err(transport("asset directory has too many pages"))
544    }
545
546    async fn get_asset(&self, symbol: &str) -> Result<pa::GetAssetResponse, Error> {
547        let symbol = symbol.to_string();
548        self.inner
549            .rpc
550            .read("GetAsset", |api| {
551                let req = pa::GetAssetRequest {
552                    symbol: symbol.clone(),
553                    // The directory is account-independent here; params would need an account.
554                    account_id: String::new(),
555                };
556                async move { api.assets().get_asset(req).await }
557            })
558            .await
559    }
560
561    /// `trade_lot_size` of `GetAssetParams` for the first account of the token, if there is one.
562    ///
563    /// `None` when the token lists no account, when the broker refuses the call (the lot of `GetAsset` is then
564    /// used), or when it answers 0 ("no value"). Other errors (quota, network) are returned: a read, repeat it.
565    async fn trade_lot_size(&self, symbol: &str) -> Result<Option<i64>, Error> {
566        let Some(account) = self.inner.rpc.api.tokens().account_ids().into_iter().next() else {
567            return Ok(None);
568        };
569        let symbol = symbol.to_string();
570        let answer = self
571            .inner
572            .rpc
573            .read("GetAssetParams", |api| {
574                let req = pa::GetAssetParamsRequest {
575                    symbol: symbol.clone(),
576                    account_id: account.clone(),
577                };
578                async move { api.assets().get_asset_params(req).await }
579            })
580            .await;
581        match answer {
582            Ok(r) => Ok((r.trade_lot_size > 0).then_some(r.trade_lot_size)),
583            Err(Error::Rejected { .. }) => Ok(None),
584            Err(e) => Err(e),
585        }
586    }
587
588    async fn schedule_of(&self, symbol: &str) -> Result<pa::ScheduleResponse, Error> {
589        let symbol = symbol.to_string();
590        self.inner
591            .rpc
592            .read("Schedule", |api| {
593                let req = pa::ScheduleRequest {
594                    symbol: symbol.clone(),
595                };
596                async move { api.assets().schedule(req).await }
597            })
598            .await
599    }
600}
601
602#[async_trait]
603impl brokapi_core::Connector for MarketDataConnector {
604    type Caps = brokapi_core::MarketDataCaps;
605
606    fn caps(&self) -> brokapi_core::MarketDataCaps {
607        MarketDataConnector::caps(self)
608    }
609
610    async fn shutdown(self, mode: brokapi_core::ShutdownMode) -> brokapi_core::ShutdownReport {
611        MarketDataConnector::shutdown(self, mode).await
612    }
613}
614
615#[async_trait]
616impl OrderBookSource for MarketDataConnector {
617    /// One `OrderBook` request. Answers `OrderBook::Snapshot`, cut to `depth` levels per side
618    /// (at most 50, the cut of this connector).
619    ///
620    /// # Errors
621    ///
622    /// - [`Error::Config`]: the instrument has no `NativeId::SymbolMic`. Nothing is sent.
623    /// - [`Error::RateLimited`]: the local quota is spent, or the broker answered `RESOURCE_EXHAUSTED`.
624    /// - [`Error::Rejected`]: the broker refused (unknown symbol, no permission).
625    /// - [`Error::Transport`]: connection failure or no answer within the request timeout.
626    ///
627    /// See the [type docs](MarketDataConnector) for an example.
628    async fn order_book(&self, id: &InstrumentId, depth: u32) -> Result<OrderBook, Error> {
629        let symbol = symbol_of(id)?;
630        let resp = self
631            .inner
632            .rpc
633            .read("OrderBook", |api| {
634                let req = pm::OrderBookRequest {
635                    symbol: symbol.clone(),
636                };
637                async move { api.market_data().order_book(req).await }
638            })
639            .await?;
640        Ok(map_rest_order_book(
641            id,
642            &resp,
643            depth.min(MAX_DEPTH),
644            brokapi_runtime::now(),
645        ))
646    }
647
648    /// Opens an `SubscribeOrderBook` stream. Envelopes carry `Event::OrderBook(OrderBook::Snapshot)`.
649    ///
650    /// `opts.depth` is the number of levels per side (default and maximum 50). Waits up to
651    /// `subscribe_ack_timeout` for the broker to open the stream: a refusal inside that time is returned. If the
652    /// stream is not open by then the call still returns, and the stream goes on opening (see
653    /// [`MarketDataConnector::sub_status`]): until it is open, nothing is delivered.
654    ///
655    /// # Errors
656    ///
657    /// - [`Error::Config`]: the instrument has no `NativeId::SymbolMic`.
658    /// - [`Error::Rejected`]: the broker refused the stream (bad symbol, no permission), or this
659    ///   connector already has the maximum number of streams (code `subscription_quota`).
660    ///
661    /// See the [type docs](MarketDataConnector) for an example.
662    async fn subscribe_order_book(
663        &self,
664        id: &InstrumentId,
665        opts: SubscribeOpts,
666    ) -> Result<SubscriptionId, Error> {
667        let symbol = symbol_of(id)?;
668        let depth = opts.depth.unwrap_or(MAX_DEPTH).min(MAX_DEPTH);
669        self.register_sub(
670            id.clone(),
671            SubscriptionKind::OrderBook {
672                instrument: id.clone(),
673                depth,
674            },
675            opts,
676            "SubscribeOrderBook",
677            |sub| {
678                let api = self.inner.rpc.api.clone();
679                let open: OpenFn<pm::SubscribeOrderBookResponse> = Arc::new(move || {
680                    let api = api.clone();
681                    let req = pm::SubscribeOrderBookRequest {
682                        symbol: symbol.clone(),
683                    };
684                    Box::pin(async move {
685                        api.market_data()
686                            .subscribe_order_book(req)
687                            .await
688                            .map(|r| r.into_inner())
689                    })
690                });
691                (open, Self::wrap(sub, MdEvent::OrderBook))
692            },
693        )
694        .await
695    }
696
697    /// Cancels the stream (the gRPC unsubscribe) and forgets the subscription. Waits up to 2 seconds
698    /// for the stream task to end.
699    ///
700    /// # Errors
701    ///
702    /// [`Error::Rejected`] with code `unknown_subscription` when `sub` is not a live subscription of
703    /// this connector (a second `unsubscribe` of the same id ends here).
704    async fn unsubscribe(&self, sub: SubscriptionId) -> Result<(), Error> {
705        if self.drop_sub(sub).await {
706            Ok(())
707        } else {
708            Err(Error::Rejected {
709                code: "unknown_subscription".into(),
710                message: format!("subscription {} is not active", sub.value()),
711            })
712        }
713    }
714}
715
716#[async_trait]
717impl MarketDataSource for MarketDataConnector {
718    /// One `LatestTrades` request. Returns the newest `limit` trades, oldest first.
719    ///
720    /// A trade that carries no side is dropped, because the core `Side` is mandatory.
721    ///
722    /// # Errors
723    ///
724    /// The same as [`order_book`](OrderBookSource::order_book).
725    async fn last_trades(&self, id: &InstrumentId, limit: u32) -> Result<Vec<MarketTrade>, Error> {
726        let symbol = symbol_of(id)?;
727        let resp = self
728            .inner
729            .rpc
730            .read("LatestTrades", |api| {
731                let req = pm::LatestTradesRequest {
732                    symbol: symbol.clone(),
733                };
734                async move { api.market_data().latest_trades(req).await }
735            })
736            .await?;
737        let now = brokapi_runtime::now();
738        let mut trades: Vec<MarketTrade> = resp
739            .trades
740            .iter()
741            .filter_map(|t| map_trade(id, t, now))
742            .collect();
743        // Newest `limit`, oldest first.
744        let skip = trades.len().saturating_sub(limit as usize);
745        Ok(trades.split_off(skip))
746    }
747
748    /// Opens a `SubscribeLatestTrades` stream. Trades seen before are not repeated after a reconnect
749    /// (the last 2048 ids are remembered). Errors as in `subscribe_order_book`.
750    async fn subscribe_market_trades(
751        &self,
752        id: &InstrumentId,
753        opts: SubscribeOpts,
754    ) -> Result<SubscriptionId, Error> {
755        let symbol = symbol_of(id)?;
756        self.register_sub(
757            id.clone(),
758            SubscriptionKind::LastTrades {
759                instrument: id.clone(),
760            },
761            opts,
762            "SubscribeLatestTrades",
763            |sub| {
764                let api = self.inner.rpc.api.clone();
765                let open: OpenFn<pm::SubscribeLatestTradesResponse> = Arc::new(move || {
766                    let api = api.clone();
767                    let req = pm::SubscribeLatestTradesRequest {
768                        symbol: symbol.clone(),
769                    };
770                    Box::pin(async move {
771                        api.market_data()
772                            .subscribe_latest_trades(req)
773                            .await
774                            .map(|r| r.into_inner())
775                    })
776                });
777                (open, Self::wrap(sub, MdEvent::Trades))
778            },
779        )
780        .await
781    }
782
783    /// Opens a `SubscribeQuote` stream for one instrument. A side with price 0 is reported as missing.
784    /// An in-band error message from the broker shows as [`SubStatus::Erroring`]. Errors as in
785    /// `subscribe_order_book`.
786    async fn subscribe_quotes(
787        &self,
788        id: &InstrumentId,
789        opts: SubscribeOpts,
790    ) -> Result<SubscriptionId, Error> {
791        let symbol = symbol_of(id)?;
792        self.register_sub(
793            id.clone(),
794            SubscriptionKind::Quotes {
795                instrument: id.clone(),
796            },
797            opts,
798            "SubscribeQuote",
799            |sub| {
800                let api = self.inner.rpc.api.clone();
801                let open: OpenFn<pm::SubscribeQuoteResponse> = Arc::new(move || {
802                    let api = api.clone();
803                    let req = pm::SubscribeQuoteRequest {
804                        symbols: vec![symbol.clone()],
805                    };
806                    Box::pin(async move {
807                        api.market_data()
808                            .subscribe_quote(req)
809                            .await
810                            .map(|r| r.into_inner())
811                    })
812                });
813                (open, Self::wrap(sub, MdEvent::Quotes))
814            },
815        )
816        .await
817    }
818
819    /// One `Bars` request.
820    ///
821    /// M10 is not on the wire: the connector asks for M5 and merges pairs. The start of the range moves
822    /// down to the 10 minute boundary so the first candle keeps both halves. A lone M5 candle (one half
823    /// had no trades) is a legitimate M10 candle and is kept.
824    ///
825    /// # Errors
826    ///
827    /// As [`order_book`](OrderBookSource::order_book), plus [`Error::Unsupported`] for a timeframe
828    /// Finam has no bars for.
829    async fn candles(
830        &self,
831        id: &InstrumentId,
832        tf: Timeframe,
833        range: TimeRange,
834    ) -> Result<Vec<Candle>, Error> {
835        let symbol = symbol_of(id)?;
836        let timeframe = wire_timeframe(tf)? as i32;
837        // M10 is built from M5: the range starts at the 10-minute boundary, so the first candle
838        // does not lose its first half.
839        let range = if tf == Timeframe::M10 {
840            TimeRange::new(floor_m10(range.from), range.to)
841        } else {
842            range
843        };
844        let resp = self
845            .inner
846            .rpc
847            .read("Bars", |api| {
848                let req = pm::BarsRequest {
849                    symbol: symbol.clone(),
850                    timeframe,
851                    interval: Some(history_interval(range)),
852                };
853                async move { api.market_data().bars(req).await }
854            })
855            .await?;
856        let now = brokapi_runtime::now();
857        if tf == Timeframe::M10 {
858            let m5: Vec<Candle> = resp
859                .bars
860                .iter()
861                .map(|b| map_bar(id, Timeframe::M5, b, now))
862                .collect();
863            return Ok(merge_m5(&m5));
864        }
865        Ok(resp.bars.iter().map(|b| map_bar(id, tf, b, now)).collect())
866    }
867
868    /// 500 bars of the timeframe. The Finam documentation of `Bars` states no limit, so this is a
869    /// cautious guess that costs only extra requests when it is too small. Not checked against the
870    /// live API.
871    fn candles_max_span(&self, tf: Timeframe) -> Option<Duration> {
872        Some(Duration::from_secs(500 * tf.seconds()))
873    }
874
875    /// Opens a `SubscribeBars` stream. Every message of a forming bar gives an updated candle, not only
876    /// the closed one. For M10 the stream is M5 and the connector builds the 10 minute candle.
877    /// Errors as in `candles` and `subscribe_order_book`.
878    async fn subscribe_candles(
879        &self,
880        id: &InstrumentId,
881        tf: Timeframe,
882        opts: SubscribeOpts,
883    ) -> Result<SubscriptionId, Error> {
884        let symbol = symbol_of(id)?;
885        // M10: the wire stream is M5, the core builds the 10-minute candle.
886        let timeframe = wire_timeframe(tf)? as i32;
887        self.register_sub(
888            id.clone(),
889            SubscriptionKind::LastCandle {
890                instrument: id.clone(),
891                timeframe: tf,
892            },
893            opts,
894            "SubscribeBars",
895            |sub| {
896                let api = self.inner.rpc.api.clone();
897                let open: OpenFn<pm::SubscribeBarsResponse> = Arc::new(move || {
898                    let api = api.clone();
899                    let req = pm::SubscribeBarsRequest {
900                        symbol: symbol.clone(),
901                        timeframe,
902                    };
903                    Box::pin(async move {
904                        api.market_data()
905                            .subscribe_bars(req)
906                            .await
907                            .map(|r| r.into_inner())
908                    })
909                });
910                (open, Self::wrap(sub, MdEvent::Bars))
911            },
912        )
913        .await
914    }
915}
916
917#[async_trait]
918impl InstrumentSource for MarketDataConnector {
919    /// `ByTicker` with an exchange is one `GetAsset`; everything else scans the (paged) directory
920    /// of active instruments, so prefer the exchange form. The library keeps no cache.
921    ///
922    /// The directory list carries no currency: `currency` is empty for the answers of a scan. Use `get`
923    /// for it. `ByKind` matches the Finam `type` string by substring, which is not verified against
924    /// the live API.
925    ///
926    /// # Errors
927    ///
928    /// [`Error::Rejected`] (a symbol the broker does not know is `not_found`), [`Error::RateLimited`],
929    /// [`Error::Transport`] (also when the directory has more pages than the safety bound of 200).
930    async fn find(&self, q: InstrumentQuery) -> Result<Vec<Instrument>, Error> {
931        match q {
932            InstrumentQuery::ByTicker {
933                ticker,
934                exchange: Some(mic),
935            } => {
936                let symbol = format!("{ticker}@{mic}");
937                Ok(vec![map_get_asset(
938                    &symbol,
939                    &self.get_asset(&symbol).await?,
940                )])
941            }
942            InstrumentQuery::ByTicker {
943                ticker,
944                exchange: None,
945            } => Ok(self
946                .all_assets()
947                .await?
948                .iter()
949                .filter(|a| a.ticker.eq_ignore_ascii_case(&ticker))
950                .map(map_asset)
951                .collect()),
952            InstrumentQuery::ByIsin(isin) => Ok(self
953                .all_assets()
954                .await?
955                .iter()
956                .filter(|a| a.isin.eq_ignore_ascii_case(&isin))
957                .map(map_asset)
958                .collect()),
959            InstrumentQuery::ByKind(kind) => Ok(self
960                .all_assets()
961                .await?
962                .iter()
963                .filter(|a| kind_from_type(&a.r#type) == kind)
964                .map(map_asset)
965                .collect()),
966        }
967    }
968
969    /// One `GetAsset`: name, ISIN, kind and currency of the instrument.
970    ///
971    /// # Errors
972    ///
973    /// [`Error::Config`] for a native id that is not `SymbolMic`. [`Error::Rejected`] when the broker
974    /// does not know the symbol. [`Error::RateLimited`] and [`Error::Transport`] as for any read.
975    async fn get(&self, id: &InstrumentId) -> Result<Instrument, Error> {
976        let symbol = symbol_of(id)?;
977        Ok(map_get_asset(&symbol, &self.get_asset(&symbol).await?))
978    }
979
980    /// Trading parameters from `GetAsset` (price step, lot size and the like). The library does not
981    /// round your orders with them. Errors as in `get`.
982    ///
983    /// The lot size comes from `GetAsset` (`lot_size`). When the token lists an account, a second call
984    /// `GetAssetParams` for the first account asks for `trade_lot_size`, the lot size for trading. If it is
985    /// above 0, it replaces `lot_size` and `min_qty`. If it is 0, missing, or the broker refuses that call, the
986    /// lot of `GetAsset` stays. Not verified live: it is not known when the two differ.
987    async fn trading_params(&self, id: &InstrumentId) -> Result<TradingParams, Error> {
988        let symbol = symbol_of(id)?;
989        let mut params = map_trading_params(&symbol, &self.get_asset(&symbol).await?);
990        if let Some(lot) = self.trade_lot_size(&symbol).await? {
991            apply_trade_lot_size(&mut params, lot);
992        }
993        Ok(params)
994    }
995
996    /// Derived from the instrument's schedule: Finam has no direct status call.
997    ///
998    /// Inside a session that is not of the closed type the status is open, otherwise closed. An empty
999    /// schedule gives `Unknown`. Errors as in `get`.
1000    async fn trading_status(&self, id: &InstrumentId) -> Result<TradingStatus, Error> {
1001        let symbol = symbol_of(id)?;
1002        let sched = self.schedule_of(&symbol).await?;
1003        let now = now_nanos();
1004        let (availability, reason) = availability_at(&sched, now);
1005        Ok(TradingStatus {
1006            instrument: id.clone(),
1007            availability,
1008            reason,
1009            ts: Some(now),
1010        })
1011    }
1012
1013    /// Finam schedules are per instrument, so `exchange` must be a symbol (`SBER@MISX`).
1014    ///
1015    /// Only sessions that overlap `range` are returned, closed sessions are left out.
1016    ///
1017    /// # Errors
1018    ///
1019    /// [`Error::Config`] when `exchange` has no `@` (nothing is sent). Otherwise as in `get`.
1020    async fn schedule(&self, exchange: &str, range: TimeRange) -> Result<Schedule, Error> {
1021        if !exchange.contains('@') {
1022            return Err(Error::Config(ConfigError::Message(
1023                "Finam schedule is per instrument: pass SYMBOL@MIC as `exchange`".into(),
1024            )));
1025        }
1026        let resp = self.schedule_of(exchange).await?;
1027        Ok(map_schedule(
1028            exchange,
1029            &resp,
1030            range.from,
1031            range.to,
1032            brokapi_runtime::now(),
1033        ))
1034    }
1035}