use rust_decimal::Decimal;
use crate::core::{PnlHaltReason, PnlOutcome as PnlOperationOutcome, PnlOutcomeAmount};
use crate::param::{AdjustmentAmount, Pnl, PositionSize, Price};
use super::error::{AdjustmentOverflowError, HoldError};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Holdings {
available: PositionSize,
held: PositionSize,
incoming: PositionSize,
avg_entry_price: Option<Price>,
realized_pnl: Option<PositionPnlState>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PositionPnlState {
Pnl(Pnl),
Halted(PnlHaltReason),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PositionPnlOperation {
holdings: Holdings,
outcome: Option<PnlOperationOutcome>,
realized_delta: Option<Pnl>,
average_entry_price: Option<Price>,
}
impl PositionPnlOperation {
fn unchanged(holdings: Holdings) -> Self {
Self {
holdings,
outcome: None,
realized_delta: None,
average_entry_price: None,
}
}
fn updated(
holdings: Holdings,
outcome: Option<PnlOperationOutcome>,
realized_delta: Option<Pnl>,
average_entry_price: Option<Price>,
) -> Self {
Self {
holdings,
outcome,
realized_delta,
average_entry_price,
}
}
pub(crate) fn holdings(&self) -> Holdings {
self.holdings
}
pub(crate) fn outcome(&self) -> Option<PnlOperationOutcome> {
self.outcome
}
pub(crate) fn realized_delta(&self) -> Option<Pnl> {
self.realized_delta
}
pub(crate) fn average_entry_price(&self) -> Option<Price> {
self.average_entry_price
}
}
impl Default for Holdings {
fn default() -> Self {
Self::zero()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AdjustmentTarget {
Available,
Held,
Incoming,
}
impl Holdings {
pub fn zero() -> Self {
Self {
avg_entry_price: None,
available: PositionSize::ZERO,
held: PositionSize::ZERO,
incoming: PositionSize::ZERO,
realized_pnl: None,
}
}
pub fn new(available: PositionSize, held: PositionSize) -> Self {
Self {
avg_entry_price: None,
available,
held,
incoming: PositionSize::ZERO,
realized_pnl: None,
}
}
pub fn available(&self) -> PositionSize {
self.available
}
pub fn held(&self) -> PositionSize {
self.held
}
pub fn incoming(&self) -> PositionSize {
self.incoming
}
pub fn avg_entry_price(&self) -> Option<Price> {
self.avg_entry_price
}
pub fn realized_pnl(&self) -> Option<Pnl> {
self.realized_pnl.and_then(|outcome| match outcome {
PositionPnlState::Pnl(value) => Some(value),
PositionPnlState::Halted(_) => None,
})
}
pub(crate) fn realized_pnl_halt_reason(&self) -> Option<PnlHaltReason> {
match self.realized_pnl {
Some(PositionPnlState::Halted(reason)) => Some(reason),
None | Some(PositionPnlState::Pnl(_)) => None,
}
}
pub(crate) fn realized_pnl_outcome(&self) -> Option<PositionPnlState> {
self.realized_pnl
}
pub(crate) fn realized_pnl_is_halted(&self) -> bool {
self.realized_pnl_halt_reason().is_some()
}
pub fn with_realized_pnl(&self, realized_pnl: Pnl) -> Self {
Self {
realized_pnl: Some(PositionPnlState::Pnl(realized_pnl)),
..*self
}
}
pub fn with_realized_pnl_opt(&self, realized_pnl: Option<Pnl>) -> Self {
Self {
realized_pnl: match (self.realized_pnl, realized_pnl) {
(_, Some(value)) => Some(PositionPnlState::Pnl(value)),
(Some(PositionPnlState::Halted(reason)), None) => {
Some(PositionPnlState::Halted(reason))
}
(None | Some(PositionPnlState::Pnl(_)), None) => None,
},
..*self
}
}
pub(crate) fn with_realized_pnl_state(&self, realized_pnl: Option<PositionPnlState>) -> Self {
Self {
realized_pnl,
..*self
}
}
pub(crate) fn halt_realized_pnl(&self, reason: PnlHaltReason) -> PositionPnlOperation {
self.halt_realized_pnl_with_average(reason, None)
}
pub(crate) fn halt_realized_pnl_preserving_average(
&self,
reason: PnlHaltReason,
) -> PositionPnlOperation {
self.halt_realized_pnl_with_average(reason, self.avg_entry_price)
}
pub(crate) fn halt_realized_pnl_for_unpriced_fill(
&self,
signed_quantity: PositionSize,
reason: PnlHaltReason,
) -> Result<PositionPnlOperation, AdjustmentOverflowError> {
let owned = self
.available
.checked_add(self.held)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
let new_owned = owned
.checked_add(signed_quantity)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
let owned = owned.to_decimal();
let signed_quantity = signed_quantity.to_decimal();
let avg_entry_price = if signed_quantity == Decimal::ZERO {
self.avg_entry_price
} else if owned == Decimal::ZERO
|| (owned > Decimal::ZERO) == (signed_quantity > Decimal::ZERO)
|| signed_quantity.abs() > owned.abs()
|| new_owned.is_zero()
{
None
} else {
self.avg_entry_price
};
let (realized_pnl, outcome) = match self.realized_pnl {
Some(PositionPnlState::Halted(existing)) => (PositionPnlState::Halted(existing), None),
None | Some(PositionPnlState::Pnl(_)) => {
(PositionPnlState::Halted(reason), Some(Err(reason)))
}
};
Ok(PositionPnlOperation::updated(
Self {
avg_entry_price,
realized_pnl: Some(realized_pnl),
..*self
},
outcome,
None,
avg_entry_price,
))
}
fn halt_realized_pnl_with_average(
&self,
reason: PnlHaltReason,
avg_entry_price: Option<Price>,
) -> PositionPnlOperation {
if self.realized_pnl_is_halted() {
return PositionPnlOperation::unchanged(*self);
}
PositionPnlOperation::updated(
Self {
avg_entry_price,
realized_pnl: Some(PositionPnlState::Halted(reason)),
..*self
},
Some(Err(reason)),
None,
None,
)
}
pub fn without_position_tracking(&self) -> Self {
Self {
avg_entry_price: None,
realized_pnl: None,
..*self
}
}
pub fn try_hold(&self, amount: PositionSize) -> Result<Self, HoldError> {
let spendable = if self.held < PositionSize::ZERO {
self.available
.checked_add(self.held)
.map_err(|_| HoldError::ArithmeticOverflow)?
} else {
self.available
};
if amount > spendable {
return Err(HoldError::InsufficientAvailable {
available: spendable,
requested: amount,
});
}
let available = self
.available
.checked_sub(amount)
.map_err(|_| HoldError::ArithmeticOverflow)?;
let held = self
.held
.checked_add(amount)
.map_err(|_| HoldError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available,
held,
incoming: self.incoming,
realized_pnl: self.realized_pnl,
})
}
pub fn hold_allow_negative(&self, amount: PositionSize) -> Result<Self, HoldError> {
let available = self
.available
.checked_sub(amount)
.map_err(|_| HoldError::ArithmeticOverflow)?;
let held = self
.held
.checked_add(amount)
.map_err(|_| HoldError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available,
held,
incoming: self.incoming,
realized_pnl: self.realized_pnl,
})
}
pub fn release(&self, amount: PositionSize) -> Result<Self, AdjustmentOverflowError> {
let available = self
.available
.checked_add(amount)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
let held = self
.held
.checked_sub(amount)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available,
held,
incoming: self.incoming,
realized_pnl: self.realized_pnl,
})
}
pub fn apply_fill_outflow(
&self,
amount: PositionSize,
) -> Result<Self, AdjustmentOverflowError> {
let held = self
.held
.checked_sub(amount)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available: self.available,
held,
incoming: self.incoming,
realized_pnl: self.realized_pnl,
})
}
pub fn apply_fill_inflow(&self, amount: PositionSize) -> Result<Self, AdjustmentOverflowError> {
let available = self
.available
.checked_add(amount)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available,
held: self.held,
incoming: self.incoming,
realized_pnl: self.realized_pnl,
})
}
pub fn reserve_incoming(&self, amount: PositionSize) -> Result<Self, AdjustmentOverflowError> {
let incoming = self
.incoming
.checked_add(amount)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available: self.available,
held: self.held,
incoming,
realized_pnl: self.realized_pnl,
})
}
pub fn consume_incoming(&self, amount: PositionSize) -> Result<Self, AdjustmentOverflowError> {
let incoming = self
.incoming
.checked_sub(amount)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available: self.available,
held: self.held,
incoming,
realized_pnl: self.realized_pnl,
})
}
pub(crate) fn realize_position_fill(
&self,
signed_qty: PositionSize,
price: Price,
) -> Result<PositionPnlOperation, AdjustmentOverflowError> {
let owned = self
.available
.checked_add(self.held)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
let new_owned = owned
.checked_add(signed_qty)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
let owned_dec = owned.to_decimal();
let delta_dec = signed_qty.to_decimal();
let price_dec = price.to_decimal();
let zero = Decimal::ZERO;
let (new_avg, realized_dec) = if owned_dec == zero {
let avg = if delta_dec == zero { None } else { Some(price) };
(avg, zero)
} else if (owned_dec > zero) == (delta_dec > zero) {
match self.avg_entry_price {
Some(avg) => {
let weighted_existing = owned_dec
.checked_mul(avg.to_decimal())
.ok_or(AdjustmentOverflowError::ArithmeticOverflow)?;
let weighted_fill = delta_dec
.checked_mul(price_dec)
.ok_or(AdjustmentOverflowError::ArithmeticOverflow)?;
let numerator = weighted_existing
.checked_add(weighted_fill)
.ok_or(AdjustmentOverflowError::ArithmeticOverflow)?;
let new_avg_dec = numerator
.checked_div(new_owned.to_decimal())
.ok_or(AdjustmentOverflowError::ArithmeticOverflow)?;
(Some(Price::new(new_avg_dec)), zero)
}
None => (None, zero),
}
} else {
match self.avg_entry_price {
Some(avg) => {
let price_minus_avg = price_dec
.checked_sub(avg.to_decimal())
.ok_or(AdjustmentOverflowError::ArithmeticOverflow)?;
if delta_dec.abs() <= owned_dec.abs() {
let closed_qty = -delta_dec;
let realized = price_minus_avg
.checked_mul(closed_qty)
.ok_or(AdjustmentOverflowError::ArithmeticOverflow)?;
let avg = if new_owned.is_zero() { None } else { Some(avg) };
(avg, realized)
} else {
let realized = price_minus_avg
.checked_mul(owned_dec)
.ok_or(AdjustmentOverflowError::ArithmeticOverflow)?;
(Some(price), realized)
}
}
None => {
if self.realized_pnl_is_halted() {
return Ok(PositionPnlOperation::unchanged(*self));
}
return Ok(PositionPnlOperation::updated(
Self {
realized_pnl: Some(PositionPnlState::Halted(
PnlHaltReason::MissingCostBasis,
)),
..*self
},
Some(Err(PnlHaltReason::MissingCostBasis)),
None,
None,
));
}
}
};
let realized_delta = Pnl::new(realized_dec);
let (realized_pnl, outcome) = match self.realized_pnl {
Some(PositionPnlState::Pnl(current)) => match current.checked_add(realized_delta) {
Ok(absolute) => (
PositionPnlState::Pnl(absolute),
(!realized_delta.is_zero()).then_some(Ok(PnlOutcomeAmount {
delta: realized_delta,
absolute,
})),
),
Err(_) => (
PositionPnlState::Halted(PnlHaltReason::ArithmeticOverflow),
Some(Err(PnlHaltReason::ArithmeticOverflow)),
),
},
None if owned.is_zero() => (PositionPnlState::Pnl(realized_delta), None),
None => (
PositionPnlState::Halted(PnlHaltReason::MissingInitialPnl),
Some(Err(PnlHaltReason::MissingInitialPnl)),
),
Some(PositionPnlState::Halted(reason)) => (PositionPnlState::Halted(reason), None),
};
Ok(PositionPnlOperation::updated(
Self {
avg_entry_price: new_avg,
available: self.available,
held: self.held,
incoming: self.incoming,
realized_pnl: Some(realized_pnl),
},
outcome,
Some(realized_delta),
new_avg,
))
}
pub(crate) fn add_realized_pnl(&self, delta: Pnl) -> PositionPnlOperation {
let Some(PositionPnlState::Pnl(current)) = self.realized_pnl else {
return PositionPnlOperation::unchanged(*self);
};
if delta.is_zero() {
return PositionPnlOperation::unchanged(*self);
}
match current.checked_add(delta) {
Ok(absolute) => PositionPnlOperation::updated(
Self {
realized_pnl: Some(PositionPnlState::Pnl(absolute)),
..*self
},
Some(Ok(PnlOutcomeAmount { delta, absolute })),
None,
None,
),
Err(_) => PositionPnlOperation::updated(
Self {
realized_pnl: Some(PositionPnlState::Halted(PnlHaltReason::ArithmeticOverflow)),
..*self
},
Some(Err(PnlHaltReason::ArithmeticOverflow)),
None,
None,
),
}
}
pub fn apply_delta_rollback(
&self,
available_delta: PositionSize,
held_delta: PositionSize,
incoming_delta: PositionSize,
) -> Result<Self, AdjustmentOverflowError> {
let available = self
.available
.checked_sub(available_delta)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
let held = self
.held
.checked_sub(held_delta)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
let incoming = self
.incoming
.checked_sub(incoming_delta)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?;
Ok(Self {
avg_entry_price: self.avg_entry_price,
available,
held,
incoming,
realized_pnl: self.realized_pnl,
})
}
pub fn apply_adjustment(
&self,
target: AdjustmentTarget,
amount: AdjustmentAmount,
) -> Result<Self, AdjustmentOverflowError> {
let mut new = *self;
let field = match target {
AdjustmentTarget::Available => &mut new.available,
AdjustmentTarget::Held => &mut new.held,
AdjustmentTarget::Incoming => &mut new.incoming,
};
*field = match amount {
AdjustmentAmount::Absolute(v) => v,
AdjustmentAmount::Delta(d) => field
.checked_add(d)
.map_err(|_| AdjustmentOverflowError::ArithmeticOverflow)?,
};
Ok(new)
}
pub fn with_avg_entry_price(&self, avg_entry_price: Option<Price>) -> Self {
Self {
avg_entry_price,
..*self
}
}
pub fn is_zero(&self) -> bool {
self.available.is_zero()
&& self.held.is_zero()
&& self.incoming.is_zero()
&& self.realized_pnl.map_or(true, |outcome| match outcome {
PositionPnlState::Pnl(value) => value.is_zero(),
PositionPnlState::Halted(_) => false,
})
&& self.avg_entry_price.is_none()
}
pub fn available_within_bounds(
&self,
lower: Option<PositionSize>,
upper: Option<PositionSize>,
) -> bool {
!lower.is_some_and(|b| self.available < b) && !upper.is_some_and(|b| self.available > b)
}
pub fn held_within_bounds(
&self,
lower: Option<PositionSize>,
upper: Option<PositionSize>,
) -> bool {
!lower.is_some_and(|b| self.held < b) && !upper.is_some_and(|b| self.held > b)
}
pub fn incoming_within_bounds(
&self,
lower: Option<PositionSize>,
upper: Option<PositionSize>,
) -> bool {
!lower.is_some_and(|b| self.incoming < b) && !upper.is_some_and(|b| self.incoming > b)
}
}
#[cfg(test)]
mod tests {
use rust_decimal::Decimal;
use crate::core::{PnlHaltReason, PnlOutcomeAmount};
use crate::param::{AdjustmentAmount, Pnl, PositionSize, Price};
use super::super::error::{AdjustmentOverflowError, HoldError};
use super::{AdjustmentTarget, Holdings, PositionPnlState};
fn ps(value: &str) -> PositionSize {
PositionSize::from_str(value).expect("position size literal must be valid")
}
fn pnl(value: &str) -> Pnl {
Pnl::from_str(value).expect("pnl literal must be valid")
}
fn px(value: &str) -> Price {
Price::from_str(value).expect("price literal must be valid")
}
fn realize_position_fill(
holdings: Holdings,
signed_qty: PositionSize,
price: Price,
) -> Result<(Holdings, Option<Pnl>), AdjustmentOverflowError> {
let operation = holdings.realize_position_fill(signed_qty, price)?;
let delta = operation
.outcome()
.and_then(Result::ok)
.map(|amount| amount.delta);
Ok((operation.holdings(), delta))
}
fn holdings(available: &str, held: &str) -> Holdings {
Holdings::new(ps(available), ps(held))
}
fn max_ps() -> PositionSize {
PositionSize::new(Decimal::MAX)
}
fn min_ps() -> PositionSize {
PositionSize::new(Decimal::MIN)
}
#[test]
fn zero_returns_empty_components() {
let value = Holdings::zero();
assert_eq!(value.available(), PositionSize::ZERO);
assert_eq!(value.held(), PositionSize::ZERO);
assert_eq!(value.incoming(), PositionSize::ZERO);
}
#[test]
fn new_stores_explicit_components() {
let value = Holdings::new(ps("5"), ps("3"));
assert_eq!(value.available(), ps("5"));
assert_eq!(value.held(), ps("3"));
assert_eq!(value.incoming(), PositionSize::ZERO);
assert_eq!(
Holdings::new(PositionSize::ZERO, PositionSize::ZERO),
Holdings::zero(),
);
}
#[test]
fn new_accepts_negative_components() {
let value = Holdings::new(ps("-1"), ps("-2"));
assert_eq!(value.available(), ps("-1"));
assert_eq!(value.held(), ps("-2"));
assert_eq!(value.incoming(), PositionSize::ZERO);
}
#[test]
fn accessors_return_constructor_values() {
let value = holdings("7", "4");
assert_eq!(value.available(), ps("7"));
assert_eq!(value.held(), ps("4"));
assert_eq!(value.incoming(), PositionSize::ZERO);
}
#[test]
fn try_hold_moves_available_to_held() {
let value = holdings("10", "0");
let updated = value.try_hold(ps("5")).expect("must hold");
assert_eq!(updated.available(), ps("5"));
assert_eq!(updated.held(), ps("5"));
}
#[test]
fn try_hold_all_available() {
let value = holdings("10", "0");
let updated = value.try_hold(ps("10")).expect("must hold");
assert_eq!(updated.available(), PositionSize::ZERO);
assert_eq!(updated.held(), ps("10"));
}
#[test]
fn try_hold_rejects_insufficient_available_without_changing_original() {
let value = holdings("10", "0");
let err = value.try_hold(ps("15")).expect_err("must fail");
assert_eq!(
err,
HoldError::InsufficientAvailable {
available: ps("10"),
requested: ps("15"),
}
);
assert_eq!(value, holdings("10", "0"));
}
#[test]
fn try_hold_negative_amount_inverts_as_arithmetic() {
let value = holdings("10", "5");
let updated = value.try_hold(ps("-3")).expect("must succeed");
assert_eq!(updated.available(), ps("13"));
assert_eq!(updated.held(), ps("2"));
}
#[test]
fn try_hold_reports_arithmetic_overflow_when_held_would_overflow() {
let value = Holdings::new(max_ps(), max_ps());
let err = value.try_hold(max_ps()).expect_err("must fail");
assert_eq!(err, HoldError::ArithmeticOverflow);
}
#[test]
fn try_hold_respects_negative_held() {
let value = Holdings::new(ps("2000"), ps("-2000"));
let err = value.try_hold(ps("1")).expect_err("must reject");
assert_eq!(
err,
HoldError::InsufficientAvailable {
available: PositionSize::ZERO,
requested: ps("1"),
}
);
}
#[test]
fn try_hold_succeeds_when_negative_held_covered_by_available() {
let value = Holdings::new(ps("5000"), ps("-2000"));
value
.try_hold(ps("3000"))
.expect("must succeed within spendable");
let err = value
.try_hold(ps("3001"))
.expect_err("must reject one over");
assert_eq!(
err,
HoldError::InsufficientAvailable {
available: ps("3000"),
requested: ps("3001"),
}
);
}
#[test]
fn try_hold_positive_held_does_not_change_spendable() {
let value = holdings("10", "5");
value
.try_hold(ps("10"))
.expect("must succeed - held is positive, spendable = available");
}
#[test]
fn hold_allow_negative_moves_available_to_held_within_balance() {
let value = holdings("10", "0");
let updated = value.hold_allow_negative(ps("5")).expect("must hold");
assert_eq!(updated.available(), ps("5"));
assert_eq!(updated.held(), ps("5"));
}
#[test]
fn hold_allow_negative_drives_available_negative_without_rejecting() {
let value = holdings("1000", "0");
let updated = value.hold_allow_negative(ps("2000")).expect("must hold");
assert_eq!(updated.available(), ps("-1000"));
assert_eq!(updated.held(), ps("2000"));
}
#[test]
fn hold_allow_negative_preserves_incoming_and_position_tracking() {
let value = holdings("10", "5")
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("7"))
.reserve_incoming(ps("4"))
.expect("seed must succeed");
let updated = value.hold_allow_negative(ps("20")).expect("must hold");
assert_eq!(updated.incoming(), ps("4"));
assert_eq!(updated.avg_entry_price(), Some(px("100")));
assert_eq!(updated.realized_pnl(), Some(pnl("7")));
}
#[test]
fn hold_allow_negative_reports_arithmetic_overflow() {
let value = Holdings::new(max_ps(), max_ps());
let err = value.hold_allow_negative(max_ps()).expect_err("must fail");
assert_eq!(err, HoldError::ArithmeticOverflow);
}
#[test]
fn release_moves_held_to_available() {
let value = holdings("2", "10");
let updated = value.release(ps("4")).expect("must release");
assert_eq!(updated.available(), ps("6"));
assert_eq!(updated.held(), ps("6"));
}
#[test]
fn release_all_held() {
let value = holdings("2", "10");
let updated = value.release(ps("10")).expect("must release");
assert_eq!(updated.available(), ps("12"));
assert_eq!(updated.held(), PositionSize::ZERO);
}
#[test]
fn release_amount_exceeding_held_drives_held_negative() {
let value = holdings("2", "10");
let updated = value.release(ps("15")).expect("must succeed");
assert_eq!(updated.available(), ps("17"));
assert_eq!(updated.held(), ps("-5"));
}
#[test]
fn release_negative_amount_inverts_as_arithmetic() {
let value = holdings("10", "5");
let updated = value.release(ps("-3")).expect("must succeed");
assert_eq!(updated.available(), ps("7"));
assert_eq!(updated.held(), ps("8"));
}
#[test]
fn release_reports_arithmetic_overflow_when_available_would_overflow() {
let value = Holdings::new(max_ps(), max_ps());
let err = value.release(max_ps()).expect_err("must fail");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn apply_fill_outflow_subtracts_held_only() {
let value = holdings("10", "5");
let updated = value.apply_fill_outflow(ps("3")).expect("must subtract");
assert_eq!(updated.available(), ps("10"));
assert_eq!(updated.held(), ps("2"));
}
#[test]
fn apply_fill_outflow_drives_held_negative_when_amount_exceeds_held() {
let value = holdings("10", "5");
let updated = value.apply_fill_outflow(ps("8")).expect("must subtract");
assert_eq!(updated.available(), ps("10"));
assert_eq!(updated.held(), ps("-3"));
}
#[test]
fn apply_fill_outflow_negative_amount_adds_to_held() {
let value = holdings("10", "5");
let updated = value.apply_fill_outflow(ps("-3")).expect("must succeed");
assert_eq!(updated.available(), ps("10"));
assert_eq!(updated.held(), ps("8"));
}
#[test]
fn apply_fill_outflow_reports_arithmetic_overflow() {
let value = Holdings::new(PositionSize::ZERO, max_ps());
let err = value
.apply_fill_outflow(min_ps())
.expect_err("must overflow");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn apply_fill_inflow_zero_amount_is_no_change() {
let value = holdings("10", "2");
let updated = value
.apply_fill_inflow(PositionSize::ZERO)
.expect("must succeed");
assert_eq!(updated, value);
}
#[test]
fn apply_fill_inflow_adds_to_available_only() {
let value = holdings("10", "5");
let updated = value.apply_fill_inflow(ps("3")).expect("must add");
assert_eq!(updated.available(), ps("13"));
assert_eq!(updated.held(), ps("5"));
}
#[test]
fn apply_fill_inflow_accepts_negative_amount_driving_available_negative() {
let value = holdings("3", "5");
let updated = value.apply_fill_inflow(ps("-7")).expect("must add");
assert_eq!(updated.available(), ps("-4"));
assert_eq!(updated.held(), ps("5"));
}
#[test]
fn apply_fill_inflow_reports_arithmetic_overflow() {
let value = Holdings::new(max_ps(), PositionSize::ZERO);
let err = value
.apply_fill_inflow(max_ps())
.expect_err("must overflow");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn apply_adjustment_sets_available_absolute_values() {
let value = holdings("5", "11");
assert_eq!(
value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Absolute(ps("7"))
)
.expect("absolute must succeed")
.available(),
ps("7")
);
assert_eq!(
value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Absolute(ps("0"))
)
.expect("absolute must succeed")
.available(),
PositionSize::ZERO
);
assert_eq!(
value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Absolute(ps("7"))
)
.expect("absolute must succeed")
.held(),
ps("11")
);
let neg = value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Absolute(ps("-1")),
)
.expect("absolute must succeed");
assert_eq!(neg.available(), ps("-1"));
assert_eq!(neg.held(), ps("11"));
}
#[test]
fn apply_adjustment_sets_held_absolute_values() {
let value = holdings("11", "5");
assert_eq!(
value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Absolute(ps("7")))
.expect("absolute must succeed")
.held(),
ps("7")
);
assert_eq!(
value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Absolute(ps("0")))
.expect("absolute must succeed")
.held(),
PositionSize::ZERO
);
assert_eq!(
value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Absolute(ps("7")))
.expect("absolute must succeed")
.available(),
ps("11")
);
let neg = value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Absolute(ps("-1")))
.expect("absolute must succeed");
assert_eq!(neg.held(), ps("-1"));
assert_eq!(neg.available(), ps("11"));
}
#[test]
fn apply_adjustment_applies_available_deltas() {
let value = holdings("5", "11");
assert_eq!(
value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Delta(ps("3"))
)
.expect("delta must succeed"),
holdings("8", "11")
);
assert_eq!(
value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Delta(ps("0"))
)
.expect("delta must succeed"),
value
);
assert_eq!(
value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Delta(ps("-3"))
)
.expect("delta must succeed"),
holdings("2", "11")
);
assert_eq!(
value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Delta(ps("-5"))
)
.expect("delta must succeed"),
holdings("0", "11")
);
let neg = value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Delta(ps("-6")),
)
.expect("delta must succeed");
assert_eq!(neg.available(), ps("-1"));
assert_eq!(neg.held(), ps("11"));
}
#[test]
fn apply_adjustment_applies_held_deltas() {
let value = holdings("11", "5");
assert_eq!(
value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Delta(ps("3")))
.expect("delta must succeed"),
holdings("11", "8")
);
assert_eq!(
value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Delta(ps("0")))
.expect("delta must succeed"),
value
);
assert_eq!(
value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Delta(ps("-3")))
.expect("delta must succeed"),
holdings("11", "2")
);
assert_eq!(
value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Delta(ps("-5")))
.expect("delta must succeed"),
holdings("11", "0")
);
let neg = value
.apply_adjustment(AdjustmentTarget::Held, AdjustmentAmount::Delta(ps("-6")))
.expect("delta must succeed");
assert_eq!(neg.held(), ps("-1"));
assert_eq!(neg.available(), ps("11"));
}
#[test]
fn apply_adjustment_reports_arithmetic_overflow_for_delta() {
let value = Holdings::new(max_ps(), PositionSize::ZERO);
let err = value
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Delta(max_ps()),
)
.expect_err("must overflow");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn apply_adjustment_sets_incoming_absolute_values() {
let value = holdings("5", "11");
let set = value
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Absolute(ps("7")),
)
.expect("absolute must succeed");
assert_eq!(set.incoming(), ps("7"));
assert_eq!(set.available(), ps("5"));
assert_eq!(set.held(), ps("11"));
let zero = value
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Absolute(ps("0")),
)
.expect("absolute must succeed");
assert_eq!(zero.incoming(), PositionSize::ZERO);
let neg = value
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Absolute(ps("-3")),
)
.expect("absolute must succeed");
assert_eq!(neg.incoming(), ps("-3"));
assert_eq!(neg.available(), ps("5"));
assert_eq!(neg.held(), ps("11"));
}
#[test]
fn apply_adjustment_applies_incoming_deltas() {
let mut base = holdings("5", "11");
base = base
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Absolute(ps("10")),
)
.expect("seed must succeed");
assert_eq!(
base.apply_adjustment(AdjustmentTarget::Incoming, AdjustmentAmount::Delta(ps("3")))
.expect("delta must succeed")
.incoming(),
ps("13")
);
assert_eq!(
base.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Delta(ps("-4"))
)
.expect("delta must succeed")
.incoming(),
ps("6")
);
let neg = base
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Delta(ps("-15")),
)
.expect("delta must succeed");
assert_eq!(neg.incoming(), ps("-5"));
assert_eq!(neg.available(), ps("5"));
assert_eq!(neg.held(), ps("11"));
}
#[test]
fn apply_adjustment_incoming_overflow_returns_error() {
let mut value = Holdings::new(PositionSize::ZERO, PositionSize::ZERO);
value = value
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Absolute(max_ps()),
)
.expect("seed must succeed");
let err = value
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Delta(max_ps()),
)
.expect_err("must overflow");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn trading_operations_do_not_touch_incoming() {
let mut base = holdings("10", "5");
base = base
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Absolute(ps("7")),
)
.expect("seed must succeed");
assert_eq!(
base.try_hold(ps("3")).expect("must hold").incoming(),
ps("7")
);
assert_eq!(
base.release(ps("2")).expect("must release").incoming(),
ps("7")
);
assert_eq!(
base.apply_fill_outflow(ps("2"))
.expect("must outflow")
.incoming(),
ps("7")
);
assert_eq!(
base.apply_fill_inflow(ps("2"))
.expect("must inflow")
.incoming(),
ps("7")
);
}
#[test]
fn reserve_incoming_adds_only_incoming() {
let base = holdings("10", "5")
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("7"));
let updated = base.reserve_incoming(ps("3")).expect("must reserve");
assert_eq!(updated.incoming(), ps("3"));
assert_eq!(updated.available(), ps("10"));
assert_eq!(updated.held(), ps("5"));
assert_eq!(updated.avg_entry_price(), Some(px("100")));
assert_eq!(updated.realized_pnl(), Some(pnl("7")));
}
#[test]
fn reserve_incoming_accumulates() {
let base = holdings("0", "0")
.reserve_incoming(ps("4"))
.expect("first reserve");
let updated = base.reserve_incoming(ps("6")).expect("second reserve");
assert_eq!(updated.incoming(), ps("10"));
}
#[test]
fn reserve_incoming_reports_arithmetic_overflow() {
let mut value = Holdings::zero();
value = value.reserve_incoming(max_ps()).expect("seed must succeed");
let err = value.reserve_incoming(max_ps()).expect_err("must overflow");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn consume_incoming_subtracts_only_incoming() {
let base = holdings("10", "5")
.reserve_incoming(ps("8"))
.expect("seed must succeed");
let updated = base.consume_incoming(ps("3")).expect("must consume");
assert_eq!(updated.incoming(), ps("5"));
assert_eq!(updated.available(), ps("10"));
assert_eq!(updated.held(), ps("5"));
}
#[test]
fn consume_incoming_can_drive_incoming_negative() {
let base = holdings("10", "5")
.reserve_incoming(ps("2"))
.expect("seed must succeed");
let updated = base.consume_incoming(ps("5")).expect("must consume");
assert_eq!(updated.incoming(), ps("-3"));
assert_eq!(updated.available(), ps("10"));
assert_eq!(updated.held(), ps("5"));
}
#[test]
fn consume_incoming_reports_arithmetic_overflow() {
let mut value = Holdings::zero();
value = value.reserve_incoming(max_ps()).expect("seed must succeed");
let err = value.consume_incoming(min_ps()).expect_err("must overflow");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn incoming_operations_do_not_touch_available_held_or_pnl() {
let base = holdings("10", "5")
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("7"));
let reserved = base.reserve_incoming(ps("4")).expect("must reserve");
assert_eq!(reserved.available(), ps("10"));
assert_eq!(reserved.held(), ps("5"));
assert_eq!(reserved.avg_entry_price(), Some(px("100")));
assert_eq!(reserved.realized_pnl(), Some(pnl("7")));
let consumed = reserved.consume_incoming(ps("4")).expect("must consume");
assert_eq!(consumed.incoming(), PositionSize::ZERO);
assert_eq!(consumed.available(), ps("10"));
assert_eq!(consumed.held(), ps("5"));
assert_eq!(consumed.avg_entry_price(), Some(px("100")));
assert_eq!(consumed.realized_pnl(), Some(pnl("7")));
}
#[test]
fn try_hold_spendable_ignores_incoming() {
let base = holdings("10", "0")
.reserve_incoming(ps("1000"))
.expect("seed must succeed");
base.try_hold(ps("10")).expect("must hold within available");
let err = base
.try_hold(ps("11"))
.expect_err("must reject over available");
assert_eq!(
err,
HoldError::InsufficientAvailable {
available: ps("10"),
requested: ps("11"),
}
);
}
#[test]
fn available_within_bounds_accepts_missing_bounds() {
assert!(holdings("5", "0").available_within_bounds(None, None));
}
#[test]
fn available_within_bounds_checks_lower_inclusively() {
assert!(holdings("5", "0").available_within_bounds(Some(ps("3")), None));
assert!(!holdings("2", "0").available_within_bounds(Some(ps("3")), None));
assert!(holdings("3", "0").available_within_bounds(Some(ps("3")), None));
}
#[test]
fn available_within_bounds_checks_upper_inclusively() {
assert!(holdings("5", "0").available_within_bounds(None, Some(ps("7"))));
assert!(!holdings("8", "0").available_within_bounds(None, Some(ps("7"))));
assert!(holdings("7", "0").available_within_bounds(None, Some(ps("7"))));
}
#[test]
fn available_within_bounds_checks_both_bounds() {
assert!(holdings("5", "0").available_within_bounds(Some(ps("3")), Some(ps("7"))));
assert!(!holdings("2", "0").available_within_bounds(Some(ps("3")), Some(ps("7"))));
assert!(!holdings("8", "0").available_within_bounds(Some(ps("3")), Some(ps("7"))));
}
#[test]
fn available_within_bounds_handles_negative_bounds() {
assert!(holdings("0", "0").available_within_bounds(Some(ps("-3")), None));
assert!(!holdings("0", "0").available_within_bounds(Some(ps("1")), None));
}
#[test]
fn held_within_bounds_checks_inclusively() {
let h = holdings("0", "5");
assert!(h.held_within_bounds(None, None));
assert!(h.held_within_bounds(Some(ps("3")), None));
assert!(!h.held_within_bounds(Some(ps("6")), None));
assert!(h.held_within_bounds(Some(ps("5")), None));
assert!(h.held_within_bounds(None, Some(ps("7"))));
assert!(!h.held_within_bounds(None, Some(ps("4"))));
assert!(h.held_within_bounds(None, Some(ps("5"))));
assert!(h.held_within_bounds(Some(ps("3")), Some(ps("7"))));
assert!(!h.held_within_bounds(Some(ps("6")), Some(ps("9"))));
}
#[test]
fn incoming_within_bounds_checks_inclusively() {
let mut base = holdings("0", "0");
base = base
.apply_adjustment(
AdjustmentTarget::Incoming,
AdjustmentAmount::Absolute(ps("5")),
)
.expect("seed must succeed");
assert!(base.incoming_within_bounds(None, None));
assert!(base.incoming_within_bounds(Some(ps("3")), None));
assert!(!base.incoming_within_bounds(Some(ps("6")), None));
assert!(base.incoming_within_bounds(Some(ps("5")), None));
assert!(base.incoming_within_bounds(None, Some(ps("7"))));
assert!(!base.incoming_within_bounds(None, Some(ps("4"))));
assert!(base.incoming_within_bounds(None, Some(ps("5"))));
assert!(base.incoming_within_bounds(Some(ps("3")), Some(ps("7"))));
assert!(!base.incoming_within_bounds(Some(ps("6")), Some(ps("9"))));
}
#[test]
fn holdings_is_copy() {
let original = holdings("10", "5");
let copied = original;
assert_eq!(copied, original);
}
#[test]
fn mutating_operations_return_new_values() {
let original = holdings("10", "5");
let held = original.try_hold(ps("3")).expect("must hold");
let released = original.release(ps("2")).expect("must release");
let outflow = original.apply_fill_outflow(ps("2")).expect("must subtract");
let inflow = original.apply_fill_inflow(ps("2")).expect("must add");
assert_eq!(original, holdings("10", "5"));
assert_eq!(held, holdings("7", "8"));
assert_eq!(released, holdings("12", "3"));
assert_eq!(outflow, holdings("10", "3"));
assert_eq!(inflow, holdings("12", "5"));
}
#[test]
fn new_and_zero_have_no_avg_and_untracked_pnl() {
let zero = Holdings::zero();
assert_eq!(zero.avg_entry_price(), None);
assert_eq!(zero.realized_pnl(), None);
let made = Holdings::new(ps("5"), ps("3"));
assert_eq!(made.avg_entry_price(), None);
assert_eq!(made.realized_pnl(), None);
}
#[test]
fn realize_open_from_flat_seeds_avg_and_realizes_nothing() {
let flat = Holdings::zero();
let (updated, realized) =
realize_position_fill(flat, ps("10"), px("100")).expect("must realize");
assert_eq!(realized, None);
assert_eq!(updated.avg_entry_price(), Some(px("100")));
assert_eq!(updated.realized_pnl(), Some(Pnl::ZERO));
}
#[test]
fn realize_open_short_from_flat_seeds_avg() {
let flat = Holdings::zero();
let (updated, realized) =
realize_position_fill(flat, ps("-4"), px("50")).expect("must realize");
assert_eq!(realized, None);
assert_eq!(updated.avg_entry_price(), Some(px("50")));
}
#[test]
fn realize_zero_qty_from_flat_keeps_avg_none() {
let flat = Holdings::zero();
let (updated, realized) =
realize_position_fill(flat, PositionSize::ZERO, px("100")).expect("must realize");
assert_eq!(realized, None);
assert_eq!(updated.avg_entry_price(), None);
}
#[test]
fn realize_add_to_long_weights_average() {
let long = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(long, ps("10"), px("200")).expect("must realize");
assert_eq!(realized, None);
assert_eq!(updated.avg_entry_price(), Some(px("150")));
}
#[test]
fn realize_add_to_short_weights_average() {
let short = Holdings::new(ps("-10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(short, ps("-10"), px("200")).expect("must realize");
assert_eq!(realized, None);
assert_eq!(updated.avg_entry_price(), Some(px("150")));
}
#[test]
fn realize_partial_close_long_realizes_positive_when_price_above_avg() {
let long = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(long, ps("-4"), px("130")).expect("must realize");
assert_eq!(realized, Some(pnl("120")));
assert_eq!(updated.avg_entry_price(), Some(px("100")));
assert_eq!(updated.realized_pnl(), Some(pnl("120")));
}
#[test]
fn position_pnl_operation_reports_only_changed_pnl_or_new_halt() {
let tracked = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("50"));
let realized = tracked
.realize_position_fill(ps("-4"), px("130"))
.expect("must realize");
assert_eq!(
realized.outcome(),
Some(Ok(PnlOutcomeAmount {
delta: pnl("120"),
absolute: pnl("170"),
}))
);
let halted = realized
.holdings()
.halt_realized_pnl_preserving_average(PnlHaltReason::MissingFx);
assert_eq!(halted.outcome(), Some(Err(PnlHaltReason::MissingFx)));
assert_eq!(
halted.holdings().realized_pnl_outcome(),
Some(PositionPnlState::Halted(PnlHaltReason::MissingFx)),
);
assert_eq!(halted.holdings().avg_entry_price(), Some(px("100")));
assert!(halted.holdings().realized_pnl_is_halted());
let repeated = halted
.holdings()
.halt_realized_pnl(PnlHaltReason::MissingFx);
assert_eq!(repeated.outcome(), None);
}
#[test]
fn unpriced_fill_preserves_basis_only_for_partial_reduction() {
let tracked = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let partial = tracked
.halt_realized_pnl_for_unpriced_fill(ps("-4"), PnlHaltReason::MissingFx)
.expect("partial close must not overflow");
assert_eq!(partial.holdings().avg_entry_price(), Some(px("100")));
assert_eq!(partial.outcome(), Some(Err(PnlHaltReason::MissingFx)));
for signed_quantity in ["5", "-10", "-15"] {
let operation = tracked
.halt_realized_pnl_for_unpriced_fill(ps(signed_quantity), PnlHaltReason::MissingFx)
.expect("basis transition must not overflow");
assert_eq!(operation.holdings().avg_entry_price(), None);
}
let halted_partial = Holdings::new(ps("6"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.halt_realized_pnl_preserving_average(PnlHaltReason::MissingFx)
.holdings();
let exact_close = halted_partial
.halt_realized_pnl_for_unpriced_fill(ps("-6"), PnlHaltReason::MissingFx)
.expect("exact close must not overflow");
assert_eq!(exact_close.holdings().avg_entry_price(), None);
assert_eq!(exact_close.outcome(), None);
}
#[test]
fn realize_partial_close_short_realizes_positive_when_price_below_avg() {
let short = Holdings::new(ps("-10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(short, ps("4"), px("70")).expect("must realize");
assert_eq!(realized, Some(pnl("120")));
assert_eq!(updated.avg_entry_price(), Some(px("100")));
}
#[test]
fn realize_exact_close_long_resets_avg_to_none_and_keeps_pnl() {
let long = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(long, ps("-10"), px("130")).expect("must realize");
assert_eq!(realized, Some(pnl("300")));
assert_eq!(updated.avg_entry_price(), None);
assert_eq!(updated.realized_pnl(), Some(pnl("300")));
}
#[test]
fn realize_flip_long_to_short_closes_then_reopens_at_price() {
let long = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(long, ps("-15"), px("130")).expect("must realize");
assert_eq!(realized, Some(pnl("300")));
assert_eq!(updated.avg_entry_price(), Some(px("130")));
}
#[test]
fn realize_flip_short_to_long_closes_then_reopens_at_price() {
let short = Holdings::new(ps("-10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(short, ps("15"), px("70")).expect("must realize");
assert_eq!(realized, Some(pnl("300")));
assert_eq!(updated.avg_entry_price(), Some(px("70")));
}
#[test]
fn realize_partial_close_long_at_loss_is_negative() {
let long = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (_updated, realized) =
realize_position_fill(long, ps("-4"), px("80")).expect("must realize");
assert_eq!(realized, Some(pnl("-80")));
}
#[test]
fn realize_owned_uses_available_plus_held() {
let long = Holdings::new(ps("6"), ps("4"))
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, realized) =
realize_position_fill(long, ps("-10"), px("130")).expect("must realize");
assert_eq!(realized, Some(pnl("300")));
assert_eq!(updated.avg_entry_price(), None);
}
#[test]
fn realize_reduce_without_basis_halts_with_missing_cost_basis() {
let basis_less = Holdings::new(ps("10"), PositionSize::ZERO);
let operation = basis_less
.realize_position_fill(ps("-4"), px("200"))
.expect("must calculate");
assert_eq!(
operation.outcome(),
Some(Err(PnlHaltReason::MissingCostBasis))
);
let updated = operation.holdings();
assert_eq!(updated.avg_entry_price(), None);
assert_eq!(updated.realized_pnl(), None);
}
#[test]
fn realize_exact_close_without_basis_halts_with_missing_cost_basis() {
let basis_less = Holdings::new(ps("-10"), PositionSize::ZERO);
let operation = basis_less
.realize_position_fill(ps("10"), px("70"))
.expect("must calculate");
assert_eq!(
operation.outcome(),
Some(Err(PnlHaltReason::MissingCostBasis))
);
let updated = operation.holdings();
assert_eq!(updated.avg_entry_price(), None);
}
#[test]
fn realize_add_without_initial_pnl_halts_with_missing_initial_pnl() {
let basis_less = Holdings::new(ps("10"), PositionSize::ZERO);
let operation = basis_less
.realize_position_fill(ps("5"), px("200"))
.expect("must calculate");
assert_eq!(
operation.outcome(),
Some(Err(PnlHaltReason::MissingInitialPnl))
);
let updated = operation.holdings();
assert_eq!(updated.avg_entry_price(), None);
}
#[test]
fn realize_flip_without_basis_halts_with_missing_cost_basis() {
let basis_less = Holdings::new(ps("10"), PositionSize::ZERO);
let operation = basis_less
.realize_position_fill(ps("-15"), px("130"))
.expect("must calculate");
assert_eq!(
operation.outcome(),
Some(Err(PnlHaltReason::MissingCostBasis))
);
let updated = operation.holdings();
assert_eq!(updated.avg_entry_price(), None);
}
#[test]
fn realize_after_rollback_to_none_halts_with_missing_initial_pnl() {
let rolled_back = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl_opt(None);
let operation = rolled_back
.realize_position_fill(ps("-4"), px("130"))
.expect("must calculate");
assert_eq!(
operation.outcome(),
Some(Err(PnlHaltReason::MissingInitialPnl))
);
let updated = operation.holdings();
assert_eq!(updated.realized_pnl(), None);
assert_eq!(updated.avg_entry_price(), Some(px("100")));
}
#[test]
fn realize_accumulates_realized_pnl_across_fills() {
let long = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("50"));
let (updated, realized) =
realize_position_fill(long, ps("-4"), px("130")).expect("must realize");
assert_eq!(realized, Some(pnl("120")));
assert_eq!(updated.realized_pnl(), Some(pnl("170")));
}
#[test]
fn realize_position_fill_leaves_quantities_untouched() {
let long = Holdings::new(ps("10"), ps("2"))
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(Pnl::ZERO);
let (updated, _realized) =
realize_position_fill(long, ps("-4"), px("130")).expect("must realize");
assert_eq!(updated.available(), ps("10"));
assert_eq!(updated.held(), ps("2"));
assert_eq!(updated.incoming(), PositionSize::ZERO);
}
#[test]
fn realize_position_fill_reports_overflow() {
let long = Holdings::new(max_ps(), PositionSize::ZERO)
.with_avg_entry_price(Some(px("2")))
.with_realized_pnl(Pnl::ZERO);
let err = long
.realize_position_fill(max_ps(), px("2"))
.expect_err("must overflow");
assert_eq!(err, AdjustmentOverflowError::ArithmeticOverflow);
}
#[test]
fn is_zero_requires_no_avg_and_zero_realized_pnl() {
assert!(Holdings::zero().is_zero());
let with_pnl = Holdings::zero().with_realized_pnl(pnl("5"));
assert!(!with_pnl.is_zero());
let with_zero_pnl = Holdings::zero().with_realized_pnl(Pnl::ZERO);
assert!(with_zero_pnl.is_zero());
let with_avg = Holdings::zero().with_avg_entry_price(Some(px("100")));
assert!(!with_avg.is_zero());
}
#[test]
fn reservation_and_cancel_preserve_avg_and_pnl() {
let base = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("7"));
let held = base.try_hold(ps("4")).expect("must hold");
assert_eq!(held.avg_entry_price(), Some(px("100")));
assert_eq!(held.realized_pnl(), Some(pnl("7")));
let released = held.release(ps("4")).expect("must release");
assert_eq!(released.avg_entry_price(), Some(px("100")));
assert_eq!(released.realized_pnl(), Some(pnl("7")));
}
#[test]
fn apply_delta_rollback_reverses_quantity_deltas() {
let slot = Holdings::new(ps("10"), ps("2"))
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("90"));
let rolled = slot
.apply_delta_rollback(ps("3"), ps("1"), PositionSize::ZERO)
.expect("rollback must succeed");
assert_eq!(rolled.available(), ps("7"));
assert_eq!(rolled.held(), ps("1"));
assert_eq!(rolled.incoming(), PositionSize::ZERO);
assert_eq!(rolled.avg_entry_price(), Some(px("100")));
assert_eq!(rolled.realized_pnl(), Some(pnl("90")));
}
#[test]
fn apply_delta_rollback_leaves_avg_and_pnl_untouched() {
let slot = Holdings::new(ps("5"), ps("0"))
.with_avg_entry_price(Some(px("42")))
.with_realized_pnl(pnl("42"));
let rolled = slot
.apply_delta_rollback(ps("3"), PositionSize::ZERO, PositionSize::ZERO)
.expect("rollback must succeed");
assert_eq!(rolled.available(), ps("2"));
assert_eq!(rolled.avg_entry_price(), Some(px("42")));
assert_eq!(rolled.realized_pnl(), Some(pnl("42")));
}
#[test]
fn with_realized_pnl_opt_restores_untracked_state() {
let tracked = Holdings::new(ps("10"), ps("0")).with_realized_pnl(pnl("5"));
let untracked = tracked.with_realized_pnl_opt(None);
assert_eq!(untracked.realized_pnl(), None);
assert_eq!(untracked.available(), ps("10"));
let retracked = untracked.with_realized_pnl_opt(Some(pnl("-3")));
assert_eq!(retracked.realized_pnl(), Some(pnl("-3")));
}
#[test]
fn halted_realized_pnl_does_not_resume_until_force_set() {
let halt =
Holdings::new(ps("10"), PositionSize::ZERO).halt_realized_pnl(PnlHaltReason::MissingFx);
assert_eq!(halt.outcome(), Some(Err(PnlHaltReason::MissingFx)));
let halted = halt.holdings();
assert!(halted.realized_pnl_is_halted());
assert!(!halted.is_zero());
let (still_halted, realized) = realize_position_fill(halted, ps("-4"), px("130"))
.expect("halted tracking must not fail");
assert_eq!(realized, None);
assert!(still_halted.realized_pnl_is_halted());
assert_eq!(still_halted.realized_pnl(), None);
let rearmed = still_halted
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("5"));
assert!(!rearmed.realized_pnl_is_halted());
let (_, realized) = realize_position_fill(rearmed, ps("-4"), px("130"))
.expect("force-set tracking must resume");
assert_eq!(realized, Some(pnl("120")));
}
#[test]
fn with_realized_pnl_force_sets_absolute_value() {
let slot = Holdings::new(ps("10"), ps("0")).with_realized_pnl(pnl("7"));
assert_eq!(
slot.with_realized_pnl(pnl("-3")).realized_pnl(),
Some(pnl("-3"))
);
let with_avg = slot.with_avg_entry_price(Some(px("100")));
let forced = with_avg.with_realized_pnl(pnl("99"));
assert_eq!(forced.realized_pnl(), Some(pnl("99")));
assert_eq!(forced.avg_entry_price(), Some(px("100")));
assert_eq!(forced.available(), ps("10"));
}
#[test]
fn quantity_adjustments_preserve_avg_and_pnl() {
let base = Holdings::new(ps("10"), PositionSize::ZERO)
.with_avg_entry_price(Some(px("100")))
.with_realized_pnl(pnl("7"));
let adjusted = base
.apply_adjustment(
AdjustmentTarget::Available,
AdjustmentAmount::Delta(ps("3")),
)
.expect("must adjust");
assert_eq!(adjusted.available(), ps("13"));
assert_eq!(adjusted.avg_entry_price(), Some(px("100")));
assert_eq!(adjusted.realized_pnl(), Some(pnl("7")));
}
#[test]
fn realize_tracked_pnl_same_side_fill_without_avg_stays_basis_less() {
let slot = Holdings::new(ps("10"), PositionSize::ZERO).with_realized_pnl(pnl("30"));
assert_eq!(slot.avg_entry_price(), None);
let (updated, delta) =
realize_position_fill(slot, ps("5"), px("200")).expect("must not overflow");
assert_eq!(delta, None);
assert_eq!(updated.avg_entry_price(), None);
assert_eq!(updated.realized_pnl(), Some(pnl("30")));
}
#[test]
fn realize_tracked_pnl_opposite_side_fill_without_avg_halts() {
let slot = Holdings::new(ps("10"), PositionSize::ZERO).with_realized_pnl(pnl("30"));
let operation = slot
.realize_position_fill(ps("-4"), px("150"))
.expect("must calculate");
assert_eq!(
operation.outcome(),
Some(Err(PnlHaltReason::MissingCostBasis))
);
let updated = operation.holdings();
assert_eq!(updated.avg_entry_price(), None);
assert_eq!(updated.realized_pnl(), None);
}
#[test]
fn realize_tracked_pnl_flip_without_avg_halts() {
let slot = Holdings::new(ps("5"), PositionSize::ZERO).with_realized_pnl(pnl("30"));
let operation = slot
.realize_position_fill(ps("-10"), px("150"))
.expect("must calculate");
assert_eq!(
operation.outcome(),
Some(Err(PnlHaltReason::MissingCostBasis))
);
let updated = operation.holdings();
assert_eq!(updated.avg_entry_price(), None);
assert_eq!(updated.realized_pnl(), None);
}
}