增加类型化开放订单改单能力

This commit is contained in:
boris
2026-08-27 08:08:39 +08:00
parent 6ee1835ca5
commit 9b00a0777a
8 changed files with 846 additions and 7 deletions
@@ -5443,6 +5443,332 @@ fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() {
assert_eq!(day2_report.fill_events[0].commission, 0.0);
}
#[test]
fn broker_modifies_gtc_limit_order_without_changing_order_identity() {
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, 9.7);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut portfolio = PortfolioState::new(1_000_000.0);
let created = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 300,
limit_price: 9.8,
reason: "gtc_modify_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..StrategyDecision::default()
},
)
.expect("create GTC order");
let order_id = created.order_events[0].order_id.expect("order id");
let modified = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::ModifyOrder {
order_id,
new_total_quantity: Some(400),
new_limit_price: Some(9.9),
reason: "raise_gtc_order".to_string(),
}],
..StrategyDecision::default()
},
)
.expect("modify GTC order");
assert!(modified.fill_events.is_empty());
assert!(modified.process_events.iter().any(|event| {
event.kind == ProcessEventKind::OrderPendingUpdate && event.order_id == Some(order_id)
}));
assert!(modified.process_events.iter().any(|event| {
event.kind == ProcessEventKind::OrderUpdatePass
&& event.order_id == Some(order_id)
&& event.detail.contains("queue_priority_reset=true")
}));
let update_event = modified
.order_events
.iter()
.find(|event| event.reason.contains("order updated"))
.expect("persistent update event");
assert_eq!(update_event.order_id, Some(order_id));
assert_eq!(update_event.requested_quantity, 400);
assert_eq!(update_event.filled_quantity, 0);
assert_eq!(update_event.status, OrderStatus::Pending);
let amended = broker.open_order_views().pop().expect("amended order");
assert_eq!(amended.order_id, order_id);
assert_eq!(amended.requested_quantity, 400);
assert_eq!(amended.remaining_quantity, 400);
assert_eq!(amended.limit_price, 9.9);
let filled = broker
.execute(day2, &mut portfolio, &data, &StrategyDecision::default())
.expect("fill amended GTC order");
assert_eq!(filled.fill_events.len(), 1);
assert_eq!(filled.fill_events[0].order_id, Some(order_id));
assert_eq!(filled.fill_events[0].quantity, 400);
assert_eq!(filled.order_events[0].requested_quantity, 400);
assert_eq!(filled.order_events[0].filled_quantity, 400);
assert_eq!(filled.order_events[0].status, OrderStatus::Filled);
}
#[test]
fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() {
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 first = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 300,
limit_price: 10.1,
reason: "partial_then_modify".to_string(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..StrategyDecision::default()
},
)
.expect("partial GTC fill");
assert_eq!(first.fill_events[0].quantity, 100);
assert_eq!(first.fill_events[0].commission, 5.0);
let order_id = first.order_events[0].order_id.expect("order id");
let modified = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::ModifyOrder {
order_id,
new_total_quantity: Some(200),
new_limit_price: None,
reason: "reduce_total_after_partial_fill".to_string(),
}],
..StrategyDecision::default()
},
)
.expect("reduce partially filled order total");
let update_event = modified
.order_events
.iter()
.find(|event| event.reason.contains("order updated"))
.expect("update event");
assert_eq!(update_event.requested_quantity, 200);
assert_eq!(update_event.filled_quantity, 100);
assert_eq!(update_event.status, OrderStatus::PartiallyFilled);
let amended = broker.open_order_views().pop().expect("amended remainder");
assert_eq!(amended.requested_quantity, 200);
assert_eq!(amended.filled_quantity, 100);
assert_eq!(amended.remaining_quantity, 100);
let final_fill = broker
.execute(day2, &mut portfolio, &data, &StrategyDecision::default())
.expect("complete amended order");
assert_eq!(final_fill.fill_events.len(), 1);
assert_eq!(final_fill.fill_events[0].quantity, 100);
assert_eq!(final_fill.fill_events[0].commission, 0.0);
assert_eq!(final_fill.order_events[0].requested_quantity, 200);
assert_eq!(final_fill.order_events[0].filled_quantity, 200);
assert_eq!(final_fill.order_events[0].status, OrderStatus::Filled);
}
#[test]
fn broker_rejected_modify_has_zero_side_effects() {
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 created = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 200,
limit_price: 10.1,
reason: "reject_modify_source".to_string(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..StrategyDecision::default()
},
)
.expect("create partially filled GTC order");
let order_id = created.order_events[0].order_id.expect("order id");
let before = broker.open_order_views();
let cash_before = portfolio.cash();
let rejected = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::ModifyOrder {
order_id,
new_total_quantity: Some(100),
new_limit_price: Some(10.105),
reason: "invalid_modify".to_string(),
}],
..StrategyDecision::default()
},
)
.expect("invalid modify is a business rejection");
assert!(rejected.fill_events.is_empty());
assert!(rejected.process_events.iter().any(|event| {
event.kind == ProcessEventKind::OrderUpdateReject
&& event.order_id == Some(order_id)
&& event.detail.contains("must_exceed_filled_quantity")
}));
assert_eq!(broker.open_order_views(), before);
assert_eq!(portfolio.cash(), cash_before);
let final_fill = broker
.execute(day2, &mut portfolio, &data, &StrategyDecision::default())
.expect("original order remains executable");
assert_eq!(final_fill.fill_events[0].order_id, Some(order_id));
assert_eq!(final_fill.fill_events[0].commission, 0.0);
}
#[test]
fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).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,
);
let mut portfolio = PortfolioState::new(1_000_000.0);
let create = |reason: &str| StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 300,
limit_price: 9.8,
reason: reason.to_string(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..StrategyDecision::default()
};
broker
.execute(date, &mut portfolio, &data, &create("first"))
.unwrap();
broker
.execute(date, &mut portfolio, &data, &create("second"))
.unwrap();
let initial_ids = broker
.open_order_views()
.iter()
.map(|order| order.order_id)
.collect::<Vec<_>>();
assert_eq!(initial_ids.len(), 2);
let reduced = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::ModifyOrder {
order_id: initial_ids[0],
new_total_quantity: Some(200),
new_limit_price: None,
reason: "reduce_without_requeue".to_string(),
}],
..StrategyDecision::default()
},
)
.unwrap();
assert!(reduced.process_events.iter().any(|event| {
event.kind == ProcessEventKind::OrderUpdatePass
&& event.detail.contains("queue_priority_reset=false")
}));
assert_eq!(
broker
.open_order_views()
.iter()
.map(|order| order.order_id)
.collect::<Vec<_>>(),
initial_ids
);
let repriced = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![OrderIntent::ModifyOrder {
order_id: initial_ids[0],
new_total_quantity: None,
new_limit_price: Some(9.9),
reason: "reprice_and_requeue".to_string(),
}],
..StrategyDecision::default()
},
)
.unwrap();
assert!(repriced.process_events.iter().any(|event| {
event.kind == ProcessEventKind::OrderUpdatePass
&& event.detail.contains("queue_priority_reset=true")
}));
assert_eq!(
broker
.open_order_views()
.iter()
.map(|order| order.order_id)
.collect::<Vec<_>>(),
vec![initial_ids[1], initial_ids[0]]
);
}
#[test]
fn broker_rejects_gtc_for_market_order() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();