增加定点金额精度验收模型
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user