Skip to main content

solver/models/
avellaneda_lot_size.rs

1//! Avellaneda-Stoikov market making with a configurable lot size.
2//!
3//! A validating instance for [`DimensionKind::Jump`] and [`JumpKernel`]. The
4//! inventory is an integer lattice with spacing `lot_size`, so a single fill
5//! moves inventory by `+lot_size` (bid fill) or `-lot_size` (ask fill) rather
6//! than one unit. With `lot_size = 1` the model reduces exactly to the base
7//! Avellaneda-Stoikov reduced value problem, providing a closed-form oracle for
8//! the general-jump machinery.
9//!
10//! # Dynamics
11//!
12//! The inventory `q` is measured in units of the lot, so the jump amplitude is
13//! `lot_size` state units per fill. The reduced value obeys
14//!
15//! ```text
16//! d_t theta + H*(q) - 0.5 gamma sigma^2 q^2 = 0
17//! ```
18//!
19//! where `q` ranges over the integer lattice `lot_size * Z` and the jump
20//! operator is `lambda_bid (theta(q+lot) - theta(q)) +
21//! lambda_ask (theta(q-lot) - theta(q))`.
22
23use crate::models::control::{ControlProblem, StateDerivatives};
24use crate::models::market_making::MarketMakingControl;
25use crate::models::normal_cdf;
26use crate::numeric::finite_difference::discretization::{
27    DimensionKind, JumpKernel, JumpTransition, Transport,
28};
29use crate::numeric::finite_difference::pde::PdeProblem;
30
31/// Avellaneda-Stoikov with lot size `lot_size` on the inventory dimension.
32#[derive(Clone, Copy, Debug)]
33pub struct AvellanedaLotSize {
34    /// Risk aversion.
35    pub gamma: f64,
36    /// Mid-price volatility.
37    pub sigma: f64,
38    /// Fill-intensity decay.
39    pub kappa: f64,
40    /// Base order arrival intensity.
41    pub a: f64,
42    /// Number of inventory units moved per fill (positive integer).
43    pub lot_size: i32,
44}
45
46impl AvellanedaLotSize {
47    /// Creates the model.
48    ///
49    /// # Panics
50    ///
51    /// Panics if `lot_size` is not positive.
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use solver::models::avellaneda_lot_size::AvellanedaLotSize;
57    /// let model = AvellanedaLotSize::new(0.5, 0.5, 1.5, 140.0, 2);
58    /// assert_eq!(model.lot_size, 2);
59    /// ```
60    pub fn new(gamma: f64, sigma: f64, kappa: f64, a: f64, lot_size: i32) -> Self {
61        assert!(lot_size > 0, "lot size must be positive");
62        Self {
63            gamma,
64            sigma,
65            kappa,
66            a,
67            lot_size,
68        }
69    }
70
71    fn base_spread(&self) -> f64 {
72        (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln()
73    }
74}
75
76impl ControlProblem<2> for AvellanedaLotSize {
77    type Control = MarketMakingControl;
78
79    fn optimize(&self, _t: f64, _state: &[f64; 2], derivs: &StateDerivatives<2>) -> Self::Control {
80        let base = self.base_spread();
81        let delta_bid = (base - derivs.fwd[0]).max(-10.0);
82        let delta_ask = (base + derivs.bwd[0]).max(-10.0);
83        MarketMakingControl::new(
84            self.a * (-self.kappa * delta_bid).exp(),
85            self.a * (-self.kappa * delta_ask).exp(),
86        )
87    }
88
89    fn running_reward(&self, _t: f64, _state: &[f64; 2], control: &Self::Control) -> f64 {
90        (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa)
91    }
92
93    fn bsde_driver(
94        &self,
95        t: f64,
96        state: &[f64; 2],
97        control: &Self::Control,
98        derivs: &StateDerivatives<2>,
99        _dt: f64,
100    ) -> f64 {
101        self.driver(t, state, control, derivs)
102    }
103
104    fn generator(
105        &self,
106        _t: f64,
107        state: &[f64; 2],
108        _control: &Self::Control,
109        _derivs: &StateDerivatives<2>,
110    ) -> f64 {
111        -0.5 * self.gamma * self.sigma.powi(2) * state[0].powi(2)
112    }
113
114    fn terminal(&self, _state: &[f64; 2]) -> f64 {
115        0.0
116    }
117
118    fn discount_rate(&self, _state: &[f64; 2]) -> f64 {
119        0.0
120    }
121
122    fn constant_discount_rate(&self) -> Option<f64> {
123        Some(0.0)
124    }
125
126    fn next_step(&self, _t: f64, state: &[f64; 2], dt: f64, noise: &[f64; 2]) -> [f64; 2] {
127        let mut next = *state;
128        let u = normal_cdf(noise[0]);
129        let base = self.base_spread();
130        let lambda_bid = self.a * (-self.kappa * base).exp();
131        let lambda_ask = self.a * (-self.kappa * base).exp();
132        let p_bid = (lambda_bid * dt).clamp(0.0, 1.0);
133        let p_ask = (lambda_ask * dt).clamp(0.0, 1.0);
134        if u < p_bid {
135            next[0] += self.lot_size as f64;
136        } else if u > 1.0 - p_ask {
137            next[0] -= self.lot_size as f64;
138        }
139        next[1] += self.sigma * dt.sqrt() * noise[1];
140        next
141    }
142
143    fn next_step_controlled(
144        &self,
145        _t: f64,
146        state: &[f64; 2],
147        control: &Self::Control,
148        dt: f64,
149        noise: &[f64; 2],
150    ) -> [f64; 2] {
151        let mut next = *state;
152        let u = normal_cdf(noise[0]);
153        let p_bid = (control.bid_intensity * dt).clamp(0.0, 1.0);
154        let p_ask = (control.ask_intensity * dt).clamp(0.0, 1.0);
155        if u < p_bid {
156            next[0] += self.lot_size as f64;
157        } else if u > 1.0 - p_ask {
158            next[0] -= self.lot_size as f64;
159        }
160        next[1] += self.sigma * dt.sqrt() * noise[1];
161        next
162    }
163
164    fn is_reduced_value(&self) -> bool {
165        true
166    }
167
168    fn is_diffusion_dimension(&self, dim: usize) -> bool {
169        dim == 1
170    }
171
172    fn gradient_step(&self, dim: usize) -> f64 {
173        if dim == 0 { self.lot_size as f64 } else { 1.0 }
174    }
175}
176
177impl PdeProblem<2> for AvellanedaLotSize {
178    fn dimension_kind(&self, dim: usize) -> DimensionKind {
179        match dim {
180            0 => DimensionKind::Jump,
181            _ => DimensionKind::Diffusion,
182        }
183    }
184
185    fn transport(
186        &self,
187        _t: f64,
188        state: &[f64; 2],
189        control: &Self::Control,
190        derivs: &StateDerivatives<2>,
191    ) -> Transport<2> {
192        let q = state[0];
193        let hamiltonian =
194            (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa);
195        let jump_transport =
196            control.bid_intensity * derivs.fwd[0] - control.ask_intensity * derivs.bwd[0];
197        let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
198        Transport::new(
199            [control.bid_intensity, 0.0],
200            [control.ask_intensity, 0.0],
201            hamiltonian + local - jump_transport,
202        )
203    }
204
205    fn jump_kernel(
206        &self,
207        _t: f64,
208        _state: &[f64; 2],
209        control: &Self::Control,
210        _derivs: &StateDerivatives<2>,
211        _transport: &Transport<2>,
212    ) -> JumpKernel<2> {
213        let mut kernel = JumpKernel::empty();
214        if control.bid_intensity > 0.0 {
215            kernel.dims[0].push(JumpTransition::new(self.lot_size, control.bid_intensity));
216        }
217        if control.ask_intensity > 0.0 {
218            kernel.dims[0].push(JumpTransition::new(-self.lot_size, control.ask_intensity));
219        }
220        kernel
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn lot_size_one_reduces_to_unit_jump_kernel() {
230        let m = AvellanedaLotSize::new(0.5, 0.5, 1.5, 140.0, 1);
231        let derivs = StateDerivatives::new([0.0; 2], [0.0; 2]);
232        let state = [0.0, 0.0];
233        let control = ControlProblem::optimize(&m, 0.0, &state, &derivs);
234        let kernel =
235            PdeProblem::jump_kernel(&m, 0.0, &state, &control, &derivs, &Transport::zero());
236        assert_eq!(kernel.dims[0][0].amplitude, 1);
237        assert_eq!(kernel.dims[0][1].amplitude, -1);
238    }
239
240    #[test]
241    fn lot_size_two_jumps_by_two() {
242        let m = AvellanedaLotSize::new(0.5, 0.5, 1.5, 140.0, 2);
243        let derivs = StateDerivatives::new([0.0; 2], [0.0; 2]);
244        let state = [0.0, 0.0];
245        let control = ControlProblem::optimize(&m, 0.0, &state, &derivs);
246        let kernel =
247            PdeProblem::jump_kernel(&m, 0.0, &state, &control, &derivs, &Transport::zero());
248        assert_eq!(kernel.dims[0][0].amplitude, 2);
249        assert_eq!(kernel.dims[0][1].amplitude, -2);
250    }
251
252    #[test]
253    fn next_step_controlled_jumps_by_lot_size() {
254        let m = AvellanedaLotSize::new(0.5, 0.5, 1.5, 140.0, 3);
255        let state = [0.0, 0.0];
256        let control = MarketMakingControl::new(1000.0, 0.0);
257        let next =
258            ControlProblem::next_step_controlled(&m, 0.0, &state, &control, 0.01, &[0.0, 0.0]);
259        assert_eq!(next[0], 3.0);
260    }
261}