Skip to main content

hypercall/pnl_attribution/
position_tracker.rs

1//! Position tracker that replays fills and computes per-symbol PnL attribution.
2
3use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5use std::collections::HashMap;
6
7/// A fill event to replay.
8#[derive(Debug, Clone)]
9pub struct Fill {
10    pub symbol: String,
11    pub side: Side,
12    pub price: Decimal,
13    pub size: Decimal,
14    pub timestamp_ms: i64,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Side {
19    Buy,
20    Sell,
21}
22
23/// Per-symbol PnL attribution at a point in time.
24#[derive(Debug, Clone)]
25pub struct SymbolAttribution {
26    /// Net position size (positive = long, negative = short).
27    pub position: Decimal,
28    /// Volume-weighted average entry price of the current position.
29    pub entry_price: Decimal,
30    /// Cumulative realized PnL from closing trades.
31    pub realized_pnl: Decimal,
32    /// Unrealized PnL at the given mark price.
33    pub unrealized_pnl: Decimal,
34    /// Total PnL (realized + unrealized).
35    pub total_pnl: Decimal,
36}
37
38/// Full attribution snapshot for a wallet.
39#[derive(Debug, Clone)]
40pub struct Attribution {
41    pub by_symbol: HashMap<String, SymbolAttribution>,
42    pub total_pnl: Decimal,
43}
44
45/// Internal position state per symbol.
46#[derive(Debug, Clone)]
47struct PositionState {
48    /// Net position (positive = long, negative = short).
49    amount: Decimal,
50    /// Volume-weighted average entry price.
51    entry_price: Decimal,
52    /// Cumulative realized PnL.
53    realized: Decimal,
54}
55
56impl PositionState {
57    fn new() -> Self {
58        Self {
59            amount: dec!(0),
60            entry_price: dec!(0),
61            realized: dec!(0),
62        }
63    }
64
65    fn apply_fill(&mut self, side: Side, price: Decimal, size: Decimal) {
66        let signed_qty = match side {
67            Side::Buy => size,
68            Side::Sell => -size,
69        };
70
71        let is_reducing = (self.amount > dec!(0) && signed_qty < dec!(0))
72            || (self.amount < dec!(0) && signed_qty > dec!(0));
73
74        if is_reducing {
75            let close_qty = size.min(self.amount.abs());
76            let pnl = if self.amount > dec!(0) {
77                // Closing long: sell price - entry
78                (price - self.entry_price) * close_qty
79            } else {
80                // Closing short: entry - buy price
81                (self.entry_price - price) * close_qty
82            };
83            self.realized += pnl;
84
85            let remaining = size - close_qty;
86            self.amount += signed_qty;
87
88            // If we flipped sides, the remaining opens a new position at fill price
89            if remaining > dec!(0) {
90                self.entry_price = price;
91                // amount already includes the flip
92            }
93            // If fully closed or partially closed, entry_price stays for remainder
94        } else {
95            // Adding to position or opening new
96            if self.amount == dec!(0) {
97                self.entry_price = price;
98            } else {
99                // Weighted average entry price
100                let old_cost = self.entry_price * self.amount.abs();
101                let new_cost = price * size;
102                let new_total = self.amount.abs() + size;
103                if new_total > dec!(0) {
104                    self.entry_price = (old_cost + new_cost) / new_total;
105                }
106            }
107            self.amount += signed_qty;
108        }
109    }
110
111    fn unrealized_at_mark(&self, mark: Decimal) -> Decimal {
112        if self.amount == dec!(0) {
113            return dec!(0);
114        }
115        if self.amount > dec!(0) {
116            (mark - self.entry_price) * self.amount
117        } else {
118            (self.entry_price - mark) * self.amount.abs()
119        }
120    }
121}
122
123/// Tracks positions for a single wallet across all symbols.
124pub struct PositionTracker {
125    positions: HashMap<String, PositionState>,
126}
127
128impl Default for PositionTracker {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl PositionTracker {
135    pub fn new() -> Self {
136        Self {
137            positions: HashMap::new(),
138        }
139    }
140
141    /// Apply a fill to update position state.
142    pub fn apply_fill(&mut self, fill: &Fill) {
143        let state = self
144            .positions
145            .entry(fill.symbol.clone())
146            .or_insert_with(PositionState::new);
147        state.apply_fill(fill.side, fill.price, fill.size);
148    }
149
150    /// Compute full attribution given mark prices for each symbol.
151    /// Symbols without a mark price get unrealized = 0.
152    pub fn attribution(&self, marks: &HashMap<String, Decimal>) -> Attribution {
153        let mut by_symbol = HashMap::new();
154        let mut total_pnl = dec!(0);
155
156        for (symbol, state) in &self.positions {
157            if state.amount == dec!(0) && state.realized == dec!(0) {
158                continue;
159            }
160
161            let mark = marks.get(symbol).copied().unwrap_or(dec!(0));
162            let unrealized = state.unrealized_at_mark(mark);
163            let symbol_total = state.realized + unrealized;
164            total_pnl += symbol_total;
165
166            by_symbol.insert(
167                symbol.clone(),
168                SymbolAttribution {
169                    position: state.amount,
170                    entry_price: state.entry_price,
171                    realized_pnl: state.realized,
172                    unrealized_pnl: unrealized,
173                    total_pnl: symbol_total,
174                },
175            );
176        }
177
178        Attribution {
179            by_symbol,
180            total_pnl,
181        }
182    }
183
184    /// Get the set of symbols with open positions (for mark lookup).
185    pub fn open_symbols(&self) -> Vec<String> {
186        self.positions
187            .iter()
188            .filter(|(_, s)| s.amount != dec!(0))
189            .map(|(sym, _)| sym.clone())
190            .collect()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    use std::str::FromStr;
199
200    fn d(v: &str) -> Decimal {
201        Decimal::from_str(v).unwrap()
202    }
203
204    fn fill(symbol: &str, side: Side, price: &str, size: &str, ts: i64) -> Fill {
205        Fill {
206            symbol: symbol.to_string(),
207            side,
208            price: d(price),
209            size: d(size),
210            timestamp_ms: ts,
211        }
212    }
213
214    fn marks(entries: &[(&str, &str)]) -> HashMap<String, Decimal> {
215        entries.iter().map(|(s, p)| (s.to_string(), d(p))).collect()
216    }
217
218    #[test]
219    fn empty_tracker_produces_empty_attribution() {
220        let tracker = PositionTracker::new();
221        let attr = tracker.attribution(&HashMap::new());
222        assert!(attr.by_symbol.is_empty());
223        assert_eq!(attr.total_pnl, dec!(0));
224    }
225
226    #[test]
227    fn single_long_position_unrealized_gain() {
228        let mut tracker = PositionTracker::new();
229        tracker.apply_fill(&fill("ETH-CALL", Side::Buy, "1.20", "100", 1000));
230
231        let attr = tracker.attribution(&marks(&[("ETH-CALL", "5")]));
232        let eth = &attr.by_symbol["ETH-CALL"];
233
234        assert_eq!(eth.position, d("100"));
235        assert_eq!(eth.realized_pnl, dec!(0));
236        assert_eq!(eth.unrealized_pnl, d("380"));
237        assert_eq!(eth.total_pnl, d("380"));
238    }
239
240    #[test]
241    fn single_long_position_unrealized_loss() {
242        let mut tracker = PositionTracker::new();
243        tracker.apply_fill(&fill("HYPE-PUT", Side::Buy, "1.20", "100", 1000));
244
245        let attr = tracker.attribution(&marks(&[("HYPE-PUT", "0.50")]));
246        assert_eq!(attr.by_symbol["HYPE-PUT"].unrealized_pnl, d("-70"));
247    }
248
249    #[test]
250    fn buy_then_sell_realizes_pnl() {
251        let mut tracker = PositionTracker::new();
252        tracker.apply_fill(&fill("BTC-CALL", Side::Buy, "100", "10", 1000));
253        tracker.apply_fill(&fill("BTC-CALL", Side::Sell, "150", "10", 2000));
254
255        let attr = tracker.attribution(&HashMap::new());
256        let btc = &attr.by_symbol["BTC-CALL"];
257
258        assert_eq!(btc.position, dec!(0));
259        assert_eq!(btc.realized_pnl, d("500"));
260        assert_eq!(btc.unrealized_pnl, dec!(0));
261    }
262
263    #[test]
264    fn partial_close_splits_realized_and_unrealized() {
265        let mut tracker = PositionTracker::new();
266        tracker.apply_fill(&fill("ETH-CALL", Side::Buy, "10", "100", 1000));
267        tracker.apply_fill(&fill("ETH-CALL", Side::Sell, "15", "40", 2000));
268
269        let attr = tracker.attribution(&marks(&[("ETH-CALL", "20")]));
270        let eth = &attr.by_symbol["ETH-CALL"];
271
272        assert_eq!(eth.position, d("60"));
273        assert_eq!(eth.realized_pnl, d("200"));
274        assert_eq!(eth.unrealized_pnl, d("600"));
275        assert_eq!(eth.total_pnl, d("800"));
276    }
277
278    #[test]
279    fn short_position_pnl() {
280        let mut tracker = PositionTracker::new();
281        tracker.apply_fill(&fill("ETH-PUT", Side::Sell, "5", "100", 1000));
282
283        let attr = tracker.attribution(&marks(&[("ETH-PUT", "3")]));
284        let eth = &attr.by_symbol["ETH-PUT"];
285
286        assert_eq!(eth.position, d("-100"));
287        assert_eq!(eth.unrealized_pnl, d("200"));
288    }
289
290    #[test]
291    fn short_close_realizes_pnl() {
292        let mut tracker = PositionTracker::new();
293        tracker.apply_fill(&fill("ETH-PUT", Side::Sell, "5", "100", 1000));
294        tracker.apply_fill(&fill("ETH-PUT", Side::Buy, "3", "100", 2000));
295
296        let attr = tracker.attribution(&HashMap::new());
297        assert_eq!(attr.by_symbol["ETH-PUT"].position, dec!(0));
298        assert_eq!(attr.by_symbol["ETH-PUT"].realized_pnl, d("200"));
299    }
300
301    #[test]
302    fn multiple_buys_average_entry_price() {
303        let mut tracker = PositionTracker::new();
304        tracker.apply_fill(&fill("BTC-CALL", Side::Buy, "100", "10", 1000));
305        tracker.apply_fill(&fill("BTC-CALL", Side::Buy, "200", "10", 2000));
306
307        let attr = tracker.attribution(&marks(&[("BTC-CALL", "200")]));
308        let btc = &attr.by_symbol["BTC-CALL"];
309
310        assert_eq!(btc.position, d("20"));
311        assert_eq!(btc.entry_price, d("150"));
312        assert_eq!(btc.unrealized_pnl, d("1000"));
313    }
314
315    #[test]
316    fn multi_symbol_attribution() {
317        let mut tracker = PositionTracker::new();
318        tracker.apply_fill(&fill("ETH-CALL", Side::Buy, "1.20", "100", 1000));
319        tracker.apply_fill(&fill("HYPE-PUT", Side::Buy, "1.20", "200", 1000));
320
321        let attr = tracker.attribution(&marks(&[("ETH-CALL", "74.25"), ("HYPE-PUT", "0.71")]));
322
323        assert_eq!(attr.by_symbol["ETH-CALL"].unrealized_pnl, d("7305"));
324        assert_eq!(attr.by_symbol["HYPE-PUT"].unrealized_pnl, d("-98"));
325        assert_eq!(attr.total_pnl, d("7207"));
326    }
327
328    #[test]
329    fn leaderboard_wallet_1_scenario() {
330        let mut tracker = PositionTracker::new();
331        tracker.apply_fill(&fill(
332            "ETH-20260414-2300-C",
333            Side::Buy,
334            "1.20",
335            "12575.8",
336            1000,
337        ));
338        tracker.apply_fill(&fill(
339            "HYPE-20260424-39-P",
340            Side::Buy,
341            "1.20",
342            "25403.6",
343            2000,
344        ));
345
346        let attr = tracker.attribution(&marks(&[
347            ("ETH-20260414-2300-C", "74.25"),
348            ("HYPE-20260424-39-P", "0.71"),
349        ]));
350
351        let eth = &attr.by_symbol["ETH-20260414-2300-C"];
352        let hype = &attr.by_symbol["HYPE-20260424-39-P"];
353
354        // ETH: (74.25 - 1.20) * 12575.8 = 918,662.190
355        assert_eq!(eth.unrealized_pnl, d("918662.190"));
356        // HYPE: (0.71 - 1.20) * 25403.6 = -12,447.764
357        assert_eq!(hype.unrealized_pnl, d("-12447.764"));
358        // Total = 906,214.426
359        assert_eq!(attr.total_pnl, d("906214.426"));
360    }
361
362    #[test]
363    fn open_symbols_returns_only_non_zero_positions() {
364        let mut tracker = PositionTracker::new();
365        tracker.apply_fill(&fill("OPEN", Side::Buy, "1", "10", 1000));
366        tracker.apply_fill(&fill("CLOSED", Side::Buy, "1", "10", 1000));
367        tracker.apply_fill(&fill("CLOSED", Side::Sell, "2", "10", 2000));
368
369        let mut open = tracker.open_symbols();
370        open.sort();
371        assert_eq!(open, vec!["OPEN"]);
372    }
373
374    #[test]
375    fn missing_mark_defaults_to_zero_unrealized() {
376        let mut tracker = PositionTracker::new();
377        tracker.apply_fill(&fill("NO-MARK", Side::Buy, "10", "5", 1000));
378
379        let attr = tracker.attribution(&HashMap::new());
380        assert_eq!(attr.by_symbol["NO-MARK"].unrealized_pnl, d("-50"));
381    }
382
383    #[test]
384    fn flip_position_from_long_to_short() {
385        let mut tracker = PositionTracker::new();
386        tracker.apply_fill(&fill("SYM", Side::Buy, "10", "100", 1000));
387        tracker.apply_fill(&fill("SYM", Side::Sell, "15", "150", 2000));
388
389        let attr = tracker.attribution(&marks(&[("SYM", "12")]));
390        let sym = &attr.by_symbol["SYM"];
391
392        assert_eq!(sym.realized_pnl, d("500"));
393        assert_eq!(sym.position, d("-50"));
394        assert_eq!(sym.entry_price, d("15"));
395        assert_eq!(sym.unrealized_pnl, d("150"));
396    }
397}