增加定点金额精度验收模型
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 engine;
|
||||||
pub mod event_bus;
|
pub mod event_bus;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod fixed_point;
|
||||||
pub mod futures;
|
pub mod futures;
|
||||||
pub mod instrument;
|
pub mod instrument;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
@@ -43,6 +44,10 @@ pub use events::{
|
|||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
ProcessEventKind,
|
||||||
};
|
};
|
||||||
|
pub use fixed_point::{
|
||||||
|
FixedAccount, FixedChinaAShareCostModel, FixedLotBook, FixedMoney, FixedTradingCost,
|
||||||
|
MONEY_SCALE,
|
||||||
|
};
|
||||||
pub use futures::{
|
pub use futures::{
|
||||||
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
||||||
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
|
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
|
||||||
|
|||||||
Reference in New Issue
Block a user