solver/models/stationary_avellaneda.rs
1//! Infinite-horizon Avellaneda-Stoikov market making in inventory space.
2//!
3//! This is a validating instance of the ergodic stationary path
4//! ([`crate::numeric::ergodic`]). It is deliberately not an
5//! [`crate::numeric::finite_difference::elliptic::EllipticControlProblem`]:
6//! the undiscounted market-making HJB is translation invariant and has no
7//! unique solution of `sup_u { f + L^u V } = 0`. Its exact
8//! `T -> infinity` limit is the principal eigenvector of the GLT operator.
9//!
10//! # Problem and conditions
11//!
12//! State: inventory `q in Z`. Control: bid/ask half-spreads
13//! `(delta_b, delta_a)`. Fill intensities are
14//!
15//! ```text
16//! lambda_b = a exp(-kappa delta_b)
17//! lambda_a = a exp(-kappa delta_a)
18//! ```
19//!
20//! The finite-horizon CARA value reduces to `theta(t, q)`; see
21//! `docs/src/reference/soc_exact.md`.
22//!
23//! # Exact solution
24//!
25//! Let `v_q = exp(kappa theta_q)`. The GLT substitution gives the linear
26//! system
27//!
28//! ```text
29//! v_q_t = alpha q^2 v_q - eta (v_{q-1} + v_{q+1})
30//! alpha = (kappa / 2) gamma sigma^2
31//! eta = a (1 + gamma/kappa)^-(1 + kappa/gamma)
32//! ```
33//!
34//! As `T -> infinity` the solution concentrates on the principal (Perron)
35//! eigenvector of the negated operator, assembled here as
36//!
37//! ```text
38//! A_qq = -alpha q^2, A_{q,q+1} = A_{q,q-1} = +eta
39//! ```
40//!
41//! The eigenvector is normalized by `theta(0) = 0`:
42//!
43//! ```text
44//! theta(q) = (1/kappa) ln(v_q / v_0)
45//! ```
46//!
47//! Optimal stationary spreads are recovered as
48//!
49//! ```text
50//! delta_b = base + theta(q) - theta(q+1)
51//! delta_a = base + theta(q) - theta(q-1)
52//! base = (1/gamma) ln(1 + gamma/kappa)
53//! ```
54//!
55//! See [`crate::analytical::avellaneda::approximations::gueant::AvellanedaGueant`]
56//! for the reference implementation used in tests.
57use crate::core::grid::Grid;
58use crate::linalg::csr::CsrMatrix;
59use crate::numeric::ergodic::StationaryEigenProblem;
60
61/// Infinite-horizon Avellaneda-Stoikov market making model in inventory space.
62///
63/// See the module-level documentation for the formulation and exact solution.
64#[derive(Clone, Debug)]
65pub struct StationaryAvellaneda {
66 /// Risk aversion for inventory holding.
67 pub gamma: f64,
68 /// Order filling intensity decay (`kappa`).
69 pub kappa: f64,
70 /// Base order arrival intensity (`A`).
71 pub a: f64,
72 /// Mid-price volatility.
73 pub sigma: f64,
74}
75
76impl StationaryAvellaneda {
77 /// Creates a stationary Avellaneda model.
78 ///
79 /// # Examples
80 ///
81 /// ```
82 /// use solver::models::stationary_avellaneda::StationaryAvellaneda;
83 /// let model = StationaryAvellaneda::new(0.1, 1.5, 140.0, 0.1);
84 /// assert_eq!(model.gamma, 0.1);
85 /// ```
86 pub fn new(gamma: f64, kappa: f64, a: f64, sigma: f64) -> Self {
87 Self {
88 gamma,
89 kappa,
90 a,
91 sigma,
92 }
93 }
94
95 /// The exact base half-spread at zero inventory.
96 pub fn base_spread(&self) -> f64 {
97 (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln()
98 }
99
100 /// The `alpha` diagonal coefficient of the GLT operator.
101 pub fn alpha(&self) -> f64 {
102 (self.kappa / 2.0) * self.gamma * self.sigma.powi(2)
103 }
104
105 /// The `eta` off-diagonal coefficient of the GLT operator.
106 pub fn eta(&self) -> f64 {
107 self.a * (1.0 + self.gamma / self.kappa).powf(-(1.0 + self.kappa / self.gamma))
108 }
109
110 /// Builds the symmetric GLT operator with the Perron sign convention.
111 fn glt_operator(&self, grid: &Grid<1>) -> CsrMatrix {
112 let n = grid.total_size();
113 let mut mat = CsrMatrix::new(n, n * 3);
114 let q0 = grid.min[0].round() as i32;
115 let alpha = self.alpha();
116 let eta = self.eta();
117
118 for i in 0..n {
119 let q = q0 + i as i32;
120 if i > 0 {
121 mat.add_entry(i - 1, eta);
122 }
123 mat.add_entry(i, -alpha * (q as f64).powi(2));
124 if i + 1 < n {
125 mat.add_entry(i + 1, eta);
126 }
127 mat.finish_row();
128 }
129
130 mat
131 }
132}
133
134impl StationaryEigenProblem<1> for StationaryAvellaneda {
135 fn operator(&self, grid: &Grid<1>) -> CsrMatrix {
136 self.glt_operator(grid)
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 fn default_model() -> StationaryAvellaneda {
145 StationaryAvellaneda::new(0.1, 1.5, 140.0, 0.1)
146 }
147
148 #[test]
149 fn base_spread_matches_reference() {
150 let model = default_model();
151 let base = (1.0 / model.gamma) * (1.0 + model.gamma / model.kappa).ln();
152 assert!((model.base_spread() - base).abs() < 1e-12);
153 }
154
155 #[test]
156 fn glt_coefficients_match_reference() {
157 let model = default_model();
158 let alpha = (model.kappa / 2.0) * model.gamma * model.sigma.powi(2);
159 let eta =
160 model.a * (1.0 + model.gamma / model.kappa).powf(-(1.0 + model.kappa / model.gamma));
161 assert!((model.alpha() - alpha).abs() < 1e-12);
162 assert!((model.eta() - eta).abs() < 1e-12);
163 }
164
165 #[test]
166 fn operator_is_symmetric_tridiagonal() {
167 let model = default_model();
168 let grid = Grid::<1>::new([9], [-4.0], [4.0]);
169 let mat = model.operator(&grid);
170
171 assert_eq!(mat.size, 9);
172 for i in 0..mat.size {
173 let start = mat.row_ptr[i];
174 let end = mat.row_ptr[i + 1];
175 assert!(end - start <= 3, "row {i} has too many entries");
176 for idx in start..end {
177 let col = mat.col_indices[idx];
178 assert!(
179 (col as i32 - i as i32).abs() <= 1,
180 "row {i} has non-tridiagonal column {col}"
181 );
182 }
183 }
184 }
185}