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}