将股票持仓盈亏切换为定点批次账本

This commit is contained in:
boris
2026-08-25 15:59:37 +08:00
parent 2574b9375d
commit 2b94d5148f
2 changed files with 321 additions and 103 deletions
@@ -10069,12 +10069,12 @@ impl PlatformExprStrategy {
equity: market_value,
value_percent,
unrealized_pnl: position.unrealized_pnl(),
realized_pnl: position.realized_pnl,
realized_pnl: position.realized_pnl(),
pnl: position.pnl(),
day_trade_quantity_delta: position.day_trade_quantity_delta() as i64,
trading_pnl: position.trading_pnl,
position_pnl: position.position_pnl,
dividend_receivable: position.dividend_receivable,
dividend_receivable: position.dividend_receivable(),
};
let stop_hit = if self.config.stop_loss_expr.trim().is_empty() {
false
+318 -100
View File
@@ -6,35 +6,78 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::data::{DataSet, DataSetError, PriceField};
use crate::fixed_point::{FixedMoney, MONEY_SCALE};
fn fixed_money(value: f64, label: &str) -> Result<FixedMoney, String> {
FixedMoney::from_f64(value)
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
}
fn fixed_money_or_panic(value: f64, label: &str) -> FixedMoney {
fixed_money(value, label).unwrap_or_else(|error| panic!("{error}"))
}
fn allocate_fixed_value(
total: FixedMoney,
allocated_quantity: u32,
total_quantity: u32,
) -> Result<FixedMoney, String> {
if allocated_quantity > total_quantity || total_quantity == 0 {
return Err("invalid fixed-point lot allocation quantity".to_string());
}
if allocated_quantity == total_quantity {
return Ok(total);
}
let product = total
.raw()
.checked_mul(i128::from(allocated_quantity))
.ok_or_else(|| "fixed-point lot allocation overflow".to_string())?;
let divisor = i128::from(total_quantity);
let quotient = product / divisor;
let remainder = product % divisor;
let rounded = if remainder
.checked_abs()
.and_then(|value| value.checked_mul(2))
.is_some_and(|value| value >= divisor)
{
quotient
.checked_add(product.signum())
.ok_or_else(|| "fixed-point lot allocation overflow".to_string())?
} else {
quotient
};
Ok(FixedMoney::from_raw(rounded))
}
#[derive(Debug, Clone)]
pub struct PositionLot {
pub acquired_date: NaiveDate,
pub quantity: u32,
pub entry_price: f64,
pub price: f64,
// Total values keep partial-lot allocation exact without a rounded per-share mirror.
entry_value: FixedMoney,
cost_basis: FixedMoney,
}
#[derive(Debug, Clone)]
pub struct Position {
pub symbol: String,
pub quantity: u32,
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
pub average_cost: f64,
pub last_price: f64,
pub realized_pnl: f64,
realized_entry_pnl: f64,
realized_pnl: FixedMoney,
realized_entry_pnl: FixedMoney,
pub trading_pnl: f64,
pub position_pnl: f64,
pub dividend_receivable: f64,
dividend_receivable: FixedMoney,
day_start_quantity: u32,
day_start_price: f64,
day_split_ratio: f64,
day_dividend_cash: f64,
day_dividend_cash: FixedMoney,
day_trade_quantity_delta: i32,
day_trade_cost: f64,
day_trade_cost: FixedMoney,
day_buy_quantity: u32,
day_sell_quantity: u32,
day_buy_value: f64,
day_sell_value: f64,
day_buy_value: FixedMoney,
day_sell_value: FixedMoney,
lots: Vec<PositionLot>,
}
@@ -45,21 +88,21 @@ impl Position {
quantity: 0,
average_cost: 0.0,
last_price: 0.0,
realized_pnl: 0.0,
realized_entry_pnl: 0.0,
realized_pnl: FixedMoney::ZERO,
realized_entry_pnl: FixedMoney::ZERO,
trading_pnl: 0.0,
position_pnl: 0.0,
dividend_receivable: 0.0,
dividend_receivable: FixedMoney::ZERO,
day_start_quantity: 0,
day_start_price: 0.0,
day_split_ratio: 1.0,
day_dividend_cash: 0.0,
day_dividend_cash: FixedMoney::ZERO,
day_trade_quantity_delta: 0,
day_trade_cost: 0.0,
day_trade_cost: FixedMoney::ZERO,
day_buy_quantity: 0,
day_sell_quantity: 0,
day_buy_value: 0.0,
day_sell_value: 0.0,
day_buy_value: FixedMoney::ZERO,
day_sell_value: FixedMoney::ZERO,
lots: Vec::new(),
}
}
@@ -85,17 +128,24 @@ impl Position {
let previous_quantity = self.quantity;
let previous_average_cost = self.average_cost;
let gross_amount = fixed_money_or_panic(
execution_price * quantity as f64,
"position buy gross amount",
);
self.lots.push(PositionLot {
acquired_date: date,
quantity,
entry_price: execution_price,
price: execution_price,
entry_value: gross_amount,
cost_basis: gross_amount,
});
self.quantity += quantity;
self.last_price = normalized_mark_price(mark_price, execution_price);
self.day_trade_quantity_delta += quantity as i32;
self.day_buy_quantity += quantity;
self.day_buy_value += execution_price * quantity as f64;
self.day_buy_value = self
.day_buy_value
.checked_add(gross_amount)
.expect("fixed-point day buy value overflow");
if previous_quantity > 0
&& previous_average_cost.is_finite()
&& previous_average_cost > 0.0
@@ -128,9 +178,14 @@ impl Position {
));
}
let total_proceeds = fixed_money(
execution_price * quantity as f64,
"position sell gross amount",
)?;
let mut remaining = quantity;
let mut realized = 0.0;
let mut realized_entry = 0.0;
let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO;
let mut realized_entry = FixedMoney::ZERO;
let average_cost_before_sell = self.average_cost;
while remaining > 0 {
@@ -138,9 +193,38 @@ impl Position {
return Err(format!("position {} has no lots to sell", self.symbol));
};
let lot_sell = remaining.min(first_lot.quantity);
realized += (execution_price - first_lot.price) * lot_sell as f64;
realized_entry += (execution_price - first_lot.entry_price) * lot_sell as f64;
let lot_quantity_before = first_lot.quantity;
let lot_sell = remaining.min(lot_quantity_before);
let lot_proceeds = allocate_fixed_value(remaining_proceeds, lot_sell, remaining)?;
let lot_cost =
allocate_fixed_value(first_lot.cost_basis, lot_sell, lot_quantity_before)?;
let lot_entry =
allocate_fixed_value(first_lot.entry_value, lot_sell, lot_quantity_before)?;
realized = realized
.checked_add(
lot_proceeds
.checked_sub(lot_cost)
.ok_or_else(|| "fixed-point realized PnL overflow".to_string())?,
)
.ok_or_else(|| "fixed-point realized PnL overflow".to_string())?;
realized_entry = realized_entry
.checked_add(
lot_proceeds
.checked_sub(lot_entry)
.ok_or_else(|| "fixed-point realized entry PnL overflow".to_string())?,
)
.ok_or_else(|| "fixed-point realized entry PnL overflow".to_string())?;
first_lot.cost_basis = first_lot
.cost_basis
.checked_sub(lot_cost)
.ok_or_else(|| "fixed-point lot cost underflow".to_string())?;
first_lot.entry_value = first_lot
.entry_value
.checked_sub(lot_entry)
.ok_or_else(|| "fixed-point lot entry underflow".to_string())?;
remaining_proceeds = remaining_proceeds
.checked_sub(lot_proceeds)
.ok_or_else(|| "fixed-point sell proceeds underflow".to_string())?;
first_lot.quantity -= lot_sell;
remaining -= lot_sell;
@@ -151,11 +235,20 @@ impl Position {
self.quantity -= quantity;
self.last_price = normalized_mark_price(mark_price, execution_price);
self.realized_pnl += realized;
self.realized_entry_pnl += realized_entry;
self.realized_pnl = self
.realized_pnl
.checked_add(realized)
.ok_or_else(|| "fixed-point realized PnL overflow".to_string())?;
self.realized_entry_pnl = self
.realized_entry_pnl
.checked_add(realized_entry)
.ok_or_else(|| "fixed-point realized entry PnL overflow".to_string())?;
self.day_trade_quantity_delta -= quantity as i32;
self.day_sell_quantity += quantity;
self.day_sell_value += execution_price * quantity as f64;
self.day_sell_value = self
.day_sell_value
.checked_add(total_proceeds)
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
if self.quantity == 0 {
self.recalculate_average_cost();
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
@@ -164,7 +257,7 @@ impl Position {
self.recalculate_average_cost();
}
self.refresh_day_pnl();
Ok(realized)
Ok(realized.to_f64())
}
pub fn sellable_qty(&self, date: NaiveDate) -> u32 {
@@ -180,22 +273,49 @@ impl Position {
}
pub fn unrealized_pnl(&self) -> f64 {
(self.last_price - self.average_cost) * self.quantity as f64
if self.quantity == 0 {
return 0.0;
}
fixed_money_or_panic(
self.last_price * self.quantity as f64,
"position marked value",
)
.checked_sub(self.total_cost_basis())
.expect("fixed-point unrealized PnL overflow")
.to_f64()
}
pub fn unrealized_entry_pnl(&self) -> f64 {
let Some(avg_price) = self.average_entry_price() else {
if self.quantity == 0 {
return 0.0;
};
(self.last_price - avg_price) * self.quantity as f64
}
fixed_money_or_panic(
self.last_price * self.quantity as f64,
"position marked value",
)
.checked_sub(self.total_entry_value())
.expect("fixed-point unrealized entry PnL overflow")
.to_f64()
}
pub fn pnl(&self) -> f64 {
self.realized_pnl + self.unrealized_pnl()
self.realized_pnl.to_f64() + self.unrealized_pnl()
}
pub fn entry_pnl(&self) -> f64 {
self.realized_entry_pnl + self.unrealized_entry_pnl()
self.realized_entry_pnl.to_f64() + self.unrealized_entry_pnl()
}
pub fn realized_pnl(&self) -> f64 {
self.realized_pnl.to_f64()
}
pub fn realized_entry_pnl(&self) -> f64 {
self.realized_entry_pnl.to_f64()
}
pub fn dividend_receivable(&self) -> f64 {
self.dividend_receivable.to_f64()
}
pub fn day_start_quantity(&self) -> u32 {
@@ -215,18 +335,18 @@ impl Position {
}
pub fn bought_value(&self) -> f64 {
self.day_buy_value
self.day_buy_value.to_f64()
}
pub fn sold_value(&self) -> f64 {
self.day_sell_value
self.day_sell_value.to_f64()
}
pub fn buy_avg_price(&self) -> f64 {
if self.day_buy_quantity == 0 {
0.0
} else {
self.day_buy_value / self.day_buy_quantity as f64
self.day_buy_value.to_f64() / self.day_buy_quantity as f64
}
}
@@ -234,31 +354,34 @@ impl Position {
if self.day_sell_quantity == 0 {
0.0
} else {
self.day_sell_value / self.day_sell_quantity as f64
self.day_sell_value.to_f64() / self.day_sell_quantity as f64
}
}
pub fn transaction_cost(&self) -> f64 {
self.day_trade_cost
self.day_trade_cost.to_f64()
}
pub fn begin_trading_day(&mut self) {
self.day_start_quantity = self.quantity;
self.day_start_price = self.last_price;
self.day_split_ratio = 1.0;
self.day_dividend_cash = 0.0;
self.day_dividend_cash = FixedMoney::ZERO;
self.day_trade_quantity_delta = 0;
self.day_trade_cost = 0.0;
self.day_trade_cost = FixedMoney::ZERO;
self.day_buy_quantity = 0;
self.day_sell_quantity = 0;
self.day_buy_value = 0.0;
self.day_sell_value = 0.0;
self.day_buy_value = FixedMoney::ZERO;
self.day_sell_value = FixedMoney::ZERO;
self.refresh_day_pnl();
}
pub fn record_trade_cost(&mut self, value: f64) {
if value.is_finite() {
self.day_trade_cost += value.max(0.0);
self.day_trade_cost = self
.day_trade_cost
.checked_add(fixed_money_or_panic(value.max(0.0), "position trade cost"))
.expect("fixed-point day trade cost overflow");
self.refresh_day_pnl();
}
}
@@ -267,27 +390,33 @@ impl Position {
if quantity == 0 || !value.is_finite() {
return;
}
let cost = value.max(0.0);
if cost <= 0.0 {
let cost = fixed_money_or_panic(value.max(0.0), "position buy trade cost");
if cost.raw() <= 0 {
return;
}
if let Some(lot) = self.lots.last_mut() {
lot.price += cost / quantity as f64;
lot.cost_basis = lot
.cost_basis
.checked_add(cost)
.expect("fixed-point lot cost overflow");
if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost += cost / self.quantity as f64;
self.average_cost += cost.to_f64() / self.quantity as f64;
} else {
self.recalculate_average_cost();
}
}
self.day_trade_cost += cost;
self.day_trade_cost = self
.day_trade_cost
.checked_add(cost)
.expect("fixed-point day trade cost overflow");
self.refresh_day_pnl();
}
pub fn set_dividend_receivable(&mut self, value: f64) {
self.dividend_receivable = if value.is_finite() {
value.max(0.0)
fixed_money_or_panic(value.max(0.0), "position dividend receivable")
} else {
0.0
FixedMoney::ZERO
};
}
@@ -306,12 +435,7 @@ impl Position {
if self.quantity == 0 {
return None;
}
let total = self
.lots
.iter()
.map(|lot| lot.entry_price * lot.quantity as f64)
.sum::<f64>();
Some(total / self.quantity as f64)
Some(self.total_entry_value().to_f64() / self.quantity as f64)
}
fn recalculate_average_cost(&mut self) {
@@ -320,13 +444,23 @@ impl Position {
return;
}
let total_cost = self
.lots
.iter()
.map(|lot| lot.price * lot.quantity as f64)
.sum::<f64>();
self.average_cost = self.total_cost_basis().to_f64() / self.quantity as f64;
}
self.average_cost = total_cost / self.quantity as f64;
fn total_entry_value(&self) -> FixedMoney {
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
total
.checked_add(lot.entry_value)
.expect("fixed-point position entry value overflow")
})
}
fn total_cost_basis(&self) -> FixedMoney {
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
total
.checked_add(lot.cost_basis)
.expect("fixed-point position cost basis overflow")
})
}
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
@@ -346,20 +480,36 @@ impl Position {
return 0.0;
}
let mut cash_delta = FixedMoney::ZERO;
for lot in &mut self.lots {
lot.entry_price -= dividend_per_share;
let lot_dividend = fixed_money_or_panic(
dividend_per_share * lot.quantity as f64,
"position cash dividend",
);
lot.entry_value = lot
.entry_value
.checked_sub(lot_dividend)
.expect("fixed-point lot entry dividend adjustment overflow");
if adjust_cost_basis {
lot.price -= dividend_per_share;
lot.cost_basis = lot
.cost_basis
.checked_sub(lot_dividend)
.expect("fixed-point lot cost dividend adjustment overflow");
}
cash_delta = cash_delta
.checked_add(lot_dividend)
.expect("fixed-point cash dividend overflow");
}
if adjust_cost_basis {
self.average_cost -= dividend_per_share;
}
self.last_price -= dividend_per_share;
let cash_delta = self.quantity as f64 * dividend_per_share;
self.day_dividend_cash += cash_delta;
self.day_dividend_cash = self
.day_dividend_cash
.checked_add(cash_delta)
.expect("fixed-point day dividend cash overflow");
self.refresh_day_pnl();
cash_delta
cash_delta.to_f64()
}
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
@@ -374,8 +524,8 @@ impl Position {
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
entry_price: lot.entry_price / ratio,
price: lot.price / ratio,
entry_value: lot.entry_value,
cost_basis: lot.cost_basis,
})
.collect::<Vec<_>>();
@@ -410,13 +560,38 @@ impl Position {
self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 {
0.0
} else {
fixed_money_or_panic(
adjusted_old_quantity
* (self.last_price - (self.day_start_price / self.day_split_ratio))
+ self.day_dividend_cash
* (self.last_price - (self.day_start_price / self.day_split_ratio)),
"position daily mark PnL",
)
.checked_add(self.day_dividend_cash)
.expect("fixed-point position daily PnL overflow")
.to_f64()
};
self.trading_pnl = (self.day_buy_quantity as f64 * self.last_price - self.day_buy_value)
+ (self.day_sell_value - self.day_sell_quantity as f64 * self.last_price)
- self.day_trade_cost;
let buy_mark = if self.day_buy_quantity == 0 {
FixedMoney::ZERO
} else {
fixed_money_or_panic(
self.day_buy_quantity as f64 * self.last_price,
"position day buy mark value",
)
};
let sell_mark = if self.day_sell_quantity == 0 {
FixedMoney::ZERO
} else {
fixed_money_or_panic(
self.day_sell_quantity as f64 * self.last_price,
"position day sell mark value",
)
};
self.trading_pnl = buy_mark
.checked_sub(self.day_buy_value)
.and_then(|value| value.checked_add(self.day_sell_value))
.and_then(|value| value.checked_sub(sell_mark))
.and_then(|value| value.checked_sub(self.day_trade_cost))
.expect("fixed-point position trading PnL overflow")
.to_f64();
}
}
@@ -467,7 +642,7 @@ pub(crate) struct SuccessorConversionOutcome {
impl PortfolioState {
pub fn new(initial_cash: f64) -> Self {
let initial_cash = Self::fixed_money(initial_cash, "initial cash")
let initial_cash = fixed_money(initial_cash, "initial cash")
.expect("initial cash must be finite fixed-point money");
Self {
initial_cash,
@@ -539,7 +714,7 @@ impl PortfolioState {
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
self.cash = self
.cash
.checked_add(Self::fixed_money(delta, "cash delta")?)
.checked_add(fixed_money(delta, "cash delta")?)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
Ok(())
}
@@ -565,9 +740,9 @@ impl PortfolioState {
}
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> {
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
let amount_money = fixed_money(amount, "deposit_withdraw amount")?;
let pending_withdrawal =
Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
let available_cash = self
.cash
.checked_sub(pending_withdrawal)
@@ -602,9 +777,9 @@ impl PortfolioState {
amount: f64,
reason: impl Into<String>,
) -> Result<(), String> {
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
let amount_money = fixed_money(amount, "deposit_withdraw amount")?;
let pending_withdrawal =
Self::fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
let available_cash = self
.cash
.checked_sub(pending_withdrawal)
@@ -685,7 +860,7 @@ impl PortfolioState {
let mut settled = Vec::with_capacity(due.len());
for flow in due {
let unit_net_value = self.unit_net_value();
let amount = Self::fixed_money(flow.amount, "scheduled cash flow")?;
let amount = fixed_money(flow.amount, "scheduled cash flow")?;
self.cash = self
.cash
.checked_add(amount)
@@ -714,7 +889,7 @@ impl PortfolioState {
}
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
let amount_money = Self::fixed_money(amount, "finance_repay amount")?;
let amount_money = fixed_money(amount, "finance_repay amount")?;
if amount_money.raw() > 0 {
self.cash_liabilities = self
.cash_liabilities
@@ -763,7 +938,7 @@ impl PortfolioState {
}
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> {
let fee_money = Self::fixed_money(fee, "management fee")?;
let fee_money = fixed_money(fee, "management fee")?;
if fee_money.raw() < 0 {
return Err("management fee must be finite and non-negative".to_string());
}
@@ -969,11 +1144,11 @@ impl PortfolioState {
0.0
},
unrealized_pnl: position.unrealized_entry_pnl(),
realized_pnl: position.realized_entry_pnl,
realized_pnl: position.realized_entry_pnl(),
pnl: position.entry_pnl(),
trading_pnl: position.trading_pnl,
position_pnl: position.position_pnl,
dividend_receivable: position.dividend_receivable,
dividend_receivable: position.dividend_receivable(),
old_quantity: position.day_start_quantity(),
bought_quantity: position.bought_quantity(),
sold_quantity: position.sold_quantity(),
@@ -1015,8 +1190,8 @@ impl PortfolioState {
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
entry_price: lot.entry_price / ratio,
price: lot.price / ratio,
entry_value: lot.entry_value,
cost_basis: lot.cost_basis,
})
.collect::<Vec<_>>();
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
@@ -1046,8 +1221,14 @@ impl PortfolioState {
let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
successor.realized_pnl += realized_pnl;
successor.realized_entry_pnl += realized_entry_pnl;
successor.realized_pnl = successor
.realized_pnl
.checked_add(realized_pnl)
.expect("fixed-point successor realized PnL overflow");
successor.realized_entry_pnl = successor
.realized_entry_pnl
.checked_add(realized_entry_pnl)
.expect("fixed-point successor realized entry PnL overflow");
if converted_last_price > 0.0 {
successor.last_price = converted_last_price;
}
@@ -1092,11 +1273,6 @@ impl PortfolioState {
})
}
fn fixed_money(value: f64, label: &str) -> Result<FixedMoney, String> {
FixedMoney::from_f64(value)
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
}
fn sum_fixed_money(
values: impl IntoIterator<Item = f64>,
label: &str,
@@ -1105,7 +1281,7 @@ impl PortfolioState {
.into_iter()
.try_fold(FixedMoney::ZERO, |total, value| {
total
.checked_add(Self::fixed_money(value, label)?)
.checked_add(fixed_money(value, label)?)
.ok_or_else(|| format!("fixed-point {label} overflow"))
})
}
@@ -1125,8 +1301,8 @@ impl PortfolioState {
unit_net_value_before: f64,
) -> Result<(), String> {
if unit_net_value_before > 0.0 && unit_net_value_before.is_finite() {
let unit_nav = Self::fixed_money(unit_net_value_before, "unit net value")?;
let total_equity = Self::fixed_money(self.total_equity(), "total equity")?;
let unit_nav = fixed_money(unit_net_value_before, "unit net value")?;
let total_equity = fixed_money(self.total_equity(), "total equity")?;
let units_raw = total_equity
.raw()
.checked_mul(MONEY_SCALE)
@@ -1164,6 +1340,48 @@ mod tests {
assert_eq!(portfolio.cash(), 1_000_000.0);
}
#[test]
fn fixed_lot_allocation_rounds_nearest_and_conserves_total() {
let total = FixedMoney::from_raw(5);
let allocated = allocate_fixed_value(total, 1, 2).expect("positive allocation");
let remainder = total.checked_sub(allocated).expect("positive remainder");
assert_eq!(allocated.raw(), 3);
assert_eq!(remainder.raw(), 2);
assert_eq!(allocated.checked_add(remainder), Some(total));
let negative_total = FixedMoney::from_raw(-5);
let negative_allocated =
allocate_fixed_value(negative_total, 1, 2).expect("negative allocation");
let negative_remainder = negative_total
.checked_sub(negative_allocated)
.expect("negative remainder");
assert_eq!(negative_allocated.raw(), -3);
assert_eq!(negative_remainder.raw(), -2);
assert_eq!(
negative_allocated.checked_add(negative_remainder),
Some(negative_total)
);
}
#[test]
fn fifo_fixed_pnl_conserves_value_while_alv_average_cost_stays_stable() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("000001.SZ");
position.buy(date, 100, 10.0);
position.record_buy_trade_cost(100, 5.0);
position.buy(date, 100, 5.0);
position.record_buy_trade_cost(100, 5.0);
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
assert_eq!(position.quantity, 100);
assert!((position.average_cost - 7.55).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
assert!((realized + 405.0).abs() < 1e-12);
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
assert!((position.pnl() + 310.0).abs() < 1e-12);
}
#[test]
fn positions_preserve_insertion_order() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
@@ -1471,7 +1689,7 @@ mod tests {
.expect("close");
let position = portfolio.position("000001.SZ").expect("position");
assert!((position.dividend_receivable - 25.0).abs() < 1e-6);
assert!((position.dividend_receivable() - 25.0).abs() < 1e-6);
assert!((position.position_pnl - 70.0).abs() < 1e-6);
assert!((position.trading_pnl + 5.0).abs() < 1e-6);
}
@@ -1702,7 +1920,7 @@ mod tests {
assert!((position.sell_avg_price() - 12.0).abs() < 1e-6);
assert!((position.transaction_cost() - 3.0).abs() < 1e-6);
assert!((realized - 80.0).abs() < 1e-6);
assert!((position.realized_pnl - 80.0).abs() < 1e-6);
assert!((position.realized_pnl() - 80.0).abs() < 1e-6);
assert!((position.position_pnl - 200.0).abs() < 1e-6);
assert!((position.trading_pnl - 47.0).abs() < 1e-6);
assert!((position.pnl() - (80.0 + position.unrealized_pnl())).abs() < 1e-6);