brokapi_alor_protocol/http/endpoints.rs
1//! One typed method per REST endpoint of the ALOR OpenAPI, as `impl` blocks of [`AlorHttpClient`].
2//!
3//! All of the 58 operations of the broker's OpenAPI file are here: the reference data and market data, the
4//! reads of the portfolio (orders, trades, positions, summary, risks), the order commands (limit, market, stop
5//! and stop-limit orders, cancel, the legacy stop-loss and take-profit family), the order groups and the
6//! estimates. The methods are thin: they build the URL, call [`AlorHttpClient::read`] or
7//! [`AlorHttpClient::command`] and return the broker's model. They do not retry (apart from the one repeat of
8//! a read after a 401), do not throttle and do not interpret the reply.
9//!
10//! The replies that the connector does not use are returned as [`serde_json::Value`] (numbers keep their
11//! digits). The query parameters that a method does not name are given in its `query` argument as pairs of
12//! the broker's names, for example `[("limit", "10".to_string())]`.
13//!
14//! Every read asks for the `Simple` format of the broker (the original field names).
15
16use reqwest::Method;
17use serde_json::Value;
18
19use crate::error::AlorApiError;
20use crate::http::client::AlorHttpClient;
21use crate::http::models::*;
22
23/// Percent-encodes one segment of a URL path.
24fn enc(segment: &str) -> String {
25 let mut out = String::with_capacity(segment.len());
26 for b in segment.bytes() {
27 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
28 out.push(b as char);
29 } else {
30 out.push_str(&format!("%{b:02X}"));
31 }
32 }
33 out
34}
35
36fn simple(mut query: Vec<(&'static str, String)>) -> Vec<(&'static str, String)> {
37 query.push(("format", "Simple".to_string()));
38 query
39}
40
41fn opt(query: &mut Vec<(&'static str, String)>, key: &'static str, value: Option<impl ToString>) {
42 if let Some(v) = value {
43 query.push((key, v.to_string()));
44 }
45}
46
47fn with_extra<'a>(
48 mut base: Vec<(&'a str, String)>,
49 extra: &'a [(&'a str, String)],
50) -> Vec<(&'a str, String)> {
51 base.extend(extra.iter().map(|(k, v)| (*k, v.clone())));
52 base
53}
54
55// ─── reference data ───────────────────────────────────────────────────────────
56
57impl AlorHttpClient {
58 /// `GET /md/v2/Securities`: a search over the instruments of all markets. `query` takes `query` (a
59 /// ticker part), `limit`, `offset`, `sector` (`FORTS`, `FOND`, `CURR`), `cficode`, `exchange`,
60 /// `instrumentGroup` and `includeNonBaseBoards`.
61 ///
62 /// # Errors
63 ///
64 /// As [`AlorHttpClient::read`].
65 pub async fn get_securities(
66 &self,
67 query: &[(&str, String)],
68 ) -> Result<Vec<SecurityInfo>, AlorApiError> {
69 self.read("/md/v2/Securities", &with_extra(simple(vec![]), query))
70 .await
71 }
72
73 /// `GET /md/v2/Securities/{exchange}`: the instruments of one exchange. `query` is as in
74 /// [`AlorHttpClient::get_securities`] (`limit`, `offset`, `sector`, ...).
75 ///
76 /// # Errors
77 ///
78 /// As [`AlorHttpClient::read`].
79 pub async fn get_securities_by_exchange(
80 &self,
81 exchange: &str,
82 query: &[(&str, String)],
83 ) -> Result<Vec<SecurityInfo>, AlorApiError> {
84 self.read(
85 &format!("/md/v2/Securities/{}", enc(exchange)),
86 &with_extra(simple(vec![]), query),
87 )
88 .await
89 }
90
91 /// `GET /md/v2/Securities/{exchange}/{symbol}`: one instrument, in the trading mode `group` (the main one if
92 /// left out).
93 ///
94 /// # Errors
95 ///
96 /// As [`AlorHttpClient::read`]; [`AlorApiError::NotFound`] for an unknown instrument.
97 ///
98 /// # Examples
99 ///
100 /// ```
101 /// # #[tokio::main(flavor = "current_thread")]
102 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
103 /// # let server = brokapi_mocks::alor::MockServer::start(Default::default()).await;
104 /// # let cfg = brokapi_alor_protocol::config::AlorConfig::for_mock("mock-refresh-token", &server.http_base);
105 /// use brokapi_alor_protocol::http::client::AlorHttpClient;
106 ///
107 /// let client = AlorHttpClient::new(cfg)?;
108 /// let sber = client.get_security("MOEX", "SBER", Some("TQBR")).await?;
109 /// assert_eq!(sber.symbol, "SBER");
110 /// assert!(sber.lot_size.is_some());
111 /// # Ok(())
112 /// # }
113 /// ```
114 pub async fn get_security(
115 &self,
116 exchange: &str,
117 symbol: &str,
118 group: Option<&str>,
119 ) -> Result<SecurityInfo, AlorApiError> {
120 let mut q = simple(vec![]);
121 opt(&mut q, "instrumentGroup", group);
122 self.read(
123 &format!("/md/v2/Securities/{}/{}", enc(exchange), enc(symbol)),
124 &q,
125 )
126 .await
127 }
128
129 /// `GET /md/v2/Securities/{exchange}/{symbol}/availableBoards`: the trading modes the instrument trades in.
130 ///
131 /// # Errors
132 ///
133 /// As [`AlorHttpClient::read`].
134 pub async fn get_available_boards(
135 &self,
136 exchange: &str,
137 symbol: &str,
138 ) -> Result<Value, AlorApiError> {
139 self.read(
140 &format!(
141 "/md/v2/Securities/{}/{}/availableBoards",
142 enc(exchange),
143 enc(symbol)
144 ),
145 &simple(vec![]),
146 )
147 .await
148 }
149
150 /// `GET /md/v2/Securities/{symbols}/quotes`: the quotations of several instruments. `symbols` are pairs of
151 /// exchange and ticker.
152 ///
153 /// # Errors
154 ///
155 /// As [`AlorHttpClient::read`].
156 pub async fn get_quotes(
157 &self,
158 symbols: &[(&str, &str)],
159 ) -> Result<Vec<QuoteRecord>, AlorApiError> {
160 let joined = symbols
161 .iter()
162 .map(|(e, s)| format!("{}:{}", enc(e), enc(s)))
163 .collect::<Vec<_>>()
164 .join(",");
165 self.read(
166 &format!("/md/v2/Securities/{joined}/quotes"),
167 &simple(vec![]),
168 )
169 .await
170 }
171
172 /// `GET /md/v2/Securities/currencyPairs`: the currency pairs.
173 ///
174 /// # Errors
175 ///
176 /// As [`AlorHttpClient::read`].
177 pub async fn get_currency_pairs(&self) -> Result<Value, AlorApiError> {
178 self.read("/md/v2/Securities/currencyPairs", &simple(vec![]))
179 .await
180 }
181
182 /// `GET /md/v2/Securities/{exchange}/{symbol}/actualFuturesQuote`: the quotation of the nearest futures of
183 /// an underlying.
184 ///
185 /// # Errors
186 ///
187 /// As [`AlorHttpClient::read`].
188 pub async fn get_actual_futures_quote(
189 &self,
190 exchange: &str,
191 symbol: &str,
192 ) -> Result<Value, AlorApiError> {
193 self.read(
194 &format!(
195 "/md/v2/Securities/{}/{}/actualFuturesQuote",
196 enc(exchange),
197 enc(symbol)
198 ),
199 &simple(vec![]),
200 )
201 .await
202 }
203
204 /// `GET /md/v2/risk/rates`: the risk rates of instruments. `query` takes `exchange`, `ticker`, `riskCategoryId`,
205 /// `search`, `limit`, `offset`, ...
206 ///
207 /// # Errors
208 ///
209 /// As [`AlorHttpClient::read`].
210 pub async fn get_risk_rates(&self, query: &[(&str, String)]) -> Result<Value, AlorApiError> {
211 self.read("/md/v2/risk/rates", &with_extra(simple(vec![]), query))
212 .await
213 }
214
215 /// `GET /md/v2/time`: the server time, Unix seconds. The broker asks for it rarely (the time is not exact).
216 ///
217 /// # Errors
218 ///
219 /// As [`AlorHttpClient::read`].
220 pub async fn get_time(&self) -> Result<i64, AlorApiError> {
221 self.read("/md/v2/time", &[]).await
222 }
223
224 /// `GET /md/status/{exchange}/{market}`: the state of the trading session of a market (`FORTS`, `FOND`, `CURR`,
225 /// `SPBX`).
226 ///
227 /// # Errors
228 ///
229 /// As [`AlorHttpClient::read`].
230 pub async fn get_market_status(
231 &self,
232 exchange: &str,
233 market: &str,
234 ) -> Result<MarketStatus, AlorApiError> {
235 self.read(
236 &format!("/md/status/{}/{}", enc(exchange), enc(market)),
237 &simple(vec![]),
238 )
239 .await
240 }
241}
242
243// ─── market data ──────────────────────────────────────────────────────────────
244
245impl AlorHttpClient {
246 /// `GET /md/v2/orderbooks/{exchange}/{symbol}`: the order book. `depth` is 20 by default and 50 at most.
247 ///
248 /// # Errors
249 ///
250 /// As [`AlorHttpClient::read`].
251 pub async fn get_order_book(
252 &self,
253 exchange: &str,
254 symbol: &str,
255 group: Option<&str>,
256 depth: Option<u32>,
257 ) -> Result<OrderBook, AlorApiError> {
258 let mut q = simple(vec![]);
259 opt(&mut q, "instrumentGroup", group);
260 opt(&mut q, "depth", depth);
261 self.read(
262 &format!("/md/v2/orderbooks/{}/{}", enc(exchange), enc(symbol)),
263 &q,
264 )
265 .await
266 }
267
268 /// `GET /md/v2/history`: candles. `tf` is a number of seconds (`60`, `3600`) or `D`, `W`, `M`, `Y`; `from` and
269 /// `to` are Unix seconds; `count_back` asks for at least that many candles.
270 ///
271 /// # Errors
272 ///
273 /// As [`AlorHttpClient::read`].
274 #[allow(clippy::too_many_arguments)]
275 pub async fn get_history(
276 &self,
277 exchange: &str,
278 symbol: &str,
279 group: Option<&str>,
280 tf: &str,
281 from: i64,
282 to: i64,
283 count_back: Option<u32>,
284 ) -> Result<HistoryResponse, AlorApiError> {
285 let mut q = simple(vec![
286 ("symbol", symbol.to_string()),
287 ("exchange", exchange.to_string()),
288 ("tf", tf.to_string()),
289 ("from", from.to_string()),
290 ("to", to.to_string()),
291 ]);
292 opt(&mut q, "instrumentGroup", group);
293 opt(&mut q, "countBack", count_back);
294 self.read("/md/v2/history", &q).await
295 }
296
297 /// `GET /md/v2/Securities/{exchange}/{symbol}/alltrades`: the trades of the market of today. `query` takes
298 /// `from`, `to` (Unix seconds), `fromId`, `toId`, `qtyFrom`, `qtyTo`, `priceFrom`, `priceTo`, `side`,
299 /// `offset`, `take`, `descending`, `includeVirtualTrades`.
300 ///
301 /// # Errors
302 ///
303 /// As [`AlorHttpClient::read`].
304 pub async fn get_all_trades(
305 &self,
306 exchange: &str,
307 symbol: &str,
308 group: Option<&str>,
309 query: &[(&str, String)],
310 ) -> Result<Vec<MarketTradeRecord>, AlorApiError> {
311 let mut q = simple(vec![]);
312 opt(&mut q, "instrumentGroup", group);
313 self.read(
314 &format!(
315 "/md/v2/Securities/{}/{}/alltrades",
316 enc(exchange),
317 enc(symbol)
318 ),
319 &with_extra(q, query),
320 )
321 .await
322 }
323
324 /// `GET /md/v2/Securities/{exchange}/{symbol}/alltrades/history`: the trades of the market of the past days,
325 /// a page at a time. `query` takes `from`, `to` (Unix seconds), `limit` (required by the broker, 1 to 50000)
326 /// and `offset`.
327 ///
328 /// # Errors
329 ///
330 /// As [`AlorHttpClient::read`].
331 pub async fn get_all_trades_history(
332 &self,
333 exchange: &str,
334 symbol: &str,
335 group: Option<&str>,
336 query: &[(&str, String)],
337 ) -> Result<AllTradesHistory, AlorApiError> {
338 let mut q = simple(vec![]);
339 opt(&mut q, "instrumentGroup", group);
340 self.read(
341 &format!(
342 "/md/v2/Securities/{}/{}/alltrades/history",
343 enc(exchange),
344 enc(symbol)
345 ),
346 &with_extra(q, query),
347 )
348 .await
349 }
350}
351
352// ─── the portfolio ────────────────────────────────────────────────────────────
353
354impl AlorHttpClient {
355 fn client_path(exchange: &str, portfolio: &str, tail: &str) -> String {
356 format!("/md/v2/Clients/{}/{}/{tail}", enc(exchange), enc(portfolio))
357 }
358
359 /// `GET /md/v2/Clients/{exchange}/{portfolio}/orders`: all orders of the portfolio of today.
360 ///
361 /// # Errors
362 ///
363 /// As [`AlorHttpClient::read`].
364 pub async fn get_orders(
365 &self,
366 exchange: &str,
367 portfolio: &str,
368 ) -> Result<Vec<OrderRecord>, AlorApiError> {
369 self.read(
370 &Self::client_path(exchange, portfolio, "orders"),
371 &simple(vec![]),
372 )
373 .await
374 }
375
376 /// `GET /md/v2/Clients/{exchange}/{portfolio}/orders/{orderId}`: one order. [`AlorApiError::NotFound`] when the
377 /// broker does not know the number.
378 ///
379 /// # Errors
380 ///
381 /// As [`AlorHttpClient::read`].
382 pub async fn get_order(
383 &self,
384 exchange: &str,
385 portfolio: &str,
386 order_id: &str,
387 ) -> Result<OrderRecord, AlorApiError> {
388 self.read(
389 &Self::client_path(exchange, portfolio, &format!("orders/{}", enc(order_id))),
390 &simple(vec![]),
391 )
392 .await
393 }
394
395 /// `GET /md/v2/Clients/{exchange}/{portfolio}/stoporders`: all stop orders of the portfolio of today.
396 ///
397 /// # Errors
398 ///
399 /// As [`AlorHttpClient::read`].
400 pub async fn get_stop_orders(
401 &self,
402 exchange: &str,
403 portfolio: &str,
404 ) -> Result<Vec<StopOrderRecord>, AlorApiError> {
405 self.read(
406 &Self::client_path(exchange, portfolio, "stoporders"),
407 &simple(vec![]),
408 )
409 .await
410 }
411
412 /// `GET /md/v2/Clients/{exchange}/{portfolio}/stoporders/{orderId}`: one stop order.
413 ///
414 /// # Errors
415 ///
416 /// As [`AlorHttpClient::read`].
417 pub async fn get_stop_order(
418 &self,
419 exchange: &str,
420 portfolio: &str,
421 order_id: &str,
422 ) -> Result<StopOrderRecord, AlorApiError> {
423 self.read(
424 &Self::client_path(
425 exchange,
426 portfolio,
427 &format!("stoporders/{}", enc(order_id)),
428 ),
429 &simple(vec![]),
430 )
431 .await
432 }
433
434 /// `GET /md/v2/Clients/legacy/{exchange}/{portfolio}/money`: the money of the portfolio (the old form).
435 ///
436 /// # Errors
437 ///
438 /// As [`AlorHttpClient::read`].
439 pub async fn get_money_legacy(
440 &self,
441 exchange: &str,
442 portfolio: &str,
443 ) -> Result<Value, AlorApiError> {
444 self.read(
445 &format!(
446 "/md/v2/Clients/legacy/{}/{}/money",
447 enc(exchange),
448 enc(portfolio)
449 ),
450 &simple(vec![]),
451 )
452 .await
453 }
454
455 /// `GET /md/v2/Clients/{exchange}/{portfolio}/summary`: the summary of the portfolio.
456 ///
457 /// # Errors
458 ///
459 /// As [`AlorHttpClient::read`].
460 pub async fn get_summary(
461 &self,
462 exchange: &str,
463 portfolio: &str,
464 ) -> Result<SummaryRecord, AlorApiError> {
465 self.read(
466 &Self::client_path(exchange, portfolio, "summary"),
467 &simple(vec![]),
468 )
469 .await
470 }
471
472 /// `GET /md/v2/Clients/{exchange}/{portfolio}/positions`: the positions, with the money as positions of
473 /// currencies unless `without_currency` is set.
474 ///
475 /// # Errors
476 ///
477 /// As [`AlorHttpClient::read`].
478 pub async fn get_positions(
479 &self,
480 exchange: &str,
481 portfolio: &str,
482 without_currency: bool,
483 ) -> Result<Vec<PositionRecord>, AlorApiError> {
484 let mut q = simple(vec![]);
485 if without_currency {
486 q.push(("withoutCurrency", "true".to_string()));
487 }
488 self.read(&Self::client_path(exchange, portfolio, "positions"), &q)
489 .await
490 }
491
492 /// `GET /md/v2/Clients/{exchange}/{portfolio}/positions/{symbol}`: the position in one instrument.
493 ///
494 /// # Errors
495 ///
496 /// As [`AlorHttpClient::read`].
497 pub async fn get_position(
498 &self,
499 exchange: &str,
500 portfolio: &str,
501 symbol: &str,
502 ) -> Result<PositionRecord, AlorApiError> {
503 self.read(
504 &Self::client_path(exchange, portfolio, &format!("positions/{}", enc(symbol))),
505 &simple(vec![]),
506 )
507 .await
508 }
509
510 /// `GET /md/v2/Clients/{login}/positions`: the positions of all portfolios of a trade login.
511 ///
512 /// # Errors
513 ///
514 /// As [`AlorHttpClient::read`].
515 pub async fn get_login_positions(
516 &self,
517 login: &str,
518 ) -> Result<Vec<PositionRecord>, AlorApiError> {
519 self.read(
520 &format!("/md/v2/Clients/{}/positions", enc(login)),
521 &simple(vec![]),
522 )
523 .await
524 }
525
526 /// `GET /md/v2/Clients/{exchange}/{portfolio}/trades`: the trades of the portfolio of today.
527 ///
528 /// # Errors
529 ///
530 /// As [`AlorHttpClient::read`].
531 pub async fn get_trades(
532 &self,
533 exchange: &str,
534 portfolio: &str,
535 ) -> Result<Vec<TradeRecord>, AlorApiError> {
536 self.read(
537 &Self::client_path(exchange, portfolio, "trades"),
538 &simple(vec![]),
539 )
540 .await
541 }
542
543 /// `GET /md/v2/Clients/{exchange}/{portfolio}/{symbol}/trades`: the trades of the portfolio in one instrument.
544 ///
545 /// # Errors
546 ///
547 /// As [`AlorHttpClient::read`].
548 pub async fn get_symbol_trades(
549 &self,
550 exchange: &str,
551 portfolio: &str,
552 symbol: &str,
553 ) -> Result<Vec<TradeRecord>, AlorApiError> {
554 self.read(
555 &Self::client_path(exchange, portfolio, &format!("{}/trades", enc(symbol))),
556 &simple(vec![]),
557 )
558 .await
559 }
560
561 /// `GET /md/v2/Clients/{exchange}/{portfolio}/fortsrisk`: the risks of the futures market.
562 ///
563 /// # Errors
564 ///
565 /// As [`AlorHttpClient::read`].
566 pub async fn get_forts_risk(
567 &self,
568 exchange: &str,
569 portfolio: &str,
570 ) -> Result<Value, AlorApiError> {
571 self.read(
572 &Self::client_path(exchange, portfolio, "fortsrisk"),
573 &simple(vec![]),
574 )
575 .await
576 }
577
578 /// `GET /md/v2/Clients/{exchange}/{portfolio}/risk`: the risks of the portfolio.
579 ///
580 /// # Errors
581 ///
582 /// As [`AlorHttpClient::read`].
583 pub async fn get_risk(&self, exchange: &str, portfolio: &str) -> Result<Value, AlorApiError> {
584 self.read(
585 &Self::client_path(exchange, portfolio, "risk"),
586 &simple(vec![]),
587 )
588 .await
589 }
590
591 /// `GET /md/stats/{exchange}/{portfolio}/history/trades`: the statistics of the trades (the old form).
592 /// `query` takes `dateFrom`, `ticker`, `id`, `limit`, `side`, `descending`, ...
593 ///
594 /// # Errors
595 ///
596 /// As [`AlorHttpClient::read`].
597 pub async fn get_trade_stats(
598 &self,
599 exchange: &str,
600 portfolio: &str,
601 query: &[(&str, String)],
602 ) -> Result<Value, AlorApiError> {
603 self.read(
604 &format!(
605 "/md/stats/{}/{}/history/trades",
606 enc(exchange),
607 enc(portfolio)
608 ),
609 &with_extra(simple(vec![]), query),
610 )
611 .await
612 }
613
614 /// `GET /md/stats/{exchange}/{portfolio}/history/trades/{symbol}`: the same for one instrument.
615 ///
616 /// # Errors
617 ///
618 /// As [`AlorHttpClient::read`].
619 pub async fn get_trade_stats_by_symbol(
620 &self,
621 exchange: &str,
622 portfolio: &str,
623 symbol: &str,
624 query: &[(&str, String)],
625 ) -> Result<Value, AlorApiError> {
626 self.read(
627 &format!(
628 "/md/stats/{}/{}/history/trades/{}",
629 enc(exchange),
630 enc(portfolio),
631 enc(symbol)
632 ),
633 &with_extra(simple(vec![]), query),
634 )
635 .await
636 }
637
638 /// `GET /md/v2/Stats/{exchange}/{portfolio}/history/trades`: the trades of the portfolio of the past days,
639 /// a page at a time. `query` takes `dateFrom` (a date or a date and time), `from` (the number of the
640 /// first trade), `limit` (1 to 1000), `ticker`, `side`, `orderByTradeDate`, `descending`, `withRepo`.
641 ///
642 /// # Errors
643 ///
644 /// As [`AlorHttpClient::read`].
645 pub async fn get_history_trades(
646 &self,
647 exchange: &str,
648 portfolio: &str,
649 query: &[(&str, String)],
650 ) -> Result<Vec<TradeRecord>, AlorApiError> {
651 self.read(
652 &format!(
653 "/md/v2/Stats/{}/{}/history/trades",
654 enc(exchange),
655 enc(portfolio)
656 ),
657 &with_extra(simple(vec![]), query),
658 )
659 .await
660 }
661
662 /// `GET /md/v2/Stats/{exchange}/{portfolio}/history/trades/{symbol}`: the same for one instrument.
663 ///
664 /// # Errors
665 ///
666 /// As [`AlorHttpClient::read`].
667 pub async fn get_history_trades_by_symbol(
668 &self,
669 exchange: &str,
670 portfolio: &str,
671 symbol: &str,
672 query: &[(&str, String)],
673 ) -> Result<Vec<TradeRecord>, AlorApiError> {
674 self.read(
675 &format!(
676 "/md/v2/Stats/{}/{}/history/trades/{}",
677 enc(exchange),
678 enc(portfolio),
679 enc(symbol)
680 ),
681 &with_extra(simple(vec![]), query),
682 )
683 .await
684 }
685}
686
687// ─── order commands ───────────────────────────────────────────────────────────
688
689const ACTIONS: &str = "/commandapi/warptrans/TRADE/v2/client/orders/actions";
690
691impl AlorHttpClient {
692 /// `POST .../orders/actions/limit`: a limit order. `req_id` is the header `X-REQID`: unique for the whole
693 /// system; the broker answers a repeated id with a copy of its first answer and does not place a second order.
694 ///
695 /// # Errors
696 ///
697 /// As [`AlorHttpClient::command`]. The call is never repeated.
698 pub async fn create_limit_order(
699 &self,
700 req_id: &str,
701 order: &LimitOrderRequest,
702 ) -> Result<OrderCommandResponse, AlorApiError> {
703 self.command(
704 Method::POST,
705 &format!("{ACTIONS}/limit"),
706 &[],
707 Some(order),
708 Some(req_id),
709 )
710 .await
711 }
712
713 /// `POST .../orders/actions/market`: a market order. See [`AlorHttpClient::create_limit_order`] for `req_id`.
714 ///
715 /// # Errors
716 ///
717 /// As [`AlorHttpClient::command`]. The call is never repeated.
718 pub async fn create_market_order(
719 &self,
720 req_id: &str,
721 order: &MarketOrderRequest,
722 ) -> Result<OrderCommandResponse, AlorApiError> {
723 self.command(
724 Method::POST,
725 &format!("{ACTIONS}/market"),
726 &[],
727 Some(order),
728 Some(req_id),
729 )
730 .await
731 }
732
733 /// `PUT .../orders/actions/limit/{orderId}`: changes a limit order (the broker takes the new quantity and
734 /// price and answers with the number of the order that replaced it).
735 ///
736 /// # Errors
737 ///
738 /// As [`AlorHttpClient::command`]. The call is never repeated.
739 pub async fn update_limit_order(
740 &self,
741 req_id: &str,
742 order_id: &str,
743 order: &LimitOrderRequest,
744 ) -> Result<OrderCommandResponse, AlorApiError> {
745 self.command(
746 Method::PUT,
747 &format!("{ACTIONS}/limit/{}", enc(order_id)),
748 &[],
749 Some(order),
750 Some(req_id),
751 )
752 .await
753 }
754
755 /// `PUT .../orders/actions/market/{orderId}`: changes a market order.
756 ///
757 /// # Errors
758 ///
759 /// As [`AlorHttpClient::command`]. The call is never repeated.
760 pub async fn update_market_order(
761 &self,
762 req_id: &str,
763 order_id: &str,
764 order: &MarketOrderRequest,
765 ) -> Result<OrderCommandResponse, AlorApiError> {
766 self.command(
767 Method::PUT,
768 &format!("{ACTIONS}/market/{}", enc(order_id)),
769 &[],
770 Some(order),
771 Some(req_id),
772 )
773 .await
774 }
775
776 /// `POST .../orders/actions/stop`: a stop order that makes a market order on trigger.
777 ///
778 /// # Errors
779 ///
780 /// As [`AlorHttpClient::command`]. The call is never repeated.
781 pub async fn create_stop_order(
782 &self,
783 req_id: &str,
784 order: &StopOrderRequest,
785 ) -> Result<OrderCommandResponse, AlorApiError> {
786 self.command(
787 Method::POST,
788 &format!("{ACTIONS}/stop"),
789 &[],
790 Some(order),
791 Some(req_id),
792 )
793 .await
794 }
795
796 /// `POST .../orders/actions/stopLimit`: a stop order that makes a limit order on trigger (`order.price`).
797 ///
798 /// # Errors
799 ///
800 /// As [`AlorHttpClient::command`]. The call is never repeated.
801 pub async fn create_stop_limit_order(
802 &self,
803 req_id: &str,
804 order: &StopOrderRequest,
805 ) -> Result<OrderCommandResponse, AlorApiError> {
806 self.command(
807 Method::POST,
808 &format!("{ACTIONS}/stopLimit"),
809 &[],
810 Some(order),
811 Some(req_id),
812 )
813 .await
814 }
815
816 /// `PUT .../orders/actions/stop/{stopOrderId}`: changes a stop order.
817 ///
818 /// # Errors
819 ///
820 /// As [`AlorHttpClient::command`]. The call is never repeated.
821 pub async fn update_stop_order(
822 &self,
823 req_id: &str,
824 order_id: &str,
825 order: &StopOrderRequest,
826 ) -> Result<OrderCommandResponse, AlorApiError> {
827 self.command(
828 Method::PUT,
829 &format!("{ACTIONS}/stop/{}", enc(order_id)),
830 &[],
831 Some(order),
832 Some(req_id),
833 )
834 .await
835 }
836
837 /// `PUT .../orders/actions/stopLimit/{stopOrderId}`: changes a stop-limit order.
838 ///
839 /// # Errors
840 ///
841 /// As [`AlorHttpClient::command`]. The call is never repeated.
842 pub async fn update_stop_limit_order(
843 &self,
844 req_id: &str,
845 order_id: &str,
846 order: &StopOrderRequest,
847 ) -> Result<OrderCommandResponse, AlorApiError> {
848 self.command(
849 Method::PUT,
850 &format!("{ACTIONS}/stopLimit/{}", enc(order_id)),
851 &[],
852 Some(order),
853 Some(req_id),
854 )
855 .await
856 }
857
858 /// `DELETE .../orders/{orderId}`: cancels an order, or a stop order when `stop` is set. The broker names the
859 /// exchange and the portfolio in the query, not in the path.
860 ///
861 /// # Errors
862 ///
863 /// As [`AlorHttpClient::command`]. The call is never repeated.
864 pub async fn cancel_order(
865 &self,
866 exchange: &str,
867 portfolio: &str,
868 order_id: &str,
869 stop: bool,
870 ) -> Result<Value, AlorApiError> {
871 let q = [
872 ("portfolio", portfolio.to_string()),
873 ("exchange", exchange.to_string()),
874 ("stop", stop.to_string()),
875 ("format", "Simple".to_string()),
876 ];
877 self.command(
878 Method::DELETE,
879 &format!(
880 "/commandapi/warptrans/TRADE/v2/client/orders/{}",
881 enc(order_id)
882 ),
883 &q,
884 None::<&()>,
885 None,
886 )
887 .await
888 }
889
890 /// `DELETE .../orders/all`: cancels all orders of the portfolio on the exchange (the stop orders when
891 /// `stop` is `Some(true)`).
892 ///
893 /// # Errors
894 ///
895 /// As [`AlorHttpClient::command`]. The call is never repeated.
896 pub async fn cancel_all_orders(
897 &self,
898 exchange: &str,
899 portfolio: &str,
900 stop: Option<bool>,
901 ) -> Result<Value, AlorApiError> {
902 let mut q = vec![
903 ("exchange", exchange.to_string()),
904 ("portfolio", portfolio.to_string()),
905 ];
906 opt(&mut q, "stop", stop);
907 self.command(
908 Method::DELETE,
909 "/commandapi/warptrans/TRADE/v2/client/orders/all",
910 &q,
911 None::<&()>,
912 None,
913 )
914 .await
915 }
916
917 /// `POST .../orders/estimate`: estimates one order (the quantity the portfolio can buy or sell, the cost, the
918 /// commission). The body is `portfolio`, `ticker`, `exchange`, `price`, `lotQuantity` or `budget`, `board`,
919 /// `includeLimitOrders`.
920 ///
921 /// # Errors
922 ///
923 /// As [`AlorHttpClient::command`]. It changes nothing at the broker, but is not repeated either.
924 pub async fn estimate_order(&self, body: &Value) -> Result<Value, AlorApiError> {
925 self.command(
926 Method::POST,
927 "/commandapi/warptrans/TRADE/v2/client/orders/estimate",
928 &[],
929 Some(body),
930 None,
931 )
932 .await
933 }
934
935 /// `POST .../orders/estimate/all`: estimates a list of orders. The body is a list of the bodies of
936 /// [`AlorHttpClient::estimate_order`].
937 ///
938 /// # Errors
939 ///
940 /// As [`AlorHttpClient::estimate_order`].
941 pub async fn estimate_orders(&self, body: &Value) -> Result<Value, AlorApiError> {
942 self.command(
943 Method::POST,
944 "/commandapi/warptrans/TRADE/v2/client/orders/estimate/all",
945 &[],
946 Some(body),
947 None,
948 )
949 .await
950 }
951}
952
953// ─── order groups ─────────────────────────────────────────────────────────────
954
955impl AlorHttpClient {
956 /// `GET /commandapi/api/orderGroups`: the order groups of the user.
957 ///
958 /// # Errors
959 ///
960 /// As [`AlorHttpClient::read`].
961 pub async fn get_order_groups(&self) -> Result<Value, AlorApiError> {
962 self.read("/commandapi/api/orderGroups", &[]).await
963 }
964
965 /// `POST /commandapi/api/orderGroups`: makes a group of orders (`orders` and `executionPolicy`), where
966 /// the execution or cancel of one order triggers the others.
967 ///
968 /// # Errors
969 ///
970 /// As [`AlorHttpClient::command`].
971 pub async fn create_order_group(&self, body: &Value) -> Result<Value, AlorApiError> {
972 self.command(
973 Method::POST,
974 "/commandapi/api/orderGroups",
975 &[],
976 Some(body),
977 None,
978 )
979 .await
980 }
981
982 /// `GET /commandapi/api/orderGroups/{orderGroupId}`: one order group.
983 ///
984 /// # Errors
985 ///
986 /// As [`AlorHttpClient::read`].
987 pub async fn get_order_group(&self, group_id: &str) -> Result<Value, AlorApiError> {
988 self.read(
989 &format!("/commandapi/api/orderGroups/{}", enc(group_id)),
990 &[],
991 )
992 .await
993 }
994
995 /// `PUT /commandapi/api/orderGroups/{orderGroupId}`: changes an order group.
996 ///
997 /// # Errors
998 ///
999 /// As [`AlorHttpClient::command`].
1000 pub async fn update_order_group(
1001 &self,
1002 group_id: &str,
1003 body: &Value,
1004 ) -> Result<Value, AlorApiError> {
1005 self.command(
1006 Method::PUT,
1007 &format!("/commandapi/api/orderGroups/{}", enc(group_id)),
1008 &[],
1009 Some(body),
1010 None,
1011 )
1012 .await
1013 }
1014
1015 /// `DELETE /commandapi/api/orderGroups/{orderGroupId}`: removes an order group (the orders stay).
1016 ///
1017 /// # Errors
1018 ///
1019 /// As [`AlorHttpClient::command`].
1020 pub async fn delete_order_group(&self, group_id: &str) -> Result<Value, AlorApiError> {
1021 self.command(
1022 Method::DELETE,
1023 &format!("/commandapi/api/orderGroups/{}", enc(group_id)),
1024 &[],
1025 None::<&()>,
1026 None,
1027 )
1028 .await
1029 }
1030}
1031
1032// ─── the legacy stop-loss and take-profit family ──────────────────────────────
1033
1034impl AlorHttpClient {
1035 async fn legacy_create(
1036 &self,
1037 kind: &str,
1038 server: &str,
1039 req_id: &str,
1040 body: &Value,
1041 ) -> Result<OrderCommandResponse, AlorApiError> {
1042 self.command(
1043 Method::POST,
1044 &format!("/warptrans/{}/v2/client/orders/actions/{kind}", enc(server)),
1045 &[],
1046 Some(body),
1047 Some(req_id),
1048 )
1049 .await
1050 }
1051
1052 async fn legacy_update(
1053 &self,
1054 kind: &str,
1055 server: &str,
1056 req_id: &str,
1057 order_id: &str,
1058 body: &Value,
1059 ) -> Result<OrderCommandResponse, AlorApiError> {
1060 self.command(
1061 Method::PUT,
1062 &format!(
1063 "/warptrans/{}/v2/client/orders/actions/{kind}/{}",
1064 enc(server),
1065 enc(order_id)
1066 ),
1067 &[],
1068 Some(body),
1069 Some(req_id),
1070 )
1071 .await
1072 }
1073
1074 /// `POST /warptrans/{tradeServerCode}/v2/client/orders/actions/stopLoss`: the old stop-loss order. The broker
1075 /// marks the whole family as outdated: use [`AlorHttpClient::create_stop_order`].
1076 ///
1077 /// # Errors
1078 ///
1079 /// As [`AlorHttpClient::command`].
1080 pub async fn create_stop_loss(
1081 &self,
1082 server: &str,
1083 req_id: &str,
1084 body: &Value,
1085 ) -> Result<OrderCommandResponse, AlorApiError> {
1086 self.legacy_create("stopLoss", server, req_id, body).await
1087 }
1088
1089 /// `POST .../actions/stopLossLimit`: the old stop-loss order with a limit price.
1090 ///
1091 /// # Errors
1092 ///
1093 /// As [`AlorHttpClient::command`].
1094 pub async fn create_stop_loss_limit(
1095 &self,
1096 server: &str,
1097 req_id: &str,
1098 body: &Value,
1099 ) -> Result<OrderCommandResponse, AlorApiError> {
1100 self.legacy_create("stopLossLimit", server, req_id, body)
1101 .await
1102 }
1103
1104 /// `POST .../actions/takeProfit`: the old take-profit order.
1105 ///
1106 /// # Errors
1107 ///
1108 /// As [`AlorHttpClient::command`].
1109 pub async fn create_take_profit(
1110 &self,
1111 server: &str,
1112 req_id: &str,
1113 body: &Value,
1114 ) -> Result<OrderCommandResponse, AlorApiError> {
1115 self.legacy_create("takeProfit", server, req_id, body).await
1116 }
1117
1118 /// `POST .../actions/takeProfitLimit`: the old take-profit order with a limit price.
1119 ///
1120 /// # Errors
1121 ///
1122 /// As [`AlorHttpClient::command`].
1123 pub async fn create_take_profit_limit(
1124 &self,
1125 server: &str,
1126 req_id: &str,
1127 body: &Value,
1128 ) -> Result<OrderCommandResponse, AlorApiError> {
1129 self.legacy_create("takeProfitLimit", server, req_id, body)
1130 .await
1131 }
1132
1133 /// `PUT .../actions/stopLoss/{orderId}`: changes an old stop-loss order.
1134 ///
1135 /// # Errors
1136 ///
1137 /// As [`AlorHttpClient::command`].
1138 pub async fn update_stop_loss(
1139 &self,
1140 server: &str,
1141 req_id: &str,
1142 order_id: &str,
1143 body: &Value,
1144 ) -> Result<OrderCommandResponse, AlorApiError> {
1145 self.legacy_update("stopLoss", server, req_id, order_id, body)
1146 .await
1147 }
1148
1149 /// `PUT .../actions/stopLossLimit/{orderId}`: changes an old stop-loss order with a limit price.
1150 ///
1151 /// # Errors
1152 ///
1153 /// As [`AlorHttpClient::command`].
1154 pub async fn update_stop_loss_limit(
1155 &self,
1156 server: &str,
1157 req_id: &str,
1158 order_id: &str,
1159 body: &Value,
1160 ) -> Result<OrderCommandResponse, AlorApiError> {
1161 self.legacy_update("stopLossLimit", server, req_id, order_id, body)
1162 .await
1163 }
1164
1165 /// `PUT .../actions/takeProfit/{orderId}`: changes an old take-profit order.
1166 ///
1167 /// # Errors
1168 ///
1169 /// As [`AlorHttpClient::command`].
1170 pub async fn update_take_profit(
1171 &self,
1172 server: &str,
1173 req_id: &str,
1174 order_id: &str,
1175 body: &Value,
1176 ) -> Result<OrderCommandResponse, AlorApiError> {
1177 self.legacy_update("takeProfit", server, req_id, order_id, body)
1178 .await
1179 }
1180
1181 /// `PUT .../actions/takeProfitLimit/{orderId}`: changes an old take-profit order with a limit price.
1182 ///
1183 /// # Errors
1184 ///
1185 /// As [`AlorHttpClient::command`].
1186 pub async fn update_take_profit_limit(
1187 &self,
1188 server: &str,
1189 req_id: &str,
1190 order_id: &str,
1191 body: &Value,
1192 ) -> Result<OrderCommandResponse, AlorApiError> {
1193 self.legacy_update("takeProfitLimit", server, req_id, order_id, body)
1194 .await
1195 }
1196
1197 /// `DELETE /warptrans/{tradeServerCode}/v2/client/orders/{orderId}`: cancels an order by the old path.
1198 ///
1199 /// # Errors
1200 ///
1201 /// As [`AlorHttpClient::command`].
1202 pub async fn cancel_order_legacy(
1203 &self,
1204 server: &str,
1205 order_id: &str,
1206 query: &[(&str, String)],
1207 ) -> Result<Value, AlorApiError> {
1208 self.command(
1209 Method::DELETE,
1210 &format!(
1211 "/warptrans/{}/v2/client/orders/{}",
1212 enc(server),
1213 enc(order_id)
1214 ),
1215 query,
1216 None::<&()>,
1217 None,
1218 )
1219 .await
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use super::enc;
1226
1227 #[test]
1228 fn path_segments_are_percent_encoded() {
1229 assert_eq!(enc("SBER"), "SBER");
1230 assert_eq!(enc("Si-12.26"), "Si-12.26");
1231 assert_eq!(enc("a b/c"), "a%20b%2Fc");
1232 assert_eq!(enc("ЛУКОЙЛ"), "%D0%9B%D0%A3%D0%9A%D0%9E%D0%99%D0%9B");
1233 }
1234}