brokapi_finam/core/ud.rs
1//! User-data core: order state, own trades, account (portfolio) updates.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5use std::time::Instant;
6
7use brokapi_core::{ClientOrderId, Event, OrderState, SubscriptionId, UnixNanos};
8use brokapi_finam_protocol::pb::grpc::tradeapi::v1::accounts::GetAccountResponse;
9use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders as pb;
10use brokapi_runtime::{FinishedWindow, OrderRuntime};
11use brokapi_sansio::{
12 BrokerCore, CoreEvent, EventSink, OrderSignal, Outbound, SubscriptionKind, SubscriptionSet,
13};
14
15use super::SubStatus;
16use crate::index::OrderIndex;
17use crate::map::order::map_order_state;
18use crate::map::portfolio::{map_account, map_trade};
19
20/// What arrived on a user data stream.
21// Boxing the large variant would change the public shape of the enum; these values are short-lived.
22#[allow(clippy::large_enum_variant)]
23#[derive(Debug, Clone)]
24pub enum UdEvent {
25 /// A message of the order stream: current states of changed orders.
26 Orders(pb::SubscribeOrdersResponse),
27 /// A message of the own trades stream.
28 Trades(pb::SubscribeTradesResponse),
29 /// A message of the account stream: the whole account state.
30 Account(GetAccountResponse),
31 /// The stream was opened (also after a reopen).
32 Connected,
33 /// The stream broke or ended and is being reopened. The text says why.
34 Disconnected(String),
35 /// The broker refuses the stream (no permission). The text says why.
36 Failed(String),
37}
38
39/// One stream event of one subscription.
40#[derive(Debug, Clone)]
41pub struct UdFrame {
42 /// The subscription whose stream produced the event.
43 pub sub: SubscriptionId,
44 /// What happened on that stream.
45 pub event: UdEvent,
46}
47
48/// What a user data subscription carries.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum UdSubKind {
51 /// Order state: `Event::Order`.
52 Orders,
53 /// Own trades: `Event::Execution`.
54 Executions,
55 /// Account: `Event::Portfolio`.
56 Portfolio,
57}
58
59/// User data core: turns stream frames into order, execution and portfolio events.
60///
61/// It resolves the client order id of every order echo through the [`OrderIndex`] and advances the
62/// local lifecycle of orders this process placed (so `order_state` and the stream agree).
63///
64/// Implements `BrokerCore` from `brokapi-sansio`.
65///
66/// # Examples
67///
68/// ```
69/// use std::sync::Arc;
70/// use std::time::Instant;
71/// use brokapi_core::{Event, SubscriptionId};
72/// use brokapi_finam::core::ud::UdSubKind;
73/// use brokapi_finam::core::{SubStatus, UdCore, UdEvent, UdFrame};
74/// use brokapi_finam::index::OrderIndex;
75/// use brokapi_sansio::{BrokerCore, EventSink};
76/// use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders as po;
77///
78/// let sub = SubscriptionId::new(1000);
79/// let mut core = UdCore::new(Arc::new(OrderIndex::new()));
80/// core.register(sub, UdSubKind::Orders);
81/// assert_eq!(core.status(sub), Some(SubStatus::Connecting));
82///
83/// // An order that this process did not place: it still reaches the consumer, under a made-up cid.
84/// let echo = po::OrderState { order_id: "ORD-7".into(), ..Default::default() };
85/// let mut out = EventSink::new();
86/// core.on_frame(
87/// UdFrame { sub, event: UdEvent::Orders(po::SubscribeOrdersResponse { orders: vec![echo] }) },
88/// Instant::now(),
89/// &mut out,
90/// );
91/// assert_eq!(core.status(sub), Some(SubStatus::Active));
92/// assert!(matches!(out.drain()[0].payload, Event::Order(_)));
93/// ```
94pub struct UdCore {
95 subs: HashMap<SubscriptionId, UdSubKind>,
96 status: HashMap<SubscriptionId, SubStatus>,
97 /// Streams of orders and fills that were open at least once.
98 seen_open: HashSet<SubscriptionId>,
99 /// When a stream of orders or fills went down, until it is open again.
100 down_since: HashMap<SubscriptionId, UnixNanos>,
101 orders: OrderRuntime,
102 index: Arc<OrderIndex>,
103 /// Finished orders, to forget their ids after the memory window.
104 done: FinishedWindow<ClientOrderId>,
105 /// Cids forgotten since the last call of `take_forgotten`.
106 forgotten: Vec<ClientOrderId>,
107}
108
109impl UdCore {
110 /// A core with no subscriptions. `index` is shared with the connector.
111 pub fn new(index: Arc<OrderIndex>) -> Self {
112 Self {
113 subs: HashMap::new(),
114 status: HashMap::new(),
115 seen_open: HashSet::new(),
116 down_since: HashMap::new(),
117 orders: OrderRuntime::new(),
118 index,
119 done: FinishedWindow::new(),
120 forgotten: Vec::new(),
121 }
122 }
123
124 /// Local lifecycle of orders placed through this connector.
125 pub fn orders(&mut self) -> &mut OrderRuntime {
126 &mut self.orders
127 }
128
129 /// Takes the cids the core forgot since the last call: orders that finished long ago (see the memory
130 /// window of [`OrderRuntime`]). The connector drops what else it keeps for them.
131 pub fn take_forgotten(&mut self) -> Vec<ClientOrderId> {
132 std::mem::take(&mut self.forgotten)
133 }
134
135 /// The order index shared with the connector.
136 pub fn index(&self) -> &Arc<OrderIndex> {
137 &self.index
138 }
139
140 /// Adds a subscription of `kind` (status `Connecting`). Executions need this call because the
141 /// shared `SubscriptionKind` has no variant for them.
142 pub fn register(&mut self, sub: SubscriptionId, kind: UdSubKind) {
143 self.subs.insert(sub, kind);
144 self.status.entry(sub).or_insert(SubStatus::Connecting);
145 }
146
147 /// Forgets subscription `sub` and its status. Late frames for it are ignored.
148 pub fn unregister(&mut self, sub: SubscriptionId) {
149 self.subs.remove(&sub);
150 self.status.remove(&sub);
151 self.seen_open.remove(&sub);
152 self.down_since.remove(&sub);
153 }
154
155 /// Stream status of subscription `sub`. `None` for an id the core does not know.
156 pub fn status(&self, sub: SubscriptionId) -> Option<SubStatus> {
157 self.status.get(&sub).cloned()
158 }
159
160 fn order_signal(state: &OrderState) -> Option<OrderSignal> {
161 Some(match state {
162 OrderState::Acked { broker_id, .. } => OrderSignal::Ack {
163 broker_id: broker_id.clone(),
164 },
165 OrderState::PartiallyFilled { filled, left, .. } => OrderSignal::PartialFill {
166 filled: *filled,
167 left: *left,
168 },
169 OrderState::Filled { .. } => OrderSignal::Filled,
170 OrderState::Cancelled { .. } => OrderSignal::Cancelled,
171 OrderState::Rejected { reason, .. } => OrderSignal::Rejected {
172 reason: reason.clone(),
173 },
174 // `InFlight`, `Unknown`, and a state of a later version: no signal.
175 _ => return None,
176 })
177 }
178
179 /// Maps one order echo and advances the local lifecycle of orders we placed.
180 pub fn ingest_order(
181 &mut self,
182 os: &pb::OrderState,
183 now: Instant,
184 ) -> brokapi_core::OrderSnapshot {
185 // An SL/TP order carries its labels in `sltp_order`, not in `order`.
186 let (client_order_id, comment) = crate::map::stop::echo_keys(os);
187 let cid = self.index.resolve(&os.order_id, &client_order_id, &comment);
188 let snap = map_order_state(os, cid, now);
189 if snap.state.is_final() {
190 self.done.push(now, cid);
191 for old in self.done.expired(now) {
192 self.index.forget(old);
193 self.forgotten.push(old);
194 }
195 }
196 if let Some(signal) = Self::order_signal(&snap.state) {
197 // Foreign orders are unknown to the registry: the error is expected and ignored.
198 self.orders.apply_signal(cid, signal, now);
199 }
200 snap
201 }
202}
203
204impl BrokerCore for UdCore {
205 type Frame = UdFrame;
206
207 fn on_frame(&mut self, frame: UdFrame, now: Instant, out: &mut EventSink) {
208 let UdFrame { sub, event } = frame;
209 let Some(kind) = self.subs.get(&sub).copied() else {
210 return;
211 };
212 match event {
213 UdEvent::Connected => {
214 self.status.insert(sub, SubStatus::Active);
215 // A stream of orders or fills that was open, went down and is open again: what happened
216 // to the orders in between never reached us. Tell the consumer (the connector also
217 // closes its trading gate on this event).
218 if matches!(kind, UdSubKind::Orders | UdSubKind::Executions) {
219 let reopened = !self.seen_open.insert(sub);
220 if let (true, Some(gap_from)) = (reopened, self.down_since.remove(&sub)) {
221 out.push(CoreEvent {
222 sub: Some(sub),
223 payload: Event::Reconnected { sub, gap_from },
224 });
225 }
226 }
227 }
228 UdEvent::Disconnected(r) => {
229 self.status.insert(sub, SubStatus::Reconnecting(r));
230 if matches!(kind, UdSubKind::Orders | UdSubKind::Executions)
231 && self.seen_open.contains(&sub)
232 {
233 // Keep the first moment of the gap if the stream goes down again before it opens.
234 self.down_since
235 .entry(sub)
236 .or_insert_with(crate::util::now_nanos);
237 }
238 }
239 UdEvent::Failed(r) => {
240 self.status.insert(sub, SubStatus::Failed(r));
241 }
242 UdEvent::Orders(msg) if kind == UdSubKind::Orders => {
243 self.status.insert(sub, SubStatus::Active);
244 for os in &msg.orders {
245 // Stop and SL/TP orders have a stream of their own (see `map::stop`). Here they would
246 // arrive as an ordinary order with no instrument, side Buy and quantity 0. The cid is
247 // still learned, so that the stop order can be cancelled by it.
248 if crate::map::stop::is_stop(os) {
249 let (client_order_id, comment) = crate::map::stop::echo_keys(os);
250 self.index.resolve(&os.order_id, &client_order_id, &comment);
251 continue;
252 }
253 let snap = self.ingest_order(os, now);
254 out.push(CoreEvent {
255 sub: Some(sub),
256 payload: Event::Order(snap),
257 });
258 }
259 }
260 UdEvent::Trades(msg) if kind == UdSubKind::Executions => {
261 self.status.insert(sub, SubStatus::Active);
262 for t in &msg.trades {
263 let cid = self.index.lookup(&t.order_id, &t.comment);
264 out.push(CoreEvent {
265 sub: Some(sub),
266 payload: Event::Execution(map_trade(t, cid, now)),
267 });
268 }
269 }
270 UdEvent::Account(msg) if kind == UdSubKind::Portfolio => {
271 self.status.insert(sub, SubStatus::Active);
272 out.push(CoreEvent {
273 sub: Some(sub),
274 payload: Event::Portfolio(map_account(&msg, now)),
275 });
276 }
277 // A message that does not match its subscription kind cannot happen.
278 _ => {}
279 }
280 }
281
282 fn poll_transmit(&mut self) -> Option<Outbound> {
283 None
284 }
285
286 fn poll_timeout(&self) -> Option<Instant> {
287 None
288 }
289
290 fn on_timeout(&mut self, _now: Instant, _out: &mut EventSink) {}
291
292 /// Order-state and portfolio subscriptions. Executions are registered with `register`
293 /// because `SubscriptionKind` has no variant for them.
294 fn apply(&mut self, desired: &SubscriptionSet) {
295 self.subs
296 .retain(|id, k| *k == UdSubKind::Executions || desired.find(*id).is_some());
297 for d in desired.iter() {
298 match d.kind {
299 SubscriptionKind::OrderState => self.register(d.id, UdSubKind::Orders),
300 SubscriptionKind::Portfolio => self.register(d.id, UdSubKind::Portfolio),
301 _ => {}
302 }
303 }
304 let live: Vec<_> = self.subs.keys().copied().collect();
305 self.status.retain(|id, _| live.contains(id));
306 }
307}