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

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
+335 -4
View File
@@ -1844,6 +1844,24 @@ where
self.cancel_open_order(date, *order_id, reason, report);
Ok(())
}
OrderIntent::ModifyOrder {
order_id,
new_total_quantity,
new_limit_price,
reason,
} => {
self.modify_open_order(
date,
portfolio,
data,
*order_id,
*new_total_quantity,
*new_limit_price,
reason,
report,
);
Ok(())
}
OrderIntent::CancelSymbol { symbol, reason } => {
self.cancel_open_orders_for_symbol(date, symbol, reason, report);
Ok(())
@@ -2095,8 +2113,14 @@ where
fn upsert_open_order(&self, open_order: OpenOrder) {
let mut open_orders = self.open_orders.borrow_mut();
open_orders.retain(|existing| existing.order_id != open_order.order_id);
open_orders.push(open_order);
if let Some(existing) = open_orders
.iter_mut()
.find(|existing| existing.order_id == open_order.order_id)
{
*existing = open_order;
} else {
open_orders.push(open_order);
}
}
fn current_decision_date(&self, date: NaiveDate) -> NaiveDate {
@@ -2400,6 +2424,272 @@ where
}
}
#[allow(clippy::too_many_arguments)]
fn modify_open_order(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
order_id: u64,
new_total_quantity: Option<u32>,
new_limit_price: Option<f64>,
reason: &str,
report: &mut BrokerExecutionReport,
) {
let Some(existing) = self
.open_orders
.borrow()
.iter()
.find(|order| order.order_id == order_id)
.cloned()
else {
Self::emit_open_order_update_rejected(
report,
date,
order_id,
None,
None,
reason,
"not_found",
);
return;
};
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderPendingUpdate,
order_id,
&existing.symbol,
existing.side,
format!("reason={reason}"),
);
let target_total_quantity = new_total_quantity.unwrap_or(existing.requested_quantity);
let target_limit_price = new_limit_price.unwrap_or(existing.limit_price);
if target_total_quantity == existing.requested_quantity
&& target_limit_price.to_bits() == existing.limit_price.to_bits()
{
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
"no_fields_changed",
);
return;
}
if target_total_quantity <= existing.filled_quantity {
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
&format!(
"new_total_quantity_must_exceed_filled_quantity new_total={} filled={}",
target_total_quantity, existing.filled_quantity
),
);
return;
}
if !target_limit_price.is_finite() || target_limit_price <= 0.0 {
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
"limit_price_must_be_positive",
);
return;
}
let Some(snapshot) = data.market(date, &existing.symbol) else {
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
"market_snapshot_missing_for_update_validation",
);
return;
};
let price_tick = snapshot.effective_price_tick().max(1e-9);
let tick_aligned_price = (target_limit_price / price_tick).round() * price_tick;
if (target_limit_price - tick_aligned_price).abs() > price_tick * 1e-6 {
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
&format!(
"limit_price_not_tick_aligned price={} tick={}",
target_limit_price, price_tick
),
);
return;
}
if (snapshot.lower_limit.is_finite()
&& snapshot.lower_limit > 0.0
&& target_limit_price + price_tick * 1e-6 < snapshot.lower_limit)
|| (snapshot.upper_limit.is_finite()
&& snapshot.upper_limit > 0.0
&& target_limit_price > snapshot.upper_limit + price_tick * 1e-6)
{
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
&format!(
"limit_price_outside_daily_range price={} lower={} upper={}",
target_limit_price, snapshot.lower_limit, snapshot.upper_limit
),
);
return;
}
let target_remaining_quantity =
target_total_quantity.saturating_sub(existing.filled_quantity);
if existing.side == OrderSide::Buy {
let minimum_order_quantity = self.minimum_order_quantity(data, &existing.symbol);
let order_step_size = self.order_step_size(data, &existing.symbol);
if self.round_buy_quantity(
target_remaining_quantity,
minimum_order_quantity,
order_step_size,
) != target_remaining_quantity
{
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
&format!(
"remaining_quantity_not_lot_aligned remaining={} minimum={} step={}",
target_remaining_quantity, minimum_order_quantity, order_step_size
),
);
return;
}
} else {
let position_quantity = portfolio
.position(&existing.symbol)
.map(|position| position.quantity)
.unwrap_or(0);
let reserved_by_other_orders =
self.reserved_open_sell_quantity(&existing.symbol, Some(order_id));
let available_quantity = position_quantity.saturating_sub(reserved_by_other_orders);
if target_remaining_quantity > available_quantity {
Self::emit_open_order_update_rejected(
report,
date,
order_id,
Some(&existing.symbol),
Some(existing.side),
reason,
&format!(
"sell_quantity_exceeds_available remaining={} available={} other_reserved={}",
target_remaining_quantity, available_quantity, reserved_by_other_orders
),
);
return;
}
}
let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|| target_total_quantity > existing.requested_quantity;
{
let mut open_orders = self.open_orders.borrow_mut();
let index = open_orders
.iter()
.position(|order| order.order_id == order_id)
.expect("open order disappeared during synchronous update");
let order = &mut open_orders[index];
order.requested_quantity = target_total_quantity;
order.remaining_quantity = target_remaining_quantity;
order.limit_price = target_limit_price;
if resets_queue_priority {
let amended = open_orders.remove(index);
open_orders.push(amended);
}
}
report.order_events.push(OrderEvent {
date,
decision_date: existing.decision_date,
order_created_date: existing.order_created_date,
execution_date: None,
order_id: Some(order_id),
symbol: existing.symbol.clone(),
side: existing.side,
requested_quantity: target_total_quantity,
filled_quantity: existing.filled_quantity,
status: if existing.filled_quantity == 0 {
OrderStatus::Pending
} else {
OrderStatus::PartiallyFilled
},
reason: format!(
"{reason}: order updated old_total={} new_total={} old_limit={} new_limit={} queue_priority_reset={}",
existing.requested_quantity,
target_total_quantity,
existing.limit_price,
target_limit_price,
resets_queue_priority
),
});
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderUpdatePass,
order_id,
&existing.symbol,
existing.side,
format!(
"old_total={} new_total={} filled={} remaining={} old_limit={} new_limit={} queue_priority_reset={}",
existing.requested_quantity,
target_total_quantity,
existing.filled_quantity,
target_remaining_quantity,
existing.limit_price,
target_limit_price,
resets_queue_priority
),
);
}
#[allow(clippy::too_many_arguments)]
fn emit_open_order_update_rejected(
report: &mut BrokerExecutionReport,
date: NaiveDate,
order_id: u64,
symbol: Option<&str>,
side: Option<OrderSide>,
reason: &str,
detail: &str,
) {
report.process_events.push(ProcessEvent {
date,
kind: ProcessEventKind::OrderUpdateReject,
order_id: Some(order_id),
symbol: symbol.map(ToString::to_string),
side,
detail: format!("reason={reason} status={detail}"),
});
}
fn cancel_open_orders_for_symbol(
&self,
date: NaiveDate,
@@ -7241,7 +7531,7 @@ mod tests {
use std::collections::BTreeMap;
use super::{
BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType,
BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, OpenOrder,
RebalanceCashMode, SlippageModel,
};
use crate::cost::ChinaAShareCostModel;
@@ -7255,7 +7545,48 @@ mod tests {
use crate::portfolio::PortfolioState;
use crate::risk_control::FidcRiskControlConfig;
use crate::rules::ChinaEquityRuleHooks;
use crate::strategy::{AlgoOrderStyle, OrderIntent, StrategyDecision};
use crate::strategy::{AlgoOrderStyle, OrderIntent, OrderTimeInForce, StrategyDecision};
fn test_open_order(order_id: u64) -> OpenOrder {
OpenOrder {
order_id,
decision_date: None,
order_created_date: None,
symbol: "000001.SZ".to_string(),
side: OrderSide::Buy,
requested_quantity: 200,
filled_quantity: 0,
remaining_quantity: 200,
limit_price: 10.0,
time_in_force: OrderTimeInForce::Gtc,
commission_remaining: None,
execution_cursor: None,
reason: format!("order_{order_id}"),
}
}
#[test]
fn open_order_upsert_replaces_in_place_and_preserves_queue_position() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks);
broker.upsert_open_order(test_open_order(1));
broker.upsert_open_order(test_open_order(2));
let mut amended = test_open_order(1);
amended.filled_quantity = 100;
amended.remaining_quantity = 100;
broker.upsert_open_order(amended);
assert_eq!(
broker
.open_orders
.borrow()
.iter()
.map(|order| order.order_id)
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(broker.open_order_views()[0].filled_quantity, 100);
}
fn limit_test_snapshot() -> DailyMarketSnapshot {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");