将股票执行资金切换为定点账本
This commit is contained in:
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::data::{DataSet, DataSetError, PriceField};
|
||||
use crate::fixed_point::{FixedMoney, MONEY_SCALE};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionLot {
|
||||
@@ -429,17 +430,17 @@ fn normalized_mark_price(mark_price: f64, fallback: f64) -> f64 {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortfolioState {
|
||||
initial_cash: f64,
|
||||
units: f64,
|
||||
cash: f64,
|
||||
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: f64,
|
||||
cash_liabilities: f64,
|
||||
external_cash_flow_total: FixedMoney,
|
||||
cash_liabilities: FixedMoney,
|
||||
management_fee_rate: f64,
|
||||
management_fees: f64,
|
||||
management_fees: FixedMoney,
|
||||
positions: IndexMap<String, Position>,
|
||||
cash_receivables: Vec<CashReceivable>,
|
||||
pending_cash_flows: Vec<PendingCashFlow>,
|
||||
@@ -466,14 +467,16 @@ pub(crate) struct SuccessorConversionOutcome {
|
||||
|
||||
impl PortfolioState {
|
||||
pub fn new(initial_cash: f64) -> Self {
|
||||
let initial_cash = Self::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: 0.0,
|
||||
cash_liabilities: 0.0,
|
||||
external_cash_flow_total: FixedMoney::ZERO,
|
||||
cash_liabilities: FixedMoney::ZERO,
|
||||
management_fee_rate: 0.0,
|
||||
management_fees: 0.0,
|
||||
management_fees: FixedMoney::ZERO,
|
||||
positions: IndexMap::new(),
|
||||
cash_receivables: Vec::new(),
|
||||
pending_cash_flows: Vec::new(),
|
||||
@@ -484,27 +487,27 @@ impl PortfolioState {
|
||||
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
|
||||
self.initial_cash.to_f64()
|
||||
}
|
||||
|
||||
pub fn initial_cash(&self) -> f64 {
|
||||
self.initial_cash
|
||||
self.initial_cash.to_f64()
|
||||
}
|
||||
|
||||
pub fn units(&self) -> f64 {
|
||||
self.units
|
||||
self.units.to_f64()
|
||||
}
|
||||
|
||||
pub fn cash(&self) -> f64 {
|
||||
self.cash
|
||||
self.cash.to_f64()
|
||||
}
|
||||
|
||||
pub fn external_cash_flow_total(&self) -> f64 {
|
||||
self.external_cash_flow_total
|
||||
self.external_cash_flow_total.to_f64()
|
||||
}
|
||||
|
||||
pub fn cash_liabilities(&self) -> f64 {
|
||||
self.cash_liabilities
|
||||
self.cash_liabilities.to_f64()
|
||||
}
|
||||
|
||||
pub fn management_fee_rate(&self) -> f64 {
|
||||
@@ -512,7 +515,7 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn management_fees(&self) -> f64 {
|
||||
self.management_fees
|
||||
self.management_fees.to_f64()
|
||||
}
|
||||
|
||||
pub fn positions(&self) -> &IndexMap<String, Position> {
|
||||
@@ -533,8 +536,12 @@ impl PortfolioState {
|
||||
.or_insert_with(|| Position::new(symbol))
|
||||
}
|
||||
|
||||
pub fn apply_cash_delta(&mut self, delta: f64) {
|
||||
self.cash += delta;
|
||||
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
|
||||
self.cash = self
|
||||
.cash
|
||||
.checked_add(Self::fixed_money(delta, "cash delta")?)
|
||||
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn prune_flat_positions(&mut self) {
|
||||
@@ -558,21 +565,34 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> {
|
||||
if !amount.is_finite() {
|
||||
return Err("deposit_withdraw amount must be finite".to_string());
|
||||
}
|
||||
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
|
||||
let available_cash = self.cash - self.pending_withdrawal_total();
|
||||
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
|
||||
let pending_withdrawal =
|
||||
Self::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
|
||||
amount,
|
||||
available_cash.to_f64()
|
||||
));
|
||||
}
|
||||
|
||||
let unit_net_value = self.unit_net_value();
|
||||
self.cash += amount;
|
||||
self.external_cash_flow_total += amount;
|
||||
self.rebase_units_after_external_cash_flow(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(())
|
||||
}
|
||||
|
||||
@@ -582,14 +602,21 @@ impl PortfolioState {
|
||||
amount: f64,
|
||||
reason: impl Into<String>,
|
||||
) -> Result<(), String> {
|
||||
if !amount.is_finite() {
|
||||
return Err("deposit_withdraw amount must be finite".to_string());
|
||||
}
|
||||
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
|
||||
let available_cash = self.cash - self.pending_withdrawal_total();
|
||||
let amount_money = Self::fixed_money(amount, "deposit_withdraw amount")?;
|
||||
let pending_withdrawal =
|
||||
Self::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
|
||||
amount,
|
||||
available_cash.to_f64()
|
||||
));
|
||||
}
|
||||
self.pending_cash_flows.push(PendingCashFlow {
|
||||
@@ -620,24 +647,33 @@ impl PortfolioState {
|
||||
// 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 = due
|
||||
.iter()
|
||||
.filter(|flow| flow.amount > 0.0)
|
||||
.map(|flow| flow.amount)
|
||||
.sum::<f64>();
|
||||
let outgoing = due
|
||||
.iter()
|
||||
.filter(|flow| flow.amount < 0.0)
|
||||
.map(|flow| flow.amount)
|
||||
.sum::<f64>();
|
||||
if self.cash + incoming + outgoing < -1e-6 {
|
||||
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,
|
||||
incoming + outgoing
|
||||
self.cash.to_f64(),
|
||||
net_due.to_f64()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -649,9 +685,16 @@ impl PortfolioState {
|
||||
let mut settled = Vec::with_capacity(due.len());
|
||||
for flow in due {
|
||||
let unit_net_value = self.unit_net_value();
|
||||
self.cash += flow.amount;
|
||||
self.external_cash_flow_total += flow.amount;
|
||||
self.rebase_units_after_external_cash_flow(unit_net_value);
|
||||
let amount = Self::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;
|
||||
@@ -671,24 +714,38 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
|
||||
if !amount.is_finite() {
|
||||
return Err("finance_repay amount must be finite".to_string());
|
||||
}
|
||||
if amount > 0.0 {
|
||||
self.cash_liabilities += amount;
|
||||
self.cash += amount;
|
||||
let amount_money = Self::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 < 0.0 {
|
||||
let repay_amount = (-amount).min(self.cash_liabilities);
|
||||
if repay_amount > self.cash + 1e-6 {
|
||||
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, self.cash
|
||||
repay_amount.to_f64(),
|
||||
self.cash.to_f64()
|
||||
));
|
||||
}
|
||||
self.cash_liabilities -= repay_amount;
|
||||
self.cash -= repay_amount;
|
||||
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(())
|
||||
}
|
||||
@@ -706,11 +763,18 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn apply_management_fee(&mut self, fee: f64) -> Result<(), String> {
|
||||
if !fee.is_finite() || fee < 0.0 {
|
||||
let fee_money = Self::fixed_money(fee, "management fee")?;
|
||||
if fee_money.raw() < 0 {
|
||||
return Err("management fee must be finite and non-negative".to_string());
|
||||
}
|
||||
self.cash -= fee;
|
||||
self.management_fees += fee;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -719,7 +783,12 @@ impl PortfolioState {
|
||||
let mut pending = Vec::new();
|
||||
for receivable in self.cash_receivables.drain(..) {
|
||||
if receivable.payable_date <= date {
|
||||
self.cash += receivable.amount;
|
||||
let amount = Self::fixed_money(receivable.amount, "cash receivable")
|
||||
.expect("cash receivable must be finite fixed-point money");
|
||||
self.cash = self
|
||||
.cash
|
||||
.checked_add(amount)
|
||||
.expect("fixed-point cash overflow while settling receivable");
|
||||
settled.push(receivable);
|
||||
} else {
|
||||
pending.push(receivable);
|
||||
@@ -838,7 +907,7 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn total_equity(&self) -> f64 {
|
||||
self.cash + self.market_value() - self.cash_liabilities
|
||||
self.total_equity_money().to_f64()
|
||||
}
|
||||
|
||||
pub fn total_value(&self) -> f64 {
|
||||
@@ -850,18 +919,18 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn unit_net_value(&self) -> f64 {
|
||||
if self.units.abs() < f64::EPSILON {
|
||||
if self.units.raw() == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.total_equity() / self.units
|
||||
self.total_equity() / self.units.to_f64()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn static_unit_net_value(&self) -> f64 {
|
||||
if self.units.abs() < f64::EPSILON {
|
||||
if self.units.raw() == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.total_equity() - self.daily_pnl()) / self.units
|
||||
(self.total_equity() - self.daily_pnl()) / self.units.to_f64()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,6 +1094,43 @@ impl PortfolioState {
|
||||
})
|
||||
}
|
||||
|
||||
fn fixed_money(value: f64, label: &str) -> Result<FixedMoney, String> {
|
||||
FixedMoney::from_f64(value)
|
||||
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
|
||||
}
|
||||
|
||||
fn sum_fixed_money(
|
||||
values: impl IntoIterator<Item = f64>,
|
||||
label: &str,
|
||||
) -> Result<FixedMoney, String> {
|
||||
values
|
||||
.into_iter()
|
||||
.try_fold(FixedMoney::ZERO, |total, value| {
|
||||
total
|
||||
.checked_add(Self::fixed_money(value, label)?)
|
||||
.ok_or_else(|| format!("fixed-point {label} overflow"))
|
||||
})
|
||||
}
|
||||
|
||||
fn market_value_money(&self) -> FixedMoney {
|
||||
self.positions
|
||||
.values()
|
||||
.fold(FixedMoney::ZERO, |total, position| {
|
||||
let value = Self::fixed_money(position.market_value(), "position market value")
|
||||
.expect("position market value must be finite fixed-point money");
|
||||
total
|
||||
.checked_add(value)
|
||||
.expect("fixed-point market value overflow")
|
||||
})
|
||||
}
|
||||
|
||||
fn total_equity_money(&self) -> FixedMoney {
|
||||
self.cash
|
||||
.checked_add(self.market_value_money())
|
||||
.and_then(|value| value.checked_sub(self.cash_liabilities))
|
||||
.expect("fixed-point total equity overflow")
|
||||
}
|
||||
|
||||
fn refresh_dividend_receivables(&mut self) {
|
||||
let mut per_symbol = BTreeMap::<String, f64>::new();
|
||||
for receivable in &self.cash_receivables {
|
||||
@@ -1035,10 +1141,21 @@ impl PortfolioState {
|
||||
}
|
||||
}
|
||||
|
||||
fn rebase_units_after_external_cash_flow(&mut self, unit_net_value_before: f64) {
|
||||
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() {
|
||||
self.units = self.total_equity() / unit_net_value_before;
|
||||
let unit_nav = Self::fixed_money(unit_net_value_before, "unit net value")?;
|
||||
let units_raw = self
|
||||
.total_equity_money()
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,6 +1169,22 @@ mod tests {
|
||||
};
|
||||
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 positions_preserve_insertion_order() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
@@ -1682,7 +1815,7 @@ mod tests {
|
||||
// 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);
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user