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::PER_M_DENOMINATOR;
7
8use super::{SwapVault, AMOUNT_EXCEEDS_MAX_I32, AMOUNT_EXCEEDS_MAX_U64};
9
10use super::error::{CoreError, ARITHMETIC_OVERFLOW};
11
12/// Polynomial degree for inventory skew outside the dead zone.
13#[derive(Default, Debug, Clone, Copy, Eq, PartialEq)]
14#[cfg_attr(feature = "wasm", wasm_expose)]
15pub enum SkewExponent {
16    #[default]
17    Linear,
18    Quadratic,
19    Cubic,
20}
21
22impl SkewExponent {
23    pub fn value(&self) -> u32 {
24        match self {
25            Self::Linear => 1,
26            Self::Quadratic => 2,
27            Self::Cubic => 3,
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy, Eq, PartialEq)]
33#[cfg_attr(feature = "wasm", wasm_expose)]
34pub struct SkewVault {
35    pub inventory_limit_upper: u64,
36    pub inventory_limit_lower: u64,
37    pub positive_skew_per_m: u32,
38    pub negative_skew_per_m: u32,
39    pub lower_skew_offset: u64,
40    pub upper_skew_offset: u64,
41    pub skew_exponent: SkewExponent,
42}
43
44impl From<SwapVault> for SkewVault {
45    fn from(swap_vault: SwapVault) -> Self {
46        Self {
47            inventory_limit_upper: swap_vault.inventory_limit_upper,
48            inventory_limit_lower: swap_vault.inventory_limit_lower,
49            positive_skew_per_m: swap_vault.positive_skew_per_m,
50            negative_skew_per_m: swap_vault.negative_skew_per_m,
51            lower_skew_offset: swap_vault.lower_skew_offset,
52            upper_skew_offset: swap_vault.upper_skew_offset,
53            skew_exponent: swap_vault.skew_exponent,
54        }
55    }
56}
57
58#[derive(Debug, Clone, Copy, Eq, PartialEq)]
59#[cfg_attr(feature = "wasm", wasm_expose)]
60pub struct SkewBand {
61    pub index: i8,
62    pub lower_limit: u64,
63    pub upper_limit: u64,
64    pub skew_per_m: i32,
65}
66
67pub const BAND_COUNT: i8 = 32;
68
69// Skew Vault:
70// | ... 32 bands ... | dead zone | ... 32 bands ... |
71// moving closer to to the dead zone:      negative skew
72// in the dead zone:                       0 skew
73// moving away from the dead zone:         positive skew
74
75#[cfg_attr(feature = "wasm", wasm_expose)]
76pub fn skew_band(balance: u64, increasing: bool, vault: SkewVault) -> Result<SkewBand, CoreError> {
77    let index = band_index(balance, increasing, &vault)?;
78    let intensity = select_intensity(index, increasing, &vault);
79    let (lower_limit, upper_limit) = band_limits(index, &vault)?;
80    let skew_per_m = band_skew(index, intensity, vault.skew_exponent, increasing)?;
81    Ok(SkewBand {
82        index,
83        lower_limit,
84        upper_limit,
85        skew_per_m,
86    })
87}
88
89pub(crate) fn prev_band(
90    current_band: &SkewBand,
91    vault: &SkewVault,
92) -> Result<Option<SkewBand>, CoreError> {
93    let prev_index = current_band.index - 1;
94    if prev_index < -BAND_COUNT {
95        return Ok(None);
96    }
97    let intensity = select_intensity(prev_index, false, vault);
98
99    let (lower_limit, upper_limit) = band_limits(prev_index, vault)?;
100    let skew_per_m = band_skew(prev_index, intensity, vault.skew_exponent, false)?;
101    Ok(Some(SkewBand {
102        index: prev_index,
103        lower_limit,
104        upper_limit,
105        skew_per_m,
106    }))
107}
108
109pub(crate) fn next_band(
110    current_band: &SkewBand,
111    vault: &SkewVault,
112) -> Result<Option<SkewBand>, CoreError> {
113    let next_index = current_band.index + 1;
114    if next_index > BAND_COUNT {
115        return Ok(None);
116    }
117    let intensity = select_intensity(next_index, true, vault);
118
119    let (lower_limit, upper_limit) = band_limits(next_index, vault)?;
120    let skew_per_m = band_skew(next_index, intensity, vault.skew_exponent, true)?;
121    Ok(Some(SkewBand {
122        index: next_index,
123        lower_limit,
124        upper_limit,
125        skew_per_m,
126    }))
127}
128
129fn band_index(balance: u64, increasing: bool, vault: &SkewVault) -> Result<i8, CoreError> {
130    let mut index = closed_band_index(balance, vault)?;
131    // Exact shared edges have zero room in the travel direction; assign the
132    // neighboring band that still has room.
133    let (lower_limit, upper_limit) = band_limits(index, vault)?;
134    if increasing && balance == upper_limit && index < BAND_COUNT {
135        index += 1;
136    } else if !increasing && balance == lower_limit && index > -BAND_COUNT {
137        index -= 1;
138    }
139    Ok(index)
140}
141
142fn closed_band_index(balance: u64, vault: &SkewVault) -> Result<i8, CoreError> {
143    let (diff, pos, sign) = if balance < vault.lower_skew_offset {
144        let diff = vault
145            .lower_skew_offset
146            .checked_sub(vault.inventory_limit_lower)
147            .ok_or(ARITHMETIC_OVERFLOW)?;
148        let pos = vault
149            .lower_skew_offset
150            .checked_sub(balance)
151            .ok_or(ARITHMETIC_OVERFLOW)?;
152        (diff, pos, -1)
153    } else if balance > vault.upper_skew_offset {
154        let diff = vault
155            .inventory_limit_upper
156            .checked_sub(vault.upper_skew_offset)
157            .ok_or(ARITHMETIC_OVERFLOW)?;
158        let pos = balance
159            .checked_sub(vault.upper_skew_offset)
160            .ok_or(ARITHMETIC_OVERFLOW)?;
161        (diff, pos, 1)
162    } else {
163        return Ok(0);
164    };
165
166    let product = pos
167        .checked_mul(BAND_COUNT as u64)
168        .ok_or(ARITHMETIC_OVERFLOW)?;
169    let quotient = product
170        .checked_div(diff.into())
171        .ok_or(ARITHMETIC_OVERFLOW)?;
172    let remainder = product
173        .checked_rem(diff.into())
174        .ok_or(ARITHMETIC_OVERFLOW)?;
175    let raw_index = if remainder > 0 {
176        quotient + 1
177    } else {
178        quotient
179    };
180    let index = raw_index.min(BAND_COUNT as u64) as i8;
181    Ok(sign * index)
182}
183
184fn away_from_dead_zone(index: i8, increasing: bool) -> bool {
185    (index > 0 && increasing) || (index < 0 && !increasing)
186}
187
188fn skew_sign(index: i8, increasing: bool) -> i32 {
189    if index == 0 {
190        0
191    } else if away_from_dead_zone(index, increasing) {
192        1
193    } else {
194        -1
195    }
196}
197
198fn select_intensity(index: i8, increasing: bool, vault: &SkewVault) -> u32 {
199    match skew_sign(index, increasing) {
200        1 => vault.positive_skew_per_m,
201        -1 => vault.negative_skew_per_m,
202        _ => 0,
203    }
204}
205
206fn band_limits(index: i8, vault: &SkewVault) -> Result<(u64, u64), CoreError> {
207    let (start, diff) = if index < 0 {
208        let diff = vault
209            .lower_skew_offset
210            .checked_sub(vault.inventory_limit_lower)
211            .ok_or(ARITHMETIC_OVERFLOW)?;
212        (vault.lower_skew_offset, diff)
213    } else if index > 0 {
214        let diff = vault
215            .inventory_limit_upper
216            .checked_sub(vault.upper_skew_offset)
217            .ok_or(ARITHMETIC_OVERFLOW)?;
218        (vault.upper_skew_offset, diff)
219    } else {
220        return Ok((vault.lower_skew_offset, vault.upper_skew_offset));
221    };
222
223    // Equal-width bands: floor((|index|-1)*range/32) .. floor(|index|*range/32).
224    // Shared inner edge belongs to the neighboring (closer-to-dead-zone) band.
225    let abs_index = index.unsigned_abs() as u128;
226    let range = u128::from(diff);
227    let band_count = BAND_COUNT as u128;
228
229    let inner_delta = range
230        .checked_mul(abs_index.saturating_sub(1))
231        .ok_or(ARITHMETIC_OVERFLOW)?
232        .checked_div(band_count)
233        .ok_or(ARITHMETIC_OVERFLOW)?;
234    let outer_delta = range
235        .checked_mul(abs_index)
236        .ok_or(ARITHMETIC_OVERFLOW)?
237        .checked_div(band_count)
238        .ok_or(ARITHMETIC_OVERFLOW)?;
239
240    let (lower_limit, upper_limit) = if index > 0 {
241        let inner_limit: u64 = u128::from(start)
242            .checked_add(inner_delta)
243            .ok_or(ARITHMETIC_OVERFLOW)?
244            .try_into()
245            .map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
246        if index == BAND_COUNT {
247            (inner_limit, u64::MAX)
248        } else {
249            let outer_limit: u64 = u128::from(start)
250                .checked_add(outer_delta)
251                .ok_or(ARITHMETIC_OVERFLOW)?
252                .try_into()
253                .map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
254            (inner_limit, outer_limit)
255        }
256    } else {
257        let inner_limit: u64 = u128::from(start)
258            .checked_sub(inner_delta)
259            .ok_or(ARITHMETIC_OVERFLOW)?
260            .try_into()
261            .map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
262        if index == -BAND_COUNT {
263            (0, inner_limit)
264        } else {
265            let outer_limit: u64 = u128::from(start)
266                .checked_sub(outer_delta)
267                .ok_or(ARITHMETIC_OVERFLOW)?
268                .try_into()
269                .map_err(|_| AMOUNT_EXCEEDS_MAX_U64)?;
270            (outer_limit, inner_limit)
271        }
272    };
273
274    Ok((lower_limit, upper_limit))
275}
276
277fn band_skew(
278    index: i8,
279    intensity: u32,
280    exponent: SkewExponent,
281    increasing: bool,
282) -> Result<i32, CoreError> {
283    let sign = skew_sign(index, increasing);
284    let numerator = u128::from(index.unsigned_abs())
285        .checked_pow(exponent.value())
286        .ok_or(ARITHMETIC_OVERFLOW)?
287        .checked_mul(intensity as u128)
288        .ok_or(ARITHMETIC_OVERFLOW)?;
289    let denominator = (BAND_COUNT as u128)
290        .checked_pow(exponent.value())
291        .ok_or(ARITHMETIC_OVERFLOW)?;
292
293    let quotient = numerator
294        .checked_div(denominator)
295        .ok_or(ARITHMETIC_OVERFLOW)?;
296    let remainder = numerator
297        .checked_rem(denominator)
298        .ok_or(ARITHMETIC_OVERFLOW)?;
299    let abs_result = if remainder > 0 {
300        quotient + 1
301    } else {
302        quotient
303    };
304
305    let result: i32 = abs_result
306        .min(PER_M_DENOMINATOR as u128)
307        .try_into()
308        .map_err(|_| AMOUNT_EXCEEDS_MAX_I32)?;
309    result.checked_mul(sign).ok_or(ARITHMETIC_OVERFLOW)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use rstest::rstest;
316
317    #[rstest]
318    #[case(0, true, 0)]
319    #[case(0, false, 0)]
320    #[case(-1, true, 3_000)]
321    #[case(-1, false, 7_000)]
322    #[case(1, true, 7_000)]
323    #[case(1, false, 3_000)]
324    fn test_select_intensity(#[case] index: i8, #[case] increasing: bool, #[case] expected: u32) {
325        let vault = SkewVault {
326            inventory_limit_upper: 64_000,
327            inventory_limit_lower: 0,
328            positive_skew_per_m: 7_000,
329            negative_skew_per_m: 3_000,
330            lower_skew_offset: 16_000,
331            upper_skew_offset: 48_000,
332            skew_exponent: SkewExponent::Linear,
333        };
334        assert_eq!(select_intensity(index, increasing, &vault), expected);
335    }
336
337    #[rstest]
338    #[case(SkewExponent::Linear, 1)]
339    #[case(SkewExponent::Quadratic, 2)]
340    #[case(SkewExponent::Cubic, 3)]
341    fn test_skew_exponent_value(#[case] exponent: SkewExponent, #[case] expected: u32) {
342        assert_eq!(exponent.value(), expected);
343    }
344
345    #[rstest]
346    #[case(16_000, true, 0)]
347    #[case(16_000, false, -1)]
348    #[case(32_000, true, 0)]
349    #[case(32_000, false, 0)]
350    #[case(48_000, true, 1)]
351    #[case(48_000, false, 0)]
352    #[case(15_999, true, -1)]
353    #[case(15_999, false, -1)]
354    #[case(48_001, true, 1)]
355    #[case(48_001, false, 1)]
356    #[case(15_500, true, -1)]
357    #[case(15_500, false, -2)]
358    #[case(15_499, true, -2)]
359    #[case(15_499, false, -2)]
360    #[case(48_500, true, 2)]
361    #[case(48_500, false, 1)]
362    #[case(48_501, true, 2)]
363    #[case(48_501, false, 2)]
364    #[case(8_000, true, -16)]
365    #[case(8_000, false, -17)]
366    #[case(56_000, true, 17)]
367    #[case(56_000, false, 16)]
368    #[case(0, true, -32)]
369    #[case(0, false, -32)]
370    #[case(64_000, true, 32)]
371    #[case(64_000, false, 32)]
372    fn test_band_index(#[case] balance: u64, #[case] increasing: bool, #[case] expected: i8) {
373        let vault = SkewVault {
374            inventory_limit_upper: 64_000,
375            inventory_limit_lower: 0,
376            positive_skew_per_m: 0,
377            negative_skew_per_m: 0,
378            lower_skew_offset: 16_000,
379            upper_skew_offset: 48_000,
380            skew_exponent: SkewExponent::Linear,
381        };
382        assert_eq!(band_index(balance, increasing, &vault).unwrap(), expected);
383    }
384
385    #[rstest]
386    #[case(1_001, true, 1)]
387    #[case(1_000, true, 1)]
388    #[case(1_000, false, -1)]
389    #[case(999, false, -1)]
390    #[case(2_000, true, 32)]
391    #[case(0, false, -32)]
392    #[case(1_031, true, 2)]
393    #[case(1_031, false, 1)]
394    #[case(1_032, true, 2)]
395    #[case(969, true, -1)]
396    #[case(969, false, -2)]
397    #[case(968, false, -2)]
398    #[case(1_968, true, 32)]
399    #[case(1_969, true, 32)]
400    fn test_band_index_rounding(
401        #[case] balance: u64,
402        #[case] increasing: bool,
403        #[case] expected: i8,
404    ) {
405        let vault = SkewVault {
406            inventory_limit_upper: 2_000,
407            inventory_limit_lower: 0,
408            positive_skew_per_m: 0,
409            negative_skew_per_m: 0,
410            lower_skew_offset: 1_000,
411            upper_skew_offset: 1_000,
412            skew_exponent: SkewExponent::Linear,
413        };
414        assert_eq!(band_index(balance, increasing, &vault).unwrap(), expected);
415    }
416
417    #[rstest]
418    #[case(0, 32_000, SkewExponent::Linear, true, 0)]
419    #[case(0, 32_000, SkewExponent::Quadratic, true, 0)]
420    #[case(1, 32_000, SkewExponent::Linear, true, 1_000)]
421    #[case(-1, 32_000, SkewExponent::Linear, true, -1_000)]
422    #[case(-1, 32_000, SkewExponent::Linear, false, 1_000)]
423    #[case(1, 32_000, SkewExponent::Linear, false, -1_000)]
424    #[case(16, 32_000, SkewExponent::Linear, true, 16_000)]
425    #[case(-16, 32_000, SkewExponent::Linear, true, -16_000)]
426    #[case(-16, 32_000, SkewExponent::Linear, false, 16_000)]
427    #[case(32, 32_000, SkewExponent::Linear, true, 32_000)]
428    #[case(-32, 32_000, SkewExponent::Linear, true, -32_000)]
429    #[case(16, 32_000, SkewExponent::Quadratic, true, 8_000)]
430    #[case(32, 32_000, SkewExponent::Quadratic, true, 32_000)]
431    #[case(32, 32_000, SkewExponent::Cubic, true, 32_000)]
432    #[case(32, 100_000, SkewExponent::Linear, true, 100_000)]
433    #[case(1, 10_000, SkewExponent::Linear, true, 313)]
434    #[case(-1, 10_000, SkewExponent::Linear, true, -313)]
435    #[case(1, 1, SkewExponent::Linear, true, 1)]
436    #[case(1, 10_000, SkewExponent::Quadratic, true, 10)]
437    #[case(1, 32_000, SkewExponent::Cubic, true, 1)]
438    #[case(8, 32_000, SkewExponent::Quadratic, true, 2_000)]
439    fn test_band_skew(
440        #[case] index: i8,
441        #[case] intensity: u32,
442        #[case] exponent: SkewExponent,
443        #[case] increasing: bool,
444        #[case] expected: i32,
445    ) {
446        assert_eq!(
447            band_skew(index, intensity, exponent, increasing).unwrap(),
448            expected
449        );
450    }
451
452    #[rstest]
453    #[case(0, 16_000, 48_000)]
454    #[case(1, 48_000, 48_500)]
455    #[case(-1, 15_500, 16_000)]
456    #[case(16, 55_500, 56_000)]
457    #[case(-16, 8_000, 8_500)]
458    #[case(32, 63_500, u64::MAX)]
459    #[case(-32, 0, 500)]
460    fn test_band_limits(
461        #[case] index: i8,
462        #[case] expected_lower: u64,
463        #[case] expected_upper: u64,
464    ) {
465        let vault = SkewVault {
466            inventory_limit_upper: 64_000,
467            inventory_limit_lower: 0,
468            positive_skew_per_m: 0,
469            negative_skew_per_m: 0,
470            lower_skew_offset: 16_000,
471            upper_skew_offset: 48_000,
472            skew_exponent: SkewExponent::Linear,
473        };
474        assert_eq!(
475            band_limits(index, &vault).unwrap(),
476            (expected_lower, expected_upper)
477        );
478    }
479
480    #[rstest]
481    #[case(1, 1_000, 1_031)]
482    #[case(-1, 969, 1_000)]
483    #[case(32, 1_968, u64::MAX)]
484    #[case(-32, 0, 32)]
485    fn test_band_limits_rounding(
486        #[case] index: i8,
487        #[case] expected_lower: u64,
488        #[case] expected_upper: u64,
489    ) {
490        let vault = SkewVault {
491            inventory_limit_upper: 2_000,
492            inventory_limit_lower: 0,
493            positive_skew_per_m: 0,
494            negative_skew_per_m: 0,
495            lower_skew_offset: 1_000,
496            upper_skew_offset: 1_000,
497            skew_exponent: SkewExponent::Linear,
498        };
499        assert_eq!(
500            band_limits(index, &vault).unwrap(),
501            (expected_lower, expected_upper)
502        );
503    }
504
505    #[rstest]
506    #[case(1)]
507    #[case(-1)]
508    #[case(2)]
509    #[case(-2)]
510    #[case(16)]
511    #[case(-16)]
512    #[case(32)]
513    #[case(-32)]
514    fn test_band_limits_matches_index(#[case] index: i8) {
515        let vault = SkewVault {
516            inventory_limit_upper: 64_000,
517            inventory_limit_lower: 0,
518            positive_skew_per_m: 0,
519            negative_skew_per_m: 0,
520            lower_skew_offset: 16_000,
521            upper_skew_offset: 48_000,
522            skew_exponent: SkewExponent::Linear,
523        };
524        assert_band_limits_match_index(index, &vault);
525    }
526
527    #[rstest]
528    #[case(1)]
529    #[case(-1)]
530    #[case(32)]
531    #[case(-32)]
532    fn test_band_limits_matches_index_rounding(#[case] index: i8) {
533        let vault = SkewVault {
534            inventory_limit_upper: 2_000,
535            inventory_limit_lower: 0,
536            positive_skew_per_m: 0,
537            negative_skew_per_m: 0,
538            lower_skew_offset: 1_000,
539            upper_skew_offset: 1_000,
540            skew_exponent: SkewExponent::Linear,
541        };
542        assert_band_limits_match_index(index, &vault);
543    }
544
545    fn assert_band_limits_match_index(index: i8, vault: &SkewVault) {
546        let (lower, upper) = band_limits(index, vault).unwrap();
547        // Increasing uses [lower, upper); decreasing uses (lower, upper].
548        // Outer ±32 bands extend to 0 / u64::MAX; sample inventory limits there so
549        // band_index does not overflow on u64::MAX * BAND_COUNT.
550        if index > 0 {
551            let sample_upper = if upper == u64::MAX {
552                vault.inventory_limit_upper
553            } else {
554                upper
555            };
556            assert_eq!(band_index(sample_upper, false, vault).unwrap(), index);
557            if lower < sample_upper {
558                assert_eq!(band_index(lower, true, vault).unwrap(), index);
559                assert_eq!(band_index(lower + 1, true, vault).unwrap(), index);
560                assert_eq!(band_index(lower + 1, false, vault).unwrap(), index);
561            }
562            if sample_upper - lower >= 2 {
563                let mid = lower + (sample_upper - lower) / 2;
564                assert_eq!(band_index(mid, true, vault).unwrap(), index);
565                assert_eq!(band_index(mid, false, vault).unwrap(), index);
566            }
567        } else {
568            assert_eq!(band_index(lower, true, vault).unwrap(), index);
569            if lower < upper {
570                assert_eq!(band_index(upper - 1, true, vault).unwrap(), index);
571                assert_eq!(band_index(upper - 1, false, vault).unwrap(), index);
572            }
573            if upper - lower >= 2 {
574                let mid = lower + (upper - lower) / 2;
575                assert_eq!(band_index(mid, true, vault).unwrap(), index);
576                assert_eq!(band_index(mid, false, vault).unwrap(), index);
577            }
578        }
579    }
580
581    #[rstest]
582    #[case(0, Some((1, 48_000, 48_500, 1_000)))]
583    #[case(-2, Some((-1, 15_500, 16_000, -500)))]
584    #[case(31, Some((32, 63_500, u64::MAX, 32_000)))]
585    #[case(32, None)]
586    fn test_next_band(#[case] current_index: i8, #[case] expected: Option<(i8, u64, u64, i32)>) {
587        let vault = SkewVault {
588            inventory_limit_upper: 64_000,
589            inventory_limit_lower: 0,
590            positive_skew_per_m: 32_000,
591            negative_skew_per_m: 16_000,
592            lower_skew_offset: 16_000,
593            upper_skew_offset: 48_000,
594            skew_exponent: SkewExponent::Linear,
595        };
596        let current = SkewBand {
597            index: current_index,
598            lower_limit: 0,
599            upper_limit: 0,
600            skew_per_m: 0,
601        };
602        let result = next_band(&current, &vault).unwrap();
603        assert_eq!(
604            result.map(|b| (b.index, b.lower_limit, b.upper_limit, b.skew_per_m)),
605            expected
606        );
607    }
608
609    #[test]
610    fn test_next_band_rounding() {
611        let vault = SkewVault {
612            inventory_limit_upper: 2_000,
613            inventory_limit_lower: 0,
614            positive_skew_per_m: 10_000,
615            negative_skew_per_m: 5_000,
616            lower_skew_offset: 1_000,
617            upper_skew_offset: 1_000,
618            skew_exponent: SkewExponent::Linear,
619        };
620        let current = SkewBand {
621            index: 0,
622            lower_limit: 0,
623            upper_limit: 0,
624            skew_per_m: 0,
625        };
626        let band = next_band(&current, &vault).unwrap().unwrap();
627        assert_eq!(
628            (
629                band.index,
630                band.lower_limit,
631                band.upper_limit,
632                band.skew_per_m
633            ),
634            (1, 1_000, 1_031, 313)
635        );
636    }
637
638    #[rstest]
639    #[case(0, Some((-1, 15_500, 16_000, 1_000)))]
640    #[case(-2, Some((-3, 14_500, 15_000, 3_000)))]
641    #[case(-31, Some((-32, 0, 500, 32_000)))]
642    #[case(-32, None)]
643    fn test_prev_band(#[case] current_index: i8, #[case] expected: Option<(i8, u64, u64, i32)>) {
644        let vault = SkewVault {
645            inventory_limit_upper: 64_000,
646            inventory_limit_lower: 0,
647            positive_skew_per_m: 32_000,
648            negative_skew_per_m: 16_000,
649            lower_skew_offset: 16_000,
650            upper_skew_offset: 48_000,
651            skew_exponent: SkewExponent::Linear,
652        };
653        let current = SkewBand {
654            index: current_index,
655            lower_limit: 0,
656            upper_limit: 0,
657            skew_per_m: 0,
658        };
659        let result = prev_band(&current, &vault).unwrap();
660        assert_eq!(
661            result.map(|b| (b.index, b.lower_limit, b.upper_limit, b.skew_per_m)),
662            expected
663        );
664    }
665
666    #[test]
667    fn test_prev_band_rounding() {
668        let vault = SkewVault {
669            inventory_limit_upper: 2_000,
670            inventory_limit_lower: 0,
671            positive_skew_per_m: 10_000,
672            negative_skew_per_m: 5_000,
673            lower_skew_offset: 1_000,
674            upper_skew_offset: 1_000,
675            skew_exponent: SkewExponent::Linear,
676        };
677        let current = SkewBand {
678            index: 0,
679            lower_limit: 0,
680            upper_limit: 0,
681            skew_per_m: 0,
682        };
683        let band = prev_band(&current, &vault).unwrap().unwrap();
684        assert_eq!(
685            (
686                band.index,
687                band.lower_limit,
688                band.upper_limit,
689                band.skew_per_m
690            ),
691            (-1, 969, 1_000, 313)
692        );
693    }
694
695    #[rstest]
696    #[case(32_000, true, SkewExponent::Linear, 0, 16_000, 48_000, 0)]
697    #[case(32_000, false, SkewExponent::Linear, 0, 16_000, 48_000, 0)]
698    #[case(48_001, true, SkewExponent::Linear, 1, 48_000, 48_500, 1_000)]
699    #[case(48_001, false, SkewExponent::Linear, 1, 48_000, 48_500, -500)]
700    #[case(15_999, true, SkewExponent::Linear, -1, 15_500, 16_000, -500)]
701    #[case(15_999, false, SkewExponent::Linear, -1, 15_500, 16_000, 1_000)]
702    #[case(55_999, true, SkewExponent::Quadratic, 16, 55_500, 56_000, 8_000)]
703    // Exact shared edges belong to the neighbor with travel-direction room.
704    #[case(48_000, true, SkewExponent::Linear, 1, 48_000, 48_500, 1_000)]
705    #[case(48_000, false, SkewExponent::Linear, 0, 16_000, 48_000, 0)]
706    #[case(16_000, false, SkewExponent::Linear, -1, 15_500, 16_000, 1_000)]
707    #[case(16_000, true, SkewExponent::Linear, 0, 16_000, 48_000, 0)]
708    #[case(48_500, true, SkewExponent::Linear, 2, 48_500, 49_000, 2_000)]
709    #[case(15_500, false, SkewExponent::Linear, -2, 15_000, 15_500, 2_000)]
710    fn test_skew_band(
711        #[case] balance: u64,
712        #[case] increasing: bool,
713        #[case] skew_exponent: SkewExponent,
714        #[case] expected_index: i8,
715        #[case] expected_lower: u64,
716        #[case] expected_upper: u64,
717        #[case] expected_skew_per_m: i32,
718    ) {
719        let vault = SkewVault {
720            inventory_limit_upper: 64_000,
721            inventory_limit_lower: 0,
722            positive_skew_per_m: 32_000,
723            negative_skew_per_m: 16_000,
724            lower_skew_offset: 16_000,
725            upper_skew_offset: 48_000,
726            skew_exponent,
727        };
728        let band = skew_band(balance, increasing, vault).unwrap();
729        assert_eq!(band.index, expected_index);
730        assert_eq!(band.lower_limit, expected_lower);
731        assert_eq!(band.upper_limit, expected_upper);
732        assert_eq!(band.skew_per_m, expected_skew_per_m);
733    }
734
735    #[test]
736    fn test_skew_band_rounding() {
737        let vault = SkewVault {
738            inventory_limit_upper: 2_000,
739            inventory_limit_lower: 0,
740            positive_skew_per_m: 10_000,
741            negative_skew_per_m: 5_000,
742            lower_skew_offset: 1_000,
743            upper_skew_offset: 1_000,
744            skew_exponent: SkewExponent::Linear,
745        };
746        let band = skew_band(1_001, true, vault).unwrap();
747        assert_eq!(band.index, 1);
748        assert_eq!(band.lower_limit, 1_000);
749        assert_eq!(band.upper_limit, 1_031);
750        assert_eq!(band.skew_per_m, 313);
751        let edge = skew_band(1_000, true, vault).unwrap();
752        assert_eq!(edge.index, 1);
753        let down = skew_band(1_000, false, vault).unwrap();
754        assert_eq!(down.index, -1);
755    }
756
757    #[rstest]
758    #[case(64_000, 32_000, 16_000, 16_000, 48_000, SkewExponent::Quadratic)]
759    #[case(2_000, 10_000, 5_000, 1_000, 1_000, SkewExponent::Cubic)]
760    fn test_from_swap_vault(
761        #[case] inventory_limit_upper: u64,
762        #[case] positive_skew_per_m: u32,
763        #[case] negative_skew_per_m: u32,
764        #[case] lower_skew_offset: u64,
765        #[case] upper_skew_offset: u64,
766        #[case] skew_exponent: SkewExponent,
767    ) {
768        let swap_vault = SwapVault {
769            swap_fee_per_m: 123,
770            inventory_limit_upper,
771            inventory_limit_lower: 0,
772            max_swap_amount: 999,
773            positive_skew_per_m,
774            negative_skew_per_m,
775            lower_skew_offset,
776            upper_skew_offset,
777            skew_exponent,
778        };
779        assert_eq!(
780            SkewVault::from(swap_vault),
781            SkewVault {
782                inventory_limit_upper,
783                inventory_limit_lower: 0,
784                positive_skew_per_m,
785                negative_skew_per_m,
786                lower_skew_offset,
787                upper_skew_offset,
788                skew_exponent,
789            }
790        );
791    }
792
793    #[rstest]
794    #[case(500, 1_000, 64_000, 1_000, 48_000)]
795    #[case(50_000, 0, 48_000, 16_000, 48_000)]
796    #[case(500, 2_000, 64_000, 1_000, 48_000)]
797    fn test_band_index_overflow(
798        #[case] balance: u64,
799        #[case] inventory_limit_lower: u64,
800        #[case] inventory_limit_upper: u64,
801        #[case] lower_skew_offset: u64,
802        #[case] upper_skew_offset: u64,
803    ) {
804        let vault = SkewVault {
805            inventory_limit_upper,
806            inventory_limit_lower,
807            positive_skew_per_m: 0,
808            negative_skew_per_m: 0,
809            lower_skew_offset,
810            upper_skew_offset,
811            skew_exponent: SkewExponent::Linear,
812        };
813        assert_eq!(band_index(balance, true, &vault), Err(ARITHMETIC_OVERFLOW));
814        assert_eq!(band_index(balance, false, &vault), Err(ARITHMETIC_OVERFLOW));
815    }
816
817    #[test]
818    fn test_band_index_clamps_beyond_limit() {
819        // pos=1000, range=32 → ceil(1000*32/32)=1000, clamped to BAND_COUNT
820        let vault = SkewVault {
821            inventory_limit_upper: 32,
822            inventory_limit_lower: 0,
823            positive_skew_per_m: 0,
824            negative_skew_per_m: 0,
825            lower_skew_offset: 0,
826            upper_skew_offset: 0,
827            skew_exponent: SkewExponent::Linear,
828        };
829        assert_eq!(band_index(1_000, true, &vault).unwrap(), BAND_COUNT);
830        assert_eq!(band_index(1_000, false, &vault).unwrap(), BAND_COUNT);
831    }
832}