From aca8292c72a751bc071cdce9319705934615e39e Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 21:57:24 -0700 Subject: [PATCH] Align China A-share costs with rqalpha rules --- crates/fidc-core/src/broker.rs | 31 ++++++++--- crates/fidc-core/src/cost.rs | 51 ++++++++++++++---- .../fidc-core/src/platform_expr_strategy.rs | 10 ++-- crates/fidc-core/src/strategy.rs | 10 ++-- crates/fidc-core/tests/core_rules.rs | 52 ++++++++++++------- 5 files changed, 111 insertions(+), 43 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index d564858..9572a07 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -463,6 +463,7 @@ where let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty); if current_qty > provisional_target_qty { projected_cash += self.estimated_sell_net_cash( + date, price, current_qty.saturating_sub(provisional_target_qty), ); @@ -484,6 +485,7 @@ where if target_qty > constraint.current_qty { let desired_additional = target_qty - constraint.current_qty; let affordable_additional = self.affordable_buy_quantity( + date, projected_cash, None, constraint.price, @@ -494,6 +496,7 @@ where .clamp(constraint.min_target_qty, constraint.max_target_qty); if target_qty > constraint.current_qty { projected_cash -= self.estimated_buy_cash_out( + date, constraint.price, target_qty - constraint.current_qty, ); @@ -582,21 +585,21 @@ where current_qty.saturating_add(additional_limit) } - fn estimated_sell_net_cash(&self, price: f64, quantity: u32) -> f64 { + fn estimated_sell_net_cash(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 { if quantity == 0 { return 0.0; } let gross = price * quantity as f64; - let cost = self.cost_model.calculate(OrderSide::Sell, gross); + let cost = self.cost_model.calculate(date, OrderSide::Sell, gross); gross - cost.total() } - fn estimated_buy_cash_out(&self, price: f64, quantity: u32) -> f64 { + fn estimated_buy_cash_out(&self, date: NaiveDate, price: f64, quantity: u32) -> f64 { if quantity == 0 { return 0.0; } let gross = price * quantity as f64; - let cost = self.cost_model.calculate(OrderSide::Buy, gross); + let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); gross + cost.total() } @@ -705,7 +708,9 @@ where (filled_qty, self.sell_price(snapshot)) }; let gross_amount = execution_price * filled_qty as f64; - let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount); + let cost = self + .cost_model + .calculate(date, OrderSide::Sell, gross_amount); let net_cash = gross_amount - cost.total(); let realized_pnl = portfolio @@ -950,6 +955,7 @@ where .map(|start_time| date.and_time(start_time)); let quotes = data.execution_quotes_on(date, symbol); if let Some(estimated) = self.select_buy_sizing_fill( + date, snapshot, quotes, start_cursor, @@ -963,6 +969,7 @@ where let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); let fallback_qty = self.affordable_buy_quantity( + date, portfolio.cash(), Some(value_budget), execution_price, @@ -994,6 +1001,7 @@ where fn select_buy_sizing_fill( &self, + date: NaiveDate, snapshot: &crate::data::DailyMarketSnapshot, quotes: &[IntradayExecutionQuote], start_cursor: Option, @@ -1057,7 +1065,9 @@ where take_qty = take_qty.saturating_sub(lot); continue; } - let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross); + let candidate_cost = + self.cost_model + .calculate(date, OrderSide::Buy, candidate_gross); if candidate_gross + candidate_cost.total() <= cash + 1e-6 { break; } @@ -1166,6 +1176,7 @@ where } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); let filled_qty = self.affordable_buy_quantity( + date, portfolio.cash(), value_budget.map(|budget| budget + 400.0), execution_price, @@ -1189,7 +1200,9 @@ where let cash_before = portfolio.cash(); let gross_amount = execution_price * filled_qty as f64; - let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount); + let cost = self + .cost_model + .calculate(date, OrderSide::Buy, gross_amount); let cash_out = gross_amount + cost.total(); portfolio.apply_cash_delta(-cash_out); @@ -1290,6 +1303,7 @@ where fn affordable_buy_quantity( &self, + date: NaiveDate, cash: f64, gross_limit: Option, price: f64, @@ -1304,7 +1318,7 @@ where quantity = quantity.saturating_sub(lot); continue; } - let cost = self.cost_model.calculate(OrderSide::Buy, gross); + let cost = self.cost_model.calculate(date, OrderSide::Buy, gross); if gross + cost.total() <= cash + 1e-6 { return quantity; } @@ -1382,6 +1396,7 @@ where let execution_price = self.snapshot_execution_price(snapshot, side); let quantity = match side { OrderSide::Buy => self.affordable_buy_quantity( + date, cash_limit.unwrap_or(f64::INFINITY), gross_limit, execution_price, diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index f814191..f09c72d 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -1,5 +1,9 @@ +use chrono::NaiveDate; + use crate::events::OrderSide; +pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28); + #[derive(Debug, Clone, Copy)] pub struct TradingCost { pub commission: f64, @@ -13,13 +17,14 @@ impl TradingCost { } pub trait CostModel { - fn calculate(&self, side: OrderSide, gross_amount: f64) -> TradingCost; + fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost; } #[derive(Debug, Clone, Copy)] pub struct ChinaAShareCostModel { pub commission_rate: f64, - pub stamp_tax_rate: f64, + pub stamp_tax_rate_before_change: f64, + pub stamp_tax_rate_after_change: f64, pub minimum_commission: f64, } @@ -27,14 +32,45 @@ impl Default for ChinaAShareCostModel { fn default() -> Self { Self { commission_rate: 0.0003, - stamp_tax_rate: 0.001, + stamp_tax_rate_before_change: 0.001, + stamp_tax_rate_after_change: 0.0005, minimum_commission: 5.0, } } } +impl ChinaAShareCostModel { + 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 { + let change_date = NaiveDate::from_ymd_opt( + STOCK_PIT_TAX_CHANGE_DATE.0, + STOCK_PIT_TAX_CHANGE_DATE.1, + STOCK_PIT_TAX_CHANGE_DATE.2, + ) + .expect("valid pit tax change date"); + if date < 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) + } +} + impl CostModel for ChinaAShareCostModel { - fn calculate(&self, side: OrderSide, gross_amount: f64) -> TradingCost { + fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost { if gross_amount <= 0.0 { return TradingCost { commission: 0.0, @@ -42,11 +78,8 @@ impl CostModel for ChinaAShareCostModel { }; } - let commission = (gross_amount * self.commission_rate).max(self.minimum_commission); - let stamp_tax = match side { - OrderSide::Buy => 0.0, - OrderSide::Sell => gross_amount * self.stamp_tax_rate, - }; + let commission = self.commission_for(gross_amount); + let stamp_tax = self.stamp_tax_for(date, side, gross_amount); TradingCost { commission, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index e491faa..c9f096b 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use rhai::{Dynamic, Engine, Map, Scope}; +use crate::cost::ChinaAShareCostModel; use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; @@ -346,11 +347,12 @@ impl PlatformExprStrategy { } fn buy_commission(&self, gross_amount: f64) -> f64 { - (gross_amount * 0.0003).max(5.0) + ChinaAShareCostModel::default().commission_for(gross_amount) } - fn sell_cost(&self, gross_amount: f64) -> f64 { - (gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001) + fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { + let model = ChinaAShareCostModel::default(); + model.commission_for(gross_amount) + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 { @@ -542,7 +544,7 @@ impl PlatformExprStrategy { + Duration::seconds(1), }); let gross_amount = fill.price * fill.quantity as f64; - let net_cash = gross_amount - self.sell_cost(gross_amount); + let net_cash = gross_amount - self.sell_cost(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index a4382b3..0411068 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -6,6 +6,7 @@ use std::sync::OnceLock; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; +use crate::cost::ChinaAShareCostModel; use crate::data::{DataSet, PriceField}; use crate::engine::BacktestError; use crate::events::OrderSide; @@ -576,11 +577,12 @@ impl JqMicroCapStrategy { } fn buy_commission(&self, gross_amount: f64) -> f64 { - (gross_amount * 0.0003).max(5.0) + ChinaAShareCostModel::default().commission_for(gross_amount) } - fn sell_cost(&self, gross_amount: f64) -> f64 { - (gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001) + fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { + let model = ChinaAShareCostModel::default(); + model.commission_for(gross_amount) + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 { @@ -750,7 +752,7 @@ impl JqMicroCapStrategy { + Duration::seconds(1), }); let gross_amount = fill.price * fill.quantity as f64; - let net_cash = gross_amount - self.sell_cost(gross_amount); + let net_cash = gross_amount - self.sell_cost(date, gross_amount); projected .position_mut(symbol) .sell(fill.quantity, fill.price) diff --git a/crates/fidc-core/tests/core_rules.rs b/crates/fidc-core/tests/core_rules.rs index 9e8aab5..ae9956a 100644 --- a/crates/fidc-core/tests/core_rules.rs +++ b/crates/fidc-core/tests/core_rules.rs @@ -54,15 +54,26 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho fn china_cost_model_applies_minimum_commission_and_stamp_tax() { let model = ChinaAShareCostModel::default(); - let buy = model.calculate(OrderSide::Buy, 1_000.0); + let buy = model.calculate(d(2023, 8, 25), OrderSide::Buy, 1_000.0); assert!((buy.commission - 5.0).abs() < 1e-9); assert_eq!(buy.stamp_tax, 0.0); - let sell = model.calculate(OrderSide::Sell, 100_000.0); + let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0); assert!((sell.commission - 30.0).abs() < 1e-9); assert!((sell.stamp_tax - 100.0).abs() < 1e-9); } +#[test] +fn china_cost_model_switches_stamp_tax_rate_after_2023_08_28() { + let model = ChinaAShareCostModel::default(); + + let before = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0); + let after = model.calculate(d(2023, 8, 28), OrderSide::Sell, 100_000.0); + + assert!((before.stamp_tax - 100.0).abs() < 1e-9); + assert!((after.stamp_tax - 50.0).abs() < 1e-9); +} + #[test] fn china_rule_hooks_block_same_day_sell_under_t_plus_one() { let hooks = ChinaEquityRuleHooks; @@ -96,13 +107,11 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() { PriceField::Open, ); assert!(!buy_check.allowed); - assert!( - buy_check - .reason - .as_deref() - .unwrap_or_default() - .contains("upper limit") - ); + assert!(buy_check + .reason + .as_deref() + .unwrap_or_default() + .contains("upper limit")); let sell_check = hooks.can_sell( d(2024, 1, 3), @@ -112,13 +121,11 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() { PriceField::Open, ); assert!(!sell_check.allowed); - assert!( - sell_check - .reason - .as_deref() - .unwrap_or_default() - .contains("lower limit") - ); + assert!(sell_check + .reason + .as_deref() + .unwrap_or_default() + .contains("lower limit")); } #[test] @@ -180,6 +187,15 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() { price_tick: 0.01, }; - let sell_check = hooks.can_sell(d(2024, 4, 7), &snapshot, &candidate, &position, PriceField::Last); - assert!(sell_check.allowed, "sell should be allowed when snapshot last is above lower limit"); + let sell_check = hooks.can_sell( + d(2024, 4, 7), + &snapshot, + &candidate, + &position, + PriceField::Last, + ); + assert!( + sell_check.allowed, + "sell should be allowed when snapshot last is above lower limit" + ); }