Skip to main content

equanetwork_math/
skew.rs

1//! Inventory skew: dead zone + polynomial intensity, path-averaged over a fill.
2
3#[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/// Polynomial degree for inventory skew outside the dead zone.
12#[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/// Band mid and per-side dead-zone edges (token amounts).
32#[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/// Map inventory balance to signed deviation in ppm (−1e6..=+1e6).
87/// Dead zone → 0; outer limits → ±1e6.
88#[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
153/// Instantaneous skew (ppm) at a deviation. Sign follows deviation; round away from zero.
154fn 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/// Instantaneous vault skew contribution (ppm) at `balance` for a move in `increasing` direction.
181#[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
227/// Signed intensity for the contribution rate on the upper / lower active region.
228fn 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
247/// ∫_a^b (t(x)/1e6)^n dx where t maps linearly from span endpoints to ppm.
248/// `t_at` maps balance → deviation magnitude in ppm on this side.
249fn 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    // ∫ (t/PER)^n db = span / PER^{n+1} * (t1^{n+1} - t0^{n+1}) / (n+1)
286    // For lower side, as b increases t decreases, so use |t0^{n+1} - t1^{n+1}|.
287    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    // Upper active region [upper_start, limit_upper] (t=0 at upper_start)
317    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    // Lower active region [limit_lower, lower_start] (t=0 at lower_start)
334    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/// Path-average skew (ppm) over `[balance_pre, balance_post]`.
358#[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    // Split at dead-zone edges for numerical stability.
397    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    // avg = integral / delta, round away from zero
420    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        // mid=500, offset 50% → lower_start=250, upper_start=750
444        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        // offset 0: skew from mid. Move up from 800→900 on [0,1000], mid=500.
486        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        // Linear: average of endpoints equals path average (no dead zone).
501        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        // Continuous average of linear is midpoint; discrete endpoint mean is close.
521        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        // For convex t^2, integral average is below the endpoint mean.
569        assert!(
570            avg < endpoint_mean,
571            "avg={avg} endpoint_mean={endpoint_mean}"
572        );
573    }
574
575    #[test]
576    fn path_through_dead_zone() {
577        // 50% dead zone: active only outside [250,750]
578        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}