1use std::collections::{HashMap, HashSet};
7
8use crate::position::Position;
9use crate::types::{GroupId, PositionId, PositionStatus, Side, TradeId};
10
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct PositionGroup {
14 pub id: GroupId,
15 pub positions: Vec<PositionId>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
20pub enum PositionManagerError {
21 #[error("position not found: {0}")]
22 PositionNotFound(PositionId),
23 #[error("trade id {trade_id:?} is already assigned to position {existing_position_id}")]
24 DuplicateTradeId {
25 trade_id: TradeId,
26 existing_position_id: PositionId,
27 },
28}
29
30#[derive(Debug, Clone, Default)]
32pub struct PositionManager {
33 positions: HashMap<PositionId, Position>,
34 groups: HashMap<GroupId, PositionGroup>,
35 symbol_index: HashMap<String, HashSet<PositionId>>,
37 trade_index: HashMap<TradeId, PositionId>,
39}
40
41#[derive(Debug)]
42pub(crate) struct PositionManagerCheckpoint {
43 positions: HashMap<PositionId, Position>,
44 existing_position_ids: HashSet<PositionId>,
45 groups: HashMap<GroupId, PositionGroup>,
46 symbol_index: HashMap<String, HashSet<PositionId>>,
47 trade_index: HashMap<TradeId, PositionId>,
48}
49
50#[derive(Debug)]
51pub(crate) struct PositionManagerQuoteCheckpoint {
52 positions: HashMap<PositionId, Position>,
53}
54
55impl PositionManager {
56 pub fn new() -> Self {
57 Self::default()
58 }
59
60 pub(crate) fn checkpoint(
61 &self,
62 position_ids: impl IntoIterator<Item = PositionId>,
63 ) -> PositionManagerCheckpoint {
64 let positions = position_ids
65 .into_iter()
66 .collect::<HashSet<_>>()
67 .into_iter()
68 .filter_map(|id| {
69 self.positions
70 .get(&id)
71 .cloned()
72 .map(|position| (id, position))
73 })
74 .collect();
75 PositionManagerCheckpoint {
76 positions,
77 existing_position_ids: self.positions.keys().cloned().collect(),
78 groups: self.groups.clone(),
79 symbol_index: self.symbol_index.clone(),
80 trade_index: self.trade_index.clone(),
81 }
82 }
83
84 pub(crate) fn restore(&mut self, checkpoint: PositionManagerCheckpoint) {
85 self.positions
86 .retain(|id, _| checkpoint.existing_position_ids.contains(id));
87 self.positions.extend(checkpoint.positions);
88 self.groups = checkpoint.groups;
89 self.symbol_index = checkpoint.symbol_index;
90 self.trade_index = checkpoint.trade_index;
91 }
92
93 pub(crate) fn checkpoint_for_quote(&self, symbol: &str) -> PositionManagerQuoteCheckpoint {
94 let positions = self
95 .symbol_index
96 .get(symbol)
97 .into_iter()
98 .flatten()
99 .filter_map(|id| {
100 let position = self.positions.get(id)?;
101 matches!(
102 position.data.status,
103 PositionStatus::Open | PositionStatus::Pending
104 )
105 .then(|| (id.clone(), position.clone()))
106 })
107 .collect();
108 PositionManagerQuoteCheckpoint { positions }
109 }
110
111 pub(crate) fn restore_quote(&mut self, checkpoint: PositionManagerQuoteCheckpoint) {
112 self.positions.extend(checkpoint.positions);
113 }
114
115 pub fn add(&mut self, position: Position) {
119 let id = position.data.id.clone();
120 let symbol = position.data.symbol.clone();
121 let trade_id = position.data.trade_id.clone();
122 self.symbol_index
123 .entry(symbol)
124 .or_default()
125 .insert(id.clone());
126 if let Some(tid) = trade_id {
127 self.trade_index.insert(tid, id.clone());
128 }
129 self.positions.insert(id, position);
130 }
131
132 pub fn add_checked(
138 &mut self,
139 position: Position,
140 ) -> std::result::Result<(), PositionManagerError> {
141 if let Some(trade_id) = position.data.trade_id.as_deref() {
142 self.ensure_trade_id_available(trade_id, Some(&position.data.id))?;
143 }
144 let id = position.data.id.clone();
145 if self.positions.contains_key(&id) {
146 self.remove(&id);
147 }
148 self.add(position);
149 Ok(())
150 }
151
152 pub fn remove(&mut self, id: &str) -> Option<Position> {
154 let pos = self.positions.remove(id);
155 if let Some(ref p) = pos {
156 if let Some(set) = self.symbol_index.get_mut(&p.data.symbol) {
157 set.remove(id);
158 if set.is_empty() {
159 self.symbol_index.remove(&p.data.symbol);
160 }
161 }
162 if let Some(tid) = p.data.trade_id.as_ref()
163 && self.trade_index.get(tid).is_some_and(|x| x == id)
164 {
165 self.trade_index.remove(tid);
166 }
167 }
168 pos
169 }
170
171 pub fn get(&self, id: &str) -> Option<&Position> {
173 self.positions.get(id)
174 }
175
176 pub fn get_mut(&mut self, id: &str) -> Option<&mut Position> {
178 self.positions.get_mut(id)
179 }
180
181 pub fn len(&self) -> usize {
183 self.positions.len()
184 }
185
186 pub fn is_empty(&self) -> bool {
187 self.positions.is_empty()
188 }
189
190 pub fn iter(&self) -> impl Iterator<Item = (&PositionId, &Position)> {
194 self.positions.iter()
195 }
196
197 pub fn iter_mut(&mut self) -> impl Iterator<Item = (&PositionId, &mut Position)> {
199 self.positions.iter_mut()
200 }
201
202 pub fn position_ids_sorted(&self) -> Vec<PositionId> {
204 sorted_ids(self.positions.keys().cloned().collect())
205 }
206
207 pub fn ids_by_symbol_status(&self, symbol: &str, status: PositionStatus) -> Vec<PositionId> {
211 self.positions
212 .iter()
213 .filter(|(_, p)| p.data.symbol == symbol && p.data.status == status)
214 .map(|(id, _)| id.clone())
215 .collect()
216 }
217
218 pub fn ids_by_symbol_status_sorted(
220 &self,
221 symbol: &str,
222 status: PositionStatus,
223 ) -> Vec<PositionId> {
224 sorted_ids(self.ids_by_symbol_status(symbol, status))
225 }
226
227 pub fn ids_by_status(&self, status: PositionStatus) -> Vec<PositionId> {
229 self.positions
230 .iter()
231 .filter(|(_, p)| p.data.status == status)
232 .map(|(id, _)| id.clone())
233 .collect()
234 }
235
236 pub fn ids_by_status_sorted(&self, status: PositionStatus) -> Vec<PositionId> {
238 sorted_ids(self.ids_by_status(status))
239 }
240
241 pub fn ids_by_symbol_side(&self, symbol: &str, side: Side) -> Vec<PositionId> {
243 self.positions
244 .iter()
245 .filter(|(_, p)| {
246 p.data.symbol == symbol
247 && p.data.side == side
248 && p.data.status == PositionStatus::Open
249 })
250 .map(|(id, _)| id.clone())
251 .collect()
252 }
253
254 pub fn ids_by_symbol_side_sorted(&self, symbol: &str, side: Side) -> Vec<PositionId> {
256 sorted_ids(self.ids_by_symbol_side(symbol, side))
257 }
258
259 pub fn open_positions(&self) -> Vec<&Position> {
261 self.positions
262 .values()
263 .filter(|p| p.data.status == PositionStatus::Open)
264 .collect()
265 }
266
267 pub fn pending_positions(&self) -> Vec<&Position> {
269 self.positions
270 .values()
271 .filter(|p| p.data.status == PositionStatus::Pending)
272 .collect()
273 }
274
275 pub fn closed_positions(&self) -> Vec<&Position> {
277 self.positions
278 .values()
279 .filter(|p| p.data.status == PositionStatus::Closed)
280 .collect()
281 }
282
283 pub fn open_ids_by_symbol(&self, symbol: &str) -> Vec<PositionId> {
285 self.ids_for_symbol(symbol)
286 .into_iter()
287 .filter(|id| {
288 self.positions
289 .get(id)
290 .is_some_and(|p| p.data.status == PositionStatus::Open)
291 })
292 .collect()
293 }
294
295 pub fn open_ids_by_symbol_sorted(&self, symbol: &str) -> Vec<PositionId> {
297 sorted_ids(self.open_ids_by_symbol(symbol))
298 }
299
300 pub fn pending_ids_by_symbol(&self, symbol: &str) -> Vec<PositionId> {
302 self.ids_for_symbol(symbol)
303 .into_iter()
304 .filter(|id| {
305 self.positions
306 .get(id)
307 .is_some_and(|p| p.data.status == PositionStatus::Pending)
308 })
309 .collect()
310 }
311
312 pub fn pending_ids_by_symbol_sorted(&self, symbol: &str) -> Vec<PositionId> {
314 sorted_ids(self.pending_ids_by_symbol(symbol))
315 }
316
317 pub fn ids_for_symbol(&self, symbol: &str) -> Vec<PositionId> {
319 self.symbol_index
320 .get(symbol)
321 .map(|s| s.iter().cloned().collect())
322 .unwrap_or_default()
323 }
324
325 pub fn ids_for_symbol_sorted(&self, symbol: &str) -> Vec<PositionId> {
327 sorted_ids(self.ids_for_symbol(symbol))
328 }
329
330 pub fn add_group(&mut self, group: PositionGroup) {
334 self.groups.insert(group.id.clone(), group);
335 }
336
337 pub fn get_group(&self, id: &str) -> Option<&PositionGroup> {
339 self.groups.get(id)
340 }
341
342 pub fn add_to_group(&mut self, group_id: &str, position_id: PositionId) {
345 self.groups
346 .entry(group_id.to_owned())
347 .or_insert_with(|| PositionGroup {
348 id: group_id.to_owned(),
349 positions: Vec::new(),
350 })
351 .positions
352 .push(position_id);
353 }
354
355 pub fn group_position_ids(&self, group_id: &str) -> Vec<PositionId> {
357 self.groups
358 .get(group_id)
359 .map(|g| g.positions.clone())
360 .unwrap_or_default()
361 }
362
363 pub fn remove_group(&mut self, group_id: &str) -> Option<PositionGroup> {
365 self.groups.remove(group_id)
366 }
367
368 pub fn open_ids_by_group(&self, group_id: &str) -> Vec<PositionId> {
370 self.group_position_ids(group_id)
371 .into_iter()
372 .filter(|id| {
373 self.positions
374 .get(id)
375 .is_some_and(|p| p.data.status == PositionStatus::Open)
376 })
377 .collect()
378 }
379
380 pub fn pending_ids_by_group(&self, group_id: &str) -> Vec<PositionId> {
382 self.group_position_ids(group_id)
383 .into_iter()
384 .filter(|id| {
385 self.positions
386 .get(id)
387 .is_some_and(|p| p.data.status == PositionStatus::Pending)
388 })
389 .collect()
390 }
391
392 pub fn all_group_ids(&self) -> Vec<&GroupId> {
394 self.groups.keys().collect()
395 }
396
397 pub fn all_group_ids_sorted(&self) -> Vec<&GroupId> {
399 let mut ids: Vec<_> = self.groups.keys().collect();
400 ids.sort();
401 ids
402 }
403
404 pub fn id_by_trade_id(&self, trade_id: &str) -> Option<PositionId> {
408 self.trade_index.get(trade_id).cloned()
409 }
410
411 pub fn would_duplicate_trade_id(&self, position: &Position) -> Option<PositionId> {
417 let trade_id = position.data.trade_id.as_deref()?;
418 self.conflicting_trade_id_owner(trade_id, Some(&position.data.id))
419 }
420
421 pub fn ensure_trade_id_available(
423 &self,
424 trade_id: &str,
425 position_id: Option<&str>,
426 ) -> std::result::Result<(), PositionManagerError> {
427 if let Some(existing_position_id) = self.conflicting_trade_id_owner(trade_id, position_id) {
428 return Err(PositionManagerError::DuplicateTradeId {
429 trade_id: trade_id.to_owned(),
430 existing_position_id,
431 });
432 }
433 Ok(())
434 }
435
436 fn conflicting_trade_id_owner(
437 &self,
438 trade_id: &str,
439 position_id: Option<&str>,
440 ) -> Option<PositionId> {
441 if trade_id.is_empty() {
442 return None;
443 }
444 self.positions.iter().find_map(|(id, position)| {
445 (position.data.trade_id.as_deref() == Some(trade_id)
446 && position_id != Some(id.as_str()))
447 .then(|| id.clone())
448 })
449 }
450
451 pub fn duplicate_trade_ids(&self) -> Vec<TradeId> {
454 let mut counts: HashMap<&str, usize> = HashMap::new();
455 for trade_id in self
456 .positions
457 .values()
458 .filter_map(|position| position.data.trade_id.as_deref())
459 {
460 *counts.entry(trade_id).or_default() += 1;
461 }
462
463 let mut duplicates: Vec<_> = counts
464 .into_iter()
465 .filter(|(_, count)| *count > 1)
466 .map(|(trade_id, _)| trade_id.to_owned())
467 .collect();
468 duplicates.sort();
469 duplicates
470 }
471
472 pub fn set_trade_id(&mut self, position_id: &str, trade_id: TradeId) {
475 self.trade_index.insert(trade_id, position_id.to_owned());
476 }
477
478 pub fn set_trade_id_checked(
480 &mut self,
481 position_id: &str,
482 trade_id: TradeId,
483 ) -> std::result::Result<(), PositionManagerError> {
484 if !self.positions.contains_key(position_id) {
485 return Err(PositionManagerError::PositionNotFound(
486 position_id.to_owned(),
487 ));
488 }
489 self.ensure_trade_id_available(&trade_id, Some(position_id))?;
490
491 let previous = self
492 .positions
493 .get(position_id)
494 .and_then(|position| position.data.trade_id.clone());
495 if let Some(previous) = previous
496 && self.trade_index.get(&previous).map(String::as_str) == Some(position_id)
497 {
498 self.trade_index.remove(&previous);
499 }
500
501 self.positions
502 .get_mut(position_id)
503 .expect("position existence checked above")
504 .set_trade_id(Some(trade_id.clone()));
505 self.trade_index.insert(trade_id, position_id.to_owned());
506 Ok(())
507 }
508
509 pub fn open_ids_by_side(&self, side: Side) -> Vec<PositionId> {
513 self.positions
514 .iter()
515 .filter(|(_, p)| p.data.status == PositionStatus::Open && p.data.side == side)
516 .map(|(id, _)| id.clone())
517 .collect()
518 }
519
520 pub fn open_ids_by_side_sorted(&self, side: Side) -> Vec<PositionId> {
522 sorted_ids(self.open_ids_by_side(side))
523 }
524
525 pub fn all_open_ids(&self) -> Vec<PositionId> {
527 self.ids_by_status(PositionStatus::Open)
528 }
529
530 pub fn all_open_ids_sorted(&self) -> Vec<PositionId> {
532 self.ids_by_status_sorted(PositionStatus::Open)
533 }
534
535 pub fn all_pending_ids(&self) -> Vec<PositionId> {
537 self.ids_by_status(PositionStatus::Pending)
538 }
539
540 pub fn all_pending_ids_sorted(&self) -> Vec<PositionId> {
542 self.ids_by_status_sorted(PositionStatus::Pending)
543 }
544}
545
546fn sorted_ids(mut ids: Vec<PositionId>) -> Vec<PositionId> {
547 ids.sort();
548 ids
549}
550
551#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::position::Position;
557 use crate::types::{CloseReason, Fill, OrderType, Side};
558 use chrono::NaiveDate;
559
560 fn ts(h: u32, m: u32, s: u32) -> chrono::NaiveDateTime {
561 NaiveDate::from_ymd_opt(2026, 1, 1)
562 .unwrap()
563 .and_hms_opt(h, m, s)
564 .unwrap()
565 }
566
567 fn make_open(id: &str, symbol: &str, side: Side) -> Position {
568 Position::new_market(
569 id.into(),
570 symbol.into(),
571 side,
572 Fill {
573 price: 1.0850,
574 size: 1.0,
575 ts: ts(10, 0, 0),
576 },
577 vec![],
578 )
579 }
580
581 fn make_pending(id: &str, symbol: &str, side: Side) -> Position {
582 Position::new_pending(
583 id.into(),
584 symbol.into(),
585 side,
586 OrderType::Limit,
587 1.0800,
588 1.0,
589 ts(9, 0, 0),
590 vec![],
591 )
592 }
593
594 #[test]
595 fn add_and_get() {
596 let mut mgr = PositionManager::new();
597 mgr.add(make_open("p1", "EURUSD", Side::Buy));
598 assert_eq!(mgr.len(), 1);
599 assert!(mgr.get("p1").is_some());
600 assert!(mgr.get("p2").is_none());
601 }
602
603 #[test]
604 fn remove_position() {
605 let mut mgr = PositionManager::new();
606 mgr.add(make_open("p1", "EURUSD", Side::Buy));
607 let removed = mgr.remove("p1");
608 assert!(removed.is_some());
609 assert!(mgr.is_empty());
610 }
611
612 #[test]
613 fn filter_by_status() {
614 let mut mgr = PositionManager::new();
615 mgr.add(make_open("p1", "EURUSD", Side::Buy));
616 mgr.add(make_pending("p2", "EURUSD", Side::Buy));
617 mgr.add(make_open("p3", "XAUUSD", Side::Sell));
618
619 assert_eq!(mgr.open_positions().len(), 2);
620 assert_eq!(mgr.pending_positions().len(), 1);
621 assert_eq!(mgr.closed_positions().len(), 0);
622 }
623
624 #[test]
625 fn quote_checkpoint_excludes_closed_history_and_other_symbols() {
626 let mut mgr = PositionManager::new();
627 for index in 0..1_000 {
628 let id = format!("closed-{index:04}");
629 let mut position = make_open(&id, "EURUSD", Side::Buy);
630 position
631 .data
632 .apply_full_close(CloseReason::Manual, ts(10, 1, 0));
633 mgr.add(position);
634 }
635 mgr.add(make_open("open", "EURUSD", Side::Buy));
636 mgr.add(make_pending("pending", "EURUSD", Side::Sell));
637 mgr.add(make_open("other-symbol", "XAUUSD", Side::Buy));
638
639 let checkpoint = mgr.checkpoint_for_quote("EURUSD");
640
641 assert_eq!(checkpoint.positions.len(), 2);
642 assert!(checkpoint.positions.contains_key("open"));
643 assert!(checkpoint.positions.contains_key("pending"));
644 }
645
646 #[test]
647 fn filter_by_symbol_status() {
648 let mut mgr = PositionManager::new();
649 mgr.add(make_open("p1", "EURUSD", Side::Buy));
650 mgr.add(make_open("p2", "XAUUSD", Side::Buy));
651 mgr.add(make_pending("p3", "EURUSD", Side::Sell));
652
653 let ids = mgr.open_ids_by_symbol("EURUSD");
654 assert_eq!(ids.len(), 1);
655 assert_eq!(ids[0], "p1");
656
657 let ids = mgr.pending_ids_by_symbol("EURUSD");
658 assert_eq!(ids.len(), 1);
659 assert_eq!(ids[0], "p3");
660 }
661
662 #[test]
663 fn filter_by_side() {
664 let mut mgr = PositionManager::new();
665 mgr.add(make_open("p1", "EURUSD", Side::Buy));
666 mgr.add(make_open("p2", "EURUSD", Side::Sell));
667 mgr.add(make_open("p3", "XAUUSD", Side::Buy));
668
669 let buy_ids = mgr.open_ids_by_side(Side::Buy);
670 assert_eq!(buy_ids.len(), 2);
671
672 let sell_ids = mgr.open_ids_by_side(Side::Sell);
673 assert_eq!(sell_ids.len(), 1);
674 }
675
676 #[test]
677 fn group_operations() {
678 let mut mgr = PositionManager::new();
679 mgr.add(make_open("p1", "EURUSD", Side::Buy));
680 mgr.add(make_open("p2", "EURUSD", Side::Buy));
681
682 mgr.add_to_group("g1", "p1".into());
683 mgr.add_to_group("g1", "p2".into());
684
685 let group_ids = mgr.group_position_ids("g1");
686 assert_eq!(group_ids.len(), 2);
687
688 assert!(mgr.get_group("g1").is_some());
689 assert!(mgr.get_group("g2").is_none());
690
691 mgr.remove_group("g1");
692 assert!(mgr.get_group("g1").is_none());
693 assert_eq!(mgr.len(), 2);
695 }
696
697 #[test]
698 fn all_open_and_pending_ids() {
699 let mut mgr = PositionManager::new();
700 mgr.add(make_open("p1", "EURUSD", Side::Buy));
701 mgr.add(make_open("p2", "XAUUSD", Side::Sell));
702 mgr.add(make_pending("p3", "EURUSD", Side::Buy));
703
704 assert_eq!(mgr.all_open_ids().len(), 2);
705 assert_eq!(mgr.all_pending_ids().len(), 1);
706 }
707
708 #[test]
709 fn sorted_queries_are_stable_and_lexicographic() {
710 let mut mgr = PositionManager::new();
711 mgr.add(make_open("p20", "EURUSD", Side::Buy));
712 mgr.add(make_pending("p03", "EURUSD", Side::Sell));
713 mgr.add(make_open("p10", "EURUSD", Side::Buy));
714 mgr.add(make_open("p01", "XAUUSD", Side::Sell));
715 mgr.add_group(PositionGroup {
716 id: "group-z".into(),
717 positions: vec![],
718 });
719 mgr.add_group(PositionGroup {
720 id: "group-a".into(),
721 positions: vec![],
722 });
723
724 assert_eq!(mgr.position_ids_sorted(), vec!["p01", "p03", "p10", "p20"]);
725 assert_eq!(
726 mgr.ids_for_symbol_sorted("EURUSD"),
727 vec!["p03", "p10", "p20"]
728 );
729 assert_eq!(
730 mgr.ids_by_symbol_status_sorted("EURUSD", PositionStatus::Open),
731 vec!["p10", "p20"]
732 );
733 assert_eq!(
734 mgr.ids_by_symbol_side_sorted("EURUSD", Side::Buy),
735 vec!["p10", "p20"]
736 );
737 assert_eq!(mgr.open_ids_by_symbol_sorted("EURUSD"), vec!["p10", "p20"]);
738 assert_eq!(mgr.pending_ids_by_symbol_sorted("EURUSD"), vec!["p03"]);
739 assert_eq!(mgr.open_ids_by_side_sorted(Side::Buy), vec!["p10", "p20"]);
740 assert_eq!(mgr.all_open_ids_sorted(), vec!["p01", "p10", "p20"]);
741 assert_eq!(mgr.all_pending_ids_sorted(), vec!["p03"]);
742 assert_eq!(
743 mgr.all_group_ids_sorted()
744 .into_iter()
745 .map(String::as_str)
746 .collect::<Vec<_>>(),
747 vec!["group-a", "group-z"]
748 );
749 }
750
751 #[test]
752 fn duplicate_trade_id_can_be_checked_before_add() {
753 let mut mgr = PositionManager::new();
754 let mut first = make_open("p1", "EURUSD", Side::Buy);
755 first.set_trade_id(Some("trade-7".into()));
756 mgr.add(first);
757
758 let mut duplicate = make_open("p2", "EURUSD", Side::Sell);
759 duplicate.set_trade_id(Some("trade-7".into()));
760 assert_eq!(mgr.would_duplicate_trade_id(&duplicate), Some("p1".into()));
761
762 let no_trade_id = make_open("p3", "EURUSD", Side::Buy);
763 assert_eq!(mgr.would_duplicate_trade_id(&no_trade_id), None);
764
765 let mut same_position = make_open("p1", "EURUSD", Side::Buy);
766 same_position.set_trade_id(Some("trade-7".into()));
767 assert_eq!(mgr.would_duplicate_trade_id(&same_position), None);
768 }
769
770 #[test]
771 fn legacy_add_still_allows_duplicates_and_audit_is_sorted() {
772 let mut mgr = PositionManager::new();
773 for (position_id, trade_id) in [
774 ("p1", "trade-z"),
775 ("p2", "trade-a"),
776 ("p3", "trade-z"),
777 ("p4", "trade-a"),
778 ("p5", "trade-ok"),
779 ] {
780 let mut position = make_open(position_id, "EURUSD", Side::Buy);
781 position.set_trade_id(Some(trade_id.into()));
782 mgr.add(position);
783 }
784
785 assert_eq!(mgr.len(), 5);
786 assert_eq!(mgr.id_by_trade_id("trade-z"), Some("p3".into()));
787 assert_eq!(mgr.duplicate_trade_ids(), vec!["trade-a", "trade-z"]);
788 }
789
790 #[test]
791 fn checked_add_rejects_duplicate_trade_id_without_corrupting_index() {
792 let mut mgr = PositionManager::new();
793 let mut first = make_open("p1", "EURUSD", Side::Buy);
794 first.set_trade_id(Some("trade-7".into()));
795 mgr.add_checked(first).unwrap();
796
797 let mut duplicate = make_open("p2", "XAUUSD", Side::Sell);
798 duplicate.set_trade_id(Some("trade-7".into()));
799 assert_eq!(
800 mgr.add_checked(duplicate),
801 Err(PositionManagerError::DuplicateTradeId {
802 trade_id: "trade-7".into(),
803 existing_position_id: "p1".into(),
804 })
805 );
806
807 assert_eq!(mgr.len(), 1);
808 assert!(mgr.get("p2").is_none());
809 assert!(mgr.ids_for_symbol("XAUUSD").is_empty());
810 assert_eq!(mgr.id_by_trade_id("trade-7"), Some("p1".into()));
811 }
812
813 #[test]
814 fn checked_add_overwrite_cleans_old_secondary_indexes() {
815 let mut mgr = PositionManager::new();
816 let mut first = make_open("p1", "EURUSD", Side::Buy);
817 first.set_trade_id(Some("trade-old".into()));
818 mgr.add_checked(first).unwrap();
819
820 let mut replacement = make_open("p1", "XAUUSD", Side::Sell);
821 replacement.set_trade_id(Some("trade-new".into()));
822 mgr.add_checked(replacement).unwrap();
823
824 assert!(mgr.ids_for_symbol("EURUSD").is_empty());
825 assert_eq!(mgr.ids_for_symbol("XAUUSD"), vec!["p1"]);
826 assert_eq!(mgr.id_by_trade_id("trade-old"), None);
827 assert_eq!(mgr.id_by_trade_id("trade-new"), Some("p1".into()));
828 }
829
830 #[test]
831 fn checked_trade_id_assignment_is_atomic_and_allows_empty_duplicates() {
832 let mut mgr = PositionManager::new();
833 let mut first = make_open("p1", "EURUSD", Side::Buy);
834 first.set_trade_id(Some("trade-7".into()));
835 mgr.add_checked(first).unwrap();
836 mgr.add_checked(make_open("p2", "EURUSD", Side::Sell))
837 .unwrap();
838
839 assert_eq!(
840 mgr.set_trade_id_checked("p2", "trade-7".into()),
841 Err(PositionManagerError::DuplicateTradeId {
842 trade_id: "trade-7".into(),
843 existing_position_id: "p1".into(),
844 })
845 );
846 assert_eq!(mgr.get("p2").unwrap().data.trade_id, None);
847 assert_eq!(mgr.id_by_trade_id("trade-7"), Some("p1".into()));
848
849 mgr.set_trade_id_checked("p2", String::new()).unwrap();
850 let mut third = make_open("p3", "EURUSD", Side::Buy);
851 third.set_trade_id(Some(String::new()));
852 mgr.add_checked(third).unwrap();
853 assert_eq!(mgr.len(), 3);
854 }
855}