增加类型化开放订单改单能力
This commit is contained in:
@@ -1844,6 +1844,24 @@ where
|
|||||||
self.cancel_open_order(date, *order_id, reason, report);
|
self.cancel_open_order(date, *order_id, reason, report);
|
||||||
Ok(())
|
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 } => {
|
OrderIntent::CancelSymbol { symbol, reason } => {
|
||||||
self.cancel_open_orders_for_symbol(date, symbol, reason, report);
|
self.cancel_open_orders_for_symbol(date, symbol, reason, report);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2095,9 +2113,15 @@ where
|
|||||||
|
|
||||||
fn upsert_open_order(&self, open_order: OpenOrder) {
|
fn upsert_open_order(&self, open_order: OpenOrder) {
|
||||||
let mut open_orders = self.open_orders.borrow_mut();
|
let mut open_orders = self.open_orders.borrow_mut();
|
||||||
open_orders.retain(|existing| existing.order_id != open_order.order_id);
|
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);
|
open_orders.push(open_order);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn current_decision_date(&self, date: NaiveDate) -> NaiveDate {
|
fn current_decision_date(&self, date: NaiveDate) -> NaiveDate {
|
||||||
self.runtime_decision_date.get().unwrap_or(date)
|
self.runtime_decision_date.get().unwrap_or(date)
|
||||||
@@ -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(
|
fn cancel_open_orders_for_symbol(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -7241,7 +7531,7 @@ mod tests {
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType,
|
BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, OpenOrder,
|
||||||
RebalanceCashMode, SlippageModel,
|
RebalanceCashMode, SlippageModel,
|
||||||
};
|
};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
@@ -7255,7 +7545,48 @@ mod tests {
|
|||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::risk_control::FidcRiskControlConfig;
|
use crate::risk_control::FidcRiskControlConfig;
|
||||||
use crate::rules::ChinaEquityRuleHooks;
|
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 {
|
fn limit_test_snapshot() -> DailyMarketSnapshot {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
|||||||
@@ -1098,6 +1098,17 @@ where
|
|||||||
merge_futures_report(directive_report, report);
|
merge_futures_report(directive_report, report);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::strategy::OrderIntent::ModifyOrder {
|
||||||
|
order_id,
|
||||||
|
new_total_quantity,
|
||||||
|
new_limit_price,
|
||||||
|
reason,
|
||||||
|
} => retained.push(crate::strategy::OrderIntent::ModifyOrder {
|
||||||
|
order_id,
|
||||||
|
new_total_quantity,
|
||||||
|
new_limit_price,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
crate::strategy::OrderIntent::CancelSymbol { symbol, reason } => {
|
crate::strategy::OrderIntent::CancelSymbol { symbol, reason } => {
|
||||||
let report = self.cancel_futures_open_orders_for_symbol(
|
let report = self.cancel_futures_open_orders_for_symbol(
|
||||||
execution_date,
|
execution_date,
|
||||||
@@ -3882,6 +3893,11 @@ fn execution_quote_symbols_for_decision(
|
|||||||
OrderIntent::CancelAll { .. } => {
|
OrderIntent::CancelAll { .. } => {
|
||||||
symbols.extend(open_orders.iter().map(|order| order.symbol.clone()));
|
symbols.extend(open_orders.iter().map(|order| order.symbol.clone()));
|
||||||
}
|
}
|
||||||
|
OrderIntent::ModifyOrder { order_id, .. } => {
|
||||||
|
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id) {
|
||||||
|
symbols.insert(order.symbol.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
OrderIntent::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
|
OrderIntent::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
|
||||||
OrderIntent::UpdateUniverse { .. }
|
OrderIntent::UpdateUniverse { .. }
|
||||||
| OrderIntent::Subscribe { .. }
|
| OrderIntent::Subscribe { .. }
|
||||||
|
|||||||
@@ -306,6 +306,9 @@ pub enum ProcessEventKind {
|
|||||||
OrderPendingCancel,
|
OrderPendingCancel,
|
||||||
OrderCancellationPass,
|
OrderCancellationPass,
|
||||||
OrderCancellationReject,
|
OrderCancellationReject,
|
||||||
|
OrderPendingUpdate,
|
||||||
|
OrderUpdatePass,
|
||||||
|
OrderUpdateReject,
|
||||||
OrderUnsolicitedUpdate,
|
OrderUnsolicitedUpdate,
|
||||||
Trade,
|
Trade,
|
||||||
UniverseUpdated,
|
UniverseUpdated,
|
||||||
@@ -348,6 +351,9 @@ impl ProcessEventKind {
|
|||||||
Self::OrderPendingCancel => "order_pending_cancel",
|
Self::OrderPendingCancel => "order_pending_cancel",
|
||||||
Self::OrderCancellationPass => "order_cancellation_pass",
|
Self::OrderCancellationPass => "order_cancellation_pass",
|
||||||
Self::OrderCancellationReject => "order_cancellation_reject",
|
Self::OrderCancellationReject => "order_cancellation_reject",
|
||||||
|
Self::OrderPendingUpdate => "order_pending_update",
|
||||||
|
Self::OrderUpdatePass => "order_update_pass",
|
||||||
|
Self::OrderUpdateReject => "order_update_reject",
|
||||||
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
|
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
|
||||||
Self::Trade => "trade",
|
Self::Trade => "trade",
|
||||||
Self::UniverseUpdated => "universe_updated",
|
Self::UniverseUpdated => "universe_updated",
|
||||||
|
|||||||
@@ -342,6 +342,14 @@ pub enum PlatformTradeAction {
|
|||||||
when_expr: Option<String>,
|
when_expr: Option<String>,
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
Modify {
|
||||||
|
symbol: Option<String>,
|
||||||
|
order_id_expr: String,
|
||||||
|
new_total_quantity_expr: Option<String>,
|
||||||
|
new_limit_price_expr: Option<String>,
|
||||||
|
when_expr: Option<String>,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -1486,6 +1494,31 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PlatformTradeAction::Modify {
|
||||||
|
order_id_expr,
|
||||||
|
new_total_quantity_expr,
|
||||||
|
new_limit_price_expr,
|
||||||
|
when_expr,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
expressions.push((
|
||||||
|
format!("explicit_actions[{index}].order_id_expr"),
|
||||||
|
order_id_expr,
|
||||||
|
));
|
||||||
|
for (name, expression) in [
|
||||||
|
(
|
||||||
|
"new_total_quantity_expr",
|
||||||
|
new_total_quantity_expr.as_deref(),
|
||||||
|
),
|
||||||
|
("new_limit_price_expr", new_limit_price_expr.as_deref()),
|
||||||
|
("when_expr", when_expr.as_deref()),
|
||||||
|
] {
|
||||||
|
if let Some(expression) = expression {
|
||||||
|
expressions
|
||||||
|
.push((format!("explicit_actions[{index}].{name}"), expression));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8365,6 +8398,64 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PlatformTradeAction::Modify {
|
||||||
|
symbol,
|
||||||
|
order_id_expr,
|
||||||
|
new_total_quantity_expr,
|
||||||
|
new_limit_price_expr,
|
||||||
|
when_expr,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
let stock_state = self.action_stock_state(ctx, date, symbol.as_deref())?;
|
||||||
|
if !self.action_when_matches(
|
||||||
|
ctx,
|
||||||
|
day,
|
||||||
|
stock_state.as_deref(),
|
||||||
|
when_expr.as_deref(),
|
||||||
|
)? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let order_id =
|
||||||
|
self.eval_u64(ctx, order_id_expr, day, stock_state.as_deref(), None)?;
|
||||||
|
if order_id == 0 {
|
||||||
|
return Err(BacktestError::Execution(
|
||||||
|
"modify_order order_id must be positive".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let new_total_quantity = new_total_quantity_expr
|
||||||
|
.as_deref()
|
||||||
|
.map(|expr| {
|
||||||
|
self.eval_u64(ctx, expr, day, stock_state.as_deref(), None)
|
||||||
|
.and_then(|value| {
|
||||||
|
u32::try_from(value).map_err(|_| {
|
||||||
|
BacktestError::Execution(format!(
|
||||||
|
"modify_order total quantity exceeds u32 order_id={order_id} quantity={value}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
if new_total_quantity == Some(0) {
|
||||||
|
return Err(BacktestError::Execution(format!(
|
||||||
|
"modify_order total quantity must be positive order_id={order_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let new_limit_price = new_limit_price_expr
|
||||||
|
.as_deref()
|
||||||
|
.map(|expr| self.eval_float(ctx, expr, day, stock_state.as_deref(), None))
|
||||||
|
.transpose()?;
|
||||||
|
if new_limit_price.is_some_and(|value| !value.is_finite() || value <= 0.0) {
|
||||||
|
return Err(BacktestError::Execution(format!(
|
||||||
|
"modify_order limit price must be positive order_id={order_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
intents.push(OrderIntent::ModifyOrder {
|
||||||
|
order_id,
|
||||||
|
new_total_quantity,
|
||||||
|
new_limit_price,
|
||||||
|
reason: reason.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
PlatformTradeAction::Universe {
|
PlatformTradeAction::Universe {
|
||||||
kind,
|
kind,
|
||||||
symbols_expr,
|
symbols_expr,
|
||||||
@@ -9571,6 +9662,7 @@ impl PlatformExprStrategy {
|
|||||||
action,
|
action,
|
||||||
PlatformTradeAction::Order { .. }
|
PlatformTradeAction::Order { .. }
|
||||||
| PlatformTradeAction::TargetPortfolioSmart { .. }
|
| PlatformTradeAction::TargetPortfolioSmart { .. }
|
||||||
|
| PlatformTradeAction::Modify { .. }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -31770,11 +31862,32 @@ mod tests {
|
|||||||
"has_open_orders && open_order_count == 1 && open_sell_qty == 200 && symbol_open_sell_qty == 200 && symbol_open_order_count == 1 && latest_open_order_status == \"pending\" && latest_open_order_unfilled_qty == 200 && latest_symbol_open_order_status == \"pending\" && latest_symbol_open_order_unfilled_qty == 200".to_string(),
|
"has_open_orders && open_order_count == 1 && open_sell_qty == 200 && symbol_open_sell_qty == 200 && symbol_open_order_count == 1 && latest_open_order_status == \"pending\" && latest_open_order_unfilled_qty == 200 && latest_symbol_open_order_status == \"pending\" && latest_symbol_open_order_unfilled_qty == 200".to_string(),
|
||||||
),
|
),
|
||||||
reason: "open_order_aware_entry".to_string(),
|
reason: "open_order_aware_entry".to_string(),
|
||||||
|
}, PlatformTradeAction::Modify {
|
||||||
|
symbol: Some("000001.SZ".to_string()),
|
||||||
|
order_id_expr: "latest_open_order_id".to_string(),
|
||||||
|
new_total_quantity_expr: Some("latest_open_order_unfilled_qty + 100".to_string()),
|
||||||
|
new_limit_price_expr: Some("10.3".to_string()),
|
||||||
|
when_expr: Some("latest_symbol_open_order_id == 42".to_string()),
|
||||||
|
reason: "reprice_open_order".to_string(),
|
||||||
}];
|
}];
|
||||||
let mut strategy = PlatformExprStrategy::new(cfg);
|
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||||
|
|
||||||
let decision = strategy.on_day(&ctx).expect("platform decision");
|
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||||
assert_eq!(decision.order_intents.len(), 1);
|
assert_eq!(decision.order_intents.len(), 2);
|
||||||
|
match &decision.order_intents[1] {
|
||||||
|
crate::strategy::OrderIntent::ModifyOrder {
|
||||||
|
order_id,
|
||||||
|
new_total_quantity,
|
||||||
|
new_limit_price,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
assert_eq!(*order_id, 42);
|
||||||
|
assert_eq!(*new_total_quantity, Some(300));
|
||||||
|
assert_eq!(*new_limit_price, Some(10.3));
|
||||||
|
assert_eq!(reason, "reprice_open_order");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected modify intent: {other:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2455,6 +2455,42 @@ fn parse_platform_trade_action(
|
|||||||
when_expr,
|
when_expr,
|
||||||
reason,
|
reason,
|
||||||
}),
|
}),
|
||||||
|
"modify_order" => {
|
||||||
|
let order_id_expr = action
|
||||||
|
.order_id_expr
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())?
|
||||||
|
.to_string();
|
||||||
|
let new_total_quantity_expr = action
|
||||||
|
.quantity_expr
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let new_limit_price_expr = action
|
||||||
|
.limit_price_expr
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
if new_total_quantity_expr.is_none() && new_limit_price_expr.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PlatformTradeAction::Modify {
|
||||||
|
symbol: action
|
||||||
|
.symbol
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string),
|
||||||
|
order_id_expr,
|
||||||
|
new_total_quantity_expr,
|
||||||
|
new_limit_price_expr,
|
||||||
|
when_expr,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
"update_universe" => Some(PlatformTradeAction::Universe {
|
"update_universe" => Some(PlatformTradeAction::Universe {
|
||||||
kind: PlatformUniverseActionKind::UpdateUniverse,
|
kind: PlatformUniverseActionKind::UpdateUniverse,
|
||||||
symbols_expr: action
|
symbols_expr: action
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub trait Strategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct OpenOrderView {
|
pub struct OpenOrderView {
|
||||||
pub order_id: u64,
|
pub order_id: u64,
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
@@ -1170,6 +1170,12 @@ pub enum OrderIntent {
|
|||||||
order_id: u64,
|
order_id: u64,
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
ModifyOrder {
|
||||||
|
order_id: u64,
|
||||||
|
new_total_quantity: Option<u32>,
|
||||||
|
new_limit_price: Option<f64>,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
CancelSymbol {
|
CancelSymbol {
|
||||||
symbol: String,
|
symbol: String,
|
||||||
reason: String,
|
reason: String,
|
||||||
@@ -1257,6 +1263,7 @@ impl OrderIntent {
|
|||||||
if matches!(
|
if matches!(
|
||||||
intent,
|
intent,
|
||||||
Self::CancelOrder { .. }
|
Self::CancelOrder { .. }
|
||||||
|
| Self::ModifyOrder { .. }
|
||||||
| Self::CancelSymbol { .. }
|
| Self::CancelSymbol { .. }
|
||||||
| Self::CancelAll { .. }
|
| Self::CancelAll { .. }
|
||||||
| Self::UpdateUniverse { .. }
|
| Self::UpdateUniverse { .. }
|
||||||
|
|||||||
@@ -265,13 +265,17 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
||||||
},
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
|
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
||||||
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||||
},
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "order.time_in_force target runtime scope".to_string(),
|
title: "order.time_in_force target runtime scope".to_string(),
|
||||||
detail: "回测支持 DAY/IOC/FOK/GTC;paper/live 当前只支持 DAY/IOC/FOK。GTC 需要持久化跨交易日 parent/child 重挂账本和券商适配器能力,在该合同实现前只允许回测,paper/live 必须明确拒绝并禁止降级为 DAY。生成策略前必须按目标运行模式选择能力。".to_string(),
|
detail: "回测支持 DAY/IOC/FOK/GTC;paper/live 当前只支持 DAY/IOC/FOK。GTC 需要持久化跨交易日 parent/child 重挂账本和券商适配器能力,在该合同实现前只允许回测,paper/live 必须明确拒绝并禁止降级为 DAY。生成策略前必须按目标运行模式选择能力。".to_string(),
|
||||||
},
|
},
|
||||||
|
ManualSection {
|
||||||
|
title: "order.modify".to_string(),
|
||||||
|
detail: "回测中可用 order.modify(order_id, total_quantity=?, limit_price=?) 原位修改仍未完成的限价单。total_quantity 是新的总委托量而不是增量,不能低于已成交量;改价或增量会重置盘口队列优先级,减少总量且不改价保留优先级,同时保留 order_id、有效期、累计成交和费用状态。paper/live 在适配器提供持久且确认的 cancel-replace 合同前必须拒绝该动作,不得静默转换为撤单加新订单。".to_string(),
|
||||||
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "when / unless / else".to_string(),
|
title: "when / unless / else".to_string(),
|
||||||
detail: "条件块支持按日期、指数、仓位等动态切换规则。".to_string(),
|
detail: "条件块支持按日期、指数、仓位等动态切换规则。".to_string(),
|
||||||
|
|||||||
@@ -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);
|
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]
|
#[test]
|
||||||
fn broker_rejects_gtc_for_market_order() {
|
fn broker_rejects_gtc_for_market_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