1use crate::error::FinError;
17use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
18use rust_decimal::Decimal;
19use std::collections::HashMap;
20
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct Fill {
24 pub symbol: Symbol,
26 pub side: Side,
28 pub quantity: Quantity,
30 pub price: Price,
32 pub timestamp: NanoTimestamp,
34 pub commission: Decimal,
36}
37
38impl Fill {
39 pub fn new(
41 symbol: Symbol,
42 side: Side,
43 quantity: Quantity,
44 price: Price,
45 timestamp: NanoTimestamp,
46 ) -> Self {
47 Self {
48 symbol,
49 side,
50 quantity,
51 price,
52 timestamp,
53 commission: Decimal::ZERO,
54 }
55 }
56
57 pub fn with_commission(
59 symbol: Symbol,
60 side: Side,
61 quantity: Quantity,
62 price: Price,
63 timestamp: NanoTimestamp,
64 commission: Decimal,
65 ) -> Self {
66 Self {
67 symbol,
68 side,
69 quantity,
70 price,
71 timestamp,
72 commission,
73 }
74 }
75
76 pub fn notional(&self) -> Decimal {
81 self.price.value() * self.quantity.value()
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub enum PositionDirection {
88 Long,
90 Short,
92 Flat,
94}
95
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
98pub struct Position {
99 pub symbol: Symbol,
101 pub quantity: Decimal,
103 pub avg_cost: Decimal,
105 pub realized_pnl: Decimal,
107 #[serde(default)]
109 pub open_bar: usize,
110}
111
112impl Position {
113 pub fn new(symbol: Symbol) -> Self {
115 Self {
116 symbol,
117 quantity: Decimal::ZERO,
118 avg_cost: Decimal::ZERO,
119 realized_pnl: Decimal::ZERO,
120 open_bar: 0,
121 }
122 }
123
124 pub fn set_open_bar(&mut self, bar: usize) {
128 self.open_bar = bar;
129 }
130
131 pub fn position_age_bars(&self, current_bar: usize) -> usize {
135 current_bar.saturating_sub(self.open_bar)
136 }
137
138 pub fn max_favorable_excursion(&self, prices: &[Price]) -> Option<Decimal> {
145 if self.is_flat() || self.avg_cost.is_zero() || prices.is_empty() {
146 return None;
147 }
148 let best = if self.is_long() {
149 prices
150 .iter()
151 .map(|p| (p.value() - self.avg_cost) * self.quantity)
152 .fold(Decimal::MIN, Decimal::max)
153 } else {
154 prices
155 .iter()
156 .map(|p| (self.avg_cost - p.value()) * self.quantity.abs())
157 .fold(Decimal::MIN, Decimal::max)
158 };
159 if best < Decimal::ZERO {
160 Some(Decimal::ZERO)
161 } else {
162 Some(best)
163 }
164 }
165
166 pub fn kelly_fraction(
173 win_rate: Decimal,
174 avg_win: Decimal,
175 avg_loss: Decimal,
176 ) -> Option<Decimal> {
177 if avg_loss.is_zero() || avg_win.is_zero() {
178 return None;
179 }
180 let odds = avg_win / avg_loss;
181 let kelly = win_rate - (Decimal::ONE - win_rate) / odds;
182 Some(kelly.max(Decimal::ZERO).min(Decimal::ONE))
183 }
184
185 pub fn apply_fill(&mut self, fill: &Fill) -> Result<Decimal, FinError> {
193 let fill_qty = match fill.side {
194 Side::Bid => fill.quantity.value(),
195 Side::Ask => -fill.quantity.value(),
196 };
197
198 let realized = if self.quantity != Decimal::ZERO
199 && (self.quantity > Decimal::ZERO) != (fill_qty > Decimal::ZERO)
200 {
201 let closed = fill_qty.abs().min(self.quantity.abs());
202 if self.quantity > Decimal::ZERO {
203 closed * (fill.price.value() - self.avg_cost)
204 } else {
205 closed * (self.avg_cost - fill.price.value())
206 }
207 } else {
208 Decimal::ZERO
209 };
210
211 let new_qty = self.quantity + fill_qty;
212 if new_qty == Decimal::ZERO {
213 self.avg_cost = Decimal::ZERO;
214 } else if (self.quantity >= Decimal::ZERO && fill_qty > Decimal::ZERO)
215 || (self.quantity <= Decimal::ZERO && fill_qty < Decimal::ZERO)
216 {
217 let total_cost =
218 self.avg_cost * self.quantity.abs() + fill.price.value() * fill_qty.abs();
219 self.avg_cost = total_cost
220 .checked_div(new_qty.abs())
221 .ok_or(FinError::ArithmeticOverflow)?;
222 } else if new_qty.abs() <= self.quantity.abs() {
223 } else {
225 self.avg_cost = fill.price.value();
227 }
228
229 self.quantity = new_qty;
230 let net_realized = realized - fill.commission;
231 self.realized_pnl += net_realized;
232 Ok(net_realized)
233 }
234
235 pub fn unrealized_pnl(&self, current_price: Price) -> Decimal {
237 self.quantity * (current_price.value() - self.avg_cost)
238 }
239
240 pub fn checked_unrealized_pnl(&self, current_price: Price) -> Result<Decimal, FinError> {
242 let diff = current_price.value() - self.avg_cost;
243 self.quantity
244 .checked_mul(diff)
245 .ok_or(FinError::ArithmeticOverflow)
246 }
247
248 pub fn unrealized_pnl_pct(&self, current_price: Price) -> Option<Decimal> {
253 if self.is_flat() || self.avg_cost.is_zero() {
254 return None;
255 }
256 let cost_basis = self.quantity.abs() * self.avg_cost;
257 if cost_basis.is_zero() {
258 return None;
259 }
260 let upnl = self.unrealized_pnl(current_price);
261 upnl.checked_div(cost_basis).map(|r| r * Decimal::from(100u32))
262 }
263
264 pub fn total_cost_basis(&self) -> Decimal {
269 self.quantity.abs() * self.avg_cost
270 }
271
272 pub fn market_value(&self, current_price: Price) -> Decimal {
274 self.quantity * current_price.value()
275 }
276
277 pub fn is_flat(&self) -> bool {
279 self.quantity == Decimal::ZERO
280 }
281
282 pub fn is_long(&self) -> bool {
284 self.quantity > Decimal::ZERO
285 }
286
287 pub fn is_short(&self) -> bool {
289 self.quantity < Decimal::ZERO
290 }
291
292 pub fn direction(&self) -> PositionDirection {
294 if self.quantity > Decimal::ZERO {
295 PositionDirection::Long
296 } else if self.quantity < Decimal::ZERO {
297 PositionDirection::Short
298 } else {
299 PositionDirection::Flat
300 }
301 }
302
303 pub fn total_pnl(&self, current_price: Price) -> Decimal {
305 self.realized_pnl + self.unrealized_pnl(current_price)
306 }
307
308 pub fn quantity_abs(&self) -> Decimal {
310 self.quantity.abs()
311 }
312
313 pub fn cost_basis(&self) -> Decimal {
318 self.avg_cost * self.quantity.abs()
319 }
320
321
322 pub fn is_profitable(&self, current_price: Price) -> bool {
324 self.unrealized_pnl(current_price) > Decimal::ZERO
325 }
326
327 pub fn avg_entry_price(&self) -> Option<Price> {
332 Price::new(self.avg_cost).ok()
333 }
334
335 pub fn exposure_pct(&self, current_price: Price, total_portfolio_value: Decimal) -> Option<Decimal> {
342 if total_portfolio_value.is_zero() || self.is_flat() {
343 return None;
344 }
345 let market_value = (self.quantity * current_price.value()).abs();
346 Some(market_value / total_portfolio_value * Decimal::ONE_HUNDRED)
347 }
348
349 pub fn stop_loss_price(&self, stop_pct: Decimal) -> Option<Price> {
362 if self.is_flat() || self.avg_cost.is_zero() {
363 return None;
364 }
365 let factor = stop_pct / Decimal::ONE_HUNDRED;
366 let stop = if self.is_long() {
367 self.avg_cost * (Decimal::ONE - factor)
368 } else {
369 self.avg_cost * (Decimal::ONE + factor)
370 };
371 Price::new(stop).ok()
372 }
373
374 pub fn take_profit_price(&self, tp_pct: Decimal) -> Option<Price> {
380 if self.is_flat() || self.avg_cost.is_zero() {
381 return None;
382 }
383 let factor = tp_pct / Decimal::ONE_HUNDRED;
384 let tp = if self.is_long() {
385 self.avg_cost * (Decimal::ONE + factor)
386 } else {
387 self.avg_cost * (Decimal::ONE - factor)
388 };
389 Price::new(tp).ok()
390 }
391
392 pub fn margin_requirement(&self, margin_pct: Decimal) -> Option<Decimal> {
396 if self.is_flat() || self.avg_cost.is_zero() {
397 return None;
398 }
399 let notional = self.quantity.abs() * self.avg_cost;
400 Some(notional * margin_pct / Decimal::ONE_HUNDRED)
401 }
402
403 pub fn risk_reward_ratio(stop_pct: Decimal, target_pct: Decimal) -> Option<f64> {
408 use rust_decimal::prelude::ToPrimitive;
409 if stop_pct <= Decimal::ZERO {
410 return None;
411 }
412 (target_pct / stop_pct).to_f64()
413 }
414
415 pub fn leverage(&self, portfolio_value: Decimal) -> Option<Decimal> {
419 if self.is_flat() || self.avg_cost.is_zero() || portfolio_value.is_zero() {
420 return None;
421 }
422 let notional = self.quantity.abs() * self.avg_cost;
423 Some(notional / portfolio_value)
424 }
425}
426
427#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
429pub struct PositionLedger {
430 positions: HashMap<Symbol, Position>,
431 cash: Decimal,
432 total_commission_paid: Decimal,
433}
434
435impl PositionLedger {
436 pub fn new(initial_cash: Decimal) -> Self {
438 Self {
439 positions: HashMap::new(),
440 cash: initial_cash,
441 total_commission_paid: Decimal::ZERO,
442 }
443 }
444
445 #[allow(clippy::needless_pass_by_value)]
450 pub fn apply_fill(&mut self, fill: Fill) -> Result<(), FinError> {
451 let cost = match fill.side {
452 Side::Bid => -(fill.quantity.value() * fill.price.value() + fill.commission),
453 Side::Ask => fill.quantity.value() * fill.price.value() - fill.commission,
454 };
455 if fill.side == Side::Bid && self.cash + cost < Decimal::ZERO {
456 return Err(FinError::InsufficientFunds {
457 need: fill.quantity.value() * fill.price.value() + fill.commission,
458 have: self.cash,
459 });
460 }
461 self.cash += cost;
462 self.total_commission_paid += fill.commission;
463 let pos = self
464 .positions
465 .entry(fill.symbol.clone())
466 .or_insert_with(|| Position::new(fill.symbol.clone()));
467 pos.apply_fill(&fill)?;
468 Ok(())
469 }
470
471 pub fn position(&self, symbol: &Symbol) -> Option<&Position> {
473 self.positions.get(symbol)
474 }
475
476 pub fn has_position(&self, symbol: &Symbol) -> bool {
478 self.positions.contains_key(symbol)
479 }
480
481 pub fn positions(&self) -> impl Iterator<Item = &Position> {
483 self.positions.values()
484 }
485
486 pub fn open_positions(&self) -> impl Iterator<Item = &Position> {
488 self.positions.values().filter(|p| !p.is_flat())
489 }
490
491 pub fn flat_positions(&self) -> impl Iterator<Item = &Position> {
493 self.positions.values().filter(|p| p.is_flat())
494 }
495
496 pub fn long_positions(&self) -> impl Iterator<Item = &Position> {
498 self.positions.values().filter(|p| p.is_long())
499 }
500
501 pub fn short_positions(&self) -> impl Iterator<Item = &Position> {
503 self.positions.values().filter(|p| p.is_short())
504 }
505
506 pub fn symbols(&self) -> impl Iterator<Item = &Symbol> {
508 self.positions.keys()
509 }
510
511 pub fn open_symbols(&self) -> impl Iterator<Item = &Symbol> {
513 self.positions
514 .iter()
515 .filter(|(_, p)| !p.is_flat())
516 .map(|(s, _)| s)
517 }
518
519 pub fn total_long_exposure(&self) -> Decimal {
523 self.positions
524 .values()
525 .filter(|p| p.is_long())
526 .map(|p| p.quantity.abs() * p.avg_cost)
527 .sum()
528 }
529
530 pub fn total_short_exposure(&self) -> Decimal {
534 self.positions
535 .values()
536 .filter(|p| p.is_short())
537 .map(|p| p.quantity.abs() * p.avg_cost)
538 .sum()
539 }
540
541 pub fn symbols_sorted(&self) -> Vec<&Symbol> {
545 let mut syms: Vec<&Symbol> = self.positions.keys().collect();
546 syms.sort();
547 syms
548 }
549
550 pub fn position_count(&self) -> usize {
552 self.positions.len()
553 }
554
555 pub fn deposit(&mut self, amount: Decimal) {
560 self.cash += amount;
561 }
562
563 pub fn withdraw(&mut self, amount: Decimal) -> Result<(), FinError> {
568 if amount > self.cash {
569 return Err(FinError::InsufficientFunds {
570 need: amount,
571 have: self.cash,
572 });
573 }
574 self.cash -= amount;
575 Ok(())
576 }
577
578 pub fn open_position_count(&self) -> usize {
580 self.positions.values().filter(|p| !p.is_flat()).count()
581 }
582
583 pub fn long_count(&self) -> usize {
585 self.positions.values().filter(|p| p.quantity > Decimal::ZERO).count()
586 }
587
588 pub fn short_count(&self) -> usize {
590 self.positions.values().filter(|p| p.quantity < Decimal::ZERO).count()
591 }
592
593 pub fn net_exposure(&self) -> Decimal {
598 self.positions.values().map(|p| p.quantity).sum()
599 }
600
601 pub fn net_market_exposure(&self, prices: &std::collections::HashMap<String, Price>) -> Option<Decimal> {
607 let mut found = false;
608 let mut net = Decimal::ZERO;
609 for pos in self.positions.values() {
610 if pos.quantity.is_zero() { continue; }
611 if let Some(&price) = prices.get(pos.symbol.as_str()) {
612 found = true;
613 net += pos.quantity * price.value();
614 }
615 }
616 if found { Some(net) } else { None }
617 }
618
619 pub fn gross_exposure(&self) -> Decimal {
623 self.positions.values().map(|p| p.quantity.abs()).sum()
624 }
625
626 pub fn open_count(&self) -> usize {
631 self.positions.values().filter(|p| !p.is_flat()).count()
632 }
633
634 pub fn largest_position(&self) -> Option<&Position> {
638 self.positions
639 .values()
640 .filter(|p| !p.is_flat())
641 .max_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
642 }
643
644 pub fn total_market_value(
649 &self,
650 prices: &HashMap<String, Price>,
651 ) -> Result<Decimal, FinError> {
652 let mut total = Decimal::ZERO;
653 for (sym, pos) in &self.positions {
654 if pos.quantity == Decimal::ZERO {
655 continue;
656 }
657 let price = prices
658 .get(sym.as_str())
659 .ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
660 total += pos.market_value(*price);
661 }
662 Ok(total)
663 }
664
665 pub fn cash(&self) -> Decimal {
667 self.cash
668 }
669
670 pub fn position_weights(&self, prices: &HashMap<String, Price>) -> Vec<(Symbol, Decimal)> {
676 let mut mv_pairs: Vec<(Symbol, Decimal)> = self
677 .positions
678 .iter()
679 .filter(|(_, p)| !p.is_flat())
680 .filter_map(|(sym, pos)| {
681 let price = prices.get(sym.as_str())?;
682 Some((sym.clone(), pos.market_value(*price).abs()))
683 })
684 .collect();
685 let total: Decimal = mv_pairs.iter().map(|(_, v)| *v).sum();
686 if total.is_zero() {
687 return vec![];
688 }
689 mv_pairs.iter_mut().for_each(|(_, v)| *v /= total);
690 mv_pairs
691 }
692
693 pub fn realized_pnl_total(&self) -> Decimal {
695 self.positions.values().map(|p| p.realized_pnl).sum()
696 }
697
698 pub fn unrealized_pnl_total(
703 &self,
704 prices: &HashMap<String, Price>,
705 ) -> Result<Decimal, FinError> {
706 let mut total = Decimal::ZERO;
707 for (sym, pos) in &self.positions {
708 if pos.quantity == Decimal::ZERO {
709 continue;
710 }
711 let price = prices
712 .get(sym.as_str())
713 .ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
714 total += pos.unrealized_pnl(*price);
715 }
716 Ok(total)
717 }
718
719 pub fn realized_pnl(&self, symbol: &Symbol) -> Option<Decimal> {
721 self.positions.get(symbol).map(|p| p.realized_pnl)
722 }
723
724 pub fn net_pnl(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
729 Ok(self.realized_pnl_total() + self.unrealized_pnl_total(prices)?)
730 }
731
732 pub fn equity(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
737 Ok(self.cash + self.unrealized_pnl_total(prices)?)
738 }
739
740 pub fn net_liquidation_value(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
748 let mut total = self.cash;
749 for (symbol, pos) in &self.positions {
750 if pos.quantity == Decimal::ZERO {
751 continue;
752 }
753 let price = prices
754 .get(symbol.as_str())
755 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
756 total += pos.quantity * price.value();
757 }
758 Ok(total)
759 }
760
761 pub fn pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> Result<HashMap<Symbol, Decimal>, FinError> {
768 let mut map = HashMap::new();
769 for (symbol, pos) in &self.positions {
770 if pos.quantity == Decimal::ZERO {
771 continue;
772 }
773 let price = prices
774 .get(symbol.as_str())
775 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
776 map.insert(symbol.clone(), pos.unrealized_pnl(*price));
777 }
778 Ok(map)
779 }
780
781 pub fn delta_neutral_check(&self, prices: &HashMap<String, Price>) -> Result<bool, FinError> {
789 let mut net = Decimal::ZERO;
790 let mut gross = Decimal::ZERO;
791 for (symbol, pos) in &self.positions {
792 if pos.quantity == Decimal::ZERO {
793 continue;
794 }
795 let price = prices
796 .get(symbol.as_str())
797 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
798 let exposure = pos.quantity * price.value();
799 net += exposure;
800 gross += exposure.abs();
801 }
802 if gross == Decimal::ZERO {
803 return Ok(true);
804 }
805 Ok((net / gross).abs() < Decimal::new(1, 2)) }
807
808 pub fn allocation_pct(
817 &self,
818 symbol: &Symbol,
819 prices: &HashMap<String, Price>,
820 ) -> Result<Option<Decimal>, crate::error::FinError> {
821 let pos = self
822 .positions
823 .get(symbol)
824 .ok_or_else(|| crate::error::FinError::PositionNotFound(symbol.to_string()))?;
825 if pos.quantity == Decimal::ZERO {
826 return Ok(None);
827 }
828 let price = match prices.get(symbol.as_str()) {
829 Some(p) => *p,
830 None => return Ok(None),
831 };
832 let notional = (pos.quantity * price.value()).abs();
833 let total = self.total_market_value(prices)?;
834 if total.is_zero() {
835 return Ok(None);
836 }
837 Ok(Some(notional / total * Decimal::ONE_HUNDRED))
838 }
839
840 pub fn positions_sorted_by_pnl(&self, prices: &HashMap<String, Price>) -> Vec<&Position> {
844 let mut open: Vec<&Position> = self
845 .positions
846 .values()
847 .filter(|p| p.quantity != Decimal::ZERO)
848 .collect();
849 open.sort_by(|a, b| {
850 let pnl_a = prices
851 .get(a.symbol.as_str())
852 .map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
853 let pnl_b = prices
854 .get(b.symbol.as_str())
855 .map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
856 pnl_b.cmp(&pnl_a)
857 });
858 open
859 }
860
861 pub fn top_n_positions<'a>(&'a self, n: usize, prices: &HashMap<String, Price>) -> Vec<&'a Position> {
865 let mut open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
866 open.sort_by(|a, b| {
867 let mv_a = prices.get(a.symbol.as_str())
868 .map_or(Decimal::ZERO, |p| (a.quantity * p.value()).abs());
869 let mv_b = prices.get(b.symbol.as_str())
870 .map_or(Decimal::ZERO, |p| (b.quantity * p.value()).abs());
871 mv_b.cmp(&mv_a)
872 });
873 open.into_iter().take(n).collect()
874 }
875
876 pub fn concentration(&self, prices: &HashMap<String, Price>) -> Result<Option<Decimal>, FinError> {
886 let gross = self.gross_exposure();
887 if gross == Decimal::ZERO {
888 return Ok(None);
889 }
890 let mut hhi = Decimal::ZERO;
891 for (symbol, pos) in &self.positions {
892 if pos.quantity == Decimal::ZERO {
893 continue;
894 }
895 let price = prices
896 .get(symbol.as_str())
897 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
898 let mv = (pos.quantity * price.value()).abs();
899 let w = mv / gross;
900 hhi += w * w;
901 }
902 Ok(Some(hhi))
903 }
904
905 pub fn margin_used(&self, prices: &HashMap<String, Price>, margin_rate: Decimal) -> Result<Decimal, FinError> {
910 let mut gross = Decimal::ZERO;
911 for (symbol, pos) in &self.positions {
912 if pos.quantity == Decimal::ZERO {
913 continue;
914 }
915 let price = prices
916 .get(symbol.as_str())
917 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
918 gross += (pos.quantity * price.value()).abs();
919 }
920 Ok(gross * margin_rate)
921 }
922
923 pub fn flat_count(&self) -> usize {
925 self.positions.values().filter(|p| p.is_flat()).count()
926 }
927
928 pub fn smallest_position(&self) -> Option<&Position> {
932 self.positions
933 .values()
934 .filter(|p| !p.is_flat())
935 .min_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
936 }
937
938 pub fn most_profitable_symbol(
942 &self,
943 prices: &HashMap<String, Price>,
944 ) -> Option<&Symbol> {
945 self.positions
946 .iter()
947 .filter(|(_, p)| !p.is_flat())
948 .filter_map(|(sym, p)| {
949 let price = prices.get(sym.as_str())?;
950 let pnl = p.unrealized_pnl(*price);
951 Some((sym, pnl))
952 })
953 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
954 .map(|(sym, _)| sym)
955 }
956
957 pub fn least_profitable_symbol(
961 &self,
962 prices: &HashMap<String, Price>,
963 ) -> Option<&Symbol> {
964 self.positions
965 .iter()
966 .filter(|(_, p)| !p.is_flat())
967 .filter_map(|(sym, p)| {
968 let price = prices.get(sym.as_str())?;
969 let pnl = p.unrealized_pnl(*price);
970 Some((sym, pnl))
971 })
972 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
973 .map(|(sym, _)| sym)
974 }
975
976 pub fn total_commission_paid(&self) -> Decimal {
978 self.total_commission_paid
979 }
980
981 pub fn symbols_with_pnl(
985 &self,
986 prices: &HashMap<String, Price>,
987 ) -> Vec<(&Symbol, Decimal)> {
988 let mut result: Vec<(&Symbol, Decimal)> = self
989 .positions
990 .iter()
991 .filter(|(_, p)| !p.is_flat())
992 .filter_map(|(sym, p)| {
993 let price = prices.get(sym.as_str())?;
994 Some((sym, p.unrealized_pnl(*price)))
995 })
996 .collect();
997 result.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
998 result
999 }
1000
1001 pub fn concentration_pct(
1006 &self,
1007 symbol: &Symbol,
1008 prices: &HashMap<String, Price>,
1009 ) -> Option<Decimal> {
1010 let pos = self.positions.get(symbol)?;
1011 let price = prices.get(symbol.as_str())?;
1012 let mv = pos.quantity.abs() * price.value();
1013 let total = self
1014 .positions
1015 .values()
1016 .filter_map(|p| {
1017 let pr = prices.get(p.symbol.as_str())?;
1018 Some(p.quantity.abs() * pr.value())
1019 })
1020 .sum::<Decimal>();
1021 if total.is_zero() {
1022 return None;
1023 }
1024 Some(mv / total * Decimal::ONE_HUNDRED)
1025 }
1026
1027 pub fn all_flat(&self) -> bool {
1029 self.positions.values().all(|p| p.is_flat())
1030 }
1031
1032 pub fn long_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1036 self.positions
1037 .iter()
1038 .filter(|(_, p)| p.is_long())
1039 .filter_map(|(sym, p)| {
1040 let price = prices.get(sym.as_str())?;
1041 Some(p.quantity.abs() * price.value())
1042 })
1043 .sum()
1044 }
1045
1046 pub fn short_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1050 self.positions
1051 .iter()
1052 .filter(|(_, p)| p.is_short())
1053 .filter_map(|(sym, p)| {
1054 let price = prices.get(sym.as_str())?;
1055 Some(p.quantity.abs() * price.value())
1056 })
1057 .sum()
1058 }
1059
1060 pub fn net_delta(&self, prices: &HashMap<String, Price>) -> Decimal {
1064 self.long_exposure(prices) - self.short_exposure(prices)
1065 }
1066
1067 pub fn avg_cost_basis(&self, symbol: &Symbol) -> Option<Decimal> {
1069 let pos = self.positions.get(symbol)?;
1070 if pos.is_flat() { return None; }
1071 Some(pos.avg_cost)
1072 }
1073
1074 pub fn active_symbols(&self) -> Vec<&Symbol> {
1076 self.positions
1077 .iter()
1078 .filter(|(_, pos)| !pos.is_flat())
1079 .map(|(sym, _)| sym)
1080 .collect()
1081 }
1082
1083 pub fn symbol_count(&self) -> usize {
1085 self.positions.len()
1086 }
1087
1088 pub fn realized_pnl_by_symbol(&self) -> Vec<(Symbol, Decimal)> {
1093 let mut pairs: Vec<(Symbol, Decimal)> = self
1094 .positions
1095 .iter()
1096 .filter_map(|(sym, pos)| {
1097 let r = pos.realized_pnl;
1098 if r != Decimal::ZERO { Some((sym.clone(), r)) } else { None }
1099 })
1100 .collect();
1101 pairs.sort_by(|a, b| b.1.cmp(&a.1));
1102 pairs
1103 }
1104
1105 pub fn top_losers<'a>(
1110 &'a self,
1111 n: usize,
1112 prices: &HashMap<String, Price>,
1113 ) -> Vec<&'a Position> {
1114 if n == 0 {
1115 return vec![];
1116 }
1117 let mut open: Vec<&Position> =
1118 self.positions.values().filter(|p| !p.is_flat()).collect();
1119 open.sort_by(|a, b| {
1120 let pnl_a = prices
1121 .get(a.symbol.as_str())
1122 .map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
1123 let pnl_b = prices
1124 .get(b.symbol.as_str())
1125 .map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
1126 pnl_a.cmp(&pnl_b) });
1128 open.into_iter().take(n).collect()
1129 }
1130
1131 pub fn flat_symbols(&self) -> Vec<&Symbol> {
1134 let mut syms: Vec<&Symbol> = self.positions
1135 .iter()
1136 .filter_map(|(sym, pos)| if pos.is_flat() { Some(sym) } else { None })
1137 .collect();
1138 syms.sort();
1139 syms
1140 }
1141
1142 pub fn max_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1146 self.positions
1147 .values()
1148 .filter(|p| !p.is_flat())
1149 .filter_map(|p| {
1150 let price = prices.get(p.symbol.as_str()).copied()?;
1151 let upnl = p.unrealized_pnl(price);
1152 if upnl < Decimal::ZERO { Some(upnl) } else { None }
1153 })
1154 .min_by(|a, b| a.cmp(b))
1155 }
1156
1157 pub fn largest_winner<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1161 self.positions
1162 .values()
1163 .filter(|p| !p.is_flat())
1164 .filter_map(|p| {
1165 let price = prices.get(p.symbol.as_str()).copied()?;
1166 let upnl = p.unrealized_pnl(price);
1167 if upnl > Decimal::ZERO { Some((p, upnl)) } else { None }
1168 })
1169 .max_by(|a, b| a.1.cmp(&b.1))
1170 .map(|(p, _)| p)
1171 }
1172
1173 pub fn largest_loser<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1177 self.positions
1178 .values()
1179 .filter(|p| !p.is_flat())
1180 .filter_map(|p| {
1181 let price = prices.get(p.symbol.as_str()).copied()?;
1182 let upnl = p.unrealized_pnl(price);
1183 if upnl < Decimal::ZERO { Some((p, upnl)) } else { None }
1184 })
1185 .min_by(|a, b| a.1.cmp(&b.1))
1186 .map(|(p, _)| p)
1187 }
1188
1189 pub fn gross_market_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1191 self.positions
1192 .values()
1193 .filter(|p| !p.is_flat())
1194 .filter_map(|p| {
1195 let price = prices.get(p.symbol.as_str()).copied()?;
1196 Some(p.market_value(price).abs())
1197 })
1198 .sum()
1199 }
1200
1201 pub fn largest_position_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1205 let total = self.gross_market_exposure(prices);
1206 if total.is_zero() { return None; }
1207 let max_mv = self.positions
1208 .values()
1209 .filter(|p| !p.is_flat())
1210 .filter_map(|p| {
1211 let price = prices.get(p.symbol.as_str()).copied()?;
1212 Some(p.market_value(price).abs())
1213 })
1214 .max_by(|a, b| a.cmp(b))?;
1215 Some(max_mv / total * Decimal::from(100u32))
1216 }
1217
1218 pub fn unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1224 let total_upnl = self.unrealized_pnl_total(prices).ok()?;
1225 let total_cost: Decimal = self.positions
1226 .values()
1227 .filter(|p| !p.is_flat())
1228 .map(|p| p.cost_basis().abs())
1229 .sum();
1230 if total_cost.is_zero() { return None; }
1231 Some(total_upnl / total_cost * Decimal::from(100u32))
1232 }
1233
1234 pub fn symbols_up<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
1238 self.positions
1239 .values()
1240 .filter(|p| !p.is_flat())
1241 .filter(|p| {
1242 prices.get(p.symbol.as_str())
1243 .map(|&price| p.unrealized_pnl(price) > Decimal::ZERO)
1244 .unwrap_or(false)
1245 })
1246 .map(|p| &p.symbol)
1247 .collect()
1248 }
1249
1250 pub fn symbols_down<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
1254 self.positions
1255 .values()
1256 .filter(|p| !p.is_flat())
1257 .filter(|p| {
1258 prices.get(p.symbol.as_str())
1259 .map(|&price| p.unrealized_pnl(price) < Decimal::ZERO)
1260 .unwrap_or(false)
1261 })
1262 .map(|p| &p.symbol)
1263 .collect()
1264 }
1265
1266 pub fn largest_unrealized_gain<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1271 self.largest_winner(prices)
1272 }
1273
1274 pub fn avg_realized_pnl_per_symbol(&self) -> Option<Decimal> {
1278 if self.positions.is_empty() { return None; }
1279 let total: Decimal = self.positions.values().map(|p| p.realized_pnl).sum();
1280 #[allow(clippy::cast_possible_truncation)]
1281 Some(total / Decimal::from(self.positions.len() as u32))
1282 }
1283
1284 pub fn win_rate(&self) -> Option<Decimal> {
1291 if self.positions.is_empty() { return None; }
1292 let total = self.positions.len();
1293 let winners = self.positions.values()
1294 .filter(|p| p.realized_pnl > Decimal::ZERO)
1295 .count();
1296 #[allow(clippy::cast_possible_truncation)]
1297 Some(Decimal::from(winners as u32) / Decimal::from(total as u32) * Decimal::from(100u32))
1298 }
1299
1300 pub fn net_pnl_excluding(
1305 &self,
1306 exclude: &Symbol,
1307 prices: &HashMap<String, Price>,
1308 ) -> Result<Decimal, FinError> {
1309 let total = self.net_pnl(prices)?;
1310 let excluded_rpnl = self.realized_pnl(exclude).unwrap_or(Decimal::ZERO);
1311 let excluded_upnl = if let Some(pos) = self.positions.get(exclude) {
1312 if !pos.is_flat() {
1313 let price = prices.get(exclude.as_str())
1314 .copied()
1315 .ok_or_else(|| FinError::InvalidSymbol(exclude.as_str().to_string()))?;
1316 pos.unrealized_pnl(price)
1317 } else {
1318 Decimal::ZERO
1319 }
1320 } else {
1321 Decimal::ZERO
1322 };
1323 Ok(total - excluded_rpnl - excluded_upnl)
1324 }
1325
1326 pub fn long_short_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1332 let long_exp = self.long_exposure(prices);
1333 let short_exp = self.short_exposure(prices).abs();
1334 if short_exp.is_zero() { return None; }
1335 long_exp.checked_div(short_exp)
1336 }
1337
1338 pub fn position_count_by_direction(&self) -> (usize, usize) {
1340 let longs = self.positions.values()
1341 .filter(|p| !p.is_flat() && p.quantity > Decimal::ZERO)
1342 .count();
1343 let shorts = self.positions.values()
1344 .filter(|p| !p.is_flat() && p.quantity < Decimal::ZERO)
1345 .count();
1346 (longs, shorts)
1347 }
1348
1349 pub fn max_position_age_bars(&self, current_bar: usize) -> Option<usize> {
1353 self.positions.values()
1354 .filter(|p| !p.is_flat())
1355 .map(|p| p.position_age_bars(current_bar))
1356 .max()
1357 }
1358
1359 pub fn avg_position_age_bars(&self, current_bar: usize) -> Option<Decimal> {
1363 let ages: Vec<usize> = self.positions.values()
1364 .filter(|p| !p.is_flat())
1365 .map(|p| p.position_age_bars(current_bar))
1366 .collect();
1367 if ages.is_empty() { return None; }
1368 let sum: usize = ages.iter().sum();
1369 Some(Decimal::from(sum as u64) / Decimal::from(ages.len() as u64))
1370 }
1371
1372 pub fn hhi_concentration(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1379 let open_positions: Vec<_> = self.positions.values()
1380 .filter(|p| !p.is_flat())
1381 .collect();
1382 if open_positions.is_empty() { return None; }
1383 let mvs: Vec<Decimal> = open_positions.iter()
1384 .filter_map(|p| {
1385 prices.get(p.symbol.as_str())
1386 .map(|&price| p.market_value(price).abs())
1387 })
1388 .collect();
1389 let total: Decimal = mvs.iter().sum();
1390 if total.is_zero() { return None; }
1391 Some(mvs.iter().map(|mv| {
1392 let w = mv / total;
1393 w * w
1394 }).sum())
1395 }
1396
1397 pub fn long_short_pnl_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1402 let long_pnl: Decimal = self.positions.values()
1403 .filter(|p| p.is_long())
1404 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1405 .sum();
1406 let short_pnl: Decimal = self.positions.values()
1407 .filter(|p| p.is_short())
1408 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1409 .sum();
1410 let short_abs = short_pnl.abs();
1411 if short_abs.is_zero() { return None; }
1412 Some(long_pnl / short_abs)
1413 }
1414
1415 pub fn unrealized_pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> HashMap<String, Decimal> {
1419 self.positions
1420 .iter()
1421 .filter(|(_, p)| !p.is_flat())
1422 .filter_map(|(sym, p)| {
1423 prices.get(sym.as_str())
1424 .map(|&price| (sym.as_str().to_owned(), p.unrealized_pnl(price)))
1425 })
1426 .collect()
1427 }
1428
1429 pub fn portfolio_beta(
1435 &self,
1436 prices: &HashMap<String, Price>,
1437 betas: &HashMap<String, f64>,
1438 ) -> Option<f64> {
1439 use rust_decimal::prelude::ToPrimitive;
1440 let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1441 if open.is_empty() { return None; }
1442 let total_mv: Decimal = open.iter()
1443 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1444 .sum();
1445 if total_mv.is_zero() { return None; }
1446 let total_mv_f64 = total_mv.to_f64()?;
1447 let beta_sum: f64 = open.iter().filter_map(|p| {
1448 let mv = prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs())?;
1449 let b = betas.get(p.symbol.as_str())?;
1450 let w = mv.to_f64()? / total_mv_f64;
1451 Some(w * b)
1452 }).sum();
1453 Some(beta_sum)
1454 }
1455
1456 pub fn total_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1461 let total: Decimal = self.positions.values()
1462 .filter(|p| !p.is_flat())
1463 .filter_map(|p| {
1464 prices.get(p.symbol.as_str())
1465 .map(|&price| p.quantity_abs() * price.value())
1466 })
1467 .sum();
1468 if total.is_zero() { None } else { Some(total) }
1469 }
1470
1471 pub fn max_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1476 self.positions.values()
1477 .filter(|p| !p.is_flat())
1478 .filter_map(|p| {
1479 prices.get(p.symbol.as_str())
1480 .map(|&price| p.unrealized_pnl(price))
1481 })
1482 .filter(|&pnl| pnl > Decimal::ZERO)
1483 .max()
1484 }
1485
1486 pub fn realized_pnl_rank(&self, symbol: &Symbol) -> Option<usize> {
1491 let target = self.positions.get(symbol).map(|p| p.realized_pnl)?;
1492 if target == Decimal::ZERO { return None; }
1493 let mut sorted: Vec<Decimal> = self.positions.values()
1494 .map(|p| p.realized_pnl)
1495 .filter(|&r| r != Decimal::ZERO)
1496 .collect();
1497 sorted.sort_by(|a, b| b.cmp(a));
1498 sorted.iter().position(|&r| r == target).map(|i| i + 1)
1499 }
1500
1501 pub fn open_positions_vec(&self) -> Vec<&Position> {
1503 let mut open: Vec<&Position> = self.positions.values()
1504 .filter(|p| !p.is_flat())
1505 .collect();
1506 open.sort_by(|a, b| a.symbol.as_str().cmp(b.symbol.as_str()));
1507 open
1508 }
1509
1510 pub fn symbols_with_pnl_above(&self, threshold: Decimal) -> Vec<Symbol> {
1514 let mut pairs: Vec<(Symbol, Decimal)> = self.positions.iter()
1515 .filter_map(|(sym, pos)| {
1516 if pos.realized_pnl > threshold { Some((sym.clone(), pos.realized_pnl)) } else { None }
1517 })
1518 .collect();
1519 pairs.sort_by(|a, b| b.1.cmp(&a.1));
1520 pairs.into_iter().map(|(s, _)| s).collect()
1521 }
1522
1523 pub fn net_long_short_count(&self) -> (usize, usize) {
1525 let long = self.positions.values().filter(|p| p.is_long()).count();
1526 let short = self.positions.values().filter(|p| p.is_short()).count();
1527 (long, short)
1528 }
1529
1530 pub fn largest_open_position(&self) -> Option<&Symbol> {
1534 self.positions.iter()
1535 .filter(|(_, p)| !p.is_flat())
1536 .max_by(|(_, a), (_, b)| a.quantity.abs().cmp(&b.quantity.abs()))
1537 .map(|(sym, _)| sym)
1538 }
1539
1540 pub fn exposure_by_direction(&self, prices: &HashMap<String, Price>) -> (Decimal, Decimal) {
1544 let long: Decimal = self.positions.values()
1545 .filter(|p| p.is_long())
1546 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr)))
1547 .sum();
1548 let short: Decimal = self.positions.values()
1549 .filter(|p| p.is_short())
1550 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1551 .sum();
1552 (long, short)
1553 }
1554
1555 pub fn total_realized_pnl(&self) -> Decimal {
1557 self.positions.values().map(|p| p.realized_pnl).sum()
1558 }
1559
1560 pub fn count_with_pnl_below(&self, threshold: Decimal) -> usize {
1562 self.positions.values().filter(|p| p.realized_pnl < threshold).count()
1563 }
1564
1565 pub fn is_net_long(&self) -> bool {
1567 let net: Decimal = self.positions.values().map(|p| p.quantity).sum();
1568 net > Decimal::ZERO
1569 }
1570
1571 pub fn total_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Decimal {
1575 self.positions.values()
1576 .filter(|p| !p.is_flat())
1577 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1578 .sum()
1579 }
1580
1581 pub fn symbols_flat(&self) -> Vec<&Symbol> {
1583 let mut flat: Vec<&Symbol> = self.positions.iter()
1584 .filter(|(_, p)| p.is_flat())
1585 .map(|(sym, _)| sym)
1586 .collect();
1587 flat.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1588 flat
1589 }
1590
1591 pub fn avg_unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1596 let pcts: Vec<Decimal> = self.positions.values()
1597 .filter(|p| !p.is_flat())
1598 .filter_map(|p| {
1599 prices.get(p.symbol.as_str()).and_then(|&pr| {
1600 let cost_basis = (p.avg_cost * p.quantity).abs();
1601 if cost_basis.is_zero() { return None; }
1602 Some(p.unrealized_pnl(pr) / cost_basis * Decimal::ONE_HUNDRED)
1603 })
1604 })
1605 .collect();
1606 if pcts.is_empty() { return None; }
1607 Some(pcts.iter().sum::<Decimal>() / Decimal::from(pcts.len()))
1608 }
1609
1610 pub fn max_drawdown_symbol<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Symbol> {
1614 self.positions.iter()
1615 .filter(|(_, p)| !p.is_flat())
1616 .filter_map(|(sym, p)| {
1617 prices.get(p.symbol.as_str())
1618 .map(|&price| (sym, p.unrealized_pnl(price)))
1619 })
1620 .min_by(|(_, a), (_, b)| a.cmp(b))
1621 .map(|(sym, _)| sym)
1622 }
1623
1624 pub fn avg_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1628 let pnls: Vec<Decimal> = self.positions.values()
1629 .filter(|p| !p.is_flat())
1630 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1631 .collect();
1632 if pnls.is_empty() { return None; }
1633 #[allow(clippy::cast_possible_truncation)]
1634 Some(pnls.iter().sum::<Decimal>() / Decimal::from(pnls.len() as u32))
1635 }
1636
1637 pub fn position_symbols(&self) -> Vec<&Symbol> {
1639 let mut syms: Vec<&Symbol> = self.positions.keys().collect();
1640 syms.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1641 syms
1642 }
1643
1644 pub fn count_profitable(&self) -> usize {
1646 self.positions.values().filter(|p| p.realized_pnl > Decimal::ZERO).count()
1647 }
1648
1649 pub fn count_losing(&self) -> usize {
1651 self.positions.values().filter(|p| p.realized_pnl < Decimal::ZERO).count()
1652 }
1653
1654 pub fn top_n_by_exposure<'a>(
1657 &'a self,
1658 prices: &HashMap<String, Price>,
1659 n: usize,
1660 ) -> Vec<(&'a Symbol, Decimal)> {
1661 let mut exposures: Vec<(&Symbol, Decimal)> = self.positions.iter()
1662 .filter(|(_, p)| !p.is_flat())
1663 .filter_map(|(sym, p)| {
1664 prices.get(p.symbol.as_str())
1665 .map(|&pr| (sym, (p.quantity * pr.value()).abs()))
1666 })
1667 .collect();
1668 exposures.sort_by(|a, b| b.1.cmp(&a.1));
1669 exposures.truncate(n);
1670 exposures
1671 }
1672
1673 pub fn has_open_positions(&self) -> bool {
1675 self.positions.values().any(|p| !p.is_flat())
1676 }
1677
1678 pub fn long_symbols(&self) -> Vec<&Symbol> {
1680 self.positions.iter()
1681 .filter(|(_, p)| p.quantity > Decimal::ZERO)
1682 .map(|(sym, _)| sym)
1683 .collect()
1684 }
1685
1686 pub fn short_symbols(&self) -> Vec<&Symbol> {
1688 self.positions.iter()
1689 .filter(|(_, p)| p.quantity < Decimal::ZERO)
1690 .map(|(sym, _)| sym)
1691 .collect()
1692 }
1693
1694 pub fn concentration_ratio(&self, prices: &HashMap<String, Price>) -> Option<f64> {
1699 use rust_decimal::prelude::ToPrimitive;
1700 let notionals: Vec<Decimal> = self.positions.values()
1701 .filter(|p| !p.is_flat())
1702 .filter_map(|p| {
1703 prices.get(p.symbol.as_str())
1704 .map(|&pr| (p.quantity * pr.value()).abs())
1705 })
1706 .collect();
1707 if notionals.is_empty() { return None; }
1708 let total: Decimal = notionals.iter().sum();
1709 if total.is_zero() { return None; }
1710 let hhi: f64 = notionals.iter()
1711 .filter_map(|n| (n / total).to_f64())
1712 .map(|w| w * w)
1713 .sum();
1714 Some(hhi)
1715 }
1716
1717 pub fn min_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1721 self.positions.values()
1722 .filter(|p| !p.is_flat())
1723 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1724 .min_by(|a, b| a.cmp(b))
1725 }
1726
1727 pub fn pct_long(&self) -> Option<Decimal> {
1731 let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1732 if open.is_empty() { return None; }
1733 let longs = open.iter().filter(|p| p.quantity > Decimal::ZERO).count() as u32;
1734 Some(Decimal::from(longs) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1735 }
1736
1737 pub fn pct_short(&self) -> Option<Decimal> {
1741 let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1742 if open.is_empty() { return None; }
1743 let shorts = open.iter().filter(|p| p.quantity < Decimal::ZERO).count() as u32;
1744 Some(Decimal::from(shorts) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1745 }
1746
1747 pub fn realized_pnl_total_abs(&self) -> Decimal {
1749 self.positions.values().map(|p| p.realized_pnl.abs()).sum()
1750 }
1751
1752 pub fn average_entry_price(&self, symbol: &Symbol) -> Option<Price> {
1756 self.positions.get(symbol)?.avg_entry_price()
1757 }
1758
1759 pub fn net_quantity(&self) -> Decimal {
1761 self.positions.values().map(|p| p.quantity).sum()
1762 }
1763
1764 pub fn max_long_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1768 self.positions.values()
1769 .filter(|p| p.quantity > Decimal::ZERO)
1770 .filter_map(|p| {
1771 prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1772 })
1773 .max_by(|a, b| a.cmp(b))
1774 }
1775
1776 pub fn max_short_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1780 self.positions.values()
1781 .filter(|p| p.quantity < Decimal::ZERO)
1782 .filter_map(|p| {
1783 prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1784 })
1785 .max_by(|a, b| a.cmp(b))
1786 }
1787
1788 pub fn max_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1792 self.positions.iter()
1793 .map(|(sym, p)| (sym, p.realized_pnl))
1794 .max_by(|(_, a), (_, b)| a.cmp(b))
1795 }
1796
1797 pub fn min_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1801 self.positions.iter()
1802 .map(|(sym, p)| (sym, p.realized_pnl))
1803 .min_by(|(_, a), (_, b)| a.cmp(b))
1804 }
1805
1806 pub fn avg_holding_bars(&self, current_bar: usize) -> Option<f64> {
1811 let open: Vec<usize> = self.positions.values()
1812 .filter(|p| !p.is_flat())
1813 .map(|p| current_bar.saturating_sub(p.open_bar))
1814 .collect();
1815 if open.is_empty() { return None; }
1816 Some(open.iter().sum::<usize>() as f64 / open.len() as f64)
1817 }
1818
1819 pub fn symbols_with_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Vec<&Symbol> {
1821 self.positions.iter()
1822 .filter(|(_, p)| !p.is_flat())
1823 .filter_map(|(sym, p)| {
1824 prices.get(p.symbol.as_str())
1825 .map(|&pr| (sym, p.unrealized_pnl(pr)))
1826 })
1827 .filter(|(_, pnl)| *pnl < Decimal::ZERO)
1828 .map(|(sym, _)| sym)
1829 .collect()
1830 }
1831
1832 pub fn avg_long_entry_price(&self) -> Option<Decimal> {
1835 let longs: Vec<&Position> = self.positions.values()
1836 .filter(|p| p.is_long())
1837 .collect();
1838 if longs.is_empty() { return None; }
1839 let total_qty: Decimal = longs.iter().map(|p| p.quantity.abs()).sum();
1840 if total_qty.is_zero() { return None; }
1841 let weighted: Decimal = longs.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1842 Some(weighted / total_qty)
1843 }
1844
1845 pub fn avg_short_entry_price(&self) -> Option<Decimal> {
1848 let shorts: Vec<&Position> = self.positions.values()
1849 .filter(|p| p.is_short())
1850 .collect();
1851 if shorts.is_empty() { return None; }
1852 let total_qty: Decimal = shorts.iter().map(|p| p.quantity.abs()).sum();
1853 if total_qty.is_zero() { return None; }
1854 let weighted: Decimal = shorts.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1855 Some(weighted / total_qty)
1856 }
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861 use super::*;
1862 use rust_decimal_macros::dec;
1863
1864 fn sym(s: &str) -> Symbol {
1865 Symbol::new(s).unwrap()
1866 }
1867
1868 fn make_fill(symbol: &str, side: Side, qty: &str, p: &str, commission: &str) -> Fill {
1869 Fill {
1870 symbol: sym(symbol),
1871 side,
1872 quantity: Quantity::new(qty.parse().unwrap()).unwrap(),
1873 price: Price::new(p.parse().unwrap()).unwrap(),
1874 timestamp: NanoTimestamp::new(0),
1875 commission: commission.parse().unwrap(),
1876 }
1877 }
1878
1879 #[test]
1880 fn test_position_apply_fill_long() {
1881 let mut pos = Position::new(sym("AAPL"));
1882 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1883 .unwrap();
1884 assert_eq!(pos.quantity, dec!(10));
1885 assert_eq!(pos.avg_cost, dec!(100));
1886 }
1887
1888 #[test]
1889 fn test_position_apply_fill_reduces_position() {
1890 let mut pos = Position::new(sym("AAPL"));
1891 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1892 .unwrap();
1893 pos.apply_fill(&make_fill("AAPL", Side::Ask, "5", "110", "0"))
1894 .unwrap();
1895 assert_eq!(pos.quantity, dec!(5));
1896 }
1897
1898 #[test]
1899 fn test_position_realized_pnl_on_close() {
1900 let mut pos = Position::new(sym("AAPL"));
1901 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1902 .unwrap();
1903 let pnl = pos
1904 .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1905 .unwrap();
1906 assert_eq!(pnl, dec!(100));
1907 assert!(pos.is_flat());
1908 }
1909
1910 #[test]
1911 fn test_position_commission_reduces_realized_pnl() {
1912 let mut pos = Position::new(sym("AAPL"));
1913 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1914 .unwrap();
1915 let pnl = pos
1916 .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "5"))
1917 .unwrap();
1918 assert_eq!(pnl, dec!(95));
1919 }
1920
1921 #[test]
1922 fn test_position_unrealized_pnl() {
1923 let mut pos = Position::new(sym("AAPL"));
1924 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1925 .unwrap();
1926 let upnl = pos.unrealized_pnl(Price::new(dec!(115)).unwrap());
1927 assert_eq!(upnl, dec!(150));
1928 }
1929
1930 #[test]
1931 fn test_position_market_value() {
1932 let mut pos = Position::new(sym("AAPL"));
1933 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1934 .unwrap();
1935 assert_eq!(pos.market_value(Price::new(dec!(120)).unwrap()), dec!(1200));
1936 }
1937
1938 #[test]
1939 fn test_position_is_flat_initially() {
1940 let pos = Position::new(sym("X"));
1941 assert!(pos.is_flat());
1942 }
1943
1944 #[test]
1945 fn test_position_is_flat_after_full_close() {
1946 let mut pos = Position::new(sym("AAPL"));
1947 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1948 .unwrap();
1949 pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1950 .unwrap();
1951 assert!(pos.is_flat());
1952 }
1953
1954 #[test]
1955 fn test_position_avg_cost_weighted_after_two_buys() {
1956 let mut pos = Position::new(sym("X"));
1957 pos.apply_fill(&make_fill("X", Side::Bid, "10", "100", "0"))
1958 .unwrap();
1959 pos.apply_fill(&make_fill("X", Side::Bid, "10", "120", "0"))
1960 .unwrap();
1961 assert_eq!(pos.avg_cost, dec!(110));
1962 }
1963
1964 #[test]
1965 fn test_position_ledger_apply_fill_updates_cash() {
1966 let mut ledger = PositionLedger::new(dec!(10000));
1967 ledger
1968 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "1"))
1969 .unwrap();
1970 assert_eq!(ledger.cash(), dec!(8999));
1971 }
1972
1973 #[test]
1974 fn test_position_ledger_insufficient_funds() {
1975 let mut ledger = PositionLedger::new(dec!(100));
1976 let result = ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
1977 assert!(matches!(result, Err(FinError::InsufficientFunds { .. })));
1978 }
1979
1980 #[test]
1981 fn test_position_ledger_equity_calculation() {
1982 let mut ledger = PositionLedger::new(dec!(10000));
1983 ledger
1984 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
1985 .unwrap();
1986 let mut prices = HashMap::new();
1987 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
1988 let equity = ledger.equity(&prices).unwrap();
1990 assert_eq!(equity, dec!(9100));
1991 }
1992
1993 #[test]
1994 fn test_position_ledger_net_liquidation_value() {
1995 let mut ledger = PositionLedger::new(dec!(10000));
1997 ledger
1998 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
1999 .unwrap();
2000 let mut prices = HashMap::new();
2001 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2002 let nlv = ledger.net_liquidation_value(&prices).unwrap();
2004 assert_eq!(nlv, dec!(10100));
2005 }
2006
2007 #[test]
2008 fn test_position_ledger_net_liquidation_missing_price() {
2009 let mut ledger = PositionLedger::new(dec!(10000));
2010 ledger
2011 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2012 .unwrap();
2013 let prices: HashMap<String, Price> = HashMap::new();
2014 assert!(ledger.net_liquidation_value(&prices).is_err());
2015 }
2016
2017 #[test]
2018 fn test_position_ledger_pnl_by_symbol() {
2019 let mut ledger = PositionLedger::new(dec!(10000));
2020 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2021 ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2022 let mut prices = HashMap::new();
2023 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2024 prices.insert("GOOG".to_owned(), Price::new(dec!(190)).unwrap());
2025 let pnl = ledger.pnl_by_symbol(&prices).unwrap();
2026 assert_eq!(*pnl.get(&sym("AAPL")).unwrap(), dec!(100)); assert_eq!(*pnl.get(&sym("GOOG")).unwrap(), dec!(-50)); }
2029
2030 #[test]
2031 fn test_position_ledger_pnl_by_symbol_missing_price() {
2032 let mut ledger = PositionLedger::new(dec!(10000));
2033 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2034 let prices: HashMap<String, Price> = HashMap::new();
2035 assert!(ledger.pnl_by_symbol(&prices).is_err());
2036 }
2037
2038 #[test]
2039 fn test_position_ledger_delta_neutral_no_positions() {
2040 let ledger = PositionLedger::new(dec!(10000));
2041 let prices: HashMap<String, Price> = HashMap::new();
2042 assert!(ledger.delta_neutral_check(&prices).unwrap());
2043 }
2044
2045 #[test]
2046 fn test_position_ledger_delta_neutral_long_short_balanced() {
2047 let mut ledger = PositionLedger::new(dec!(10000));
2048 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2049 ledger.apply_fill(make_fill("GOOG", Side::Ask, "10", "100", "0")).unwrap();
2050 let mut prices = HashMap::new();
2051 prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2052 prices.insert("GOOG".to_owned(), Price::new(dec!(100)).unwrap());
2053 assert!(ledger.delta_neutral_check(&prices).unwrap());
2055 }
2056
2057 #[test]
2058 fn test_position_ledger_delta_neutral_one_sided_not_neutral() {
2059 let mut ledger = PositionLedger::new(dec!(10000));
2060 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2061 let mut prices = HashMap::new();
2062 prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2063 assert!(!ledger.delta_neutral_check(&prices).unwrap());
2065 }
2066
2067 #[test]
2068 fn test_position_ledger_open_count_zero_when_empty() {
2069 assert_eq!(PositionLedger::new(dec!(10000)).open_count(), 0);
2070 }
2071
2072 #[test]
2073 fn test_position_ledger_open_count_tracks_positions() {
2074 let mut ledger = PositionLedger::new(dec!(10000));
2075 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2076 assert_eq!(ledger.open_count(), 1);
2077 ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2078 assert_eq!(ledger.open_count(), 2);
2079 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "105", "0")).unwrap();
2081 assert_eq!(ledger.open_count(), 1);
2082 }
2083
2084 #[test]
2085 fn test_position_ledger_sell_increases_cash() {
2086 let mut ledger = PositionLedger::new(dec!(10000));
2087 ledger
2088 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2089 .unwrap();
2090 ledger
2091 .apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"))
2092 .unwrap();
2093 assert_eq!(ledger.cash(), dec!(10100));
2094 }
2095
2096 #[test]
2097 fn test_position_checked_unrealized_pnl_matches() {
2098 let mut pos = Position::new(sym("AAPL"));
2099 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2100 .unwrap();
2101 let price = Price::new(dec!(115)).unwrap();
2102 let checked = pos.checked_unrealized_pnl(price).unwrap();
2103 let unchecked = pos.unrealized_pnl(price);
2104 assert_eq!(checked, unchecked);
2105 assert_eq!(checked, dec!(150));
2106 }
2107
2108 #[test]
2109 fn test_position_checked_unrealized_pnl_flat_position() {
2110 let pos = Position::new(sym("X"));
2111 let price = Price::new(dec!(100)).unwrap();
2112 assert_eq!(pos.checked_unrealized_pnl(price).unwrap(), dec!(0));
2113 }
2114
2115 #[test]
2116 fn test_position_direction_flat() {
2117 let pos = Position::new(sym("X"));
2118 assert_eq!(pos.direction(), PositionDirection::Flat);
2119 }
2120
2121 #[test]
2122 fn test_position_direction_long() {
2123 let mut pos = Position::new(sym("X"));
2124 pos.apply_fill(&make_fill("X", Side::Bid, "5", "100", "0"))
2125 .unwrap();
2126 assert_eq!(pos.direction(), PositionDirection::Long);
2127 }
2128
2129 #[test]
2130 fn test_position_direction_short() {
2131 let mut pos = Position::new(sym("X"));
2132 pos.apply_fill(&make_fill("X", Side::Ask, "5", "100", "0"))
2134 .unwrap();
2135 assert_eq!(pos.direction(), PositionDirection::Short);
2136 }
2137
2138 #[test]
2139 fn test_position_ledger_positions_iterator() {
2140 let mut ledger = PositionLedger::new(dec!(10000));
2141 ledger
2142 .apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0"))
2143 .unwrap();
2144 ledger
2145 .apply_fill(make_fill("MSFT", Side::Bid, "1", "200", "0"))
2146 .unwrap();
2147 let count = ledger.positions().count();
2148 assert_eq!(count, 2);
2149 }
2150
2151 #[test]
2152 fn test_position_ledger_total_market_value() {
2153 let mut ledger = PositionLedger::new(dec!(10000));
2154 ledger
2155 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2156 .unwrap();
2157 ledger
2158 .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2159 .unwrap();
2160 let mut prices = HashMap::new();
2161 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2162 prices.insert("MSFT".to_owned(), Price::new(dec!(210)).unwrap());
2163 let mv = ledger.total_market_value(&prices).unwrap();
2165 assert_eq!(mv, dec!(2150));
2166 }
2167
2168 #[test]
2169 fn test_position_ledger_total_market_value_missing_price() {
2170 let mut ledger = PositionLedger::new(dec!(10000));
2171 ledger
2172 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2173 .unwrap();
2174 let prices: HashMap<String, Price> = HashMap::new();
2175 assert!(matches!(
2176 ledger.total_market_value(&prices),
2177 Err(FinError::PositionNotFound(_))
2178 ));
2179 }
2180
2181 #[test]
2182 fn test_position_ledger_unrealized_pnl_total() {
2183 let mut ledger = PositionLedger::new(dec!(10000));
2184 ledger
2185 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2186 .unwrap();
2187 let mut prices = HashMap::new();
2188 prices.insert("AAPL".to_owned(), Price::new(dec!(105)).unwrap());
2189 let upnl = ledger.unrealized_pnl_total(&prices).unwrap();
2190 assert_eq!(upnl, dec!(50));
2191 }
2192
2193 #[test]
2194 fn test_position_ledger_position_count_includes_flat() {
2195 let mut ledger = PositionLedger::new(dec!(10000));
2196 ledger
2198 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2199 .unwrap();
2200 ledger
2201 .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2202 .unwrap();
2203 ledger
2205 .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2206 .unwrap();
2207 assert_eq!(ledger.position_count(), 2, "both symbols tracked");
2208 assert_eq!(ledger.open_position_count(), 1, "only MSFT open");
2209 }
2210
2211 #[test]
2212 fn test_position_ledger_position_count_zero_on_empty() {
2213 let ledger = PositionLedger::new(dec!(10000));
2214 assert_eq!(ledger.position_count(), 0);
2215 }
2216
2217 #[test]
2218 fn test_position_unrealized_pnl_pct_long_gain() {
2219 let mut pos = Position::new(sym("AAPL"));
2220 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2221 .unwrap();
2222 let current = Price::new(dec!(110)).unwrap();
2223 let pct = pos.unrealized_pnl_pct(current).unwrap();
2224 assert_eq!(pct, dec!(10));
2225 }
2226
2227 #[test]
2228 fn test_position_unrealized_pnl_pct_flat_returns_none() {
2229 let pos = Position::new(sym("AAPL"));
2230 let current = Price::new(dec!(110)).unwrap();
2231 assert!(pos.unrealized_pnl_pct(current).is_none());
2232 }
2233
2234 #[test]
2235 fn test_position_unrealized_pnl_pct_loss() {
2236 let mut pos = Position::new(sym("AAPL"));
2237 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2238 .unwrap();
2239 let current = Price::new(dec!(90)).unwrap();
2240 let pct = pos.unrealized_pnl_pct(current).unwrap();
2241 assert_eq!(pct, dec!(-10));
2242 }
2243
2244 #[test]
2245 fn test_position_ledger_open_positions_excludes_flat() {
2246 let mut ledger = PositionLedger::new(dec!(10000));
2247 ledger
2248 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2249 .unwrap();
2250 ledger
2251 .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2252 .unwrap();
2253 ledger
2254 .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2255 .unwrap();
2256 let open: Vec<_> = ledger.open_positions().collect();
2257 assert_eq!(open.len(), 1);
2258 assert_eq!(open[0].symbol.as_str(), "MSFT");
2259 }
2260
2261 #[test]
2262 fn test_position_ledger_open_positions_empty_when_all_flat() {
2263 let mut ledger = PositionLedger::new(dec!(10000));
2264 ledger
2265 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2266 .unwrap();
2267 ledger
2268 .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2269 .unwrap();
2270 let open: Vec<_> = ledger.open_positions().collect();
2271 assert!(open.is_empty());
2272 }
2273
2274 #[test]
2275 fn test_position_is_long() {
2276 let mut pos = Position::new(sym("AAPL"));
2277 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2278 .unwrap();
2279 assert!(pos.is_long());
2280 assert!(!pos.is_short());
2281 assert!(!pos.is_flat());
2282 }
2283
2284 #[test]
2285 fn test_position_is_short() {
2286 let mut pos = Position::new(sym("AAPL"));
2287 pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2288 .unwrap();
2289 assert!(pos.is_short());
2290 assert!(!pos.is_long());
2291 assert!(!pos.is_flat());
2292 }
2293
2294 #[test]
2295 fn test_position_is_flat_after_close() {
2296 let mut pos = Position::new(sym("AAPL"));
2297 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2298 .unwrap();
2299 pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2300 .unwrap();
2301 assert!(pos.is_flat());
2302 assert!(!pos.is_long());
2303 assert!(!pos.is_short());
2304 }
2305
2306 #[test]
2307 fn test_position_ledger_flat_positions() {
2308 let mut ledger = PositionLedger::new(dec!(10000));
2309 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2311 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2312 ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0")).unwrap();
2314 let flat: Vec<_> = ledger.flat_positions().collect();
2315 assert_eq!(flat.len(), 1);
2316 assert_eq!(flat[0].symbol, sym("AAPL"));
2317 }
2318
2319 #[test]
2320 fn test_position_ledger_flat_positions_empty_when_all_open() {
2321 let mut ledger = PositionLedger::new(dec!(10000));
2322 ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2323 assert_eq!(ledger.flat_positions().count(), 0);
2324 }
2325
2326 #[test]
2327 fn test_position_ledger_deposit_increases_cash() {
2328 let mut ledger = PositionLedger::new(dec!(1000));
2329 ledger.deposit(dec!(500));
2330 assert_eq!(ledger.cash(), dec!(1500));
2331 }
2332
2333 #[test]
2334 fn test_position_ledger_withdraw_decreases_cash() {
2335 let mut ledger = PositionLedger::new(dec!(1000));
2336 ledger.withdraw(dec!(300)).unwrap();
2337 assert_eq!(ledger.cash(), dec!(700));
2338 }
2339
2340 #[test]
2341 fn test_position_ledger_withdraw_insufficient_fails() {
2342 let mut ledger = PositionLedger::new(dec!(100));
2343 assert!(matches!(
2344 ledger.withdraw(dec!(200)),
2345 Err(FinError::InsufficientFunds { .. })
2346 ));
2347 assert_eq!(ledger.cash(), dec!(100), "cash unchanged on failure");
2348 }
2349
2350 #[test]
2351 fn test_position_is_profitable_true() {
2352 let mut pos = Position::new(sym("AAPL"));
2353 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2354 .unwrap();
2355 let current = Price::new(dec!(110)).unwrap();
2356 assert!(pos.is_profitable(current));
2357 }
2358
2359 #[test]
2360 fn test_position_is_profitable_false_when_at_loss() {
2361 let mut pos = Position::new(sym("AAPL"));
2362 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2363 .unwrap();
2364 let current = Price::new(dec!(90)).unwrap();
2365 assert!(!pos.is_profitable(current));
2366 }
2367
2368 #[test]
2369 fn test_position_ledger_long_positions() {
2370 let mut ledger = PositionLedger::new(dec!(10000));
2371 ledger
2372 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2373 .unwrap();
2374 let longs: Vec<_> = ledger.long_positions().collect();
2375 assert_eq!(longs.len(), 1);
2376 assert_eq!(longs[0].symbol.as_str(), "AAPL");
2377 }
2378
2379 #[test]
2380 fn test_position_ledger_short_positions_empty_for_long_only() {
2381 let mut ledger = PositionLedger::new(dec!(10000));
2382 ledger
2383 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2384 .unwrap();
2385 let shorts: Vec<_> = ledger.short_positions().collect();
2386 assert!(shorts.is_empty());
2387 }
2388
2389 #[test]
2390 fn test_position_ledger_realized_pnl_after_close() {
2391 let mut ledger = PositionLedger::new(dec!(10000));
2392 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2393 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2394 assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(100)));
2395 }
2396
2397 #[test]
2398 fn test_position_ledger_realized_pnl_unknown_symbol_returns_none() {
2399 let ledger = PositionLedger::new(dec!(10000));
2400 assert!(ledger.realized_pnl(&sym("AAPL")).is_none());
2401 }
2402
2403 #[test]
2404 fn test_position_ledger_realized_pnl_zero_before_close() {
2405 let mut ledger = PositionLedger::new(dec!(10000));
2406 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2407 assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(0)));
2408 }
2409
2410 #[test]
2411 fn test_position_ledger_symbols_sorted_order() {
2412 let mut ledger = PositionLedger::new(dec!(10000));
2413 ledger.apply_fill(make_fill("MSFT", Side::Bid, "1", "100", "0")).unwrap();
2414 ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2415 ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "100", "0")).unwrap();
2416 let sorted = ledger.symbols_sorted();
2417 let names: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
2418 assert_eq!(names, vec!["AAPL", "GOOG", "MSFT"]);
2419 }
2420
2421 #[test]
2422 fn test_position_ledger_symbols_sorted_empty() {
2423 let ledger = PositionLedger::new(dec!(10000));
2424 assert!(ledger.symbols_sorted().is_empty());
2425 }
2426
2427 #[test]
2428 fn test_position_avg_entry_price_long() {
2429 let sym = Symbol::new("AAPL").unwrap();
2430 let mut pos = Position::new(sym.clone());
2431 let fill = Fill::new(
2432 sym,
2433 Side::Bid,
2434 Quantity::new(dec!(10)).unwrap(),
2435 Price::new(dec!(150)).unwrap(),
2436 NanoTimestamp::new(0),
2437 );
2438 pos.apply_fill(&fill).unwrap();
2439 assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(150));
2440 }
2441
2442 #[test]
2443 fn test_position_avg_entry_price_flat_returns_none() {
2444 let sym = Symbol::new("AAPL").unwrap();
2445 let pos = Position::new(sym);
2446 assert!(pos.avg_entry_price().is_none());
2447 }
2448
2449 #[test]
2450 fn test_position_avg_entry_price_after_partial_close() {
2451 let sym = Symbol::new("X").unwrap();
2452 let mut pos = Position::new(sym.clone());
2453 pos.apply_fill(&Fill::new(sym.clone(), Side::Bid,
2454 Quantity::new(dec!(10)).unwrap(), Price::new(dec!(100)).unwrap(),
2455 NanoTimestamp::new(0))).unwrap();
2456 pos.apply_fill(&Fill::new(sym.clone(), Side::Ask,
2457 Quantity::new(dec!(5)).unwrap(), Price::new(dec!(100)).unwrap(),
2458 NanoTimestamp::new(1))).unwrap();
2459 assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(100));
2461 }
2462
2463 #[test]
2464 fn test_position_ledger_has_position_true_after_fill() {
2465 let mut ledger = PositionLedger::new(dec!(10000));
2466 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2467 assert!(ledger.has_position(&sym("AAPL")));
2468 }
2469
2470 #[test]
2471 fn test_position_ledger_has_position_false_for_unknown() {
2472 let ledger = PositionLedger::new(dec!(10000));
2473 assert!(!ledger.has_position(&sym("AAPL")));
2474 }
2475
2476 #[test]
2477 fn test_position_ledger_has_position_true_even_when_flat() {
2478 let mut ledger = PositionLedger::new(dec!(10000));
2479 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2480 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2481 assert!(ledger.has_position(&sym("AAPL")));
2483 }
2484
2485 #[test]
2486 fn test_position_ledger_open_symbols_returns_non_flat() {
2487 let mut ledger = PositionLedger::new(dec!(10000));
2488 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2489 ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "1")).unwrap();
2490 let symbols: Vec<_> = ledger.open_symbols().collect();
2491 assert_eq!(symbols.len(), 2);
2492 }
2493
2494 #[test]
2495 fn test_position_ledger_open_symbols_excludes_flat() {
2496 let mut ledger = PositionLedger::new(dec!(10000));
2497 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2498 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap(); ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "2")).unwrap();
2500 let symbols: Vec<_> = ledger.open_symbols().collect();
2501 assert_eq!(symbols.len(), 1);
2502 assert_eq!(symbols[0].as_str(), "MSFT");
2503 }
2504
2505 #[test]
2506 fn test_position_ledger_open_symbols_empty_when_all_flat() {
2507 let mut ledger = PositionLedger::new(dec!(10000));
2508 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2509 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap();
2510 let symbols: Vec<_> = ledger.open_symbols().collect();
2511 assert!(symbols.is_empty());
2512 }
2513
2514 #[test]
2515 fn test_position_ledger_total_long_exposure() {
2516 let mut ledger = PositionLedger::new(dec!(100000));
2517 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2518 assert_eq!(ledger.total_long_exposure(), dec!(1000));
2520 }
2521
2522 #[test]
2523 fn test_position_ledger_total_long_exposure_zero_when_flat() {
2524 let ledger = PositionLedger::new(dec!(10000));
2525 assert_eq!(ledger.total_long_exposure(), dec!(0));
2526 }
2527
2528 #[test]
2529 fn test_position_ledger_total_short_exposure_zero_when_no_shorts() {
2530 let mut ledger = PositionLedger::new(dec!(100000));
2531 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2532 assert_eq!(ledger.total_short_exposure(), dec!(0));
2533 }
2534
2535 #[test]
2536 fn test_allocation_pct_single_position() {
2537 let mut ledger = PositionLedger::new(dec!(100000));
2538 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2539 let mut prices = HashMap::new();
2540 let sym = Symbol::new("AAPL").unwrap();
2541 prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2542 let pct = ledger.allocation_pct(&sym, &prices).unwrap();
2543 assert_eq!(pct, Some(dec!(100)));
2545 }
2546
2547 #[test]
2548 fn test_allocation_pct_flat_position_returns_none() {
2549 let ledger = PositionLedger::new(dec!(100000));
2550 let mut prices = HashMap::new();
2551 let sym = Symbol::new("AAPL").unwrap();
2552 prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2553 assert!(ledger.allocation_pct(&sym, &prices).is_err());
2555 }
2556
2557 #[test]
2558 fn test_positions_sorted_by_pnl_descending() {
2559 let mut ledger = PositionLedger::new(dec!(100000));
2560 ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2561 ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "200", "0")).unwrap();
2562 let mut prices = HashMap::new();
2563 prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2565 prices.insert("GOOG".to_string(), Price::new(dec!(250)).unwrap());
2566 let sorted = ledger.positions_sorted_by_pnl(&prices);
2567 assert_eq!(sorted[0].symbol.as_str(), "GOOG");
2569 assert_eq!(sorted[1].symbol.as_str(), "AAPL");
2570 }
2571
2572 #[test]
2573 fn test_positions_sorted_by_pnl_empty_when_all_flat() {
2574 let ledger = PositionLedger::new(dec!(100000));
2575 let prices = HashMap::new();
2576 assert!(ledger.positions_sorted_by_pnl(&prices).is_empty());
2577 }
2578
2579 #[test]
2580 fn test_all_flat_initially() {
2581 let ledger = PositionLedger::new(dec!(100000));
2582 assert!(ledger.all_flat());
2583 }
2584
2585 #[test]
2586 fn test_all_flat_false_after_open_position() {
2587 let mut ledger = PositionLedger::new(dec!(100000));
2588 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0"));
2589 assert!(!ledger.all_flat());
2590 }
2591
2592 #[test]
2593 fn test_all_flat_true_after_close_position() {
2594 let mut ledger = PositionLedger::new(dec!(100000));
2595 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0"));
2596 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "155", "0"));
2597 assert!(ledger.all_flat());
2598 }
2599
2600 #[test]
2601 fn test_concentration_pct_single_position() {
2602 let mut ledger = PositionLedger::new(dec!(100000));
2603 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0"));
2604 let sym = Symbol::new("AAPL").unwrap();
2605 let mut prices = HashMap::new();
2606 prices.insert("AAPL".to_string(), Price::new(dec!(150)).unwrap());
2607 let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2609 assert_eq!(pct, dec!(100));
2610 }
2611
2612 #[test]
2613 fn test_concentration_pct_two_equal_positions() {
2614 let mut ledger = PositionLedger::new(dec!(100000));
2615 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2616 ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0"));
2617 let sym = Symbol::new("AAPL").unwrap();
2618 let mut prices = HashMap::new();
2619 prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2620 prices.insert("GOOG".to_string(), Price::new(dec!(100)).unwrap());
2621 let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2622 assert_eq!(pct, dec!(50));
2623 }
2624
2625 #[test]
2626 fn test_concentration_pct_missing_price_returns_none() {
2627 let mut ledger = PositionLedger::new(dec!(100000));
2628 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2629 let sym = Symbol::new("AAPL").unwrap();
2630 let prices = HashMap::new(); assert!(ledger.concentration_pct(&sym, &prices).is_none());
2632 }
2633
2634 #[test]
2635 fn test_avg_realized_pnl_per_symbol_none_when_empty() {
2636 let ledger = PositionLedger::new(dec!(100000));
2637 assert!(ledger.avg_realized_pnl_per_symbol().is_none());
2638 }
2639
2640 #[test]
2641 fn test_avg_realized_pnl_per_symbol_with_closed_trade() {
2642 let mut ledger = PositionLedger::new(dec!(100000));
2643 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2645 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"));
2646 let avg = ledger.avg_realized_pnl_per_symbol().unwrap();
2647 assert_eq!(avg, dec!(100));
2648 }
2649
2650 #[test]
2651 fn test_net_exposure_no_prices_returns_none() {
2652 let mut ledger = PositionLedger::new(dec!(100000));
2653 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2654 let prices = HashMap::new();
2655 assert!(ledger.net_market_exposure(&prices).is_none());
2656 }
2657
2658 #[test]
2659 fn test_net_exposure_long_only() {
2660 let mut ledger = PositionLedger::new(dec!(100000));
2661 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2662 let mut prices = HashMap::new();
2663 prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2664 assert_eq!(ledger.net_market_exposure(&prices).unwrap(), dec!(1100));
2665 }
2666
2667 #[test]
2668 fn test_win_rate_none_when_empty() {
2669 let ledger = PositionLedger::new(dec!(100000));
2670 assert!(ledger.win_rate().is_none());
2671 }
2672
2673 #[test]
2674 fn test_win_rate_one_winner() {
2675 let mut ledger = PositionLedger::new(dec!(100000));
2676 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
2678 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"));
2679 ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0"));
2681 let rate = ledger.win_rate().unwrap();
2682 assert_eq!(rate, dec!(50));
2684 }
2685}