Skip to main content

qs_core/
position_manager.rs

1//! Position manager — collection of positions with lookup helpers.
2//!
3//! `PositionManager` owns all positions and provides query/filter methods
4//! used by the [`TradeEngine`](crate::engine::TradeEngine).
5
6use std::collections::{HashMap, HashSet};
7
8use crate::position::Position;
9use crate::types::{GroupId, PositionId, PositionStatus, Side, TradeId};
10
11/// Optional grouping of positions for coordinated management.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct PositionGroup {
14    pub id: GroupId,
15    pub positions: Vec<PositionId>,
16}
17
18/// Errors from atomic checked manager mutations.
19#[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/// Owns all positions and provides indexed access.
31#[derive(Debug, Clone, Default)]
32pub struct PositionManager {
33    positions: HashMap<PositionId, Position>,
34    groups: HashMap<GroupId, PositionGroup>,
35    /// Secondary index: symbol → position IDs (all statuses).
36    symbol_index: HashMap<String, HashSet<PositionId>>,
37    /// Secondary index: trade_id → position ID (current/last position per trade id).
38    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    // ── CRUD ────────────────────────────────────────────────────────────
116
117    /// Insert a position.  Overwrites if the id already exists.
118    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    /// Atomically insert a position after rejecting a duplicate non-empty trade ID.
133    ///
134    /// Validation happens before any primary or secondary index mutation. The
135    /// legacy [`Self::add`] API remains available for callers that require its
136    /// historical last-write-wins behavior.
137    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    /// Remove a position by id.
153    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    /// Immutable lookup.
172    pub fn get(&self, id: &str) -> Option<&Position> {
173        self.positions.get(id)
174    }
175
176    /// Mutable lookup.
177    pub fn get_mut(&mut self, id: &str) -> Option<&mut Position> {
178        self.positions.get_mut(id)
179    }
180
181    /// Total number of tracked positions (all statuses).
182    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    // ── Iterators ───────────────────────────────────────────────────────
191
192    /// Iterate over all positions.
193    pub fn iter(&self) -> impl Iterator<Item = (&PositionId, &Position)> {
194        self.positions.iter()
195    }
196
197    /// Iterate mutably over all positions.
198    pub fn iter_mut(&mut self) -> impl Iterator<Item = (&PositionId, &mut Position)> {
199        self.positions.iter_mut()
200    }
201
202    /// All position IDs in stable lexicographic order.
203    pub fn position_ids_sorted(&self) -> Vec<PositionId> {
204        sorted_ids(self.positions.keys().cloned().collect())
205    }
206
207    // ── Filtered queries ────────────────────────────────────────────────
208
209    /// Collect ids of positions matching the given status and symbol.
210    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    /// Stable lexicographic variant of [`Self::ids_by_symbol_status`].
219    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    /// Collect ids of all positions with the given status.
228    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    /// Stable lexicographic variant of [`Self::ids_by_status`].
237    pub fn ids_by_status_sorted(&self, status: PositionStatus) -> Vec<PositionId> {
238        sorted_ids(self.ids_by_status(status))
239    }
240
241    /// Collect ids of open positions on a given symbol with a given side.
242    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    /// Stable lexicographic variant of [`Self::ids_by_symbol_side`].
255    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    /// All open (active) positions.
260    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    /// All pending (unfilled) positions.
268    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    /// All closed positions.
276    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    /// Collect ids of all open positions on a given symbol.
284    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    /// Stable lexicographic variant of [`Self::open_ids_by_symbol`].
296    pub fn open_ids_by_symbol_sorted(&self, symbol: &str) -> Vec<PositionId> {
297        sorted_ids(self.open_ids_by_symbol(symbol))
298    }
299
300    /// Collect ids of all pending positions on a given symbol.
301    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    /// Stable lexicographic variant of [`Self::pending_ids_by_symbol`].
313    pub fn pending_ids_by_symbol_sorted(&self, symbol: &str) -> Vec<PositionId> {
314        sorted_ids(self.pending_ids_by_symbol(symbol))
315    }
316
317    /// All position IDs for a symbol (any status), via the symbol index.
318    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    /// Stable lexicographic variant of [`Self::ids_for_symbol`].
326    pub fn ids_for_symbol_sorted(&self, symbol: &str) -> Vec<PositionId> {
327        sorted_ids(self.ids_for_symbol(symbol))
328    }
329
330    // ── Groups ──────────────────────────────────────────────────────────
331
332    /// Create or update a position group.
333    pub fn add_group(&mut self, group: PositionGroup) {
334        self.groups.insert(group.id.clone(), group);
335    }
336
337    /// Get a group by id.
338    pub fn get_group(&self, id: &str) -> Option<&PositionGroup> {
339        self.groups.get(id)
340    }
341
342    /// Add a position to an existing group.  Creates the group if it does not
343    /// exist.
344    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    /// Return all position ids belonging to a group.
356    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    /// Remove a group (does **not** close or remove the positions).
364    pub fn remove_group(&mut self, group_id: &str) -> Option<PositionGroup> {
365        self.groups.remove(group_id)
366    }
367
368    /// All open position IDs in a group.
369    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    /// All pending position IDs in a group.
381    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    /// All group IDs that currently exist.
393    pub fn all_group_ids(&self) -> Vec<&GroupId> {
394        self.groups.keys().collect()
395    }
396
397    /// All group IDs in stable lexicographic order.
398    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    // ── Trade ID helpers ─────────────────────────────────────────────────
405
406    /// Look up the position ID for a given application-defined trade id.
407    pub fn id_by_trade_id(&self, trade_id: &str) -> Option<PositionId> {
408        self.trade_index.get(trade_id).cloned()
409    }
410
411    /// Return the current owner when adding `position` would duplicate a trade
412    /// ID already assigned to a different position.
413    ///
414    /// This is opt-in validation; [`Self::add`] intentionally retains its
415    /// legacy last-write-wins trade-index behavior.
416    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    /// Validate a non-empty trade ID without mutating manager state.
422    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    /// Find every trade ID currently present on more than one stored position.
452    /// Results are stable and lexicographically sorted.
453    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    /// Update the trade_id mapping for a position (used when trade_id
473    /// is attached after position creation, e.g. during scale-in).
474    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    /// Atomically attach a trade ID to a stored position and update the index.
479    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    // ── Bulk helpers (used by engine) ───────────────────────────────────
510
511    /// Collect ids of all open positions matching a side filter.
512    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    /// Stable lexicographic variant of [`Self::open_ids_by_side`].
521    pub fn open_ids_by_side_sorted(&self, side: Side) -> Vec<PositionId> {
522        sorted_ids(self.open_ids_by_side(side))
523    }
524
525    /// Collect ids of **all** open positions regardless of symbol.
526    pub fn all_open_ids(&self) -> Vec<PositionId> {
527        self.ids_by_status(PositionStatus::Open)
528    }
529
530    /// All open position IDs in stable lexicographic order.
531    pub fn all_open_ids_sorted(&self) -> Vec<PositionId> {
532        self.ids_by_status_sorted(PositionStatus::Open)
533    }
534
535    /// Collect ids of **all** pending positions regardless of symbol.
536    pub fn all_pending_ids(&self) -> Vec<PositionId> {
537        self.ids_by_status(PositionStatus::Pending)
538    }
539
540    /// All pending position IDs in stable lexicographic order.
541    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// ─── Tests ──────────────────────────────────────────────────────────────────
552
553#[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        // Positions are still there
694        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}