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