Skip to main content

engine/strategies/
avellaneda_stoikov_bilateral_hawkes_order_flow_imbalance.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 with order flow imbalance 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 `BilateralHawkesOrderFlowImbalance` HJB PDE offline.
11///
12/// The key behavioral difference from plain `BilateralHawkes`:
13/// - Spreads adapt to order flow imbalance (lambda+ vs lambda-) with sensitivity parameter eta
14/// - When lambda+ > lambda- (toxic buying): widens ask, narrows bid
15/// - When lambda- > lambda+ (toxic selling): narrows ask, widens bid
16#[derive(Clone, Debug)]
17pub struct AvellanedaStoikovBilateralHawkesOrderFlowImbalanceParameters {
18    /// Trading horizon in model seconds -- must match the tau axis of the tables.
19    pub t_horizon: f64,
20    /// Fallback buy-side intensity when `hawkes_buy_intensity` is absent.
21    pub base_intensity_buy: f64,
22    /// Fallback sell-side intensity when `hawkes_sell_intensity` is absent.
23    pub base_intensity_sell: f64,
24}
25
26/// Avellaneda-Stoikov strategy driven by precomputed FDM bilateral-Hawkes-with-OFI spreads.
27///
28/// On every tick the strategy:
29///   1. Reads `hawkes_buy_intensity` (lambda+) and `hawkes_sell_intensity` (lambda-)
30///      from `obs.parameters`.
31///   2. Interpolates the 4-D FDM spread tables at `(q, lambda+, lambda-, tau)`.
32///   3. Places bid/ask orders at `mid -/+ interpolated_spread`.
33///
34/// The spreads embed the adverse-selection response to order flow imbalance,
35/// automatically skewing quotes when one side is more toxic than the other.
36///
37/// # Table format
38/// Both tables must be 4-dimensional with axes `[q, lambda+, lambda-, tau]`:
39///   - axis 0: inventory grid (e.g. -10..10, 21 points)
40///   - axis 1: buy-side intensity grid (e.g. 0.0..8.0, 40 points)
41///   - axis 2: sell-side intensity grid (e.g. 0.0..8.0, 40 points)
42///   - axis 3: time remaining in seconds (0.0..T_HORIZON)
43#[derive(Clone)]
44pub struct AvellanedaStoikovBilateralHawkesOrderFlowImbalanceStrategy {
45    pub params: AvellanedaStoikovBilateralHawkesOrderFlowImbalanceParameters,
46    /// 4D lookup table for the bid half-spread: axes [q, lambda+, lambda-, tau].
47    pub bid_spread_table: Arc<LookupTable>,
48    /// 4D lookup table for the ask half-spread: axes [q, lambda+, lambda-, tau].
49    pub ask_spread_table: Arc<LookupTable>,
50    q_min: f64,
51    q_max: f64,
52    start_time: Option<f64>,
53    order_counter: u64,
54}
55
56impl AvellanedaStoikovBilateralHawkesOrderFlowImbalanceStrategy {
57    pub fn new(
58        params: AvellanedaStoikovBilateralHawkesOrderFlowImbalanceParameters,
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        let q_axis = &bid_spread_table.axes[0];
73        assert!(!q_axis.is_empty(), "q axis must not be empty");
74        let q_min = q_axis[0];
75        let q_max = q_axis[q_axis.len() - 1];
76        Self {
77            params,
78            bid_spread_table,
79            ask_spread_table,
80            q_min,
81            q_max,
82            start_time: None,
83            order_counter: 0,
84        }
85    }
86}
87
88impl Strategy for AvellanedaStoikovBilateralHawkesOrderFlowImbalanceStrategy {
89    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
90        if self.start_time.is_none() {
91            self.start_time = Some(obs.timestamp);
92        }
93
94        let elapsed = obs.timestamp - self.start_time.unwrap();
95        let tau = (self.params.t_horizon - elapsed).max(0.0);
96
97        if tau <= 0.0 {
98            requests.push(OrderRequest::CancelAll);
99            return;
100        }
101
102        let mid = obs.mid_price();
103        let q = obs.portfolio.position;
104
105        let params = obs.parameters.as_ref();
106
107        // lambda+ = buy market-order intensity (drives ask fills)
108        let lambda_plus = params
109            .and_then(|p| p.get("hawkes_buy_intensity").copied())
110            .unwrap_or(self.params.base_intensity_buy);
111
112        // lambda- = sell market-order intensity (drives bid fills)
113        let lambda_minus = params
114            .and_then(|p| p.get("hawkes_sell_intensity").copied())
115            .unwrap_or(self.params.base_intensity_sell);
116
117        // Clamp to 1e-9 rather than 0.0 -- see avellaneda_stoikov_heston.rs.
118        let delta_bid = self
119            .bid_spread_table
120            .interpolate(&[q, lambda_plus, lambda_minus, tau])
121            .max(1e-9);
122        let delta_ask = self
123            .ask_spread_table
124            .interpolate(&[q, lambda_plus, lambda_minus, tau])
125            .max(1e-9);
126
127        // Use exact prices (no tick rounding). See avellaneda_stoikov_heston.rs.
128        let bid_price = mid - delta_bid;
129        let ask_price = mid + delta_ask;
130        // Mirror the HJB calibration boundary enforcement:
131        //   lambda_bid_fill = 0 when q >= q_max  =>  no bid at or above q_max
132        //   lambda_ask_fill = 0 when q <= q_min  =>  no ask at or below q_min
133        let place_bid = q < self.q_max;
134        let place_ask = q > self.q_min;
135
136        requests.push(OrderRequest::CancelAll);
137        if place_bid {
138            self.order_counter += 1;
139            let bid_id = self.order_counter;
140            requests.push(OrderRequest::New(Order::new(
141                bid_id,
142                Side::Buy,
143                bid_price,
144                1.0,
145            )));
146        }
147        if place_ask {
148            self.order_counter += 1;
149            let ask_id = self.order_counter;
150            requests.push(OrderRequest::New(Order::new(
151                ask_id,
152                Side::Sell,
153                ask_price,
154                1.0,
155            )));
156        }
157    }
158
159    fn as_any(&self) -> &dyn std::any::Any {
160        self
161    }
162    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
163        self
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::types::PortfolioSnapshot;
171    use ndarray::{ArrayD, IxDyn};
172    use solver::lookup::LookupTable;
173
174    fn flat_table_4d(axes: Vec<Vec<f64>>, value: f64) -> LookupTable {
175        let shape: Vec<usize> = axes.iter().map(|a| a.len()).collect();
176        let n: usize = shape.iter().product();
177        let data = ArrayD::from_shape_vec(IxDyn(&shape), vec![value; n]).unwrap();
178        LookupTable::new(axes, data)
179    }
180
181    #[allow(dead_code)]
182    fn make_obs_ofi(position: f64, lambda_plus: f64, lambda_minus: f64) -> Observation {
183        let mut params = std::collections::HashMap::new();
184        params.insert("hawkes_buy_intensity".to_string(), lambda_plus);
185        params.insert("hawkes_sell_intensity".to_string(), lambda_minus);
186        Observation {
187            timestamp: 0.0,
188            best_bid: 99.95,
189            best_ask: 100.05,
190            last_price: Some(100.0),
191            portfolio: PortfolioSnapshot {
192                cash: 10_000.0,
193                position: position,
194            },
195            volatility: None,
196            drift: None,
197            parameters: Some(params),
198        }
199    }
200
201    #[test]
202    fn test_ofi_strategy_creation() {
203        let axes = vec![
204            vec![-10.0, 0.0, 10.0],
205            vec![0.0, 1.0, 2.0],
206            vec![0.0, 1.0, 2.0],
207            vec![0.0, 500.0, 1000.0],
208        ];
209        let bid_tbl = flat_table_4d(axes.clone(), 0.05);
210        let ask_tbl = flat_table_4d(axes, 0.05);
211
212        let params = AvellanedaStoikovBilateralHawkesOrderFlowImbalanceParameters {
213            t_horizon: 1000.0,
214            base_intensity_buy: 1.0,
215            base_intensity_sell: 1.0,
216        };
217
218        let strategy = AvellanedaStoikovBilateralHawkesOrderFlowImbalanceStrategy::new(
219            params,
220            Arc::new(bid_tbl),
221            Arc::new(ask_tbl),
222        );
223
224        assert_eq!(strategy.params.t_horizon, 1000.0);
225    }
226}