Skip to main content

engine/strategies/
avellaneda_stoikov_exact.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use solver::lookup::LookupTable;
4use std::sync::Arc;
5
6/// Parameters for the exact Avellaneda-Stoikov GBM strategy (constant sigma).
7///
8/// The strategy looks up optimal bid/ask spreads from precomputed 2-D tables
9/// indexed by `[inventory q, time remaining tau]`.
10/// Tables are generated by solving the AS matrix ODE exactly (no approximation).
11#[derive(Clone, Debug)]
12pub struct AvellanedaStoikovExactParameters {
13    /// Trading horizon in model seconds -- must match the tau axis of the tables.
14    pub t_horizon: f64,
15}
16
17/// Avellaneda-Stoikov strategy driven by the exact GBM matrix-ODE solution.
18///
19/// On every tick the strategy:
20///   1. Computes tau = t_horizon - elapsed_seconds.
21///   2. Interpolates the 2-D spread tables at (q, tau).
22///   3. Places bid/ask orders at mid +/- interpolated_spread.
23///
24/// # Table format
25/// Both tables must be 2-dimensional with axes [q, tau]:
26///   - axis 0: inventory grid (e.g. -10..10)
27///   - axis 1: time remaining in model seconds (0.0..T, increasing)
28///
29/// Use `gen_as_spread_tables` from the Python bindings to generate compatible tables.
30#[derive(Clone)]
31pub struct AvellanedaStoikovExactStrategy {
32    pub params: AvellanedaStoikovExactParameters,
33    /// 2D lookup table for the bid half-spread: axes [q, tau].
34    pub bid_spread_table: Arc<LookupTable>,
35    /// 2D lookup table for the ask half-spread: axes [q, tau].
36    pub ask_spread_table: Arc<LookupTable>,
37    q_min: f64,
38    q_max: f64,
39    start_time: Option<f64>,
40    order_counter: u64,
41}
42
43impl AvellanedaStoikovExactStrategy {
44    pub fn new(
45        params: AvellanedaStoikovExactParameters,
46        bid_spread_table: Arc<LookupTable>,
47        ask_spread_table: Arc<LookupTable>,
48    ) -> Self {
49        assert_eq!(
50            bid_spread_table.axes.len(),
51            2,
52            "bid table must be 2D [q, tau]"
53        );
54        assert_eq!(
55            ask_spread_table.axes.len(),
56            2,
57            "ask table must be 2D [q, tau]"
58        );
59        let q_axis = &bid_spread_table.axes[0];
60        assert!(!q_axis.is_empty(), "q axis must not be empty");
61        let q_min = q_axis[0];
62        let q_max = q_axis[q_axis.len() - 1];
63        Self {
64            params,
65            bid_spread_table,
66            ask_spread_table,
67            q_min,
68            q_max,
69            start_time: None,
70            order_counter: 0,
71        }
72    }
73}
74
75impl Strategy for AvellanedaStoikovExactStrategy {
76    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
77        if self.start_time.is_none() {
78            self.start_time = Some(obs.timestamp);
79        }
80
81        let elapsed = obs.timestamp - self.start_time.unwrap();
82        let tau = (self.params.t_horizon - elapsed).max(0.0);
83
84        if tau <= 0.0 {
85            requests.push(OrderRequest::CancelAll);
86            return;
87        }
88
89        let mid = obs.mid_price();
90        let q = obs.portfolio.position;
91
92        // Clamp to 1e-9 rather than 0.0 -- see avellaneda_stoikov_heston.rs.
93        let delta_bid = self.bid_spread_table.interpolate(&[q, tau]).max(1e-9);
94        let delta_ask = self.ask_spread_table.interpolate(&[q, tau]).max(1e-9);
95
96        // Use exact prices (no tick rounding). See avellaneda_stoikov_heston.rs.
97        let bid_price = mid - delta_bid;
98        let ask_price = mid + delta_ask;
99
100        // Mirror the HJB calibration boundary enforcement:
101        //   lambda_bid = 0 when q >= q_max  =>  no bid at or above q_max
102        //   lambda_ask = 0 when q <= q_min  =>  no ask at or below q_min
103        let place_bid = q < self.q_max;
104        let place_ask = q > self.q_min;
105
106        requests.push(OrderRequest::CancelAll);
107        if place_bid {
108            self.order_counter += 1;
109            let bid_id = self.order_counter;
110            requests.push(OrderRequest::New(Order::new(
111                bid_id,
112                Side::Buy,
113                bid_price,
114                1.0,
115            )));
116        }
117        if place_ask {
118            self.order_counter += 1;
119            let ask_id = self.order_counter;
120            requests.push(OrderRequest::New(Order::new(
121                ask_id,
122                Side::Sell,
123                ask_price,
124                1.0,
125            )));
126        }
127    }
128
129    fn as_any(&self) -> &dyn std::any::Any {
130        self
131    }
132    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
133        self
134    }
135}