diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index ae15040..a1a9e0c 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -16,7 +16,8 @@ use crate::portfolio::PortfolioState; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope}; use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::strategy::{ - AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, + AlgoOrderStyle, OpenOrderView, OrderIntent, OrderTimeInForce, StrategyDecision, + TargetPortfolioOrderPricing, }; #[derive(Debug, Default)] @@ -64,6 +65,9 @@ struct OpenOrder { filled_quantity: u32, remaining_quantity: u32, limit_price: f64, + time_in_force: OrderTimeInForce, + commission_remaining: Option, + execution_cursor: Option, reason: String, } @@ -98,6 +102,14 @@ pub enum RebalanceCashMode { PreOpenCash, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemainderPolicy { + Cancel, + KeepUntilClose, + KeepUntilCanceled, + FillOrKill, +} + impl Default for RebalanceCashMode { fn default() -> Self { Self::SellThenBuy @@ -205,6 +217,7 @@ pub struct BrokerSimulator { runtime_order_created_date: Cell>, runtime_decision_total_equity: Cell>, runtime_target_position_limit: Cell>, + runtime_time_in_force: Cell>, next_order_id: Cell, open_orders: RefCell>, } @@ -235,6 +248,7 @@ impl BrokerSimulator { runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), + runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -269,6 +283,7 @@ impl BrokerSimulator { runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), + runtime_time_in_force: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -366,6 +381,35 @@ impl BrokerSimulator { } } + fn effective_remainder_policy(&self, allow_pending_limit: bool) -> RemainderPolicy { + match self.runtime_time_in_force.get() { + Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, + Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled, + Some(OrderTimeInForce::Day) if allow_pending_limit => RemainderPolicy::KeepUntilClose, + Some(OrderTimeInForce::Day) => RemainderPolicy::Cancel, + Some(OrderTimeInForce::Ioc) => RemainderPolicy::Cancel, + None if allow_pending_limit => RemainderPolicy::KeepUntilClose, + None => RemainderPolicy::Cancel, + } + } + + fn pending_time_in_force(remainder_policy: RemainderPolicy) -> OrderTimeInForce { + match remainder_policy { + RemainderPolicy::KeepUntilClose => OrderTimeInForce::Day, + RemainderPolicy::KeepUntilCanceled => OrderTimeInForce::Gtc, + RemainderPolicy::Cancel | RemainderPolicy::FillOrKill => { + unreachable!("non-pending policy cannot create an open order") + } + } + } + + fn keeps_remainder_open(remainder_policy: RemainderPolicy) -> bool { + matches!( + remainder_policy, + RemainderPolicy::KeepUntilClose | RemainderPolicy::KeepUntilCanceled + ) + } + pub fn execution_price_field(&self) -> PriceField { self.execution_price_field } @@ -507,7 +551,7 @@ where } }; - match intent { + match intent.unwrapped() { OrderIntent::Shares { quantity, .. } | OrderIntent::LimitShares { quantity, .. } => { Some(if *quantity < 0 { OrderSide::Sell @@ -596,7 +640,7 @@ where } fn target_position_intent(intent: &OrderIntent) -> Option<(&str, bool)> { - match intent { + match intent.unwrapped() { OrderIntent::TargetShares { symbol, target_quantity, @@ -1248,7 +1292,39 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + if let OrderIntent::WithTimeInForce { + intent: wrapped, + time_in_force, + } = intent + { + if self.runtime_time_in_force.get().is_some() { + return Err(BacktestError::Execution( + "nested time-in-force wrappers are not allowed".to_string(), + )); + } + if !wrapped.supports_time_in_force(*time_in_force) { + return Err(BacktestError::Execution(format!( + "time_in_force={} is not supported for this order intent", + time_in_force.as_str() + ))); + } + let previous = self.runtime_time_in_force.replace(Some(*time_in_force)); + let result = self.process_order_intent( + date, + portfolio, + data, + wrapped, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ); + self.runtime_time_in_force.set(previous); + return result; + } match intent { + OrderIntent::WithTimeInForce { .. } => unreachable!("wrapper handled above"), OrderIntent::Shares { symbol, quantity, @@ -1883,6 +1959,47 @@ where .retain(|existing| existing.order_id != order_id); } + fn emit_fill_or_kill_canceled( + report: &mut BrokerExecutionReport, + date: NaiveDate, + order_id: u64, + symbol: &str, + side: OrderSide, + requested_quantity: u32, + possible_quantity: u32, + reason: &str, + ) { + let detail = format!( + "{reason}: FOK not fully fillable requested={requested_quantity} possible={possible_quantity}" + ); + report.order_events.push(OrderEvent { + date, + decision_date: None, + order_created_date: None, + execution_date: None, + order_id: Some(order_id), + symbol: symbol.to_string(), + side, + requested_quantity, + filled_quantity: 0, + status: OrderStatus::Canceled, + reason: detail.clone(), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order_id, + symbol, + side, + format!("status=Canceled reason={detail}"), + ); + report.diagnostics.push(format!( + "fok_order_canceled symbol={symbol} side={} requested={requested_quantity} possible={possible_quantity}", + side.as_str() + )); + } + fn mark_same_day_sold(&self, date: NaiveDate, symbol: &str) { self.same_day_sold_symbols .borrow_mut() @@ -1954,12 +2071,29 @@ where for order in pending_orders { let order_event_start = report.order_events.len(); let fill_event_start = report.fill_events.len(); + if let Some(commission_remaining) = order.commission_remaining { + commission_state.insert(order.order_id, commission_remaining); + } + if let Some(cursor) = order.execution_cursor { + execution_cursors + .entry(order.symbol.clone()) + .and_modify(|existing| *existing = (*existing).max(cursor)) + .or_insert(cursor); + if self.uses_serial_execution_cursor(&order.reason) + && global_execution_cursor.is_none_or(|existing| cursor > existing) + { + *global_execution_cursor = Some(cursor); + } + } let signed_quantity = if order.side == OrderSide::Buy { order.remaining_quantity as i32 } else { -(order.remaining_quantity as i32) }; - self.process_limit_shares_internal( + let previous_time_in_force = self + .runtime_time_in_force + .replace(Some(order.time_in_force)); + let execution_result = self.process_limit_shares_internal( date, portfolio, data, @@ -1974,7 +2108,80 @@ where global_execution_cursor, commission_state, report, - )?; + ); + self.runtime_time_in_force.set(previous_time_in_force); + execution_result?; + let attempt_filled = report.fill_events[fill_event_start..] + .iter() + .filter(|fill| fill.order_id == Some(order.order_id)) + .map(|fill| fill.quantity) + .sum::(); + let cumulative_filled = order.filled_quantity.saturating_add(attempt_filled); + let remaining_quantity = order.requested_quantity.saturating_sub(cumulative_filled); + let mut remains_open = false; + { + let mut open_orders = self.open_orders.borrow_mut(); + if let Some(reopened) = open_orders + .iter_mut() + .find(|reopened| reopened.order_id == order.order_id) + { + remains_open = remaining_quantity > 0; + reopened.decision_date = order.decision_date; + reopened.order_created_date = order.order_created_date; + reopened.requested_quantity = order.requested_quantity; + reopened.filled_quantity = cumulative_filled; + reopened.remaining_quantity = remaining_quantity; + reopened.time_in_force = order.time_in_force; + reopened.commission_remaining = commission_state.get(&order.order_id).copied(); + reopened.execution_cursor = execution_cursors.get(&order.symbol).copied(); + } + if !remains_open { + open_orders.retain(|open| open.order_id != order.order_id); + } + } + if report.order_events.len() == order_event_start && !remains_open { + report.order_events.push(OrderEvent { + date, + decision_date: order.decision_date, + order_created_date: order.order_created_date, + execution_date: Some(date), + order_id: Some(order.order_id), + symbol: order.symbol.clone(), + side: order.side, + requested_quantity: order.requested_quantity, + filled_quantity: cumulative_filled, + status: OrderStatus::Canceled, + reason: format!( + "{}: open order remainder canceled because no executable position remained", + order.reason + ), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderUnsolicitedUpdate, + order.order_id, + &order.symbol, + order.side, + "status=Canceled reason=no executable position remained", + ); + } + for event in &mut report.order_events[order_event_start..] { + if event.order_id != Some(order.order_id) { + continue; + } + event.requested_quantity = order.requested_quantity; + event.filled_quantity = cumulative_filled; + if remains_open { + event.status = if cumulative_filled == 0 { + OrderStatus::Pending + } else { + OrderStatus::PartiallyFilled + }; + } else if cumulative_filled > 0 && event.status == OrderStatus::Rejected { + event.status = OrderStatus::Canceled; + } + } Self::annotate_report_range( report, order_event_start, @@ -2130,9 +2337,13 @@ where std::mem::take(&mut *open_orders) }; for order in pending { + if order.time_in_force == OrderTimeInForce::Gtc { + self.upsert_open_order(order); + continue; + } let market_close_reason = format!( - "Order Rejected: {} can not match. Market close.", - order.symbol + "DAY order expired at market close: {} remaining_quantity={}", + order.symbol, order.remaining_quantity ); report.order_events.push(OrderEvent { date, @@ -2144,7 +2355,7 @@ where side: order.side, requested_quantity: order.requested_quantity, filled_quantity: order.filled_quantity, - status: OrderStatus::Rejected, + status: OrderStatus::Expired, reason: market_close_reason.clone(), }); Self::emit_order_process_event( @@ -2155,7 +2366,7 @@ where &order.symbol, order.side, format!( - "status=Rejected requested_quantity={} filled_quantity={} reason={market_close_reason}", + "status=Expired requested_quantity={} filled_quantity={} reason={market_close_reason}", order.requested_quantity, order.filled_quantity ), ); @@ -3339,6 +3550,7 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + let remainder_policy = self.effective_remainder_policy(allow_pending_limit); let Some(position) = portfolio.position(symbol) else { return Ok(()); }; @@ -3479,7 +3691,20 @@ where quantity } Err(limit_reason) => { - if allow_pending_limit { + if remainder_policy == RemainderPolicy::FillOrKill { + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + 0, + reason, + ); + return Ok(()); + } + if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -3490,6 +3715,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -3545,7 +3773,20 @@ where } }; if fillable_qty == 0 { - if allow_pending_limit { + if remainder_policy == RemainderPolicy::FillOrKill { + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + 0, + reason, + ); + return Ok(()); + } + if Self::keeps_remainder_open(remainder_policy) { let detail = partial_fill_reason .as_deref() .unwrap_or("no sellable quantity"); @@ -3559,6 +3800,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -3628,14 +3872,10 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs) = if let Some(fill) = fill { - execution_cursors.insert(symbol.to_string(), fill.next_cursor); - if self.uses_serial_execution_cursor(reason) { - *global_execution_cursor = Some(fill.next_cursor); - } + let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs) + (fill.quantity, fill.legs, Some(fill.next_cursor)) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); @@ -3643,7 +3883,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } else if !self.price_satisfies_limit( OrderSide::Sell, execution_price, @@ -3654,7 +3894,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new()) + (0, Vec::new(), None) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -3669,20 +3909,43 @@ where mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), quantity: fillable_qty, }], + None, ), Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } } } }; + if remainder_policy == RemainderPolicy::FillOrKill && filled_qty < requested_qty { + self.clear_open_order(order_id); + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Sell, + requested_qty, + filled_qty, + reason, + ); + return Ok(()); + } + if let Some(next_cursor) = next_cursor { + execution_cursors.insert(symbol.to_string(), next_cursor); + if self.uses_serial_execution_cursor(reason) { + *global_execution_cursor = Some(next_cursor); + } + } if filled_qty == 0 { let detail = partial_fill_reason .as_deref() .unwrap_or("limit price not marketable yet"); - if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) { + if Self::keeps_remainder_open(remainder_policy) + && Self::limit_order_can_remain_open(Some(detail)) + { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -3693,6 +3956,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -3838,7 +4104,7 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); - let keep_open = allow_pending_limit + let keep_open = Self::keeps_remainder_open(remainder_policy) && remaining_qty > 0 && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); if keep_open { @@ -3852,6 +4118,9 @@ where filled_quantity: filled_qty, remaining_quantity: remaining_qty, limit_price: limit_price.expect("limit price for pending limit sell"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); } else { @@ -4979,6 +5248,7 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + let remainder_policy = self.effective_remainder_policy(allow_pending_limit); if portfolio .position(symbol) .is_none_or(|position| position.quantity == 0) @@ -5130,7 +5400,20 @@ where quantity } Err(limit_reason) => { - if allow_pending_limit { + if remainder_policy == RemainderPolicy::FillOrKill { + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Buy, + requested_qty, + 0, + reason, + ); + return Ok(()); + } + if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -5141,6 +5424,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit buy"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -5223,14 +5509,10 @@ where algo_request, limit_price, ); - let (filled_qty, execution_legs) = if let Some(fill) = fill { - execution_cursors.insert(symbol.to_string(), fill.next_cursor); - if self.uses_serial_execution_cursor(reason) { - *global_execution_cursor = Some(fill.next_cursor); - } + let (filled_qty, execution_legs, next_cursor) = if let Some(fill) = fill { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.legs) + (fill.quantity, fill.legs, Some(fill.next_cursor)) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); @@ -5238,7 +5520,7 @@ where self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } else if !self.price_satisfies_limit( OrderSide::Buy, execution_price, @@ -5249,7 +5531,7 @@ where partial_fill_reason, Some("limit price not marketable yet"), ); - (0, Vec::new()) + (0, Vec::new(), None) } else { match self.execution_price_with_limit_slippage_or_rejection( snapshot, @@ -5260,7 +5542,7 @@ where Err(reason) => { partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, Some(reason)); - (0, Vec::new()) + (0, Vec::new(), None) } Ok(mut execution_price) => { let mut filled_qty = self.affordable_buy_quantity( @@ -5297,7 +5579,7 @@ where } } if blocked_by_final_price { - (0, Vec::new()) + (0, Vec::new(), None) } else { if filled_qty < constrained_qty { partial_fill_reason = merge_partial_fill_reason( @@ -5318,17 +5600,40 @@ where mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), quantity: filled_qty, }], + None, ) } } } } }; + if remainder_policy == RemainderPolicy::FillOrKill && filled_qty < requested_qty { + self.clear_open_order(order_id); + Self::emit_fill_or_kill_canceled( + report, + date, + order_id, + symbol, + OrderSide::Buy, + requested_qty, + filled_qty, + reason, + ); + return Ok(()); + } + if let Some(next_cursor) = next_cursor { + execution_cursors.insert(symbol.to_string(), next_cursor); + if self.uses_serial_execution_cursor(reason) { + *global_execution_cursor = Some(next_cursor); + } + } if filled_qty == 0 { let detail = partial_fill_reason .as_deref() .unwrap_or("insufficient cash after fees"); - if allow_pending_limit && Self::limit_order_can_remain_open(Some(detail)) { + if Self::keeps_remainder_open(remainder_policy) + && Self::limit_order_can_remain_open(Some(detail)) + { self.upsert_open_order(OpenOrder { order_id, decision_date: Some(self.current_decision_date(date)), @@ -5339,6 +5644,9 @@ where filled_quantity: 0, remaining_quantity: requested_qty, limit_price: limit_price.expect("limit price for pending limit buy"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); report.order_events.push(OrderEvent { @@ -5486,7 +5794,7 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); - let keep_open = allow_pending_limit + let keep_open = Self::keeps_remainder_open(remainder_policy) && remaining_qty > 0 && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); if keep_open { @@ -5500,6 +5808,9 @@ where filled_quantity: filled_qty, remaining_quantity: remaining_qty, limit_price: limit_price.expect("limit price for pending limit buy"), + time_in_force: Self::pending_time_in_force(remainder_policy), + commission_remaining: commission_state.get(&order_id).copied(), + execution_cursor: execution_cursors.get(symbol).copied(), reason: reason.to_string(), }); } else { diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6b99072..5f57246 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3824,7 +3824,7 @@ fn has_execution_quote_near_start_time( fn decision_has_algo_execution(decision: &StrategyDecision) -> bool { decision.order_intents.iter().any(|intent| { matches!( - intent, + intent.unwrapped(), OrderIntent::AlgoValue { .. } | OrderIntent::AlgoPercent { .. } | OrderIntent::TimedTargetValue { .. } @@ -3852,7 +3852,7 @@ fn execution_quote_symbols_for_decision( } for intent in &decision.order_intents { - match intent { + match intent.unwrapped() { OrderIntent::Shares { symbol, .. } | OrderIntent::LimitShares { symbol, .. } | OrderIntent::Lots { symbol, .. } @@ -3880,6 +3880,7 @@ fn execution_quote_symbols_for_decision( OrderIntent::CancelAll { .. } => { symbols.extend(open_orders.iter().map(|order| order.symbol.clone())); } + OrderIntent::WithTimeInForce { .. } => unreachable!("intent is unwrapped"), OrderIntent::UpdateUniverse { .. } | OrderIntent::Subscribe { .. } | OrderIntent::Unsubscribe { .. } @@ -3901,7 +3902,7 @@ fn algo_execution_quote_windows_for_decision( ) -> BTreeMap<(Option, Option), BTreeSet> { let mut groups = BTreeMap::<(Option, Option), BTreeSet>::new(); for intent in &decision.order_intents { - match intent { + match intent.unwrapped() { OrderIntent::AlgoValue { symbol, start_time, diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 0db981e..6eddd15 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -72,6 +72,7 @@ pub enum OrderStatus { PartiallyFilled, Canceled, Rejected, + Expired, } impl OrderStatus { @@ -82,6 +83,7 @@ impl OrderStatus { Self::PartiallyFilled => "partially_filled", Self::Canceled => "canceled", Self::Rejected => "rejected", + Self::Expired => "expired", } } } @@ -128,6 +130,7 @@ impl OrderEvent { } OrderStatus::Canceled => self.filled_quantity < self.requested_quantity, OrderStatus::Rejected => self.filled_quantity == 0, + OrderStatus::Expired => self.filled_quantity < self.requested_quantity, }; if !quantity_valid { return Err(format!( @@ -327,10 +330,16 @@ mod tests { assert!(order_event(OrderStatus::Filled, 100).validate().is_ok()); assert!(order_event(OrderStatus::Canceled, 40).validate().is_ok()); assert!(order_event(OrderStatus::Rejected, 0).validate().is_ok()); + assert!(order_event(OrderStatus::Expired, 40).validate().is_ok()); - assert!(order_event(OrderStatus::PartiallyFilled, 0).validate().is_err()); + assert!( + order_event(OrderStatus::PartiallyFilled, 0) + .validate() + .is_err() + ); assert!(order_event(OrderStatus::Filled, 99).validate().is_err()); assert!(order_event(OrderStatus::Canceled, 100).validate().is_err()); assert!(order_event(OrderStatus::Rejected, 1).validate().is_err()); + assert!(order_event(OrderStatus::Expired, 100).validate().is_err()); } } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 723f687..c9a73a8 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -89,8 +89,8 @@ pub use scheduler::{ }; pub use strategy::{ AlgoOrderStyle, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, OmniMicroCapConfig, - OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, PortfolioRuntimeView, - Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing, + OmniMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, OrderTimeInForce, + PortfolioRuntimeView, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing, }; pub use strategy_ai::{ ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index bbf87bc..2e27992 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -28,7 +28,7 @@ use crate::scheduler::{ ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, }; use crate::strategy::{ - AlgoOrderStyle, OrderIntent, Strategy, StrategyContext, StrategyDecision, + AlgoOrderStyle, OrderIntent, OrderTimeInForce, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing, }; @@ -298,6 +298,7 @@ pub enum PlatformTradeAction { symbol: String, amount_expr: String, limit_price_expr: Option, + time_in_force: Option, start_time_expr: Option, end_time_expr: Option, when_expr: Option, @@ -307,6 +308,7 @@ pub enum PlatformTradeAction { target_weights_expr: String, order_prices_expr: Option, valuation_prices_expr: Option, + time_in_force: Option, when_expr: Option, reason: String, }, @@ -7851,6 +7853,7 @@ impl PlatformExprStrategy { symbol, amount_expr, limit_price_expr, + time_in_force, start_time_expr, end_time_expr, when_expr, @@ -7872,6 +7875,7 @@ impl PlatformExprStrategy { )); continue; } + let intent_start = intents.len(); match kind { PlatformExplicitOrderKind::Shares => { let quantity = @@ -8218,6 +8222,18 @@ impl PlatformExprStrategy { }); } } + if let Some(time_in_force) = time_in_force + && intents.len() > intent_start + { + let intent = intents.pop().expect("explicit order intent was appended"); + if !intent.supports_time_in_force(*time_in_force) { + return Err(BacktestError::Execution(format!( + "time_in_force={} is unsupported for action={kind:?}", + time_in_force.as_str() + ))); + } + intents.push(intent.with_time_in_force(*time_in_force)); + } } PlatformTradeAction::Futures { symbol, @@ -8423,6 +8439,7 @@ impl PlatformExprStrategy { target_weights_expr, order_prices_expr, valuation_prices_expr, + time_in_force, when_expr, reason, } => { @@ -8461,12 +8478,23 @@ impl PlatformExprStrategy { .as_deref() .map(|expr| self.eval_float_map_expr(ctx, expr, day, None, None)) .transpose()?; - intents.push(OrderIntent::TargetPortfolioSmart { + let intent = OrderIntent::TargetPortfolioSmart { target_weights, order_prices, valuation_prices, reason: reason.clone(), - }); + }; + if let Some(time_in_force) = time_in_force { + if !intent.supports_time_in_force(*time_in_force) { + return Err(BacktestError::Execution(format!( + "time_in_force={} is unsupported for target_portfolio_smart", + time_in_force.as_str() + ))); + } + intents.push(intent.with_time_in_force(*time_in_force)); + } else { + intents.push(intent); + } } } } @@ -8484,6 +8512,7 @@ impl PlatformExprStrategy { let mut filtered = Vec::with_capacity(intents.len()); let mut diagnostics = Vec::new(); for intent in intents { + let (intent, time_in_force) = intent.into_time_in_force_parts(); if let OrderIntent::TargetPortfolioSmart { mut target_weights, order_prices, @@ -8503,12 +8532,15 @@ impl PlatformExprStrategy { symbol, reason )); } - filtered.push(OrderIntent::TargetPortfolioSmart { - target_weights, - order_prices, - valuation_prices, - reason, - }); + filtered.push( + OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices, + valuation_prices, + reason, + } + .apply_time_in_force(time_in_force), + ); continue; } @@ -8577,7 +8609,7 @@ impl PlatformExprStrategy { )); continue; } - filtered.push(intent); + filtered.push(intent.apply_time_in_force(time_in_force)); } (filtered, diagnostics) } @@ -11974,8 +12006,8 @@ mod tests { DailyFactorSnapshot, DailyMarketSnapshot, DataSet, EligibleUniverseSnapshot, FactorTextValue, FuturesCommissionType, FuturesTradingParameter, Instrument, IntradayExecutionQuote, MatchingType, OpenOrderView, OrderIntent, OrderSide, - PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode, ScheduleStage, - ScheduleTimeRule, Scheduler, SlippageModel, Strategy, StrategyContext, + OrderTimeInForce, PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode, + ScheduleStage, ScheduleTimeRule, Scheduler, SlippageModel, Strategy, StrategyContext, TargetPortfolioOrderPricing, TradingCalendar, default_stage_time, }; @@ -12323,6 +12355,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: Some("close * 1.01".to_string()), + time_in_force: None, start_time_expr: Some("\"10:00\"".to_string()), end_time_expr: Some("\"10:30\"".to_string()), when_expr: Some("close > 0.0".to_string()), @@ -21904,6 +21937,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy && !touched_upper_limit".to_string()), @@ -22141,6 +22175,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "1000".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some(concat!( @@ -22303,6 +22338,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "1000".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -22911,6 +22947,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "1000".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -30402,6 +30439,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "2000".to_string(), limit_price_expr: None, + time_in_force: Some(OrderTimeInForce::Fok), start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -30413,15 +30451,21 @@ mod tests { assert_eq!(decision.order_intents.len(), 1); match &decision.order_intents[0] { - crate::strategy::OrderIntent::TargetShares { - symbol, - target_quantity, - reason, - } => { - assert_eq!(symbol, "000001.SZ"); - assert_eq!(*target_quantity, 2000); - assert_eq!(reason, "platform_target_shares"); - } + crate::strategy::OrderIntent::WithTimeInForce { + intent, + time_in_force: OrderTimeInForce::Fok, + } => match intent.as_ref() { + crate::strategy::OrderIntent::TargetShares { + symbol, + target_quantity, + reason, + } => { + assert_eq!(symbol, "000001.SZ"); + assert_eq!(*target_quantity, 2000); + assert_eq!(reason, "platform_target_shares"); + } + other => panic!("unexpected wrapped target shares intent: {other:?}"), + }, other => panic!("unexpected explicit target shares intent: {other:?}"), } } @@ -30524,6 +30568,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: Some("\"09:31\"".to_string()), end_time_expr: Some("\"09:40\"".to_string()), when_expr: Some("allow_buy".to_string()), @@ -30534,6 +30579,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.05".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: Some("\"10:00\"".to_string()), end_time_expr: Some("\"10:30\"".to_string()), when_expr: Some("allow_buy".to_string()), @@ -30651,6 +30697,7 @@ mod tests { valuation_prices_expr: Some( "{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}".to_string(), ), + time_in_force: None, when_expr: Some("benchmark_close > 0".to_string()), reason: "platform_target_portfolio_smart".to_string(), }]; @@ -30801,6 +30848,7 @@ mod tests { target_weights_expr: "{\"000001.SZ\": 0.50, \"000002.SZ\": 0.50}".to_string(), order_prices_expr: None, valuation_prices_expr: None, + time_in_force: None, when_expr: None, reason: "fixed_signal_target".to_string(), }]; @@ -30906,6 +30954,7 @@ mod tests { .to_string(), order_prices_expr: None, valuation_prices_expr: None, + time_in_force: None, when_expr: Some( "current_date >= \"2023-01-03\" && current_date <= \"2023-03-02\"".to_string(), ), @@ -31089,6 +31138,7 @@ mod tests { .to_string(), order_prices_expr: Some("execution_day_open".to_string()), valuation_prices_expr: Some("execution_day_open".to_string()), + time_in_force: None, when_expr: Some( "decision_date == \"2023-01-03\" && execution_date == \"2023-01-04\"" .to_string(), @@ -31194,6 +31244,7 @@ mod tests { target_weights_expr: "{\"000001.SZ\": 0.30}".to_string(), order_prices_expr: Some("VWAPOrder(930, 940)".to_string()), valuation_prices_expr: Some("{\"000001.SZ\": signal_close}".to_string()), + time_in_force: None, when_expr: None, reason: "platform_target_portfolio_smart_algo".to_string(), }]; @@ -31299,6 +31350,7 @@ mod tests { valuation_prices_expr: Some( "{\"000001.SZ\": signal_close, \"000002.SZ\": signal_close}".to_string(), ), + time_in_force: None, when_expr: Some("subscription_guard_required".to_string()), reason: "guarded_target_portfolio_smart".to_string(), }]; @@ -31427,6 +31479,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.25".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -31570,6 +31623,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.25".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -31709,6 +31763,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -32222,6 +32277,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( @@ -32332,6 +32388,7 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some("allow_buy".to_string()), @@ -32578,6 +32635,7 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0; symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + time_in_force: None, start_time_expr: None, end_time_expr: None, when_expr: Some( diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 4c9bbbf..33b3799 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -10,7 +10,7 @@ use crate::{ PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection, - futures::FuturesPositionEffect, + futures::FuturesPositionEffect, strategy::OrderTimeInForce, }; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -793,6 +793,8 @@ pub struct StrategyExpressionActionConfig { pub end_time_expr: Option, #[serde(default)] pub limit_price_expr: Option, + #[serde(default, alias = "time_in_force")] + pub time_in_force: Option, #[serde(default)] pub target_weights_expr: Option, #[serde(default)] @@ -2178,6 +2180,15 @@ fn parse_platform_trade_action( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string); + let time_in_force = match action + .time_in_force + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => Some(OrderTimeInForce::parse(value)?), + None => None, + }; match kind.as_str() { "target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart { target_weights_expr: action @@ -2198,6 +2209,7 @@ fn parse_platform_trade_action( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), + time_in_force, when_expr, reason, }), @@ -2327,6 +2339,7 @@ fn parse_platform_trade_action( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), + time_in_force, start_time_expr: action .start_time_expr .as_deref() @@ -2648,6 +2661,63 @@ mod tests { assert_eq!(cfg.explicit_actions.len(), 1); } + #[test] + fn parses_typed_time_in_force_for_explicit_orders() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "limit_shares", + "symbol": "000001.SZ", + "amountExpr": "200", + "limitPriceExpr": "10.25", + "timeInForce": "FOK", + "reason": "fok_entry" + }] + } + } + }); + + let cfg = platform_expr_config_from_value("tif", "000300.SH", &spec) + .expect("time-in-force config"); + + assert!(matches!( + cfg.explicit_actions.as_slice(), + [PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::LimitShares, + time_in_force: Some(OrderTimeInForce::Fok), + .. + }] + )); + } + + #[test] + fn rejects_unknown_time_in_force_in_runtime_contract() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "shares", + "symbol": "000001.SZ", + "amountExpr": "200", + "timeInForce": "until_lucky", + "reason": "invalid_tif" + }] + } + } + }); + + let error = platform_expr_config_from_value("tif", "000300.SH", &spec) + .expect_err("unknown time-in-force must be rejected"); + assert!( + error + .to_string() + .contains("runtimeExpressions.trading.actions[0]") + ); + } + #[test] fn parses_delayed_deposit_receiving_days_expression() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 3470740..538a814 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -1014,6 +1014,35 @@ pub enum AlgoOrderStyle { Twap, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderTimeInForce { + Day, + Ioc, + Fok, + Gtc, +} + +impl OrderTimeInForce { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "day" => Some(Self::Day), + "ioc" | "immediate_or_cancel" | "immediate-or-cancel" => Some(Self::Ioc), + "fok" | "fill_or_kill" | "fill-or-kill" => Some(Self::Fok), + "gtc" | "good_til_canceled" | "good-til-canceled" => Some(Self::Gtc), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Day => "day", + Self::Ioc => "ioc", + Self::Fok => "fok", + Self::Gtc => "gtc", + } + } +} + #[derive(Debug, Clone)] pub enum TargetPortfolioOrderPricing { LimitPrices(BTreeMap), @@ -1026,6 +1055,10 @@ pub enum TargetPortfolioOrderPricing { #[derive(Debug, Clone)] pub enum OrderIntent { + WithTimeInForce { + intent: Box, + time_in_force: OrderTimeInForce, + }, Shares { symbol: String, quantity: i32, @@ -1174,6 +1207,100 @@ pub enum OrderIntent { }, } +impl OrderIntent { + pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self { + match self { + Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce { + intent, + time_in_force, + }, + intent => Self::WithTimeInForce { + intent: Box::new(intent), + time_in_force, + }, + } + } + + pub fn time_in_force(&self) -> Option { + match self { + Self::WithTimeInForce { time_in_force, .. } => Some(*time_in_force), + _ => None, + } + } + + pub fn into_time_in_force_parts(self) -> (Self, Option) { + match self { + Self::WithTimeInForce { + intent, + time_in_force, + } => (*intent, Some(time_in_force)), + intent => (intent, None), + } + } + + pub fn apply_time_in_force(self, time_in_force: Option) -> Self { + match time_in_force { + Some(time_in_force) => self.with_time_in_force(time_in_force), + None => self, + } + } + + pub fn unwrapped(&self) -> &Self { + match self { + Self::WithTimeInForce { intent, .. } => intent.unwrapped(), + _ => self, + } + } + + pub fn supports_time_in_force(&self, time_in_force: OrderTimeInForce) -> bool { + let intent = self.unwrapped(); + if matches!( + intent, + Self::CancelOrder { .. } + | Self::CancelSymbol { .. } + | Self::CancelAll { .. } + | Self::UpdateUniverse { .. } + | Self::Subscribe { .. } + | Self::Unsubscribe { .. } + | Self::DepositWithdraw { .. } + | Self::FinanceRepay { .. } + | Self::SetManagementFeeRate { .. } + | Self::Futures { .. } + ) { + return false; + } + let is_algo = matches!( + intent, + Self::AlgoValue { .. } | Self::AlgoPercent { .. } | Self::TimedTargetValue { .. } + ) || matches!( + intent, + Self::TargetPortfolioSmart { + order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }), + .. + } + ); + let is_limit = matches!( + intent, + Self::LimitShares { .. } + | Self::LimitLots { .. } + | Self::LimitTargetShares { .. } + | Self::LimitTargetValue { .. } + | Self::LimitValue { .. } + | Self::LimitPercent { .. } + | Self::LimitTargetPercent { .. } + | Self::TargetPortfolioSmart { + order_prices: Some(TargetPortfolioOrderPricing::LimitPrices(_)), + .. + } + ); + match time_in_force { + OrderTimeInForce::Day | OrderTimeInForce::Ioc => true, + OrderTimeInForce::Fok => !is_algo, + OrderTimeInForce::Gtc => is_limit, + } + } +} + #[derive(Debug, Clone)] pub struct CnSmallCapRotationConfig { pub strategy_name: String, @@ -2909,6 +3036,53 @@ mod tests { use super::*; use crate::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot}; + #[test] + fn order_time_in_force_parsing_and_order_type_contract_are_explicit() { + assert_eq!(OrderTimeInForce::parse("DAY"), Some(OrderTimeInForce::Day)); + assert_eq!( + OrderTimeInForce::parse("immediate_or_cancel"), + Some(OrderTimeInForce::Ioc) + ); + assert_eq!( + OrderTimeInForce::parse("fill-or-kill"), + Some(OrderTimeInForce::Fok) + ); + assert_eq!( + OrderTimeInForce::parse("good_til_canceled"), + Some(OrderTimeInForce::Gtc) + ); + assert_eq!(OrderTimeInForce::parse("unknown"), None); + + let market = OrderIntent::Shares { + symbol: "000001.SZ".to_string(), + quantity: 100, + reason: "market".to_string(), + }; + assert!(market.supports_time_in_force(OrderTimeInForce::Day)); + assert!(market.supports_time_in_force(OrderTimeInForce::Ioc)); + assert!(market.supports_time_in_force(OrderTimeInForce::Fok)); + assert!(!market.supports_time_in_force(OrderTimeInForce::Gtc)); + + let limit = OrderIntent::LimitShares { + symbol: "000001.SZ".to_string(), + quantity: 100, + limit_price: 10.0, + reason: "limit".to_string(), + }; + assert!(limit.supports_time_in_force(OrderTimeInForce::Gtc)); + + let algo = OrderIntent::AlgoValue { + symbol: "000001.SZ".to_string(), + value: 10_000.0, + style: AlgoOrderStyle::Vwap, + start_time: None, + end_time: None, + reason: "algo".to_string(), + }; + assert!(!algo.supports_time_in_force(OrderTimeInForce::Fok)); + assert!(!algo.supports_time_in_force(OrderTimeInForce::Gtc)); + } + #[test] fn omni_microcap_projection_uses_configured_trading_cost() { let mut cfg = OmniMicroCapConfig::omni_microcap(); diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 8d58a0d..b37c173 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -266,7 +266,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "trading.rotation / order.* / 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)、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)。期货 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 { title: "when / unless / else".to_string(), @@ -672,6 +672,9 @@ mod tests { assert!(markdown.contains("源策略明确写出的业务选股排除属于策略本身")); assert!(markdown.contains("不能反向修改冻结的 reject_*_selection 开关")); assert!(markdown.contains("冻结的 `reject_*_selection` 值不得改变")); + assert!(markdown.contains("time_in_force=\"day|ioc|fok|gtc\"")); + assert!(markdown.contains("FOK 必须全量可成交否则零成交")); + assert!(markdown.contains("GTC 仅支持限价单并跨交易日保留")); } #[test] diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 3a1162e..5e1d104 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -2956,7 +2956,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() { } #[test] -fn engine_rejects_pending_limit_orders_at_market_close() { +fn engine_expires_pending_day_limit_orders_at_market_close() { let date1 = d(2025, 1, 2); let date2 = d(2025, 1, 3); let data = DataSet::from_components( @@ -3118,8 +3118,8 @@ fn engine_rejects_pending_limit_orders_at_market_close() { ); assert!(result.order_events.iter().any(|event| { event.date == date1 - && event.status == fidc_core::OrderStatus::Rejected - && event.reason.contains("Market close") + && event.status == fidc_core::OrderStatus::Expired + && event.reason.contains("DAY order expired at market close") })); assert!(result.process_events.iter().any(|event| { event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index e84fef4..3796cb3 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -3,8 +3,8 @@ use fidc_core::{ AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig, FidcRiskControlConfig, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, - OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision, - TargetPortfolioOrderPricing, + OrderStatus, OrderTimeInForce, PortfolioState, PriceField, ProcessEventKind, SlippageModel, + StrategyDecision, TargetPortfolioOrderPricing, }; use std::collections::{BTreeMap, BTreeSet}; @@ -4715,7 +4715,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet { } #[test] -fn broker_rejects_open_limit_buy_at_market_close() { +fn broker_expires_day_limit_buy_at_market_close() { 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); @@ -4756,11 +4756,11 @@ fn broker_rejects_open_limit_buy_at_market_close() { assert!(close_report.fill_events.is_empty()); assert_eq!(close_report.order_events.len(), 1); assert_eq!(close_report.order_events[0].order_id, Some(order_id)); - assert_eq!(close_report.order_events[0].status, OrderStatus::Rejected); + assert_eq!(close_report.order_events[0].status, OrderStatus::Expired); assert!( close_report.order_events[0] .reason - .contains("Order Rejected: 000002.SZ can not match. Market close.") + .contains("DAY order expired at market close") ); assert!(close_report.process_events.iter().any(|event| { event.kind == ProcessEventKind::OrderUnsolicitedUpdate && event.order_id == Some(order_id) @@ -4787,6 +4787,328 @@ fn broker_rejects_open_limit_buy_at_market_close() { assert!(portfolio.position("000002.SZ").is_none()); } +#[test] +fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { + 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, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 10.1, + reason: "ioc_limit_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Ioc), + ], + ..StrategyDecision::default() + }, + ) + .expect("IOC execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 100); + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert_eq!(report.order_events[0].filled_quantity, 100); + assert!(broker.open_order_views().is_empty()); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100); +} + +#[test] +fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() { + 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, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "day_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Day), + ], + ..StrategyDecision::default() + }, + ) + .expect("DAY market execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 100); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert_eq!(report.order_events[0].filled_quantity, 100); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() { + 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, + ) + .with_volume_limit(true) + .with_volume_percent(0.001) + .with_liquidity_limit(false); + let initial_cash = 1_000_000.0; + let mut portfolio = PortfolioState::new(initial_cash); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "fok_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Fok), + ], + ..StrategyDecision::default() + }, + ) + .expect("FOK execution"); + + assert!(report.fill_events.is_empty()); + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert_eq!(report.order_events[0].filled_quantity, 0); + assert!( + report.order_events[0] + .reason + .contains("FOK not fully fillable") + ); + assert!(portfolio.position("000002.SZ").is_none()); + assert!((portfolio.cash() - initial_cash).abs() < 1e-9); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn broker_fok_order_fills_when_full_quantity_is_available() { + 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, + ) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "fok_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Fok), + ], + ..StrategyDecision::default() + }, + ) + .expect("FOK execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 200); + assert_eq!(report.order_events[0].status, OrderStatus::Filled); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200); +} + +#[test] +fn broker_gtc_limit_order_survives_close_and_fills_next_day() { + 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 day1_report = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 9.8, + reason: "gtc_limit_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect("GTC day one execution"); + assert_eq!(day1_report.order_events[0].status, OrderStatus::Pending); + assert_eq!(broker.open_order_views().len(), 1); + + let close_report = broker.after_trading(day1); + assert!(close_report.order_events.is_empty()); + assert_eq!(broker.open_order_views().len(), 1); + + let day2_report = broker + .execute(day2, &mut portfolio, &data, &StrategyDecision::default()) + .expect("GTC day two execution"); + assert_eq!(day2_report.fill_events.len(), 1); + assert_eq!(day2_report.fill_events[0].quantity, 200); + assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled); + assert!(broker.open_order_views().is_empty()); + assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200); +} + +#[test] +fn broker_gtc_partial_fills_preserve_cumulative_order_and_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 day1_report = broker + .execute( + day1, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 10.1, + reason: "gtc_partial_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect("GTC first partial fill"); + assert_eq!(day1_report.fill_events[0].quantity, 100); + assert_eq!( + day1_report.order_events[0].status, + OrderStatus::PartiallyFilled + ); + let open_order = broker + .open_order_views() + .pop() + .expect("remaining GTC order"); + assert_eq!(open_order.requested_quantity, 200); + assert_eq!(open_order.filled_quantity, 100); + assert_eq!(open_order.remaining_quantity, 100); + assert!(broker.after_trading(day1).order_events.is_empty()); + + let day2_report = broker + .execute(day2, &mut portfolio, &data, &StrategyDecision::default()) + .expect("GTC final fill"); + assert_eq!(day2_report.fill_events[0].quantity, 100); + assert_eq!(day2_report.order_events[0].requested_quantity, 200); + assert_eq!(day2_report.order_events[0].filled_quantity, 200); + assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled); + assert!(broker.open_order_views().is_empty()); + + let total_commission = day1_report + .fill_events + .iter() + .chain(day2_report.fill_events.iter()) + .map(|fill| fill.commission) + .sum::(); + assert!((total_commission - 5.0).abs() < 1e-9, "{total_commission}"); + assert_eq!(day2_report.fill_events[0].commission, 0.0); +} + +#[test] +fn broker_rejects_gtc_for_market_order() { + 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 error = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 200, + reason: "invalid_gtc_market_buy".to_string(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..StrategyDecision::default() + }, + ) + .expect_err("market GTC must be rejected"); + + assert!( + error + .to_string() + .contains("time_in_force=gtc is not supported") + ); + assert!(portfolio.position("000002.SZ").is_none()); +} + #[test] fn broker_uses_limit_price_slippage_for_limit_orders() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); @@ -5152,7 +5474,11 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() { assert_eq!(report.order_events[0].status, OrderStatus::Pending); assert_eq!(report.order_events[1].status, OrderStatus::Canceled); assert_eq!(report.order_events[1].filled_quantity, 100); - assert!(report.order_events[1].reason.contains("remaining quantity canceled")); + assert!( + report.order_events[1] + .reason + .contains("remaining quantity canceled") + ); let open_orders = broker.open_order_views(); assert_eq!(open_orders.len(), 1); assert_eq!(open_orders[0].reason, "reserve_sell");