1use crate::{
13 order::id::{ClientOrderId, OrderId},
14 trade::Trade,
15};
16use rustrade_integration::collection::FnvIndexMap;
17use serde::{Deserialize, Serialize};
18use tracing::debug;
19
20#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
22struct LiveOrder {
23 reduce_only: bool,
24 venue_id: Option<OrderId>,
25}
26
27#[non_exhaustive]
29#[derive(Debug, Clone, Copy, Eq, PartialEq)]
30pub struct BookedOrder<'a> {
31 pub cid: &'a ClientOrderId,
33 pub reduce_only: bool,
35}
36
37#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
60#[serde(from = "BookParts")]
61pub struct ReduceOnlyBook {
62 live: FnvIndexMap<ClientOrderId, LiveOrder>,
64 retired: FnvIndexMap<ClientOrderId, Option<OrderId>>,
67 #[serde(skip)]
70 live_reduce_only: usize,
71}
72
73#[derive(Deserialize)]
75struct BookParts {
76 #[serde(default)]
77 live: FnvIndexMap<ClientOrderId, LiveOrder>,
78 #[serde(default)]
79 retired: FnvIndexMap<ClientOrderId, Option<OrderId>>,
80}
81
82impl From<BookParts> for ReduceOnlyBook {
83 fn from(parts: BookParts) -> Self {
84 let BookParts { live, mut retired } = parts;
85 retired.retain(|cid, _| !live.contains_key(cid));
87 let excess = retired.len().saturating_sub(Self::RETIRED_CAPACITY);
88 retired.drain(..excess);
89 let live_reduce_only = live.values().filter(|order| order.reduce_only).count();
90 Self {
91 live,
92 retired,
93 live_reduce_only,
94 }
95 }
96}
97
98impl ReduceOnlyBook {
99 pub const RETIRED_CAPACITY: usize = 64;
101
102 pub fn new() -> Self {
104 Self::default()
105 }
106
107 pub fn is_empty(&self) -> bool {
109 self.live.is_empty() && self.retired.is_empty()
110 }
111
112 pub fn live_len(&self) -> usize {
115 self.live.len()
116 }
117
118 pub fn has_reduce_only(&self) -> bool {
121 self.live_reduce_only > 0 || !self.retired.is_empty()
122 }
123
124 pub fn register(&mut self, cid: &ClientOrderId, reduce_only: bool) {
131 self.retired.shift_remove(cid);
132 if let Some(earlier) = self.live.shift_remove(cid) {
133 self.live_reduce_only -= usize::from(earlier.reduce_only);
134 }
135 self.live_reduce_only += usize::from(reduce_only);
136 self.live.insert(
137 cid.clone(),
138 LiveOrder {
139 reduce_only,
140 venue_id: None,
141 },
142 );
143 }
144
145 pub fn acknowledge(&mut self, cid: &ClientOrderId, venue_id: &OrderId) {
152 if let Some(live) = self.live.get_mut(cid) {
153 live.venue_id = Some(venue_id.clone());
154 } else if let Some(retired) = self.retired.get_mut(cid) {
155 *retired = Some(venue_id.clone());
156 }
157 }
158
159 pub fn retire(&mut self, cid: &ClientOrderId) {
164 if let Some(live) = self.live.shift_remove(cid)
165 && live.reduce_only
166 {
167 self.live_reduce_only -= 1;
168 self.insert_retired(cid.clone(), live.venue_id);
169 }
170 }
171
172 pub fn retire_untracked(&mut self, mut is_tracked: impl FnMut(&ClientOrderId) -> bool) {
178 let mut ended: Vec<(ClientOrderId, Option<OrderId>)> = Vec::new();
179 self.live.retain(|cid, order| {
180 let tracked = is_tracked(cid);
181 if !tracked && order.reduce_only {
182 ended.push((cid.clone(), order.venue_id.take()));
183 }
184 tracked
185 });
186 self.live_reduce_only -= ended.len();
187 for (cid, venue_id) in ended {
188 self.insert_retired(cid, venue_id);
189 }
190 }
191
192 pub fn lookup<AssetKey, InstrumentKey>(
198 &self,
199 trade: &Trade<AssetKey, InstrumentKey>,
200 ) -> Option<BookedOrder<'_>> {
201 self.lookup_ids(trade.client_order_id.as_ref(), &trade.order_id)
202 }
203
204 pub fn lookup_ids(
208 &self,
209 cid: Option<&ClientOrderId>,
210 order_id: &OrderId,
211 ) -> Option<BookedOrder<'_>> {
212 if let Some(cid) = cid {
213 return self
214 .live
215 .get_key_value(cid)
216 .map(|(cid, live)| BookedOrder {
217 cid,
218 reduce_only: live.reduce_only,
219 })
220 .or_else(|| {
221 self.retired.get_key_value(cid).map(|(cid, _)| BookedOrder {
222 cid,
223 reduce_only: true,
224 })
225 });
226 }
227 self.live
230 .iter()
231 .find(|(_, live)| live.venue_id.as_ref() == Some(order_id))
232 .map(|(cid, live)| BookedOrder {
233 cid,
234 reduce_only: live.reduce_only,
235 })
236 .or_else(|| {
237 self.retired
238 .iter()
239 .find(|(_, venue_id)| venue_id.as_ref() == Some(order_id))
240 .map(|(cid, _)| BookedOrder {
241 cid,
242 reduce_only: true,
243 })
244 })
245 }
246
247 pub fn is_reduce_only<AssetKey, InstrumentKey>(
250 &self,
251 trade: &Trade<AssetKey, InstrumentKey>,
252 ) -> bool {
253 self.lookup(trade).is_some_and(|order| order.reduce_only)
254 }
255
256 pub fn clear(&mut self) {
258 self.live.clear();
259 self.retired.clear();
260 self.live_reduce_only = 0;
261 }
262
263 fn insert_retired(&mut self, cid: ClientOrderId, venue_id: Option<OrderId>) {
264 if self.retired.len() >= Self::RETIRED_CAPACITY
265 && !self.retired.contains_key(&cid)
266 && let Some((evicted, _)) = self.retired.shift_remove_index(0)
267 {
268 debug!(
269 evicted_cid = %evicted,
270 capacity = Self::RETIRED_CAPACITY,
271 "Retired reduce-only orders at capacity: evicting the oldest. A late fill of the \
272 evicted order is now not recognised as reduce-only."
273 );
274 }
275 self.retired.insert(cid, venue_id);
276 }
277}
278
279#[cfg(test)]
280#[allow(clippy::unwrap_used)] mod tests {
282 use super::*;
283 use crate::{order::id::StrategyId, trade::AssetFees};
284 use chrono::DateTime;
285 use rust_decimal_macros::dec;
286 use rustrade_instrument::Side;
287 use rustrade_instrument::asset::QuoteAsset;
288
289 fn cid(id: &str) -> ClientOrderId {
290 ClientOrderId::new(id)
291 }
292
293 fn venue(id: &str) -> OrderId {
294 OrderId::new(id)
295 }
296
297 fn fill(cid: Option<&str>, venue_id: &str) -> Trade<QuoteAsset, ()> {
298 let trade = Trade::new(
299 crate::trade::TradeId::new("t"),
300 venue(venue_id),
301 (),
302 StrategyId::new("s"),
303 DateTime::UNIX_EPOCH,
304 Side::Sell,
305 dec!(1),
306 dec!(1),
307 None,
308 AssetFees::quote_fees(dec!(0)),
309 );
310 match cid {
311 Some(id) => trade.with_client_order_id(self::cid(id)),
312 None => trade,
313 }
314 }
315
316 fn flag(book: &ReduceOnlyBook, trade: &Trade<QuoteAsset, ()>) -> Option<bool> {
317 book.lookup(trade).map(|order| order.reduce_only)
318 }
319
320 #[test]
321 fn a_fill_finds_its_order_by_client_order_id() {
322 let mut book = ReduceOnlyBook::new();
323 book.register(&cid("ro"), true);
324 book.register(&cid("plain"), false);
325 assert_eq!(flag(&book, &fill(Some("ro"), "v")), Some(true));
326 assert_eq!(flag(&book, &fill(Some("plain"), "v")), Some(false));
327 assert_eq!(flag(&book, &fill(Some("other"), "v")), None);
328 assert!(book.is_reduce_only(&fill(Some("ro"), "v")));
329 assert!(!book.is_reduce_only(&fill(Some("plain"), "v")));
330 assert!(!book.is_reduce_only(&fill(Some("other"), "v")));
331 }
332
333 #[test]
334 fn a_fill_naming_only_the_venue_id_finds_its_order_once_acknowledged() {
335 let mut book = ReduceOnlyBook::new();
336 book.register(&cid("ro"), true);
337 assert_eq!(flag(&book, &fill(None, "v1")), None, "not yet acknowledged");
338 book.acknowledge(&cid("ro"), &venue("v1"));
339 let trade = fill(None, "v1");
340 let found = book.lookup(&trade).unwrap();
341 assert_eq!((found.cid, found.reduce_only), (&cid("ro"), true));
342 }
343
344 #[test]
345 fn a_client_order_id_the_book_lacks_does_not_fall_back_to_the_venue_id() {
346 let mut book = ReduceOnlyBook::new();
347 book.register(&cid("ro"), true);
348 book.acknowledge(&cid("ro"), &venue("v1"));
349 assert_eq!(flag(&book, &fill(Some("other"), "v1")), None);
350 }
351
352 #[test]
353 fn a_retired_reduce_only_order_is_still_found_and_an_ordinary_one_is_forgotten() {
354 let mut book = ReduceOnlyBook::new();
355 book.register(&cid("ro"), true);
356 book.register(&cid("plain"), false);
357 book.acknowledge(&cid("ro"), &venue("v1"));
358 book.acknowledge(&cid("plain"), &venue("v2"));
359 book.retire(&cid("ro"));
360 book.retire(&cid("plain"));
361 assert_eq!(flag(&book, &fill(Some("ro"), "x")), Some(true));
362 assert_eq!(flag(&book, &fill(None, "v1")), Some(true));
363 assert_eq!(flag(&book, &fill(Some("plain"), "x")), None);
364 assert_eq!(flag(&book, &fill(None, "v2")), None);
365 }
366
367 #[test]
368 fn an_order_retired_before_its_acknowledgement_takes_the_venue_id_after() {
369 let mut book = ReduceOnlyBook::new();
370 book.register(&cid("ro"), true);
371 book.retire(&cid("ro"));
372 assert_eq!(flag(&book, &fill(None, "v1")), None);
373 book.acknowledge(&cid("ro"), &venue("v1"));
374 assert_eq!(flag(&book, &fill(None, "v1")), Some(true));
375 }
376
377 #[test]
378 fn a_live_order_outranks_a_retired_one_under_the_same_venue_id() {
379 let mut book = ReduceOnlyBook::new();
380 book.register(&cid("old"), true);
381 book.acknowledge(&cid("old"), &venue("v1"));
382 book.retire(&cid("old"));
383 book.register(&cid("new"), false);
384 book.acknowledge(&cid("new"), &venue("v1"));
385 let trade = fill(None, "v1");
386 let found = book.lookup(&trade).unwrap();
387 assert_eq!((found.cid, found.reduce_only), (&cid("new"), false));
388 }
389
390 #[test]
391 fn registering_a_reused_client_order_id_drops_what_the_earlier_order_left() {
392 for retire_first in [false, true] {
393 let mut book = ReduceOnlyBook::new();
394 book.register(&cid("c"), true);
395 book.acknowledge(&cid("c"), &venue("v1"));
396 if retire_first {
397 book.retire(&cid("c"));
398 }
399 book.register(&cid("c"), false);
400 assert_eq!(flag(&book, &fill(Some("c"), "x")), Some(false));
401 assert_eq!(flag(&book, &fill(None, "v1")), None, "{retire_first}");
402 }
403 }
404
405 #[test]
406 fn the_oldest_retirement_is_dropped_at_capacity() {
407 let mut book = ReduceOnlyBook::new();
408 let cids: Vec<_> = (0..=ReduceOnlyBook::RETIRED_CAPACITY)
409 .map(|i| cid(&format!("c{i}")))
410 .collect();
411 for c in &cids {
412 book.register(c, true);
413 book.retire(c);
414 }
415 assert_eq!(book.retired.len(), ReduceOnlyBook::RETIRED_CAPACITY);
416 assert_eq!(flag(&book, &fill(Some("c0"), "x")), None);
417 assert_eq!(flag(&book, &fill(Some("c1"), "x")), Some(true));
418 assert_eq!(flag(&book, &fill(Some("c64"), "x")), Some(true));
419 }
420
421 #[test]
422 fn ordinary_orders_do_not_push_out_retired_reduce_only_ones() {
423 let mut book = ReduceOnlyBook::new();
424 book.register(&cid("ro"), true);
425 book.retire(&cid("ro"));
426 for i in 0..ReduceOnlyBook::RETIRED_CAPACITY * 2 {
427 let c = cid(&format!("p{i}"));
428 book.register(&c, false);
429 book.retire(&c);
430 }
431 assert_eq!(flag(&book, &fill(Some("ro"), "x")), Some(true));
432 }
433
434 #[test]
435 fn retire_untracked_retires_in_the_order_registered() {
436 let mut book = ReduceOnlyBook::new();
437 let cids: Vec<_> = (0..5).map(|i| cid(&format!("c{i}"))).collect();
438 for c in &cids {
439 book.register(c, true);
440 }
441 let kept = cid("c2");
442 book.retire_untracked(|c| *c == kept);
443 assert!(
444 book.retired
445 .keys()
446 .eq([&cids[0], &cids[1], &cids[3], &cids[4]])
447 );
448 assert!(book.live.keys().eq([&kept]));
449 }
450
451 #[test]
452 fn retire_untracked_keeps_the_reduce_only_count_and_forgets_ordinary_orders() {
453 let mut book = ReduceOnlyBook::new();
454 book.register(&cid("ro1"), true);
455 book.register(&cid("gone"), false);
456 book.register(&cid("ro2"), true);
457 book.register(&cid("kept"), false);
458 book.retire_untracked(|c| *c == cid("kept"));
459 assert_eq!(book.live_reduce_only, 0);
460 assert!(book.live.keys().eq([&cid("kept")]));
461 assert!(book.retired.keys().eq([&cid("ro1"), &cid("ro2")]));
462 assert!(book.has_reduce_only(), "retired ones are still held");
463 book.retired.clear();
464 assert!(!book.has_reduce_only());
465 }
466
467 #[test]
468 fn retiring_or_acknowledging_an_unknown_order_does_nothing() {
469 let mut book = ReduceOnlyBook::new();
470 book.retire(&cid("none"));
471 book.acknowledge(&cid("none"), &venue("v"));
472 assert!(book.is_empty());
473 }
474
475 #[test]
476 fn clear_forgets_everything() {
477 let mut book = ReduceOnlyBook::new();
478 book.register(&cid("a"), true);
479 book.register(&cid("b"), true);
480 book.retire(&cid("b"));
481 book.clear();
482 assert!(book.is_empty());
483 }
484
485 #[test]
486 fn is_empty_and_live_len_count_live_and_retired_orders() {
487 let mut book = ReduceOnlyBook::new();
488 assert_eq!((book.is_empty(), book.live_len()), (true, 0));
489 book.register(&cid("plain"), false);
490 book.register(&cid("ro"), true);
491 assert_eq!((book.is_empty(), book.live_len()), (false, 2), "live only");
492 book.retire(&cid("ro"));
493 assert_eq!((book.is_empty(), book.live_len()), (false, 1));
494 book.retire(&cid("plain"));
495 assert_eq!(
496 (book.is_empty(), book.live_len()),
497 (false, 0),
498 "retired only"
499 );
500 }
501
502 #[test]
503 fn has_reduce_only_follows_the_orders_held() {
504 let mut book = ReduceOnlyBook::new();
505 assert!(!book.has_reduce_only());
506 book.register(&cid("plain"), false);
507 assert!(!book.has_reduce_only());
508 book.register(&cid("ro"), true);
509 assert!(book.has_reduce_only());
510 book.register(&cid("ro"), false);
511 assert!(!book.has_reduce_only(), "replaced by an ordinary order");
512 book.register(&cid("ro"), true);
513 book.retire(&cid("ro"));
514 assert!(book.has_reduce_only(), "retired but still held");
515 book.clear();
516 assert!(!book.has_reduce_only());
517 book.register(&cid("a"), true);
518 book.register(&cid("b"), true);
519 book.retire_untracked(|c| *c == cid("a"));
520 assert_eq!((book.live_len(), book.has_reduce_only()), (1, true));
521 }
522
523 #[test]
524 fn the_serialised_form_is_pinned_and_keeps_the_retirement_order() {
525 let mut book = ReduceOnlyBook::new();
526 book.register(&cid("b"), true);
527 book.acknowledge(&cid("b"), &venue("v"));
528 book.retire(&cid("b"));
529 book.register(&cid("a"), true);
530 book.retire(&cid("a"));
531 book.register(&cid("c"), false);
532 let json = serde_json::to_string(&book).unwrap();
533 assert_eq!(
534 json,
535 r#"{"live":{"c":{"reduce_only":false,"venue_id":null}},"retired":{"b":"v","a":null}}"#
536 );
537 let loaded: ReduceOnlyBook = serde_json::from_str(&json).unwrap();
538 assert!(loaded.retired.keys().eq(book.retired.keys()));
539 assert_eq!(loaded.has_reduce_only(), book.has_reduce_only());
540 }
541
542 #[test]
543 fn a_loaded_book_keeps_its_bound_and_counts_its_live_reduce_only_orders() {
544 let retired: Vec<String> = (0..100).map(|i| format!(r#""c{i}":null"#)).collect();
546 let json = format!(
547 r#"{{"live":{{"l":{{"reduce_only":true,"venue_id":null}},"c99":{{"reduce_only":false,"venue_id":null}}}},"retired":{{{}}}}}"#,
548 retired.join(",")
549 );
550 let mut book: ReduceOnlyBook = serde_json::from_str(&json).unwrap();
551 assert_eq!(book.retired.len(), ReduceOnlyBook::RETIRED_CAPACITY);
552 assert!(book.retired.keys().next().is_some_and(|c| *c == cid("c35")));
553 assert!(!book.retired.contains_key(&cid("c99")), "live wins");
554 book.retire(&cid("l"));
555 assert_eq!(book.retired.len(), ReduceOnlyBook::RETIRED_CAPACITY);
556 book.retire(&cid("c99"));
557 assert!(book.live.is_empty() && book.live_reduce_only == 0);
558 }
559
560 #[test]
561 fn a_book_round_trips_through_json_and_loads_from_nothing() {
562 let mut book = ReduceOnlyBook::new();
563 book.register(&cid("live"), true);
564 book.acknowledge(&cid("live"), &venue("v1"));
565 book.register(&cid("gone"), true);
566 book.retire(&cid("gone"));
567 let json = serde_json::to_string(&book).unwrap();
568 assert_eq!(serde_json::from_str::<ReduceOnlyBook>(&json).unwrap(), book);
569 assert_eq!(
570 serde_json::from_str::<ReduceOnlyBook>("{}").unwrap(),
571 ReduceOnlyBook::new()
572 );
573 }
574}