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

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, equity: market_value,
value_percent, value_percent,
unrealized_pnl: position.unrealized_pnl(), unrealized_pnl: position.unrealized_pnl(),
realized_pnl: position.realized_pnl, realized_pnl: position.realized_pnl(),
pnl: position.pnl(), pnl: position.pnl(),
day_trade_quantity_delta: position.day_trade_quantity_delta() as i64, day_trade_quantity_delta: position.day_trade_quantity_delta() as i64,
trading_pnl: position.trading_pnl, trading_pnl: position.trading_pnl,
position_pnl: position.position_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() { let stop_hit = if self.config.stop_loss_expr.trim().is_empty() {
false false
+318 -100
View File
@@ -6,35 +6,78 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::data::{DataSet, DataSetError, PriceField}; use crate::data::{DataSet, DataSetError, PriceField};
use crate::fixed_point::{FixedMoney, MONEY_SCALE}; 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)] #[derive(Debug, Clone)]
pub struct PositionLot { pub struct PositionLot {
pub acquired_date: NaiveDate, pub acquired_date: NaiveDate,
pub quantity: u32, pub quantity: u32,
pub entry_price: f64, // Total values keep partial-lot allocation exact without a rounded per-share mirror.
pub price: f64, entry_value: FixedMoney,
cost_basis: FixedMoney,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Position { pub struct Position {
pub symbol: String, pub symbol: String,
pub quantity: u32, pub quantity: u32,
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
pub average_cost: f64, pub average_cost: f64,
pub last_price: f64, pub last_price: f64,
pub realized_pnl: f64, realized_pnl: FixedMoney,
realized_entry_pnl: f64, realized_entry_pnl: FixedMoney,
pub trading_pnl: f64, pub trading_pnl: f64,
pub position_pnl: f64, pub position_pnl: f64,
pub dividend_receivable: f64, dividend_receivable: FixedMoney,
day_start_quantity: u32, day_start_quantity: u32,
day_start_price: f64, day_start_price: f64,
day_split_ratio: f64, day_split_ratio: f64,
day_dividend_cash: f64, day_dividend_cash: FixedMoney,
day_trade_quantity_delta: i32, day_trade_quantity_delta: i32,
day_trade_cost: f64, day_trade_cost: FixedMoney,
day_buy_quantity: u32, day_buy_quantity: u32,
day_sell_quantity: u32, day_sell_quantity: u32,
day_buy_value: f64, day_buy_value: FixedMoney,
day_sell_value: f64, day_sell_value: FixedMoney,
lots: Vec<PositionLot>, lots: Vec<PositionLot>,
} }
@@ -45,21 +88,21 @@ impl Position {
quantity: 0, quantity: 0,
average_cost: 0.0, average_cost: 0.0,
last_price: 0.0, last_price: 0.0,
realized_pnl: 0.0, realized_pnl: FixedMoney::ZERO,
realized_entry_pnl: 0.0, realized_entry_pnl: FixedMoney::ZERO,
trading_pnl: 0.0, trading_pnl: 0.0,
position_pnl: 0.0, position_pnl: 0.0,
dividend_receivable: 0.0, dividend_receivable: FixedMoney::ZERO,
day_start_quantity: 0, day_start_quantity: 0,
day_start_price: 0.0, day_start_price: 0.0,
day_split_ratio: 1.0, day_split_ratio: 1.0,
day_dividend_cash: 0.0, day_dividend_cash: FixedMoney::ZERO,
day_trade_quantity_delta: 0, day_trade_quantity_delta: 0,
day_trade_cost: 0.0, day_trade_cost: FixedMoney::ZERO,
day_buy_quantity: 0, day_buy_quantity: 0,
day_sell_quantity: 0, day_sell_quantity: 0,
day_buy_value: 0.0, day_buy_value: FixedMoney::ZERO,
day_sell_value: 0.0, day_sell_value: FixedMoney::ZERO,
lots: Vec::new(), lots: Vec::new(),
} }
} }
@@ -85,17 +128,24 @@ impl Position {
let previous_quantity = self.quantity; let previous_quantity = self.quantity;
let previous_average_cost = self.average_cost; 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 { self.lots.push(PositionLot {
acquired_date: date, acquired_date: date,
quantity, quantity,
entry_price: execution_price, entry_value: gross_amount,
price: execution_price, cost_basis: gross_amount,
}); });
self.quantity += quantity; self.quantity += quantity;
self.last_price = normalized_mark_price(mark_price, execution_price); self.last_price = normalized_mark_price(mark_price, execution_price);
self.day_trade_quantity_delta += quantity as i32; self.day_trade_quantity_delta += quantity as i32;
self.day_buy_quantity += quantity; 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 if previous_quantity > 0
&& previous_average_cost.is_finite() && previous_average_cost.is_finite()
&& previous_average_cost > 0.0 && 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 remaining = quantity;
let mut realized = 0.0; let mut remaining_proceeds = total_proceeds;
let mut realized_entry = 0.0; let mut realized = FixedMoney::ZERO;
let mut realized_entry = FixedMoney::ZERO;
let average_cost_before_sell = self.average_cost; let average_cost_before_sell = self.average_cost;
while remaining > 0 { while remaining > 0 {
@@ -138,9 +193,38 @@ impl Position {
return Err(format!("position {} has no lots to sell", self.symbol)); return Err(format!("position {} has no lots to sell", self.symbol));
}; };
let lot_sell = remaining.min(first_lot.quantity); let lot_quantity_before = first_lot.quantity;
realized += (execution_price - first_lot.price) * lot_sell as f64; let lot_sell = remaining.min(lot_quantity_before);
realized_entry += (execution_price - first_lot.entry_price) * lot_sell as f64; 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; first_lot.quantity -= lot_sell;
remaining -= lot_sell; remaining -= lot_sell;
@@ -151,11 +235,20 @@ impl Position {
self.quantity -= quantity; self.quantity -= quantity;
self.last_price = normalized_mark_price(mark_price, execution_price); self.last_price = normalized_mark_price(mark_price, execution_price);
self.realized_pnl += realized; self.realized_pnl = self
self.realized_entry_pnl += realized_entry; .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_trade_quantity_delta -= quantity as i32;
self.day_sell_quantity += quantity; 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 { if self.quantity == 0 {
self.recalculate_average_cost(); self.recalculate_average_cost();
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 { } 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.recalculate_average_cost();
} }
self.refresh_day_pnl(); self.refresh_day_pnl();
Ok(realized) Ok(realized.to_f64())
} }
pub fn sellable_qty(&self, date: NaiveDate) -> u32 { pub fn sellable_qty(&self, date: NaiveDate) -> u32 {
@@ -180,22 +273,49 @@ impl Position {
} }
pub fn unrealized_pnl(&self) -> f64 { 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 { pub fn unrealized_entry_pnl(&self) -> f64 {
let Some(avg_price) = self.average_entry_price() else { if self.quantity == 0 {
return 0.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 { 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 { 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 { pub fn day_start_quantity(&self) -> u32 {
@@ -215,18 +335,18 @@ impl Position {
} }
pub fn bought_value(&self) -> f64 { pub fn bought_value(&self) -> f64 {
self.day_buy_value self.day_buy_value.to_f64()
} }
pub fn sold_value(&self) -> f64 { pub fn sold_value(&self) -> f64 {
self.day_sell_value self.day_sell_value.to_f64()
} }
pub fn buy_avg_price(&self) -> f64 { pub fn buy_avg_price(&self) -> f64 {
if self.day_buy_quantity == 0 { if self.day_buy_quantity == 0 {
0.0 0.0
} else { } 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 { if self.day_sell_quantity == 0 {
0.0 0.0
} else { } 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 { pub fn transaction_cost(&self) -> f64 {
self.day_trade_cost self.day_trade_cost.to_f64()
} }
pub fn begin_trading_day(&mut self) { pub fn begin_trading_day(&mut self) {
self.day_start_quantity = self.quantity; self.day_start_quantity = self.quantity;
self.day_start_price = self.last_price; self.day_start_price = self.last_price;
self.day_split_ratio = 1.0; 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_quantity_delta = 0;
self.day_trade_cost = 0.0; self.day_trade_cost = FixedMoney::ZERO;
self.day_buy_quantity = 0; self.day_buy_quantity = 0;
self.day_sell_quantity = 0; self.day_sell_quantity = 0;
self.day_buy_value = 0.0; self.day_buy_value = FixedMoney::ZERO;
self.day_sell_value = 0.0; self.day_sell_value = FixedMoney::ZERO;
self.refresh_day_pnl(); self.refresh_day_pnl();
} }
pub fn record_trade_cost(&mut self, value: f64) { pub fn record_trade_cost(&mut self, value: f64) {
if value.is_finite() { 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(); self.refresh_day_pnl();
} }
} }
@@ -267,27 +390,33 @@ impl Position {
if quantity == 0 || !value.is_finite() { if quantity == 0 || !value.is_finite() {
return; return;
} }
let cost = value.max(0.0); let cost = fixed_money_or_panic(value.max(0.0), "position buy trade cost");
if cost <= 0.0 { if cost.raw() <= 0 {
return; return;
} }
if let Some(lot) = self.lots.last_mut() { 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 { 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 { } else {
self.recalculate_average_cost(); 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(); self.refresh_day_pnl();
} }
pub fn set_dividend_receivable(&mut self, value: f64) { pub fn set_dividend_receivable(&mut self, value: f64) {
self.dividend_receivable = if value.is_finite() { self.dividend_receivable = if value.is_finite() {
value.max(0.0) fixed_money_or_panic(value.max(0.0), "position dividend receivable")
} else { } else {
0.0 FixedMoney::ZERO
}; };
} }
@@ -306,12 +435,7 @@ impl Position {
if self.quantity == 0 { if self.quantity == 0 {
return None; return None;
} }
let total = self Some(self.total_entry_value().to_f64() / self.quantity as f64)
.lots
.iter()
.map(|lot| lot.entry_price * lot.quantity as f64)
.sum::<f64>();
Some(total / self.quantity as f64)
} }
fn recalculate_average_cost(&mut self) { fn recalculate_average_cost(&mut self) {
@@ -320,13 +444,23 @@ impl Position {
return; return;
} }
let total_cost = self self.average_cost = self.total_cost_basis().to_f64() / self.quantity as f64;
.lots }
.iter()
.map(|lot| lot.price * lot.quantity as f64)
.sum::<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 { pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
@@ -346,20 +480,36 @@ impl Position {
return 0.0; return 0.0;
} }
let mut cash_delta = FixedMoney::ZERO;
for lot in &mut self.lots { 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 { 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 { if adjust_cost_basis {
self.average_cost -= dividend_per_share; self.average_cost -= dividend_per_share;
} }
self.last_price -= dividend_per_share; self.last_price -= dividend_per_share;
let cash_delta = self.quantity as f64 * dividend_per_share; self.day_dividend_cash = self
self.day_dividend_cash += cash_delta; .day_dividend_cash
.checked_add(cash_delta)
.expect("fixed-point day dividend cash overflow");
self.refresh_day_pnl(); self.refresh_day_pnl();
cash_delta cash_delta.to_f64()
} }
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 { pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
@@ -374,8 +524,8 @@ impl Position {
.map(|lot| PositionLot { .map(|lot| PositionLot {
acquired_date: lot.acquired_date, acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio), quantity: round_half_up_u32(lot.quantity as f64 * ratio),
entry_price: lot.entry_price / ratio, entry_value: lot.entry_value,
price: lot.price / ratio, cost_basis: lot.cost_basis,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -410,13 +560,38 @@ impl Position {
self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 { self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 {
0.0 0.0
} else { } else {
fixed_money_or_panic(
adjusted_old_quantity adjusted_old_quantity
* (self.last_price - (self.day_start_price / self.day_split_ratio)) * (self.last_price - (self.day_start_price / self.day_split_ratio)),
+ self.day_dividend_cash "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) let buy_mark = if self.day_buy_quantity == 0 {
+ (self.day_sell_value - self.day_sell_quantity as f64 * self.last_price) FixedMoney::ZERO
- self.day_trade_cost; } 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 { impl PortfolioState {
pub fn new(initial_cash: f64) -> Self { 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"); .expect("initial cash must be finite fixed-point money");
Self { Self {
initial_cash, initial_cash,
@@ -539,7 +714,7 @@ impl PortfolioState {
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> { pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
self.cash = self self.cash = self
.cash .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_or_else(|| "fixed-point cash overflow".to_string())?;
Ok(()) Ok(())
} }
@@ -565,9 +740,9 @@ impl PortfolioState {
} }
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> { 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 = 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 let available_cash = self
.cash .cash
.checked_sub(pending_withdrawal) .checked_sub(pending_withdrawal)
@@ -602,9 +777,9 @@ impl PortfolioState {
amount: f64, amount: f64,
reason: impl Into<String>, reason: impl Into<String>,
) -> Result<(), 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 = 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 let available_cash = self
.cash .cash
.checked_sub(pending_withdrawal) .checked_sub(pending_withdrawal)
@@ -685,7 +860,7 @@ impl PortfolioState {
let mut settled = Vec::with_capacity(due.len()); let mut settled = Vec::with_capacity(due.len());
for flow in due { for flow in due {
let unit_net_value = self.unit_net_value(); 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 self.cash = self
.cash .cash
.checked_add(amount) .checked_add(amount)
@@ -714,7 +889,7 @@ impl PortfolioState {
} }
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> { 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 { if amount_money.raw() > 0 {
self.cash_liabilities = self self.cash_liabilities = self
.cash_liabilities .cash_liabilities
@@ -763,7 +938,7 @@ impl PortfolioState {
} }
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> { 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 { if fee_money.raw() < 0 {
return Err("management fee must be finite and non-negative".to_string()); return Err("management fee must be finite and non-negative".to_string());
} }
@@ -969,11 +1144,11 @@ impl PortfolioState {
0.0 0.0
}, },
unrealized_pnl: position.unrealized_entry_pnl(), unrealized_pnl: position.unrealized_entry_pnl(),
realized_pnl: position.realized_entry_pnl, realized_pnl: position.realized_entry_pnl(),
pnl: position.entry_pnl(), pnl: position.entry_pnl(),
trading_pnl: position.trading_pnl, trading_pnl: position.trading_pnl,
position_pnl: position.position_pnl, position_pnl: position.position_pnl,
dividend_receivable: position.dividend_receivable, dividend_receivable: position.dividend_receivable(),
old_quantity: position.day_start_quantity(), old_quantity: position.day_start_quantity(),
bought_quantity: position.bought_quantity(), bought_quantity: position.bought_quantity(),
sold_quantity: position.sold_quantity(), sold_quantity: position.sold_quantity(),
@@ -1015,8 +1190,8 @@ impl PortfolioState {
.map(|lot| PositionLot { .map(|lot| PositionLot {
acquired_date: lot.acquired_date, acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio), quantity: round_half_up_u32(lot.quantity as f64 * ratio),
entry_price: lot.entry_price / ratio, entry_value: lot.entry_value,
price: lot.price / ratio, cost_basis: lot.cost_basis,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let expected_total = round_half_up_u32(old_quantity as f64 * ratio); 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; let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots); successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
successor.realized_pnl += realized_pnl; successor.realized_pnl = successor
successor.realized_entry_pnl += realized_entry_pnl; .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 { if converted_last_price > 0.0 {
successor.last_price = converted_last_price; 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( fn sum_fixed_money(
values: impl IntoIterator<Item = f64>, values: impl IntoIterator<Item = f64>,
label: &str, label: &str,
@@ -1105,7 +1281,7 @@ impl PortfolioState {
.into_iter() .into_iter()
.try_fold(FixedMoney::ZERO, |total, value| { .try_fold(FixedMoney::ZERO, |total, value| {
total total
.checked_add(Self::fixed_money(value, label)?) .checked_add(fixed_money(value, label)?)
.ok_or_else(|| format!("fixed-point {label} overflow")) .ok_or_else(|| format!("fixed-point {label} overflow"))
}) })
} }
@@ -1125,8 +1301,8 @@ impl PortfolioState {
unit_net_value_before: f64, unit_net_value_before: f64,
) -> Result<(), String> { ) -> Result<(), String> {
if unit_net_value_before > 0.0 && unit_net_value_before.is_finite() { 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 unit_nav = fixed_money(unit_net_value_before, "unit net value")?;
let total_equity = Self::fixed_money(self.total_equity(), "total equity")?; let total_equity = fixed_money(self.total_equity(), "total equity")?;
let units_raw = total_equity let units_raw = total_equity
.raw() .raw()
.checked_mul(MONEY_SCALE) .checked_mul(MONEY_SCALE)
@@ -1164,6 +1340,48 @@ mod tests {
assert_eq!(portfolio.cash(), 1_000_000.0); 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] #[test]
fn positions_preserve_insertion_order() { fn positions_preserve_insertion_order() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
@@ -1471,7 +1689,7 @@ mod tests {
.expect("close"); .expect("close");
let position = portfolio.position("000001.SZ").expect("position"); 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.position_pnl - 70.0).abs() < 1e-6);
assert!((position.trading_pnl + 5.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.sell_avg_price() - 12.0).abs() < 1e-6);
assert!((position.transaction_cost() - 3.0).abs() < 1e-6); assert!((position.transaction_cost() - 3.0).abs() < 1e-6);
assert!((realized - 80.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.position_pnl - 200.0).abs() < 1e-6);
assert!((position.trading_pnl - 47.0).abs() < 1e-6); assert!((position.trading_pnl - 47.0).abs() < 1e-6);
assert!((position.pnl() - (80.0 + position.unrealized_pnl())).abs() < 1e-6); assert!((position.pnl() - (80.0 + position.unrealized_pnl())).abs() < 1e-6);