Skip to main content

fin_primitives/position/
mod.rs

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