tesser_execution/algorithm/
sniper.rs1use 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
23pub 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}