From 1c04318ecf4ef4a5b367071c45cbeccf6311e0ac Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 24 Aug 2026 17:25:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AE=9A=E7=82=B9=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E7=B2=BE=E5=BA=A6=E9=AA=8C=E6=94=B6=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/fixed_point.rs | 531 ++++++++++++++++++++++++++++ crates/fidc-core/src/lib.rs | 5 + 2 files changed, 536 insertions(+) create mode 100644 crates/fidc-core/src/fixed_point.rs diff --git a/crates/fidc-core/src/fixed_point.rs b/crates/fidc-core/src/fixed_point.rs new file mode 100644 index 0000000..ccd2d79 --- /dev/null +++ b/crates/fidc-core/src/fixed_point.rs @@ -0,0 +1,531 @@ +//! Independent fixed-point acceptance model 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. + +use std::collections::{BTreeMap, VecDeque}; + +use chrono::NaiveDate; + +use crate::events::OrderSide; + +pub const MONEY_SCALE: i128 = 1_000_000; +const MONEY_SCALE_F64: f64 = MONEY_SCALE as f64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct FixedMoney(i128); + +impl FixedMoney { + pub const ZERO: Self = Self(0); + + pub const fn from_raw(raw: i128) -> Self { + Self(raw) + } + + pub const fn raw(self) -> i128 { + self.0 + } + + pub fn from_decimal_str(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("fixed money value is empty".to_string()); + } + let (negative, unsigned) = match value.as_bytes()[0] { + b'-' => (true, &value[1..]), + b'+' => (false, &value[1..]), + _ => (false, value), + }; + let mut parts = unsigned.split('.'); + let whole = parts.next().unwrap_or_default(); + let fractional = parts.next().unwrap_or_default(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fractional.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(format!("invalid fixed money decimal: {value}")); + } + let whole = whole + .parse::() + .map_err(|_| format!("fixed money whole part is out of range: {value}"))?; + let mut fractional_digits = fractional.as_bytes().to_vec(); + let round_up = fractional_digits.len() > 6 && fractional_digits[6] >= b'5'; + fractional_digits.truncate(6); + while fractional_digits.len() < 6 { + fractional_digits.push(b'0'); + } + let fractional = if fractional_digits.is_empty() { + 0 + } else { + std::str::from_utf8(&fractional_digits) + .expect("fractional digits are ASCII") + .parse::() + .map_err(|_| format!("fixed money fractional part is invalid: {value}"))? + }; + let mut raw = whole + .checked_mul(MONEY_SCALE) + .and_then(|raw| raw.checked_add(fractional)) + .ok_or_else(|| format!("fixed money value is out of range: {value}"))?; + if round_up { + raw = raw + .checked_add(1) + .ok_or_else(|| format!("fixed money value is out of range: {value}"))?; + } + Ok(Self(if negative { -raw } else { raw })) + } + + pub fn from_f64(value: f64) -> Option { + if !value.is_finite() { + return None; + } + let raw = (value * MONEY_SCALE_F64).round(); + if !raw.is_finite() || raw < i128::MIN as f64 || raw > i128::MAX as f64 { + return None; + } + Some(Self(raw as i128)) + } + + pub fn to_f64(self) -> f64 { + self.0 as f64 / MONEY_SCALE_F64 + } + + pub fn checked_add(self, other: Self) -> Option { + self.0.checked_add(other.0).map(Self) + } + + pub fn checked_sub(self, other: Self) -> Option { + self.0.checked_sub(other.0).map(Self) + } + + pub fn checked_mul_quantity(self, quantity: u64) -> Option { + self.0.checked_mul(i128::from(quantity)).map(Self) + } + + pub fn checked_mul_rate(self, rate: Self) -> Option { + let product = self.0.checked_mul(rate.0)?; + let half = MONEY_SCALE / 2; + let rounded = if product >= 0 { + product.checked_add(half)? / MONEY_SCALE + } else { + product.checked_sub(half)? / MONEY_SCALE + }; + Some(Self(rounded)) + } + + pub fn abs(self) -> Self { + Self(self.0.abs()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FixedTradingCost { + pub commission: FixedMoney, + pub stamp_tax: FixedMoney, + pub transfer_fee: FixedMoney, +} + +impl FixedTradingCost { + pub fn total(self) -> FixedMoney { + FixedMoney::from_raw(self.commission.raw() + self.stamp_tax.raw() + self.transfer_fee.raw()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FixedChinaAShareCostModel { + pub commission_rate: FixedMoney, + pub stamp_tax_rate_before_change: FixedMoney, + pub stamp_tax_rate_after_change: FixedMoney, + pub stamp_tax_change_date: NaiveDate, + pub minimum_commission: FixedMoney, + pub transfer_fee_rate: FixedMoney, +} + +impl FixedChinaAShareCostModel { + pub fn commission_for(self, gross_amount: 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"); + raw.max(self.minimum_commission) + } + + pub fn stamp_tax_rate_for(self, date: NaiveDate) -> FixedMoney { + if date < self.stamp_tax_change_date { + self.stamp_tax_rate_before_change + } else { + self.stamp_tax_rate_after_change + } + } + + pub fn stamp_tax_for( + self, + date: NaiveDate, + side: OrderSide, + gross_amount: FixedMoney, + ) -> FixedMoney { + if gross_amount.raw() <= 0 || side == OrderSide::Buy { + return FixedMoney::ZERO; + } + gross_amount + .checked_mul_rate(self.stamp_tax_rate_for(date)) + .expect("fixed stamp tax multiplication overflow") + } + + pub fn transfer_fee_for(self, gross_amount: FixedMoney) -> FixedMoney { + if gross_amount.raw() <= 0 { + return FixedMoney::ZERO; + } + gross_amount + .checked_mul_rate(self.transfer_fee_rate) + .expect("fixed transfer fee multiplication overflow") + } + + pub fn calculate( + self, + date: NaiveDate, + side: OrderSide, + gross_amount: FixedMoney, + ) -> FixedTradingCost { + FixedTradingCost { + commission: self.commission_for(gross_amount), + stamp_tax: self.stamp_tax_for(date, side, gross_amount), + transfer_fee: self.transfer_fee_for(gross_amount), + } + } + + pub fn commission_for_order_fill( + self, + gross_amount: FixedMoney, + order_id: Option, + commission_state: &mut BTreeMap, + ) -> FixedMoney { + if gross_amount.raw() <= 0 { + return FixedMoney::ZERO; + } + let raw = gross_amount + .checked_mul_rate(self.commission_rate) + .expect("fixed commission multiplication overflow"); + let Some(order_id) = order_id else { + return raw.max(self.minimum_commission); + }; + let remaining = commission_state + .entry(order_id) + .or_insert(self.minimum_commission); + if raw > *remaining { + let charged = if *remaining == self.minimum_commission { + raw + } else { + raw.checked_sub(*remaining) + .expect("fixed remaining commission underflow") + }; + *remaining = FixedMoney::ZERO; + charged + } else { + let charged = if *remaining == self.minimum_commission { + self.minimum_commission + } else { + FixedMoney::ZERO + }; + *remaining = remaining + .checked_sub(raw) + .expect("fixed remaining commission underflow"); + charged + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FixedLot { + pub acquired_date: NaiveDate, + pub quantity: u64, + pub entry_price: FixedMoney, +} + +#[derive(Debug, Clone, Default)] +pub struct FixedLotBook { + lots: VecDeque, + pub realized_pnl: FixedMoney, + pub quantity: u64, +} + +impl FixedLotBook { + pub fn buy(&mut self, date: NaiveDate, quantity: u64, price: FixedMoney) { + if quantity == 0 { + return; + } + self.lots.push_back(FixedLot { + acquired_date: date, + quantity, + entry_price: price, + }); + self.quantity = self.quantity.saturating_add(quantity); + } + + pub fn sell(&mut self, quantity: u64, price: FixedMoney) -> Result { + if quantity > self.quantity { + return Err(format!( + "fixed sell quantity {} exceeds current quantity {}", + quantity, self.quantity + )); + } + let mut remaining = quantity; + let mut realized = FixedMoney::ZERO; + while remaining > 0 { + let Some(mut lot) = self.lots.pop_front() else { + return Err("fixed lot book is empty while selling".to_string()); + }; + let sold = remaining.min(lot.quantity); + let price_delta = price + .checked_sub(lot.entry_price) + .and_then(|delta| delta.checked_mul_quantity(sold)) + .ok_or_else(|| "fixed realized PnL overflow".to_string())?; + realized = realized + .checked_add(price_delta) + .ok_or_else(|| "fixed realized PnL overflow".to_string())?; + lot.quantity -= sold; + remaining -= sold; + if lot.quantity > 0 { + self.lots.push_front(lot); + } + } + self.quantity -= quantity; + self.realized_pnl = self + .realized_pnl + .checked_add(realized) + .ok_or_else(|| "fixed realized PnL overflow".to_string())?; + Ok(realized) + } + + pub fn market_value(&self, mark_price: FixedMoney) -> FixedMoney { + mark_price + .checked_mul_quantity(self.quantity) + .expect("fixed market value overflow") + } + + pub fn unrealized_pnl(&self, mark_price: FixedMoney) -> FixedMoney { + self.lots.iter().fold(FixedMoney::ZERO, |total, lot| { + let delta = mark_price + .checked_sub(lot.entry_price) + .and_then(|value| value.checked_mul_quantity(lot.quantity)) + .expect("fixed unrealized PnL overflow"); + total + .checked_add(delta) + .expect("fixed unrealized PnL overflow") + }) + } +} + +#[derive(Debug, Clone)] +pub struct FixedAccount { + pub cash: FixedMoney, + pub units: FixedMoney, + pub external_cash_flow_total: FixedMoney, +} + +impl FixedAccount { + pub fn new(initial_cash: FixedMoney) -> Self { + Self { + cash: initial_cash, + units: initial_cash, + external_cash_flow_total: FixedMoney::ZERO, + } + } + + pub fn apply_external_cash_flow( + &mut self, + amount: FixedMoney, + unit_nav: FixedMoney, + ) -> Result<(), String> { + if unit_nav.raw() <= 0 { + return Err("fixed unit NAV must be positive".to_string()); + } + let exact_units_raw = amount + .raw() + .checked_mul(MONEY_SCALE) + .and_then(|value| value.checked_div(unit_nav.raw())) + .ok_or_else(|| "fixed external flow unit conversion overflow".to_string())?; + self.cash = self + .cash + .checked_add(amount) + .ok_or_else(|| "fixed cash overflow".to_string())?; + self.units = self + .units + .checked_add(FixedMoney::from_raw(exact_units_raw)) + .ok_or_else(|| "fixed units overflow".to_string())?; + self.external_cash_flow_total = self + .external_cash_flow_total + .checked_add(amount) + .ok_or_else(|| "fixed external flow overflow".to_string())?; + Ok(()) + } + + pub fn unit_nav(&self, total_equity: FixedMoney) -> Result { + if self.units.raw() <= 0 { + return Err("fixed account has no units".to_string()); + } + let raw = total_equity + .raw() + .checked_mul(MONEY_SCALE) + .and_then(|value| value.checked_div(self.units.raw())) + .ok_or_else(|| "fixed unit NAV overflow".to_string())?; + Ok(FixedMoney::from_raw(raw)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cost::{ChinaAShareCostModel, CostModel}; + use crate::risk_control::TradingConstraintConfig; + + fn fixed_model() -> FixedChinaAShareCostModel { + let config = TradingConstraintConfig::default(); + FixedChinaAShareCostModel { + commission_rate: FixedMoney::from_f64(config.commission_rate).unwrap(), + stamp_tax_rate_before_change: FixedMoney::from_f64(config.stamp_tax_rate_before_change) + .unwrap(), + stamp_tax_rate_after_change: FixedMoney::from_f64(config.stamp_tax_rate_after_change) + .unwrap(), + stamp_tax_change_date: config.stamp_tax_change_date, + minimum_commission: FixedMoney::from_f64(config.minimum_commission).unwrap(), + transfer_fee_rate: FixedMoney::from_f64(config.transfer_fee_rate).unwrap(), + } + } + + #[test] + fn decimal_parser_rounds_only_beyond_money_scale() { + assert_eq!( + FixedMoney::from_decimal_str("1.234567").unwrap().raw(), + 1_234_567 + ); + assert_eq!( + FixedMoney::from_decimal_str("1.2345675").unwrap().raw(), + 1_234_568 + ); + assert_eq!( + FixedMoney::from_decimal_str("-0.0000014").unwrap().raw(), + -1 + ); + } + + #[test] + fn fixed_cost_matches_float_cost_model_within_one_micro_yuan() { + let fixed = fixed_model(); + let float = ChinaAShareCostModel::default(); + let dates = [ + NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(), + ]; + for gross in [0.01, 10.0, 16_666.67, 248_059.812, 1_000_000.01] { + let fixed_gross = FixedMoney::from_f64(gross).unwrap(); + for date in dates { + for side in [OrderSide::Buy, OrderSide::Sell] { + let expected = float.calculate(date, side, gross); + let actual = fixed.calculate(date, side, fixed_gross); + for (actual, expected) in [ + (actual.commission, expected.commission), + (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 + ); + } + } + } + } + } + + #[test] + fn fixed_order_commission_state_matches_float_order_split() { + let fixed = fixed_model(); + let float = ChinaAShareCostModel::default(); + let mut fixed_state = BTreeMap::new(); + let mut float_state = BTreeMap::new(); + let mut fixed_total = FixedMoney::ZERO; + let mut float_total = 0.0; + for gross in [1000.0, 2000.0, 4000.0, 40_000.0] { + let fixed_fee = fixed.commission_for_order_fill( + FixedMoney::from_f64(gross).unwrap(), + Some(42), + &mut fixed_state, + ); + let float_fee = float.commission_for_order_fill(gross, Some(42), &mut float_state); + 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); + } + + #[test] + fn fixed_budget_never_exceeds_cash_after_cost() { + let model = fixed_model(); + let date = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap(); + let cash = FixedMoney::from_decimal_str("99880.00").unwrap(); + let price = FixedMoney::from_decimal_str("19.9731").unwrap(); + let mut quantity = 5_000u64; + while quantity > 0 { + let gross = price.checked_mul_quantity(quantity).unwrap(); + if gross + .checked_add(model.calculate(date, OrderSide::Buy, gross).total()) + .unwrap() + <= cash + { + break; + } + quantity -= 100; + } + let gross = price.checked_mul_quantity(quantity).unwrap(); + let total = gross + .checked_add(model.calculate(date, OrderSide::Buy, gross).total()) + .unwrap(); + assert!(total <= cash); + assert!(quantity < 5_000); + } + + #[test] + fn fixed_fifo_pnl_and_external_flow_are_deterministic() { + let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let day_two = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); + let mut book = FixedLotBook::default(); + book.buy(day_one, 100, FixedMoney::from_decimal_str("10.01").unwrap()); + book.buy(day_two, 100, FixedMoney::from_decimal_str("10.03").unwrap()); + let realized = book + .sell(150, FixedMoney::from_decimal_str("10.11").unwrap()) + .unwrap(); + assert_eq!(realized.raw(), 14_000_000); + assert_eq!(book.quantity, 50); + assert_eq!( + book.unrealized_pnl(FixedMoney::from_decimal_str("10.20").unwrap()) + .raw(), + 8_500_000 + ); + + let mut account = FixedAccount::new(FixedMoney::from_decimal_str("100.00").unwrap()); + account + .apply_external_cash_flow( + FixedMoney::from_decimal_str("50.00").unwrap(), + FixedMoney::from_decimal_str("1.00").unwrap(), + ) + .unwrap(); + assert_eq!(account.units.raw(), 150 * MONEY_SCALE); + assert_eq!( + account + .unit_nav(FixedMoney::from_decimal_str("150.00").unwrap()) + .unwrap() + .raw(), + MONEY_SCALE + ); + assert_eq!(account.external_cash_flow_total.raw(), 50 * MONEY_SCALE); + } +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index c25cafe..a2de0c6 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod data; pub mod engine; pub mod event_bus; pub mod events; +pub mod fixed_point; pub mod futures; pub mod instrument; pub mod metrics; @@ -43,6 +44,10 @@ pub use events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; +pub use fixed_point::{ + FixedAccount, FixedChinaAShareCostModel, FixedLotBook, FixedMoney, FixedTradingCost, + MONEY_SCALE, +}; pub use futures::{ FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,