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