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