1 Commits

Author SHA1 Message Date
boris
94662b6e75 chore: 更新 fidc-backtest-engine - 2026-05-13 2026-05-13 23:48:16 +08:00
4 changed files with 92 additions and 26 deletions

View File

@@ -2919,6 +2919,7 @@ where
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty = self.value_buy_quantity(
date,
value.abs(),
price,
minimum_order_quantity,
@@ -3014,6 +3015,7 @@ where
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty = self.value_buy_quantity(
date,
value.abs(),
price,
minimum_order_quantity,
@@ -3181,6 +3183,7 @@ where
let order_step_size = self.order_step_size(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty = self.value_buy_quantity(
date,
value.abs(),
price,
minimum_order_quantity,
@@ -4066,6 +4069,7 @@ where
fn value_buy_quantity(
&self,
date: NaiveDate,
value_budget: f64,
price: f64,
minimum_order_quantity: u32,
@@ -4075,13 +4079,17 @@ where
return 0;
}
let minimum = minimum_order_quantity.max(1);
let step = order_step_size.max(1);
if price * minimum as f64 > value_budget + 1e-6 {
return 0;
let raw_quantity = (value_budget / price).floor() as u32;
let mut quantity =
self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum {
if self.estimated_buy_cash_out(date, price, quantity) <= value_budget + 1e-6 {
return quantity;
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
let raw_steps = (value_budget / price / step as f64).round();
let requested = ((raw_steps.max(1.0) as u32) * step).max(minimum);
self.round_buy_quantity(requested, minimum_order_quantity, order_step_size)
0
}
fn decrement_order_quantity(
@@ -4364,13 +4372,15 @@ where
return None;
}
let quote_quantity_limited = self.quote_quantity_limited(matching_type);
let lot = round_lot.max(1);
let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes
.iter()
.filter(|quote| {
!start_cursor.is_some_and(|cursor| quote.timestamp < cursor)
&& !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)
&& self.quote_has_executable_liquidity(quote, side, matching_type)
&& (!quote_quantity_limited
|| self.quote_has_executable_liquidity(quote, side, matching_type))
})
.collect();
let mut filled_qty = 0_u32;
@@ -4398,21 +4408,26 @@ where
continue;
}
let quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
let top_level_liquidity = match side {
OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_volume,
};
let available_qty = top_level_liquidity
.saturating_mul(lot as u64)
.min(u32::MAX as u64) as u32;
if available_qty == 0 {
continue;
}
let remaining_qty = requested_qty.saturating_sub(filled_qty);
if remaining_qty == 0 {
break;
}
let available_qty = if quote_quantity_limited {
let top_level_liquidity = match side {
OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_volume,
};
top_level_liquidity
.saturating_mul(lot as u64)
.min(u32::MAX as u64) as u32
} else {
remaining_qty
};
if available_qty == 0 {
continue;
}
let mut take_qty = if matching_type == MatchingType::Twap {
let remaining_quotes = (eligible_quotes.len() - quote_index) as u32;
let scheduled_qty =
@@ -4514,6 +4529,10 @@ where
}
}
fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool {
self.volume_limit || self.liquidity_limit || matching_type != MatchingType::NextTickLast
}
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
let _ = reason;
false
@@ -4579,3 +4598,35 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
"rebalance_sell"
}
}
#[cfg(test)]
mod tests {
use super::{BrokerSimulator, MatchingType};
use crate::cost::ChinaAShareCostModel;
use crate::rules::ChinaEquityRuleHooks;
#[test]
fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false)
.with_liquidity_limit(false);
assert!(!broker.quote_quantity_limited(MatchingType::NextTickLast));
assert!(broker.quote_quantity_limited(MatchingType::CounterpartyOffer));
}
#[test]
fn next_tick_last_keeps_quote_quantity_cap_when_limits_enabled() {
let volume_limited =
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(true)
.with_liquidity_limit(false);
let liquidity_limited =
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false)
.with_liquidity_limit(true);
assert!(volume_limited.quote_quantity_limited(MatchingType::NextTickLast));
assert!(liquidity_limited.quote_quantity_limited(MatchingType::NextTickLast));
}
}

View File

@@ -44,7 +44,7 @@ pub struct ChinaAShareCostModel {
impl Default for ChinaAShareCostModel {
fn default() -> Self {
Self {
commission_rate: 0.0003,
commission_rate: 0.0008,
stamp_tax_rate_before_change: 0.001,
stamp_tax_rate_after_change: 0.0005,
minimum_commission: 5.0,

View File

@@ -822,13 +822,18 @@ impl PlatformExprStrategy {
return 0;
}
let minimum = minimum_order_quantity.max(1);
let step = order_step_size.max(1);
if price * minimum as f64 > value_budget + 1e-6 {
return 0;
let raw_quantity = (value_budget / price).floor() as u32;
let mut quantity =
self.round_lot_quantity(raw_quantity, minimum_order_quantity, order_step_size);
while quantity >= minimum {
let gross_amount = price * quantity as f64;
if gross_amount + self.buy_commission(gross_amount) <= value_budget + 1e-6 {
return quantity;
}
quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
}
let raw_steps = (value_budget / price / step as f64).round();
let requested = ((raw_steps.max(1.0) as u32) * step).max(minimum);
self.round_lot_quantity(requested, minimum_order_quantity, order_step_size)
0
}
fn decrement_order_quantity(
@@ -5093,6 +5098,16 @@ mod tests {
assert!(!rewritten.contains('?'));
}
#[test]
fn platform_order_value_quantity_includes_buy_commission_budget() {
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
assert_eq!(strategy.value_buy_quantity(4_000.0, 20.38, 100, 100), 100);
assert_eq!(strategy.value_buy_quantity(4_000.0, 15.20, 100, 100), 200);
assert_eq!(strategy.value_buy_quantity(4_000.0, 28.85, 100, 100), 100);
assert_eq!(strategy.value_buy_quantity(4_000.0, 37.40, 100, 100), 100);
}
fn sample_calendar() -> TradingCalendar {
TradingCalendar::new(vec![
d(2025, 1, 30),

View File

@@ -61,7 +61,7 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
assert_eq!(buy.stamp_tax, 0.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 - 80.0).abs() < 1e-9);
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
}
@@ -112,7 +112,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() {
assert!((first.commission - 5.0).abs() < 1e-9);
assert!(second.commission.abs() < 1e-9);
assert!((third.commission - 1.6).abs() < 1e-9);
assert!((third.commission - 12.6).abs() < 1e-9);
assert!((another_order.commission - 5.0).abs() < 1e-9);
}