From ea2871a0f2c810f0408e5dba20b0e943b11fa977 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 23:36:20 -0700 Subject: [PATCH] Align order costs and rebalance priority with rqalpha --- crates/fidc-core/src/broker.rs | 151 ++++++++++++--- crates/fidc-core/src/cost.rs | 74 ++++++++ crates/fidc-core/src/engine.rs | 4 +- crates/fidc-core/src/events.rs | 4 + crates/fidc-core/tests/core_rules.rs | 66 ++++++- crates/fidc-core/tests/explicit_order_flow.rs | 173 ++++++++++++++++++ 6 files changed, 436 insertions(+), 36 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 57c3e1a..bef6bad 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -29,9 +29,11 @@ struct ExecutionFill { struct TargetConstraint { symbol: String, current_qty: u32, + desired_qty: u32, min_target_qty: u32, max_target_qty: u32, provisional_target_qty: u32, + target_weight: f64, price: f64, minimum_order_quantity: u32, order_step_size: u32, @@ -263,6 +265,8 @@ where let mut intraday_turnover = BTreeMap::::new(); let mut execution_cursors = BTreeMap::::new(); let mut global_execution_cursor = None::; + let mut commission_state = BTreeMap::::new(); + let mut next_order_id = 1_u64; if !decision.order_intents.is_empty() { for intent in &decision.order_intents { self.process_order_intent( @@ -273,6 +277,8 @@ where &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, + &mut commission_state, + &mut next_order_id, &mut report, )?; } @@ -316,10 +322,12 @@ where data, &symbol, requested_qty, + Self::reserve_order_id(&mut next_order_id), sell_reason(decision, &symbol), &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, + &mut commission_state, &mut report, )?; } @@ -339,10 +347,12 @@ where data, &symbol, requested_qty, + Self::reserve_order_id(&mut next_order_id), "rebalance_buy", &mut intraday_turnover, &mut execution_cursors, &mut global_execution_cursor, + &mut commission_state, None, &mut report, )?; @@ -363,6 +373,8 @@ where intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + next_order_id: &mut u64, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { match intent { @@ -376,10 +388,12 @@ where data, symbol, *target_value, + next_order_id, reason, intraday_turnover, execution_cursors, global_execution_cursor, + commission_state, report, ), OrderIntent::Value { @@ -392,15 +406,23 @@ where data, symbol, *value, + next_order_id, reason, intraday_turnover, execution_cursors, global_execution_cursor, + commission_state, report, ), } } + fn reserve_order_id(next_order_id: &mut u64) -> u64 { + let order_id = *next_order_id; + *next_order_id = next_order_id.saturating_add(1); + order_id + } + fn target_quantities( &self, date: NaiveDate, @@ -409,6 +431,7 @@ where target_weights: &BTreeMap, ) -> Result, BacktestError> { let equity = self.total_equity_at(date, portfolio, data, self.execution_price_field)?; + let target_weight_sum = target_weights.values().copied().sum::(); let mut desired_targets = BTreeMap::new(); for (symbol, weight) in target_weights { let price = data @@ -477,40 +500,83 @@ where ); } constraints.push(TargetConstraint { - symbol, + symbol: symbol.clone(), current_qty, + desired_qty, min_target_qty, max_target_qty, provisional_target_qty, + target_weight: *target_weights.get(&symbol).unwrap_or(&0.0), price, minimum_order_quantity, order_step_size, }); } + let safety = if target_weight_sum > 0.95 { 1.2 } else { 1.0 }; let mut targets = BTreeMap::new(); for constraint in &constraints { - let mut target_qty = constraint.provisional_target_qty; - 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, - desired_additional, + if constraint.provisional_target_qty > constraint.current_qty { + continue; + } + if constraint.provisional_target_qty > 0 { + targets.insert(constraint.symbol.clone(), constraint.provisional_target_qty); + } + } + + let mut buy_constraints = constraints + .iter() + .filter(|constraint| constraint.provisional_target_qty > constraint.current_qty) + .collect::>(); + buy_constraints.sort_by(|lhs, rhs| { + rhs.target_weight + .partial_cmp(&lhs.target_weight) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + let lhs_gap = (lhs.provisional_target_qty.saturating_sub(lhs.current_qty)) + as f64 + * lhs.price; + let rhs_gap = (rhs.provisional_target_qty.saturating_sub(rhs.current_qty)) + as f64 + * rhs.price; + rhs_gap + .partial_cmp(&lhs_gap) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| lhs.symbol.cmp(&rhs.symbol)) + }); + + for constraint in buy_constraints { + let mut target_qty = if safety > 1.0 { + let scaled_desired_qty = ((constraint.desired_qty as f64) * safety).floor() as u32; + self.round_buy_quantity( + scaled_desired_qty, constraint.minimum_order_quantity, constraint.order_step_size, + ) + .clamp(constraint.current_qty, constraint.max_target_qty) + } else { + constraint.provisional_target_qty + }; + target_qty = target_qty.max(constraint.current_qty); + let desired_additional = target_qty.saturating_sub(constraint.current_qty); + let affordable_additional = self.affordable_buy_quantity( + date, + projected_cash, + None, + constraint.price, + desired_additional, + constraint.minimum_order_quantity, + constraint.order_step_size, + ); + target_qty = (constraint.current_qty + affordable_additional) + .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, ); - target_qty = (constraint.current_qty + affordable_additional) - .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, - ); - } } if target_qty > 0 { @@ -631,10 +697,12 @@ where data: &DataSet, symbol: &str, requested_qty: u32, + order_id: u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; @@ -659,6 +727,7 @@ where }; report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, @@ -684,6 +753,7 @@ where Err(limit_reason) => { report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, @@ -697,6 +767,7 @@ where if filled_qty == 0 { report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, @@ -734,9 +805,13 @@ where (filled_qty, self.sell_price(snapshot)) }; let gross_amount = execution_price * filled_qty as f64; - let cost = self - .cost_model - .calculate(date, OrderSide::Sell, gross_amount); + let cost = self.cost_model.calculate_with_order_state( + date, + OrderSide::Sell, + gross_amount, + Some(order_id), + commission_state, + ); let net_cash = gross_amount - cost.total(); let realized_pnl = portfolio @@ -755,6 +830,7 @@ where report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, @@ -764,6 +840,7 @@ where }); report.fill_events.push(FillEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Sell, quantity: filled_qty, @@ -806,10 +883,12 @@ where data: &DataSet, symbol: &str, target_value: f64, + next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let price = data @@ -838,10 +917,12 @@ where data, symbol, current_qty - target_qty, + Self::reserve_order_id(next_order_id), reason, intraday_turnover, execution_cursors, global_execution_cursor, + commission_state, report, )?; } else if target_qty > current_qty { @@ -851,16 +932,19 @@ where data, symbol, target_qty - current_qty, + Self::reserve_order_id(next_order_id), reason, intraday_turnover, execution_cursors, global_execution_cursor, + commission_state, None, report, )?; } else if (current_value - target_value).abs() <= f64::EPSILON { report.order_events.push(OrderEvent { date, + order_id: None, symbol: symbol.to_string(), side: if current_qty > 0 { OrderSide::Sell @@ -884,10 +968,12 @@ where data: &DataSet, symbol: &str, value: f64, + next_order_id: &mut u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { if value.abs() <= f64::EPSILON { @@ -928,10 +1014,12 @@ where data, symbol, requested_qty, + Self::reserve_order_id(next_order_id), reason, intraday_turnover, execution_cursors, global_execution_cursor, + commission_state, Some(value.abs()), report, ) @@ -948,10 +1036,12 @@ where data, symbol, requested_qty, + Self::reserve_order_id(next_order_id), reason, intraday_turnover, execution_cursors, global_execution_cursor, + commission_state, report, ) } @@ -980,10 +1070,12 @@ where data: &DataSet, symbol: &str, requested_qty: u32, + order_id: u64, reason: &str, intraday_turnover: &mut BTreeMap, execution_cursors: &mut BTreeMap, global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, value_budget: Option, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { @@ -996,6 +1088,7 @@ where if !rule.allowed { report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, @@ -1020,6 +1113,7 @@ where Err(limit_reason) => { report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, @@ -1069,6 +1163,7 @@ where if filled_qty == 0 { report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, @@ -1081,9 +1176,13 @@ where let cash_before = portfolio.cash(); let gross_amount = execution_price * filled_qty as f64; - let cost = self - .cost_model - .calculate(date, OrderSide::Buy, gross_amount); + let cost = self.cost_model.calculate_with_order_state( + date, + OrderSide::Buy, + gross_amount, + Some(order_id), + commission_state, + ); let cash_out = gross_amount + cost.total(); portfolio.apply_cash_delta(-cash_out); @@ -1100,6 +1199,7 @@ where report.order_events.push(OrderEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Buy, requested_quantity: requested_qty, @@ -1109,6 +1209,7 @@ where }); report.fill_events.push(FillEvent { date, + order_id: Some(order_id), symbol: symbol.to_string(), side: OrderSide::Buy, quantity: filled_qty, diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index f09c72d..174c899 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use chrono::NaiveDate; use crate::events::OrderSide; @@ -18,6 +20,17 @@ impl TradingCost { 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, + _commission_state: &mut BTreeMap, + ) -> TradingCost { + self.calculate(date, side, gross_amount) + } } #[derive(Debug, Clone, Copy)] @@ -67,6 +80,43 @@ impl ChinaAShareCostModel { } gross_amount * self.stamp_tax_rate_for(date) } + + pub fn commission_for_order_fill( + &self, + gross_amount: f64, + order_id: Option, + commission_state: &mut BTreeMap, + ) -> 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 { @@ -86,4 +136,28 @@ impl CostModel for ChinaAShareCostModel { stamp_tax, } } + + fn calculate_with_order_state( + &self, + date: NaiveDate, + side: OrderSide, + gross_amount: f64, + order_id: Option, + commission_state: &mut BTreeMap, + ) -> TradingCost { + if gross_amount <= 0.0 { + return TradingCost { + commission: 0.0, + stamp_tax: 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); + + TradingCost { + commission, + stamp_tax, + } + } } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index a3e752b..82d85a7 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -6,7 +6,7 @@ use crate::broker::{BrokerExecutionReport, BrokerSimulator}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; -use crate::metrics::{compute_backtest_metrics, BacktestMetrics}; +use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; use crate::strategy::{Strategy, StrategyContext}; @@ -574,6 +574,7 @@ where notes.push(reason.clone()); report.order_events.push(OrderEvent { date, + order_id: None, symbol: symbol.clone(), side: OrderSide::Sell, requested_quantity: quantity, @@ -583,6 +584,7 @@ where }); report.fill_events.push(FillEvent { date, + order_id: None, symbol: symbol.clone(), side: OrderSide::Sell, quantity, diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index d775abb..7919ff5 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -41,6 +41,8 @@ pub enum OrderStatus { pub struct OrderEvent { #[serde(with = "date_format")] pub date: NaiveDate, + #[serde(default)] + pub order_id: Option, pub symbol: String, pub side: OrderSide, pub requested_quantity: u32, @@ -53,6 +55,8 @@ pub struct OrderEvent { pub struct FillEvent { #[serde(with = "date_format")] pub date: NaiveDate, + #[serde(default)] + pub order_id: Option, pub symbol: String, pub side: OrderSide, pub quantity: u32, diff --git a/crates/fidc-core/tests/core_rules.rs b/crates/fidc-core/tests/core_rules.rs index ae9956a..fdee5f2 100644 --- a/crates/fidc-core/tests/core_rules.rs +++ b/crates/fidc-core/tests/core_rules.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use chrono::NaiveDate; use fidc_core::cost::CostModel; use fidc_core::rules::EquityRuleHooks; @@ -74,6 +76,46 @@ fn china_cost_model_switches_stamp_tax_rate_after_2023_08_28() { assert!((after.stamp_tax - 50.0).abs() < 1e-9); } +#[test] +fn china_cost_model_tracks_minimum_commission_per_order_id() { + let model = ChinaAShareCostModel::default(); + let mut commission_state = BTreeMap::new(); + + let first = model.calculate_with_order_state( + d(2024, 1, 3), + OrderSide::Buy, + 1_000.0, + Some(7), + &mut commission_state, + ); + let second = model.calculate_with_order_state( + d(2024, 1, 3), + OrderSide::Buy, + 1_000.0, + Some(7), + &mut commission_state, + ); + let third = model.calculate_with_order_state( + d(2024, 1, 3), + OrderSide::Buy, + 20_000.0, + Some(7), + &mut commission_state, + ); + let another_order = model.calculate_with_order_state( + d(2024, 1, 3), + OrderSide::Buy, + 1_000.0, + Some(8), + &mut commission_state, + ); + + assert!((first.commission - 5.0).abs() < 1e-9); + assert!(second.commission.abs() < 1e-9); + assert!((third.commission - 1.6).abs() < 1e-9); + assert!((another_order.commission - 5.0).abs() < 1e-9); +} + #[test] fn china_rule_hooks_block_same_day_sell_under_t_plus_one() { let hooks = ChinaEquityRuleHooks; @@ -107,11 +149,13 @@ 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), @@ -121,11 +165,13 @@ 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] diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 893c139..cc365c1 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -477,6 +477,179 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { ); } +#[test] +fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![ + Instrument { + symbol: "000001.SZ".to_string(), + name: "LowerWeight".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: "000002.SZ".to_string(), + name: "HigherWeight".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 60.0, + free_float_cap_bn: 50.0, + pe_ttm: 18.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(10_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([ + ("000001.SZ".to_string(), 0.2), + ("000002.SZ".to_string(), 0.8), + ]), + exit_symbols: BTreeSet::new(), + order_intents: Vec::new(), + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!( + portfolio + .position("000002.SZ") + .map(|position| position.quantity) + .unwrap_or(0), + 900 + ); + assert!( + portfolio.position("000001.SZ").is_none(), + "higher target weight should consume the limited rebalance cash first" + ); + assert!( + report + .order_events + .iter() + .any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy) + ); +} + #[test] fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();