修正跨调度撮合流动性重复消费
This commit is contained in:
+398
-92
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();
|
||||
|
||||
Reference in New Issue
Block a user