Files
fidc-backtest-engine/crates/fidc-core/src/cost.rs
T

230 lines
6.7 KiB
Rust

use std::collections::BTreeMap;
use chrono::NaiveDate;
use crate::events::OrderSide;
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.commission + self.stamp_tax + self.transfer_fee
}
}
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 {
pub commission_rate: f64,
pub stamp_tax_rate_before_change: f64,
pub stamp_tax_rate_after_change: f64,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: f64,
pub transfer_fee_rate: f64,
}
impl Default for ChinaAShareCostModel {
fn default() -> Self {
Self::from_trading_constraints(TradingConstraintConfig::default())
}
}
impl ChinaAShareCostModel {
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self {
commission_rate: config.commission_rate,
stamp_tax_rate_before_change: config.stamp_tax_rate_before_change,
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: config.minimum_commission,
transfer_fee_rate: config.transfer_fee_rate,
}
}
pub fn commission_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
(gross_amount * self.commission_rate).max(self.minimum_commission)
}
pub fn stamp_tax_rate_for(&self, date: NaiveDate) -> f64 {
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: f64) -> f64 {
if gross_amount <= 0.0 || side == OrderSide::Buy {
return 0.0;
}
gross_amount * self.stamp_tax_rate_for(date)
}
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
gross_amount * self.transfer_fee_rate
}
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 raw_commission = gross_amount * self.commission_rate;
let Some(order_id) = order_id else {
return raw_commission.max(self.minimum_commission);
};
let remaining_minimum = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
if raw_commission > *remaining_minimum {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
raw_commission
} else {
raw_commission - *remaining_minimum
};
*remaining_minimum = 0.0;
charged
} else {
let charged = if (*remaining_minimum - self.minimum_commission).abs() < 1e-12 {
self.minimum_commission
} else {
0.0
};
*remaining_minimum -= raw_commission;
charged
}
}
}
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,
};
}
let commission = self.commission_for(gross_amount);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
let transfer_fee = self.transfer_fee_for(gross_amount);
TradingCost {
commission,
stamp_tax,
transfer_fee,
}
}
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 commission = self.commission_for_order_fill(gross_amount, order_id, commission_state);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
let transfer_fee = self.transfer_fee_for(gross_amount);
TradingCost {
commission,
stamp_tax,
transfer_fee,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_matches_configurable_trading_constraints() {
let model = ChinaAShareCostModel::default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
assert!(
(model.stamp_tax_for(date, OrderSide::Sell, 245_747.007) - 122.8735035).abs() < 1e-9
);
}
#[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
);
}
}