tesser_execution/algorithm/
twap.rs

1//! Time-Weighted Average Price (TWAP) execution algorithm.
2
3use anyhow::{anyhow, Result};
4use chrono::{DateTime, Duration, Utc};
5use rust_decimal::{prelude::FromPrimitive, Decimal};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use super::{AlgoStatus, ChildOrderAction, ChildOrderRequest, ExecutionAlgorithm};
10use tesser_core::{Fill, Order, OrderRequest, OrderStatus, OrderType, Quantity, Signal, Tick};
11
12/// Persistent state for the TWAP algorithm.
13#[derive(Debug, Deserialize, Serialize)]
14struct TwapState {
15    id: Uuid,
16    parent_signal: Signal,
17    status: String,
18
19    start_time: DateTime<Utc>,
20    end_time: DateTime<Utc>,
21
22    total_quantity: Quantity,
23    filled_quantity: Quantity,
24
25    num_slices: u32,
26    executed_slices: u32,
27
28    next_slice_time: DateTime<Utc>,
29    slice_interval: Duration,
30}
31
32/// TWAP (Time-Weighted Average Price) execution algorithm.
33///
34/// This algorithm spreads an order over a specified time duration by breaking it
35/// into smaller slices executed at regular intervals. This helps minimize market
36/// impact and achieve a more representative average price.
37pub struct TwapAlgorithm {
38    state: TwapState,
39}
40
41impl TwapAlgorithm {
42    /// Create a new TWAP algorithm instance.
43    ///
44    /// # Arguments
45    /// * `signal` - The parent signal that triggered this algorithm
46    /// * `total_quantity` - Total quantity to be executed
47    /// * `duration` - Time period over which to spread the execution
48    /// * `num_slices` - Number of smaller orders to break the total into
49    pub fn new(
50        signal: Signal,
51        total_quantity: Quantity,
52        duration: Duration,
53        num_slices: u32,
54    ) -> Result<Self> {
55        if duration <= Duration::zero() || num_slices == 0 {
56            return Err(anyhow!("TWAP duration and slices must be positive"));
57        }
58
59        if total_quantity <= Decimal::ZERO {
60            return Err(anyhow!("TWAP total quantity must be positive"));
61        }
62
63        let now = Utc::now();
64        let slice_interval =
65            Duration::seconds((duration.num_seconds() as f64 / num_slices as f64).ceil() as i64);
66
67        Ok(Self {
68            state: TwapState {
69                id: Uuid::new_v4(),
70                parent_signal: signal,
71                status: "Working".to_string(),
72                start_time: now,
73                end_time: now + duration,
74                total_quantity,
75                filled_quantity: Decimal::ZERO,
76                num_slices,
77                executed_slices: 0,
78                next_slice_time: now,
79                slice_interval,
80            },
81        })
82    }
83
84    /// Check if we should execute the next slice based on current time.
85    fn should_execute_slice(&self) -> bool {
86        let now = Utc::now();
87        now >= self.state.next_slice_time
88            && self.state.executed_slices < self.state.num_slices
89            && now < self.state.end_time
90    }
91
92    /// Calculate the quantity for the next slice.
93    fn calculate_slice_quantity(&self) -> Quantity {
94        let remaining_qty = self.state.total_quantity - self.state.filled_quantity;
95        if remaining_qty <= Decimal::ZERO {
96            return Decimal::ZERO;
97        }
98
99        let remaining_slices = self.state.num_slices - self.state.executed_slices;
100        if remaining_slices == 0 {
101            return Decimal::ZERO;
102        }
103
104        let divisor = Decimal::from_u32(remaining_slices).unwrap_or(Decimal::ONE);
105        if divisor.is_zero() {
106            Decimal::ZERO
107        } else {
108            remaining_qty / divisor
109        }
110    }
111
112    /// Generate a child order request for the next slice.
113    fn create_slice_order(&self, slice_qty: Quantity) -> ChildOrderRequest {
114        ChildOrderRequest {
115            parent_algo_id: self.state.id,
116            action: ChildOrderAction::Place(OrderRequest {
117                symbol: self.state.parent_signal.symbol,
118                side: self.state.parent_signal.kind.side(),
119                order_type: OrderType::Market,
120                quantity: slice_qty,
121                price: None,
122                trigger_price: None,
123                time_in_force: None,
124                client_order_id: Some(format!(
125                    "twap-{}-slice-{}",
126                    self.state.id,
127                    self.state.executed_slices + 1
128                )),
129                take_profit: None,
130                stop_loss: None,
131                display_quantity: None,
132            }),
133        }
134    }
135
136    /// Check if the algorithm should complete based on time or quantity.
137    fn check_completion(&mut self) {
138        let now = Utc::now();
139
140        if now >= self.state.end_time {
141            self.state.status = "Completed".to_string();
142            tracing::info!(
143                id = %self.state.id,
144                "TWAP completed due to reaching end time"
145            );
146        } else if self.state.filled_quantity >= self.state.total_quantity {
147            self.state.status = "Completed".to_string();
148            tracing::info!(
149                id = %self.state.id,
150                filled = %self.state.filled_quantity,
151                total = %self.state.total_quantity,
152                "TWAP completed due to reaching total quantity"
153            );
154        }
155    }
156
157    fn slice_from_client_id(&self, client_id: &str) -> Option<u32> {
158        let rest = client_id.strip_prefix("twap-")?;
159        let (id_part, slice_part) = rest.split_once("-slice-")?;
160        if id_part != self.state.id.to_string() {
161            return None;
162        }
163        slice_part.parse().ok()
164    }
165
166    fn is_active_status(status: OrderStatus) -> bool {
167        matches!(
168            status,
169            OrderStatus::PendingNew | OrderStatus::Accepted | OrderStatus::PartiallyFilled
170        )
171    }
172}
173
174impl ExecutionAlgorithm for TwapAlgorithm {
175    fn kind(&self) -> &'static str {
176        "TWAP"
177    }
178
179    fn id(&self) -> &Uuid {
180        &self.state.id
181    }
182
183    fn status(&self) -> AlgoStatus {
184        match self.state.status.as_str() {
185            "Working" => AlgoStatus::Working,
186            "Completed" => AlgoStatus::Completed,
187            "Cancelled" => AlgoStatus::Cancelled,
188            "Failed" => AlgoStatus::Failed("Generic failure".to_string()),
189            _ => AlgoStatus::Failed("Unknown state".to_string()),
190        }
191    }
192
193    fn start(&mut self) -> Result<Vec<ChildOrderRequest>> {
194        // TWAP starts by waiting for the first timer tick, so no initial orders
195        tracing::info!(
196            id = %self.state.id,
197            duration_secs = self.state.end_time.signed_duration_since(self.state.start_time).num_seconds(),
198            slices = self.state.num_slices,
199            total_qty = %self.state.total_quantity,
200            "TWAP algorithm started"
201        );
202        Ok(vec![])
203    }
204
205    fn on_child_order_placed(&mut self, order: &Order) {
206        tracing::debug!(
207            id = %self.state.id,
208            order_id = %order.id,
209            qty = %order.request.quantity,
210            "TWAP child order placed"
211        );
212        // For a simple TWAP, we don't need special handling when orders are placed
213        // A more sophisticated version could track in-flight orders
214    }
215
216    fn on_fill(&mut self, fill: &Fill) -> Result<Vec<ChildOrderRequest>> {
217        tracing::debug!(
218            id = %self.state.id,
219            fill_qty = %fill.fill_quantity,
220            fill_price = %fill.fill_price,
221            "TWAP received fill"
222        );
223
224        self.state.filled_quantity += fill.fill_quantity;
225        self.check_completion();
226
227        Ok(vec![])
228    }
229
230    fn on_tick(&mut self, _tick: &Tick) -> Result<Vec<ChildOrderRequest>> {
231        // TWAP is time-driven, not tick-driven
232        Ok(vec![])
233    }
234
235    fn on_timer(&mut self) -> Result<Vec<ChildOrderRequest>> {
236        // Check if algorithm should complete
237        self.check_completion();
238
239        if !matches!(self.status(), AlgoStatus::Working) {
240            return Ok(vec![]);
241        }
242
243        // Check if we should execute a slice
244        if !self.should_execute_slice() {
245            return Ok(vec![]);
246        }
247
248        let slice_qty = self.calculate_slice_quantity();
249        if slice_qty <= Decimal::ZERO {
250            return Ok(vec![]);
251        }
252
253        // Update state for the new slice
254        self.state.executed_slices += 1;
255        self.state.next_slice_time = Utc::now() + self.state.slice_interval;
256
257        tracing::debug!(
258            id = %self.state.id,
259            slice = self.state.executed_slices,
260            qty = %slice_qty,
261            next_slice_time = %self.state.next_slice_time,
262            "Executing TWAP slice"
263        );
264
265        let request = self.create_slice_order(slice_qty);
266        Ok(vec![request])
267    }
268
269    fn cancel(&mut self) -> Result<()> {
270        self.state.status = "Cancelled".to_string();
271        tracing::info!(id = %self.state.id, "TWAP algorithm cancelled");
272        Ok(())
273    }
274
275    fn bind_child_order(&mut self, order: Order) -> Result<()> {
276        if !Self::is_active_status(order.status) {
277            return Ok(());
278        }
279        let Some(client_id) = order.request.client_order_id.as_deref() else {
280            return Ok(());
281        };
282        let Some(slice_number) = self.slice_from_client_id(client_id) else {
283            return Ok(());
284        };
285
286        let previous = self.state.executed_slices;
287        if slice_number > previous {
288            self.state.executed_slices = slice_number;
289        }
290        self.state.next_slice_time = Utc::now() + self.state.slice_interval;
291        if !matches!(self.status(), AlgoStatus::Working) {
292            self.state.status = "Working".to_string();
293        }
294
295        tracing::info!(
296            id = %self.state.id,
297            slice = slice_number,
298            order_id = %order.id,
299            "bound TWAP child order after recovery"
300        );
301        Ok(())
302    }
303
304    fn state(&self) -> serde_json::Value {
305        serde_json::to_value(&self.state).expect("Failed to serialize TWAP state")
306    }
307
308    fn from_state(state_val: serde_json::Value) -> Result<Self>
309    where
310        Self: Sized,
311    {
312        let state: TwapState = serde_json::from_value(state_val)?;
313        tracing::debug!(
314            symbol = %state.parent_signal.symbol,
315            id = %state.id,
316            "Restored TWAP algorithm state"
317        );
318        Ok(Self { state })
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use tesser_core::SignalKind;
326
327    #[test]
328    fn test_twap_creation() {
329        let signal = Signal::new("BTCUSDT", SignalKind::EnterLong, 0.8);
330        let duration = Duration::minutes(30);
331        let twap = TwapAlgorithm::new(signal, Decimal::ONE, duration, 10).unwrap();
332
333        assert_eq!(twap.state.total_quantity, Decimal::ONE);
334        assert_eq!(twap.state.num_slices, 10);
335        assert_eq!(twap.status(), AlgoStatus::Working);
336    }
337
338    #[test]
339    fn test_twap_invalid_parameters() {
340        let signal = Signal::new("BTCUSDT", SignalKind::EnterLong, 0.8);
341
342        // Zero duration should fail
343        let result = TwapAlgorithm::new(signal.clone(), Decimal::ONE, Duration::zero(), 10);
344        assert!(result.is_err());
345
346        // Zero slices should fail
347        let result = TwapAlgorithm::new(signal, Decimal::ONE, Duration::minutes(30), 0);
348        assert!(result.is_err());
349    }
350
351    #[test]
352    fn test_slice_quantity_calculation() {
353        let signal = Signal::new("BTCUSDT", SignalKind::EnterLong, 0.8);
354        let twap = TwapAlgorithm::new(
355            signal,
356            Decimal::from_i32(10).unwrap(),
357            Duration::minutes(30),
358            5,
359        )
360        .unwrap();
361
362        let slice_qty = twap.calculate_slice_quantity();
363        assert_eq!(slice_qty, Decimal::from_i32(2).unwrap());
364    }
365}