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>;