增加定点金额精度验收模型

This commit is contained in:
boris
2026-08-24 17:25:53 +08:00
parent 4b577517a9
commit 1c04318ecf
2 changed files with 536 additions and 0 deletions
+531
View File
@@ -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<Self, String> {
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::<i128>()
.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::<i128>()
.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<Self> {
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> {
self.0.checked_add(other.0).map(Self)
}
pub fn checked_sub(self, other: Self) -> Option<Self> {
self.0.checked_sub(other.0).map(Self)
}
pub fn checked_mul_quantity(self, quantity: u64) -> Option<Self> {
self.0.checked_mul(i128::from(quantity)).map(Self)
}
pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
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<u64>,
commission_state: &mut BTreeMap<u64, 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");
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<FixedLot>,
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<FixedMoney, String> {
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<FixedMoney, String> {
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);
}
}