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
7use serde::{Deserialize, Serialize};
8
9/// Basis-point transaction cost model.
10#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
11pub struct CostModel {
12    pub fee_bps: f64,
13    pub slippage_bps: f64,
14    /// Own-order market-impact coefficient (bps at 100% participation). Slippage
15    /// grows with the square root of the trade's share of portfolio NAV, so an
16    /// agent that wins by betting huge pays for the size it moves.
17    pub impact_bps: f64,
18    /// Per-step financing cost (bps) charged on leveraged exposure above 1× NAV —
19    /// the cost of carrying borrowed money. Long-only, fully-invested books
20    /// (gross ≤ 1) pay nothing; leverage pays for the size it borrows.
21    pub financing_bps: f64,
22    /// Liquidity cap: the most an agent may trade in one step, as a fraction of
23    /// NAV. An order larger than this only **partially fills**; the remainder is
24    /// left for later steps. `f64::INFINITY` (the default) = unlimited liquidity.
25    pub max_participation: f64,
26    /// Optional proportional turnover cost (per-unit, e.g. `0.001` = 10 bps) used
27    /// by [`trf_factor`] to compute the cost-aware reallocation factor (Jiang et
28    /// al.). `None` (the default) leaves cost behaviour byte-identical to the
29    /// fee/slippage/impact model — the turnover factor is opt-in, consumed by
30    /// callers that want the closed-form remainder rather than per-order fills.
31    #[serde(default)]
32    pub trf_cost: Option<f64>,
33}
34
35impl Default for CostModel {
36    fn default() -> Self {
37        Self {
38            fee_bps: 2.0,
39            slippage_bps: 3.0,
40            impact_bps: 50.0,
41            financing_bps: 5.0,
42            max_participation: f64::INFINITY,
43            trf_cost: None,
44        }
45    }
46}
47
48/// The transaction-remainder factor `μ` (Jiang et al., 2017): the fraction of
49/// portfolio value that survives reallocating from `weights_prev` (the drifted
50/// pre-trade weights) to `weights_new` (the targets) at proportional turnover
51/// cost `c`. Solves the fixed point
52///
53/// ```text
54/// μ = (1 − c·w0 − (2c − c²)·Σ max(w_prev_i − μ·w_new_i, 0)) / (1 − c·w0)
55/// ```
56///
57/// where `w0 = 1 − Σ w_new` is the residual cash weight of the target book. The
58/// iteration is deterministic — only mul/add/div/max — and is capped at a pinned
59/// 20 sweeps (it contracts to the 1e-10 tolerance well inside that). `c = 0`
60/// returns exactly `μ = 1` (no cost, nothing lost to turnover).
61pub fn trf_factor(weights_prev: &[f64], weights_new: &[f64], c: f64) -> f64 {
62    let sum_new: f64 = weights_new.iter().sum();
63    let w0 = 1.0 - sum_new;
64    let denom = 1.0 - c * w0;
65    let coef = 2.0 * c - c * c;
66    let mut mu = 1.0;
67    for _ in 0..20 {
68        let mut sell = 0.0;
69        for (prev, new) in weights_prev.iter().zip(weights_new.iter()) {
70            sell += (prev - mu * new).max(0.0);
71        }
72        let mu_next = (1.0 - c * w0 - coef * sell) / denom;
73        if (mu_next - mu).abs() < 1e-10 {
74            mu = mu_next;
75            break;
76        }
77        mu = mu_next;
78    }
79    mu
80}
81
82/// Execution-robustness profile: a named bundle of a [`CostModel`] plus a logical
83/// **decision-to-fill delay** (how many sim-bars an order waits before it becomes
84/// eligible to fill). Lets "score this agent under worst-case execution" be a
85/// single swappable axis rather than hand-tuned cost fields scattered per test.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum CostProfile {
88    /// Frictionless: no fees, no slippage, no impact, no delay. The ceiling case.
89    None,
90    /// A realistic retail/institutional blend — the default-ish baseline.
91    Typical,
92    /// Stressed execution: wide fees + slippage + impact and a multi-bar fill delay.
93    WorstCase,
94}
95
96/// A cost profile resolved to a concrete [`CostModel`] and a decision-to-fill
97/// delay in sim-bars.
98#[derive(Clone, Copy, Debug)]
99pub struct ExecutionProfile {
100    pub costs: CostModel,
101    /// Bars an order waits after the decision before it is eligible to fill.
102    pub decision_delay_bars: usize,
103}
104
105impl CostProfile {
106    /// Resolve this profile to its [`CostModel`] and decision-to-fill delay.
107    pub fn resolve(self) -> ExecutionProfile {
108        match self {
109            CostProfile::None => ExecutionProfile {
110                costs: CostModel {
111                    fee_bps: 0.0,
112                    slippage_bps: 0.0,
113                    impact_bps: 0.0,
114                    financing_bps: 0.0,
115                    max_participation: f64::INFINITY,
116                    trf_cost: None,
117                },
118                decision_delay_bars: 0,
119            },
120            CostProfile::Typical => ExecutionProfile {
121                costs: CostModel::default(),
122                decision_delay_bars: 0,
123            },
124            CostProfile::WorstCase => ExecutionProfile {
125                costs: CostModel {
126                    fee_bps: 10.0,
127                    slippage_bps: 15.0,
128                    impact_bps: 150.0,
129                    financing_bps: 20.0,
130                    max_participation: 0.1,
131                    trf_cost: None,
132                },
133                decision_delay_bars: 2,
134            },
135        }
136    }
137}
138
139/// Per-step financing cost as a fraction of NAV: `financing_bps` applied to the
140/// leveraged portion of gross exposure (everything above 1× NAV). Zero at or below
141/// full investment.
142pub fn financing_cost_frac(financing_bps: f64, gross_exposure: f64) -> f64 {
143    financing_bps / 10_000.0 * (gross_exposure - 1.0).max(0.0)
144}
145
146/// Apply the liquidity cap to a desired trade value: an order is clamped to
147/// `±max_participation × nav`, modelling a partial fill of the rest.
148pub fn liquidity_capped_delta(delta_value: f64, max_participation: f64, nav: f64) -> f64 {
149    if !max_participation.is_finite() {
150        return delta_value;
151    }
152    let cap = max_participation * nav.max(0.0);
153    delta_value.clamp(-cap, cap)
154}
155
156/// Own-order market impact as a return fraction: a concave (square-root law)
157/// function of `participation` = |trade value| / portfolio NAV. Concavity is the
158/// empirical Almgren shape — the first dollar moves the price more than the last.
159pub fn market_impact_frac(impact_bps: f64, participation: f64) -> f64 {
160    impact_bps / 10_000.0 * participation.max(0.0).sqrt()
161}
162
163/// Minimal deterministic PRNG (SplitMix64) for seeded execution noise.
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165pub struct Rng(u64);
166
167impl Rng {
168    pub fn new(seed: u64) -> Self {
169        Rng(seed ^ 0xA5A5_5A5A_C3C3_3C3C)
170    }
171    fn next_u64(&mut self) -> u64 {
172        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
173        let mut z = self.0;
174        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
175        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
176        z ^ (z >> 31)
177    }
178    /// Uniform in [-1, 1].
179    pub fn signed_unit(&mut self) -> f64 {
180        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
181    }
182
183    /// Uniform in [0, 1).
184    pub fn unit(&mut self) -> f64 {
185        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn impact_grows_with_participation() {
195        let small = market_impact_frac(50.0, 0.01);
196        let big = market_impact_frac(50.0, 0.5);
197        assert!(big > small, "bigger trade should cost more");
198        assert!(market_impact_frac(50.0, 0.0).abs() < 1e-12);
199    }
200
201    #[test]
202    fn impact_is_concave() {
203        // Square-root law: doubling participation less-than-doubles the impact.
204        let a = market_impact_frac(50.0, 0.1);
205        let b = market_impact_frac(50.0, 0.2);
206        assert!(b < 2.0 * a, "impact must be concave in size");
207    }
208
209    #[test]
210    fn financing_only_bites_above_full_investment() {
211        assert_eq!(financing_cost_frac(50.0, 1.0), 0.0);
212        assert_eq!(financing_cost_frac(50.0, 0.5), 0.0);
213        assert!(financing_cost_frac(50.0, 2.0) > 0.0);
214    }
215
216    #[test]
217    fn profile_none_is_frictionless() {
218        let p = CostProfile::None.resolve();
219        assert_eq!(p.costs.fee_bps, 0.0);
220        assert_eq!(p.costs.slippage_bps, 0.0);
221        assert_eq!(p.costs.impact_bps, 0.0);
222        assert_eq!(p.costs.financing_bps, 0.0);
223        assert!(!p.costs.max_participation.is_finite());
224        assert_eq!(p.decision_delay_bars, 0);
225    }
226
227    #[test]
228    fn profile_typical_matches_default_costs_no_delay() {
229        let p = CostProfile::Typical.resolve();
230        let d = CostModel::default();
231        assert_eq!(p.costs.fee_bps, d.fee_bps);
232        assert_eq!(p.costs.slippage_bps, d.slippage_bps);
233        assert_eq!(p.decision_delay_bars, 0);
234    }
235
236    #[test]
237    fn worst_case_is_strictly_harsher_with_delay() {
238        let none = CostProfile::None.resolve();
239        let typ = CostProfile::Typical.resolve();
240        let worst = CostProfile::WorstCase.resolve();
241        // Monotone friction across the three profiles.
242        assert!(none.costs.fee_bps <= typ.costs.fee_bps);
243        assert!(typ.costs.fee_bps < worst.costs.fee_bps);
244        assert!(typ.costs.slippage_bps < worst.costs.slippage_bps);
245        assert!(typ.costs.impact_bps < worst.costs.impact_bps);
246        // Worst-case caps liquidity and imposes a fill delay; the others don't.
247        assert!(worst.costs.max_participation.is_finite());
248        assert!(worst.decision_delay_bars > 0);
249        assert_eq!(typ.decision_delay_bars, 0);
250    }
251
252    #[test]
253    fn trf_factor_matches_hand_computed_fixture() {
254        // prev = 50% in asset 0 (50% cash); target = 50% in asset 1 (50% cash).
255        // w0 = 1 - 0.5 = 0.5; the only positive sell term is asset 0 (0.5), which
256        // is μ-independent here, so the fixed point is reached in one sweep:
257        //   μ = (1 - 0.01·0.5 - (0.0199)·0.5) / (1 - 0.01·0.5)
258        //     = 0.98505 / 0.995 = 0.99 exactly.
259        let mu = trf_factor(&[0.5, 0.0], &[0.0, 0.5], 0.01);
260        assert!((mu - 0.99).abs() < 1e-12, "expected μ=0.99, got {mu}");
261    }
262
263    #[test]
264    fn trf_factor_zero_cost_is_unity() {
265        // c = 0 ⇒ nothing is lost to turnover, μ = 1 exactly.
266        assert_eq!(trf_factor(&[0.3, 0.7], &[0.6, 0.4], 0.0), 1.0);
267    }
268
269    #[test]
270    fn trf_factor_converges_within_the_pinned_cap() {
271        // A μ-dependent sell term (target keeps weight in a held name): the result
272        // must be a fixed point to tolerance — i.e. one more sweep barely moves it,
273        // proving convergence happened inside the 20-iteration cap.
274        let prev = [0.8, 0.1];
275        let new = [0.2, 0.6];
276        let c = 0.005;
277        let mu = trf_factor(&prev, &new, c);
278        let w0 = 1.0 - (new[0] + new[1]);
279        let coef = 2.0 * c - c * c;
280        let sell: f64 = prev
281            .iter()
282            .zip(new.iter())
283            .map(|(p, n)| (p - mu * n).max(0.0))
284            .sum();
285        let residual = (1.0 - c * w0 - coef * sell) / (1.0 - c * w0) - mu;
286        assert!(residual.abs() < 1e-10, "μ is not a fixed point: {residual}");
287        assert!(mu > 0.0 && mu <= 1.0, "μ out of range: {mu}");
288    }
289
290    #[test]
291    fn trf_cost_defaults_to_none_and_is_byte_neutral() {
292        // The new field is opt-in: the default model is unchanged, and an explicit
293        // `None` is indistinguishable from the default for every other field.
294        assert_eq!(CostModel::default().trf_cost, None);
295    }
296
297    #[test]
298    fn liquidity_cap_clamps_large_trades() {
299        // 5% of a 1000 NAV = 50 cap.
300        assert_eq!(liquidity_capped_delta(200.0, 0.05, 1000.0), 50.0);
301        assert_eq!(liquidity_capped_delta(-200.0, 0.05, 1000.0), -50.0);
302        // Small trades pass through, and an infinite cap never clamps.
303        assert_eq!(liquidity_capped_delta(30.0, 0.05, 1000.0), 30.0);
304        assert_eq!(liquidity_capped_delta(1e9, f64::INFINITY, 1000.0), 1e9);
305    }
306}