use nautilus_common::config::{ConfigError, ConfigErrorCollector, ConfigResult};
use nautilus_core::Params;
use nautilus_model::{
enums::{BookType, OrderType, TimeInForce, TrailingOffsetType, TriggerType},
identifiers::{ClientId, InstrumentId, StrategyId},
types::Quantity,
};
use nautilus_trading::strategy::StrategyConfig;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, bon::Builder)]
#[builder(finish_fn(name = build_inner, vis = ""))]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.testkit", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.testkit")
)]
#[expect(
clippy::struct_excessive_bools,
reason = "tester configuration exposes independent execution scenario toggles"
)]
#[allow(
clippy::unsafe_derive_deserialize,
reason = "config type deserializes plain field values; unsafe PyO3 methods are unrelated"
)]
pub struct ExecTesterConfig {
#[builder(default)]
pub base: StrategyConfig,
#[builder(default = InstrumentId::from("BTCUSDT-PERP.BINANCE"))]
pub instrument_id: InstrumentId,
#[builder(default = Quantity::from("0.001"))]
pub order_qty: Quantity,
pub order_display_qty: Option<Quantity>,
pub order_expire_time_delta_mins: Option<u64>,
pub order_params: Option<Params>,
pub client_id: Option<ClientId>,
#[builder(default = false)]
pub subscribe_book: bool,
#[builder(default = true)]
pub subscribe_quotes: bool,
#[builder(default = true)]
pub subscribe_trades: bool,
#[builder(default = BookType::L2_MBP)]
pub book_type: BookType,
pub book_depth: Option<usize>,
#[builder(default = 1000)]
pub book_interval_ms: usize,
#[builder(default = 10)]
pub book_levels_to_print: usize,
pub open_position_on_start_qty: Option<Decimal>,
#[builder(default = false)]
pub open_position_on_first_quote: bool,
#[builder(default = TimeInForce::Gtc)]
pub open_position_time_in_force: TimeInForce,
#[builder(default = true)]
pub enable_limit_buys: bool,
#[builder(default = true)]
pub enable_limit_sells: bool,
#[builder(default = false)]
pub enable_stop_buys: bool,
#[builder(default = false)]
pub enable_stop_sells: bool,
#[builder(default = 500)]
pub tob_offset_ticks: u64,
pub limit_time_in_force: Option<TimeInForce>,
#[builder(default = OrderType::StopMarket)]
pub stop_order_type: OrderType,
#[builder(default = 100)]
pub stop_offset_ticks: u64,
pub stop_limit_offset_ticks: Option<u64>,
#[builder(default = TriggerType::Default)]
pub stop_trigger_type: TriggerType,
pub stop_time_in_force: Option<TimeInForce>,
pub trailing_offset: Option<Decimal>,
#[builder(default = TrailingOffsetType::BasisPoints)]
pub trailing_offset_type: TrailingOffsetType,
#[builder(default = false)]
pub enable_brackets: bool,
#[builder(default = false)]
pub batch_submit_limit_pair: bool,
#[builder(default = OrderType::Limit)]
pub bracket_entry_order_type: OrderType,
#[builder(default = 500)]
pub bracket_offset_ticks: u64,
#[builder(default = false)]
pub modify_orders_to_maintain_tob_offset: bool,
#[builder(default = false)]
pub modify_stop_orders_to_maintain_offset: bool,
#[builder(default = false)]
pub cancel_replace_orders_to_maintain_tob_offset: bool,
#[builder(default = false)]
pub cancel_replace_stop_orders_to_maintain_offset: bool,
#[builder(default = false)]
pub use_post_only: bool,
#[builder(default = false)]
pub limit_aggressive: bool,
#[builder(default = false)]
pub use_quote_quantity: bool,
pub emulation_trigger: Option<TriggerType>,
#[builder(default = true)]
pub cancel_orders_on_stop: bool,
#[builder(default = true)]
pub close_positions_on_stop: bool,
pub close_positions_time_in_force: Option<TimeInForce>,
#[builder(default = true)]
pub reduce_only_on_stop: bool,
#[builder(default = false)]
pub use_individual_cancels_on_stop: bool,
#[builder(default = false)]
pub use_batch_cancel_on_stop: bool,
#[builder(default = false)]
pub dry_run: bool,
#[builder(default = true)]
pub log_data: bool,
#[builder(default = false)]
pub test_reject_post_only: bool,
#[builder(default = false)]
pub test_reject_reduce_only: bool,
#[builder(default = false)]
pub test_modify_rejected: bool,
#[builder(default = true)]
pub can_unsubscribe: bool,
#[builder(default = false)]
pub clamp_to_instrument_price_range: bool,
}
impl<S: exec_tester_config_builder::IsComplete> ExecTesterConfigBuilder<S> {
pub fn build(self) -> ConfigResult<ExecTesterConfig> {
let config = self.build_inner();
config.validate()?;
Ok(config)
}
}
impl ExecTesterConfig {
pub fn validate(&self) -> ConfigResult<()> {
let mut errors = ConfigErrorCollector::new();
errors.check(
self.book_interval_ms > 0,
ConfigError::range("book_interval_ms", "must be positive, was 0"),
);
if let Some(book_depth) = self.book_depth {
errors.check(
book_depth > 0,
ConfigError::range("book_depth", "must be positive, was 0"),
);
}
errors.into_result()
}
#[must_use]
pub fn new(
strategy_id: StrategyId,
instrument_id: InstrumentId,
client_id: ClientId,
order_qty: Quantity,
) -> Self {
Self {
base: StrategyConfig {
strategy_id: Some(strategy_id),
order_id_tag: None,
..Default::default()
},
instrument_id,
order_qty,
order_display_qty: None,
order_expire_time_delta_mins: None,
order_params: None,
client_id: Some(client_id),
subscribe_quotes: true,
subscribe_trades: true,
subscribe_book: false,
book_type: BookType::L2_MBP,
book_depth: None,
book_interval_ms: 1000,
book_levels_to_print: 10,
open_position_on_start_qty: None,
open_position_on_first_quote: false,
open_position_time_in_force: TimeInForce::Gtc,
enable_limit_buys: true,
enable_limit_sells: true,
enable_stop_buys: false,
enable_stop_sells: false,
tob_offset_ticks: 500,
limit_time_in_force: None,
stop_order_type: OrderType::StopMarket,
stop_offset_ticks: 100,
stop_limit_offset_ticks: None,
stop_trigger_type: TriggerType::Default,
stop_time_in_force: None,
trailing_offset: None,
trailing_offset_type: TrailingOffsetType::BasisPoints,
enable_brackets: false,
batch_submit_limit_pair: false,
bracket_entry_order_type: OrderType::Limit,
bracket_offset_ticks: 500,
modify_orders_to_maintain_tob_offset: false,
modify_stop_orders_to_maintain_offset: false,
cancel_replace_orders_to_maintain_tob_offset: false,
cancel_replace_stop_orders_to_maintain_offset: false,
use_post_only: false,
limit_aggressive: false,
use_quote_quantity: false,
emulation_trigger: None,
cancel_orders_on_stop: true,
close_positions_on_stop: true,
close_positions_time_in_force: None,
reduce_only_on_stop: true,
use_individual_cancels_on_stop: false,
use_batch_cancel_on_stop: false,
dry_run: false,
log_data: true,
test_reject_post_only: false,
test_reject_reduce_only: false,
test_modify_rejected: false,
can_unsubscribe: true,
clamp_to_instrument_price_range: false,
}
}
}
impl Default for ExecTesterConfig {
fn default() -> Self {
Self::builder()
.build()
.expect("default ExecTesterConfig should be valid")
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
fn test_default_config_is_valid() {
assert!(ExecTesterConfig::builder().build().is_ok());
}
#[rstest]
fn test_zero_book_interval_ms_rejected() {
let result = ExecTesterConfig::builder().book_interval_ms(0).build();
assert!(
matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_interval_ms")
);
}
#[rstest]
fn test_zero_book_depth_rejected() {
let result = ExecTesterConfig::builder().book_depth(0).build();
assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "book_depth"));
}
}