Skip to main content

fin_primitives/position/
mod.rs

1//! # Module: position
2//!
3//! ## Responsibility
4//! Tracks individual positions per symbol and a multi-position ledger with cash accounting.
5//! Computes realized and unrealized P&L from fills.
6//!
7//! ## Guarantees
8//! - `Position::apply_fill` returns realized `PnL` (non-zero only when reducing position)
9//! - `PositionLedger::apply_fill` debits/credits cash correctly including commissions
10//! - `Position::is_flat` is true iff `quantity == 0`
11//!
12//! ## NOT Responsible For
13//! - Risk checks (see `risk` module)
14//! - Order management
15
16use crate::error::FinError;
17use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
18use rust_decimal::Decimal;
19use std::collections::HashMap;
20
21/// A single trade execution event.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct Fill {
24    /// The instrument traded.
25    pub symbol: Symbol,
26    /// Whether this fill is a buy (Bid) or sell (Ask).
27    pub side: Side,
28    /// The number of units traded.
29    pub quantity: Quantity,
30    /// The execution price.
31    pub price: Price,
32    /// When the fill occurred.
33    pub timestamp: NanoTimestamp,
34    /// Commission charged.
35    pub commission: Decimal,
36}
37
38impl Fill {
39    /// Constructs a `Fill` without commission (zero commission).
40    pub fn new(
41        symbol: Symbol,
42        side: Side,
43        quantity: Quantity,
44        price: Price,
45        timestamp: NanoTimestamp,
46    ) -> Self {
47        Self {
48            symbol,
49            side,
50            quantity,
51            price,
52            timestamp,
53            commission: Decimal::ZERO,
54        }
55    }
56
57    /// Constructs a `Fill` with the specified commission.
58    pub fn with_commission(
59        symbol: Symbol,
60        side: Side,
61        quantity: Quantity,
62        price: Price,
63        timestamp: NanoTimestamp,
64        commission: Decimal,
65    ) -> Self {
66        Self {
67            symbol,
68            side,
69            quantity,
70            price,
71            timestamp,
72            commission,
73        }
74    }
75
76    /// Returns the gross notional value of this fill: `price × quantity`.
77    ///
78    /// Does not subtract commission. Useful for computing total capital deployed
79    /// per fill and aggregate turnover statistics.
80    pub fn notional(&self) -> Decimal {
81        self.price.value() * self.quantity.value()
82    }
83}
84
85/// Direction of an open position.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub enum PositionDirection {
88    /// Net quantity is positive.
89    Long,
90    /// Net quantity is negative.
91    Short,
92    /// Net quantity is zero.
93    Flat,
94}
95
96/// A single-symbol position tracking quantity, average cost, and realized P&L.
97#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
98pub struct Position {
99    /// The instrument.
100    pub symbol: Symbol,
101    /// Current net quantity (positive = long, negative = short, zero = flat).
102    pub quantity: Decimal,
103    /// Volume-weighted average cost of the current position.
104    pub avg_cost: Decimal,
105    /// Cumulative realized P&L for this position (net of commissions).
106    pub realized_pnl: Decimal,
107    /// Bar index at which the current position leg was opened. Set via [`Position::set_open_bar`].
108    #[serde(default)]
109    pub open_bar: usize,
110}
111
112impl Position {
113    /// Creates a new flat `Position` for `symbol`.
114    pub fn new(symbol: Symbol) -> Self {
115        Self {
116            symbol,
117            quantity: Decimal::ZERO,
118            avg_cost: Decimal::ZERO,
119            realized_pnl: Decimal::ZERO,
120            open_bar: 0,
121        }
122    }
123
124    /// Records the bar index at which the current position leg was opened.
125    ///
126    /// Call this whenever transitioning from flat to a new position.
127    pub fn set_open_bar(&mut self, bar: usize) {
128        self.open_bar = bar;
129    }
130
131    /// Returns how many bars the current position has been open.
132    ///
133    /// `age = current_bar - self.open_bar` (saturating at 0).
134    pub fn position_age_bars(&self, current_bar: usize) -> usize {
135        current_bar.saturating_sub(self.open_bar)
136    }
137
138    /// Maximum favorable excursion (MFE): the best unrealized P&L seen across `prices`.
139    ///
140    /// For a long position, this is `max(price - avg_cost) * quantity`.
141    /// For a short position, this is `max(avg_cost - price) * |quantity|`.
142    ///
143    /// Returns `None` when the position is flat, `avg_cost` is zero, or `prices` is empty.
144    pub fn max_favorable_excursion(&self, prices: &[Price]) -> Option<Decimal> {
145        if self.is_flat() || self.avg_cost.is_zero() || prices.is_empty() {
146            return None;
147        }
148        let best = if self.is_long() {
149            prices
150                .iter()
151                .map(|p| (p.value() - self.avg_cost) * self.quantity)
152                .fold(Decimal::MIN, Decimal::max)
153        } else {
154            prices
155                .iter()
156                .map(|p| (self.avg_cost - p.value()) * self.quantity.abs())
157                .fold(Decimal::MIN, Decimal::max)
158        };
159        if best < Decimal::ZERO {
160            Some(Decimal::ZERO)
161        } else {
162            Some(best)
163        }
164    }
165
166    /// Kelly fraction: optimal bet size as a fraction of capital.
167    ///
168    /// `Kelly = win_rate - (1 - win_rate) / (avg_win / avg_loss)`
169    ///
170    /// Returns `None` when `avg_loss` or `avg_win` is zero.
171    /// The result is clamped to `[0, 1]` — never bet more than 100% or go short via Kelly.
172    pub fn kelly_fraction(
173        win_rate: Decimal,
174        avg_win: Decimal,
175        avg_loss: Decimal,
176    ) -> Option<Decimal> {
177        if avg_loss.is_zero() || avg_win.is_zero() {
178            return None;
179        }
180        let odds = avg_win / avg_loss;
181        let kelly = win_rate - (Decimal::ONE - win_rate) / odds;
182        Some(kelly.max(Decimal::ZERO).min(Decimal::ONE))
183    }
184
185    /// Applies a fill, updating quantity, `avg_cost`, and `realized_pnl`.
186    ///
187    /// # Returns
188    /// The realized P&L contributed by this fill (0 if position is increasing).
189    ///
190    /// # Errors
191    /// Returns [`FinError::ArithmeticOverflow`] on checked arithmetic failure.
192    pub fn apply_fill(&mut self, fill: &Fill) -> Result<Decimal, FinError> {
193        let fill_qty = match fill.side {
194            Side::Bid => fill.quantity.value(),
195            Side::Ask => -fill.quantity.value(),
196        };
197
198        let realized = if self.quantity != Decimal::ZERO
199            && (self.quantity > Decimal::ZERO) != (fill_qty > Decimal::ZERO)
200        {
201            let closed = fill_qty.abs().min(self.quantity.abs());
202            if self.quantity > Decimal::ZERO {
203                closed * (fill.price.value() - self.avg_cost)
204            } else {
205                closed * (self.avg_cost - fill.price.value())
206            }
207        } else {
208            Decimal::ZERO
209        };
210
211        let new_qty = self.quantity + fill_qty;
212        if new_qty == Decimal::ZERO {
213            self.avg_cost = Decimal::ZERO;
214        } else if (self.quantity >= Decimal::ZERO && fill_qty > Decimal::ZERO)
215            || (self.quantity <= Decimal::ZERO && fill_qty < Decimal::ZERO)
216        {
217            let total_cost =
218                self.avg_cost * self.quantity.abs() + fill.price.value() * fill_qty.abs();
219            self.avg_cost = total_cost
220                .checked_div(new_qty.abs())
221                .ok_or(FinError::ArithmeticOverflow)?;
222        } else if new_qty.abs() <= self.quantity.abs() {
223            // Partial close: avg_cost unchanged.
224        } else {
225            // Position flipped.
226            self.avg_cost = fill.price.value();
227        }
228
229        self.quantity = new_qty;
230        let net_realized = realized - fill.commission;
231        self.realized_pnl += net_realized;
232        Ok(net_realized)
233    }
234
235    /// Returns unrealized P&L at `current_price`.
236    pub fn unrealized_pnl(&self, current_price: Price) -> Decimal {
237        self.quantity * (current_price.value() - self.avg_cost)
238    }
239
240    /// Returns unrealized P&L at `current_price`, returning `Err` on arithmetic overflow.
241    pub fn checked_unrealized_pnl(&self, current_price: Price) -> Result<Decimal, FinError> {
242        let diff = current_price.value() - self.avg_cost;
243        self.quantity
244            .checked_mul(diff)
245            .ok_or(FinError::ArithmeticOverflow)
246    }
247
248    /// Returns unrealized P&L as a percentage of cost basis at `current_price`.
249    ///
250    /// `pct = unrealized_pnl / (|quantity| × avg_cost) × 100`.
251    /// Returns `None` if the position is flat or `avg_cost` is zero.
252    pub fn unrealized_pnl_pct(&self, current_price: Price) -> Option<Decimal> {
253        if self.is_flat() || self.avg_cost.is_zero() {
254            return None;
255        }
256        let cost_basis = self.quantity.abs() * self.avg_cost;
257        if cost_basis.is_zero() {
258            return None;
259        }
260        let upnl = self.unrealized_pnl(current_price);
261        upnl.checked_div(cost_basis).map(|r| r * Decimal::from(100u32))
262    }
263
264    /// Returns the total cost basis: `|quantity| * avg_cost`.
265    ///
266    /// Represents the total capital committed to this position.
267    /// Returns zero for flat positions.
268    pub fn total_cost_basis(&self) -> Decimal {
269        self.quantity.abs() * self.avg_cost
270    }
271
272    /// Returns the market value of this position at `current_price`.
273    pub fn market_value(&self, current_price: Price) -> Decimal {
274        self.quantity * current_price.value()
275    }
276
277    /// Returns `true` if the position is flat (zero quantity).
278    pub fn is_flat(&self) -> bool {
279        self.quantity == Decimal::ZERO
280    }
281
282    /// Returns `true` if the position is long (positive quantity).
283    pub fn is_long(&self) -> bool {
284        self.quantity > Decimal::ZERO
285    }
286
287    /// Returns `true` if the position is short (negative quantity).
288    pub fn is_short(&self) -> bool {
289        self.quantity < Decimal::ZERO
290    }
291
292    /// Returns the direction of the position.
293    pub fn direction(&self) -> PositionDirection {
294        if self.quantity > Decimal::ZERO {
295            PositionDirection::Long
296        } else if self.quantity < Decimal::ZERO {
297            PositionDirection::Short
298        } else {
299            PositionDirection::Flat
300        }
301    }
302
303    /// Returns total P&L: `realized_pnl + unrealized_pnl(current_price)`.
304    pub fn total_pnl(&self, current_price: Price) -> Decimal {
305        self.realized_pnl + self.unrealized_pnl(current_price)
306    }
307
308    /// Returns the absolute magnitude of the current quantity.
309    pub fn quantity_abs(&self) -> Decimal {
310        self.quantity.abs()
311    }
312
313    /// Returns the cost basis of the current position: `avg_cost * |quantity|`.
314    ///
315    /// Represents total capital deployed, excluding any realized P&L.
316    /// Returns `0` when the position is flat.
317    pub fn cost_basis(&self) -> Decimal {
318        self.avg_cost * self.quantity.abs()
319    }
320
321
322    /// Returns `true` if unrealized PnL at `current_price` is strictly positive.
323    pub fn is_profitable(&self, current_price: Price) -> bool {
324        self.unrealized_pnl(current_price) > Decimal::ZERO
325    }
326
327    /// Returns the average entry price as a `Price`, or `None` if the position is flat.
328    ///
329    /// This is `avg_cost` expressed as a validated `Price`. Returns `None` when
330    /// `avg_cost == 0` (no open position).
331    pub fn avg_entry_price(&self) -> Option<Price> {
332        Price::new(self.avg_cost).ok()
333    }
334
335    /// Returns the position's current market value as a percentage of `total_portfolio_value`.
336    ///
337    /// `exposure_pct = |quantity × current_price| / total_portfolio_value × 100`
338    ///
339    /// Returns `None` when `total_portfolio_value` is zero, the position is flat, or
340    /// `current_price` is zero.
341    pub fn exposure_pct(&self, current_price: Price, total_portfolio_value: Decimal) -> Option<Decimal> {
342        if total_portfolio_value.is_zero() || self.is_flat() {
343            return None;
344        }
345        let market_value = (self.quantity * current_price.value()).abs();
346        Some(market_value / total_portfolio_value * Decimal::ONE_HUNDRED)
347    }
348
349    /// Returns the stop-loss price at `stop_pct` percent below (long) or above (short) entry.
350    ///
351    /// - Long: `stop = avg_cost × (1 - stop_pct / 100)`
352    /// - Short: `stop = avg_cost × (1 + stop_pct / 100)`
353    ///
354    /// Returns `None` when the position is flat or `avg_cost` is zero.
355    ///
356    /// # Example
357    /// ```rust,ignore
358    /// // A 2% stop loss on a long position at avg_cost=100 → stop at 98
359    /// position.stop_loss_price(dec!(2)).unwrap() == Price::new(dec!(98)).unwrap()
360    /// ```
361    pub fn stop_loss_price(&self, stop_pct: Decimal) -> Option<Price> {
362        if self.is_flat() || self.avg_cost.is_zero() {
363            return None;
364        }
365        let factor = stop_pct / Decimal::ONE_HUNDRED;
366        let stop = if self.is_long() {
367            self.avg_cost * (Decimal::ONE - factor)
368        } else {
369            self.avg_cost * (Decimal::ONE + factor)
370        };
371        Price::new(stop).ok()
372    }
373
374    /// Returns the take-profit price for the current position at `tp_pct` percent gain.
375    ///
376    /// Returns `None` when the position is flat or `avg_cost` is zero.
377    /// For a long position, the take-profit price is `avg_cost * (1 + tp_pct / 100)`.
378    /// For a short position, the take-profit price is `avg_cost * (1 - tp_pct / 100)`.
379    pub fn take_profit_price(&self, tp_pct: Decimal) -> Option<Price> {
380        if self.is_flat() || self.avg_cost.is_zero() {
381            return None;
382        }
383        let factor = tp_pct / Decimal::ONE_HUNDRED;
384        let tp = if self.is_long() {
385            self.avg_cost * (Decimal::ONE + factor)
386        } else {
387            self.avg_cost * (Decimal::ONE - factor)
388        };
389        Price::new(tp).ok()
390    }
391
392    /// Returns the margin requirement for the current position: `|net_quantity| × avg_cost × margin_pct / 100`.
393    ///
394    /// Returns `None` if the position is flat or `avg_cost` is zero.
395    pub fn margin_requirement(&self, margin_pct: Decimal) -> Option<Decimal> {
396        if self.is_flat() || self.avg_cost.is_zero() {
397            return None;
398        }
399        let notional = self.quantity.abs() * self.avg_cost;
400        Some(notional * margin_pct / Decimal::ONE_HUNDRED)
401    }
402
403    /// Returns the risk/reward ratio: `target_pct / stop_pct`.
404    ///
405    /// This is a pure calculation and does not depend on position state.
406    /// Returns `None` if `stop_pct` is zero or negative.
407    pub fn risk_reward_ratio(stop_pct: Decimal, target_pct: Decimal) -> Option<f64> {
408        use rust_decimal::prelude::ToPrimitive;
409        if stop_pct <= Decimal::ZERO {
410            return None;
411        }
412        (target_pct / stop_pct).to_f64()
413    }
414
415    /// Leverage: `|quantity × avg_cost| / portfolio_value`.
416    ///
417    /// Returns `None` if the position is flat, `avg_cost` is zero, or `portfolio_value` is zero.
418    pub fn leverage(&self, portfolio_value: Decimal) -> Option<Decimal> {
419        if self.is_flat() || self.avg_cost.is_zero() || portfolio_value.is_zero() {
420            return None;
421        }
422        let notional = self.quantity.abs() * self.avg_cost;
423        Some(notional / portfolio_value)
424    }
425}
426
427/// A multi-symbol ledger tracking positions and a cash balance.
428#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
429pub struct PositionLedger {
430    positions: HashMap<Symbol, Position>,
431    cash: Decimal,
432    total_commission_paid: Decimal,
433}
434
435impl PositionLedger {
436    /// Creates a new `PositionLedger` with the given initial cash balance.
437    pub fn new(initial_cash: Decimal) -> Self {
438        Self {
439            positions: HashMap::new(),
440            cash: initial_cash,
441            total_commission_paid: Decimal::ZERO,
442        }
443    }
444
445    /// Applies a fill to the appropriate position and updates cash.
446    ///
447    /// # Errors
448    /// Returns [`FinError::InsufficientFunds`] if a buy would require more cash than available.
449    #[allow(clippy::needless_pass_by_value)]
450    pub fn apply_fill(&mut self, fill: Fill) -> Result<(), FinError> {
451        let cost = match fill.side {
452            Side::Bid => -(fill.quantity.value() * fill.price.value() + fill.commission),
453            Side::Ask => fill.quantity.value() * fill.price.value() - fill.commission,
454        };
455        if fill.side == Side::Bid && self.cash + cost < Decimal::ZERO {
456            return Err(FinError::InsufficientFunds {
457                need: fill.quantity.value() * fill.price.value() + fill.commission,
458                have: self.cash,
459            });
460        }
461        self.cash += cost;
462        self.total_commission_paid += fill.commission;
463        let pos = self
464            .positions
465            .entry(fill.symbol.clone())
466            .or_insert_with(|| Position::new(fill.symbol.clone()));
467        pos.apply_fill(&fill)?;
468        Ok(())
469    }
470
471    /// Returns the position for `symbol`, or `None` if no position exists.
472    pub fn position(&self, symbol: &Symbol) -> Option<&Position> {
473        self.positions.get(symbol)
474    }
475
476    /// Returns `true` if the ledger is tracking `symbol` (even if flat).
477    pub fn has_position(&self, symbol: &Symbol) -> bool {
478        self.positions.contains_key(symbol)
479    }
480
481    /// Returns an iterator over all tracked positions (including flat ones).
482    pub fn positions(&self) -> impl Iterator<Item = &Position> {
483        self.positions.values()
484    }
485
486    /// Returns an iterator over positions with non-zero quantity.
487    pub fn open_positions(&self) -> impl Iterator<Item = &Position> {
488        self.positions.values().filter(|p| !p.is_flat())
489    }
490
491    /// Returns an iterator over flat (zero-quantity) positions.
492    pub fn flat_positions(&self) -> impl Iterator<Item = &Position> {
493        self.positions.values().filter(|p| p.is_flat())
494    }
495
496    /// Returns an iterator over long (positive-quantity) positions.
497    pub fn long_positions(&self) -> impl Iterator<Item = &Position> {
498        self.positions.values().filter(|p| p.is_long())
499    }
500
501    /// Returns an iterator over short (negative-quantity) positions.
502    pub fn short_positions(&self) -> impl Iterator<Item = &Position> {
503        self.positions.values().filter(|p| p.is_short())
504    }
505
506    /// Returns an iterator over the symbols being tracked by this ledger.
507    pub fn symbols(&self) -> impl Iterator<Item = &Symbol> {
508        self.positions.keys()
509    }
510
511    /// Returns an iterator over symbols that have a non-flat (open) position.
512    pub fn open_symbols(&self) -> impl Iterator<Item = &Symbol> {
513        self.positions
514            .iter()
515            .filter(|(_, p)| !p.is_flat())
516            .map(|(s, _)| s)
517    }
518
519    /// Returns the sum of `|quantity| × avg_cost` for all long (positive quantity) positions.
520    ///
521    /// Represents the notional value invested on the long side.
522    pub fn total_long_exposure(&self) -> Decimal {
523        self.positions
524            .values()
525            .filter(|p| p.is_long())
526            .map(|p| p.quantity.abs() * p.avg_cost)
527            .sum()
528    }
529
530    /// Returns the sum of `|quantity| × avg_cost` for all short (negative quantity) positions.
531    ///
532    /// Represents the notional value of the short exposure.
533    pub fn total_short_exposure(&self) -> Decimal {
534        self.positions
535            .values()
536            .filter(|p| p.is_short())
537            .map(|p| p.quantity.abs() * p.avg_cost)
538            .sum()
539    }
540
541    /// Returns a sorted `Vec` of all tracked symbols in lexicographic order.
542    ///
543    /// Useful when deterministic output ordering is required (e.g. reports, snapshots).
544    pub fn symbols_sorted(&self) -> Vec<&Symbol> {
545        let mut syms: Vec<&Symbol> = self.positions.keys().collect();
546        syms.sort();
547        syms
548    }
549
550    /// Returns the total number of symbols tracked by this ledger (open and flat).
551    pub fn position_count(&self) -> usize {
552        self.positions.len()
553    }
554
555    /// Deposits `amount` into the cash balance (increases cash).
556    ///
557    /// # Panics
558    /// Does not panic; accepts any `Decimal` including negative (use `withdraw` for cleaner API).
559    pub fn deposit(&mut self, amount: Decimal) {
560        self.cash += amount;
561    }
562
563    /// Withdraws `amount` from the cash balance.
564    ///
565    /// # Errors
566    /// Returns [`FinError::InsufficientFunds`] if `amount > self.cash`.
567    pub fn withdraw(&mut self, amount: Decimal) -> Result<(), FinError> {
568        if amount > self.cash {
569            return Err(FinError::InsufficientFunds {
570                need: amount,
571                have: self.cash,
572            });
573        }
574        self.cash -= amount;
575        Ok(())
576    }
577
578    /// Returns the number of non-flat (open) positions.
579    pub fn open_position_count(&self) -> usize {
580        self.positions.values().filter(|p| !p.is_flat()).count()
581    }
582
583    /// Returns the number of long (positive quantity) open positions.
584    pub fn long_count(&self) -> usize {
585        self.positions.values().filter(|p| p.quantity > Decimal::ZERO).count()
586    }
587
588    /// Returns the number of short (negative quantity) open positions.
589    pub fn short_count(&self) -> usize {
590        self.positions.values().filter(|p| p.quantity < Decimal::ZERO).count()
591    }
592
593    /// Returns the net signed quantity exposure across all positions.
594    ///
595    /// Long positions contribute positive values; short positions contribute negative values.
596    /// A result near zero indicates a roughly delta-neutral portfolio.
597    pub fn net_exposure(&self) -> Decimal {
598        self.positions.values().map(|p| p.quantity).sum()
599    }
600
601    /// Net market exposure using current prices: sum of (quantity × price) across all positions.
602    ///
603    /// Long positions contribute positive values; short positions contribute negative values.
604    /// Prices missing from `prices` are skipped.
605    /// Returns `None` if no open positions have prices available.
606    pub fn net_market_exposure(&self, prices: &std::collections::HashMap<String, Price>) -> Option<Decimal> {
607        let mut found = false;
608        let mut net = Decimal::ZERO;
609        for pos in self.positions.values() {
610            if pos.quantity.is_zero() { continue; }
611            if let Some(&price) = prices.get(pos.symbol.as_str()) {
612                found = true;
613                net += pos.quantity * price.value();
614            }
615        }
616        if found { Some(net) } else { None }
617    }
618
619    /// Returns the gross (absolute) quantity exposure across all positions.
620    ///
621    /// Sums `|quantity|` for every position regardless of direction.
622    pub fn gross_exposure(&self) -> Decimal {
623        self.positions.values().map(|p| p.quantity.abs()).sum()
624    }
625
626    /// Returns a reference to the open position with the largest absolute quantity.
627    ///
628    /// Returns `None` when there are no open (non-flat) positions.
629    /// Returns the number of positions with non-zero quantity.
630    pub fn open_count(&self) -> usize {
631        self.positions.values().filter(|p| !p.is_flat()).count()
632    }
633
634    /// Returns a reference to the open position with the largest absolute quantity.
635    ///
636    /// Returns `None` if there are no open (non-flat) positions.
637    pub fn largest_position(&self) -> Option<&Position> {
638        self.positions
639            .values()
640            .filter(|p| !p.is_flat())
641            .max_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
642    }
643
644    /// Returns the total market value of all open positions given a price map.
645    ///
646    /// # Errors
647    /// Returns [`FinError::PositionNotFound`] if a non-flat position has no price in `prices`.
648    pub fn total_market_value(
649        &self,
650        prices: &HashMap<String, Price>,
651    ) -> Result<Decimal, FinError> {
652        let mut total = Decimal::ZERO;
653        for (sym, pos) in &self.positions {
654            if pos.quantity == Decimal::ZERO {
655                continue;
656            }
657            let price = prices
658                .get(sym.as_str())
659                .ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
660            total += pos.market_value(*price);
661        }
662        Ok(total)
663    }
664
665    /// Returns the current cash balance.
666    pub fn cash(&self) -> Decimal {
667        self.cash
668    }
669
670    /// Returns each open position's market value as a fraction of total market value.
671    ///
672    /// Returns a `Vec<(Symbol, Decimal)>` where the second element is `[0, 1]`.
673    /// Flat positions are excluded. Returns an empty vec if total market value is zero
674    /// or if `prices` lacks an entry for an open position (graceful skip).
675    pub fn position_weights(&self, prices: &HashMap<String, Price>) -> Vec<(Symbol, Decimal)> {
676        let mut mv_pairs: Vec<(Symbol, Decimal)> = self
677            .positions
678            .iter()
679            .filter(|(_, p)| !p.is_flat())
680            .filter_map(|(sym, pos)| {
681                let price = prices.get(sym.as_str())?;
682                Some((sym.clone(), pos.market_value(*price).abs()))
683            })
684            .collect();
685        let total: Decimal = mv_pairs.iter().map(|(_, v)| *v).sum();
686        if total.is_zero() {
687            return vec![];
688        }
689        mv_pairs.iter_mut().for_each(|(_, v)| *v /= total);
690        mv_pairs
691    }
692
693    /// Returns the total realized P&L across all positions.
694    pub fn realized_pnl_total(&self) -> Decimal {
695        self.positions.values().map(|p| p.realized_pnl).sum()
696    }
697
698    /// Returns the total unrealized P&L given a map of current prices.
699    ///
700    /// # Errors
701    /// Returns [`FinError::PositionNotFound`] if a non-flat position has no price in `prices`.
702    pub fn unrealized_pnl_total(
703        &self,
704        prices: &HashMap<String, Price>,
705    ) -> Result<Decimal, FinError> {
706        let mut total = Decimal::ZERO;
707        for (sym, pos) in &self.positions {
708            if pos.quantity == Decimal::ZERO {
709                continue;
710            }
711            let price = prices
712                .get(sym.as_str())
713                .ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
714            total += pos.unrealized_pnl(*price);
715        }
716        Ok(total)
717    }
718
719    /// Returns the realized P&L for `symbol`, or `None` if the symbol is not tracked.
720    pub fn realized_pnl(&self, symbol: &Symbol) -> Option<Decimal> {
721        self.positions.get(symbol).map(|p| p.realized_pnl)
722    }
723
724    /// Returns total net P&L: `realized_pnl_total + unrealized_pnl_total(prices)`.
725    ///
726    /// # Errors
727    /// Returns [`FinError::PositionNotFound`] if a non-flat position has no price in `prices`.
728    pub fn net_pnl(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
729        Ok(self.realized_pnl_total() + self.unrealized_pnl_total(prices)?)
730    }
731
732    /// Returns total equity: `cash + sum(unrealized P&L of open positions)`.
733    ///
734    /// # Errors
735    /// Returns [`FinError::PositionNotFound`] if a position has no price in `prices`.
736    pub fn equity(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
737        Ok(self.cash + self.unrealized_pnl_total(prices)?)
738    }
739
740    /// Returns the net liquidation value: `cash + sum(market_value of each open position)`.
741    ///
742    /// Market value of a position = `quantity × current_price`. This differs from
743    /// `equity` which adds unrealized P&L rather than raw market value.
744    ///
745    /// # Errors
746    /// Returns [`FinError::PositionNotFound`] if a position has no price in `prices`.
747    pub fn net_liquidation_value(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
748        let mut total = self.cash;
749        for (symbol, pos) in &self.positions {
750            if pos.quantity == Decimal::ZERO {
751                continue;
752            }
753            let price = prices
754                .get(symbol.as_str())
755                .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
756            total += pos.quantity * price.value();
757        }
758        Ok(total)
759    }
760
761    /// Returns the gross exposure: sum of `|quantity × price|` across all open positions.
762    ///
763    /// Returns unrealized P&L per symbol as a `HashMap`.
764    ///
765    /// # Errors
766    /// Returns [`FinError::PositionNotFound`] if a non-flat position has no price in `prices`.
767    pub fn pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> Result<HashMap<Symbol, Decimal>, FinError> {
768        let mut map = HashMap::new();
769        for (symbol, pos) in &self.positions {
770            if pos.quantity == Decimal::ZERO {
771                continue;
772            }
773            let price = prices
774                .get(symbol.as_str())
775                .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
776            map.insert(symbol.clone(), pos.unrealized_pnl(*price));
777        }
778        Ok(map)
779    }
780
781    /// Returns `true` if the portfolio is approximately delta-neutral.
782    ///
783    /// Delta-neutral: `|net_exposure| / gross_exposure < 0.01` (within 1%).
784    /// Returns `true` when there are no open positions.
785    ///
786    /// # Errors
787    /// Returns [`FinError::PositionNotFound`] if a non-flat position has no price in `prices`.
788    pub fn delta_neutral_check(&self, prices: &HashMap<String, Price>) -> Result<bool, FinError> {
789        let mut net = Decimal::ZERO;
790        let mut gross = Decimal::ZERO;
791        for (symbol, pos) in &self.positions {
792            if pos.quantity == Decimal::ZERO {
793                continue;
794            }
795            let price = prices
796                .get(symbol.as_str())
797                .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
798            let exposure = pos.quantity * price.value();
799            net += exposure;
800            gross += exposure.abs();
801        }
802        if gross == Decimal::ZERO {
803            return Ok(true);
804        }
805        Ok((net / gross).abs() < Decimal::new(1, 2)) // < 0.01
806    }
807
808    /// Returns the allocation percentage of a symbol within the total portfolio value.
809    ///
810    /// `allocation = |qty * price| / total_market_value * 100`.
811    /// Returns `None` if the symbol has no open position, the price is not provided,
812    /// or total market value is zero.
813    ///
814    /// # Errors
815    /// Returns [`crate::error::FinError::PositionNotFound`] if `symbol` is unknown.
816    pub fn allocation_pct(
817        &self,
818        symbol: &Symbol,
819        prices: &HashMap<String, Price>,
820    ) -> Result<Option<Decimal>, crate::error::FinError> {
821        let pos = self
822            .positions
823            .get(symbol)
824            .ok_or_else(|| crate::error::FinError::PositionNotFound(symbol.to_string()))?;
825        if pos.quantity == Decimal::ZERO {
826            return Ok(None);
827        }
828        let price = match prices.get(symbol.as_str()) {
829            Some(p) => *p,
830            None => return Ok(None),
831        };
832        let notional = (pos.quantity * price.value()).abs();
833        let total = self.total_market_value(prices)?;
834        if total.is_zero() {
835            return Ok(None);
836        }
837        Ok(Some(notional / total * Decimal::ONE_HUNDRED))
838    }
839
840    /// Returns open positions sorted descending by unrealized PnL.
841    ///
842    /// Positions not in `prices` are assigned a PnL of zero for sorting purposes.
843    pub fn positions_sorted_by_pnl(&self, prices: &HashMap<String, Price>) -> Vec<&Position> {
844        let mut open: Vec<&Position> = self
845            .positions
846            .values()
847            .filter(|p| p.quantity != Decimal::ZERO)
848            .collect();
849        open.sort_by(|a, b| {
850            let pnl_a = prices
851                .get(a.symbol.as_str())
852                .map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
853            let pnl_b = prices
854                .get(b.symbol.as_str())
855                .map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
856            pnl_b.cmp(&pnl_a)
857        });
858        open
859    }
860
861    /// Returns the top `n` open positions sorted by absolute market value descending.
862    ///
863    /// Positions missing from `prices` are assigned market value of zero and sink to the bottom.
864    pub fn top_n_positions<'a>(&'a self, n: usize, prices: &HashMap<String, Price>) -> Vec<&'a Position> {
865        let mut open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
866        open.sort_by(|a, b| {
867            let mv_a = prices.get(a.symbol.as_str())
868                .map_or(Decimal::ZERO, |p| (a.quantity * p.value()).abs());
869            let mv_b = prices.get(b.symbol.as_str())
870                .map_or(Decimal::ZERO, |p| (b.quantity * p.value()).abs());
871            mv_b.cmp(&mv_a)
872        });
873        open.into_iter().take(n).collect()
874    }
875
876    /// Returns the Herfindahl-Hirschman Index of position weights (0–1).
877    ///
878    /// `HHI = Σ(weight_i²)` where `weight_i = |mv_i| / gross_exposure`.
879    ///
880    /// Values near 1 indicate high concentration (single dominant position);
881    /// near `1/n` indicate equal distribution. Returns `None` when no open positions.
882    ///
883    /// # Errors
884    /// Returns [`FinError::PositionNotFound`] if a non-flat position has no price in `prices`.
885    pub fn concentration(&self, prices: &HashMap<String, Price>) -> Result<Option<Decimal>, FinError> {
886        let gross = self.gross_exposure();
887        if gross == Decimal::ZERO {
888            return Ok(None);
889        }
890        let mut hhi = Decimal::ZERO;
891        for (symbol, pos) in &self.positions {
892            if pos.quantity == Decimal::ZERO {
893                continue;
894            }
895            let price = prices
896                .get(symbol.as_str())
897                .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
898            let mv = (pos.quantity * price.value()).abs();
899            let w = mv / gross;
900            hhi += w * w;
901        }
902        Ok(Some(hhi))
903    }
904
905    /// Returns the margin required: `gross_exposure × margin_rate`.
906    ///
907    /// # Errors
908    /// Returns [`FinError::PositionNotFound`] if a non-flat position has no price in `prices`.
909    pub fn margin_used(&self, prices: &HashMap<String, Price>, margin_rate: Decimal) -> Result<Decimal, FinError> {
910        let mut gross = Decimal::ZERO;
911        for (symbol, pos) in &self.positions {
912            if pos.quantity == Decimal::ZERO {
913                continue;
914            }
915            let price = prices
916                .get(symbol.as_str())
917                .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
918            gross += (pos.quantity * price.value()).abs();
919        }
920        Ok(gross * margin_rate)
921    }
922
923    /// Returns the count of tracked positions with zero quantity (flat positions).
924    pub fn flat_count(&self) -> usize {
925        self.positions.values().filter(|p| p.is_flat()).count()
926    }
927
928    /// Returns the open position with the smallest absolute quantity.
929    ///
930    /// Returns `None` if there are no open (non-flat) positions.
931    pub fn smallest_position(&self) -> Option<&Position> {
932        self.positions
933            .values()
934            .filter(|p| !p.is_flat())
935            .min_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
936    }
937
938    /// Returns the symbol with the highest unrealized PnL given current `prices`.
939    ///
940    /// Returns `None` if there are no open positions or the price map is empty.
941    pub fn most_profitable_symbol(
942        &self,
943        prices: &HashMap<String, Price>,
944    ) -> Option<&Symbol> {
945        self.positions
946            .iter()
947            .filter(|(_, p)| !p.is_flat())
948            .filter_map(|(sym, p)| {
949                let price = prices.get(sym.as_str())?;
950                let pnl = p.unrealized_pnl(*price);
951                Some((sym, pnl))
952            })
953            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
954            .map(|(sym, _)| sym)
955    }
956
957    /// Returns the symbol with the lowest (most negative) unrealized PnL given current `prices`.
958    ///
959    /// Returns `None` if there are no open positions or the price map is empty.
960    pub fn least_profitable_symbol(
961        &self,
962        prices: &HashMap<String, Price>,
963    ) -> Option<&Symbol> {
964        self.positions
965            .iter()
966            .filter(|(_, p)| !p.is_flat())
967            .filter_map(|(sym, p)| {
968                let price = prices.get(sym.as_str())?;
969                let pnl = p.unrealized_pnl(*price);
970                Some((sym, pnl))
971            })
972            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
973            .map(|(sym, _)| sym)
974    }
975
976    /// Returns the cumulative commissions paid across all fills processed by this ledger.
977    pub fn total_commission_paid(&self) -> Decimal {
978        self.total_commission_paid
979    }
980
981    /// Returns all open positions as `(Symbol, unrealized_pnl)` sorted by PnL descending.
982    ///
983    /// Symbols without a price entry in `prices` are skipped.
984    pub fn symbols_with_pnl(
985        &self,
986        prices: &HashMap<String, Price>,
987    ) -> Vec<(&Symbol, Decimal)> {
988        let mut result: Vec<(&Symbol, Decimal)> = self
989            .positions
990            .iter()
991            .filter(|(_, p)| !p.is_flat())
992            .filter_map(|(sym, p)| {
993                let price = prices.get(sym.as_str())?;
994                Some((sym, p.unrealized_pnl(*price)))
995            })
996            .collect();
997        result.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
998        result
999    }
1000
1001    /// Returns the fraction of total portfolio value held in a single symbol (as a percentage).
1002    ///
1003    /// `concentration = market_value(symbol) / total_market_value * 100`.
1004    /// Returns `None` if the symbol is not found, price is missing, or total value is zero.
1005    pub fn concentration_pct(
1006        &self,
1007        symbol: &Symbol,
1008        prices: &HashMap<String, Price>,
1009    ) -> Option<Decimal> {
1010        let pos = self.positions.get(symbol)?;
1011        let price = prices.get(symbol.as_str())?;
1012        let mv = pos.quantity.abs() * price.value();
1013        let total = self
1014            .positions
1015            .values()
1016            .filter_map(|p| {
1017                let pr = prices.get(p.symbol.as_str())?;
1018                Some(p.quantity.abs() * pr.value())
1019            })
1020            .sum::<Decimal>();
1021        if total.is_zero() {
1022            return None;
1023        }
1024        Some(mv / total * Decimal::ONE_HUNDRED)
1025    }
1026
1027    /// Returns `true` if all registered positions are flat (zero quantity).
1028    pub fn all_flat(&self) -> bool {
1029        self.positions.values().all(|p| p.is_flat())
1030    }
1031
1032    /// Total market value of all long (positive quantity) positions.
1033    ///
1034    /// Skips any symbol not present in `prices`. Returns `Decimal::ZERO` when there are no longs.
1035    pub fn long_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1036        self.positions
1037            .iter()
1038            .filter(|(_, p)| p.is_long())
1039            .filter_map(|(sym, p)| {
1040                let price = prices.get(sym.as_str())?;
1041                Some(p.quantity.abs() * price.value())
1042            })
1043            .sum()
1044    }
1045
1046    /// Total market value of all short (negative quantity) positions.
1047    ///
1048    /// Skips any symbol not present in `prices`. Returns `Decimal::ZERO` when there are no shorts.
1049    pub fn short_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1050        self.positions
1051            .iter()
1052            .filter(|(_, p)| p.is_short())
1053            .filter_map(|(sym, p)| {
1054                let price = prices.get(sym.as_str())?;
1055                Some(p.quantity.abs() * price.value())
1056            })
1057            .sum()
1058    }
1059
1060    /// Signed net market value: `long_exposure - short_exposure`.
1061    ///
1062    /// Positive = net long; negative = net short; zero = balanced or flat.
1063    pub fn net_delta(&self, prices: &HashMap<String, Price>) -> Decimal {
1064        self.long_exposure(prices) - self.short_exposure(prices)
1065    }
1066
1067    /// Returns the average cost basis for `symbol`, or `None` if the position is flat or unknown.
1068    pub fn avg_cost_basis(&self, symbol: &Symbol) -> Option<Decimal> {
1069        let pos = self.positions.get(symbol)?;
1070        if pos.is_flat() { return None; }
1071        Some(pos.avg_cost)
1072    }
1073
1074    /// Returns a list of symbols that currently have a non-flat (open) position.
1075    pub fn active_symbols(&self) -> Vec<&Symbol> {
1076        self.positions
1077            .iter()
1078            .filter(|(_, pos)| !pos.is_flat())
1079            .map(|(sym, _)| sym)
1080            .collect()
1081    }
1082
1083    /// Returns the total number of symbols tracked by this ledger (including flat positions).
1084    pub fn symbol_count(&self) -> usize {
1085        self.positions.len()
1086    }
1087
1088    /// Returns the realized P&L for every symbol that has a non-zero realized P&L,
1089    /// sorted descending by value.
1090    ///
1091    /// Symbols with zero realized P&L are excluded.
1092    pub fn realized_pnl_by_symbol(&self) -> Vec<(Symbol, Decimal)> {
1093        let mut pairs: Vec<(Symbol, Decimal)> = self
1094            .positions
1095            .iter()
1096            .filter_map(|(sym, pos)| {
1097                let r = pos.realized_pnl;
1098                if r != Decimal::ZERO { Some((sym.clone(), r)) } else { None }
1099            })
1100            .collect();
1101        pairs.sort_by(|a, b| b.1.cmp(&a.1));
1102        pairs
1103    }
1104
1105    /// Returns up to `n` open positions with the worst (most negative) unrealized P&L.
1106    ///
1107    /// Positions missing from `prices` receive an unrealized PnL of zero.
1108    /// Returns an empty slice when `n == 0` or no open positions exist.
1109    pub fn top_losers<'a>(
1110        &'a self,
1111        n: usize,
1112        prices: &HashMap<String, Price>,
1113    ) -> Vec<&'a Position> {
1114        if n == 0 {
1115            return vec![];
1116        }
1117        let mut open: Vec<&Position> =
1118            self.positions.values().filter(|p| !p.is_flat()).collect();
1119        open.sort_by(|a, b| {
1120            let pnl_a = prices
1121                .get(a.symbol.as_str())
1122                .map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
1123            let pnl_b = prices
1124                .get(b.symbol.as_str())
1125                .map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
1126            pnl_a.cmp(&pnl_b) // ascending: worst first
1127        });
1128        open.into_iter().take(n).collect()
1129    }
1130
1131    /// Returns the symbols that currently have flat (zero-quantity) positions,
1132    /// sorted lexicographically.
1133    pub fn flat_symbols(&self) -> Vec<&Symbol> {
1134        let mut syms: Vec<&Symbol> = self.positions
1135            .iter()
1136            .filter_map(|(sym, pos)| if pos.is_flat() { Some(sym) } else { None })
1137            .collect();
1138        syms.sort();
1139        syms
1140    }
1141
1142    /// Largest unrealized loss among all open positions.
1143    ///
1144    /// Returns `None` if there are no open positions or all unrealized PnLs are non-negative.
1145    pub fn max_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1146        self.positions
1147            .values()
1148            .filter(|p| !p.is_flat())
1149            .filter_map(|p| {
1150                let price = prices.get(p.symbol.as_str()).copied()?;
1151                let upnl = p.unrealized_pnl(price);
1152                if upnl < Decimal::ZERO { Some(upnl) } else { None }
1153            })
1154            .min_by(|a, b| a.cmp(b))
1155    }
1156
1157    /// Returns the position with the largest positive unrealized P&L at the given prices.
1158    ///
1159    /// Returns `None` if there are no open positions or no position has a positive unrealized PnL.
1160    pub fn largest_winner<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1161        self.positions
1162            .values()
1163            .filter(|p| !p.is_flat())
1164            .filter_map(|p| {
1165                let price = prices.get(p.symbol.as_str()).copied()?;
1166                let upnl = p.unrealized_pnl(price);
1167                if upnl > Decimal::ZERO { Some((p, upnl)) } else { None }
1168            })
1169            .max_by(|a, b| a.1.cmp(&b.1))
1170            .map(|(p, _)| p)
1171    }
1172
1173    /// Returns the position with the largest negative unrealized P&L at the given prices.
1174    ///
1175    /// Returns `None` if there are no open positions or no position has a negative unrealized PnL.
1176    pub fn largest_loser<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1177        self.positions
1178            .values()
1179            .filter(|p| !p.is_flat())
1180            .filter_map(|p| {
1181                let price = prices.get(p.symbol.as_str()).copied()?;
1182                let upnl = p.unrealized_pnl(price);
1183                if upnl < Decimal::ZERO { Some((p, upnl)) } else { None }
1184            })
1185            .min_by(|a, b| a.1.cmp(&b.1))
1186            .map(|(p, _)| p)
1187    }
1188
1189    /// Returns the gross market exposure: sum of absolute market values across all open positions.
1190    pub fn gross_market_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1191        self.positions
1192            .values()
1193            .filter(|p| !p.is_flat())
1194            .filter_map(|p| {
1195                let price = prices.get(p.symbol.as_str()).copied()?;
1196                Some(p.market_value(price).abs())
1197            })
1198            .sum()
1199    }
1200
1201    /// Returns the largest single-position market value as a percentage of total gross exposure.
1202    ///
1203    /// Returns `None` if there are no open positions or total exposure is zero.
1204    pub fn largest_position_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1205        let total = self.gross_market_exposure(prices);
1206        if total.is_zero() { return None; }
1207        let max_mv = self.positions
1208            .values()
1209            .filter(|p| !p.is_flat())
1210            .filter_map(|p| {
1211                let price = prices.get(p.symbol.as_str()).copied()?;
1212                Some(p.market_value(price).abs())
1213            })
1214            .max_by(|a, b| a.cmp(b))?;
1215        Some(max_mv / total * Decimal::from(100u32))
1216    }
1217
1218    /// Total unrealized P&L as a percentage of total cost basis.
1219    ///
1220    /// `upnl_pct = unrealized_pnl_total / total_cost_basis × 100`
1221    ///
1222    /// Returns `None` if total cost basis is zero.
1223    pub fn unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1224        let total_upnl = self.unrealized_pnl_total(prices).ok()?;
1225        let total_cost: Decimal = self.positions
1226            .values()
1227            .filter(|p| !p.is_flat())
1228            .map(|p| p.cost_basis().abs())
1229            .sum();
1230        if total_cost.is_zero() { return None; }
1231        Some(total_upnl / total_cost * Decimal::from(100u32))
1232    }
1233
1234    /// Returns the symbols of all open positions with positive unrealized P&L at `prices`.
1235    ///
1236    /// A position is "up" if `unrealized_pnl > 0` at the given prices.
1237    pub fn symbols_up<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
1238        self.positions
1239            .values()
1240            .filter(|p| !p.is_flat())
1241            .filter(|p| {
1242                prices.get(p.symbol.as_str())
1243                    .map_or(false, |&price| p.unrealized_pnl(price) > Decimal::ZERO)
1244            })
1245            .map(|p| &p.symbol)
1246            .collect()
1247    }
1248
1249    /// Returns the symbols of all open positions with negative unrealized P&L at `prices`.
1250    ///
1251    /// A position is "down" if `unrealized_pnl < 0` at the given prices.
1252    pub fn symbols_down<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
1253        self.positions
1254            .values()
1255            .filter(|p| !p.is_flat())
1256            .filter(|p| {
1257                prices.get(p.symbol.as_str())
1258                    .map_or(false, |&price| p.unrealized_pnl(price) < Decimal::ZERO)
1259            })
1260            .map(|p| &p.symbol)
1261            .collect()
1262    }
1263
1264    /// Returns the open position with the largest positive unrealized P&L at `prices`.
1265    ///
1266    /// Alias for [`PositionLedger::largest_winner`] with a more descriptive name.
1267    /// Returns `None` if no positions have positive unrealized PnL.
1268    pub fn largest_unrealized_gain<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1269        self.largest_winner(prices)
1270    }
1271
1272    /// Average realized P&L per symbol across all positions (including flat ones).
1273    ///
1274    /// Returns `None` if there are no positions.
1275    pub fn avg_realized_pnl_per_symbol(&self) -> Option<Decimal> {
1276        if self.positions.is_empty() { return None; }
1277        let total: Decimal = self.positions.values().map(|p| p.realized_pnl).sum();
1278        #[allow(clippy::cast_possible_truncation)]
1279        Some(total / Decimal::from(self.positions.len() as u32))
1280    }
1281
1282    /// Win rate: fraction of positions with strictly positive realized P&L, as a percentage.
1283    ///
1284    /// Only positions that have been at least partially closed (non-zero realized PnL activity)
1285    /// are considered; positions with zero realized P&L are treated as losses.
1286    ///
1287    /// Returns `None` if there are no positions.
1288    pub fn win_rate(&self) -> Option<Decimal> {
1289        if self.positions.is_empty() { return None; }
1290        let total = self.positions.len();
1291        let winners = self.positions.values()
1292            .filter(|p| p.realized_pnl > Decimal::ZERO)
1293            .count();
1294        #[allow(clippy::cast_possible_truncation)]
1295        Some(Decimal::from(winners as u32) / Decimal::from(total as u32) * Decimal::from(100u32))
1296    }
1297
1298    /// Total P&L (realized + unrealized) excluding a specific symbol.
1299    ///
1300    /// Useful for single-symbol attribution analysis.
1301    /// Returns `Err` if any open position's price is missing from `prices`.
1302    pub fn net_pnl_excluding(
1303        &self,
1304        exclude: &Symbol,
1305        prices: &HashMap<String, Price>,
1306    ) -> Result<Decimal, FinError> {
1307        let total = self.net_pnl(prices)?;
1308        let excluded_rpnl = self.realized_pnl(exclude).unwrap_or(Decimal::ZERO);
1309        let excluded_upnl = if let Some(pos) = self.positions.get(exclude) {
1310            if !pos.is_flat() {
1311                let price = prices.get(exclude.as_str())
1312                    .copied()
1313                    .ok_or_else(|| FinError::InvalidSymbol(exclude.as_str().to_string()))?;
1314                pos.unrealized_pnl(price)
1315            } else {
1316                Decimal::ZERO
1317            }
1318        } else {
1319            Decimal::ZERO
1320        };
1321        Ok(total - excluded_rpnl - excluded_upnl)
1322    }
1323
1324    /// Ratio of total long market exposure to total absolute short market exposure.
1325    ///
1326    /// `long_short_ratio = long_exposure / |short_exposure|`
1327    ///
1328    /// Returns `None` if there is no short exposure or `short_exposure` is zero.
1329    pub fn long_short_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1330        let long_exp = self.long_exposure(prices);
1331        let short_exp = self.short_exposure(prices).abs();
1332        if short_exp.is_zero() { return None; }
1333        long_exp.checked_div(short_exp)
1334    }
1335
1336    /// Returns `(long_count, short_count)` — the number of open long and short positions.
1337    pub fn position_count_by_direction(&self) -> (usize, usize) {
1338        let longs = self.positions.values()
1339            .filter(|p| !p.is_flat() && p.quantity > Decimal::ZERO)
1340            .count();
1341        let shorts = self.positions.values()
1342            .filter(|p| !p.is_flat() && p.quantity < Decimal::ZERO)
1343            .count();
1344        (longs, shorts)
1345    }
1346
1347    /// Returns the age in bars of the oldest open position.
1348    ///
1349    /// Returns `None` if there are no open positions or no position has an open bar set.
1350    pub fn max_position_age_bars(&self, current_bar: usize) -> Option<usize> {
1351        self.positions.values()
1352            .filter(|p| !p.is_flat())
1353            .map(|p| p.position_age_bars(current_bar))
1354            .max()
1355    }
1356
1357    /// Returns the mean age in bars of all open positions.
1358    ///
1359    /// Returns `None` if there are no open positions.
1360    pub fn avg_position_age_bars(&self, current_bar: usize) -> Option<Decimal> {
1361        let ages: Vec<usize> = self.positions.values()
1362            .filter(|p| !p.is_flat())
1363            .map(|p| p.position_age_bars(current_bar))
1364            .collect();
1365        if ages.is_empty() { return None; }
1366        let sum: usize = ages.iter().sum();
1367        Some(Decimal::from(sum as u64) / Decimal::from(ages.len() as u64))
1368    }
1369
1370    /// Herfindahl-Hirschman Index (HHI) of portfolio concentration by market value.
1371    ///
1372    /// HHI = Σ(weight_i²) where weight_i = |market_value_i| / total_gross_exposure.
1373    /// Range [0, 1]: 0 = perfectly diversified, 1 = entirely in one position.
1374    ///
1375    /// Returns `None` if there are no open positions or total gross exposure is zero.
1376    pub fn hhi_concentration(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1377        let open_positions: Vec<_> = self.positions.values()
1378            .filter(|p| !p.is_flat())
1379            .collect();
1380        if open_positions.is_empty() { return None; }
1381        let mvs: Vec<Decimal> = open_positions.iter()
1382            .filter_map(|p| {
1383                prices.get(p.symbol.as_str())
1384                    .map(|&price| p.market_value(price).abs())
1385            })
1386            .collect();
1387        let total: Decimal = mvs.iter().sum();
1388        if total.is_zero() { return None; }
1389        Some(mvs.iter().map(|mv| {
1390            let w = mv / total;
1391            w * w
1392        }).sum())
1393    }
1394
1395    /// Ratio of total long unrealized P&L to absolute total short unrealized P&L.
1396    ///
1397    /// Values > 1 mean longs are outperforming; values < 1 mean shorts are leading.
1398    /// Returns `None` if short PnL is zero or no short prices are available.
1399    pub fn long_short_pnl_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1400        let long_pnl: Decimal = self.positions.values()
1401            .filter(|p| p.is_long())
1402            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1403            .sum();
1404        let short_pnl: Decimal = self.positions.values()
1405            .filter(|p| p.is_short())
1406            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1407            .sum();
1408        let short_abs = short_pnl.abs();
1409        if short_abs.is_zero() { return None; }
1410        Some(long_pnl / short_abs)
1411    }
1412
1413    /// Unrealized P&L for each open position, keyed by symbol string.
1414    ///
1415    /// Positions absent from `prices` are omitted from the result.
1416    pub fn unrealized_pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> HashMap<String, Decimal> {
1417        self.positions
1418            .iter()
1419            .filter(|(_, p)| !p.is_flat())
1420            .filter_map(|(sym, p)| {
1421                prices.get(sym.as_str())
1422                    .map(|&price| (sym.as_str().to_owned(), p.unrealized_pnl(price)))
1423            })
1424            .collect()
1425    }
1426
1427    /// Portfolio-level beta: sum of (weight * beta) for each open position.
1428    ///
1429    /// `betas` maps symbol string to the symbol's beta coefficient.
1430    /// Positions with unknown beta or missing from `prices` are skipped.
1431    /// Returns `None` if total market value is zero or no betas are available.
1432    pub fn portfolio_beta(
1433        &self,
1434        prices: &HashMap<String, Price>,
1435        betas: &HashMap<String, f64>,
1436    ) -> Option<f64> {
1437        use rust_decimal::prelude::ToPrimitive;
1438        let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1439        if open.is_empty() { return None; }
1440        let total_mv: Decimal = open.iter()
1441            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1442            .sum();
1443        if total_mv.is_zero() { return None; }
1444        let total_mv_f64 = total_mv.to_f64()?;
1445        let beta_sum: f64 = open.iter().filter_map(|p| {
1446            let mv = prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs())?;
1447            let b = betas.get(p.symbol.as_str())?;
1448            let w = mv.to_f64()? / total_mv_f64;
1449            Some(w * b)
1450        }).sum();
1451        Some(beta_sum)
1452    }
1453
1454    /// Returns the total notional value: sum of `|quantity| × price` for all open positions.
1455    ///
1456    /// Positions absent from `prices` are skipped. Returns `None` if no open positions
1457    /// have a matching price.
1458    pub fn total_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1459        let total: Decimal = self.positions.values()
1460            .filter(|p| !p.is_flat())
1461            .filter_map(|p| {
1462                prices.get(p.symbol.as_str())
1463                    .map(|&price| p.quantity_abs() * price.value())
1464            })
1465            .sum();
1466        if total.is_zero() { None } else { Some(total) }
1467    }
1468
1469    /// Returns the largest unrealized gain (most positive unrealized P&L) among open positions.
1470    ///
1471    /// Returns `None` if no open positions have a matching price, or all unrealized P&Ls are
1472    /// non-positive.
1473    pub fn max_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1474        self.positions.values()
1475            .filter(|p| !p.is_flat())
1476            .filter_map(|p| {
1477                prices.get(p.symbol.as_str())
1478                    .map(|&price| p.unrealized_pnl(price))
1479            })
1480            .filter(|&pnl| pnl > Decimal::ZERO)
1481            .max()
1482    }
1483
1484    /// Returns the 1-based rank (1 = best) of `symbol`'s realized P&L among all symbols
1485    /// that have non-zero realized P&L.
1486    ///
1487    /// Returns `None` if `symbol` has no realized P&L or if it is not found.
1488    pub fn realized_pnl_rank(&self, symbol: &Symbol) -> Option<usize> {
1489        let target = self.positions.get(symbol).map(|p| p.realized_pnl)?;
1490        if target == Decimal::ZERO { return None; }
1491        let mut sorted: Vec<Decimal> = self.positions.values()
1492            .map(|p| p.realized_pnl)
1493            .filter(|&r| r != Decimal::ZERO)
1494            .collect();
1495        sorted.sort_by(|a, b| b.cmp(a));
1496        sorted.iter().position(|&r| r == target).map(|i| i + 1)
1497    }
1498
1499    /// Returns a `Vec` of references to all open (non-flat) positions, sorted by symbol.
1500    pub fn open_positions_vec(&self) -> Vec<&Position> {
1501        let mut open: Vec<&Position> = self.positions.values()
1502            .filter(|p| !p.is_flat())
1503            .collect();
1504        open.sort_by(|a, b| a.symbol.as_str().cmp(b.symbol.as_str()));
1505        open
1506    }
1507
1508    /// Returns all symbols whose realized P&L strictly exceeds `threshold`.
1509    ///
1510    /// Results are sorted by realized P&L descending.
1511    pub fn symbols_with_pnl_above(&self, threshold: Decimal) -> Vec<Symbol> {
1512        let mut pairs: Vec<(Symbol, Decimal)> = self.positions.iter()
1513            .filter_map(|(sym, pos)| {
1514                if pos.realized_pnl > threshold { Some((sym.clone(), pos.realized_pnl)) } else { None }
1515            })
1516            .collect();
1517        pairs.sort_by(|a, b| b.1.cmp(&a.1));
1518        pairs.into_iter().map(|(s, _)| s).collect()
1519    }
1520
1521    /// Returns `(long_count, short_count)` of currently open (non-flat) positions.
1522    pub fn net_long_short_count(&self) -> (usize, usize) {
1523        let long = self.positions.values().filter(|p| p.is_long()).count();
1524        let short = self.positions.values().filter(|p| p.is_short()).count();
1525        (long, short)
1526    }
1527
1528    /// Returns the symbol of the open position with the largest absolute quantity.
1529    ///
1530    /// Returns `None` if there are no open positions.
1531    pub fn largest_open_position(&self) -> Option<&Symbol> {
1532        self.positions.iter()
1533            .filter(|(_, p)| !p.is_flat())
1534            .max_by(|(_, a), (_, b)| a.quantity.abs().cmp(&b.quantity.abs()))
1535            .map(|(sym, _)| sym)
1536    }
1537
1538    /// Market exposure broken down by direction: `(long_exposure, short_exposure)`.
1539    ///
1540    /// Both values are positive (abs). Positions not in `prices` contribute zero.
1541    pub fn exposure_by_direction(&self, prices: &HashMap<String, Price>) -> (Decimal, Decimal) {
1542        let long: Decimal = self.positions.values()
1543            .filter(|p| p.is_long())
1544            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr)))
1545            .sum();
1546        let short: Decimal = self.positions.values()
1547            .filter(|p| p.is_short())
1548            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1549            .sum();
1550        (long, short)
1551    }
1552
1553    /// Returns the sum of realized P&L across all positions in this ledger.
1554    pub fn total_realized_pnl(&self) -> Decimal {
1555        self.positions.values().map(|p| p.realized_pnl).sum()
1556    }
1557
1558    /// Returns the number of positions whose realized P&L is strictly below `threshold`.
1559    pub fn count_with_pnl_below(&self, threshold: Decimal) -> usize {
1560        self.positions.values().filter(|p| p.realized_pnl < threshold).count()
1561    }
1562
1563    /// Returns `true` if the sum of all position quantities is positive (net long exposure).
1564    pub fn is_net_long(&self) -> bool {
1565        let net: Decimal = self.positions.values().map(|p| p.quantity).sum();
1566        net > Decimal::ZERO
1567    }
1568
1569    /// Total unrealized P&L across all open positions that have a price available.
1570    ///
1571    /// Positions absent from `prices` contribute zero.
1572    pub fn total_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Decimal {
1573        self.positions.values()
1574            .filter(|p| !p.is_flat())
1575            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1576            .sum()
1577    }
1578
1579    /// Returns symbols that have a flat (zero-quantity) position in this ledger, sorted.
1580    pub fn symbols_flat(&self) -> Vec<&Symbol> {
1581        let mut flat: Vec<&Symbol> = self.positions.iter()
1582            .filter(|(_, p)| p.is_flat())
1583            .map(|(sym, _)| sym)
1584            .collect();
1585        flat.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1586        flat
1587    }
1588
1589    /// Returns the average unrealized P&L percentage across all open positions.
1590    ///
1591    /// Each position's unrealized PnL % is `unrealized_pnl / (avg_price * qty).abs() * 100`.
1592    /// Returns `None` if there are no open positions with valid prices.
1593    pub fn avg_unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1594        let pcts: Vec<Decimal> = self.positions.values()
1595            .filter(|p| !p.is_flat())
1596            .filter_map(|p| {
1597                prices.get(p.symbol.as_str()).and_then(|&pr| {
1598                    let cost_basis = (p.avg_cost * p.quantity).abs();
1599                    if cost_basis.is_zero() { return None; }
1600                    Some(p.unrealized_pnl(pr) / cost_basis * Decimal::ONE_HUNDRED)
1601                })
1602            })
1603            .collect();
1604        if pcts.is_empty() { return None; }
1605        Some(pcts.iter().sum::<Decimal>() / Decimal::from(pcts.len()))
1606    }
1607
1608    /// Returns the symbol with the worst (most negative) unrealized P&L.
1609    ///
1610    /// Returns `None` if there are no open positions or none have a price in `prices`.
1611    pub fn max_drawdown_symbol<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Symbol> {
1612        self.positions.iter()
1613            .filter(|(_, p)| !p.is_flat())
1614            .filter_map(|(sym, p)| {
1615                prices.get(p.symbol.as_str())
1616                    .map(|&price| (sym, p.unrealized_pnl(price)))
1617            })
1618            .min_by(|(_, a), (_, b)| a.cmp(b))
1619            .map(|(sym, _)| sym)
1620    }
1621
1622    /// Average unrealized P&L across all open positions that have a price in `prices`.
1623    ///
1624    /// Returns `None` if there are no open positions with prices available.
1625    pub fn avg_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1626        let pnls: Vec<Decimal> = self.positions.values()
1627            .filter(|p| !p.is_flat())
1628            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1629            .collect();
1630        if pnls.is_empty() { return None; }
1631        #[allow(clippy::cast_possible_truncation)]
1632        Some(pnls.iter().sum::<Decimal>() / Decimal::from(pnls.len() as u32))
1633    }
1634
1635    /// Returns a sorted `Vec` of all symbols tracked by this ledger (open or closed).
1636    pub fn position_symbols(&self) -> Vec<&Symbol> {
1637        let mut syms: Vec<&Symbol> = self.positions.keys().collect();
1638        syms.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1639        syms
1640    }
1641
1642    /// Returns the count of positions with strictly positive realized P&L.
1643    pub fn count_profitable(&self) -> usize {
1644        self.positions.values().filter(|p| p.realized_pnl > Decimal::ZERO).count()
1645    }
1646
1647    /// Returns the count of positions with strictly negative realized P&L.
1648    pub fn count_losing(&self) -> usize {
1649        self.positions.values().filter(|p| p.realized_pnl < Decimal::ZERO).count()
1650    }
1651
1652    /// Returns the top `n` open positions by absolute notional exposure (`|qty * price|`),
1653    /// sorted descending. Positions without a price in `prices` are excluded.
1654    pub fn top_n_by_exposure<'a>(
1655        &'a self,
1656        prices: &HashMap<String, Price>,
1657        n: usize,
1658    ) -> Vec<(&'a Symbol, Decimal)> {
1659        let mut exposures: Vec<(&Symbol, Decimal)> = self.positions.iter()
1660            .filter(|(_, p)| !p.is_flat())
1661            .filter_map(|(sym, p)| {
1662                prices.get(p.symbol.as_str())
1663                    .map(|&pr| (sym, (p.quantity * pr.value()).abs()))
1664            })
1665            .collect();
1666        exposures.sort_by(|a, b| b.1.cmp(&a.1));
1667        exposures.truncate(n);
1668        exposures
1669    }
1670
1671    /// Returns `true` if there is at least one non-flat position.
1672    pub fn has_open_positions(&self) -> bool {
1673        self.positions.values().any(|p| !p.is_flat())
1674    }
1675
1676    /// Symbols with a strictly positive (long) quantity.
1677    pub fn long_symbols(&self) -> Vec<&Symbol> {
1678        self.positions.iter()
1679            .filter(|(_, p)| p.quantity > Decimal::ZERO)
1680            .map(|(sym, _)| sym)
1681            .collect()
1682    }
1683
1684    /// Symbols with a strictly negative (short) quantity.
1685    pub fn short_symbols(&self) -> Vec<&Symbol> {
1686        self.positions.iter()
1687            .filter(|(_, p)| p.quantity < Decimal::ZERO)
1688            .map(|(sym, _)| sym)
1689            .collect()
1690    }
1691
1692    /// Herfindahl-Hirschman Index of notional exposure: `Σ w_i²` where `w_i = |notional_i| / Σ|notional|`.
1693    ///
1694    /// Returns `1.0` (full concentration) for a single position.
1695    /// Returns `None` if there are no open positions with available prices.
1696    pub fn concentration_ratio(&self, prices: &HashMap<String, Price>) -> Option<f64> {
1697        use rust_decimal::prelude::ToPrimitive;
1698        let notionals: Vec<Decimal> = self.positions.values()
1699            .filter(|p| !p.is_flat())
1700            .filter_map(|p| {
1701                prices.get(p.symbol.as_str())
1702                    .map(|&pr| (p.quantity * pr.value()).abs())
1703            })
1704            .collect();
1705        if notionals.is_empty() { return None; }
1706        let total: Decimal = notionals.iter().sum();
1707        if total.is_zero() { return None; }
1708        let hhi: f64 = notionals.iter()
1709            .filter_map(|n| (n / total).to_f64())
1710            .map(|w| w * w)
1711            .sum();
1712        Some(hhi)
1713    }
1714
1715    /// Minimum unrealized P&L across all open positions.
1716    ///
1717    /// Returns `None` if there are no open positions with a known price.
1718    pub fn min_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1719        self.positions.values()
1720            .filter(|p| !p.is_flat())
1721            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1722            .min_by(|a, b| a.cmp(b))
1723    }
1724
1725    /// Percentage of non-flat positions that are long (quantity > 0).
1726    ///
1727    /// Returns `None` if there are no open positions.
1728    pub fn pct_long(&self) -> Option<Decimal> {
1729        let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1730        if open.is_empty() { return None; }
1731        let longs = open.iter().filter(|p| p.quantity > Decimal::ZERO).count() as u32;
1732        Some(Decimal::from(longs) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1733    }
1734
1735    /// Percentage of non-flat positions that are short (quantity < 0).
1736    ///
1737    /// Returns `None` if there are no open positions.
1738    pub fn pct_short(&self) -> Option<Decimal> {
1739        let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1740        if open.is_empty() { return None; }
1741        let shorts = open.iter().filter(|p| p.quantity < Decimal::ZERO).count() as u32;
1742        Some(Decimal::from(shorts) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1743    }
1744
1745    /// Sum of absolute values of all realized P&L across positions.
1746    pub fn realized_pnl_total_abs(&self) -> Decimal {
1747        self.positions.values().map(|p| p.realized_pnl.abs()).sum()
1748    }
1749
1750    /// Average entry price for a symbol's current position.
1751    ///
1752    /// Returns `None` if the symbol is not tracked or the position is flat.
1753    pub fn average_entry_price(&self, symbol: &Symbol) -> Option<Price> {
1754        self.positions.get(symbol)?.avg_entry_price()
1755    }
1756
1757    /// Net sum of all position quantities across all symbols.
1758    pub fn net_quantity(&self) -> Decimal {
1759        self.positions.values().map(|p| p.quantity).sum()
1760    }
1761
1762    /// Maximum notional exposure (`|qty * price|`) of any single long position.
1763    ///
1764    /// Returns `None` if no long positions have a price in `prices`.
1765    pub fn max_long_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1766        self.positions.values()
1767            .filter(|p| p.quantity > Decimal::ZERO)
1768            .filter_map(|p| {
1769                prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1770            })
1771            .max_by(|a, b| a.cmp(b))
1772    }
1773
1774    /// Maximum notional exposure (`|qty * price|`) of any single short position.
1775    ///
1776    /// Returns `None` if no short positions have a price in `prices`.
1777    pub fn max_short_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1778        self.positions.values()
1779            .filter(|p| p.quantity < Decimal::ZERO)
1780            .filter_map(|p| {
1781                prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1782            })
1783            .max_by(|a, b| a.cmp(b))
1784    }
1785
1786    /// Symbol with the highest realized P&L.
1787    ///
1788    /// Returns `None` if no positions have been tracked.
1789    pub fn max_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1790        self.positions.iter()
1791            .map(|(sym, p)| (sym, p.realized_pnl))
1792            .max_by(|(_, a), (_, b)| a.cmp(b))
1793    }
1794
1795    /// Symbol with the lowest (most negative) realized P&L.
1796    ///
1797    /// Returns `None` if no positions have been tracked.
1798    pub fn min_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1799        self.positions.iter()
1800            .map(|(sym, p)| (sym, p.realized_pnl))
1801            .min_by(|(_, a), (_, b)| a.cmp(b))
1802    }
1803
1804    /// Average holding duration in bars for all open positions.
1805    ///
1806    /// Uses `current_bar - p.open_bar` for each open position.
1807    /// Returns `None` if there are no open positions.
1808    pub fn avg_holding_bars(&self, current_bar: usize) -> Option<f64> {
1809        let open: Vec<usize> = self.positions.values()
1810            .filter(|p| !p.is_flat())
1811            .map(|p| current_bar.saturating_sub(p.open_bar))
1812            .collect();
1813        if open.is_empty() { return None; }
1814        Some(open.iter().sum::<usize>() as f64 / open.len() as f64)
1815    }
1816
1817    /// Symbols of open positions that currently have a negative unrealized P&L.
1818    pub fn symbols_with_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Vec<&Symbol> {
1819        self.positions.iter()
1820            .filter(|(_, p)| !p.is_flat())
1821            .filter_map(|(sym, p)| {
1822                prices.get(p.symbol.as_str())
1823                    .map(|&pr| (sym, p.unrealized_pnl(pr)))
1824            })
1825            .filter(|(_, pnl)| *pnl < Decimal::ZERO)
1826            .map(|(sym, _)| sym)
1827            .collect()
1828    }
1829
1830    /// Volume-weighted average entry price across all open long positions. Returns `None` if
1831    /// there are no long positions.
1832    pub fn avg_long_entry_price(&self) -> Option<Decimal> {
1833        let longs: Vec<&Position> = self.positions.values()
1834            .filter(|p| p.is_long())
1835            .collect();
1836        if longs.is_empty() { return None; }
1837        let total_qty: Decimal = longs.iter().map(|p| p.quantity.abs()).sum();
1838        if total_qty.is_zero() { return None; }
1839        let weighted: Decimal = longs.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1840        Some(weighted / total_qty)
1841    }
1842
1843    /// Volume-weighted average entry price across all open short positions. Returns `None` if
1844    /// there are no short positions.
1845    pub fn avg_short_entry_price(&self) -> Option<Decimal> {
1846        let shorts: Vec<&Position> = self.positions.values()
1847            .filter(|p| p.is_short())
1848            .collect();
1849        if shorts.is_empty() { return None; }
1850        let total_qty: Decimal = shorts.iter().map(|p| p.quantity.abs()).sum();
1851        if total_qty.is_zero() { return None; }
1852        let weighted: Decimal = shorts.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1853        Some(weighted / total_qty)
1854    }
1855}
1856
1857#[cfg(test)]
1858mod tests {
1859    use super::*;
1860    use rust_decimal_macros::dec;
1861
1862    fn sym(s: &str) -> Symbol {
1863        Symbol::new(s).unwrap()
1864    }
1865
1866    fn make_fill(symbol: &str, side: Side, qty: &str, p: &str, commission: &str) -> Fill {
1867        Fill {
1868            symbol: sym(symbol),
1869            side,
1870            quantity: Quantity::new(qty.parse().unwrap()).unwrap(),
1871            price: Price::new(p.parse().unwrap()).unwrap(),
1872            timestamp: NanoTimestamp::new(0),
1873            commission: commission.parse().unwrap(),
1874        }
1875    }
1876
1877    #[test]
1878    fn test_position_apply_fill_long() {
1879        let mut pos = Position::new(sym("AAPL"));
1880        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1881            .unwrap();
1882        assert_eq!(pos.quantity, dec!(10));
1883        assert_eq!(pos.avg_cost, dec!(100));
1884    }
1885
1886    #[test]
1887    fn test_position_apply_fill_reduces_position() {
1888        let mut pos = Position::new(sym("AAPL"));
1889        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1890            .unwrap();
1891        pos.apply_fill(&make_fill("AAPL", Side::Ask, "5", "110", "0"))
1892            .unwrap();
1893        assert_eq!(pos.quantity, dec!(5));
1894    }
1895
1896    #[test]
1897    fn test_position_realized_pnl_on_close() {
1898        let mut pos = Position::new(sym("AAPL"));
1899        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1900            .unwrap();
1901        let pnl = pos
1902            .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1903            .unwrap();
1904        assert_eq!(pnl, dec!(100));
1905        assert!(pos.is_flat());
1906    }
1907
1908    #[test]
1909    fn test_position_commission_reduces_realized_pnl() {
1910        let mut pos = Position::new(sym("AAPL"));
1911        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1912            .unwrap();
1913        let pnl = pos
1914            .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "5"))
1915            .unwrap();
1916        assert_eq!(pnl, dec!(95));
1917    }
1918
1919    #[test]
1920    fn test_position_unrealized_pnl() {
1921        let mut pos = Position::new(sym("AAPL"));
1922        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1923            .unwrap();
1924        let upnl = pos.unrealized_pnl(Price::new(dec!(115)).unwrap());
1925        assert_eq!(upnl, dec!(150));
1926    }
1927
1928    #[test]
1929    fn test_position_market_value() {
1930        let mut pos = Position::new(sym("AAPL"));
1931        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1932            .unwrap();
1933        assert_eq!(pos.market_value(Price::new(dec!(120)).unwrap()), dec!(1200));
1934    }
1935
1936    #[test]
1937    fn test_position_is_flat_initially() {
1938        let pos = Position::new(sym("X"));
1939        assert!(pos.is_flat());
1940    }
1941
1942    #[test]
1943    fn test_position_is_flat_after_full_close() {
1944        let mut pos = Position::new(sym("AAPL"));
1945        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1946            .unwrap();
1947        pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1948            .unwrap();
1949        assert!(pos.is_flat());
1950    }
1951
1952    #[test]
1953    fn test_position_avg_cost_weighted_after_two_buys() {
1954        let mut pos = Position::new(sym("X"));
1955        pos.apply_fill(&make_fill("X", Side::Bid, "10", "100", "0"))
1956            .unwrap();
1957        pos.apply_fill(&make_fill("X", Side::Bid, "10", "120", "0"))
1958            .unwrap();
1959        assert_eq!(pos.avg_cost, dec!(110));
1960    }
1961
1962    #[test]
1963    fn test_position_ledger_apply_fill_updates_cash() {
1964        let mut ledger = PositionLedger::new(dec!(10000));
1965        ledger
1966            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "1"))
1967            .unwrap();
1968        assert_eq!(ledger.cash(), dec!(8999));
1969    }
1970
1971    #[test]
1972    fn test_position_ledger_insufficient_funds() {
1973        let mut ledger = PositionLedger::new(dec!(100));
1974        let result = ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
1975        assert!(matches!(result, Err(FinError::InsufficientFunds { .. })));
1976    }
1977
1978    #[test]
1979    fn test_position_ledger_equity_calculation() {
1980        let mut ledger = PositionLedger::new(dec!(10000));
1981        ledger
1982            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
1983            .unwrap();
1984        let mut prices = HashMap::new();
1985        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
1986        // equity = cash + unrealized = 9000 + (110-100)*10 = 9100
1987        let equity = ledger.equity(&prices).unwrap();
1988        assert_eq!(equity, dec!(9100));
1989    }
1990
1991    #[test]
1992    fn test_position_ledger_net_liquidation_value() {
1993        // buy 10 AAPL @ 100 → cash = 10000 - 1000 = 9000
1994        let mut ledger = PositionLedger::new(dec!(10000));
1995        ledger
1996            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
1997            .unwrap();
1998        let mut prices = HashMap::new();
1999        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2000        // NLV = cash(9000) + 10×110 = 9000 + 1100 = 10100
2001        let nlv = ledger.net_liquidation_value(&prices).unwrap();
2002        assert_eq!(nlv, dec!(10100));
2003    }
2004
2005    #[test]
2006    fn test_position_ledger_net_liquidation_missing_price() {
2007        let mut ledger = PositionLedger::new(dec!(10000));
2008        ledger
2009            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2010            .unwrap();
2011        let prices: HashMap<String, Price> = HashMap::new();
2012        assert!(ledger.net_liquidation_value(&prices).is_err());
2013    }
2014
2015    #[test]
2016    fn test_position_ledger_pnl_by_symbol() {
2017        let mut ledger = PositionLedger::new(dec!(10000));
2018        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2019        ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2020        let mut prices = HashMap::new();
2021        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2022        prices.insert("GOOG".to_owned(), Price::new(dec!(190)).unwrap());
2023        let pnl = ledger.pnl_by_symbol(&prices).unwrap();
2024        assert_eq!(*pnl.get(&sym("AAPL")).unwrap(), dec!(100));  // (110-100)*10
2025        assert_eq!(*pnl.get(&sym("GOOG")).unwrap(), dec!(-50));  // (190-200)*5
2026    }
2027
2028    #[test]
2029    fn test_position_ledger_pnl_by_symbol_missing_price() {
2030        let mut ledger = PositionLedger::new(dec!(10000));
2031        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2032        let prices: HashMap<String, Price> = HashMap::new();
2033        assert!(ledger.pnl_by_symbol(&prices).is_err());
2034    }
2035
2036    #[test]
2037    fn test_position_ledger_delta_neutral_no_positions() {
2038        let ledger = PositionLedger::new(dec!(10000));
2039        let prices: HashMap<String, Price> = HashMap::new();
2040        assert!(ledger.delta_neutral_check(&prices).unwrap());
2041    }
2042
2043    #[test]
2044    fn test_position_ledger_delta_neutral_long_short_balanced() {
2045        let mut ledger = PositionLedger::new(dec!(10000));
2046        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2047        ledger.apply_fill(make_fill("GOOG", Side::Ask, "10", "100", "0")).unwrap();
2048        let mut prices = HashMap::new();
2049        prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2050        prices.insert("GOOG".to_owned(), Price::new(dec!(100)).unwrap());
2051        // net=0, gross=2000 → ratio=0 → neutral
2052        assert!(ledger.delta_neutral_check(&prices).unwrap());
2053    }
2054
2055    #[test]
2056    fn test_position_ledger_delta_neutral_one_sided_not_neutral() {
2057        let mut ledger = PositionLedger::new(dec!(10000));
2058        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2059        let mut prices = HashMap::new();
2060        prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2061        // net=1000, gross=1000 → ratio=1 → not neutral
2062        assert!(!ledger.delta_neutral_check(&prices).unwrap());
2063    }
2064
2065    #[test]
2066    fn test_position_ledger_open_count_zero_when_empty() {
2067        assert_eq!(PositionLedger::new(dec!(10000)).open_count(), 0);
2068    }
2069
2070    #[test]
2071    fn test_position_ledger_open_count_tracks_positions() {
2072        let mut ledger = PositionLedger::new(dec!(10000));
2073        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2074        assert_eq!(ledger.open_count(), 1);
2075        ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2076        assert_eq!(ledger.open_count(), 2);
2077        // close AAPL fully
2078        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "105", "0")).unwrap();
2079        assert_eq!(ledger.open_count(), 1);
2080    }
2081
2082    #[test]
2083    fn test_position_ledger_sell_increases_cash() {
2084        let mut ledger = PositionLedger::new(dec!(10000));
2085        ledger
2086            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2087            .unwrap();
2088        ledger
2089            .apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"))
2090            .unwrap();
2091        assert_eq!(ledger.cash(), dec!(10100));
2092    }
2093
2094    #[test]
2095    fn test_position_checked_unrealized_pnl_matches() {
2096        let mut pos = Position::new(sym("AAPL"));
2097        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2098            .unwrap();
2099        let price = Price::new(dec!(115)).unwrap();
2100        let checked = pos.checked_unrealized_pnl(price).unwrap();
2101        let unchecked = pos.unrealized_pnl(price);
2102        assert_eq!(checked, unchecked);
2103        assert_eq!(checked, dec!(150));
2104    }
2105
2106    #[test]
2107    fn test_position_checked_unrealized_pnl_flat_position() {
2108        let pos = Position::new(sym("X"));
2109        let price = Price::new(dec!(100)).unwrap();
2110        assert_eq!(pos.checked_unrealized_pnl(price).unwrap(), dec!(0));
2111    }
2112
2113    #[test]
2114    fn test_position_direction_flat() {
2115        let pos = Position::new(sym("X"));
2116        assert_eq!(pos.direction(), PositionDirection::Flat);
2117    }
2118
2119    #[test]
2120    fn test_position_direction_long() {
2121        let mut pos = Position::new(sym("X"));
2122        pos.apply_fill(&make_fill("X", Side::Bid, "5", "100", "0"))
2123            .unwrap();
2124        assert_eq!(pos.direction(), PositionDirection::Long);
2125    }
2126
2127    #[test]
2128    fn test_position_direction_short() {
2129        let mut pos = Position::new(sym("X"));
2130        // Short: sell without prior long (negative quantity via negative fill)
2131        pos.apply_fill(&make_fill("X", Side::Ask, "5", "100", "0"))
2132            .unwrap();
2133        assert_eq!(pos.direction(), PositionDirection::Short);
2134    }
2135
2136    #[test]
2137    fn test_position_ledger_positions_iterator() {
2138        let mut ledger = PositionLedger::new(dec!(10000));
2139        ledger
2140            .apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0"))
2141            .unwrap();
2142        ledger
2143            .apply_fill(make_fill("MSFT", Side::Bid, "1", "200", "0"))
2144            .unwrap();
2145        let count = ledger.positions().count();
2146        assert_eq!(count, 2);
2147    }
2148
2149    #[test]
2150    fn test_position_ledger_total_market_value() {
2151        let mut ledger = PositionLedger::new(dec!(10000));
2152        ledger
2153            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2154            .unwrap();
2155        ledger
2156            .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2157            .unwrap();
2158        let mut prices = HashMap::new();
2159        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2160        prices.insert("MSFT".to_owned(), Price::new(dec!(210)).unwrap());
2161        // 10*110 + 5*210 = 1100 + 1050 = 2150
2162        let mv = ledger.total_market_value(&prices).unwrap();
2163        assert_eq!(mv, dec!(2150));
2164    }
2165
2166    #[test]
2167    fn test_position_ledger_total_market_value_missing_price() {
2168        let mut ledger = PositionLedger::new(dec!(10000));
2169        ledger
2170            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2171            .unwrap();
2172        let prices: HashMap<String, Price> = HashMap::new();
2173        assert!(matches!(
2174            ledger.total_market_value(&prices),
2175            Err(FinError::PositionNotFound(_))
2176        ));
2177    }
2178
2179    #[test]
2180    fn test_position_ledger_unrealized_pnl_total() {
2181        let mut ledger = PositionLedger::new(dec!(10000));
2182        ledger
2183            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2184            .unwrap();
2185        let mut prices = HashMap::new();
2186        prices.insert("AAPL".to_owned(), Price::new(dec!(105)).unwrap());
2187        let upnl = ledger.unrealized_pnl_total(&prices).unwrap();
2188        assert_eq!(upnl, dec!(50));
2189    }
2190
2191    #[test]
2192    fn test_position_ledger_position_count_includes_flat() {
2193        let mut ledger = PositionLedger::new(dec!(10000));
2194        // open AAPL long then close it
2195        ledger
2196            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2197            .unwrap();
2198        ledger
2199            .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2200            .unwrap();
2201        // open MSFT long (stays open)
2202        ledger
2203            .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2204            .unwrap();
2205        assert_eq!(ledger.position_count(), 2, "both symbols tracked");
2206        assert_eq!(ledger.open_position_count(), 1, "only MSFT open");
2207    }
2208
2209    #[test]
2210    fn test_position_ledger_position_count_zero_on_empty() {
2211        let ledger = PositionLedger::new(dec!(10000));
2212        assert_eq!(ledger.position_count(), 0);
2213    }
2214
2215    #[test]
2216    fn test_position_unrealized_pnl_pct_long_gain() {
2217        let mut pos = Position::new(sym("AAPL"));
2218        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2219            .unwrap();
2220        let current = Price::new(dec!(110)).unwrap();
2221        let pct = pos.unrealized_pnl_pct(current).unwrap();
2222        assert_eq!(pct, dec!(10));
2223    }
2224
2225    #[test]
2226    fn test_position_unrealized_pnl_pct_flat_returns_none() {
2227        let pos = Position::new(sym("AAPL"));
2228        let current = Price::new(dec!(110)).unwrap();
2229        assert!(pos.unrealized_pnl_pct(current).is_none());
2230    }
2231
2232    #[test]
2233    fn test_position_unrealized_pnl_pct_loss() {
2234        let mut pos = Position::new(sym("AAPL"));
2235        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2236            .unwrap();
2237        let current = Price::new(dec!(90)).unwrap();
2238        let pct = pos.unrealized_pnl_pct(current).unwrap();
2239        assert_eq!(pct, dec!(-10));
2240    }
2241
2242    #[test]
2243    fn test_position_ledger_open_positions_excludes_flat() {
2244        let mut ledger = PositionLedger::new(dec!(10000));
2245        ledger
2246            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2247            .unwrap();
2248        ledger
2249            .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2250            .unwrap();
2251        ledger
2252            .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2253            .unwrap();
2254        let open: Vec<_> = ledger.open_positions().collect();
2255        assert_eq!(open.len(), 1);
2256        assert_eq!(open[0].symbol.as_str(), "MSFT");
2257    }
2258
2259    #[test]
2260    fn test_position_ledger_open_positions_empty_when_all_flat() {
2261        let mut ledger = PositionLedger::new(dec!(10000));
2262        ledger
2263            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2264            .unwrap();
2265        ledger
2266            .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2267            .unwrap();
2268        let open: Vec<_> = ledger.open_positions().collect();
2269        assert!(open.is_empty());
2270    }
2271
2272    #[test]
2273    fn test_position_is_long() {
2274        let mut pos = Position::new(sym("AAPL"));
2275        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2276            .unwrap();
2277        assert!(pos.is_long());
2278        assert!(!pos.is_short());
2279        assert!(!pos.is_flat());
2280    }
2281
2282    #[test]
2283    fn test_position_is_short() {
2284        let mut pos = Position::new(sym("AAPL"));
2285        pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2286            .unwrap();
2287        assert!(pos.is_short());
2288        assert!(!pos.is_long());
2289        assert!(!pos.is_flat());
2290    }
2291
2292    #[test]
2293    fn test_position_is_flat_after_close() {
2294        let mut pos = Position::new(sym("AAPL"));
2295        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2296            .unwrap();
2297        pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2298            .unwrap();
2299        assert!(pos.is_flat());
2300        assert!(!pos.is_long());
2301        assert!(!pos.is_short());
2302    }
2303
2304    #[test]
2305    fn test_position_ledger_flat_positions() {
2306        let mut ledger = PositionLedger::new(dec!(10000));
2307        // open AAPL, then close it
2308        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2309        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2310        // leave MSFT open
2311        ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0")).unwrap();
2312        let flat: Vec<_> = ledger.flat_positions().collect();
2313        assert_eq!(flat.len(), 1);
2314        assert_eq!(flat[0].symbol, sym("AAPL"));
2315    }
2316
2317    #[test]
2318    fn test_position_ledger_flat_positions_empty_when_all_open() {
2319        let mut ledger = PositionLedger::new(dec!(10000));
2320        ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2321        assert_eq!(ledger.flat_positions().count(), 0);
2322    }
2323
2324    #[test]
2325    fn test_position_ledger_deposit_increases_cash() {
2326        let mut ledger = PositionLedger::new(dec!(1000));
2327        ledger.deposit(dec!(500));
2328        assert_eq!(ledger.cash(), dec!(1500));
2329    }
2330
2331    #[test]
2332    fn test_position_ledger_withdraw_decreases_cash() {
2333        let mut ledger = PositionLedger::new(dec!(1000));
2334        ledger.withdraw(dec!(300)).unwrap();
2335        assert_eq!(ledger.cash(), dec!(700));
2336    }
2337
2338    #[test]
2339    fn test_position_ledger_withdraw_insufficient_fails() {
2340        let mut ledger = PositionLedger::new(dec!(100));
2341        assert!(matches!(
2342            ledger.withdraw(dec!(200)),
2343            Err(FinError::InsufficientFunds { .. })
2344        ));
2345        assert_eq!(ledger.cash(), dec!(100), "cash unchanged on failure");
2346    }
2347
2348    #[test]
2349    fn test_position_is_profitable_true() {
2350        let mut pos = Position::new(sym("AAPL"));
2351        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2352            .unwrap();
2353        let current = Price::new(dec!(110)).unwrap();
2354        assert!(pos.is_profitable(current));
2355    }
2356
2357    #[test]
2358    fn test_position_is_profitable_false_when_at_loss() {
2359        let mut pos = Position::new(sym("AAPL"));
2360        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2361            .unwrap();
2362        let current = Price::new(dec!(90)).unwrap();
2363        assert!(!pos.is_profitable(current));
2364    }
2365
2366    #[test]
2367    fn test_position_ledger_long_positions() {
2368        let mut ledger = PositionLedger::new(dec!(10000));
2369        ledger
2370            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2371            .unwrap();
2372        let longs: Vec<_> = ledger.long_positions().collect();
2373        assert_eq!(longs.len(), 1);
2374        assert_eq!(longs[0].symbol.as_str(), "AAPL");
2375    }
2376
2377    #[test]
2378    fn test_position_ledger_short_positions_empty_for_long_only() {
2379        let mut ledger = PositionLedger::new(dec!(10000));
2380        ledger
2381            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2382            .unwrap();
2383        let shorts: Vec<_> = ledger.short_positions().collect();
2384        assert!(shorts.is_empty());
2385    }
2386
2387    #[test]
2388    fn test_position_ledger_realized_pnl_after_close() {
2389        let mut ledger = PositionLedger::new(dec!(10000));
2390        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2391        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2392        assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(100)));
2393    }
2394
2395    #[test]
2396    fn test_position_ledger_realized_pnl_unknown_symbol_returns_none() {
2397        let ledger = PositionLedger::new(dec!(10000));
2398        assert!(ledger.realized_pnl(&sym("AAPL")).is_none());
2399    }
2400
2401    #[test]
2402    fn test_position_ledger_realized_pnl_zero_before_close() {
2403        let mut ledger = PositionLedger::new(dec!(10000));
2404        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2405        assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(0)));
2406    }
2407
2408    #[test]
2409    fn test_position_ledger_symbols_sorted_order() {
2410        let mut ledger = PositionLedger::new(dec!(10000));
2411        ledger.apply_fill(make_fill("MSFT", Side::Bid, "1", "100", "0")).unwrap();
2412        ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2413        ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "100", "0")).unwrap();
2414        let sorted = ledger.symbols_sorted();
2415        let names: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
2416        assert_eq!(names, vec!["AAPL", "GOOG", "MSFT"]);
2417    }
2418
2419    #[test]
2420    fn test_position_ledger_symbols_sorted_empty() {
2421        let ledger = PositionLedger::new(dec!(10000));
2422        assert!(ledger.symbols_sorted().is_empty());
2423    }
2424
2425    #[test]
2426    fn test_position_avg_entry_price_long() {
2427        let sym = Symbol::new("AAPL").unwrap();
2428        let mut pos = Position::new(sym.clone());
2429        let fill = Fill::new(
2430            sym,
2431            Side::Bid,
2432            Quantity::new(dec!(10)).unwrap(),
2433            Price::new(dec!(150)).unwrap(),
2434            NanoTimestamp::new(0),
2435        );
2436        pos.apply_fill(&fill).unwrap();
2437        assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(150));
2438    }
2439
2440    #[test]
2441    fn test_position_avg_entry_price_flat_returns_none() {
2442        let sym = Symbol::new("AAPL").unwrap();
2443        let pos = Position::new(sym);
2444        assert!(pos.avg_entry_price().is_none());
2445    }
2446
2447    #[test]
2448    fn test_position_avg_entry_price_after_partial_close() {
2449        let sym = Symbol::new("X").unwrap();
2450        let mut pos = Position::new(sym.clone());
2451        pos.apply_fill(&Fill::new(sym.clone(), Side::Bid,
2452            Quantity::new(dec!(10)).unwrap(), Price::new(dec!(100)).unwrap(),
2453            NanoTimestamp::new(0))).unwrap();
2454        pos.apply_fill(&Fill::new(sym.clone(), Side::Ask,
2455            Quantity::new(dec!(5)).unwrap(), Price::new(dec!(100)).unwrap(),
2456            NanoTimestamp::new(1))).unwrap();
2457        // Still long 5 at avg_cost = 100
2458        assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(100));
2459    }
2460
2461    #[test]
2462    fn test_position_ledger_has_position_true_after_fill() {
2463        let mut ledger = PositionLedger::new(dec!(10000));
2464        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2465        assert!(ledger.has_position(&sym("AAPL")));
2466    }
2467
2468    #[test]
2469    fn test_position_ledger_has_position_false_for_unknown() {
2470        let ledger = PositionLedger::new(dec!(10000));
2471        assert!(!ledger.has_position(&sym("AAPL")));
2472    }
2473
2474    #[test]
2475    fn test_position_ledger_has_position_true_even_when_flat() {
2476        let mut ledger = PositionLedger::new(dec!(10000));
2477        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2478        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2479        // position is flat but still tracked
2480        assert!(ledger.has_position(&sym("AAPL")));
2481    }
2482
2483    #[test]
2484    fn test_position_ledger_open_symbols_returns_non_flat() {
2485        let mut ledger = PositionLedger::new(dec!(10000));
2486        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2487        ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "1")).unwrap();
2488        let symbols: Vec<_> = ledger.open_symbols().collect();
2489        assert_eq!(symbols.len(), 2);
2490    }
2491
2492    #[test]
2493    fn test_position_ledger_open_symbols_excludes_flat() {
2494        let mut ledger = PositionLedger::new(dec!(10000));
2495        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2496        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap(); // flat
2497        ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "2")).unwrap();
2498        let symbols: Vec<_> = ledger.open_symbols().collect();
2499        assert_eq!(symbols.len(), 1);
2500        assert_eq!(symbols[0].as_str(), "MSFT");
2501    }
2502
2503    #[test]
2504    fn test_position_ledger_open_symbols_empty_when_all_flat() {
2505        let mut ledger = PositionLedger::new(dec!(10000));
2506        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2507        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap();
2508        let symbols: Vec<_> = ledger.open_symbols().collect();
2509        assert!(symbols.is_empty());
2510    }
2511
2512    #[test]
2513    fn test_position_ledger_total_long_exposure() {
2514        let mut ledger = PositionLedger::new(dec!(100000));
2515        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2516        // 10 * avg_cost(100) = 1000
2517        assert_eq!(ledger.total_long_exposure(), dec!(1000));
2518    }
2519
2520    #[test]
2521    fn test_position_ledger_total_long_exposure_zero_when_flat() {
2522        let ledger = PositionLedger::new(dec!(10000));
2523        assert_eq!(ledger.total_long_exposure(), dec!(0));
2524    }
2525
2526    #[test]
2527    fn test_position_ledger_total_short_exposure_zero_when_no_shorts() {
2528        let mut ledger = PositionLedger::new(dec!(100000));
2529        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2530        assert_eq!(ledger.total_short_exposure(), dec!(0));
2531    }
2532
2533    #[test]
2534    fn test_allocation_pct_single_position() {
2535        let mut ledger = PositionLedger::new(dec!(100000));
2536        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2537        let mut prices = HashMap::new();
2538        let sym = Symbol::new("AAPL").unwrap();
2539        prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2540        let pct = ledger.allocation_pct(&sym, &prices).unwrap();
2541        // 10 shares * $100 / ($1000 total) = 100%
2542        assert_eq!(pct, Some(dec!(100)));
2543    }
2544
2545    #[test]
2546    fn test_allocation_pct_flat_position_returns_none() {
2547        let ledger = PositionLedger::new(dec!(100000));
2548        let mut prices = HashMap::new();
2549        let sym = Symbol::new("AAPL").unwrap();
2550        prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2551        // No fill → no position in ledger → error
2552        assert!(ledger.allocation_pct(&sym, &prices).is_err());
2553    }
2554
2555    #[test]
2556    fn test_positions_sorted_by_pnl_descending() {
2557        let mut ledger = PositionLedger::new(dec!(100000));
2558        ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2559        ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "200", "0")).unwrap();
2560        let mut prices = HashMap::new();
2561        // AAPL gained $10, GOOG gained $50
2562        prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2563        prices.insert("GOOG".to_string(), Price::new(dec!(250)).unwrap());
2564        let sorted = ledger.positions_sorted_by_pnl(&prices);
2565        // GOOG (pnl=50) should come before AAPL (pnl=10)
2566        assert_eq!(sorted[0].symbol.as_str(), "GOOG");
2567        assert_eq!(sorted[1].symbol.as_str(), "AAPL");
2568    }
2569
2570    #[test]
2571    fn test_positions_sorted_by_pnl_empty_when_all_flat() {
2572        let ledger = PositionLedger::new(dec!(100000));
2573        let prices = HashMap::new();
2574        assert!(ledger.positions_sorted_by_pnl(&prices).is_empty());
2575    }
2576
2577    #[test]
2578    fn test_all_flat_initially() {
2579        let ledger = PositionLedger::new(dec!(100000));
2580        assert!(ledger.all_flat());
2581    }
2582
2583    #[test]
2584    fn test_all_flat_false_after_open_position() {
2585        let mut ledger = PositionLedger::new(dec!(100000));
2586        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
2587        assert!(!ledger.all_flat());
2588    }
2589
2590    #[test]
2591    fn test_all_flat_true_after_close_position() {
2592        let mut ledger = PositionLedger::new(dec!(100000));
2593        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
2594        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "155", "0")).unwrap();
2595        assert!(ledger.all_flat());
2596    }
2597
2598    #[test]
2599    fn test_concentration_pct_single_position() {
2600        let mut ledger = PositionLedger::new(dec!(100000));
2601        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
2602        let sym = Symbol::new("AAPL").unwrap();
2603        let mut prices = HashMap::new();
2604        prices.insert("AAPL".to_string(), Price::new(dec!(150)).unwrap());
2605        // Only one position so concentration = 100%
2606        let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2607        assert_eq!(pct, dec!(100));
2608    }
2609
2610    #[test]
2611    fn test_concentration_pct_two_equal_positions() {
2612        let mut ledger = PositionLedger::new(dec!(100000));
2613        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2614        ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0")).unwrap();
2615        let sym = Symbol::new("AAPL").unwrap();
2616        let mut prices = HashMap::new();
2617        prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2618        prices.insert("GOOG".to_string(), Price::new(dec!(100)).unwrap());
2619        let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2620        assert_eq!(pct, dec!(50));
2621    }
2622
2623    #[test]
2624    fn test_concentration_pct_missing_price_returns_none() {
2625        let mut ledger = PositionLedger::new(dec!(100000));
2626        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2627        let sym = Symbol::new("AAPL").unwrap();
2628        let prices = HashMap::new(); // empty price map
2629        assert!(ledger.concentration_pct(&sym, &prices).is_none());
2630    }
2631
2632    #[test]
2633    fn test_avg_realized_pnl_per_symbol_none_when_empty() {
2634        let ledger = PositionLedger::new(dec!(100000));
2635        assert!(ledger.avg_realized_pnl_per_symbol().is_none());
2636    }
2637
2638    #[test]
2639    fn test_avg_realized_pnl_per_symbol_with_closed_trade() {
2640        let mut ledger = PositionLedger::new(dec!(100000));
2641        // Buy 10 @ 100, sell 10 @ 110 → realized = +100
2642        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2643        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2644        let avg = ledger.avg_realized_pnl_per_symbol().unwrap();
2645        assert_eq!(avg, dec!(100));
2646    }
2647
2648    #[test]
2649    fn test_net_exposure_no_prices_returns_none() {
2650        let mut ledger = PositionLedger::new(dec!(100000));
2651        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2652        let prices = HashMap::new();
2653        assert!(ledger.net_market_exposure(&prices).is_none());
2654    }
2655
2656    #[test]
2657    fn test_net_exposure_long_only() {
2658        let mut ledger = PositionLedger::new(dec!(100000));
2659        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2660        let mut prices = HashMap::new();
2661        prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2662        assert_eq!(ledger.net_market_exposure(&prices).unwrap(), dec!(1100));
2663    }
2664
2665    #[test]
2666    fn test_win_rate_none_when_empty() {
2667        let ledger = PositionLedger::new(dec!(100000));
2668        assert!(ledger.win_rate().is_none());
2669    }
2670
2671    #[test]
2672    fn test_win_rate_one_winner() {
2673        let mut ledger = PositionLedger::new(dec!(100000));
2674        // Buy and sell AAPL for +100 realized
2675        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2676        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2677        // GOOG still open at cost (realized=0)
2678        ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0")).unwrap();
2679        let rate = ledger.win_rate().unwrap();
2680        // 1 winner (AAPL) out of 2 positions = 50%
2681        assert_eq!(rate, dec!(50));
2682    }
2683}