增加类型化开放订单改单能力
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user