实现类型化订单有效期合同

This commit is contained in:
boris
2026-08-26 19:48:21 +08:00
parent bc0f5f6089
commit 8e238f9131
10 changed files with 1025 additions and 73 deletions
+346 -35
View File
@@ -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<f64>,
execution_cursor: Option<NaiveDateTime>,
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<C, R> {
runtime_order_created_date: Cell<Option<NaiveDate>>,
runtime_decision_total_equity: Cell<Option<f64>>,
runtime_target_position_limit: Cell<Option<usize>>,
runtime_time_in_force: Cell<Option<OrderTimeInForce>>,
next_order_id: Cell<u64>,
open_orders: RefCell<Vec<OpenOrder>>,
}
@@ -235,6 +248,7 @@ impl<C, R> BrokerSimulator<C, R> {
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<C, R> BrokerSimulator<C, R> {
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<C, R> BrokerSimulator<C, R> {
}
}
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<u64, f64>,
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::<u32>();
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 {
+4 -3
View File
@@ -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<NaiveTime>, Option<NaiveTime>), BTreeSet<String>> {
let mut groups = BTreeMap::<(Option<NaiveTime>, Option<NaiveTime>), BTreeSet<String>>::new();
for intent in &decision.order_intents {
match intent {
match intent.unwrapped() {
OrderIntent::AlgoValue {
symbol,
start_time,
+10 -1
View File
@@ -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());
}
}
+2 -2
View File
@@ -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,
+79 -21
View File
@@ -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<String>,
time_in_force: Option<OrderTimeInForce>,
start_time_expr: Option<String>,
end_time_expr: Option<String>,
when_expr: Option<String>,
@@ -307,6 +308,7 @@ pub enum PlatformTradeAction {
target_weights_expr: String,
order_prices_expr: Option<String>,
valuation_prices_expr: Option<String>,
time_in_force: Option<OrderTimeInForce>,
when_expr: Option<String>,
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(
+71 -1
View File
@@ -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<String>,
#[serde(default)]
pub limit_price_expr: Option<String>,
#[serde(default, alias = "time_in_force")]
pub time_in_force: Option<String>,
#[serde(default)]
pub target_weights_expr: Option<String>,
#[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!({
+174
View File
@@ -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<Self> {
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<String, f64>),
@@ -1026,6 +1055,10 @@ pub enum TargetPortfolioOrderPricing {
#[derive(Debug, Clone)]
pub enum OrderIntent {
WithTimeInForce {
intent: Box<OrderIntent>,
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<OrderTimeInForce> {
match self {
Self::WithTimeInForce { time_in_force, .. } => Some(*time_in_force),
_ => None,
}
}
pub fn into_time_in_force_parts(self) -> (Self, Option<OrderTimeInForce>) {
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<OrderTimeInForce>) -> 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();
+4 -1
View File
@@ -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[].timeInForceDAY 日内保留并在收盘 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]
+3 -3
View File
@@ -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
+332 -6
View File
@@ -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::<f64>();
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");