Skip to main content

engine/strategies/
avellaneda_stoikov_hawkes.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use solver::lookup::LookupTable;
4use std::path::Path;
5use std::sync::Arc;
6
7/// Parameters for the Hawkes-aware Avellaneda-Stoikov strategy.
8///
9/// The strategy uses two precomputed FDM lookup tables (bid spread, ask spread)
10/// indexed by [inventory q, Hawkes intensity lambda, time remaining tau].
11/// Tables are generated offline via `examples/gen_hawkes_table.rs`.
12#[derive(Clone, Debug, Default)]
13pub struct AvellanedaStoikovHawkesParameters {
14    /// Trading horizon in seconds -- must match the tau axis of the loaded tables.
15    pub t_horizon: f64,
16    /// Simulation time step in seconds (used only for elapsed-time calculation).
17    pub dt: f64,
18    /// Fallback arrival rate when `hawkes_intensity` is absent from the observation.
19    /// Typically set to the baseline mu of the Hawkes process.
20    pub base_intensity: f64,
21}
22
23/// Avellaneda-Stoikov strategy driven by precomputed FDM Hawkes spreads.
24///
25/// On every tick the strategy:
26///   1. Reads `hawkes_intensity` from `obs.parameters` (injected by the engine
27///      from `StochasticMatcher::augment_parameters`).
28///   2. Interpolates the FDM spread tables at `(q, lambda, tau)`.
29///   3. Places bid/ask orders at `mid +/- interpolated_spread`.
30///
31/// # Table format
32/// Both tables must be 3-dimensional with axes `[q, lambda, tau]`:
33///   - axis 0: inventory grid (same range used by the FDM solver, e.g. -10..10)
34///   - axis 1: Hawkes intensity grid (e.g. 0.0 .. lambda_max)
35///   - axis 2: time remaining in seconds (0.0 .. T, increasing)
36///
37/// Use `examples/gen_hawkes_table.rs` to generate compatible `.bin` files.
38#[derive(Clone)]
39pub struct AvellanedaStoikovHawkesStrategy {
40    pub params: AvellanedaStoikovHawkesParameters,
41    /// 3D lookup table for the bid half-spread: axes [q, lambda, tau].
42    pub bid_spread_table: Arc<LookupTable>,
43    /// 3D lookup table for the ask half-spread: axes [q, lambda, tau].
44    pub ask_spread_table: Arc<LookupTable>,
45    start_time: Option<f64>,
46    order_counter: u64,
47}
48
49impl AvellanedaStoikovHawkesStrategy {
50    pub fn new(
51        params: AvellanedaStoikovHawkesParameters,
52        bid_spread_table: Arc<LookupTable>,
53        ask_spread_table: Arc<LookupTable>,
54    ) -> Self {
55        assert_eq!(
56            bid_spread_table.axes.len(),
57            3,
58            "bid table must be 3D [q, lambda, tau]"
59        );
60        assert_eq!(
61            ask_spread_table.axes.len(),
62            3,
63            "ask table must be 3D [q, lambda, tau]"
64        );
65        Self {
66            params,
67            bid_spread_table,
68            ask_spread_table,
69            start_time: None,
70            order_counter: 0,
71        }
72    }
73
74    /// Load both spread tables from `.bin` files produced by the table
75    /// generation example.
76    pub fn load(
77        params: AvellanedaStoikovHawkesParameters,
78        bid_path: &Path,
79        ask_path: &Path,
80    ) -> Result<Self, Box<dyn std::error::Error>> {
81        let bid_spread_table = Arc::new(LookupTable::load(bid_path)?);
82        let ask_spread_table = Arc::new(LookupTable::load(ask_path)?);
83        Ok(Self::new(params, bid_spread_table, ask_spread_table))
84    }
85}
86
87impl Strategy for AvellanedaStoikovHawkesStrategy {
88    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
89        if self.start_time.is_none() {
90            self.start_time = Some(obs.timestamp);
91        }
92
93        let elapsed = obs.timestamp - self.start_time.unwrap();
94        let tau = (self.params.t_horizon - elapsed).max(0.0);
95
96        if tau <= 0.0 {
97            requests.push(OrderRequest::CancelAll);
98            return;
99        }
100
101        let mid = obs.mid_price();
102        let q = obs.portfolio.position;
103
104        // In regular Hawkes mode the engine publishes a single "hawkes_intensity".
105        // In bilateral mode it publishes "hawkes_buy_intensity" (lambda+, buy MOs
106        // arrive and hit the ask) and "hawkes_sell_intensity" (lambda-, sell MOs
107        // arrive and hit the bid).
108        //
109        // Mapping to the 3-D tables [q, lambda, tau]:
110        //   bid spread -- driven by sell-side market order flow (lambda-)
111        //   ask spread -- driven by buy-side  market order flow (lambda+)
112        let params = obs.parameters.as_ref();
113        let unified = params.and_then(|p| p.get("hawkes_intensity").copied());
114        let lambda_bid = params
115            .and_then(|p| p.get("hawkes_sell_intensity").copied())
116            .or(unified)
117            .unwrap_or(self.params.base_intensity);
118        let lambda_ask = params
119            .and_then(|p| p.get("hawkes_buy_intensity").copied())
120            .or(unified)
121            .unwrap_or(self.params.base_intensity);
122
123        let delta_bid = self
124            .bid_spread_table
125            .interpolate(&[q, lambda_bid, tau])
126            .max(0.0);
127        let delta_ask = self
128            .ask_spread_table
129            .interpolate(&[q, lambda_ask, tau])
130            .max(0.0);
131
132        let bid_price = ((mid - delta_bid) * 100.0).round() / 100.0;
133        let ask_price = ((mid + delta_ask) * 100.0).round() / 100.0;
134
135        self.order_counter += 1;
136        let bid_id = self.order_counter;
137        self.order_counter += 1;
138        let ask_id = self.order_counter;
139
140        requests.push(OrderRequest::CancelAll);
141        requests.push(OrderRequest::New(Order::new(
142            bid_id,
143            Side::Buy,
144            bid_price,
145            1.0,
146        )));
147        requests.push(OrderRequest::New(Order::new(
148            ask_id,
149            Side::Sell,
150            ask_price,
151            1.0,
152        )));
153    }
154
155    fn as_any(&self) -> &dyn std::any::Any {
156        self
157    }
158    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
159        self
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::types::PortfolioSnapshot;
167    use ndarray::{ArrayD, IxDyn};
168    use solver::lookup::LookupTable;
169
170    fn flat_table(axes: Vec<Vec<f64>>, value: f64) -> LookupTable {
171        let shape: Vec<usize> = axes.iter().map(|a| a.len()).collect();
172        let n: usize = shape.iter().product();
173        let data = ArrayD::from_shape_vec(IxDyn(&shape), vec![value; n]).unwrap();
174        LookupTable::new(axes, data)
175    }
176
177    fn make_obs(position: f64, intensity: f64) -> Observation {
178        let mut params = std::collections::HashMap::new();
179        params.insert("hawkes_intensity".to_string(), intensity);
180        Observation {
181            timestamp: 0.0,
182            best_bid: 99.95,
183            best_ask: 100.05,
184            last_price: Some(100.0),
185            portfolio: PortfolioSnapshot {
186                cash: 10000.0,
187                position,
188            },
189            volatility: None,
190            drift: None,
191            parameters: Some(params),
192        }
193    }
194
195    #[test]
196    fn test_places_quotes_at_flat_spread() {
197        let axes = vec![
198            vec![-1.0, 0.0, 1.0],     // q
199            vec![0.5, 1.0, 2.0],      // lambda
200            vec![0.0, 500.0, 1000.0], // tau
201        ];
202        let bid_table = flat_table(axes.clone(), 0.05);
203        let ask_table = flat_table(axes, 0.05);
204        let params = AvellanedaStoikovHawkesParameters {
205            t_horizon: 1000.0,
206            dt: 1.0,
207            base_intensity: 1.0,
208        };
209        let mut strategy =
210            AvellanedaStoikovHawkesStrategy::new(params, Arc::new(bid_table), Arc::new(ask_table));
211        let obs = make_obs(0.0, 1.0);
212
213        let mut requests = Vec::new();
214        strategy.on_tick(&obs, &mut requests);
215
216        assert_eq!(requests.len(), 3); // CancelAll + 2 orders
217        let bid = match &requests[1] {
218            OrderRequest::New(o) => {
219                assert_eq!(o.side, Side::Buy);
220                o.limit_price
221            }
222            _ => panic!("expected bid order"),
223        };
224        let ask = match &requests[2] {
225            OrderRequest::New(o) => {
226                assert_eq!(o.side, Side::Sell);
227                o.limit_price
228            }
229            _ => panic!("expected ask order"),
230        };
231        assert!((bid - 99.95).abs() < 0.01);
232        assert!((ask - 100.05).abs() < 0.01);
233    }
234
235    #[test]
236    fn test_fallback_to_base_intensity_when_params_absent() {
237        let axes = vec![vec![0.0], vec![1.0], vec![0.0, 1000.0]];
238        let bid_table = flat_table(axes.clone(), 0.03);
239        let ask_table = flat_table(axes, 0.03);
240        let params = AvellanedaStoikovHawkesParameters {
241            t_horizon: 1000.0,
242            dt: 1.0,
243            base_intensity: 1.0,
244        };
245        let mut strategy =
246            AvellanedaStoikovHawkesStrategy::new(params, Arc::new(bid_table), Arc::new(ask_table));
247        let obs_no_params = Observation {
248            timestamp: 0.0,
249            best_bid: 99.95,
250            best_ask: 100.05,
251            last_price: None,
252            portfolio: PortfolioSnapshot {
253                cash: 10000.0,
254                position: 0.0,
255            },
256            volatility: None,
257            drift: None,
258            parameters: None,
259        };
260        let mut requests = Vec::new();
261        strategy.on_tick(&obs_no_params, &mut requests);
262        assert_eq!(requests.len(), 3);
263    }
264
265    #[test]
266    fn test_cancels_at_end_of_horizon() {
267        let axes = vec![vec![0.0], vec![1.0], vec![0.0]];
268        let t = Arc::new(flat_table(axes.clone(), 0.05));
269        let params = AvellanedaStoikovHawkesParameters {
270            t_horizon: 0.0,
271            dt: 1.0,
272            base_intensity: 1.0,
273        };
274        let mut strategy = AvellanedaStoikovHawkesStrategy::new(params, Arc::clone(&t), t);
275        let obs = make_obs(0.0, 1.0);
276        let mut requests = Vec::new();
277        strategy.on_tick(&obs, &mut requests);
278        assert_eq!(requests.len(), 1);
279        assert!(matches!(requests[0], OrderRequest::CancelAll));
280    }
281
282    #[test]
283    fn test_bilateral_hawkes_uses_side_specific_intensities() {
284        // bid spread table has value 0.10, ask spread table has value 0.20.
285        // In bilateral mode the bid spread is driven by lambda- (sell intensity = 0.5)
286        // and the ask spread is driven by lambda+ (buy intensity = 2.0).
287        // Both use the same flat tables so the spread values should be 0.10 / 0.20.
288        let axes = vec![
289            vec![-1.0, 0.0, 1.0],
290            vec![0.0, 1.0, 3.0],
291            vec![0.0, 500.0, 1000.0],
292        ];
293        let bid_table = Arc::new(flat_table(axes.clone(), 0.10));
294        let ask_table = Arc::new(flat_table(axes, 0.20));
295        let params = AvellanedaStoikovHawkesParameters {
296            t_horizon: 1000.0,
297            dt: 1.0,
298            base_intensity: 1.0,
299        };
300        let mut strategy = AvellanedaStoikovHawkesStrategy::new(params, bid_table, ask_table);
301
302        let mut bilateral_params = std::collections::HashMap::new();
303        bilateral_params.insert("hawkes_buy_intensity".to_string(), 2.0);
304        bilateral_params.insert("hawkes_sell_intensity".to_string(), 0.5);
305        let obs = Observation {
306            timestamp: 0.0,
307            best_bid: 99.95,
308            best_ask: 100.05,
309            last_price: Some(100.0),
310            portfolio: PortfolioSnapshot {
311                cash: 10000.0,
312                position: 0.0,
313            },
314            volatility: None,
315            drift: None,
316            parameters: Some(bilateral_params),
317        };
318
319        let mut requests = Vec::new();
320        strategy.on_tick(&obs, &mut requests);
321
322        let bid_price = match &requests[1] {
323            OrderRequest::New(o) => o.limit_price,
324            _ => panic!("expected bid order"),
325        };
326        let ask_price = match &requests[2] {
327            OrderRequest::New(o) => o.limit_price,
328            _ => panic!("expected ask order"),
329        };
330        // mid=100.0; bid = 100.0 - 0.10 = 99.90; ask = 100.0 + 0.20 = 100.20
331        assert!((bid_price - 99.90).abs() < 0.01, "bid_price={bid_price}");
332        assert!((ask_price - 100.20).abs() < 0.01, "ask_price={ask_price}");
333    }
334}