Skip to main content

fin_primitives/impact/
mod.rs

1//! Almgren-Chriss optimal order execution and market impact model.
2//!
3//! ## Responsibility
4//! Almgren-Chriss optimal execution framework for minimising expected market
5//! impact cost plus timing risk when liquidating (or acquiring) a large position.
6//!
7//! ## Model Summary
8//! Given total shares X to trade over horizon T with N equal time steps:
9//! - **Permanent impact**: γ·v  (linear in trade rate v, persists)
10//! - **Temporary impact**: η·v  (linear in trade rate v, dissipates each step)
11//! - **Timing risk**: variance of price path × risk-aversion λ
12//!
13//! The optimal trajectory minimises:
14//!   E\[cost\] + λ · Var\[cost\]
15//!
16//! Closed-form solution (Almgren-Chriss 2001):
17//!   x(τ) = X · sinh(κ(T-τ)) / sinh(κT)
18//! where κ² = λσ² / η̃  and η̃ = η - γΔt/2.
19//!
20//! ## NOT Responsible For
21//! - Dynamic/adaptive execution (static schedule only)
22//! - Non-linear impact models
23//! - Intraday liquidity constraints
24
25use crate::error::FinError;
26
27// ─── parameters ───────────────────────────────────────────────────────────────
28
29/// Almgren-Chriss model parameters.
30#[derive(Debug, Clone, Copy)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct AlmgrenChrissParams {
33    /// Total shares (or notional units) to execute. Positive = buy, negative = sell.
34    pub total_shares: f64,
35    /// Execution horizon in units of time steps (e.g., seconds, minutes).
36    pub time_steps: usize,
37    /// Annualised (or per-step) price volatility σ.
38    pub volatility: f64,
39    /// Permanent impact coefficient γ (price shift per unit traded).
40    pub permanent_impact: f64,
41    /// Temporary impact coefficient η (instantaneous slippage per unit rate).
42    pub temporary_impact: f64,
43    /// Risk aversion parameter λ. Higher → trade faster to reduce timing risk.
44    pub risk_aversion: f64,
45}
46
47// ─── trajectory ───────────────────────────────────────────────────────────────
48
49/// A single step in the optimal execution trajectory.
50#[derive(Debug, Clone, Copy)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub struct TrajectoryStep {
53    /// Time step index (0 = now, N = horizon end).
54    pub step: usize,
55    /// Remaining inventory at this step (shares not yet traded).
56    pub inventory: f64,
57    /// Shares traded during this step (trade_size = inventory\[t\] - inventory[t+1]).
58    pub trade_size: f64,
59    /// Expected instantaneous market impact cost of this step's trade.
60    pub impact_cost: f64,
61}
62
63/// The full optimal execution schedule.
64#[derive(Debug, Clone)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct OptimalTrajectory {
67    /// Ordered sequence of execution steps.
68    pub steps: Vec<TrajectoryStep>,
69    /// Total expected permanent impact cost over the horizon.
70    pub total_permanent_cost: f64,
71    /// Total expected temporary impact cost over the horizon.
72    pub total_temporary_cost: f64,
73    /// Total expected execution cost (permanent + temporary).
74    pub total_expected_cost: f64,
75    /// Variance of the execution cost (before risk-aversion weighting).
76    pub cost_variance: f64,
77    /// Efficient frontier objective value: E\[cost\] + λ·Var\[cost\].
78    pub objective: f64,
79}
80
81// ─── engine ───────────────────────────────────────────────────────────────────
82
83/// Almgren-Chriss optimal execution engine.
84pub struct AlmgrenChriss;
85
86impl AlmgrenChriss {
87    /// Compute the optimal execution trajectory for the given parameters.
88    ///
89    /// Returns an `OptimalTrajectory` with per-step inventory, trade sizes,
90    /// and aggregated cost statistics.
91    ///
92    /// # Errors
93    /// - `FinError::InvalidInput` if `time_steps == 0`, volatility ≤ 0, or
94    ///   impact coefficients are negative.
95    /// - `FinError::ArithmeticOverflow` on internal numeric failure (e.g. κ computation).
96    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        // Time step size (normalised to 1 unless caller provides physical units)
107        let dt = 1.0_f64;
108
109        // Adjusted temporary impact (accounts for permanent impact bleed-in)
110        let eta_tilde = eta - 0.5 * gamma * dt;
111        // Protect against degenerate case where eta_tilde <= 0
112        let eta_tilde = if eta_tilde <= 0.0 { eta } else { eta_tilde };
113
114        // κ parameter: kappa^2 = lambda * sigma^2 / eta_tilde
115        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        // Total horizon T = N * dt
122        let big_t = n as f64 * dt;
123
124        // Pre-compute sinh(kappa * T) for inventory formula
125        let sinh_kt = (kappa * big_t).sinh();
126        if sinh_kt.abs() < f64::EPSILON {
127            // κ ≈ 0: risk-neutral case → TWAP (uniform liquidation)
128            return Self::twap_fallback(params);
129        }
130
131        // Build inventory trajectory: x(t_j) = X * sinh(kappa*(T - t_j)) / sinh(kappa*T)
132        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        // Compute per-step trade sizes and costs
140        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; // shares sold this step
149            let trade_rate = trade / dt;
150
151            // Temporary impact cost: eta * (trade/dt)^2 * dt = eta * trade^2 / dt
152            let temp_cost = eta * trade_rate * trade_rate * dt;
153            // Permanent impact: gamma * trade_rate * dt * remaining_inv (cross term)
154            // Simplified: perm cost contribution = 0.5 * gamma * trade^2
155            let perm_cost = 0.5 * gamma * trade * trade;
156
157            // Impact cost for this step (market impact paid)
158            let impact_cost = temp_cost + perm_cost;
159
160            // Variance contribution: sigma^2 * inv_now^2 * dt
161            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    /// TWAP fallback for the risk-neutral (λ≈0 or κ≈0) case.
188    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// ─── tests ────────────────────────────────────────────────────────────────────
249
250#[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, // risk-neutral → approaches TWAP
329        };
330        let traj = AlmgrenChriss::compute(&p).unwrap();
331        assert_eq!(traj.steps.len(), 5);
332    }
333}