use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerType {
StopLoss,
TakeProfit,
TrailingStop,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TriggerAction {
pub trigger_type: TriggerType,
pub position_id: u64,
pub perp_id: [u8; 32],
pub trigger_price: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ManagedPosition {
pub perp_id: [u8; 32],
pub position_id: u64,
pub is_long: bool,
pub entry_price: f64,
pub margin: f64,
pub stop_loss: Option<f64>,
pub take_profit: Option<f64>,
pub trailing_stop_pct: Option<f64>,
pub trailing_stop_anchor: Option<f64>,
}
impl ManagedPosition {
#[inline]
fn update_anchor(&mut self, current_price: f64) {
if self.trailing_stop_pct.is_none() {
return;
}
match self.trailing_stop_anchor {
None => {
self.trailing_stop_anchor = Some(current_price);
}
Some(anchor) => {
let should_update = if self.is_long {
current_price > anchor
} else {
current_price < anchor
};
if should_update {
self.trailing_stop_anchor = Some(current_price);
}
}
}
}
#[inline]
fn trailing_stop_price(&self) -> Option<f64> {
let pct = self.trailing_stop_pct?;
let anchor = self.trailing_stop_anchor?;
if self.is_long {
Some(anchor * (1.0 - pct))
} else {
Some(anchor * (1.0 + pct))
}
}
#[inline]
fn check(&self, current_price: f64) -> Option<TriggerAction> {
if let Some(sl) = self.stop_loss {
let triggered = if self.is_long {
current_price <= sl
} else {
current_price >= sl
};
if triggered {
return Some(TriggerAction {
trigger_type: TriggerType::StopLoss,
position_id: self.position_id,
perp_id: self.perp_id,
trigger_price: sl,
});
}
}
if let Some(tp) = self.take_profit {
let triggered = if self.is_long {
current_price >= tp
} else {
current_price <= tp
};
if triggered {
return Some(TriggerAction {
trigger_type: TriggerType::TakeProfit,
position_id: self.position_id,
perp_id: self.perp_id,
trigger_price: tp,
});
}
}
if let Some(ts_price) = self.trailing_stop_price() {
let triggered = if self.is_long {
current_price <= ts_price
} else {
current_price >= ts_price
};
if triggered {
return Some(TriggerAction {
trigger_type: TriggerType::TrailingStop,
position_id: self.position_id,
perp_id: self.perp_id,
trigger_price: ts_price,
});
}
}
None
}
}
#[derive(Debug)]
pub struct PositionManager {
positions: HashMap<u64, ManagedPosition>,
}
impl PositionManager {
pub fn new() -> Self {
Self {
positions: HashMap::new(),
}
}
pub fn track(&mut self, pos: ManagedPosition) {
self.positions.insert(pos.position_id, pos);
}
pub fn untrack(&mut self, position_id: u64) -> bool {
self.positions.remove(&position_id).is_some()
}
pub fn get(&self, position_id: u64) -> Option<&ManagedPosition> {
self.positions.get(&position_id)
}
pub fn get_mut(&mut self, position_id: u64) -> Option<&mut ManagedPosition> {
self.positions.get_mut(&position_id)
}
pub fn check_triggers(&mut self, prices: &HashMap<[u8; 32], f64>) -> Vec<TriggerAction> {
let mut actions = Vec::new();
self.check_triggers_into(prices, &mut actions);
actions
}
#[inline]
pub fn check_triggers_into(
&mut self,
prices: &HashMap<[u8; 32], f64>,
out: &mut Vec<TriggerAction>,
) {
for pos in self.positions.values_mut() {
let Some(¤t_price) = prices.get(&pos.perp_id) else {
continue;
};
pos.update_anchor(current_price);
if let Some(action) = pos.check(current_price) {
out.push(action);
}
}
}
pub fn count(&self) -> usize {
self.positions.len()
}
}
impl Default for PositionManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn long_pos(id: u64, entry: f64) -> ManagedPosition {
ManagedPosition {
perp_id: [0xAA; 32],
position_id: id,
is_long: true,
entry_price: entry,
margin: 100.0,
stop_loss: None,
take_profit: None,
trailing_stop_pct: None,
trailing_stop_anchor: None,
}
}
fn short_pos(id: u64, entry: f64) -> ManagedPosition {
ManagedPosition {
perp_id: [0xBB; 32],
position_id: id,
is_long: false,
entry_price: entry,
margin: 100.0,
stop_loss: None,
take_profit: None,
trailing_stop_pct: None,
trailing_stop_anchor: None,
}
}
#[test]
fn track_and_untrack() {
let mut mgr = PositionManager::new();
mgr.track(long_pos(1, 100.0));
assert_eq!(mgr.count(), 1);
assert!(mgr.get(1).is_some());
assert!(mgr.untrack(1));
assert_eq!(mgr.count(), 0);
assert!(!mgr.untrack(1)); }
fn prices_for(perp_id: [u8; 32], price: f64) -> HashMap<[u8; 32], f64> {
HashMap::from([(perp_id, price)])
}
fn long_prices(price: f64) -> HashMap<[u8; 32], f64> {
prices_for([0xAA; 32], price)
}
fn short_prices(price: f64) -> HashMap<[u8; 32], f64> {
prices_for([0xBB; 32], price)
}
#[test]
fn long_stop_loss_triggers_below() {
let mut mgr = PositionManager::new();
let mut pos = long_pos(1, 100.0);
pos.stop_loss = Some(90.0);
mgr.track(pos);
let t = mgr.check_triggers(&long_prices(95.0));
assert!(t.is_empty());
let t = mgr.check_triggers(&long_prices(90.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::StopLoss);
assert_eq!(t[0].trigger_price, 90.0);
}
#[test]
fn short_stop_loss_triggers_above() {
let mut mgr = PositionManager::new();
let mut pos = short_pos(1, 100.0);
pos.stop_loss = Some(110.0);
mgr.track(pos);
let t = mgr.check_triggers(&short_prices(105.0));
assert!(t.is_empty());
let t = mgr.check_triggers(&short_prices(110.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::StopLoss);
}
#[test]
fn long_take_profit_triggers_above() {
let mut mgr = PositionManager::new();
let mut pos = long_pos(1, 100.0);
pos.take_profit = Some(120.0);
mgr.track(pos);
let t = mgr.check_triggers(&long_prices(115.0));
assert!(t.is_empty());
let t = mgr.check_triggers(&long_prices(125.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::TakeProfit);
}
#[test]
fn short_take_profit_triggers_below() {
let mut mgr = PositionManager::new();
let mut pos = short_pos(1, 100.0);
pos.take_profit = Some(80.0);
mgr.track(pos);
let t = mgr.check_triggers(&short_prices(85.0));
assert!(t.is_empty());
let t = mgr.check_triggers(&short_prices(75.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::TakeProfit);
}
#[test]
fn long_trailing_stop() {
let mut mgr = PositionManager::new();
let mut pos = long_pos(1, 100.0);
pos.trailing_stop_pct = Some(0.05); mgr.track(pos);
let t = mgr.check_triggers(&long_prices(110.0));
assert!(t.is_empty());
assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(110.0));
let t = mgr.check_triggers(&long_prices(120.0));
assert!(t.is_empty());
assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(120.0));
let t = mgr.check_triggers(&long_prices(115.0));
assert!(t.is_empty());
let t = mgr.check_triggers(&long_prices(113.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::TrailingStop);
assert!((t[0].trigger_price - 114.0).abs() < 1e-10);
}
#[test]
fn short_trailing_stop() {
let mut mgr = PositionManager::new();
let mut pos = short_pos(1, 100.0);
pos.trailing_stop_pct = Some(0.05);
mgr.track(pos);
let t = mgr.check_triggers(&short_prices(90.0));
assert!(t.is_empty());
assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(90.0));
let t = mgr.check_triggers(&short_prices(80.0));
assert!(t.is_empty());
let t = mgr.check_triggers(&short_prices(84.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::TrailingStop);
}
#[test]
fn stop_loss_takes_priority_over_trailing_stop() {
let mut mgr = PositionManager::new();
let mut pos = long_pos(1, 100.0);
pos.stop_loss = Some(85.0);
pos.trailing_stop_pct = Some(0.05);
pos.trailing_stop_anchor = Some(100.0); mgr.track(pos);
let t = mgr.check_triggers(&long_prices(80.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::StopLoss);
}
#[test]
fn take_profit_takes_priority_over_trailing_stop() {
let mut mgr = PositionManager::new();
let mut pos = short_pos(1, 100.0);
pos.take_profit = Some(80.0);
pos.trailing_stop_pct = Some(0.50); pos.trailing_stop_anchor = Some(50.0); mgr.track(pos);
let t = mgr.check_triggers(&short_prices(70.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].trigger_type, TriggerType::TakeProfit);
}
#[test]
fn no_triggers_means_no_actions() {
let mut mgr = PositionManager::new();
mgr.track(long_pos(1, 100.0)); let t = mgr.check_triggers(&long_prices(50.0));
assert!(t.is_empty());
}
#[test]
fn multiple_positions_independent_perps() {
let mut mgr = PositionManager::new();
let mut pos1 = long_pos(1, 100.0); pos1.stop_loss = Some(90.0);
mgr.track(pos1);
let mut pos2 = short_pos(2, 100.0); pos2.take_profit = Some(80.0);
mgr.track(pos2);
let t = mgr.check_triggers(&long_prices(85.0));
assert_eq!(t.len(), 1);
assert_eq!(t[0].position_id, 1);
let mut both = long_prices(75.0);
both.insert([0xBB; 32], 75.0);
let t = mgr.check_triggers(&both);
assert_eq!(t.len(), 2);
}
#[test]
fn position_skipped_when_no_price_for_perp() {
let mut mgr = PositionManager::new();
let mut pos = long_pos(1, 100.0);
pos.stop_loss = Some(90.0);
mgr.track(pos);
let t = mgr.check_triggers(&short_prices(50.0));
assert!(t.is_empty());
}
#[test]
fn anchor_only_moves_favorably() {
let mut mgr = PositionManager::new();
let mut pos = long_pos(1, 100.0);
pos.trailing_stop_pct = Some(0.10);
mgr.track(pos);
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));
mgr.check_triggers(&long_prices(115.0)); assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, Some(115.0));
}
#[test]
fn trailing_stop_no_pct_means_no_anchor_update() {
let mut mgr = PositionManager::new();
let pos = long_pos(1, 100.0); mgr.track(pos);
mgr.check_triggers(&long_prices(200.0));
assert_eq!(mgr.get(1).unwrap().trailing_stop_anchor, None);
}
}