Skip to main content

brokapi_alor_protocol/ws/
models.rs

1//! The messages of the data channel `/ws`: the subscription requests and the frames that come back.
2//!
3//! Every request carries `opcode` (what to do), `guid` (an id of ours, repeated in every answer to it) and
4//! `token` (the access token: the channel checks it on every message, so a session writes a fresh one into each
5//! request when it sends it). A subscription is answered once with a status frame (`httpCode` 200), then data
6//! frames arrive with the same `guid` until the subscription is cancelled with `unsubscribe`.
7
8use serde::Serialize;
9use serde_json::{Map, Value, json};
10
11/// The path of the data channel on the WebSocket host.
12pub const WS_PATH: &str = "/ws";
13/// The path of the trading channel.
14pub const CWS_PATH: &str = "/cws";
15
16/// A subscription request, without the token (the session adds it when it sends).
17///
18/// Build one with the constructors ([`WsSubscribe::order_book`] and so on, one per opcode of the broker). The
19/// `guid` is yours: it names the subscription for `unsubscribe` and marks its data frames.
20///
21/// # Examples
22///
23/// ```
24/// use brokapi_alor_protocol::ws::models::WsSubscribe;
25///
26/// let sub = WsSubscribe::order_book("g1", "MOEX", "SBER", Some("TQBR"), 10);
27/// let json: serde_json::Value = serde_json::from_str(&sub.to_json("token")).unwrap();
28/// assert_eq!(json["opcode"], "OrderBookGetAndSubscribe");
29/// assert_eq!(json["guid"], "g1");
30/// assert_eq!(json["token"], "token");
31/// assert_eq!(json["depth"], 10);
32/// assert_eq!(json["format"], "Simple");
33/// ```
34#[derive(Debug, Clone, PartialEq, Serialize)]
35pub struct WsSubscribe {
36    /// What to subscribe to, in the words of the broker (`OrderBookGetAndSubscribe`, ...).
37    pub opcode: String,
38    /// The id of this subscription.
39    pub guid: String,
40    /// The other fields of the request.
41    pub params: Map<String, Value>,
42}
43
44impl WsSubscribe {
45    fn new(opcode: &str, guid: &str, params: Value) -> Self {
46        let mut params = params.as_object().cloned().unwrap_or_default();
47        params.insert("format".into(), json!("Simple"));
48        Self {
49            opcode: opcode.to_string(),
50            guid: guid.to_string(),
51            params,
52        }
53    }
54
55    fn instrument(
56        opcode: &str,
57        guid: &str,
58        exchange: &str,
59        code: &str,
60        group: Option<&str>,
61        mut extra: Value,
62    ) -> Self {
63        extra["exchange"] = json!(exchange);
64        extra["code"] = json!(code);
65        if let Some(g) = group {
66            extra["instrumentGroup"] = json!(g);
67        }
68        Self::new(opcode, guid, extra)
69    }
70
71    fn portfolio(
72        opcode: &str,
73        guid: &str,
74        exchange: &str,
75        portfolio: &str,
76        skip_history: bool,
77    ) -> Self {
78        Self::new(
79            opcode,
80            guid,
81            json!({"exchange": exchange, "portfolio": portfolio, "skipHistory": skip_history}),
82        )
83    }
84
85    /// The order book of an instrument (`OrderBookGetAndSubscribe`): a whole book per message, `depth` levels
86    /// (20 by default, 50 at most).
87    pub fn order_book(
88        guid: &str,
89        exchange: &str,
90        code: &str,
91        group: Option<&str>,
92        depth: u32,
93    ) -> Self {
94        Self::instrument(
95            "OrderBookGetAndSubscribe",
96            guid,
97            exchange,
98            code,
99            group,
100            json!({"depth": depth}),
101        )
102    }
103
104    /// Candles of an instrument (`BarsGetAndSubscribe`). `tf` is a number of seconds or `D`, `W`, `M`, `Y`;
105    /// `from` (Unix seconds) is the first candle of the history that comes first; `skip_history` leaves it out.
106    pub fn bars(
107        guid: &str,
108        exchange: &str,
109        code: &str,
110        group: Option<&str>,
111        tf: &str,
112        from: i64,
113        skip_history: bool,
114    ) -> Self {
115        Self::instrument(
116            "BarsGetAndSubscribe",
117            guid,
118            exchange,
119            code,
120            group,
121            json!({"tf": tf, "from": from, "skipHistory": skip_history}),
122        )
123    }
124
125    /// Quotations of an instrument (`QuotesSubscribe`).
126    pub fn quotes(guid: &str, exchange: &str, code: &str, group: Option<&str>) -> Self {
127        Self::instrument("QuotesSubscribe", guid, exchange, code, group, json!({}))
128    }
129
130    /// All trades of the market in an instrument (`AllTradesGetAndSubscribe`).
131    pub fn all_trades(guid: &str, exchange: &str, code: &str, group: Option<&str>) -> Self {
132        Self::instrument(
133            "AllTradesGetAndSubscribe",
134            guid,
135            exchange,
136            code,
137            group,
138            json!({}),
139        )
140    }
141
142    /// Changes of the instruments of an exchange (`InstrumentsGetAndSubscribeV2`).
143    pub fn instruments(guid: &str, exchange: &str, code: &str, group: Option<&str>) -> Self {
144        Self::instrument(
145            "InstrumentsGetAndSubscribeV2",
146            guid,
147            exchange,
148            code,
149            group,
150            json!({}),
151        )
152    }
153
154    /// Orders of a portfolio (`OrdersGetAndSubscribeV2`). Without `skip_history` the orders of today come first
155    /// (frames with `existing: true`), then the changes.
156    pub fn orders(guid: &str, exchange: &str, portfolio: &str, skip_history: bool) -> Self {
157        Self::portfolio(
158            "OrdersGetAndSubscribeV2",
159            guid,
160            exchange,
161            portfolio,
162            skip_history,
163        )
164    }
165
166    /// Stop orders of a portfolio (`StopOrdersGetAndSubscribeV2`).
167    pub fn stop_orders(guid: &str, exchange: &str, portfolio: &str, skip_history: bool) -> Self {
168        Self::portfolio(
169            "StopOrdersGetAndSubscribeV2",
170            guid,
171            exchange,
172            portfolio,
173            skip_history,
174        )
175    }
176
177    /// Trades (fills) of a portfolio (`TradesGetAndSubscribeV2`).
178    pub fn trades(guid: &str, exchange: &str, portfolio: &str, skip_history: bool) -> Self {
179        Self::portfolio(
180            "TradesGetAndSubscribeV2",
181            guid,
182            exchange,
183            portfolio,
184            skip_history,
185        )
186    }
187
188    /// Positions and money of a portfolio (`PositionsGetAndSubscribeV2`).
189    pub fn positions(guid: &str, exchange: &str, portfolio: &str, skip_history: bool) -> Self {
190        Self::portfolio(
191            "PositionsGetAndSubscribeV2",
192            guid,
193            exchange,
194            portfolio,
195            skip_history,
196        )
197    }
198
199    /// The summary of a portfolio (`SummariesGetAndSubscribeV2`).
200    pub fn summaries(guid: &str, exchange: &str, portfolio: &str, skip_history: bool) -> Self {
201        Self::portfolio(
202            "SummariesGetAndSubscribeV2",
203            guid,
204            exchange,
205            portfolio,
206            skip_history,
207        )
208    }
209
210    /// The risks of a portfolio (`RisksGetAndSubscribe`).
211    pub fn risks(guid: &str, exchange: &str, portfolio: &str, skip_history: bool) -> Self {
212        Self::portfolio(
213            "RisksGetAndSubscribe",
214            guid,
215            exchange,
216            portfolio,
217            skip_history,
218        )
219    }
220
221    /// The risks of the futures market of a portfolio (`SpectraRisksGetAndSubscribe`).
222    pub fn spectra_risks(guid: &str, exchange: &str, portfolio: &str, skip_history: bool) -> Self {
223        Self::portfolio(
224            "SpectraRisksGetAndSubscribe",
225            guid,
226            exchange,
227            portfolio,
228            skip_history,
229        )
230    }
231
232    /// The request that cancels the subscription named by `guid` (`unsubscribe`). It is sent in the connection
233    /// the subscription lives in.
234    pub fn unsubscribe(guid: &str) -> Self {
235        Self {
236            opcode: "unsubscribe".to_string(),
237            guid: guid.to_string(),
238            params: Map::new(),
239        }
240    }
241
242    /// The JSON text of the request with `token` written into it.
243    pub fn to_json(&self, token: &str) -> String {
244        let mut m = self.params.clone();
245        m.insert("opcode".into(), json!(self.opcode));
246        m.insert("guid".into(), json!(self.guid));
247        m.insert("token".into(), json!(token));
248        Value::Object(m).to_string()
249    }
250
251    /// `true` for the request that cancels a subscription.
252    pub fn is_unsubscribe(&self) -> bool {
253        self.opcode == "unsubscribe"
254    }
255}
256
257/// A frame of the data channel, parsed.
258#[derive(Debug, Clone, PartialEq)]
259pub enum WsIncoming {
260    /// A data frame of a subscription: `{"data": ..., "guid": ...}`.
261    Data {
262        /// The id of the subscription that the frame belongs to.
263        guid: String,
264        /// The payload, in the `Simple` format.
265        data: Value,
266    },
267    /// A status frame: `{"requestGuid": ..., "httpCode": ..., "message": ...}`. 200 confirms a request, 400 or 401
268    /// refuses it (the broker closes the connection after a 401).
269    Status {
270        /// The `guid` of the request, if the frame names one.
271        guid: Option<String>,
272        /// The HTTP-like code.
273        code: u16,
274        /// The text.
275        message: String,
276    },
277    /// The session connected (or reconnected): data frames of the restored subscriptions follow.
278    Reconnected,
279    /// A frame that is neither of the above.
280    Unknown(String),
281}
282
283/// Parses one text frame of the data channel.
284///
285/// # Examples
286///
287/// ```
288/// use brokapi_alor_protocol::ws::models::{WsIncoming, parse_incoming};
289///
290/// match parse_incoming(r#"{"data":{"bids":[],"asks":[]},"guid":"g1"}"#) {
291///     WsIncoming::Data { guid, .. } => assert_eq!(guid, "g1"),
292///     other => panic!("{other:?}"),
293/// }
294/// match parse_incoming(r#"{"requestGuid":"g1","httpCode":200,"message":"Handled successfully"}"#) {
295///     WsIncoming::Status { code, .. } => assert_eq!(code, 200),
296///     other => panic!("{other:?}"),
297/// }
298/// assert!(matches!(parse_incoming("not json"), WsIncoming::Unknown(_)));
299/// ```
300pub fn parse_incoming(text: &str) -> WsIncoming {
301    let Ok(v) = serde_json::from_str::<Value>(text) else {
302        return WsIncoming::Unknown(text.to_string());
303    };
304    if let Some(code) = v.get("httpCode").and_then(Value::as_u64) {
305        return WsIncoming::Status {
306            guid: v
307                .get("requestGuid")
308                .and_then(Value::as_str)
309                .map(str::to_string),
310            code: code as u16,
311            message: v
312                .get("message")
313                .and_then(Value::as_str)
314                .unwrap_or_default()
315                .to_string(),
316        };
317    }
318    match (v.get("data"), v.get("guid").and_then(Value::as_str)) {
319        (Some(data), Some(guid)) => WsIncoming::Data {
320            guid: guid.to_string(),
321            data: data.clone(),
322        },
323        _ => WsIncoming::Unknown(text.to_string()),
324    }
325}