Skip to main content

perpl_sdk/state/
position.rs

1use alloy::primitives::U256;
2use fastnum::{D64, D256, UD64, UD128, udec64};
3
4use super::num;
5use crate::{abi::dex::Exchange::PositionInfoV2, types};
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum PositionType {
9    Long = 0,
10    Short = 1,
11}
12
13/// Open perpetual contract position.
14#[derive(Clone, derive_more::Debug)]
15pub struct Position {
16    instant: types::StateInstant,
17    funding_instant: types::StateInstant,
18    perpetual_id: types::PerpetualId,
19    account_id: types::AccountId,
20    r#type: PositionType,
21    #[debug("{entry_price}")]
22    entry_price: UD64, // SC allocates 32 bits + 16 bits residue
23    #[debug("{size}")]
24    size: UD64, // SC allocates 40 bits
25    #[debug("{deposit}")]
26    deposit: UD128, // SC allocates 80 bits
27    #[debug("{delta_pnl}")]
28    delta_pnl: D256, // SC calculations and ABI use 256 bits
29    #[debug("{premium_pnl}")]
30    premium_pnl: D256, // SC calculations and ABI use 256 bits
31    #[debug("{maintenance_margin_requirement}")]
32    maintenance_margin_requirement: UD128,
33}
34
35impl Position {
36    pub(crate) fn new(
37        instant: types::StateInstant,
38        perpetual_id: types::PerpetualId,
39        info: &PositionInfoV2,
40        collateral_converter: num::Converter,
41        price_converter: num::Converter,
42        size_converter: num::Converter,
43        maintenance_margin: UD64,
44    ) -> Self {
45        let r#type = info.positionType.into();
46        let entry_price = Self::effective_entry_price(
47            r#type,
48            info.pricePNS,
49            info.priceResiduePNSQ16.to(),
50            price_converter,
51        );
52        let size = size_converter.from_unsigned(info.lotLNS);
53        Self {
54            instant,
55            funding_instant: instant,
56            perpetual_id,
57            account_id: info.accountId.to(),
58            r#type,
59            entry_price,
60            size,
61            deposit: collateral_converter.from_unsigned(info.depositCNS),
62            delta_pnl: collateral_converter.from_signed(info.deltaPnlCNS),
63            premium_pnl: collateral_converter.from_signed(info.premiumPnlCNS),
64            maintenance_margin_requirement: entry_price.resize() * size.resize()
65                / maintenance_margin.resize(),
66        }
67    }
68
69    #[allow(clippy::too_many_arguments)]
70    pub(crate) fn opened(
71        instant: types::StateInstant,
72        perpetual_id: types::PerpetualId,
73        account_id: types::AccountId,
74        r#type: PositionType,
75        price_pns: U256,
76        price_residue_pnsq16: u32,
77        price_converter: num::Converter,
78        size: UD64,
79        deposit: UD128,
80        maintenance_margin: UD64,
81    ) -> Self {
82        let entry_price =
83            Self::effective_entry_price(r#type, price_pns, price_residue_pnsq16, price_converter);
84        Self {
85            instant,
86            funding_instant: instant,
87            perpetual_id,
88            account_id,
89            r#type,
90            entry_price,
91            size,
92            deposit,
93            delta_pnl: D256::ZERO,
94            premium_pnl: D256::ZERO,
95            maintenance_margin_requirement: entry_price.resize() * size.resize()
96                / maintenance_margin.resize(),
97        }
98    }
99
100    /// Instant the position state is consistent with or was last updated at.
101    pub fn instant(&self) -> types::StateInstant { self.instant }
102
103    /// ID of the perpetual contract.
104    pub fn perpetual_id(&self) -> types::PerpetualId { self.perpetual_id }
105
106    /// ID of the account holding the position.
107    pub fn account_id(&self) -> types::AccountId { self.account_id }
108
109    /// Type of the position.
110    pub fn r#type(&self) -> PositionType { self.r#type }
111
112    /// Position entry price, full precision - including 16 bit rounding residue
113    pub fn entry_price(&self) -> UD64 { self.entry_price }
114
115    /// Size of the position.
116    pub fn size(&self) -> UD64 { self.size }
117
118    /// Collateral deposit / margin locked in the position.
119    pub fn deposit(&self) -> UD128 { self.deposit }
120
121    /// Unrealized Delta PnL of the position.
122    pub fn delta_pnl(&self) -> D256 { self.delta_pnl }
123
124    /// Unrealized Premium PnL of the position.
125    pub fn premium_pnl(&self) -> D256 { self.premium_pnl }
126
127    /// Unrealized PnL of the position.
128    pub fn pnl(&self) -> D256 { self.delta_pnl + self.premium_pnl }
129
130    /// Maintenance margin requirement of the position.
131    pub fn maintenance_margin_requirement(&self) -> UD128 { self.maintenance_margin_requirement }
132
133    /// Liquidation price of the position.
134    pub fn liquidation_price(&self) -> UD64 {
135        let side = if self.r#type.is_long() { D256::ONE } else { D256::ONE.neg() };
136        let liquidation_price = self.entry_price.to_signed()
137            + (side
138                * (self.maintenance_margin_requirement.to_signed().resize()
139                    - self.deposit.to_signed().resize()
140                    - self.premium_pnl)
141                / self.size.to_signed().resize())
142            .resize();
143        liquidation_price.max(D64::ZERO).unsigned_abs()
144    }
145
146    /// Bankruptcy price of the position.
147    pub fn bankruptcy_price(&self) -> UD64 {
148        let side = if self.r#type.is_long() { D256::ONE } else { D256::ONE.neg() };
149        let bankruptcy_price = self.entry_price.to_signed()
150            - (side * (self.deposit.to_signed().resize() + self.premium_pnl)
151                / self.size.to_signed().resize())
152            .resize();
153        bankruptcy_price.max(D64::ZERO).unsigned_abs()
154    }
155
156    pub(crate) fn update_type(&mut self, instant: types::StateInstant, r#type: PositionType) {
157        self.r#type = r#type;
158        self.instant = instant;
159    }
160
161    pub(crate) fn update_entry_price(
162        &mut self,
163        instant: types::StateInstant,
164        price_pns: U256,
165        price_residue_pnsq16: u32,
166        price_converter: num::Converter,
167    ) {
168        self.entry_price = Self::effective_entry_price(
169            self.r#type,
170            price_pns,
171            price_residue_pnsq16,
172            price_converter,
173        );
174        self.instant = instant;
175    }
176
177    pub(crate) fn update_size(&mut self, instant: types::StateInstant, size: UD64) {
178        self.size = size;
179        self.instant = instant;
180    }
181
182    pub(crate) fn update_deposit(&mut self, instant: types::StateInstant, deposit: UD128) {
183        self.deposit = deposit;
184        self.instant = instant;
185    }
186    pub(crate) fn update_premium_pnl(&mut self, instant: types::StateInstant, premium_pnl: D256) {
187        self.premium_pnl = premium_pnl;
188        self.instant = instant;
189        self.funding_instant = instant;
190    }
191
192    pub(crate) fn apply_mark_price(&mut self, instant: types::StateInstant, mark_price: UD64) {
193        let sign = if self.r#type.is_long() { D256::ONE } else { D256::ONE.neg() };
194        self.delta_pnl = sign
195            * (mark_price.resize().to_signed() - self.entry_price.resize().to_signed())
196            * self.size.resize().to_signed();
197        self.instant = instant;
198    }
199
200    pub(crate) fn apply_funding_payment(
201        &mut self,
202        instant: types::StateInstant,
203        payment_per_unit: D256,
204    ) -> bool {
205        // Updating premium PnL only if it wasn't updated at the same instant
206        if self.funding_instant >= instant {
207            return false;
208        }
209
210        // Positive funding payment means longs pay shorts
211        let sign = if self.r#type.is_long() { D256::ONE.neg() } else { D256::ONE };
212        self.premium_pnl += sign * payment_per_unit * self.size.resize().to_signed();
213        self.instant = instant;
214        self.funding_instant = instant;
215        true
216    }
217
218    pub(crate) fn apply_maintenance_margin(
219        &mut self,
220        instant: types::StateInstant,
221        maintenance_margin: UD64,
222    ) {
223        self.maintenance_margin_requirement =
224            self.entry_price.resize() * self.size.resize() / maintenance_margin.resize();
225        self.instant = instant;
226    }
227
228    /// Calculates effective entry price considering:
229    /// - CEIL rounding for LONG positions
230    /// - FLOOR rounding for SHORT positions
231    /// - 16 bit rounding residue preserved by the smart contract separately
232    ///   from PNS rounded price
233    ///
234    ///   LONG  (stored entry_price = ceil):  
235    ///   = (entry_price - 1) + residue / Q    if residue > 0
236    ///   = entry_price                        if residue == 0
237    ///
238    ///   SHORT (stored entry_price = floor):
239    ///   = entry_price  + residue / Q
240    ///
241    ///   where Q = 2^16 = 65536
242    pub(crate) fn effective_entry_price(
243        position_type: PositionType,
244        price_pns: U256,
245        price_residue_pnsq16: u32,
246        price_converter: num::Converter,
247    ) -> UD64 {
248        const Q: UD64 = udec64!(65536).with_rounding_mode(fastnum::decimal::RoundingMode::Floor);
249        if price_residue_pnsq16 == 0 {
250            price_converter.from_unsigned(price_pns)
251        } else {
252            let mut entry_price = UD64::from_u64(price_pns.to())
253                .with_rounding_mode(fastnum::decimal::RoundingMode::Floor); // SC allocates 32 bits
254            if position_type.is_long() && entry_price >= UD64::ONE {
255                entry_price -= UD64::ONE;
256            }
257            (entry_price
258                + UD64::from_u32(price_residue_pnsq16)
259                    .with_rounding_mode(fastnum::decimal::RoundingMode::Floor)
260                    / Q)
261                / price_converter.scale()
262        }
263    }
264}
265
266impl PositionType {
267    pub fn is_long(&self) -> bool { matches!(self, PositionType::Long) }
268
269    pub fn is_short(&self) -> bool { matches!(self, PositionType::Short) }
270}
271
272impl From<u8> for PositionType {
273    fn from(value: u8) -> Self {
274        match value {
275            0 => PositionType::Long,
276            1 => PositionType::Short,
277            _ => unreachable!(),
278        }
279    }
280}
281
282impl std::fmt::Display for PositionType {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            PositionType::Long => write!(f, "Long"),
286            PositionType::Short => write!(f, "Short"),
287        }
288    }
289}
290
291#[cfg(feature = "display")]
292impl tabled::Tabled for Position {
293    const LENGTH: usize = 9;
294
295    fn fields(&self) -> Vec<std::borrow::Cow<'_, str>> {
296        use colored::Colorize;
297        vec![
298            self.perpetual_id().to_string().into(),
299            if self.r#type.is_long() {
300                self.r#type().to_string().green().to_string().into()
301            } else {
302                self.r#type().to_string().red().to_string().into()
303            },
304            self.entry_price().to_string().into(),
305            self.size().to_string().into(),
306            self.deposit().to_string().into(),
307            if self.delta_pnl.is_negative() {
308                self.delta_pnl.to_string().red().to_string().into()
309            } else {
310                self.delta_pnl.to_string().green().to_string().into()
311            },
312            if self.premium_pnl.is_negative() {
313                self.premium_pnl.to_string().red().to_string().into()
314            } else {
315                self.premium_pnl.to_string().green().to_string().into()
316            },
317            if self.pnl().is_negative() {
318                self.pnl().to_string().red().to_string().into()
319            } else {
320                self.pnl().to_string().green().to_string().into()
321            },
322            format!("{:.6}", self.liquidation_price()).into(),
323            format!("{:.6}", self.bankruptcy_price()).into(),
324        ]
325    }
326
327    fn headers() -> Vec<std::borrow::Cow<'static, str>> {
328        vec![
329            "Perp ID".into(),
330            "Type".into(),
331            "Entry Price".into(),
332            "Size".into(),
333            "Deposit".into(),
334            "Delta PnL".into(),
335            "Premium PnL".into(),
336            "Total PnL".into(),
337            "Liq Price".into(),
338            "Bnkrp Price".into(),
339        ]
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use fastnum::{dec256, udec64, udec128};
346
347    use super::*;
348    use crate::types::StateInstant;
349
350    #[test]
351    fn test_effective_entry_price() {
352        let pc = num::Converter::new(4);
353        assert_eq!(
354            Position::effective_entry_price(PositionType::Long, U256::from(1), 0, pc),
355            udec64!(0.0001)
356        );
357        assert_eq!(
358            Position::effective_entry_price(PositionType::Long, U256::from(1), 1, pc),
359            udec64!(0.00000000152587890625) // 1 / 65536 * 10 ^ -4
360        );
361        assert_eq!(
362            Position::effective_entry_price(PositionType::Long, U256::from(10001), 8, pc),
363            udec64!(1.00000001220703125) // 1 + (8 / 65536 * 10 ^ -4)
364        );
365        assert_eq!(
366            Position::effective_entry_price(PositionType::Long, U256::from(10000), 65535, pc),
367            udec64!(0.9999999984741210937) // 1 - (1 / 65536 * 10 ^ -4)
368        );
369
370        assert_eq!(
371            Position::effective_entry_price(PositionType::Short, U256::from(1), 0, pc),
372            udec64!(0.0001)
373        );
374        assert_eq!(
375            Position::effective_entry_price(PositionType::Short, U256::from(0), 1, pc),
376            udec64!(0.00000000152587890625) // 1 / 65536 * 10 ^ -4
377        );
378        assert_eq!(
379            Position::effective_entry_price(PositionType::Short, U256::from(10000), 8, pc),
380            udec64!(1.00000001220703125) // 1 + (8 / 65536 * 10 ^ -4)
381        );
382        assert_eq!(
383            Position::effective_entry_price(PositionType::Short, U256::from(9999), 65535, pc),
384            udec64!(0.9999999984741210937) // 1 - (1 / 65536 * 10 ^ -4)
385        );
386    }
387
388    #[test]
389    fn test_apply_mark_price() {
390        let pc = num::Converter::new(4);
391        let mut pos = Position::opened(
392            StateInstant::default(),
393            1,
394            1,
395            PositionType::Long,
396            U256::from(100),
397            0,
398            pc,
399            udec64!(10),
400            UD128::ZERO,
401            UD64::ONE,
402        );
403
404        pos.apply_mark_price(StateInstant::default(), udec64!(0.015));
405        assert_eq!(pos.delta_pnl(), dec256!(0.05));
406
407        pos.apply_mark_price(StateInstant::default(), udec64!(0.005));
408        assert_eq!(pos.delta_pnl(), dec256!(-0.05));
409
410        let mut pos = Position::opened(
411            StateInstant::default(),
412            1,
413            1,
414            PositionType::Short,
415            U256::from(100),
416            0,
417            pc,
418            udec64!(10),
419            UD128::ZERO,
420            UD64::ONE,
421        );
422        pos.apply_mark_price(StateInstant::default(), udec64!(0.015));
423        assert_eq!(pos.delta_pnl(), dec256!(-0.05));
424
425        pos.apply_mark_price(StateInstant::default(), udec64!(0.005));
426        assert_eq!(pos.delta_pnl(), dec256!(0.05));
427    }
428
429    #[test]
430    fn test_apply_funding_payment() {
431        let pc = num::Converter::new(4);
432        let (i0, i1, i2) =
433            (StateInstant::default(), StateInstant::new(1, 1), StateInstant::new(2, 2));
434        let mut pos = Position::opened(
435            i0,
436            1,
437            1,
438            PositionType::Long,
439            U256::from(100),
440            0,
441            pc,
442            udec64!(10),
443            UD128::ZERO,
444            UD64::ONE,
445        );
446
447        assert!(pos.apply_funding_payment(i1, dec256!(5)));
448        assert_eq!(pos.premium_pnl(), dec256!(-50));
449
450        assert!(pos.apply_funding_payment(i2, dec256!(-10)));
451        assert_eq!(pos.premium_pnl(), dec256!(50));
452
453        assert!(!pos.apply_funding_payment(i2, dec256!(-10)));
454
455        let mut pos = Position::opened(
456            i0,
457            1,
458            1,
459            PositionType::Short,
460            U256::from(100),
461            0,
462            pc,
463            udec64!(10),
464            UD128::ZERO,
465            UD64::ONE,
466        );
467
468        pos.apply_funding_payment(i1, dec256!(5));
469        assert_eq!(pos.premium_pnl(), dec256!(50));
470
471        pos.apply_funding_payment(i2, dec256!(-10));
472        assert_eq!(pos.premium_pnl(), dec256!(-50));
473    }
474
475    // Funding-accrual vs. position-mutating events (Bug 44).
476    //
477    // `Exchange::apply_events` settles the block's scheduled funding before the
478    // block's decreases, so the tick lands on each position's pre-decrease size
479    // and the SDK matches the contract's re-derived premium. The tests below
480    // verify that ordering at the Position level. A short of size 10 receives
481    // +5*10 = +50 for a `payment_per_unit` of 5.
482    fn short_at(instant: StateInstant) -> Position {
483        Position::opened(
484            instant,
485            1,
486            1,
487            PositionType::Short,
488            U256::from(100),
489            0,
490            num::Converter::new(4),
491            udec64!(10),
492            UD128::ZERO,
493            UD64::ONE,
494        )
495    }
496
497    // ── A/B/C: funding + decrease(s) in the SAME (funding-event) block ──────
498    //
499    // Scenario, matching the incident (one block holds the funding effect AND the
500    // decreases):
501    //
502    // Block N:
503    // the short (size 10) already exists and carries a funding balance —
504    // opened earlier, with a prior funding tick that set premium = 50.
505    //
506    // Block N + m:
507    // the funding-event block. Its scheduled funding takes effect here, in the
508    // same block as the decrease(s), in some on-chain order:
509    //     Tx A   decrease (10 -> 6)
510    //     Tx B   decrease   (6 -> 3) [two-decrease test only]
511    //     C      funding takes effect (effective-dated; not itself a tx)
512    //
513    // The contract re-derives premium at block N+m as
514    //   (fundingSumAtBlock - entryFundingSum) * size / scaling
515    // (`CalculationsLib::computePremiumPnl`), with `getFundingSumAtBlock`
516    // returning the sum "prior to OR AT" the block, so the remaining premium is
517    // (sn - entry) * final_size — independent of the on-chain order of A, B, C.
518    //
519    // `apply_events` settles the funding (C) in Pass 1, on each position's
520    // pre-decrease size, before Pass 2 applies the decreases (A, B) — so the
521    // SDK matches the contract for any order. These Position-level tests mirror
522    // that ordering (funding first, then the decreases). so=5 -> sn=6 gives a
523    // one-tick per-lot funding Δ=1; short of size 10, entry funding sum 0.
524
525    #[test]
526    fn test_funding_applied_before_same_block_decrease() {
527        // The tick due at i2 is applied even though the position is also touched by a
528        // decrease in the same block, because funding (Pass 1) runs first (Bug
529        // 44 fix).
530        let (i1, i2) = (StateInstant::new(1, 1), StateInstant::new(2, 2));
531        let mut pos = short_at(StateInstant::default());
532        assert!(pos.apply_funding_payment(i1, dec256!(5))); // premium = 50
533        // Pass 1: funding on the full pre-decrease size 10.
534        assert!(
535            pos.apply_funding_payment(i2, dec256!(5)),
536            "funding must be applied before the same-block decrease"
537        );
538        assert_eq!(pos.premium_pnl(), dec256!(100));
539        // Pass 2: a decrease at i2 (value unchanged here to isolate the tick) does not
540        // drop it.
541        pos.update_premium_pnl(i2, pos.premium_pnl());
542        assert_eq!(pos.premium_pnl(), dec256!(100));
543    }
544
545    #[test]
546    fn test_funding_block_single_decrease_matches_sc() {
547        let (i1, i2) = (StateInstant::new(1, 1), StateInstant::new(2, 2));
548        // Block N: short size 10 with a prior funding tick (premium = so*10 = 50).
549        let mut pos = short_at(StateInstant::default());
550        assert!(pos.apply_funding_payment(i1, dec256!(5)));
551        // Block N+m (funding-event block). Pass 1: the tick Δ=1 lands on the FULL
552        // pre-decrease size 10 first -> premium = 50 + 1*10 = 60 (per-lot sum
553        // now sn=6).
554        assert!(pos.apply_funding_payment(i2, dec256!(1)));
555        assert_eq!(pos.premium_pnl(), dec256!(60));
556        // Pass 2: decrease 10 -> 6 (closes 4), realized at sn=6: fundingCNS = 6*4 = 24.
557        pos.update_size(i2, udec64!(6));
558        pos.update_premium_pnl(i2, pos.premium_pnl() - dec256!(24));
559        // Matches the contract: (sn - entry) * final_size = 6 * 6 = 36 (the old bug
560        // gave 26).
561        assert_eq!(pos.premium_pnl(), dec256!(36));
562    }
563
564    #[test]
565    fn test_funding_block_two_decreases_matches_sc() {
566        // The A/B/C scenario: block N+m holds TWO decreases (A, B) AND the funding tick
567        // (C). Funding (Pass 1) runs first, so the result equals the contract's
568        // value for any on-chain tx order of A, B, C.
569        let (i1, i2) = (StateInstant::new(1, 1), StateInstant::new(2, 2));
570        // Block N: short size 10 with a prior funding tick (premium = 50).
571        let mut pos = short_at(StateInstant::default());
572        assert!(pos.apply_funding_payment(i1, dec256!(5)));
573        // Pass 1: tick Δ=1 on the FULL pre-decrease size 10 -> 60.
574        assert!(pos.apply_funding_payment(i2, dec256!(1)));
575        assert_eq!(pos.premium_pnl(), dec256!(60));
576        // Pass 2 -- A: 10 -> 6 (closes 4), fundingCNS_A = 6*4 = 24.
577        pos.update_size(i2, udec64!(6));
578        pos.update_premium_pnl(i2, pos.premium_pnl() - dec256!(24)); // 36
579        // Pass 2 -- B: 6 -> 3 (closes 3), fundingCNS_B = 6*3 = 18.
580        pos.update_size(i2, udec64!(3));
581        pos.update_premium_pnl(i2, pos.premium_pnl() - dec256!(18)); // 18
582        // Matches the contract: (sn - entry) * final_size = 6 * 3 = 18 (the old bug
583        // gave 8).
584        assert_eq!(pos.premium_pnl(), dec256!(18));
585    }
586
587    #[test]
588    fn test_maintenance_margin_requirement() {
589        let pc = num::Converter::new(4);
590        let i0 = StateInstant::default();
591        let (mm1, mm2) = (udec64!(20), udec64!(10));
592
593        let mut pos = Position::opened(
594            i0,
595            1,
596            1,
597            PositionType::Long,
598            U256::from(1000000),
599            0,
600            pc,
601            udec64!(10),
602            udec128!(100),
603            mm1,
604        );
605        assert_eq!(pos.maintenance_margin_requirement(), udec128!(50));
606
607        pos.update_entry_price(i0, U256::from(800000), 0, pc);
608        pos.apply_maintenance_margin(i0, mm1);
609        assert_eq!(pos.maintenance_margin_requirement(), udec128!(40));
610
611        pos.update_size(i0, udec64!(20));
612        pos.apply_maintenance_margin(i0, mm1);
613        assert_eq!(pos.maintenance_margin_requirement(), udec128!(80));
614
615        pos.apply_maintenance_margin(i0, mm2);
616        assert_eq!(pos.maintenance_margin_requirement(), udec128!(160));
617
618        let mut pos = Position::opened(
619            i0,
620            1,
621            1,
622            PositionType::Short,
623            U256::from(1000000),
624            0,
625            pc,
626            udec64!(10),
627            udec128!(100),
628            mm1,
629        );
630        assert_eq!(pos.maintenance_margin_requirement(), udec128!(50));
631
632        pos.update_entry_price(i0, U256::from(800000), 0, pc);
633        pos.apply_maintenance_margin(i0, mm1);
634        assert_eq!(pos.maintenance_margin_requirement(), udec128!(40));
635
636        pos.update_size(i0, udec64!(20));
637        pos.apply_maintenance_margin(i0, mm1);
638        assert_eq!(pos.maintenance_margin_requirement(), udec128!(80));
639
640        pos.apply_maintenance_margin(i0, mm2);
641        assert_eq!(pos.maintenance_margin_requirement(), udec128!(160));
642    }
643
644    #[test]
645    fn test_liquidation_price() {
646        let pc = num::Converter::new(4);
647        let (i0, i1) = (StateInstant::default(), StateInstant::new(1, 1));
648        let mm1 = udec64!(20);
649
650        let mut pos = Position::opened(
651            i0,
652            1,
653            1,
654            PositionType::Long,
655            U256::from(1000000),
656            0,
657            pc,
658            udec64!(10),
659            udec128!(100),
660            mm1,
661        );
662        assert_eq!(pos.liquidation_price(), udec64!(95));
663
664        assert!(pos.apply_funding_payment(i1, dec256!(5)));
665        assert_eq!(pos.liquidation_price(), udec64!(100));
666
667        let mut pos = Position::opened(
668            i0,
669            1,
670            1,
671            PositionType::Short,
672            U256::from(1000000),
673            0,
674            pc,
675            udec64!(10),
676            udec128!(100),
677            mm1,
678        );
679        assert_eq!(pos.liquidation_price(), udec64!(105));
680
681        assert!(pos.apply_funding_payment(i1, dec256!(-5)));
682        assert_eq!(pos.liquidation_price(), udec64!(100));
683    }
684
685    #[test]
686    fn test_bankruptcy_price() {
687        let pc = num::Converter::new(4);
688        let (i0, i1) = (StateInstant::default(), StateInstant::new(1, 1));
689        let mm1 = udec64!(20);
690
691        let mut pos = Position::opened(
692            i0,
693            1,
694            1,
695            PositionType::Long,
696            U256::from(1000000),
697            0,
698            pc,
699            udec64!(10),
700            udec128!(100),
701            mm1,
702        );
703        assert_eq!(pos.bankruptcy_price(), udec64!(90));
704
705        assert!(pos.apply_funding_payment(i1, dec256!(5)));
706        assert_eq!(pos.bankruptcy_price(), udec64!(95));
707
708        let mut pos = Position::opened(
709            i0,
710            1,
711            1,
712            PositionType::Short,
713            U256::from(1000000),
714            0,
715            pc,
716            udec64!(10),
717            udec128!(100),
718            mm1,
719        );
720        assert_eq!(pos.bankruptcy_price(), udec64!(110));
721
722        assert!(pos.apply_funding_payment(i1, dec256!(-5)));
723        assert_eq!(pos.bankruptcy_price(), udec64!(105));
724    }
725
726    #[test]
727    fn test_liquidation_price_after_funding_received() {
728        // Complements test_liquidation_price, which only drives premium NEGATIVE
729        // (funding paid). Here the position RECEIVES funding, driving premium
730        // to +50 and moving the liquidation price AWAY from entry — long 95 ->
731        // 90, short 105 -> 110. Setup mirrors test_liquidation_price (entry
732        // 100, size 10, deposit 100, mm 20 -> MMR 50).
733        let pc = num::Converter::new(4);
734        let (i0, i1) = (StateInstant::default(), StateInstant::new(1, 1));
735        let mm1 = udec64!(20);
736
737        // Long receives funding when longs are paid (negative payment).
738        let mut pos = Position::opened(
739            i0,
740            1,
741            1,
742            PositionType::Long,
743            U256::from(1000000),
744            0,
745            pc,
746            udec64!(10),
747            udec128!(100),
748            mm1,
749        );
750        assert_eq!(pos.liquidation_price(), udec64!(95)); // 100 + (50-100-0)/10
751        assert!(pos.apply_funding_payment(i1, dec256!(-5)), "long receives funding");
752        assert_eq!(pos.premium_pnl(), dec256!(50)); // long sign -1: += -1*(-5)*10 = +50
753        assert_eq!(pos.liquidation_price(), udec64!(90)); // 100 + (50-100-50)/10
754
755        // Short receives funding when shorts are paid (positive payment).
756        let mut pos = Position::opened(
757            i0,
758            1,
759            1,
760            PositionType::Short,
761            U256::from(1000000),
762            0,
763            pc,
764            udec64!(10),
765            udec128!(100),
766            mm1,
767        );
768        assert_eq!(pos.liquidation_price(), udec64!(105)); // 100 - (50-100-0)/10
769        assert!(pos.apply_funding_payment(i1, dec256!(5)), "short receives funding");
770        assert_eq!(pos.premium_pnl(), dec256!(50)); // short sign +1: += 1*5*10 = +50
771        assert_eq!(pos.liquidation_price(), udec64!(110)); // 100 - (50-100-50)/10
772    }
773
774    #[test]
775    fn test_bankruptcy_price_after_funding_received() {
776        // Complements test_bankruptcy_price (premium negative only). The position
777        // RECEIVES funding (+50): long bank 90 -> 85, short bank 110 -> 115.
778        // Same setup as test_bankruptcy_price.
779        let pc = num::Converter::new(4);
780        let (i0, i1) = (StateInstant::default(), StateInstant::new(1, 1));
781        let mm1 = udec64!(20);
782
783        let mut pos = Position::opened(
784            i0,
785            1,
786            1,
787            PositionType::Long,
788            U256::from(1000000),
789            0,
790            pc,
791            udec64!(10),
792            udec128!(100),
793            mm1,
794        );
795        assert_eq!(pos.bankruptcy_price(), udec64!(90)); // 100 - (100+0)/10
796        assert!(pos.apply_funding_payment(i1, dec256!(-5)), "long receives funding"); // premium +50
797        assert_eq!(pos.bankruptcy_price(), udec64!(85)); // 100 - (100+50)/10
798
799        let mut pos = Position::opened(
800            i0,
801            1,
802            1,
803            PositionType::Short,
804            U256::from(1000000),
805            0,
806            pc,
807            udec64!(10),
808            udec128!(100),
809            mm1,
810        );
811        assert_eq!(pos.bankruptcy_price(), udec64!(110)); // 100 + (100+0)/10
812        assert!(pos.apply_funding_payment(i1, dec256!(5)), "short receives funding"); // premium +50
813        assert_eq!(pos.bankruptcy_price(), udec64!(115)); // 100 + (100+50)/10
814    }
815
816    #[test]
817    fn test_apply_mark_price_with_residue() {
818        let i0 = StateInstant::default();
819        let half_lsb = dec256!(0.0000005);
820        let pc = num::Converter::new(6);
821
822        let mut pos = Position::opened(
823            i0,
824            1,
825            1,
826            PositionType::Long,
827            U256::from(100000000),
828            32768, // residue = Q/2
829            pc,
830            udec64!(10),
831            UD128::ZERO,
832            UD64::ONE,
833        );
834        pos.apply_mark_price(i0, udec64!(100));
835        // mark - effective = 100 - (100 - 0.5e-6) = +0.5e-6, * size 10 = 0.5e-5
836        assert_eq!(pos.delta_pnl(), half_lsb * dec256!(10));
837
838        let mut pos = Position::opened(
839            i0,
840            1,
841            1,
842            PositionType::Short,
843            U256::from(100000000),
844            32768,
845            pc,
846            udec64!(10),
847            UD128::ZERO,
848            UD64::ONE,
849        );
850        pos.apply_mark_price(i0, udec64!(100));
851        // short: sign flip, effective = entry + 0.5e-6, (mark - effective) = -0.5e-6
852        // delta_pnl = -1 * -0.5e-6 * 10 = 0.5e-5
853        assert_eq!(pos.delta_pnl(), half_lsb * dec256!(10));
854
855        // residue = 0 reproduces legacy behavior byte-identically.
856        let mut pos = Position::opened(
857            i0,
858            1,
859            1,
860            PositionType::Long,
861            U256::from(100000000),
862            0,
863            pc,
864            udec64!(10),
865            UD128::ZERO,
866            UD64::ONE,
867        );
868        pos.apply_mark_price(i0, udec64!(150));
869        assert_eq!(pos.delta_pnl(), dec256!(500));
870    }
871}