1use serde::{Deserialize, Serialize};
8
9#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
11pub struct CostModel {
12 pub fee_bps: f64,
13 pub slippage_bps: f64,
14 pub impact_bps: f64,
18 pub financing_bps: f64,
22 pub max_participation: f64,
26 #[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
48pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum CostProfile {
88 None,
90 Typical,
92 WorstCase,
94}
95
96#[derive(Clone, Copy, Debug)]
99pub struct ExecutionProfile {
100 pub costs: CostModel,
101 pub decision_delay_bars: usize,
103}
104
105impl CostProfile {
106 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
139pub 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
146pub 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
156pub fn market_impact_frac(impact_bps: f64, participation: f64) -> f64 {
160 impact_bps / 10_000.0 * participation.max(0.0).sqrt()
161}
162
163#[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 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 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 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 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 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 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 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 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 assert_eq!(CostModel::default().trf_cost, None);
295 }
296
297 #[test]
298 fn liquidity_cap_clamps_large_trades() {
299 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 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}