322 lines
10 KiB
Rust
322 lines
10 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use chrono::NaiveDate;
|
|
|
|
use crate::events::OrderSide;
|
|
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
|
|
use crate::risk_control::TradingConstraintConfig;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct TradingCost {
|
|
pub commission: f64,
|
|
pub stamp_tax: f64,
|
|
pub transfer_fee: f64,
|
|
}
|
|
|
|
impl TradingCost {
|
|
pub fn total(self) -> f64 {
|
|
self.fixed_total().to_f64()
|
|
}
|
|
|
|
pub fn fixed_total(self) -> FixedMoney {
|
|
FixedMoney::checked_sum_f64([self.commission, self.stamp_tax, self.transfer_fee])
|
|
.expect("trading costs must be finite fixed-point money")
|
|
}
|
|
|
|
fn from_fixed(value: FixedTradingCost) -> Self {
|
|
Self {
|
|
commission: value.commission.to_f64(),
|
|
stamp_tax: value.stamp_tax.to_f64(),
|
|
transfer_fee: value.transfer_fee.to_f64(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait CostModel {
|
|
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost;
|
|
|
|
fn calculate_with_order_state(
|
|
&self,
|
|
date: NaiveDate,
|
|
side: OrderSide,
|
|
gross_amount: f64,
|
|
_order_id: Option<u64>,
|
|
_commission_state: &mut BTreeMap<u64, f64>,
|
|
) -> TradingCost {
|
|
self.calculate(date, side, gross_amount)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ChinaAShareCostModel {
|
|
fixed: FixedChinaAShareCostModel,
|
|
}
|
|
|
|
impl Default for ChinaAShareCostModel {
|
|
fn default() -> Self {
|
|
Self::from_trading_constraints(TradingConstraintConfig::default())
|
|
}
|
|
}
|
|
|
|
impl ChinaAShareCostModel {
|
|
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
|
|
Self {
|
|
fixed: FixedChinaAShareCostModel {
|
|
commission_rate: Self::fixed_money(config.commission_rate, "commission rate"),
|
|
stamp_tax_rate_before_change: Self::fixed_money(
|
|
config.stamp_tax_rate_before_change,
|
|
"stamp tax rate before change",
|
|
),
|
|
stamp_tax_rate_after_change: Self::fixed_money(
|
|
config.stamp_tax_rate_after_change,
|
|
"stamp tax rate after change",
|
|
),
|
|
stamp_tax_change_date: config.stamp_tax_change_date,
|
|
minimum_commission: Self::fixed_money(
|
|
config.minimum_commission,
|
|
"minimum commission",
|
|
),
|
|
transfer_fee_rate: Self::fixed_money(config.transfer_fee_rate, "transfer fee rate"),
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn set_commission_rate(&mut self, value: f64) {
|
|
self.fixed.commission_rate = Self::fixed_money(value, "commission rate");
|
|
}
|
|
|
|
pub fn set_minimum_commission(&mut self, value: f64) {
|
|
self.fixed.minimum_commission = Self::fixed_money(value, "minimum commission");
|
|
}
|
|
|
|
pub fn set_transfer_fee_rate(&mut self, value: f64) {
|
|
self.fixed.transfer_fee_rate = Self::fixed_money(value, "transfer fee rate");
|
|
}
|
|
|
|
pub fn set_stamp_tax_rate_before_change(&mut self, value: f64) {
|
|
self.fixed.stamp_tax_rate_before_change =
|
|
Self::fixed_money(value, "stamp tax rate before change");
|
|
}
|
|
|
|
pub fn set_stamp_tax_rate_after_change(&mut self, value: f64) {
|
|
self.fixed.stamp_tax_rate_after_change =
|
|
Self::fixed_money(value, "stamp tax rate after change");
|
|
}
|
|
|
|
pub fn set_stamp_tax_change_date(&mut self, value: NaiveDate) {
|
|
self.fixed.stamp_tax_change_date = value;
|
|
}
|
|
|
|
pub fn commission_rate(&self) -> f64 {
|
|
self.fixed.commission_rate.to_f64()
|
|
}
|
|
|
|
pub fn minimum_commission(&self) -> f64 {
|
|
self.fixed.minimum_commission.to_f64()
|
|
}
|
|
|
|
pub fn transfer_fee_rate(&self) -> f64 {
|
|
self.fixed.transfer_fee_rate.to_f64()
|
|
}
|
|
|
|
pub fn stamp_tax_rate_before_change(&self) -> f64 {
|
|
self.fixed.stamp_tax_rate_before_change.to_f64()
|
|
}
|
|
|
|
pub fn stamp_tax_rate_after_change(&self) -> f64 {
|
|
self.fixed.stamp_tax_rate_after_change.to_f64()
|
|
}
|
|
|
|
pub fn stamp_tax_change_date(&self) -> NaiveDate {
|
|
self.fixed.stamp_tax_change_date
|
|
}
|
|
|
|
pub fn with_commission_rate(mut self, value: f64) -> Self {
|
|
self.set_commission_rate(value);
|
|
self
|
|
}
|
|
|
|
pub fn with_minimum_commission(mut self, value: f64) -> Self {
|
|
self.set_minimum_commission(value);
|
|
self
|
|
}
|
|
|
|
pub fn with_stamp_tax_rates(mut self, before: f64, after: f64) -> Self {
|
|
self.set_stamp_tax_rate_before_change(before);
|
|
self.set_stamp_tax_rate_after_change(after);
|
|
self
|
|
}
|
|
|
|
pub fn commission_for(&self, gross_amount: f64) -> f64 {
|
|
if gross_amount <= 0.0 {
|
|
return 0.0;
|
|
}
|
|
self.fixed_model()
|
|
.commission_for(Self::fixed_money(gross_amount, "gross amount"))
|
|
.to_f64()
|
|
}
|
|
|
|
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
|
|
self.fixed.stamp_tax_rate_for(date).to_f64()
|
|
}
|
|
|
|
pub fn stamp_tax_for(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> f64 {
|
|
if gross_amount <= 0.0 || side == OrderSide::Buy {
|
|
return 0.0;
|
|
}
|
|
self.fixed_model()
|
|
.stamp_tax_for(date, side, Self::fixed_money(gross_amount, "gross amount"))
|
|
.to_f64()
|
|
}
|
|
|
|
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
|
|
if gross_amount <= 0.0 {
|
|
return 0.0;
|
|
}
|
|
self.fixed_model()
|
|
.transfer_fee_for(Self::fixed_money(gross_amount, "gross amount"))
|
|
.to_f64()
|
|
}
|
|
|
|
pub fn commission_for_order_fill(
|
|
&self,
|
|
gross_amount: f64,
|
|
order_id: Option<u64>,
|
|
commission_state: &mut BTreeMap<u64, f64>,
|
|
) -> f64 {
|
|
if gross_amount <= 0.0 {
|
|
return 0.0;
|
|
}
|
|
|
|
let Some(order_id) = order_id else {
|
|
return self.commission_for(gross_amount);
|
|
};
|
|
|
|
let remaining_minimum = commission_state
|
|
.entry(order_id)
|
|
.or_insert(self.fixed.minimum_commission.to_f64());
|
|
let mut fixed_remaining = Self::fixed_money(*remaining_minimum, "remaining commission");
|
|
let charged = self.fixed_model().commission_for_order_fill_remaining(
|
|
Self::fixed_money(gross_amount, "gross amount"),
|
|
&mut fixed_remaining,
|
|
);
|
|
*remaining_minimum = fixed_remaining.to_f64();
|
|
charged.to_f64()
|
|
}
|
|
|
|
fn fixed_money(value: f64, label: &str) -> FixedMoney {
|
|
FixedMoney::from_f64(value)
|
|
.unwrap_or_else(|| panic!("{label} is not representable as fixed-point money: {value}"))
|
|
}
|
|
|
|
fn fixed_model(&self) -> FixedChinaAShareCostModel {
|
|
self.fixed
|
|
}
|
|
}
|
|
|
|
impl CostModel for ChinaAShareCostModel {
|
|
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
|
|
if gross_amount <= 0.0 {
|
|
return TradingCost {
|
|
commission: 0.0,
|
|
stamp_tax: 0.0,
|
|
transfer_fee: 0.0,
|
|
};
|
|
}
|
|
|
|
TradingCost::from_fixed(self.fixed_model().calculate(
|
|
date,
|
|
side,
|
|
Self::fixed_money(gross_amount, "gross amount"),
|
|
))
|
|
}
|
|
|
|
fn calculate_with_order_state(
|
|
&self,
|
|
date: NaiveDate,
|
|
side: OrderSide,
|
|
gross_amount: f64,
|
|
order_id: Option<u64>,
|
|
commission_state: &mut BTreeMap<u64, f64>,
|
|
) -> TradingCost {
|
|
if gross_amount <= 0.0 {
|
|
return TradingCost {
|
|
commission: 0.0,
|
|
stamp_tax: 0.0,
|
|
transfer_fee: 0.0,
|
|
};
|
|
}
|
|
|
|
let fixed_model = self.fixed_model();
|
|
let fixed_gross = Self::fixed_money(gross_amount, "gross amount");
|
|
let commission = if let Some(order_id) = order_id {
|
|
let remaining = commission_state
|
|
.entry(order_id)
|
|
.or_insert(self.fixed.minimum_commission.to_f64());
|
|
let mut fixed_remaining = Self::fixed_money(*remaining, "remaining commission");
|
|
let commission =
|
|
fixed_model.commission_for_order_fill_remaining(fixed_gross, &mut fixed_remaining);
|
|
*remaining = fixed_remaining.to_f64();
|
|
commission
|
|
} else {
|
|
fixed_model.commission_for(fixed_gross)
|
|
};
|
|
TradingCost::from_fixed(FixedTradingCost {
|
|
commission,
|
|
stamp_tax: fixed_model.stamp_tax_for(date, side, fixed_gross),
|
|
transfer_fee: fixed_model.transfer_fee_for(fixed_gross),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_quantizes_fees_to_micro_yuan() {
|
|
let model = ChinaAShareCostModel::default();
|
|
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
|
|
|
|
assert!((model.commission_for(248_059.812) - 74.417944).abs() < 1e-12);
|
|
assert!(
|
|
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.873504).abs() < 1e-12
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cost_model_can_use_configurable_stamp_tax_change_date() {
|
|
let config = TradingConstraintConfig {
|
|
commission_rate: 0.0003,
|
|
minimum_commission: 5.0,
|
|
transfer_fee_rate: 0.00001,
|
|
stamp_tax_rate_before_change: 0.002,
|
|
stamp_tax_rate_after_change: 0.001,
|
|
stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
|
|
..TradingConstraintConfig::default()
|
|
};
|
|
let model = ChinaAShareCostModel::from_trading_constraints(config);
|
|
|
|
assert!((model.transfer_fee_for(10_000.0) - 0.1).abs() < 1e-12);
|
|
|
|
assert!(
|
|
(model.stamp_tax_for(
|
|
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
|
|
OrderSide::Sell,
|
|
10_000.0
|
|
) - 20.0)
|
|
.abs()
|
|
< 1e-9
|
|
);
|
|
assert!(
|
|
(model.stamp_tax_for(
|
|
NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
|
|
OrderSide::Sell,
|
|
10_000.0
|
|
) - 10.0)
|
|
.abs()
|
|
< 1e-9
|
|
);
|
|
}
|
|
}
|