1use crate::error::DecimalError;
8use crate::model::decimal::{d_div, d_mul, d_sub, d_sum_iter};
9use crate::model::types::UnderlyingAssetType;
10use positive::{Positive, PositiveError};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13
14#[derive(Clone, PartialEq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
21pub struct Balance {
22 pub symbol: String,
24 pub quantity: Positive,
26 pub average_premium: Positive,
28 pub current_premium: Option<Positive>,
30 pub exchange: String,
32 pub underlying_asset_type: UnderlyingAssetType,
34 pub margin_info: Option<MarginInfo>,
36}
37
38crate::impl_json_debug_pretty!(Balance);
39crate::impl_json_display!(Balance);
40
41impl Balance {
42 #[must_use]
58 pub fn new(
59 symbol: String,
60 quantity: Positive,
61 average_premium: Positive,
62 current_premium: Option<Positive>,
63 exchange: String,
64 underlying_asset_type: UnderlyingAssetType,
65 margin_info: Option<MarginInfo>,
66 ) -> Self {
67 Self {
68 symbol,
69 quantity,
70 average_premium,
71 current_premium,
72 exchange,
73 underlying_asset_type,
74 margin_info,
75 }
76 }
77
78 pub fn get_total_value(&self) -> Result<Positive, PositiveError> {
91 let premium = self.current_premium.unwrap_or(self.average_premium);
93 self.quantity.checked_mul(&premium)
94 }
95
96 pub fn get_unrealized_pnl(&self) -> Result<Decimal, DecimalError> {
109 match self.current_premium {
110 Some(current_price) => {
111 let current_value = d_mul(
112 self.quantity.value(),
113 current_price.value(),
114 "Balance::get_unrealized_pnl/current_value",
115 )?;
116 let cost_basis = d_mul(
117 self.quantity.value(),
118 self.average_premium.value(),
119 "Balance::get_unrealized_pnl/cost_basis",
120 )?;
121 d_sub(current_value, cost_basis, "Balance::get_unrealized_pnl")
122 }
123 None => Ok(Decimal::ZERO),
124 }
125 }
126
127 pub fn update_current_premium(&mut self, new_premium: Positive) {
133 self.current_premium = Some(new_premium);
134 }
135
136 pub fn is_profitable(&self) -> Result<bool, DecimalError> {
147 Ok(self.get_unrealized_pnl()? > Decimal::ZERO)
148 }
149
150 pub fn get_cost_basis(&self) -> Result<Positive, PositiveError> {
162 self.quantity.checked_mul(&self.average_premium)
163 }
164
165 pub fn get_percentage_return(&self) -> Result<Decimal, DecimalError> {
178 match self.current_premium {
179 Some(_current_price) => {
180 let pnl = self.get_unrealized_pnl()?;
181 let cost_basis = d_mul(
182 self.quantity.to_dec(),
183 self.average_premium.to_dec(),
184 "Balance::get_percentage_return/cost_basis",
185 )?;
186 if cost_basis > Decimal::ZERO {
187 let ratio = d_div(pnl, cost_basis, "Balance::get_percentage_return/ratio")?;
188 d_mul(
189 ratio,
190 Decimal::ONE_HUNDRED,
191 "Balance::get_percentage_return/percent",
192 )
193 } else {
194 Ok(Decimal::ZERO)
195 }
196 }
197 None => Ok(Decimal::ZERO),
198 }
199 }
200}
201
202#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
204#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
205pub struct MarginInfo {
206 pub available_margin: Decimal,
208 pub used_margin: Decimal,
210 pub maintenance_margin: Decimal,
212 pub initial_margin: Option<Decimal>,
214 pub margin_ratio: Option<Decimal>,
216}
217
218crate::impl_json_debug_pretty!(MarginInfo);
219crate::impl_json_display!(MarginInfo);
220
221#[derive(Clone, PartialEq, Serialize, Deserialize)]
226#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
227pub struct Portfolio {
228 pub balances: Vec<Balance>,
230 pub name: String,
232}
233
234crate::impl_json_debug_pretty!(Portfolio);
235crate::impl_json_display!(Portfolio);
236
237impl Portfolio {
238 #[must_use]
248 pub fn new(name: String) -> Self {
249 Self {
250 balances: Vec::new(),
251 name,
252 }
253 }
254
255 pub fn add_balance(&mut self, balance: Balance) {
261 self.balances.push(balance);
262 }
263
264 pub fn remove_balance(&mut self, symbol: &str, exchange: &str) -> bool {
275 if let Some(pos) = self
276 .balances
277 .iter()
278 .position(|b| b.symbol == symbol && b.exchange == exchange)
279 {
280 self.balances.remove(pos);
281 true
282 } else {
283 false
284 }
285 }
286
287 #[must_use]
298 pub fn get_balance(&self, symbol: &str, exchange: &str) -> Option<&Balance> {
299 self.balances
300 .iter()
301 .find(|b| b.symbol == symbol && b.exchange == exchange)
302 }
303
304 pub fn get_balance_mut(&mut self, symbol: &str, exchange: &str) -> Option<&mut Balance> {
315 self.balances
316 .iter_mut()
317 .find(|b| b.symbol == symbol && b.exchange == exchange)
318 }
319
320 pub fn get_total_value(&self) -> Result<Positive, PositiveError> {
332 self.balances
333 .iter()
334 .try_fold(Positive::ZERO, |total, balance| {
335 total.checked_add(&balance.get_total_value()?)
336 })
337 }
338
339 pub fn get_total_unrealized_pnl(&self) -> Result<Decimal, DecimalError> {
351 let pnls = self
352 .balances
353 .iter()
354 .map(Balance::get_unrealized_pnl)
355 .collect::<Result<Vec<_>, _>>()?;
356 d_sum_iter(pnls, "Portfolio::get_total_unrealized_pnl")
357 }
358
359 #[must_use]
369 pub fn get_balances_by_exchange(&self, exchange: &str) -> Vec<&Balance> {
370 self.balances
371 .iter()
372 .filter(|balance| balance.exchange == exchange)
373 .collect()
374 }
375
376 pub fn has_profitable_positions(&self) -> Result<bool, DecimalError> {
386 for balance in &self.balances {
387 if balance.is_profitable()? {
388 return Ok(true);
389 }
390 }
391 Ok(false)
392 }
393
394 #[must_use]
400 pub fn balance_count(&self) -> usize {
401 self.balances.len()
402 }
403
404 #[must_use]
410 pub fn is_empty(&self) -> bool {
411 self.balances.is_empty()
412 }
413}
414
415impl Default for Portfolio {
416 fn default() -> Self {
417 Self::new("Default Portfolio".to_string())
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use positive::pos_or_panic;
425
426 use rust_decimal_macros::dec;
427
428 #[test]
429 fn test_balance_creation() {
430 let balance = Balance::new(
431 "AAPL240315C00150000".to_string(),
432 pos_or_panic!(10.0),
433 pos_or_panic!(5.50),
434 None,
435 "CBOE".to_string(),
436 UnderlyingAssetType::Stock,
437 None,
438 );
439
440 assert_eq!(balance.symbol, "AAPL240315C00150000");
441 assert_eq!(balance.quantity, pos_or_panic!(10.0));
442 assert_eq!(balance.average_premium, pos_or_panic!(5.50));
443 assert_eq!(balance.exchange, "CBOE");
444 assert_eq!(balance.underlying_asset_type, UnderlyingAssetType::Stock);
445 assert!(balance.current_premium.is_none());
446 assert!(balance.margin_info.is_none());
447 }
448
449 #[test]
450 fn test_balance_total_value() {
451 let balance = Balance::new(
452 "TSLA240315C00200000".to_string(),
453 pos_or_panic!(5.0),
454 pos_or_panic!(8.00),
455 Some(pos_or_panic!(12.50)),
456 "CBOE".to_string(),
457 UnderlyingAssetType::Stock,
458 None,
459 );
460
461 let total_value = balance.get_total_value().expect("finite product");
462 assert_eq!(total_value, pos_or_panic!(62.5)); }
464
465 #[test]
466 fn test_balance_unrealized_pnl() {
467 let balance = Balance::new(
468 "AAPL240315C00150000".to_string(),
469 pos_or_panic!(10.0),
470 pos_or_panic!(5.50),
471 Some(pos_or_panic!(7.00)),
472 "CBOE".to_string(),
473 UnderlyingAssetType::Stock,
474 None,
475 );
476
477 let pnl = balance.get_unrealized_pnl();
478 assert_eq!(pnl.ok(), Some(dec!(15.0))); }
480
481 #[test]
482 fn test_balance_is_profitable() {
483 let profitable_balance = Balance::new(
484 "GOOGL240315C00200000".to_string(),
485 pos_or_panic!(5.0),
486 pos_or_panic!(10.00),
487 Some(pos_or_panic!(12.00)),
488 "CBOE".to_string(),
489 UnderlyingAssetType::Stock,
490 None,
491 );
492
493 let losing_balance = Balance::new(
494 "TSLA240315C00080000".to_string(),
495 pos_or_panic!(3.0),
496 pos_or_panic!(8.00),
497 Some(pos_or_panic!(6.50)),
498 "CBOE".to_string(),
499 UnderlyingAssetType::Stock,
500 None,
501 );
502
503 assert_eq!(profitable_balance.is_profitable().ok(), Some(true));
504 assert_eq!(losing_balance.is_profitable().ok(), Some(false));
505 }
506
507 #[test]
508 fn test_portfolio_creation() {
509 let portfolio = Portfolio::new("My Portfolio".to_string());
510 assert_eq!(portfolio.name, "My Portfolio");
511 assert!(portfolio.is_empty());
512 assert_eq!(portfolio.balance_count(), 0);
513 }
514
515 #[test]
516 fn test_portfolio_add_balance() {
517 let mut portfolio = Portfolio::new("Test Portfolio".to_string());
518 let balance = Balance::new(
519 "AAPL240315C00150000".to_string(),
520 pos_or_panic!(10.0),
521 pos_or_panic!(5.50),
522 Some(pos_or_panic!(7.00)),
523 "CBOE".to_string(),
524 UnderlyingAssetType::Stock,
525 None,
526 );
527
528 portfolio.add_balance(balance);
529 assert_eq!(portfolio.balance_count(), 1);
530 assert!(!portfolio.is_empty());
531 }
532
533 #[test]
534 fn test_portfolio_total_value() {
535 let mut portfolio = Portfolio::new("Test Portfolio".to_string());
536
537 let balance1 = Balance::new(
538 "AAPL240315C00150000".to_string(),
539 pos_or_panic!(10.0),
540 pos_or_panic!(5.50),
541 Some(pos_or_panic!(7.00)),
542 "CBOE".to_string(),
543 UnderlyingAssetType::Stock,
544 None,
545 );
546
547 let balance2 = Balance::new(
548 "TSLA240315C00200000".to_string(),
549 pos_or_panic!(5.0),
550 pos_or_panic!(8.00),
551 Some(pos_or_panic!(12.50)),
552 "CBOE".to_string(),
553 UnderlyingAssetType::Stock,
554 None,
555 );
556
557 portfolio.add_balance(balance1);
558 portfolio.add_balance(balance2);
559
560 let total_value = portfolio.get_total_value().expect("finite sum");
561 assert_eq!(total_value, pos_or_panic!(132.5)); }
563
564 #[test]
565 fn test_portfolio_get_balance() {
566 let mut portfolio = Portfolio::new("Test Portfolio".to_string());
567 let balance = Balance::new(
568 "AAPL240315C00150000".to_string(),
569 pos_or_panic!(10.0),
570 pos_or_panic!(5.50),
571 Some(pos_or_panic!(7.00)),
572 "CBOE".to_string(),
573 UnderlyingAssetType::Stock,
574 None,
575 );
576
577 portfolio.add_balance(balance);
578
579 let found_balance = portfolio.get_balance("AAPL240315C00150000", "CBOE");
580 assert!(found_balance.is_some());
581 assert_eq!(found_balance.unwrap().symbol, "AAPL240315C00150000");
582
583 let not_found = portfolio.get_balance("TSLA240315C00200000", "CBOE");
584 assert!(not_found.is_none());
585 }
586
587 fn overflowing_balance(current_premium: Option<Positive>) -> Balance {
588 Balance::new(
589 "BIG".to_string(),
590 Positive::MAX,
591 pos_or_panic!(2.0),
592 current_premium,
593 "CBOE".to_string(),
594 UnderlyingAssetType::Stock,
595 None,
596 )
597 }
598
599 #[test]
600 fn test_balance_values_are_exact_decimal_products() {
601 let balance = Balance::new(
602 "AAPL240315C00150000".to_string(),
603 pos_or_panic!(3.0),
604 pos_or_panic!(0.1),
605 Some(pos_or_panic!(0.2)),
606 "CBOE".to_string(),
607 UnderlyingAssetType::Stock,
608 None,
609 );
610
611 assert_eq!(
612 balance.get_total_value().expect("finite product").to_dec(),
613 dec!(0.6)
614 );
615 assert_eq!(
616 balance.get_cost_basis().expect("finite product").to_dec(),
617 dec!(0.3)
618 );
619 assert_eq!(
620 balance.get_percentage_return().expect("finite ratio"),
621 dec!(100)
622 );
623 }
624
625 #[test]
626 fn test_balance_without_current_premium_values_at_cost() {
627 let balance = Balance::new(
628 "AAPL240315C00150000".to_string(),
629 pos_or_panic!(10.0),
630 pos_or_panic!(5.50),
631 None,
632 "CBOE".to_string(),
633 UnderlyingAssetType::Stock,
634 None,
635 );
636
637 assert_eq!(
638 balance.get_total_value().expect("finite product"),
639 pos_or_panic!(55.0)
640 );
641 assert_eq!(
642 balance.get_percentage_return().expect("no premium"),
643 Decimal::ZERO
644 );
645 }
646
647 #[test]
648 fn test_balance_total_value_overflow_is_an_error_not_zero() {
649 let balance = overflowing_balance(Some(pos_or_panic!(2.0)));
650 assert!(matches!(
651 balance.get_total_value(),
652 Err(PositiveError::ArithmeticError { .. })
653 ));
654 }
655
656 #[test]
657 fn test_balance_cost_basis_overflow_is_an_error_not_zero() {
658 let balance = overflowing_balance(None);
659 assert!(matches!(
660 balance.get_cost_basis(),
661 Err(PositiveError::ArithmeticError { .. })
662 ));
663 }
664
665 #[test]
666 fn test_balance_percentage_return_ratio_overflow_is_an_error() {
667 let balance = Balance::new(
670 "TINY".to_string(),
671 Positive::ONE,
672 pos_or_panic!(0.000_000_000_000_000_000_01),
673 Some(pos_or_panic!(10_000_000_000.0)),
674 "CBOE".to_string(),
675 UnderlyingAssetType::Stock,
676 None,
677 );
678 assert!(matches!(
679 balance.get_percentage_return(),
680 Err(DecimalError::Overflow { .. })
681 ));
682 }
683
684 #[test]
685 fn test_portfolio_total_value_overflow_is_an_error_not_zero() {
686 let mut portfolio = Portfolio::new("Overflow".to_string());
687 portfolio.add_balance(overflowing_balance(Some(pos_or_panic!(1.0))));
688 portfolio.add_balance(overflowing_balance(Some(pos_or_panic!(1.0))));
689 assert!(matches!(
690 portfolio.get_total_value(),
691 Err(PositiveError::ArithmeticError { .. })
692 ));
693 }
694
695 #[test]
696 fn test_empty_portfolio_total_value_is_zero() {
697 let portfolio = Portfolio::new("Empty".to_string());
698 assert_eq!(
699 portfolio.get_total_value().expect("empty sum"),
700 Positive::ZERO
701 );
702 }
703
704 #[test]
707 fn test_unrealized_pnl_overflow_is_error() {
708 let wide = overflowing_balance(Some(pos_or_panic!(2.0)));
709 assert!(wide.get_unrealized_pnl().is_err());
710 assert!(wide.is_profitable().is_err());
711
712 let half = Positive::new_decimal(Decimal::MAX / dec!(2)).unwrap();
713 let mut portfolio = Portfolio::new("Overflow".to_string());
714 for _ in 0..3 {
715 portfolio.add_balance(Balance::new(
716 "HALF".to_string(),
717 half,
718 Positive::ZERO,
719 Some(Positive::ONE),
720 "CBOE".to_string(),
721 UnderlyingAssetType::Stock,
722 None,
723 ));
724 }
725 assert!(portfolio.get_total_unrealized_pnl().is_err());
726 assert_eq!(portfolio.has_profitable_positions().ok(), Some(true));
727 portfolio.add_balance(wide);
728 assert!(portfolio.has_profitable_positions().is_ok());
729 }
730
731 #[test]
732 fn test_portfolio_total_unrealized_pnl_sums_balances() {
733 let mut portfolio = Portfolio::new("Sum".to_string());
734 portfolio.add_balance(Balance::new(
735 "A".to_string(),
736 pos_or_panic!(10.0),
737 pos_or_panic!(5.50),
738 Some(pos_or_panic!(7.00)),
739 "CBOE".to_string(),
740 UnderlyingAssetType::Stock,
741 None,
742 ));
743 portfolio.add_balance(Balance::new(
744 "B".to_string(),
745 pos_or_panic!(3.0),
746 pos_or_panic!(8.00),
747 Some(pos_or_panic!(6.50)),
748 "CBOE".to_string(),
749 UnderlyingAssetType::Stock,
750 None,
751 ));
752 assert_eq!(portfolio.get_total_unrealized_pnl().ok(), Some(dec!(10.5)));
754 assert_eq!(portfolio.has_profitable_positions().ok(), Some(true));
755 }
756}