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