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}