Skip to main content

hypercall_engine/
position.rs

1//! Engine-owned position tracking.
2//!
3//! This module provides [`EnginePosition`], the engine's internal representation
4//! of a single position in a given instrument. It tracks the current quantity
5//! (positive for long, negative for short) and a weighted-average entry price.
6//!
7//! The core operation is [`EnginePosition::apply_fill`], which handles all
8//! position lifecycle transitions: opening, adding, partial close, full close,
9//! and direction flip. It returns the realized PnL for any reducing portion
10//! of the fill.
11//!
12//! All arithmetic uses [`rust_decimal::Decimal`] for exact results. No floating
13//! point is used anywhere in position or PnL calculations.
14
15use hypercall_types::WalletAddress;
16use rust_decimal::Decimal;
17use rust_decimal_macros::dec;
18use std::collections::HashMap;
19
20/// A single position in the engine's internal state.
21///
22/// Tracks the signed quantity (positive = long, negative = short) and the
23/// weighted-average entry price. The entry price is updated on fills that
24/// increase the position, and is used to compute realized PnL on fills
25/// that reduce it.
26///
27/// When the position is fully closed (`quantity == 0`), `entry_price` resets
28/// to zero.
29///
30/// # Invariants
31///
32/// These invariants are verified by Kani proofs in the `proofs` module:
33///
34/// - After any fill: `new_qty == old_qty + signed_qty`
35/// - After any fill: `entry_price >= 0`
36/// - Conservation: `old_qty * old_entry + signed_qty * fill_price == new_qty * new_entry + pnl`
37/// - Exact close: when `new_qty == 0`, both `quantity` and `entry_price` are zero
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39pub struct EnginePosition {
40    /// Signed position quantity. Positive for long, negative for short, zero for flat.
41    pub quantity: Decimal,
42    /// Weighted-average entry price. Zero when the position is flat.
43    pub entry_price: Decimal,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct PositionFillTransition {
48    pub quantity: Decimal,
49    pub entry_price: Decimal,
50    pub realized_pnl: Decimal,
51}
52
53impl EnginePosition {
54    /// Calculate the position state and realized PnL that would result from a fill.
55    pub fn fill_transition(
56        position: Option<&EnginePosition>,
57        signed_qty: Decimal,
58        fill_price: Decimal,
59    ) -> PositionFillTransition {
60        let mut transitioned = position.cloned().unwrap_or(EnginePosition {
61            quantity: Decimal::ZERO,
62            entry_price: Decimal::ZERO,
63        });
64        let realized_pnl = transitioned.apply_fill(signed_qty, fill_price);
65        PositionFillTransition {
66            quantity: transitioned.quantity,
67            entry_price: transitioned.entry_price,
68            realized_pnl,
69        }
70    }
71
72    /// Apply a fill to this position, returning the realized PnL.
73    ///
74    /// `signed_qty` is the signed fill quantity: positive for a buy, negative for a sell.
75    /// `fill_price` is the execution price of this fill.
76    ///
77    /// # Behavior by case
78    ///
79    /// | Current position | Fill direction | Effect                                     |
80    /// |-----------------|----------------|--------------------------------------------|
81    /// | Flat (qty=0)     | Buy or Sell    | Opens new position at fill_price           |
82    /// | Long (qty>0)     | Buy            | Adds to long, entry price becomes weighted avg |
83    /// | Long (qty>0)     | Sell (partial) | Partially closes long, realizes PnL        |
84    /// | Long (qty>0)     | Sell (full)    | Fully closes long, realizes PnL, resets to zero |
85    /// | Long (qty>0)     | Sell (flip)    | Closes long, realizes PnL, opens short at fill_price |
86    /// | Short (qty<0)    | Sell           | Adds to short, entry price becomes weighted avg |
87    /// | Short (qty<0)    | Buy (partial)  | Partially closes short, realizes PnL       |
88    /// | Short (qty<0)    | Buy (full)     | Fully closes short, realizes PnL, resets to zero |
89    /// | Short (qty<0)    | Buy (flip)     | Closes short, realizes PnL, opens long at fill_price |
90    ///
91    /// # PnL calculation
92    ///
93    /// Realized PnL is computed only on the reducing portion of a fill:
94    ///
95    /// - Closing a long: `pnl = (fill_price - entry_price) * qty_closed`
96    /// - Closing a short: `pnl = (entry_price - fill_price) * qty_closed`
97    ///
98    /// Fills that only increase the position return zero PnL.
99    ///
100    /// # Returns
101    ///
102    /// The realized PnL from the reducing portion of this fill. Positive means profit,
103    /// negative means loss. Zero if the fill only increases the position.
104    pub fn apply_fill(&mut self, signed_qty: Decimal, fill_price: Decimal) -> Decimal {
105        let old_qty = self.quantity;
106        let fill_size = signed_qty.abs();
107        let is_buy = signed_qty > dec!(0);
108
109        let mut pnl = dec!(0);
110        let is_reducing_short = old_qty < dec!(0) && is_buy;
111        let is_reducing_long = old_qty > dec!(0) && !is_buy;
112
113        if is_reducing_short {
114            let qty_closed = fill_size.min(old_qty.abs());
115            pnl = (self.entry_price - fill_price) * qty_closed;
116        } else if is_reducing_long {
117            let qty_closed = fill_size.min(old_qty);
118            pnl = (fill_price - self.entry_price) * qty_closed;
119        }
120
121        let new_qty = old_qty + signed_qty;
122        if new_qty == dec!(0) {
123            self.quantity = dec!(0);
124            self.entry_price = dec!(0);
125        } else {
126            let same_dir = (old_qty >= dec!(0)) == (new_qty >= dec!(0));
127            if old_qty == dec!(0) || !same_dir {
128                self.entry_price = fill_price;
129            } else if new_qty.abs() > old_qty.abs() {
130                self.entry_price = (self.entry_price * old_qty.abs()
131                    + fill_price * signed_qty.abs())
132                    / new_qty.abs();
133            }
134            self.quantity = new_qty;
135        }
136
137        pnl
138    }
139}
140
141/// The engine's position map, keyed by `(wallet, instrument_symbol)`.
142///
143/// Each entry tracks a single wallet's position in a single instrument.
144/// The `String` key is the instrument symbol (e.g., `"BTC-20260501-80000-C"`).
145pub type EnginePositionMap = HashMap<(WalletAddress, String), EnginePosition>;