修正跨调度撮合流动性重复消费

This commit is contained in:
boris
2026-08-27 00:57:34 +08:00
parent 78c5b72ed3
commit 01d1e5073d
2 changed files with 754 additions and 92 deletions
File diff suppressed because it is too large Load Diff
@@ -77,6 +77,117 @@ fn order_value_rounding_data(date: NaiveDate, symbol: &str, price: f64) -> DataS
.expect("dataset")
}
fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: symbol.to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some(format!("{date} 10:19:00")),
day_open: 10.0,
open: 10.0,
high: 10.2,
low: 9.8,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
minute_volume: 1_000,
bid1_volume: 5,
ask1_volume: 5,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.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(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
Vec::new(),
vec![
IntradayExecutionQuote {
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
last_price: 10.0,
bid1: 9.99,
ask1: 10.0,
bid1_volume: 4,
ask1_volume: 4,
volume_delta: 1_000,
amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 19, 0).unwrap(),
last_price: 10.0,
bid1: 9.99,
ask1: 10.0,
bid1_volume: 4,
ask1_volume: 4,
volume_delta: 1_000,
amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 20, 0).unwrap(),
last_price: 10.0,
bid1: 9.99,
ask1: 10.0,
bid1_volume: 5,
ask1_volume: 5,
volume_delta: 1_000,
amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()),
},
],
)
.expect("dataset")
}
fn execute_single_value_order(
date: NaiveDate,
data: &DataSet,
@@ -4830,6 +4941,251 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() {
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100);
}
#[test]
fn broker_persists_daily_volume_consumption_across_execute_calls() {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let data = two_day_limit_order_data(10.0, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
let decision = || StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: "000002.SZ".to_string(),
quantity: 100,
reason: "daily_volume_session_buy".to_string(),
}],
..StrategyDecision::default()
};
let first = broker
.execute(day1, &mut portfolio, &data, &decision())
.expect("first same-day execution");
assert_eq!(first.fill_events.len(), 1);
assert_eq!(first.fill_events[0].quantity, 100);
let second = broker
.execute(day1, &mut portfolio, &data, &decision())
.expect("second same-day execution");
assert!(second.fill_events.is_empty());
assert_eq!(second.order_events.len(), 1);
assert_eq!(second.order_events[0].status, OrderStatus::Canceled);
assert_eq!(second.order_events[0].filled_quantity, 0);
assert!(second.order_events[0].reason.contains("daily volume limit"));
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100);
let next_day = broker
.execute(day2, &mut portfolio, &data, &decision())
.expect("next-day execution resets daily liquidity");
assert_eq!(next_day.fill_events.len(), 1);
assert_eq!(next_day.fill_events[0].quantity, 100);
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200);
}
#[test]
fn broker_persists_quote_depth_until_fresh_level_data_arrives() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let symbol = "000002.SZ";
let data = intraday_liquidity_data(date, symbol);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Last,
)
.with_matching_type(MatchingType::MinuteLast)
.with_volume_limit(false)
.with_liquidity_limit(true);
let mut portfolio = PortfolioState::new(1_000_000.0);
let decision = |quantity| StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: symbol.to_string(),
quantity,
reason: "quote_depth_session_buy".to_string(),
}],
..StrategyDecision::default()
};
let at_1018 = NaiveTime::from_hms_opt(10, 18, 0).unwrap();
let at_1019 = NaiveTime::from_hms_opt(10, 19, 0).unwrap();
let at_1020 = NaiveTime::from_hms_opt(10, 20, 0).unwrap();
let atomic_reject = broker
.execute_between(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::Shares {
symbol: symbol.to_string(),
quantity: 500,
reason: "quote_depth_fok_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Fok),
],
..StrategyDecision::default()
},
Some(at_1018),
Some(at_1018),
)
.expect("FOK rejection must not consume quote depth");
assert!(atomic_reject.fill_events.is_empty());
assert_eq!(atomic_reject.order_events[0].status, OrderStatus::Canceled);
assert!(portfolio.position(symbol).is_none());
let first = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(300),
Some(at_1018),
Some(at_1018),
)
.expect("first quote-depth execution");
assert_eq!(first.fill_events.len(), 1);
assert_eq!(first.fill_events[0].quantity, 300);
let second = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(200),
Some(at_1018),
Some(at_1018),
)
.expect("second quote-depth execution");
assert_eq!(second.fill_events.len(), 1);
assert_eq!(second.fill_events[0].quantity, 100);
assert_eq!(second.order_events[0].status, OrderStatus::Canceled);
assert_eq!(second.order_events[0].filled_quantity, 100);
assert!(
second.order_events[0]
.reason
.contains("intraday quote liquidity exhausted")
);
let unchanged_level = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(100),
Some(at_1019),
Some(at_1019),
)
.expect("unchanged level must remain depleted");
assert!(unchanged_level.fill_events.is_empty());
assert_eq!(
unchanged_level.order_events[0].status,
OrderStatus::Canceled
);
assert!(
unchanged_level.order_events[0]
.reason
.contains("intraday quote liquidity exhausted")
);
let fresh_level = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(200),
Some(at_1020),
Some(at_1020),
)
.expect("fresh quote level resets depth consumption");
assert_eq!(fresh_level.fill_events.len(), 1);
assert_eq!(fresh_level.fill_events[0].quantity, 200);
assert_eq!(fresh_level.order_events[0].status, OrderStatus::Filled);
assert_eq!(portfolio.position(symbol).unwrap().quantity, 600);
}
#[test]
fn broker_persists_quote_volume_participation_until_next_quote() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let symbol = "000002.SZ";
let data = intraday_liquidity_data(date, symbol);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Last,
)
.with_matching_type(MatchingType::MinuteLast)
.with_volume_limit(true)
.with_volume_percent(0.25)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
let decision = |quantity| StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: symbol.to_string(),
quantity,
reason: "quote_volume_session_buy".to_string(),
}],
..StrategyDecision::default()
};
let at_1018 = NaiveTime::from_hms_opt(10, 18, 0).unwrap();
let at_1019 = NaiveTime::from_hms_opt(10, 19, 0).unwrap();
let first = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(100),
Some(at_1018),
Some(at_1018),
)
.expect("first quote-volume execution");
assert_eq!(first.fill_events[0].quantity, 100);
let second = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(200),
Some(at_1018),
Some(at_1018),
)
.expect("second quote-volume execution");
assert_eq!(second.fill_events[0].quantity, 100);
assert_eq!(second.order_events[0].status, OrderStatus::Canceled);
let exhausted = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(100),
Some(at_1018),
Some(at_1018),
)
.expect("quote volume must remain exhausted");
assert!(exhausted.fill_events.is_empty());
assert_eq!(exhausted.order_events[0].status, OrderStatus::Canceled);
let next_quote = broker
.execute_between(
date,
&mut portfolio,
&data,
&decision(200),
Some(at_1019),
Some(at_1019),
)
.expect("next quote receives a fresh participation bucket");
assert_eq!(next_quote.fill_events[0].quantity, 200);
assert_eq!(portfolio.position(symbol).unwrap().quantity, 400);
}
#[test]
fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();