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(|&price| p.unrealized_pnl(price) > Decimal::ZERO)
1244                    .unwrap_or(false)
1245            })
1246            .map(|p| &p.symbol)
1247            .collect()
1248    }
1249
1250    /// Returns the symbols of all open positions with negative unrealized P&L at `prices`.
1251    ///
1252    /// A position is "down" if `unrealized_pnl < 0` at the given prices.
1253    pub fn symbols_down<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
1254        self.positions
1255            .values()
1256            .filter(|p| !p.is_flat())
1257            .filter(|p| {
1258                prices.get(p.symbol.as_str())
1259                    .map(|&price| p.unrealized_pnl(price) < Decimal::ZERO)
1260                    .unwrap_or(false)
1261            })
1262            .map(|p| &p.symbol)
1263            .collect()
1264    }
1265
1266    /// Returns the open position with the largest positive unrealized P&L at `prices`.
1267    ///
1268    /// Alias for [`PositionLedger::largest_winner`] with a more descriptive name.
1269    /// Returns `None` if no positions have positive unrealized PnL.
1270    pub fn largest_unrealized_gain<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1271        self.largest_winner(prices)
1272    }
1273
1274    /// Average realized P&L per symbol across all positions (including flat ones).
1275    ///
1276    /// Returns `None` if there are no positions.
1277    pub fn avg_realized_pnl_per_symbol(&self) -> Option<Decimal> {
1278        if self.positions.is_empty() { return None; }
1279        let total: Decimal = self.positions.values().map(|p| p.realized_pnl).sum();
1280        #[allow(clippy::cast_possible_truncation)]
1281        Some(total / Decimal::from(self.positions.len() as u32))
1282    }
1283
1284    /// Win rate: fraction of positions with strictly positive realized P&L, as a percentage.
1285    ///
1286    /// Only positions that have been at least partially closed (non-zero realized PnL activity)
1287    /// are considered; positions with zero realized P&L are treated as losses.
1288    ///
1289    /// Returns `None` if there are no positions.
1290    pub fn win_rate(&self) -> Option<Decimal> {
1291        if self.positions.is_empty() { return None; }
1292        let total = self.positions.len();
1293        let winners = self.positions.values()
1294            .filter(|p| p.realized_pnl > Decimal::ZERO)
1295            .count();
1296        #[allow(clippy::cast_possible_truncation)]
1297        Some(Decimal::from(winners as u32) / Decimal::from(total as u32) * Decimal::from(100u32))
1298    }
1299
1300    /// Total P&L (realized + unrealized) excluding a specific symbol.
1301    ///
1302    /// Useful for single-symbol attribution analysis.
1303    /// Returns `Err` if any open position's price is missing from `prices`.
1304    pub fn net_pnl_excluding(
1305        &self,
1306        exclude: &Symbol,
1307        prices: &HashMap<String, Price>,
1308    ) -> Result<Decimal, FinError> {
1309        let total = self.net_pnl(prices)?;
1310        let excluded_rpnl = self.realized_pnl(exclude).unwrap_or(Decimal::ZERO);
1311        let excluded_upnl = if let Some(pos) = self.positions.get(exclude) {
1312            if !pos.is_flat() {
1313                let price = prices.get(exclude.as_str())
1314                    .copied()
1315                    .ok_or_else(|| FinError::InvalidSymbol(exclude.as_str().to_string()))?;
1316                pos.unrealized_pnl(price)
1317            } else {
1318                Decimal::ZERO
1319            }
1320        } else {
1321            Decimal::ZERO
1322        };
1323        Ok(total - excluded_rpnl - excluded_upnl)
1324    }
1325
1326    /// Ratio of total long market exposure to total absolute short market exposure.
1327    ///
1328    /// `long_short_ratio = long_exposure / |short_exposure|`
1329    ///
1330    /// Returns `None` if there is no short exposure or `short_exposure` is zero.
1331    pub fn long_short_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1332        let long_exp = self.long_exposure(prices);
1333        let short_exp = self.short_exposure(prices).abs();
1334        if short_exp.is_zero() { return None; }
1335        long_exp.checked_div(short_exp)
1336    }
1337
1338    /// Returns `(long_count, short_count)` — the number of open long and short positions.
1339    pub fn position_count_by_direction(&self) -> (usize, usize) {
1340        let longs = self.positions.values()
1341            .filter(|p| !p.is_flat() && p.quantity > Decimal::ZERO)
1342            .count();
1343        let shorts = self.positions.values()
1344            .filter(|p| !p.is_flat() && p.quantity < Decimal::ZERO)
1345            .count();
1346        (longs, shorts)
1347    }
1348
1349    /// Returns the age in bars of the oldest open position.
1350    ///
1351    /// Returns `None` if there are no open positions or no position has an open bar set.
1352    pub fn max_position_age_bars(&self, current_bar: usize) -> Option<usize> {
1353        self.positions.values()
1354            .filter(|p| !p.is_flat())
1355            .map(|p| p.position_age_bars(current_bar))
1356            .max()
1357    }
1358
1359    /// Returns the mean age in bars of all open positions.
1360    ///
1361    /// Returns `None` if there are no open positions.
1362    pub fn avg_position_age_bars(&self, current_bar: usize) -> Option<Decimal> {
1363        let ages: Vec<usize> = self.positions.values()
1364            .filter(|p| !p.is_flat())
1365            .map(|p| p.position_age_bars(current_bar))
1366            .collect();
1367        if ages.is_empty() { return None; }
1368        let sum: usize = ages.iter().sum();
1369        Some(Decimal::from(sum as u64) / Decimal::from(ages.len() as u64))
1370    }
1371
1372    /// Herfindahl-Hirschman Index (HHI) of portfolio concentration by market value.
1373    ///
1374    /// HHI = Σ(weight_i²) where weight_i = |market_value_i| / total_gross_exposure.
1375    /// Range [0, 1]: 0 = perfectly diversified, 1 = entirely in one position.
1376    ///
1377    /// Returns `None` if there are no open positions or total gross exposure is zero.
1378    pub fn hhi_concentration(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1379        let open_positions: Vec<_> = self.positions.values()
1380            .filter(|p| !p.is_flat())
1381            .collect();
1382        if open_positions.is_empty() { return None; }
1383        let mvs: Vec<Decimal> = open_positions.iter()
1384            .filter_map(|p| {
1385                prices.get(p.symbol.as_str())
1386                    .map(|&price| p.market_value(price).abs())
1387            })
1388            .collect();
1389        let total: Decimal = mvs.iter().sum();
1390        if total.is_zero() { return None; }
1391        Some(mvs.iter().map(|mv| {
1392            let w = mv / total;
1393            w * w
1394        }).sum())
1395    }
1396
1397    /// Ratio of total long unrealized P&L to absolute total short unrealized P&L.
1398    ///
1399    /// Values > 1 mean longs are outperforming; values < 1 mean shorts are leading.
1400    /// Returns `None` if short PnL is zero or no short prices are available.
1401    pub fn long_short_pnl_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1402        let long_pnl: Decimal = self.positions.values()
1403            .filter(|p| p.is_long())
1404            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1405            .sum();
1406        let short_pnl: Decimal = self.positions.values()
1407            .filter(|p| p.is_short())
1408            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1409            .sum();
1410        let short_abs = short_pnl.abs();
1411        if short_abs.is_zero() { return None; }
1412        Some(long_pnl / short_abs)
1413    }
1414
1415    /// Unrealized P&L for each open position, keyed by symbol string.
1416    ///
1417    /// Positions absent from `prices` are omitted from the result.
1418    pub fn unrealized_pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> HashMap<String, Decimal> {
1419        self.positions
1420            .iter()
1421            .filter(|(_, p)| !p.is_flat())
1422            .filter_map(|(sym, p)| {
1423                prices.get(sym.as_str())
1424                    .map(|&price| (sym.as_str().to_owned(), p.unrealized_pnl(price)))
1425            })
1426            .collect()
1427    }
1428
1429    /// Portfolio-level beta: sum of (weight * beta) for each open position.
1430    ///
1431    /// `betas` maps symbol string to the symbol's beta coefficient.
1432    /// Positions with unknown beta or missing from `prices` are skipped.
1433    /// Returns `None` if total market value is zero or no betas are available.
1434    pub fn portfolio_beta(
1435        &self,
1436        prices: &HashMap<String, Price>,
1437        betas: &HashMap<String, f64>,
1438    ) -> Option<f64> {
1439        use rust_decimal::prelude::ToPrimitive;
1440        let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1441        if open.is_empty() { return None; }
1442        let total_mv: Decimal = open.iter()
1443            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1444            .sum();
1445        if total_mv.is_zero() { return None; }
1446        let total_mv_f64 = total_mv.to_f64()?;
1447        let beta_sum: f64 = open.iter().filter_map(|p| {
1448            let mv = prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs())?;
1449            let b = betas.get(p.symbol.as_str())?;
1450            let w = mv.to_f64()? / total_mv_f64;
1451            Some(w * b)
1452        }).sum();
1453        Some(beta_sum)
1454    }
1455
1456    /// Returns the total notional value: sum of `|quantity| × price` for all open positions.
1457    ///
1458    /// Positions absent from `prices` are skipped. Returns `None` if no open positions
1459    /// have a matching price.
1460    pub fn total_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1461        let total: Decimal = self.positions.values()
1462            .filter(|p| !p.is_flat())
1463            .filter_map(|p| {
1464                prices.get(p.symbol.as_str())
1465                    .map(|&price| p.quantity_abs() * price.value())
1466            })
1467            .sum();
1468        if total.is_zero() { None } else { Some(total) }
1469    }
1470
1471    /// Returns the largest unrealized gain (most positive unrealized P&L) among open positions.
1472    ///
1473    /// Returns `None` if no open positions have a matching price, or all unrealized P&Ls are
1474    /// non-positive.
1475    pub fn max_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1476        self.positions.values()
1477            .filter(|p| !p.is_flat())
1478            .filter_map(|p| {
1479                prices.get(p.symbol.as_str())
1480                    .map(|&price| p.unrealized_pnl(price))
1481            })
1482            .filter(|&pnl| pnl > Decimal::ZERO)
1483            .max()
1484    }
1485
1486    /// Returns the 1-based rank (1 = best) of `symbol`'s realized P&L among all symbols
1487    /// that have non-zero realized P&L.
1488    ///
1489    /// Returns `None` if `symbol` has no realized P&L or if it is not found.
1490    pub fn realized_pnl_rank(&self, symbol: &Symbol) -> Option<usize> {
1491        let target = self.positions.get(symbol).map(|p| p.realized_pnl)?;
1492        if target == Decimal::ZERO { return None; }
1493        let mut sorted: Vec<Decimal> = self.positions.values()
1494            .map(|p| p.realized_pnl)
1495            .filter(|&r| r != Decimal::ZERO)
1496            .collect();
1497        sorted.sort_by(|a, b| b.cmp(a));
1498        sorted.iter().position(|&r| r == target).map(|i| i + 1)
1499    }
1500
1501    /// Returns a `Vec` of references to all open (non-flat) positions, sorted by symbol.
1502    pub fn open_positions_vec(&self) -> Vec<&Position> {
1503        let mut open: Vec<&Position> = self.positions.values()
1504            .filter(|p| !p.is_flat())
1505            .collect();
1506        open.sort_by(|a, b| a.symbol.as_str().cmp(b.symbol.as_str()));
1507        open
1508    }
1509
1510    /// Returns all symbols whose realized P&L strictly exceeds `threshold`.
1511    ///
1512    /// Results are sorted by realized P&L descending.
1513    pub fn symbols_with_pnl_above(&self, threshold: Decimal) -> Vec<Symbol> {
1514        let mut pairs: Vec<(Symbol, Decimal)> = self.positions.iter()
1515            .filter_map(|(sym, pos)| {
1516                if pos.realized_pnl > threshold { Some((sym.clone(), pos.realized_pnl)) } else { None }
1517            })
1518            .collect();
1519        pairs.sort_by(|a, b| b.1.cmp(&a.1));
1520        pairs.into_iter().map(|(s, _)| s).collect()
1521    }
1522
1523    /// Returns `(long_count, short_count)` of currently open (non-flat) positions.
1524    pub fn net_long_short_count(&self) -> (usize, usize) {
1525        let long = self.positions.values().filter(|p| p.is_long()).count();
1526        let short = self.positions.values().filter(|p| p.is_short()).count();
1527        (long, short)
1528    }
1529
1530    /// Returns the symbol of the open position with the largest absolute quantity.
1531    ///
1532    /// Returns `None` if there are no open positions.
1533    pub fn largest_open_position(&self) -> Option<&Symbol> {
1534        self.positions.iter()
1535            .filter(|(_, p)| !p.is_flat())
1536            .max_by(|(_, a), (_, b)| a.quantity.abs().cmp(&b.quantity.abs()))
1537            .map(|(sym, _)| sym)
1538    }
1539
1540    /// Market exposure broken down by direction: `(long_exposure, short_exposure)`.
1541    ///
1542    /// Both values are positive (abs). Positions not in `prices` contribute zero.
1543    pub fn exposure_by_direction(&self, prices: &HashMap<String, Price>) -> (Decimal, Decimal) {
1544        let long: Decimal = self.positions.values()
1545            .filter(|p| p.is_long())
1546            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr)))
1547            .sum();
1548        let short: Decimal = self.positions.values()
1549            .filter(|p| p.is_short())
1550            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1551            .sum();
1552        (long, short)
1553    }
1554
1555    /// Returns the sum of realized P&L across all positions in this ledger.
1556    pub fn total_realized_pnl(&self) -> Decimal {
1557        self.positions.values().map(|p| p.realized_pnl).sum()
1558    }
1559
1560    /// Returns the number of positions whose realized P&L is strictly below `threshold`.
1561    pub fn count_with_pnl_below(&self, threshold: Decimal) -> usize {
1562        self.positions.values().filter(|p| p.realized_pnl < threshold).count()
1563    }
1564
1565    /// Returns `true` if the sum of all position quantities is positive (net long exposure).
1566    pub fn is_net_long(&self) -> bool {
1567        let net: Decimal = self.positions.values().map(|p| p.quantity).sum();
1568        net > Decimal::ZERO
1569    }
1570
1571    /// Total unrealized P&L across all open positions that have a price available.
1572    ///
1573    /// Positions absent from `prices` contribute zero.
1574    pub fn total_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Decimal {
1575        self.positions.values()
1576            .filter(|p| !p.is_flat())
1577            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1578            .sum()
1579    }
1580
1581    /// Returns symbols that have a flat (zero-quantity) position in this ledger, sorted.
1582    pub fn symbols_flat(&self) -> Vec<&Symbol> {
1583        let mut flat: Vec<&Symbol> = self.positions.iter()
1584            .filter(|(_, p)| p.is_flat())
1585            .map(|(sym, _)| sym)
1586            .collect();
1587        flat.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1588        flat
1589    }
1590
1591    /// Returns the average unrealized P&L percentage across all open positions.
1592    ///
1593    /// Each position's unrealized PnL % is `unrealized_pnl / (avg_price * qty).abs() * 100`.
1594    /// Returns `None` if there are no open positions with valid prices.
1595    pub fn avg_unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1596        let pcts: Vec<Decimal> = self.positions.values()
1597            .filter(|p| !p.is_flat())
1598            .filter_map(|p| {
1599                prices.get(p.symbol.as_str()).and_then(|&pr| {
1600                    let cost_basis = (p.avg_cost * p.quantity).abs();
1601                    if cost_basis.is_zero() { return None; }
1602                    Some(p.unrealized_pnl(pr) / cost_basis * Decimal::ONE_HUNDRED)
1603                })
1604            })
1605            .collect();
1606        if pcts.is_empty() { return None; }
1607        Some(pcts.iter().sum::<Decimal>() / Decimal::from(pcts.len()))
1608    }
1609
1610    /// Returns the symbol with the worst (most negative) unrealized P&L.
1611    ///
1612    /// Returns `None` if there are no open positions or none have a price in `prices`.
1613    pub fn max_drawdown_symbol<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Symbol> {
1614        self.positions.iter()
1615            .filter(|(_, p)| !p.is_flat())
1616            .filter_map(|(sym, p)| {
1617                prices.get(p.symbol.as_str())
1618                    .map(|&price| (sym, p.unrealized_pnl(price)))
1619            })
1620            .min_by(|(_, a), (_, b)| a.cmp(b))
1621            .map(|(sym, _)| sym)
1622    }
1623
1624    /// Average unrealized P&L across all open positions that have a price in `prices`.
1625    ///
1626    /// Returns `None` if there are no open positions with prices available.
1627    pub fn avg_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1628        let pnls: Vec<Decimal> = self.positions.values()
1629            .filter(|p| !p.is_flat())
1630            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1631            .collect();
1632        if pnls.is_empty() { return None; }
1633        #[allow(clippy::cast_possible_truncation)]
1634        Some(pnls.iter().sum::<Decimal>() / Decimal::from(pnls.len() as u32))
1635    }
1636
1637    /// Returns a sorted `Vec` of all symbols tracked by this ledger (open or closed).
1638    pub fn position_symbols(&self) -> Vec<&Symbol> {
1639        let mut syms: Vec<&Symbol> = self.positions.keys().collect();
1640        syms.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1641        syms
1642    }
1643
1644    /// Returns the count of positions with strictly positive realized P&L.
1645    pub fn count_profitable(&self) -> usize {
1646        self.positions.values().filter(|p| p.realized_pnl > Decimal::ZERO).count()
1647    }
1648
1649    /// Returns the count of positions with strictly negative realized P&L.
1650    pub fn count_losing(&self) -> usize {
1651        self.positions.values().filter(|p| p.realized_pnl < Decimal::ZERO).count()
1652    }
1653
1654    /// Returns the top `n` open positions by absolute notional exposure (`|qty * price|`),
1655    /// sorted descending. Positions without a price in `prices` are excluded.
1656    pub fn top_n_by_exposure<'a>(
1657        &'a self,
1658        prices: &HashMap<String, Price>,
1659        n: usize,
1660    ) -> Vec<(&'a Symbol, Decimal)> {
1661        let mut exposures: Vec<(&Symbol, Decimal)> = self.positions.iter()
1662            .filter(|(_, p)| !p.is_flat())
1663            .filter_map(|(sym, p)| {
1664                prices.get(p.symbol.as_str())
1665                    .map(|&pr| (sym, (p.quantity * pr.value()).abs()))
1666            })
1667            .collect();
1668        exposures.sort_by(|a, b| b.1.cmp(&a.1));
1669        exposures.truncate(n);
1670        exposures
1671    }
1672
1673    /// Returns `true` if there is at least one non-flat position.
1674    pub fn has_open_positions(&self) -> bool {
1675        self.positions.values().any(|p| !p.is_flat())
1676    }
1677
1678    /// Symbols with a strictly positive (long) quantity.
1679    pub fn long_symbols(&self) -> Vec<&Symbol> {
1680        self.positions.iter()
1681            .filter(|(_, p)| p.quantity > Decimal::ZERO)
1682            .map(|(sym, _)| sym)
1683            .collect()
1684    }
1685
1686    /// Symbols with a strictly negative (short) quantity.
1687    pub fn short_symbols(&self) -> Vec<&Symbol> {
1688        self.positions.iter()
1689            .filter(|(_, p)| p.quantity < Decimal::ZERO)
1690            .map(|(sym, _)| sym)
1691            .collect()
1692    }
1693
1694    /// Herfindahl-Hirschman Index of notional exposure: `Σ w_i²` where `w_i = |notional_i| / Σ|notional|`.
1695    ///
1696    /// Returns `1.0` (full concentration) for a single position.
1697    /// Returns `None` if there are no open positions with available prices.
1698    pub fn concentration_ratio(&self, prices: &HashMap<String, Price>) -> Option<f64> {
1699        use rust_decimal::prelude::ToPrimitive;
1700        let notionals: Vec<Decimal> = self.positions.values()
1701            .filter(|p| !p.is_flat())
1702            .filter_map(|p| {
1703                prices.get(p.symbol.as_str())
1704                    .map(|&pr| (p.quantity * pr.value()).abs())
1705            })
1706            .collect();
1707        if notionals.is_empty() { return None; }
1708        let total: Decimal = notionals.iter().sum();
1709        if total.is_zero() { return None; }
1710        let hhi: f64 = notionals.iter()
1711            .filter_map(|n| (n / total).to_f64())
1712            .map(|w| w * w)
1713            .sum();
1714        Some(hhi)
1715    }
1716
1717    /// Minimum unrealized P&L across all open positions.
1718    ///
1719    /// Returns `None` if there are no open positions with a known price.
1720    pub fn min_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1721        self.positions.values()
1722            .filter(|p| !p.is_flat())
1723            .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1724            .min_by(|a, b| a.cmp(b))
1725    }
1726
1727    /// Percentage of non-flat positions that are long (quantity > 0).
1728    ///
1729    /// Returns `None` if there are no open positions.
1730    pub fn pct_long(&self) -> Option<Decimal> {
1731        let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1732        if open.is_empty() { return None; }
1733        let longs = open.iter().filter(|p| p.quantity > Decimal::ZERO).count() as u32;
1734        Some(Decimal::from(longs) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1735    }
1736
1737    /// Percentage of non-flat positions that are short (quantity < 0).
1738    ///
1739    /// Returns `None` if there are no open positions.
1740    pub fn pct_short(&self) -> Option<Decimal> {
1741        let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1742        if open.is_empty() { return None; }
1743        let shorts = open.iter().filter(|p| p.quantity < Decimal::ZERO).count() as u32;
1744        Some(Decimal::from(shorts) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1745    }
1746
1747    /// Sum of absolute values of all realized P&L across positions.
1748    pub fn realized_pnl_total_abs(&self) -> Decimal {
1749        self.positions.values().map(|p| p.realized_pnl.abs()).sum()
1750    }
1751
1752    /// Average entry price for a symbol's current position.
1753    ///
1754    /// Returns `None` if the symbol is not tracked or the position is flat.
1755    pub fn average_entry_price(&self, symbol: &Symbol) -> Option<Price> {
1756        self.positions.get(symbol)?.avg_entry_price()
1757    }
1758
1759    /// Net sum of all position quantities across all symbols.
1760    pub fn net_quantity(&self) -> Decimal {
1761        self.positions.values().map(|p| p.quantity).sum()
1762    }
1763
1764    /// Maximum notional exposure (`|qty * price|`) of any single long position.
1765    ///
1766    /// Returns `None` if no long positions have a price in `prices`.
1767    pub fn max_long_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1768        self.positions.values()
1769            .filter(|p| p.quantity > Decimal::ZERO)
1770            .filter_map(|p| {
1771                prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1772            })
1773            .max_by(|a, b| a.cmp(b))
1774    }
1775
1776    /// Maximum notional exposure (`|qty * price|`) of any single short position.
1777    ///
1778    /// Returns `None` if no short positions have a price in `prices`.
1779    pub fn max_short_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1780        self.positions.values()
1781            .filter(|p| p.quantity < Decimal::ZERO)
1782            .filter_map(|p| {
1783                prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1784            })
1785            .max_by(|a, b| a.cmp(b))
1786    }
1787
1788    /// Symbol with the highest realized P&L.
1789    ///
1790    /// Returns `None` if no positions have been tracked.
1791    pub fn max_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1792        self.positions.iter()
1793            .map(|(sym, p)| (sym, p.realized_pnl))
1794            .max_by(|(_, a), (_, b)| a.cmp(b))
1795    }
1796
1797    /// Symbol with the lowest (most negative) realized P&L.
1798    ///
1799    /// Returns `None` if no positions have been tracked.
1800    pub fn min_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1801        self.positions.iter()
1802            .map(|(sym, p)| (sym, p.realized_pnl))
1803            .min_by(|(_, a), (_, b)| a.cmp(b))
1804    }
1805
1806    /// Average holding duration in bars for all open positions.
1807    ///
1808    /// Uses `current_bar - p.open_bar` for each open position.
1809    /// Returns `None` if there are no open positions.
1810    pub fn avg_holding_bars(&self, current_bar: usize) -> Option<f64> {
1811        let open: Vec<usize> = self.positions.values()
1812            .filter(|p| !p.is_flat())
1813            .map(|p| current_bar.saturating_sub(p.open_bar))
1814            .collect();
1815        if open.is_empty() { return None; }
1816        Some(open.iter().sum::<usize>() as f64 / open.len() as f64)
1817    }
1818
1819    /// Symbols of open positions that currently have a negative unrealized P&L.
1820    pub fn symbols_with_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Vec<&Symbol> {
1821        self.positions.iter()
1822            .filter(|(_, p)| !p.is_flat())
1823            .filter_map(|(sym, p)| {
1824                prices.get(p.symbol.as_str())
1825                    .map(|&pr| (sym, p.unrealized_pnl(pr)))
1826            })
1827            .filter(|(_, pnl)| *pnl < Decimal::ZERO)
1828            .map(|(sym, _)| sym)
1829            .collect()
1830    }
1831
1832    /// Volume-weighted average entry price across all open long positions. Returns `None` if
1833    /// there are no long positions.
1834    pub fn avg_long_entry_price(&self) -> Option<Decimal> {
1835        let longs: Vec<&Position> = self.positions.values()
1836            .filter(|p| p.is_long())
1837            .collect();
1838        if longs.is_empty() { return None; }
1839        let total_qty: Decimal = longs.iter().map(|p| p.quantity.abs()).sum();
1840        if total_qty.is_zero() { return None; }
1841        let weighted: Decimal = longs.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1842        Some(weighted / total_qty)
1843    }
1844
1845    /// Volume-weighted average entry price across all open short positions. Returns `None` if
1846    /// there are no short positions.
1847    pub fn avg_short_entry_price(&self) -> Option<Decimal> {
1848        let shorts: Vec<&Position> = self.positions.values()
1849            .filter(|p| p.is_short())
1850            .collect();
1851        if shorts.is_empty() { return None; }
1852        let total_qty: Decimal = shorts.iter().map(|p| p.quantity.abs()).sum();
1853        if total_qty.is_zero() { return None; }
1854        let weighted: Decimal = shorts.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1855        Some(weighted / total_qty)
1856    }
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861    use super::*;
1862    use rust_decimal_macros::dec;
1863
1864    fn sym(s: &str) -> Symbol {
1865        Symbol::new(s).unwrap()
1866    }
1867
1868    fn make_fill(symbol: &str, side: Side, qty: &str, p: &str, commission: &str) -> Fill {
1869        Fill {
1870            symbol: sym(symbol),
1871            side,
1872            quantity: Quantity::new(qty.parse().unwrap()).unwrap(),
1873            price: Price::new(p.parse().unwrap()).unwrap(),
1874            timestamp: NanoTimestamp::new(0),
1875            commission: commission.parse().unwrap(),
1876        }
1877    }
1878
1879    #[test]
1880    fn test_position_apply_fill_long() {
1881        let mut pos = Position::new(sym("AAPL"));
1882        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1883            .unwrap();
1884        assert_eq!(pos.quantity, dec!(10));
1885        assert_eq!(pos.avg_cost, dec!(100));
1886    }
1887
1888    #[test]
1889    fn test_position_apply_fill_reduces_position() {
1890        let mut pos = Position::new(sym("AAPL"));
1891        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1892            .unwrap();
1893        pos.apply_fill(&make_fill("AAPL", Side::Ask, "5", "110", "0"))
1894            .unwrap();
1895        assert_eq!(pos.quantity, dec!(5));
1896    }
1897
1898    #[test]
1899    fn test_position_realized_pnl_on_close() {
1900        let mut pos = Position::new(sym("AAPL"));
1901        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1902            .unwrap();
1903        let pnl = pos
1904            .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1905            .unwrap();
1906        assert_eq!(pnl, dec!(100));
1907        assert!(pos.is_flat());
1908    }
1909
1910    #[test]
1911    fn test_position_commission_reduces_realized_pnl() {
1912        let mut pos = Position::new(sym("AAPL"));
1913        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1914            .unwrap();
1915        let pnl = pos
1916            .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "5"))
1917            .unwrap();
1918        assert_eq!(pnl, dec!(95));
1919    }
1920
1921    #[test]
1922    fn test_position_unrealized_pnl() {
1923        let mut pos = Position::new(sym("AAPL"));
1924        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1925            .unwrap();
1926        let upnl = pos.unrealized_pnl(Price::new(dec!(115)).unwrap());
1927        assert_eq!(upnl, dec!(150));
1928    }
1929
1930    #[test]
1931    fn test_position_market_value() {
1932        let mut pos = Position::new(sym("AAPL"));
1933        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1934            .unwrap();
1935        assert_eq!(pos.market_value(Price::new(dec!(120)).unwrap()), dec!(1200));
1936    }
1937
1938    #[test]
1939    fn test_position_is_flat_initially() {
1940        let pos = Position::new(sym("X"));
1941        assert!(pos.is_flat());
1942    }
1943
1944    #[test]
1945    fn test_position_is_flat_after_full_close() {
1946        let mut pos = Position::new(sym("AAPL"));
1947        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1948            .unwrap();
1949        pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1950            .unwrap();
1951        assert!(pos.is_flat());
1952    }
1953
1954    #[test]
1955    fn test_position_avg_cost_weighted_after_two_buys() {
1956        let mut pos = Position::new(sym("X"));
1957        pos.apply_fill(&make_fill("X", Side::Bid, "10", "100", "0"))
1958            .unwrap();
1959        pos.apply_fill(&make_fill("X", Side::Bid, "10", "120", "0"))
1960            .unwrap();
1961        assert_eq!(pos.avg_cost, dec!(110));
1962    }
1963
1964    #[test]
1965    fn test_position_ledger_apply_fill_updates_cash() {
1966        let mut ledger = PositionLedger::new(dec!(10000));
1967        ledger
1968            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "1"))
1969            .unwrap();
1970        assert_eq!(ledger.cash(), dec!(8999));
1971    }
1972
1973    #[test]
1974    fn test_position_ledger_insufficient_funds() {
1975        let mut ledger = PositionLedger::new(dec!(100));
1976        let result = ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
1977        assert!(matches!(result, Err(FinError::InsufficientFunds { .. })));
1978    }
1979
1980    #[test]
1981    fn test_position_ledger_equity_calculation() {
1982        let mut ledger = PositionLedger::new(dec!(10000));
1983        ledger
1984            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
1985            .unwrap();
1986        let mut prices = HashMap::new();
1987        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
1988        // equity = cash + unrealized = 9000 + (110-100)*10 = 9100
1989        let equity = ledger.equity(&prices).unwrap();
1990        assert_eq!(equity, dec!(9100));
1991    }
1992
1993    #[test]
1994    fn test_position_ledger_net_liquidation_value() {
1995        // buy 10 AAPL @ 100 → cash = 10000 - 1000 = 9000
1996        let mut ledger = PositionLedger::new(dec!(10000));
1997        ledger
1998            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
1999            .unwrap();
2000        let mut prices = HashMap::new();
2001        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2002        // NLV = cash(9000) + 10×110 = 9000 + 1100 = 10100
2003        let nlv = ledger.net_liquidation_value(&prices).unwrap();
2004        assert_eq!(nlv, dec!(10100));
2005    }
2006
2007    #[test]
2008    fn test_position_ledger_net_liquidation_missing_price() {
2009        let mut ledger = PositionLedger::new(dec!(10000));
2010        ledger
2011            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2012            .unwrap();
2013        let prices: HashMap<String, Price> = HashMap::new();
2014        assert!(ledger.net_liquidation_value(&prices).is_err());
2015    }
2016
2017    #[test]
2018    fn test_position_ledger_pnl_by_symbol() {
2019        let mut ledger = PositionLedger::new(dec!(10000));
2020        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2021        ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2022        let mut prices = HashMap::new();
2023        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2024        prices.insert("GOOG".to_owned(), Price::new(dec!(190)).unwrap());
2025        let pnl = ledger.pnl_by_symbol(&prices).unwrap();
2026        assert_eq!(*pnl.get(&sym("AAPL")).unwrap(), dec!(100));  // (110-100)*10
2027        assert_eq!(*pnl.get(&sym("GOOG")).unwrap(), dec!(-50));  // (190-200)*5
2028    }
2029
2030    #[test]
2031    fn test_position_ledger_pnl_by_symbol_missing_price() {
2032        let mut ledger = PositionLedger::new(dec!(10000));
2033        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2034        let prices: HashMap<String, Price> = HashMap::new();
2035        assert!(ledger.pnl_by_symbol(&prices).is_err());
2036    }
2037
2038    #[test]
2039    fn test_position_ledger_delta_neutral_no_positions() {
2040        let ledger = PositionLedger::new(dec!(10000));
2041        let prices: HashMap<String, Price> = HashMap::new();
2042        assert!(ledger.delta_neutral_check(&prices).unwrap());
2043    }
2044
2045    #[test]
2046    fn test_position_ledger_delta_neutral_long_short_balanced() {
2047        let mut ledger = PositionLedger::new(dec!(10000));
2048        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2049        ledger.apply_fill(make_fill("GOOG", Side::Ask, "10", "100", "0")).unwrap();
2050        let mut prices = HashMap::new();
2051        prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2052        prices.insert("GOOG".to_owned(), Price::new(dec!(100)).unwrap());
2053        // net=0, gross=2000 → ratio=0 → neutral
2054        assert!(ledger.delta_neutral_check(&prices).unwrap());
2055    }
2056
2057    #[test]
2058    fn test_position_ledger_delta_neutral_one_sided_not_neutral() {
2059        let mut ledger = PositionLedger::new(dec!(10000));
2060        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2061        let mut prices = HashMap::new();
2062        prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2063        // net=1000, gross=1000 → ratio=1 → not neutral
2064        assert!(!ledger.delta_neutral_check(&prices).unwrap());
2065    }
2066
2067    #[test]
2068    fn test_position_ledger_open_count_zero_when_empty() {
2069        assert_eq!(PositionLedger::new(dec!(10000)).open_count(), 0);
2070    }
2071
2072    #[test]
2073    fn test_position_ledger_open_count_tracks_positions() {
2074        let mut ledger = PositionLedger::new(dec!(10000));
2075        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2076        assert_eq!(ledger.open_count(), 1);
2077        ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2078        assert_eq!(ledger.open_count(), 2);
2079        // close AAPL fully
2080        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "105", "0")).unwrap();
2081        assert_eq!(ledger.open_count(), 1);
2082    }
2083
2084    #[test]
2085    fn test_position_ledger_sell_increases_cash() {
2086        let mut ledger = PositionLedger::new(dec!(10000));
2087        ledger
2088            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2089            .unwrap();
2090        ledger
2091            .apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"))
2092            .unwrap();
2093        assert_eq!(ledger.cash(), dec!(10100));
2094    }
2095
2096    #[test]
2097    fn test_position_checked_unrealized_pnl_matches() {
2098        let mut pos = Position::new(sym("AAPL"));
2099        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2100            .unwrap();
2101        let price = Price::new(dec!(115)).unwrap();
2102        let checked = pos.checked_unrealized_pnl(price).unwrap();
2103        let unchecked = pos.unrealized_pnl(price);
2104        assert_eq!(checked, unchecked);
2105        assert_eq!(checked, dec!(150));
2106    }
2107
2108    #[test]
2109    fn test_position_checked_unrealized_pnl_flat_position() {
2110        let pos = Position::new(sym("X"));
2111        let price = Price::new(dec!(100)).unwrap();
2112        assert_eq!(pos.checked_unrealized_pnl(price).unwrap(), dec!(0));
2113    }
2114
2115    #[test]
2116    fn test_position_direction_flat() {
2117        let pos = Position::new(sym("X"));
2118        assert_eq!(pos.direction(), PositionDirection::Flat);
2119    }
2120
2121    #[test]
2122    fn test_position_direction_long() {
2123        let mut pos = Position::new(sym("X"));
2124        pos.apply_fill(&make_fill("X", Side::Bid, "5", "100", "0"))
2125            .unwrap();
2126        assert_eq!(pos.direction(), PositionDirection::Long);
2127    }
2128
2129    #[test]
2130    fn test_position_direction_short() {
2131        let mut pos = Position::new(sym("X"));
2132        // Short: sell without prior long (negative quantity via negative fill)
2133        pos.apply_fill(&make_fill("X", Side::Ask, "5", "100", "0"))
2134            .unwrap();
2135        assert_eq!(pos.direction(), PositionDirection::Short);
2136    }
2137
2138    #[test]
2139    fn test_position_ledger_positions_iterator() {
2140        let mut ledger = PositionLedger::new(dec!(10000));
2141        ledger
2142            .apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0"))
2143            .unwrap();
2144        ledger
2145            .apply_fill(make_fill("MSFT", Side::Bid, "1", "200", "0"))
2146            .unwrap();
2147        let count = ledger.positions().count();
2148        assert_eq!(count, 2);
2149    }
2150
2151    #[test]
2152    fn test_position_ledger_total_market_value() {
2153        let mut ledger = PositionLedger::new(dec!(10000));
2154        ledger
2155            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2156            .unwrap();
2157        ledger
2158            .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2159            .unwrap();
2160        let mut prices = HashMap::new();
2161        prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2162        prices.insert("MSFT".to_owned(), Price::new(dec!(210)).unwrap());
2163        // 10*110 + 5*210 = 1100 + 1050 = 2150
2164        let mv = ledger.total_market_value(&prices).unwrap();
2165        assert_eq!(mv, dec!(2150));
2166    }
2167
2168    #[test]
2169    fn test_position_ledger_total_market_value_missing_price() {
2170        let mut ledger = PositionLedger::new(dec!(10000));
2171        ledger
2172            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2173            .unwrap();
2174        let prices: HashMap<String, Price> = HashMap::new();
2175        assert!(matches!(
2176            ledger.total_market_value(&prices),
2177            Err(FinError::PositionNotFound(_))
2178        ));
2179    }
2180
2181    #[test]
2182    fn test_position_ledger_unrealized_pnl_total() {
2183        let mut ledger = PositionLedger::new(dec!(10000));
2184        ledger
2185            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2186            .unwrap();
2187        let mut prices = HashMap::new();
2188        prices.insert("AAPL".to_owned(), Price::new(dec!(105)).unwrap());
2189        let upnl = ledger.unrealized_pnl_total(&prices).unwrap();
2190        assert_eq!(upnl, dec!(50));
2191    }
2192
2193    #[test]
2194    fn test_position_ledger_position_count_includes_flat() {
2195        let mut ledger = PositionLedger::new(dec!(10000));
2196        // open AAPL long then close it
2197        ledger
2198            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2199            .unwrap();
2200        ledger
2201            .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2202            .unwrap();
2203        // open MSFT long (stays open)
2204        ledger
2205            .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2206            .unwrap();
2207        assert_eq!(ledger.position_count(), 2, "both symbols tracked");
2208        assert_eq!(ledger.open_position_count(), 1, "only MSFT open");
2209    }
2210
2211    #[test]
2212    fn test_position_ledger_position_count_zero_on_empty() {
2213        let ledger = PositionLedger::new(dec!(10000));
2214        assert_eq!(ledger.position_count(), 0);
2215    }
2216
2217    #[test]
2218    fn test_position_unrealized_pnl_pct_long_gain() {
2219        let mut pos = Position::new(sym("AAPL"));
2220        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2221            .unwrap();
2222        let current = Price::new(dec!(110)).unwrap();
2223        let pct = pos.unrealized_pnl_pct(current).unwrap();
2224        assert_eq!(pct, dec!(10));
2225    }
2226
2227    #[test]
2228    fn test_position_unrealized_pnl_pct_flat_returns_none() {
2229        let pos = Position::new(sym("AAPL"));
2230        let current = Price::new(dec!(110)).unwrap();
2231        assert!(pos.unrealized_pnl_pct(current).is_none());
2232    }
2233
2234    #[test]
2235    fn test_position_unrealized_pnl_pct_loss() {
2236        let mut pos = Position::new(sym("AAPL"));
2237        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2238            .unwrap();
2239        let current = Price::new(dec!(90)).unwrap();
2240        let pct = pos.unrealized_pnl_pct(current).unwrap();
2241        assert_eq!(pct, dec!(-10));
2242    }
2243
2244    #[test]
2245    fn test_position_ledger_open_positions_excludes_flat() {
2246        let mut ledger = PositionLedger::new(dec!(10000));
2247        ledger
2248            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2249            .unwrap();
2250        ledger
2251            .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2252            .unwrap();
2253        ledger
2254            .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2255            .unwrap();
2256        let open: Vec<_> = ledger.open_positions().collect();
2257        assert_eq!(open.len(), 1);
2258        assert_eq!(open[0].symbol.as_str(), "MSFT");
2259    }
2260
2261    #[test]
2262    fn test_position_ledger_open_positions_empty_when_all_flat() {
2263        let mut ledger = PositionLedger::new(dec!(10000));
2264        ledger
2265            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2266            .unwrap();
2267        ledger
2268            .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2269            .unwrap();
2270        let open: Vec<_> = ledger.open_positions().collect();
2271        assert!(open.is_empty());
2272    }
2273
2274    #[test]
2275    fn test_position_is_long() {
2276        let mut pos = Position::new(sym("AAPL"));
2277        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2278            .unwrap();
2279        assert!(pos.is_long());
2280        assert!(!pos.is_short());
2281        assert!(!pos.is_flat());
2282    }
2283
2284    #[test]
2285    fn test_position_is_short() {
2286        let mut pos = Position::new(sym("AAPL"));
2287        pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2288            .unwrap();
2289        assert!(pos.is_short());
2290        assert!(!pos.is_long());
2291        assert!(!pos.is_flat());
2292    }
2293
2294    #[test]
2295    fn test_position_is_flat_after_close() {
2296        let mut pos = Position::new(sym("AAPL"));
2297        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2298            .unwrap();
2299        pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2300            .unwrap();
2301        assert!(pos.is_flat());
2302        assert!(!pos.is_long());
2303        assert!(!pos.is_short());
2304    }
2305
2306    #[test]
2307    fn test_position_ledger_flat_positions() {
2308        let mut ledger = PositionLedger::new(dec!(10000));
2309        // open AAPL, then close it
2310        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2311        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2312        // leave MSFT open
2313        ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0")).unwrap();
2314        let flat: Vec<_> = ledger.flat_positions().collect();
2315        assert_eq!(flat.len(), 1);
2316        assert_eq!(flat[0].symbol, sym("AAPL"));
2317    }
2318
2319    #[test]
2320    fn test_position_ledger_flat_positions_empty_when_all_open() {
2321        let mut ledger = PositionLedger::new(dec!(10000));
2322        ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2323        assert_eq!(ledger.flat_positions().count(), 0);
2324    }
2325
2326    #[test]
2327    fn test_position_ledger_deposit_increases_cash() {
2328        let mut ledger = PositionLedger::new(dec!(1000));
2329        ledger.deposit(dec!(500));
2330        assert_eq!(ledger.cash(), dec!(1500));
2331    }
2332
2333    #[test]
2334    fn test_position_ledger_withdraw_decreases_cash() {
2335        let mut ledger = PositionLedger::new(dec!(1000));
2336        ledger.withdraw(dec!(300)).unwrap();
2337        assert_eq!(ledger.cash(), dec!(700));
2338    }
2339
2340    #[test]
2341    fn test_position_ledger_withdraw_insufficient_fails() {
2342        let mut ledger = PositionLedger::new(dec!(100));
2343        assert!(matches!(
2344            ledger.withdraw(dec!(200)),
2345            Err(FinError::InsufficientFunds { .. })
2346        ));
2347        assert_eq!(ledger.cash(), dec!(100), "cash unchanged on failure");
2348    }
2349
2350    #[test]
2351    fn test_position_is_profitable_true() {
2352        let mut pos = Position::new(sym("AAPL"));
2353        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2354            .unwrap();
2355        let current = Price::new(dec!(110)).unwrap();
2356        assert!(pos.is_profitable(current));
2357    }
2358
2359    #[test]
2360    fn test_position_is_profitable_false_when_at_loss() {
2361        let mut pos = Position::new(sym("AAPL"));
2362        pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2363            .unwrap();
2364        let current = Price::new(dec!(90)).unwrap();
2365        assert!(!pos.is_profitable(current));
2366    }
2367
2368    #[test]
2369    fn test_position_ledger_long_positions() {
2370        let mut ledger = PositionLedger::new(dec!(10000));
2371        ledger
2372            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2373            .unwrap();
2374        let longs: Vec<_> = ledger.long_positions().collect();
2375        assert_eq!(longs.len(), 1);
2376        assert_eq!(longs[0].symbol.as_str(), "AAPL");
2377    }
2378
2379    #[test]
2380    fn test_position_ledger_short_positions_empty_for_long_only() {
2381        let mut ledger = PositionLedger::new(dec!(10000));
2382        ledger
2383            .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2384            .unwrap();
2385        let shorts: Vec<_> = ledger.short_positions().collect();
2386        assert!(shorts.is_empty());
2387    }
2388
2389    #[test]
2390    fn test_position_ledger_realized_pnl_after_close() {
2391        let mut ledger = PositionLedger::new(dec!(10000));
2392        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2393        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2394        assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(100)));
2395    }
2396
2397    #[test]
2398    fn test_position_ledger_realized_pnl_unknown_symbol_returns_none() {
2399        let ledger = PositionLedger::new(dec!(10000));
2400        assert!(ledger.realized_pnl(&sym("AAPL")).is_none());
2401    }
2402
2403    #[test]
2404    fn test_position_ledger_realized_pnl_zero_before_close() {
2405        let mut ledger = PositionLedger::new(dec!(10000));
2406        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2407        assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(0)));
2408    }
2409
2410    #[test]
2411    fn test_position_ledger_symbols_sorted_order() {
2412        let mut ledger = PositionLedger::new(dec!(10000));
2413        ledger.apply_fill(make_fill("MSFT", Side::Bid, "1", "100", "0")).unwrap();
2414        ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2415        ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "100", "0")).unwrap();
2416        let sorted = ledger.symbols_sorted();
2417        let names: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
2418        assert_eq!(names, vec!["AAPL", "GOOG", "MSFT"]);
2419    }
2420
2421    #[test]
2422    fn test_position_ledger_symbols_sorted_empty() {
2423        let ledger = PositionLedger::new(dec!(10000));
2424        assert!(ledger.symbols_sorted().is_empty());
2425    }
2426
2427    #[test]
2428    fn test_position_avg_entry_price_long() {
2429        let sym = Symbol::new("AAPL").unwrap();
2430        let mut pos = Position::new(sym.clone());
2431        let fill = Fill::new(
2432            sym,
2433            Side::Bid,
2434            Quantity::new(dec!(10)).unwrap(),
2435            Price::new(dec!(150)).unwrap(),
2436            NanoTimestamp::new(0),
2437        );
2438        pos.apply_fill(&fill).unwrap();
2439        assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(150));
2440    }
2441
2442    #[test]
2443    fn test_position_avg_entry_price_flat_returns_none() {
2444        let sym = Symbol::new("AAPL").unwrap();
2445        let pos = Position::new(sym);
2446        assert!(pos.avg_entry_price().is_none());
2447    }
2448
2449    #[test]
2450    fn test_position_avg_entry_price_after_partial_close() {
2451        let sym = Symbol::new("X").unwrap();
2452        let mut pos = Position::new(sym.clone());
2453        pos.apply_fill(&Fill::new(sym.clone(), Side::Bid,
2454            Quantity::new(dec!(10)).unwrap(), Price::new(dec!(100)).unwrap(),
2455            NanoTimestamp::new(0))).unwrap();
2456        pos.apply_fill(&Fill::new(sym.clone(), Side::Ask,
2457            Quantity::new(dec!(5)).unwrap(), Price::new(dec!(100)).unwrap(),
2458            NanoTimestamp::new(1))).unwrap();
2459        // Still long 5 at avg_cost = 100
2460        assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(100));
2461    }
2462
2463    #[test]
2464    fn test_position_ledger_has_position_true_after_fill() {
2465        let mut ledger = PositionLedger::new(dec!(10000));
2466        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2467        assert!(ledger.has_position(&sym("AAPL")));
2468    }
2469
2470    #[test]
2471    fn test_position_ledger_has_position_false_for_unknown() {
2472        let ledger = PositionLedger::new(dec!(10000));
2473        assert!(!ledger.has_position(&sym("AAPL")));
2474    }
2475
2476    #[test]
2477    fn test_position_ledger_has_position_true_even_when_flat() {
2478        let mut ledger = PositionLedger::new(dec!(10000));
2479        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2480        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2481        // position is flat but still tracked
2482        assert!(ledger.has_position(&sym("AAPL")));
2483    }
2484
2485    #[test]
2486    fn test_position_ledger_open_symbols_returns_non_flat() {
2487        let mut ledger = PositionLedger::new(dec!(10000));
2488        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2489        ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "1")).unwrap();
2490        let symbols: Vec<_> = ledger.open_symbols().collect();
2491        assert_eq!(symbols.len(), 2);
2492    }
2493
2494    #[test]
2495    fn test_position_ledger_open_symbols_excludes_flat() {
2496        let mut ledger = PositionLedger::new(dec!(10000));
2497        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2498        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap(); // flat
2499        ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "2")).unwrap();
2500        let symbols: Vec<_> = ledger.open_symbols().collect();
2501        assert_eq!(symbols.len(), 1);
2502        assert_eq!(symbols[0].as_str(), "MSFT");
2503    }
2504
2505    #[test]
2506    fn test_position_ledger_open_symbols_empty_when_all_flat() {
2507        let mut ledger = PositionLedger::new(dec!(10000));
2508        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2509        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap();
2510        let symbols: Vec<_> = ledger.open_symbols().collect();
2511        assert!(symbols.is_empty());
2512    }
2513
2514    #[test]
2515    fn test_position_ledger_total_long_exposure() {
2516        let mut ledger = PositionLedger::new(dec!(100000));
2517        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2518        // 10 * avg_cost(100) = 1000
2519        assert_eq!(ledger.total_long_exposure(), dec!(1000));
2520    }
2521
2522    #[test]
2523    fn test_position_ledger_total_long_exposure_zero_when_flat() {
2524        let ledger = PositionLedger::new(dec!(10000));
2525        assert_eq!(ledger.total_long_exposure(), dec!(0));
2526    }
2527
2528    #[test]
2529    fn test_position_ledger_total_short_exposure_zero_when_no_shorts() {
2530        let mut ledger = PositionLedger::new(dec!(100000));
2531        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2532        assert_eq!(ledger.total_short_exposure(), dec!(0));
2533    }
2534
2535    #[test]
2536    fn test_allocation_pct_single_position() {
2537        let mut ledger = PositionLedger::new(dec!(100000));
2538        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2539        let mut prices = HashMap::new();
2540        let sym = Symbol::new("AAPL").unwrap();
2541        prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2542        let pct = ledger.allocation_pct(&sym, &prices).unwrap();
2543        // 10 shares * $100 / ($1000 total) = 100%
2544        assert_eq!(pct, Some(dec!(100)));
2545    }
2546
2547    #[test]
2548    fn test_allocation_pct_flat_position_returns_none() {
2549        let ledger = PositionLedger::new(dec!(100000));
2550        let mut prices = HashMap::new();
2551        let sym = Symbol::new("AAPL").unwrap();
2552        prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2553        // No fill → no position in ledger → error
2554        assert!(ledger.allocation_pct(&sym, &prices).is_err());
2555    }
2556
2557    #[test]
2558    fn test_positions_sorted_by_pnl_descending() {
2559        let mut ledger = PositionLedger::new(dec!(100000));
2560        ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2561        ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "200", "0")).unwrap();
2562        let mut prices = HashMap::new();
2563        // AAPL gained $10, GOOG gained $50
2564        prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2565        prices.insert("GOOG".to_string(), Price::new(dec!(250)).unwrap());
2566        let sorted = ledger.positions_sorted_by_pnl(&prices);
2567        // GOOG (pnl=50) should come before AAPL (pnl=10)
2568        assert_eq!(sorted[0].symbol.as_str(), "GOOG");
2569        assert_eq!(sorted[1].symbol.as_str(), "AAPL");
2570    }
2571
2572    #[test]
2573    fn test_positions_sorted_by_pnl_empty_when_all_flat() {
2574        let ledger = PositionLedger::new(dec!(100000));
2575        let prices = HashMap::new();
2576        assert!(ledger.positions_sorted_by_pnl(&prices).is_empty());
2577    }
2578
2579    #[test]
2580    fn test_all_flat_initially() {
2581        let ledger = PositionLedger::new(dec!(100000));
2582        assert!(ledger.all_flat());
2583    }
2584
2585    #[test]
2586    fn test_all_flat_false_after_open_position() {
2587        let mut ledger = PositionLedger::new(dec!(100000));
2588        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0"));
2589        assert!(!ledger.all_flat());
2590    }
2591
2592    #[test]
2593    fn test_all_flat_true_after_close_position() {
2594        let mut ledger = PositionLedger::new(dec!(100000));
2595        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0"));
2596        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "155", "0"));
2597        assert!(ledger.all_flat());
2598    }
2599
2600    #[test]
2601    fn test_concentration_pct_single_position() {
2602        let mut ledger = PositionLedger::new(dec!(100000));
2603        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0"));
2604        let sym = Symbol::new("AAPL").unwrap();
2605        let mut prices = HashMap::new();
2606        prices.insert("AAPL".to_string(), Price::new(dec!(150)).unwrap());
2607        // Only one position so concentration = 100%
2608        let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2609        assert_eq!(pct, dec!(100));
2610    }
2611
2612    #[test]
2613    fn test_concentration_pct_two_equal_positions() {
2614        let mut ledger = PositionLedger::new(dec!(100000));
2615        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2616        ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0"));
2617        let sym = Symbol::new("AAPL").unwrap();
2618        let mut prices = HashMap::new();
2619        prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2620        prices.insert("GOOG".to_string(), Price::new(dec!(100)).unwrap());
2621        let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2622        assert_eq!(pct, dec!(50));
2623    }
2624
2625    #[test]
2626    fn test_concentration_pct_missing_price_returns_none() {
2627        let mut ledger = PositionLedger::new(dec!(100000));
2628        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2629        let sym = Symbol::new("AAPL").unwrap();
2630        let prices = HashMap::new(); // empty price map
2631        assert!(ledger.concentration_pct(&sym, &prices).is_none());
2632    }
2633
2634    #[test]
2635    fn test_avg_realized_pnl_per_symbol_none_when_empty() {
2636        let ledger = PositionLedger::new(dec!(100000));
2637        assert!(ledger.avg_realized_pnl_per_symbol().is_none());
2638    }
2639
2640    #[test]
2641    fn test_avg_realized_pnl_per_symbol_with_closed_trade() {
2642        let mut ledger = PositionLedger::new(dec!(100000));
2643        // Buy 10 @ 100, sell 10 @ 110 → realized = +100
2644        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2645        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"));
2646        let avg = ledger.avg_realized_pnl_per_symbol().unwrap();
2647        assert_eq!(avg, dec!(100));
2648    }
2649
2650    #[test]
2651    fn test_net_exposure_no_prices_returns_none() {
2652        let mut ledger = PositionLedger::new(dec!(100000));
2653        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2654        let prices = HashMap::new();
2655        assert!(ledger.net_market_exposure(&prices).is_none());
2656    }
2657
2658    #[test]
2659    fn test_net_exposure_long_only() {
2660        let mut ledger = PositionLedger::new(dec!(100000));
2661        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2662        let mut prices = HashMap::new();
2663        prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2664        assert_eq!(ledger.net_market_exposure(&prices).unwrap(), dec!(1100));
2665    }
2666
2667    #[test]
2668    fn test_win_rate_none_when_empty() {
2669        let ledger = PositionLedger::new(dec!(100000));
2670        assert!(ledger.win_rate().is_none());
2671    }
2672
2673    #[test]
2674    fn test_win_rate_one_winner() {
2675        let mut ledger = PositionLedger::new(dec!(100000));
2676        // Buy and sell AAPL for +100 realized
2677        ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2678        ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"));
2679        // GOOG still open at cost (realized=0)
2680        ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0"));
2681        let rate = ledger.win_rate().unwrap();
2682        // 1 winner (AAPL) out of 2 positions = 50%
2683        assert_eq!(rate, dec!(50));
2684    }
2685}