Skip to main content

engine/strategies/
avellaneda_stoikov_bilateral_hawkes.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use solver::lookup::LookupTable;
4use std::sync::Arc;
5
6/// Parameters for the bilateral Hawkes FDM strategy.
7///
8/// The strategy uses two precomputed FDM lookup tables indexed by
9/// [inventory q, buy intensity lambda+, sell intensity lambda-, time remaining tau].
10/// The tables are generated by solving the bilateral Hawkes HJB PDE offline
11/// via `BilateralHawkes` model + `PolicyIterationSolver::solve_all_steps`.
12#[derive(Clone, Debug)]
13pub struct AvellanedaStoikovBilateralHawkesParameters {
14    /// Trading horizon in model seconds -- must match the tau axis of the tables.
15    pub t_horizon: f64,
16    /// Fallback buy-side intensity when `hawkes_buy_intensity` is absent.
17    /// Typically set to the baseline mu of the Hawkes process.
18    pub base_intensity_buy: f64,
19    /// Fallback sell-side intensity when `hawkes_sell_intensity` is absent.
20    pub base_intensity_sell: f64,
21}
22
23/// Avellaneda-Stoikov strategy driven by precomputed FDM bilateral-Hawkes spreads.
24///
25/// On every tick the strategy:
26///   1. Reads `hawkes_buy_intensity` (lambda+) and `hawkes_sell_intensity` (lambda-)
27///      from `obs.parameters`, as published by `StochasticMatcher::augment_parameters`
28///      when constructed with `with_bilateral_hawkes`.
29///   2. Interpolates the 4-D FDM spread tables at `(q, lambda+, lambda-, tau)`.
30///   3. Places bid/ask orders at `mid -/+ interpolated_spread`.
31///
32/// # Table format
33/// Both tables must be 4-dimensional with axes `[q, lambda+, lambda-, tau]`:
34///   - axis 0: inventory grid (e.g. -10..10, 21 points)
35///   - axis 1: buy-side intensity grid (e.g. 0.0..LAMBDA_MAX, N_LAMBDA points)
36///   - axis 2: sell-side intensity grid (e.g. 0.0..LAMBDA_MAX, N_LAMBDA points)
37///   - axis 3: time remaining in seconds (0.0..T_HORIZON, increasing)
38///
39/// # Intensity mapping
40/// In the bilateral Hawkes model:
41///   - lambda+ = buy market-order intensity  -> fills the MM ask (ask fill rate proportional to lambda+)
42///   - lambda- = sell market-order intensity -> fills the MM bid (bid fill rate proportional to lambda-)
43///
44/// Both intensities jointly determine optimal bid and ask through the 4-D value function.
45#[derive(Clone)]
46pub struct AvellanedaStoikovBilateralHawkesStrategy {
47    pub params: AvellanedaStoikovBilateralHawkesParameters,
48    /// 4D lookup table for the bid half-spread: axes [q, lambda+, lambda-, tau].
49    pub bid_spread_table: Arc<LookupTable>,
50    /// 4D lookup table for the ask half-spread: axes [q, lambda+, lambda-, tau].
51    pub ask_spread_table: Arc<LookupTable>,
52    start_time: Option<f64>,
53    order_counter: u64,
54}
55
56impl AvellanedaStoikovBilateralHawkesStrategy {
57    pub fn new(
58        params: AvellanedaStoikovBilateralHawkesParameters,
59        bid_spread_table: Arc<LookupTable>,
60        ask_spread_table: Arc<LookupTable>,
61    ) -> Self {
62        assert_eq!(
63            bid_spread_table.axes.len(),
64            4,
65            "bid table must be 4D [q, lambda+, lambda-, tau]"
66        );
67        assert_eq!(
68            ask_spread_table.axes.len(),
69            4,
70            "ask table must be 4D [q, lambda+, lambda-, tau]"
71        );
72        Self {
73            params,
74            bid_spread_table,
75            ask_spread_table,
76            start_time: None,
77            order_counter: 0,
78        }
79    }
80}
81
82impl Strategy for AvellanedaStoikovBilateralHawkesStrategy {
83    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
84        if self.start_time.is_none() {
85            self.start_time = Some(obs.timestamp);
86        }
87
88        let elapsed = obs.timestamp - self.start_time.unwrap();
89        let tau = (self.params.t_horizon - elapsed).max(0.0);
90
91        if tau <= 0.0 {
92            requests.push(OrderRequest::CancelAll);
93            return;
94        }
95
96        let mid = obs.mid_price();
97        let q = obs.portfolio.position;
98
99        let params = obs.parameters.as_ref();
100
101        // lambda+ = buy market-order intensity (drives ask fills)
102        let lambda_plus = params
103            .and_then(|p| p.get("hawkes_buy_intensity").copied())
104            .unwrap_or(self.params.base_intensity_buy);
105
106        // lambda- = sell market-order intensity (drives bid fills)
107        let lambda_minus = params
108            .and_then(|p| p.get("hawkes_sell_intensity").copied())
109            .unwrap_or(self.params.base_intensity_sell);
110
111        let delta_bid = self
112            .bid_spread_table
113            .interpolate(&[q, lambda_plus, lambda_minus, tau])
114            .max(0.0);
115        let delta_ask = self
116            .ask_spread_table
117            .interpolate(&[q, lambda_plus, lambda_minus, tau])
118            .max(0.0);
119
120        let bid_price = ((mid - delta_bid) * 100.0).round() / 100.0;
121        let ask_price = ((mid + delta_ask) * 100.0).round() / 100.0;
122
123        self.order_counter += 1;
124        let bid_id = self.order_counter;
125        self.order_counter += 1;
126        let ask_id = self.order_counter;
127
128        requests.push(OrderRequest::CancelAll);
129        requests.push(OrderRequest::New(Order::new(
130            bid_id,
131            Side::Buy,
132            bid_price,
133            1.0,
134        )));
135        requests.push(OrderRequest::New(Order::new(
136            ask_id,
137            Side::Sell,
138            ask_price,
139            1.0,
140        )));
141    }
142
143    fn as_any(&self) -> &dyn std::any::Any {
144        self
145    }
146    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
147        self
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::types::PortfolioSnapshot;
155    use ndarray::{ArrayD, IxDyn};
156    use solver::lookup::LookupTable;
157
158    fn flat_table_4d(axes: Vec<Vec<f64>>, value: f64) -> LookupTable {
159        let shape: Vec<usize> = axes.iter().map(|a| a.len()).collect();
160        let n: usize = shape.iter().product();
161        let data = ArrayD::from_shape_vec(IxDyn(&shape), vec![value; n]).unwrap();
162        LookupTable::new(axes, data)
163    }
164
165    fn make_obs_bilateral(position: f64, lambda_plus: f64, lambda_minus: f64) -> Observation {
166        let mut params = std::collections::HashMap::new();
167        params.insert("hawkes_buy_intensity".to_string(), lambda_plus);
168        params.insert("hawkes_sell_intensity".to_string(), lambda_minus);
169        Observation {
170            timestamp: 0.0,
171            best_bid: 99.95,
172            best_ask: 100.05,
173            last_price: Some(100.0),
174            portfolio: PortfolioSnapshot {
175                cash: 10000.0,
176                position,
177            },
178            volatility: None,
179            drift: None,
180            parameters: Some(params),
181        }
182    }
183
184    fn make_strategy(spread: f64) -> AvellanedaStoikovBilateralHawkesStrategy {
185        let axes = vec![
186            vec![-1.0, 0.0, 1.0],     // q
187            vec![0.5, 1.0, 2.0],      // lambda+
188            vec![0.5, 1.0, 2.0],      // lambda-
189            vec![0.0, 500.0, 1000.0], // tau
190        ];
191        let bid_table = flat_table_4d(axes.clone(), spread);
192        let ask_table = flat_table_4d(axes, spread);
193        let params = AvellanedaStoikovBilateralHawkesParameters {
194            t_horizon: 1000.0,
195            base_intensity_buy: 1.0,
196            base_intensity_sell: 1.0,
197        };
198        AvellanedaStoikovBilateralHawkesStrategy::new(
199            params,
200            Arc::new(bid_table),
201            Arc::new(ask_table),
202        )
203    }
204
205    #[test]
206    fn test_places_bid_ask_around_mid() {
207        let mut strategy = make_strategy(0.05);
208        let obs = make_obs_bilateral(0.0, 1.0, 1.0);
209        let mut requests = Vec::new();
210        strategy.on_tick(&obs, &mut requests);
211
212        assert_eq!(requests.len(), 3); // CancelAll + bid + ask
213        let bid_price = match &requests[1] {
214            OrderRequest::New(o) => {
215                assert_eq!(o.side, Side::Buy);
216                o.limit_price
217            }
218            _ => panic!("expected bid order"),
219        };
220        let ask_price = match &requests[2] {
221            OrderRequest::New(o) => {
222                assert_eq!(o.side, Side::Sell);
223                o.limit_price
224            }
225            _ => panic!("expected ask order"),
226        };
227        assert!((bid_price - 99.95).abs() < 0.01, "bid={bid_price}");
228        assert!((ask_price - 100.05).abs() < 0.01, "ask={ask_price}");
229    }
230
231    #[test]
232    fn test_fallback_to_base_intensity_when_params_absent() {
233        let mut strategy = make_strategy(0.05);
234        let obs = Observation {
235            timestamp: 0.0,
236            best_bid: 99.95,
237            best_ask: 100.05,
238            last_price: Some(100.0),
239            portfolio: PortfolioSnapshot {
240                cash: 10000.0,
241                position: 0.0,
242            },
243            volatility: None,
244            drift: None,
245            parameters: None, // no Hawkes intensity provided
246        };
247        let mut requests = Vec::new();
248        strategy.on_tick(&obs, &mut requests);
249        // Should not panic; fallback intensities used
250        assert_eq!(requests.len(), 3);
251    }
252
253    #[test]
254    fn test_cancels_all_when_horizon_elapsed() {
255        let axes = vec![
256            vec![-1.0, 0.0, 1.0],
257            vec![0.5, 1.0, 2.0],
258            vec![0.5, 1.0, 2.0],
259            vec![0.0, 500.0, 1000.0],
260        ];
261        let bid_table = flat_table_4d(axes.clone(), 0.05);
262        let ask_table = flat_table_4d(axes, 0.05);
263        // t_horizon = 0 means tau <= 0 immediately
264        let params = AvellanedaStoikovBilateralHawkesParameters {
265            t_horizon: 0.0,
266            base_intensity_buy: 1.0,
267            base_intensity_sell: 1.0,
268        };
269        let mut strategy = AvellanedaStoikovBilateralHawkesStrategy::new(
270            params,
271            Arc::new(bid_table),
272            Arc::new(ask_table),
273        );
274        let obs = make_obs_bilateral(0.0, 1.0, 1.0);
275        let mut requests = Vec::new();
276        strategy.on_tick(&obs, &mut requests);
277        assert_eq!(requests.len(), 1);
278        assert!(matches!(requests[0], OrderRequest::CancelAll));
279    }
280}