Align China A-share costs with rqalpha rules

This commit is contained in:
boris
2026-04-22 21:57:24 -07:00
parent 650e2e8319
commit aca8292c72
5 changed files with 111 additions and 43 deletions

View File

@@ -463,6 +463,7 @@ where
let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty); let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty);
if current_qty > provisional_target_qty { if current_qty > provisional_target_qty {
projected_cash += self.estimated_sell_net_cash( projected_cash += self.estimated_sell_net_cash(
date,
price, price,
current_qty.saturating_sub(provisional_target_qty), current_qty.saturating_sub(provisional_target_qty),
); );
@@ -484,6 +485,7 @@ where
if target_qty > constraint.current_qty { if target_qty > constraint.current_qty {
let desired_additional = target_qty - constraint.current_qty; let desired_additional = target_qty - constraint.current_qty;
let affordable_additional = self.affordable_buy_quantity( let affordable_additional = self.affordable_buy_quantity(
date,
projected_cash, projected_cash,
None, None,
constraint.price, constraint.price,
@@ -494,6 +496,7 @@ where
.clamp(constraint.min_target_qty, constraint.max_target_qty); .clamp(constraint.min_target_qty, constraint.max_target_qty);
if target_qty > constraint.current_qty { if target_qty > constraint.current_qty {
projected_cash -= self.estimated_buy_cash_out( projected_cash -= self.estimated_buy_cash_out(
date,
constraint.price, constraint.price,
target_qty - constraint.current_qty, target_qty - constraint.current_qty,
); );
@@ -582,21 +585,21 @@ where
current_qty.saturating_add(additional_limit) 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 { if quantity == 0 {
return 0.0; return 0.0;
} }
let gross = price * quantity as f64; 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() 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 { if quantity == 0 {
return 0.0; return 0.0;
} }
let gross = price * quantity as f64; 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() gross + cost.total()
} }
@@ -705,7 +708,9 @@ where
(filled_qty, self.sell_price(snapshot)) (filled_qty, self.sell_price(snapshot))
}; };
let gross_amount = execution_price * filled_qty as f64; 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 net_cash = gross_amount - cost.total();
let realized_pnl = portfolio let realized_pnl = portfolio
@@ -950,6 +955,7 @@ where
.map(|start_time| date.and_time(start_time)); .map(|start_time| date.and_time(start_time));
let quotes = data.execution_quotes_on(date, symbol); let quotes = data.execution_quotes_on(date, symbol);
if let Some(estimated) = self.select_buy_sizing_fill( if let Some(estimated) = self.select_buy_sizing_fill(
date,
snapshot, snapshot,
quotes, quotes,
start_cursor, start_cursor,
@@ -963,6 +969,7 @@ where
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
let fallback_qty = self.affordable_buy_quantity( let fallback_qty = self.affordable_buy_quantity(
date,
portfolio.cash(), portfolio.cash(),
Some(value_budget), Some(value_budget),
execution_price, execution_price,
@@ -994,6 +1001,7 @@ where
fn select_buy_sizing_fill( fn select_buy_sizing_fill(
&self, &self,
date: NaiveDate,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
quotes: &[IntradayExecutionQuote], quotes: &[IntradayExecutionQuote],
start_cursor: Option<NaiveDateTime>, start_cursor: Option<NaiveDateTime>,
@@ -1057,7 +1065,9 @@ where
take_qty = take_qty.saturating_sub(lot); take_qty = take_qty.saturating_sub(lot);
continue; 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 { if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
break; break;
} }
@@ -1166,6 +1176,7 @@ where
} else { } else {
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
let filled_qty = self.affordable_buy_quantity( let filled_qty = self.affordable_buy_quantity(
date,
portfolio.cash(), portfolio.cash(),
value_budget.map(|budget| budget + 400.0), value_budget.map(|budget| budget + 400.0),
execution_price, execution_price,
@@ -1189,7 +1200,9 @@ where
let cash_before = portfolio.cash(); let cash_before = portfolio.cash();
let gross_amount = execution_price * filled_qty as f64; 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(); let cash_out = gross_amount + cost.total();
portfolio.apply_cash_delta(-cash_out); portfolio.apply_cash_delta(-cash_out);
@@ -1290,6 +1303,7 @@ where
fn affordable_buy_quantity( fn affordable_buy_quantity(
&self, &self,
date: NaiveDate,
cash: f64, cash: f64,
gross_limit: Option<f64>, gross_limit: Option<f64>,
price: f64, price: f64,
@@ -1304,7 +1318,7 @@ where
quantity = quantity.saturating_sub(lot); quantity = quantity.saturating_sub(lot);
continue; 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 { if gross + cost.total() <= cash + 1e-6 {
return quantity; return quantity;
} }
@@ -1382,6 +1396,7 @@ where
let execution_price = self.snapshot_execution_price(snapshot, side); let execution_price = self.snapshot_execution_price(snapshot, side);
let quantity = match side { let quantity = match side {
OrderSide::Buy => self.affordable_buy_quantity( OrderSide::Buy => self.affordable_buy_quantity(
date,
cash_limit.unwrap_or(f64::INFINITY), cash_limit.unwrap_or(f64::INFINITY),
gross_limit, gross_limit,
execution_price, execution_price,

View File

@@ -1,5 +1,9 @@
use chrono::NaiveDate;
use crate::events::OrderSide; use crate::events::OrderSide;
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct TradingCost { pub struct TradingCost {
pub commission: f64, pub commission: f64,
@@ -13,13 +17,14 @@ impl TradingCost {
} }
pub trait CostModel { 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)] #[derive(Debug, Clone, Copy)]
pub struct ChinaAShareCostModel { pub struct ChinaAShareCostModel {
pub commission_rate: f64, 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, pub minimum_commission: f64,
} }
@@ -27,14 +32,45 @@ impl Default for ChinaAShareCostModel {
fn default() -> Self { fn default() -> Self {
Self { Self {
commission_rate: 0.0003, 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, 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 { 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 { if gross_amount <= 0.0 {
return TradingCost { return TradingCost {
commission: 0.0, commission: 0.0,
@@ -42,11 +78,8 @@ impl CostModel for ChinaAShareCostModel {
}; };
} }
let commission = (gross_amount * self.commission_rate).max(self.minimum_commission); let commission = self.commission_for(gross_amount);
let stamp_tax = match side { let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
OrderSide::Buy => 0.0,
OrderSide::Sell => gross_amount * self.stamp_tax_rate,
};
TradingCost { TradingCost {
commission, commission,

View File

@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet};
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use rhai::{Dynamic, Engine, Map, Scope}; use rhai::{Dynamic, Engine, Map, Scope};
use crate::cost::ChinaAShareCostModel;
use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::OrderSide; use crate::events::OrderSide;
@@ -346,11 +347,12 @@ impl PlatformExprStrategy {
} }
fn buy_commission(&self, gross_amount: f64) -> f64 { 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 { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001) 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 { fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
@@ -542,7 +544,7 @@ impl PlatformExprStrategy {
+ Duration::seconds(1), + Duration::seconds(1),
}); });
let gross_amount = fill.price * fill.quantity as f64; 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 projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)

View File

@@ -6,6 +6,7 @@ use std::sync::OnceLock;
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::ChinaAShareCostModel;
use crate::data::{DataSet, PriceField}; use crate::data::{DataSet, PriceField};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::OrderSide; use crate::events::OrderSide;
@@ -576,11 +577,12 @@ impl JqMicroCapStrategy {
} }
fn buy_commission(&self, gross_amount: f64) -> f64 { 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 { fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001) 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 { fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
@@ -750,7 +752,7 @@ impl JqMicroCapStrategy {
+ Duration::seconds(1), + Duration::seconds(1),
}); });
let gross_amount = fill.price * fill.quantity as f64; 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 projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)

View File

@@ -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() { fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
let model = ChinaAShareCostModel::default(); 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!((buy.commission - 5.0).abs() < 1e-9);
assert_eq!(buy.stamp_tax, 0.0); 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.commission - 30.0).abs() < 1e-9);
assert!((sell.stamp_tax - 100.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] #[test]
fn china_rule_hooks_block_same_day_sell_under_t_plus_one() { fn china_rule_hooks_block_same_day_sell_under_t_plus_one() {
let hooks = ChinaEquityRuleHooks; let hooks = ChinaEquityRuleHooks;
@@ -96,13 +107,11 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
PriceField::Open, PriceField::Open,
); );
assert!(!buy_check.allowed); assert!(!buy_check.allowed);
assert!( assert!(buy_check
buy_check
.reason .reason
.as_deref() .as_deref()
.unwrap_or_default() .unwrap_or_default()
.contains("upper limit") .contains("upper limit"));
);
let sell_check = hooks.can_sell( let sell_check = hooks.can_sell(
d(2024, 1, 3), d(2024, 1, 3),
@@ -112,13 +121,11 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
PriceField::Open, PriceField::Open,
); );
assert!(!sell_check.allowed); assert!(!sell_check.allowed);
assert!( assert!(sell_check
sell_check
.reason .reason
.as_deref() .as_deref()
.unwrap_or_default() .unwrap_or_default()
.contains("lower limit") .contains("lower limit"));
);
} }
#[test] #[test]
@@ -180,6 +187,15 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
price_tick: 0.01, price_tick: 0.01,
}; };
let sell_check = hooks.can_sell(d(2024, 4, 7), &snapshot, &candidate, &position, PriceField::Last); let sell_check = hooks.can_sell(
assert!(sell_check.allowed, "sell should be allowed when snapshot last is above lower limit"); d(2024, 4, 7),
&snapshot,
&candidate,
&position,
PriceField::Last,
);
assert!(
sell_check.allowed,
"sell should be allowed when snapshot last is above lower limit"
);
} }