tesser_execution/algorithm/
trailing_stop.rs

1use anyhow::{anyhow, Result};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::{AlgoStatus, ChildOrderAction, ChildOrderRequest, ExecutionAlgorithm};
7use tesser_core::{Fill, Order, OrderRequest, OrderType, Price, Quantity, Side, Signal, Tick};
8
9#[derive(Debug, Deserialize, Serialize)]
10struct TrailingStopState {
11    id: Uuid,
12    parent_signal: Signal,
13    status: String,
14    total_quantity: Quantity,
15    filled_quantity: Quantity,
16    activation_price: Price,
17    callback_rate: Decimal,
18    highest_market_price: Price,
19    activated: bool,
20    triggered: bool,
21}
22
23/// Simple trailing stop that arms once price trades through an activation level and
24/// fires a market sell when price retraces by the configured callback percentage.
25pub struct TrailingStopAlgorithm {
26    state: TrailingStopState,
27}
28
29impl TrailingStopAlgorithm {
30    pub fn new(
31        signal: Signal,
32        total_quantity: Quantity,
33        activation_price: Price,
34        callback_rate: Decimal,
35    ) -> Result<Self> {
36        if total_quantity <= Decimal::ZERO {
37            return Err(anyhow!("trailing stop quantity must be positive"));
38        }
39        if signal.kind.side() != Side::Sell {
40            return Err(anyhow!(
41                "trailing stop currently supports sell-side signals only"
42            ));
43        }
44        if activation_price <= Decimal::ZERO {
45            return Err(anyhow!("activation price must be positive"));
46        }
47        if callback_rate <= Decimal::ZERO || callback_rate >= Decimal::ONE {
48            return Err(anyhow!("callback rate must be between 0 and 1"));
49        }
50
51        Ok(Self {
52            state: TrailingStopState {
53                id: Uuid::new_v4(),
54                parent_signal: signal,
55                status: "Working".into(),
56                total_quantity,
57                filled_quantity: Decimal::ZERO,
58                activation_price,
59                callback_rate,
60                highest_market_price: activation_price,
61                activated: false,
62                triggered: false,
63            },
64        })
65    }
66
67    fn remaining(&self) -> Quantity {
68        (self.state.total_quantity - self.state.filled_quantity).max(Decimal::ZERO)
69    }
70
71    fn try_activate(&mut self, price: Price) {
72        if !self.state.activated && price >= self.state.activation_price {
73            self.state.activated = true;
74            self.state.highest_market_price = price;
75        }
76    }
77
78    fn update_trail(&mut self, price: Price) {
79        if price > self.state.highest_market_price {
80            self.state.highest_market_price = price;
81        }
82    }
83
84    fn build_child(&self, qty: Quantity) -> ChildOrderRequest {
85        ChildOrderRequest {
86            parent_algo_id: self.state.id,
87            action: ChildOrderAction::Place(OrderRequest {
88                symbol: self.state.parent_signal.symbol,
89                side: self.state.parent_signal.kind.side(),
90                order_type: OrderType::Market,
91                quantity: qty,
92                price: None,
93                trigger_price: None,
94                time_in_force: None,
95                client_order_id: Some(format!("trailing-{}", self.state.id)),
96                take_profit: None,
97                stop_loss: None,
98                display_quantity: None,
99            }),
100        }
101    }
102}
103
104impl ExecutionAlgorithm for TrailingStopAlgorithm {
105    fn kind(&self) -> &'static str {
106        "TRAILING_STOP"
107    }
108
109    fn id(&self) -> &Uuid {
110        &self.state.id
111    }
112
113    fn status(&self) -> AlgoStatus {
114        match self.state.status.as_str() {
115            "Working" => AlgoStatus::Working,
116            "Completed" => AlgoStatus::Completed,
117            "Cancelled" => AlgoStatus::Cancelled,
118            other => AlgoStatus::Failed(other.to_string()),
119        }
120    }
121
122    fn start(&mut self) -> Result<Vec<ChildOrderRequest>> {
123        Ok(Vec::new())
124    }
125
126    fn on_child_order_placed(&mut self, _order: &Order) {}
127
128    fn on_fill(&mut self, fill: &Fill) -> Result<Vec<ChildOrderRequest>> {
129        self.state.filled_quantity += fill.fill_quantity;
130        if self.remaining() <= Decimal::ZERO {
131            self.state.status = "Completed".into();
132        }
133        Ok(Vec::new())
134    }
135
136    fn on_tick(&mut self, tick: &Tick) -> Result<Vec<ChildOrderRequest>> {
137        if !matches!(self.status(), AlgoStatus::Working) {
138            return Ok(Vec::new());
139        }
140
141        if !self.state.activated {
142            self.try_activate(tick.price);
143            return Ok(Vec::new());
144        }
145
146        if self.state.triggered {
147            return Ok(Vec::new());
148        }
149
150        self.update_trail(tick.price);
151        let threshold = self.state.highest_market_price * (Decimal::ONE - self.state.callback_rate);
152        if tick.price <= threshold {
153            self.state.triggered = true;
154            let qty = self.remaining();
155            if qty > Decimal::ZERO {
156                return Ok(vec![self.build_child(qty)]);
157            }
158        }
159        Ok(Vec::new())
160    }
161
162    fn on_timer(&mut self) -> Result<Vec<ChildOrderRequest>> {
163        Ok(Vec::new())
164    }
165
166    fn cancel(&mut self) -> Result<()> {
167        self.state.status = "Cancelled".into();
168        Ok(())
169    }
170
171    fn state(&self) -> serde_json::Value {
172        serde_json::to_value(&self.state).expect("trailing stop state serialization failed")
173    }
174
175    fn from_state(state: serde_json::Value) -> Result<Self>
176    where
177        Self: Sized,
178    {
179        let state: TrailingStopState = serde_json::from_value(state)?;
180        Ok(Self { state })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use chrono::Utc;
188    use tesser_core::SignalKind;
189
190    fn tick(price: Price) -> Tick {
191        Tick {
192            symbol: "BTCUSDT".into(),
193            price,
194            size: Decimal::ONE,
195            side: tesser_core::Side::Buy,
196            exchange_timestamp: Utc::now(),
197            received_at: Utc::now(),
198        }
199    }
200
201    #[test]
202    fn trailing_stop_requires_activation() {
203        let signal = Signal::new("BTCUSDT", SignalKind::ExitLong, 1.0);
204        let mut algo = TrailingStopAlgorithm::new(
205            signal,
206            Decimal::from(2),
207            Decimal::from(100),
208            Decimal::new(5, 2),
209        )
210        .unwrap();
211        let orders = algo.on_tick(&tick(Decimal::from(95))).unwrap();
212        assert!(orders.is_empty());
213        assert!(!algo.state.activated);
214        let _ = algo.on_tick(&tick(Decimal::from(101))).unwrap();
215        assert!(algo.state.activated);
216    }
217
218    #[test]
219    fn trailing_stop_triggers_after_callback() {
220        let signal = Signal::new("BTCUSDT", SignalKind::ExitLong, 1.0);
221        let mut algo = TrailingStopAlgorithm::new(
222            signal,
223            Decimal::from(3),
224            Decimal::from(100),
225            Decimal::new(5, 2),
226        )
227        .unwrap();
228        // Activate and push to a new high
229        algo.on_tick(&tick(Decimal::from(105))).unwrap();
230        algo.on_tick(&tick(Decimal::from(112))).unwrap();
231        // Drop below the trailing threshold (112 * (1 - 0.05) = 106.4)
232        let orders = algo.on_tick(&tick(Decimal::from(105))).unwrap();
233        assert_eq!(orders.len(), 1);
234        match &orders[0].action {
235            ChildOrderAction::Place(request) => {
236                assert_eq!(request.order_type, OrderType::Market);
237                assert_eq!(request.quantity, Decimal::from(3));
238                assert!(request
239                    .client_order_id
240                    .as_ref()
241                    .expect("client id missing")
242                    .starts_with("trailing-"));
243            }
244            other => panic!("unexpected action: {other:?}"),
245        }
246    }
247}