From 9b00a0777a9b59c4523c32bbd6a6fe10300e9389 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 08:08:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=B1=BB=E5=9E=8B=E5=8C=96?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E8=AE=A2=E5=8D=95=E6=94=B9=E5=8D=95=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 339 +++++++++++++++++- crates/fidc-core/src/engine.rs | 16 + crates/fidc-core/src/events.rs | 6 + .../fidc-core/src/platform_expr_strategy.rs | 115 +++++- .../fidc-core/src/platform_strategy_spec.rs | 36 ++ crates/fidc-core/src/strategy.rs | 9 +- crates/fidc-core/src/strategy_ai.rs | 6 +- crates/fidc-core/tests/explicit_order_flow.rs | 326 +++++++++++++++++ 8 files changed, 846 insertions(+), 7 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 8e1fc59..ab3e188 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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, + new_limit_price: Option, + 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, + 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![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"); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 2de624c..6f61bda 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1098,6 +1098,17 @@ where 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 } => { let report = self.cancel_futures_open_orders_for_symbol( execution_date, @@ -3882,6 +3893,11 @@ fn execution_quote_symbols_for_decision( OrderIntent::CancelAll { .. } => { 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::UpdateUniverse { .. } | OrderIntent::Subscribe { .. } diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 539b77a..c53d6c2 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -306,6 +306,9 @@ pub enum ProcessEventKind { OrderPendingCancel, OrderCancellationPass, OrderCancellationReject, + OrderPendingUpdate, + OrderUpdatePass, + OrderUpdateReject, OrderUnsolicitedUpdate, Trade, UniverseUpdated, @@ -348,6 +351,9 @@ impl ProcessEventKind { Self::OrderPendingCancel => "order_pending_cancel", Self::OrderCancellationPass => "order_cancellation_pass", 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::Trade => "trade", Self::UniverseUpdated => "universe_updated", diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2e27992..0e4272d 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -342,6 +342,14 @@ pub enum PlatformTradeAction { when_expr: Option, reason: String, }, + Modify { + symbol: Option, + order_id_expr: String, + new_total_quantity_expr: Option, + new_limit_price_expr: Option, + when_expr: Option, + reason: String, + }, } #[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 { kind, symbols_expr, @@ -9571,6 +9662,7 @@ impl PlatformExprStrategy { action, PlatformTradeAction::Order { .. } | 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(), ), 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 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] diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 633be0f..9c3d7bc 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -2455,6 +2455,42 @@ fn parse_platform_trade_action( when_expr, 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 { kind: PlatformUniverseActionKind::UpdateUniverse, symbols_expr: action diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 538a814..74cb752 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -89,7 +89,7 @@ pub trait Strategy { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct OpenOrderView { pub order_id: u64, pub symbol: String, @@ -1170,6 +1170,12 @@ pub enum OrderIntent { order_id: u64, reason: String, }, + ModifyOrder { + order_id: u64, + new_total_quantity: Option, + new_limit_price: Option, + reason: String, + }, CancelSymbol { symbol: String, reason: String, @@ -1257,6 +1263,7 @@ impl OrderIntent { if matches!( intent, Self::CancelOrder { .. } + | Self::ModifyOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. } | Self::UpdateUniverse { .. } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 7ea2dad..553fea5 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -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(), }, 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(), }, ManualSection { 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(), }, + 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 { title: "when / unless / else".to_string(), detail: "条件块支持按日期、指数、仓位等动态切换规则。".to_string(), diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 870d9f0..6a76874 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -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::>(); + 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::>(), + 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![initial_ids[1], initial_ids[0]] + ); +} + #[test] fn broker_rejects_gtc_for_market_order() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();