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(¶ms)`).
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(¶ms)`) 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}