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