Skip to main content

solver/models/
avellaneda_impact.rs

1use super::control::{ControlProblem, StateDerivatives};
2use super::market_making::MarketMakingControl;
3use super::normal_cdf;
4use crate::numeric::finite_difference::discretization::{DimensionKind, Transport};
5use crate::numeric::finite_difference::pde::PdeProblem;
6
7/// Avellaneda-Stoikov Model with Market Impact
8///
9/// Models a market maker whose trades permanently impact the mid-price.
10/// The inventory penalty is adjusted implicitly by the cost of moving the price against oneself.
11///
12/// # Dynamics
13/// * $S_t \to S_t + \xi$ (Buy) or $S_t - \xi$ (Sell)
14/// * Impact is linear and permanent.
15#[derive(Clone)]
16pub struct AvellanedaImpact {
17    pub gamma: f64,
18    pub sigma: f64,
19    pub kappa: f64,
20    pub a: f64,
21    /// Permanent market impact parameter ($\xi$).
22    /// A trade of size 1 moves the price by $\xi$.
23    pub xi: f64,
24}
25
26impl AvellanedaImpact {
27    /// Computes optimal spreads, adjusted for market impact.
28    pub fn get_spreads(&self, derivs: &StateDerivatives<2>, q: f64) -> (f64, f64) {
29        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
30
31        let dv_dq_buy = derivs.fwd[0];
32        let dv_dq_sell = derivs.bwd[0];
33
34        let mut delta_bid = base_spread - dv_dq_buy + (q + 1.0) * self.xi;
35        let mut delta_ask = base_spread + dv_dq_sell - (q - 1.0) * self.xi;
36
37        let min_spread = -5.0;
38        delta_bid = delta_bid.max(min_spread);
39        delta_ask = delta_ask.max(min_spread);
40
41        (delta_bid, delta_ask)
42    }
43
44    /// The base arrival intensity used for intensity-to-spread conversion.
45    pub fn fill_rate_base(&self, _state: &[f64; 2]) -> f64 {
46        self.a
47    }
48
49    /// The fill-rate decay parameter used for intensity-to-spread conversion.
50    pub fn fill_rate_decay(&self) -> f64 {
51        self.kappa
52    }
53}
54
55impl ControlProblem<2> for AvellanedaImpact {
56    type Control = MarketMakingControl;
57
58    fn optimize(&self, _t: f64, state: &[f64; 2], derivs: &StateDerivatives<2>) -> Self::Control {
59        let q = state[0];
60        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
61        let delta_bid = (base_spread - derivs.fwd[0] + (q + 1.0) * self.xi).max(-5.0);
62        let delta_ask = (base_spread + derivs.bwd[0] - (q - 1.0) * self.xi).max(-5.0);
63
64        MarketMakingControl::new(
65            self.a * (-self.kappa * delta_bid).exp(),
66            self.a * (-self.kappa * delta_ask).exp(),
67        )
68    }
69
70    fn running_reward(&self, _t: f64, _state: &[f64; 2], control: &Self::Control) -> f64 {
71        (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa)
72    }
73
74    fn bsde_driver(
75        &self,
76        t: f64,
77        state: &[f64; 2],
78        control: &Self::Control,
79        derivs: &StateDerivatives<2>,
80        _dt: f64,
81    ) -> f64 {
82        self.driver(t, state, control, derivs)
83    }
84
85    fn generator(
86        &self,
87        _t: f64,
88        state: &[f64; 2],
89        _control: &Self::Control,
90        _derivs: &StateDerivatives<2>,
91    ) -> f64 {
92        let q = state[0];
93        -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2)
94    }
95
96    fn terminal(&self, state: &[f64; 2]) -> f64 {
97        let q = state[0];
98        -0.5 * self.xi * q.powi(2)
99    }
100
101    fn discount_rate(&self, _state: &[f64; 2]) -> f64 {
102        0.0
103    }
104
105    fn constant_discount_rate(&self) -> Option<f64> {
106        Some(0.0)
107    }
108
109    fn next_step(&self, _t: f64, state: &[f64; 2], dt: f64, noise: &[f64; 2]) -> [f64; 2] {
110        let mut next = *state;
111
112        let q = state[0];
113        let u = normal_cdf(noise[0]);
114        let base_spread = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
115        // Forward proxy for the optimal spreads without the value gradient:
116        // the permanent-impact shift is retained so the simulated fill
117        // intensities reflect the impact-adjusted quotes.
118        let delta_bid = base_spread + (q + 1.0) * self.xi;
119        let delta_ask = base_spread - (q - 1.0) * self.xi;
120        let lambda_bid = self.a * (-self.kappa * delta_bid).exp();
121        let lambda_ask = self.a * (-self.kappa * delta_ask).exp();
122        let p_bid = (lambda_bid * dt).clamp(0.0, 1.0);
123        let p_ask = (lambda_ask * 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
130        next[1] += self.sigma * dt.sqrt() * noise[1];
131        next
132    }
133
134    fn next_step_controlled(
135        &self,
136        _t: f64,
137        state: &[f64; 2],
138        control: &Self::Control,
139        dt: f64,
140        noise: &[f64; 2],
141    ) -> [f64; 2] {
142        let mut next = *state;
143
144        // The optimal control already carries the impact-adjusted fill
145        // intensities, so the Bernoulli fill events use them directly instead
146        // of a frozen proxy quote.
147        let u = normal_cdf(noise[0]);
148        let p_bid = (control.bid_intensity * dt).clamp(0.0, 1.0);
149        let p_ask = (control.ask_intensity * dt).clamp(0.0, 1.0);
150        if u < p_bid {
151            next[0] += 1.0;
152        } else if u > 1.0 - p_ask {
153            next[0] -= 1.0;
154        }
155
156        next[1] += self.sigma * dt.sqrt() * noise[1];
157        next
158    }
159
160    fn is_reduced_value(&self) -> bool {
161        true
162    }
163
164    fn is_diffusion_dimension(&self, dim: usize) -> bool {
165        dim == 1
166    }
167
168    fn gradient_step(&self, _dim: usize) -> f64 {
169        1.0
170    }
171}
172
173impl PdeProblem<2> for AvellanedaImpact {
174    fn dimension_kind(&self, dim: usize) -> DimensionKind {
175        match dim {
176            0 => DimensionKind::DiscreteJump,
177            _ => DimensionKind::Diffusion,
178        }
179    }
180
181    fn transport(
182        &self,
183        _t: f64,
184        state: &[f64; 2],
185        control: &Self::Control,
186        derivs: &StateDerivatives<2>,
187    ) -> Transport<2> {
188        let q = state[0];
189        let hamiltonian =
190            (control.bid_intensity + control.ask_intensity) / (self.gamma + self.kappa);
191        let jump_transport =
192            control.bid_intensity * derivs.fwd[0] - control.ask_intensity * derivs.bwd[0];
193        let local = -0.5 * self.gamma * self.sigma.powi(2) * q.powi(2);
194        Transport::new(
195            [control.bid_intensity, 0.0],
196            [control.ask_intensity, 0.0],
197            hamiltonian + local - jump_transport,
198        )
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn model() -> AvellanedaImpact {
207        AvellanedaImpact {
208            gamma: 0.5,
209            sigma: 0.5,
210            kappa: 1.5,
211            a: 140.0,
212            xi: 0.01,
213        }
214    }
215
216    #[test]
217    fn controlled_step_uses_optimal_intensities() {
218        let m = model();
219        let state = [1.0, 100.0];
220
221        // Zero intensity: no fill can occur, so inventory is unchanged and only
222        // the price diffuses.
223        let ctrl = MarketMakingControl::new(0.0, 0.0);
224        let next = ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0]);
225        assert_eq!(next[0], 1.0);
226
227        // Saturating intensity with a zero cdf draw forces a bid fill
228        // (inventory +1).
229        let ctrl = MarketMakingControl::new(1000.0, 0.0);
230        let next = ControlProblem::next_step_controlled(&m, 0.0, &state, &ctrl, 0.01, &[0.0, 0.0]);
231        assert_eq!(next[0], 2.0);
232    }
233
234    #[test]
235    fn impact_model_is_reduced_value() {
236        let m = model();
237        assert!(ControlProblem::is_reduced_value(&m));
238        assert!(ControlProblem::is_diffusion_dimension(&m, 1));
239        assert!(!ControlProblem::is_diffusion_dimension(&m, 0));
240    }
241}