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.core.nautilus_pyo3.model", from_py_object)
66)]
67pub struct OrderList {
68    pub id: OrderListId,
69    pub instrument_id: InstrumentId,
70    pub strategy_id: StrategyId,
71    pub client_order_ids: Vec<ClientOrderId>,
72    pub ts_init: UnixNanos,
73}
74
75impl OrderList {
76    /// Creates a new [`OrderList`] instance.
77    ///
78    /// Construction is infallible. [`OrderList::validate`] checks the
79    /// syntactic invariants (non-empty, unique `client_order_ids`); the
80    /// strategy submission path (`Strategy::submit_order_list`) runs it
81    /// before the list reaches the cache.
82    #[must_use]
83    pub fn new(
84        order_list_id: OrderListId,
85        instrument_id: InstrumentId,
86        strategy_id: StrategyId,
87        client_order_ids: Vec<ClientOrderId>,
88        ts_init: UnixNanos,
89    ) -> Self {
90        Self {
91            id: order_list_id,
92            instrument_id,
93            strategy_id,
94            client_order_ids,
95            ts_init,
96        }
97    }
98
99    /// Creates a new [`OrderList`] from a slice of orders.
100    ///
101    /// Derives `order_list_id`, `instrument_id`, and `strategy_id` from the
102    /// first order. The `instrument_id` is representative only; orders in
103    /// the list may target different instruments at the same venue.
104    /// Callers in the production path (`OrderFactory` plus a single
105    /// strategy instance) produce orders with a consistent `order_list_id`
106    /// and `strategy_id`. [`OrderList::validate`] checks the syntactic
107    /// invariants (non-empty, unique `client_order_ids`); it does not
108    /// check cross-field consistency.
109    ///
110    /// # Panics
111    ///
112    /// Panics if `orders` is empty, if the first order has no
113    /// `order_list_id`, or if orders span more than one venue. Callers
114    /// are expected to guard non-empty input; `Strategy::submit_order_list`
115    /// filters out the empty case and bails on mixed venues before
116    /// reaching this constructor.
117    #[must_use]
118    pub fn from_orders(orders: &[OrderAny], ts_init: UnixNanos) -> Self {
119        let first = orders
120            .first()
121            .expect("OrderList::from_orders requires non-empty orders");
122        let order_list_id = first
123            .order_list_id()
124            .expect("OrderList::from_orders requires first order to have order_list_id");
125        let instrument_id = first.instrument_id();
126        let strategy_id = first.strategy_id();
127        let venue = instrument_id.venue;
128
129        for order in orders {
130            assert!(
131                order.instrument_id().venue == venue,
132                "OrderList::from_orders requires all orders to share the same venue; \
133                 expected {venue}, found {} on {}",
134                order.instrument_id().venue,
135                order.client_order_id(),
136            );
137        }
138
139        let client_order_ids = orders.iter().map(|o| o.client_order_id()).collect();
140
141        Self {
142            id: order_list_id,
143            instrument_id,
144            strategy_id,
145            client_order_ids,
146            ts_init,
147        }
148    }
149
150    /// Validates this [`OrderList`]'s own invariants.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if `client_order_ids` is empty or contains duplicates.
155    pub fn validate(&self) -> Result<(), OrderListValidationError> {
156        if self.client_order_ids.is_empty() {
157            return Err(OrderListValidationError::EmptyClientOrderIds {
158                order_list_id: self.id,
159            });
160        }
161
162        let unique: AHashSet<&ClientOrderId> = self.client_order_ids.iter().collect();
163        if unique.len() != self.client_order_ids.len() {
164            return Err(OrderListValidationError::DuplicateClientOrderIds {
165                order_list_id: self.id,
166            });
167        }
168
169        Ok(())
170    }
171
172    #[must_use]
173    pub fn first(&self) -> Option<&ClientOrderId> {
174        self.client_order_ids.first()
175    }
176
177    /// Returns the number of orders in the list.
178    #[must_use]
179    pub fn len(&self) -> usize {
180        self.client_order_ids.len()
181    }
182
183    /// Returns true if the list contains no orders.
184    #[must_use]
185    pub fn is_empty(&self) -> bool {
186        self.client_order_ids.is_empty()
187    }
188}
189
190impl PartialEq for OrderList {
191    fn eq(&self, other: &Self) -> bool {
192        self.id == other.id
193    }
194}
195
196impl Hash for OrderList {
197    fn hash<H: Hasher>(&self, state: &mut H) {
198        self.id.hash(state);
199    }
200}
201
202impl Display for OrderList {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        write!(
205            f,
206            "OrderList(\
207            id={}, \
208            instrument_id={}, \
209            strategy_id={}, \
210            client_order_ids={:?}, \
211            ts_init={}\
212            )",
213            self.id, self.instrument_id, self.strategy_id, self.client_order_ids, self.ts_init,
214        )
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use std::collections::hash_map::DefaultHasher;
221
222    use rstest::rstest;
223
224    use super::*;
225    use crate::{
226        enums::OrderType,
227        identifiers::{InstrumentId, OrderListId},
228        orders::builder::OrderTestBuilder,
229        types::Quantity,
230    };
231
232    fn create_client_order_ids(count: usize) -> Vec<ClientOrderId> {
233        (0..count)
234            .map(|i| ClientOrderId::from(format!("O-00{}", i + 1).as_str()))
235            .collect()
236    }
237
238    fn create_orders(count: usize, order_list_id: OrderListId) -> Vec<OrderAny> {
239        (0..count)
240            .map(|i| {
241                OrderTestBuilder::new(OrderType::Market)
242                    .instrument_id(InstrumentId::from("AUD/USD.SIM"))
243                    .client_order_id(ClientOrderId::from(format!("O-00{}", i + 1).as_str()))
244                    .order_list_id(order_list_id)
245                    .quantity(Quantity::from(1))
246                    .build()
247            })
248            .collect()
249    }
250
251    #[rstest]
252    fn test_new_and_display() {
253        let orders = create_client_order_ids(3);
254
255        let order_list = OrderList::new(
256            OrderListId::from("OL-001"),
257            InstrumentId::from("AUD/USD.SIM"),
258            StrategyId::from("S-001"),
259            orders,
260            UnixNanos::default(),
261        );
262
263        assert!(order_list.to_string().starts_with(
264            "OrderList(id=OL-001, instrument_id=AUD/USD.SIM, strategy_id=S-001, client_order_ids="
265        ));
266    }
267
268    fn create_orders_for_instrument(
269        instrument_ids: &[&str],
270        order_list_id: OrderListId,
271    ) -> Vec<OrderAny> {
272        instrument_ids
273            .iter()
274            .enumerate()
275            .map(|(i, instrument)| {
276                OrderTestBuilder::new(OrderType::Market)
277                    .instrument_id(InstrumentId::from(*instrument))
278                    .client_order_id(ClientOrderId::from(format!("O-00{}", i + 1).as_str()))
279                    .order_list_id(order_list_id)
280                    .quantity(Quantity::from(1))
281                    .build()
282            })
283            .collect()
284    }
285
286    #[rstest]
287    fn test_from_orders_accepts_mixed_instruments_same_venue() {
288        let order_list_id = OrderListId::from("OL-MIXED-001");
289        let orders = create_orders_for_instrument(&["AUD/USD.SIM", "EUR/USD.SIM"], order_list_id);
290
291        let order_list = OrderList::from_orders(&orders, UnixNanos::default());
292
293        assert_eq!(order_list.len(), 2);
294        assert_eq!(order_list.instrument_id, InstrumentId::from("AUD/USD.SIM"));
295    }
296
297    #[rstest]
298    #[should_panic(expected = "share the same venue")]
299    fn test_from_orders_panics_on_mixed_venues() {
300        let order_list_id = OrderListId::from("OL-MIXED-002");
301        let orders =
302            create_orders_for_instrument(&["AUD/USD.SIM", "EUR/USD.IDEALPRO"], order_list_id);
303
304        let _ = OrderList::from_orders(&orders, UnixNanos::default());
305    }
306
307    #[rstest]
308    fn test_from_orders() {
309        let order_list_id = OrderListId::from("OL-002");
310        let orders = create_orders(3, order_list_id);
311
312        let order_list = OrderList::from_orders(&orders, UnixNanos::default());
313
314        assert_eq!(order_list.id, order_list_id);
315        assert_eq!(order_list.len(), 3);
316        assert_eq!(order_list.instrument_id, InstrumentId::from("AUD/USD.SIM"));
317        assert_eq!(order_list.client_order_ids[0], ClientOrderId::from("O-001"));
318    }
319
320    #[rstest]
321    fn test_order_list_equality() {
322        let orders = create_client_order_ids(1);
323
324        let order_list1 = OrderList::new(
325            OrderListId::from("OL-006"),
326            InstrumentId::from("AUD/USD.SIM"),
327            StrategyId::from("S-001"),
328            orders.clone(),
329            UnixNanos::default(),
330        );
331
332        let order_list2 = OrderList::new(
333            OrderListId::from("OL-006"),
334            InstrumentId::from("AUD/USD.SIM"),
335            StrategyId::from("S-001"),
336            orders,
337            UnixNanos::default(),
338        );
339
340        assert_eq!(order_list1, order_list2);
341    }
342
343    #[rstest]
344    fn test_order_list_inequality() {
345        let orders = create_client_order_ids(1);
346
347        let order_list1 = OrderList::new(
348            OrderListId::from("OL-007"),
349            InstrumentId::from("AUD/USD.SIM"),
350            StrategyId::from("S-001"),
351            orders.clone(),
352            UnixNanos::default(),
353        );
354
355        let order_list2 = OrderList::new(
356            OrderListId::from("OL-008"),
357            InstrumentId::from("AUD/USD.SIM"),
358            StrategyId::from("S-001"),
359            orders,
360            UnixNanos::default(),
361        );
362
363        assert_ne!(order_list1, order_list2);
364    }
365
366    #[rstest]
367    fn test_order_list_first() {
368        let orders = create_client_order_ids(2);
369        let first_id = orders[0];
370
371        let order_list = OrderList::new(
372            OrderListId::from("OL-009"),
373            InstrumentId::from("AUD/USD.SIM"),
374            StrategyId::from("S-001"),
375            orders,
376            UnixNanos::default(),
377        );
378
379        let first = order_list.first();
380        assert!(first.is_some());
381        assert_eq!(*first.unwrap(), first_id);
382    }
383
384    #[rstest]
385    fn test_order_list_len() {
386        let orders = create_client_order_ids(3);
387
388        let order_list = OrderList::new(
389            OrderListId::from("OL-010"),
390            InstrumentId::from("AUD/USD.SIM"),
391            StrategyId::from("S-001"),
392            orders,
393            UnixNanos::default(),
394        );
395
396        assert_eq!(order_list.len(), 3);
397        assert!(!order_list.is_empty());
398    }
399
400    #[rstest]
401    fn test_order_list_hash() {
402        let orders = create_client_order_ids(1);
403
404        let order_list1 = OrderList::new(
405            OrderListId::from("OL-011"),
406            InstrumentId::from("AUD/USD.SIM"),
407            StrategyId::from("S-001"),
408            orders.clone(),
409            UnixNanos::default(),
410        );
411
412        let order_list2 = OrderList::new(
413            OrderListId::from("OL-011"),
414            InstrumentId::from("AUD/USD.SIM"),
415            StrategyId::from("S-001"),
416            orders,
417            UnixNanos::default(),
418        );
419
420        let mut hasher1 = DefaultHasher::new();
421        let mut hasher2 = DefaultHasher::new();
422        order_list1.hash(&mut hasher1);
423        order_list2.hash(&mut hasher2);
424
425        assert_eq!(hasher1.finish(), hasher2.finish());
426    }
427
428    #[rstest]
429    fn test_validate_accepts_well_formed_list() {
430        let orders = create_client_order_ids(3);
431        let order_list = OrderList::new(
432            OrderListId::from("OL-VALID-001"),
433            InstrumentId::from("AUD/USD.SIM"),
434            StrategyId::from("S-001"),
435            orders,
436            UnixNanos::default(),
437        );
438        order_list
439            .validate()
440            .expect("well-formed list should validate");
441    }
442
443    #[rstest]
444    fn test_validate_rejects_empty_list() {
445        let order_list = OrderList::new(
446            OrderListId::from("OL-EMPTY-001"),
447            InstrumentId::from("AUD/USD.SIM"),
448            StrategyId::from("S-001"),
449            Vec::new(),
450            UnixNanos::default(),
451        );
452        let err = order_list.validate().expect_err("empty list should fail");
453        assert_eq!(
454            err,
455            OrderListValidationError::EmptyClientOrderIds {
456                order_list_id: OrderListId::from("OL-EMPTY-001"),
457            },
458        );
459        assert_eq!(err.to_string(), "OrderList OL-EMPTY-001 has no orders");
460    }
461
462    #[rstest]
463    fn test_validate_rejects_duplicate_client_order_ids() {
464        let id = ClientOrderId::from("O-001");
465        let order_list = OrderList::new(
466            OrderListId::from("OL-DUP-001"),
467            InstrumentId::from("AUD/USD.SIM"),
468            StrategyId::from("S-001"),
469            vec![id, id],
470            UnixNanos::default(),
471        );
472        let err = order_list
473            .validate()
474            .expect_err("duplicate client_order_ids should fail");
475        assert_eq!(
476            err,
477            OrderListValidationError::DuplicateClientOrderIds {
478                order_list_id: OrderListId::from("OL-DUP-001"),
479            },
480        );
481        assert_eq!(
482            err.to_string(),
483            "OrderList OL-DUP-001 contains duplicate client_order_ids",
484        );
485    }
486}