1use std::collections::HashMap;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum TriggerType {
45 StopLoss,
47 TakeProfit,
49 TrailingStop,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct TriggerAction {
56 pub trigger_type: TriggerType,
58 pub position_id: u64,
60 pub perp_id: [u8; 32],
62 pub trigger_price: f64,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct ManagedPosition {
69 pub perp_id: [u8; 32],
71 pub position_id: u64,
73 pub is_long: bool,
75 pub entry_price: f64,
77 pub margin: f64,
79
80 pub stop_loss: Option<f64>,
82 pub take_profit: Option<f64>,
84 pub trailing_stop_pct: Option<f64>,
86 pub trailing_stop_anchor: Option<f64>,
91}
92
93impl ManagedPosition {
94 #[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 #[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 #[inline]
139 fn check(&self, current_price: f64) -> Option<TriggerAction> {
140 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 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 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#[derive(Debug)]
200pub struct PositionManager {
201 positions: HashMap<u64, ManagedPosition>,
202}
203
204impl PositionManager {
205 pub fn new() -> Self {
207 Self {
208 positions: HashMap::new(),
209 }
210 }
211
212 pub fn track(&mut self, pos: ManagedPosition) {
214 self.positions.insert(pos.position_id, pos);
215 }
216
217 pub fn untrack(&mut self, position_id: u64) -> bool {
219 self.positions.remove(&position_id).is_some()
220 }
221
222 pub fn get(&self, position_id: u64) -> Option<&ManagedPosition> {
224 self.positions.get(&position_id)
225 }
226
227 pub fn get_mut(&mut self, position_id: u64) -> Option<&mut ManagedPosition> {
229 self.positions.get_mut(&position_id)
230 }
231
232 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 #[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(¤t_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 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 #[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)); }
333
334 fn prices_for(perp_id: [u8; 32], price: f64) -> HashMap<[u8; 32], f64> {
336 HashMap::from([(perp_id, price)])
337 }
338
339 fn long_prices(price: f64) -> HashMap<[u8; 32], f64> {
341 prices_for([0xAA; 32], price)
342 }
343
344 fn short_prices(price: f64) -> HashMap<[u8; 32], f64> {
346 prices_for([0xBB; 32], price)
347 }
348
349 #[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 let t = mgr.check_triggers(&long_prices(95.0));
360 assert!(t.is_empty());
361
362 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 #[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 #[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); mgr.track(pos);
424
425 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 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 let t = mgr.check_triggers(&long_prices(115.0));
438 assert!(t.is_empty());
439
440 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 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 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 let t = mgr.check_triggers(&short_prices(80.0));
462 assert!(t.is_empty());
463
464 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 #[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); mgr.track(pos);
481
482 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); pos.trailing_stop_anchor = Some(50.0); mgr.track(pos);
496
497 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 #[test]
506 fn no_triggers_means_no_actions() {
507 let mut mgr = PositionManager::new();
508 mgr.track(long_pos(1, 100.0)); let t = mgr.check_triggers(&long_prices(50.0));
510 assert!(t.is_empty());
511 }
512
513 #[test]
516 fn multiple_positions_independent_perps() {
517 let mut mgr = PositionManager::new();
518
519 let mut pos1 = long_pos(1, 100.0); pos1.stop_loss = Some(90.0);
521 mgr.track(pos1);
522
523 let mut pos2 = short_pos(2, 100.0); pos2.take_profit = Some(80.0);
525 mgr.track(pos2);
526
527 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 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 let t = mgr.check_triggers(&short_prices(50.0));
548 assert!(t.is_empty());
549 }
550
551 #[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)); mgr.check_triggers(&long_prices(105.0)); assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(110.0));
563
564 mgr.check_triggers(&long_prices(115.0)); 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); 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}