1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use solver::lookup::LookupTable;
4use std::sync::Arc;
5
6#[derive(Clone, Debug)]
13pub struct AvellanedaStoikovBilateralHawkesParameters {
14 pub t_horizon: f64,
16 pub base_intensity_buy: f64,
19 pub base_intensity_sell: f64,
21}
22
23#[derive(Clone)]
46pub struct AvellanedaStoikovBilateralHawkesStrategy {
47 pub params: AvellanedaStoikovBilateralHawkesParameters,
48 pub bid_spread_table: Arc<LookupTable>,
50 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 let lambda_plus = params
103 .and_then(|p| p.get("hawkes_buy_intensity").copied())
104 .unwrap_or(self.params.base_intensity_buy);
105
106 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], vec![0.5, 1.0, 2.0], vec![0.5, 1.0, 2.0], vec![0.0, 500.0, 1000.0], ];
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); 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, };
247 let mut requests = Vec::new();
248 strategy.on_tick(&obs, &mut requests);
249 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 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}