Align order costs and rebalance priority with rqalpha

This commit is contained in:
boris
2026-04-22 23:36:20 -07:00
parent c85116c59d
commit ea2871a0f2
6 changed files with 436 additions and 36 deletions

View File

@@ -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]

View File

@@ -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();