Skip to main content

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}