Skip to main content

engine/strategies/
avellaneda_stoikov_heston.rs

1use super::Strategy;
2use crate::types::{Observation, Order, OrderRequest, Side};
3use solver::lookup::LookupTable;
4use std::sync::Arc;
5
6/// Parameters for the Heston-aware FDM Avellaneda-Stoikov strategy.
7///
8/// The strategy looks up optimal bid/ask spreads from precomputed 3-D FDM tables
9/// indexed by `[inventory q, variance v, time remaining tau]`.
10/// Tables are generated by solving the Heston market-making HJB PDE offline.
11#[derive(Clone, Debug)]
12pub struct AvellanedaStoikovHestonParameters {
13    /// Trading horizon in model seconds -- must match the tau axis of the tables.
14    pub t_horizon: f64,
15    /// Fallback variance (v = sigma^2) when `obs.volatility` is absent.
16    pub base_variance: f64,
17}
18
19/// Avellaneda-Stoikov strategy driven by precomputed FDM Heston spreads.
20///
21/// On every tick the strategy:
22///   1. Reads variance v = obs.volatility^2 (obs.volatility = sqrt(v_t) from HestonProcess).
23///   2. Interpolates the FDM spread tables at (q, v, tau).
24///   3. Places bid/ask orders at mid +/- interpolated_spread.
25///
26/// # Table format
27/// Both tables must be 3-dimensional with axes [q, v, tau]:
28///   - axis 0: inventory grid (e.g. -10..10)
29///   - axis 1: variance grid  (e.g. 0.0001..0.05, matching v_theta of the Heston model)
30///   - axis 2: time remaining in model seconds (0.0..T_HORIZON, increasing)
31///
32/// Use `build_heston_tables` from `heston_param_sweep.rs` to generate compatible tables.
33#[derive(Clone)]
34pub struct AvellanedaStoikovHestonStrategy {
35    pub params: AvellanedaStoikovHestonParameters,
36    /// 3D lookup table for the bid half-spread: axes [q, v, tau].
37    pub bid_spread_table: Arc<LookupTable>,
38    /// 3D lookup table for the ask half-spread: axes [q, v, tau].
39    pub ask_spread_table: Arc<LookupTable>,
40    q_min: f64,
41    q_max: f64,
42    start_time: Option<f64>,
43    order_counter: u64,
44}
45
46impl AvellanedaStoikovHestonStrategy {
47    pub fn new(
48        params: AvellanedaStoikovHestonParameters,
49        bid_spread_table: Arc<LookupTable>,
50        ask_spread_table: Arc<LookupTable>,
51    ) -> Self {
52        assert_eq!(
53            bid_spread_table.axes.len(),
54            3,
55            "bid table must be 3D [q, v, tau]"
56        );
57        assert_eq!(
58            ask_spread_table.axes.len(),
59            3,
60            "ask table must be 3D [q, v, tau]"
61        );
62        let q_axis = &bid_spread_table.axes[0];
63        assert!(!q_axis.is_empty(), "q axis must not be empty");
64        let q_min = q_axis[0];
65        let q_max = q_axis[q_axis.len() - 1];
66        Self {
67            params,
68            bid_spread_table,
69            ask_spread_table,
70            q_min,
71            q_max,
72            start_time: None,
73            order_counter: 0,
74        }
75    }
76}
77
78impl Strategy for AvellanedaStoikovHestonStrategy {
79    fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
80        if self.start_time.is_none() {
81            self.start_time = Some(obs.timestamp);
82        }
83
84        let elapsed = obs.timestamp - self.start_time.unwrap();
85        let tau = (self.params.t_horizon - elapsed).max(0.0);
86
87        if tau <= 0.0 {
88            requests.push(OrderRequest::CancelAll);
89            return;
90        }
91
92        let mid = obs.mid_price();
93        let q = obs.portfolio.position;
94
95        // obs.volatility = sqrt(v_t) injected by the engine from HestonProcess.
96        // Squaring recovers the instantaneous variance v_t.
97        let v = obs
98            .volatility
99            .map(|vol| vol * vol)
100            .unwrap_or(self.params.base_variance);
101
102        // Clamp to 1e-9 rather than 0.0.
103        // When the HJB table gives delta < 0 (exit side, large inventory), the
104        // model intent is a near-instant fill (lambda -> a as delta -> 0+).
105        // Using exactly 0.0 sets bid_price = mid, which triggers the price-sweep
106        // fill condition (mid <= bid_price) on every step. Using 1e-9 keeps the
107        // order strictly passive while preserving the near-maximum fill rate.
108        let delta_bid = self.bid_spread_table.interpolate(&[q, v, tau]).max(1e-9);
109        let delta_ask = self.ask_spread_table.interpolate(&[q, v, tau]).max(1e-9);
110
111        // Use exact prices (no tick rounding).
112        //
113        // The stochastic fill model (lambda = a * exp(-k * delta)) uses the exact
114        // limit price; it is not a discrete LOB. Rounding is problematic at the
115        // normalised price scale S0=1 where a 1-cent tick is orders of magnitude
116        // larger than the market half-spread, and even 4dp ticks can trigger the
117        // price-sweep fill condition when delta is small.
118        let bid_price = mid - delta_bid;
119        let ask_price = mid + delta_ask;
120
121        // Mirror the HJB calibration boundary enforcement:
122        //   lambda_bid = 0 when q >= q_max  =>  no bid at or above q_max
123        //   lambda_ask = 0 when q <= q_min  =>  no ask at or below q_min
124        let place_bid = q < self.q_max;
125        let place_ask = q > self.q_min;
126
127        requests.push(OrderRequest::CancelAll);
128        if place_bid {
129            self.order_counter += 1;
130            let bid_id = self.order_counter;
131            requests.push(OrderRequest::New(Order::new(
132                bid_id,
133                Side::Buy,
134                bid_price,
135                1.0,
136            )));
137        }
138        if place_ask {
139            self.order_counter += 1;
140            let ask_id = self.order_counter;
141            requests.push(OrderRequest::New(Order::new(
142                ask_id,
143                Side::Sell,
144                ask_price,
145                1.0,
146            )));
147        }
148    }
149
150    fn as_any(&self) -> &dyn std::any::Any {
151        self
152    }
153    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
154        self
155    }
156}