1use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use brokapi_alor_protocol::http::models::{OrderRecord, PositionRecord, TradeRecord};
8use brokapi_alor_protocol::ws::models::WsIncoming;
9use brokapi_core::{ClientOrderId, Event, SubscriptionId};
10use brokapi_sansio::{
11 BrokerCore, CoreEvent, DesiredSubscription, EventSink, Outbound, SubscriptionKind,
12 SubscriptionSet,
13};
14use tokio::sync::Notify;
15use tracing::warn;
16
17use crate::cid::{cid_from_comment, foreign_cid};
18use crate::core::md::SubState;
19use crate::map::{execution, order, portfolio};
20use crate::subs::UdSubKind;
21
22pub type BoardBook = Arc<Mutex<HashMap<String, String>>>;
25
26pub fn board_key(exchange: &str, ticker: &str) -> String {
28 format!("{exchange}:{ticker}")
29}
30
31const SEEN_EXECUTIONS_CAP: usize = 20_000;
32const ORDER_IDS_CAP: usize = 20_000;
33
34#[derive(Debug)]
36pub struct UdCore {
37 desired: SubscriptionSet,
38 by_guid: HashMap<String, (SubscriptionId, UdSubKind)>,
39 seen_executions: HashSet<String>,
40 seen_order: VecDeque<String>,
41 order_ids: HashMap<String, ClientOrderId>,
42 order_ids_order: VecDeque<String>,
43 boards: BoardBook,
44 exchange: String,
45 connects: u32,
46 states: HashMap<SubscriptionId, (SubState, Option<String>)>,
47 ack: Arc<Notify>,
48}
49
50impl UdCore {
51 pub fn new(exchange: impl Into<String>, boards: BoardBook) -> Self {
53 Self {
54 desired: SubscriptionSet::new(),
55 by_guid: HashMap::new(),
56 seen_executions: HashSet::new(),
57 seen_order: VecDeque::new(),
58 order_ids: HashMap::new(),
59 order_ids_order: VecDeque::new(),
60 boards,
61 exchange: exchange.into(),
62 connects: 0,
63 states: HashMap::new(),
64 ack: Arc::new(Notify::new()),
65 }
66 }
67
68 pub fn notifier(&self) -> Arc<Notify> {
71 Arc::clone(&self.ack)
72 }
73
74 pub fn actual_state(&self, id: SubscriptionId) -> Option<SubState> {
76 self.states.get(&id).map(|(s, _)| *s)
77 }
78
79 pub fn failure(&self, id: SubscriptionId) -> Option<&str> {
81 self.states.get(&id).and_then(|(_, r)| r.as_deref())
82 }
83
84 pub fn register(&mut self, guid: &str, id: SubscriptionId, kind: UdSubKind) {
86 self.by_guid.insert(guid.to_string(), (id, kind));
87 self.states.insert(id, (SubState::Pending, None));
88 self.refresh_desired();
89 }
90
91 pub fn forget(&mut self, guid: &str) {
93 if let Some((id, _)) = self.by_guid.remove(guid) {
94 self.states.remove(&id);
95 }
96 self.refresh_desired();
97 }
98
99 fn refresh_desired(&mut self) {
100 let mut subs: Vec<DesiredSubscription> = self
101 .by_guid
102 .values()
103 .map(|(id, kind)| DesiredSubscription {
104 id: *id,
105 kind: match kind {
106 UdSubKind::OrderState => SubscriptionKind::OrderState,
107 UdSubKind::Portfolio => SubscriptionKind::Portfolio,
108 UdSubKind::Executions => SubscriptionKind::Executions,
109 },
110 })
111 .collect();
112 subs.sort_by_key(|s| s.id.value());
113 self.desired = SubscriptionSet::with_subscriptions(subs);
114 }
115
116 pub fn learn_order_id(&mut self, order_id: &str, cid: ClientOrderId) {
118 if order_id.is_empty() || cid.is_nil() {
119 return;
120 }
121 if self.order_ids.insert(order_id.to_string(), cid).is_none() {
122 self.order_ids_order.push_back(order_id.to_string());
123 if self.order_ids_order.len() > ORDER_IDS_CAP
124 && let Some(old) = self.order_ids_order.pop_front()
125 {
126 self.order_ids.remove(&old);
127 }
128 }
129 }
130
131 pub fn cid_of_order_id(&self, order_id: &str) -> Option<ClientOrderId> {
133 self.order_ids.get(order_id).copied()
134 }
135
136 pub fn resolve_cid(&mut self, order_id: &str, comment: Option<&str>) -> ClientOrderId {
139 if let Some(cid) = cid_from_comment(comment) {
140 self.learn_order_id(order_id, cid);
141 return cid;
142 }
143 self.cid_of_order_id(order_id)
144 .unwrap_or_else(|| foreign_cid(order_id))
145 }
146
147 fn find(&self, kind: UdSubKind) -> Option<SubscriptionId> {
148 self.by_guid
149 .values()
150 .find(|(_, k)| *k == kind)
151 .map(|(id, _)| *id)
152 }
153
154 fn on_connect(&mut self, out: &mut EventSink) {
155 self.connects += 1;
156 if self.connects < 2 {
157 return;
158 }
159 for (state, _) in self.states.values_mut() {
160 *state = SubState::Pending;
161 }
162 let gap_from = crate::map::unix_nanos(chrono::Utc::now());
163 for kind in [UdSubKind::OrderState, UdSubKind::Executions] {
164 if let Some(sub) = self.find(kind) {
165 out.push(CoreEvent {
166 sub: Some(sub),
167 payload: Event::Reconnected { sub, gap_from },
168 });
169 }
170 }
171 }
172
173 fn on_data(&mut self, guid: &str, data: serde_json::Value, now: Instant, out: &mut EventSink) {
174 let Some(&(id, kind)) = self.by_guid.get(guid) else {
175 return;
176 };
177 if self
178 .states
179 .get(&id)
180 .is_some_and(|(s, _)| *s != SubState::Active)
181 {
182 self.states.insert(id, (SubState::Active, None));
183 self.ack.notify_waiters();
184 }
185 let payload = match kind {
186 UdSubKind::OrderState => read::<OrderRecord>(&data).and_then(|r| {
187 let cid = self.resolve_cid(&r.id, r.comment.as_deref());
188 order::order_snapshot(&r, cid, now).map(Event::Order)
189 }),
190 UdSubKind::Executions => read::<TradeRecord>(&data).and_then(|r| {
191 if !self.seen_executions.insert(r.id.clone()) {
193 return None;
194 }
195 self.seen_order.push_back(r.id.clone());
196 if self.seen_order.len() > SEEN_EXECUTIONS_CAP
197 && let Some(old) = self.seen_order.pop_front()
198 {
199 self.seen_executions.remove(&old);
200 }
201 let order_no = r.orderno.clone().unwrap_or_default();
202 let cid = cid_from_comment(r.comment.as_deref())
203 .or_else(|| self.cid_of_order_id(&order_no));
204 execution::execution(&r, cid, now).map(Event::Execution)
205 }),
206 UdSubKind::Portfolio => read::<PositionRecord>(&data).map(|r| {
207 if portfolio::is_money(&r) {
208 Event::Balances(vec![portfolio::balance(&r, now)])
209 } else {
210 let board = self
211 .boards
212 .lock()
213 .ok()
214 .and_then(|b| b.get(&board_key(&self.exchange, &r.symbol)).cloned())
215 .unwrap_or_default();
216 Event::Positions(vec![portfolio::position(&r, &board, now)])
217 }
218 }),
219 };
220 if let Some(payload) = payload {
221 out.push(CoreEvent {
222 sub: Some(id),
223 payload,
224 });
225 }
226 }
227}
228
229fn read<T: serde::de::DeserializeOwned>(data: &serde_json::Value) -> Option<T> {
230 match serde_json::from_value(data.clone()) {
231 Ok(v) => Some(v),
232 Err(e) => {
233 warn!("a data frame cannot be read: {e}");
234 None
235 }
236 }
237}
238
239impl BrokerCore for UdCore {
240 type Frame = WsIncoming;
241
242 fn on_frame(&mut self, frame: Self::Frame, now: Instant, out: &mut EventSink) {
243 match frame {
244 WsIncoming::Reconnected => self.on_connect(out),
245 WsIncoming::Data { guid, data } => self.on_data(&guid, data, now, out),
246 WsIncoming::Status {
247 guid: Some(guid),
248 code,
249 message,
250 } => {
251 if let Some(&(id, _)) = self.by_guid.get(&guid) {
252 let st = if code == 200 {
253 (SubState::Active, None)
254 } else {
255 (SubState::Failed, Some(message))
256 };
257 self.states.insert(id, st);
258 self.ack.notify_waiters();
259 }
260 }
261 WsIncoming::Status { .. } | WsIncoming::Unknown(_) => {}
262 }
263 }
264
265 fn poll_transmit(&mut self) -> Option<Outbound> {
266 None
267 }
268
269 fn poll_timeout(&self) -> Option<Instant> {
270 None
271 }
272
273 fn on_timeout(&mut self, _now: Instant, _out: &mut EventSink) {}
274
275 fn apply(&mut self, desired: &SubscriptionSet) {
276 self.desired = desired.clone();
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use serde_json::json;
284 use uuid::Uuid;
285
286 fn core() -> UdCore {
287 UdCore::new("MOEX", BoardBook::default())
288 }
289
290 fn data(guid: &str, d: serde_json::Value) -> WsIncoming {
291 WsIncoming::Data {
292 guid: guid.into(),
293 data: d,
294 }
295 }
296
297 #[test]
298 fn the_cid_comes_from_the_comment_and_is_learned_for_the_number() {
299 let mut c = core();
300 let id = SubscriptionId::new(1001);
301 c.register("o", id, UdSubKind::OrderState);
302 let cid = Uuid::new_v4();
303 let mut out = EventSink::new();
304 let now = Instant::now();
305 let rec = |status: &str, comment: Option<String>| {
306 json!({"id":"77","symbol":"SBER","board":"TQBR","type":"limit","side":"buy","status":status,
307 "qtyUnits":20,"filledQtyUnits":0,"price":100,"comment":comment})
308 };
309 c.on_frame(
310 data("o", rec("working", Some(cid.to_string()))),
311 now,
312 &mut out,
313 );
314 c.on_frame(data("o", rec("canceled", None)), now, &mut out);
316 let events = out.drain();
317 assert_eq!(events.len(), 2);
318 for e in &events {
319 let Event::Order(s) = &e.payload else {
320 panic!("an order")
321 };
322 assert_eq!(s.cid, cid);
323 assert_eq!(e.sub, Some(id));
324 }
325 }
326
327 #[test]
328 fn an_order_that_is_not_ours_gets_a_stable_made_up_cid() {
329 let mut c = core();
330 assert_eq!(
331 c.resolve_cid("500", Some("someone else's note")),
332 foreign_cid("500")
333 );
334 assert_eq!(c.resolve_cid("500", None), foreign_cid("500"));
335 }
336
337 #[test]
338 fn a_fill_is_told_once_and_carries_the_cid_of_its_order() {
339 let mut c = core();
340 c.register("t", SubscriptionId::new(1002), UdSubKind::Executions);
341 let cid = Uuid::new_v4();
342 c.learn_order_id("77", cid);
343 let mut out = EventSink::new();
344 let f = || {
345 data(
346 "t",
347 json!({"id":"T1","orderno":"77","symbol":"SBER","board":"TQBR","qtyUnits":20,"price":100.5,"side":"buy"}),
348 )
349 };
350 c.on_frame(f(), Instant::now(), &mut out);
351 c.on_frame(f(), Instant::now(), &mut out);
352 let events = out.drain();
353 assert_eq!(events.len(), 1);
354 let Event::Execution(e) = &events[0].payload else {
355 panic!("an execution")
356 };
357 assert_eq!(e.cid, Some(cid));
358 }
359
360 #[test]
361 fn a_position_uses_the_board_the_connector_looked_up() {
362 let boards = BoardBook::default();
363 boards
364 .lock()
365 .unwrap()
366 .insert(board_key("MOEX", "SBER"), "TQBR".into());
367 let mut c = UdCore::new("MOEX", boards);
368 c.register("p", SubscriptionId::new(1003), UdSubKind::Portfolio);
369 let mut out = EventSink::new();
370 c.on_frame(
371 data(
372 "p",
373 json!({"symbol":"SBER","qtyUnits":30,"isCurrency":false}),
374 ),
375 Instant::now(),
376 &mut out,
377 );
378 c.on_frame(
379 data(
380 "p",
381 json!({"symbol":"RUB","qtyUnits":1000,"isCurrency":true}),
382 ),
383 Instant::now(),
384 &mut out,
385 );
386 let events = out.drain();
387 let Event::Positions(p) = &events[0].payload else {
388 panic!("positions")
389 };
390 assert_eq!(p[0].instrument.key.display(), "SBER@TQBR");
391 assert!(matches!(events[1].payload, Event::Balances(_)));
392 }
393
394 #[test]
395 fn a_reconnect_tells_the_order_and_fill_subscriptions_from_the_second_connect_on() {
396 let mut c = core();
397 c.register("o", SubscriptionId::new(1001), UdSubKind::OrderState);
398 c.register("p", SubscriptionId::new(1003), UdSubKind::Portfolio);
399 let mut out = EventSink::new();
400 c.on_frame(WsIncoming::Reconnected, Instant::now(), &mut out);
401 assert!(out.drain().is_empty());
402 c.on_frame(WsIncoming::Reconnected, Instant::now(), &mut out);
403 let events = out.drain();
404 assert_eq!(events.len(), 1);
405 assert!(matches!(events[0].payload, Event::Reconnected { .. }));
406 }
407
408 #[test]
409 fn a_refused_subscription_keeps_the_reason_and_frames_nobody_wants_give_nothing() {
410 let mut c = core();
411 let id = SubscriptionId::new(1001);
412 c.register("o", id, UdSubKind::OrderState);
413 let mut out = EventSink::new();
414 let now = Instant::now();
415 c.on_frame(
416 WsIncoming::Status {
417 guid: Some("o".into()),
418 code: 400,
419 message: "refused".into(),
420 },
421 now,
422 &mut out,
423 );
424 assert_eq!(c.actual_state(id), Some(SubState::Failed));
425 assert_eq!(c.failure(id), Some("refused"));
426 c.on_frame(
427 WsIncoming::Status {
428 guid: None,
429 code: 200,
430 message: String::new(),
431 },
432 now,
433 &mut out,
434 );
435 c.on_frame(WsIncoming::Unknown("x".into()), now, &mut out);
436 c.on_frame(data("other", json!({})), now, &mut out);
438 c.on_frame(data("o", json!({"id": 5})), now, &mut out);
439 assert!(out.drain().is_empty());
440 c.forget("o");
442 assert_eq!(c.actual_state(id), None);
443 }
444
445 #[test]
446 fn the_memory_of_order_numbers_and_trades_is_bounded() {
447 let mut c = core();
448 let cid = Uuid::new_v4();
449 c.learn_order_id("", cid);
450 c.learn_order_id("1", Uuid::nil());
451 assert_eq!(c.cid_of_order_id("1"), None);
452 for n in 0..=ORDER_IDS_CAP {
453 c.learn_order_id(&n.to_string(), cid);
454 }
455 assert_eq!(
456 c.cid_of_order_id("0"),
457 None,
458 "the oldest number is forgotten"
459 );
460 assert_eq!(c.cid_of_order_id(&ORDER_IDS_CAP.to_string()), Some(cid));
461
462 c.register("e", SubscriptionId::new(1002), UdSubKind::Executions);
463 let mut out = EventSink::new();
464 let now = Instant::now();
465 let trade = |n: usize| {
466 json!({"id": n.to_string(), "orderno": "9", "symbol": "SBER", "board": "TQBR", "side": "buy",
467 "qtyUnits": 1, "price": 10, "date": "2026-10-20T10:00:00Z"})
468 };
469 for n in 0..=SEEN_EXECUTIONS_CAP {
470 c.on_frame(data("e", trade(n)), now, &mut out);
471 }
472 assert_eq!(out.drain().len(), SEEN_EXECUTIONS_CAP + 1);
473 c.on_frame(data("e", trade(0)), now, &mut out);
474 assert_eq!(out.drain().len(), 1, "the oldest trade is told again");
475 }
476
477 #[test]
478 fn the_core_sends_nothing_by_itself_and_has_no_timers() {
479 let mut c = core();
480 assert!(c.poll_transmit().is_none());
481 assert!(c.poll_timeout().is_none());
482 c.on_timeout(Instant::now(), &mut EventSink::new());
483 c.apply(&SubscriptionSet::new());
484 }
485}