Skip to main content

brokapi_bcs_protocol/http/
endpoints.rs

1//! One typed method per REST endpoint of the BCS Trade API, as `impl` blocks of [`BcsHttpClient`].
2//!
3//! The methods are grouped by the service of the broker: authorization, information (reference data),
4//! limits, margin, market data, orders, portfolio, trades and non-trade operations. All of them are
5//! thin: they build the URL, call [`BcsHttpClient::get`], [`BcsHttpClient::get_with_query`] or
6//! [`BcsHttpClient::post`], and return the broker's model. They do not retry (apart from the one
7//! repeat after a 401, see [`BcsHttpClient`]), do not throttle and do not interpret the reply.
8//!
9//! All dates and times the broker sends are UTC, as its docs say.
10
11use crate::error::BcsApiError;
12use crate::http::client::BcsHttpClient;
13use crate::http::models::*;
14use chrono::Utc;
15use uuid::Uuid;
16
17// ─── auth ─────────────────────────────────────────────────────────────────────
18
19impl BcsHttpClient {
20    /// Gets a fresh access token and reports it in a [`TokenResponse`].
21    ///
22    /// It calls [`BcsHttpClient::refresh_access_token`], so the token service is contacted every time.
23    /// The returned value is **built locally**, not copied from the broker's reply: `expires_in` and
24    /// `refresh_expires_in` are always `0`, `token_type` is the fixed text `Bearer`, `session_state`
25    /// and `scope` are `None`, and `refresh_token` is the refresh token the client holds now. Read the
26    /// lifetimes from the broker docs (24 hours and 90 days), not from this value.
27    ///
28    /// You do not have to call it: every other method authorizes itself.
29    ///
30    /// # Errors
31    ///
32    /// As [`BcsHttpClient::refresh_access_token`]: [`BcsApiError::AuthError`] if the refresh token is
33    /// refused, [`BcsApiError::HttpError`] on transport failures.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// # #[tokio::main(flavor = "current_thread")]
39    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
40    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
41    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
42    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
43    ///
44    /// let client = BcsHttpClient::new(cfg)?;
45    /// let token = client.authenticate().await?;
46    /// assert!(!token.access_token.is_empty());
47    /// assert_eq!(token.token_type, "Bearer");
48    /// # Ok(())
49    /// # }
50    /// ```
51    pub async fn authenticate(&self) -> Result<TokenResponse, BcsApiError> {
52        let token = self.refresh_access_token().await?;
53        Ok(TokenResponse {
54            access_token: token,
55            expires_in: 0,
56            refresh_expires_in: 0,
57            refresh_token: Some(self.current_refresh_token().await),
58            token_type: "Bearer".to_string(),
59            session_state: None,
60            scope: None,
61        })
62    }
63}
64
65// ─── information ──────────────────────────────────────────────────────────────
66
67const DAILY_SCHEDULE_PATH: &str =
68    "/trade-api-information-service/api/v1/trading-schedule/daily-schedule";
69const TRADING_STATUS_PATH: &str = "/trade-api-information-service/api/v1/trading-schedule/status";
70const INSTRUMENTS_BY_ISINS_PATH: &str =
71    "/trade-api-information-service/api/v1/instruments/by-isins";
72const INSTRUMENTS_BY_TICKERS_PATH: &str =
73    "/trade-api-information-service/api/v1/instruments/by-tickers";
74const INSTRUMENTS_BY_TYPE_PATH: &str = "/trade-api-information-service/api/v1/instruments/by-type";
75
76impl BcsHttpClient {
77    /// Gets the trading schedule of one instrument for the day: `GET .../trading-schedule/daily-schedule`.
78    ///
79    /// Both `class_code` and `ticker` are required by the broker. The reply lists the sessions of the
80    /// day with their start and end as times of day (`HH:MM:SS`). The broker docs do not say in which
81    /// time zone those times are, and the names of the sessions are in Russian (for example
82    /// "Торговый период"). Not verified against the live API.
83    ///
84    /// # Errors
85    ///
86    /// [`BcsApiError::ValidationError`] if a parameter is missing or empty. See [`BcsApiError`] for the
87    /// other variants.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// # #[tokio::main(flavor = "current_thread")]
93    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
94    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
95    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
96    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
97    /// use brokapi_bcs_protocol::http::models::DailyScheduleRequest;
98    ///
99    /// let client = BcsHttpClient::new(cfg)?;
100    /// let day = client
101    ///     .get_daily_schedule(&DailyScheduleRequest { class_code: "TQBR".into(), ticker: "SBER".into() })
102    ///     .await?;
103    /// assert!(day.is_work_day);
104    /// assert!(!day.daily_schedule.is_empty());
105    /// # Ok(())
106    /// # }
107    /// ```
108    pub async fn get_daily_schedule(
109        &self,
110        req: &DailyScheduleRequest,
111    ) -> Result<DailyScheduleResponse, BcsApiError> {
112        let query = [
113            ("classCode", req.class_code.as_str()),
114            ("ticker", req.ticker.as_str()),
115        ];
116        self.get_with_query(DAILY_SCHEDULE_PATH, &query).await
117    }
118
119    /// Gets the current trading session status of a board: `GET .../trading-schedule/status`.
120    ///
121    /// The status is per class code (board), not per instrument. The reply says whether the session is
122    /// open and when the status changes next.
123    ///
124    /// # Errors
125    ///
126    /// [`BcsApiError::ValidationError`] if `class_code` is missing. [`BcsApiError::ParseError`] if the
127    /// reply carries a `nextSessionDate` that is not an ISO 8601 date-time. See [`BcsApiError`] for the
128    /// other variants.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// # #[tokio::main(flavor = "current_thread")]
134    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
135    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
136    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
137    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
138    /// use brokapi_bcs_protocol::http::models::TradingStatusRequest;
139    ///
140    /// let client = BcsHttpClient::new(cfg)?;
141    /// let status = client
142    ///     .get_trading_status(&TradingStatusRequest { class_code: "TQBR".into() })
143    ///     .await?;
144    /// assert_eq!(status.trading_session_status, "OPEN");
145    /// # Ok(())
146    /// # }
147    /// ```
148    pub async fn get_trading_status(
149        &self,
150        req: &TradingStatusRequest,
151    ) -> Result<TradingStatusResponse, BcsApiError> {
152        let query = [("classCode", req.class_code.as_str())];
153        self.get_with_query(TRADING_STATUS_PATH, &query).await
154    }
155
156    /// Looks up instruments by ISIN: `POST .../instruments/by-isins`.
157    ///
158    /// `size` (1 to 100, default 50) and `page` (from 0) page the answer. The broker does not say how
159    /// many pages there are: a page shorter than `size` is the last one, a full page means "ask for
160    /// `page + 1`". The parameters are sent in the query string (the broker docs mark them as path
161    /// parameters, but the path has none, not verified against the live API).
162    ///
163    /// # Errors
164    ///
165    /// See [`BcsApiError`]. An unknown ISIN is probably just missing from the list (not verified
166    /// against the live API).
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// # #[tokio::main(flavor = "current_thread")]
172    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
173    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
174    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
175    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
176    /// use brokapi_bcs_protocol::http::models::InstrumentsByIsinsRequest;
177    ///
178    /// let client = BcsHttpClient::new(cfg)?;
179    /// let found = client
180    ///     .get_instruments_by_isins(
181    ///         &InstrumentsByIsinsRequest { isins: vec!["RU0009029540".into()] },
182    ///         Some(50),
183    ///         Some(0),
184    ///     )
185    ///     .await?;
186    /// assert_eq!(found[0].ticker, "SBER");
187    /// # Ok(())
188    /// # }
189    /// ```
190    pub async fn get_instruments_by_isins(
191        &self,
192        req: &InstrumentsByIsinsRequest,
193        size: Option<u32>,
194        page: Option<u32>,
195    ) -> Result<Vec<InstrumentInfo>, BcsApiError> {
196        let mut path = INSTRUMENTS_BY_ISINS_PATH.to_string();
197        let mut params = Vec::new();
198        if let Some(s) = size {
199            params.push(format!("size={}", s));
200        }
201        if let Some(p) = page {
202            params.push(format!("page={}", p));
203        }
204        if !params.is_empty() {
205            path.push('?');
206            path.push_str(&params.join("&"));
207        }
208        self.post(&path, req).await
209    }
210
211    /// Looks up instruments by ticker: `POST .../instruments/by-tickers`.
212    ///
213    /// One ticker can trade on several boards, so the answer has an [`InstrumentInfo`] per instrument
214    /// and each lists its [`InstrumentInfo::boards`]. Paging works as in
215    /// [`BcsHttpClient::get_instruments_by_isins`].
216    ///
217    /// # Errors
218    ///
219    /// See [`BcsApiError`].
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// # #[tokio::main(flavor = "current_thread")]
225    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
226    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
227    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
228    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
229    /// use brokapi_bcs_protocol::http::models::InstrumentsByTickersRequest;
230    ///
231    /// let client = BcsHttpClient::new(cfg)?;
232    /// let found = client
233    ///     .get_instruments_by_tickers(&InstrumentsByTickersRequest { tickers: vec!["SBER".into()] }, None, None)
234    ///     .await?;
235    /// let params = found[0].trading_params_primary().expect("a primary board");
236    /// assert_eq!(params.class_code, "TQBR");
237    /// # Ok(())
238    /// # }
239    /// ```
240    pub async fn get_instruments_by_tickers(
241        &self,
242        req: &InstrumentsByTickersRequest,
243        size: Option<u32>,
244        page: Option<u32>,
245    ) -> Result<Vec<InstrumentInfo>, BcsApiError> {
246        let mut path = INSTRUMENTS_BY_TICKERS_PATH.to_string();
247        let mut params = Vec::new();
248        if let Some(s) = size {
249            params.push(format!("size={}", s));
250        }
251        if let Some(p) = page {
252            params.push(format!("page={}", p));
253        }
254        if !params.is_empty() {
255            path.push('?');
256            path.push_str(&params.join("&"));
257        }
258        self.post(&path, req).await
259    }
260
261    /// Lists instruments of one kind: `GET .../instruments/by-type`.
262    ///
263    /// [`InstrumentsByTypeRequest::instrument_type`] must be one of the broker's names: `CURRENCY`,
264    /// `STOCK`, `FOREIGN_STOCK`, `BONDS`, `NOTES`, `DEPOSITARY_RECEIPTS`, `EURO_BONDS`, `MUTUAL_FUNDS`,
265    /// `ETF`, `FUTURES`, `OPTIONS`, `GOODS`, `INDICES`. For `OPTIONS` the broker also requires the base
266    /// asset ticker. A full list is long, so read it page by page: `size` 1 to 100, `page` from 0, a
267    /// page shorter than `size` is the last.
268    ///
269    /// # Errors
270    ///
271    /// [`BcsApiError::ValidationError`] for an unknown type, a missing base asset for options, or a
272    /// `size` outside 1 to 100. See [`BcsApiError`] for the other variants.
273    ///
274    /// # Examples
275    ///
276    /// ```
277    /// # #[tokio::main(flavor = "current_thread")]
278    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
279    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
280    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
281    /// use brokapi_bcs_protocol::error::BcsApiError;
282    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
283    /// use brokapi_bcs_protocol::http::models::InstrumentsByTypeRequest;
284    ///
285    /// let client = BcsHttpClient::new(cfg)?;
286    /// let mut req = InstrumentsByTypeRequest {
287    ///     instrument_type: "STOCK".into(),
288    ///     base_asset_ticker: None,
289    ///     size: Some(100),
290    ///     page: Some(0),
291    /// };
292    /// let stocks = client.get_instruments_by_type(&req).await?;
293    /// assert!(!stocks.is_empty());
294    ///
295    /// // A name the broker does not know is refused.
296    /// req.instrument_type = "Stock".into();
297    /// let err = client.get_instruments_by_type(&req).await.unwrap_err();
298    /// assert!(matches!(err, BcsApiError::ValidationError { .. }));
299    /// # Ok(())
300    /// # }
301    /// ```
302    pub async fn get_instruments_by_type(
303        &self,
304        req: &InstrumentsByTypeRequest,
305    ) -> Result<Vec<InstrumentInfo>, BcsApiError> {
306        let mut query: Vec<(&str, String)> = vec![("type", req.instrument_type.clone())];
307        if let Some(ref ba) = req.base_asset_ticker {
308            query.push(("baseAssetTicker", ba.clone()));
309        }
310        if let Some(s) = req.size {
311            query.push(("size", s.to_string()));
312        }
313        if let Some(p) = req.page {
314            query.push(("page", p.to_string()));
315        }
316        self.get_with_query(INSTRUMENTS_BY_TYPE_PATH, &query).await
317    }
318}
319
320// ─── limits ───────────────────────────────────────────────────────────────────
321
322const LIMITS_PATH: &str = "/trade-api-bff-limit/api/v1/limits";
323
324impl BcsHttpClient {
325    /// Gets the account limits: positions, money and futures limits (`GET .../limits`).
326    ///
327    /// It is a snapshot of what the account holds and what is locked by active orders. The same data
328    /// comes as a stream on the limits WebSocket channel, see [`crate::ws::client::BcsWsClient::connect_limits`].
329    ///
330    /// # Errors
331    ///
332    /// See [`BcsApiError`].
333    ///
334    /// # Examples
335    ///
336    /// ```
337    /// # #[tokio::main(flavor = "current_thread")]
338    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
339    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
340    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
341    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
342    ///
343    /// let client = BcsHttpClient::new(cfg)?;
344    /// let limits = client.get_limits().await?;
345    /// let rub = limits.money_limits.iter().find(|m| m.currency.as_deref() == Some("RUB"));
346    /// assert!(rub.is_some());
347    /// # Ok(())
348    /// # }
349    /// ```
350    pub async fn get_limits(&self) -> Result<LimitsResponse, BcsApiError> {
351        self.get(LIMITS_PATH).await
352    }
353}
354
355// ─── marginal ─────────────────────────────────────────────────────────────────
356
357const MARGINAL_PATH: &str = "/trade-api-bff-marginal-indicators/api/v1/instruments-discounts";
358
359impl BcsHttpClient {
360    /// Gets the margin discounts of instruments (`GET .../instruments-discounts`).
361    ///
362    /// A discount is the broker's rate for an instrument bought on margin (long) or sold short. The
363    /// broker docs give the field names but not the unit (fraction or percent), not verified against
364    /// the live API.
365    ///
366    /// # Errors
367    ///
368    /// See [`BcsApiError`].
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// # #[tokio::main(flavor = "current_thread")]
374    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
375    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
376    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
377    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
378    ///
379    /// let client = BcsHttpClient::new(cfg)?;
380    /// let discounts = client.get_instruments_discounts().await?;
381    /// assert_eq!(discounts[0].ticker, "SBER");
382    /// # Ok(())
383    /// # }
384    /// ```
385    pub async fn get_instruments_discounts(
386        &self,
387    ) -> Result<InstrumentDiscountsResponse, BcsApiError> {
388        self.get(MARGINAL_PATH).await
389    }
390}
391
392// ─── market_data ──────────────────────────────────────────────────────────────
393
394const CANDLES_PATH: &str = "/trade-api-market-data-connector/api/v1/candles-chart";
395const ORDER_BOOK_PATH: &str = "/trade-api-market-data-connector/api/v1/order-book";
396const LAST_TRADES_PATH: &str = "/trade-api-market-data-connector/api/v1/last-trades";
397const QUOTES_PATH: &str = "/trade-api-market-data-connector/api/v1/quotes";
398
399impl BcsHttpClient {
400    /// Gets historical candles: `GET .../candles-chart`.
401    ///
402    /// The broker allows at most **1440 bars** in one request. A longer interval is refused, so split
403    /// it or use a larger [`CandlesRequest::time_frame`]. The time frame names are
404    /// `M1 M5 M15 M30 H1 H4 D W MN`. Times are UTC.
405    ///
406    /// # Errors
407    ///
408    /// [`BcsApiError::ValidationError`] for a time frame the broker does not know or an interval of
409    /// more than 1440 bars. See [`BcsApiError`] for the other variants.
410    ///
411    /// # Examples
412    ///
413    /// ```
414    /// # #[tokio::main(flavor = "current_thread")]
415    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
416    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
417    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
418    /// use brokapi_bcs_protocol::error::BcsApiError;
419    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
420    /// use brokapi_bcs_protocol::http::models::CandlesRequest;
421    /// use chrono::{TimeZone, Utc};
422    ///
423    /// let client = BcsHttpClient::new(cfg)?;
424    /// let mut req = CandlesRequest {
425    ///     ticker: "SBER".into(),
426    ///     class_code: "TQBR".into(),
427    ///     start_date: Utc.with_ymd_and_hms(2026, 10, 19, 7, 0, 0).unwrap(),
428    ///     end_date: Utc.with_ymd_and_hms(2026, 10, 19, 8, 0, 0).unwrap(),
429    ///     time_frame: "M1".into(),
430    /// };
431    /// let candles = client.get_candles(&req).await?;
432    /// assert!(!candles.bars.is_empty());
433    ///
434    /// // "M10" is not a time frame of the broker.
435    /// req.time_frame = "M10".into();
436    /// let err = client.get_candles(&req).await.unwrap_err();
437    /// assert!(matches!(err, BcsApiError::ValidationError { .. }));
438    /// # Ok(())
439    /// # }
440    /// ```
441    pub async fn get_candles(
442        &self,
443        request: &CandlesRequest,
444    ) -> Result<CandlesResponse, BcsApiError> {
445        self.get_with_query(CANDLES_PATH, request).await
446    }
447
448    /// Gets a snapshot of the order book: `GET .../order-book`.
449    ///
450    /// `depth` is 1 to 20 levels per side (the broker default is 20). For a stream of changes use the
451    /// market data WebSocket, see [`crate::ws::client::BcsWsClient::subscribe_order_book`].
452    ///
453    /// # Errors
454    ///
455    /// [`BcsApiError::RateLimitExceeded`] on a bare HTTP 429. [`BcsApiError::ValidationError`] for a
456    /// `depth` outside the range or an unknown instrument. See [`BcsApiError`] for the other variants.
457    ///
458    /// # Examples
459    ///
460    /// ```
461    /// # #[tokio::main(flavor = "current_thread")]
462    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
463    /// # let options = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
464    /// # let server = brokapi_mocks::bcs::MockServer::start(options.clone()).await;
465    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
466    /// use brokapi_bcs_protocol::error::BcsApiError;
467    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
468    /// use brokapi_bcs_protocol::http::models::OrderBookRequest;
469    ///
470    /// let client = BcsHttpClient::new(cfg)?;
471    /// let req = OrderBookRequest { ticker: "SBER".into(), class_code: "TQBR".into(), depth: 10 };
472    /// let book = client.get_order_book(&req).await?;
473    /// assert!(!book.bids.is_empty() && !book.asks.is_empty());
474    ///
475    /// // When the broker says "too many requests" you get RateLimitExceeded: wait, then call again.
476    /// # options.enable_rate_limit_order_book();
477    /// let err = client.get_order_book(&req).await.unwrap_err();
478    /// assert!(matches!(err, BcsApiError::RateLimitExceeded));
479    /// # Ok(())
480    /// # }
481    /// ```
482    pub async fn get_order_book(
483        &self,
484        request: &OrderBookRequest,
485    ) -> Result<OrderBookResponse, BcsApiError> {
486        self.get_with_query(ORDER_BOOK_PATH, request).await
487    }
488
489    /// Gets the anonymous trades of the current session: `POST .../last-trades`.
490    ///
491    /// The request names the instrument and may narrow the period and the side. The broker has no
492    /// `limit` parameter. The reply is read in both shapes seen so far, `{"records": [...]}` (the
493    /// docs) and a bare array (older samples). Records carry no instrument, it is the one you asked
494    /// for.
495    ///
496    /// # Errors
497    ///
498    /// [`BcsApiError::ValidationError`] if `ticker` or `class_code` is missing. See [`BcsApiError`] for
499    /// the other variants.
500    ///
501    /// # Examples
502    ///
503    /// ```
504    /// # use rust_decimal::Decimal;
505    /// # use rust_decimal_macros::dec;
506    /// # #[tokio::main(flavor = "current_thread")]
507    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
508    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
509    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
510    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
511    /// use brokapi_bcs_protocol::http::models::LastTradesRequest;
512    ///
513    /// let client = BcsHttpClient::new(cfg)?;
514    /// let trades = client
515    ///     .get_last_trades(&LastTradesRequest {
516    ///         ticker: "SBER".into(),
517    ///         class_code: "TQBR".into(),
518    ///         ..Default::default()
519    ///     })
520    ///     .await?;
521    /// assert!(trades.iter().all(|t| t.price > dec!(0.0)));
522    /// # Ok(())
523    /// # }
524    /// ```
525    pub async fn get_last_trades(
526        &self,
527        request: &LastTradesRequest,
528    ) -> Result<LastTradesResponse, BcsApiError> {
529        let wire: LastTradesWire = self.post(LAST_TRADES_PATH, request).await?;
530        Ok(wire.into_vec())
531    }
532
533    /// Gets current quotes for a list of instruments (1 to 100): `POST .../quotes`.
534    ///
535    /// Every field of a [`QuoteRecord`] except the key may be missing, so check before you use it.
536    ///
537    /// # Errors
538    ///
539    /// [`BcsApiError::ValidationError`] for an empty list or more than 100 instruments (the docs'
540    /// range, not verified against the live API). See [`BcsApiError`] for the other variants.
541    ///
542    /// # Examples
543    ///
544    /// ```
545    /// # #[tokio::main(flavor = "current_thread")]
546    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
547    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
548    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
549    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
550    /// use brokapi_bcs_protocol::http::models::{QuoteInstrument, QuotesRequest};
551    ///
552    /// let client = BcsHttpClient::new(cfg)?;
553    /// let quotes = client
554    ///     .get_quotes(&QuotesRequest { instruments: vec![QuoteInstrument::new("SBER", "TQBR")] })
555    ///     .await?;
556    /// let sber = &quotes.records[0];
557    /// assert!(sber.bid.is_some() && sber.offer.is_some());
558    /// # Ok(())
559    /// # }
560    /// ```
561    pub async fn get_quotes(&self, request: &QuotesRequest) -> Result<QuotesResponse, BcsApiError> {
562        self.post(QUOTES_PATH, request).await
563    }
564}
565
566// ─── orders ───────────────────────────────────────────────────────────────────
567
568const ORDERS_SEARCH_PATH: &str = "/trade-api-bff-order-details/api/v1/orders";
569const ORDERS_OPERATIONS_PATH: &str = "/trade-api-bff-operations/api/v1/orders";
570
571impl BcsHttpClient {
572    /// Lists your orders by filter: `POST .../orders/search`.
573    ///
574    /// The list holds only orders placed since 2026-01-26 and at most the 1000 latest (broker docs).
575    /// `size` is 1 to 100 (default 50), `page` starts at 0. The records carry the broker's own order id
576    /// and **not** your `clientOrderId`. To find an order by your id use [`BcsHttpClient::get_order`].
577    ///
578    /// The status codes of this list differ from the codes of order events: see [`OrderRecord`].
579    ///
580    /// # Errors
581    ///
582    /// [`BcsApiError::ValidationError`] for a bad period or filter. See [`BcsApiError`] for the other
583    /// variants.
584    ///
585    /// # Examples
586    ///
587    /// ```
588    /// # use rust_decimal::Decimal;
589    /// # use rust_decimal_macros::dec;
590    /// # #[tokio::main(flavor = "current_thread")]
591    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
592    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
593    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
594    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
595    /// use brokapi_bcs_protocol::http::models::{CreateOrderRequest, OrderSearchRequest};
596    /// use brokapi_bcs_protocol::models::enums::OrderSide;
597    /// use chrono::{Duration, Utc};
598    ///
599    /// let client = BcsHttpClient::new(cfg)?;
600    /// # let cid = uuid::Uuid::new_v4();
601    /// # client.create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0))).await?;
602    /// let now = Utc::now();
603    /// let request = OrderSearchRequest {
604    ///     start_date_time: now - Duration::hours(24),
605    ///     end_date_time: now,
606    ///     side: None,
607    ///     order_status: Some(vec![3]), // 3 = active in the order list
608    ///     order_types: None,
609    ///     tickers: Some(vec!["SBER".into()]),
610    ///     class_codes: None,
611    /// };
612    /// let page = client.search_orders(request, Some(0), Some(50)).await?;
613    /// assert_eq!(page.records.len(), 1);
614    /// # Ok(())
615    /// # }
616    /// ```
617    pub async fn search_orders(
618        &self,
619        request: OrderSearchRequest,
620        page: Option<i32>,
621        size: Option<i32>,
622    ) -> Result<OrderSearchResponse, BcsApiError> {
623        let mut url = format!("{}/search", ORDERS_SEARCH_PATH);
624        let mut query_params = vec![];
625        if let Some(p) = page {
626            query_params.push(format!("page={}", p));
627        }
628        if let Some(s) = size {
629            query_params.push(format!("size={}", s));
630        }
631        if !query_params.is_empty() {
632            url.push('?');
633            url.push_str(&query_params.join("&"));
634        }
635        self.post(&url, &Some(&request)).await
636    }
637
638    /// Lists your orders of the last `hours` hours, optionally for some tickers.
639    ///
640    /// A shortcut over [`BcsHttpClient::search_orders`]: the period is `now - hours` to `now` by the
641    /// local clock, there is no other filter, and the first page of the default size (50) is returned.
642    /// It does not read further pages: when `total_pages` of the answer is above 1, call
643    /// [`BcsHttpClient::search_orders`] yourself.
644    ///
645    /// # Errors
646    ///
647    /// The same as [`BcsHttpClient::search_orders`].
648    ///
649    /// # Examples
650    ///
651    /// ```
652    /// # #[tokio::main(flavor = "current_thread")]
653    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
654    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
655    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
656    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
657    ///
658    /// let client = BcsHttpClient::new(cfg)?;
659    /// let recent = client.search_orders_simple(Some(vec!["SBER".to_string()]), 24).await?;
660    /// assert_eq!(recent.total_records, 0); // a fresh mock account has no orders
661    /// # Ok(())
662    /// # }
663    /// ```
664    pub async fn search_orders_simple(
665        &self,
666        tickers: Option<Vec<String>>,
667        hours: i64,
668    ) -> Result<OrderSearchResponse, BcsApiError> {
669        let end = Utc::now();
670        let start = end - chrono::Duration::hours(hours);
671        let request = OrderSearchRequest {
672            start_date_time: start,
673            end_date_time: end,
674            side: None,
675            order_status: None,
676            order_types: None,
677            tickers,
678            class_codes: None,
679        };
680        self.search_orders(request, None, None).await
681    }
682
683    /// Places an order and returns when the broker has **accepted the request**: `POST .../orders`.
684    ///
685    /// Needs a write token ([`crate::config::BcsConfig::is_write`]). The reply (`status`) only
686    /// confirms that the request was taken. It does not say that the order is on the exchange or
687    /// filled. The state comes later on the order events channel
688    /// ([`crate::ws::client::BcsWsClient::connect_order_events`]) or from [`BcsHttpClient::get_order`].
689    /// To get the state in the reply use [`BcsHttpClient::create_order_sync`].
690    ///
691    /// Your `client_order_id` is the id you must keep. The broker docs say it is used to track the
692    /// order and to prevent duplicates. The quantity is in pieces, not in lots.
693    ///
694    /// # No automatic retry
695    ///
696    /// If the call ends with a timeout or a transport error, **do not send it again**. The order may
697    /// exist. A repeat could place a second order. Look the order up first with
698    /// [`BcsHttpClient::get_order`] by the same `client_order_id`: an answer means it exists,
699    /// [`BcsApiError::NotFound`] means it does not (not verified against the live API how long the
700    /// broker needs before the order becomes visible).
701    ///
702    /// # Errors
703    ///
704    /// * [`BcsApiError::ValidationError`]: the broker refused the order, nothing was placed.
705    /// * [`BcsApiError::HttpError`] with `FORBIDDEN` or `USER_BLOCKED` in `message`: no trading rights,
706    ///   for example a read token (not verified against the live API which type the broker uses).
707    /// * [`BcsApiError::RateLimitExceeded`] or [`BcsApiError::HttpError`] with status 429: too many
708    ///   requests. The broker also caps orders per day (200000 by its docs).
709    /// * [`BcsApiError::HttpError`] without a status or [`BcsApiError::ParseError`]: unknown outcome,
710    ///   see above.
711    ///
712    /// # Examples
713    ///
714    /// ```
715    /// # use rust_decimal::Decimal;
716    /// # use rust_decimal_macros::dec;
717    /// # #[tokio::main(flavor = "current_thread")]
718    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
719    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
720    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
721    /// use brokapi_bcs_protocol::error::BcsApiError;
722    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
723    /// use brokapi_bcs_protocol::http::models::CreateOrderRequest;
724    /// use brokapi_bcs_protocol::models::enums::OrderSide;
725    /// use uuid::Uuid;
726    ///
727    /// let client = BcsHttpClient::new(cfg)?;
728    ///
729    /// // Buy 1 piece of SBER at 300.00. Keep `cid`: it is how you find this order later.
730    /// let cid = Uuid::new_v4();
731    /// let ack = client
732    ///     .create_order(CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0)))
733    ///     .await?;
734    /// assert_eq!(ack.client_order_id, cid);
735    ///
736    /// // A refusal is a ValidationError and means "no order". (The imitation refuses this price.)
737    /// let refused = client
738    ///     .create_order(CreateOrderRequest::limit(
739    ///         Uuid::new_v4(), "SBER", "TQBR", OrderSide::Buy, 1, brokapi_conformance::REJECT_PRICE.decimal(),
740    ///     ))
741    ///     .await;
742    /// assert!(matches!(refused, Err(BcsApiError::ValidationError { .. })));
743    /// # Ok(())
744    /// # }
745    /// ```
746    pub async fn create_order(
747        &self,
748        request: CreateOrderRequest,
749    ) -> Result<OrderOperationResponse, BcsApiError> {
750        self.post_command(ORDERS_OPERATIONS_PATH, &Some(&request))
751            .await
752    }
753
754    /// Cancels an order, the **deprecated** form with the id in the path: `POST .../orders/{id}/cancel`.
755    ///
756    /// The broker docs list this method in their deprecated section. Use
757    /// [`BcsHttpClient::cancel_order_by_id`]. The in-process imitation does not serve the deprecated
758    /// forms, so this method has no runnable example. `original_client_order_id` is the
759    /// `client_order_id` of the order to cancel, `request.client_order_id` is the id of this request.
760    ///
761    /// # Errors
762    ///
763    /// See [`BcsApiError`]. A cancel that timed out has an unknown outcome: ask for the order with
764    /// [`BcsHttpClient::get_order`] before you decide.
765    pub async fn cancel_order(
766        &self,
767        original_client_order_id: Uuid,
768        request: CancelOrderRequest,
769    ) -> Result<OrderOperationResponse, BcsApiError> {
770        let url = format!(
771            "{}/{}/cancel",
772            ORDERS_OPERATIONS_PATH, original_client_order_id
773        );
774        self.post_command(&url, &Some(&request)).await
775    }
776
777    /// Changes an order, the **deprecated** form with the id in the path: `POST .../orders/{id}`.
778    ///
779    /// The broker docs list this method in their deprecated section. Use [`BcsHttpClient::edit_order`].
780    /// The in-process imitation does not serve the deprecated forms, so this method has no runnable
781    /// example.
782    ///
783    /// # Errors
784    ///
785    /// See [`BcsApiError`].
786    pub async fn modify_order(
787        &self,
788        original_client_order_id: Uuid,
789        request: ModifyOrderRequest,
790    ) -> Result<OrderOperationResponse, BcsApiError> {
791        let url = format!("{}/{}", ORDERS_OPERATIONS_PATH, original_client_order_id);
792        self.post_command(&url, &Some(&request)).await
793    }
794
795    /// Places an order and waits for the broker to process it: `POST .../orders/sync`.
796    ///
797    /// The body is the same as for [`BcsHttpClient::create_order`]. The reply is the current state of
798    /// the order, in the format of [`BcsHttpClient::get_order`]. A refusal of the exchange can show up
799    /// as an order in status "rejected" (`orderStatus = 8`) with a `rejectReason` and a normal HTTP 200,
800    /// and not as an error (not verified against the live API). Check
801    /// [`OrderExecutionData::order_status`] and [`OrderExecutionData::reject_reason`], not only `Ok`.
802    ///
803    /// The rules for timeouts are the same as for [`BcsHttpClient::create_order`]: **never repeat** a
804    /// placement that ended without an answer. Look the order up first.
805    ///
806    /// # Errors
807    ///
808    /// The same as [`BcsHttpClient::create_order`].
809    ///
810    /// # Examples
811    ///
812    /// ```
813    /// # use rust_decimal::Decimal;
814    /// # use rust_decimal_macros::dec;
815    /// # #[tokio::main(flavor = "current_thread")]
816    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
817    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
818    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
819    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
820    /// use brokapi_bcs_protocol::http::models::CreateOrderRequest;
821    /// use brokapi_bcs_protocol::models::enums::OrderSide;
822    /// use uuid::Uuid;
823    ///
824    /// let client = BcsHttpClient::new(cfg)?;
825    /// let cid = Uuid::new_v4();
826    /// let state = client
827    ///     .create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0)))
828    ///     .await?;
829    /// let report = &state.data[0];
830    /// assert_eq!(report.order_status.as_deref(), Some("0")); // 0 = new
831    /// assert_eq!(report.ticker.as_deref(), Some("SBER"));
832    /// # Ok(())
833    /// # }
834    /// ```
835    pub async fn create_order_sync(
836        &self,
837        request: &CreateOrderRequest,
838    ) -> Result<OrderStateListResponse, BcsApiError> {
839        self.post_command(&format!("{}/sync", ORDERS_OPERATIONS_PATH), request)
840            .await
841    }
842
843    /// Cancels an order named in the body: `POST .../orders/cancel`.
844    ///
845    /// This is the current form (the id is in the body, not in the path). The reply only confirms that
846    /// the cancel request was taken. The order is cancelled when its state says so: watch the order
847    /// events channel or call [`BcsHttpClient::get_order`], or use [`BcsHttpClient::cancel_order_by_id_sync`].
848    /// The broker allows to cancel orders in status new (0), partly filled (1), replaced (5) and
849    /// waiting for confirmation (10).
850    ///
851    /// A cancel that ended without an answer has an unknown outcome. Asking again is harmless in
852    /// principle (the order is cancelled once), but check the order first.
853    ///
854    /// # Errors
855    ///
856    /// * [`BcsApiError::NotFound`]: no such order (not verified against the live API).
857    /// * [`BcsApiError::ValidationError`]: the order is not in a state that can be cancelled.
858    /// * Other variants as in [`BcsApiError`].
859    ///
860    /// # Examples
861    ///
862    /// ```
863    /// # use rust_decimal::Decimal;
864    /// # use rust_decimal_macros::dec;
865    /// # #[tokio::main(flavor = "current_thread")]
866    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
867    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
868    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
869    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
870    /// use brokapi_bcs_protocol::http::models::{CancelByIdRequest, CreateOrderRequest};
871    /// use brokapi_bcs_protocol::models::enums::OrderSide;
872    /// use uuid::Uuid;
873    ///
874    /// let client = BcsHttpClient::new(cfg)?;
875    /// let cid = Uuid::new_v4();
876    /// client
877    ///     .create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0)))
878    ///     .await?;
879    ///
880    /// // `cid` names the order, the second id names this cancel request.
881    /// let ack = client
882    ///     .cancel_order_by_id(&CancelByIdRequest::by_client_order_id(cid, Uuid::new_v4()))
883    ///     .await?;
884    /// assert_eq!(ack.status, "OK");
885    /// # Ok(())
886    /// # }
887    /// ```
888    pub async fn cancel_order_by_id(
889        &self,
890        request: &CancelByIdRequest,
891    ) -> Result<OrderOperationResponse, BcsApiError> {
892        self.post_command(&format!("{}/cancel", ORDERS_OPERATIONS_PATH), request)
893            .await
894    }
895
896    /// Cancels an order and waits for the result: `POST .../orders/cancel/sync`.
897    ///
898    /// The reply is the order state after the cancel, in the format of [`BcsHttpClient::get_order`]:
899    /// look at [`OrderExecutionData::order_status`] (`4` = cancelled). Rules and errors are as for
900    /// [`BcsHttpClient::cancel_order_by_id`].
901    ///
902    /// # Errors
903    ///
904    /// The same as [`BcsHttpClient::cancel_order_by_id`].
905    ///
906    /// # Examples
907    ///
908    /// ```
909    /// # use rust_decimal::Decimal;
910    /// # use rust_decimal_macros::dec;
911    /// # #[tokio::main(flavor = "current_thread")]
912    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
913    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
914    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
915    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
916    /// use brokapi_bcs_protocol::http::models::{CancelByIdRequest, CreateOrderRequest};
917    /// use brokapi_bcs_protocol::models::enums::OrderSide;
918    /// use uuid::Uuid;
919    ///
920    /// let client = BcsHttpClient::new(cfg)?;
921    /// let cid = Uuid::new_v4();
922    /// client
923    ///     .create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0)))
924    ///     .await?;
925    /// let state = client
926    ///     .cancel_order_by_id_sync(&CancelByIdRequest::by_client_order_id(cid, Uuid::new_v4()))
927    ///     .await?;
928    /// assert_eq!(state.data[0].order_status.as_deref(), Some("4")); // 4 = cancelled
929    /// # Ok(())
930    /// # }
931    /// ```
932    pub async fn cancel_order_by_id_sync(
933        &self,
934        request: &CancelByIdRequest,
935    ) -> Result<OrderStateListResponse, BcsApiError> {
936        self.post_command(&format!("{}/cancel/sync", ORDERS_OPERATIONS_PATH), request)
937            .await
938    }
939
940    /// Changes an order: `POST .../orders/edit`.
941    ///
942    /// The broker does the change as a **cancel of the original and a new order** (broker docs). So the
943    /// `client_order_id` of the request is the id of the **new** order, and the original ends in status
944    /// "replaced" (5, by the status table of the docs, not verified against the live API). Orders in
945    /// status new (0), partly filled (1), replaced (5) and waiting for confirmation (10) can be changed. The reply only confirms that the request was taken.
946    ///
947    /// Because it creates an order, an edit that ended without an answer has an unknown outcome and
948    /// must **not** be repeated before you look at the orders.
949    ///
950    /// # Errors
951    ///
952    /// As for [`BcsHttpClient::create_order`], plus [`BcsApiError::NotFound`] for an unknown original
953    /// order (not verified against the live API).
954    ///
955    /// # Examples
956    ///
957    /// ```
958    /// # use rust_decimal::Decimal;
959    /// # use rust_decimal_macros::dec;
960    /// # #[tokio::main(flavor = "current_thread")]
961    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
962    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
963    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
964    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
965    /// use brokapi_bcs_protocol::http::models::{CreateOrderRequest, EditOrderRequest, OrderExtras};
966    /// use brokapi_bcs_protocol::models::enums::OrderSide;
967    /// use uuid::Uuid;
968    ///
969    /// let client = BcsHttpClient::new(cfg)?;
970    /// let cid = Uuid::new_v4();
971    /// client
972    ///     .create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0)))
973    ///     .await?;
974    ///
975    /// let new_cid = Uuid::new_v4();
976    /// let ack = client
977    ///     .edit_order(&EditOrderRequest {
978    ///         order_id_type: Some("1".into()),
979    ///         order_id: cid.to_string(),
980    ///         client_order_id: new_cid,
981    ///         order_type: None,
982    ///         price: Some(dec!(301.0)),
983    ///         order_quantity: 1,
984    ///         extras: OrderExtras::default(),
985    ///     })
986    ///     .await?;
987    /// assert_eq!(ack.client_order_id, new_cid);
988    /// # Ok(())
989    /// # }
990    /// ```
991    pub async fn edit_order(
992        &self,
993        request: &EditOrderRequest,
994    ) -> Result<OrderOperationResponse, BcsApiError> {
995        self.post_command(&format!("{}/edit", ORDERS_OPERATIONS_PATH), request)
996            .await
997    }
998
999    /// Changes an order and waits for the result: `POST .../orders/edit/sync`.
1000    ///
1001    /// The reply is the state of the **new** order, in the format of [`BcsHttpClient::get_order`].
1002    /// Everything else is as for [`BcsHttpClient::edit_order`].
1003    ///
1004    /// # Errors
1005    ///
1006    /// The same as [`BcsHttpClient::edit_order`].
1007    ///
1008    /// # Examples
1009    ///
1010    /// ```
1011    /// # use rust_decimal::Decimal;
1012    /// # use rust_decimal_macros::dec;
1013    /// # #[tokio::main(flavor = "current_thread")]
1014    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1015    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
1016    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1017    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
1018    /// use brokapi_bcs_protocol::http::models::{CreateOrderRequest, EditOrderRequest, OrderExtras};
1019    /// use brokapi_bcs_protocol::models::enums::OrderSide;
1020    /// use uuid::Uuid;
1021    ///
1022    /// let client = BcsHttpClient::new(cfg)?;
1023    /// let cid = Uuid::new_v4();
1024    /// client
1025    ///     .create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0)))
1026    ///     .await?;
1027    ///
1028    /// let state = client
1029    ///     .edit_order_sync(&EditOrderRequest {
1030    ///         order_id_type: None, // default: the id is the clientOrderId of the original
1031    ///         order_id: cid.to_string(),
1032    ///         client_order_id: Uuid::new_v4(),
1033    ///         order_type: None,
1034    ///         price: Some(dec!(301.0)),
1035    ///         order_quantity: 2,
1036    ///         extras: OrderExtras::default(),
1037    ///     })
1038    ///     .await?;
1039    /// assert_eq!(state.original_client_order_id.as_deref(), Some(cid.to_string().as_str()));
1040    /// # Ok(())
1041    /// # }
1042    /// ```
1043    pub async fn edit_order_sync(
1044        &self,
1045        request: &EditOrderRequest,
1046    ) -> Result<OrderStateListResponse, BcsApiError> {
1047        self.post_command(&format!("{}/edit/sync", ORDERS_OPERATIONS_PATH), request)
1048            .await
1049    }
1050
1051    /// Gets the current state of one order: `GET .../orders?orderIdType=&orderId=`.
1052    ///
1053    /// `order_id_type` is `"1"` for your `clientOrderId` (the broker default) or `"2"` for the
1054    /// exchange order id (format `YYMMDD-ClassCode-OrderNumber`). It works for orders of any
1055    /// supported type. The reply holds a list of reports. The docs do not say how many reports the list
1056    /// has, so read the last one if you need the latest state.
1057    ///
1058    /// This is the method to ask after a placement that timed out. [`BcsApiError::NotFound`] (HTTP 404)
1059    /// means the broker has no such order.
1060    ///
1061    /// # Errors
1062    ///
1063    /// [`BcsApiError::NotFound`] if there is no such order. See [`BcsApiError`] for the other variants.
1064    ///
1065    /// # Examples
1066    ///
1067    /// ```
1068    /// # use rust_decimal::Decimal;
1069    /// # use rust_decimal_macros::dec;
1070    /// # #[tokio::main(flavor = "current_thread")]
1071    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1072    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
1073    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1074    /// use brokapi_bcs_protocol::error::BcsApiError;
1075    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
1076    /// use brokapi_bcs_protocol::http::models::CreateOrderRequest;
1077    /// use brokapi_bcs_protocol::models::enums::OrderSide;
1078    /// use uuid::Uuid;
1079    ///
1080    /// let client = BcsHttpClient::new(cfg)?;
1081    /// let cid = Uuid::new_v4();
1082    /// client
1083    ///     .create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0)))
1084    ///     .await?;
1085    ///
1086    /// let state = client.get_order("1", &cid.to_string()).await?;
1087    /// assert_eq!(state.data[0].executed_quantity, Some(dec!(0.0)));
1088    ///
1089    /// // An id the broker does not know.
1090    /// let missing = client.get_order("1", &Uuid::new_v4().to_string()).await;
1091    /// assert!(matches!(missing, Err(BcsApiError::NotFound { .. })));
1092    /// # Ok(())
1093    /// # }
1094    /// ```
1095    pub async fn get_order(
1096        &self,
1097        order_id_type: &str,
1098        order_id: &str,
1099    ) -> Result<OrderStateListResponse, BcsApiError> {
1100        let query = [("orderIdType", order_id_type), ("orderId", order_id)];
1101        self.get_with_query(ORDERS_OPERATIONS_PATH, &query).await
1102    }
1103
1104    /// Gets the state of an order, the **deprecated** form with the id in the path: `GET .../orders/{id}`.
1105    ///
1106    /// The broker docs list this method in their deprecated section. Use [`BcsHttpClient::get_order`].
1107    /// The in-process imitation does not serve the deprecated forms, so this method has no runnable
1108    /// example. Unlike the current form, `data` of the reply is a single object.
1109    ///
1110    /// # Errors
1111    ///
1112    /// [`BcsApiError::NotFound`] if there is no such order. See [`BcsApiError`] for the other variants.
1113    pub async fn get_order_status(
1114        &self,
1115        original_client_order_id: Uuid,
1116    ) -> Result<OrderStatusResponse, BcsApiError> {
1117        let url = format!("{}/{}", ORDERS_OPERATIONS_PATH, original_client_order_id);
1118        self.get(&url).await
1119    }
1120}
1121
1122// ─── portfolio ────────────────────────────────────────────────────────────────
1123
1124const PORTFOLIO_PATH: &str = "/trade-api-bff-portfolio/api/v1/portfolio";
1125
1126impl BcsHttpClient {
1127    /// Gets the portfolio: one record per position and per currency balance (`GET .../portfolio`).
1128    ///
1129    /// The list holds positions of several kinds, told apart by [`PortfolioPosition::position_type`].
1130    /// Many fields are absent for some kinds, so they are all optional. The same data streams on the
1131    /// portfolio WebSocket channel, see [`crate::ws::client::BcsWsClient::connect_portfolio`].
1132    ///
1133    /// # Errors
1134    ///
1135    /// See [`BcsApiError`].
1136    ///
1137    /// # Examples
1138    ///
1139    /// ```
1140    /// # #[tokio::main(flavor = "current_thread")]
1141    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1142    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
1143    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
1144    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
1145    ///
1146    /// let client = BcsHttpClient::new(cfg)?;
1147    /// let positions = client.get_portfolio().await?;
1148    /// assert!(!positions.is_empty());
1149    /// # Ok(())
1150    /// # }
1151    /// ```
1152    pub async fn get_portfolio(&self) -> Result<PortfolioResponse, BcsApiError> {
1153        self.get(PORTFOLIO_PATH).await
1154    }
1155}
1156
1157// ─── trades ───────────────────────────────────────────────────────────────────
1158
1159const TRADES_BASE: &str = "/trade-api-bff-trade-details/api/v1/trades";
1160
1161impl BcsHttpClient {
1162    /// Lists your own trades (fills) by filter: `POST .../trades/search`.
1163    ///
1164    /// The list holds trades made since 2026-01-26 and at most the 1000 latest (broker docs). `size`
1165    /// is 1 to 100 (default 50), `page` starts at 0. A record has the exchange order number and the
1166    /// trade number but **not** your `clientOrderId`, and no commission.
1167    ///
1168    /// # Errors
1169    ///
1170    /// See [`BcsApiError`].
1171    ///
1172    /// # Examples
1173    ///
1174    /// ```
1175    /// # use rust_decimal::Decimal;
1176    /// # use rust_decimal_macros::dec;
1177    /// # #[tokio::main(flavor = "current_thread")]
1178    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1179    /// # let options = std::sync::Arc::new(brokapi_mocks::bcs::MockOptions::default());
1180    /// # let server = brokapi_mocks::bcs::MockServer::start(options).await;
1181    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
1182    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
1183    /// use brokapi_bcs_protocol::http::models::{CreateOrderRequest, TradeSearchRequest};
1184    /// use brokapi_bcs_protocol::models::enums::OrderSide;
1185    /// use uuid::Uuid;
1186    ///
1187    /// let client = BcsHttpClient::new(cfg)?;
1188    /// # let cid = Uuid::new_v4();
1189    /// # client.create_order_sync(&CreateOrderRequest::limit(cid, "SBER", "TQBR", OrderSide::Buy, 1, dec!(300.0))).await?;
1190    /// # server.fill(&cid.to_string(), brokapi_core::Qty::from(1), brokapi_core::Price::from(300)); // the imitation fills the order
1191    /// let trades = client
1192    ///     .search_trades(
1193    ///         &TradeSearchRequest {
1194    ///             start_date_time: "2026-01-26T00:00:00.000Z".into(),
1195    ///             end_date_time: "2099-01-01T00:00:00.000Z".into(),
1196    ///             side: None,
1197    ///             tickers: Some(vec!["SBER".into()]),
1198    ///         },
1199    ///         Some(50),
1200    ///         Some(0),
1201    ///     )
1202    ///     .await?;
1203    /// assert_eq!(trades.records.len(), 1);
1204    /// assert_eq!(trades.records[0].price, Some(dec!(300.0)));
1205    /// # Ok(())
1206    /// # }
1207    /// ```
1208    pub async fn search_trades(
1209        &self,
1210        req: &TradeSearchRequest,
1211        size: Option<u32>,
1212        page: Option<u32>,
1213    ) -> Result<PaginatedResponse<TradeRecord>, BcsApiError> {
1214        let mut path = format!("{}/search", TRADES_BASE);
1215        let mut params = Vec::new();
1216        if let Some(s) = size {
1217            params.push(format!("size={}", s));
1218        }
1219        if let Some(p) = page {
1220            params.push(format!("page={}", p));
1221        }
1222        if !params.is_empty() {
1223            path = format!("{}?{}", path, params.join("&"));
1224        }
1225        self.post(&path, req).await
1226    }
1227}
1228
1229// ─── non-trade operations ─────────────────────────────────────────────────────
1230
1231const OPERATIONS_SEARCH_PATH: &str = "/trade-api-bff-nontrade-operations/api/v1/operations/search";
1232
1233impl BcsHttpClient {
1234    /// Lists non-trade operations: deposits, withdrawals, dividends, coupons, commissions, taxes
1235    /// (`POST .../operations/search`).
1236    ///
1237    /// All filters in the request are optional. `size` (1 to 100, default 50) and `page` (from 0) are
1238    /// sent in the query string. The broker docs mark them as path parameters, but the path of the
1239    /// method has none (the same as for the trades search), so the query is an assumption that is not
1240    /// verified against the live API. This service has the tightest rate limit of the API, 3 requests
1241    /// per second.
1242    ///
1243    /// # Errors
1244    ///
1245    /// See [`BcsApiError`].
1246    ///
1247    /// # Examples
1248    ///
1249    /// ```
1250    /// # #[tokio::main(flavor = "current_thread")]
1251    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1252    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
1253    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
1254    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
1255    /// use brokapi_bcs_protocol::http::models::OperationHistoryRequest;
1256    ///
1257    /// let client = BcsHttpClient::new(cfg)?;
1258    /// let history = client
1259    ///     .search_operations(
1260    ///         &OperationHistoryRequest {
1261    ///             operation_types: Some(vec!["PayIn".into()]),
1262    ///             ..Default::default()
1263    ///         },
1264    ///         Some(20),
1265    ///         Some(0),
1266    ///     )
1267    ///     .await?;
1268    /// assert_eq!(history.records[0].operation_type.as_deref(), Some("PayIn"));
1269    /// # Ok(())
1270    /// # }
1271    /// ```
1272    pub async fn search_operations(
1273        &self,
1274        req: &OperationHistoryRequest,
1275        size: Option<u32>,
1276        page: Option<u32>,
1277    ) -> Result<OperationHistoryResponse, BcsApiError> {
1278        let mut path = OPERATIONS_SEARCH_PATH.to_string();
1279        let mut params = Vec::new();
1280        if let Some(s) = size {
1281            params.push(format!("size={}", s));
1282        }
1283        if let Some(p) = page {
1284            params.push(format!("page={}", p));
1285        }
1286        if !params.is_empty() {
1287            path.push('?');
1288            path.push_str(&params.join("&"));
1289        }
1290        self.post(&path, req).await
1291    }
1292}