Skip to main content

perpcity_sdk/math/
position.rs

1//! Position-level math: entry price, size, value, leverage, liquidation price.
2//!
3//! All functions are pure and take Alloy primitives (`I256`) for on-chain
4//! signed values and `f64` for pre-converted human-readable values (margin,
5//! ratios). No structs — just functions.
6//!
7//! # On-chain representation
8//!
9//! Position deltas are signed 256-bit integers scaled by 1e6:
10//! - `entry_perp_delta`: positive = long, negative = short (in base units × 1e6)
11//! - `entry_usd_delta`: positive = received USD, negative = paid USD (in USDC × 1e6)
12//!
13//! Margin and fee ratios are pre-converted to `f64` by the caller.
14
15use alloy::primitives::I256;
16
17/// Scale factor for on-chain 6-decimal values.
18const SCALE_1E6: f64 = 1_000_000.0;
19
20/// Convert an `I256` to `f64`.
21///
22/// For values that fit in `i128` (which covers all practical position sizes),
23/// this is a direct cast. Returns `±inf` for values beyond `i128` range.
24// Was not inlined — 2 function calls per entry_price. Now inlined: ~2ns saved per call.
25#[inline]
26fn i256_to_f64(x: I256) -> f64 {
27    // Fast path: all realistic position sizes fit in i64 → single scvtf instruction
28    if let Ok(narrow) = i64::try_from(x) {
29        return narrow as f64;
30    }
31    i256_to_f64_slow(x)
32}
33
34/// Slow path for I256 → f64 conversion (values beyond i64 range).
35#[cold]
36#[inline(never)]
37fn i256_to_f64_slow(x: I256) -> f64 {
38    if let Ok(narrow) = i128::try_from(x) {
39        return narrow as f64;
40    }
41    // Fallback: convert via absolute value.
42    let is_neg = x.is_negative();
43    let abs = x.unsigned_abs();
44    if let Ok(narrow) = u128::try_from(abs) {
45        let f = narrow as f64;
46        return if is_neg { -f } else { f };
47    }
48    // Beyond u128 range: return infinity as a sentinel.
49    if is_neg {
50        f64::NEG_INFINITY
51    } else {
52        f64::INFINITY
53    }
54}
55
56/// Calculate the entry price of a position.
57///
58/// ```text
59/// entry_price = |entry_usd_delta| / |entry_perp_delta|
60/// ```
61///
62/// Both deltas are scaled by 1e6, so the ratio gives the price directly
63/// (the scaling factors cancel out).
64///
65/// Returns `0.0` if `entry_perp_delta` is zero.
66///
67/// # Examples
68///
69/// ```
70/// # use perpcity_sdk::math::position::entry_price;
71/// # use alloy::primitives::I256;
72/// // 1 ETH at $1500: perp_delta = 1e6, usd_delta = -1500e6
73/// let price = entry_price(
74///     I256::try_from(1_000_000i64).unwrap(),
75///     I256::try_from(-1_500_000_000i64).unwrap(),
76/// );
77/// assert!((price - 1500.0).abs() < 0.001);
78/// ```
79#[inline]
80pub fn entry_price(entry_perp_delta: I256, entry_usd_delta: I256) -> f64 {
81    let perp_f = i256_to_f64(entry_perp_delta);
82    if perp_f == 0.0 {
83        return 0.0;
84    }
85    i256_to_f64(entry_usd_delta).abs() / perp_f.abs()
86}
87
88/// Calculate the position size in base units (not scaled).
89///
90/// ```text
91/// size = entry_perp_delta / 1e6
92/// ```
93///
94/// Positive = long, negative = short.
95///
96/// # Examples
97///
98/// ```
99/// # use perpcity_sdk::math::position::position_size;
100/// # use alloy::primitives::I256;
101/// let size = position_size(I256::try_from(2_500_000i64).unwrap());
102/// assert!((size - 2.5).abs() < 1e-6);
103/// ```
104#[inline]
105pub fn position_size(entry_perp_delta: I256) -> f64 {
106    i256_to_f64(entry_perp_delta) / SCALE_1E6
107}
108
109/// Calculate the current position value at a given mark price.
110///
111/// ```text
112/// value = |size| × mark_price
113/// ```
114///
115/// # Examples
116///
117/// ```
118/// # use perpcity_sdk::math::position::position_value;
119/// # use alloy::primitives::I256;
120/// let val = position_value(I256::try_from(1_000_000i64).unwrap(), 1600.0);
121/// assert!((val - 1600.0).abs() < 0.001);
122/// ```
123#[inline]
124pub fn position_value(entry_perp_delta: I256, mark_price: f64) -> f64 {
125    let size = position_size(entry_perp_delta);
126    size.abs() * mark_price
127}
128
129/// Calculate the leverage of a position.
130///
131/// ```text
132/// leverage = position_value / effective_margin
133/// ```
134///
135/// Returns `f64::INFINITY` if effective margin is ≤ 0.
136///
137/// # Examples
138///
139/// ```
140/// # use perpcity_sdk::math::position::leverage;
141/// let lev = leverage(1000.0, 100.0);
142/// assert!((lev - 10.0).abs() < 0.001);
143/// ```
144#[inline]
145pub fn leverage(position_value: f64, effective_margin: f64) -> f64 {
146    if effective_margin <= 0.0 {
147        return f64::INFINITY;
148    }
149    position_value / effective_margin
150}
151
152/// Calculate the liquidation price of a position.
153///
154/// Returns `None` if size is zero or margin ≤ 0.
155///
156/// For **long** positions (`is_long = true`):
157/// ```text
158/// liq_price = entry_price − (margin − liq_ratio × notional) / |size|
159/// ```
160/// Clamped to ≥ 0 (price can't go negative).
161///
162/// For **short** positions (`is_long = false`):
163/// ```text
164/// liq_price = entry_price + (margin − liq_ratio × notional) / |size|
165/// ```
166///
167/// # Arguments
168///
169/// - `entry_perp_delta`, `entry_usd_delta`: On-chain signed deltas (I256, scaled 1e6)
170/// - `margin`: Current margin in USDC (human-readable f64)
171/// - `liq_ratio_scaled`: Liquidation margin ratio, scaled by 1e6 (e.g. `25_000` = 2.5%)
172/// - `is_long`: Whether this is a long position
173///
174/// # Examples
175///
176/// ```
177/// # use perpcity_sdk::math::position::liquidation_price;
178/// # use alloy::primitives::I256;
179/// // Long 1 ETH at $1500, $100 margin, 2.5% liq ratio
180/// let liq = liquidation_price(
181///     I256::try_from(1_000_000i64).unwrap(),
182///     I256::try_from(-1_500_000_000i64).unwrap(),
183///     100.0,
184///     25_000,
185///     true,
186/// );
187/// assert!((liq.unwrap() - 1437.5).abs() < 0.01);
188/// ```
189#[inline]
190pub fn liquidation_price(
191    entry_perp_delta: I256,
192    entry_usd_delta: I256,
193    margin: f64,
194    liq_ratio_scaled: u32,
195    is_long: bool,
196) -> Option<f64> {
197    let size = position_size(entry_perp_delta);
198    if size == 0.0 {
199        return None;
200    }
201    if margin <= 0.0 {
202        return None;
203    }
204
205    let ep = entry_price(entry_perp_delta, entry_usd_delta);
206    let abs_size = size.abs();
207    let notional = abs_size * ep;
208    let liq_ratio = liq_ratio_scaled as f64 / SCALE_1E6;
209
210    let margin_excess = margin - liq_ratio * notional;
211
212    if is_long {
213        let liq = ep - margin_excess / abs_size;
214        Some(liq.max(0.0))
215    } else {
216        let liq = ep + margin_excess / abs_size;
217        Some(liq)
218    }
219}
220
221// ── Tests ──────────────────────────────────────────────────────────────
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    // Helper to make I256 from i64 without verbosity.
228    fn i(val: i64) -> I256 {
229        I256::try_from(val).unwrap()
230    }
231
232    // ── i256_to_f64 ──────────────────────────────────────────────
233
234    #[test]
235    fn i256_to_f64_positive() {
236        assert!((i256_to_f64(i(1_000_000)) - 1_000_000.0).abs() < 0.5);
237    }
238
239    #[test]
240    fn i256_to_f64_negative() {
241        assert!((i256_to_f64(i(-1_000_000)) - (-1_000_000.0)).abs() < 0.5);
242    }
243
244    #[test]
245    fn i256_to_f64_zero() {
246        assert_eq!(i256_to_f64(I256::ZERO), 0.0);
247    }
248
249    #[test]
250    fn i256_to_f64_beyond_i128() {
251        // Positive beyond i128 but within u128: the unsigned fallback path
252        // must produce a finite result (not the infinity sentinel).
253        let beyond_i128 = I256::try_from(i128::MAX).unwrap() + I256::try_from(1i64).unwrap();
254        let f = i256_to_f64(beyond_i128);
255        assert!(f.is_finite());
256        assert!(f > 0.0);
257
258        // Beyond u128 range entirely: returns infinity sentinel.
259        assert_eq!(i256_to_f64(I256::MAX), f64::INFINITY);
260        assert_eq!(i256_to_f64(I256::MIN), f64::NEG_INFINITY);
261    }
262
263    // ── entry_price ──────────────────────────────────────────────
264
265    #[test]
266    fn entry_price_basic() {
267        // 1 ETH at 1500 USDC: perp_delta = 1e6, usd_delta = -1500e6
268        let price = entry_price(i(1_000_000), i(-1_500_000_000));
269        assert!(
270            (price - 1500.0).abs() < 0.001,
271            "price={price}, expected 1500"
272        );
273    }
274
275    #[test]
276    fn entry_price_short() {
277        // Short 1 ETH at 1500: perp_delta = -1e6, usd_delta = +1500e6
278        let price = entry_price(i(-1_000_000), i(1_500_000_000));
279        assert!(
280            (price - 1500.0).abs() < 0.001,
281            "price={price}, expected 1500"
282        );
283    }
284
285    #[test]
286    fn entry_price_fractional() {
287        // 0.5 ETH at 2000: perp_delta = 500_000, usd_delta = -1_000_000_000
288        let price = entry_price(i(500_000), i(-1_000_000_000));
289        assert!(
290            (price - 2000.0).abs() < 0.001,
291            "price={price}, expected 2000"
292        );
293    }
294
295    #[test]
296    fn entry_price_zero_perp_returns_zero() {
297        assert_eq!(entry_price(I256::ZERO, I256::ZERO), 0.0);
298    }
299
300    // ── position_size ────────────────────────────────────────────
301
302    #[test]
303    fn position_size_basic() {
304        let size = position_size(i(2_500_000));
305        assert!((size - 2.5).abs() < 1e-6);
306    }
307
308    #[test]
309    fn position_size_negative() {
310        let size = position_size(i(-1_000_000));
311        assert!((size - (-1.0)).abs() < 1e-6);
312    }
313
314    #[test]
315    fn position_size_zero() {
316        assert_eq!(position_size(I256::ZERO), 0.0);
317    }
318
319    #[test]
320    fn position_size_fractional_eth() {
321        // 0.001 ETH = 1000 on-chain
322        let size = position_size(i(1_000));
323        assert!((size - 0.001).abs() < 1e-9);
324    }
325
326    // ── position_value ───────────────────────────────────────────
327
328    #[test]
329    fn position_value_basic() {
330        // 1 ETH at mark price 1600 → value = 1600
331        let val = position_value(i(1_000_000), 1600.0);
332        assert!((val - 1600.0).abs() < 0.001);
333    }
334
335    #[test]
336    fn position_value_short() {
337        // Short 1 ETH at mark 1600 → value still 1600 (absolute)
338        let val = position_value(i(-1_000_000), 1600.0);
339        assert!((val - 1600.0).abs() < 0.001);
340    }
341
342    #[test]
343    fn position_value_half_eth() {
344        let val = position_value(i(500_000), 2000.0);
345        assert!((val - 1000.0).abs() < 0.001);
346    }
347
348    // ── leverage ─────────────────────────────────────────────────
349
350    #[test]
351    fn leverage_basic() {
352        let lev = leverage(1000.0, 100.0);
353        assert!((lev - 10.0).abs() < 0.001);
354    }
355
356    #[test]
357    fn leverage_1x() {
358        let lev = leverage(1000.0, 1000.0);
359        assert!((lev - 1.0).abs() < 0.001);
360    }
361
362    #[test]
363    fn leverage_zero_margin() {
364        assert!(leverage(1000.0, 0.0).is_infinite());
365    }
366
367    #[test]
368    fn leverage_negative_margin() {
369        assert!(leverage(1000.0, -50.0).is_infinite());
370    }
371
372    // ── liquidation_price ────────────────────────────────────────
373
374    #[test]
375    fn liquidation_price_long() {
376        // Long 1 ETH at $1500, $100 margin, 2.5% liq ratio
377        // liq = 1500 - (100 - 0.025 * 1500) / 1 = 1500 - 62.5 = 1437.5
378        let liq = liquidation_price(i(1_000_000), i(-1_500_000_000), 100.0, 25_000, true);
379        assert!(liq.is_some());
380        assert!(
381            (liq.unwrap() - 1437.5).abs() < 0.01,
382            "liq={}, expected 1437.5",
383            liq.unwrap()
384        );
385    }
386
387    #[test]
388    fn liquidation_price_short() {
389        // Short 1 ETH at $1500, $100 margin, 2.5% liq ratio
390        // liq = 1500 + (100 - 0.025 * 1500) / 1 = 1500 + 62.5 = 1562.5
391        let liq = liquidation_price(i(-1_000_000), i(1_500_000_000), 100.0, 25_000, false);
392        assert!(liq.is_some());
393        assert!(
394            (liq.unwrap() - 1562.5).abs() < 0.01,
395            "liq={}, expected 1562.5",
396            liq.unwrap()
397        );
398    }
399
400    #[test]
401    fn liquidation_price_long_clamped_to_zero() {
402        // If margin is so large that liq_price would go negative, clamp to 0.
403        // 1 ETH at $100, $200 margin, 2.5% liq ratio
404        // liq = 100 - (200 - 0.025 * 100) / 1 = 100 - 197.5 = -97.5 → clamped to 0
405        let liq = liquidation_price(i(1_000_000), i(-100_000_000), 200.0, 25_000, true);
406        assert!(liq.is_some());
407        assert_eq!(liq.unwrap(), 0.0);
408    }
409
410    #[test]
411    fn liquidation_price_zero_size() {
412        assert_eq!(
413            liquidation_price(I256::ZERO, I256::ZERO, 100.0, 25_000, true),
414            None
415        );
416    }
417
418    #[test]
419    fn liquidation_price_zero_margin() {
420        assert_eq!(
421            liquidation_price(i(1_000_000), i(-1_500_000_000), 0.0, 25_000, true),
422            None
423        );
424    }
425
426    #[test]
427    fn liquidation_price_negative_margin() {
428        assert_eq!(
429            liquidation_price(i(1_000_000), i(-1_500_000_000), -50.0, 25_000, true),
430            None
431        );
432    }
433
434    #[test]
435    fn liquidation_price_high_leverage_long() {
436        // 1 ETH at $1500, $15 margin (100x leverage), 2.5% liq ratio
437        // liq = 1500 - (15 - 0.025 * 1500) / 1 = 1500 - (15 - 37.5) = 1500 + 22.5 = 1522.5
438        // With extremely high leverage, liq price is ABOVE entry (very close to liquidation).
439        let liq = liquidation_price(i(1_000_000), i(-1_500_000_000), 15.0, 25_000, true);
440        assert!(liq.is_some());
441        let liq_val = liq.unwrap();
442        assert!(
443            (liq_val - 1522.5).abs() < 0.01,
444            "liq={liq_val}, expected 1522.5"
445        );
446        // Liq price is above entry price — position is almost liquidated immediately.
447        assert!(liq_val > 1500.0);
448    }
449
450    #[test]
451    fn liquidation_price_5_percent_ratio() {
452        // Long 2 ETH at $1000, $200 margin, 5% liq ratio
453        // notional = 2 * 1000 = 2000
454        // liq = 1000 - (200 - 0.05 * 2000) / 2 = 1000 - (200 - 100) / 2 = 1000 - 50 = 950
455        let liq = liquidation_price(i(2_000_000), i(-2_000_000_000), 200.0, 50_000, true);
456        assert!(liq.is_some());
457        assert!(
458            (liq.unwrap() - 950.0).abs() < 0.01,
459            "liq={}, expected 950",
460            liq.unwrap()
461        );
462    }
463}