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, serde::Serialize, serde::Deserialize)]
31pub struct AlmgrenChrissParams {
32    /// Total shares (or notional units) to execute. Positive = buy, negative = sell.
33    pub total_shares: f64,
34    /// Execution horizon in units of time steps (e.g., seconds, minutes).
35    pub time_steps: usize,
36    /// Annualised (or per-step) price volatility σ.
37    pub volatility: f64,
38    /// Permanent impact coefficient γ (price shift per unit traded).
39    pub permanent_impact: f64,
40    /// Temporary impact coefficient η (instantaneous slippage per unit rate).
41    pub temporary_impact: f64,
42    /// Risk aversion parameter λ. Higher → trade faster to reduce timing risk.
43    pub risk_aversion: f64,
44}
45
46// ─── trajectory ───────────────────────────────────────────────────────────────
47
48/// A single step in the optimal execution trajectory.
49#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
50pub struct TrajectoryStep {
51    /// Time step index (0 = now, N = horizon end).
52    pub step: usize,
53    /// Remaining inventory at this step (shares not yet traded).
54    pub inventory: f64,
55    /// Shares traded during this step (trade_size = inventory\[t\] - inventory[t+1]).
56    pub trade_size: f64,
57    /// Expected instantaneous market impact cost of this step's trade.
58    pub impact_cost: f64,
59}
60
61/// The full optimal execution schedule.
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct OptimalTrajectory {
64    /// Ordered sequence of execution steps.
65    pub steps: Vec<TrajectoryStep>,
66    /// Total expected permanent impact cost over the horizon.
67    pub total_permanent_cost: f64,
68    /// Total expected temporary impact cost over the horizon.
69    pub total_temporary_cost: f64,
70    /// Total expected execution cost (permanent + temporary).
71    pub total_expected_cost: f64,
72    /// Variance of the execution cost (before risk-aversion weighting).
73    pub cost_variance: f64,
74    /// Efficient frontier objective value: E\[cost\] + λ·Var\[cost\].
75    pub objective: f64,
76}
77
78// ─── engine ───────────────────────────────────────────────────────────────────
79
80/// Almgren-Chriss optimal execution engine.
81pub struct AlmgrenChriss;
82
83impl AlmgrenChriss {
84    /// Compute the optimal execution trajectory for the given parameters.
85    ///
86    /// Returns an `OptimalTrajectory` with per-step inventory, trade sizes,
87    /// and aggregated cost statistics.
88    ///
89    /// # Errors
90    /// - `FinError::InvalidInput` if `time_steps == 0`, volatility ≤ 0, or
91    ///   impact coefficients are negative.
92    /// - `FinError::ArithmeticOverflow` on internal numeric failure (e.g. κ computation).
93    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        // Time step size (normalised to 1 unless caller provides physical units)
104        let dt = 1.0_f64;
105
106        // Adjusted temporary impact (accounts for permanent impact bleed-in)
107        let eta_tilde = eta - 0.5 * gamma * dt;
108        // Protect against degenerate case where eta_tilde <= 0
109        let eta_tilde = if eta_tilde <= 0.0 { eta } else { eta_tilde };
110
111        // κ parameter: kappa^2 = lambda * sigma^2 / eta_tilde
112        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        // Total horizon T = N * dt
119        let big_t = n as f64 * dt;
120
121        // Pre-compute sinh(kappa * T) for inventory formula
122        let sinh_kt = (kappa * big_t).sinh();
123        if sinh_kt.abs() < f64::EPSILON {
124            // κ ≈ 0: risk-neutral case → TWAP (uniform liquidation)
125            return Self::twap_fallback(params);
126        }
127
128        // Build inventory trajectory: x(t_j) = X * sinh(kappa*(T - t_j)) / sinh(kappa*T)
129        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        // Compute per-step trade sizes and costs
137        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; // shares sold this step
146            let trade_rate = trade / dt;
147
148            // Temporary impact cost: eta * (trade/dt)^2 * dt = eta * trade^2 / dt
149            let temp_cost = eta * trade_rate * trade_rate * dt;
150            // Permanent impact: gamma * trade_rate * dt * remaining_inv (cross term)
151            // Simplified: perm cost contribution = 0.5 * gamma * trade^2
152            let perm_cost = 0.5 * gamma * trade * trade;
153
154            // Impact cost for this step (market impact paid)
155            let impact_cost = temp_cost + perm_cost;
156
157            // Variance contribution: sigma^2 * inv_now^2 * dt
158            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    /// TWAP fallback for the risk-neutral (λ≈0 or κ≈0) case.
185    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// ─── tests ────────────────────────────────────────────────────────────────────
246
247#[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, // risk-neutral → approaches TWAP
326        };
327        let traj = AlmgrenChriss::compute(&p).unwrap();
328        assert_eq!(traj.steps.len(), 5);
329    }
330}