tesser_execution/algorithm/
trailing_stop.rs1use 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
23pub 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 algo.on_tick(&tick(Decimal::from(105))).unwrap();
230 algo.on_tick(&tick(Decimal::from(112))).unwrap();
231 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}