实现类型化订单有效期合同
This commit is contained in:
+346
-35
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
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,6 +30451,10 @@ mod tests {
|
||||
|
||||
assert_eq!(decision.order_intents.len(), 1);
|
||||
match &decision.order_intents[0] {
|
||||
crate::strategy::OrderIntent::WithTimeInForce {
|
||||
intent,
|
||||
time_in_force: OrderTimeInForce::Fok,
|
||||
} => match intent.as_ref() {
|
||||
crate::strategy::OrderIntent::TargetShares {
|
||||
symbol,
|
||||
target_quantity,
|
||||
@@ -30422,6 +30464,8 @@ mod tests {
|
||||
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(
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user