brokapi_runtime/reconcile.rs
1//! Bootstrap ledger reconciliation.
2
3use std::collections::{HashMap, HashSet};
4
5use brokapi_core::{ClientOrderId, OrderSnapshot, ReconcileReport};
6
7/// Compares the client's ledger of orders with the broker's active orders and sorts them into three
8/// groups.
9///
10/// - `confirmed`: in both. The broker's snapshot is returned.
11/// - `closed_offline`: in the ledger, not active at the broker. They filled, were cancelled or
12/// were rejected while the client was away. Their cids are returned. The function does not say
13/// which of these happened. Ask the broker for the order state.
14/// - `foreign`: active at the broker, not in the ledger. They were placed by someone else (another
15/// program, the broker's app). They must not be touched. Why: cancelling an order the client
16/// never created could damage someone else's strategy.
17///
18/// Broker orders whose cid is the nil UUID (no client id) are ignored. They cannot be matched with
19/// a ledger entry and are not reported as foreign. The order of the returned lists is not defined.
20///
21/// The function is pure: no I/O and no state.
22///
23/// # Examples
24///
25/// ```
26/// use std::time::Instant;
27/// use brokapi_core::{
28/// BrokerOrderId, ClientOrderId, InstrumentId, InstrumentKey, NativeId, OrderSnapshot,
29/// OrderState, OrderType, Price, Qty, Side, Timestamps, UnixNanos,
30/// };
31/// use brokapi_runtime::reconcile_ledger;
32///
33/// let sber = InstrumentId::new(
34/// InstrumentKey::new("SBER", "TQBR"),
35/// NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() },
36/// );
37/// let order = |cid: ClientOrderId| OrderSnapshot::new(cid, sber.clone(), OrderState::Acked { cid, broker_id: BrokerOrderId::new("1") }, Side::Buy, OrderType::Limit, Qty::from(1), Timestamps::new(UnixNanos::ZERO, Instant::now())).with_filled(Qty::from(0)).with_price(Some(Price::from(100)));
38/// let (kept, gone, other) = (ClientOrderId::new_v4(), ClientOrderId::new_v4(), ClientOrderId::new_v4());
39///
40/// // We remember `kept` and `gone`. The broker shows `kept` and one we never placed.
41/// let ledger = [order(kept), order(gone)];
42/// let at_broker = [order(kept), order(other)];
43/// let report = reconcile_ledger(&ledger, &at_broker);
44///
45/// assert_eq!(report.confirmed.len(), 1);
46/// assert_eq!(report.confirmed[0].cid, kept);
47/// assert_eq!(report.closed_offline, vec![gone]);
48/// assert_eq!(report.foreign[0].cid, other);
49/// ```
50pub fn reconcile_ledger(
51 ledger: &[OrderSnapshot],
52 broker_active: &[OrderSnapshot],
53) -> ReconcileReport {
54 let ledger_map: HashMap<ClientOrderId, &OrderSnapshot> =
55 ledger.iter().map(|o| (o.cid, o)).collect();
56 let broker_map: HashMap<ClientOrderId, &OrderSnapshot> = broker_active
57 .iter()
58 .filter(|o| !o.cid.is_nil())
59 .map(|o| (o.cid, o))
60 .collect();
61
62 let ledger_ids: HashSet<_> = ledger_map.keys().copied().collect();
63 let broker_ids: HashSet<_> = broker_map.keys().copied().collect();
64
65 let confirmed = ledger_ids
66 .intersection(&broker_ids)
67 .filter_map(|cid| broker_map.get(cid).copied().cloned())
68 .collect();
69
70 let closed_offline = ledger_ids.difference(&broker_ids).copied().collect();
71
72 let foreign = broker_ids
73 .difference(&ledger_ids)
74 .filter_map(|cid| broker_map.get(cid).copied().cloned())
75 .collect();
76
77 ReconcileReport::new(confirmed, closed_offline, foreign)
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 use brokapi_core::{
85 InstrumentId, InstrumentKey, NativeId, OrderState, OrderType, Qty, Side, Timestamps,
86 UnixNanos,
87 };
88 use std::time::Instant;
89
90 fn snapshot(cid: u8) -> OrderSnapshot {
91 OrderSnapshot::new(
92 ClientOrderId::from_bytes([cid; 16]),
93 InstrumentId::new(
94 InstrumentKey::new("SBER", "TQBR"),
95 NativeId::TickerClass {
96 ticker: "SBER".into(),
97 class_code: "TQBR".into(),
98 },
99 ),
100 OrderState::Acked {
101 cid: ClientOrderId::from_bytes([cid; 16]),
102 broker_id: brokapi_core::BrokerOrderId::new("1"),
103 },
104 Side::Buy,
105 OrderType::Limit,
106 Qty::from(1),
107 Timestamps::new(UnixNanos::ZERO, Instant::now()),
108 )
109 .with_filled(Qty::from(0))
110 }
111
112 #[test]
113 fn splits_confirmed_closed_foreign() {
114 let ledger = vec![snapshot(1), snapshot(2)];
115 let broker = vec![snapshot(1), snapshot(3)];
116 let report = reconcile_ledger(&ledger, &broker);
117 assert_eq!(report.confirmed.len(), 1);
118 assert_eq!(report.closed_offline.len(), 1);
119 assert_eq!(report.foreign.len(), 1);
120 }
121}