Files
fidc-backtest-engine/crates/fidc-core/src/portfolio.rs
T
2026-09-07 17:53:36 +08:00

2169 lines
78 KiB
Rust

use chrono::NaiveDate;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
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,
// 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 execution price; partial sells do not rebase it.
pub average_price: f64,
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
pub average_cost: f64,
pub last_price: f64,
realized_pnl: FixedMoney,
realized_entry_pnl: FixedMoney,
pub trading_pnl: f64,
pub position_pnl: f64,
dividend_receivable: FixedMoney,
day_start_quantity: u32,
day_start_price: f64,
day_split_ratio: f64,
day_dividend_cash: FixedMoney,
day_trade_quantity_delta: i32,
day_trade_cost: FixedMoney,
day_buy_quantity: u32,
day_sell_quantity: u32,
day_buy_value: FixedMoney,
day_sell_value: FixedMoney,
lots: Vec<PositionLot>,
}
impl Position {
pub fn new(symbol: impl Into<String>) -> Self {
Self {
symbol: symbol.into(),
quantity: 0,
average_price: 0.0,
average_cost: 0.0,
last_price: 0.0,
realized_pnl: FixedMoney::ZERO,
realized_entry_pnl: FixedMoney::ZERO,
trading_pnl: 0.0,
position_pnl: 0.0,
dividend_receivable: FixedMoney::ZERO,
day_start_quantity: 0,
day_start_price: 0.0,
day_split_ratio: 1.0,
day_dividend_cash: FixedMoney::ZERO,
day_trade_quantity_delta: 0,
day_trade_cost: FixedMoney::ZERO,
day_buy_quantity: 0,
day_sell_quantity: 0,
day_buy_value: FixedMoney::ZERO,
day_sell_value: FixedMoney::ZERO,
lots: Vec::new(),
}
}
pub fn is_flat(&self) -> bool {
self.quantity == 0
}
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
self.buy_with_mark_price(date, quantity, price, price);
}
pub fn buy_with_mark_price(
&mut self,
date: NaiveDate,
quantity: u32,
execution_price: f64,
mark_price: f64,
) {
if quantity == 0 {
return;
}
let previous_quantity = self.quantity;
let previous_average_price = self.average_price;
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_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 = self
.day_buy_value
.checked_add(gross_amount)
.expect("fixed-point day buy value overflow");
if previous_quantity > 0
&& previous_average_price.is_finite()
&& previous_average_price > 0.0
&& execution_price.is_finite()
&& execution_price > 0.0
{
self.average_price = (previous_average_price * previous_quantity as f64
+ execution_price * quantity as f64)
/ self.quantity as f64;
} else {
self.average_price = execution_price;
}
if previous_quantity > 0
&& previous_average_cost.is_finite()
&& previous_average_cost > 0.0
&& execution_price.is_finite()
&& execution_price > 0.0
{
self.average_cost = (previous_average_cost * previous_quantity as f64
+ execution_price * quantity as f64)
/ self.quantity as f64;
} else {
self.recalculate_average_cost();
}
self.refresh_day_pnl();
}
pub fn sell(&mut self, quantity: u32, price: f64) -> Result<f64, String> {
self.sell_with_mark_price(quantity, price, price)
}
pub fn sell_with_mark_price(
&mut self,
quantity: u32,
execution_price: f64,
mark_price: f64,
) -> Result<f64, String> {
if quantity > self.quantity {
return Err(format!(
"sell quantity {} exceeds current quantity {} for {}",
quantity, self.quantity, self.symbol
));
}
let total_proceeds = fixed_money(
execution_price * quantity as f64,
"position sell gross amount",
)?;
let mut remaining = quantity;
let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO;
let mut realized_entry = FixedMoney::ZERO;
let average_price_before_sell = self.average_price;
let average_cost_before_sell = self.average_cost;
while remaining > 0 {
let Some(first_lot) = self.lots.first_mut() else {
return Err(format!("position {} has no lots to sell", self.symbol));
};
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;
if first_lot.quantity == 0 {
self.lots.remove(0);
}
}
self.quantity -= quantity;
self.last_price = normalized_mark_price(mark_price, execution_price);
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 = self
.day_sell_value
.checked_add(total_proceeds)
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
if self.quantity == 0 {
self.average_price = 0.0;
self.recalculate_average_cost();
} else {
if average_price_before_sell.is_finite() && average_price_before_sell > 0.0 {
self.average_price = average_price_before_sell;
} else {
self.average_price = self.average_entry_price().unwrap_or(0.0);
}
if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
self.average_cost = average_cost_before_sell;
} else {
self.recalculate_average_cost();
}
}
self.refresh_day_pnl();
Ok(realized.to_f64())
}
pub fn sellable_qty(&self, date: NaiveDate) -> u32 {
self.lots
.iter()
.filter(|lot| lot.acquired_date < date)
.map(|lot| lot.quantity)
.sum()
}
pub fn market_value(&self) -> f64 {
self.quantity as f64 * self.last_price
}
pub fn unrealized_pnl(&self) -> 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 {
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_entry_value())
.expect("fixed-point unrealized entry PnL overflow")
.to_f64()
}
pub fn unrealized_average_price_pnl(&self) -> f64 {
if self.quantity == 0 || !self.average_price.is_finite() || self.average_price <= 0.0 {
return 0.0;
}
(self.last_price - self.average_price) * self.quantity as f64
}
pub fn pnl(&self) -> f64 {
self.realized_pnl.to_f64() + self.unrealized_pnl()
}
pub fn entry_pnl(&self) -> f64 {
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 {
self.day_start_quantity
}
pub fn day_trade_quantity_delta(&self) -> i32 {
self.day_trade_quantity_delta
}
pub fn bought_quantity(&self) -> u32 {
self.day_buy_quantity
}
pub fn sold_quantity(&self) -> u32 {
self.day_sell_quantity
}
pub fn bought_value(&self) -> f64 {
self.day_buy_value.to_f64()
}
pub fn sold_value(&self) -> f64 {
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.to_f64() / self.day_buy_quantity as f64
}
}
pub fn sell_avg_price(&self) -> f64 {
if self.day_sell_quantity == 0 {
0.0
} else {
self.day_sell_value.to_f64() / self.day_sell_quantity as f64
}
}
pub fn transaction_cost(&self) -> f64 {
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 = FixedMoney::ZERO;
self.day_trade_quantity_delta = 0;
self.day_trade_cost = FixedMoney::ZERO;
self.day_buy_quantity = 0;
self.day_sell_quantity = 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 = 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();
}
}
pub fn record_buy_trade_cost(&mut self, quantity: u32, value: f64) {
if quantity == 0 || !value.is_finite() {
return;
}
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.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.to_f64() / self.quantity as f64;
} else {
self.recalculate_average_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() {
fixed_money_or_panic(value.max(0.0), "position dividend receivable")
} else {
FixedMoney::ZERO
};
}
pub fn holding_return(&self, price: f64) -> Option<f64> {
let avg_price = self
.average_price
.is_finite()
.then_some(self.average_price)
.filter(|value| *value > 0.0)
.or_else(|| self.average_entry_price())?;
if avg_price <= 0.0 {
None
} else {
Some((price / avg_price) - 1.0)
}
}
pub fn average_entry_price(&self) -> Option<f64> {
if self.quantity == 0 {
return None;
}
Some(self.total_entry_value().to_f64() / self.quantity as f64)
}
fn recalculate_average_cost(&mut self) {
if self.quantity == 0 {
self.average_cost = 0.0;
return;
}
self.average_cost = self.total_cost_basis().to_f64() / 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 {
self.apply_cash_dividend_internal(dividend_per_share, true)
}
pub fn apply_cash_dividend_preserve_cost_basis(&mut self, dividend_per_share: f64) -> f64 {
self.apply_cash_dividend_internal(dividend_per_share, false)
}
fn apply_cash_dividend_internal(
&mut self,
dividend_per_share: f64,
adjust_cost_basis: bool,
) -> f64 {
if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 {
return 0.0;
}
let mut cash_delta = FixedMoney::ZERO;
for lot in &mut self.lots {
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.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.average_price -= dividend_per_share;
self.last_price -= dividend_per_share;
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.to_f64()
}
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9 {
return 0;
}
let old_quantity = self.quantity;
let mut scaled_lots = self
.lots
.iter()
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * 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);
let scaled_total = scaled_lots.iter().map(|lot| lot.quantity).sum::<u32>();
if let Some(last_lot) = scaled_lots.last_mut() {
if scaled_total < expected_total {
last_lot.quantity += expected_total - scaled_total;
} else if scaled_total > expected_total {
last_lot.quantity = last_lot
.quantity
.saturating_sub(scaled_total - expected_total);
}
}
scaled_lots.retain(|lot| lot.quantity > 0);
self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio;
if self.average_price.is_finite() && self.average_price > 0.0 {
self.average_price /= ratio;
} else {
self.average_price = self.average_entry_price().unwrap_or(0.0);
}
if self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost /= ratio;
} else {
self.recalculate_average_cost();
}
self.day_split_ratio *= ratio;
self.refresh_day_pnl();
self.quantity as i32 - old_quantity as i32
}
fn refresh_day_pnl(&mut self) {
let adjusted_old_quantity = self.day_start_quantity as f64 * self.day_split_ratio;
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)),
"position daily mark PnL",
)
.checked_add(self.day_dividend_cash)
.expect("fixed-point position daily PnL overflow")
.to_f64()
};
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();
}
}
fn normalized_mark_price(mark_price: f64, fallback: f64) -> f64 {
if mark_price.is_finite() && mark_price > 0.0 {
mark_price
} else {
fallback
}
}
#[derive(Debug, Clone)]
pub struct PortfolioState {
initial_cash: FixedMoney,
units: FixedMoney,
cash: FixedMoney,
/// Cumulative external cash flow (deposits positive, withdrawals negative).
/// Trading proceeds, dividends, fees and financing are deliberately not
/// included. The value is used by the engine to build a cash-flow-neutral
/// equity curve and is not a return measure itself.
external_cash_flow_total: FixedMoney,
cash_liabilities: FixedMoney,
management_fee_rate: f64,
management_fees: FixedMoney,
positions: IndexMap<String, Position>,
cash_receivables: Vec<CashReceivable>,
pending_cash_flows: Vec<PendingCashFlow>,
day_sold_symbols: BTreeSet<String>,
}
#[derive(Debug, Clone)]
pub struct PendingCashFlow {
pub payable_date: NaiveDate,
pub amount: f64,
pub reason: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SuccessorConversionOutcome {
pub old_symbol: String,
pub new_symbol: String,
pub old_quantity: u32,
pub new_quantity_delta: i32,
pub new_quantity_after: u32,
pub new_average_cost_after: f64,
pub cash_delta: f64,
}
impl PortfolioState {
pub fn new(initial_cash: f64) -> Self {
let initial_cash = fixed_money(initial_cash, "initial cash")
.expect("initial cash must be finite fixed-point money");
Self {
initial_cash,
units: initial_cash,
cash: initial_cash,
external_cash_flow_total: FixedMoney::ZERO,
cash_liabilities: FixedMoney::ZERO,
management_fee_rate: 0.0,
management_fees: FixedMoney::ZERO,
positions: IndexMap::new(),
cash_receivables: Vec::new(),
pending_cash_flows: Vec::new(),
day_sold_symbols: BTreeSet::new(),
}
}
pub fn starting_cash(&self) -> f64 {
// Keep the configured opening capital stable. External flows change
// `units`, not the meaning of this reporting field.
self.initial_cash.to_f64()
}
pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64()
}
pub fn units(&self) -> f64 {
self.units.to_f64()
}
pub fn cash(&self) -> f64 {
self.cash.to_f64()
}
pub fn external_cash_flow_total(&self) -> f64 {
self.external_cash_flow_total.to_f64()
}
pub fn cash_liabilities(&self) -> f64 {
self.cash_liabilities.to_f64()
}
pub fn management_fee_rate(&self) -> f64 {
self.management_fee_rate
}
pub fn management_fees(&self) -> f64 {
self.management_fees.to_f64()
}
pub fn positions(&self) -> &IndexMap<String, Position> {
&self.positions
}
pub fn position(&self, symbol: &str) -> Option<&Position> {
self.positions.get(symbol)
}
pub fn position_mut_if_exists(&mut self, symbol: &str) -> Option<&mut Position> {
self.positions.get_mut(symbol)
}
pub fn position_mut(&mut self, symbol: &str) -> &mut Position {
self.positions
.entry(symbol.to_string())
.or_insert_with(|| Position::new(symbol))
}
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
self.cash = self
.cash
.checked_add(fixed_money(delta, "cash delta")?)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
Ok(())
}
pub fn prune_flat_positions(&mut self) {
let mut sold_symbols = Vec::new();
self.positions.retain(|symbol, position| {
if position.is_flat() {
if position.sold_quantity() > 0 {
sold_symbols.push(symbol.clone());
}
false
} else {
true
}
});
self.day_sold_symbols.extend(sold_symbols);
}
pub fn add_cash_receivable(&mut self, receivable: CashReceivable) {
self.cash_receivables.push(receivable);
self.refresh_dividend_receivables();
}
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> {
let amount_money = fixed_money(amount, "deposit_withdraw amount")?;
let pending_withdrawal =
fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
let available_cash = self
.cash
.checked_sub(pending_withdrawal)
.ok_or_else(|| "fixed-point available cash overflow".to_string())?;
let cash_after = available_cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point deposit_withdraw overflow".to_string())?;
if amount_money.raw() < 0 && cash_after.raw() < 0 {
return Err(format!(
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}",
amount,
available_cash.to_f64()
));
}
let unit_net_value = self.unit_net_value();
self.cash = self
.cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
self.external_cash_flow_total = self
.external_cash_flow_total
.checked_add(amount_money)
.ok_or_else(|| "fixed-point external cash flow overflow".to_string())?;
self.rebase_units_after_external_cash_flow(unit_net_value)?;
Ok(())
}
pub fn schedule_deposit_withdraw(
&mut self,
payable_date: NaiveDate,
amount: f64,
reason: impl Into<String>,
) -> Result<(), String> {
let amount_money = fixed_money(amount, "deposit_withdraw amount")?;
let pending_withdrawal =
fixed_money(self.pending_withdrawal_total(), "pending withdrawal total")?;
let available_cash = self
.cash
.checked_sub(pending_withdrawal)
.ok_or_else(|| "fixed-point available cash overflow".to_string())?;
let cash_after = available_cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point scheduled cash flow overflow".to_string())?;
if amount_money.raw() < 0 && cash_after.raw() < 0 {
return Err(format!(
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
amount,
available_cash.to_f64()
));
}
self.pending_cash_flows.push(PendingCashFlow {
payable_date,
amount,
reason: reason.into(),
});
self.pending_cash_flows
.sort_by_key(|flow| flow.payable_date);
Ok(())
}
pub fn settle_pending_cash_flows(
&mut self,
date: NaiveDate,
) -> Result<Vec<PendingCashFlow>, String> {
let mut due = Vec::new();
let mut pending = Vec::new();
for flow in std::mem::take(&mut self.pending_cash_flows) {
if flow.payable_date <= date {
due.push(flow);
} else {
pending.push(flow);
}
}
// A delayed withdrawal must not be allowed to make the account
// negative after trades on an earlier day. Validate the complete due
// batch before mutating either cash or the pending queue so a failed
// settlement is atomic and can be diagnosed/retried safely.
let incoming = Self::sum_fixed_money(
due.iter()
.filter(|flow| flow.amount > 0.0)
.map(|flow| flow.amount),
"incoming scheduled cash flows",
)?;
let outgoing = Self::sum_fixed_money(
due.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| flow.amount),
"outgoing scheduled cash flows",
)?;
let net_due = incoming
.checked_add(outgoing)
.ok_or_else(|| "fixed-point scheduled cash flow overflow".to_string())?;
let cash_after = self
.cash
.checked_add(net_due)
.ok_or_else(|| "fixed-point cash settlement overflow".to_string())?;
if cash_after.raw() < 0 {
self.pending_cash_flows = due.into_iter().chain(pending).collect();
self.pending_cash_flows
.sort_by_key(|flow| flow.payable_date);
return Err(format!(
"insufficient cash to settle delayed cash flows on {date}: cash={:.2} net_due={:.2}",
self.cash.to_f64(),
net_due.to_f64()
));
}
// There is no sub-day ordering in the strategy contract for flows
// sharing a payable date. Apply deposits first, then withdrawals, so
// a same-day net-zero batch is deterministic and never fails merely
// because a withdrawal happened to be listed first.
due.sort_by_key(|flow| (flow.payable_date, flow.amount < 0.0));
let mut settled = Vec::with_capacity(due.len());
for flow in due {
let unit_net_value = self.unit_net_value();
let amount = fixed_money(flow.amount, "scheduled cash flow")?;
self.cash = self
.cash
.checked_add(amount)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
self.external_cash_flow_total = self
.external_cash_flow_total
.checked_add(amount)
.ok_or_else(|| "fixed-point external cash flow overflow".to_string())?;
self.rebase_units_after_external_cash_flow(unit_net_value)?;
settled.push(flow);
}
self.pending_cash_flows = pending;
Ok(settled)
}
pub fn pending_cash_flows(&self) -> &[PendingCashFlow] {
&self.pending_cash_flows
}
pub fn pending_withdrawal_total(&self) -> f64 {
self.pending_cash_flows
.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| -flow.amount)
.sum()
}
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
let amount_money = fixed_money(amount, "finance_repay amount")?;
if amount_money.raw() > 0 {
self.cash_liabilities = self
.cash_liabilities
.checked_add(amount_money)
.ok_or_else(|| "fixed-point cash liability overflow".to_string())?;
self.cash = self
.cash
.checked_add(amount_money)
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
return Ok(());
}
if amount_money.raw() < 0 {
let requested = amount_money
.checked_neg()
.ok_or_else(|| "fixed-point finance repayment overflow".to_string())?;
let repay_amount = requested.min(self.cash_liabilities);
if repay_amount > self.cash {
return Err(format!(
"insufficient cash for finance repay amount={:.2} cash={:.2}",
repay_amount.to_f64(),
self.cash.to_f64()
));
}
self.cash_liabilities = self
.cash_liabilities
.checked_sub(repay_amount)
.ok_or_else(|| "fixed-point cash liability underflow".to_string())?;
self.cash = self
.cash
.checked_sub(repay_amount)
.ok_or_else(|| "fixed-point cash underflow".to_string())?;
}
Ok(())
}
pub fn set_management_fee_rate(&mut self, rate: f64) -> Result<(), String> {
if !rate.is_finite() || rate < 0.0 {
return Err("management fee rate must be finite and non-negative".to_string());
}
self.management_fee_rate = rate;
Ok(())
}
pub fn default_management_fee(&self) -> f64 {
self.total_equity().max(0.0) * self.management_fee_rate
}
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> {
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());
}
self.cash = self
.cash
.checked_sub(fee_money)
.ok_or_else(|| "fixed-point cash underflow".to_string())?;
self.management_fees = self
.management_fees
.checked_add(fee_money)
.ok_or_else(|| "fixed-point management fee overflow".to_string())?;
Ok(())
}
pub fn take_due_cash_receivables(&mut self, date: NaiveDate) -> Vec<CashReceivable> {
let mut due = Vec::new();
let mut pending = Vec::new();
for receivable in self.cash_receivables.drain(..) {
if receivable.payable_date <= date {
due.push(receivable);
} else {
pending.push(receivable);
}
}
self.cash_receivables = pending;
self.refresh_dividend_receivables();
due
}
pub fn settle_cash_receivable(&mut self, receivable: &CashReceivable) -> Result<(), String> {
self.apply_cash_delta(receivable.amount)
}
pub fn cash_receivables(&self) -> &[CashReceivable] {
&self.cash_receivables
}
pub fn begin_trading_day(&mut self) {
self.day_sold_symbols.clear();
for position in self.positions.values_mut() {
position.begin_trading_day();
}
self.refresh_dividend_receivables();
}
pub fn update_prices(
&mut self,
date: NaiveDate,
data: &DataSet,
field: PriceField,
) -> Result<(), DataSetError> {
self.update_prices_with_options(date, data, field, false)
}
pub fn update_prices_with_options(
&mut self,
date: NaiveDate,
data: &DataSet,
field: PriceField,
same_day_buy_close_mark_at_fill: bool,
) -> Result<(), DataSetError> {
let day_sold_symbols = self.day_sold_symbols.clone();
for position in self.positions.values_mut() {
let current_market_missing = data.market(date, &position.symbol).is_none();
let unresolved_delisting = current_market_missing
&& data.instrument(&position.symbol).is_some_and(|instrument| {
instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none())
});
if unresolved_delisting {
position.last_price = 0.0;
position.refresh_day_pnl();
continue;
}
let sold_today =
position.sold_quantity() > 0 || day_sold_symbols.contains(&position.symbol);
if same_day_buy_close_mark_at_fill
&& field == PriceField::Close
&& position.day_buy_quantity > 0
&& !sold_today
&& position.sellable_qty(date) == 0
&& position.last_price.is_finite()
&& position.last_price > 0.0
{
position.refresh_day_pnl();
continue;
}
let price = data
.price(date, &position.symbol, field)
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
.or_else(|| {
(position.last_price.is_finite() && position.last_price > 0.0)
.then_some(position.last_price)
})
.ok_or_else(|| DataSetError::MissingSnapshot {
kind: match field {
PriceField::DayOpen => "day open price",
PriceField::Open => "open price",
PriceField::Close => "close price",
PriceField::Last => "last price",
},
date,
symbol: position.symbol.clone(),
})?;
position.last_price = price;
position.refresh_day_pnl();
}
Ok(())
}
pub fn market_value(&self) -> f64 {
self.positions.values().map(Position::market_value).sum()
}
pub fn transaction_cost(&self) -> f64 {
self.positions
.values()
.map(Position::transaction_cost)
.sum()
}
pub fn trading_pnl(&self) -> f64 {
self.positions
.values()
.map(|position| position.trading_pnl)
.sum()
}
pub fn position_pnl(&self) -> f64 {
self.positions
.values()
.map(|position| position.position_pnl)
.sum()
}
pub fn daily_pnl(&self) -> f64 {
self.trading_pnl() + self.position_pnl()
}
pub fn total_equity(&self) -> f64 {
self.cash.to_f64() + self.market_value() - self.cash_liabilities.to_f64()
}
pub fn total_value(&self) -> f64 {
self.total_equity()
}
pub fn portfolio_value(&self) -> f64 {
self.total_equity()
}
pub fn unit_net_value(&self) -> f64 {
if self.units.raw() == 0 {
0.0
} else {
self.total_equity() / self.units.to_f64()
}
}
pub fn static_unit_net_value(&self) -> f64 {
if self.units.raw() == 0 {
0.0
} else {
(self.total_equity() - self.daily_pnl()) / self.units.to_f64()
}
}
pub fn daily_returns(&self) -> f64 {
let previous_value = self.total_equity() - self.daily_pnl();
if previous_value.abs() < f64::EPSILON {
0.0
} else {
self.daily_pnl() / previous_value
}
}
pub fn total_returns(&self) -> f64 {
self.unit_net_value() - 1.0
}
pub fn holdings_summary(&self, date: NaiveDate) -> Vec<HoldingSummary> {
let total_equity = self.total_equity();
self.positions
.values()
.filter(|position| position.quantity > 0)
.map(|position| {
let market_value = position.market_value();
let entry_average_cost = position
.average_price
.is_finite()
.then_some(position.average_price)
.filter(|value| value.is_finite() && *value > 0.0)
.or_else(|| position.average_entry_price())
.unwrap_or(position.average_cost);
HoldingSummary {
date,
symbol: position.symbol.clone(),
quantity: position.quantity,
average_cost: entry_average_cost,
last_price: position.last_price,
market_value,
value_percent: if total_equity > 0.0 {
market_value / total_equity
} else {
0.0
},
unrealized_pnl: position.unrealized_average_price_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(),
old_quantity: position.day_start_quantity(),
bought_quantity: position.bought_quantity(),
sold_quantity: position.sold_quantity(),
buy_avg_price: position.buy_avg_price(),
sell_avg_price: position.sell_avg_price(),
bought_value: position.bought_value(),
sold_value: position.sold_value(),
transaction_cost: position.transaction_cost(),
day_trade_quantity_delta: position.day_trade_quantity_delta(),
}
})
.collect()
}
pub(crate) fn apply_successor_conversion(
&mut self,
old_symbol: &str,
new_symbol: &str,
ratio: f64,
cash_per_old_share: f64,
) -> Option<SuccessorConversionOutcome> {
if !ratio.is_finite() || ratio <= 0.0 {
return None;
}
let old_symbol_owned = old_symbol.to_string();
let old_position = self.positions.shift_remove(old_symbol)?;
if old_position.quantity == 0 {
return None;
}
let old_quantity = old_position.quantity;
let last_price = old_position.last_price;
let old_average_price = old_position.average_price;
let old_average_cost = old_position.average_cost;
let realized_pnl = old_position.realized_pnl;
let realized_entry_pnl = old_position.realized_entry_pnl;
let mut converted_lots = old_position
.lots
.into_iter()
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * 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);
let scaled_total = converted_lots.iter().map(|lot| lot.quantity).sum::<u32>();
if let Some(last_lot) = converted_lots.last_mut() {
if scaled_total < expected_total {
last_lot.quantity += expected_total - scaled_total;
} else if scaled_total > expected_total {
last_lot.quantity = last_lot
.quantity
.saturating_sub(scaled_total - expected_total);
}
}
converted_lots.retain(|lot| lot.quantity > 0);
let converted_quantity = converted_lots.iter().map(|lot| lot.quantity).sum::<u32>();
let converted_last_price = if last_price > 0.0 {
last_price / ratio
} else {
0.0
};
let successor = self
.positions
.entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity;
let successor_average_price_before = successor.average_price;
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 = 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;
}
let converted_average_price = if old_average_price.is_finite()
&& old_average_price > 0.0
&& ratio.is_finite()
&& ratio > 0.0
{
Some(old_average_price / ratio)
} else {
None
};
if let Some(converted_average_price) = converted_average_price {
if successor_quantity_before > 0
&& successor_average_price_before.is_finite()
&& successor_average_price_before > 0.0
{
successor.average_price = (successor_average_price_before
* successor_quantity_before as f64
+ converted_average_price * converted_quantity as f64)
/ successor.quantity as f64;
} else {
successor.average_price = converted_average_price;
}
} else {
successor.average_price = successor.average_entry_price().unwrap_or(0.0);
}
let converted_average_cost = if old_average_cost.is_finite()
&& old_average_cost > 0.0
&& ratio.is_finite()
&& ratio > 0.0
{
Some(old_average_cost / ratio)
} else {
None
};
if let Some(converted_average_cost) = converted_average_cost {
if successor_quantity_before > 0
&& successor_average_cost_before.is_finite()
&& successor_average_cost_before > 0.0
{
successor.average_cost = (successor_average_cost_before
* successor_quantity_before as f64
+ converted_average_cost * converted_quantity as f64)
/ successor.quantity as f64;
} else {
successor.average_cost = converted_average_cost;
}
} else {
successor.recalculate_average_cost();
}
successor.refresh_day_pnl();
Some(SuccessorConversionOutcome {
old_symbol: old_symbol_owned,
new_symbol: new_symbol.to_string(),
old_quantity,
new_quantity_delta: converted_quantity as i32,
new_quantity_after: successor.quantity,
new_average_cost_after: successor.average_cost,
cash_delta: if cash_per_old_share.is_finite() {
old_quantity as f64 * cash_per_old_share
} else {
0.0
},
})
}
fn sum_fixed_money(
values: impl IntoIterator<Item = f64>,
label: &str,
) -> Result<FixedMoney, String> {
values
.into_iter()
.try_fold(FixedMoney::ZERO, |total, value| {
total
.checked_add(fixed_money(value, label)?)
.ok_or_else(|| format!("fixed-point {label} overflow"))
})
}
fn refresh_dividend_receivables(&mut self) {
let mut per_symbol = BTreeMap::<String, f64>::new();
for receivable in &self.cash_receivables {
*per_symbol.entry(receivable.symbol.clone()).or_insert(0.0) += receivable.amount;
}
for (symbol, position) in &mut self.positions {
position.set_dividend_receivable(per_symbol.get(symbol).copied().unwrap_or(0.0));
}
}
fn rebase_units_after_external_cash_flow(
&mut self,
unit_net_value_before: f64,
) -> Result<(), String> {
if unit_net_value_before > 0.0 && unit_net_value_before.is_finite() {
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)
.and_then(|value| value.checked_div(unit_nav.raw()))
.ok_or_else(|| "fixed-point unit rebase overflow".to_string())?;
self.units = FixedMoney::from_raw(units_raw);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Instrument;
use crate::data::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
use std::collections::BTreeMap;
#[test]
fn cash_ledger_accumulates_micro_yuan_exactly() {
let mut portfolio = PortfolioState::new(1_000_000.0);
for _ in 0..100_000 {
portfolio.apply_cash_delta(-0.000001).unwrap();
}
assert_eq!(portfolio.cash, FixedMoney::from_raw(999_999_900_000));
assert_eq!(portfolio.cash(), 999_999.9);
for _ in 0..100_000 {
portfolio.apply_cash_delta(0.000001).unwrap();
}
assert_eq!(portfolio.cash, FixedMoney::from_raw(1_000_000_000_000));
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_price - 7.5).abs() < 1e-12);
assert!((position.average_cost - 7.55).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
assert!((position.unrealized_average_price_pnl() + 150.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();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio.position_mut("603657.SH").buy(date, 100, 10.0);
portfolio.position_mut("001266.SZ").buy(date, 100, 10.0);
portfolio.position_mut("601798.SH").buy(date, 100, 10.0);
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
assert_eq!(
symbols,
vec![
"603657.SH".to_string(),
"001266.SZ".to_string(),
"601798.SH".to_string()
]
);
}
#[test]
fn strategy_entry_price_excludes_buy_commission_cost_basis() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("600561.SH");
position.buy(date, 22_200, 5.66);
position.record_buy_trade_cost(22_200, 100.0);
assert!(position.average_cost > 5.66);
assert!((position.average_price - 5.66).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
}
#[test]
fn partial_sell_preserves_remaining_average_cost() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("603958.SH");
position.buy(date, 800, 18.0981);
position.record_buy_trade_cost(800, 5.0);
position.buy(date, 1700, 19.4694);
position.record_buy_trade_cost(1700, 8.27451625);
position.buy(date, 200, 18.4584);
position.record_buy_trade_cost(200, 5.0);
position.buy(date, 100, 17.8378);
position.record_buy_trade_cost(100, 5.0);
let average_cost_before = position.average_cost;
position.sell(2700, 16.8331).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_price - 18.94711428571429).abs() < 1e-12);
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
}
#[test]
fn buy_after_partial_sell_continues_moving_average_cost_basis() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("300405.SZ");
position.buy(date, 100, 10.0);
position.buy(date, 100, 5.0);
assert!((position.average_cost - 7.5).abs() < 1e-12);
position.sell(100, 6.0).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_price - 7.5).abs() < 1e-12);
assert!((position.average_cost - 7.5).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
position.buy(date, 100, 5.0);
assert_eq!(position.quantity, 200);
assert!((position.average_price - 6.25).abs() < 1e-12);
assert!((position.average_cost - 6.25).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
}
#[test]
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
{
let position = portfolio.position_mut("600561.SH");
position.buy(date, 100, 10.0);
position.record_buy_trade_cost(100, 5.0);
position.last_price = 10.5;
}
let summary = portfolio.holdings_summary(date);
assert_eq!(summary.len(), 1);
assert!((summary[0].average_cost - 10.0).abs() < 1e-12);
assert!((summary[0].unrealized_pnl - 50.0).abs() < 1e-12);
assert!((summary[0].realized_pnl - 0.0).abs() < 1e-12);
assert!(
portfolio
.position("600561.SH")
.expect("position")
.average_cost
> summary[0].average_cost
);
}
#[test]
fn cash_dividend_can_preserve_avg_cost_for_aiquant_rules() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("603102.SH");
position.buy(date, 1000, 46.45);
position.record_buy_trade_cost(1000, 37.16);
let cost_before = position.average_cost;
let entry_before = position.average_entry_price().unwrap();
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
assert!((cash - 600.0).abs() < 1e-12);
assert!((position.average_price - 45.85).abs() < 1e-12);
assert!((position.average_cost - cost_before).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
assert!((position.last_price - 45.85).abs() < 1e-12);
}
#[test]
fn portfolio_tracks_dividend_receivable_and_day_pnl() {
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 100, 10.0);
portfolio
.update_prices(
prev_date,
&DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![
DailyMarketSnapshot {
date: prev_date,
symbol: "000001.SZ".to_string(),
timestamp: None,
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 9.8,
volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: None,
day_open: 10.5,
open: 10.5,
high: 10.5,
low: 10.5,
close: 10.5,
last_price: 10.5,
bid1: 10.49,
ask1: 10.51,
prev_close: 10.0,
volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 999.0,
volume: 1000,
}],
)
.expect("dataset"),
PriceField::Close,
)
.expect("prev close");
portfolio.begin_trading_day();
portfolio.add_cash_receivable(CashReceivable {
symbol: "000001.SZ".to_string(),
ex_date: prev_date,
payable_date: date.succ_opt().unwrap(),
amount: 25.0,
reason: "cash_dividend".to_string(),
});
portfolio
.position_mut_if_exists("000001.SZ")
.expect("position")
.apply_cash_dividend(0.2);
portfolio
.position_mut_if_exists("000001.SZ")
.expect("position")
.record_trade_cost(5.0);
portfolio
.update_prices(
date,
&DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: None,
day_open: 10.5,
open: 10.5,
high: 10.5,
low: 10.5,
close: 10.5,
last_price: 10.5,
bid1: 10.49,
ask1: 10.51,
prev_close: 10.0,
volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 999.0,
volume: 1000,
}],
)
.expect("dataset"),
PriceField::Close,
)
.expect("close");
let position = portfolio.position("000001.SZ").expect("position");
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);
}
#[test]
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.position_mut("601028.SH")
.buy(prev_date, 100, 10.0);
let dataset = DataSet::from_components(
vec![Instrument {
symbol: "601028.SH".to_string(),
name: "Missing Row Test".to_string(),
board: "SH".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date: prev_date,
symbol: "601028.SH".to_string(),
timestamp: None,
day_open: 10.2,
open: 10.2,
high: 10.4,
low: 9.9,
close: 10.3,
last_price: 10.3,
bid1: 10.29,
ask1: 10.31,
prev_close: 10.0,
volume: 1000,
minute_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
Vec::new(),
Vec::new(),
vec![BenchmarkSnapshot {
date: prev_date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 999.0,
volume: 1000,
}],
)
.expect("dataset");
portfolio
.update_prices(prev_date, &dataset, PriceField::Close)
.expect("previous close");
portfolio.begin_trading_day();
portfolio
.update_prices(missing_date, &dataset, PriceField::Close)
.expect("missing current row should carry previous close");
let position = portfolio.position("601028.SH").expect("position");
assert!((position.last_price - 10.3).abs() < 1e-6);
assert!((position.market_value() - 1030.0).abs() < 1e-6);
assert!(position.position_pnl.abs() < 1e-6);
}
#[test]
fn portfolio_marks_same_day_buy_at_fill_until_next_trading_day() {
let buy_date = NaiveDate::from_ymd_opt(2025, 2, 10).unwrap();
let next_date = NaiveDate::from_ymd_opt(2025, 2, 11).unwrap();
let symbol = "002652.SZ";
let mut portfolio = PortfolioState::new(20_000.0);
portfolio.position_mut(symbol).buy(buy_date, 1300, 3.01);
let dataset = DataSet::from_components(
vec![Instrument {
symbol: symbol.to_string(),
name: "Same Day Buy Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![
DailyMarketSnapshot {
date: buy_date,
symbol: symbol.to_string(),
timestamp: None,
day_open: 2.99,
open: 2.99,
high: 3.06,
low: 2.98,
close: 3.06,
last_price: 3.06,
bid1: 3.01,
ask1: 3.02,
prev_close: 2.98,
volume: 152_975,
minute_volume: 152_975,
bid1_volume: 338,
ask1_volume: 2476,
trading_phase: None,
paused: false,
upper_limit: 3.28,
lower_limit: 2.68,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: next_date,
symbol: symbol.to_string(),
timestamp: None,
day_open: 3.03,
open: 3.03,
high: 3.08,
low: 3.00,
close: 3.07,
last_price: 3.07,
bid1: 3.06,
ask1: 3.07,
prev_close: 3.06,
volume: 160_000,
minute_volume: 160_000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
paused: false,
upper_limit: 3.37,
lower_limit: 2.75,
price_tick: 0.01,
},
],
Vec::new(),
Vec::new(),
vec![
BenchmarkSnapshot {
date: buy_date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 999.0,
volume: 1000,
},
BenchmarkSnapshot {
date: next_date,
benchmark: "000852.SH".to_string(),
open: 1001.0,
close: 1001.0,
prev_close: 1000.0,
volume: 1000,
},
],
)
.expect("dataset");
portfolio
.update_prices_with_options(buy_date, &dataset, PriceField::Close, true)
.expect("same day close");
let position = portfolio.position(symbol).expect("position");
assert!((position.last_price - 3.01).abs() < 1e-9);
assert!((position.market_value() - 3913.0).abs() < 1e-6);
portfolio.begin_trading_day();
portfolio
.update_prices(next_date, &dataset, PriceField::Close)
.expect("next day close");
let position = portfolio.position(symbol).expect("position");
assert!((position.last_price - 3.07).abs() < 1e-9);
assert!((position.market_value() - 3991.0).abs() < 1e-6);
let prev_date = NaiveDate::from_ymd_opt(2025, 2, 7).unwrap();
let mut roundtrip_portfolio = PortfolioState::new(20_000.0);
roundtrip_portfolio
.position_mut(symbol)
.buy(prev_date, 2000, 2.90);
roundtrip_portfolio.begin_trading_day();
roundtrip_portfolio
.position_mut(symbol)
.sell(2000, 3.01)
.expect("same day sell");
roundtrip_portfolio.prune_flat_positions();
roundtrip_portfolio
.position_mut(symbol)
.buy(buy_date, 1800, 3.01);
roundtrip_portfolio
.update_prices(buy_date, &dataset, PriceField::Close)
.expect("same day roundtrip close");
let position = roundtrip_portfolio.position(symbol).expect("position");
assert!((position.last_price - 3.06).abs() < 1e-9);
assert!((position.market_value() - 5508.0).abs() < 1e-6);
}
#[test]
fn position_tracks_day_lifecycle_fields() {
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 100, 10.0);
portfolio.begin_trading_day();
portfolio.position_mut("000001.SZ").buy(date, 50, 11.0);
let realized = portfolio
.position_mut("000001.SZ")
.sell(40, 12.0)
.expect("sell");
portfolio
.position_mut_if_exists("000001.SZ")
.expect("position")
.record_trade_cost(3.0);
let position = portfolio.position("000001.SZ").expect("position");
assert_eq!(position.day_start_quantity(), 100);
assert_eq!(position.bought_quantity(), 50);
assert_eq!(position.sold_quantity(), 40);
assert_eq!(position.day_trade_quantity_delta(), 10);
assert!((position.bought_value() - 550.0).abs() < 1e-6);
assert!((position.sold_value() - 480.0).abs() < 1e-6);
assert!((position.buy_avg_price() - 11.0).abs() < 1e-6);
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.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);
let summary = portfolio.holdings_summary(date);
assert_eq!(summary[0].old_quantity, 100);
assert_eq!(summary[0].bought_quantity, 50);
assert_eq!(summary[0].sold_quantity, 40);
assert!((summary[0].buy_avg_price - 11.0).abs() < 1e-6);
assert!((summary[0].sell_avg_price - 12.0).abs() < 1e-6);
assert!((summary[0].transaction_cost - 3.0).abs() < 1e-6);
assert!(summary[0].value_percent > 0.0);
}
#[test]
fn portfolio_exposes_engine_native_account_metrics() {
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 100, 10.0);
portfolio.begin_trading_day();
portfolio.position_mut("000001.SZ").buy(date, 50, 11.0);
portfolio
.position_mut("000001.SZ")
.sell(40, 12.0)
.expect("sell");
portfolio.position_mut("000001.SZ").record_trade_cost(3.0);
assert!((portfolio.starting_cash() - 10_000.0).abs() < 1e-6);
assert!((portfolio.units() - 10_000.0).abs() < 1e-6);
assert!((portfolio.transaction_cost() - 3.0).abs() < 1e-6);
assert!((portfolio.trading_pnl() - 47.0).abs() < 1e-6);
assert!((portfolio.position_pnl() - 200.0).abs() < 1e-6);
assert!((portfolio.daily_pnl() - 247.0).abs() < 1e-6);
assert!((portfolio.total_value() - portfolio.total_equity()).abs() < 1e-6);
assert!((portfolio.portfolio_value() - portfolio.total_equity()).abs() < 1e-6);
assert!((portfolio.unit_net_value() - portfolio.total_equity() / 10_000.0).abs() < 1e-6);
assert!(
(portfolio.static_unit_net_value()
- (portfolio.total_equity() - portfolio.daily_pnl()) / 10_000.0)
.abs()
< 1e-6
);
assert!(
(portfolio.daily_returns()
- portfolio.daily_pnl() / (portfolio.total_equity() - portfolio.daily_pnl()))
.abs()
< 1e-6
);
assert!((portfolio.total_returns() - (portfolio.unit_net_value() - 1.0)).abs() < 1e-6);
assert_eq!(portfolio.cash_receivables().len(), 0);
}
#[test]
fn external_cash_flow_rebases_units_without_changing_nav() {
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.deposit_withdraw(5_000.0)
.expect("deposit should settle");
assert!((portfolio.cash() - 15_000.0).abs() < 1e-6);
assert!((portfolio.units() - 15_000.0).abs() < 1e-6);
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
assert!((portfolio.external_cash_flow_total() - 5_000.0).abs() < 1e-6);
portfolio
.deposit_withdraw(-2_000.0)
.expect("withdrawal should settle");
assert!((portfolio.cash() - 13_000.0).abs() < 1e-6);
assert!((portfolio.units() - 13_000.0).abs() < 1e-6);
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
assert!((portfolio.external_cash_flow_total() - 3_000.0).abs() < 1e-6);
}
#[test]
fn delayed_withdrawals_are_reserved_and_settled_atomically() {
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.schedule_deposit_withdraw(date, -8_000.0, "first")
.expect("first withdrawal should reserve cash");
assert!((portfolio.pending_withdrawal_total() - 8_000.0).abs() < 1e-6);
assert!(
portfolio
.schedule_deposit_withdraw(date, -3_000.0, "overcommit")
.is_err()
);
// A strategy cannot spend the reserved cash by scheduling a second
// withdrawal; settlement remains safe even if earlier trading reduced
// the current cash balance.
portfolio.apply_cash_delta(-3_000.0).unwrap();
let error = portfolio
.settle_pending_cash_flows(date)
.expect_err("settlement must reject an underfunded withdrawal batch");
assert!(error.contains("insufficient cash"));
assert_eq!(portfolio.pending_cash_flows().len(), 1);
assert!((portfolio.cash() - 7_000.0).abs() < 1e-6);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldingSummary {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub symbol: String,
pub quantity: u32,
pub average_cost: f64,
pub last_price: f64,
pub market_value: f64,
pub value_percent: f64,
pub unrealized_pnl: f64,
pub realized_pnl: f64,
pub pnl: f64,
pub trading_pnl: f64,
pub position_pnl: f64,
pub dividend_receivable: f64,
pub old_quantity: u32,
pub bought_quantity: u32,
pub sold_quantity: u32,
pub buy_avg_price: f64,
pub sell_avg_price: f64,
pub bought_value: f64,
pub sold_value: f64,
pub transaction_cost: f64,
pub day_trade_quantity_delta: i32,
}
#[derive(Debug, Clone)]
pub struct CashReceivable {
pub symbol: String,
pub ex_date: NaiveDate,
pub payable_date: NaiveDate,
pub amount: f64,
pub reason: String,
}
mod date_format {
use chrono::NaiveDate;
use serde::{Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d";
pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(&value, FORMAT).map_err(serde::de::Error::custom)
}
}
fn round_half_up_u32(value: f64) -> u32 {
if !value.is_finite() || value <= 0.0 {
0
} else {
value.round() as u32
}
}