Skip to main content

nautilus_testkit/testers/data/
config.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::num::NonZeroUsize;
17
18use jiff::Timestamp;
19use nautilus_common::{
20    actor::DataActorConfig,
21    config::{ConfigError, ConfigErrorCollector, ConfigResult},
22};
23use nautilus_core::Params;
24use nautilus_model::{
25    data::bar::BarType,
26    enums::BookType,
27    identifiers::{ClientId, InstrumentId},
28};
29use serde::{Deserialize, Serialize};
30
31/// Configuration for the data tester actor.
32#[derive(Debug, Clone, Deserialize, Serialize, bon::Builder)]
33#[builder(finish_fn(name = build_inner, vis = ""))]
34#[serde(default, deny_unknown_fields)]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.testkit", from_py_object)
38)]
39#[cfg_attr(
40    feature = "python",
41    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.testkit")
42)]
43#[expect(
44    clippy::struct_excessive_bools,
45    reason = "tester configuration exposes independent scenario toggles"
46)]
47#[allow(
48    clippy::unsafe_derive_deserialize,
49    reason = "config type deserializes plain field values; unsafe PyO3 methods are unrelated"
50)]
51pub struct DataTesterConfig {
52    /// Base data actor configuration.
53    #[builder(default)]
54    pub base: DataActorConfig,
55    /// Instrument IDs to subscribe to.
56    #[builder(default)]
57    pub instrument_ids: Vec<InstrumentId>,
58    /// Client ID to use for subscriptions.
59    pub client_id: Option<ClientId>,
60    /// Bar types to subscribe to.
61    pub bar_types: Option<Vec<BarType>>,
62    /// Whether to subscribe to order book deltas.
63    #[builder(default = false)]
64    pub subscribe_book_deltas: bool,
65    /// Whether to subscribe to order book depth snapshots.
66    #[builder(default = false)]
67    pub subscribe_book_depth: bool,
68    /// Whether to subscribe to order book at interval.
69    #[builder(default = false)]
70    pub subscribe_book_at_interval: bool,
71    /// Whether to subscribe to quotes.
72    #[builder(default = false)]
73    pub subscribe_quotes: bool,
74    /// Whether to subscribe to trades.
75    #[builder(default = false)]
76    pub subscribe_trades: bool,
77    /// Whether to subscribe to mark prices.
78    #[builder(default = false)]
79    pub subscribe_mark_prices: bool,
80    /// Whether to subscribe to index prices.
81    #[builder(default = false)]
82    pub subscribe_index_prices: bool,
83    /// Whether to subscribe to funding rates.
84    #[builder(default = false)]
85    pub subscribe_funding_rates: bool,
86    /// Whether to subscribe to bars.
87    #[builder(default = false)]
88    pub subscribe_bars: bool,
89    /// Whether to subscribe to instrument updates.
90    #[builder(default = false)]
91    pub subscribe_instrument: bool,
92    /// Whether to subscribe to instrument status.
93    #[builder(default = false)]
94    pub subscribe_instrument_status: bool,
95    /// Whether to subscribe to instrument close.
96    #[builder(default = false)]
97    pub subscribe_instrument_close: bool,
98    /// Whether to subscribe to option greeks.
99    #[builder(default = false)]
100    pub subscribe_option_greeks: bool,
101    /// Optional parameters passed to all subscribe calls.
102    pub subscribe_params: Option<Params>,
103    /// Optional parameters passed to all request calls.
104    pub request_params: Option<Params>,
105    /// Whether unsubscribe is supported on stop.
106    #[builder(default = true)]
107    pub can_unsubscribe: bool,
108    /// Whether to request instruments on start.
109    #[builder(default = false)]
110    pub request_instruments: bool,
111    /// Whether to request historical quotes.
112    #[builder(default = false)]
113    pub request_quotes: bool,
114    /// Whether to request historical trades.
115    #[builder(default = false)]
116    pub request_trades: bool,
117    /// Whether to request historical bars.
118    #[builder(default = false)]
119    pub request_bars: bool,
120    /// Whether to request order book snapshots.
121    #[builder(default = false)]
122    pub request_book_snapshot: bool,
123    // TODO: Support request_book_deltas when Rust data engine has RequestBookDeltas
124    /// Whether to request historical order book deltas (not yet implemented).
125    #[builder(default = false)]
126    pub request_book_deltas: bool,
127    /// Whether to request historical funding rates.
128    #[builder(default = false)]
129    pub request_funding_rates: bool,
130    /// Book type for order book subscriptions.
131    #[builder(default = BookType::L2_MBP)]
132    pub book_type: BookType,
133    /// Order book depth for subscriptions.
134    pub book_depth: Option<usize>,
135    // TODO: Support book_group_size when order book grouping is implemented
136    /// Order book interval in milliseconds for `at_interval` subscriptions.
137    #[builder(default = 1000)]
138    pub book_interval_ms: usize,
139    /// Number of order book levels to print when logging.
140    #[builder(default = 10)]
141    pub book_levels_to_print: usize,
142    /// Whether to manage local order book from deltas.
143    #[builder(default = true)]
144    pub manage_book: bool,
145    /// Whether to log received data.
146    #[builder(default = true)]
147    pub log_data: bool,
148    /// Stats logging interval in seconds (0 to disable).
149    #[builder(default = 5)]
150    pub stats_interval_secs: u64,
151    /// Start time for historical quotes, trades, bars, and funding rates.
152    /// Defaults to one hour before the end time, or seven days for funding rates.
153    pub request_start: Option<Timestamp>,
154    /// End time for historical quotes, trades, bars, and funding rates; `None` uses now.
155    pub request_end: Option<Timestamp>,
156    /// Record limit per historical quote, trade, bar, or funding rate request.
157    pub request_limit: Option<NonZeroUsize>,
158}
159
160impl<S: data_tester_config_builder::IsComplete> DataTesterConfigBuilder<S> {
161    /// Validates and builds the [`DataTesterConfig`].
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if any field fails validation (see [`DataTesterConfig::validate`]).
166    pub fn build(self) -> ConfigResult<DataTesterConfig> {
167        let config = self.build_inner();
168        config.validate()?;
169        Ok(config)
170    }
171}
172
173impl DataTesterConfig {
174    /// Validates the data tester configuration, collecting every field violation.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if any field fails validation. Multiple validation failures are collected
179    /// into one error.
180    pub fn validate(&self) -> ConfigResult<()> {
181        let mut errors = ConfigErrorCollector::new();
182
183        errors.check(
184            self.book_interval_ms > 0,
185            ConfigError::range("book_interval_ms", "must be positive, was 0"),
186        );
187
188        if let Some(book_depth) = self.book_depth {
189            errors.check(
190                book_depth > 0,
191                ConfigError::range("book_depth", "must be positive, was 0"),
192            );
193        }
194
195        if let (Some(start), Some(end)) = (self.request_start, self.request_end) {
196            errors.check(
197                start <= end,
198                ConfigError::range("request_start", "must not be later than request_end"),
199            );
200        }
201
202        errors.into_result()
203    }
204
205    /// Creates a new [`DataTesterConfig`] instance with minimal settings.
206    #[must_use]
207    pub fn new(client_id: ClientId, instrument_ids: Vec<InstrumentId>) -> Self {
208        Self {
209            base: DataActorConfig::default(),
210            instrument_ids,
211            client_id: Some(client_id),
212            bar_types: None,
213            subscribe_book_deltas: false,
214            subscribe_book_depth: false,
215            subscribe_book_at_interval: false,
216            subscribe_quotes: false,
217            subscribe_trades: false,
218            subscribe_mark_prices: false,
219            subscribe_index_prices: false,
220            subscribe_funding_rates: false,
221            subscribe_bars: false,
222
223            subscribe_instrument: false,
224            subscribe_instrument_status: false,
225            subscribe_instrument_close: false,
226            subscribe_option_greeks: false,
227            subscribe_params: None,
228            request_params: None,
229            can_unsubscribe: true,
230            request_instruments: false,
231            request_quotes: false,
232            request_trades: false,
233            request_bars: false,
234            request_book_snapshot: false,
235            request_book_deltas: false,
236            request_funding_rates: false,
237            book_type: BookType::L2_MBP,
238            book_depth: None,
239            book_interval_ms: 1000,
240            book_levels_to_print: 10,
241            manage_book: true,
242            log_data: true,
243            stats_interval_secs: 5,
244            request_start: None,
245            request_end: None,
246            request_limit: None,
247        }
248    }
249}
250
251impl Default for DataTesterConfig {
252    fn default() -> Self {
253        Self::builder()
254            .build()
255            .expect("default DataTesterConfig should be valid")
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use rstest::rstest;
262
263    use super::*;
264
265    #[rstest]
266    fn test_default_config_is_valid() {
267        assert!(DataTesterConfig::builder().build().is_ok());
268    }
269
270    #[rstest]
271    fn test_zero_book_interval_ms_rejected() {
272        let result = DataTesterConfig::builder().book_interval_ms(0).build();
273        assert!(
274            matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_interval_ms")
275        );
276    }
277
278    #[rstest]
279    fn test_zero_book_depth_rejected() {
280        let result = DataTesterConfig::builder().book_depth(0).build();
281        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_depth"));
282    }
283
284    #[rstest]
285    #[case(1, true)]
286    #[case(0, true)]
287    #[case(-1, false)]
288    fn test_request_window_validation(#[case] end_seconds: i64, #[case] valid: bool) {
289        let start = Timestamp::from_second(1_700_000_000).unwrap();
290        let end = start + jiff::SignedDuration::from_secs(end_seconds);
291        let result = DataTesterConfig::builder()
292            .request_start(start)
293            .request_end(end)
294            .build();
295
296        if valid {
297            let config = result.unwrap();
298            assert_eq!(config.request_start, Some(start));
299            assert_eq!(config.request_end, Some(end));
300        } else {
301            assert!(matches!(result, Err(ConfigError::Range { field, reason })
302                if field == "request_start" && reason == "must not be later than request_end"));
303        }
304    }
305
306    #[rstest]
307    fn test_request_bounds_serde_roundtrip() {
308        let start = Timestamp::from_second(1_700_000_000).unwrap();
309        let end = Timestamp::from_second(1_700_000_123).unwrap();
310        let limit = NonZeroUsize::new(37).unwrap();
311        let config = DataTesterConfig::builder()
312            .request_start(start)
313            .request_end(end)
314            .request_limit(limit)
315            .build()
316            .unwrap();
317
318        let value = serde_json::to_value(config).unwrap();
319        let decoded: DataTesterConfig = serde_json::from_value(value).unwrap();
320
321        assert_eq!(decoded.request_start, Some(start));
322        assert_eq!(decoded.request_end, Some(end));
323        assert_eq!(decoded.request_limit, Some(limit));
324    }
325}