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}