diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index fca7803..e443483 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -10,6 +10,7 @@ use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; +use crate::fixed_point::FixedMoney; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope}; @@ -2585,7 +2586,7 @@ where } else { 0.0 }; - if buy_cash_out <= projected_cash + 1e-6 { + if Self::fixed_cash_fits(buy_cash_out, projected_cash) { if proportion_diff < best_proportion_diff - 1e-12 || ((proportion_diff - best_proportion_diff).abs() <= 1e-12 && safety_value > best_safety) @@ -3150,9 +3151,14 @@ where if quantity == 0 { return 0.0; } - let gross = price * quantity as f64; - let cost = self.cost_model.calculate(date, OrderSide::Sell, gross); - gross - cost.total() + let gross = Self::fixed_gross_amount(price, quantity); + let cost = self + .cost_model + .calculate(date, OrderSide::Sell, gross.to_f64()); + gross + .checked_sub(cost.fixed_total()) + .expect("fixed-point sell proceeds underflow") + .to_f64() } fn sell_target_denial_reason( @@ -3256,9 +3262,23 @@ where if quantity == 0 { return 0.0; } - let gross = price * quantity as f64; - let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); - gross + cost.total() + let gross = Self::fixed_gross_amount(price, quantity); + let cost = self + .cost_model + .calculate(date, OrderSide::Buy, gross.to_f64()); + gross + .checked_add(cost.fixed_total()) + .expect("fixed-point buy cash overflow") + .to_f64() + } + + fn fixed_gross_amount(price: f64, quantity: u32) -> FixedMoney { + FixedMoney::from_f64(price * quantity as f64) + .expect("execution gross amount must be finite fixed-point money") + } + + fn fixed_cash_fits(value: f64, limit: f64) -> bool { + FixedMoney::f64_fits_within(value, limit).unwrap_or(false) } fn can_afford_minimum_buy( @@ -3283,8 +3303,10 @@ where } let minimum_execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity)); - self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity) - <= portfolio.cash() + 1e-6 + Self::fixed_cash_fits( + self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity), + portfolio.cash(), + ) } fn process_sell( @@ -3722,7 +3744,8 @@ where } for leg in &execution_legs { let leg_cash_before = portfolio.cash(); - let gross_amount = leg.price * leg.quantity as f64; + let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity); + let gross_amount = gross_money.to_f64(); let cost = self.cost_model.calculate_with_order_state( date, OrderSide::Sell, @@ -3730,7 +3753,10 @@ where Some(order_id), commission_state, ); - let net_cash = gross_amount - cost.total(); + let net_cash = gross_money + .checked_sub(cost.fixed_total()) + .expect("fixed-point sell proceeds underflow") + .to_f64(); let realized_pnl = portfolio .position_mut(symbol) .sell_with_mark_price(leg.quantity, leg.price, leg.mark_price) @@ -3738,7 +3764,9 @@ where if let Some(position) = portfolio.position_mut_if_exists(symbol) { position.record_trade_cost(cost.total()); } - portfolio.apply_cash_delta(net_cash); + portfolio + .apply_cash_delta(net_cash) + .map_err(BacktestError::Execution)?; report.fill_events.push(FillEvent { date, @@ -5371,7 +5399,8 @@ where } for leg in &execution_legs { let leg_cash_before = portfolio.cash(); - let gross_amount = leg.price * leg.quantity as f64; + let gross_money = Self::fixed_gross_amount(leg.price, leg.quantity); + let gross_amount = gross_money.to_f64(); let cost = self.cost_model.calculate_with_order_state( date, OrderSide::Buy, @@ -5379,9 +5408,14 @@ where Some(order_id), commission_state, ); - let cash_out = gross_amount + cost.total(); + let cash_out = gross_money + .checked_add(cost.fixed_total()) + .expect("fixed-point buy cash overflow") + .to_f64(); - portfolio.apply_cash_delta(-cash_out); + portfolio + .apply_cash_delta(-cash_out) + .map_err(BacktestError::Execution)?; portfolio.position_mut(symbol).buy_with_mark_price( date, leg.quantity, @@ -5766,7 +5800,10 @@ where let mut quantity = self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size); while quantity >= minimum { - if self.estimated_buy_cash_out(date, price, quantity) <= value_budget + 1e-6 { + if Self::fixed_cash_fits( + self.estimated_buy_cash_out(date, price, quantity), + value_budget, + ) { return quantity; } quantity = @@ -5820,7 +5857,10 @@ where }) .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(fallback_price); - if self.estimated_buy_cash_out(date, execution_price, quantity) <= value_budget + 1e-6 { + if Self::fixed_cash_fits( + self.estimated_buy_cash_out(date, execution_price, quantity), + value_budget, + ) { return quantity; } quantity = @@ -5857,7 +5897,7 @@ where self.round_buy_quantity(requested_qty, minimum_order_quantity, order_step_size); while quantity > 0 { let gross = price * quantity as f64; - if gross_limit.is_some_and(|limit| gross > limit + 1e-6) { + if gross_limit.is_some_and(|limit| !Self::fixed_cash_fits(gross, limit)) { quantity = self.decrement_order_quantity( quantity, minimum_order_quantity, @@ -5866,7 +5906,10 @@ where continue; } let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); - if gross + cost.total() <= cash + 1e-6 { + let cash_out = FixedMoney::checked_sum_f64([gross, cost.total()]) + .expect("buy cash must be finite fixed-point money") + .to_f64(); + if Self::fixed_cash_fits(cash_out, cash) { return quantity; } quantity = @@ -5886,7 +5929,9 @@ where if filled_qty >= requested_qty { return None; } - if gross_limit.is_some_and(|limit| price * requested_qty as f64 > limit + 1e-6) { + if gross_limit + .is_some_and(|limit| !Self::fixed_cash_fits(price * requested_qty as f64, limit)) + { Some("value budget limit") } else if cash_limit.is_finite() { Some("insufficient cash after fees") @@ -6326,7 +6371,9 @@ where break; } let candidate_gross = gross_amount + quote_price * take_qty as f64; - if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + if gross_limit + .is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit)) + { budget_block_reason = Some("value budget limit"); take_qty = self.decrement_order_quantity( take_qty, @@ -6339,7 +6386,11 @@ where .cost_model .calculate(snapshot.date, OrderSide::Buy, candidate_gross) .total(); - if candidate_gross + candidate_cost <= cash + 1e-6 { + let candidate_cash = + FixedMoney::checked_sum_f64([candidate_gross, candidate_cost]) + .expect("buy cash must be finite fixed-point money") + .to_f64(); + if Self::fixed_cash_fits(candidate_cash, cash) { break; } budget_block_reason = Some("insufficient cash after fees"); @@ -6586,6 +6637,7 @@ mod tests { IntradayExecutionQuote, PriceField, }; use crate::events::{OrderSide, OrderStatus}; + use crate::fixed_point::FixedMoney; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::risk_control::FidcRiskControlConfig; @@ -7650,7 +7702,7 @@ mod tests { 1_000, 10.0, ); - portfolio.apply_cash_delta(-10_000.0); + portfolio.apply_cash_delta(-10_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker @@ -7708,7 +7760,7 @@ mod tests { 10_000, 10.0, ); - portfolio.apply_cash_delta(-100_000.0); + portfolio.apply_cash_delta(-100_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker @@ -7766,7 +7818,7 @@ mod tests { 1_000, 10.0, ); - portfolio.apply_cash_delta(-10_000.0); + portfolio.apply_cash_delta(-10_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker @@ -7856,7 +7908,7 @@ mod tests { 1_000, 10.0, ); - portfolio.apply_cash_delta(-10_000.0); + portfolio.apply_cash_delta(-10_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker @@ -7943,7 +7995,7 @@ mod tests { 10_000, 10.0, ); - portfolio.apply_cash_delta(-100_000.0); + portfolio.apply_cash_delta(-100_000.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker @@ -9122,7 +9174,7 @@ mod tests { .expect("valid dataset"); let mut portfolio = PortfolioState::new(1_000_000.0); portfolio.position_mut(symbol).buy(prev_date, 72_600, 4.0); - portfolio.apply_cash_delta(-290_400.0); + portfolio.apply_cash_delta(-290_400.0).unwrap(); let mut report = BrokerExecutionReport::default(); broker @@ -9166,13 +9218,10 @@ mod tests { let date = chrono::NaiveDate::from_ymd_opt(2023, 5, 8).expect("valid date"); let symbol = "603101.SH"; let broker = BrokerSimulator::new_with_execution_price( - ChinaAShareCostModel { - commission_rate: 0.0003, - stamp_tax_rate_before_change: 0.0005, - stamp_tax_rate_after_change: 0.0005, - minimum_commission: 5.0, - ..ChinaAShareCostModel::default() - }, + ChinaAShareCostModel::default() + .with_commission_rate(0.0003) + .with_stamp_tax_rates(0.0005, 0.0005) + .with_minimum_commission(5.0), ChinaEquityRuleHooks, PriceField::Last, ) @@ -9515,7 +9564,20 @@ mod tests { let fill = report.fill_events.first().expect("fill event"); assert_eq!(fill.quantity, 17_400); - assert!(fill.gross_amount + fill.commission <= value_budget + 1e-6); + let cash_out = FixedMoney::checked_sum_f64([ + fill.gross_amount, + fill.commission, + fill.stamp_tax, + fill.transfer_fee, + ]) + .unwrap() + .to_f64(); + assert!( + BrokerSimulator::::fixed_cash_fits( + cash_out, + value_budget + ) + ); assert!((fill.price - 7.15428).abs() < 1e-6); } diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index 7537692..61ad310 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use chrono::NaiveDate; use crate::events::OrderSide; +use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost}; use crate::risk_control::TradingConstraintConfig; #[derive(Debug, Clone, Copy)] @@ -14,7 +15,20 @@ pub struct TradingCost { impl TradingCost { pub fn total(self) -> f64 { - self.commission + self.stamp_tax + self.transfer_fee + self.fixed_total().to_f64() + } + + pub fn fixed_total(self) -> FixedMoney { + FixedMoney::checked_sum_f64([self.commission, self.stamp_tax, self.transfer_fee]) + .expect("trading costs must be finite fixed-point money") + } + + fn from_fixed(value: FixedTradingCost) -> Self { + Self { + commission: value.commission.to_f64(), + stamp_tax: value.stamp_tax.to_f64(), + transfer_fee: value.transfer_fee.to_f64(), + } } } @@ -35,12 +49,7 @@ pub trait CostModel { #[derive(Debug, Clone, Copy)] pub struct ChinaAShareCostModel { - pub commission_rate: f64, - pub stamp_tax_rate_before_change: f64, - pub stamp_tax_rate_after_change: f64, - pub stamp_tax_change_date: NaiveDate, - pub minimum_commission: f64, - pub transfer_fee_rate: f64, + fixed: FixedChinaAShareCostModel, } impl Default for ChinaAShareCostModel { @@ -52,42 +61,93 @@ impl Default for ChinaAShareCostModel { impl ChinaAShareCostModel { pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self { Self { - commission_rate: config.commission_rate, - stamp_tax_rate_before_change: config.stamp_tax_rate_before_change, - stamp_tax_rate_after_change: config.stamp_tax_rate_after_change, - stamp_tax_change_date: config.stamp_tax_change_date, - minimum_commission: config.minimum_commission, - transfer_fee_rate: config.transfer_fee_rate, + fixed: FixedChinaAShareCostModel { + commission_rate: Self::fixed_money(config.commission_rate, "commission rate"), + stamp_tax_rate_before_change: Self::fixed_money( + config.stamp_tax_rate_before_change, + "stamp tax rate before change", + ), + stamp_tax_rate_after_change: Self::fixed_money( + config.stamp_tax_rate_after_change, + "stamp tax rate after change", + ), + stamp_tax_change_date: config.stamp_tax_change_date, + minimum_commission: Self::fixed_money( + config.minimum_commission, + "minimum commission", + ), + transfer_fee_rate: Self::fixed_money(config.transfer_fee_rate, "transfer fee rate"), + }, } } + pub fn set_commission_rate(&mut self, value: f64) { + self.fixed.commission_rate = Self::fixed_money(value, "commission rate"); + } + + pub fn set_minimum_commission(&mut self, value: f64) { + self.fixed.minimum_commission = Self::fixed_money(value, "minimum commission"); + } + + pub fn set_stamp_tax_rate_before_change(&mut self, value: f64) { + self.fixed.stamp_tax_rate_before_change = + Self::fixed_money(value, "stamp tax rate before change"); + } + + pub fn set_stamp_tax_rate_after_change(&mut self, value: f64) { + self.fixed.stamp_tax_rate_after_change = + Self::fixed_money(value, "stamp tax rate after change"); + } + + pub fn set_stamp_tax_change_date(&mut self, value: NaiveDate) { + self.fixed.stamp_tax_change_date = value; + } + + pub fn with_commission_rate(mut self, value: f64) -> Self { + self.set_commission_rate(value); + self + } + + pub fn with_minimum_commission(mut self, value: f64) -> Self { + self.set_minimum_commission(value); + self + } + + pub fn with_stamp_tax_rates(mut self, before: f64, after: f64) -> Self { + self.set_stamp_tax_rate_before_change(before); + self.set_stamp_tax_rate_after_change(after); + self + } + pub fn commission_for(&self, gross_amount: f64) -> f64 { if gross_amount <= 0.0 { return 0.0; } - (gross_amount * self.commission_rate).max(self.minimum_commission) + self.fixed_model() + .commission_for(Self::fixed_money(gross_amount, "gross amount")) + .to_f64() } pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 { - if date < self.stamp_tax_change_date { - self.stamp_tax_rate_before_change - } else { - self.stamp_tax_rate_after_change - } + self.fixed.stamp_tax_rate_for(date).to_f64() } pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 { if gross_amount <= 0.0 || side == OrderSide::Buy { return 0.0; } - gross_amount * self.stamp_tax_rate_for(date) + self.fixed_model() + .stamp_tax_for(date, side, Self::fixed_money(gross_amount, "gross amount")) + .to_f64() } pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 { if gross_amount <= 0.0 { return 0.0; } - gross_amount * self.transfer_fee_rate + self.fixed_model() + .transfer_fee_for(Self::fixed_money(gross_amount, "gross amount")) + .to_f64() } pub fn commission_for_order_fill( @@ -100,31 +160,29 @@ impl ChinaAShareCostModel { return 0.0; } - let raw_commission = gross_amount * self.commission_rate; let Some(order_id) = order_id else { - return raw_commission.max(self.minimum_commission); + return self.commission_for(gross_amount); }; let remaining_minimum = commission_state .entry(order_id) - .or_insert(self.minimum_commission); - if raw_commission > *remaining_minimum { - let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 { - raw_commission - } else { - raw_commission - *remaining_minimum - }; - *remaining_minimum = 0.0; - charged - } else { - let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 { - self.minimum_commission - } else { - 0.0 - }; - *remaining_minimum -= raw_commission; - charged - } + .or_insert(self.fixed.minimum_commission.to_f64()); + let mut fixed_remaining = Self::fixed_money(*remaining_minimum, "remaining commission"); + let charged = self.fixed_model().commission_for_order_fill_remaining( + Self::fixed_money(gross_amount, "gross amount"), + &mut fixed_remaining, + ); + *remaining_minimum = fixed_remaining.to_f64(); + charged.to_f64() + } + + fn fixed_money(value: f64, label: &str) -> FixedMoney { + FixedMoney::from_f64(value) + .unwrap_or_else(|| panic!("{label} is not representable as fixed-point money: {value}")) + } + + fn fixed_model(&self) -> FixedChinaAShareCostModel { + self.fixed } } @@ -138,15 +196,11 @@ impl CostModel for ChinaAShareCostModel { }; } - let commission = self.commission_for(gross_amount); - let stamp_tax = self.stamp_tax_for(date, side, gross_amount); - let transfer_fee = self.transfer_fee_for(gross_amount); - - TradingCost { - commission, - stamp_tax, - transfer_fee, - } + TradingCost::from_fixed(self.fixed_model().calculate( + date, + side, + Self::fixed_money(gross_amount, "gross amount"), + )) } fn calculate_with_order_state( @@ -165,15 +219,25 @@ impl CostModel for ChinaAShareCostModel { }; } - let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state); - let stamp_tax = self.stamp_tax_for(date, side, gross_amount); - let transfer_fee = self.transfer_fee_for(gross_amount); - - TradingCost { + let fixed_model = self.fixed_model(); + let fixed_gross = Self::fixed_money(gross_amount, "gross amount"); + let commission = if let Some(order_id) = order_id { + let remaining = commission_state + .entry(order_id) + .or_insert(self.fixed.minimum_commission.to_f64()); + let mut fixed_remaining = Self::fixed_money(*remaining, "remaining commission"); + let commission = + fixed_model.commission_for_order_fill_remaining(fixed_gross, &mut fixed_remaining); + *remaining = fixed_remaining.to_f64(); + commission + } else { + fixed_model.commission_for(fixed_gross) + }; + TradingCost::from_fixed(FixedTradingCost { commission, - stamp_tax, - transfer_fee, - } + stamp_tax: fixed_model.stamp_tax_for(date, side, fixed_gross), + transfer_fee: fixed_model.transfer_fee_for(fixed_gross), + }) } } @@ -182,13 +246,13 @@ mod tests { use super::*; #[test] - fn default_matches_configurable_trading_constraints() { + fn default_quantizes_fees_to_micro_yuan() { let model = ChinaAShareCostModel::default(); let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date"); - assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9); + assert!((model.commission_for(248_059.812) - 74.417944).abs() < 1e-12); assert!( - (model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9 + (model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.873504).abs() < 1e-12 ); } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index a318cf1..fd30cfc 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3217,7 +3217,9 @@ where }); if outcome.cash_delta.abs() > f64::EPSILON { let cash_before = portfolio.cash(); - portfolio.apply_cash_delta(outcome.cash_delta); + portfolio + .apply_cash_delta(outcome.cash_delta) + .map_err(BacktestError::Execution)?; report.account_events.push(AccountEvent { date, cash_before, @@ -3279,7 +3281,9 @@ where if reinvest_quantity > 0 { let reinvest_cash = reinvest_quantity as f64 * price; let residual_cash = receivable.amount - reinvest_cash; - portfolio.apply_cash_delta(-reinvest_cash); + portfolio + .apply_cash_delta(-reinvest_cash) + .map_err(BacktestError::Execution)?; portfolio.position_mut(&receivable.symbol).buy( date, reinvest_quantity, diff --git a/crates/fidc-core/src/fixed_point.rs b/crates/fidc-core/src/fixed_point.rs index ccd2d79..ff309a0 100644 --- a/crates/fidc-core/src/fixed_point.rs +++ b/crates/fidc-core/src/fixed_point.rs @@ -1,10 +1,9 @@ -//! Independent fixed-point acceptance model for money and fee arithmetic. +//! Fixed-point execution primitives for money and fee arithmetic. //! -//! The execution kernel still exposes f64 because prices and source rows are -//! represented that way today. This module is deliberately separate: it is a -//! deterministic shadow model used to prove that cash, fees, budget checks, -//! FIFO PnL, and external cash flows do not depend on binary floating-point -//! accumulation. +//! Market data and analytics remain floating point at their API boundaries. +//! The execution kernel quantizes monetary values to micro-yuan before fee, +//! budget and cash-ledger arithmetic so repeated fills and external cash flows +//! do not accumulate binary floating-point drift. use std::collections::{BTreeMap, VecDeque}; @@ -105,6 +104,10 @@ impl FixedMoney { self.0.checked_mul(i128::from(quantity)).map(Self) } + pub fn checked_neg(self) -> Option { + self.0.checked_neg().map(Self) + } + pub fn checked_mul_rate(self, rate: Self) -> Option { let product = self.0.checked_mul(rate.0)?; let half = MONEY_SCALE / 2; @@ -116,6 +119,20 @@ impl FixedMoney { Some(Self(rounded)) } + pub fn checked_sum_f64(values: impl IntoIterator) -> Option { + values.into_iter().try_fold(Self::ZERO, |total, value| { + total.checked_add(Self::from_f64(value)?) + }) + } + + pub fn f64_fits_within(value: f64, limit: f64) -> Option { + let value = Self::from_f64(value)?; + if limit == f64::INFINITY { + return Some(true); + } + Some(value <= Self::from_f64(limit)?) + } + pub fn abs(self) -> Self { Self(self.0.abs()) } @@ -217,6 +234,20 @@ impl FixedChinaAShareCostModel { let remaining = commission_state .entry(order_id) .or_insert(self.minimum_commission); + self.commission_for_order_fill_remaining(gross_amount, remaining) + } + + pub fn commission_for_order_fill_remaining( + self, + gross_amount: FixedMoney, + remaining: &mut FixedMoney, + ) -> FixedMoney { + if gross_amount.raw() <= 0 { + return FixedMoney::ZERO; + } + let raw = gross_amount + .checked_mul_rate(self.commission_rate) + .expect("fixed commission multiplication overflow"); if raw > *remaining { let charged = if *remaining == self.minimum_commission { raw @@ -415,7 +446,7 @@ mod tests { } #[test] - fn fixed_cost_matches_float_cost_model_within_one_micro_yuan() { + fn runtime_cost_model_matches_fixed_execution_primitive() { let fixed = fixed_model(); let float = ChinaAShareCostModel::default(); let dates = [ @@ -433,13 +464,7 @@ mod tests { (actual.stamp_tax, expected.stamp_tax), (actual.transfer_fee, expected.transfer_fee), ] { - assert!( - (actual.to_f64() - expected).abs() <= 1.0 / MONEY_SCALE_F64, - "fixed={} float={} gross={} date={date} side={side:?}", - actual.to_f64(), - expected, - gross - ); + assert_eq!(actual.to_f64(), expected); } } } @@ -447,7 +472,7 @@ mod tests { } #[test] - fn fixed_order_commission_state_matches_float_order_split() { + fn runtime_split_commission_matches_fixed_execution_primitive() { let fixed = fixed_model(); let float = ChinaAShareCostModel::default(); let mut fixed_state = BTreeMap::new(); @@ -464,7 +489,7 @@ mod tests { fixed_total = fixed_total.checked_add(fixed_fee).unwrap(); float_total += float_fee; } - assert!((fixed_total.to_f64() - float_total).abs() <= 4.0 / MONEY_SCALE_F64); + assert_eq!(fixed_total.to_f64(), float_total); } #[test] @@ -493,6 +518,16 @@ mod tests { assert!(quantity < 5_000); } + #[test] + fn fixed_budget_comparison_rejects_one_micro_yuan_overrun() { + assert_eq!(FixedMoney::f64_fits_within(100.0, 100.0), Some(true)); + assert_eq!(FixedMoney::f64_fits_within(100.000001, 100.0), Some(false)); + assert_eq!( + FixedMoney::f64_fits_within(100.000001, f64::INFINITY), + Some(true) + ); + } + #[test] fn fixed_fifo_pnl_and_external_flow_are_deterministic() { let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 66e6019..6eef6f4 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -14,6 +14,7 @@ use crate::data::{ }; use crate::engine::BacktestError; use crate::events::OrderSide; +use crate::fixed_point::FixedMoney; use crate::numeric_expr_vm::{ self, EvalError as NumericVmEvalError, Program as NumericVmProgram, Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, @@ -1904,14 +1905,47 @@ impl PlatformExprStrategy { (holding_days >= max_days).then_some(holding_days) } - fn buy_commission(&self, gross_amount: f64) -> f64 { - self.cost_model().commission_for(gross_amount) + fn buy_cost(&self, gross_amount: f64) -> f64 { + let model = self.cost_model(); + FixedMoney::checked_sum_f64([ + model.commission_for(gross_amount), + model.transfer_fee_for(gross_amount), + ]) + .expect("projected buy costs must be finite fixed-point money") + .to_f64() } fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { let model = self.cost_model(); - model.commission_for(gross_amount) - + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) + FixedMoney::checked_sum_f64([ + model.commission_for(gross_amount), + model.stamp_tax_for(date, OrderSide::Sell, gross_amount), + model.transfer_fee_for(gross_amount), + ]) + .expect("projected sell costs must be finite fixed-point money") + .to_f64() + } + + fn buy_cash_out(&self, gross_amount: f64) -> f64 { + FixedMoney::checked_sum_f64([gross_amount, self.buy_cost(gross_amount)]) + .expect("projected buy cash must be finite fixed-point money") + .to_f64() + } + + fn sell_net_cash(&self, date: NaiveDate, gross_amount: f64) -> f64 { + let gross = FixedMoney::from_f64(gross_amount) + .expect("projected sell gross must be finite fixed-point money"); + gross + .checked_sub( + FixedMoney::from_f64(self.sell_cost(date, gross.to_f64())) + .expect("projected sell costs must be finite fixed-point money"), + ) + .expect("projected sell proceeds underflow") + .to_f64() + } + + fn fixed_cash_fits(value: f64, limit: f64) -> bool { + FixedMoney::f64_fits_within(value, limit).unwrap_or(false) } fn cost_model(&self) -> ChinaAShareCostModel { @@ -1919,19 +1953,19 @@ impl PlatformExprStrategy { self.config.risk_config.trading_constraints, ); if let Some(value) = self.config.commission_rate { - model.commission_rate = value; + model.set_commission_rate(value); } if let Some(value) = self.config.minimum_commission { - model.minimum_commission = value; + model.set_minimum_commission(value); } if let Some(value) = self.config.stamp_tax_rate_before_change { - model.stamp_tax_rate_before_change = value; + model.set_stamp_tax_rate_before_change(value); } if let Some(value) = self.config.stamp_tax_rate_after_change { - model.stamp_tax_rate_after_change = value; + model.set_stamp_tax_rate_after_change(value); } if let Some(value) = self.config.stamp_tax_change_date { - model.stamp_tax_change_date = value; + model.set_stamp_tax_change_date(value); } model } @@ -2026,7 +2060,7 @@ impl PlatformExprStrategy { self.round_lot_quantity(raw_quantity, minimum_order_quantity, order_step_size); while quantity >= minimum { let gross_amount = price * quantity as f64; - if gross_amount + self.buy_commission(gross_amount) <= value_budget + 1e-6 { + if Self::fixed_cash_fits(self.buy_cash_out(gross_amount), value_budget) { return quantity; } quantity = @@ -2609,7 +2643,9 @@ impl PlatformExprStrategy { break; } let candidate_gross = gross_amount + quote_price * take_qty as f64; - if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + if gross_limit + .is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit)) + { take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, @@ -2617,7 +2653,7 @@ impl PlatformExprStrategy { ); continue; } - if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { + if Self::fixed_cash_fits(self.buy_cash_out(candidate_gross), cash) { break; } take_qty = self.decrement_order_quantity( @@ -2772,12 +2808,14 @@ impl PlatformExprStrategy { } })?; let gross_amount = fill.price * fill.quantity as f64; - let sell_cost = self.sell_cost(date, gross_amount); + let net_cash = self.sell_net_cash(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) .ok()?; - projected.apply_cash_delta(gross_amount - sell_cost); + projected + .apply_cash_delta(net_cash) + .expect("projected sell cash must fit fixed-point ledger"); *execution_state .intraday_turnover .entry(symbol.to_string()) @@ -2872,12 +2910,14 @@ impl PlatformExprStrategy { execution_state, )?; let gross_amount = fill.price * fill.quantity as f64; - let sell_cost = self.sell_cost(date, gross_amount); + let net_cash = self.sell_net_cash(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) .ok()?; - projected.apply_cash_delta(gross_amount - sell_cost); + projected + .apply_cash_delta(net_cash) + .expect("projected sell cash must fit fixed-point ledger"); *execution_state .intraday_turnover .entry(symbol.to_string()) @@ -3339,8 +3379,8 @@ impl PlatformExprStrategy { }; while quantity > 0 { let gross_amount = sizing_price * quantity as f64; - if gross_limit.map_or(true, |limit| gross_amount <= limit + 1e-6) - && gross_amount + self.buy_commission(gross_amount) <= cash_limit + 1e-6 + if gross_limit.is_none_or(|limit| Self::fixed_cash_fits(gross_amount, limit)) + && Self::fixed_cash_fits(self.buy_cash_out(gross_amount), cash_limit) { break; } @@ -3411,11 +3451,13 @@ impl PlatformExprStrategy { return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); }; let gross_amount = fill.price * fill.quantity as f64; - let cash_out = gross_amount + self.buy_commission(gross_amount); - if cash_out > cash_limit + 1e-6 { + let cash_out = self.buy_cash_out(gross_amount); + if !Self::fixed_cash_fits(cash_out, cash_limit) { return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); } - projected.apply_cash_delta(-cash_out); + projected + .apply_cash_delta(-cash_out) + .expect("projected buy cash must fit fixed-point ledger"); projected .position_mut(symbol) .buy(date, fill.quantity, fill.price); @@ -13401,8 +13443,8 @@ mod tests { cfg.minimum_commission = Some(5.0); let strategy = PlatformExprStrategy::new(cfg); - assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9); - assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9); + assert!((strategy.buy_cost(100_000.0) - 30.0).abs() < 1e-9); + assert!((strategy.buy_cost(1_000.0) - 5.0).abs() < 1e-9); } #[test] @@ -13412,7 +13454,7 @@ mod tests { cfg.minimum_commission = Some(0.0); let strategy = PlatformExprStrategy::new(cfg); - assert!((strategy.buy_commission(1_000.0) - 0.3).abs() < 1e-9); + assert!((strategy.buy_cost(1_000.0) - 0.3).abs() < 1e-9); } #[test] @@ -18211,7 +18253,7 @@ mod tests { portfolio .position_mut(symbol) .buy(d(2023, 5, 4), 30_000, 10.0); - portfolio.apply_cash_delta(-300_000.0); + portfolio.apply_cash_delta(-300_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -18324,7 +18366,7 @@ mod tests { portfolio .position_mut(symbol) .buy(d(2023, 5, 4), 30_000, 10.0); - portfolio.apply_cash_delta(-300_000.0); + portfolio.apply_cash_delta(-300_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -18455,7 +18497,7 @@ mod tests { portfolio .position_mut(symbol) .buy(d(2023, 5, 4), 30_000, 10.0); - portfolio.apply_cash_delta(-300_000.0); + portfolio.apply_cash_delta(-300_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -18582,7 +18624,7 @@ mod tests { portfolio .position_mut(symbol) .buy(d(2023, 5, 4), 30_000, 10.0); - portfolio.apply_cash_delta(-300_000.0); + portfolio.apply_cash_delta(-300_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date, @@ -18736,7 +18778,7 @@ mod tests { portfolio .position_mut("000002.SZ") .buy(prev_date, 2_400, 10.0); - portfolio.apply_cash_delta(-54_000.0); + portfolio.apply_cash_delta(-54_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -18897,7 +18939,7 @@ mod tests { .expect("dataset"); let mut portfolio = PortfolioState::new(100_000.0); portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.0); - portfolio.apply_cash_delta(-30_000.0); + portfolio.apply_cash_delta(-30_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -19053,7 +19095,7 @@ mod tests { .expect("dataset"); let mut portfolio = PortfolioState::new(100_000.0); portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.0); - portfolio.apply_cash_delta(-30_000.0); + portfolio.apply_cash_delta(-30_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -19209,7 +19251,7 @@ mod tests { .expect("dataset"); let mut portfolio = PortfolioState::new(100_000.0); portfolio.position_mut(symbol).buy(prev_date, 3_000, 10.0); - portfolio.apply_cash_delta(-30_000.0); + portfolio.apply_cash_delta(-30_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -19370,7 +19412,7 @@ mod tests { portfolio .position_mut("000002.SZ") .buy(prev_date, 2_400, 10.0); - portfolio.apply_cash_delta(-54_000.0); + portfolio.apply_cash_delta(-54_000.0).unwrap(); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index e867b99..db00ff7 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -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, cash_receivables: Vec, pending_cash_flows: Vec, @@ -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 { @@ -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, ) -> 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::(); - let outgoing = due - .iter() - .filter(|flow| flow.amount < 0.0) - .map(|flow| flow.amount) - .sum::(); - 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::from_f64(value) + .ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}")) + } + + fn sum_fixed_money( + values: impl IntoIterator, + label: &str, + ) -> Result { + 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::::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"); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index ae198b2..8c8aed1 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -10,6 +10,7 @@ use crate::data::{ }; use crate::engine::BacktestError; use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}; +use crate::fixed_point::FixedMoney; use crate::futures::{FuturesAccountState, FuturesOrderIntent}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; @@ -1750,14 +1751,47 @@ impl OmniMicroCapStrategy { ChinaAShareCostModel::from_trading_constraints(self.config.risk_config.trading_constraints) } - fn buy_commission(&self, gross_amount: f64) -> f64 { - self.cost_model().commission_for(gross_amount) + fn buy_cost(&self, gross_amount: f64) -> f64 { + let model = self.cost_model(); + FixedMoney::checked_sum_f64([ + model.commission_for(gross_amount), + model.transfer_fee_for(gross_amount), + ]) + .expect("projected buy costs must be finite fixed-point money") + .to_f64() } fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { let model = self.cost_model(); - model.commission_for(gross_amount) - + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) + FixedMoney::checked_sum_f64([ + model.commission_for(gross_amount), + model.stamp_tax_for(date, OrderSide::Sell, gross_amount), + model.transfer_fee_for(gross_amount), + ]) + .expect("projected sell costs must be finite fixed-point money") + .to_f64() + } + + fn buy_cash_out(&self, gross_amount: f64) -> f64 { + FixedMoney::checked_sum_f64([gross_amount, self.buy_cost(gross_amount)]) + .expect("projected buy cash must be finite fixed-point money") + .to_f64() + } + + fn sell_net_cash(&self, date: NaiveDate, gross_amount: f64) -> f64 { + let gross = FixedMoney::from_f64(gross_amount) + .expect("projected sell gross must be finite fixed-point money"); + gross + .checked_sub( + FixedMoney::from_f64(self.sell_cost(date, gross.to_f64())) + .expect("projected sell costs must be finite fixed-point money"), + ) + .expect("projected sell proceeds underflow") + .to_f64() + } + + fn fixed_cash_fits(value: f64, limit: f64) -> bool { + FixedMoney::f64_fits_within(value, limit).unwrap_or(false) } fn round_lot_quantity( @@ -1826,7 +1860,7 @@ impl OmniMicroCapStrategy { let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100); while quantity > 0 { let gross_amount = execution_price * quantity as f64; - if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 { + if Self::fixed_cash_fits(self.buy_cash_out(gross_amount), cash) { return quantity; } quantity = self.decrement_order_quantity(quantity, 100, 100); @@ -1874,8 +1908,10 @@ impl OmniMicroCapStrategy { ); while snapshot_requested_qty > 0 { let gross_amount = sizing_price * snapshot_requested_qty as f64; - let cash_out = gross_amount + self.buy_commission(gross_amount); - if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { + let cash_out = self.buy_cash_out(gross_amount); + if Self::fixed_cash_fits(cash_out, order_value) + && Self::fixed_cash_fits(cash_out, projected.cash()) + { break; } snapshot_requested_qty = self.decrement_order_quantity( @@ -1902,8 +1938,10 @@ impl OmniMicroCapStrategy { let mut quantity = snapshot_requested_qty; while quantity > 0 { let gross_amount = projected_execution_price * quantity as f64; - let cash_out = gross_amount + self.buy_commission(gross_amount); - if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { + let cash_out = self.buy_cash_out(gross_amount); + if Self::fixed_cash_fits(cash_out, order_value) + && Self::fixed_cash_fits(cash_out, projected.cash()) + { break; } quantity = @@ -1918,8 +1956,10 @@ impl OmniMicroCapStrategy { .unwrap_or(projected_execution_price); while quantity > 0 { let gross_amount = execution_price * quantity as f64; - let cash_out = gross_amount + self.buy_commission(gross_amount); - if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { + let cash_out = self.buy_cash_out(gross_amount); + if Self::fixed_cash_fits(cash_out, order_value) + && Self::fixed_cash_fits(cash_out, projected.cash()) + { break; } quantity = @@ -1934,11 +1974,15 @@ impl OmniMicroCapStrategy { next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1), }; let gross_amount = fill.price * fill.quantity as f64; - let cash_out = gross_amount + self.buy_commission(gross_amount); - if cash_out > projected.cash() + 1e-6 || cash_out > order_value + 1e-6 { + let cash_out = self.buy_cash_out(gross_amount); + if !Self::fixed_cash_fits(cash_out, projected.cash()) + || !Self::fixed_cash_fits(cash_out, order_value) + { return 0; } - projected.apply_cash_delta(-cash_out); + projected + .apply_cash_delta(-cash_out) + .expect("projected buy cash must fit fixed-point ledger"); projected .position_mut(symbol) .buy(date, fill.quantity, fill.price); @@ -1994,12 +2038,14 @@ impl OmniMicroCapStrategy { + Duration::seconds(1), }); let gross_amount = fill.price * fill.quantity as f64; - let net_cash = gross_amount - self.sell_cost(date, gross_amount); + let net_cash = self.sell_net_cash(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) .ok()?; - projected.apply_cash_delta(net_cash); + projected + .apply_cash_delta(net_cash) + .expect("projected sell cash must fit fixed-point ledger"); *execution_state .intraday_turnover .entry(symbol.to_string()) @@ -2144,7 +2190,9 @@ impl OmniMicroCapStrategy { ); while take_qty > 0 { let candidate_gross = execution_price * take_qty as f64; - if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + if gross_limit + .is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit)) + { take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, @@ -2152,9 +2200,8 @@ impl OmniMicroCapStrategy { ); continue; } - let candidate_cash = - candidate_gross + self.buy_commission(candidate_gross); - if candidate_cash <= cash + 1e-6 { + let candidate_cash = self.buy_cash_out(candidate_gross); + if Self::fixed_cash_fits(candidate_cash, cash) { break; } take_qty = self.decrement_order_quantity( @@ -2254,7 +2301,9 @@ impl OmniMicroCapStrategy { if let Some(cash) = cash_limit { while take_qty > 0 { let candidate_gross = gross_amount + quote_price * take_qty as f64; - if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + if gross_limit + .is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit)) + { take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, @@ -2262,7 +2311,7 @@ impl OmniMicroCapStrategy { ); continue; } - if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 { + if Self::fixed_cash_fits(self.buy_cash_out(candidate_gross), cash) { break; } take_qty = self.decrement_order_quantity( @@ -2870,8 +2919,8 @@ mod tests { .stamp_tax_rate_after_change = 0.0005; let strategy = OmniMicroCapStrategy::new(cfg); - assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9); - assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9); + assert!((strategy.buy_cost(100_000.0) - 30.0).abs() < 1e-9); + assert!((strategy.buy_cost(1_000.0) - 5.0).abs() < 1e-9); assert!( (strategy.sell_cost(NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), 100_000.0) - 80.0) .abs() diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index 643a3f9..0e34073 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -368,11 +368,9 @@ fn engine_reinvests_dividend_receivable_in_round_lots() { first_date: buy_date, }, BrokerSimulator::new_with_execution_price( - ChinaAShareCostModel { - commission_rate: 0.0008, - minimum_commission: 0.0, - ..ChinaAShareCostModel::default() - }, + ChinaAShareCostModel::default() + .with_commission_rate(0.0008) + .with_minimum_commission(0.0), ChinaEquityRuleHooks::default(), PriceField::Open, ), diff --git a/docs/engine-capability-roadmap.md b/docs/engine-capability-roadmap.md index 789d6ce..cba2ca3 100644 --- a/docs/engine-capability-roadmap.md +++ b/docs/engine-capability-roadmap.md @@ -39,6 +39,7 @@ futures path. Confirmed aligned areas: | P0 | Futures intraday matching | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. | | P0 | Futures open-order lifecycle | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires extra intermediate event names. | | P0 | Combined multi-account NAV | Closed. `DailyEquityPoint`, progress events, and metrics use aggregate stock + futures initial cash and total equity. | None. | +| P0 | Fixed-point execution money | Stock execution now freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows and account units. Market indicators and return statistics remain `f64` outside the execution boundary. | Migrate position cost/PnL and the standalone futures cash ledger only after independent artifact and performance A/B gates. | | P1 | Futures trading parameter data source | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. | | P1 | Futures transaction cost decider | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. | | P1 | Futures settlement price mode | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. | @@ -55,6 +56,8 @@ futures path. Confirmed aligned areas: - [x] Minute-level `time_rule` semantics including market-open, market-close, and physical-time style schedules. - [x] Fine-grained daily and minute execution quote strategy entrypoints. +- [x] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan + fixed-point execution primitive; one-micro over-budget orders fail. - [x] Scheduled actions evaluated against explicit intraday times. - [x] `update_universe`, `subscribe`, and `unsubscribe`. - [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.