Skip to main content

sharpebench_sim/
costs.rs

1//! Transaction costs + a tiny seeded PRNG for execution noise.
2//!
3//! Realistic costs (fees, slippage, and seed-varying execution noise) are what
4//! make pass^k meaningful: the same agent run under different execution seeds
5//! produces slightly different returns, so a one-seed fluke can't top the board.
6
7/// Basis-point transaction cost model.
8#[derive(Clone, Copy, Debug)]
9pub struct CostModel {
10    pub fee_bps: f64,
11    pub slippage_bps: f64,
12    /// Own-order market-impact coefficient (bps at 100% participation). Slippage
13    /// grows with the square root of the trade's share of portfolio NAV, so an
14    /// agent that wins by betting huge pays for the size it moves.
15    pub impact_bps: f64,
16    /// Per-step financing cost (bps) charged on leveraged exposure above 1× NAV —
17    /// the cost of carrying borrowed money. Long-only, fully-invested books
18    /// (gross ≤ 1) pay nothing; leverage pays for the size it borrows.
19    pub financing_bps: f64,
20    /// Liquidity cap: the most an agent may trade in one step, as a fraction of
21    /// NAV. An order larger than this only **partially fills**; the remainder is
22    /// left for later steps. `f64::INFINITY` (the default) = unlimited liquidity.
23    pub max_participation: f64,
24}
25
26impl Default for CostModel {
27    fn default() -> Self {
28        Self {
29            fee_bps: 2.0,
30            slippage_bps: 3.0,
31            impact_bps: 50.0,
32            financing_bps: 5.0,
33            max_participation: f64::INFINITY,
34        }
35    }
36}
37
38/// Execution-robustness profile: a named bundle of a [`CostModel`] plus a logical
39/// **decision-to-fill delay** (how many sim-bars an order waits before it becomes
40/// eligible to fill). Lets "score this agent under worst-case execution" be a
41/// single swappable axis rather than hand-tuned cost fields scattered per test.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum CostProfile {
44    /// Frictionless: no fees, no slippage, no impact, no delay. The ceiling case.
45    None,
46    /// A realistic retail/institutional blend — the default-ish baseline.
47    Typical,
48    /// Stressed execution: wide fees + slippage + impact and a multi-bar fill delay.
49    WorstCase,
50}
51
52/// A cost profile resolved to a concrete [`CostModel`] and a decision-to-fill
53/// delay in sim-bars.
54#[derive(Clone, Copy, Debug)]
55pub struct ExecutionProfile {
56    pub costs: CostModel,
57    /// Bars an order waits after the decision before it is eligible to fill.
58    pub decision_delay_bars: usize,
59}
60
61impl CostProfile {
62    /// Resolve this profile to its [`CostModel`] and decision-to-fill delay.
63    pub fn resolve(self) -> ExecutionProfile {
64        match self {
65            CostProfile::None => ExecutionProfile {
66                costs: CostModel {
67                    fee_bps: 0.0,
68                    slippage_bps: 0.0,
69                    impact_bps: 0.0,
70                    financing_bps: 0.0,
71                    max_participation: f64::INFINITY,
72                },
73                decision_delay_bars: 0,
74            },
75            CostProfile::Typical => ExecutionProfile {
76                costs: CostModel::default(),
77                decision_delay_bars: 0,
78            },
79            CostProfile::WorstCase => ExecutionProfile {
80                costs: CostModel {
81                    fee_bps: 10.0,
82                    slippage_bps: 15.0,
83                    impact_bps: 150.0,
84                    financing_bps: 20.0,
85                    max_participation: 0.1,
86                },
87                decision_delay_bars: 2,
88            },
89        }
90    }
91}
92
93/// Per-step financing cost as a fraction of NAV: `financing_bps` applied to the
94/// leveraged portion of gross exposure (everything above 1× NAV). Zero at or below
95/// full investment.
96pub fn financing_cost_frac(financing_bps: f64, gross_exposure: f64) -> f64 {
97    financing_bps / 10_000.0 * (gross_exposure - 1.0).max(0.0)
98}
99
100/// Apply the liquidity cap to a desired trade value: an order is clamped to
101/// `±max_participation × nav`, modelling a partial fill of the rest.
102pub fn liquidity_capped_delta(delta_value: f64, max_participation: f64, nav: f64) -> f64 {
103    if !max_participation.is_finite() {
104        return delta_value;
105    }
106    let cap = max_participation * nav.max(0.0);
107    delta_value.clamp(-cap, cap)
108}
109
110/// Own-order market impact as a return fraction: a concave (square-root law)
111/// function of `participation` = |trade value| / portfolio NAV. Concavity is the
112/// empirical Almgren shape — the first dollar moves the price more than the last.
113pub fn market_impact_frac(impact_bps: f64, participation: f64) -> f64 {
114    impact_bps / 10_000.0 * participation.max(0.0).sqrt()
115}
116
117/// Minimal deterministic PRNG (SplitMix64) for seeded execution noise.
118pub struct Rng(u64);
119
120impl Rng {
121    pub fn new(seed: u64) -> Self {
122        Rng(seed ^ 0xA5A5_5A5A_C3C3_3C3C)
123    }
124    fn next_u64(&mut self) -> u64 {
125        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
126        let mut z = self.0;
127        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
128        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
129        z ^ (z >> 31)
130    }
131    /// Uniform in [-1, 1].
132    pub fn signed_unit(&mut self) -> f64 {
133        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
134    }
135
136    /// Uniform in [0, 1).
137    pub fn unit(&mut self) -> f64 {
138        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn impact_grows_with_participation() {
148        let small = market_impact_frac(50.0, 0.01);
149        let big = market_impact_frac(50.0, 0.5);
150        assert!(big > small, "bigger trade should cost more");
151        assert!(market_impact_frac(50.0, 0.0).abs() < 1e-12);
152    }
153
154    #[test]
155    fn impact_is_concave() {
156        // Square-root law: doubling participation less-than-doubles the impact.
157        let a = market_impact_frac(50.0, 0.1);
158        let b = market_impact_frac(50.0, 0.2);
159        assert!(b < 2.0 * a, "impact must be concave in size");
160    }
161
162    #[test]
163    fn financing_only_bites_above_full_investment() {
164        assert_eq!(financing_cost_frac(50.0, 1.0), 0.0);
165        assert_eq!(financing_cost_frac(50.0, 0.5), 0.0);
166        assert!(financing_cost_frac(50.0, 2.0) > 0.0);
167    }
168
169    #[test]
170    fn profile_none_is_frictionless() {
171        let p = CostProfile::None.resolve();
172        assert_eq!(p.costs.fee_bps, 0.0);
173        assert_eq!(p.costs.slippage_bps, 0.0);
174        assert_eq!(p.costs.impact_bps, 0.0);
175        assert_eq!(p.costs.financing_bps, 0.0);
176        assert!(!p.costs.max_participation.is_finite());
177        assert_eq!(p.decision_delay_bars, 0);
178    }
179
180    #[test]
181    fn profile_typical_matches_default_costs_no_delay() {
182        let p = CostProfile::Typical.resolve();
183        let d = CostModel::default();
184        assert_eq!(p.costs.fee_bps, d.fee_bps);
185        assert_eq!(p.costs.slippage_bps, d.slippage_bps);
186        assert_eq!(p.decision_delay_bars, 0);
187    }
188
189    #[test]
190    fn worst_case_is_strictly_harsher_with_delay() {
191        let none = CostProfile::None.resolve();
192        let typ = CostProfile::Typical.resolve();
193        let worst = CostProfile::WorstCase.resolve();
194        // Monotone friction across the three profiles.
195        assert!(none.costs.fee_bps <= typ.costs.fee_bps);
196        assert!(typ.costs.fee_bps < worst.costs.fee_bps);
197        assert!(typ.costs.slippage_bps < worst.costs.slippage_bps);
198        assert!(typ.costs.impact_bps < worst.costs.impact_bps);
199        // Worst-case caps liquidity and imposes a fill delay; the others don't.
200        assert!(worst.costs.max_participation.is_finite());
201        assert!(worst.decision_delay_bars > 0);
202        assert_eq!(typ.decision_delay_bars, 0);
203    }
204
205    #[test]
206    fn liquidity_cap_clamps_large_trades() {
207        // 5% of a 1000 NAV = 50 cap.
208        assert_eq!(liquidity_capped_delta(200.0, 0.05, 1000.0), 50.0);
209        assert_eq!(liquidity_capped_delta(-200.0, 0.05, 1000.0), -50.0);
210        // Small trades pass through, and an infinite cap never clamps.
211        assert_eq!(liquidity_capped_delta(30.0, 0.05, 1000.0), 30.0);
212        assert_eq!(liquidity_capped_delta(1e9, f64::INFINITY, 1000.0), 1e9);
213    }
214}