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}