fin_primitives/impact/
mod.rs1use crate::error::FinError;
26
27#[derive(Debug, Clone, Copy)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct AlmgrenChrissParams {
33 pub total_shares: f64,
35 pub time_steps: usize,
37 pub volatility: f64,
39 pub permanent_impact: f64,
41 pub temporary_impact: f64,
43 pub risk_aversion: f64,
45}
46
47#[derive(Debug, Clone, Copy)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub struct TrajectoryStep {
53 pub step: usize,
55 pub inventory: f64,
57 pub trade_size: f64,
59 pub impact_cost: f64,
61}
62
63#[derive(Debug, Clone)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct OptimalTrajectory {
67 pub steps: Vec<TrajectoryStep>,
69 pub total_permanent_cost: f64,
71 pub total_temporary_cost: f64,
73 pub total_expected_cost: f64,
75 pub cost_variance: f64,
77 pub objective: f64,
79}
80
81pub struct AlmgrenChriss;
85
86impl AlmgrenChriss {
87 pub fn compute(params: &AlmgrenChrissParams) -> Result<OptimalTrajectory, FinError> {
97 Self::validate(params)?;
98
99 let n = params.time_steps;
100 let x = params.total_shares;
101 let sigma = params.volatility;
102 let gamma = params.permanent_impact;
103 let eta = params.temporary_impact;
104 let lambda = params.risk_aversion;
105
106 let dt = 1.0_f64;
108
109 let eta_tilde = eta - 0.5 * gamma * dt;
111 let eta_tilde = if eta_tilde <= 0.0 { eta } else { eta_tilde };
113
114 let kappa_sq = lambda * sigma * sigma / eta_tilde;
116 if !kappa_sq.is_finite() || kappa_sq < 0.0 {
117 return Err(FinError::ArithmeticOverflow);
118 }
119 let kappa = kappa_sq.sqrt();
120
121 let big_t = n as f64 * dt;
123
124 let sinh_kt = (kappa * big_t).sinh();
126 if sinh_kt.abs() < f64::EPSILON {
127 return Self::twap_fallback(params);
129 }
130
131 let mut inventories = Vec::with_capacity(n + 1);
133 for j in 0..=n {
134 let tau = j as f64 * dt;
135 let inv = x * (kappa * (big_t - tau)).sinh() / sinh_kt;
136 inventories.push(inv);
137 }
138
139 let mut steps = Vec::with_capacity(n);
141 let mut total_perm = 0.0_f64;
142 let mut total_temp = 0.0_f64;
143 let mut cost_var_sum = 0.0_f64;
144
145 for j in 0..n {
146 let inv_now = inventories[j];
147 let inv_next = inventories[j + 1];
148 let trade = inv_now - inv_next; let trade_rate = trade / dt;
150
151 let temp_cost = eta * trade_rate * trade_rate * dt;
153 let perm_cost = 0.5 * gamma * trade * trade;
156
157 let impact_cost = temp_cost + perm_cost;
159
160 cost_var_sum += sigma * sigma * inv_now * inv_now * dt;
162
163 total_perm += perm_cost;
164 total_temp += temp_cost;
165
166 steps.push(TrajectoryStep {
167 step: j,
168 inventory: inv_now,
169 trade_size: trade,
170 impact_cost,
171 });
172 }
173
174 let total_cost = total_perm + total_temp;
175 let objective = total_cost + lambda * cost_var_sum;
176
177 Ok(OptimalTrajectory {
178 steps,
179 total_permanent_cost: total_perm,
180 total_temporary_cost: total_temp,
181 total_expected_cost: total_cost,
182 cost_variance: cost_var_sum,
183 objective,
184 })
185 }
186
187 fn twap_fallback(params: &AlmgrenChrissParams) -> Result<OptimalTrajectory, FinError> {
189 let n = params.time_steps;
190 let x = params.total_shares;
191 let trade_per_step = x / n as f64;
192
193 let mut steps = Vec::with_capacity(n);
194 let mut total_perm = 0.0;
195 let mut total_temp = 0.0;
196
197 for j in 0..n {
198 let inv = x - j as f64 * trade_per_step;
199 let temp_cost = params.temporary_impact * trade_per_step * trade_per_step;
200 let perm_cost = 0.5 * params.permanent_impact * trade_per_step * trade_per_step;
201 total_perm += perm_cost;
202 total_temp += temp_cost;
203 steps.push(TrajectoryStep {
204 step: j,
205 inventory: inv,
206 trade_size: trade_per_step,
207 impact_cost: temp_cost + perm_cost,
208 });
209 }
210
211 let total_cost = total_perm + total_temp;
212 let cost_var = params.volatility * params.volatility * x * x;
213
214 Ok(OptimalTrajectory {
215 steps,
216 total_permanent_cost: total_perm,
217 total_temporary_cost: total_temp,
218 total_expected_cost: total_cost,
219 cost_variance: cost_var,
220 objective: total_cost + params.risk_aversion * cost_var,
221 })
222 }
223
224 fn validate(p: &AlmgrenChrissParams) -> Result<(), FinError> {
225 if p.time_steps == 0 {
226 return Err(FinError::InvalidInput("time_steps must be at least 1".to_owned()));
227 }
228 if p.volatility <= 0.0 {
229 return Err(FinError::InvalidInput("volatility must be positive".to_owned()));
230 }
231 if p.permanent_impact < 0.0 {
232 return Err(FinError::InvalidInput(
233 "permanent_impact must be non-negative".to_owned(),
234 ));
235 }
236 if p.temporary_impact < 0.0 {
237 return Err(FinError::InvalidInput(
238 "temporary_impact must be non-negative".to_owned(),
239 ));
240 }
241 if p.risk_aversion < 0.0 {
242 return Err(FinError::InvalidInput("risk_aversion must be non-negative".to_owned()));
243 }
244 Ok(())
245 }
246}
247
248#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn default_params() -> AlmgrenChrissParams {
255 AlmgrenChrissParams {
256 total_shares: 10_000.0,
257 time_steps: 10,
258 volatility: 0.02,
259 permanent_impact: 1e-7,
260 temporary_impact: 1e-6,
261 risk_aversion: 1e-5,
262 }
263 }
264
265 #[test]
266 fn test_trajectory_has_correct_step_count() {
267 let traj = AlmgrenChriss::compute(&default_params()).unwrap();
268 assert_eq!(traj.steps.len(), 10);
269 }
270
271 #[test]
272 fn test_first_step_inventory_is_total_shares() {
273 let p = default_params();
274 let traj = AlmgrenChriss::compute(&p).unwrap();
275 let first_inv = traj.steps[0].inventory;
276 assert!((first_inv - p.total_shares).abs() < 1.0, "first inventory: {first_inv}");
277 }
278
279 #[test]
280 fn test_total_shares_roughly_traded() {
281 let p = default_params();
282 let traj = AlmgrenChriss::compute(&p).unwrap();
283 let total_traded: f64 = traj.steps.iter().map(|s| s.trade_size).sum();
284 assert!(
285 (total_traded - p.total_shares).abs() < 1.0,
286 "total traded {total_traded} vs {}", p.total_shares
287 );
288 }
289
290 #[test]
291 fn test_costs_positive() {
292 let traj = AlmgrenChriss::compute(&default_params()).unwrap();
293 assert!(traj.total_expected_cost > 0.0);
294 assert!(traj.cost_variance > 0.0);
295 }
296
297 #[test]
298 fn test_objective_equals_cost_plus_risk() {
299 let p = default_params();
300 let traj = AlmgrenChriss::compute(&p).unwrap();
301 let expected_obj = traj.total_expected_cost + p.risk_aversion * traj.cost_variance;
302 assert!((traj.objective - expected_obj).abs() < 1e-6);
303 }
304
305 #[test]
306 fn test_invalid_params() {
307 let mut p = default_params();
308 p.time_steps = 0;
309 assert!(AlmgrenChriss::compute(&p).is_err());
310
311 p = default_params();
312 p.volatility = -1.0;
313 assert!(AlmgrenChriss::compute(&p).is_err());
314
315 p = default_params();
316 p.risk_aversion = -1.0;
317 assert!(AlmgrenChriss::compute(&p).is_err());
318 }
319
320 #[test]
321 fn test_twap_fallback_zero_risk_aversion() {
322 let p = AlmgrenChrissParams {
323 total_shares: 1000.0,
324 time_steps: 5,
325 volatility: 0.01,
326 permanent_impact: 0.0,
327 temporary_impact: 1e-6,
328 risk_aversion: 0.0, };
330 let traj = AlmgrenChriss::compute(&p).unwrap();
331 assert_eq!(traj.steps.len(), 5);
332 }
333}