1use std::collections::{HashMap, HashSet, VecDeque};
4use std::time::{Duration, Instant};
5
6use brokapi_bcs_protocol::http::models::{
7 OrderOperationResponse, OrderSearchResponse, OrderStatusResponse,
8};
9use brokapi_bcs_protocol::models::messages::IncomingMessage;
10use brokapi_bcs_protocol::ws::models::OrderMessage;
11use brokapi_core::SubscriptionId;
12use brokapi_sansio::{
13 BrokerCore, CoreEvent, EventSink, OrderRegistry, OrderSignal, Outbound, SubscriptionKind,
14 SubscriptionSet,
15};
16
17use crate::map::execution::{Cumulative, cumulative_of, map_ws_execution};
18use crate::map::order::{map_order_status, map_place_response, map_ws_order_message};
19use crate::map::portfolio::map_ws_portfolio;
20
21#[allow(clippy::large_enum_variant)]
27#[derive(Debug, Clone)]
28pub enum UdRestFrame {
29 Place(OrderOperationResponse),
31 Cancel(OrderOperationResponse),
33 Replace(OrderOperationResponse),
35 OrderState(OrderStatusResponse),
37 ActiveOrders(OrderSearchResponse),
39}
40
41#[allow(clippy::large_enum_variant)]
44#[derive(Debug, Clone)]
45pub enum UdFrame {
46 Ws {
48 channel: brokapi_bcs_protocol::ws::client::BcsWsChannel,
50 text: String,
52 },
53 Rest(UdRestFrame),
55}
56
57#[derive(Debug)]
103pub struct UdCore {
104 desired: SubscriptionSet,
105 actual: HashMap<SubscriptionId, bool>,
106 outbound: VecDeque<Outbound>,
107 orders: OrderRegistry,
108 command_timeout: Duration,
109 next_deadline: Option<Instant>,
110 seen_executions: HashSet<String>,
112 seen_order: VecDeque<String>,
113 order_ids: HashMap<String, brokapi_core::ClientOrderId>,
117 totals: HashMap<String, Cumulative>,
120 totals_order: VecDeque<String>,
121 done: brokapi_runtime::FinishedWindow<(brokapi_core::ClientOrderId, Option<String>)>,
123 connects: u32,
126}
127
128const SEEN_EXECUTIONS_CAP: usize = 20_000;
130const TOTALS_CAP: usize = 20_000;
132
133impl Default for UdCore {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl UdCore {
140 pub fn new() -> Self {
143 Self {
144 desired: SubscriptionSet::new(),
145 actual: HashMap::new(),
146 outbound: VecDeque::new(),
147 orders: OrderRegistry::new(),
148 command_timeout: Duration::from_secs(30),
149 next_deadline: None,
150 seen_executions: HashSet::new(),
151 seen_order: VecDeque::new(),
152 order_ids: HashMap::new(),
153 totals: HashMap::new(),
154 totals_order: VecDeque::new(),
155 done: brokapi_runtime::FinishedWindow::new(),
156 connects: 0,
157 }
158 }
159
160 pub fn learn_order_id(&mut self, order_id: &str, cid: brokapi_core::ClientOrderId) {
162 if !order_id.is_empty() && !cid.is_nil() {
163 self.order_ids.insert(order_id.to_string(), cid);
164 }
165 }
166
167 pub fn cid_of_order_id(&self, order_id: &str) -> Option<brokapi_core::ClientOrderId> {
169 self.order_ids.get(order_id).copied()
170 }
171
172 pub fn orders(&self) -> &OrderRegistry {
174 &self.orders
175 }
176
177 pub fn orders_mut(&mut self) -> &mut OrderRegistry {
179 &mut self.orders
180 }
181
182 pub fn register_place(&mut self, cid: brokapi_core::ClientOrderId, now: Instant) {
187 self.orders.register_place(cid, now);
188 self.next_deadline = Some(now + self.command_timeout);
189 }
190
191 fn handle_ws(
192 &mut self,
193 channel: brokapi_bcs_protocol::ws::client::BcsWsChannel,
194 text: &str,
195 now: Instant,
196 out: &mut EventSink,
197 ) {
198 let msg = brokapi_bcs_protocol::ws::parse_incoming(text, channel);
199 self.handle_incoming(msg, now, out);
200 }
201
202 pub fn on_incoming(&mut self, msg: IncomingMessage, now: Instant, out: &mut EventSink) {
209 self.handle_incoming(msg, now, out);
210 }
211
212 fn handle_incoming(&mut self, msg: IncomingMessage, now: Instant, out: &mut EventSink) {
213 match msg {
214 IncomingMessage::OrderExecution(order) => {
215 self.push_order(&order, now, out);
218 self.push_execution(&order, now, out);
219 }
220 IncomingMessage::OrderTransaction(order) => self.push_order(&order, now, out),
221 IncomingMessage::Reconnected => self.on_connect(out),
222 IncomingMessage::Portfolio(positions) => {
223 out.push(CoreEvent {
224 sub: self.find_portfolio_sub(),
225 payload: map_ws_portfolio(&positions, now),
226 });
227 if let Some(id) = self.find_portfolio_sub() {
228 self.actual.insert(id, true);
229 }
230 }
231 _ => {}
232 }
233 }
234
235 fn on_connect(&mut self, out: &mut EventSink) {
239 self.connects += 1;
240 if self.connects < 2 {
241 return;
242 }
243 let gap_from = crate::map::unix_nanos(chrono::Utc::now());
244 for sub in [self.find_order_state_sub(), self.find_execution_sub()]
245 .into_iter()
246 .flatten()
247 {
248 out.push(CoreEvent {
249 sub: Some(sub),
250 payload: brokapi_core::Event::Reconnected { sub, gap_from },
251 });
252 }
253 }
254
255 fn push_order(&mut self, order: &OrderMessage, now: Instant, out: &mut EventSink) {
256 if let Some(event) = map_ws_order_message(order, now) {
257 if let brokapi_core::Event::Order(snapshot) = &event {
258 if let Some(id) = &snapshot.broker_id {
259 self.learn_order_id(id.as_str(), snapshot.cid);
260 }
261 self.apply_order_snapshot(snapshot, now);
262 }
263 out.push(CoreEvent {
264 sub: self.find_order_state_sub(),
265 payload: event,
266 });
267 }
268 }
269
270 fn push_execution(&mut self, order: &OrderMessage, now: Instant, out: &mut EventSink) {
274 let Some(data) = order.data.as_ref() else {
275 return;
276 };
277 let order_id = data.order_id.clone().filter(|s| !s.is_empty());
278 let before = order_id
279 .as_ref()
280 .and_then(|id| self.totals.get(id))
281 .copied();
282 let after = cumulative_of(data);
283 let exec = match self.find_execution_sub() {
284 Some(sub) => map_ws_execution(order, now, before).map(|e| (sub, e)),
285 None => None,
286 };
287 if let (Some(id), Some(after)) = (order_id, after) {
288 self.remember_totals(id, after);
289 }
290 let Some((sub, exec)) = exec else {
291 return;
292 };
293 if !self.seen_executions.insert(exec.trade_id.clone()) {
294 return;
295 }
296 self.seen_order.push_back(exec.trade_id.clone());
297 if self.seen_order.len() > SEEN_EXECUTIONS_CAP
298 && let Some(old) = self.seen_order.pop_front()
299 {
300 self.seen_executions.remove(&old);
301 }
302 out.push(CoreEvent {
303 sub: Some(sub),
304 payload: brokapi_core::Event::Execution(exec),
305 });
306 }
307
308 fn remember_totals(&mut self, order_id: String, totals: Cumulative) {
309 if self.totals.insert(order_id.clone(), totals).is_none() {
310 self.totals_order.push_back(order_id);
311 if self.totals_order.len() > TOTALS_CAP
312 && let Some(old) = self.totals_order.pop_front()
313 {
314 self.totals.remove(&old);
315 }
316 }
317 }
318
319 fn find_execution_sub(&self) -> Option<SubscriptionId> {
320 self.desired
321 .subs()
322 .iter()
323 .find_map(|s| matches!(s.kind, SubscriptionKind::Executions).then_some(s.id))
324 }
325
326 fn handle_rest(&mut self, frame: UdRestFrame, now: Instant, out: &mut EventSink) {
327 match frame {
328 UdRestFrame::Place(resp) => {
329 self.orders
330 .apply(
331 resp.client_order_id,
332 OrderSignal::Ack {
333 broker_id: brokapi_core::BrokerOrderId::new(
334 resp.client_order_id.hyphenated().to_string(),
335 ),
336 },
337 now,
338 )
339 .ok();
340 out.push(CoreEvent {
341 sub: None,
342 payload: map_place_response(&resp, now),
343 });
344 }
345 UdRestFrame::Cancel(resp) => {
346 self.orders
347 .apply(resp.client_order_id, OrderSignal::Cancelled, now)
348 .ok();
349 }
350 UdRestFrame::Replace(resp) => {
351 self.orders
352 .apply(
353 resp.client_order_id,
354 OrderSignal::Ack {
355 broker_id: brokapi_core::BrokerOrderId::new(
356 resp.client_order_id.hyphenated().to_string(),
357 ),
358 },
359 now,
360 )
361 .ok();
362 }
363 UdRestFrame::OrderState(resp) => {
364 if let Some(event) = map_order_status(&resp, now) {
365 if let brokapi_core::Event::Order(snapshot) = &event {
366 self.apply_order_snapshot(snapshot, now);
367 }
368 out.push(CoreEvent {
369 sub: self.find_order_state_sub(),
370 payload: event,
371 });
372 }
373 }
374 UdRestFrame::ActiveOrders(_resp) => {}
375 }
376 }
377
378 fn apply_order_snapshot(&mut self, snapshot: &brokapi_core::OrderSnapshot, now: Instant) {
379 let signal = match &snapshot.state {
380 brokapi_core::OrderState::Acked { broker_id, .. } => OrderSignal::Ack {
381 broker_id: broker_id.clone(),
382 },
383 brokapi_core::OrderState::PartiallyFilled { filled, left, .. } => {
384 OrderSignal::PartialFill {
385 filled: *filled,
386 left: *left,
387 }
388 }
389 brokapi_core::OrderState::Filled { .. } => OrderSignal::Filled,
390 brokapi_core::OrderState::Cancelled { .. } => OrderSignal::Cancelled,
391 brokapi_core::OrderState::Rejected { reason, .. } => OrderSignal::Rejected {
392 reason: reason.clone(),
393 },
394 _ => return,
395 };
396 let _ = self.orders.apply(snapshot.cid, signal, now);
397 if snapshot.state.is_final() {
398 let order_id = snapshot.broker_id.as_ref().map(|b| b.as_str().to_string());
399 self.done.push(now, (snapshot.cid, order_id));
400 for (cid, order_id) in self.done.expired(now) {
401 self.orders.forget_finished(cid);
402 if let Some(id) = order_id {
403 self.order_ids.remove(&id);
404 self.totals.remove(&id);
405 }
406 }
407 }
408 }
409
410 fn find_order_state_sub(&self) -> Option<SubscriptionId> {
411 self.desired.subs().iter().find_map(|s| {
412 if matches!(s.kind, SubscriptionKind::OrderState) {
413 Some(s.id)
414 } else {
415 None
416 }
417 })
418 }
419
420 fn find_portfolio_sub(&self) -> Option<SubscriptionId> {
421 self.desired.subs().iter().find_map(|s| {
422 if matches!(s.kind, SubscriptionKind::Portfolio) {
423 Some(s.id)
424 } else {
425 None
426 }
427 })
428 }
429
430 pub fn feed_ws(
434 &mut self,
435 channel: brokapi_bcs_protocol::ws::client::BcsWsChannel,
436 text: &str,
437 now: Instant,
438 out: &mut EventSink,
439 ) {
440 let msg = brokapi_bcs_protocol::ws::parse_incoming(text, channel);
441 self.handle_incoming(msg, now, out);
442 }
443}
444
445impl BrokerCore for UdCore {
446 type Frame = UdFrame;
447
448 fn on_frame(&mut self, frame: Self::Frame, now: Instant, out: &mut EventSink) {
449 match frame {
450 UdFrame::Ws { channel, text } => self.handle_ws(channel, &text, now, out),
451 UdFrame::Rest(rest) => self.handle_rest(rest, now, out),
452 }
453 }
454
455 fn poll_transmit(&mut self) -> Option<Outbound> {
456 self.outbound.pop_front()
457 }
458
459 fn poll_timeout(&self) -> Option<Instant> {
460 self.next_deadline
461 }
462
463 fn on_timeout(&mut self, now: Instant, out: &mut EventSink) {
464 let changed = self.orders.on_global_timeout(now, self.command_timeout);
465 for (cid, state) in changed {
466 out.push(CoreEvent {
467 sub: None,
468 payload: brokapi_core::Event::Order(brokapi_core::OrderSnapshot::new(
469 cid,
470 brokapi_core::InstrumentId::new(
471 brokapi_core::InstrumentKey::new("", ""),
472 brokapi_core::NativeId::TickerClass {
473 ticker: String::new(),
474 class_code: String::new(),
475 },
476 ),
477 state,
478 brokapi_core::Side::Buy,
479 brokapi_core::OrderType::Limit,
480 brokapi_core::Qty::ZERO,
481 brokapi_core::Timestamps::new(brokapi_core::UnixNanos::ZERO, now),
482 )),
483 });
484 }
485 self.next_deadline = None;
486 }
487
488 fn apply(&mut self, desired: &SubscriptionSet) {
489 self.desired = desired.clone();
490 for sub in desired.subs() {
491 match sub.kind {
492 SubscriptionKind::OrderState
493 | SubscriptionKind::Portfolio
494 | SubscriptionKind::Executions => {
495 self.actual.insert(sub.id, false);
496 }
497 _ => {}
498 }
499 }
500 }
501}