567 lines
19 KiB
Rust
567 lines
19 KiB
Rust
//! Fixed-point execution primitives for money and fee arithmetic.
|
|
//!
|
|
//! 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};
|
|
|
|
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_neg(self) -> Option<Self> {
|
|
self.0.checked_neg().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 checked_sum_f64(values: impl IntoIterator<Item = f64>) -> Option<Self> {
|
|
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<bool> {
|
|
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())
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
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
|
|
} 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 runtime_cost_model_matches_fixed_execution_primitive() {
|
|
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_eq!(actual.to_f64(), expected);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_split_commission_matches_fixed_execution_primitive() {
|
|
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_eq!(fixed_total.to_f64(), float_total);
|
|
}
|
|
|
|
#[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_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();
|
|
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);
|
|
}
|
|
}
|