Skip to main content

solver/analytical/market_impact/
exact.rs

1use crate::analytical::traits::AnalyticalSolution;
2use crate::models::traits::ControlOutput;
3use crate::numeric::ode::LinearSpectralSolver;
4
5/// The exact analytical solution for the Avellaneda-Stoikov model with Market Impact.
6/// Solves the system of ODEs for v_q(t).
7pub struct AvellanedaImpactExact {
8    pub gamma: f64,
9    pub sigma: f64,
10    pub kappa: f64,
11    pub a: f64,
12    pub xi: f64,  // Market Impact
13    pub phi: f64, // Inventory Penalty
14    pub terminal_time: f64,
15    pub q_max: usize,
16}
17
18impl AvellanedaImpactExact {
19    #[allow(clippy::too_many_arguments)]
20    pub fn new(
21        gamma: f64,
22        sigma: f64,
23        kappa: f64,
24        a: f64,
25        xi: f64,
26        phi: f64,
27        terminal_time: f64,
28        q_max: usize,
29    ) -> Self {
30        Self {
31            gamma,
32            sigma,
33            kappa,
34            a,
35            xi,
36            phi,
37            terminal_time,
38            q_max,
39        }
40    }
41
42    /// Helper to compute z(t) and alpha_trans parameter.
43    ///
44    /// Corresponds to Section "Market Impact" in `market_making.tex`.
45    /// The text defines the system $\dot{\mathbf{v}}(t) = -\tilde{M}_\xi \mathbf{v}(t)$ (Eq \ref{eq:ode_impact}).
46    /// To solve this using a symmetric spectral solver, we apply the transformation:
47    /// $v_q(t) = \exp(\alpha_{trans} q^2) z_q(t)$ where $\alpha_{trans} = 0.5 k \xi$.
48    /// This symmetrizes the transition matrix.
49    fn compute_z_and_alpha(&self, t: f64) -> (Vec<f64>, f64) {
50        let n = 2 * self.q_max + 1;
51        let time_remaining = self.terminal_time - t;
52        let alpha_trans = 0.5 * self.kappa * self.xi; // Transformation parameter
53
54        if time_remaining <= 1e-9 {
55            // Terminal condition from text: v(T, q) = \exp(-0.5 * k * xi * q^2).
56            // Transformation: z(T, q) = \exp(-\alpha_{trans} q^2) v(T, q)
57            // = \exp(-0.5 * k * xi * q^2) * \exp(-0.5 * k * xi * q^2)
58            // = \exp(-k * xi * q^2).
59
60            let mut z_t = vec![0.0; n];
61            for (i, item) in z_t.iter_mut().enumerate() {
62                let q_val = (i as i32) - (self.q_max as i32);
63                let q = q_val as f64;
64                *item = (-self.kappa * self.xi * q.powi(2)).exp();
65            }
66            return (z_t, alpha_trans);
67        }
68
69        // alpha_risk = k * (0.5 * gamma * sigma^2 + phi) (corresponds to \tilde{\alpha} in text)
70        let alpha_risk = self.kappa * (0.5 * self.gamma * self.sigma.powi(2) + self.phi);
71        let eta = self.a * (1.0 + self.gamma / self.kappa).powf(-(1.0 + self.kappa / self.gamma));
72
73        let mut d = vec![0.0; n];
74        let mut e = vec![0.0; n];
75
76        // Symmetrization of the Matrix \tilde{M}_\xi.
77        // The text's matrix has off-diagonals \eta e^{k\xi(q-1)} and \eta e^{-k\xi(q+1)}.
78        // The transformation $v_q = e^{\alpha_{trans} q^2} z_q$ yields a symmetric system for z
79        // with constant off-diagonal: \eta e^{-0.5 * k * xi}.
80        let off_diag_val = eta * (-alpha_trans).exp();
81
82        // Note: The text defines \dot{v} = -\tilde{M} v.
83        // Our solver computes \exp(A \tau).
84        // For the equation \dot{v} = -\tilde{M} v, the solution is v(t) = \exp(\tilde{M}(T-t)) v(T).
85        // So we construct A = \tilde{M}_{sym} directly.
86
87        for i in 0..n {
88            let q_val = (i as i32) - (self.q_max as i32);
89            let q = q_val as f64;
90            // Matrix Diagonal from text: -\tilde{\alpha} q^2
91            d[i] = -alpha_risk * q.powi(2);
92
93            if i < n - 1 {
94                e[i] = off_diag_val;
95            }
96        }
97
98        // Initial Condition z(T)
99        // z(T) = exp(-k * xi * q^2)
100        let mut z0 = vec![0.0; n];
101        for (i, item) in z0.iter_mut().enumerate() {
102            let q_val = (i as i32) - (self.q_max as i32);
103            let q = q_val as f64;
104            *item = (-self.kappa * self.xi * q.powi(2)).exp();
105        }
106
107        // Solve for z(tau) = exp(M_sym * tau) * z0
108        let z_t = LinearSpectralSolver::solve_tridiagonal(d, e, &z0, time_remaining);
109
110        (z_t, alpha_trans)
111    }
112
113    /// Solves for the optimal spreads at time t given inventory q.
114    pub fn exact_spreads(&self, t: f64, q: f64) -> (f64, f64) {
115        let (z_t, alpha_trans) = self.compute_z_and_alpha(t);
116        let n = z_t.len();
117
118        // Check for validity
119        for val in &z_t {
120            if val.is_nan() {
121                return (f64::NAN, f64::NAN);
122            }
123        }
124
125        let const_term = (1.0 / self.gamma) * (1.0 + self.gamma / self.kappa).ln();
126        let q_idx = (q.round() as i32 + self.q_max as i32) as usize;
127
128        if q_idx == 0 || q_idx >= n - 1 {
129            return (999.0, 999.0);
130        }
131
132        // Clamp values to be positive to avoid NaN from log(<=0) due to numerical noise/underflow
133        let z_q = z_t[q_idx].max(1e-50);
134        let z_qp1 = z_t[q_idx + 1].max(1e-50);
135        let z_qm1 = z_t[q_idx - 1].max(1e-50);
136
137        // ln(v_q / v_{q+1}) = ln(z_q / z_{q+1}) + alpha_trans * (q^2 - (q+1)^2)
138        // = ln(z_q) - ln(z_{q+1}) - alpha_trans * (2q + 1)
139        let ln_ratio_bid = z_q.ln() - z_qp1.ln() - alpha_trans * (2.0 * q + 1.0);
140
141        // ln(v_q / v_{q-1}) = ln(z_q / z_{q-1}) + alpha_trans * (q^2 - (q-1)^2)
142        // = ln(z_q) - ln(z_{q-1}) + alpha_trans * (2q - 1)
143        let ln_ratio_ask = z_q.ln() - z_qm1.ln() + alpha_trans * (2.0 * q - 1.0);
144
145        let bid = (1.0 / self.kappa) * ln_ratio_bid + (q + 1.0) * self.xi + const_term;
146        let ask = (1.0 / self.kappa) * ln_ratio_ask - (q - 1.0) * self.xi + const_term;
147
148        (bid, ask)
149    }
150}
151
152impl AnalyticalSolution<2> for AvellanedaImpactExact {
153    fn value_function(&self, t: f64, state: &[f64; 2]) -> f64 {
154        let q = state[0];
155        let (z_t, alpha_trans) = self.compute_z_and_alpha(t);
156        let q_idx = (q.round() as i32 + self.q_max as i32) as usize;
157
158        if q_idx >= z_t.len() {
159            return 0.0;
160        }
161
162        // theta(t,q) = (1/kappa) * ln(v(t,q))
163        // ln(v) = alpha_trans * q^2 + ln(z)
164        (1.0 / self.kappa) * (alpha_trans * q.powi(2) + z_t[q_idx].ln())
165    }
166
167    fn optimal_controls(&self, t: f64, state: &[f64; 2]) -> ControlOutput<2> {
168        let q = state[0];
169        let (d_bid, d_ask) = self.exact_spreads(t, q);
170
171        let lambda_bid = self.a * (-self.kappa * d_bid).exp();
172        let lambda_ask = self.a * (-self.kappa * d_ask).exp();
173
174        let flow = lambda_bid * d_bid + lambda_ask * d_ask;
175
176        ControlOutput {
177            lambda_plus: [lambda_bid, 0.0],
178            lambda_minus: [lambda_ask, 0.0],
179            flow,
180        }
181    }
182}