Skip to main content

perpcity_sdk/hft/
position_manager.rs

1//! Position tracking with automated trigger evaluation.
2//!
3//! The [`PositionManager`] tracks open positions and evaluates stop-loss,
4//! take-profit, and trailing-stop triggers against the current mark price.
5//!
6//! # Trigger precedence
7//!
8//! When multiple triggers fire simultaneously:
9//! 1. **Stop-loss** (highest priority — capital preservation)
10//! 2. **Take-profit**
11//! 3. **Trailing stop**
12//!
13//! Only one trigger is returned per position per `check_triggers` call.
14//!
15//! # Example
16//!
17//! ```
18//! use perpcity_sdk::hft::position_manager::{ManagedPosition, PositionManager};
19//! use std::collections::HashMap;
20//!
21//! let mut mgr = PositionManager::new();
22//! mgr.track(ManagedPosition {
23//!     perp_id: [0xAA; 32],
24//!     position_id: 1,
25//!     is_long: true,
26//!     entry_price: 100.0,
27//!     margin: 10.0,
28//!     stop_loss: Some(90.0),
29//!     take_profit: Some(120.0),
30//!     trailing_stop_pct: None,
31//!     trailing_stop_anchor: None,
32//! });
33//!
34//! let mut prices = HashMap::new();
35//! prices.insert([0xAA; 32], 85.0);
36//! let triggers = mgr.check_triggers(&prices);
37//! assert_eq!(triggers.len(), 1);
38//! ```
39
40use std::collections::HashMap;
41
42/// What kind of trigger fired.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum TriggerType {
45    /// Price hit the stop-loss level.
46    StopLoss,
47    /// Price hit the take-profit level.
48    TakeProfit,
49    /// Price pulled back beyond the trailing stop threshold.
50    TrailingStop,
51}
52
53/// A triggered action for a position.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct TriggerAction {
56    /// The type of trigger that fired.
57    pub trigger_type: TriggerType,
58    /// The position's unique ID.
59    pub position_id: u64,
60    /// The perp market identifier.
61    pub perp_id: [u8; 32],
62    /// The price threshold that was breached.
63    pub trigger_price: f64,
64}
65
66/// A position being tracked for automated trigger evaluation.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct ManagedPosition {
69    /// Perp market identifier.
70    pub perp_id: [u8; 32],
71    /// Position NFT ID.
72    pub position_id: u64,
73    /// `true` = long, `false` = short.
74    pub is_long: bool,
75    /// Entry price in USDC.
76    pub entry_price: f64,
77    /// Margin deposited in USDC.
78    pub margin: f64,
79
80    /// Stop-loss price. `None` = disabled.
81    pub stop_loss: Option<f64>,
82    /// Take-profit price. `None` = disabled.
83    pub take_profit: Option<f64>,
84    /// Trailing stop percentage (e.g. 0.02 = 2%). `None` = disabled.
85    pub trailing_stop_pct: Option<f64>,
86    /// High-water mark (longs) or low-water mark (shorts) for trailing stop.
87    ///
88    /// Updated automatically by [`check_triggers`](PositionManager::check_triggers).
89    /// Set to `None` initially; the first price observation will seed it.
90    pub trailing_stop_anchor: Option<f64>,
91}
92
93impl ManagedPosition {
94    /// Update the trailing stop anchor (high/low water mark) for a new price.
95    ///
96    /// - Longs: tracks the **highest** price seen.
97    /// - Shorts: tracks the **lowest** price seen.
98    #[inline]
99    fn update_anchor(&mut self, current_price: f64) {
100        if self.trailing_stop_pct.is_none() {
101            return;
102        }
103        match self.trailing_stop_anchor {
104            None => {
105                self.trailing_stop_anchor = Some(current_price);
106            }
107            Some(anchor) => {
108                let should_update = if self.is_long {
109                    current_price > anchor
110                } else {
111                    current_price < anchor
112                };
113                if should_update {
114                    self.trailing_stop_anchor = Some(current_price);
115                }
116            }
117        }
118    }
119
120    /// Compute the trailing stop trigger price from the current anchor.
121    ///
122    /// - Long: `anchor * (1 - pct)`
123    /// - Short: `anchor * (1 + pct)`
124    #[inline]
125    fn trailing_stop_price(&self) -> Option<f64> {
126        let pct = self.trailing_stop_pct?;
127        let anchor = self.trailing_stop_anchor?;
128        if self.is_long {
129            Some(anchor * (1.0 - pct))
130        } else {
131            Some(anchor * (1.0 + pct))
132        }
133    }
134
135    /// Evaluate which trigger (if any) fires at the given price.
136    ///
137    /// Must be called after `update_anchor` so trailing stop is current.
138    #[inline]
139    fn check(&self, current_price: f64) -> Option<TriggerAction> {
140        // 1. Stop-loss (highest priority)
141        if let Some(sl) = self.stop_loss {
142            let triggered = if self.is_long {
143                current_price <= sl
144            } else {
145                current_price >= sl
146            };
147            if triggered {
148                return Some(TriggerAction {
149                    trigger_type: TriggerType::StopLoss,
150                    position_id: self.position_id,
151                    perp_id: self.perp_id,
152                    trigger_price: sl,
153                });
154            }
155        }
156
157        // 2. Take-profit
158        if let Some(tp) = self.take_profit {
159            let triggered = if self.is_long {
160                current_price >= tp
161            } else {
162                current_price <= tp
163            };
164            if triggered {
165                return Some(TriggerAction {
166                    trigger_type: TriggerType::TakeProfit,
167                    position_id: self.position_id,
168                    perp_id: self.perp_id,
169                    trigger_price: tp,
170                });
171            }
172        }
173
174        // 3. Trailing stop
175        if let Some(ts_price) = self.trailing_stop_price() {
176            let triggered = if self.is_long {
177                current_price <= ts_price
178            } else {
179                current_price >= ts_price
180            };
181            if triggered {
182                return Some(TriggerAction {
183                    trigger_type: TriggerType::TrailingStop,
184                    position_id: self.position_id,
185                    perp_id: self.perp_id,
186                    trigger_price: ts_price,
187                });
188            }
189        }
190
191        None
192    }
193}
194
195/// Manages tracked positions and evaluates triggers.
196///
197/// Not thread-safe — intended to be owned by a single trading loop.
198/// For concurrent access, wrap in `Mutex` or `RwLock`.
199#[derive(Debug)]
200pub struct PositionManager {
201    positions: HashMap<u64, ManagedPosition>,
202}
203
204impl PositionManager {
205    /// Create an empty manager.
206    pub fn new() -> Self {
207        Self {
208            positions: HashMap::new(),
209        }
210    }
211
212    /// Start tracking a position.
213    pub fn track(&mut self, pos: ManagedPosition) {
214        self.positions.insert(pos.position_id, pos);
215    }
216
217    /// Stop tracking a position. Returns `true` if it was tracked.
218    pub fn untrack(&mut self, position_id: u64) -> bool {
219        self.positions.remove(&position_id).is_some()
220    }
221
222    /// Get a reference to a tracked position.
223    pub fn get(&self, position_id: u64) -> Option<&ManagedPosition> {
224        self.positions.get(&position_id)
225    }
226
227    /// Get a mutable reference to a tracked position (e.g. to update triggers).
228    pub fn get_mut(&mut self, position_id: u64) -> Option<&mut ManagedPosition> {
229        self.positions.get_mut(&position_id)
230    }
231
232    /// Evaluate all positions against per-perp mark prices.
233    ///
234    /// Each position is only evaluated against the price for its own `perp_id`.
235    /// Positions whose `perp_id` is not in the prices map are skipped.
236    /// Returns at most one [`TriggerAction`] per position.
237    pub fn check_triggers(&mut self, prices: &HashMap<[u8; 32], f64>) -> Vec<TriggerAction> {
238        let mut actions = Vec::new();
239        self.check_triggers_into(prices, &mut actions);
240        actions
241    }
242
243    /// Zero-allocation trigger check: appends fired triggers to `out`.
244    ///
245    /// Each position is only evaluated against the price for its own `perp_id`.
246    /// Call with a reusable `Vec` to avoid heap allocation on the hot path:
247    ///
248    /// ```
249    /// # use perpcity_sdk::hft::position_manager::{PositionManager, TriggerAction};
250    /// # use std::collections::HashMap;
251    /// let mut mgr = PositionManager::new();
252    /// let mut buf: Vec<TriggerAction> = Vec::with_capacity(16);
253    /// let mut prices = HashMap::new();
254    /// prices.insert([0xAA; 32], 105.0);
255    /// // On each price tick:
256    /// buf.clear();
257    /// mgr.check_triggers_into(&prices, &mut buf);
258    /// // Process buf — no allocation after the first call.
259    /// ```
260    #[inline]
261    pub fn check_triggers_into(
262        &mut self,
263        prices: &HashMap<[u8; 32], f64>,
264        out: &mut Vec<TriggerAction>,
265    ) {
266        for pos in self.positions.values_mut() {
267            let Some(&current_price) = prices.get(&pos.perp_id) else {
268                continue;
269            };
270            pos.update_anchor(current_price);
271            if let Some(action) = pos.check(current_price) {
272                out.push(action);
273            }
274        }
275    }
276
277    /// Number of tracked positions.
278    pub fn count(&self) -> usize {
279        self.positions.len()
280    }
281}
282
283impl Default for PositionManager {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn long_pos(id: u64, entry: f64) -> ManagedPosition {
294        ManagedPosition {
295            perp_id: [0xAA; 32],
296            position_id: id,
297            is_long: true,
298            entry_price: entry,
299            margin: 100.0,
300            stop_loss: None,
301            take_profit: None,
302            trailing_stop_pct: None,
303            trailing_stop_anchor: None,
304        }
305    }
306
307    fn short_pos(id: u64, entry: f64) -> ManagedPosition {
308        ManagedPosition {
309            perp_id: [0xBB; 32],
310            position_id: id,
311            is_long: false,
312            entry_price: entry,
313            margin: 100.0,
314            stop_loss: None,
315            take_profit: None,
316            trailing_stop_pct: None,
317            trailing_stop_anchor: None,
318        }
319    }
320
321    // ── Basic management ───────────────────────────────────────
322
323    #[test]
324    fn track_and_untrack() {
325        let mut mgr = PositionManager::new();
326        mgr.track(long_pos(1, 100.0));
327        assert_eq!(mgr.count(), 1);
328        assert!(mgr.get(1).is_some());
329        assert!(mgr.untrack(1));
330        assert_eq!(mgr.count(), 0);
331        assert!(!mgr.untrack(1)); // already removed
332    }
333
334    /// Helper: build a price map for a single perp_id.
335    fn prices_for(perp_id: [u8; 32], price: f64) -> HashMap<[u8; 32], f64> {
336        HashMap::from([(perp_id, price)])
337    }
338
339    /// Helper: long positions use perp_id [0xAA; 32].
340    fn long_prices(price: f64) -> HashMap<[u8; 32], f64> {
341        prices_for([0xAA; 32], price)
342    }
343
344    /// Helper: short positions use perp_id [0xBB; 32].
345    fn short_prices(price: f64) -> HashMap<[u8; 32], f64> {
346        prices_for([0xBB; 32], price)
347    }
348
349    // ── Stop-loss triggers ─────────────────────────────────────
350
351    #[test]
352    fn long_stop_loss_triggers_below() {
353        let mut mgr = PositionManager::new();
354        let mut pos = long_pos(1, 100.0);
355        pos.stop_loss = Some(90.0);
356        mgr.track(pos);
357
358        // Price above SL: no trigger
359        let t = mgr.check_triggers(&long_prices(95.0));
360        assert!(t.is_empty());
361
362        // Price at SL: triggers
363        let t = mgr.check_triggers(&long_prices(90.0));
364        assert_eq!(t.len(), 1);
365        assert_eq!(t[0].trigger_type, TriggerType::StopLoss);
366        assert_eq!(t[0].trigger_price, 90.0);
367    }
368
369    #[test]
370    fn short_stop_loss_triggers_above() {
371        let mut mgr = PositionManager::new();
372        let mut pos = short_pos(1, 100.0);
373        pos.stop_loss = Some(110.0);
374        mgr.track(pos);
375
376        let t = mgr.check_triggers(&short_prices(105.0));
377        assert!(t.is_empty());
378
379        let t = mgr.check_triggers(&short_prices(110.0));
380        assert_eq!(t.len(), 1);
381        assert_eq!(t[0].trigger_type, TriggerType::StopLoss);
382    }
383
384    // ── Take-profit triggers ───────────────────────────────────
385
386    #[test]
387    fn long_take_profit_triggers_above() {
388        let mut mgr = PositionManager::new();
389        let mut pos = long_pos(1, 100.0);
390        pos.take_profit = Some(120.0);
391        mgr.track(pos);
392
393        let t = mgr.check_triggers(&long_prices(115.0));
394        assert!(t.is_empty());
395
396        let t = mgr.check_triggers(&long_prices(125.0));
397        assert_eq!(t.len(), 1);
398        assert_eq!(t[0].trigger_type, TriggerType::TakeProfit);
399    }
400
401    #[test]
402    fn short_take_profit_triggers_below() {
403        let mut mgr = PositionManager::new();
404        let mut pos = short_pos(1, 100.0);
405        pos.take_profit = Some(80.0);
406        mgr.track(pos);
407
408        let t = mgr.check_triggers(&short_prices(85.0));
409        assert!(t.is_empty());
410
411        let t = mgr.check_triggers(&short_prices(75.0));
412        assert_eq!(t.len(), 1);
413        assert_eq!(t[0].trigger_type, TriggerType::TakeProfit);
414    }
415
416    // ── Trailing stop ──────────────────────────────────────────
417
418    #[test]
419    fn long_trailing_stop() {
420        let mut mgr = PositionManager::new();
421        let mut pos = long_pos(1, 100.0);
422        pos.trailing_stop_pct = Some(0.05); // 5% trailing
423        mgr.track(pos);
424
425        // Price rises to 110 → anchor set to 110
426        let t = mgr.check_triggers(&long_prices(110.0));
427        assert!(t.is_empty());
428        assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(110.0));
429
430        // Price rises to 120 → anchor updates to 120
431        let t = mgr.check_triggers(&long_prices(120.0));
432        assert!(t.is_empty());
433        assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(120.0));
434
435        // Price drops to 115 → trailing stop = 120 * 0.95 = 114
436        // 115 > 114 → no trigger
437        let t = mgr.check_triggers(&long_prices(115.0));
438        assert!(t.is_empty());
439
440        // Price drops to 113 → 113 < 114 → trigger!
441        let t = mgr.check_triggers(&long_prices(113.0));
442        assert_eq!(t.len(), 1);
443        assert_eq!(t[0].trigger_type, TriggerType::TrailingStop);
444        // Trigger price should be 120 * 0.95 = 114.0
445        assert!((t[0].trigger_price - 114.0).abs() < 1e-10);
446    }
447
448    #[test]
449    fn short_trailing_stop() {
450        let mut mgr = PositionManager::new();
451        let mut pos = short_pos(1, 100.0);
452        pos.trailing_stop_pct = Some(0.05);
453        mgr.track(pos);
454
455        // Price drops to 90 → anchor set to 90
456        let t = mgr.check_triggers(&short_prices(90.0));
457        assert!(t.is_empty());
458        assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(90.0));
459
460        // Price drops to 80 → anchor updates to 80
461        let t = mgr.check_triggers(&short_prices(80.0));
462        assert!(t.is_empty());
463
464        // Price rises to 84 → trailing stop = 80 * 1.05 = 84
465        // 84 >= 84 → trigger!
466        let t = mgr.check_triggers(&short_prices(84.0));
467        assert_eq!(t.len(), 1);
468        assert_eq!(t[0].trigger_type, TriggerType::TrailingStop);
469    }
470
471    // ── Priority / precedence ──────────────────────────────────
472
473    #[test]
474    fn stop_loss_takes_priority_over_trailing_stop() {
475        let mut mgr = PositionManager::new();
476        let mut pos = long_pos(1, 100.0);
477        pos.stop_loss = Some(85.0);
478        pos.trailing_stop_pct = Some(0.05);
479        pos.trailing_stop_anchor = Some(100.0); // trailing stop at 95
480        mgr.track(pos);
481
482        // Price = 80: both SL (85) and trailing (95) would fire
483        let t = mgr.check_triggers(&long_prices(80.0));
484        assert_eq!(t.len(), 1);
485        assert_eq!(t[0].trigger_type, TriggerType::StopLoss);
486    }
487
488    #[test]
489    fn take_profit_takes_priority_over_trailing_stop() {
490        let mut mgr = PositionManager::new();
491        let mut pos = short_pos(1, 100.0);
492        pos.take_profit = Some(80.0);
493        pos.trailing_stop_pct = Some(0.50); // 50% trailing (absurdly wide)
494        pos.trailing_stop_anchor = Some(50.0); // trailing stop at 75
495        mgr.track(pos);
496
497        // Price = 70: TP (80) fires because 70 <= 80, trailing (75) also fires
498        let t = mgr.check_triggers(&short_prices(70.0));
499        assert_eq!(t.len(), 1);
500        assert_eq!(t[0].trigger_type, TriggerType::TakeProfit);
501    }
502
503    // ── No triggers set ────────────────────────────────────────
504
505    #[test]
506    fn no_triggers_means_no_actions() {
507        let mut mgr = PositionManager::new();
508        mgr.track(long_pos(1, 100.0)); // no SL, TP, or trailing
509        let t = mgr.check_triggers(&long_prices(50.0));
510        assert!(t.is_empty());
511    }
512
513    // ── Multiple positions (different perps) ───────────────────
514
515    #[test]
516    fn multiple_positions_independent_perps() {
517        let mut mgr = PositionManager::new();
518
519        let mut pos1 = long_pos(1, 100.0); // perp_id [0xAA; 32]
520        pos1.stop_loss = Some(90.0);
521        mgr.track(pos1);
522
523        let mut pos2 = short_pos(2, 100.0); // perp_id [0xBB; 32]
524        pos2.take_profit = Some(80.0);
525        mgr.track(pos2);
526
527        // Only BTC price available — only pos1 evaluated
528        let t = mgr.check_triggers(&long_prices(85.0));
529        assert_eq!(t.len(), 1);
530        assert_eq!(t[0].position_id, 1);
531
532        // Both prices available
533        let mut both = long_prices(75.0);
534        both.insert([0xBB; 32], 75.0);
535        let t = mgr.check_triggers(&both);
536        assert_eq!(t.len(), 2);
537    }
538
539    #[test]
540    fn position_skipped_when_no_price_for_perp() {
541        let mut mgr = PositionManager::new();
542        let mut pos = long_pos(1, 100.0);
543        pos.stop_loss = Some(90.0);
544        mgr.track(pos);
545
546        // Pass price for a different perp — position should be skipped
547        let t = mgr.check_triggers(&short_prices(50.0));
548        assert!(t.is_empty());
549    }
550
551    // ── Trailing stop anchor updates correctly ─────────────────
552
553    #[test]
554    fn anchor_only_moves_favorably() {
555        let mut mgr = PositionManager::new();
556        let mut pos = long_pos(1, 100.0);
557        pos.trailing_stop_pct = Some(0.10);
558        mgr.track(pos);
559
560        mgr.check_triggers(&long_prices(110.0)); // anchor → 110
561        mgr.check_triggers(&long_prices(105.0)); // anchor stays 110 (not 105)
562        assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(110.0));
563
564        mgr.check_triggers(&long_prices(115.0)); // anchor → 115
565        assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(115.0));
566    }
567
568    #[test]
569    fn trailing_stop_no_pct_means_no_anchor_update() {
570        let mut mgr = PositionManager::new();
571        let pos = long_pos(1, 100.0); // no trailing_stop_pct
572        mgr.track(pos);
573
574        mgr.check_triggers(&long_prices(200.0));
575        assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, None);
576    }
577}