tesser_execution/algorithm/
pegged.rs

1use anyhow::{anyhow, Result};
2use chrono::{DateTime, Duration, Utc};
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5use tesser_core::{
6    Order, OrderRequest, OrderStatus, OrderType, Quantity, Signal, Tick, TimeInForce,
7};
8use uuid::Uuid;
9
10use super::{AlgoStatus, ChildOrderRequest, ExecutionAlgorithm};
11
12#[derive(Debug, Deserialize, Serialize)]
13struct PeggedState {
14    id: Uuid,
15    parent_signal: Signal,
16    status: String,
17    total_quantity: Quantity,
18    filled_quantity: Quantity,
19    clip_size: Quantity,
20    offset_bps: Decimal,
21    refresh: Duration,
22    last_order_time: Option<DateTime<Utc>>,
23    last_peg_price: Option<Decimal>,
24    next_child_seq: u32,
25}
26
27/// Algorithm that repeatedly submits IOC limit orders pegged to the top of book.
28pub struct PeggedBestAlgorithm {
29    state: PeggedState,
30}
31
32impl PeggedBestAlgorithm {
33    pub fn new(
34        signal: Signal,
35        total_quantity: Quantity,
36        offset_bps: Decimal,
37        clip_size: Option<Quantity>,
38        refresh: Duration,
39    ) -> Result<Self> {
40        if total_quantity <= Decimal::ZERO {
41            return Err(anyhow!("pegged algorithm requires positive quantity"));
42        }
43        if offset_bps < Decimal::ZERO {
44            return Err(anyhow!("offset must be non-negative"));
45        }
46        if refresh <= Duration::zero() {
47            return Err(anyhow!("refresh interval must be positive"));
48        }
49        let clip = clip_size
50            .unwrap_or(total_quantity)
51            .max(Decimal::ZERO)
52            .min(total_quantity);
53        Ok(Self {
54            state: PeggedState {
55                id: Uuid::new_v4(),
56                parent_signal: signal,
57                status: "Working".into(),
58                total_quantity,
59                filled_quantity: Decimal::ZERO,
60                clip_size: if clip <= Decimal::ZERO {
61                    total_quantity
62                } else {
63                    clip
64                },
65                offset_bps,
66                refresh,
67                last_order_time: None,
68                last_peg_price: None,
69                next_child_seq: 0,
70            },
71        })
72    }
73
74    fn remaining(&self) -> Quantity {
75        (self.state.total_quantity - self.state.filled_quantity).max(Decimal::ZERO)
76    }
77
78    fn peg_price(&self, tick_price: Decimal) -> Decimal {
79        let offset = self.state.offset_bps / Decimal::from(10_000);
80        match self.state.parent_signal.kind.side() {
81            tesser_core::Side::Buy => tick_price * (Decimal::ONE - offset),
82            tesser_core::Side::Sell => tick_price * (Decimal::ONE + offset),
83        }
84    }
85
86    fn should_refresh(&self, price: Decimal, now: DateTime<Utc>) -> bool {
87        if self.last_emit_elapsed(now) >= self.state.refresh {
88            return true;
89        }
90        self.state
91            .last_peg_price
92            .map(|prev| (prev - price).abs() > Decimal::ZERO)
93            .unwrap_or(true)
94    }
95
96    fn last_emit_elapsed(&self, now: DateTime<Utc>) -> Duration {
97        self.state
98            .last_order_time
99            .map(|ts| now.signed_duration_since(ts))
100            .unwrap_or(self.state.refresh)
101    }
102
103    fn build_child(&mut self, price: Decimal, qty: Quantity) -> ChildOrderRequest {
104        self.state.next_child_seq += 1;
105        ChildOrderRequest {
106            parent_algo_id: self.state.id,
107            order_request: OrderRequest {
108                symbol: self.state.parent_signal.symbol.clone(),
109                side: self.state.parent_signal.kind.side(),
110                order_type: OrderType::Limit,
111                quantity: qty,
112                price: Some(price),
113                trigger_price: None,
114                time_in_force: Some(TimeInForce::ImmediateOrCancel),
115                client_order_id: Some(format!(
116                    "peg-{}-{}",
117                    self.state.id, self.state.next_child_seq
118                )),
119                take_profit: None,
120                stop_loss: None,
121                display_quantity: None,
122            },
123        }
124    }
125
126    fn mark_completed(&mut self) {
127        if self.remaining() <= Decimal::ZERO {
128            self.state.status = "Completed".into();
129        }
130    }
131
132    fn sequence_from_client_id(&self, client_id: &str) -> Option<u32> {
133        let rest = client_id.strip_prefix("peg-")?;
134        let (id_part, seq_part) = rest.rsplit_once('-')?;
135        if id_part != self.state.id.to_string() {
136            return None;
137        }
138        seq_part.parse().ok()
139    }
140
141    fn is_active_status(status: OrderStatus) -> bool {
142        matches!(
143            status,
144            OrderStatus::PendingNew | OrderStatus::Accepted | OrderStatus::PartiallyFilled
145        )
146    }
147}
148
149impl ExecutionAlgorithm for PeggedBestAlgorithm {
150    fn kind(&self) -> &'static str {
151        "PEGGED_BEST"
152    }
153
154    fn id(&self) -> &Uuid {
155        &self.state.id
156    }
157
158    fn status(&self) -> AlgoStatus {
159        match self.state.status.as_str() {
160            "Working" => AlgoStatus::Working,
161            "Completed" => AlgoStatus::Completed,
162            "Cancelled" => AlgoStatus::Cancelled,
163            other => AlgoStatus::Failed(other.to_string()),
164        }
165    }
166
167    fn start(&mut self) -> Result<Vec<ChildOrderRequest>> {
168        Ok(Vec::new())
169    }
170
171    fn on_child_order_placed(&mut self, _order: &Order) {
172        self.state.last_order_time = Some(Utc::now());
173    }
174
175    fn on_fill(&mut self, fill: &tesser_core::Fill) -> Result<Vec<ChildOrderRequest>> {
176        self.state.filled_quantity += fill.fill_quantity;
177        self.mark_completed();
178        Ok(Vec::new())
179    }
180
181    fn on_tick(&mut self, tick: &Tick) -> Result<Vec<ChildOrderRequest>> {
182        if !matches!(self.status(), AlgoStatus::Working) {
183            return Ok(Vec::new());
184        }
185        let remaining = self.remaining();
186        if remaining <= Decimal::ZERO {
187            self.state.status = "Completed".into();
188            return Ok(Vec::new());
189        }
190        let now = Utc::now();
191        let price = self.peg_price(tick.price.max(Decimal::ZERO));
192        if !self.should_refresh(price, now) {
193            return Ok(Vec::new());
194        }
195        self.state.last_peg_price = Some(price);
196        let qty = remaining.min(self.state.clip_size);
197        Ok(vec![self.build_child(price, qty)])
198    }
199
200    fn on_timer(&mut self) -> Result<Vec<ChildOrderRequest>> {
201        self.mark_completed();
202        Ok(Vec::new())
203    }
204
205    fn cancel(&mut self) -> Result<()> {
206        self.state.status = "Cancelled".into();
207        Ok(())
208    }
209
210    fn bind_child_order(&mut self, order: Order) -> Result<()> {
211        if !Self::is_active_status(order.status) {
212            return Ok(());
213        }
214        let Some(client_id) = order.request.client_order_id.as_deref() else {
215            return Ok(());
216        };
217        if let Some(seq) = self.sequence_from_client_id(client_id) {
218            self.state.next_child_seq = self.state.next_child_seq.max(seq);
219        }
220        self.state.last_order_time = Some(order.updated_at);
221        if let Some(price) = order.request.price {
222            self.state.last_peg_price = Some(price);
223        }
224        if !matches!(self.status(), AlgoStatus::Working) {
225            self.state.status = "Working".into();
226        }
227        Ok(())
228    }
229
230    fn state(&self) -> serde_json::Value {
231        serde_json::to_value(&self.state).expect("pegged state serialization failed")
232    }
233
234    fn from_state(state: serde_json::Value) -> Result<Self>
235    where
236        Self: Sized,
237    {
238        let state: PeggedState = serde_json::from_value(state)?;
239        Ok(Self { state })
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use tesser_core::{Signal, SignalKind, Tick};
247
248    #[test]
249    fn emits_child_after_tick() {
250        let signal = Signal::new("BTCUSDT", SignalKind::EnterLong, 0.9);
251        let mut algo = PeggedBestAlgorithm::new(
252            signal,
253            Decimal::from(5),
254            Decimal::new(5, 1),
255            None,
256            Duration::seconds(1),
257        )
258        .unwrap();
259        let tick = Tick {
260            symbol: "BTCUSDT".into(),
261            price: Decimal::from(100),
262            size: Decimal::ONE,
263            side: tesser_core::Side::Buy,
264            exchange_timestamp: Utc::now(),
265            received_at: Utc::now(),
266        };
267        let orders = algo.on_tick(&tick).unwrap();
268        assert_eq!(orders.len(), 1);
269        assert!(orders[0].order_request.quantity > Decimal::ZERO);
270    }
271}