Skip to main content

nautilus_trading/algorithm/
twap.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
16//! Time-Weighted Average Price (TWAP) execution algorithm.
17//!
18//! The TWAP algorithm executes orders by evenly spreading them over a specified
19//! time horizon at regular intervals. This helps reduce market impact by avoiding
20//! concentration of trade size at any given time.
21//!
22//! # Parameters
23//!
24//! Orders submitted to this algorithm must include `exec_algorithm_params` with:
25//! - `horizon_secs`: Total execution horizon in seconds.
26//! - `interval_secs`: Interval between child orders in seconds.
27//!
28//! # Example
29//!
30//! An order with `horizon_secs=60` and `interval_secs=10` will spawn 6 child
31//! orders over 60 seconds, one every 10 seconds.
32
33use std::time::Duration;
34
35use ahash::AHashMap;
36use nautilus_common::{actor::DataActor, nautilus_actor, timer::TimeEvent};
37use nautilus_model::{
38    enums::OrderType,
39    identifiers::ClientOrderId,
40    instruments::Instrument,
41    orders::{Order, OrderAny},
42    types::{Quantity, quantity::QuantityRaw},
43};
44use ustr::Ustr;
45
46use super::{ExecutionAlgorithm, ExecutionAlgorithmConfig, ExecutionAlgorithmCore};
47
48/// Configuration for [`TwapAlgorithm`].
49pub type TwapAlgorithmConfig = ExecutionAlgorithmConfig;
50
51/// Time-Weighted Average Price (TWAP) execution algorithm.
52///
53/// Executes orders by evenly spreading them over a specified time horizon,
54/// at regular intervals. The algorithm receives a primary order and spawns
55/// smaller child orders that are executed at regular intervals.
56#[derive(Debug)]
57pub struct TwapAlgorithm {
58    /// The algorithm core.
59    pub core: ExecutionAlgorithmCore,
60    /// Scheduled sizes for each primary order.
61    scheduled_sizes: AHashMap<ClientOrderId, Vec<Quantity>>,
62}
63
64impl TwapAlgorithm {
65    /// Creates a new [`TwapAlgorithm`] instance.
66    #[must_use]
67    pub fn new(config: TwapAlgorithmConfig) -> Self {
68        Self {
69            core: ExecutionAlgorithmCore::new(config),
70            scheduled_sizes: AHashMap::new(),
71        }
72    }
73
74    /// Completes the execution sequence for a primary order.
75    fn complete_sequence(&mut self, primary_id: &ClientOrderId) {
76        let timer_name = primary_id.as_str();
77        if self.core.clock().timer_names().contains(&timer_name) {
78            self.core.clock().cancel_timer(timer_name);
79        }
80        self.scheduled_sizes.remove(primary_id);
81        log::info!("Completed TWAP execution for {primary_id}");
82    }
83}
84
85impl DataActor for TwapAlgorithm {}
86
87nautilus_actor!(TwapAlgorithm);
88
89impl ExecutionAlgorithm for TwapAlgorithm {
90    fn core_mut(&mut self) -> &mut ExecutionAlgorithmCore {
91        &mut self.core
92    }
93
94    fn on_order(&mut self, order: OrderAny) -> anyhow::Result<()> {
95        let primary_id = order.client_order_id();
96
97        if self.scheduled_sizes.contains_key(&primary_id) {
98            anyhow::bail!("Order {primary_id} already being executed");
99        }
100
101        log::info!("Received order for TWAP execution: {order:?}");
102
103        // Only market orders supported
104        if order.order_type() != OrderType::Market {
105            log::error!(
106                "Cannot execute order: only implemented for market orders, order_type={:?}",
107                order.order_type()
108            );
109            return Ok(());
110        }
111
112        let instrument = {
113            let cache = self.core.cache();
114            cache.instrument(&order.instrument_id()).cloned()
115        };
116
117        let Some(instrument) = instrument else {
118            log::error!(
119                "Cannot execute order: instrument {} not found",
120                order.instrument_id()
121            );
122            return Ok(());
123        };
124
125        let Some(exec_params) = order.exec_algorithm_params() else {
126            log::error!(
127                "Cannot execute order: exec_algorithm_params not found for primary order {primary_id}"
128            );
129            return Ok(());
130        };
131
132        let Some(horizon_secs_str) = exec_params.get(&Ustr::from("horizon_secs")) else {
133            log::error!("Cannot execute order: horizon_secs not found in exec_algorithm_params");
134            return Ok(());
135        };
136
137        let horizon_secs: f64 = horizon_secs_str.parse().map_err(|e| {
138            log::error!("Cannot parse horizon_secs: {e}");
139            anyhow::anyhow!("Invalid horizon_secs")
140        })?;
141
142        let Some(interval_secs_str) = exec_params.get(&Ustr::from("interval_secs")) else {
143            log::error!("Cannot execute order: interval_secs not found in exec_algorithm_params");
144            return Ok(());
145        };
146
147        let interval_secs: f64 = interval_secs_str.parse().map_err(|e| {
148            log::error!("Cannot parse interval_secs: {e}");
149            anyhow::anyhow!("Invalid interval_secs")
150        })?;
151
152        if !horizon_secs.is_finite() || horizon_secs <= 0.0 {
153            log::error!(
154                "Cannot execute order: horizon_secs={horizon_secs} must be finite and positive"
155            );
156            return Ok(());
157        }
158
159        if !interval_secs.is_finite() || interval_secs <= 0.0 {
160            log::error!(
161                "Cannot execute order: interval_secs={interval_secs} must be finite and positive"
162            );
163            return Ok(());
164        }
165
166        if horizon_secs < interval_secs {
167            log::error!(
168                "Cannot execute order: horizon_secs={horizon_secs} was less than interval_secs={interval_secs}"
169            );
170            return Ok(());
171        }
172
173        let num_intervals = (horizon_secs / interval_secs).floor() as u64;
174        if num_intervals == 0 {
175            log::error!("Cannot execute order: num_intervals is 0");
176            return Ok(());
177        }
178
179        let total_qty = order.quantity();
180        let total_raw = total_qty.raw;
181        let precision = total_qty.precision;
182
183        let qty_per_interval_raw = total_raw / (num_intervals as QuantityRaw);
184        let qty_per_interval = Quantity::from_raw(qty_per_interval_raw, precision);
185
186        if qty_per_interval == total_qty || qty_per_interval < instrument.size_increment() {
187            log::warn!(
188                "Submitting for entire size: qty_per_interval={qty_per_interval}, order_quantity={total_qty}"
189            );
190            self.submit_order(order, None, None)?;
191            return Ok(());
192        }
193
194        if let Some(min_qty) = instrument.min_quantity()
195            && qty_per_interval < min_qty
196        {
197            log::warn!(
198                "Submitting for entire size: qty_per_interval={qty_per_interval} < min_quantity={min_qty}"
199            );
200            self.submit_order(order, None, None)?;
201            return Ok(());
202        }
203
204        let mut scheduled_sizes: Vec<Quantity> = vec![qty_per_interval; num_intervals as usize];
205
206        // Remainder goes in the last slice
207        let scheduled_total = qty_per_interval_raw * (num_intervals as QuantityRaw);
208        let remainder_raw = total_raw - scheduled_total;
209        if remainder_raw > 0 {
210            let remainder = Quantity::from_raw(remainder_raw, total_qty.precision);
211            scheduled_sizes.push(remainder);
212        }
213
214        log::info!("Order execution size schedule: {scheduled_sizes:?}");
215
216        // Add primary order to cache so on_time_event can retrieve it
217        {
218            let cache_rc = self.core.cache_rc();
219            let mut cache = cache_rc.borrow_mut();
220            cache.add_order(order.clone(), None, None, false)?;
221        }
222
223        self.scheduled_sizes
224            .insert(primary_id, scheduled_sizes.clone());
225
226        let first_qty = self.scheduled_sizes.get_mut(&primary_id).unwrap().remove(0);
227        let is_single_slice = self
228            .scheduled_sizes
229            .get(&primary_id)
230            .is_some_and(|s| s.is_empty());
231
232        // Single slice: submit the primary order directly
233        if is_single_slice {
234            self.submit_order(order, None, None)?;
235            self.complete_sequence(&primary_id);
236            return Ok(());
237        }
238
239        // Multiple slices: spawn first child order and reduce primary
240        let tags = order.tags().map(|t| t.to_vec());
241        let time_in_force = order.time_in_force();
242        let reduce_only = order.is_reduce_only();
243        let mut order = order;
244        let spawned = self.spawn_market(
245            &mut order,
246            first_qty,
247            time_in_force,
248            reduce_only,
249            tags,
250            true,
251        );
252        self.submit_order(spawned.into(), None, None)?;
253
254        self.core.clock().set_timer(
255            primary_id.as_str(),
256            Duration::from_secs_f64(interval_secs),
257            None,
258            None,
259            None,
260            None,
261            None,
262        )?;
263
264        log::info!(
265            "Started TWAP execution for {primary_id}: horizon_secs={horizon_secs}, interval_secs={interval_secs}"
266        );
267
268        Ok(())
269    }
270
271    fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
272        log::info!("Received time event: {event:?}");
273
274        let primary_id = ClientOrderId::new(event.name.as_str());
275
276        let primary = {
277            let cache = self.core.cache();
278            cache.order(&primary_id).map(|o| o.clone())
279        };
280
281        let Some(primary) = primary else {
282            log::error!("Cannot find primary order for exec_spawn_id={primary_id}");
283            return Ok(());
284        };
285
286        if primary.is_closed() {
287            self.complete_sequence(&primary_id);
288            return Ok(());
289        }
290
291        let Some(scheduled_sizes) = self.scheduled_sizes.get_mut(&primary_id) else {
292            log::error!("Cannot find scheduled sizes for exec_spawn_id={primary_id}");
293            return Ok(());
294        };
295
296        if scheduled_sizes.is_empty() {
297            log::warn!("No more size to execute for exec_spawn_id={primary_id}");
298            return Ok(());
299        }
300
301        let quantity = scheduled_sizes.remove(0);
302        let is_final_slice = scheduled_sizes.is_empty();
303
304        // Final slice: submit the primary order (already reduced to remaining quantity)
305        if is_final_slice {
306            self.submit_order(primary, None, None)?;
307            self.complete_sequence(&primary_id);
308            return Ok(());
309        }
310
311        // Intermediate slice: spawn child order and reduce primary
312        let tags = primary.tags().map(|t| t.to_vec());
313        let time_in_force = primary.time_in_force();
314        let reduce_only = primary.is_reduce_only();
315        let mut primary = primary;
316        let spawned = self.spawn_market(
317            &mut primary,
318            quantity,
319            time_in_force,
320            reduce_only,
321            tags,
322            true,
323        );
324        self.submit_order(spawned.into(), None, None)?;
325
326        Ok(())
327    }
328
329    fn on_stop(&mut self) -> anyhow::Result<()> {
330        self.core.clock().cancel_timers();
331        Ok(())
332    }
333
334    fn on_reset(&mut self) -> anyhow::Result<()> {
335        self.unsubscribe_all_strategy_events();
336        self.core.reset();
337        self.scheduled_sizes.clear();
338        Ok(())
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use std::{cell::RefCell, rc::Rc};
345
346    use indexmap::IndexMap;
347    use nautilus_common::{
348        cache::Cache,
349        clock::{Clock, TestClock},
350        component::Component,
351        enums::ComponentTrigger,
352    };
353    use nautilus_core::UUID4;
354    use nautilus_model::{
355        enums::{OrderSide, TimeInForce},
356        events::OrderEventAny,
357        identifiers::{ExecAlgorithmId, InstrumentId, StrategyId, TraderId},
358        orders::{LimitOrder, MarketOrder},
359        types::Price,
360    };
361    use rstest::rstest;
362    use ustr::Ustr;
363
364    use super::*;
365
366    fn create_twap_algorithm() -> TwapAlgorithm {
367        // Use unique ID to avoid thread-local registry/msgbus conflicts in parallel tests
368        let unique_id = format!("TWAP-{}", UUID4::new());
369        let config = TwapAlgorithmConfig {
370            exec_algorithm_id: Some(ExecAlgorithmId::new(&unique_id)),
371            ..Default::default()
372        };
373        TwapAlgorithm::new(config)
374    }
375
376    fn register_algorithm(algo: &mut TwapAlgorithm) {
377        use nautilus_common::timer::TimeEventCallback;
378
379        let trader_id = TraderId::from("TRADER-001");
380        let clock = Rc::new(RefCell::new(TestClock::new()));
381        let cache = Rc::new(RefCell::new(Cache::default()));
382
383        // Register a no-op default handler for timer callbacks
384        clock
385            .borrow_mut()
386            .register_default_handler(TimeEventCallback::Rust(std::sync::Arc::new(|_| {})));
387
388        algo.core.register(trader_id, clock, cache).unwrap();
389
390        // Transition to Running state for tests
391        algo.transition_state(ComponentTrigger::Initialize).unwrap();
392        algo.transition_state(ComponentTrigger::Start).unwrap();
393        algo.transition_state(ComponentTrigger::StartCompleted)
394            .unwrap();
395    }
396
397    fn add_instrument_to_cache(algo: &TwapAlgorithm) {
398        use nautilus_model::instruments::{InstrumentAny, stubs::crypto_perpetual_ethusdt};
399
400        let instrument = crypto_perpetual_ethusdt();
401        let cache_rc = algo.core.cache_rc();
402        let mut cache = cache_rc.borrow_mut();
403        cache
404            .add_instrument(InstrumentAny::CryptoPerpetual(instrument))
405            .unwrap();
406    }
407
408    fn create_market_order_with_params(params: IndexMap<Ustr, Ustr>) -> OrderAny {
409        create_market_order_with_params_and_qty(params, Quantity::from("1.0"))
410    }
411
412    fn create_market_order_with_params_and_qty(
413        params: IndexMap<Ustr, Ustr>,
414        quantity: Quantity,
415    ) -> OrderAny {
416        OrderAny::Market(MarketOrder::new(
417            TraderId::from("TRADER-001"),
418            StrategyId::from("STRAT-001"),
419            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
420            ClientOrderId::from("O-001"),
421            OrderSide::Buy,
422            quantity,
423            TimeInForce::Gtc,
424            UUID4::new(),
425            0.into(),
426            false,
427            false,
428            None,
429            None,
430            None,
431            None,
432            Some(ExecAlgorithmId::new("TWAP")),
433            Some(params),
434            None,
435            None,
436        ))
437    }
438
439    #[rstest]
440    fn test_twap_creation() {
441        let algo = create_twap_algorithm();
442        assert!(algo.core.exec_algorithm_id.inner().starts_with("TWAP"));
443        assert!(algo.scheduled_sizes.is_empty());
444    }
445
446    #[rstest]
447    fn test_twap_registration() {
448        let mut algo = create_twap_algorithm();
449        register_algorithm(&mut algo);
450
451        assert!(algo.core.trader_id().is_some());
452    }
453
454    #[rstest]
455    fn test_twap_reset_clears_scheduled_sizes() {
456        let mut algo = create_twap_algorithm();
457        let primary_id = ClientOrderId::new("O-001");
458
459        algo.scheduled_sizes
460            .insert(primary_id, vec![Quantity::from("1.0")]);
461
462        assert!(!algo.scheduled_sizes.is_empty());
463
464        ExecutionAlgorithm::on_reset(&mut algo).unwrap();
465
466        assert!(algo.scheduled_sizes.is_empty());
467    }
468
469    #[rstest]
470    fn test_twap_rejects_non_market_orders() {
471        let mut algo = create_twap_algorithm();
472        register_algorithm(&mut algo);
473
474        let order = OrderAny::Limit(LimitOrder::new(
475            TraderId::from("TRADER-001"),
476            StrategyId::from("STRAT-001"),
477            InstrumentId::from("BTC/USDT.BINANCE"),
478            ClientOrderId::from("O-001"),
479            OrderSide::Buy,
480            Quantity::from("1.0"),
481            Price::from("50000.0"),
482            TimeInForce::Gtc,
483            None,  // expire_time
484            false, // post_only
485            false, // reduce_only
486            false, // quote_quantity
487            None,  // display_qty
488            None,  // emulation_trigger
489            None,  // trigger_instrument_id
490            None,  // contingency_type
491            None,  // order_list_id
492            None,  // linked_order_ids
493            None,  // parent_order_id
494            None,  // exec_algorithm_id
495            None,  // exec_algorithm_params
496            None,  // exec_spawn_id
497            None,  // tags
498            UUID4::new(),
499            0.into(),
500        ));
501
502        // Should not error, just log and return
503        let result = algo.on_order(order);
504        assert!(result.is_ok());
505    }
506
507    #[rstest]
508    fn test_twap_rejects_missing_params() {
509        let mut algo = create_twap_algorithm();
510        register_algorithm(&mut algo);
511
512        let order = OrderAny::Market(MarketOrder::new(
513            TraderId::from("TRADER-001"),
514            StrategyId::from("STRAT-001"),
515            InstrumentId::from("BTC/USDT.BINANCE"),
516            ClientOrderId::from("O-001"),
517            OrderSide::Buy,
518            Quantity::from("1.0"),
519            TimeInForce::Gtc,
520            UUID4::new(),
521            0.into(),
522            false,
523            false,
524            None,
525            None,
526            None,
527            None,
528            None,
529            None, // No exec_algorithm_params
530            None,
531            None,
532        ));
533
534        // Should not error, just log and return
535        let result = algo.on_order(order);
536        assert!(result.is_ok());
537    }
538
539    #[rstest]
540    fn test_twap_rejects_horizon_less_than_interval() {
541        let mut algo = create_twap_algorithm();
542        register_algorithm(&mut algo);
543
544        add_instrument_to_cache(&algo);
545
546        let mut params = IndexMap::new();
547        params.insert(Ustr::from("horizon_secs"), Ustr::from("30"));
548        params.insert(Ustr::from("interval_secs"), Ustr::from("60"));
549
550        let order = create_market_order_with_params(params);
551        let result = algo.on_order(order);
552
553        assert!(result.is_ok());
554        assert!(algo.scheduled_sizes.is_empty());
555    }
556
557    #[rstest]
558    fn test_twap_rejects_duplicate_order() {
559        let mut algo = create_twap_algorithm();
560        register_algorithm(&mut algo);
561
562        add_instrument_to_cache(&algo);
563
564        let mut params = IndexMap::new();
565        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
566        params.insert(Ustr::from("interval_secs"), Ustr::from("10"));
567
568        let order1 = create_market_order_with_params(params.clone());
569        let order2 = create_market_order_with_params(params);
570
571        algo.on_order(order1).unwrap();
572        let result = algo.on_order(order2);
573
574        assert!(result.is_err());
575        assert!(
576            result
577                .unwrap_err()
578                .to_string()
579                .contains("already being executed")
580        );
581    }
582
583    #[rstest]
584    fn test_twap_calculates_size_schedule_evenly() {
585        let mut algo = create_twap_algorithm();
586        register_algorithm(&mut algo);
587
588        add_instrument_to_cache(&algo);
589
590        // 1.2 qty over 60s with 20s intervals = 3 intervals of 0.4 each (divides evenly)
591        let mut params = IndexMap::new();
592        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
593        params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
594
595        let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
596        let primary_id = order.client_order_id();
597
598        algo.on_order(order).unwrap();
599
600        // First slice spawned immediately, remaining 2 slices scheduled (no remainder)
601        let remaining = algo.scheduled_sizes.get(&primary_id).unwrap();
602        assert_eq!(remaining.len(), 2);
603
604        for qty in remaining {
605            assert_eq!(*qty, Quantity::from("0.4"));
606        }
607    }
608
609    #[rstest]
610    fn test_twap_calculates_size_schedule_with_remainder() {
611        let mut algo = create_twap_algorithm();
612        register_algorithm(&mut algo);
613
614        add_instrument_to_cache(&algo);
615
616        // 1.0 qty over 60s with 20s intervals = 3 intervals
617        // Raw is scaled to FIXED_PRECISION: 9 (standard) or 16 (high-precision)
618        let mut params = IndexMap::new();
619        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
620        params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
621
622        let order = create_market_order_with_params(params);
623        let primary_id = order.client_order_id();
624
625        algo.on_order(order).unwrap();
626
627        // First slice spawned, 3 remaining (2 regular + 1 remainder)
628        let remaining = algo.scheduled_sizes.get(&primary_id).unwrap();
629        assert_eq!(remaining.len(), 3);
630
631        // Expected raw values depend on FIXED_PRECISION
632        // Standard (9):  1_000_000_000 / 3 = 333_333_333, remainder = 1
633        // High (16): 10_000_000_000_000_000 / 3 = 3_333_333_333_333_333, remainder = 1
634        #[cfg(feature = "high-precision")]
635        {
636            assert_eq!(remaining[0].raw, 3_333_333_333_333_333);
637            assert_eq!(remaining[1].raw, 3_333_333_333_333_333);
638            assert_eq!(remaining[2].raw, 1);
639        }
640        #[cfg(not(feature = "high-precision"))]
641        {
642            assert_eq!(remaining[0].raw, 333_333_333);
643            assert_eq!(remaining[1].raw, 333_333_333);
644            assert_eq!(remaining[2].raw, 1);
645        }
646    }
647
648    #[rstest]
649    fn test_twap_on_time_event_spawns_next_slice() {
650        let mut algo = create_twap_algorithm();
651        register_algorithm(&mut algo);
652
653        add_instrument_to_cache(&algo);
654
655        // Use qty that divides evenly: 1.2 / 3 = 0.4 each
656        let mut params = IndexMap::new();
657        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
658        params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
659
660        let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
661        let primary_id = order.client_order_id();
662
663        algo.on_order(order).unwrap();
664
665        // Verify 2 slices remain after first spawn (no remainder)
666        assert_eq!(algo.scheduled_sizes.get(&primary_id).unwrap().len(), 2);
667
668        // Simulate timer firing
669        let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
670        ExecutionAlgorithm::on_time_event(&mut algo, &event).unwrap();
671
672        // One slice consumed
673        assert_eq!(algo.scheduled_sizes.get(&primary_id).unwrap().len(), 1);
674    }
675
676    #[rstest]
677    fn test_twap_on_time_event_completes_on_final_slice() {
678        let mut algo = create_twap_algorithm();
679        register_algorithm(&mut algo);
680
681        add_instrument_to_cache(&algo);
682
683        // 2 intervals: first spawned immediately, one in scheduled_sizes
684        let mut params = IndexMap::new();
685        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
686        params.insert(Ustr::from("interval_secs"), Ustr::from("30"));
687
688        let order = create_market_order_with_params(params);
689        let primary_id = order.client_order_id();
690
691        algo.on_order(order).unwrap();
692        assert_eq!(algo.scheduled_sizes.get(&primary_id).unwrap().len(), 1);
693
694        // Simulate timer firing for final slice
695        let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
696        ExecutionAlgorithm::on_time_event(&mut algo, &event).unwrap();
697
698        // Sequence completed, scheduled_sizes removed
699        assert!(algo.scheduled_sizes.get(&primary_id).is_none());
700    }
701
702    #[rstest]
703    fn test_twap_on_time_event_completes_when_primary_closed() {
704        use nautilus_model::events::OrderCanceled;
705
706        let mut algo = create_twap_algorithm();
707        register_algorithm(&mut algo);
708
709        add_instrument_to_cache(&algo);
710
711        let mut params = IndexMap::new();
712        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
713        params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
714
715        let order = create_market_order_with_params_and_qty(params, Quantity::from("1.2"));
716        let primary_id = order.client_order_id();
717
718        algo.on_order(order).unwrap();
719        assert_eq!(algo.scheduled_sizes.get(&primary_id).unwrap().len(), 2);
720
721        // Mark primary order as closed (canceled)
722        {
723            let cache_rc = algo.core.cache_rc();
724            let mut cache = cache_rc.borrow_mut();
725            let primary = cache.order(&primary_id).map(|o| o.clone()).unwrap();
726
727            let canceled = OrderCanceled::new(
728                primary.trader_id(),
729                primary.strategy_id(),
730                primary.instrument_id(),
731                primary.client_order_id(),
732                UUID4::new(),
733                0.into(),
734                0.into(),
735                false,
736                None,
737                None,
738            );
739            cache
740                .update_order(&OrderEventAny::Canceled(canceled))
741                .unwrap();
742        }
743
744        // Timer fires but primary is closed
745        let event = TimeEvent::new(primary_id.inner(), UUID4::new(), 0.into(), 0.into());
746        ExecutionAlgorithm::on_time_event(&mut algo, &event).unwrap();
747
748        // Sequence should complete early since primary is closed
749        assert!(algo.scheduled_sizes.get(&primary_id).is_none());
750    }
751
752    #[rstest]
753    fn test_twap_on_stop_cancels_timers() {
754        let mut algo = create_twap_algorithm();
755        register_algorithm(&mut algo);
756
757        add_instrument_to_cache(&algo);
758
759        let mut params = IndexMap::new();
760        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
761        params.insert(Ustr::from("interval_secs"), Ustr::from("20"));
762
763        let order = create_market_order_with_params(params);
764        let primary_id = order.client_order_id();
765
766        algo.on_order(order).unwrap();
767
768        // Verify timer is set
769        assert!(
770            algo.core
771                .clock()
772                .timer_names()
773                .contains(&primary_id.as_str())
774        );
775
776        // Stop the algorithm
777        ExecutionAlgorithm::on_stop(&mut algo).unwrap();
778
779        // Timer should be canceled
780        assert!(algo.core.clock().timer_names().is_empty());
781    }
782
783    #[rstest]
784    fn test_twap_fractional_interval_secs() {
785        let mut algo = create_twap_algorithm();
786        register_algorithm(&mut algo);
787
788        add_instrument_to_cache(&algo);
789
790        // Use fractional interval like Python tests: 3 second horizon, 0.5 second interval
791        let mut params = IndexMap::new();
792        params.insert(Ustr::from("horizon_secs"), Ustr::from("3"));
793        params.insert(Ustr::from("interval_secs"), Ustr::from("0.5"));
794
795        let order = create_market_order_with_params(params);
796        let primary_id = order.client_order_id();
797
798        // Should not error - fractional seconds should parse correctly
799        algo.on_order(order).unwrap();
800
801        // 3 / 0.5 = 6 intervals, first spawned immediately, 5 remaining (plus possible remainder)
802        let remaining = algo.scheduled_sizes.get(&primary_id).unwrap();
803        assert!(remaining.len() >= 5);
804    }
805
806    #[rstest]
807    fn test_twap_submits_entire_size_when_qty_per_interval_below_size_increment() {
808        use nautilus_model::instruments::{InstrumentAny, stubs::equity_aapl};
809
810        let mut algo = create_twap_algorithm();
811        register_algorithm(&mut algo);
812
813        // Use equity with size_increment of 1 (whole shares only)
814        let instrument = equity_aapl();
815        let instrument_id = instrument.id();
816        {
817            let cache_rc = algo.core.cache_rc();
818            let mut cache = cache_rc.borrow_mut();
819            cache
820                .add_instrument(InstrumentAny::Equity(instrument))
821                .unwrap();
822        }
823
824        // 2 shares over 60s with 10s intervals = 6 intervals
825        // 2 / 6 = 0.333... which is less than size_increment of 1
826        let mut params = IndexMap::new();
827        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
828        params.insert(Ustr::from("interval_secs"), Ustr::from("10"));
829
830        let order = OrderAny::Market(MarketOrder::new(
831            TraderId::from("TRADER-001"),
832            StrategyId::from("STRAT-001"),
833            instrument_id,
834            ClientOrderId::from("O-002"),
835            OrderSide::Buy,
836            Quantity::from("2"),
837            TimeInForce::Gtc,
838            UUID4::new(),
839            0.into(),
840            false,
841            false,
842            None,
843            None,
844            None,
845            None,
846            Some(ExecAlgorithmId::new("TWAP")),
847            Some(params),
848            None,
849            None,
850        ));
851
852        let primary_id = order.client_order_id();
853        algo.on_order(order).unwrap();
854
855        // Should submit entire size directly (no scheduling)
856        assert!(algo.scheduled_sizes.get(&primary_id).is_none());
857    }
858
859    #[rstest]
860    fn test_twap_rejects_negative_interval_secs() {
861        let mut algo = create_twap_algorithm();
862        register_algorithm(&mut algo);
863
864        add_instrument_to_cache(&algo);
865
866        let mut params = IndexMap::new();
867        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
868        params.insert(Ustr::from("interval_secs"), Ustr::from("-0.5"));
869
870        let order = create_market_order_with_params(params);
871
872        // Should not error but should reject the order (no scheduling)
873        let result = algo.on_order(order);
874        assert!(result.is_ok());
875        assert!(algo.scheduled_sizes.is_empty());
876    }
877
878    #[rstest]
879    fn test_twap_rejects_negative_horizon_secs() {
880        let mut algo = create_twap_algorithm();
881        register_algorithm(&mut algo);
882
883        add_instrument_to_cache(&algo);
884
885        let mut params = IndexMap::new();
886        params.insert(Ustr::from("horizon_secs"), Ustr::from("-10"));
887        params.insert(Ustr::from("interval_secs"), Ustr::from("1"));
888
889        let order = create_market_order_with_params(params);
890
891        // Should not error but should reject the order (no scheduling)
892        let result = algo.on_order(order);
893        assert!(result.is_ok());
894        assert!(algo.scheduled_sizes.is_empty());
895    }
896
897    #[rstest]
898    fn test_twap_rejects_zero_interval_secs() {
899        let mut algo = create_twap_algorithm();
900        register_algorithm(&mut algo);
901
902        add_instrument_to_cache(&algo);
903
904        let mut params = IndexMap::new();
905        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
906        params.insert(Ustr::from("interval_secs"), Ustr::from("0"));
907
908        let order = create_market_order_with_params(params);
909
910        // Should not error but should reject the order (no scheduling)
911        let result = algo.on_order(order);
912        assert!(result.is_ok());
913        assert!(algo.scheduled_sizes.is_empty());
914    }
915
916    #[rstest]
917    fn test_twap_rejects_nan_interval_secs() {
918        let mut algo = create_twap_algorithm();
919        register_algorithm(&mut algo);
920
921        add_instrument_to_cache(&algo);
922
923        let mut params = IndexMap::new();
924        params.insert(Ustr::from("horizon_secs"), Ustr::from("60"));
925        params.insert(Ustr::from("interval_secs"), Ustr::from("NaN"));
926
927        let order = create_market_order_with_params(params);
928
929        let result = algo.on_order(order);
930        assert!(result.is_ok());
931        assert!(algo.scheduled_sizes.is_empty());
932    }
933
934    #[rstest]
935    fn test_twap_rejects_infinity_horizon_secs() {
936        let mut algo = create_twap_algorithm();
937        register_algorithm(&mut algo);
938
939        add_instrument_to_cache(&algo);
940
941        let mut params = IndexMap::new();
942        params.insert(Ustr::from("horizon_secs"), Ustr::from("inf"));
943        params.insert(Ustr::from("interval_secs"), Ustr::from("10"));
944
945        let order = create_market_order_with_params(params);
946
947        let result = algo.on_order(order);
948        assert!(result.is_ok());
949        assert!(algo.scheduled_sizes.is_empty());
950    }
951}