修正跨调度撮合流动性重复消费
This commit is contained in:
+395
-89
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")
|
.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(
|
fn execute_single_value_order(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
data: &DataSet,
|
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);
|
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]
|
#[test]
|
||||||
fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() {
|
fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() {
|
||||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user