Skip to main content

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}