use crate::error::FinError;
use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
use rust_decimal::Decimal;
use std::collections::HashMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Fill {
pub symbol: Symbol,
pub side: Side,
pub quantity: Quantity,
pub price: Price,
pub timestamp: NanoTimestamp,
pub commission: Decimal,
}
impl Fill {
pub fn new(
symbol: Symbol,
side: Side,
quantity: Quantity,
price: Price,
timestamp: NanoTimestamp,
) -> Self {
Self {
symbol,
side,
quantity,
price,
timestamp,
commission: Decimal::ZERO,
}
}
pub fn with_commission(
symbol: Symbol,
side: Side,
quantity: Quantity,
price: Price,
timestamp: NanoTimestamp,
commission: Decimal,
) -> Self {
Self {
symbol,
side,
quantity,
price,
timestamp,
commission,
}
}
pub fn notional(&self) -> Decimal {
self.price.value() * self.quantity.value()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PositionDirection {
Long,
Short,
Flat,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Position {
pub symbol: Symbol,
pub quantity: Decimal,
pub avg_cost: Decimal,
pub realized_pnl: Decimal,
#[serde(default)]
pub open_bar: usize,
}
impl Position {
pub fn new(symbol: Symbol) -> Self {
Self {
symbol,
quantity: Decimal::ZERO,
avg_cost: Decimal::ZERO,
realized_pnl: Decimal::ZERO,
open_bar: 0,
}
}
pub fn set_open_bar(&mut self, bar: usize) {
self.open_bar = bar;
}
pub fn position_age_bars(&self, current_bar: usize) -> usize {
current_bar.saturating_sub(self.open_bar)
}
pub fn max_favorable_excursion(&self, prices: &[Price]) -> Option<Decimal> {
if self.is_flat() || self.avg_cost.is_zero() || prices.is_empty() {
return None;
}
let best = if self.is_long() {
prices
.iter()
.map(|p| (p.value() - self.avg_cost) * self.quantity)
.fold(Decimal::MIN, Decimal::max)
} else {
prices
.iter()
.map(|p| (self.avg_cost - p.value()) * self.quantity.abs())
.fold(Decimal::MIN, Decimal::max)
};
if best < Decimal::ZERO {
Some(Decimal::ZERO)
} else {
Some(best)
}
}
pub fn kelly_fraction(
win_rate: Decimal,
avg_win: Decimal,
avg_loss: Decimal,
) -> Option<Decimal> {
if avg_loss.is_zero() || avg_win.is_zero() {
return None;
}
let odds = avg_win / avg_loss;
let kelly = win_rate - (Decimal::ONE - win_rate) / odds;
Some(kelly.max(Decimal::ZERO).min(Decimal::ONE))
}
pub fn apply_fill(&mut self, fill: &Fill) -> Result<Decimal, FinError> {
let fill_qty = match fill.side {
Side::Bid => fill.quantity.value(),
Side::Ask => -fill.quantity.value(),
};
let realized = if self.quantity != Decimal::ZERO
&& (self.quantity > Decimal::ZERO) != (fill_qty > Decimal::ZERO)
{
let closed = fill_qty.abs().min(self.quantity.abs());
if self.quantity > Decimal::ZERO {
closed * (fill.price.value() - self.avg_cost)
} else {
closed * (self.avg_cost - fill.price.value())
}
} else {
Decimal::ZERO
};
let new_qty = self.quantity + fill_qty;
if new_qty == Decimal::ZERO {
self.avg_cost = Decimal::ZERO;
} else if (self.quantity >= Decimal::ZERO && fill_qty > Decimal::ZERO)
|| (self.quantity <= Decimal::ZERO && fill_qty < Decimal::ZERO)
{
let total_cost =
self.avg_cost * self.quantity.abs() + fill.price.value() * fill_qty.abs();
self.avg_cost = total_cost
.checked_div(new_qty.abs())
.ok_or(FinError::ArithmeticOverflow)?;
} else if new_qty.abs() <= self.quantity.abs() {
} else {
self.avg_cost = fill.price.value();
}
self.quantity = new_qty;
let net_realized = realized - fill.commission;
self.realized_pnl += net_realized;
Ok(net_realized)
}
pub fn unrealized_pnl(&self, current_price: Price) -> Decimal {
self.quantity * (current_price.value() - self.avg_cost)
}
pub fn checked_unrealized_pnl(&self, current_price: Price) -> Result<Decimal, FinError> {
let diff = current_price.value() - self.avg_cost;
self.quantity
.checked_mul(diff)
.ok_or(FinError::ArithmeticOverflow)
}
pub fn unrealized_pnl_pct(&self, current_price: Price) -> Option<Decimal> {
if self.is_flat() || self.avg_cost.is_zero() {
return None;
}
let cost_basis = self.quantity.abs() * self.avg_cost;
if cost_basis.is_zero() {
return None;
}
let upnl = self.unrealized_pnl(current_price);
upnl.checked_div(cost_basis).map(|r| r * Decimal::from(100u32))
}
pub fn total_cost_basis(&self) -> Decimal {
self.quantity.abs() * self.avg_cost
}
pub fn market_value(&self, current_price: Price) -> Decimal {
self.quantity * current_price.value()
}
pub fn is_flat(&self) -> bool {
self.quantity == Decimal::ZERO
}
pub fn is_long(&self) -> bool {
self.quantity > Decimal::ZERO
}
pub fn is_short(&self) -> bool {
self.quantity < Decimal::ZERO
}
pub fn direction(&self) -> PositionDirection {
if self.quantity > Decimal::ZERO {
PositionDirection::Long
} else if self.quantity < Decimal::ZERO {
PositionDirection::Short
} else {
PositionDirection::Flat
}
}
pub fn total_pnl(&self, current_price: Price) -> Decimal {
self.realized_pnl + self.unrealized_pnl(current_price)
}
pub fn quantity_abs(&self) -> Decimal {
self.quantity.abs()
}
pub fn cost_basis(&self) -> Decimal {
self.avg_cost * self.quantity.abs()
}
pub fn is_profitable(&self, current_price: Price) -> bool {
self.unrealized_pnl(current_price) > Decimal::ZERO
}
pub fn avg_entry_price(&self) -> Option<Price> {
Price::new(self.avg_cost).ok()
}
pub fn exposure_pct(&self, current_price: Price, total_portfolio_value: Decimal) -> Option<Decimal> {
if total_portfolio_value.is_zero() || self.is_flat() {
return None;
}
let market_value = (self.quantity * current_price.value()).abs();
Some(market_value / total_portfolio_value * Decimal::ONE_HUNDRED)
}
pub fn stop_loss_price(&self, stop_pct: Decimal) -> Option<Price> {
if self.is_flat() || self.avg_cost.is_zero() {
return None;
}
let factor = stop_pct / Decimal::ONE_HUNDRED;
let stop = if self.is_long() {
self.avg_cost * (Decimal::ONE - factor)
} else {
self.avg_cost * (Decimal::ONE + factor)
};
Price::new(stop).ok()
}
pub fn take_profit_price(&self, tp_pct: Decimal) -> Option<Price> {
if self.is_flat() || self.avg_cost.is_zero() {
return None;
}
let factor = tp_pct / Decimal::ONE_HUNDRED;
let tp = if self.is_long() {
self.avg_cost * (Decimal::ONE + factor)
} else {
self.avg_cost * (Decimal::ONE - factor)
};
Price::new(tp).ok()
}
pub fn margin_requirement(&self, margin_pct: Decimal) -> Option<Decimal> {
if self.is_flat() || self.avg_cost.is_zero() {
return None;
}
let notional = self.quantity.abs() * self.avg_cost;
Some(notional * margin_pct / Decimal::ONE_HUNDRED)
}
pub fn risk_reward_ratio(stop_pct: Decimal, target_pct: Decimal) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
if stop_pct <= Decimal::ZERO {
return None;
}
(target_pct / stop_pct).to_f64()
}
pub fn leverage(&self, portfolio_value: Decimal) -> Option<Decimal> {
if self.is_flat() || self.avg_cost.is_zero() || portfolio_value.is_zero() {
return None;
}
let notional = self.quantity.abs() * self.avg_cost;
Some(notional / portfolio_value)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PositionLedger {
positions: HashMap<Symbol, Position>,
cash: Decimal,
total_commission_paid: Decimal,
}
impl PositionLedger {
pub fn new(initial_cash: Decimal) -> Self {
Self {
positions: HashMap::new(),
cash: initial_cash,
total_commission_paid: Decimal::ZERO,
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn apply_fill(&mut self, fill: Fill) -> Result<(), FinError> {
let cost = match fill.side {
Side::Bid => -(fill.quantity.value() * fill.price.value() + fill.commission),
Side::Ask => fill.quantity.value() * fill.price.value() - fill.commission,
};
if fill.side == Side::Bid && self.cash + cost < Decimal::ZERO {
return Err(FinError::InsufficientFunds {
need: fill.quantity.value() * fill.price.value() + fill.commission,
have: self.cash,
});
}
self.cash += cost;
self.total_commission_paid += fill.commission;
let pos = self
.positions
.entry(fill.symbol.clone())
.or_insert_with(|| Position::new(fill.symbol.clone()));
pos.apply_fill(&fill)?;
Ok(())
}
pub fn position(&self, symbol: &Symbol) -> Option<&Position> {
self.positions.get(symbol)
}
pub fn has_position(&self, symbol: &Symbol) -> bool {
self.positions.contains_key(symbol)
}
pub fn positions(&self) -> impl Iterator<Item = &Position> {
self.positions.values()
}
pub fn open_positions(&self) -> impl Iterator<Item = &Position> {
self.positions.values().filter(|p| !p.is_flat())
}
pub fn flat_positions(&self) -> impl Iterator<Item = &Position> {
self.positions.values().filter(|p| p.is_flat())
}
pub fn long_positions(&self) -> impl Iterator<Item = &Position> {
self.positions.values().filter(|p| p.is_long())
}
pub fn short_positions(&self) -> impl Iterator<Item = &Position> {
self.positions.values().filter(|p| p.is_short())
}
pub fn symbols(&self) -> impl Iterator<Item = &Symbol> {
self.positions.keys()
}
pub fn open_symbols(&self) -> impl Iterator<Item = &Symbol> {
self.positions
.iter()
.filter(|(_, p)| !p.is_flat())
.map(|(s, _)| s)
}
pub fn total_long_exposure(&self) -> Decimal {
self.positions
.values()
.filter(|p| p.is_long())
.map(|p| p.quantity.abs() * p.avg_cost)
.sum()
}
pub fn total_short_exposure(&self) -> Decimal {
self.positions
.values()
.filter(|p| p.is_short())
.map(|p| p.quantity.abs() * p.avg_cost)
.sum()
}
pub fn symbols_sorted(&self) -> Vec<&Symbol> {
let mut syms: Vec<&Symbol> = self.positions.keys().collect();
syms.sort();
syms
}
pub fn position_count(&self) -> usize {
self.positions.len()
}
pub fn deposit(&mut self, amount: Decimal) {
self.cash += amount;
}
pub fn withdraw(&mut self, amount: Decimal) -> Result<(), FinError> {
if amount > self.cash {
return Err(FinError::InsufficientFunds {
need: amount,
have: self.cash,
});
}
self.cash -= amount;
Ok(())
}
pub fn open_position_count(&self) -> usize {
self.positions.values().filter(|p| !p.is_flat()).count()
}
pub fn long_count(&self) -> usize {
self.positions.values().filter(|p| p.quantity > Decimal::ZERO).count()
}
pub fn short_count(&self) -> usize {
self.positions.values().filter(|p| p.quantity < Decimal::ZERO).count()
}
pub fn net_exposure(&self) -> Decimal {
self.positions.values().map(|p| p.quantity).sum()
}
pub fn net_market_exposure(&self, prices: &std::collections::HashMap<String, Price>) -> Option<Decimal> {
let mut found = false;
let mut net = Decimal::ZERO;
for pos in self.positions.values() {
if pos.quantity.is_zero() { continue; }
if let Some(&price) = prices.get(pos.symbol.as_str()) {
found = true;
net += pos.quantity * price.value();
}
}
if found { Some(net) } else { None }
}
pub fn gross_exposure(&self) -> Decimal {
self.positions.values().map(|p| p.quantity.abs()).sum()
}
pub fn open_count(&self) -> usize {
self.positions.values().filter(|p| !p.is_flat()).count()
}
pub fn largest_position(&self) -> Option<&Position> {
self.positions
.values()
.filter(|p| !p.is_flat())
.max_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn total_market_value(
&self,
prices: &HashMap<String, Price>,
) -> Result<Decimal, FinError> {
let mut total = Decimal::ZERO;
for (sym, pos) in &self.positions {
if pos.quantity == Decimal::ZERO {
continue;
}
let price = prices
.get(sym.as_str())
.ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
total += pos.market_value(*price);
}
Ok(total)
}
pub fn cash(&self) -> Decimal {
self.cash
}
pub fn position_weights(&self, prices: &HashMap<String, Price>) -> Vec<(Symbol, Decimal)> {
let mut mv_pairs: Vec<(Symbol, Decimal)> = self
.positions
.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, pos)| {
let price = prices.get(sym.as_str())?;
Some((sym.clone(), pos.market_value(*price).abs()))
})
.collect();
let total: Decimal = mv_pairs.iter().map(|(_, v)| *v).sum();
if total.is_zero() {
return vec![];
}
mv_pairs.iter_mut().for_each(|(_, v)| *v /= total);
mv_pairs
}
pub fn realized_pnl_total(&self) -> Decimal {
self.positions.values().map(|p| p.realized_pnl).sum()
}
pub fn unrealized_pnl_total(
&self,
prices: &HashMap<String, Price>,
) -> Result<Decimal, FinError> {
let mut total = Decimal::ZERO;
for (sym, pos) in &self.positions {
if pos.quantity == Decimal::ZERO {
continue;
}
let price = prices
.get(sym.as_str())
.ok_or_else(|| FinError::PositionNotFound(sym.as_str().to_owned()))?;
total += pos.unrealized_pnl(*price);
}
Ok(total)
}
pub fn realized_pnl(&self, symbol: &Symbol) -> Option<Decimal> {
self.positions.get(symbol).map(|p| p.realized_pnl)
}
pub fn net_pnl(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
Ok(self.realized_pnl_total() + self.unrealized_pnl_total(prices)?)
}
pub fn equity(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
Ok(self.cash + self.unrealized_pnl_total(prices)?)
}
pub fn net_liquidation_value(&self, prices: &HashMap<String, Price>) -> Result<Decimal, FinError> {
let mut total = self.cash;
for (symbol, pos) in &self.positions {
if pos.quantity == Decimal::ZERO {
continue;
}
let price = prices
.get(symbol.as_str())
.ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
total += pos.quantity * price.value();
}
Ok(total)
}
pub fn pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> Result<HashMap<Symbol, Decimal>, FinError> {
let mut map = HashMap::new();
for (symbol, pos) in &self.positions {
if pos.quantity == Decimal::ZERO {
continue;
}
let price = prices
.get(symbol.as_str())
.ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
map.insert(symbol.clone(), pos.unrealized_pnl(*price));
}
Ok(map)
}
pub fn delta_neutral_check(&self, prices: &HashMap<String, Price>) -> Result<bool, FinError> {
let mut net = Decimal::ZERO;
let mut gross = Decimal::ZERO;
for (symbol, pos) in &self.positions {
if pos.quantity == Decimal::ZERO {
continue;
}
let price = prices
.get(symbol.as_str())
.ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
let exposure = pos.quantity * price.value();
net += exposure;
gross += exposure.abs();
}
if gross == Decimal::ZERO {
return Ok(true);
}
Ok((net / gross).abs() < Decimal::new(1, 2)) }
pub fn allocation_pct(
&self,
symbol: &Symbol,
prices: &HashMap<String, Price>,
) -> Result<Option<Decimal>, crate::error::FinError> {
let pos = self
.positions
.get(symbol)
.ok_or_else(|| crate::error::FinError::PositionNotFound(symbol.to_string()))?;
if pos.quantity == Decimal::ZERO {
return Ok(None);
}
let price = match prices.get(symbol.as_str()) {
Some(p) => *p,
None => return Ok(None),
};
let notional = (pos.quantity * price.value()).abs();
let total = self.total_market_value(prices)?;
if total.is_zero() {
return Ok(None);
}
Ok(Some(notional / total * Decimal::ONE_HUNDRED))
}
pub fn positions_sorted_by_pnl(&self, prices: &HashMap<String, Price>) -> Vec<&Position> {
let mut open: Vec<&Position> = self
.positions
.values()
.filter(|p| p.quantity != Decimal::ZERO)
.collect();
open.sort_by(|a, b| {
let pnl_a = prices
.get(a.symbol.as_str())
.map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
let pnl_b = prices
.get(b.symbol.as_str())
.map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
pnl_b.cmp(&pnl_a)
});
open
}
pub fn top_n_positions<'a>(&'a self, n: usize, prices: &HashMap<String, Price>) -> Vec<&'a Position> {
let mut open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
open.sort_by(|a, b| {
let mv_a = prices.get(a.symbol.as_str())
.map_or(Decimal::ZERO, |p| (a.quantity * p.value()).abs());
let mv_b = prices.get(b.symbol.as_str())
.map_or(Decimal::ZERO, |p| (b.quantity * p.value()).abs());
mv_b.cmp(&mv_a)
});
open.into_iter().take(n).collect()
}
pub fn concentration(&self, prices: &HashMap<String, Price>) -> Result<Option<Decimal>, FinError> {
let gross = self.gross_exposure();
if gross == Decimal::ZERO {
return Ok(None);
}
let mut hhi = Decimal::ZERO;
for (symbol, pos) in &self.positions {
if pos.quantity == Decimal::ZERO {
continue;
}
let price = prices
.get(symbol.as_str())
.ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
let mv = (pos.quantity * price.value()).abs();
let w = mv / gross;
hhi += w * w;
}
Ok(Some(hhi))
}
pub fn margin_used(&self, prices: &HashMap<String, Price>, margin_rate: Decimal) -> Result<Decimal, FinError> {
let mut gross = Decimal::ZERO;
for (symbol, pos) in &self.positions {
if pos.quantity == Decimal::ZERO {
continue;
}
let price = prices
.get(symbol.as_str())
.ok_or_else(|| FinError::PositionNotFound(symbol.to_string()))?;
gross += (pos.quantity * price.value()).abs();
}
Ok(gross * margin_rate)
}
pub fn flat_count(&self) -> usize {
self.positions.values().filter(|p| p.is_flat()).count()
}
pub fn smallest_position(&self) -> Option<&Position> {
self.positions
.values()
.filter(|p| !p.is_flat())
.min_by(|a, b| a.quantity.abs().partial_cmp(&b.quantity.abs()).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn most_profitable_symbol(
&self,
prices: &HashMap<String, Price>,
) -> Option<&Symbol> {
self.positions
.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, p)| {
let price = prices.get(sym.as_str())?;
let pnl = p.unrealized_pnl(*price);
Some((sym, pnl))
})
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(sym, _)| sym)
}
pub fn least_profitable_symbol(
&self,
prices: &HashMap<String, Price>,
) -> Option<&Symbol> {
self.positions
.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, p)| {
let price = prices.get(sym.as_str())?;
let pnl = p.unrealized_pnl(*price);
Some((sym, pnl))
})
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(sym, _)| sym)
}
pub fn total_commission_paid(&self) -> Decimal {
self.total_commission_paid
}
pub fn symbols_with_pnl(
&self,
prices: &HashMap<String, Price>,
) -> Vec<(&Symbol, Decimal)> {
let mut result: Vec<(&Symbol, Decimal)> = self
.positions
.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, p)| {
let price = prices.get(sym.as_str())?;
Some((sym, p.unrealized_pnl(*price)))
})
.collect();
result.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
result
}
pub fn concentration_pct(
&self,
symbol: &Symbol,
prices: &HashMap<String, Price>,
) -> Option<Decimal> {
let pos = self.positions.get(symbol)?;
let price = prices.get(symbol.as_str())?;
let mv = pos.quantity.abs() * price.value();
let total = self
.positions
.values()
.filter_map(|p| {
let pr = prices.get(p.symbol.as_str())?;
Some(p.quantity.abs() * pr.value())
})
.sum::<Decimal>();
if total.is_zero() {
return None;
}
Some(mv / total * Decimal::ONE_HUNDRED)
}
pub fn all_flat(&self) -> bool {
self.positions.values().all(|p| p.is_flat())
}
pub fn long_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
self.positions
.iter()
.filter(|(_, p)| p.is_long())
.filter_map(|(sym, p)| {
let price = prices.get(sym.as_str())?;
Some(p.quantity.abs() * price.value())
})
.sum()
}
pub fn short_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
self.positions
.iter()
.filter(|(_, p)| p.is_short())
.filter_map(|(sym, p)| {
let price = prices.get(sym.as_str())?;
Some(p.quantity.abs() * price.value())
})
.sum()
}
pub fn net_delta(&self, prices: &HashMap<String, Price>) -> Decimal {
self.long_exposure(prices) - self.short_exposure(prices)
}
pub fn avg_cost_basis(&self, symbol: &Symbol) -> Option<Decimal> {
let pos = self.positions.get(symbol)?;
if pos.is_flat() { return None; }
Some(pos.avg_cost)
}
pub fn active_symbols(&self) -> Vec<&Symbol> {
self.positions
.iter()
.filter(|(_, pos)| !pos.is_flat())
.map(|(sym, _)| sym)
.collect()
}
pub fn symbol_count(&self) -> usize {
self.positions.len()
}
pub fn realized_pnl_by_symbol(&self) -> Vec<(Symbol, Decimal)> {
let mut pairs: Vec<(Symbol, Decimal)> = self
.positions
.iter()
.filter_map(|(sym, pos)| {
let r = pos.realized_pnl;
if r != Decimal::ZERO { Some((sym.clone(), r)) } else { None }
})
.collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1));
pairs
}
pub fn top_losers<'a>(
&'a self,
n: usize,
prices: &HashMap<String, Price>,
) -> Vec<&'a Position> {
if n == 0 {
return vec![];
}
let mut open: Vec<&Position> =
self.positions.values().filter(|p| !p.is_flat()).collect();
open.sort_by(|a, b| {
let pnl_a = prices
.get(a.symbol.as_str())
.map_or(Decimal::ZERO, |&p| a.unrealized_pnl(p));
let pnl_b = prices
.get(b.symbol.as_str())
.map_or(Decimal::ZERO, |&p| b.unrealized_pnl(p));
pnl_a.cmp(&pnl_b) });
open.into_iter().take(n).collect()
}
pub fn flat_symbols(&self) -> Vec<&Symbol> {
let mut syms: Vec<&Symbol> = self.positions
.iter()
.filter_map(|(sym, pos)| if pos.is_flat() { Some(sym) } else { None })
.collect();
syms.sort();
syms
}
pub fn max_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
self.positions
.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
let price = prices.get(p.symbol.as_str()).copied()?;
let upnl = p.unrealized_pnl(price);
if upnl < Decimal::ZERO { Some(upnl) } else { None }
})
.min_by(|a, b| a.cmp(b))
}
pub fn largest_winner<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
self.positions
.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
let price = prices.get(p.symbol.as_str()).copied()?;
let upnl = p.unrealized_pnl(price);
if upnl > Decimal::ZERO { Some((p, upnl)) } else { None }
})
.max_by(|a, b| a.1.cmp(&b.1))
.map(|(p, _)| p)
}
pub fn largest_loser<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
self.positions
.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
let price = prices.get(p.symbol.as_str()).copied()?;
let upnl = p.unrealized_pnl(price);
if upnl < Decimal::ZERO { Some((p, upnl)) } else { None }
})
.min_by(|a, b| a.1.cmp(&b.1))
.map(|(p, _)| p)
}
pub fn gross_market_exposure(&self, prices: &HashMap<String, Price>) -> Decimal {
self.positions
.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
let price = prices.get(p.symbol.as_str()).copied()?;
Some(p.market_value(price).abs())
})
.sum()
}
pub fn largest_position_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let total = self.gross_market_exposure(prices);
if total.is_zero() { return None; }
let max_mv = self.positions
.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
let price = prices.get(p.symbol.as_str()).copied()?;
Some(p.market_value(price).abs())
})
.max_by(|a, b| a.cmp(b))?;
Some(max_mv / total * Decimal::from(100u32))
}
pub fn unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let total_upnl = self.unrealized_pnl_total(prices).ok()?;
let total_cost: Decimal = self.positions
.values()
.filter(|p| !p.is_flat())
.map(|p| p.cost_basis().abs())
.sum();
if total_cost.is_zero() { return None; }
Some(total_upnl / total_cost * Decimal::from(100u32))
}
pub fn symbols_up<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
self.positions
.values()
.filter(|p| !p.is_flat())
.filter(|p| {
prices.get(p.symbol.as_str())
.map_or(false, |&price| p.unrealized_pnl(price) > Decimal::ZERO)
})
.map(|p| &p.symbol)
.collect()
}
pub fn symbols_down<'a>(&'a self, prices: &HashMap<String, Price>) -> Vec<&'a Symbol> {
self.positions
.values()
.filter(|p| !p.is_flat())
.filter(|p| {
prices.get(p.symbol.as_str())
.map_or(false, |&price| p.unrealized_pnl(price) < Decimal::ZERO)
})
.map(|p| &p.symbol)
.collect()
}
pub fn largest_unrealized_gain<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Position> {
self.largest_winner(prices)
}
pub fn avg_realized_pnl_per_symbol(&self) -> Option<Decimal> {
if self.positions.is_empty() { return None; }
let total: Decimal = self.positions.values().map(|p| p.realized_pnl).sum();
#[allow(clippy::cast_possible_truncation)]
Some(total / Decimal::from(self.positions.len() as u32))
}
pub fn win_rate(&self) -> Option<Decimal> {
if self.positions.is_empty() { return None; }
let total = self.positions.len();
let winners = self.positions.values()
.filter(|p| p.realized_pnl > Decimal::ZERO)
.count();
#[allow(clippy::cast_possible_truncation)]
Some(Decimal::from(winners as u32) / Decimal::from(total as u32) * Decimal::from(100u32))
}
pub fn net_pnl_excluding(
&self,
exclude: &Symbol,
prices: &HashMap<String, Price>,
) -> Result<Decimal, FinError> {
let total = self.net_pnl(prices)?;
let excluded_rpnl = self.realized_pnl(exclude).unwrap_or(Decimal::ZERO);
let excluded_upnl = if let Some(pos) = self.positions.get(exclude) {
if !pos.is_flat() {
let price = prices.get(exclude.as_str())
.copied()
.ok_or_else(|| FinError::InvalidSymbol(exclude.as_str().to_string()))?;
pos.unrealized_pnl(price)
} else {
Decimal::ZERO
}
} else {
Decimal::ZERO
};
Ok(total - excluded_rpnl - excluded_upnl)
}
pub fn long_short_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let long_exp = self.long_exposure(prices);
let short_exp = self.short_exposure(prices).abs();
if short_exp.is_zero() { return None; }
long_exp.checked_div(short_exp)
}
pub fn position_count_by_direction(&self) -> (usize, usize) {
let longs = self.positions.values()
.filter(|p| !p.is_flat() && p.quantity > Decimal::ZERO)
.count();
let shorts = self.positions.values()
.filter(|p| !p.is_flat() && p.quantity < Decimal::ZERO)
.count();
(longs, shorts)
}
pub fn max_position_age_bars(&self, current_bar: usize) -> Option<usize> {
self.positions.values()
.filter(|p| !p.is_flat())
.map(|p| p.position_age_bars(current_bar))
.max()
}
pub fn avg_position_age_bars(&self, current_bar: usize) -> Option<Decimal> {
let ages: Vec<usize> = self.positions.values()
.filter(|p| !p.is_flat())
.map(|p| p.position_age_bars(current_bar))
.collect();
if ages.is_empty() { return None; }
let sum: usize = ages.iter().sum();
Some(Decimal::from(sum as u64) / Decimal::from(ages.len() as u64))
}
pub fn hhi_concentration(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let open_positions: Vec<_> = self.positions.values()
.filter(|p| !p.is_flat())
.collect();
if open_positions.is_empty() { return None; }
let mvs: Vec<Decimal> = open_positions.iter()
.filter_map(|p| {
prices.get(p.symbol.as_str())
.map(|&price| p.market_value(price).abs())
})
.collect();
let total: Decimal = mvs.iter().sum();
if total.is_zero() { return None; }
Some(mvs.iter().map(|mv| {
let w = mv / total;
w * w
}).sum())
}
pub fn long_short_pnl_ratio(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let long_pnl: Decimal = self.positions.values()
.filter(|p| p.is_long())
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
.sum();
let short_pnl: Decimal = self.positions.values()
.filter(|p| p.is_short())
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
.sum();
let short_abs = short_pnl.abs();
if short_abs.is_zero() { return None; }
Some(long_pnl / short_abs)
}
pub fn unrealized_pnl_by_symbol(&self, prices: &HashMap<String, Price>) -> HashMap<String, Decimal> {
self.positions
.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, p)| {
prices.get(sym.as_str())
.map(|&price| (sym.as_str().to_owned(), p.unrealized_pnl(price)))
})
.collect()
}
pub fn portfolio_beta(
&self,
prices: &HashMap<String, Price>,
betas: &HashMap<String, f64>,
) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
if open.is_empty() { return None; }
let total_mv: Decimal = open.iter()
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
.sum();
if total_mv.is_zero() { return None; }
let total_mv_f64 = total_mv.to_f64()?;
let beta_sum: f64 = open.iter().filter_map(|p| {
let mv = prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs())?;
let b = betas.get(p.symbol.as_str())?;
let w = mv.to_f64()? / total_mv_f64;
Some(w * b)
}).sum();
Some(beta_sum)
}
pub fn total_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let total: Decimal = self.positions.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
prices.get(p.symbol.as_str())
.map(|&price| p.quantity_abs() * price.value())
})
.sum();
if total.is_zero() { None } else { Some(total) }
}
pub fn max_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
self.positions.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
prices.get(p.symbol.as_str())
.map(|&price| p.unrealized_pnl(price))
})
.filter(|&pnl| pnl > Decimal::ZERO)
.max()
}
pub fn realized_pnl_rank(&self, symbol: &Symbol) -> Option<usize> {
let target = self.positions.get(symbol).map(|p| p.realized_pnl)?;
if target == Decimal::ZERO { return None; }
let mut sorted: Vec<Decimal> = self.positions.values()
.map(|p| p.realized_pnl)
.filter(|&r| r != Decimal::ZERO)
.collect();
sorted.sort_by(|a, b| b.cmp(a));
sorted.iter().position(|&r| r == target).map(|i| i + 1)
}
pub fn open_positions_vec(&self) -> Vec<&Position> {
let mut open: Vec<&Position> = self.positions.values()
.filter(|p| !p.is_flat())
.collect();
open.sort_by(|a, b| a.symbol.as_str().cmp(b.symbol.as_str()));
open
}
pub fn symbols_with_pnl_above(&self, threshold: Decimal) -> Vec<Symbol> {
let mut pairs: Vec<(Symbol, Decimal)> = self.positions.iter()
.filter_map(|(sym, pos)| {
if pos.realized_pnl > threshold { Some((sym.clone(), pos.realized_pnl)) } else { None }
})
.collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1));
pairs.into_iter().map(|(s, _)| s).collect()
}
pub fn net_long_short_count(&self) -> (usize, usize) {
let long = self.positions.values().filter(|p| p.is_long()).count();
let short = self.positions.values().filter(|p| p.is_short()).count();
(long, short)
}
pub fn largest_open_position(&self) -> Option<&Symbol> {
self.positions.iter()
.filter(|(_, p)| !p.is_flat())
.max_by(|(_, a), (_, b)| a.quantity.abs().cmp(&b.quantity.abs()))
.map(|(sym, _)| sym)
}
pub fn exposure_by_direction(&self, prices: &HashMap<String, Price>) -> (Decimal, Decimal) {
let long: Decimal = self.positions.values()
.filter(|p| p.is_long())
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr)))
.sum();
let short: Decimal = self.positions.values()
.filter(|p| p.is_short())
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.market_value(pr).abs()))
.sum();
(long, short)
}
pub fn total_realized_pnl(&self) -> Decimal {
self.positions.values().map(|p| p.realized_pnl).sum()
}
pub fn count_with_pnl_below(&self, threshold: Decimal) -> usize {
self.positions.values().filter(|p| p.realized_pnl < threshold).count()
}
pub fn is_net_long(&self) -> bool {
let net: Decimal = self.positions.values().map(|p| p.quantity).sum();
net > Decimal::ZERO
}
pub fn total_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Decimal {
self.positions.values()
.filter(|p| !p.is_flat())
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
.sum()
}
pub fn symbols_flat(&self) -> Vec<&Symbol> {
let mut flat: Vec<&Symbol> = self.positions.iter()
.filter(|(_, p)| p.is_flat())
.map(|(sym, _)| sym)
.collect();
flat.sort_by(|a, b| a.as_str().cmp(b.as_str()));
flat
}
pub fn avg_unrealized_pnl_pct(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let pcts: Vec<Decimal> = self.positions.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
prices.get(p.symbol.as_str()).and_then(|&pr| {
let cost_basis = (p.avg_cost * p.quantity).abs();
if cost_basis.is_zero() { return None; }
Some(p.unrealized_pnl(pr) / cost_basis * Decimal::ONE_HUNDRED)
})
})
.collect();
if pcts.is_empty() { return None; }
Some(pcts.iter().sum::<Decimal>() / Decimal::from(pcts.len()))
}
pub fn max_drawdown_symbol<'a>(&'a self, prices: &HashMap<String, Price>) -> Option<&'a Symbol> {
self.positions.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, p)| {
prices.get(p.symbol.as_str())
.map(|&price| (sym, p.unrealized_pnl(price)))
})
.min_by(|(_, a), (_, b)| a.cmp(b))
.map(|(sym, _)| sym)
}
pub fn avg_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
let pnls: Vec<Decimal> = self.positions.values()
.filter(|p| !p.is_flat())
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
.collect();
if pnls.is_empty() { return None; }
#[allow(clippy::cast_possible_truncation)]
Some(pnls.iter().sum::<Decimal>() / Decimal::from(pnls.len() as u32))
}
pub fn position_symbols(&self) -> Vec<&Symbol> {
let mut syms: Vec<&Symbol> = self.positions.keys().collect();
syms.sort_by(|a, b| a.as_str().cmp(b.as_str()));
syms
}
pub fn count_profitable(&self) -> usize {
self.positions.values().filter(|p| p.realized_pnl > Decimal::ZERO).count()
}
pub fn count_losing(&self) -> usize {
self.positions.values().filter(|p| p.realized_pnl < Decimal::ZERO).count()
}
pub fn top_n_by_exposure<'a>(
&'a self,
prices: &HashMap<String, Price>,
n: usize,
) -> Vec<(&'a Symbol, Decimal)> {
let mut exposures: Vec<(&Symbol, Decimal)> = self.positions.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, p)| {
prices.get(p.symbol.as_str())
.map(|&pr| (sym, (p.quantity * pr.value()).abs()))
})
.collect();
exposures.sort_by(|a, b| b.1.cmp(&a.1));
exposures.truncate(n);
exposures
}
pub fn has_open_positions(&self) -> bool {
self.positions.values().any(|p| !p.is_flat())
}
pub fn long_symbols(&self) -> Vec<&Symbol> {
self.positions.iter()
.filter(|(_, p)| p.quantity > Decimal::ZERO)
.map(|(sym, _)| sym)
.collect()
}
pub fn short_symbols(&self) -> Vec<&Symbol> {
self.positions.iter()
.filter(|(_, p)| p.quantity < Decimal::ZERO)
.map(|(sym, _)| sym)
.collect()
}
pub fn concentration_ratio(&self, prices: &HashMap<String, Price>) -> Option<f64> {
use rust_decimal::prelude::ToPrimitive;
let notionals: Vec<Decimal> = self.positions.values()
.filter(|p| !p.is_flat())
.filter_map(|p| {
prices.get(p.symbol.as_str())
.map(|&pr| (p.quantity * pr.value()).abs())
})
.collect();
if notionals.is_empty() { return None; }
let total: Decimal = notionals.iter().sum();
if total.is_zero() { return None; }
let hhi: f64 = notionals.iter()
.filter_map(|n| (n / total).to_f64())
.map(|w| w * w)
.sum();
Some(hhi)
}
pub fn min_unrealized_pnl(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
self.positions.values()
.filter(|p| !p.is_flat())
.filter_map(|p| prices.get(p.symbol.as_str()).map(|&pr| p.unrealized_pnl(pr)))
.min_by(|a, b| a.cmp(b))
}
pub fn pct_long(&self) -> Option<Decimal> {
let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
if open.is_empty() { return None; }
let longs = open.iter().filter(|p| p.quantity > Decimal::ZERO).count() as u32;
Some(Decimal::from(longs) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
}
pub fn pct_short(&self) -> Option<Decimal> {
let open: Vec<&Position> = self.positions.values().filter(|p| !p.is_flat()).collect();
if open.is_empty() { return None; }
let shorts = open.iter().filter(|p| p.quantity < Decimal::ZERO).count() as u32;
Some(Decimal::from(shorts) / Decimal::from(open.len() as u32) * Decimal::ONE_HUNDRED)
}
pub fn realized_pnl_total_abs(&self) -> Decimal {
self.positions.values().map(|p| p.realized_pnl.abs()).sum()
}
pub fn average_entry_price(&self, symbol: &Symbol) -> Option<Price> {
self.positions.get(symbol)?.avg_entry_price()
}
pub fn net_quantity(&self) -> Decimal {
self.positions.values().map(|p| p.quantity).sum()
}
pub fn max_long_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
self.positions.values()
.filter(|p| p.quantity > Decimal::ZERO)
.filter_map(|p| {
prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
})
.max_by(|a, b| a.cmp(b))
}
pub fn max_short_notional(&self, prices: &HashMap<String, Price>) -> Option<Decimal> {
self.positions.values()
.filter(|p| p.quantity < Decimal::ZERO)
.filter_map(|p| {
prices.get(p.symbol.as_str()).map(|&pr| (p.quantity * pr.value()).abs())
})
.max_by(|a, b| a.cmp(b))
}
pub fn max_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
self.positions.iter()
.map(|(sym, p)| (sym, p.realized_pnl))
.max_by(|(_, a), (_, b)| a.cmp(b))
}
pub fn min_realized_pnl(&self) -> Option<(&Symbol, Decimal)> {
self.positions.iter()
.map(|(sym, p)| (sym, p.realized_pnl))
.min_by(|(_, a), (_, b)| a.cmp(b))
}
pub fn avg_holding_bars(&self, current_bar: usize) -> Option<f64> {
let open: Vec<usize> = self.positions.values()
.filter(|p| !p.is_flat())
.map(|p| current_bar.saturating_sub(p.open_bar))
.collect();
if open.is_empty() { return None; }
Some(open.iter().sum::<usize>() as f64 / open.len() as f64)
}
pub fn symbols_with_unrealized_loss(&self, prices: &HashMap<String, Price>) -> Vec<&Symbol> {
self.positions.iter()
.filter(|(_, p)| !p.is_flat())
.filter_map(|(sym, p)| {
prices.get(p.symbol.as_str())
.map(|&pr| (sym, p.unrealized_pnl(pr)))
})
.filter(|(_, pnl)| *pnl < Decimal::ZERO)
.map(|(sym, _)| sym)
.collect()
}
pub fn avg_long_entry_price(&self) -> Option<Decimal> {
let longs: Vec<&Position> = self.positions.values()
.filter(|p| p.is_long())
.collect();
if longs.is_empty() { return None; }
let total_qty: Decimal = longs.iter().map(|p| p.quantity.abs()).sum();
if total_qty.is_zero() { return None; }
let weighted: Decimal = longs.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
Some(weighted / total_qty)
}
pub fn avg_short_entry_price(&self) -> Option<Decimal> {
let shorts: Vec<&Position> = self.positions.values()
.filter(|p| p.is_short())
.collect();
if shorts.is_empty() { return None; }
let total_qty: Decimal = shorts.iter().map(|p| p.quantity.abs()).sum();
if total_qty.is_zero() { return None; }
let weighted: Decimal = shorts.iter().map(|p| p.avg_cost * p.quantity.abs()).sum();
Some(weighted / total_qty)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn sym(s: &str) -> Symbol {
Symbol::new(s).unwrap()
}
fn make_fill(symbol: &str, side: Side, qty: &str, p: &str, commission: &str) -> Fill {
Fill {
symbol: sym(symbol),
side,
quantity: Quantity::new(qty.parse().unwrap()).unwrap(),
price: Price::new(p.parse().unwrap()).unwrap(),
timestamp: NanoTimestamp::new(0),
commission: commission.parse().unwrap(),
}
}
#[test]
fn test_position_apply_fill_long() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
assert_eq!(pos.quantity, dec!(10));
assert_eq!(pos.avg_cost, dec!(100));
}
#[test]
fn test_position_apply_fill_reduces_position() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
pos.apply_fill(&make_fill("AAPL", Side::Ask, "5", "110", "0"))
.unwrap();
assert_eq!(pos.quantity, dec!(5));
}
#[test]
fn test_position_realized_pnl_on_close() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let pnl = pos
.apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
.unwrap();
assert_eq!(pnl, dec!(100));
assert!(pos.is_flat());
}
#[test]
fn test_position_commission_reduces_realized_pnl() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let pnl = pos
.apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "5"))
.unwrap();
assert_eq!(pnl, dec!(95));
}
#[test]
fn test_position_unrealized_pnl() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let upnl = pos.unrealized_pnl(Price::new(dec!(115)).unwrap());
assert_eq!(upnl, dec!(150));
}
#[test]
fn test_position_market_value() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
assert_eq!(pos.market_value(Price::new(dec!(120)).unwrap()), dec!(1200));
}
#[test]
fn test_position_is_flat_initially() {
let pos = Position::new(sym("X"));
assert!(pos.is_flat());
}
#[test]
fn test_position_is_flat_after_full_close() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "110", "0"))
.unwrap();
assert!(pos.is_flat());
}
#[test]
fn test_position_avg_cost_weighted_after_two_buys() {
let mut pos = Position::new(sym("X"));
pos.apply_fill(&make_fill("X", Side::Bid, "10", "100", "0"))
.unwrap();
pos.apply_fill(&make_fill("X", Side::Bid, "10", "120", "0"))
.unwrap();
assert_eq!(pos.avg_cost, dec!(110));
}
#[test]
fn test_position_ledger_apply_fill_updates_cash() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "1"))
.unwrap();
assert_eq!(ledger.cash(), dec!(8999));
}
#[test]
fn test_position_ledger_insufficient_funds() {
let mut ledger = PositionLedger::new(dec!(100));
let result = ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"));
assert!(matches!(result, Err(FinError::InsufficientFunds { .. })));
}
#[test]
fn test_position_ledger_equity_calculation() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
let equity = ledger.equity(&prices).unwrap();
assert_eq!(equity, dec!(9100));
}
#[test]
fn test_position_ledger_net_liquidation_value() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
let nlv = ledger.net_liquidation_value(&prices).unwrap();
assert_eq!(nlv, dec!(10100));
}
#[test]
fn test_position_ledger_net_liquidation_missing_price() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let prices: HashMap<String, Price> = HashMap::new();
assert!(ledger.net_liquidation_value(&prices).is_err());
}
#[test]
fn test_position_ledger_pnl_by_symbol() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
prices.insert("GOOG".to_owned(), Price::new(dec!(190)).unwrap());
let pnl = ledger.pnl_by_symbol(&prices).unwrap();
assert_eq!(*pnl.get(&sym("AAPL")).unwrap(), dec!(100)); assert_eq!(*pnl.get(&sym("GOOG")).unwrap(), dec!(-50)); }
#[test]
fn test_position_ledger_pnl_by_symbol_missing_price() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
let prices: HashMap<String, Price> = HashMap::new();
assert!(ledger.pnl_by_symbol(&prices).is_err());
}
#[test]
fn test_position_ledger_delta_neutral_no_positions() {
let ledger = PositionLedger::new(dec!(10000));
let prices: HashMap<String, Price> = HashMap::new();
assert!(ledger.delta_neutral_check(&prices).unwrap());
}
#[test]
fn test_position_ledger_delta_neutral_long_short_balanced() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("GOOG", Side::Ask, "10", "100", "0")).unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
prices.insert("GOOG".to_owned(), Price::new(dec!(100)).unwrap());
assert!(ledger.delta_neutral_check(&prices).unwrap());
}
#[test]
fn test_position_ledger_delta_neutral_one_sided_not_neutral() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_owned(), Price::new(dec!(100)).unwrap());
assert!(!ledger.delta_neutral_check(&prices).unwrap());
}
#[test]
fn test_position_ledger_open_count_zero_when_empty() {
assert_eq!(PositionLedger::new(dec!(10000)).open_count(), 0);
}
#[test]
fn test_position_ledger_open_count_tracks_positions() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
assert_eq!(ledger.open_count(), 1);
ledger.apply_fill(make_fill("GOOG", Side::Bid, "5", "200", "0")).unwrap();
assert_eq!(ledger.open_count(), 2);
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "105", "0")).unwrap();
assert_eq!(ledger.open_count(), 1);
}
#[test]
fn test_position_ledger_sell_increases_cash() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0"))
.unwrap();
assert_eq!(ledger.cash(), dec!(10100));
}
#[test]
fn test_position_checked_unrealized_pnl_matches() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let price = Price::new(dec!(115)).unwrap();
let checked = pos.checked_unrealized_pnl(price).unwrap();
let unchecked = pos.unrealized_pnl(price);
assert_eq!(checked, unchecked);
assert_eq!(checked, dec!(150));
}
#[test]
fn test_position_checked_unrealized_pnl_flat_position() {
let pos = Position::new(sym("X"));
let price = Price::new(dec!(100)).unwrap();
assert_eq!(pos.checked_unrealized_pnl(price).unwrap(), dec!(0));
}
#[test]
fn test_position_direction_flat() {
let pos = Position::new(sym("X"));
assert_eq!(pos.direction(), PositionDirection::Flat);
}
#[test]
fn test_position_direction_long() {
let mut pos = Position::new(sym("X"));
pos.apply_fill(&make_fill("X", Side::Bid, "5", "100", "0"))
.unwrap();
assert_eq!(pos.direction(), PositionDirection::Long);
}
#[test]
fn test_position_direction_short() {
let mut pos = Position::new(sym("X"));
pos.apply_fill(&make_fill("X", Side::Ask, "5", "100", "0"))
.unwrap();
assert_eq!(pos.direction(), PositionDirection::Short);
}
#[test]
fn test_position_ledger_positions_iterator() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("MSFT", Side::Bid, "1", "200", "0"))
.unwrap();
let count = ledger.positions().count();
assert_eq!(count, 2);
}
#[test]
fn test_position_ledger_total_market_value() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
.unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_owned(), Price::new(dec!(110)).unwrap());
prices.insert("MSFT".to_owned(), Price::new(dec!(210)).unwrap());
let mv = ledger.total_market_value(&prices).unwrap();
assert_eq!(mv, dec!(2150));
}
#[test]
fn test_position_ledger_total_market_value_missing_price() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let prices: HashMap<String, Price> = HashMap::new();
assert!(matches!(
ledger.total_market_value(&prices),
Err(FinError::PositionNotFound(_))
));
}
#[test]
fn test_position_ledger_unrealized_pnl_total() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_owned(), Price::new(dec!(105)).unwrap());
let upnl = ledger.unrealized_pnl_total(&prices).unwrap();
assert_eq!(upnl, dec!(50));
}
#[test]
fn test_position_ledger_position_count_includes_flat() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
.unwrap();
assert_eq!(ledger.position_count(), 2, "both symbols tracked");
assert_eq!(ledger.open_position_count(), 1, "only MSFT open");
}
#[test]
fn test_position_ledger_position_count_zero_on_empty() {
let ledger = PositionLedger::new(dec!(10000));
assert_eq!(ledger.position_count(), 0);
}
#[test]
fn test_position_unrealized_pnl_pct_long_gain() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let current = Price::new(dec!(110)).unwrap();
let pct = pos.unrealized_pnl_pct(current).unwrap();
assert_eq!(pct, dec!(10));
}
#[test]
fn test_position_unrealized_pnl_pct_flat_returns_none() {
let pos = Position::new(sym("AAPL"));
let current = Price::new(dec!(110)).unwrap();
assert!(pos.unrealized_pnl_pct(current).is_none());
}
#[test]
fn test_position_unrealized_pnl_pct_loss() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let current = Price::new(dec!(90)).unwrap();
let pct = pos.unrealized_pnl_pct(current).unwrap();
assert_eq!(pct, dec!(-10));
}
#[test]
fn test_position_ledger_open_positions_excludes_flat() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0"))
.unwrap();
let open: Vec<_> = ledger.open_positions().collect();
assert_eq!(open.len(), 1);
assert_eq!(open[0].symbol.as_str(), "MSFT");
}
#[test]
fn test_position_ledger_open_positions_empty_when_all_flat() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
ledger
.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0"))
.unwrap();
let open: Vec<_> = ledger.open_positions().collect();
assert!(open.is_empty());
}
#[test]
fn test_position_is_long() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
assert!(pos.is_long());
assert!(!pos.is_short());
assert!(!pos.is_flat());
}
#[test]
fn test_position_is_short() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
.unwrap();
assert!(pos.is_short());
assert!(!pos.is_long());
assert!(!pos.is_flat());
}
#[test]
fn test_position_is_flat_after_close() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
pos.apply_fill(&make_fill("AAPL", Side::Ask, "10", "100", "0"))
.unwrap();
assert!(pos.is_flat());
assert!(!pos.is_long());
assert!(!pos.is_short());
}
#[test]
fn test_position_ledger_flat_positions() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "0")).unwrap();
let flat: Vec<_> = ledger.flat_positions().collect();
assert_eq!(flat.len(), 1);
assert_eq!(flat[0].symbol, sym("AAPL"));
}
#[test]
fn test_position_ledger_flat_positions_empty_when_all_open() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
assert_eq!(ledger.flat_positions().count(), 0);
}
#[test]
fn test_position_ledger_deposit_increases_cash() {
let mut ledger = PositionLedger::new(dec!(1000));
ledger.deposit(dec!(500));
assert_eq!(ledger.cash(), dec!(1500));
}
#[test]
fn test_position_ledger_withdraw_decreases_cash() {
let mut ledger = PositionLedger::new(dec!(1000));
ledger.withdraw(dec!(300)).unwrap();
assert_eq!(ledger.cash(), dec!(700));
}
#[test]
fn test_position_ledger_withdraw_insufficient_fails() {
let mut ledger = PositionLedger::new(dec!(100));
assert!(matches!(
ledger.withdraw(dec!(200)),
Err(FinError::InsufficientFunds { .. })
));
assert_eq!(ledger.cash(), dec!(100), "cash unchanged on failure");
}
#[test]
fn test_position_is_profitable_true() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let current = Price::new(dec!(110)).unwrap();
assert!(pos.is_profitable(current));
}
#[test]
fn test_position_is_profitable_false_when_at_loss() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let current = Price::new(dec!(90)).unwrap();
assert!(!pos.is_profitable(current));
}
#[test]
fn test_position_ledger_long_positions() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let longs: Vec<_> = ledger.long_positions().collect();
assert_eq!(longs.len(), 1);
assert_eq!(longs[0].symbol.as_str(), "AAPL");
}
#[test]
fn test_position_ledger_short_positions_empty_for_long_only() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger
.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0"))
.unwrap();
let shorts: Vec<_> = ledger.short_positions().collect();
assert!(shorts.is_empty());
}
#[test]
fn test_position_ledger_realized_pnl_after_close() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(100)));
}
#[test]
fn test_position_ledger_realized_pnl_unknown_symbol_returns_none() {
let ledger = PositionLedger::new(dec!(10000));
assert!(ledger.realized_pnl(&sym("AAPL")).is_none());
}
#[test]
fn test_position_ledger_realized_pnl_zero_before_close() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
assert_eq!(ledger.realized_pnl(&sym("AAPL")), Some(dec!(0)));
}
#[test]
fn test_position_ledger_symbols_sorted_order() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("MSFT", Side::Bid, "1", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "100", "0")).unwrap();
let sorted = ledger.symbols_sorted();
let names: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
assert_eq!(names, vec!["AAPL", "GOOG", "MSFT"]);
}
#[test]
fn test_position_ledger_symbols_sorted_empty() {
let ledger = PositionLedger::new(dec!(10000));
assert!(ledger.symbols_sorted().is_empty());
}
#[test]
fn test_position_avg_entry_price_long() {
let sym = Symbol::new("AAPL").unwrap();
let mut pos = Position::new(sym.clone());
let fill = Fill::new(
sym,
Side::Bid,
Quantity::new(dec!(10)).unwrap(),
Price::new(dec!(150)).unwrap(),
NanoTimestamp::new(0),
);
pos.apply_fill(&fill).unwrap();
assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(150));
}
#[test]
fn test_position_avg_entry_price_flat_returns_none() {
let sym = Symbol::new("AAPL").unwrap();
let pos = Position::new(sym);
assert!(pos.avg_entry_price().is_none());
}
#[test]
fn test_position_avg_entry_price_after_partial_close() {
let sym = Symbol::new("X").unwrap();
let mut pos = Position::new(sym.clone());
pos.apply_fill(&Fill::new(sym.clone(), Side::Bid,
Quantity::new(dec!(10)).unwrap(), Price::new(dec!(100)).unwrap(),
NanoTimestamp::new(0))).unwrap();
pos.apply_fill(&Fill::new(sym.clone(), Side::Ask,
Quantity::new(dec!(5)).unwrap(), Price::new(dec!(100)).unwrap(),
NanoTimestamp::new(1))).unwrap();
assert_eq!(pos.avg_entry_price().unwrap().value(), dec!(100));
}
#[test]
fn test_position_ledger_has_position_true_after_fill() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
assert!(ledger.has_position(&sym("AAPL")));
}
#[test]
fn test_position_ledger_has_position_false_for_unknown() {
let ledger = PositionLedger::new(dec!(10000));
assert!(!ledger.has_position(&sym("AAPL")));
}
#[test]
fn test_position_ledger_has_position_true_even_when_flat() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "0")).unwrap();
assert!(ledger.has_position(&sym("AAPL")));
}
#[test]
fn test_position_ledger_open_symbols_returns_non_flat() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "1")).unwrap();
let symbols: Vec<_> = ledger.open_symbols().collect();
assert_eq!(symbols.len(), 2);
}
#[test]
fn test_position_ledger_open_symbols_excludes_flat() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap(); ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "200", "2")).unwrap();
let symbols: Vec<_> = ledger.open_symbols().collect();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].as_str(), "MSFT");
}
#[test]
fn test_position_ledger_open_symbols_empty_when_all_flat() {
let mut ledger = PositionLedger::new(dec!(10000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "100", "1")).unwrap();
let symbols: Vec<_> = ledger.open_symbols().collect();
assert!(symbols.is_empty());
}
#[test]
fn test_position_ledger_total_long_exposure() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
assert_eq!(ledger.total_long_exposure(), dec!(1000));
}
#[test]
fn test_position_ledger_total_long_exposure_zero_when_flat() {
let ledger = PositionLedger::new(dec!(10000));
assert_eq!(ledger.total_long_exposure(), dec!(0));
}
#[test]
fn test_position_ledger_total_short_exposure_zero_when_no_shorts() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
assert_eq!(ledger.total_short_exposure(), dec!(0));
}
#[test]
fn test_allocation_pct_single_position() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
let mut prices = HashMap::new();
let sym = Symbol::new("AAPL").unwrap();
prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
let pct = ledger.allocation_pct(&sym, &prices).unwrap();
assert_eq!(pct, Some(dec!(100)));
}
#[test]
fn test_allocation_pct_flat_position_returns_none() {
let ledger = PositionLedger::new(dec!(100000));
let mut prices = HashMap::new();
let sym = Symbol::new("AAPL").unwrap();
prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
assert!(ledger.allocation_pct(&sym, &prices).is_err());
}
#[test]
fn test_positions_sorted_by_pnl_descending() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "1", "100", "0")).unwrap();
ledger.apply_fill(make_fill("GOOG", Side::Bid, "1", "200", "0")).unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
prices.insert("GOOG".to_string(), Price::new(dec!(250)).unwrap());
let sorted = ledger.positions_sorted_by_pnl(&prices);
assert_eq!(sorted[0].symbol.as_str(), "GOOG");
assert_eq!(sorted[1].symbol.as_str(), "AAPL");
}
#[test]
fn test_positions_sorted_by_pnl_empty_when_all_flat() {
let ledger = PositionLedger::new(dec!(100000));
let prices = HashMap::new();
assert!(ledger.positions_sorted_by_pnl(&prices).is_empty());
}
#[test]
fn test_all_flat_initially() {
let ledger = PositionLedger::new(dec!(100000));
assert!(ledger.all_flat());
}
#[test]
fn test_all_flat_false_after_open_position() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
assert!(!ledger.all_flat());
}
#[test]
fn test_all_flat_true_after_close_position() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "155", "0")).unwrap();
assert!(ledger.all_flat());
}
#[test]
fn test_concentration_pct_single_position() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150", "0")).unwrap();
let sym = Symbol::new("AAPL").unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_string(), Price::new(dec!(150)).unwrap());
let pct = ledger.concentration_pct(&sym, &prices).unwrap();
assert_eq!(pct, dec!(100));
}
#[test]
fn test_concentration_pct_two_equal_positions() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0")).unwrap();
let sym = Symbol::new("AAPL").unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_string(), Price::new(dec!(100)).unwrap());
prices.insert("GOOG".to_string(), Price::new(dec!(100)).unwrap());
let pct = ledger.concentration_pct(&sym, &prices).unwrap();
assert_eq!(pct, dec!(50));
}
#[test]
fn test_concentration_pct_missing_price_returns_none() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
let sym = Symbol::new("AAPL").unwrap();
let prices = HashMap::new(); assert!(ledger.concentration_pct(&sym, &prices).is_none());
}
#[test]
fn test_avg_realized_pnl_per_symbol_none_when_empty() {
let ledger = PositionLedger::new(dec!(100000));
assert!(ledger.avg_realized_pnl_per_symbol().is_none());
}
#[test]
fn test_avg_realized_pnl_per_symbol_with_closed_trade() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
let avg = ledger.avg_realized_pnl_per_symbol().unwrap();
assert_eq!(avg, dec!(100));
}
#[test]
fn test_net_exposure_no_prices_returns_none() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
let prices = HashMap::new();
assert!(ledger.net_market_exposure(&prices).is_none());
}
#[test]
fn test_net_exposure_long_only() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
let mut prices = HashMap::new();
prices.insert("AAPL".to_string(), Price::new(dec!(110)).unwrap());
assert_eq!(ledger.net_market_exposure(&prices).unwrap(), dec!(1100));
}
#[test]
fn test_win_rate_none_when_empty() {
let ledger = PositionLedger::new(dec!(100000));
assert!(ledger.win_rate().is_none());
}
#[test]
fn test_win_rate_one_winner() {
let mut ledger = PositionLedger::new(dec!(100000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100", "0")).unwrap();
ledger.apply_fill(make_fill("AAPL", Side::Ask, "10", "110", "0")).unwrap();
ledger.apply_fill(make_fill("GOOG", Side::Bid, "10", "100", "0")).unwrap();
let rate = ledger.win_rate().unwrap();
assert_eq!(rate, dec!(50));
}
}