Skip to main content

solver/analytical/avellaneda/approximations/
gueant.rs

1use crate::analytical::traits::AnalyticalSolution;
2use crate::linalg::eigen::eigen_symmetric_tridiagonal;
3use crate::models::market_making::MarketMakingControl;
4
5/// The asymptotic approximation (infinite horizon) for the Avellaneda-Stoikov model.
6/// Based on the Gueant-Lehalle-Tapia closed-form solution.
7///
8/// Reference: Guéant, O., Lehalle, C. A., & Tapia, J. (2013). Dealing with the inventory risk: a solution to the market making problem.
9pub struct AvellanedaGueant {
10    pub gamma: f64,
11    pub sigma: f64,
12    pub kappa: f64,
13    pub a: f64,
14    /// Inventory truncation used when evaluating the value reference.
15    pub q_max: usize,
16}
17
18impl AvellanedaGueant {
19    pub fn new(gamma: f64, sigma: f64, kappa: f64, a: f64) -> Self {
20        Self {
21            gamma,
22            sigma,
23            kappa,
24            a,
25            q_max: 50,
26        }
27    }
28
29    /// Sets the inventory truncation used for the value reference.
30    pub fn with_q_max(mut self, q_max: usize) -> Self {
31        assert!(q_max > 0, "q_max must be positive");
32        self.q_max = q_max;
33        self
34    }
35
36    /// Guéant-Lehalle-Tapia closed-form approximation for optimal spreads.
37    ///
38    /// This approximation assumes infinite time horizon ($T \to \infty$) and small spreads,
39    /// providing a stationary strategy that depends on inventory $q$ but not time $t$.
40    ///
41    /// The spreads are symmetric around the mid-price, shifted by inventory:
42    /// $\delta^\pm(q) \approx \text{RiskNeutral} \pm \text{InventorySkew} \cdot q$
43    ///
44    /// Reference: Guéant, O., Lehalle, C. A., & Tapia, J. (2013). Dealing with the inventory risk.
45    pub fn approximate_spreads(&self, q: f64) -> (f64, f64) {
46        let risk_factor = (self.sigma.powi(2) * self.gamma / (2.0 * self.kappa * self.a)).sqrt();
47        let const_term = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
48        let correction =
49            (1.0 + self.gamma / self.kappa).powf(0.5 * (1.0 + self.kappa / self.gamma));
50
51        let delta_bid = const_term + (2.0 * q + 1.0) / 2.0 * risk_factor * correction;
52        let delta_ask = const_term - (2.0 * q - 1.0) / 2.0 * risk_factor * correction;
53
54        (delta_bid, delta_ask)
55    }
56
57    /// Stationary value function `theta(q)` for the reduced Avellaneda model.
58    ///
59    /// The exact stationary solution of the GLT linearized HJB is the principal
60    /// eigenvector `v(q)` of the symmetric tridiagonal operator with diagonal
61    /// `-alpha q^2` and off-diagonal `eta`, where
62    ///
63    /// ```text
64    /// alpha = (kappa / 2) gamma sigma^2
65    /// eta   = a (1 + gamma/kappa)^{-(1 + kappa/gamma)}
66    /// ```
67    ///
68    /// The reduced solver works directly in `theta` space, with
69    /// `v(q) = exp(kappa theta(q))`. The value is normalized so `theta(0) = 0`,
70    /// which removes the one-parameter translation invariance of the stationary
71    /// HJB. Only differences `theta(q) - theta(q')` are invariant under the
72    /// normalization and therefore meaningful.
73    ///
74    /// # Panics
75    ///
76    /// Panics if `q` is outside `[-q_max, q_max]`.
77    pub fn stationary_theta(&self, q_max: usize, q: i32) -> f64 {
78        let n = 2 * q_max + 1;
79        assert!(
80            q >= -(q_max as i32) && q <= q_max as i32,
81            "inventory q must lie within [-q_max, q_max]"
82        );
83        let idx = (q + q_max as i32) as usize;
84        assert!(idx < n, "index {idx} out of range for n={n}");
85
86        let alpha = (self.kappa / 2.0) * self.gamma * self.sigma.powi(2);
87        let eta = self.a * (1.0 + self.gamma / self.kappa).powf(-(1.0 + self.kappa / self.gamma));
88
89        let mut d = vec![0.0; n];
90        let mut e = vec![0.0; n - 1];
91        for i in 0..n {
92            let q_val = (i as i32) - (q_max as i32);
93            d[i] = -alpha * (q_val as f64).powi(2);
94            if i < n - 1 {
95                e[i] = eta;
96            }
97        }
98
99        let (_evals, evecs) = eigen_symmetric_tridiagonal(d, e);
100        // dstev returns eigenvalues ascending; the principal eigenvector is the
101        // one with the largest eigenvalue.
102        let k = n - 1;
103        let v_q = evecs[idx + k * n];
104        let v_0 = evecs[q_max + k * n];
105
106        (1.0 / self.kappa) * (v_q / v_0).ln()
107    }
108
109    /// Stationary bid/ask spreads implied by the principal-eigenvector value.
110    ///
111    /// The bid spread is `base + (theta(q) - theta(q+1)) / 2` adjusted for the
112    /// half-inventory convention, and the ask spread is the mirror around the
113    /// base spread. This matches the `approximate_spreads` small-gamma limit.
114    pub fn stationary_spreads(&self, q_max: usize, q: i32) -> (f64, f64) {
115        let base = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
116        let theta_q = self.stationary_theta(q_max, q);
117
118        // Use one-sided differences at the inventory boundary, matching the
119        // solver's boundary derivative convention.
120        let theta_up = if q < q_max as i32 {
121            self.stationary_theta(q_max, q + 1)
122        } else {
123            theta_q
124        };
125        let theta_dn = if q > -(q_max as i32) {
126            self.stationary_theta(q_max, q - 1)
127        } else {
128            theta_q
129        };
130
131        let bid = base + (theta_q - theta_up);
132        let ask = base + (theta_q - theta_dn);
133        (bid, ask)
134    }
135}
136
137impl AnalyticalSolution<2> for AvellanedaGueant {
138    fn value_function(&self, _t: f64, state: &[f64; 2]) -> f64 {
139        self.stationary_theta(self.q_max, state[0].round() as i32)
140    }
141
142    fn optimal_controls(&self, _t: f64, state: &[f64; 2]) -> MarketMakingControl {
143        let q = state[0];
144        let (d_bid, d_ask) = self.approximate_spreads(q);
145
146        let lambda_bid = self.a * (-self.kappa * d_bid).exp();
147        let lambda_ask = self.a * (-self.kappa * d_ask).exp();
148
149        MarketMakingControl::new(lambda_bid, lambda_ask)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn default_gueant() -> AvellanedaGueant {
158        AvellanedaGueant::new(0.1, 0.1, 1.5, 140.0).with_q_max(50)
159    }
160
161    #[test]
162    fn stationary_theta_is_zero_at_zero_inventory() {
163        let gueant = default_gueant();
164        let theta = gueant.stationary_theta(50, 0);
165        assert!((theta - 0.0).abs() < 1e-12);
166    }
167
168    #[test]
169    fn stationary_theta_decreases_with_inventory_magnitude() {
170        let gueant = default_gueant();
171        let theta0 = gueant.stationary_theta(50, 0);
172        let theta1 = gueant.stationary_theta(50, 1);
173        let theta2 = gueant.stationary_theta(50, 2);
174        assert!(theta1 < theta0);
175        assert!(theta2 < theta1);
176    }
177
178    #[test]
179    fn stationary_spreads_match_small_gamma_approximation() {
180        let gueant = default_gueant();
181        let q = 3;
182        let (bid_approx, ask_approx) = gueant.approximate_spreads(q as f64);
183        let (bid, ask) = gueant.stationary_spreads(50, q);
184
185        // The GLT approximation and the principal-eigenvector shape agree in the
186        // small-gamma (small-spread) limit. A loose tolerance is intentional.
187        assert!((bid - bid_approx).abs() < 1e-2, "bid {bid} vs {bid_approx}");
188        assert!((ask - ask_approx).abs() < 1e-2, "ask {ask} vs {ask_approx}");
189    }
190}