Skip to main content

nautilus_model/orders/
list.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{
17    fmt::Display,
18    hash::{Hash, Hasher},
19};
20
21use ahash::AHashSet;
22use nautilus_core::UnixNanos;
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::{
27    identifiers::{ClientOrderId, InstrumentId, OrderListId, StrategyId},
28    orders::{Order, OrderAny},
29};
30
31/// Error returned when [`OrderList::validate`] fails.
32#[derive(Debug, Clone, PartialEq, Eq, Error)]
33pub enum OrderListValidationError {
34    /// The order list contains no client order IDs.
35    #[error("OrderList {order_list_id} has no orders")]
36    EmptyClientOrderIds {
37        /// The invalid order list ID.
38        order_list_id: OrderListId,
39    },
40    /// The order list contains duplicate client order IDs.
41    #[error("OrderList {order_list_id} contains duplicate client_order_ids")]
42    DuplicateClientOrderIds {
43        /// The invalid order list ID.
44        order_list_id: OrderListId,
45    },
46}
47
48/// Lightweight identifier container for a group of related orders.
49///
50/// Stores only the order IDs; full order data lives in the cache.
51/// For serialization payload, see `SubmitOrderList.order_inits`.
52///
53/// All orders should share the same venue. The production constructors
54/// enforce this: [`OrderList::from_orders`] and `OrderFactory::create_list`
55/// panic on mixed venues, and `Strategy::submit_order_list` bails at the
56/// user-facing entry. [`OrderList::new`] is infallible and takes
57/// `instrument_id` directly; it does not verify the venues of the supplied
58/// `client_order_ids`. The `instrument_id` is a representative value taken
59/// from the first order; orders may target different instruments at that
60/// venue. Downstream consumers that need a per-order instrument should
61/// resolve each order from the cache.
62#[derive(Clone, Eq, Debug, Serialize, Deserialize)]
63#[cfg_attr(
64    feature = "python",
65    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
66)]
67#[cfg_attr(
68    feature = "python",
69    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
70)]
71pub struct OrderList {
72    pub id: OrderListId,
73    pub instrument_id: InstrumentId,
74    pub strategy_id: StrategyId,
75    pub client_order_ids: Vec<ClientOrderId>,
76    pub ts_init: UnixNanos,
77}
78
79impl OrderList {
80    /// Creates a new [`OrderList`] instance.
81    ///
82    /// Construction is infallible. [`OrderList::validate`] checks the
83    /// syntactic invariants (non-empty, unique `client_order_ids`); the
84    /// strategy submission path (`Strategy::submit_order_list`) runs it
85    /// before the list reaches the cache.
86    #[must_use]
87    pub fn new(
88        order_list_id: OrderListId,
89        instrument_id: InstrumentId,
90        strategy_id: StrategyId,
91        client_order_ids: Vec<ClientOrderId>,
92        ts_init: UnixNanos,
93    ) -> Self {
94        Self {
95            id: order_list_id,
96            instrument_id,
97            strategy_id,
98            client_order_ids,
99            ts_init,
100        }
101    }
102
103    /// Creates a new [`OrderList`] from a slice of orders.
104    ///
105    /// Derives `order_list_id`, `instrument_id`, and `strategy_id` from the
106    /// first order. The `instrument_id` is representative only; orders in
107    /// the list may target different instruments at the same venue.
108    /// Callers in the production path (`OrderFactory` plus a single
109    /// strategy instance) produce orders with a consistent `order_list_id`
110    /// and `strategy_id`. [`OrderList::validate`] checks the syntactic
111    /// invariants (non-empty, unique `client_order_ids`); it does not
112    /// check cross-field consistency.
113    ///
114    /// # Panics
115    ///
116    /// Panics if `orders` is empty, if the first order has no
117    /// `order_list_id`, or if orders span more than one venue. Callers
118    /// are expected to guard non-empty input; `Strategy::submit_order_list`
119    /// filters out the empty case and bails on mixed venues before
120    /// reaching this constructor.
121    #[must_use]
122    pub fn from_orders(orders: &[OrderAny], ts_init: UnixNanos) -> Self {
123        let first = orders
124            .first()
125            .expect("OrderList::from_orders requires non-empty orders");
126        let order_list_id = first
127            .order_list_id()
128            .expect("OrderList::from_orders requires first order to have order_list_id");
129        let instrument_id = first.instrument_id();
130        let strategy_id = first.strategy_id();
131        let venue = instrument_id.venue;
132
133        for order in orders {
134            assert!(
135                order.instrument_id().venue == venue,
136                "OrderList::from_orders requires all orders to share the same venue; \
137                 expected {venue}, found {} on {}",
138                order.instrument_id().venue,
139                order.client_order_id(),
140            );
141        }
142
143        let client_order_ids = orders.iter().map(Order::client_order_id).collect();
144
145        Self {
146            id: order_list_id,
147            instrument_id,
148            strategy_id,
149            client_order_ids,
150            ts_init,
151        }
152    }
153
154    /// Validates this [`OrderList`]'s own invariants.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if `client_order_ids` is empty or contains duplicates.
159    pub fn validate(&self) -> Result<(), OrderListValidationError> {
160        if self.client_order_ids.is_empty() {
161            return Err(OrderListValidationError::EmptyClientOrderIds {
162                order_list_id: self.id,
163            });
164        }
165
166        let unique: AHashSet<&ClientOrderId> = self.client_order_ids.iter().collect();
167        if unique.len() != self.client_order_ids.len() {
168            return Err(OrderListValidationError::DuplicateClientOrderIds {
169                order_list_id: self.id,
170            });
171        }
172
173        Ok(())
174    }
175
176    #[must_use]
177    pub fn first(&self) -> Option<&ClientOrderId> {
178        self.client_order_ids.first()
179    }
180
181    /// Returns the number of orders in the list.
182    #[must_use]
183    pub fn len(&self) -> usize {
184        self.client_order_ids.len()
185    }
186
187    /// Returns true if the list contains no orders.
188    #[must_use]
189    pub fn is_empty(&self) -> bool {
190        self.client_order_ids.is_empty()
191    }
192}
193
194impl PartialEq for OrderList {
195    fn eq(&self, other: &Self) -> bool {
196        self.id == other.id
197    }
198}
199
200impl Hash for OrderList {
201    fn hash<H: Hasher>(&self, state: &mut H) {
202        self.id.hash(state);
203    }
204}
205
206impl Display for OrderList {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        write!(
209            f,
210            "OrderList(\
211            id={}, \
212            instrument_id={}, \
213            strategy_id={}, \
214            client_order_ids={:?}, \
215            ts_init={}\
216            )",
217            self.id, self.instrument_id, self.strategy_id, self.client_order_ids, self.ts_init,
218        )
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use std::collections::hash_map::DefaultHasher;
225
226    use rstest::rstest;
227
228    use super::*;
229    use crate::{
230        enums::OrderType,
231        identifiers::{InstrumentId, OrderListId},
232        orders::builder::OrderTestBuilder,
233        types::Quantity,
234    };
235
236    fn create_client_order_ids(count: usize) -> Vec<ClientOrderId> {
237        (0..count)
238            .map(|i| ClientOrderId::from(format!("O-00{}", i + 1).as_str()))
239            .collect()
240    }
241
242    fn create_orders(count: usize, order_list_id: OrderListId) -> Vec<OrderAny> {
243        (0..count)
244            .map(|i| {
245                OrderTestBuilder::new(OrderType::Market)
246                    .instrument_id(InstrumentId::from("AUD/USD.SIM"))
247                    .client_order_id(ClientOrderId::from(format!("O-00{}", i + 1).as_str()))
248                    .order_list_id(order_list_id)
249                    .quantity(Quantity::from(1))
250                    .build()
251            })
252            .collect()
253    }
254
255    #[rstest]
256    fn test_new_and_display() {
257        let orders = create_client_order_ids(3);
258
259        let order_list = OrderList::new(
260            OrderListId::from("OL-001"),
261            InstrumentId::from("AUD/USD.SIM"),
262            StrategyId::from("S-001"),
263            orders,
264            UnixNanos::default(),
265        );
266
267        assert!(order_list.to_string().starts_with(
268            "OrderList(id=OL-001, instrument_id=AUD/USD.SIM, strategy_id=S-001, client_order_ids="
269        ));
270    }
271
272    fn create_orders_for_instrument(
273        instrument_ids: &[&str],
274        order_list_id: OrderListId,
275    ) -> Vec<OrderAny> {
276        instrument_ids
277            .iter()
278            .enumerate()
279            .map(|(i, instrument)| {
280                OrderTestBuilder::new(OrderType::Market)
281                    .instrument_id(InstrumentId::from(*instrument))
282                    .client_order_id(ClientOrderId::from(format!("O-00{}", i + 1).as_str()))
283                    .order_list_id(order_list_id)
284                    .quantity(Quantity::from(1))
285                    .build()
286            })
287            .collect()
288    }
289
290    #[rstest]
291    fn test_from_orders_accepts_mixed_instruments_same_venue() {
292        let order_list_id = OrderListId::from("OL-MIXED-001");
293        let orders = create_orders_for_instrument(&["AUD/USD.SIM", "EUR/USD.SIM"], order_list_id);
294
295        let order_list = OrderList::from_orders(&orders, UnixNanos::default());
296
297        assert_eq!(order_list.len(), 2);
298        assert_eq!(order_list.instrument_id, InstrumentId::from("AUD/USD.SIM"));
299    }
300
301    #[rstest]
302    #[should_panic(expected = "share the same venue")]
303    fn test_from_orders_panics_on_mixed_venues() {
304        let order_list_id = OrderListId::from("OL-MIXED-002");
305        let orders =
306            create_orders_for_instrument(&["AUD/USD.SIM", "EUR/USD.IDEALPRO"], order_list_id);
307
308        let _ = OrderList::from_orders(&orders, UnixNanos::default());
309    }
310
311    #[rstest]
312    fn test_from_orders() {
313        let order_list_id = OrderListId::from("OL-002");
314        let orders = create_orders(3, order_list_id);
315
316        let order_list = OrderList::from_orders(&orders, UnixNanos::default());
317
318        assert_eq!(order_list.id, order_list_id);
319        assert_eq!(order_list.len(), 3);
320        assert_eq!(order_list.instrument_id, InstrumentId::from("AUD/USD.SIM"));
321        assert_eq!(order_list.client_order_ids[0], ClientOrderId::from("O-001"));
322    }
323
324    #[rstest]
325    fn test_order_list_equality() {
326        let orders = create_client_order_ids(1);
327
328        let order_list1 = OrderList::new(
329            OrderListId::from("OL-006"),
330            InstrumentId::from("AUD/USD.SIM"),
331            StrategyId::from("S-001"),
332            orders.clone(),
333            UnixNanos::default(),
334        );
335
336        let order_list2 = OrderList::new(
337            OrderListId::from("OL-006"),
338            InstrumentId::from("AUD/USD.SIM"),
339            StrategyId::from("S-001"),
340            orders,
341            UnixNanos::default(),
342        );
343
344        assert_eq!(order_list1, order_list2);
345    }
346
347    #[rstest]
348    fn test_order_list_inequality() {
349        let orders = create_client_order_ids(1);
350
351        let order_list1 = OrderList::new(
352            OrderListId::from("OL-007"),
353            InstrumentId::from("AUD/USD.SIM"),
354            StrategyId::from("S-001"),
355            orders.clone(),
356            UnixNanos::default(),
357        );
358
359        let order_list2 = OrderList::new(
360            OrderListId::from("OL-008"),
361            InstrumentId::from("AUD/USD.SIM"),
362            StrategyId::from("S-001"),
363            orders,
364            UnixNanos::default(),
365        );
366
367        assert_ne!(order_list1, order_list2);
368    }
369
370    #[rstest]
371    fn test_order_list_first() {
372        let orders = create_client_order_ids(2);
373        let first_id = orders[0];
374
375        let order_list = OrderList::new(
376            OrderListId::from("OL-009"),
377            InstrumentId::from("AUD/USD.SIM"),
378            StrategyId::from("S-001"),
379            orders,
380            UnixNanos::default(),
381        );
382
383        let first = order_list.first();
384        assert!(first.is_some());
385        assert_eq!(*first.unwrap(), first_id);
386    }
387
388    #[rstest]
389    fn test_order_list_len() {
390        let orders = create_client_order_ids(3);
391
392        let order_list = OrderList::new(
393            OrderListId::from("OL-010"),
394            InstrumentId::from("AUD/USD.SIM"),
395            StrategyId::from("S-001"),
396            orders,
397            UnixNanos::default(),
398        );
399
400        assert_eq!(order_list.len(), 3);
401        assert!(!order_list.is_empty());
402    }
403
404    #[rstest]
405    fn test_order_list_hash() {
406        let orders = create_client_order_ids(1);
407
408        let order_list1 = OrderList::new(
409            OrderListId::from("OL-011"),
410            InstrumentId::from("AUD/USD.SIM"),
411            StrategyId::from("S-001"),
412            orders.clone(),
413            UnixNanos::default(),
414        );
415
416        let order_list2 = OrderList::new(
417            OrderListId::from("OL-011"),
418            InstrumentId::from("AUD/USD.SIM"),
419            StrategyId::from("S-001"),
420            orders,
421            UnixNanos::default(),
422        );
423
424        let mut hasher1 = DefaultHasher::new();
425        let mut hasher2 = DefaultHasher::new();
426        order_list1.hash(&mut hasher1);
427        order_list2.hash(&mut hasher2);
428
429        assert_eq!(hasher1.finish(), hasher2.finish());
430    }
431
432    #[rstest]
433    fn test_validate_accepts_well_formed_list() {
434        let orders = create_client_order_ids(3);
435        let order_list = OrderList::new(
436            OrderListId::from("OL-VALID-001"),
437            InstrumentId::from("AUD/USD.SIM"),
438            StrategyId::from("S-001"),
439            orders,
440            UnixNanos::default(),
441        );
442        order_list
443            .validate()
444            .expect("well-formed list should validate");
445    }
446
447    #[rstest]
448    fn test_validate_rejects_empty_list() {
449        let order_list = OrderList::new(
450            OrderListId::from("OL-EMPTY-001"),
451            InstrumentId::from("AUD/USD.SIM"),
452            StrategyId::from("S-001"),
453            Vec::new(),
454            UnixNanos::default(),
455        );
456        let err = order_list.validate().expect_err("empty list should fail");
457        assert_eq!(
458            err,
459            OrderListValidationError::EmptyClientOrderIds {
460                order_list_id: OrderListId::from("OL-EMPTY-001"),
461            },
462        );
463        assert_eq!(err.to_string(), "OrderList OL-EMPTY-001 has no orders");
464    }
465
466    #[rstest]
467    fn test_validate_rejects_duplicate_client_order_ids() {
468        let id = ClientOrderId::from("O-001");
469        let order_list = OrderList::new(
470            OrderListId::from("OL-DUP-001"),
471            InstrumentId::from("AUD/USD.SIM"),
472            StrategyId::from("S-001"),
473            vec![id, id],
474            UnixNanos::default(),
475        );
476        let err = order_list
477            .validate()
478            .expect_err("duplicate client_order_ids should fail");
479        assert_eq!(
480            err,
481            OrderListValidationError::DuplicateClientOrderIds {
482                order_list_id: OrderListId::from("OL-DUP-001"),
483            },
484        );
485        assert_eq!(
486            err.to_string(),
487            "OrderList OL-DUP-001 contains duplicate client_order_ids",
488        );
489    }
490}