1pub mod kelly;
22
23pub use kelly::{fractional_kelly, full_kelly, KellyInput, KellyPortfolio, KellyResult};
24
25use crate::error::FinError;
26use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
27use rust_decimal::Decimal;
28use std::collections::HashMap;
29
30#[derive(Debug, Clone)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct Fill {
34 pub symbol: Symbol,
36 pub side: Side,
38 pub quantity: Quantity,
40 pub price: Price,
42 pub timestamp: NanoTimestamp,
44 pub commission: Decimal,
46}
47
48impl Fill {
49 pub fn new(
51 symbol: Symbol,
52 side: Side,
53 quantity: Quantity,
54 price: Price,
55 timestamp: NanoTimestamp,
56 ) -> Self {
57 Self {
58 symbol,
59 side,
60 quantity,
61 price,
62 timestamp,
63 commission: Decimal::ZERO,
64 }
65 }
66
67 pub fn with_commission(
69 symbol: Symbol,
70 side: Side,
71 quantity: Quantity,
72 price: Price,
73 timestamp: NanoTimestamp,
74 commission: Decimal,
75 ) -> Self {
76 Self {
77 symbol,
78 side,
79 quantity,
80 price,
81 timestamp,
82 commission,
83 }
84 }
85
86 pub fn notional(&self) -> Decimal {
91 self.price.value() * self.quantity.value()
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98pub enum PositionDirection {
99 Long,
101 Short,
103 Flat,
105}
106
107#[derive(Debug, Clone)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct Position {
111 pub symbol: Symbol,
113 pub quantity: Decimal,
115 pub avg_cost: Decimal,
117 pub realized_pnl: Decimal,
119 #[cfg_attr(feature = "serde", serde(default))]
121 pub open_bar: usize,
122}
123
124impl Position {
125 pub fn new(symbol: Symbol) -> Self {
127 Self {
128 symbol,
129 quantity: Decimal::ZERO,
130 avg_cost: Decimal::ZERO,
131 realized_pnl: Decimal::ZERO,
132 open_bar: 0,
133 }
134 }
135
136 pub fn set_open_bar(&mut self, bar: usize) {
140 self.open_bar = bar;
141 }
142
143 pub fn position_age_bars(&self, current_bar: usize) -> usize {
147 current_bar.saturating_sub(self.open_bar)
148 }
149
150 pub fn max_favorable_excursion(&self, prices: &[Price]) -> Option<Decimal> {
157 if self.is_flat() || self.avg_cost.is_zero() || prices.is_empty() {
158 return None;
159 }
160 let best = if self.is_long() {
161 prices
162 .iter()
163 .map(|p| (p.value() - self.avg_cost) * self.quantity)
164 .fold(Decimal::MIN, Decimal::max)
165 } else {
166 prices
167 .iter()
168 .map(|p| (self.avg_cost - p.value()) * self.quantity.abs())
169 .fold(Decimal::MIN, Decimal::max)
170 };
171 if best < Decimal::ZERO {
172 Some(Decimal::ZERO)
173 } else {
174 Some(best)
175 }
176 }
177
178 pub fn kelly_fraction(
185 win_rate: Decimal,
186 avg_win: Decimal,
187 avg_loss: Decimal,
188 ) -> Option<Decimal> {
189 if avg_loss.is_zero() || avg_win.is_zero() {
190 return None;
191 }
192 let odds = avg_win / avg_loss;
193 let kelly = win_rate - (Decimal::ONE - win_rate) / odds;
194 Some(kelly.max(Decimal::ZERO).min(Decimal::ONE))
195 }
196
197 pub fn apply_fill(&mut self, fill: &Fill) -> Result<Decimal, FinError> {
205 let fill_qty = match fill.side {
206 Side::Bid => fill.quantity.value(),
207 Side::Ask => -fill.quantity.value(),
208 };
209
210 let realized = if self.quantity != Decimal::ZERO
211 && (self.quantity > Decimal::ZERO) != (fill_qty > Decimal::ZERO)
212 {
213 let closed = fill_qty.abs().min(self.quantity.abs());
214 if self.quantity > Decimal::ZERO {
215 closed * (fill.price.value() - self.avg_cost)
216 } else {
217 closed * (self.avg_cost - fill.price.value())
218 }
219 } else {
220 Decimal::ZERO
221 };
222
223 let new_qty = self.quantity + fill_qty;
224 if new_qty == Decimal::ZERO {
225 self.avg_cost = Decimal::ZERO;
226 } else if (self.quantity >= Decimal::ZERO && fill_qty > Decimal::ZERO)
227 || (self.quantity <= Decimal::ZERO && fill_qty < Decimal::ZERO)
228 {
229 let total_cost =
230 self.avg_cost * self.quantity.abs() + fill.price.value() * fill_qty.abs();
231 self.avg_cost = total_cost
232 .checked_div(new_qty.abs())
233 .ok_or(FinError::ArithmeticOverflow)?;
234 } else if new_qty.abs() <= self.quantity.abs() {
235 } else {
237 self.avg_cost = fill.price.value();
239 }
240
241 self.quantity = new_qty;
242 let net_realized = realized - fill.commission;
243 self.realized_pnl += net_realized;
244 Ok(net_realized)
245 }
246
247 pub fn unrealized_pnl(&self, current_price: Price) -> Decimal {
249 self.quantity * (current_price.value() - self.avg_cost)
250 }
251
252 pub fn checked_unrealized_pnl(&self, current_price: Price) -> Result<Decimal, FinError> {
254 let diff = current_price.value() - self.avg_cost;
255 self.quantity
256 .checked_mul(diff)
257 .ok_or(FinError::ArithmeticOverflow)
258 }
259
260 pub fn unrealized_pnl_pct(&self, current_price: Price) -> Option<Decimal> {
265 if self.is_flat() || self.avg_cost.is_zero() {
266 return None;
267 }
268 let cost_basis = self.quantity.abs() * self.avg_cost;
269 if cost_basis.is_zero() {
270 return None;
271 }
272 let upnl = self.unrealized_pnl(current_price);
273 upnl.checked_div(cost_basis).map(|r| r * Decimal::from(100u32))
274 }
275
276 pub fn total_cost_basis(&self) -> Decimal {
281 self.quantity.abs() * self.avg_cost
282 }
283
284 pub fn market_value(&self, current_price: Price) -> Decimal {
286 self.quantity * current_price.value()
287 }
288
289 pub fn is_flat(&self) -> bool {
291 self.quantity == Decimal::ZERO
292 }
293
294 pub fn is_long(&self) -> bool {
296 self.quantity > Decimal::ZERO
297 }
298
299 pub fn is_short(&self) -> bool {
301 self.quantity < Decimal::ZERO
302 }
303
304 pub fn direction(&self) -> PositionDirection {
306 if self.quantity > Decimal::ZERO {
307 PositionDirection::Long
308 } else if self.quantity < Decimal::ZERO {
309 PositionDirection::Short
310 } else {
311 PositionDirection::Flat
312 }
313 }
314
315 pub fn total_pnl(&self, current_price: Price) -> Decimal {
317 self.realized_pnl + self.unrealized_pnl(current_price)
318 }
319
320 pub fn quantity_abs(&self) -> Decimal {
322 self.quantity.abs()
323 }
324
325 pub fn cost_basis(&self) -> Decimal {
330 self.avg_cost * self.quantity.abs()
331 }
332
333
334 pub fn is_profitable(&self, current_price: Price) -> bool {
336 self.unrealized_pnl(current_price) > Decimal::ZERO
337 }
338
339 pub fn avg_entry_price(&self) -> Option<Price> {
344 Price::new(self.avg_cost).ok()
345 }
346
347 pub fn exposure_pct(&self, current_price: Price, total_portfolio_value: Decimal) -> Option<Decimal> {
354 if total_portfolio_value.is_zero() || self.is_flat() {
355 return None;
356 }
357 let market_value = (self.quantity * current_price.value()).abs();
358 Some(market_value / total_portfolio_value * Decimal::ONE_HUNDRED)
359 }
360
361 pub fn stop_loss_price(&self, stop_pct: Decimal) -> Option<Price> {
374 if self.is_flat() || self.avg_cost.is_zero() {
375 return None;
376 }
377 let factor = stop_pct / Decimal::ONE_HUNDRED;
378 let stop = if self.is_long() {
379 self.avg_cost * (Decimal::ONE - factor)
380 } else {
381 self.avg_cost * (Decimal::ONE + factor)
382 };
383 Price::new(stop).ok()
384 }
385
386 pub fn take_profit_price(&self, tp_pct: Decimal) -> Option<Price> {
392 if self.is_flat() || self.avg_cost.is_zero() {
393 return None;
394 }
395 let factor = tp_pct / Decimal::ONE_HUNDRED;
396 let tp = if self.is_long() {
397 self.avg_cost * (Decimal::ONE + factor)
398 } else {
399 self.avg_cost * (Decimal::ONE - factor)
400 };
401 Price::new(tp).ok()
402 }
403
404 pub fn margin_requirement(&self, margin_pct: Decimal) -> Option<Decimal> {
408 if self.is_flat() || self.avg_cost.is_zero() {
409 return None;
410 }
411 let notional = self.quantity.abs() * self.avg_cost;
412 Some(notional * margin_pct / Decimal::ONE_HUNDRED)
413 }
414
415 pub fn risk_reward_ratio(stop_pct: Decimal, target_pct: Decimal) -> Option<f64> {
420 use rust_decimal::prelude::ToPrimitive;
421 if stop_pct <= Decimal::ZERO {
422 return None;
423 }
424 (target_pct / stop_pct).to_f64()
425 }
426
427 pub fn leverage(&self, portfolio_value: Decimal) -> Option<Decimal> {
431 if self.is_flat() || self.avg_cost.is_zero() || portfolio_value.is_zero() {
432 return None;
433 }
434 let notional = self.quantity.abs() * self.avg_cost;
435 Some(notional / portfolio_value)
436 }
437}
438
439#[derive(Debug, Clone)]
441#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
442pub struct PositionLedger {
443 positions: HashMap<Symbol, Position>,
444 cash: Decimal,
445 total_commission_paid: Decimal,
446}
447
448impl PositionLedger {
449 pub fn new(initial_cash: Decimal) -> Self {
451 Self {
452 positions: HashMap::new(),
453 cash: initial_cash,
454 total_commission_paid: Decimal::ZERO,
455 }
456 }
457
458 #[allow(clippy::needless_pass_by_value)]
463 pub fn apply_fill(&mut self, fill: Fill) -> Result<(), FinError> {
464 let cost = match fill.side {
465 Side::Bid => -(fill.quantity.value() * fill.price.value() + fill.commission),
466 Side::Ask => fill.quantity.value() * fill.price.value() - fill.commission,
467 };
468 if fill.side == Side::Bid && self.cash + cost < Decimal::ZERO {
469 return Err(FinError::InsufficientFunds {
470 need: fill.quantity.value() * fill.price.value() + fill.commission,
471 have: self.cash,
472 });
473 }
474 self.cash += cost;
475 self.total_commission_paid += fill.commission;
476 let pos = self
477 .positions
478 .entry(fill.symbol.clone())
479 .or_insert_with(|| Position::new(fill.symbol.clone()));
480 pos.apply_fill(&fill)?;
481 Ok(())
482 }
483
484 pub fn position(&self, symbol: &Symbol) -> Option<&Position> {
486 self.positions.get(symbol)
487 }
488
489 pub fn has_position(&self, symbol: &Symbol) -> bool {
491 self.positions.contains_key(symbol)
492 }
493
494 pub fn positions(&self) -> impl Iterator<Item = &Position> {
496 self.positions.values()
497 }
498
499 pub fn open_positions(&self) -> impl Iterator<Item = &Position> {
501 self.positions.values().filter(|p| !p.is_flat())
502 }
503
504 pub fn flat_positions(&self) -> impl Iterator<Item = &Position> {
506 self.positions.values().filter(|p| p.is_flat())
507 }
508
509 pub fn long_positions(&self) -> impl Iterator<Item = &Position> {
511 self.positions.values().filter(|p| p.is_long())
512 }
513
514 pub fn short_positions(&self) -> impl Iterator<Item = &Position> {
516 self.positions.values().filter(|p| p.is_short())
517 }
518
519 pub fn symbols(&self) -> impl Iterator<Item = &Symbol> {
521 self.positions.keys()
522 }
523
524 pub fn open_symbols(&self) -> impl Iterator<Item = &Symbol> {
526 self.positions
527 .iter()
528 .filter(|(_, p)| !p.is_flat())
529 .map(|(s, _)| s)
530 }
531
532 pub fn total_long_exposure(&self) -> Decimal {
536 self.positions
537 .values()
538 .filter(|p| p.is_long())
539 .map(|p| p.quantity.abs() * p.avg_cost)
540 .sum()
541 }
542
543 pub fn total_short_exposure(&self) -> Decimal {
547 self.positions
548 .values()
549 .filter(|p| p.is_short())
550 .map(|p| p.quantity.abs() * p.avg_cost)
551 .sum()
552 }
553
554 pub fn symbols_sorted(&self) -> Vec<&Symbol> {
558 let mut syms: Vec<&Symbol> = self.positions.keys().collect();
559 syms.sort();
560 syms
561 }
562
563 pub fn position_count(&self) -> usize {
565 self.positions.len()
566 }
567
568 pub fn deposit(&mut self, amount: Decimal) {
573 self.cash += amount;
574 }
575
576 pub fn withdraw(&mut self, amount: Decimal) -> Result<(), FinError> {
581 if amount > self.cash {
582 return Err(FinError::InsufficientFunds {
583 need: amount,
584 have: self.cash,
585 });
586 }
587 self.cash -= amount;
588 Ok(())
589 }
590
591 pub fn open_position_count(&self) -> usize {
593 self.positions.values().filter(|p| !p.is_flat()).count()
594 }
595
596 pub fn long_count(&self) -> usize {
598 self.positions.values().filter(|p| p.quantity > Decimal::ZERO).count()
599 }
600
601 pub fn short_count(&self) -> usize {
603 self.positions.values().filter(|p| p.quantity < Decimal::ZERO).count()
604 }
605
606 pub fn net_exposure(&self) -> Decimal {
611 self.positions.values().map(|p| p.quantity).sum()
612 }
613
614 pub fn net_market_exposure(&self, prices: &std::collections::HashMap<String, Price>) -> Option<Decimal> {
620 let mut found = false;
621 let mut net = Decimal::ZERO;
622 for pos in self.positions.values() {
623 if pos.quantity.is_zero() { continue; }
624 if let Some(&price) = prices.get(pos.symbol.as_str()) {
625 found = true;
626 net += pos.quantity * price.value();
627 }
628 }
629 if found { Some(net) } else { None }
630 }
631
632 pub fn gross_exposure(&self) -> Decimal {
636 self.positions.values().map(|p| p.quantity.abs()).sum()
637 }
638
639 pub fn open_count(&self) -> usize {
644 self.positions.values().filter(|p| !p.is_flat()).count()
645 }
646
647 pub fn largest_position(&self) -> Option<&Position> {
651 self.positions
652 .values()
653 .filter(|p| !p.is_flat())
654 .max_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
655 }
656
657 pub fn total_market_value(
662 &self,
663 prices: &HashMap<String, Price>,
664 ) -> Result<Decimal, FinError> {
665 let mut total = Decimal::ZERO;
666 for (sym, pos) in &self.positions {
667 if pos.quantity == Decimal::ZERO {
668 continue;
669 }
670 let price = prices
671 .get(sym.as_str())
672 .ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
673 total += pos.market_value(*price);
674 }
675 Ok(total)
676 }
677
678 pub fn cash(&self) -> Decimal {
680 self.cash
681 }
682
683 pub fn position_weights(&self, prices: &HashMap<String, Price>) -> Vec<(Symbol, Decimal)> {
689 let mut mv_pairs: Vec<(Symbol, Decimal)> = self
690 .positions
691 .iter()
692 .filter(|(_, p)| !p.is_flat())
693 .filter_map(|(sym, pos)| {
694 let price = prices.get(sym.as_str())?;
695 Some((sym.clone(), pos.market_value(*price).abs()))
696 })
697 .collect();
698 let total: Decimal = mv_pairs.iter().map(|(_, v)| *v).sum();
699 if total.is_zero() {
700 return vec![];
701 }
702 mv_pairs.iter_mut().for_each(|(_, v)| *v /= total);
703 mv_pairs
704 }
705
706 pub fn realized_pnl_total(&self) -> Decimal {
708 self.positions.values().map(|p| p.realized_pnl).sum()
709 }
710
711 pub fn unrealized_pnl_total(
716 &self,
717 prices: &HashMap<String, Price>,
718 ) -> Result<Decimal, FinError> {
719 let mut total = Decimal::ZERO;
720 for (sym, pos) in &self.positions {
721 if pos.quantity == Decimal::ZERO {
722 continue;
723 }
724 let price = prices
725 .get(sym.as_str())
726 .ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
727 total += pos.unrealized_pnl(*price);
728 }
729 Ok(total)
730 }
731
732 pub fn realized_pnl(&self, symbol: &Symbol) -> Option<Decimal> {
734 self.positions.get(symbol).map(|p| p.realized_pnl)
735 }
736
737 pub fn net_pnl(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
742 Ok(self.realized_pnl_total() + self.unrealized_pnl_total(prices)?)
743 }
744
745 pub fn equity(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
755 Ok(self.cash + self.unrealized_pnl_total(prices)?)
756 }
757
758 pub fn net_liquidation_value(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
766 let mut total = self.cash;
767 for (symbol, pos) in &self.positions {
768 if pos.quantity == Decimal::ZERO {
769 continue;
770 }
771 let price = prices
772 .get(symbol.as_str())
773 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
774 total += pos.quantity * price.value();
775 }
776 Ok(total)
777 }
778
779 pub fn pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> Result<HashMap<Symbol, Decimal>, FinError> {
786 let mut map = HashMap::new();
787 for (symbol, pos) in &self.positions {
788 if pos.quantity == Decimal::ZERO {
789 continue;
790 }
791 let price = prices
792 .get(symbol.as_str())
793 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
794 map.insert(symbol.clone(), pos.unrealized_pnl(*price));
795 }
796 Ok(map)
797 }
798
799 pub fn delta_neutral_check(&self, prices: &HashMap<String, Price>) -> Result<bool, FinError> {
807 let mut net = Decimal::ZERO;
808 let mut gross = Decimal::ZERO;
809 for (symbol, pos) in &self.positions {
810 if pos.quantity == Decimal::ZERO {
811 continue;
812 }
813 let price = prices
814 .get(symbol.as_str())
815 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
816 let exposure = pos.quantity * price.value();
817 net += exposure;
818 gross += exposure.abs();
819 }
820 if gross == Decimal::ZERO {
821 return Ok(true);
822 }
823 Ok((net / gross).abs() < Decimal::new(1, 2)) }
825
826 pub fn allocation_pct(
835 &self,
836 symbol: &Symbol,
837 prices: &HashMap<String, Price>,
838 ) -> Result<Option<Decimal>, crate::error::FinError> {
839 let pos = self
840 .positions
841 .get(symbol)
842 .ok_or_else(|| crate::error::FinError::PositionNotFound(symbol.to_string()))?;
843 if pos.quantity == Decimal::ZERO {
844 return Ok(None);
845 }
846 let price = match prices.get(symbol.as_str()) {
847 Some(p) => *p,
848 None => return Ok(None),
849 };
850 let notional = (pos.quantity * price.value()).abs();
851 let total = self.total_market_value(prices)?;
852 if total.is_zero() {
853 return Ok(None);
854 }
855 Ok(Some(notional / total * Decimal::ONE_HUNDRED))
856 }
857
858 pub fn positions_sorted_by_pnl(&self, prices: &HashMap<String, Price>) -> Vec<&Position> {
862 let mut open: Vec<&Position> = self
863 .positions
864 .values()
865 .filter(|p| p.quantity != Decimal::ZERO)
866 .collect();
867 open.sort_by(|a, b| {
868 let pnl_a = prices
869 .get(a.symbol.as_str())
870 .map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
871 let pnl_b = prices
872 .get(b.symbol.as_str())
873 .map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
874 pnl_b.cmp(&pnl_a)
875 });
876 open
877 }
878
879 pub fn top_n_positions<'a>(&'a self, n: usize, prices: &HashMap<String, Price>) -> Vec<&'a Position> {
883 let mut open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
884 open.sort_by(|a, b| {
885 let mv_a = prices.get(a.symbol.as_str())
886 .map_or(Decimal::ZERO, |p| (a.quantity * p.value()).abs());
887 let mv_b = prices.get(b.symbol.as_str())
888 .map_or(Decimal::ZERO, |p| (b.quantity * p.value()).abs());
889 mv_b.cmp(&mv_a)
890 });
891 open.into_iter().take(n).collect()
892 }
893
894 pub fn concentration(&self, prices: &HashMap<String, Price>) -> Result<Option<Decimal>, FinError> {
904 let gross = self.gross_exposure();
905 if gross == Decimal::ZERO {
906 return Ok(None);
907 }
908 let mut hhi = Decimal::ZERO;
909 for (symbol, pos) in &self.positions {
910 if pos.quantity == Decimal::ZERO {
911 continue;
912 }
913 let price = prices
914 .get(symbol.as_str())
915 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
916 let mv = (pos.quantity * price.value()).abs();
917 let w = mv / gross;
918 hhi += w * w;
919 }
920 Ok(Some(hhi))
921 }
922
923 pub fn margin_used(&self, prices: &HashMap<String, Price>, margin_rate: Decimal) -> Result<Decimal, FinError> {
928 let mut gross = Decimal::ZERO;
929 for (symbol, pos) in &self.positions {
930 if pos.quantity == Decimal::ZERO {
931 continue;
932 }
933 let price = prices
934 .get(symbol.as_str())
935 .ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
936 gross += (pos.quantity * price.value()).abs();
937 }
938 Ok(gross * margin_rate)
939 }
940
941 pub fn flat_count(&self) -> usize {
943 self.positions.values().filter(|p| p.is_flat()).count()
944 }
945
946 pub fn smallest_position(&self) -> Option<&Position> {
950 self.positions
951 .values()
952 .filter(|p| !p.is_flat())
953 .min_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
954 }
955
956 pub fn most_profitable_symbol(
960 &self,
961 prices: &HashMap<String, Price>,
962 ) -> Option<&Symbol> {
963 self.positions
964 .iter()
965 .filter(|(_, p)| !p.is_flat())
966 .filter_map(|(sym, p)| {
967 let price = prices.get(sym.as_str())?;
968 let pnl = p.unrealized_pnl(*price);
969 Some((sym, pnl))
970 })
971 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
972 .map(|(sym, _)| sym)
973 }
974
975 pub fn least_profitable_symbol(
979 &self,
980 prices: &HashMap<String, Price>,
981 ) -> Option<&Symbol> {
982 self.positions
983 .iter()
984 .filter(|(_, p)| !p.is_flat())
985 .filter_map(|(sym, p)| {
986 let price = prices.get(sym.as_str())?;
987 let pnl = p.unrealized_pnl(*price);
988 Some((sym, pnl))
989 })
990 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
991 .map(|(sym, _)| sym)
992 }
993
994 pub fn total_commission_paid(&self) -> Decimal {
996 self.total_commission_paid
997 }
998
999 pub fn symbols_with_pnl(
1003 &self,
1004 prices: &HashMap<String, Price>,
1005 ) -> Vec<(&Symbol, Decimal)> {
1006 let mut result: Vec<(&Symbol, Decimal)> = self
1007 .positions
1008 .iter()
1009 .filter(|(_, p)| !p.is_flat())
1010 .filter_map(|(sym, p)| {
1011 let price = prices.get(sym.as_str())?;
1012 Some((sym, p.unrealized_pnl(*price)))
1013 })
1014 .collect();
1015 result.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
1016 result
1017 }
1018
1019 pub fn concentration_pct(
1024 &self,
1025 symbol: &Symbol,
1026 prices: &HashMap<String, Price>,
1027 ) -> Option<Decimal> {
1028 let pos = self.positions.get(symbol)?;
1029 let price = prices.get(symbol.as_str())?;
1030 let mv = pos.quantity.abs() * price.value();
1031 let total = self
1032 .positions
1033 .values()
1034 .filter_map(|p| {
1035 let pr = prices.get(p.symbol.as_str())?;
1036 Some(p.quantity.abs() * pr.value())
1037 })
1038 .sum::<Decimal>();
1039 if total.is_zero() {
1040 return None;
1041 }
1042 Some(mv / total * Decimal::ONE_HUNDRED)
1043 }
1044
1045 pub fn all_flat(&self) -> bool {
1047 self.positions.values().all(|p| p.is_flat())
1048 }
1049
1050 pub fn long_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1054 self.positions
1055 .iter()
1056 .filter(|(_, p)| p.is_long())
1057 .filter_map(|(sym, p)| {
1058 let price = prices.get(sym.as_str())?;
1059 Some(p.quantity.abs() * price.value())
1060 })
1061 .sum()
1062 }
1063
1064 pub fn short_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1068 self.positions
1069 .iter()
1070 .filter(|(_, p)| p.is_short())
1071 .filter_map(|(sym, p)| {
1072 let price = prices.get(sym.as_str())?;
1073 Some(p.quantity.abs() * price.value())
1074 })
1075 .sum()
1076 }
1077
1078 pub fn net_delta(&self, prices: &HashMap<String, Price>) -> Decimal {
1082 self.long_exposure(prices) - self.short_exposure(prices)
1083 }
1084
1085 pub fn avg_cost_basis(&self, symbol: &Symbol) -> Option<Decimal> {
1087 let pos = self.positions.get(symbol)?;
1088 if pos.is_flat() { return None; }
1089 Some(pos.avg_cost)
1090 }
1091
1092 pub fn active_symbols(&self) -> Vec<&Symbol> {
1094 self.positions
1095 .iter()
1096 .filter(|(_, pos)| !pos.is_flat())
1097 .map(|(sym, _)| sym)
1098 .collect()
1099 }
1100
1101 pub fn symbol_count(&self) -> usize {
1103 self.positions.len()
1104 }
1105
1106 pub fn realized_pnl_by_symbol(&self) -> Vec<(Symbol, Decimal)> {
1111 let mut pairs: Vec<(Symbol, Decimal)> = self
1112 .positions
1113 .iter()
1114 .filter_map(|(sym, pos)| {
1115 let r = pos.realized_pnl;
1116 if r != Decimal::ZERO { Some((sym.clone(), r)) } else { None }
1117 })
1118 .collect();
1119 pairs.sort_by_key(|x| std::cmp::Reverse(x.1));
1120 pairs
1121 }
1122
1123 pub fn top_losers<'a>(
1128 &'a self,
1129 n: usize,
1130 prices: &HashMap<String, Price>,
1131 ) -> Vec<&'a Position> {
1132 if n == 0 {
1133 return vec![];
1134 }
1135 let mut open: Vec<&Position> =
1136 self.positions.values().filter(|p| !p.is_flat()).collect();
1137 open.sort_by(|a, b| {
1138 let pnl_a = prices
1139 .get(a.symbol.as_str())
1140 .map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
1141 let pnl_b = prices
1142 .get(b.symbol.as_str())
1143 .map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
1144 pnl_a.cmp(&pnl_b) });
1146 open.into_iter().take(n).collect()
1147 }
1148
1149 pub fn flat_symbols(&self) -> Vec<&Symbol> {
1152 let mut syms: Vec<&Symbol> = self.positions
1153 .iter()
1154 .filter_map(|(sym, pos)| if pos.is_flat() { Some(sym) } else { None })
1155 .collect();
1156 syms.sort();
1157 syms
1158 }
1159
1160 pub fn max_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1164 self.positions
1165 .values()
1166 .filter(|p| !p.is_flat())
1167 .filter_map(|p| {
1168 let price = prices.get(p.symbol.as_str()).copied()?;
1169 let upnl = p.unrealized_pnl(price);
1170 if upnl < Decimal::ZERO { Some(upnl) } else { None }
1171 })
1172 .min_by(|a, b| a.cmp(b))
1173 }
1174
1175 pub fn largest_winner<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1179 self.positions
1180 .values()
1181 .filter(|p| !p.is_flat())
1182 .filter_map(|p| {
1183 let price = prices.get(p.symbol.as_str()).copied()?;
1184 let upnl = p.unrealized_pnl(price);
1185 if upnl > Decimal::ZERO { Some((p, upnl)) } else { None }
1186 })
1187 .max_by(|a, b| a.1.cmp(&b.1))
1188 .map(|(p, _)| p)
1189 }
1190
1191 pub fn largest_loser<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1195 self.positions
1196 .values()
1197 .filter(|p| !p.is_flat())
1198 .filter_map(|p| {
1199 let price = prices.get(p.symbol.as_str()).copied()?;
1200 let upnl = p.unrealized_pnl(price);
1201 if upnl < Decimal::ZERO { Some((p, upnl)) } else { None }
1202 })
1203 .min_by(|a, b| a.1.cmp(&b.1))
1204 .map(|(p, _)| p)
1205 }
1206
1207 pub fn gross_market_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
1209 self.positions
1210 .values()
1211 .filter(|p| !p.is_flat())
1212 .filter_map(|p| {
1213 let price = prices.get(p.symbol.as_str()).copied()?;
1214 Some(p.market_value(price).abs())
1215 })
1216 .sum()
1217 }
1218
1219 pub fn largest_position_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1223 let total = self.gross_market_exposure(prices);
1224 if total.is_zero() { return None; }
1225 let max_mv = self.positions
1226 .values()
1227 .filter(|p| !p.is_flat())
1228 .filter_map(|p| {
1229 let price = prices.get(p.symbol.as_str()).copied()?;
1230 Some(p.market_value(price).abs())
1231 })
1232 .max_by(|a, b| a.cmp(b))?;
1233 Some(max_mv / total * Decimal::from(100u32))
1234 }
1235
1236 pub fn unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1242 let total_upnl = self.unrealized_pnl_total(prices).ok()?;
1243 let total_cost: Decimal = self.positions
1244 .values()
1245 .filter(|p| !p.is_flat())
1246 .map(|p| p.cost_basis().abs())
1247 .sum();
1248 if total_cost.is_zero() { return None; }
1249 Some(total_upnl / total_cost * Decimal::from(100u32))
1250 }
1251
1252 pub fn symbols_up<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
1256 self.positions
1257 .values()
1258 .filter(|p| !p.is_flat())
1259 .filter(|p| {
1260 prices.get(p.symbol.as_str())
1261 .is_some_and(|&price| p.unrealized_pnl(price) > Decimal::ZERO)
1262 })
1263 .map(|p| &p.symbol)
1264 .collect()
1265 }
1266
1267 pub fn symbols_down<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
1271 self.positions
1272 .values()
1273 .filter(|p| !p.is_flat())
1274 .filter(|p| {
1275 prices.get(p.symbol.as_str())
1276 .is_some_and(|&price| p.unrealized_pnl(price) < Decimal::ZERO)
1277 })
1278 .map(|p| &p.symbol)
1279 .collect()
1280 }
1281
1282 pub fn largest_unrealized_gain<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
1287 self.largest_winner(prices)
1288 }
1289
1290 pub fn avg_realized_pnl_per_symbol(&self) -> Option<Decimal> {
1294 if self.positions.is_empty() { return None; }
1295 let total: Decimal = self.positions.values().map(|p| p.realized_pnl).sum();
1296 #[allow(clippy::cast_possible_truncation)]
1297 Some(total / Decimal::from(self.positions.len() as u32))
1298 }
1299
1300 pub fn win_rate(&self) -> Option<Decimal> {
1307 if self.positions.is_empty() { return None; }
1308 let total = self.positions.len();
1309 let winners = self.positions.values()
1310 .filter(|p| p.realized_pnl > Decimal::ZERO)
1311 .count();
1312 #[allow(clippy::cast_possible_truncation)]
1313 Some(Decimal::from(winners as u32) / Decimal::from(total as u32) * Decimal::from(100u32))
1314 }
1315
1316 pub fn net_pnl_excluding(
1321 &self,
1322 exclude: &Symbol,
1323 prices: &HashMap<String, Price>,
1324 ) -> Result<Decimal, FinError> {
1325 let total = self.net_pnl(prices)?;
1326 let excluded_rpnl = self.realized_pnl(exclude).unwrap_or(Decimal::ZERO);
1327 let excluded_upnl = if let Some(pos) = self.positions.get(exclude) {
1328 if !pos.is_flat() {
1329 let price = prices.get(exclude.as_str())
1330 .copied()
1331 .ok_or_else(|| FinError::InvalidSymbol(exclude.as_str().to_string()))?;
1332 pos.unrealized_pnl(price)
1333 } else {
1334 Decimal::ZERO
1335 }
1336 } else {
1337 Decimal::ZERO
1338 };
1339 Ok(total - excluded_rpnl - excluded_upnl)
1340 }
1341
1342 pub fn long_short_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1348 let long_exp = self.long_exposure(prices);
1349 let short_exp = self.short_exposure(prices).abs();
1350 if short_exp.is_zero() { return None; }
1351 long_exp.checked_div(short_exp)
1352 }
1353
1354 pub fn position_count_by_direction(&self) -> (usize, usize) {
1356 let longs = self.positions.values()
1357 .filter(|p| !p.is_flat() && p.quantity > Decimal::ZERO)
1358 .count();
1359 let shorts = self.positions.values()
1360 .filter(|p| !p.is_flat() && p.quantity < Decimal::ZERO)
1361 .count();
1362 (longs, shorts)
1363 }
1364
1365 pub fn max_position_age_bars(&self, current_bar: usize) -> Option<usize> {
1369 self.positions.values()
1370 .filter(|p| !p.is_flat())
1371 .map(|p| p.position_age_bars(current_bar))
1372 .max()
1373 }
1374
1375 pub fn avg_position_age_bars(&self, current_bar: usize) -> Option<Decimal> {
1379 let ages: Vec<usize> = self.positions.values()
1380 .filter(|p| !p.is_flat())
1381 .map(|p| p.position_age_bars(current_bar))
1382 .collect();
1383 if ages.is_empty() { return None; }
1384 let sum: usize = ages.iter().sum();
1385 Some(Decimal::from(sum as u64) / Decimal::from(ages.len() as u64))
1386 }
1387
1388 pub fn hhi_concentration(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1395 let open_positions: Vec<_> = self.positions.values()
1396 .filter(|p| !p.is_flat())
1397 .collect();
1398 if open_positions.is_empty() { return None; }
1399 let mvs: Vec<Decimal> = open_positions.iter()
1400 .filter_map(|p| {
1401 prices.get(p.symbol.as_str())
1402 .map(|&price| p.market_value(price).abs())
1403 })
1404 .collect();
1405 let total: Decimal = mvs.iter().sum();
1406 if total.is_zero() { return None; }
1407 Some(mvs.iter().map(|mv| {
1408 let w = mv / total;
1409 w * w
1410 }).sum())
1411 }
1412
1413 pub fn long_short_pnl_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1418 let long_pnl: Decimal = self.positions.values()
1419 .filter(|p| p.is_long())
1420 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1421 .sum();
1422 let short_pnl: Decimal = self.positions.values()
1423 .filter(|p| p.is_short())
1424 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1425 .sum();
1426 let short_abs = short_pnl.abs();
1427 if short_abs.is_zero() { return None; }
1428 Some(long_pnl / short_abs)
1429 }
1430
1431 pub fn unrealized_pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> HashMap<String, Decimal> {
1435 self.positions
1436 .iter()
1437 .filter(|(_, p)| !p.is_flat())
1438 .filter_map(|(sym, p)| {
1439 prices.get(sym.as_str())
1440 .map(|&price| (sym.as_str().to_owned(), p.unrealized_pnl(price)))
1441 })
1442 .collect()
1443 }
1444
1445 pub fn portfolio_beta(
1451 &self,
1452 prices: &HashMap<String, Price>,
1453 betas: &HashMap<String, f64>,
1454 ) -> Option<f64> {
1455 use rust_decimal::prelude::ToPrimitive;
1456 let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1457 if open.is_empty() { return None; }
1458 let total_mv: Decimal = open.iter()
1459 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1460 .sum();
1461 if total_mv.is_zero() { return None; }
1462 let total_mv_f64 = total_mv.to_f64()?;
1463 let beta_sum: f64 = open.iter().filter_map(|p| {
1464 let mv = prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs())?;
1465 let b = betas.get(p.symbol.as_str())?;
1466 let w = mv.to_f64()? / total_mv_f64;
1467 Some(w * b)
1468 }).sum();
1469 Some(beta_sum)
1470 }
1471
1472 pub fn total_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1477 let total: Decimal = self.positions.values()
1478 .filter(|p| !p.is_flat())
1479 .filter_map(|p| {
1480 prices.get(p.symbol.as_str())
1481 .map(|&price| p.quantity_abs() * price.value())
1482 })
1483 .sum();
1484 if total.is_zero() { None } else { Some(total) }
1485 }
1486
1487 pub fn max_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1492 self.positions.values()
1493 .filter(|p| !p.is_flat())
1494 .filter_map(|p| {
1495 prices.get(p.symbol.as_str())
1496 .map(|&price| p.unrealized_pnl(price))
1497 })
1498 .filter(|&pnl| pnl > Decimal::ZERO)
1499 .max()
1500 }
1501
1502 pub fn realized_pnl_rank(&self, symbol: &Symbol) -> Option<usize> {
1507 let target = self.positions.get(symbol).map(|p| p.realized_pnl)?;
1508 if target == Decimal::ZERO { return None; }
1509 let mut sorted: Vec<Decimal> = self.positions.values()
1510 .map(|p| p.realized_pnl)
1511 .filter(|&r| r != Decimal::ZERO)
1512 .collect();
1513 sorted.sort_by(|a, b| b.cmp(a));
1514 sorted.iter().position(|&r| r == target).map(|i| i + 1)
1515 }
1516
1517 pub fn open_positions_vec(&self) -> Vec<&Position> {
1519 let mut open: Vec<&Position> = self.positions.values()
1520 .filter(|p| !p.is_flat())
1521 .collect();
1522 open.sort_by(|a, b| a.symbol.as_str().cmp(b.symbol.as_str()));
1523 open
1524 }
1525
1526 pub fn symbols_with_pnl_above(&self, threshold: Decimal) -> Vec<Symbol> {
1530 let mut pairs: Vec<(Symbol, Decimal)> = self.positions.iter()
1531 .filter_map(|(sym, pos)| {
1532 if pos.realized_pnl > threshold { Some((sym.clone(), pos.realized_pnl)) } else { None }
1533 })
1534 .collect();
1535 pairs.sort_by_key(|x| std::cmp::Reverse(x.1));
1536 pairs.into_iter().map(|(s, _)| s).collect()
1537 }
1538
1539 pub fn net_long_short_count(&self) -> (usize, usize) {
1541 let long = self.positions.values().filter(|p| p.is_long()).count();
1542 let short = self.positions.values().filter(|p| p.is_short()).count();
1543 (long, short)
1544 }
1545
1546 pub fn largest_open_position(&self) -> Option<&Symbol> {
1550 self.positions.iter()
1551 .filter(|(_, p)| !p.is_flat())
1552 .max_by(|(_, a), (_, b)| a.quantity.abs().cmp(&b.quantity.abs()))
1553 .map(|(sym, _)| sym)
1554 }
1555
1556 pub fn exposure_by_direction(&self, prices: &HashMap<String, Price>) -> (Decimal, Decimal) {
1560 let long: Decimal = self.positions.values()
1561 .filter(|p| p.is_long())
1562 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr)))
1563 .sum();
1564 let short: Decimal = self.positions.values()
1565 .filter(|p| p.is_short())
1566 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
1567 .sum();
1568 (long, short)
1569 }
1570
1571 pub fn total_realized_pnl(&self) -> Decimal {
1573 self.positions.values().map(|p| p.realized_pnl).sum()
1574 }
1575
1576 pub fn count_with_pnl_below(&self, threshold: Decimal) -> usize {
1578 self.positions.values().filter(|p| p.realized_pnl < threshold).count()
1579 }
1580
1581 pub fn is_net_long(&self) -> bool {
1583 let net: Decimal = self.positions.values().map(|p| p.quantity).sum();
1584 net > Decimal::ZERO
1585 }
1586
1587 pub fn total_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Decimal {
1591 self.positions.values()
1592 .filter(|p| !p.is_flat())
1593 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1594 .sum()
1595 }
1596
1597 pub fn symbols_flat(&self) -> Vec<&Symbol> {
1599 let mut flat: Vec<&Symbol> = self.positions.iter()
1600 .filter(|(_, p)| p.is_flat())
1601 .map(|(sym, _)| sym)
1602 .collect();
1603 flat.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1604 flat
1605 }
1606
1607 pub fn avg_unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1612 let pcts: Vec<Decimal> = self.positions.values()
1613 .filter(|p| !p.is_flat())
1614 .filter_map(|p| {
1615 prices.get(p.symbol.as_str()).and_then(|&pr| {
1616 let cost_basis = (p.avg_cost * p.quantity).abs();
1617 if cost_basis.is_zero() { return None; }
1618 Some(p.unrealized_pnl(pr) / cost_basis * Decimal::ONE_HUNDRED)
1619 })
1620 })
1621 .collect();
1622 if pcts.is_empty() { return None; }
1623 Some(pcts.iter().sum::<Decimal>() / Decimal::from(pcts.len()))
1624 }
1625
1626 pub fn max_drawdown_symbol<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Symbol> {
1630 self.positions.iter()
1631 .filter(|(_, p)| !p.is_flat())
1632 .filter_map(|(sym, p)| {
1633 prices.get(p.symbol.as_str())
1634 .map(|&price| (sym, p.unrealized_pnl(price)))
1635 })
1636 .min_by(|(_, a), (_, b)| a.cmp(b))
1637 .map(|(sym, _)| sym)
1638 }
1639
1640 pub fn avg_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1644 let pnls: Vec<Decimal> = self.positions.values()
1645 .filter(|p| !p.is_flat())
1646 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1647 .collect();
1648 if pnls.is_empty() { return None; }
1649 #[allow(clippy::cast_possible_truncation)]
1650 Some(pnls.iter().sum::<Decimal>() / Decimal::from(pnls.len() as u32))
1651 }
1652
1653 pub fn position_symbols(&self) -> Vec<&Symbol> {
1655 let mut syms: Vec<&Symbol> = self.positions.keys().collect();
1656 syms.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1657 syms
1658 }
1659
1660 pub fn count_profitable(&self) -> usize {
1662 self.positions.values().filter(|p| p.realized_pnl > Decimal::ZERO).count()
1663 }
1664
1665 pub fn count_losing(&self) -> usize {
1667 self.positions.values().filter(|p| p.realized_pnl < Decimal::ZERO).count()
1668 }
1669
1670 pub fn top_n_by_exposure<'a>(
1673 &'a self,
1674 prices: &HashMap<String, Price>,
1675 n: usize,
1676 ) -> Vec<(&'a Symbol, Decimal)> {
1677 let mut exposures: Vec<(&Symbol, Decimal)> = self.positions.iter()
1678 .filter(|(_, p)| !p.is_flat())
1679 .filter_map(|(sym, p)| {
1680 prices.get(p.symbol.as_str())
1681 .map(|&pr| (sym, (p.quantity * pr.value()).abs()))
1682 })
1683 .collect();
1684 exposures.sort_by_key(|x| std::cmp::Reverse(x.1));
1685 exposures.truncate(n);
1686 exposures
1687 }
1688
1689 pub fn has_open_positions(&self) -> bool {
1691 self.positions.values().any(|p| !p.is_flat())
1692 }
1693
1694 pub fn long_symbols(&self) -> Vec<&Symbol> {
1696 self.positions.iter()
1697 .filter(|(_, p)| p.quantity > Decimal::ZERO)
1698 .map(|(sym, _)| sym)
1699 .collect()
1700 }
1701
1702 pub fn short_symbols(&self) -> Vec<&Symbol> {
1704 self.positions.iter()
1705 .filter(|(_, p)| p.quantity < Decimal::ZERO)
1706 .map(|(sym, _)| sym)
1707 .collect()
1708 }
1709
1710 pub fn concentration_ratio(&self, prices: &HashMap<String, Price>) -> Option<f64> {
1715 use rust_decimal::prelude::ToPrimitive;
1716 let notionals: Vec<Decimal> = self.positions.values()
1717 .filter(|p| !p.is_flat())
1718 .filter_map(|p| {
1719 prices.get(p.symbol.as_str())
1720 .map(|&pr| (p.quantity * pr.value()).abs())
1721 })
1722 .collect();
1723 if notionals.is_empty() { return None; }
1724 let total: Decimal = notionals.iter().sum();
1725 if total.is_zero() { return None; }
1726 let hhi: f64 = notionals.iter()
1727 .filter_map(|n| (n / total).to_f64())
1728 .map(|w| w * w)
1729 .sum();
1730 Some(hhi)
1731 }
1732
1733 pub fn min_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1737 self.positions.values()
1738 .filter(|p| !p.is_flat())
1739 .filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
1740 .min_by(|a, b| a.cmp(b))
1741 }
1742
1743 pub fn pct_long(&self) -> Option<Decimal> {
1747 let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1748 if open.is_empty() { return None; }
1749 let longs = open.iter().filter(|p| p.quantity > Decimal::ZERO).count() as u32;
1750 Some(Decimal::from(longs) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1751 }
1752
1753 pub fn pct_short(&self) -> Option<Decimal> {
1757 let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
1758 if open.is_empty() { return None; }
1759 let shorts = open.iter().filter(|p| p.quantity < Decimal::ZERO).count() as u32;
1760 Some(Decimal::from(shorts) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
1761 }
1762
1763 pub fn realized_pnl_total_abs(&self) -> Decimal {
1765 self.positions.values().map(|p| p.realized_pnl.abs()).sum()
1766 }
1767
1768 pub fn average_entry_price(&self, symbol: &Symbol) -> Option<Price> {
1772 self.positions.get(symbol)?.avg_entry_price()
1773 }
1774
1775 pub fn net_quantity(&self) -> Decimal {
1777 self.positions.values().map(|p| p.quantity).sum()
1778 }
1779
1780 pub fn max_long_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1784 self.positions.values()
1785 .filter(|p| p.quantity > Decimal::ZERO)
1786 .filter_map(|p| {
1787 prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1788 })
1789 .max_by(|a, b| a.cmp(b))
1790 }
1791
1792 pub fn max_short_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
1796 self.positions.values()
1797 .filter(|p| p.quantity < Decimal::ZERO)
1798 .filter_map(|p| {
1799 prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
1800 })
1801 .max_by(|a, b| a.cmp(b))
1802 }
1803
1804 pub fn max_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1808 self.positions.iter()
1809 .map(|(sym, p)| (sym, p.realized_pnl))
1810 .max_by(|(_, a), (_, b)| a.cmp(b))
1811 }
1812
1813 pub fn min_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
1817 self.positions.iter()
1818 .map(|(sym, p)| (sym, p.realized_pnl))
1819 .min_by(|(_, a), (_, b)| a.cmp(b))
1820 }
1821
1822 pub fn avg_holding_bars(&self, current_bar: usize) -> Option<f64> {
1827 let open: Vec<usize> = self.positions.values()
1828 .filter(|p| !p.is_flat())
1829 .map(|p| current_bar.saturating_sub(p.open_bar))
1830 .collect();
1831 if open.is_empty() { return None; }
1832 Some(open.iter().sum::<usize>() as f64 / open.len() as f64)
1833 }
1834
1835 pub fn symbols_with_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Vec<&Symbol> {
1837 self.positions.iter()
1838 .filter(|(_, p)| !p.is_flat())
1839 .filter_map(|(sym, p)| {
1840 prices.get(p.symbol.as_str())
1841 .map(|&pr| (sym, p.unrealized_pnl(pr)))
1842 })
1843 .filter(|(_, pnl)| *pnl < Decimal::ZERO)
1844 .map(|(sym, _)| sym)
1845 .collect()
1846 }
1847
1848 pub fn avg_long_entry_price(&self) -> Option<Decimal> {
1851 let longs: Vec<&Position> = self.positions.values()
1852 .filter(|p| p.is_long())
1853 .collect();
1854 if longs.is_empty() { return None; }
1855 let total_qty: Decimal = longs.iter().map(|p| p.quantity.abs()).sum();
1856 if total_qty.is_zero() { return None; }
1857 let weighted: Decimal = longs.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1858 Some(weighted / total_qty)
1859 }
1860
1861 pub fn avg_short_entry_price(&self) -> Option<Decimal> {
1864 let shorts: Vec<&Position> = self.positions.values()
1865 .filter(|p| p.is_short())
1866 .collect();
1867 if shorts.is_empty() { return None; }
1868 let total_qty: Decimal = shorts.iter().map(|p| p.quantity.abs()).sum();
1869 if total_qty.is_zero() { return None; }
1870 let weighted: Decimal = shorts.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
1871 Some(weighted / total_qty)
1872 }
1873}
1874
1875#[cfg(test)]
1876mod tests {
1877 use super::*;
1878 use rust_decimal_macros::dec;
1879
1880 fn sym(s: &str) -> Symbol {
1881 Symbol::new(s).unwrap()
1882 }
1883
1884 fn make_fill(symbol: &str, side: Side, qty: &str, p: &str, commission: &str) -> Fill {
1885 Fill {
1886 symbol: sym(symbol),
1887 side,
1888 quantity: Quantity::new(qty.parse().unwrap()).unwrap(),
1889 price: Price::new(p.parse().unwrap()).unwrap(),
1890 timestamp: NanoTimestamp::new(0),
1891 commission: commission.parse().unwrap(),
1892 }
1893 }
1894
1895 #[test]
1896 fn test_position_apply_fill_long() {
1897 let mut pos = Position::new(sym("AAPL"));
1898 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1899 .unwrap();
1900 assert_eq!(pos.quantity, dec!(10));
1901 assert_eq!(pos.avg_cost, dec!(100));
1902 }
1903
1904 #[test]
1905 fn test_position_apply_fill_reduces_position() {
1906 let mut pos = Position::new(sym("AAPL"));
1907 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1908 .unwrap();
1909 pos.apply_fill(&make_fill("AAPL", Side::Ask, "5", "110", "0"))
1910 .unwrap();
1911 assert_eq!(pos.quantity, dec!(5));
1912 }
1913
1914 #[test]
1915 fn test_position_realized_pnl_on_close() {
1916 let mut pos = Position::new(sym("AAPL"));
1917 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1918 .unwrap();
1919 let pnl = pos
1920 .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1921 .unwrap();
1922 assert_eq!(pnl, dec!(100));
1923 assert!(pos.is_flat());
1924 }
1925
1926 #[test]
1927 fn test_position_commission_reduces_realized_pnl() {
1928 let mut pos = Position::new(sym("AAPL"));
1929 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1930 .unwrap();
1931 let pnl = pos
1932 .apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "5"))
1933 .unwrap();
1934 assert_eq!(pnl, dec!(95));
1935 }
1936
1937 #[test]
1938 fn test_position_unrealized_pnl() {
1939 let mut pos = Position::new(sym("AAPL"));
1940 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1941 .unwrap();
1942 let upnl = pos.unrealized_pnl(Price::new(dec!(115)).unwrap());
1943 assert_eq!(upnl, dec!(150));
1944 }
1945
1946 #[test]
1947 fn test_position_market_value() {
1948 let mut pos = Position::new(sym("AAPL"));
1949 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1950 .unwrap();
1951 assert_eq!(pos.market_value(Price::new(dec!(120)).unwrap()), dec!(1200));
1952 }
1953
1954 #[test]
1955 fn test_position_is_flat_initially() {
1956 let pos = Position::new(sym("X"));
1957 assert!(pos.is_flat());
1958 }
1959
1960 #[test]
1961 fn test_position_is_flat_after_full_close() {
1962 let mut pos = Position::new(sym("AAPL"));
1963 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
1964 .unwrap();
1965 pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
1966 .unwrap();
1967 assert!(pos.is_flat());
1968 }
1969
1970 #[test]
1971 fn test_position_avg_cost_weighted_after_two_buys() {
1972 let mut pos = Position::new(sym("X"));
1973 pos.apply_fill(&make_fill("X", Side::Bid, "10", "100", "0"))
1974 .unwrap();
1975 pos.apply_fill(&make_fill("X", Side::Bid, "10", "120", "0"))
1976 .unwrap();
1977 assert_eq!(pos.avg_cost, dec!(110));
1978 }
1979
1980 #[test]
1981 fn test_position_ledger_apply_fill_updates_cash() {
1982 let mut ledger = PositionLedger::new(dec!(10000));
1983 ledger
1984 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "1"))
1985 .unwrap();
1986 assert_eq!(ledger.cash(), dec!(8999));
1987 }
1988
1989 #[test]
1990 fn test_position_ledger_insufficient_funds() {
1991 let mut ledger = PositionLedger::new(dec!(100));
1992 let result = ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
1993 assert!(matches!(result, Err(FinError::InsufficientFunds { .. })));
1994 }
1995
1996 #[test]
1997 fn test_position_ledger_equity_calculation() {
1998 let mut ledger = PositionLedger::new(dec!(10000));
1999 ledger
2000 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2001 .unwrap();
2002 let mut prices = HashMap::new();
2003 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2004 let equity = ledger.equity(&prices).unwrap();
2006 assert_eq!(equity, dec!(9100));
2007 }
2008
2009 #[test]
2010 fn test_position_ledger_net_liquidation_value() {
2011 let mut ledger = PositionLedger::new(dec!(10000));
2013 ledger
2014 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2015 .unwrap();
2016 let mut prices = HashMap::new();
2017 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2018 let nlv = ledger.net_liquidation_value(&prices).unwrap();
2020 assert_eq!(nlv, dec!(10100));
2021 }
2022
2023 #[test]
2024 fn test_position_ledger_net_liquidation_missing_price() {
2025 let mut ledger = PositionLedger::new(dec!(10000));
2026 ledger
2027 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2028 .unwrap();
2029 let prices: HashMap<String, Price> = HashMap::new();
2030 assert!(ledger.net_liquidation_value(&prices).is_err());
2031 }
2032
2033 #[test]
2034 fn test_position_ledger_pnl_by_symbol() {
2035 let mut ledger = PositionLedger::new(dec!(10000));
2036 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2037 ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2038 let mut prices = HashMap::new();
2039 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2040 prices.insert("GOOG".to_owned(), Price::new(dec!(190)).unwrap());
2041 let pnl = ledger.pnl_by_symbol(&prices).unwrap();
2042 assert_eq!(*pnl.get(&sym("AAPL")).unwrap(), dec!(100)); assert_eq!(*pnl.get(&sym("GOOG")).unwrap(), dec!(-50)); }
2045
2046 #[test]
2047 fn test_position_ledger_pnl_by_symbol_missing_price() {
2048 let mut ledger = PositionLedger::new(dec!(10000));
2049 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2050 let prices: HashMap<String, Price> = HashMap::new();
2051 assert!(ledger.pnl_by_symbol(&prices).is_err());
2052 }
2053
2054 #[test]
2055 fn test_position_ledger_delta_neutral_no_positions() {
2056 let ledger = PositionLedger::new(dec!(10000));
2057 let prices: HashMap<String, Price> = HashMap::new();
2058 assert!(ledger.delta_neutral_check(&prices).unwrap());
2059 }
2060
2061 #[test]
2062 fn test_position_ledger_delta_neutral_long_short_balanced() {
2063 let mut ledger = PositionLedger::new(dec!(10000));
2064 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2065 ledger.apply_fill(make_fill("GOOG", Side::Ask, "10", "100", "0")).unwrap();
2066 let mut prices = HashMap::new();
2067 prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2068 prices.insert("GOOG".to_owned(), Price::new(dec!(100)).unwrap());
2069 assert!(ledger.delta_neutral_check(&prices).unwrap());
2071 }
2072
2073 #[test]
2074 fn test_position_ledger_delta_neutral_one_sided_not_neutral() {
2075 let mut ledger = PositionLedger::new(dec!(10000));
2076 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2077 let mut prices = HashMap::new();
2078 prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
2079 assert!(!ledger.delta_neutral_check(&prices).unwrap());
2081 }
2082
2083 #[test]
2084 fn test_position_ledger_open_count_zero_when_empty() {
2085 assert_eq!(PositionLedger::new(dec!(10000)).open_count(), 0);
2086 }
2087
2088 #[test]
2089 fn test_position_ledger_open_count_tracks_positions() {
2090 let mut ledger = PositionLedger::new(dec!(10000));
2091 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2092 assert_eq!(ledger.open_count(), 1);
2093 ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
2094 assert_eq!(ledger.open_count(), 2);
2095 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "105", "0")).unwrap();
2097 assert_eq!(ledger.open_count(), 1);
2098 }
2099
2100 #[test]
2101 fn test_position_ledger_sell_increases_cash() {
2102 let mut ledger = PositionLedger::new(dec!(10000));
2103 ledger
2104 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2105 .unwrap();
2106 ledger
2107 .apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"))
2108 .unwrap();
2109 assert_eq!(ledger.cash(), dec!(10100));
2110 }
2111
2112 #[test]
2113 fn test_position_checked_unrealized_pnl_matches() {
2114 let mut pos = Position::new(sym("AAPL"));
2115 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2116 .unwrap();
2117 let price = Price::new(dec!(115)).unwrap();
2118 let checked = pos.checked_unrealized_pnl(price).unwrap();
2119 let unchecked = pos.unrealized_pnl(price);
2120 assert_eq!(checked, unchecked);
2121 assert_eq!(checked, dec!(150));
2122 }
2123
2124 #[test]
2125 fn test_position_checked_unrealized_pnl_flat_position() {
2126 let pos = Position::new(sym("X"));
2127 let price = Price::new(dec!(100)).unwrap();
2128 assert_eq!(pos.checked_unrealized_pnl(price).unwrap(), dec!(0));
2129 }
2130
2131 #[test]
2132 fn test_position_direction_flat() {
2133 let pos = Position::new(sym("X"));
2134 assert_eq!(pos.direction(), PositionDirection::Flat);
2135 }
2136
2137 #[test]
2138 fn test_position_direction_long() {
2139 let mut pos = Position::new(sym("X"));
2140 pos.apply_fill(&make_fill("X", Side::Bid, "5", "100", "0"))
2141 .unwrap();
2142 assert_eq!(pos.direction(), PositionDirection::Long);
2143 }
2144
2145 #[test]
2146 fn test_position_direction_short() {
2147 let mut pos = Position::new(sym("X"));
2148 pos.apply_fill(&make_fill("X", Side::Ask, "5", "100", "0"))
2150 .unwrap();
2151 assert_eq!(pos.direction(), PositionDirection::Short);
2152 }
2153
2154 #[test]
2155 fn test_position_ledger_positions_iterator() {
2156 let mut ledger = PositionLedger::new(dec!(10000));
2157 ledger
2158 .apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0"))
2159 .unwrap();
2160 ledger
2161 .apply_fill(make_fill("MSFT", Side::Bid, "1", "200", "0"))
2162 .unwrap();
2163 let count = ledger.positions().count();
2164 assert_eq!(count, 2);
2165 }
2166
2167 #[test]
2168 fn test_position_ledger_total_market_value() {
2169 let mut ledger = PositionLedger::new(dec!(10000));
2170 ledger
2171 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2172 .unwrap();
2173 ledger
2174 .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2175 .unwrap();
2176 let mut prices = HashMap::new();
2177 prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
2178 prices.insert("MSFT".to_owned(), Price::new(dec!(210)).unwrap());
2179 let mv = ledger.total_market_value(&prices).unwrap();
2181 assert_eq!(mv, dec!(2150));
2182 }
2183
2184 #[test]
2185 fn test_position_ledger_total_market_value_missing_price() {
2186 let mut ledger = PositionLedger::new(dec!(10000));
2187 ledger
2188 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2189 .unwrap();
2190 let prices: HashMap<String, Price> = HashMap::new();
2191 assert!(matches!(
2192 ledger.total_market_value(&prices),
2193 Err(FinError::PositionNotFound(_))
2194 ));
2195 }
2196
2197 #[test]
2198 fn test_position_ledger_unrealized_pnl_total() {
2199 let mut ledger = PositionLedger::new(dec!(10000));
2200 ledger
2201 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2202 .unwrap();
2203 let mut prices = HashMap::new();
2204 prices.insert("AAPL".to_owned(), Price::new(dec!(105)).unwrap());
2205 let upnl = ledger.unrealized_pnl_total(&prices).unwrap();
2206 assert_eq!(upnl, dec!(50));
2207 }
2208
2209 #[test]
2210 fn test_position_ledger_position_count_includes_flat() {
2211 let mut ledger = PositionLedger::new(dec!(10000));
2212 ledger
2214 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2215 .unwrap();
2216 ledger
2217 .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2218 .unwrap();
2219 ledger
2221 .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2222 .unwrap();
2223 assert_eq!(ledger.position_count(), 2, "both symbols tracked");
2224 assert_eq!(ledger.open_position_count(), 1, "only MSFT open");
2225 }
2226
2227 #[test]
2228 fn test_position_ledger_position_count_zero_on_empty() {
2229 let ledger = PositionLedger::new(dec!(10000));
2230 assert_eq!(ledger.position_count(), 0);
2231 }
2232
2233 #[test]
2234 fn test_position_unrealized_pnl_pct_long_gain() {
2235 let mut pos = Position::new(sym("AAPL"));
2236 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2237 .unwrap();
2238 let current = Price::new(dec!(110)).unwrap();
2239 let pct = pos.unrealized_pnl_pct(current).unwrap();
2240 assert_eq!(pct, dec!(10));
2241 }
2242
2243 #[test]
2244 fn test_position_unrealized_pnl_pct_flat_returns_none() {
2245 let pos = Position::new(sym("AAPL"));
2246 let current = Price::new(dec!(110)).unwrap();
2247 assert!(pos.unrealized_pnl_pct(current).is_none());
2248 }
2249
2250 #[test]
2251 fn test_position_unrealized_pnl_pct_loss() {
2252 let mut pos = Position::new(sym("AAPL"));
2253 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2254 .unwrap();
2255 let current = Price::new(dec!(90)).unwrap();
2256 let pct = pos.unrealized_pnl_pct(current).unwrap();
2257 assert_eq!(pct, dec!(-10));
2258 }
2259
2260 #[test]
2261 fn test_position_ledger_open_positions_excludes_flat() {
2262 let mut ledger = PositionLedger::new(dec!(10000));
2263 ledger
2264 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2265 .unwrap();
2266 ledger
2267 .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2268 .unwrap();
2269 ledger
2270 .apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
2271 .unwrap();
2272 let open: Vec<_> = ledger.open_positions().collect();
2273 assert_eq!(open.len(), 1);
2274 assert_eq!(open[0].symbol.as_str(), "MSFT");
2275 }
2276
2277 #[test]
2278 fn test_position_ledger_open_positions_empty_when_all_flat() {
2279 let mut ledger = PositionLedger::new(dec!(10000));
2280 ledger
2281 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2282 .unwrap();
2283 ledger
2284 .apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
2285 .unwrap();
2286 let open: Vec<_> = ledger.open_positions().collect();
2287 assert!(open.is_empty());
2288 }
2289
2290 #[test]
2291 fn test_position_is_long() {
2292 let mut pos = Position::new(sym("AAPL"));
2293 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2294 .unwrap();
2295 assert!(pos.is_long());
2296 assert!(!pos.is_short());
2297 assert!(!pos.is_flat());
2298 }
2299
2300 #[test]
2301 fn test_position_is_short() {
2302 let mut pos = Position::new(sym("AAPL"));
2303 pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2304 .unwrap();
2305 assert!(pos.is_short());
2306 assert!(!pos.is_long());
2307 assert!(!pos.is_flat());
2308 }
2309
2310 #[test]
2311 fn test_position_is_flat_after_close() {
2312 let mut pos = Position::new(sym("AAPL"));
2313 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2314 .unwrap();
2315 pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
2316 .unwrap();
2317 assert!(pos.is_flat());
2318 assert!(!pos.is_long());
2319 assert!(!pos.is_short());
2320 }
2321
2322 #[test]
2323 fn test_position_ledger_flat_positions() {
2324 let mut ledger = PositionLedger::new(dec!(10000));
2325 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2327 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2328 ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0")).unwrap();
2330 let flat: Vec<_> = ledger.flat_positions().collect();
2331 assert_eq!(flat.len(), 1);
2332 assert_eq!(flat[0].symbol, sym("AAPL"));
2333 }
2334
2335 #[test]
2336 fn test_position_ledger_flat_positions_empty_when_all_open() {
2337 let mut ledger = PositionLedger::new(dec!(10000));
2338 ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2339 assert_eq!(ledger.flat_positions().count(), 0);
2340 }
2341
2342 #[test]
2343 fn test_position_ledger_deposit_increases_cash() {
2344 let mut ledger = PositionLedger::new(dec!(1000));
2345 ledger.deposit(dec!(500));
2346 assert_eq!(ledger.cash(), dec!(1500));
2347 }
2348
2349 #[test]
2350 fn test_position_ledger_withdraw_decreases_cash() {
2351 let mut ledger = PositionLedger::new(dec!(1000));
2352 ledger.withdraw(dec!(300)).unwrap();
2353 assert_eq!(ledger.cash(), dec!(700));
2354 }
2355
2356 #[test]
2357 fn test_position_ledger_withdraw_insufficient_fails() {
2358 let mut ledger = PositionLedger::new(dec!(100));
2359 assert!(matches!(
2360 ledger.withdraw(dec!(200)),
2361 Err(FinError::InsufficientFunds { .. })
2362 ));
2363 assert_eq!(ledger.cash(), dec!(100), "cash unchanged on failure");
2364 }
2365
2366 #[test]
2367 fn test_position_is_profitable_true() {
2368 let mut pos = Position::new(sym("AAPL"));
2369 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2370 .unwrap();
2371 let current = Price::new(dec!(110)).unwrap();
2372 assert!(pos.is_profitable(current));
2373 }
2374
2375 #[test]
2376 fn test_position_is_profitable_false_when_at_loss() {
2377 let mut pos = Position::new(sym("AAPL"));
2378 pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
2379 .unwrap();
2380 let current = Price::new(dec!(90)).unwrap();
2381 assert!(!pos.is_profitable(current));
2382 }
2383
2384 #[test]
2385 fn test_position_ledger_long_positions() {
2386 let mut ledger = PositionLedger::new(dec!(10000));
2387 ledger
2388 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2389 .unwrap();
2390 let longs: Vec<_> = ledger.long_positions().collect();
2391 assert_eq!(longs.len(), 1);
2392 assert_eq!(longs[0].symbol.as_str(), "AAPL");
2393 }
2394
2395 #[test]
2396 fn test_position_ledger_short_positions_empty_for_long_only() {
2397 let mut ledger = PositionLedger::new(dec!(10000));
2398 ledger
2399 .apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
2400 .unwrap();
2401 let shorts: Vec<_> = ledger.short_positions().collect();
2402 assert!(shorts.is_empty());
2403 }
2404
2405 #[test]
2406 fn test_position_ledger_realized_pnl_after_close() {
2407 let mut ledger = PositionLedger::new(dec!(10000));
2408 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2409 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2410 assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(100)));
2411 }
2412
2413 #[test]
2414 fn test_position_ledger_realized_pnl_unknown_symbol_returns_none() {
2415 let ledger = PositionLedger::new(dec!(10000));
2416 assert!(ledger.realized_pnl(&sym("AAPL")).is_none());
2417 }
2418
2419 #[test]
2420 fn test_position_ledger_realized_pnl_zero_before_close() {
2421 let mut ledger = PositionLedger::new(dec!(10000));
2422 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2423 assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(0)));
2424 }
2425
2426 #[test]
2427 fn test_position_ledger_symbols_sorted_order() {
2428 let mut ledger = PositionLedger::new(dec!(10000));
2429 ledger.apply_fill(make_fill("MSFT", Side::Bid, "1", "100", "0")).unwrap();
2430 ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2431 ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "100", "0")).unwrap();
2432 let sorted = ledger.symbols_sorted();
2433 let names: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
2434 assert_eq!(names, vec!["AAPL", "GOOG", "MSFT"]);
2435 }
2436
2437 #[test]
2438 fn test_position_ledger_symbols_sorted_empty() {
2439 let ledger = PositionLedger::new(dec!(10000));
2440 assert!(ledger.symbols_sorted().is_empty());
2441 }
2442
2443 #[test]
2444 fn test_position_avg_entry_price_long() {
2445 let sym = Symbol::new("AAPL").unwrap();
2446 let mut pos = Position::new(sym.clone());
2447 let fill = Fill::new(
2448 sym,
2449 Side::Bid,
2450 Quantity::new(dec!(10)).unwrap(),
2451 Price::new(dec!(150)).unwrap(),
2452 NanoTimestamp::new(0),
2453 );
2454 pos.apply_fill(&fill).unwrap();
2455 assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(150));
2456 }
2457
2458 #[test]
2459 fn test_position_avg_entry_price_flat_returns_none() {
2460 let sym = Symbol::new("AAPL").unwrap();
2461 let pos = Position::new(sym);
2462 assert!(pos.avg_entry_price().is_none());
2463 }
2464
2465 #[test]
2466 fn test_position_avg_entry_price_after_partial_close() {
2467 let sym = Symbol::new("X").unwrap();
2468 let mut pos = Position::new(sym.clone());
2469 pos.apply_fill(&Fill::new(sym.clone(), Side::Bid,
2470 Quantity::new(dec!(10)).unwrap(), Price::new(dec!(100)).unwrap(),
2471 NanoTimestamp::new(0))).unwrap();
2472 pos.apply_fill(&Fill::new(sym.clone(), Side::Ask,
2473 Quantity::new(dec!(5)).unwrap(), Price::new(dec!(100)).unwrap(),
2474 NanoTimestamp::new(1))).unwrap();
2475 assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(100));
2477 }
2478
2479 #[test]
2480 fn test_position_ledger_has_position_true_after_fill() {
2481 let mut ledger = PositionLedger::new(dec!(10000));
2482 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2483 assert!(ledger.has_position(&sym("AAPL")));
2484 }
2485
2486 #[test]
2487 fn test_position_ledger_has_position_false_for_unknown() {
2488 let ledger = PositionLedger::new(dec!(10000));
2489 assert!(!ledger.has_position(&sym("AAPL")));
2490 }
2491
2492 #[test]
2493 fn test_position_ledger_has_position_true_even_when_flat() {
2494 let mut ledger = PositionLedger::new(dec!(10000));
2495 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2496 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
2497 assert!(ledger.has_position(&sym("AAPL")));
2499 }
2500
2501 #[test]
2502 fn test_position_ledger_open_symbols_returns_non_flat() {
2503 let mut ledger = PositionLedger::new(dec!(10000));
2504 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2505 ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "1")).unwrap();
2506 let symbols: Vec<_> = ledger.open_symbols().collect();
2507 assert_eq!(symbols.len(), 2);
2508 }
2509
2510 #[test]
2511 fn test_position_ledger_open_symbols_excludes_flat() {
2512 let mut ledger = PositionLedger::new(dec!(10000));
2513 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2514 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap(); ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "2")).unwrap();
2516 let symbols: Vec<_> = ledger.open_symbols().collect();
2517 assert_eq!(symbols.len(), 1);
2518 assert_eq!(symbols[0].as_str(), "MSFT");
2519 }
2520
2521 #[test]
2522 fn test_position_ledger_open_symbols_empty_when_all_flat() {
2523 let mut ledger = PositionLedger::new(dec!(10000));
2524 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2525 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap();
2526 let symbols: Vec<_> = ledger.open_symbols().collect();
2527 assert!(symbols.is_empty());
2528 }
2529
2530 #[test]
2531 fn test_position_ledger_total_long_exposure() {
2532 let mut ledger = PositionLedger::new(dec!(100000));
2533 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2534 assert_eq!(ledger.total_long_exposure(), dec!(1000));
2536 }
2537
2538 #[test]
2539 fn test_position_ledger_total_long_exposure_zero_when_flat() {
2540 let ledger = PositionLedger::new(dec!(10000));
2541 assert_eq!(ledger.total_long_exposure(), dec!(0));
2542 }
2543
2544 #[test]
2545 fn test_position_ledger_total_short_exposure_zero_when_no_shorts() {
2546 let mut ledger = PositionLedger::new(dec!(100000));
2547 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2548 assert_eq!(ledger.total_short_exposure(), dec!(0));
2549 }
2550
2551 #[test]
2552 fn test_allocation_pct_single_position() {
2553 let mut ledger = PositionLedger::new(dec!(100000));
2554 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2555 let mut prices = HashMap::new();
2556 let sym = Symbol::new("AAPL").unwrap();
2557 prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2558 let pct = ledger.allocation_pct(&sym, &prices).unwrap();
2559 assert_eq!(pct, Some(dec!(100)));
2561 }
2562
2563 #[test]
2564 fn test_allocation_pct_flat_position_returns_none() {
2565 let ledger = PositionLedger::new(dec!(100000));
2566 let mut prices = HashMap::new();
2567 let sym = Symbol::new("AAPL").unwrap();
2568 prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2569 assert!(ledger.allocation_pct(&sym, &prices).is_err());
2571 }
2572
2573 #[test]
2574 fn test_positions_sorted_by_pnl_descending() {
2575 let mut ledger = PositionLedger::new(dec!(100000));
2576 ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
2577 ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "200", "0")).unwrap();
2578 let mut prices = HashMap::new();
2579 prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2581 prices.insert("GOOG".to_string(), Price::new(dec!(250)).unwrap());
2582 let sorted = ledger.positions_sorted_by_pnl(&prices);
2583 assert_eq!(sorted[0].symbol.as_str(), "GOOG");
2585 assert_eq!(sorted[1].symbol.as_str(), "AAPL");
2586 }
2587
2588 #[test]
2589 fn test_positions_sorted_by_pnl_empty_when_all_flat() {
2590 let ledger = PositionLedger::new(dec!(100000));
2591 let prices = HashMap::new();
2592 assert!(ledger.positions_sorted_by_pnl(&prices).is_empty());
2593 }
2594
2595 #[test]
2596 fn test_all_flat_initially() {
2597 let ledger = PositionLedger::new(dec!(100000));
2598 assert!(ledger.all_flat());
2599 }
2600
2601 #[test]
2602 fn test_all_flat_false_after_open_position() {
2603 let mut ledger = PositionLedger::new(dec!(100000));
2604 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
2605 assert!(!ledger.all_flat());
2606 }
2607
2608 #[test]
2609 fn test_all_flat_true_after_close_position() {
2610 let mut ledger = PositionLedger::new(dec!(100000));
2611 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
2612 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "155", "0")).unwrap();
2613 assert!(ledger.all_flat());
2614 }
2615
2616 #[test]
2617 fn test_concentration_pct_single_position() {
2618 let mut ledger = PositionLedger::new(dec!(100000));
2619 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
2620 let sym = Symbol::new("AAPL").unwrap();
2621 let mut prices = HashMap::new();
2622 prices.insert("AAPL".to_string(), Price::new(dec!(150)).unwrap());
2623 let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2625 assert_eq!(pct, dec!(100));
2626 }
2627
2628 #[test]
2629 fn test_concentration_pct_two_equal_positions() {
2630 let mut ledger = PositionLedger::new(dec!(100000));
2631 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2632 ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0")).unwrap();
2633 let sym = Symbol::new("AAPL").unwrap();
2634 let mut prices = HashMap::new();
2635 prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
2636 prices.insert("GOOG".to_string(), Price::new(dec!(100)).unwrap());
2637 let pct = ledger.concentration_pct(&sym, &prices).unwrap();
2638 assert_eq!(pct, dec!(50));
2639 }
2640
2641 #[test]
2642 fn test_concentration_pct_missing_price_returns_none() {
2643 let mut ledger = PositionLedger::new(dec!(100000));
2644 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2645 let sym = Symbol::new("AAPL").unwrap();
2646 let prices = HashMap::new(); assert!(ledger.concentration_pct(&sym, &prices).is_none());
2648 }
2649
2650 #[test]
2651 fn test_avg_realized_pnl_per_symbol_none_when_empty() {
2652 let ledger = PositionLedger::new(dec!(100000));
2653 assert!(ledger.avg_realized_pnl_per_symbol().is_none());
2654 }
2655
2656 #[test]
2657 fn test_avg_realized_pnl_per_symbol_with_closed_trade() {
2658 let mut ledger = PositionLedger::new(dec!(100000));
2659 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2661 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2662 let avg = ledger.avg_realized_pnl_per_symbol().unwrap();
2663 assert_eq!(avg, dec!(100));
2664 }
2665
2666 #[test]
2667 fn test_net_exposure_no_prices_returns_none() {
2668 let mut ledger = PositionLedger::new(dec!(100000));
2669 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2670 let prices = HashMap::new();
2671 assert!(ledger.net_market_exposure(&prices).is_none());
2672 }
2673
2674 #[test]
2675 fn test_net_exposure_long_only() {
2676 let mut ledger = PositionLedger::new(dec!(100000));
2677 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2678 let mut prices = HashMap::new();
2679 prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
2680 assert_eq!(ledger.net_market_exposure(&prices).unwrap(), dec!(1100));
2681 }
2682
2683 #[test]
2684 fn test_win_rate_none_when_empty() {
2685 let ledger = PositionLedger::new(dec!(100000));
2686 assert!(ledger.win_rate().is_none());
2687 }
2688
2689 #[test]
2690 fn test_win_rate_one_winner() {
2691 let mut ledger = PositionLedger::new(dec!(100000));
2692 ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
2694 ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
2695 ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0")).unwrap();
2697 let rate = ledger.win_rate().unwrap();
2698 assert_eq!(rate, dec!(50));
2700 }
2701}