Skip to main content

solver/models/
avellaneda.rs

1// src/models/avellaneda.rs
2use super::control::{ControlProblem, StateDerivatives};
3use super::market_making::MarketMakingControl;
4use super::normal_cdf;
5use crate::numeric::finite_difference::discretization::{DimensionKind, Transport};
6use crate::numeric::finite_difference::elliptic::EllipticControlProblem;
7use crate::numeric::finite_difference::pde::PdeProblem;
8
9/// The classic Avellaneda-Stoikov market making model.
10///
11/// Models a market maker optimizing bid/ask quotes to maximize utility of terminal wealth
12/// while penalizing inventory risk.
13///
14/// # Dynamics
15/// * Price: $dS_t = \sigma dW_t$
16/// * Inventory: $dq_t = dN^{buy}_t - dN^{sell}_t$
17/// * Cash: $dX_t = (S_t + \delta_a) dN^{sell}_t - (S_t - \delta_b) dN^{buy}_t$
18#[derive(Clone)]
19pub struct AvellanedaStoikov {
20    /// Risk aversion parameter ($\gamma$). Controls the penalty for holding inventory.
21    pub gamma: f64,
22    /// Volatility of the mid-price ($\sigma$).
23    pub sigma: f64,
24    /// Order filling probability parameter ($\kappa$). Intensity decay rate.
25    pub kappa: f64,
26    /// Base order arrival intensity ($A$).
27    pub a: f64,
28}
29
30impl AvellanedaStoikov {
31    /// Computes the optimal bid/ask spreads based on current value function gradients.
32    ///
33    /// # formula
34    /// $$ \delta^* = \frac{1}{\gamma} \ln(1 + \frac{\gamma}{\kappa}) \pm \frac{\partial V}{\partial q} $$
35    pub fn get_spreads(&self, derivs: &StateDerivatives<2>) -> (f64, f64) {
36        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
37
38        let dv_dq_buy = derivs.fwd[0];
39        let dv_dq_sell = derivs.bwd[0];
40
41        let mut delta_bid = base_spread - dv_dq_buy;
42        let mut delta_ask = base_spread + dv_dq_sell;
43
44        // Safety cap to prevent e^50 explosions
45        let min_spread = -10.0;
46        delta_bid = delta_bid.max(min_spread);
47        delta_ask = delta_ask.max(min_spread);
48
49        (delta_bid, delta_ask)
50    }
51
52    /// The base arrival intensity used for intensity-to-spread conversion.
53    pub fn fill_rate_base(&self, _state: &[f64; 2]) -> f64 {
54        self.a
55    }
56
57    /// The fill-rate decay parameter used for intensity-to-spread conversion.
58    pub fn fill_rate_decay(&self) -> f64 {
59        self.kappa
60    }
61}
62
63impl ControlProblem<2> for AvellanedaStoikov {
64    type Control = MarketMakingControl;
65
66    fn optimize(&self, _t: f64, _state: &[f64; 2], derivs: &StateDerivatives<2>) -> Self::Control {
67        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
68        let delta_bid = (base_spread - derivs.fwd[0]).max(-10.0);
69        let delta_ask = (base_spread + derivs.bwd[0]).max(-10.0);
70
71        MarketMakingControl::new(
72            self.a * (-self.kappa * delta_bid).exp(),
73            self.a * (-self.kappa * delta_ask).exp(),
74        )
75    }
76
77    fn running_reward(&self, _t: f64, _state: &[f64; 2], control: &Self::Control) -> f64 {
78        // Optimized Hamiltonian H* = (lambda_bid + lambda_ask) / (gamma + kappa).
79        (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa)
80    }
81
82    fn bsde_driver(
83        &self,
84        t: f64,
85        state: &[f64; 2],
86        control: &Self::Control,
87        derivs: &StateDerivatives<2>,
88        _dt: f64,
89    ) -> f64 {
90        // The reduced theta value satisfies d_t theta + H* - 0.5 gamma sigma^2 q^2 = 0.
91        // The inventory-risk term is a local source, not forward transport, so
92        // the BSDE backward step must include the full reduced driver.
93        self.driver(t, state, control, derivs)
94    }
95
96    fn generator(
97        &self,
98        _t: f64,
99        state: &[f64; 2],
100        _control: &Self::Control,
101        _derivs: &StateDerivatives<2>,
102    ) -> f64 {
103        // Price-diffusion risk penalty: -(1/2) gamma sigma^2 q^2.
104        -0.5 * self.gamma * self.sigma.powi(2) * state[0].powi(2)
105    }
106
107    fn terminal(&self, _state: &[f64; 2]) -> f64 {
108        0.0
109    }
110
111    fn discount_rate(&self, _state: &[f64; 2]) -> f64 {
112        0.0
113    }
114
115    fn constant_discount_rate(&self) -> Option<f64> {
116        Some(0.0)
117    }
118
119    fn next_step(&self, _t: f64, state: &[f64; 2], dt: f64, noise: &[f64; 2]) -> [f64; 2] {
120        let mut next = *state;
121
122        // Inventory q jumps by +/-1 based on the optimal fill probabilities.
123        let u = normal_cdf(noise[0]);
124        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
125        let delta_bid = base_spread;
126        let delta_ask = base_spread;
127        let lambda_bid = self.a * (-self.kappa * delta_bid).exp();
128        let lambda_ask = self.a * (-self.kappa * delta_ask).exp();
129        let p_bid = (lambda_bid * dt).clamp(0.0, 1.0);
130        let p_ask = (lambda_ask * dt).clamp(0.0, 1.0);
131        if u < p_bid {
132            next[0] += 1.0;
133        } else if u > 1.0 - p_ask {
134            next[0] -= 1.0;
135        }
136
137        // Price diffuses.
138        next[1] += self.sigma * dt.sqrt() * noise[1];
139        next
140    }
141
142    fn is_reduced_value(&self) -> bool {
143        true
144    }
145
146    fn is_diffusion_dimension(&self, dim: usize) -> bool {
147        dim == 1
148    }
149
150    fn gradient_step(&self, _dim: usize) -> f64 {
151        1.0
152    }
153}
154
155impl PdeProblem<2> for AvellanedaStoikov {
156    fn dimension_kind(&self, dim: usize) -> DimensionKind {
157        match dim {
158            0 => DimensionKind::DiscreteJump,
159            _ => DimensionKind::Diffusion,
160        }
161    }
162
163    fn transport(
164        &self,
165        _t: f64,
166        state: &[f64; 2],
167        control: &Self::Control,
168        derivs: &StateDerivatives<2>,
169    ) -> Transport<2> {
170        // The solver adds inventory transport for the two optimal fill rates.
171        // The residual source is therefore the full optimized driver minus the
172        // transport contribution that the operator will produce.
173        let q = state[0];
174        let hamiltonian =
175            (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa);
176        let jump_transport =
177            control.bid_intensity * derivs.fwd[0] - control.ask_intensity * derivs.bwd[0];
178        let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
179        Transport::new(
180            [control.bid_intensity, 0.0],
181            [control.ask_intensity, 0.0],
182            hamiltonian + local - jump_transport,
183        )
184    }
185}
186
187impl EllipticControlProblem<2> for AvellanedaStoikov {
188    fn dimension_kind(&self, dim: usize) -> DimensionKind {
189        <Self as PdeProblem<2>>::dimension_kind(self, dim)
190    }
191
192    fn transport(
193        &self,
194        state: &[f64; 2],
195        control: &Self::Control,
196        derivs: &StateDerivatives<2>,
197    ) -> Transport<2> {
198        <Self as PdeProblem<2>>::transport(self, 0.0, state, control, derivs)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn zero_derivs() -> StateDerivatives<2> {
207        StateDerivatives::new([0.0; 2], [0.0; 2])
208    }
209
210    fn default_model() -> AvellanedaStoikov {
211        AvellanedaStoikov {
212            gamma: 0.1,
213            sigma: 0.1,
214            kappa: 1.5,
215            a: 140.0,
216        }
217    }
218
219    #[test]
220    fn zero_gradient_gives_base_spread() {
221        let m = default_model();
222        let derivs = zero_derivs();
223        let (bid, ask) = m.get_spreads(&derivs);
224        let base = (1.0 / m.gamma) * (1.0 + m.gamma / m.kappa).ln();
225        assert!((bid - base).abs() < 1e-12);
226        assert!((ask - base).abs() < 1e-12);
227    }
228
229    #[test]
230    fn symmetric_at_zero_inventory() {
231        let m = default_model();
232        let derivs = zero_derivs();
233        let ctrl = ControlProblem::optimize(&m, 0.0, &[0.0, 100.0], &derivs);
234        assert!(
235            (ctrl.bid_intensity - ctrl.ask_intensity).abs() < 1e-12,
236            "Bid/ask intensities should be equal at q=0"
237        );
238    }
239
240    #[test]
241    fn terminal_is_zero() {
242        let m = default_model();
243        assert_eq!(ControlProblem::terminal(&m, &[0.0, 100.0]), 0.0);
244        assert_eq!(ControlProblem::terminal(&m, &[5.0, 200.0]), 0.0);
245    }
246
247    #[test]
248    fn dim1_is_diffusion() {
249        let m = default_model();
250        assert!(!ControlProblem::is_diffusion_dimension(&m, 0));
251        assert!(ControlProblem::is_diffusion_dimension(&m, 1));
252    }
253
254    #[test]
255    fn dimension_kinds_match_state_space() {
256        let m = default_model();
257        assert_eq!(
258            PdeProblem::dimension_kind(&m, 0),
259            DimensionKind::DiscreteJump
260        );
261        assert_eq!(PdeProblem::dimension_kind(&m, 1), DimensionKind::Diffusion);
262    }
263
264    #[test]
265    fn transport_source_reconstructs_full_driver() {
266        let m = default_model();
267        let state = [3.0, 100.0];
268        let derivs =
269            StateDerivatives::with_directional([0.1, 0.0], [0.0, 0.0], [0.25, 0.0], [0.15, 0.0]);
270
271        let control = ControlProblem::optimize(&m, 0.0, &state, &derivs);
272        let transport = PdeProblem::transport(&m, 0.0, &state, &control, &derivs);
273
274        // The discrete operator applies
275        //   plus * fwd_delta + minus * bwd_delta + source
276        // to reconstruct the full optimized driver. With a unit jump size the
277        // forward/bwd directional derivatives are already the discrete deltas.
278        let operator_driver = transport.source
279            + transport.plus[0] * derivs.fwd[0]
280            + transport.minus[0] * (-derivs.bwd[0]);
281
282        let expected = ControlProblem::driver(&m, 0.0, &state, &control, &derivs);
283        assert!(
284            (operator_driver - expected).abs() < 1e-12,
285            "transport decomposition {operator_driver} does not match driver {expected}"
286        );
287    }
288}