Skip to main content

solver/models/
avellaneda.rs

1// src/models/avellaneda.rs
2use super::normal_cdf;
3use super::traits::{ControlOutput, Gradients, Model};
4
5/// The classic Avellaneda-Stoikov market making model.
6///
7/// Models a market maker optimizing bid/ask quotes to maximize utility of terminal wealth
8/// while penalizing inventory risk.
9///
10/// # Dynamics
11/// * Price: $dS_t = \sigma dW_t$
12/// * Inventory: $dq_t = dN^{buy}_t - dN^{sell}_t$
13/// * Cash: $dX_t = (S_t + \delta_a) dN^{sell}_t - (S_t - \delta_b) dN^{buy}_t$
14#[derive(Clone)]
15pub struct AvellanedaStoikov {
16    /// Risk aversion parameter ($\gamma$). Controls the penalty for holding inventory.
17    pub gamma: f64,
18    /// Volatility of the mid-price ($\sigma$).
19    pub sigma: f64,
20    /// Order filling probability parameter ($\kappa$). Intensity decay rate.
21    pub kappa: f64,
22    /// Base order arrival intensity ($A$).
23    pub a: f64,
24}
25
26impl AvellanedaStoikov {
27    /// Computes the optimal bid/ask spreads based on current value function gradients.
28    ///
29    /// # formula
30    /// $$ \delta^* = \frac{1}{\gamma} \ln(1 + \frac{\gamma}{\kappa}) \pm \frac{\partial V}{\partial q} $$
31    pub fn get_spreads(&self, grads: &Gradients<2>) -> (f64, f64) {
32        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
33
34        let dv_dq_buy = grads.fwd[0];
35        let dv_dq_sell = grads.bwd[0];
36
37        let mut delta_bid = base_spread - dv_dq_buy;
38        let mut delta_ask = base_spread + dv_dq_sell;
39
40        // Safety cap to prevent e^50 explosions
41        let min_spread = -10.0;
42        delta_bid = delta_bid.max(min_spread);
43        delta_ask = delta_ask.max(min_spread);
44
45        (delta_bid, delta_ask)
46    }
47}
48
49impl Model<2> for AvellanedaStoikov {
50    type Process = ();
51
52    fn process(&self) {}
53
54    fn optimize(&self, state: &[f64; 2], grads: &Gradients<2>) -> ControlOutput<2> {
55        let q = state[0];
56        // state[1] is Price S, but AS model drift/intensity depends only on inventory q.
57
58        let (d_bid, d_ask) = self.get_spreads(grads);
59
60        // Compute intensities based on spreads: Lambda = A * e^{-k * delta}
61        let lambda_bid = self.a * (-self.kappa * d_bid).exp();
62        let lambda_ask = self.a * (-self.kappa * d_ask).exp();
63
64        // Hamiltonian for Exponential Utility (Guéant-Lehalle-Tapia transformation):
65        // The standard HJB for the transformed variable \theta(t,q) involves:
66        // H = sup_d { \lambda(d) [1 - e^{-\gamma(d - \Delta \theta)}] } / \gamma
67        // With optimal spreads, this simplifies to term below:
68        let hamiltonian_val = (lambda_bid + lambda_ask) / (self.gamma + self.kappa);
69
70        // Running penalty term (risk aversion * volatility)
71        let risk_penalty = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
72
73        // Term Cancellation Strategy:
74        // The analytical control (Gueant-Lehalle-Tapia) implicitly includes the jump cost terms
75        // (lambda * (V(q') - V(q))) due to the utility transformation.
76        // However, the BSDE solver explicitly adds these drift/jump terms via its driver update.
77        // To avoid double counting, we must subtract the explicit jump terms here.
78        let drift_correction = lambda_bid * grads.fwd[0] - lambda_ask * grads.bwd[0];
79
80        // Dim 0 is Inventory.
81
82        // lambda_plus[0] = Buy Intensity (q -> q+1)
83        // lambda_minus[0] = Sell Intensity (q -> q-1)
84        // Dim 1 is Price (No jumps relevant to value function in reduced coords, but kept for interface)
85        ControlOutput {
86            lambda_plus: [lambda_bid, 0.0],
87            lambda_minus: [lambda_ask, 0.0],
88            flow: hamiltonian_val + risk_penalty - drift_correction,
89        }
90    }
91
92    fn terminal(&self, _state: &[f64; 2]) -> f64 {
93        0.0
94    }
95
96    fn constant_discount_rate(&self) -> Option<f64> {
97        Some(0.0)
98    }
99
100    fn next_step(&self, current_state: &[f64; 2], dt: f64, noise: &[f64; 2]) -> [f64; 2] {
101        let mut next = *current_state;
102        // q (index 0) does not diffuse
103        // S (index 1) diffuses with volatility sigma
104        // noise is N(0, 1)
105
106        let sqrt_dt = dt.sqrt();
107        next[1] += self.sigma * sqrt_dt * noise[1];
108
109        next
110    }
111
112    fn next_step_controlled(
113        &self,
114        current_state: &[f64; 2],
115        control: &ControlOutput<2>,
116        dt: f64,
117        noise: &[f64; 2],
118    ) -> [f64; 2] {
119        let mut next = *current_state;
120        // Dim 0 (q): Bernoulli fill simulation.
121        let u = normal_cdf(noise[0]);
122        let p_bid = (control.lambda_plus[0] * dt).clamp(0.0, 1.0);
123        let p_ask = (control.lambda_minus[0] * dt).clamp(0.0, 1.0);
124        if u < p_bid {
125            next[0] += 1.0;
126        } else if u > 1.0 - p_ask {
127            next[0] -= 1.0;
128        }
129        // Dim 1 (S): diffusive price.
130        let sqrt_dt = dt.sqrt();
131        next[1] += self.sigma * sqrt_dt * noise[1];
132        next
133    }
134
135    fn is_diffusion_dimension(&self, dim: usize) -> bool {
136        dim == 1
137    }
138
139    fn is_integer_dimension(&self, dim: usize) -> bool {
140        dim == 0 // q is discrete integer; S is continuous
141    }
142
143    fn fill_rate_base(&self, _state: &[f64; 2]) -> f64 {
144        self.a
145    }
146
147    fn fill_rate_decay(&self) -> f64 {
148        self.kappa
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::super::traits::{Gradients, Model};
155    use super::*;
156
157    fn zero_grads() -> Gradients<2> {
158        Gradients {
159            fwd: [0.0; 2],
160            bwd: [0.0; 2],
161        }
162    }
163
164    fn default_model() -> AvellanedaStoikov {
165        AvellanedaStoikov {
166            gamma: 0.1,
167            sigma: 0.1,
168            kappa: 1.5,
169            a: 140.0,
170        }
171    }
172
173    #[test]
174    fn zero_gradient_gives_base_spread() {
175        let m = default_model();
176        let grads = zero_grads();
177        let (bid, ask) = m.get_spreads(&grads);
178        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
179        assert!((bid - base).abs() < 1e-12);
180        assert!((ask - base).abs() < 1e-12);
181    }
182
183    #[test]
184    fn symmetric_at_zero_inventory() {
185        let m = default_model();
186        let grads = zero_grads();
187        let ctrl = m.optimize(&[0.0, 100.0], &grads);
188        assert!(
189            (ctrl.lambda_plus[0] - ctrl.lambda_minus[0]).abs() < 1e-12,
190            "Bid/ask intensities should be equal at q=0"
191        );
192    }
193
194    #[test]
195    fn positive_flow_at_zero_inventory() {
196        let m = default_model();
197        let grads = zero_grads();
198        let ctrl = m.optimize(&[0.0, 100.0], &grads);
199        assert!(
200            ctrl.flow > 0.0,
201            "Flow should be positive at q=0: hamiltonian > risk_penalty"
202        );
203    }
204
205    #[test]
206    fn risk_penalty_increases_with_inventory() {
207        let m = default_model();
208        let grads = zero_grads();
209        let ctrl_0 = m.optimize(&[0.0, 100.0], &grads);
210        let ctrl_5 = m.optimize(&[5.0, 100.0], &grads);
211        assert!(
212            ctrl_5.flow < ctrl_0.flow,
213            "Higher inventory should decrease flow due to risk penalty"
214        );
215    }
216
217    #[test]
218    fn terminal_is_zero() {
219        let m = default_model();
220        assert_eq!(m.terminal(&[0.0, 100.0]), 0.0);
221        assert_eq!(m.terminal(&[5.0, 200.0]), 0.0);
222    }
223
224    #[test]
225    fn next_step_preserves_inventory() {
226        let m = default_model();
227        let state = [3.0, 100.0];
228        let next = m.next_step(&state, 0.01, &[0.5, 1.0]);
229        assert_eq!(
230            next[0], 3.0,
231            "Inventory should not change in diffusion step"
232        );
233    }
234
235    #[test]
236    fn next_step_diffuses_price() {
237        let m = default_model();
238        let state = [0.0, 100.0];
239        let next = m.next_step(&state, 0.01, &[0.0, 1.0]);
240        assert!(
241            (next[1] - 100.0).abs() > 1e-10,
242            "Price should diffuse with non-zero noise"
243        );
244    }
245
246    #[test]
247    fn dim1_is_diffusion() {
248        let m = default_model();
249        assert!(!m.is_diffusion_dimension(0));
250        assert!(m.is_diffusion_dimension(1));
251    }
252}