Skip to main content

rustrade_execution/
reduce_only.rs

1//! Which of an instrument's orders were sent reduce-only, kept so a fill can be matched to the
2//! [`ReduceOnly`] of its order.
3//!
4//! Neither an [`Order`](crate::order::Order) nor a venue's report of one carries what
5//! [`RequestOpen::reduce_only`](crate::order::request::RequestOpen::reduce_only) set. Whoever
6//! sends the request is the one place that learns it, so whoever needs it for a fill later, such
7//! as a tracker that cuts a reduce-only fill short of flipping a position (see
8//! [`Trade::holding_move`](crate::trade::Trade::holding_move)), has to keep it. A
9//! [`ReduceOnlyBook`] is that record, and finds the order of a fill by its client order id or, for
10//! a fill that carries only the venue's id (a recovered fill), by the venue order id.
11
12use crate::{
13    order::{
14        id::{ClientOrderId, OrderId},
15        request::ReduceOnly,
16    },
17    trade::Trade,
18};
19use rustrade_integration::collection::FnvIndexMap;
20use serde::{Deserialize, Deserializer, Serialize};
21use std::fmt;
22use tracing::debug;
23
24/// An order the book was told of: live, or retired if it was reduce-only.
25#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
26struct SentOrder {
27    reduce_only: ReduceOnly,
28    venue_id: Option<OrderId>,
29}
30
31/// A retired order as serialised. Before the book kept a retired order's [`ReduceOnly`], it kept
32/// its venue order id alone (`<id>|null`), and every retired order was [`ReduceOnly::Position`].
33/// A human-readable format can hold either form; a binary one holds the current form alone.
34struct RetiredOrder(SentOrder);
35
36impl<'de> Deserialize<'de> for RetiredOrder {
37    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
38        struct RetiredOrderVisitor;
39
40        impl<'de> serde::de::Visitor<'de> for RetiredOrderVisitor {
41            type Value = SentOrder;
42
43            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44                formatter.write_str("a retired order, or its venue order id alone, or null")
45            }
46
47            // Delegated whole, so an error inside the order keeps its own message.
48            fn visit_map<A: serde::de::MapAccess<'de>>(
49                self,
50                map: A,
51            ) -> Result<SentOrder, A::Error> {
52                SentOrder::deserialize(serde::de::value::MapAccessDeserializer::new(map))
53            }
54
55            fn visit_str<E: serde::de::Error>(self, venue_id: &str) -> Result<SentOrder, E> {
56                Ok(bare(Some(OrderId::new(venue_id))))
57            }
58
59            fn visit_unit<E: serde::de::Error>(self) -> Result<SentOrder, E> {
60                Ok(bare(None))
61            }
62
63            fn visit_none<E: serde::de::Error>(self) -> Result<SentOrder, E> {
64                Ok(bare(None))
65            }
66
67            fn visit_some<D: Deserializer<'de>>(self, inner: D) -> Result<SentOrder, D::Error> {
68                inner.deserialize_any(self)
69            }
70        }
71
72        /// A retired order of the earlier form: always reduce-only.
73        fn bare(venue_id: Option<OrderId>) -> SentOrder {
74            SentOrder {
75                reduce_only: ReduceOnly::Position,
76                venue_id,
77            }
78        }
79
80        if deserializer.is_human_readable() {
81            deserializer.deserialize_any(RetiredOrderVisitor)
82        } else {
83            SentOrder::deserialize(deserializer)
84        }
85        .map(Self)
86    }
87}
88
89/// An order found in a [`ReduceOnlyBook`].
90#[non_exhaustive]
91#[derive(Debug, Clone, Copy, Eq, PartialEq)]
92pub struct BookedOrder<'a> {
93    /// The order's client order id, as the book holds it.
94    pub cid: &'a ClientOrderId,
95    /// What the order was sent as: never [`ReduceOnly::No`] for a retired order.
96    pub reduce_only: ReduceOnly,
97}
98
99/// One instrument's record of which orders were sent reduce-only. Plain data with no locking and
100/// no I/O, so the owner keeps it behind whatever synchronisation it already has, one book for each
101/// instrument.
102///
103/// The owner calls, for each order:
104/// 1. [`register`](Self::register) when the open request is sent,
105/// 2. [`acknowledge`](Self::acknowledge) when the venue reports the order's id, and
106/// 3. [`retire`](Self::retire) when the order ends. **Every** order must be retired, or the book
107///    keeps it for good.
108///
109/// Then [`lookup`](Self::lookup) finds what a fill's order was sent as.
110///
111/// A retired order stays findable only if it was reduce-only, and only the last
112/// [`RETIRED_CAPACITY`](Self::RETIRED_CAPACITY) of those, oldest dropped first, so that a late
113/// fill of one is still recognised and ordinary orders do not push them out. A late fill of any
114/// other retired order is not found: read that as not reduce-only.
115///
116/// # Serialised form
117/// `{"live": {<cid>: <order>}, "retired": {<cid>: <order>}}`, where an `<order>` is
118/// `{"reduce_only": <ReduceOnly>, "venue_id": <id>|null}`, each map in order, either part
119/// optional. It is stored as part of its owner's state, so a change to it is a breaking change.
120/// A book loaded with more retired orders than [`RETIRED_CAPACITY`](Self::RETIRED_CAPACITY) keeps
121/// the latest. A retired order serialised as its venue order id alone (`<id>|null`), the form
122/// before a retired order kept its [`ReduceOnly`], loads as [`ReduceOnly::Position`], as does a
123/// `reduce_only` of `true`.
124#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
125#[serde(from = "BookParts")]
126pub struct ReduceOnlyBook {
127    /// In the order registered.
128    live: FnvIndexMap<ClientOrderId, SentOrder>,
129    /// The reduce-only orders that ended, in the order they ended.
130    retired: FnvIndexMap<ClientOrderId, SentOrder>,
131    /// How many of `live` are reduce-only, so that [`has_reduce_only`](Self::has_reduce_only) is
132    /// O(1). Derived, so it is not serialised.
133    #[serde(skip)]
134    live_reduce_only: usize,
135}
136
137/// What is serialised of a [`ReduceOnlyBook`]; loading one rebuilds the rest.
138#[derive(Deserialize)]
139struct BookParts {
140    #[serde(default)]
141    live: FnvIndexMap<ClientOrderId, SentOrder>,
142    #[serde(default)]
143    retired: FnvIndexMap<ClientOrderId, RetiredOrder>,
144}
145
146impl From<BookParts> for ReduceOnlyBook {
147    fn from(parts: BookParts) -> Self {
148        let BookParts { live, retired } = parts;
149        // A retired order that was not reduce-only is not one the book would have kept.
150        let mut retired: FnvIndexMap<ClientOrderId, SentOrder> = retired
151            .into_iter()
152            .map(|(cid, RetiredOrder(order))| (cid, order))
153            .filter(|(_, order)| order.reduce_only.is_reduce_only())
154            .collect();
155        // An order is live or retired, never both; the live one wins.
156        retired.retain(|cid, _| !live.contains_key(cid));
157        let excess = retired.len().saturating_sub(Self::RETIRED_CAPACITY);
158        retired.drain(..excess);
159        let live_reduce_only = live
160            .values()
161            .filter(|order| order.reduce_only.is_reduce_only())
162            .count();
163        Self {
164            live,
165            retired,
166            live_reduce_only,
167        }
168    }
169}
170
171impl ReduceOnlyBook {
172    /// How many retired reduce-only orders the book keeps.
173    pub const RETIRED_CAPACITY: usize = 64;
174
175    /// An empty book.
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Whether the book holds no order, live or retired.
181    pub fn is_empty(&self) -> bool {
182        self.live.is_empty() && self.retired.is_empty()
183    }
184
185    /// How many orders are live: registered and not yet retired. An owner that retires every
186    /// order sees this fall back to the number it has open.
187    pub fn live_len(&self) -> usize {
188        self.live.len()
189    }
190
191    /// Whether any order the book holds is reduce-only, live or retired. When it is not, every
192    /// [`lookup`](Self::lookup) finds no reduce-only order, so a caller can skip it. O(1).
193    pub fn has_reduce_only(&self) -> bool {
194        self.live_reduce_only > 0 || !self.retired.is_empty()
195    }
196
197    /// Record that the open request `cid` is being sent, and what as.
198    ///
199    /// A client order id reused for a new order replaces everything an earlier order under it
200    /// left, live or retired, so its flag does not outlive the order. The new order counts as the
201    /// latest registered. Registering a client order id that is still live replaces that order,
202    /// dropping the venue order id it was acknowledged with, so register once for each order sent.
203    pub fn register(&mut self, cid: &ClientOrderId, reduce_only: ReduceOnly) {
204        self.retired.shift_remove(cid);
205        if let Some(earlier) = self.live.shift_remove(cid) {
206            self.live_reduce_only -= usize::from(earlier.reduce_only.is_reduce_only());
207        }
208        self.live_reduce_only += usize::from(reduce_only.is_reduce_only());
209        self.live.insert(
210            cid.clone(),
211            SentOrder {
212                reduce_only,
213                venue_id: None,
214            },
215        );
216    }
217
218    /// Record the venue's order id for `cid`, live or retired, so a fill that names only that id
219    /// finds the order. Replaces an id given before. Does nothing for an order the book does not
220    /// hold.
221    ///
222    /// An order can end before its id is known, such as one filled on its acknowledgement, so a
223    /// retired order takes it too.
224    pub fn acknowledge(&mut self, cid: &ClientOrderId, venue_id: &OrderId) {
225        if let Some(order) = self.live.get_mut(cid).or_else(|| self.retired.get_mut(cid)) {
226            order.venue_id = Some(venue_id.clone());
227        }
228    }
229
230    /// Record that the order `cid` ended. Does nothing for an order the book does not hold.
231    ///
232    /// A reduce-only order moves to the retired orders, dropping the oldest of them when there
233    /// are [`RETIRED_CAPACITY`](Self::RETIRED_CAPACITY). Any other order is forgotten.
234    pub fn retire(&mut self, cid: &ClientOrderId) {
235        if let Some(order) = self.live.shift_remove(cid)
236            && order.reduce_only.is_reduce_only()
237        {
238            self.live_reduce_only -= 1;
239            self.insert_retired(cid.clone(), order);
240        }
241    }
242
243    /// [`retire`](Self::retire) every live order for which `is_tracked` is `false`, in the order
244    /// they were registered, so which retirement is dropped first never depends on hashing.
245    ///
246    /// One pass over the live orders, so O(live orders), and it allocates only when a reduce-only
247    /// order ended.
248    pub fn retire_untracked(&mut self, mut is_tracked: impl FnMut(&ClientOrderId) -> bool) {
249        let mut ended: Vec<(ClientOrderId, SentOrder)> = Vec::new();
250        self.live.retain(|cid, order| {
251            let tracked = is_tracked(cid);
252            if !tracked && order.reduce_only.is_reduce_only() {
253                ended.push((
254                    cid.clone(),
255                    SentOrder {
256                        reduce_only: order.reduce_only,
257                        venue_id: order.venue_id.take(),
258                    },
259                ));
260            }
261            tracked
262        });
263        self.live_reduce_only -= ended.len();
264        for (cid, order) in ended {
265            self.insert_retired(cid, order);
266        }
267    }
268
269    /// Find the order a fill belongs to.
270    ///
271    /// By the fill's client order id if it has one, and else by its venue order id: a live order
272    /// first, then a retired one. A client order id the book does not hold finds nothing, and does
273    /// not fall back to the venue's id.
274    pub fn lookup<AssetKey, InstrumentKey>(
275        &self,
276        trade: &Trade<AssetKey, InstrumentKey>,
277    ) -> Option<BookedOrder<'_>> {
278        self.lookup_ids(trade.client_order_id.as_ref(), &trade.order_id)
279    }
280
281    /// [`lookup`](Self::lookup) for the ids of a fill, for a caller that resolved the client order
282    /// id itself. A `cid` the book does not hold finds nothing and does not fall back to
283    /// `order_id`; pass `None` to look up by the venue's id alone.
284    pub fn lookup_ids(
285        &self,
286        cid: Option<&ClientOrderId>,
287        order_id: &OrderId,
288    ) -> Option<BookedOrder<'_>> {
289        fn booked<'a>((cid, order): (&'a ClientOrderId, &'a SentOrder)) -> BookedOrder<'a> {
290            BookedOrder {
291                cid,
292                reduce_only: order.reduce_only,
293            }
294        }
295        if let Some(cid) = cid {
296            return self
297                .live
298                .get_key_value(cid)
299                .or_else(|| self.retired.get_key_value(cid))
300                .map(booked);
301        }
302        // A live order outranks a retired one under the same venue order id. The scan is of the
303        // open orders, and at most `RETIRED_CAPACITY` retired ones.
304        let by_venue_id =
305            |(_, order): &(&ClientOrderId, &SentOrder)| order.venue_id.as_ref() == Some(order_id);
306        self.live
307            .iter()
308            .find(by_venue_id)
309            .or_else(|| self.retired.iter().find(by_venue_id))
310            .map(booked)
311    }
312
313    /// Whether `trade` is a fill of an order sent reduce-only, either way: [`lookup`](Self::lookup)
314    /// with an order the book does not hold counted as not.
315    pub fn is_reduce_only<AssetKey, InstrumentKey>(
316        &self,
317        trade: &Trade<AssetKey, InstrumentKey>,
318    ) -> bool {
319        self.lookup(trade)
320            .is_some_and(|order| order.reduce_only.is_reduce_only())
321    }
322
323    /// Forget every order, for when the orders the book records are dropped wholesale.
324    pub fn clear(&mut self) {
325        self.live.clear();
326        self.retired.clear();
327        self.live_reduce_only = 0;
328    }
329
330    fn insert_retired(&mut self, cid: ClientOrderId, order: SentOrder) {
331        if self.retired.len() >= Self::RETIRED_CAPACITY
332            && !self.retired.contains_key(&cid)
333            && let Some((evicted, _)) = self.retired.shift_remove_index(0)
334        {
335            debug!(
336                evicted_cid = %evicted,
337                capacity = Self::RETIRED_CAPACITY,
338                "Retired reduce-only orders at capacity: evicting the oldest. A late fill of the \
339                 evicted order is now not recognised as reduce-only."
340            );
341        }
342        self.retired.insert(cid, order);
343    }
344}
345
346#[cfg(test)]
347#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
348mod tests {
349    use super::*;
350    use crate::{order::id::StrategyId, trade::AssetFees};
351    use ReduceOnly::{No, Position, PositionAndFreeBase};
352    use chrono::DateTime;
353    use rust_decimal_macros::dec;
354    use rustrade_instrument::Side;
355    use rustrade_instrument::asset::QuoteAsset;
356
357    fn cid(id: &str) -> ClientOrderId {
358        ClientOrderId::new(id)
359    }
360
361    fn venue(id: &str) -> OrderId {
362        OrderId::new(id)
363    }
364
365    fn fill(cid: Option<&str>, venue_id: &str) -> Trade<QuoteAsset, ()> {
366        let trade = Trade::new(
367            crate::trade::TradeId::new("t"),
368            venue(venue_id),
369            (),
370            StrategyId::new("s"),
371            DateTime::UNIX_EPOCH,
372            Side::Sell,
373            dec!(1),
374            dec!(1),
375            None,
376            AssetFees::quote_fees(dec!(0)),
377        );
378        match cid {
379            Some(id) => trade.with_client_order_id(self::cid(id)),
380            None => trade,
381        }
382    }
383
384    fn flag(book: &ReduceOnlyBook, trade: &Trade<QuoteAsset, ()>) -> Option<ReduceOnly> {
385        book.lookup(trade).map(|order| order.reduce_only)
386    }
387
388    #[test]
389    fn a_fill_finds_its_order_by_client_order_id() {
390        let mut book = ReduceOnlyBook::new();
391        book.register(&cid("ro"), Position);
392        book.register(&cid("plain"), No);
393        assert_eq!(flag(&book, &fill(Some("ro"), "v")), Some(Position));
394        assert_eq!(flag(&book, &fill(Some("plain"), "v")), Some(No));
395        assert_eq!(flag(&book, &fill(Some("other"), "v")), None);
396        assert!(book.is_reduce_only(&fill(Some("ro"), "v")));
397        assert!(!book.is_reduce_only(&fill(Some("plain"), "v")));
398        assert!(!book.is_reduce_only(&fill(Some("other"), "v")));
399    }
400
401    #[test]
402    fn a_fill_naming_only_the_venue_id_finds_its_order_once_acknowledged() {
403        let mut book = ReduceOnlyBook::new();
404        book.register(&cid("ro"), Position);
405        assert_eq!(flag(&book, &fill(None, "v1")), None, "not yet acknowledged");
406        book.acknowledge(&cid("ro"), &venue("v1"));
407        let trade = fill(None, "v1");
408        let found = book.lookup(&trade).unwrap();
409        assert_eq!((found.cid, found.reduce_only), (&cid("ro"), Position));
410    }
411
412    #[test]
413    fn a_client_order_id_the_book_lacks_does_not_fall_back_to_the_venue_id() {
414        let mut book = ReduceOnlyBook::new();
415        book.register(&cid("ro"), Position);
416        book.acknowledge(&cid("ro"), &venue("v1"));
417        assert_eq!(flag(&book, &fill(Some("other"), "v1")), None);
418    }
419
420    #[test]
421    fn a_retired_reduce_only_order_is_still_found_and_an_ordinary_one_is_forgotten() {
422        let mut book = ReduceOnlyBook::new();
423        book.register(&cid("ro"), Position);
424        book.register(&cid("plain"), No);
425        book.acknowledge(&cid("ro"), &venue("v1"));
426        book.acknowledge(&cid("plain"), &venue("v2"));
427        book.retire(&cid("ro"));
428        book.retire(&cid("plain"));
429        assert_eq!(flag(&book, &fill(Some("ro"), "x")), Some(Position));
430        assert_eq!(flag(&book, &fill(None, "v1")), Some(Position));
431        assert_eq!(flag(&book, &fill(Some("plain"), "x")), None);
432        assert_eq!(flag(&book, &fill(None, "v2")), None);
433    }
434
435    #[test]
436    fn an_order_retired_before_its_acknowledgement_takes_the_venue_id_after() {
437        let mut book = ReduceOnlyBook::new();
438        book.register(&cid("ro"), Position);
439        book.retire(&cid("ro"));
440        assert_eq!(flag(&book, &fill(None, "v1")), None);
441        book.acknowledge(&cid("ro"), &venue("v1"));
442        assert_eq!(flag(&book, &fill(None, "v1")), Some(Position));
443    }
444
445    #[test]
446    fn a_live_order_outranks_a_retired_one_under_the_same_venue_id() {
447        let mut book = ReduceOnlyBook::new();
448        book.register(&cid("old"), Position);
449        book.acknowledge(&cid("old"), &venue("v1"));
450        book.retire(&cid("old"));
451        book.register(&cid("new"), No);
452        book.acknowledge(&cid("new"), &venue("v1"));
453        let trade = fill(None, "v1");
454        let found = book.lookup(&trade).unwrap();
455        assert_eq!((found.cid, found.reduce_only), (&cid("new"), No));
456    }
457
458    #[test]
459    fn registering_a_reused_client_order_id_drops_what_the_earlier_order_left() {
460        for retire_first in [false, true] {
461            let mut book = ReduceOnlyBook::new();
462            book.register(&cid("c"), Position);
463            book.acknowledge(&cid("c"), &venue("v1"));
464            if retire_first {
465                book.retire(&cid("c"));
466            }
467            book.register(&cid("c"), No);
468            assert_eq!(flag(&book, &fill(Some("c"), "x")), Some(No));
469            assert_eq!(flag(&book, &fill(None, "v1")), None, "{retire_first}");
470        }
471    }
472
473    #[test]
474    fn the_oldest_retirement_is_dropped_at_capacity() {
475        let mut book = ReduceOnlyBook::new();
476        let cids: Vec<_> = (0..=ReduceOnlyBook::RETIRED_CAPACITY)
477            .map(|i| cid(&format!("c{i}")))
478            .collect();
479        for c in &cids {
480            book.register(c, Position);
481            book.retire(c);
482        }
483        assert_eq!(book.retired.len(), ReduceOnlyBook::RETIRED_CAPACITY);
484        assert_eq!(flag(&book, &fill(Some("c0"), "x")), None);
485        assert_eq!(flag(&book, &fill(Some("c1"), "x")), Some(Position));
486        assert_eq!(flag(&book, &fill(Some("c64"), "x")), Some(Position));
487    }
488
489    #[test]
490    fn ordinary_orders_do_not_push_out_retired_reduce_only_ones() {
491        let mut book = ReduceOnlyBook::new();
492        book.register(&cid("ro"), Position);
493        book.retire(&cid("ro"));
494        for i in 0..ReduceOnlyBook::RETIRED_CAPACITY * 2 {
495            let c = cid(&format!("p{i}"));
496            book.register(&c, No);
497            book.retire(&c);
498        }
499        assert_eq!(flag(&book, &fill(Some("ro"), "x")), Some(Position));
500    }
501
502    #[test]
503    fn retire_untracked_retires_in_the_order_registered() {
504        let mut book = ReduceOnlyBook::new();
505        let cids: Vec<_> = (0..5).map(|i| cid(&format!("c{i}"))).collect();
506        for c in &cids {
507            book.register(c, Position);
508        }
509        let kept = cid("c2");
510        book.retire_untracked(|c| *c == kept);
511        assert!(
512            book.retired
513                .keys()
514                .eq([&cids[0], &cids[1], &cids[3], &cids[4]])
515        );
516        assert!(book.live.keys().eq([&kept]));
517    }
518
519    #[test]
520    fn retire_untracked_keeps_the_reduce_only_count_and_forgets_ordinary_orders() {
521        let mut book = ReduceOnlyBook::new();
522        book.register(&cid("ro1"), Position);
523        book.register(&cid("gone"), No);
524        book.register(&cid("ro2"), Position);
525        book.register(&cid("kept"), No);
526        book.retire_untracked(|c| *c == cid("kept"));
527        assert_eq!(book.live_reduce_only, 0);
528        assert!(book.live.keys().eq([&cid("kept")]));
529        assert!(book.retired.keys().eq([&cid("ro1"), &cid("ro2")]));
530        assert!(book.has_reduce_only(), "retired ones are still held");
531        book.retired.clear();
532        assert!(!book.has_reduce_only());
533    }
534
535    #[test]
536    fn retiring_or_acknowledging_an_unknown_order_does_nothing() {
537        let mut book = ReduceOnlyBook::new();
538        book.retire(&cid("none"));
539        book.acknowledge(&cid("none"), &venue("v"));
540        assert!(book.is_empty());
541    }
542
543    #[test]
544    fn clear_forgets_everything() {
545        let mut book = ReduceOnlyBook::new();
546        book.register(&cid("a"), Position);
547        book.register(&cid("b"), Position);
548        book.retire(&cid("b"));
549        book.clear();
550        assert!(book.is_empty());
551    }
552
553    #[test]
554    fn is_empty_and_live_len_count_live_and_retired_orders() {
555        let mut book = ReduceOnlyBook::new();
556        assert_eq!((book.is_empty(), book.live_len()), (true, 0));
557        book.register(&cid("plain"), No);
558        book.register(&cid("ro"), Position);
559        assert_eq!((book.is_empty(), book.live_len()), (false, 2), "live only");
560        book.retire(&cid("ro"));
561        assert_eq!((book.is_empty(), book.live_len()), (false, 1));
562        book.retire(&cid("plain"));
563        assert_eq!(
564            (book.is_empty(), book.live_len()),
565            (false, 0),
566            "retired only"
567        );
568    }
569
570    #[test]
571    fn has_reduce_only_follows_the_orders_held() {
572        let mut book = ReduceOnlyBook::new();
573        assert!(!book.has_reduce_only());
574        book.register(&cid("plain"), No);
575        assert!(!book.has_reduce_only());
576        book.register(&cid("ro"), Position);
577        assert!(book.has_reduce_only());
578        book.register(&cid("ro"), No);
579        assert!(!book.has_reduce_only(), "replaced by an ordinary order");
580        book.register(&cid("ro"), Position);
581        book.retire(&cid("ro"));
582        assert!(book.has_reduce_only(), "retired but still held");
583        book.clear();
584        assert!(!book.has_reduce_only());
585        book.register(&cid("a"), Position);
586        book.register(&cid("b"), Position);
587        book.retire_untracked(|c| *c == cid("a"));
588        assert_eq!((book.live_len(), book.has_reduce_only()), (1, true));
589    }
590
591    #[test]
592    fn the_serialised_form_is_pinned_and_keeps_the_retirement_order() {
593        let mut book = ReduceOnlyBook::new();
594        book.register(&cid("b"), Position);
595        book.acknowledge(&cid("b"), &venue("v"));
596        book.retire(&cid("b"));
597        book.register(&cid("a"), Position);
598        book.retire(&cid("a"));
599        book.register(&cid("c"), No);
600        let json = serde_json::to_string(&book).unwrap();
601        assert_eq!(
602            json,
603            r#"{"live":{"c":{"reduce_only":"no","venue_id":null}},"retired":{"b":{"reduce_only":"position","venue_id":"v"},"a":{"reduce_only":"position","venue_id":null}}}"#
604        );
605        let loaded: ReduceOnlyBook = serde_json::from_str(&json).unwrap();
606        assert!(loaded.retired.keys().eq(book.retired.keys()));
607        assert_eq!(loaded.has_reduce_only(), book.has_reduce_only());
608    }
609
610    #[test]
611    fn a_loaded_book_keeps_its_bound_and_counts_its_live_reduce_only_orders() {
612        // Built as text: `serde_json::Value` would sort the keys.
613        let retired: Vec<String> = (0..100).map(|i| format!(r#""c{i}":null"#)).collect();
614        let json = format!(
615            r#"{{"live":{{"l":{{"reduce_only":true,"venue_id":null}},"c99":{{"reduce_only":false,"venue_id":null}}}},"retired":{{{}}}}}"#,
616            retired.join(",")
617        );
618        let mut book: ReduceOnlyBook = serde_json::from_str(&json).unwrap();
619        assert_eq!(book.retired.len(), ReduceOnlyBook::RETIRED_CAPACITY);
620        assert!(book.retired.keys().next().is_some_and(|c| *c == cid("c35")));
621        assert!(!book.retired.contains_key(&cid("c99")), "live wins");
622        book.retire(&cid("l"));
623        assert_eq!(book.retired.len(), ReduceOnlyBook::RETIRED_CAPACITY);
624        book.retire(&cid("c99"));
625        assert!(book.live.is_empty() && book.live_reduce_only == 0);
626    }
627
628    #[test]
629    fn a_book_serialised_with_a_reduce_only_flag_and_bare_retirements_loads() {
630        // The form before the book kept a `ReduceOnly`: a flag for a live order, and the venue
631        // order id alone for a retired one, which was always reduce-only.
632        let json = r#"{"live":{"l":{"reduce_only":true,"venue_id":"v0"},"p":{"reduce_only":false,"venue_id":null}},"retired":{"b":"v1","a":null}}"#;
633        let book: ReduceOnlyBook = serde_json::from_str(json).unwrap();
634        assert_eq!(flag(&book, &fill(Some("l"), "x")), Some(Position));
635        assert_eq!(flag(&book, &fill(Some("p"), "x")), Some(No));
636        assert_eq!(flag(&book, &fill(Some("a"), "x")), Some(Position));
637        assert_eq!(flag(&book, &fill(None, "v1")), Some(Position));
638        assert_eq!(book.live_reduce_only, 1);
639    }
640
641    #[test]
642    fn a_loaded_retirement_that_was_not_reduce_only_is_dropped() {
643        for not_reduce_only in [r#""no""#, "false"] {
644            let json = format!(
645                r#"{{"retired":{{"p":{{"reduce_only":{not_reduce_only},"venue_id":"v"}}}}}}"#
646            );
647            let book: ReduceOnlyBook = serde_json::from_str(&json).unwrap();
648            assert!(
649                book.is_empty() && !book.has_reduce_only(),
650                "{not_reduce_only}"
651            );
652        }
653    }
654
655    #[test]
656    fn a_loaded_retirement_with_a_reduce_only_flag_is_kept_as_position() {
657        let json = r#"{"retired":{"r":{"reduce_only":true,"venue_id":"v"}}}"#;
658        let book: ReduceOnlyBook = serde_json::from_str(json).unwrap();
659        assert_eq!(flag(&book, &fill(None, "v")), Some(Position));
660    }
661
662    /// A retired order that cannot be read says why, not only that it matched no form.
663    #[test]
664    fn a_malformed_retirement_keeps_the_error_inside_it() {
665        let json = r#"{"retired":{"r":{"reduce_only":"sometimes","venue_id":null}}}"#;
666        let error = serde_json::from_str::<ReduceOnlyBook>(json).unwrap_err();
667        assert!(error.to_string().contains("sometimes"), "{error}");
668
669        let error = serde_json::from_str::<ReduceOnlyBook>(r#"{"retired":{"r":7}}"#).unwrap_err();
670        assert!(
671            error
672                .to_string()
673                .contains("a retired order, or its venue order id alone, or null"),
674            "{error}"
675        );
676    }
677
678    #[test]
679    fn an_order_keeps_what_it_was_sent_as_once_retired() {
680        let mut book = ReduceOnlyBook::new();
681        book.register(&cid("sweep"), PositionAndFreeBase);
682        book.register(&cid("close"), Position);
683        book.acknowledge(&cid("sweep"), &venue("v1"));
684        book.retire(&cid("sweep"));
685        book.retire_untracked(|_| false);
686        assert_eq!(
687            flag(&book, &fill(Some("sweep"), "x")),
688            Some(PositionAndFreeBase)
689        );
690        assert_eq!(flag(&book, &fill(None, "v1")), Some(PositionAndFreeBase));
691        assert_eq!(flag(&book, &fill(Some("close"), "x")), Some(Position));
692        assert!(book.is_reduce_only(&fill(Some("sweep"), "x")));
693        let json = serde_json::to_string(&book).unwrap();
694        assert_eq!(serde_json::from_str::<ReduceOnlyBook>(&json).unwrap(), book);
695    }
696
697    #[test]
698    fn a_book_round_trips_through_json_and_loads_from_nothing() {
699        let mut book = ReduceOnlyBook::new();
700        book.register(&cid("live"), Position);
701        book.acknowledge(&cid("live"), &venue("v1"));
702        book.register(&cid("gone"), Position);
703        book.retire(&cid("gone"));
704        let json = serde_json::to_string(&book).unwrap();
705        assert_eq!(serde_json::from_str::<ReduceOnlyBook>(&json).unwrap(), book);
706        assert_eq!(
707            serde_json::from_str::<ReduceOnlyBook>("{}").unwrap(),
708            ReduceOnlyBook::new()
709        );
710    }
711}