1use std::time::Duration;
34
35use ahash::AHashMap;
36use nautilus_common::{
37 actor::{DataActor, DataActorNative},
38 timer::TimeEvent,
39};
40use nautilus_model::{
41 enums::OrderType,
42 events::OrderDeniedReason,
43 identifiers::ClientOrderId,
44 instruments::Instrument,
45 orders::{Order, OrderAny},
46 types::Quantity,
47};
48use rust_decimal::{Decimal, RoundingStrategy};
49use ustr::Ustr;
50
51use super::{
52 ExecutionAlgorithm, ExecutionAlgorithmConfig, ExecutionAlgorithmCore, ExecutionAlgorithmNative,
53};
54use crate::nautilus_execution_algorithm;
55
56pub type TwapAlgorithmConfig = ExecutionAlgorithmConfig;
58
59#[derive(Debug)]
65pub struct TwapAlgorithm {
66 pub core: ExecutionAlgorithmCore,
68 scheduled_orders: AHashMap<ClientOrderId, TwapSchedule>,
70}
71
72impl TwapAlgorithm {
73 #[must_use]
75 pub fn new(config: TwapAlgorithmConfig) -> Self {
76 Self {
77 core: ExecutionAlgorithmCore::new(config),
78 scheduled_orders: AHashMap::new(),
79 }
80 }
81
82 fn complete_sequence(&mut self, primary_id: ClientOrderId) {
84 let timer_name = primary_id.as_str();
85 let core = ExecutionAlgorithmNative::exec_algorithm_core_mut(self);
86 if core.clock_mut().timer_names().contains(&timer_name) {
87 core.clock_mut().cancel_timer(timer_name);
88 }
89 core.remove_submit_params(&primary_id);
90 self.scheduled_orders.remove(&primary_id);
91 log::info!("Completed TWAP execution for {primary_id}");
92 }
93}
94
95impl DataActor for TwapAlgorithm {
98 fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
99 ExecutionAlgorithm::on_time_event(self, event)
100 }
101
102 fn on_stop(&mut self) -> anyhow::Result<()> {
103 ExecutionAlgorithm::on_stop(self)
104 }
105
106 fn on_resume(&mut self) -> anyhow::Result<()> {
107 ExecutionAlgorithm::on_resume(self)
108 }
109
110 fn on_reset(&mut self) -> anyhow::Result<()> {
111 ExecutionAlgorithm::on_reset(self)
112 }
113}
114
115nautilus_execution_algorithm!(TwapAlgorithm, {
116 fn on_order(&mut self, order: OrderAny) -> anyhow::Result<()> {
117 let primary_id = order.client_order_id();
118
119 if self.scheduled_orders.contains_key(&primary_id) {
120 anyhow::bail!("Order {primary_id} already being executed");
121 }
122
123 log::info!("Received order for TWAP execution: {order:?}");
124
125 if order.order_type() != OrderType::Market {
127 let reason = OrderDeniedReason::UnsupportedOrderType {
128 order_type: order.order_type(),
129 }
130 .to_string();
131 return self.deny_order(&order, Ustr::from(&reason));
132 }
133
134 let instrument = {
135 let cache = ExecutionAlgorithmNative::exec_algorithm_core(self).cache_ref();
136 cache.instrument(&order.instrument_id()).cloned()
137 };
138
139 let Some(instrument) = instrument else {
140 let reason = OrderDeniedReason::InstrumentNotFound {
141 instrument_id: order.instrument_id(),
142 }
143 .to_string();
144 return self.deny_order(&order, Ustr::from(&reason));
145 };
146
147 let Some(exec_params) = order.exec_algorithm_params() else {
148 return self.deny_order(&order, validation_failed("exec_algorithm_params not found"));
149 };
150
151 let Some(horizon_secs_str) = exec_params.get(&Ustr::from("horizon_secs")) else {
152 return self.deny_order(
153 &order,
154 validation_failed("horizon_secs not found in exec_algorithm_params"),
155 );
156 };
157
158 let horizon_secs: f64 = match horizon_secs_str.parse() {
159 Ok(value) => value,
160 Err(_) => {
161 return self.deny_order(
162 &order,
163 validation_failed(format!(
164 "horizon_secs={horizon_secs_str} is not a valid number"
165 )),
166 );
167 }
168 };
169
170 let Some(interval_secs_str) = exec_params.get(&Ustr::from("interval_secs")) else {
171 return self.deny_order(
172 &order,
173 validation_failed("interval_secs not found in exec_algorithm_params"),
174 );
175 };
176
177 let interval_secs: f64 = match interval_secs_str.parse() {
178 Ok(value) => value,
179 Err(_) => {
180 return self.deny_order(
181 &order,
182 validation_failed(format!(
183 "interval_secs={interval_secs_str} is not a valid number"
184 )),
185 );
186 }
187 };
188
189 if !horizon_secs.is_finite() || horizon_secs <= 0.0 {
190 return self.deny_order(
191 &order,
192 validation_failed(format!(
193 "horizon_secs={horizon_secs} must be finite and positive"
194 )),
195 );
196 }
197
198 if !interval_secs.is_finite() || interval_secs <= 0.0 {
199 return self.deny_order(
200 &order,
201 validation_failed(format!(
202 "interval_secs={interval_secs} must be finite and positive"
203 )),
204 );
205 }
206
207 if horizon_secs < interval_secs {
208 return self.deny_order(
209 &order,
210 validation_failed(format!(
211 "horizon_secs={horizon_secs} must be greater than or equal to interval_secs={interval_secs}"
212 )),
213 );
214 }
215
216 let num_intervals = (horizon_secs / interval_secs).floor() as u64;
217 if num_intervals == 0 {
218 return self.deny_order(&order, validation_failed("num_intervals is 0"));
219 }
220
221 let total_qty = order.quantity();
222 let interval_count = Decimal::from(num_intervals);
223 let quotient = total_qty.as_decimal() / interval_count;
224 let floored = quotient.round_dp_with_strategy(
225 u32::from(instrument.size_precision()),
226 RoundingStrategy::ToZero,
227 );
228 let qty_per_interval = match instrument.try_make_qty_from_decimal(floored, None) {
229 Ok(quantity) => quantity,
230 Err(e) => {
231 return self.deny_order(
232 &order,
233 validation_failed(format!("invalid qty_per_interval={floored}: {e}")),
234 );
235 }
236 };
237 let remainder = total_qty.as_decimal() - floored * interval_count;
238
239 if qty_per_interval == total_qty || qty_per_interval < instrument.size_increment() {
240 log::warn!(
241 "Submitting for entire size: qty_per_interval={qty_per_interval}, order_quantity={total_qty}"
242 );
243 self.submit_order(order, None, None)?;
244 self.complete_sequence(primary_id);
245 return Ok(());
246 }
247
248 if let Some(min_qty) = instrument.min_quantity()
249 && qty_per_interval < min_qty
250 {
251 log::warn!(
252 "Submitting for entire size: qty_per_interval={qty_per_interval} < min_quantity={min_qty}"
253 );
254 self.submit_order(order, None, None)?;
255 self.complete_sequence(primary_id);
256 return Ok(());
257 }
258
259 let interval = match Duration::try_from_secs_f64(interval_secs) {
260 Ok(interval) => interval,
261 Err(e) => {
262 return self.deny_order(
263 &order,
264 validation_failed(format!(
265 "interval_secs={interval_secs} is not a valid duration: {e}"
266 )),
267 );
268 }
269 };
270
271 if interval == Duration::ZERO {
272 return self.deny_order(
273 &order,
274 validation_failed(format!(
275 "interval_secs={interval_secs} rounds to a zero duration"
276 )),
277 );
278 }
279 let Ok(interval_ns) = u64::try_from(interval.as_nanos()) else {
280 return self.deny_order(
281 &order,
282 validation_failed(format!(
283 "interval_secs={interval_secs} exceeds the clock nanosecond range"
284 )),
285 );
286 };
287 let timestamp_ns = ExecutionAlgorithmNative::exec_algorithm_core_mut(self)
288 .clock_mut()
289 .timestamp_ns()
290 .as_u64();
291
292 if timestamp_ns.checked_add(interval_ns).is_none() {
293 return self.deny_order(
294 &order,
295 validation_failed(format!(
296 "interval_secs={interval_secs} exceeds the clock timestamp headroom"
297 )),
298 );
299 }
300
301 let mut scheduled_sizes: Vec<Quantity> = vec![qty_per_interval; num_intervals as usize];
302
303 if remainder > Decimal::ZERO {
304 let remainder_qty = match instrument.try_make_qty_from_decimal(remainder, None) {
305 Ok(quantity) => quantity,
306 Err(e) => {
307 return self.deny_order(
308 &order,
309 validation_failed(format!("invalid qty_remainder={remainder}: {e}")),
310 );
311 }
312 };
313 scheduled_sizes.push(remainder_qty);
314 }
315
316 let scheduled_total = scheduled_sizes
317 .iter()
318 .fold(Decimal::ZERO, |total, quantity| {
319 total + quantity.as_decimal()
320 });
321
322 if scheduled_total != total_qty.as_decimal() {
323 return self.deny_order(
324 &order,
325 validation_failed(format!(
326 "scheduled quantity {scheduled_total} does not equal order quantity {total_qty}"
327 )),
328 );
329 }
330
331 log::info!("Order execution size schedule: {scheduled_sizes:?}");
332
333 {
336 let cache_rc = ExecutionAlgorithmNative::exec_algorithm_core(self).cache_rc();
337 let mut cache = cache_rc.borrow_mut();
338 if !cache.order_exists(&primary_id) {
339 cache.add_order(order.clone(), None, None, false)?;
340 }
341 }
342
343 self.scheduled_orders.insert(
344 primary_id,
345 TwapSchedule {
346 remaining_sizes: scheduled_sizes.clone(),
347 interval,
348 },
349 );
350
351 let schedule = self.scheduled_orders.get_mut(&primary_id).unwrap();
352 let first_qty = schedule.remaining_sizes.remove(0);
353 let is_single_slice = self
354 .scheduled_orders
355 .get(&primary_id)
356 .is_some_and(|schedule| schedule.remaining_sizes.is_empty());
357
358 if is_single_slice {
360 self.submit_order(order, None, None)?;
361 self.complete_sequence(primary_id);
362 return Ok(());
363 }
364
365 let tags = order.tags().map(<[Ustr]>::to_vec);
367 let time_in_force = order.time_in_force();
368 let reduce_only = order.is_reduce_only();
369 let mut order = order;
370 let spawned = self.spawn_market(
371 &mut order,
372 first_qty,
373 time_in_force,
374 reduce_only,
375 tags,
376 true,
377 );
378 self.submit_order(spawned.into(), None, None)?;
379
380 ExecutionAlgorithmNative::exec_algorithm_core_mut(self)
381 .clock_mut()
382 .set_timer(primary_id.as_str(), interval, None, None, None, None, None)?;
383
384 log::info!(
385 "Started TWAP execution for {primary_id}: horizon_secs={horizon_secs}, interval_secs={interval_secs}"
386 );
387
388 Ok(())
389 }
390
391 fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
392 log::info!("Received time event: {event}");
393
394 let primary_id = ClientOrderId::new(event.name);
395
396 let primary = {
397 let cache = ExecutionAlgorithmNative::exec_algorithm_core(self).cache_ref();
398 cache.order(&primary_id).map(|o| o.clone())
399 };
400
401 let Some(primary) = primary else {
402 log::error!("Cannot find primary order for exec_spawn_id={primary_id}");
403 self.complete_sequence(primary_id);
404 return Ok(());
405 };
406
407 if primary.is_closed() {
408 self.complete_sequence(primary_id);
409 return Ok(());
410 }
411
412 let Some(schedule) = self.scheduled_orders.get_mut(&primary_id) else {
413 log::error!("Cannot find scheduled sizes for exec_spawn_id={primary_id}");
414 return Ok(());
415 };
416
417 if schedule.remaining_sizes.is_empty() {
418 log::warn!("No more size to execute for exec_spawn_id={primary_id}");
419 return Ok(());
420 }
421
422 let quantity = schedule.remaining_sizes.remove(0);
423 let is_final_slice = schedule.remaining_sizes.is_empty();
424
425 if is_final_slice {
427 self.submit_order(primary, None, None)?;
428 self.complete_sequence(primary_id);
429 return Ok(());
430 }
431
432 let tags = primary.tags().map(<[Ustr]>::to_vec);
434 let time_in_force = primary.time_in_force();
435 let reduce_only = primary.is_reduce_only();
436 let mut primary = primary;
437 let spawned = self.spawn_market(
438 &mut primary,
439 quantity,
440 time_in_force,
441 reduce_only,
442 tags,
443 true,
444 );
445 self.submit_order(spawned.into(), None, None)?;
446
447 Ok(())
448 }
449
450 fn on_stop(&mut self) -> anyhow::Result<()> {
451 ExecutionAlgorithmNative::exec_algorithm_core_mut(self)
452 .clock_mut()
453 .cancel_timers();
454 Ok(())
455 }
456
457 fn on_resume(&mut self) -> anyhow::Result<()> {
458 let primary_ids: Vec<ClientOrderId> = self.scheduled_orders.keys().copied().collect();
459
460 for primary_id in primary_ids {
461 let primary_is_open = {
462 let cache = ExecutionAlgorithmNative::exec_algorithm_core(self).cache_ref();
463 cache.order(&primary_id).map(|primary| !primary.is_closed())
464 };
465
466 if primary_is_open.is_none() {
467 log::error!("Cannot find primary order for exec_spawn_id={primary_id}");
468 }
469 let interval = self.scheduled_orders.get(&primary_id).and_then(|schedule| {
470 (!schedule.remaining_sizes.is_empty()).then_some(schedule.interval)
471 });
472
473 let Some(interval) = interval.filter(|_| primary_is_open == Some(true)) else {
474 self.complete_sequence(primary_id);
475 continue;
476 };
477
478 ExecutionAlgorithmNative::exec_algorithm_core_mut(self)
479 .clock_mut()
480 .set_timer(primary_id.as_str(), interval, None, None, None, None, None)?;
481 }
482
483 Ok(())
484 }
485
486 fn on_reset(&mut self) -> anyhow::Result<()> {
487 self.unsubscribe_all_strategy_events();
488 ExecutionAlgorithmNative::exec_algorithm_core_mut(self).reset();
489 self.scheduled_orders.clear();
490 Ok(())
491 }
492});
493
494#[derive(Debug)]
495struct TwapSchedule {
496 remaining_sizes: Vec<Quantity>,
497 interval: Duration,
498}
499
500fn validation_failed(detail: impl Into<String>) -> Ustr {
501 let reason = OrderDeniedReason::ValidationFailed {
502 detail: detail.into(),
503 }
504 .to_string();
505 Ustr::from(&reason)
506}
507
508#[cfg(test)]
509mod tests {
510 use std::{cell::RefCell, rc::Rc};
511
512 use indexmap::IndexMap;
513 use nautilus_common::{
514 cache::Cache,
515 clock::{Clock, VirtualClock},
516 component::Component,
517 enums::ComponentTrigger,
518 messages::execution::{ModifyOrder, SubmitOrder, TradingCommand},
519 msgbus::{self, MessagingSwitchboard, TypedHandler},
520 };
521 use nautilus_core::{DurationNanos, Params, UUID4, UnixNanos};
522 use nautilus_model::{
523 enums::{OrderSide, OrderStatus, TimeInForce},
524 events::{OrderDeniedReason, OrderEventAny, order::spec::OrderCanceledSpec},
525 identifiers::{ExecAlgorithmId, InstrumentId, StrategyId, TraderId},
526 orders::{LimitOrder, MarketOrder},
527 types::Price,
528 };
529 use rstest::rstest;
530 use ustr::Ustr;
531
532 use super::*;
533
534 fn create_twap_algorithm() -> TwapAlgorithm {
535 let unique_id = format!("TWAP-{}", UUID4::new());
537 let config = TwapAlgorithmConfig {
538 exec_algorithm_id: Some(ExecAlgorithmId::new(&unique_id)),
539 ..Default::default()
540 };
541 TwapAlgorithm::new(config)
542 }
543
544 fn register_algorithm_with_clock(algo: &mut TwapAlgorithm) -> Rc<RefCell<VirtualClock>> {
545 use nautilus_common::timer::TimeEventCallback;
546
547 let trader_id = TraderId::from("TRADER-001");
548 let clock = Rc::new(RefCell::new(VirtualClock::new()));
549 let cache = Rc::new(RefCell::new(Cache::default()));
550
551 clock
553 .borrow_mut()
554 .register_default_handler(TimeEventCallback::Rust(std::sync::Arc::new(|_| {})));
555
556 algo.core.register(trader_id, clock.clone(), cache).unwrap();
557
558 algo.transition_state(ComponentTrigger::Initialize).unwrap();
560 algo.transition_state(ComponentTrigger::Start).unwrap();
561 algo.transition_state(ComponentTrigger::StartCompleted)
562 .unwrap();
563
564 clock
565 }
566
567 fn register_algorithm(algo: &mut TwapAlgorithm) {
568 let _ = register_algorithm_with_clock(algo);
569 }
570
571 fn add_instrument_to_cache(algo: &TwapAlgorithm) {
572 use nautilus_model::instruments::{InstrumentAny, stubs::crypto_perpetual_ethusdt};
573
574 let instrument = crypto_perpetual_ethusdt();
575 let cache_rc = algo.core.cache_rc();
576 let mut cache = cache_rc.borrow_mut();
577 cache
578 .add_instrument(InstrumentAny::CryptoPerpetual(instrument))
579 .unwrap();
580 }
581
582 fn create_market_order_with_params(params: IndexMap<Ustr, Ustr>) -> OrderAny {
583 create_market_order_with_params_and_qty(params, Quantity::from("1.0"))
584 }
585
586 fn create_market_order_with_params_and_qty(
587 params: IndexMap<Ustr, Ustr>,
588 quantity: Quantity,
589 ) -> OrderAny {
590 let client_order_id = ClientOrderId::from("O-001");
591 OrderAny::Market(MarketOrder::new(
592 TraderId::from("TRADER-001"),
593 StrategyId::from("STRAT-001"),
594 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
595 client_order_id,
596 OrderSide::Buy,
597 quantity,
598 TimeInForce::Gtc,
599 UUID4::new(),
600 0.into(),
601 false,
602 false,
603 None,
604 None,
605 None,
606 None,
607 Some(ExecAlgorithmId::new("TWAP")),
608 Some(params),
609 Some(client_order_id),
610 None,
611 ))
612 }
613
614 fn assert_twap_denied(algo: &mut TwapAlgorithm, order: &OrderAny, expected_reason: &str) {
615 let strategy_id = order.strategy_id();
616 {
617 let cache_rc = algo.core.cache_rc();
618 cache_rc
619 .borrow_mut()
620 .add_order(order.clone(), None, None, false)
621 .unwrap();
622 }
623 let events = Rc::new(RefCell::new(Vec::new()));
624 let handler = TypedHandler::from({
625 let events = events.clone();
626 move |event: &OrderEventAny| events.borrow_mut().push(event.clone())
627 });
628 let topic = format!("events.order.{strategy_id}");
629 msgbus::subscribe_order_events(topic.clone().into(), handler.clone(), None);
630
631 algo.on_order(order.clone()).unwrap();
632 algo.on_order(order.clone()).unwrap();
633
634 msgbus::unsubscribe_order_events(topic.into(), &handler);
635 let cached_order = algo.cache().order(&order.client_order_id()).unwrap();
636 let events = events.borrow();
637
638 assert_eq!(cached_order.status(), OrderStatus::Denied);
639 assert_eq!(events.len(), 1);
640 assert!(matches!(
641 &events[0],
642 OrderEventAny::Denied(event)
643 if event.reason == expected_reason
644 && event.strategy_id == strategy_id
645 && event.client_order_id == order.client_order_id()
646 ));
647 assert!(algo.scheduled_orders.is_empty());
648 assert!(algo.clock().timer_names().is_empty());
649 }
650
651 #[rstest]
652 fn test_twap_creation() {
653 let algo = create_twap_algorithm();
654 assert!(algo.id().inner().starts_with("TWAP"));
655 assert!(algo.scheduled_orders.is_empty());
656 }
657
658 #[rstest]
659 fn test_twap_registration() {
660 let mut algo = create_twap_algorithm();
661 register_algorithm(&mut algo);
662
663 assert_eq!(algo.trader_id(), Some(TraderId::from("TRADER-001")));
664 }
665
666 #[rstest]
667 fn test_twap_reset_clears_scheduled_sizes() {
668 let mut algo = create_twap_algorithm();
669 algo.scheduled_orders.insert(
670 ClientOrderId::new("O-001"),
671 TwapSchedule {
672 remaining_sizes: vec![Quantity::from("1.0")],
673 interval: Duration::from_secs(1),
674 },
675 );
676 algo.scheduled_orders.insert(
677 ClientOrderId::new("O-002"),
678 TwapSchedule {
679 remaining_sizes: vec![Quantity::from("2.0")],
680 interval: Duration::from_secs(2),
681 },
682 );
683
684 assert!(!algo.scheduled_orders.is_empty());
685
686 DataActor::on_reset(&mut algo).unwrap();
688
689 assert!(algo.scheduled_orders.is_empty());
690 }
691
692 #[rstest]
693 fn test_twap_rejects_non_market_orders() {
694 let mut algo = create_twap_algorithm();
695 register_algorithm(&mut algo);
696
697 let order = OrderAny::Limit(LimitOrder::new(
698 TraderId::from("TRADER-001"),
699 StrategyId::from("STRAT-001"),
700 InstrumentId::from("BTC/USDT.BINANCE"),
701 ClientOrderId::from("O-001"),
702 OrderSide::Buy,
703 Quantity::from("1.0"),
704 Price::from("50000.0"),
705 TimeInForce::Gtc,
706 None, false, false, false, None, None, None, None, None, None, None, None, None, None, None, UUID4::new(),
722 0.into(),
723 ));
724
725 let reason = OrderDeniedReason::UnsupportedOrderType {
726 order_type: OrderType::Limit,
727 }
728 .to_string();
729 assert_twap_denied(&mut algo, &order, &reason);
730 }
731
732 #[rstest]
733 fn test_twap_denies_missing_instrument() {
734 let mut algo = create_twap_algorithm();
735 register_algorithm(&mut algo);
736
737 let mut params = IndexMap::new();
738 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
739 params.insert(Ustr::from("interval_secs"), Ustr::from("10"));
740 let order = create_market_order_with_params(params);
741 let reason = OrderDeniedReason::InstrumentNotFound {
742 instrument_id: order.instrument_id(),
743 }
744 .to_string();
745
746 assert_twap_denied(&mut algo, &order, &reason);
747 }
748
749 #[rstest]
750 fn test_twap_rejects_missing_params() {
751 let mut algo = create_twap_algorithm();
752 register_algorithm(&mut algo);
753
754 add_instrument_to_cache(&algo);
755
756 let order = OrderAny::Market(MarketOrder::new(
757 TraderId::from("TRADER-001"),
758 StrategyId::from("STRAT-001"),
759 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
760 ClientOrderId::from("O-001"),
761 OrderSide::Buy,
762 Quantity::from("1.0"),
763 TimeInForce::Gtc,
764 UUID4::new(),
765 0.into(),
766 false,
767 false,
768 None,
769 None,
770 None,
771 None,
772 None,
773 None, None,
775 None,
776 ));
777
778 assert_twap_denied(
779 &mut algo,
780 &order,
781 "VALIDATION_FAILED: exec_algorithm_params not found",
782 );
783 }
784
785 #[rstest]
786 #[case(
787 None,
788 Some("10"),
789 "VALIDATION_FAILED: horizon_secs not found in exec_algorithm_params"
790 )]
791 #[case(
792 Some("60"),
793 None,
794 "VALIDATION_FAILED: interval_secs not found in exec_algorithm_params"
795 )]
796 #[case(
797 Some("not-a-number"),
798 Some("10"),
799 "VALIDATION_FAILED: horizon_secs=not-a-number is not a valid number"
800 )]
801 #[case(
802 Some("60"),
803 Some("not-a-number"),
804 "VALIDATION_FAILED: interval_secs=not-a-number is not a valid number"
805 )]
806 fn test_twap_denies_missing_or_malformed_schedule_parameter(
807 #[case] horizon_secs: Option<&str>,
808 #[case] interval_secs: Option<&str>,
809 #[case] expected_reason: &str,
810 ) {
811 let mut algo = create_twap_algorithm();
812 register_algorithm(&mut algo);
813 add_instrument_to_cache(&algo);
814
815 let mut params = IndexMap::new();
816
817 if let Some(horizon_secs) = horizon_secs {
818 params.insert(Ustr::from("horizon_secs"), Ustr::from(horizon_secs));
819 }
820
821 if let Some(interval_secs) = interval_secs {
822 params.insert(Ustr::from("interval_secs"), Ustr::from(interval_secs));
823 }
824
825 assert_twap_denied(
826 &mut algo,
827 &create_market_order_with_params(params),
828 expected_reason,
829 );
830 }
831
832 #[rstest]
833 fn test_twap_rejects_horizon_less_than_interval() {
834 let mut algo = create_twap_algorithm();
835 register_algorithm(&mut algo);
836
837 add_instrument_to_cache(&algo);
838
839 let mut params = IndexMap::new();
840 params.insert(Ustr::from("horizon_secs"), Ustr::from("30"));
841 params.insert(Ustr::from("interval_secs"), Ustr::from("60"));
842
843 let order = create_market_order_with_params(params);
844 assert_twap_denied(
845 &mut algo,
846 &order,
847 "VALIDATION_FAILED: horizon_secs=30 must be greater than or equal to interval_secs=60",
848 );
849 }
850
851 #[rstest]
852 fn test_twap_rejects_duplicate_order() {
853 let mut algo = create_twap_algorithm();
854 register_algorithm(&mut algo);
855
856 add_instrument_to_cache(&algo);
857
858 let mut params = IndexMap::new();
859 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
860 params.insert(Ustr::from("interval_secs"), Ustr::from("10"));
861
862 let order1 = create_market_order_with_params(params.clone());
863 let order2 = create_market_order_with_params(params);
864
865 algo.on_order(order1).unwrap();
866 let result = algo.on_order(order2);
867
868 assert!(result.is_err());
869 assert!(
870 result
871 .unwrap_err()
872 .to_string()
873 .contains("already being executed")
874 );
875 }
876
877 #[rstest]
878 fn test_twap_calculates_size_schedule_evenly() {
879 let mut algo = create_twap_algorithm();
880 register_algorithm(&mut algo);
881
882 add_instrument_to_cache(&algo);
883
884 let mut params = IndexMap::new();
886 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
887 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
888
889 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
890 let primary_id = order.client_order_id();
891
892 algo.on_order(order).unwrap();
893
894 let remaining = &algo
896 .scheduled_orders
897 .get(&primary_id)
898 .unwrap()
899 .remaining_sizes;
900 assert_eq!(remaining.len(), 2);
901
902 for qty in remaining {
903 assert_eq!(*qty, Quantity::from("0.4"));
904 }
905 }
906
907 #[rstest]
908 fn test_twap_reduces_cached_primary_after_first_child_spawn() {
909 let mut algo = create_twap_algorithm();
910 register_algorithm(&mut algo);
911
912 add_instrument_to_cache(&algo);
913
914 let mut params = IndexMap::new();
915 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
916 params.insert(Ustr::from("interval_secs"), Ustr::from("30"));
917
918 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
919 let primary_id = order.client_order_id();
920
921 algo.on_order(order).unwrap();
922
923 let cache = algo.cache();
924 let primary = cache.order(&primary_id).unwrap();
925 let spawned = cache.order(&ClientOrderId::from("O-001-E1")).unwrap();
926
927 assert_eq!(primary.quantity(), Quantity::from("0.6"));
928 assert_eq!(spawned.quantity(), Quantity::from("0.6"));
929 assert_eq!(spawned.exec_spawn_id(), Some(primary_id));
930 }
931
932 #[rstest]
933 fn test_twap_refused_modify_preserves_remaining_quantity_schedule() {
934 let mut algo = create_twap_algorithm();
935 register_algorithm(&mut algo);
936 add_instrument_to_cache(&algo);
937
938 let mut params = IndexMap::new();
939 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
940 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
941 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
942 let primary_id = order.client_order_id();
943 algo.on_order(order).unwrap();
944
945 let command = ModifyOrder::new(
946 TraderId::from("TRADER-001"),
947 None,
948 StrategyId::from("STRAT-001"),
949 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
950 primary_id,
951 None,
952 Some(Quantity::from("0.4")),
953 None,
954 None,
955 UUID4::new(),
956 0.into(),
957 None,
958 None,
959 );
960
961 algo.handle_modify_order(command).unwrap();
962
963 let primary_quantity = algo.cache().order(&primary_id).unwrap().quantity();
964 let scheduled_quantity = algo.scheduled_orders[&primary_id]
965 .remaining_sizes
966 .iter()
967 .fold(Decimal::ZERO, |total, quantity| {
968 total + quantity.as_decimal()
969 });
970 assert_eq!(primary_quantity.as_decimal(), scheduled_quantity);
971 assert_eq!(primary_quantity, Quantity::from("0.8"));
972 }
973
974 #[rstest]
975 fn test_twap_on_order_accepts_already_cached_primary() {
976 let mut algo = create_twap_algorithm();
977 register_algorithm(&mut algo);
978
979 add_instrument_to_cache(&algo);
980
981 let mut params = IndexMap::new();
982 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
983 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
984
985 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
986 let primary_id = order.client_order_id();
987
988 {
990 let cache_rc = algo.core.cache_rc();
991 let mut cache = cache_rc.borrow_mut();
992 cache.add_order(order.clone(), None, None, false).unwrap();
993 }
994
995 algo.on_order(order).unwrap();
996
997 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 2);
998 }
999
1000 #[rstest]
1001 fn test_twap_calculates_size_schedule_with_remainder() {
1002 let mut algo = create_twap_algorithm();
1003 register_algorithm(&mut algo);
1004
1005 add_instrument_to_cache(&algo);
1006 let instrument = nautilus_model::instruments::stubs::crypto_perpetual_ethusdt();
1007
1008 let mut params = IndexMap::new();
1010 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1011 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1012
1013 let order = create_market_order_with_params(params);
1014 let primary_id = order.client_order_id();
1015
1016 algo.on_order(order).unwrap();
1017
1018 let remaining = &algo
1020 .scheduled_orders
1021 .get(&primary_id)
1022 .unwrap()
1023 .remaining_sizes;
1024 assert_eq!(remaining.len(), 3);
1025 assert_eq!(
1026 remaining,
1027 &[
1028 Quantity::from("0.333"),
1029 Quantity::from("0.333"),
1030 Quantity::from("0.001"),
1031 ]
1032 );
1033
1034 for quantity in remaining {
1035 assert_eq!(quantity.precision, instrument.size_precision());
1036 assert_eq!(quantity.raw() % instrument.size_increment().raw(), 0);
1037 }
1038
1039 let first = algo
1040 .cache()
1041 .order(&ClientOrderId::from("O-001-E1"))
1042 .unwrap()
1043 .quantity();
1044 let total = remaining
1045 .iter()
1046 .fold(first.as_decimal(), |sum, qty| sum + qty.as_decimal());
1047 assert_eq!(total, Quantity::from("1.0").as_decimal());
1048 }
1049
1050 #[rstest]
1051 fn test_twap_children_use_instrument_size_precision() {
1052 let mut algo = create_twap_algorithm();
1053 register_algorithm(&mut algo);
1054
1055 add_instrument_to_cache(&algo);
1056 let instrument = nautilus_model::instruments::stubs::crypto_perpetual_ethusdt();
1057
1058 let mut params = IndexMap::new();
1059 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1060 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1061
1062 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.000000000"));
1063 let primary_id = order.client_order_id();
1064
1065 algo.on_order(order).unwrap();
1066
1067 let spawned = algo
1068 .cache()
1069 .order(&ClientOrderId::from("O-001-E1"))
1070 .unwrap()
1071 .quantity();
1072 assert_eq!(spawned.precision, instrument.size_precision());
1073 assert!(
1074 algo.scheduled_orders[&primary_id]
1075 .remaining_sizes
1076 .iter()
1077 .all(|quantity| quantity.precision == instrument.size_precision())
1078 );
1079 }
1080
1081 #[rstest]
1082 fn test_twap_on_time_event_spawns_next_slice() {
1083 let mut algo = create_twap_algorithm();
1084 register_algorithm(&mut algo);
1085
1086 add_instrument_to_cache(&algo);
1087
1088 let mut params = IndexMap::new();
1090 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1091 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1092
1093 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1094 let primary_id = order.client_order_id();
1095
1096 let submit_params = Params::new(
1097 [(
1098 "routing_profile".to_string(),
1099 serde_json::Value::String("intermediate-slice".to_string()),
1100 )]
1101 .into(),
1102 );
1103 algo.core
1104 .remember_submit_params(primary_id, Some(submit_params.clone()));
1105
1106 algo.on_order(order).unwrap();
1107
1108 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 2);
1110
1111 let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
1113 ExecutionAlgorithm::on_time_event(&mut algo, &event).unwrap();
1114
1115 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 1);
1117 assert_eq!(algo.core.submit_params(&primary_id), Some(submit_params));
1118 }
1119
1120 #[rstest]
1121 fn test_twap_data_actor_dispatch_spawns_next_slice() {
1122 let mut algo = create_twap_algorithm();
1123 register_algorithm(&mut algo);
1124
1125 add_instrument_to_cache(&algo);
1126
1127 let mut params = IndexMap::new();
1128 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1129 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1130
1131 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1132 let primary_id = order.client_order_id();
1133
1134 algo.on_order(order).unwrap();
1135 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 2);
1136
1137 let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
1139 algo.handle_time_event(&event);
1140
1141 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 1);
1142 }
1143
1144 #[rstest]
1145 fn test_twap_on_time_event_completes_on_final_slice() {
1146 let mut algo = create_twap_algorithm();
1147 register_algorithm(&mut algo);
1148
1149 add_instrument_to_cache(&algo);
1150
1151 let mut params = IndexMap::new();
1153 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1154 params.insert(Ustr::from("interval_secs"), Ustr::from("30"));
1155
1156 let order = create_market_order_with_params(params);
1157 let primary_id = order.client_order_id();
1158
1159 let submit_params = Params::new(
1160 [(
1161 "routing_profile".to_string(),
1162 serde_json::Value::String("final-slice".to_string()),
1163 )]
1164 .into(),
1165 );
1166 algo.core
1167 .remember_submit_params(primary_id, Some(submit_params.clone()));
1168
1169 algo.on_order(order).unwrap();
1170 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 1);
1171 assert_eq!(
1172 algo.core.submit_params(&primary_id),
1173 Some(submit_params.clone())
1174 );
1175
1176 let received = Rc::new(RefCell::new(None::<SubmitOrder>));
1177 let handler = msgbus::TypedIntoHandler::from({
1178 let captured = received.clone();
1179 move |cmd: TradingCommand| {
1180 if let TradingCommand::SubmitOrder(cmd) = cmd {
1181 *captured.borrow_mut() = Some(cmd);
1182 }
1183 }
1184 });
1185 msgbus::register_trading_command_endpoint(
1186 MessagingSwitchboard::risk_engine_queue_execute(),
1187 handler,
1188 );
1189
1190 let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
1192 ExecutionAlgorithm::on_time_event(&mut algo, &event).unwrap();
1193
1194 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1196 assert_eq!(
1197 received
1198 .borrow()
1199 .as_ref()
1200 .and_then(|cmd| cmd.params.clone()),
1201 Some(submit_params),
1202 );
1203 assert_eq!(algo.core.submit_params(&primary_id), None);
1204 }
1205
1206 #[rstest]
1207 fn test_twap_on_time_event_completes_when_primary_closed() {
1208 let mut algo = create_twap_algorithm();
1209 register_algorithm(&mut algo);
1210
1211 add_instrument_to_cache(&algo);
1212
1213 let mut params = IndexMap::new();
1214 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1215 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1216
1217 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1218 let primary_id = order.client_order_id();
1219
1220 let submit_params = Params::new(
1221 [(
1222 "routing_profile".to_string(),
1223 serde_json::Value::String("closed-primary".to_string()),
1224 )]
1225 .into(),
1226 );
1227 algo.core
1228 .remember_submit_params(primary_id, Some(submit_params));
1229
1230 algo.on_order(order).unwrap();
1231 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 2);
1232
1233 {
1235 let cache_rc = algo.core.cache_rc();
1236 let mut cache = cache_rc.borrow_mut();
1237 let primary = cache.order(&primary_id).map(|o| o.clone()).unwrap();
1238
1239 let canceled = OrderCanceledSpec::builder()
1240 .trader_id(primary.trader_id())
1241 .strategy_id(primary.strategy_id())
1242 .instrument_id(primary.instrument_id())
1243 .client_order_id(primary.client_order_id())
1244 .build();
1245 cache
1246 .update_order(&OrderEventAny::Canceled(canceled))
1247 .unwrap();
1248 }
1249
1250 let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
1252 ExecutionAlgorithm::on_time_event(&mut algo, &event).unwrap();
1253
1254 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1256 assert_eq!(algo.core.submit_params(&primary_id), None);
1257 }
1258
1259 #[rstest]
1260 fn test_twap_on_time_event_completes_when_primary_missing() {
1261 let mut algo = create_twap_algorithm();
1262 register_algorithm(&mut algo);
1263 add_instrument_to_cache(&algo);
1264
1265 let mut params = IndexMap::new();
1266 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1267 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1268
1269 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1270 let primary_id = order.client_order_id();
1271 algo.on_order(order).unwrap();
1272
1273 {
1274 let cache_rc = algo.core.cache_rc();
1275 let mut cache = cache_rc.borrow_mut();
1276 let primary = cache.order(&primary_id).map(|order| order.clone()).unwrap();
1277 let canceled = OrderCanceledSpec::builder()
1278 .trader_id(primary.trader_id())
1279 .strategy_id(primary.strategy_id())
1280 .instrument_id(primary.instrument_id())
1281 .client_order_id(primary.client_order_id())
1282 .build();
1283 cache
1284 .update_order(&OrderEventAny::Canceled(canceled))
1285 .unwrap();
1286 cache.purge_order(primary_id);
1287 }
1288 assert!(algo.cache().order(&primary_id).is_none());
1289
1290 let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
1291 ExecutionAlgorithm::on_time_event(&mut algo, &event).unwrap();
1292
1293 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1295 assert!(algo.clock().timer_names().is_empty());
1296 }
1297
1298 #[rstest]
1299 fn test_twap_on_stop_cancels_timers() {
1300 let mut algo = create_twap_algorithm();
1301 register_algorithm(&mut algo);
1302
1303 add_instrument_to_cache(&algo);
1304
1305 let mut params = IndexMap::new();
1306 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1307 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1308
1309 let order = create_market_order_with_params(params);
1310 let primary_id = order.client_order_id();
1311
1312 algo.on_order(order).unwrap();
1313
1314 assert!(
1316 algo.clock()
1317 .timer_names()
1318 .iter()
1319 .any(|name| name.as_str() == primary_id.as_str())
1320 );
1321
1322 DataActor::on_stop(&mut algo).unwrap();
1324
1325 assert!(algo.clock().timer_names().is_empty());
1327 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 3);
1328 }
1329
1330 #[rstest]
1331 fn test_twap_on_resume_rearms_timer_without_submitting() {
1332 let mut algo = create_twap_algorithm();
1333 register_algorithm(&mut algo);
1334 add_instrument_to_cache(&algo);
1335
1336 let mut params = IndexMap::new();
1337 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1338 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1339
1340 let order = create_market_order_with_params(params);
1341 let primary_id = order.client_order_id();
1342 algo.on_order(order).unwrap();
1343 let order_count = algo
1344 .cache()
1345 .orders_total_count(None, None, None, None, None);
1346
1347 Component::stop(&mut algo).unwrap();
1348
1349 let received = Rc::new(RefCell::new(None::<SubmitOrder>));
1352 let handler = msgbus::TypedIntoHandler::from({
1353 let captured = received.clone();
1354 move |cmd: TradingCommand| {
1355 if let TradingCommand::SubmitOrder(cmd) = cmd {
1356 *captured.borrow_mut() = Some(cmd);
1357 }
1358 }
1359 });
1360 msgbus::register_trading_command_endpoint(
1361 MessagingSwitchboard::risk_engine_queue_execute(),
1362 handler,
1363 );
1364
1365 let resume_time = algo.clock().timestamp_ns();
1366 Component::resume(&mut algo).unwrap();
1367
1368 assert!(received.borrow().is_none());
1369 assert_eq!(algo.clock().timer_count(), 1);
1370 assert_eq!(
1371 algo.clock().next_time_ns(primary_id.as_str()),
1372 Some(resume_time + DurationNanos::from_secs(20))
1373 );
1374 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 3);
1375 assert_eq!(
1376 algo.cache()
1377 .orders_total_count(None, None, None, None, None),
1378 order_count
1379 );
1380 }
1381
1382 #[rstest]
1383 fn test_twap_on_resume_executes_remaining_slices() {
1384 let mut algo = create_twap_algorithm();
1385 let clock = register_algorithm_with_clock(&mut algo);
1386 add_instrument_to_cache(&algo);
1387
1388 let mut params = IndexMap::new();
1389 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1390 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1391
1392 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1393 let primary_id = order.client_order_id();
1394 algo.on_order(order).unwrap();
1395
1396 Component::stop(&mut algo).unwrap();
1397 Component::resume(&mut algo).unwrap();
1398 assert_eq!(algo.clock().timer_count(), 1);
1399
1400 let first_events = clock.borrow_mut().advance_time(20_000_000_000.into(), true);
1401 assert_eq!(first_events.len(), 1);
1402 algo.handle_time_event(&first_events[0]);
1403 assert_eq!(algo.scheduled_orders[&primary_id].remaining_sizes.len(), 1);
1404
1405 let final_events = clock.borrow_mut().advance_time(40_000_000_000.into(), true);
1406 assert_eq!(final_events.len(), 1);
1407 algo.handle_time_event(&final_events[0]);
1408 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1409 }
1410
1411 #[rstest]
1412 fn test_twap_on_resume_completes_closed_primary() {
1413 let mut algo = create_twap_algorithm();
1414 register_algorithm(&mut algo);
1415 add_instrument_to_cache(&algo);
1416
1417 let mut params = IndexMap::new();
1418 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1419 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1420
1421 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1422 let primary_id = order.client_order_id();
1423 algo.on_order(order).unwrap();
1424 Component::stop(&mut algo).unwrap();
1425
1426 {
1427 let cache_rc = algo.core.cache_rc();
1428 let mut cache = cache_rc.borrow_mut();
1429 let primary = cache.order(&primary_id).map(|order| order.clone()).unwrap();
1430 let canceled = OrderCanceledSpec::builder()
1431 .trader_id(primary.trader_id())
1432 .strategy_id(primary.strategy_id())
1433 .instrument_id(primary.instrument_id())
1434 .client_order_id(primary.client_order_id())
1435 .build();
1436 cache
1437 .update_order(&OrderEventAny::Canceled(canceled))
1438 .unwrap();
1439 }
1440
1441 Component::resume(&mut algo).unwrap();
1442
1443 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1444 assert_eq!(algo.clock().timer_count(), 0);
1445 }
1446
1447 #[rstest]
1448 fn test_twap_on_resume_completes_missing_primary() {
1449 let mut algo = create_twap_algorithm();
1450 register_algorithm(&mut algo);
1451 add_instrument_to_cache(&algo);
1452
1453 let mut params = IndexMap::new();
1454 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1455 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1456
1457 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1458 let primary_id = order.client_order_id();
1459 algo.on_order(order).unwrap();
1460 Component::stop(&mut algo).unwrap();
1461
1462 {
1463 let cache_rc = algo.core.cache_rc();
1464 let mut cache = cache_rc.borrow_mut();
1465 let primary = cache.order(&primary_id).map(|order| order.clone()).unwrap();
1466 let canceled = OrderCanceledSpec::builder()
1467 .trader_id(primary.trader_id())
1468 .strategy_id(primary.strategy_id())
1469 .instrument_id(primary.instrument_id())
1470 .client_order_id(primary.client_order_id())
1471 .build();
1472 cache
1473 .update_order(&OrderEventAny::Canceled(canceled))
1474 .unwrap();
1475 cache.purge_order(primary_id);
1476 }
1477
1478 assert!(algo.cache().order(&primary_id).is_none());
1479 Component::resume(&mut algo).unwrap();
1480
1481 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1482 assert_eq!(algo.clock().timer_count(), 0);
1483 }
1484
1485 #[rstest]
1486 fn test_twap_fractional_interval_secs() {
1487 let mut algo = create_twap_algorithm();
1488 register_algorithm(&mut algo);
1489
1490 add_instrument_to_cache(&algo);
1491
1492 let mut params = IndexMap::new();
1494 params.insert(Ustr::from("horizon_secs"), Ustr::from("3"));
1495 params.insert(Ustr::from("interval_secs"), Ustr::from("0.5"));
1496
1497 let order = create_market_order_with_params(params);
1498 let primary_id = order.client_order_id();
1499
1500 algo.on_order(order).unwrap();
1502
1503 let remaining = &algo
1505 .scheduled_orders
1506 .get(&primary_id)
1507 .unwrap()
1508 .remaining_sizes;
1509 assert!(remaining.len() >= 5);
1510 }
1511
1512 #[rstest]
1513 fn test_twap_submits_entire_size_when_qty_per_interval_below_size_increment() {
1514 use nautilus_model::instruments::{InstrumentAny, stubs::equity_aapl};
1515
1516 let mut algo = create_twap_algorithm();
1517 register_algorithm(&mut algo);
1518
1519 let instrument = equity_aapl();
1521 let instrument_id = instrument.id();
1522 {
1523 let cache_rc = algo.core.cache_rc();
1524 let mut cache = cache_rc.borrow_mut();
1525 cache
1526 .add_instrument(InstrumentAny::Equity(instrument))
1527 .unwrap();
1528 }
1529
1530 let mut params = IndexMap::new();
1533 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1534 params.insert(Ustr::from("interval_secs"), Ustr::from("10"));
1535
1536 let client_order_id = ClientOrderId::from("O-002");
1537 let order = OrderAny::Market(MarketOrder::new(
1538 TraderId::from("TRADER-001"),
1539 StrategyId::from("STRAT-001"),
1540 instrument_id,
1541 client_order_id,
1542 OrderSide::Buy,
1543 Quantity::from("2"),
1544 TimeInForce::Gtc,
1545 UUID4::new(),
1546 0.into(),
1547 false,
1548 false,
1549 None,
1550 None,
1551 None,
1552 None,
1553 Some(ExecAlgorithmId::new("TWAP")),
1554 Some(params),
1555 Some(client_order_id),
1556 None,
1557 ));
1558
1559 let primary_id = order.client_order_id();
1560
1561 let submit_params = Params::new(
1562 [(
1563 "routing_profile".to_string(),
1564 serde_json::Value::String("whole-size".to_string()),
1565 )]
1566 .into(),
1567 );
1568 algo.core
1569 .remember_submit_params(primary_id, Some(submit_params));
1570 algo.on_order(order).unwrap();
1571
1572 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1574 assert_eq!(algo.core.submit_params(&primary_id), None);
1575 }
1576
1577 #[rstest]
1578 fn test_twap_submits_entire_size_when_qty_per_interval_below_min_quantity() {
1579 use nautilus_model::instruments::{InstrumentAny, stubs::crypto_perpetual_ethusdt};
1580
1581 let mut algo = create_twap_algorithm();
1582 register_algorithm(&mut algo);
1583
1584 let mut instrument = crypto_perpetual_ethusdt();
1585 instrument.min_quantity = Some(Quantity::from("0.5"));
1586 {
1587 let cache_rc = algo.core.cache_rc();
1588 let mut cache = cache_rc.borrow_mut();
1589 cache
1590 .add_instrument(InstrumentAny::CryptoPerpetual(instrument))
1591 .unwrap();
1592 }
1593
1594 let mut params = IndexMap::new();
1595 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1596 params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
1597
1598 let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
1599 let primary_id = order.client_order_id();
1600
1601 let submit_params = Params::new(
1602 [(
1603 "routing_profile".to_string(),
1604 serde_json::Value::String("minimum-quantity".to_string()),
1605 )]
1606 .into(),
1607 );
1608 algo.core
1609 .remember_submit_params(primary_id, Some(submit_params));
1610
1611 algo.on_order(order).unwrap();
1612
1613 assert!(algo.scheduled_orders.get(&primary_id).is_none());
1614 assert_eq!(algo.core.submit_params(&primary_id), None);
1615 }
1616
1617 #[rstest]
1618 fn test_twap_rejects_negative_interval_secs() {
1619 let mut algo = create_twap_algorithm();
1620 register_algorithm(&mut algo);
1621
1622 add_instrument_to_cache(&algo);
1623
1624 let mut params = IndexMap::new();
1625 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1626 params.insert(Ustr::from("interval_secs"), Ustr::from("-0.5"));
1627
1628 let order = create_market_order_with_params(params);
1629 assert_twap_denied(
1630 &mut algo,
1631 &order,
1632 "VALIDATION_FAILED: interval_secs=-0.5 must be finite and positive",
1633 );
1634 }
1635
1636 #[rstest]
1637 fn test_twap_rejects_negative_horizon_secs() {
1638 let mut algo = create_twap_algorithm();
1639 register_algorithm(&mut algo);
1640
1641 add_instrument_to_cache(&algo);
1642
1643 let mut params = IndexMap::new();
1644 params.insert(Ustr::from("horizon_secs"), Ustr::from("-10"));
1645 params.insert(Ustr::from("interval_secs"), Ustr::from("1"));
1646
1647 let order = create_market_order_with_params(params);
1648 assert_twap_denied(
1649 &mut algo,
1650 &order,
1651 "VALIDATION_FAILED: horizon_secs=-10 must be finite and positive",
1652 );
1653 }
1654
1655 #[rstest]
1656 fn test_twap_rejects_zero_interval_secs() {
1657 let mut algo = create_twap_algorithm();
1658 register_algorithm(&mut algo);
1659
1660 add_instrument_to_cache(&algo);
1661
1662 let mut params = IndexMap::new();
1663 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1664 params.insert(Ustr::from("interval_secs"), Ustr::from("0"));
1665
1666 let order = create_market_order_with_params(params);
1667 assert_twap_denied(
1668 &mut algo,
1669 &order,
1670 "VALIDATION_FAILED: interval_secs=0 must be finite and positive",
1671 );
1672 }
1673
1674 #[rstest]
1675 fn test_twap_rejects_huge_finite_interval_before_submission() {
1676 let mut algo = create_twap_algorithm();
1677 register_algorithm(&mut algo);
1678
1679 add_instrument_to_cache(&algo);
1680
1681 let received = Rc::new(RefCell::new(None::<SubmitOrder>));
1682 let handler = msgbus::TypedIntoHandler::from({
1683 let captured = received.clone();
1684 move |cmd: TradingCommand| {
1685 if let TradingCommand::SubmitOrder(cmd) = cmd {
1686 *captured.borrow_mut() = Some(cmd);
1687 }
1688 }
1689 });
1690 msgbus::register_trading_command_endpoint(
1691 MessagingSwitchboard::risk_engine_queue_execute(),
1692 handler,
1693 );
1694
1695 let mut params = IndexMap::new();
1696 params.insert(Ustr::from("horizon_secs"), Ustr::from("2e20"));
1697 params.insert(Ustr::from("interval_secs"), Ustr::from("1e20"));
1698
1699 let duration_error = Duration::try_from_secs_f64(1e20).unwrap_err();
1700 let reason = OrderDeniedReason::ValidationFailed {
1701 detail: format!(
1702 "interval_secs=100000000000000000000 is not a valid duration: {duration_error}"
1703 ),
1704 }
1705 .to_string();
1706 assert_twap_denied(&mut algo, &create_market_order_with_params(params), &reason);
1707
1708 assert!(received.borrow().is_none());
1709 }
1710
1711 #[rstest]
1712 fn test_twap_rejects_subnanosecond_interval_before_submission() {
1713 let mut algo = create_twap_algorithm();
1714 register_algorithm(&mut algo);
1715
1716 add_instrument_to_cache(&algo);
1717
1718 let received = Rc::new(RefCell::new(None::<SubmitOrder>));
1719 let handler = msgbus::TypedIntoHandler::from({
1720 let captured = received.clone();
1721 move |cmd: TradingCommand| {
1722 if let TradingCommand::SubmitOrder(cmd) = cmd {
1723 *captured.borrow_mut() = Some(cmd);
1724 }
1725 }
1726 });
1727 msgbus::register_trading_command_endpoint(
1728 MessagingSwitchboard::risk_engine_queue_execute(),
1729 handler,
1730 );
1731
1732 let mut params = IndexMap::new();
1733 params.insert(Ustr::from("horizon_secs"), Ustr::from("2e-10"));
1734 params.insert(Ustr::from("interval_secs"), Ustr::from("1e-10"));
1735
1736 assert_twap_denied(
1737 &mut algo,
1738 &create_market_order_with_params(params),
1739 "VALIDATION_FAILED: interval_secs=0.0000000001 rounds to a zero duration",
1740 );
1741
1742 assert!(received.borrow().is_none());
1743 }
1744
1745 #[rstest]
1746 fn test_twap_rejects_interval_exceeding_timestamp_headroom_before_submission() {
1747 let mut algo = create_twap_algorithm();
1748 register_algorithm(&mut algo);
1749
1750 add_instrument_to_cache(&algo);
1751 DataActorNative::clock_mut(&mut algo)
1752 .as_any_mut()
1753 .downcast_mut::<VirtualClock>()
1754 .unwrap()
1755 .set_time(UnixNanos::new(u64::MAX - 500_000_000));
1756
1757 let received = Rc::new(RefCell::new(None::<SubmitOrder>));
1758 let handler = msgbus::TypedIntoHandler::from({
1759 let captured = received.clone();
1760 move |cmd: TradingCommand| {
1761 if let TradingCommand::SubmitOrder(cmd) = cmd {
1762 *captured.borrow_mut() = Some(cmd);
1763 }
1764 }
1765 });
1766 msgbus::register_trading_command_endpoint(
1767 MessagingSwitchboard::risk_engine_queue_execute(),
1768 handler,
1769 );
1770
1771 let mut params = IndexMap::new();
1772 params.insert(Ustr::from("horizon_secs"), Ustr::from("2"));
1773 params.insert(Ustr::from("interval_secs"), Ustr::from("1"));
1774
1775 assert_twap_denied(
1776 &mut algo,
1777 &create_market_order_with_params(params),
1778 "VALIDATION_FAILED: interval_secs=1 exceeds the clock timestamp headroom",
1779 );
1780
1781 assert!(received.borrow().is_none());
1782 }
1783
1784 #[rstest]
1785 fn test_twap_rejects_nan_interval_secs() {
1786 let mut algo = create_twap_algorithm();
1787 register_algorithm(&mut algo);
1788
1789 add_instrument_to_cache(&algo);
1790
1791 let mut params = IndexMap::new();
1792 params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
1793 params.insert(Ustr::from("interval_secs"), Ustr::from("NaN"));
1794
1795 let order = create_market_order_with_params(params);
1796 assert_twap_denied(
1797 &mut algo,
1798 &order,
1799 "VALIDATION_FAILED: interval_secs=NaN must be finite and positive",
1800 );
1801 }
1802
1803 #[rstest]
1804 fn test_twap_rejects_infinity_horizon_secs() {
1805 let mut algo = create_twap_algorithm();
1806 register_algorithm(&mut algo);
1807
1808 add_instrument_to_cache(&algo);
1809
1810 let mut params = IndexMap::new();
1811 params.insert(Ustr::from("horizon_secs"), Ustr::from("inf"));
1812 params.insert(Ustr::from("interval_secs"), Ustr::from("10"));
1813
1814 let order = create_market_order_with_params(params);
1815 assert_twap_denied(
1816 &mut algo,
1817 &order,
1818 "VALIDATION_FAILED: horizon_secs=inf must be finite and positive",
1819 );
1820 }
1821}