From 2b94d5148fdd3595f3a80c859d8a1c15f8926e67 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 25 Aug 2026 15:59:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E8=82=A1=E7=A5=A8=E6=8C=81=E4=BB=93?= =?UTF-8?q?=E7=9B=88=E4=BA=8F=E5=88=87=E6=8D=A2=E4=B8=BA=E5=AE=9A=E7=82=B9?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E8=B4=A6=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 4 +- crates/fidc-core/src/portfolio.rs | 420 +++++++++++++----- 2 files changed, 321 insertions(+), 103 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 6eef6f4..ac823e2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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 diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index a36447f..1d828e7 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -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::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 { + 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, } @@ -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::(); - 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::(); + 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::>(); @@ -410,13 +560,38 @@ impl Position { self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 { 0.0 } else { - adjusted_old_quantity - * (self.last_price - (self.day_start_price / self.day_split_ratio)) - + self.day_dividend_cash + fixed_money_or_panic( + adjusted_old_quantity + * (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, ) -> 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::>(); 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::from_f64(value) - .ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}")) - } - fn sum_fixed_money( values: impl IntoIterator, 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);