1#[cfg(feature = "wasm")]
4use equanetwork_macros::wasm_expose;
5
6use super::consts::{BPS_DENOMINATOR, MAX_VAULT_SKEW_PER_1M, PER_1M_DENOMINATOR};
7use super::error::{
8 CoreError, AMOUNT_EXCEEDS_MAX_I32, ARITHMETIC_OVERFLOW, DIVISION_BY_ZERO, INVALID_SKEW,
9};
10
11#[derive(Default, Debug, Clone, Copy, Eq, PartialEq)]
13#[cfg_attr(feature = "wasm", wasm_expose)]
14pub enum SkewExponent {
15 #[default]
16 Linear,
17 Quadratic,
18 Cubic,
19}
20
21impl SkewExponent {
22 pub fn value(&self) -> u32 {
23 match self {
24 Self::Linear => 1,
25 Self::Quadratic => 2,
26 Self::Cubic => 3,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, Eq, PartialEq)]
33struct SkewBand {
34 mid: u64,
35 lower_start: u64,
36 upper_start: u64,
37 limit_lower: u64,
38 limit_upper: u64,
39}
40
41impl SkewBand {
42 fn new(
43 limit_lower: u64,
44 limit_upper: u64,
45 lower_skew_offset_bps: u16,
46 upper_skew_offset_bps: u16,
47 ) -> Result<Option<Self>, CoreError> {
48 if lower_skew_offset_bps as u64 > BPS_DENOMINATOR
49 || upper_skew_offset_bps as u64 > BPS_DENOMINATOR
50 {
51 return Err(INVALID_SKEW);
52 }
53 let band = limit_upper.saturating_sub(limit_lower);
54 if band == 0 {
55 return Ok(None);
56 }
57 let half = band / 2;
58 if half == 0 {
59 return Ok(None);
60 }
61 let mid = limit_lower.saturating_add(half);
62 let lower_span = mid.saturating_sub(limit_lower);
63 let upper_span = limit_upper.saturating_sub(mid);
64 let lower_offset = (lower_span as u128)
65 .checked_mul(lower_skew_offset_bps as u128)
66 .ok_or(ARITHMETIC_OVERFLOW)?
67 .checked_div(BPS_DENOMINATOR as u128)
68 .ok_or(DIVISION_BY_ZERO)?;
69 let upper_offset = (upper_span as u128)
70 .checked_mul(upper_skew_offset_bps as u128)
71 .ok_or(ARITHMETIC_OVERFLOW)?
72 .checked_div(BPS_DENOMINATOR as u128)
73 .ok_or(DIVISION_BY_ZERO)?;
74 let lower_start = mid.saturating_sub(lower_offset as u64);
75 let upper_start = mid.saturating_add(upper_offset as u64);
76 Ok(Some(Self {
77 mid,
78 lower_start,
79 upper_start,
80 limit_lower,
81 limit_upper,
82 }))
83 }
84}
85
86#[cfg_attr(feature = "wasm", wasm_expose)]
89pub fn deviation_per_m(
90 balance: u64,
91 limit_lower: u64,
92 limit_upper: u64,
93 lower_skew_offset_bps: u16,
94 upper_skew_offset_bps: u16,
95) -> Result<i32, CoreError> {
96 let Some(band) = SkewBand::new(
97 limit_lower,
98 limit_upper,
99 lower_skew_offset_bps,
100 upper_skew_offset_bps,
101 )?
102 else {
103 return Ok(0);
104 };
105 deviation_per_m_in_band(balance, &band)
106}
107
108fn deviation_per_m_in_band(balance: u64, band: &SkewBand) -> Result<i32, CoreError> {
109 if balance > band.upper_start {
110 let span = band.limit_upper.saturating_sub(band.upper_start);
111 if span == 0 {
112 return Ok(0);
113 }
114 let dist = balance.saturating_sub(band.upper_start).min(span);
115 let ppm = (dist as u128)
116 .checked_mul(PER_1M_DENOMINATOR as u128)
117 .ok_or(ARITHMETIC_OVERFLOW)?
118 .checked_div(span as u128)
119 .ok_or(DIVISION_BY_ZERO)?;
120 let ppm = core::cmp::min(ppm, PER_1M_DENOMINATOR as u128);
121 return i32::try_from(ppm).map_err(|_| AMOUNT_EXCEEDS_MAX_I32);
122 }
123 if balance < band.lower_start {
124 let span = band.lower_start.saturating_sub(band.limit_lower);
125 if span == 0 {
126 return Ok(0);
127 }
128 let dist = band.lower_start.saturating_sub(balance).min(span);
129 let ppm = (dist as u128)
130 .checked_mul(PER_1M_DENOMINATOR as u128)
131 .ok_or(ARITHMETIC_OVERFLOW)?
132 .checked_div(span as u128)
133 .ok_or(DIVISION_BY_ZERO)?;
134 let ppm = core::cmp::min(ppm, PER_1M_DENOMINATOR as u128);
135 let v = i32::try_from(ppm).map_err(|_| AMOUNT_EXCEEDS_MAX_I32)?;
136 return Ok(-v);
137 }
138 Ok(0)
139}
140
141fn select_intensity(
142 deviation_per_m: i32,
143 increasing: bool,
144 positive_skew_per_1m: u32,
145 negative_skew_per_1m: u32,
146) -> u32 {
147 match (deviation_per_m >= 0, increasing) {
148 (true, true) | (false, false) => positive_skew_per_1m,
149 (false, true) | (true, false) => negative_skew_per_1m,
150 }
151}
152
153fn poly_skew_magnitude(
155 abs_dev: u128,
156 intensity: u32,
157 exponent: SkewExponent,
158) -> Result<u128, CoreError> {
159 if intensity == 0 || abs_dev == 0 {
160 return Ok(0);
161 }
162 let exp = exponent.value();
163 let numerator = abs_dev
164 .checked_pow(exp)
165 .ok_or(ARITHMETIC_OVERFLOW)?
166 .checked_mul(intensity as u128)
167 .ok_or(ARITHMETIC_OVERFLOW)?;
168 let denominator = (PER_1M_DENOMINATOR as u128)
169 .checked_pow(exp)
170 .ok_or(ARITHMETIC_OVERFLOW)?;
171 let quotient = numerator.checked_div(denominator).ok_or(DIVISION_BY_ZERO)?;
172 let remainder = numerator.checked_rem(denominator).ok_or(DIVISION_BY_ZERO)?;
173 if remainder > 0 {
174 quotient.checked_add(1).ok_or(ARITHMETIC_OVERFLOW)
175 } else {
176 Ok(quotient)
177 }
178}
179
180#[allow(clippy::too_many_arguments)]
182#[cfg_attr(feature = "wasm", wasm_expose)]
183pub fn compute_skew_per_m(
184 balance: u64,
185 limit_lower: u64,
186 limit_upper: u64,
187 lower_skew_offset_bps: u16,
188 upper_skew_offset_bps: u16,
189 positive_skew_per_1m: u32,
190 negative_skew_per_1m: u32,
191 exponent: SkewExponent,
192 increasing: bool,
193) -> Result<i64, CoreError> {
194 if positive_skew_per_1m > MAX_VAULT_SKEW_PER_1M || negative_skew_per_1m > MAX_VAULT_SKEW_PER_1M
195 {
196 return Err(INVALID_SKEW);
197 }
198 let deviation = deviation_per_m(
199 balance,
200 limit_lower,
201 limit_upper,
202 lower_skew_offset_bps,
203 upper_skew_offset_bps,
204 )?;
205 if deviation == 0 {
206 return Ok(0);
207 }
208 let intensity = select_intensity(
209 deviation,
210 increasing,
211 positive_skew_per_1m,
212 negative_skew_per_1m,
213 );
214 let abs_dev = deviation.unsigned_abs() as u128;
215 let mag = poly_skew_magnitude(abs_dev, intensity, exponent)?;
216 let mag = i64::try_from(mag).map_err(|_| AMOUNT_EXCEEDS_MAX_I32)?;
217 let raw = (deviation.signum() as i64)
218 .checked_mul(mag)
219 .ok_or(ARITHMETIC_OVERFLOW)?;
220 if increasing {
221 Ok(raw)
222 } else {
223 raw.checked_neg().ok_or(ARITHMETIC_OVERFLOW)
224 }
225}
226
227fn region_intensity_signed(
229 upper: bool,
230 increasing: bool,
231 positive_skew_per_1m: u32,
232 negative_skew_per_1m: u32,
233) -> i64 {
234 if upper {
235 if increasing {
236 positive_skew_per_1m as i64
237 } else {
238 -(negative_skew_per_1m as i64)
239 }
240 } else if increasing {
241 -(negative_skew_per_1m as i64)
242 } else {
243 positive_skew_per_1m as i64
244 }
245}
246
247fn integrate_poly_over_span(
250 a: u64,
251 b: u64,
252 span_start: u64,
253 span_end: u64,
254 upper: bool,
255 exponent: SkewExponent,
256) -> Result<u128, CoreError> {
257 if a >= b {
258 return Ok(0);
259 }
260 let span = if upper {
261 span_end.saturating_sub(span_start)
262 } else {
263 span_start.saturating_sub(span_end)
264 };
265 if span == 0 {
266 return Ok(0);
267 }
268 let t = |bal: u64| -> Result<u128, CoreError> {
269 let dist = if upper {
270 bal.saturating_sub(span_start).min(span)
271 } else {
272 span_start.saturating_sub(bal).min(span)
273 };
274 let ppm = (dist as u128)
275 .checked_mul(PER_1M_DENOMINATOR as u128)
276 .ok_or(ARITHMETIC_OVERFLOW)?
277 .checked_div(span as u128)
278 .ok_or(DIVISION_BY_ZERO)?;
279 Ok(core::cmp::min(ppm, PER_1M_DENOMINATOR as u128))
280 };
281 let t0 = t(a)?;
282 let t1 = t(b)?;
283 let n = exponent.value();
284 let n1 = n.checked_add(1).ok_or(ARITHMETIC_OVERFLOW)?;
285 let p0 = t0.checked_pow(n1).ok_or(ARITHMETIC_OVERFLOW)?;
288 let p1 = t1.checked_pow(n1).ok_or(ARITHMETIC_OVERFLOW)?;
289 let delta_p = p0.abs_diff(p1);
290 let numer = (span as u128)
291 .checked_mul(delta_p)
292 .ok_or(ARITHMETIC_OVERFLOW)?;
293 let denom = (PER_1M_DENOMINATOR as u128)
294 .checked_pow(n1)
295 .ok_or(ARITHMETIC_OVERFLOW)?
296 .checked_mul(n1 as u128)
297 .ok_or(ARITHMETIC_OVERFLOW)?;
298 numer.checked_div(denom).ok_or(DIVISION_BY_ZERO)
299}
300
301fn integral_rate_segment(
302 a: u64,
303 b: u64,
304 band: &SkewBand,
305 increasing: bool,
306 positive_skew_per_1m: u32,
307 negative_skew_per_1m: u32,
308 exponent: SkewExponent,
309) -> Result<i128, CoreError> {
310 if a >= b {
311 return Ok(0);
312 }
313
314 let mut sum: i128 = 0;
315
316 let u0 = core::cmp::max(a, band.upper_start);
318 let u1 = core::cmp::min(b, band.limit_upper);
319 if u0 < u1 && band.limit_upper > band.upper_start {
320 let poly =
321 integrate_poly_over_span(u0, u1, band.upper_start, band.limit_upper, true, exponent)?;
322 let intensity =
323 region_intensity_signed(true, increasing, positive_skew_per_1m, negative_skew_per_1m);
324 sum = sum
325 .checked_add(
326 (intensity as i128)
327 .checked_mul(poly as i128)
328 .ok_or(ARITHMETIC_OVERFLOW)?,
329 )
330 .ok_or(ARITHMETIC_OVERFLOW)?;
331 }
332
333 let l0 = core::cmp::max(a, band.limit_lower);
335 let l1 = core::cmp::min(b, band.lower_start);
336 if l0 < l1 && band.lower_start > band.limit_lower {
337 let poly =
338 integrate_poly_over_span(l0, l1, band.lower_start, band.limit_lower, false, exponent)?;
339 let intensity = region_intensity_signed(
340 false,
341 increasing,
342 positive_skew_per_1m,
343 negative_skew_per_1m,
344 );
345 sum = sum
346 .checked_add(
347 (intensity as i128)
348 .checked_mul(poly as i128)
349 .ok_or(ARITHMETIC_OVERFLOW)?,
350 )
351 .ok_or(ARITHMETIC_OVERFLOW)?;
352 }
353
354 Ok(sum)
355}
356
357#[allow(clippy::too_many_arguments)]
359#[cfg_attr(feature = "wasm", wasm_expose)]
360pub fn average_skew_per_m(
361 balance_pre: u64,
362 balance_post: u64,
363 limit_lower: u64,
364 limit_upper: u64,
365 lower_skew_offset_bps: u16,
366 upper_skew_offset_bps: u16,
367 positive_skew_per_1m: u32,
368 negative_skew_per_1m: u32,
369 exponent: SkewExponent,
370) -> Result<i64, CoreError> {
371 if positive_skew_per_1m > MAX_VAULT_SKEW_PER_1M || negative_skew_per_1m > MAX_VAULT_SKEW_PER_1M
372 {
373 return Err(INVALID_SKEW);
374 }
375 if balance_pre == balance_post {
376 return Ok(0);
377 }
378 let Some(band) = SkewBand::new(
379 limit_lower,
380 limit_upper,
381 lower_skew_offset_bps,
382 upper_skew_offset_bps,
383 )?
384 else {
385 return Ok(0);
386 };
387
388 let increasing = balance_post > balance_pre;
389 let lo = core::cmp::min(balance_pre, balance_post);
390 let hi = core::cmp::max(balance_pre, balance_post);
391 let delta = (hi as u128).saturating_sub(lo as u128);
392 if delta == 0 {
393 return Ok(0);
394 }
395
396 let mut points = [lo, band.lower_start, band.upper_start, hi];
398 points.sort_unstable();
399 let mut integral: i128 = 0;
400 for w in points.windows(2) {
401 let a = core::cmp::max(w[0], lo);
402 let b = core::cmp::min(w[1], hi);
403 if a >= b {
404 continue;
405 }
406 integral = integral
407 .checked_add(integral_rate_segment(
408 a,
409 b,
410 &band,
411 increasing,
412 positive_skew_per_1m,
413 negative_skew_per_1m,
414 exponent,
415 )?)
416 .ok_or(ARITHMETIC_OVERFLOW)?;
417 }
418
419 let abs_int = integral.unsigned_abs();
421 let quot = abs_int.checked_div(delta).ok_or(DIVISION_BY_ZERO)?;
422 let rem = abs_int.checked_rem(delta).ok_or(DIVISION_BY_ZERO)?;
423 let abs_avg = if rem > 0 {
424 quot.checked_add(1).ok_or(ARITHMETIC_OVERFLOW)?
425 } else {
426 quot
427 };
428 let abs_avg = i64::try_from(abs_avg).map_err(|_| AMOUNT_EXCEEDS_MAX_I32)?;
429 if integral < 0 {
430 abs_avg.checked_neg().ok_or(ARITHMETIC_OVERFLOW)
431 } else {
432 Ok(abs_avg)
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use rstest::rstest;
440
441 #[test]
442 fn dead_zone_zero_deviation() {
443 assert_eq!(deviation_per_m(500, 0, 1000, 5_000, 5_000).unwrap(), 0);
445 assert_eq!(deviation_per_m(250, 0, 1000, 5_000, 5_000).unwrap(), 0);
446 assert_eq!(deviation_per_m(750, 0, 1000, 5_000, 5_000).unwrap(), 0);
447 }
448
449 #[test]
450 fn deviation_at_outer_limits() {
451 assert_eq!(
452 deviation_per_m(1000, 0, 1000, 5_000, 5_000).unwrap(),
453 1_000_000
454 );
455 assert_eq!(
456 deviation_per_m(0, 0, 1000, 5_000, 5_000).unwrap(),
457 -1_000_000
458 );
459 }
460
461 #[test]
462 fn offset_full_disables_skew() {
463 assert_eq!(deviation_per_m(0, 0, 1000, 10_000, 10_000).unwrap(), 0);
464 assert_eq!(deviation_per_m(1000, 0, 1000, 10_000, 10_000).unwrap(), 0);
465 }
466
467 #[rstest]
468 #[case(SkewExponent::Linear, 200_000, 1_000_000, 200_000)]
469 #[case(SkewExponent::Quadratic, 200_000, 1_000_000, 40_000)]
470 #[case(SkewExponent::Cubic, 200_000, 1_000_000, 8_000)]
471 fn poly_point_values(
472 #[case] exp: SkewExponent,
473 #[case] abs_dev: u128,
474 #[case] intensity: u32,
475 #[case] expected: u128,
476 ) {
477 assert_eq!(
478 poly_skew_magnitude(abs_dev, intensity, exp).unwrap(),
479 expected
480 );
481 }
482
483 #[test]
484 fn worsening_above_mid_positive() {
485 let skew =
487 average_skew_per_m(800, 900, 0, 1000, 0, 0, 10_000, 0, SkewExponent::Linear).unwrap();
488 assert!(skew > 0, "worsening should be positive: {skew}");
489 }
490
491 #[test]
492 fn rebalancing_above_mid_negative() {
493 let skew =
494 average_skew_per_m(900, 800, 0, 1000, 0, 0, 0, 10_000, SkewExponent::Linear).unwrap();
495 assert!(skew < 0, "rebalancing should be negative: {skew}");
496 }
497
498 #[test]
499 fn linear_average_matches_midpoint() {
500 let pre = 600u64;
502 let post = 800u64;
503 let intensity = 50_000u32;
504 let avg = average_skew_per_m(pre, post, 0, 1000, 0, 0, intensity, 0, SkewExponent::Linear)
505 .unwrap();
506 let s0 = compute_skew_per_m(pre, 0, 1000, 0, 0, intensity, 0, SkewExponent::Linear, true)
507 .unwrap();
508 let s1 = compute_skew_per_m(
509 post,
510 0,
511 1000,
512 0,
513 0,
514 intensity,
515 0,
516 SkewExponent::Linear,
517 true,
518 )
519 .unwrap();
520 let mid = (s0 + s1) / 2;
522 assert!((avg - mid).abs() <= 1, "avg={avg} mid={mid}");
523 }
524
525 #[test]
526 fn quadratic_average_not_endpoint_mean() {
527 let pre = 600u64;
528 let post = 1000u64;
529 let intensity = 50_000u32;
530 let avg = average_skew_per_m(
531 pre,
532 post,
533 0,
534 1000,
535 0,
536 0,
537 intensity,
538 0,
539 SkewExponent::Quadratic,
540 )
541 .unwrap();
542 let s0 = compute_skew_per_m(
543 pre,
544 0,
545 1000,
546 0,
547 0,
548 intensity,
549 0,
550 SkewExponent::Quadratic,
551 true,
552 )
553 .unwrap();
554 let s1 = compute_skew_per_m(
555 post,
556 0,
557 1000,
558 0,
559 0,
560 intensity,
561 0,
562 SkewExponent::Quadratic,
563 true,
564 )
565 .unwrap();
566 let endpoint_mean = (s0 + s1) / 2;
567 assert_ne!(avg, endpoint_mean);
568 assert!(
570 avg < endpoint_mean,
571 "avg={avg} endpoint_mean={endpoint_mean}"
572 );
573 }
574
575 #[test]
576 fn path_through_dead_zone() {
577 let skew = average_skew_per_m(
579 400,
580 600,
581 0,
582 1000,
583 5_000,
584 5_000,
585 10_000,
586 10_000,
587 SkewExponent::Linear,
588 )
589 .unwrap();
590 assert_eq!(skew, 0);
591 }
592}