use crate::error::DecimalError;
use crate::model::decimal::{d_div, d_mul, d_sub, d_sum_iter};
use crate::model::types::UnderlyingAssetType;
use positive::{Positive, PositiveError};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Balance {
pub symbol: String,
pub quantity: Positive,
pub average_premium: Positive,
pub current_premium: Option<Positive>,
pub exchange: String,
pub underlying_asset_type: UnderlyingAssetType,
pub margin_info: Option<MarginInfo>,
}
crate::impl_json_debug_pretty!(Balance);
crate::impl_json_display!(Balance);
impl Balance {
#[must_use]
pub fn new(
symbol: String,
quantity: Positive,
average_premium: Positive,
current_premium: Option<Positive>,
exchange: String,
underlying_asset_type: UnderlyingAssetType,
margin_info: Option<MarginInfo>,
) -> Self {
Self {
symbol,
quantity,
average_premium,
current_premium,
exchange,
underlying_asset_type,
margin_info,
}
}
pub fn get_total_value(&self) -> Result<Positive, PositiveError> {
let premium = self.current_premium.unwrap_or(self.average_premium);
self.quantity.checked_mul(&premium)
}
pub fn get_unrealized_pnl(&self) -> Result<Decimal, DecimalError> {
match self.current_premium {
Some(current_price) => {
let current_value = d_mul(
self.quantity.value(),
current_price.value(),
"Balance::get_unrealized_pnl/current_value",
)?;
let cost_basis = d_mul(
self.quantity.value(),
self.average_premium.value(),
"Balance::get_unrealized_pnl/cost_basis",
)?;
d_sub(current_value, cost_basis, "Balance::get_unrealized_pnl")
}
None => Ok(Decimal::ZERO),
}
}
pub fn update_current_premium(&mut self, new_premium: Positive) {
self.current_premium = Some(new_premium);
}
pub fn is_profitable(&self) -> Result<bool, DecimalError> {
Ok(self.get_unrealized_pnl()? > Decimal::ZERO)
}
pub fn get_cost_basis(&self) -> Result<Positive, PositiveError> {
self.quantity.checked_mul(&self.average_premium)
}
pub fn get_percentage_return(&self) -> Result<Decimal, DecimalError> {
match self.current_premium {
Some(_current_price) => {
let pnl = self.get_unrealized_pnl()?;
let cost_basis = d_mul(
self.quantity.to_dec(),
self.average_premium.to_dec(),
"Balance::get_percentage_return/cost_basis",
)?;
if cost_basis > Decimal::ZERO {
let ratio = d_div(pnl, cost_basis, "Balance::get_percentage_return/ratio")?;
d_mul(
ratio,
Decimal::ONE_HUNDRED,
"Balance::get_percentage_return/percent",
)
} else {
Ok(Decimal::ZERO)
}
}
None => Ok(Decimal::ZERO),
}
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct MarginInfo {
pub available_margin: Decimal,
pub used_margin: Decimal,
pub maintenance_margin: Decimal,
pub initial_margin: Option<Decimal>,
pub margin_ratio: Option<Decimal>,
}
crate::impl_json_debug_pretty!(MarginInfo);
crate::impl_json_display!(MarginInfo);
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Portfolio {
pub balances: Vec<Balance>,
pub name: String,
}
crate::impl_json_debug_pretty!(Portfolio);
crate::impl_json_display!(Portfolio);
impl Portfolio {
#[must_use]
pub fn new(name: String) -> Self {
Self {
balances: Vec::new(),
name,
}
}
pub fn add_balance(&mut self, balance: Balance) {
self.balances.push(balance);
}
pub fn remove_balance(&mut self, symbol: &str, exchange: &str) -> bool {
if let Some(pos) = self
.balances
.iter()
.position(|b| b.symbol == symbol && b.exchange == exchange)
{
self.balances.remove(pos);
true
} else {
false
}
}
#[must_use]
pub fn get_balance(&self, symbol: &str, exchange: &str) -> Option<&Balance> {
self.balances
.iter()
.find(|b| b.symbol == symbol && b.exchange == exchange)
}
pub fn get_balance_mut(&mut self, symbol: &str, exchange: &str) -> Option<&mut Balance> {
self.balances
.iter_mut()
.find(|b| b.symbol == symbol && b.exchange == exchange)
}
pub fn get_total_value(&self) -> Result<Positive, PositiveError> {
self.balances
.iter()
.try_fold(Positive::ZERO, |total, balance| {
total.checked_add(&balance.get_total_value()?)
})
}
pub fn get_total_unrealized_pnl(&self) -> Result<Decimal, DecimalError> {
let pnls = self
.balances
.iter()
.map(Balance::get_unrealized_pnl)
.collect::<Result<Vec<_>, _>>()?;
d_sum_iter(pnls, "Portfolio::get_total_unrealized_pnl")
}
#[must_use]
pub fn get_balances_by_exchange(&self, exchange: &str) -> Vec<&Balance> {
self.balances
.iter()
.filter(|balance| balance.exchange == exchange)
.collect()
}
pub fn has_profitable_positions(&self) -> Result<bool, DecimalError> {
for balance in &self.balances {
if balance.is_profitable()? {
return Ok(true);
}
}
Ok(false)
}
#[must_use]
pub fn balance_count(&self) -> usize {
self.balances.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.balances.is_empty()
}
}
impl Default for Portfolio {
fn default() -> Self {
Self::new("Default Portfolio".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use positive::pos_or_panic;
use rust_decimal_macros::dec;
#[test]
fn test_balance_creation() {
let balance = Balance::new(
"AAPL240315C00150000".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(5.50),
None,
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
assert_eq!(balance.symbol, "AAPL240315C00150000");
assert_eq!(balance.quantity, pos_or_panic!(10.0));
assert_eq!(balance.average_premium, pos_or_panic!(5.50));
assert_eq!(balance.exchange, "CBOE");
assert_eq!(balance.underlying_asset_type, UnderlyingAssetType::Stock);
assert!(balance.current_premium.is_none());
assert!(balance.margin_info.is_none());
}
#[test]
fn test_balance_total_value() {
let balance = Balance::new(
"TSLA240315C00200000".to_string(),
pos_or_panic!(5.0),
pos_or_panic!(8.00),
Some(pos_or_panic!(12.50)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
let total_value = balance.get_total_value().expect("finite product");
assert_eq!(total_value, pos_or_panic!(62.5)); }
#[test]
fn test_balance_unrealized_pnl() {
let balance = Balance::new(
"AAPL240315C00150000".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(5.50),
Some(pos_or_panic!(7.00)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
let pnl = balance.get_unrealized_pnl();
assert_eq!(pnl.ok(), Some(dec!(15.0))); }
#[test]
fn test_balance_is_profitable() {
let profitable_balance = Balance::new(
"GOOGL240315C00200000".to_string(),
pos_or_panic!(5.0),
pos_or_panic!(10.00),
Some(pos_or_panic!(12.00)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
let losing_balance = Balance::new(
"TSLA240315C00080000".to_string(),
pos_or_panic!(3.0),
pos_or_panic!(8.00),
Some(pos_or_panic!(6.50)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
assert_eq!(profitable_balance.is_profitable().ok(), Some(true));
assert_eq!(losing_balance.is_profitable().ok(), Some(false));
}
#[test]
fn test_portfolio_creation() {
let portfolio = Portfolio::new("My Portfolio".to_string());
assert_eq!(portfolio.name, "My Portfolio");
assert!(portfolio.is_empty());
assert_eq!(portfolio.balance_count(), 0);
}
#[test]
fn test_portfolio_add_balance() {
let mut portfolio = Portfolio::new("Test Portfolio".to_string());
let balance = Balance::new(
"AAPL240315C00150000".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(5.50),
Some(pos_or_panic!(7.00)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
portfolio.add_balance(balance);
assert_eq!(portfolio.balance_count(), 1);
assert!(!portfolio.is_empty());
}
#[test]
fn test_portfolio_total_value() {
let mut portfolio = Portfolio::new("Test Portfolio".to_string());
let balance1 = Balance::new(
"AAPL240315C00150000".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(5.50),
Some(pos_or_panic!(7.00)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
let balance2 = Balance::new(
"TSLA240315C00200000".to_string(),
pos_or_panic!(5.0),
pos_or_panic!(8.00),
Some(pos_or_panic!(12.50)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
portfolio.add_balance(balance1);
portfolio.add_balance(balance2);
let total_value = portfolio.get_total_value().expect("finite sum");
assert_eq!(total_value, pos_or_panic!(132.5)); }
#[test]
fn test_portfolio_get_balance() {
let mut portfolio = Portfolio::new("Test Portfolio".to_string());
let balance = Balance::new(
"AAPL240315C00150000".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(5.50),
Some(pos_or_panic!(7.00)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
portfolio.add_balance(balance);
let found_balance = portfolio.get_balance("AAPL240315C00150000", "CBOE");
assert!(found_balance.is_some());
assert_eq!(found_balance.unwrap().symbol, "AAPL240315C00150000");
let not_found = portfolio.get_balance("TSLA240315C00200000", "CBOE");
assert!(not_found.is_none());
}
fn overflowing_balance(current_premium: Option<Positive>) -> Balance {
Balance::new(
"BIG".to_string(),
Positive::MAX,
pos_or_panic!(2.0),
current_premium,
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
)
}
#[test]
fn test_balance_values_are_exact_decimal_products() {
let balance = Balance::new(
"AAPL240315C00150000".to_string(),
pos_or_panic!(3.0),
pos_or_panic!(0.1),
Some(pos_or_panic!(0.2)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
assert_eq!(
balance.get_total_value().expect("finite product").to_dec(),
dec!(0.6)
);
assert_eq!(
balance.get_cost_basis().expect("finite product").to_dec(),
dec!(0.3)
);
assert_eq!(
balance.get_percentage_return().expect("finite ratio"),
dec!(100)
);
}
#[test]
fn test_balance_without_current_premium_values_at_cost() {
let balance = Balance::new(
"AAPL240315C00150000".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(5.50),
None,
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
assert_eq!(
balance.get_total_value().expect("finite product"),
pos_or_panic!(55.0)
);
assert_eq!(
balance.get_percentage_return().expect("no premium"),
Decimal::ZERO
);
}
#[test]
fn test_balance_total_value_overflow_is_an_error_not_zero() {
let balance = overflowing_balance(Some(pos_or_panic!(2.0)));
assert!(matches!(
balance.get_total_value(),
Err(PositiveError::ArithmeticError { .. })
));
}
#[test]
fn test_balance_cost_basis_overflow_is_an_error_not_zero() {
let balance = overflowing_balance(None);
assert!(matches!(
balance.get_cost_basis(),
Err(PositiveError::ArithmeticError { .. })
));
}
#[test]
fn test_balance_percentage_return_ratio_overflow_is_an_error() {
let balance = Balance::new(
"TINY".to_string(),
Positive::ONE,
pos_or_panic!(0.000_000_000_000_000_000_01),
Some(pos_or_panic!(10_000_000_000.0)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
);
assert!(matches!(
balance.get_percentage_return(),
Err(DecimalError::Overflow { .. })
));
}
#[test]
fn test_portfolio_total_value_overflow_is_an_error_not_zero() {
let mut portfolio = Portfolio::new("Overflow".to_string());
portfolio.add_balance(overflowing_balance(Some(pos_or_panic!(1.0))));
portfolio.add_balance(overflowing_balance(Some(pos_or_panic!(1.0))));
assert!(matches!(
portfolio.get_total_value(),
Err(PositiveError::ArithmeticError { .. })
));
}
#[test]
fn test_empty_portfolio_total_value_is_zero() {
let portfolio = Portfolio::new("Empty".to_string());
assert_eq!(
portfolio.get_total_value().expect("empty sum"),
Positive::ZERO
);
}
#[test]
fn test_unrealized_pnl_overflow_is_error() {
let wide = overflowing_balance(Some(pos_or_panic!(2.0)));
assert!(wide.get_unrealized_pnl().is_err());
assert!(wide.is_profitable().is_err());
let half = Positive::new_decimal(Decimal::MAX / dec!(2)).unwrap();
let mut portfolio = Portfolio::new("Overflow".to_string());
for _ in 0..3 {
portfolio.add_balance(Balance::new(
"HALF".to_string(),
half,
Positive::ZERO,
Some(Positive::ONE),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
));
}
assert!(portfolio.get_total_unrealized_pnl().is_err());
assert_eq!(portfolio.has_profitable_positions().ok(), Some(true));
portfolio.add_balance(wide);
assert!(portfolio.has_profitable_positions().is_ok());
}
#[test]
fn test_portfolio_total_unrealized_pnl_sums_balances() {
let mut portfolio = Portfolio::new("Sum".to_string());
portfolio.add_balance(Balance::new(
"A".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(5.50),
Some(pos_or_panic!(7.00)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
));
portfolio.add_balance(Balance::new(
"B".to_string(),
pos_or_panic!(3.0),
pos_or_panic!(8.00),
Some(pos_or_panic!(6.50)),
"CBOE".to_string(),
UnderlyingAssetType::Stock,
None,
));
assert_eq!(portfolio.get_total_unrealized_pnl().ok(), Some(dec!(10.5)));
assert_eq!(portfolio.has_profitable_positions().ok(), Some(true));
}
}