solver/models/
avellaneda.rs1use super::normal_cdf;
3use super::traits::{ControlOutput, Gradients, Model};
4
5#[derive(Clone)]
15pub struct AvellanedaStoikov {
16 pub gamma: f64,
18 pub sigma: f64,
20 pub kappa: f64,
22 pub a: f64,
24}
25
26impl AvellanedaStoikov {
27 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 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 let (d_bid, d_ask) = self.get_spreads(grads);
59
60 let lambda_bid = self.a * (-self.kappa * d_bid).exp();
62 let lambda_ask = self.a * (-self.kappa * d_ask).exp();
63
64 let hamiltonian_val = (lambda_bid + lambda_ask) / (self.gamma + self.kappa);
69
70 let risk_penalty = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
72
73 let drift_correction = lambda_bid * grads.fwd[0] - lambda_ask * grads.bwd[0];
79
80 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 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 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 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 }
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}