fin_primitives/execution_cost/mod.rs
1//! Execution cost models: commission (Fixed, Proportional, Tiered, ZeroCommission),
2//! SpreadCost, MarketImpact (linear, sqrt, Almgren-Chriss), TotalExecutionCost, ExecutionCostBreakdown.
3//!
4//! ## Responsibility
5//! Execution cost models including commission, bid-ask spread cost,
6//! and market impact estimation (linear, square-root, Almgren-Chriss).
7//!
8//! ## Guarantees
9//! - Zero panics on well-formed inputs; computations are pure functions
10//! - `f64` is used intentionally for statistical cost estimates (not prices)
11//! - Almgren-Chriss returns `(permanent_impact, temporary_impact)` as a tuple
12
13// ─── ExecutionCostModel ───────────────────────────────────────────────────────
14
15/// Commission model variants for execution cost estimation.
16///
17/// # Example
18/// ```rust
19/// use fin_primitives::execution_cost::ExecutionCostModel;
20///
21/// let fixed = ExecutionCostModel::Fixed(5.0);
22/// let prop = ExecutionCostModel::Proportional(10.0); // 10 bps
23/// let zero = ExecutionCostModel::ZeroCommission;
24/// ```
25#[derive(Debug, Clone, PartialEq)]
26pub enum ExecutionCostModel {
27 /// Flat commission in USD per trade.
28 Fixed(f64),
29 /// Commission as a proportion of notional value, in basis points.
30 Proportional(f64),
31 /// Tiered commission: Vec of `(notional_threshold_usd, rate_bps)`.
32 /// The applicable tier is the last tier whose threshold is <= notional.
33 Tiered(Vec<(f64, f64)>),
34 /// No commission (e.g. payment-for-order-flow brokers).
35 ZeroCommission,
36}
37
38impl ExecutionCostModel {
39 /// Compute commission in USD for a given notional value.
40 pub fn commission_usd(&self, notional: f64) -> f64 {
41 match self {
42 ExecutionCostModel::Fixed(c) => *c,
43 ExecutionCostModel::Proportional(bps) => notional * bps / 10_000.0,
44 ExecutionCostModel::Tiered(tiers) => {
45 // Find last tier whose threshold <= notional
46 let rate_bps = tiers
47 .iter()
48 .filter(|(threshold, _)| notional >= *threshold)
49 .last()
50 .map(|(_, rate)| *rate)
51 .unwrap_or_else(|| tiers.first().map(|(_, r)| *r).unwrap_or(0.0));
52 notional * rate_bps / 10_000.0
53 }
54 ExecutionCostModel::ZeroCommission => 0.0,
55 }
56 }
57}
58
59// ─── SpreadCost ───────────────────────────────────────────────────────────────
60
61/// Bid-ask spread cost estimator.
62///
63/// # Example
64/// ```rust
65/// use fin_primitives::execution_cost::SpreadCost;
66///
67/// // Bid = 99.90, Ask = 100.10 -> half-spread = 10 bps
68/// let hs = SpreadCost::half_spread_bps(99.90, 100.10);
69/// assert!((hs - 10.0).abs() < 1e-6);
70/// ```
71pub struct SpreadCost;
72
73impl SpreadCost {
74 /// Half the bid-ask spread expressed in basis points.
75 ///
76 /// `half_spread_bps = (ask - bid) / (2 * mid) * 10_000`
77 pub fn half_spread_bps(bid: f64, ask: f64) -> f64 {
78 let mid = (bid + ask) / 2.0;
79 if mid == 0.0 {
80 return 0.0;
81 }
82 (ask - bid) / (2.0 * mid) * 10_000.0
83 }
84
85 /// Total spread cost in USD for a given order size.
86 ///
87 /// `spread_cost = size * half_spread_bps / 10_000 * mid_price`
88 pub fn spread_cost(size: f64, bid: f64, ask: f64) -> f64 {
89 let mid = (bid + ask) / 2.0;
90 let hs_bps = Self::half_spread_bps(bid, ask);
91 size * hs_bps / 10_000.0 * mid
92 }
93}
94
95// ─── MarketImpact ─────────────────────────────────────────────────────────────
96
97/// Market impact estimators: linear, square-root, and Almgren-Chriss.
98pub struct MarketImpact;
99
100impl MarketImpact {
101 /// Linear market impact in USD.
102 ///
103 /// `impact = size / volume * impact_bps_per_pct / 10_000 * size`
104 ///
105 /// where `size / volume` is the participation rate in [0, 1].
106 pub fn linear_impact(size: f64, volume: f64, impact_bps_per_pct: f64) -> f64 {
107 if volume == 0.0 {
108 return 0.0;
109 }
110 let participation = size / volume;
111 participation * impact_bps_per_pct / 10_000.0 * size
112 }
113
114 /// Square-root market impact in USD.
115 ///
116 /// `impact = eta * sigma * sqrt(size / volume) * size`
117 pub fn sqrt_impact(size: f64, volume: f64, sigma: f64, eta: f64) -> f64 {
118 if volume == 0.0 {
119 return 0.0;
120 }
121 eta * sigma * (size / volume).sqrt() * size
122 }
123
124 /// Almgren-Chriss permanent and temporary market impact.
125 ///
126 /// Returns `(permanent_impact_usd, temporary_impact_usd)`.
127 ///
128 /// - Permanent: `gamma * size` — persistent price shift
129 /// - Temporary: `eta * sigma * sqrt(size / volume) * size`
130 pub fn almgren_chriss(
131 size: f64,
132 volume: f64,
133 sigma: f64,
134 gamma: f64,
135 eta: f64,
136 ) -> (f64, f64) {
137 let permanent = gamma * size;
138 let temporary = Self::sqrt_impact(size, volume, sigma, eta);
139 (permanent, temporary)
140 }
141}
142
143// ─── ExecutionCostBreakdown ───────────────────────────────────────────────────
144
145/// Breakdown of total execution cost across its components.
146#[derive(Debug, Clone, PartialEq)]
147pub struct ExecutionCostBreakdown {
148 /// Commission in USD.
149 pub commission_usd: f64,
150 /// Bid-ask spread cost in USD.
151 pub spread_cost_usd: f64,
152 /// Market impact cost in USD (square-root model).
153 pub market_impact_usd: f64,
154 /// Total cost in USD.
155 pub total_usd: f64,
156 /// Total cost in basis points of notional.
157 pub total_bps: f64,
158}
159
160// ─── TotalExecutionCost ───────────────────────────────────────────────────────
161
162/// Computes the full execution cost breakdown for an order.
163///
164/// # Example
165/// ```rust
166/// use fin_primitives::execution_cost::{TotalExecutionCost, ExecutionCostModel};
167///
168/// let breakdown = TotalExecutionCost::compute(
169/// 1000.0, // size
170/// 150.0, // price
171/// &ExecutionCostModel::Proportional(5.0), // 5 bps commission
172/// 149.90, // bid
173/// 150.10, // ask
174/// 1_000_000.0, // daily volume
175/// 0.02, // sigma (daily vol)
176/// );
177/// assert!(breakdown.total_usd > 0.0);
178/// ```
179pub struct TotalExecutionCost;
180
181impl TotalExecutionCost {
182 /// Compute the total execution cost breakdown.
183 ///
184 /// Uses the square-root (Kyle-style) model for market impact with `eta = 0.1`.
185 pub fn compute(
186 size: f64,
187 price: f64,
188 commission_model: &ExecutionCostModel,
189 bid: f64,
190 ask: f64,
191 volume: f64,
192 sigma: f64,
193 ) -> ExecutionCostBreakdown {
194 let notional = size * price;
195 let commission_usd = commission_model.commission_usd(notional);
196 let spread_cost_usd = SpreadCost::spread_cost(size, bid, ask);
197 let market_impact_usd = MarketImpact::sqrt_impact(size, volume, sigma, 0.1);
198
199 let total_usd = commission_usd + spread_cost_usd + market_impact_usd;
200 let total_bps = if notional > 0.0 {
201 total_usd / notional * 10_000.0
202 } else {
203 0.0
204 };
205
206 ExecutionCostBreakdown {
207 commission_usd,
208 spread_cost_usd,
209 market_impact_usd,
210 total_usd,
211 total_bps,
212 }
213 }
214}
215
216// ─── Tests ────────────────────────────────────────────────────────────────────
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 // ── ExecutionCostModel ──
223
224 #[test]
225 fn test_fixed_commission() {
226 let m = ExecutionCostModel::Fixed(7.50);
227 assert!((m.commission_usd(10_000.0) - 7.50).abs() < 1e-9);
228 assert!((m.commission_usd(1.0) - 7.50).abs() < 1e-9);
229 }
230
231 #[test]
232 fn test_proportional_commission() {
233 // 10 bps on $10_000 notional = $10
234 let m = ExecutionCostModel::Proportional(10.0);
235 assert!((m.commission_usd(10_000.0) - 10.0).abs() < 1e-9);
236 }
237
238 #[test]
239 fn test_zero_commission() {
240 let m = ExecutionCostModel::ZeroCommission;
241 assert_eq!(m.commission_usd(100_000.0), 0.0);
242 }
243
244 #[test]
245 fn test_tiered_commission() {
246 // Tier 0: 0 threshold -> 20 bps
247 // Tier 1: 10_000 threshold -> 10 bps
248 // Tier 2: 100_000 threshold -> 5 bps
249 let m = ExecutionCostModel::Tiered(vec![
250 (0.0, 20.0),
251 (10_000.0, 10.0),
252 (100_000.0, 5.0),
253 ]);
254 // notional $5000 -> tier 0: 20 bps = $10
255 assert!((m.commission_usd(5_000.0) - 10.0).abs() < 1e-9);
256 // notional $50_000 -> tier 1: 10 bps = $50
257 assert!((m.commission_usd(50_000.0) - 50.0).abs() < 1e-9);
258 // notional $200_000 -> tier 2: 5 bps = $100
259 assert!((m.commission_usd(200_000.0) - 100.0).abs() < 1e-9);
260 }
261
262 // ── SpreadCost ──
263
264 #[test]
265 fn test_half_spread_bps() {
266 // bid=99.90, ask=100.10, mid=100.0 -> half spread = 0.10/100.0 * 5000 = 10 bps
267 let hs = SpreadCost::half_spread_bps(99.90, 100.10);
268 assert!((hs - 10.0).abs() < 1e-6);
269 }
270
271 #[test]
272 fn test_half_spread_bps_zero_mid() {
273 assert_eq!(SpreadCost::half_spread_bps(0.0, 0.0), 0.0);
274 }
275
276 #[test]
277 fn test_spread_cost() {
278 // size=1000, bid=99.90, ask=100.10, mid=100.0, hs=10bps
279 // cost = 1000 * 10/10000 * 100 = $100
280 let cost = SpreadCost::spread_cost(1000.0, 99.90, 100.10);
281 assert!((cost - 100.0).abs() < 1e-6);
282 }
283
284 // ── MarketImpact ──
285
286 #[test]
287 fn test_linear_impact() {
288 // size=1000, volume=100_000, participation=1%, impact_bps_per_pct=50
289 // impact = 0.01 * 50/10000 * 1000 = 0.05
290 let imp = MarketImpact::linear_impact(1000.0, 100_000.0, 50.0);
291 assert!((imp - 0.05).abs() < 1e-9);
292 }
293
294 #[test]
295 fn test_linear_impact_zero_volume() {
296 assert_eq!(MarketImpact::linear_impact(1000.0, 0.0, 50.0), 0.0);
297 }
298
299 #[test]
300 fn test_sqrt_impact() {
301 // size=10000, volume=1_000_000, sigma=0.02, eta=0.1
302 // impact = 0.1 * 0.02 * sqrt(0.01) * 10000 = 0.1 * 0.02 * 0.1 * 10000 = 2.0
303 let imp = MarketImpact::sqrt_impact(10_000.0, 1_000_000.0, 0.02, 0.1);
304 assert!((imp - 2.0).abs() < 1e-9);
305 }
306
307 #[test]
308 fn test_sqrt_impact_zero_volume() {
309 assert_eq!(MarketImpact::sqrt_impact(1000.0, 0.0, 0.02, 0.1), 0.0);
310 }
311
312 #[test]
313 fn test_almgren_chriss() {
314 // gamma=0.001, eta=0.1, sigma=0.02
315 // permanent = 0.001 * 10000 = 10
316 // temporary = sqrt_impact(10000, 1_000_000, 0.02, 0.1) = 2.0
317 let (perm, temp) = MarketImpact::almgren_chriss(10_000.0, 1_000_000.0, 0.02, 0.001, 0.1);
318 assert!((perm - 10.0).abs() < 1e-9);
319 assert!((temp - 2.0).abs() < 1e-9);
320 }
321
322 // ── TotalExecutionCost ──
323
324 #[test]
325 fn test_total_execution_cost_components() {
326 let breakdown = TotalExecutionCost::compute(
327 1000.0,
328 100.0,
329 &ExecutionCostModel::Fixed(5.0),
330 99.90,
331 100.10,
332 1_000_000.0,
333 0.02,
334 );
335 // commission = $5 (fixed)
336 assert!((breakdown.commission_usd - 5.0).abs() < 1e-9);
337 // spread cost: size=1000, hs=10bps, mid=100 -> $100
338 assert!((breakdown.spread_cost_usd - 100.0).abs() < 1e-6);
339 // market impact: eta=0.1, sigma=0.02, sqrt(1000/1_000_000)=0.03162, *1000 = 3.162*0.002 = 0.0632...
340 // sqrt_impact = 0.1 * 0.02 * sqrt(0.001) * 1000 = 0.1 * 0.02 * 0.031623 * 1000 = 0.063246
341 assert!(breakdown.market_impact_usd > 0.0);
342 assert!((breakdown.total_usd - (breakdown.commission_usd + breakdown.spread_cost_usd + breakdown.market_impact_usd)).abs() < 1e-9);
343 }
344
345 #[test]
346 fn test_total_execution_cost_bps() {
347 let breakdown = TotalExecutionCost::compute(
348 1000.0,
349 100.0,
350 &ExecutionCostModel::ZeroCommission,
351 99.90,
352 100.10,
353 1_000_000.0,
354 0.02,
355 );
356 // notional = $100_000
357 let expected_bps = breakdown.total_usd / 100_000.0 * 10_000.0;
358 assert!((breakdown.total_bps - expected_bps).abs() < 1e-9);
359 }
360
361 #[test]
362 fn test_total_execution_cost_zero_commission() {
363 let breakdown = TotalExecutionCost::compute(
364 100.0,
365 50.0,
366 &ExecutionCostModel::ZeroCommission,
367 49.95,
368 50.05,
369 100_000.0,
370 0.015,
371 );
372 assert_eq!(breakdown.commission_usd, 0.0);
373 assert!(breakdown.total_usd > 0.0);
374 }
375}