tesser_execution/algorithm/
sniper.rs

1use anyhow::{anyhow, Result};
2use chrono::{Duration, Utc};
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5use tesser_core::{Order, OrderRequest, OrderType, Price, Quantity, Signal, SignalKind, Tick};
6use uuid::Uuid;
7
8use super::{AlgoStatus, ChildOrderAction, ChildOrderRequest, ExecutionAlgorithm};
9
10#[derive(Debug, Deserialize, Serialize)]
11struct SniperState {
12    id: Uuid,
13    parent_signal: Signal,
14    status: String,
15    total_quantity: Quantity,
16    filled_quantity: Quantity,
17    trigger_price: Price,
18    timeout: Option<Duration>,
19    triggered: bool,
20    started_at: chrono::DateTime<Utc>,
21}
22
23/// Fires a single aggressive order once a target price is touched.
24pub struct SniperAlgorithm {
25    state: SniperState,
26}
27
28impl SniperAlgorithm {
29    pub fn new(
30        signal: Signal,
31        total_quantity: Quantity,
32        trigger_price: Price,
33        timeout: Option<Duration>,
34    ) -> Result<Self> {
35        if total_quantity <= Decimal::ZERO {
36            return Err(anyhow!("sniper quantity must be positive"));
37        }
38        if trigger_price <= Decimal::ZERO {
39            return Err(anyhow!("trigger price must be positive"));
40        }
41        Ok(Self {
42            state: SniperState {
43                id: Uuid::new_v4(),
44                parent_signal: signal,
45                status: "Working".into(),
46                total_quantity,
47                filled_quantity: Decimal::ZERO,
48                trigger_price,
49                timeout,
50                triggered: false,
51                started_at: Utc::now(),
52            },
53        })
54    }
55
56    fn remaining(&self) -> Quantity {
57        (self.state.total_quantity - self.state.filled_quantity).max(Decimal::ZERO)
58    }
59
60    fn ready_to_fire(&self, price: Price) -> bool {
61        match self.state.parent_signal.kind {
62            SignalKind::EnterLong | SignalKind::ExitShort => price <= self.state.trigger_price,
63            SignalKind::EnterShort | SignalKind::ExitLong => price >= self.state.trigger_price,
64            SignalKind::Flatten => false,
65        }
66    }
67
68    fn build_child(&mut self, qty: Quantity) -> ChildOrderRequest {
69        ChildOrderRequest {
70            parent_algo_id: self.state.id,
71            action: ChildOrderAction::Place(OrderRequest {
72                symbol: self.state.parent_signal.symbol,
73                side: self.state.parent_signal.kind.side(),
74                order_type: OrderType::Market,
75                quantity: qty,
76                price: None,
77                trigger_price: None,
78                time_in_force: None,
79                client_order_id: Some(format!("sniper-{}", self.state.id)),
80                take_profit: None,
81                stop_loss: None,
82                display_quantity: None,
83            }),
84        }
85    }
86
87    fn check_expired(&mut self) {
88        if let Some(timeout) = self.state.timeout {
89            if Utc::now() - self.state.started_at >= timeout {
90                self.state.status = "Cancelled".into();
91            }
92        }
93    }
94}
95
96impl ExecutionAlgorithm for SniperAlgorithm {
97    fn kind(&self) -> &'static str {
98        "SNIPER"
99    }
100
101    fn id(&self) -> &Uuid {
102        &self.state.id
103    }
104
105    fn status(&self) -> AlgoStatus {
106        match self.state.status.as_str() {
107            "Working" => AlgoStatus::Working,
108            "Completed" => AlgoStatus::Completed,
109            "Cancelled" => AlgoStatus::Cancelled,
110            other => AlgoStatus::Failed(other.to_string()),
111        }
112    }
113
114    fn start(&mut self) -> Result<Vec<ChildOrderRequest>> {
115        Ok(Vec::new())
116    }
117
118    fn on_child_order_placed(&mut self, _order: &Order) {}
119
120    fn on_fill(&mut self, fill: &tesser_core::Fill) -> Result<Vec<ChildOrderRequest>> {
121        self.state.filled_quantity += fill.fill_quantity;
122        if self.remaining() <= Decimal::ZERO {
123            self.state.status = "Completed".into();
124        }
125        Ok(Vec::new())
126    }
127
128    fn on_tick(&mut self, tick: &Tick) -> Result<Vec<ChildOrderRequest>> {
129        if !matches!(self.status(), AlgoStatus::Working) {
130            return Ok(Vec::new());
131        }
132        if self.state.triggered {
133            return Ok(Vec::new());
134        }
135        if self.ready_to_fire(tick.price) {
136            self.state.triggered = true;
137            let qty = self.remaining();
138            if qty > Decimal::ZERO {
139                return Ok(vec![self.build_child(qty)]);
140            }
141        }
142        Ok(Vec::new())
143    }
144
145    fn on_timer(&mut self) -> Result<Vec<ChildOrderRequest>> {
146        self.check_expired();
147        Ok(Vec::new())
148    }
149
150    fn cancel(&mut self) -> Result<()> {
151        self.state.status = "Cancelled".into();
152        Ok(())
153    }
154
155    fn state(&self) -> serde_json::Value {
156        serde_json::to_value(&self.state).expect("sniper state serialization failed")
157    }
158
159    fn from_state(state: serde_json::Value) -> Result<Self>
160    where
161        Self: Sized,
162    {
163        let state: SniperState = serde_json::from_value(state)?;
164        Ok(Self { state })
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use tesser_core::{Signal, SignalKind, Tick};
172
173    #[test]
174    fn sniper_triggers_when_price_crosses() {
175        let signal = Signal::new("BTCUSDT", SignalKind::EnterLong, 0.5);
176        let mut algo =
177            SniperAlgorithm::new(signal, Decimal::from(2), Decimal::from(100), None).unwrap();
178        let tick = Tick {
179            symbol: "BTCUSDT".into(),
180            price: Decimal::from(95),
181            size: Decimal::ONE,
182            side: tesser_core::Side::Sell,
183            exchange_timestamp: Utc::now(),
184            received_at: Utc::now(),
185        };
186        let orders = algo.on_tick(&tick).unwrap();
187        assert_eq!(orders.len(), 1);
188        match &orders[0].action {
189            ChildOrderAction::Place(request) => {
190                assert_eq!(request.order_type, OrderType::Market);
191            }
192            other => panic!("unexpected action: {other:?}"),
193        }
194    }
195}