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(¶ms.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(¶ms.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 = "es.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(¶ms.join("&"));
1289 }
1290 self.post(&path, req).await
1291 }
1292}