diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 53deff4..081f14d 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -216,6 +216,9 @@ struct OpenOrder { commission_remaining: Option, execution_cursor: Option, reason: String, + algo_request: Option, + value_budget: Option, + reserved_cash: Option, } #[derive(Debug, Clone, Copy)] @@ -225,6 +228,13 @@ struct RestingOrderOrigin { accepted_date: NaiveDate, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum BrokerCallbackPhase { + Normal, + ControlsOnly, + BeforeStrategy, +} + #[derive(Debug, Default)] struct BrokerExecutionSession { date: Option, @@ -420,6 +430,15 @@ struct AlgoExecutionRequest { style: AlgoExecutionStyle, start_time: Option, end_time: Option, + total_quantity: Option, + filled_quantity: u32, + commission_remaining: Option, + order_id: Option, +} + +struct RestoreCell<'a, T: Copy>(&'a Cell, T); +impl Drop for RestoreCell<'_, T> { + fn drop(&mut self) { self.0.set(self.1); } } pub struct BrokerSimulator { @@ -450,6 +469,10 @@ pub struct BrokerSimulator { intraday_execution_start_time: Option, runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, + runtime_execution_clock: Cell>, + runtime_callback_phase: Cell, + runtime_algo_schedule: Cell>, + runtime_unprocessed_algorithm_cash: Cell, runtime_decision_date: Cell>, runtime_buy_denials: RefCell>, runtime_auto_buy_denials: RefCell>, @@ -494,6 +517,10 @@ impl BrokerSimulator { intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), + runtime_execution_clock: Cell::new(None), + runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal), + runtime_algo_schedule: Cell::new(None), + runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO), runtime_decision_date: Cell::new(None), runtime_buy_denials: RefCell::new(BTreeMap::new()), runtime_auto_buy_denials: RefCell::new(BTreeMap::new()), @@ -542,6 +569,10 @@ impl BrokerSimulator { intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), + runtime_execution_clock: Cell::new(None), + runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal), + runtime_algo_schedule: Cell::new(None), + runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO), runtime_decision_date: Cell::new(None), runtime_buy_denials: RefCell::new(BTreeMap::new()), runtime_auto_buy_denials: RefCell::new(BTreeMap::new()), @@ -726,6 +757,10 @@ impl BrokerSimulator { .or(self.intraday_execution_start_time) } + fn execution_clock(&self) -> Option { + self.runtime_execution_clock.get().or(self.runtime_intraday_start_time.get()) + } + fn order_origin(&self) -> (Option, Option) { self.runtime_resting_order_origin.get().map_or( (self.runtime_order_created_date.get(), self.submission_time()), @@ -898,6 +933,7 @@ impl BrokerSimulator { avg_price: 0.0, transaction_cost: 0.0, limit_price: order.limit_price, + reserved_cash: order.reserved_cash, reason: order.reason.clone(), }) .collect() @@ -916,11 +952,12 @@ impl BrokerSimulator { fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime { let post_close = self.execution_phase_for_submission(date, order.order_created_date, order.submission_time) == EquityExecutionPhase::PostCloseFixedPrice; - NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end") + let close=NaiveTime::from_hms_opt(15, if post_close { 30 } else { 0 }, 0).expect("session end"); + order.algo_request.and_then(|request|request.end_time).map_or(close,|end|end.min(close)) } pub(crate) fn next_day_order_expiry(&self, date: NaiveDate) -> Option { - self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day) + self.open_orders.borrow().iter().filter(|order| order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some()) .map(|order| self.resting_order_session_close(date, order)).min() } } @@ -1616,17 +1653,21 @@ where self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id); } } - self.process_open_orders( - date, - portfolio, - data, - &mut session.intraday_turnover, - &mut session.execution_cursors, - &mut session.global_execution_cursor, - &mut session.commission_state, - &mut report, - )?; - self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?; + if self.runtime_callback_phase.get() != BrokerCallbackPhase::ControlsOnly { + self.process_open_orders( + date, + portfolio, + data, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, + &mut report, + )?; + if self.runtime_callback_phase.get() == BrokerCallbackPhase::Normal { + self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?; + } + } if !decision.order_intents.is_empty() { let mut ordered_intents = decision.order_intents.iter().collect::>(); if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash @@ -1803,6 +1844,110 @@ where ) } + #[allow(clippy::too_many_arguments)] + pub(crate) fn execute_controls_without_matching( + &self, + date: NaiveDate, + decision_date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + clock: Option, + ) -> Result { + if decision.rebalance + || !decision.target_weights.is_empty() + || !decision.exit_symbols.is_empty() + || decision.order_intents.iter().any(|intent| { + !matches!( + intent.unwrapped(), + OrderIntent::CancelOrder { .. } + | OrderIntent::CancelSymbol { .. } + | OrderIntent::CancelAll { .. } + | OrderIntent::ModifyOrder { .. } + ) + }) + { + return Err(BacktestError::Execution( + "non-matching control phase only accepts cancel or modify requests".into(), + )); + } + let _guard = RestoreCell( + &self.runtime_callback_phase, + self.runtime_callback_phase + .replace(BrokerCallbackPhase::ControlsOnly), + ); + self.execute_between_with_event_dates( + date, + decision_date, + decision_date, + portfolio, + data, + decision, + clock, + clock, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn execute_coarse_at_clock( + &self, + date: NaiveDate, + decision_date: NaiveDate, + order_created_date: NaiveDate, + decision_total_equity: Option, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + clock: Option, + ) -> Result { + // Advancing the engine clock must not turn a daily closing-bar order + // into an explicitly submitted post-close order. + let _clock_guard = RestoreCell( + &self.runtime_execution_clock, + self.runtime_execution_clock.replace(clock), + ); + self.execute_between_with_event_dates_and_decision_equity( + date, + decision_date, + order_created_date, + decision_total_equity, + portfolio, + data, + decision, + None, + clock, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn execute_before_strategy_at_clock( + &self, + date: NaiveDate, + decision_date: NaiveDate, + order_created_date: NaiveDate, + decision_total_equity: Option, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + clock: Option, + ) -> Result { + let _guard = RestoreCell( + &self.runtime_callback_phase, + self.runtime_callback_phase + .replace(BrokerCallbackPhase::BeforeStrategy), + ); + self.execute_coarse_at_clock( + date, + decision_date, + order_created_date, + decision_total_equity, + portfolio, + data, + decision, + clock, + ) + } + pub fn execute_between_with_event_dates( &self, date: NaiveDate, @@ -2682,18 +2827,26 @@ where let mut open_orders = self.open_orders.borrow_mut(); std::mem::take(&mut *open_orders) }; + let reserved=FixedMoney::checked_sum_f64(pending_orders.iter().filter_map(|order|order.reserved_cash)) + .ok_or_else(||BacktestError::Execution("working order cash reservation is invalid".into()))?; + let _reservation_guard=RestoreCell(&self.runtime_unprocessed_algorithm_cash, + self.runtime_unprocessed_algorithm_cash.replace(reserved)); for order in pending_orders { + if let Some(reserved)=order.reserved_cash { + self.runtime_unprocessed_algorithm_cash.set(self.runtime_unprocessed_algorithm_cash.get() + .checked_sub(FixedMoney::from_f64(reserved).expect("validated reservation")).expect("reserved cash subset")); + } if self.matching_type == MatchingType::NextBarOpen && self.runtime_intraday_start_time.get().is_none() - && order.accepted_date == date { + && order.accepted_date == date && order.algo_request.is_none() { self.open_orders.borrow_mut().push(order); continue; } let close = self.resting_order_session_close(date, &order); - let clock = self.submission_time(); - let past_day = order.time_in_force == OrderTimeInForce::Day + let clock = self.execution_clock().or(self.submission_time()); + let past_day = (order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some()) && order.accepted_date < date; if past_day || clock.is_some_and(|time| time > close) { - if order.time_in_force == OrderTimeInForce::Day { + if order.time_in_force == OrderTimeInForce::Day || order.algo_request.is_some() { Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity); } else { self.open_orders.borrow_mut().push(order); @@ -2730,7 +2883,18 @@ where accepted_date: order.accepted_date, })); let previous_decision_date = self.runtime_decision_date.replace(order.decision_date); - let execution_result = self.process_limit_shares_internal( + let execution_result = if let Some(mut algorithm)=order.algo_request { + algorithm.total_quantity=Some(order.requested_quantity); + algorithm.filled_quantity=order.filled_quantity; + algorithm.commission_remaining=order.commission_remaining; + if order.side==OrderSide::Buy { + self.process_buy(date,portfolio,data,&order.symbol,order.remaining_quantity,order.order_id,&order.reason, + intraday_turnover,execution_cursors,global_execution_cursor,commission_state,order.value_budget,None,false,false,Some(&algorithm),report) + } else { + self.process_sell(date,portfolio,data,&order.symbol,order.remaining_quantity,order.order_id,&order.reason, + intraday_turnover,execution_cursors,global_execution_cursor,commission_state,None,false,false,Some(&algorithm),report) + } + } else { self.process_limit_shares_internal( date, portfolio, data, @@ -2745,7 +2909,7 @@ where global_execution_cursor, commission_state, report, - ); + ) }; self.runtime_time_in_force.set(previous_time_in_force); self.runtime_resting_order_origin.set(previous_origin); self.runtime_decision_date.set(previous_decision_date); @@ -2843,7 +3007,8 @@ where } fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) { - let detail = format!("DAY order expired at market close: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled)); + let label=if order.algo_request.is_some() {"algorithm execution window expired"} else {"DAY order expired at market close"}; + let detail = format!("{label}: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled)); 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(), @@ -2929,6 +3094,11 @@ where let target_total_quantity = new_total_quantity.unwrap_or(existing.requested_quantity); let target_limit_price = new_limit_price.unwrap_or(existing.limit_price); + if existing.algo_request.is_some() { + Self::emit_open_order_update_rejected(report,date,order_id,Some(&existing.symbol),Some(existing.side),reason, + "algorithm schedule is immutable; cancel it before submitting a different schedule"); + return; + } if target_total_quantity == existing.requested_quantity && target_limit_price.to_bits() == existing.limit_price.to_bits() { @@ -3898,6 +4068,10 @@ where }, start_time: *start_time, end_time: *end_time, + total_quantity: None, + filled_quantity: 0, + commission_remaining: None, + order_id: None, }), _ => None, }; @@ -4173,9 +4347,8 @@ where return self.execution_limit_check_price(snapshot, side); } let matching_type = self.matching_type_for_algo_request(algo_request); - let start_cursor = algo_request - .and_then(|request| request.start_time) - .or(self.runtime_intraday_start_time.get()) + let start_cursor = self.execution_clock() + .or_else(||algo_request.and_then(|request| request.start_time)) .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); self.latest_known_quote_at_or_before( @@ -4187,7 +4360,9 @@ where false, ) .and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type)) - .unwrap_or_else(|| self.execution_limit_check_price(snapshot, side)) + .unwrap_or_else(|| if algo_request.is_some() && self.execution_clock().is_some() { + f64::NAN + } else {self.execution_limit_check_price(snapshot, side)}) } #[cfg(test)] @@ -4534,6 +4709,8 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + let algorithm = self.normalized_algorithm(date, requested_qty, order_id, commission_state.get(&order_id).copied(), algo_request); + let algo_request = algorithm.as_ref(); // Existing accepted orders are not canceled by a subsequently enabled lock. if emit_creation_events && self.runtime_auto_sell_denials.borrow().contains_key(symbol) { return Ok(()); @@ -4768,6 +4945,9 @@ where 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(), + algo_request: None, + value_budget: None, + reserved_cash: None, reason: reason.to_string(), }); // Waiting without a fill is not a new order-state transition. @@ -4859,6 +5039,9 @@ where 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(), + algo_request: None, + value_budget: None, + reserved_cash: None, reason: reason.to_string(), }); // Waiting without a fill is not a new order-state transition. @@ -4976,8 +5159,8 @@ where price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), quantity: fillable_qty, - execution_start_timestamp: None, - execution_timestamp: None, + execution_start_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)), + execution_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)), }], None, Vec::new(), @@ -5014,8 +5197,9 @@ where let detail = partial_fill_reason .as_deref() .unwrap_or("limit price not marketable yet"); - if Self::keeps_remainder_open(remainder_policy) - && Self::limit_order_can_remain_open(Some(detail)) + if (Self::keeps_remainder_open(remainder_policy) + && Self::limit_order_can_remain_open(Some(detail))) + || self.algorithm_still_working(algo_request, Some(detail)) { self.upsert_open_order(OpenOrder { order_id, @@ -5028,10 +5212,13 @@ where requested_quantity: requested_qty, 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), + limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit sell")}, + time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)}, commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), + algo_request: Self::progressed_algorithm(algo_request, 0, commission_state.get(&order_id).copied()), + value_budget: None, + reserved_cash: None, reason: reason.to_string(), }); // Waiting without a fill is not a new order-state transition. @@ -5072,7 +5259,7 @@ where side: OrderSide::Sell, requested_quantity: requested_qty, filled_quantity: 0, - status: zero_fill_status_for_reason(detail), + status: self.unfilled_algorithm_status(algo_request, detail), reason: format!("{reason}: {detail}"), }); Self::emit_order_process_event( @@ -5084,7 +5271,7 @@ where OrderSide::Sell, format!( "status={:?} reason={detail}", - zero_fill_status_for_reason(detail) + self.unfilled_algorithm_status(algo_request, detail) ), ); self.clear_open_order(order_id); @@ -5185,9 +5372,10 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); - let keep_open = Self::keeps_remainder_open(remainder_policy) + let keep_open = (Self::keeps_remainder_open(remainder_policy) && remaining_qty > 0 - && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); + && Self::limit_order_can_remain_open(partial_fill_reason.as_deref())) + || (remaining_qty > 0 && self.algorithm_still_working(algo_request,partial_fill_reason.as_deref())); if keep_open { self.upsert_open_order(OpenOrder { order_id, @@ -5200,10 +5388,13 @@ where requested_quantity: requested_qty, 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), + limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit sell")}, + time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)}, commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), + algo_request: Self::progressed_algorithm(algo_request, filled_qty, commission_state.get(&order_id).copied()), + value_budget: None, + reserved_cash: None, reason: reason.to_string(), }); } else { @@ -5213,7 +5404,7 @@ where let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { - OrderStatus::Canceled + if self.algorithm_window_expired(algo_request, partial_fill_reason.as_deref().unwrap_or("")) {OrderStatus::Expired} else {OrderStatus::Canceled} } else { OrderStatus::Filled }; @@ -5250,7 +5441,7 @@ where status, reason: order_reason, }); - if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) { + if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected | OrderStatus::Expired) { Self::emit_order_process_event( report, date, @@ -5399,6 +5590,10 @@ where }, start_time, end_time, + total_quantity: None, + filled_quantity: 0, + commission_remaining: None, + order_id: None, }; if target_value <= f64::EPSILON { @@ -6080,12 +6275,19 @@ where }, start_time, end_time, + total_quantity: None, + filled_quantity: 0, + commission_remaining: None, + order_id: None, }; if value > 0.0 { let round_lot = self.round_lot(data, symbol); let minimum_order_quantity = self.minimum_order_quantity(data, symbol); let order_step_size = self.order_step_size(data, symbol); - let price = self.sizing_price(snapshot); + let price = self.execution_order_limit_check_price(date, data, symbol, snapshot, OrderSide::Buy, Some(&algo_request)); + if !price.is_finite() || price <= 0.0 { + return Err(BacktestError::MissingPrice {date, symbol:symbol.to_string(), field:"algorithm_submission_price"}); + } let snapshot_requested_qty = self.value_buy_quantity( date, value.abs(), @@ -6126,7 +6328,10 @@ where report, ) } else { - let price = self.sizing_price(snapshot); + let price = self.execution_order_limit_check_price(date, data, symbol, snapshot, OrderSide::Sell, Some(&algo_request)); + if !price.is_finite() || price <= 0.0 { + return Err(BacktestError::MissingPrice {date, symbol:symbol.to_string(), field:"algorithm_submission_price"}); + } let requested_qty = self.round_buy_quantity( (value.abs() / price).floor() as u32, self.minimum_order_quantity(data, symbol), @@ -6337,6 +6542,9 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + let algorithm = self.normalized_algorithm(date, requested_qty, order_id, commission_state.get(&order_id).copied(), algo_request); + let algo_request = algorithm.as_ref(); + let fill_start = report.fill_events.len(); if emit_creation_events && self.runtime_auto_buy_denials.borrow().contains_key(symbol) { return Ok(()); } @@ -6592,6 +6800,9 @@ where 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(), + algo_request: None, + value_budget: None, + reserved_cash: None, reason: reason.to_string(), }); // Waiting without a fill is not a new order-state transition. @@ -6651,13 +6862,14 @@ where } }; let value_gross_limit = self.value_buy_gross_limit(value_budget); + let available_cash=self.cash_after_algorithm_reservations(portfolio.cash(),Some(order_id))?; let buy_cash_limit = if self.strict_value_budget { value_budget .filter(|budget| budget.is_finite() && *budget > 0.0) - .map(|budget| portfolio.cash().min(budget)) - .unwrap_or_else(|| portfolio.cash()) + .map(|budget| available_cash.min(budget)) + .unwrap_or(available_cash) } else { - portfolio.cash() + available_cash }; let fill = self.resolve_execution_fill( @@ -6779,8 +6991,8 @@ where price: execution_price, mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), quantity: filled_qty, - execution_start_timestamp: None, - execution_timestamp: None, + execution_start_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)), + execution_timestamp: self.runtime_execution_clock.get().map(|time|date.and_time(time)), }], None, Vec::new(), @@ -6814,8 +7026,9 @@ where let detail = partial_fill_reason .as_deref() .unwrap_or("insufficient cash after fees"); - if Self::keeps_remainder_open(remainder_policy) - && Self::limit_order_can_remain_open(Some(detail)) + if (Self::keeps_remainder_open(remainder_policy) + && Self::limit_order_can_remain_open(Some(detail))) + || self.algorithm_still_working(algo_request,Some(detail)) { self.upsert_open_order(OpenOrder { order_id, @@ -6828,10 +7041,13 @@ where requested_quantity: requested_qty, 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), + limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit buy")}, + time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)}, commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), + algo_request: Self::progressed_algorithm(algo_request, 0, commission_state.get(&order_id).copied()), + value_budget: if algo_request.is_some() {value_budget} else {None}, + reserved_cash: if algo_request.is_some() {Some(self.algorithm_cash_reservation(date,value_budget,requested_qty,size_check_price,order_id,commission_state.get(&order_id).copied(),data.instruments().get(symbol),portfolio.cash())?)} else {None}, reason: reason.to_string(), }); // Waiting without a fill is not a new order-state transition. @@ -6872,7 +7088,7 @@ where side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: 0, - status: zero_fill_status_for_reason(detail), + status: self.unfilled_algorithm_status(algo_request, detail), reason: format!("{reason}: {detail}"), }); Self::emit_order_process_event( @@ -6884,7 +7100,7 @@ where OrderSide::Buy, format!( "status={:?} reason={detail}", - zero_fill_status_for_reason(detail) + self.unfilled_algorithm_status(algo_request, detail) ), ); self.clear_open_order(order_id); @@ -6987,9 +7203,10 @@ where *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let remaining_qty = requested_qty.saturating_sub(filled_qty); - let keep_open = Self::keeps_remainder_open(remainder_policy) + let keep_open = (Self::keeps_remainder_open(remainder_policy) && remaining_qty > 0 - && Self::limit_order_can_remain_open(partial_fill_reason.as_deref()); + && Self::limit_order_can_remain_open(partial_fill_reason.as_deref())) + || (remaining_qty > 0 && self.algorithm_still_working(algo_request,partial_fill_reason.as_deref())); if keep_open { self.upsert_open_order(OpenOrder { order_id, @@ -7002,10 +7219,13 @@ where requested_quantity: requested_qty, 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), + limit_price: if algo_request.is_some() {limit_price.unwrap_or(0.0)} else {limit_price.expect("limit price for pending limit buy")}, + time_in_force: if algo_request.is_some() {self.runtime_time_in_force.get().unwrap_or(OrderTimeInForce::Day)} else {Self::pending_time_in_force(remainder_policy)}, commission_remaining: commission_state.get(&order_id).copied(), execution_cursor: execution_cursors.get(symbol).copied(), + algo_request: Self::progressed_algorithm(algo_request, filled_qty, commission_state.get(&order_id).copied()), + value_budget: if algo_request.is_some() {self.remaining_algorithm_budget(value_budget,&report.fill_events[fill_start..])?} else {None}, + reserved_cash: if algo_request.is_some() {Some(self.algorithm_cash_reservation(date,self.remaining_algorithm_budget(value_budget,&report.fill_events[fill_start..])?,remaining_qty,size_check_price,order_id,commission_state.get(&order_id).copied(),data.instruments().get(symbol),portfolio.cash())?)} else {None}, reason: reason.to_string(), }); } else { @@ -7015,7 +7235,7 @@ where let status = if keep_open { OrderStatus::PartiallyFilled } else if filled_qty < requested_qty { - OrderStatus::Canceled + if self.algorithm_window_expired(algo_request, partial_fill_reason.as_deref().unwrap_or("")) {OrderStatus::Expired} else {OrderStatus::Canceled} } else { OrderStatus::Filled }; @@ -7052,7 +7272,7 @@ where status, reason: order_reason, }); - if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) { + if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected | OrderStatus::Expired) { Self::emit_order_process_event( report, date, @@ -7569,6 +7789,192 @@ where }) } + fn normalized_algorithm( + &self, + date: NaiveDate, + quantity: u32, + order_id: u64, + commission: Option, + request: Option<&AlgoExecutionRequest>, + ) -> Option { + request + .copied() + .or_else(|| { + (self.matching_type == MatchingType::Vwap).then_some(AlgoExecutionRequest { + style: AlgoExecutionStyle::Vwap, + start_time: self.submission_time(), + end_time: None, + total_quantity: None, + filled_quantity: 0, + commission_remaining: None, + order_id: None, + }) + }) + .map(|mut request| { + request.total_quantity.get_or_insert(quantity); + request.order_id = Some(order_id); + request.commission_remaining = commission; + if request.start_time.is_none() { + request.start_time = self.execution_clock().or(self.submission_time()); + } + if request.end_time.is_none() && request.style == AlgoExecutionStyle::Vwap { + request.end_time = Some( + self.post_close_execution_window(date) + .map(|(_, end)| end.time()) + .unwrap_or_else(|| { + NaiveTime::from_hms_opt(15, 0, 0).expect("cash session close") + }), + ); + } + request + }) + } + + fn cash_after_algorithm_reservations( + &self, + cash: f64, + except: Option, + ) -> Result { + let reserved = FixedMoney::checked_sum_f64( + self.open_orders + .borrow() + .iter() + .filter(|order| except != Some(order.order_id)) + .filter_map(|order| order.reserved_cash), + ) + .and_then(|amount| amount.checked_add(self.runtime_unprocessed_algorithm_cash.get())) + .ok_or_else(|| BacktestError::Execution("algorithm reserved cash overflow".into()))?; + FixedMoney::from_f64(cash) + .and_then(|cash| cash.checked_sub(reserved)) + .map(|available| available.max(FixedMoney::ZERO).to_f64()) + .ok_or_else(|| BacktestError::Execution("algorithm available cash is invalid".into())) + } + + #[allow(clippy::too_many_arguments)] + fn algorithm_cash_reservation( + &self, + date: NaiveDate, + budget: Option, + quantity: u32, + price: f64, + order_id: u64, + commission: Option, + instrument: Option<&Instrument>, + cash: f64, + ) -> Result { + let available = self.cash_after_algorithm_reservations(cash, Some(order_id))?; + if let Some(budget) = budget.filter(|_| self.strict_value_budget) { + return Ok(budget.min(available)); + } + let gross = budget.unwrap_or(price * f64::from(quantity)); + if !gross.is_finite() || gross < 0. { + return Err(BacktestError::Execution( + "algorithm reservation requires a current price or explicit value budget".into(), + )); + } + let mut state = commission + .map(|left| (order_id, left)) + .into_iter() + .collect(); + let cost = self.cost_model.calculate_with_order_state_for_instrument( + date, + OrderSide::Buy, + gross, + Some(order_id), + &mut state, + instrument, + ); + FixedMoney::checked_sum_f64([gross, cost.total()]) + .map(|amount| amount.to_f64().min(available)) + .ok_or_else(|| BacktestError::Execution("algorithm cash reservation overflow".into())) + } + + fn algorithm_still_working( + &self, + request: Option<&AlgoExecutionRequest>, + reason: Option<&str>, + ) -> bool { + request.is_some_and(|request| { + self.runtime_intraday_end_time + .get() + .zip(request.end_time) + .is_some_and(|(clock, end)| clock < end) + }) && self + .runtime_time_in_force + .get() + .is_none_or(|tif| matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc)) + && Self::limit_order_can_remain_open(reason) + } + + fn unfilled_algorithm_status( + &self, + request: Option<&AlgoExecutionRequest>, + reason: &str, + ) -> OrderStatus { + if self.algorithm_window_expired(request, reason) { + OrderStatus::Expired + } else { + zero_fill_status_for_reason(reason) + } + } + + fn algorithm_window_expired( + &self, + request: Option<&AlgoExecutionRequest>, + reason: &str, + ) -> bool { + request.is_some_and(|request| { + self.runtime_intraday_end_time + .get() + .zip(request.end_time) + .is_some_and(|(clock, end)| clock >= end) + }) && matches!( + reason, + "intraday quote liquidity exhausted" + | "no execution quotes after start" + | "no execution quotes at or before start" + ) + } + + fn progressed_algorithm( + request: Option<&AlgoExecutionRequest>, + filled: u32, + commission: Option, + ) -> Option { + request.copied().map(|mut request| { + request.filled_quantity = request.filled_quantity.saturating_add(filled); + request.commission_remaining = commission; + request + }) + } + + fn remaining_algorithm_budget( + &self, + budget: Option, + fills: &[FillEvent], + ) -> Result, BacktestError> { + let Some(budget) = budget else { + return Ok(None); + }; + let spent = FixedMoney::checked_sum_f64(fills.iter().map(|fill| { + if self.strict_value_budget { + -fill.net_cash_flow + } else { + fill.gross_amount + } + })) + .ok_or_else(|| { + BacktestError::Execution("algorithm budget spent amount is invalid".into()) + })?; + let remaining = FixedMoney::from_f64(budget) + .and_then(|budget| budget.checked_sub(spent)) + .filter(|remaining| *remaining >= FixedMoney::ZERO) + .ok_or_else(|| { + BacktestError::Execution("algorithm spent more than its frozen value budget".into()) + })?; + Ok(Some(remaining.to_f64())) + } + fn resolve_execution_fill( &self, date: NaiveDate, @@ -7614,6 +8020,12 @@ where { Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted)))) } else { start_cursor }; + let start_cursor = if algo_request.is_some() { + match (start_cursor, self.execution_clock().map(|time| date.and_time(time))) { + (Some(declared), Some(clock)) => Some(declared.max(clock)), + (start, _) => start, + } + } else { start_cursor }; let end_cursor = post_close_window.map(|window| { runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end))) }).or_else(|| { @@ -7630,10 +8042,17 @@ where } else { end_cursor }; + let end_cursor = if algo_request.is_some() { + match (end_cursor, runtime_end_time.map(|time| date.and_time(time))) { + (Some(declared), Some(clock)) => Some(declared.min(clock)), + (end, _) => end, + } + } else { end_cursor }; let quotes = data.execution_quotes_on(date, symbol); let calibration = self.slippage_calibration(data, snapshot)?; - if let Some(fill) = self.select_execution_fill_with_ledger( + let previous_schedule = self.runtime_algo_schedule.replace(algo_request.copied()); + let selected = self.select_execution_fill_with_ledger( symbol, snapshot, quotes, @@ -7652,7 +8071,9 @@ where execution_ledger, calibration.as_ref(), data.instruments().get(symbol), - )? { + ); + self.runtime_algo_schedule.set(previous_schedule); + if let Some(fill) = selected? { return Ok(Some(fill)); } @@ -7662,11 +8083,8 @@ where || runtime_end_time.is_some() || self.intraday_execution_start_time.is_some() { - let next_cursor = algo_request - .and_then(|request| request.start_time) - .or(runtime_start_time) - .or(self.intraday_execution_start_time) - .map(|start_time| date.and_time(start_time) + Duration::seconds(1)) + let next_cursor = start_cursor + .map(|time| time + Duration::seconds(1)) .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); return Ok(Some(ExecutionFill { quantity: 0, @@ -7778,16 +8196,24 @@ where return Ok(None); } + let algo_schedule = self.runtime_algo_schedule.get(); + let mut preview_commission_state = BTreeMap::new(); + let schedule_start = algo_schedule.and_then(|request| request.start_time) + .map(|time| snapshot.date.and_time(time)).or(start_cursor); + let schedule_end = algo_schedule.and_then(|request| request.end_time) + .map(|time| snapshot.date.and_time(time)).or(end_cursor); let quote_quantity_limited = - self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor); + self.quote_quantity_limited_for_window(matching_type, schedule_start, schedule_end); let twap_schedule = (matching_type == MatchingType::Twap) - .then(|| TwapSchedule::new(start_cursor, end_cursor, requested_qty)) + .then(|| TwapSchedule::new(schedule_start, schedule_end, + algo_schedule.and_then(|request|request.total_quantity).unwrap_or(requested_qty))) .transpose()?; let lot = round_lot.max(1); let exact_time_order_quote = matching_type != MatchingType::MinuteLast && start_cursor.is_some() && end_cursor.is_some() - && start_cursor == end_cursor; + && start_cursor == end_cursor + && !(algo_schedule.is_some() && schedule_start != schedule_end); let use_decision_time_quote = !self.is_post_close_fixed_price(snapshot.date) && start_cursor.is_some() && (matching_type == MatchingType::MinuteLast || exact_time_order_quote); @@ -7923,7 +8349,8 @@ where } let mut take_qty = if let Some(schedule) = &twap_schedule { - remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at, filled_qty)) + remaining_qty.min(available_qty).min(schedule.due_quantity(execution_at, + algo_schedule.map_or(0,|request|request.filled_quantity).saturating_add(filled_qty))) } else { remaining_qty.min(available_qty) }; @@ -7984,10 +8411,16 @@ where ); continue; } - let candidate_cost = self - .cost_model - .calculate_for_instrument(snapshot.date, OrderSide::Buy, candidate_gross, instrument) - .total(); + let candidate_cost = if let Some(request)=algo_schedule { + preview_commission_state.clear(); + if let (Some(id),Some(remaining))=(request.order_id,request.commission_remaining) { + preview_commission_state.insert(id,remaining); + } + self.cost_model.calculate_with_order_state_for_instrument(snapshot.date,OrderSide::Buy, + candidate_gross,request.order_id,&mut preview_commission_state,instrument).total() + } else { + self.cost_model.calculate_for_instrument(snapshot.date,OrderSide::Buy,candidate_gross,instrument).total() + }; let candidate_cash = FixedMoney::checked_sum_f64([candidate_gross, candidate_cost]) .expect("buy cash must be finite fixed-point money") @@ -8252,6 +8685,8 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { #[cfg(test)] mod tests { + mod algorithm_clock; + use std::collections::BTreeMap; use chrono::NaiveTime; @@ -8291,6 +8726,9 @@ mod tests { time_in_force: OrderTimeInForce::Gtc, commission_remaining: None, execution_cursor: None, + algo_request: None, + value_budget: None, + reserved_cash: None, reason: format!("order_{order_id}"), } } diff --git a/crates/fidc-core/src/broker/tests/algorithm_clock.rs b/crates/fidc-core/src/broker/tests/algorithm_clock.rs new file mode 100644 index 0000000..bc1ce66 --- /dev/null +++ b/crates/fidc-core/src/broker/tests/algorithm_clock.rs @@ -0,0 +1,778 @@ +use super::*; + +fn time(minute: u32) -> NaiveTime { + NaiveTime::from_hms_opt(10, minute, 0).unwrap() +} + +fn data(quotes: &[(u32, f64, u32)]) -> DataSet { + data_with_snapshot(quotes, limit_test_snapshot()) +} + +fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet { + DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot], + vec![], + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + vec![], + quotes + .iter() + .map(|&(minute, price, volume)| { + let mut quote = limit_test_quote(price, price, price); + quote.timestamp = quote.date.and_time(time(minute)); + quote.volume_delta = u64::from(volume); + quote.amount_delta = price * f64::from(volume); + quote.bid1_volume = u64::from(volume / 100); + quote.ask1_volume = u64::from(volume / 100); + quote + }) + .collect(), + ) + .unwrap() +} + +fn broker() -> BrokerSimulator { + BrokerSimulator::new( + ChinaAShareCostModel::default() + .with_commission_rate(0.0003) + .with_minimum_commission(5.), + ChinaEquityRuleHooks, + ) + .with_matching_type(MatchingType::MinuteLast) + .with_execution_price_field(PriceField::Last) + .with_intraday_execution_start_time(time(0)) + .with_volume_limit(true) + .with_volume_percent(0.25) + .with_liquidity_limit(false) + .with_inactive_limit(false) + .with_strict_value_budget(true) +} + +fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision { + StrategyDecision { + order_intents: vec![OrderIntent::AlgoValue { + symbol: "000001.SZ".into(), + value, + style, + start_time: Some(time(0)), + end_time: Some(time(10)), + reason: "clock-algorithm".into(), + }], + ..Default::default() + } +} + +fn step( + broker: &BrokerSimulator, + portfolio: &mut PortfolioState, + data: &DataSet, + minute: u32, + decision: &StrategyDecision, +) -> BrokerExecutionReport { + broker + .execute_between( + limit_test_snapshot().date, + portfolio, + data, + decision, + Some(time(minute)), + Some(time(minute)), + ) + .unwrap() +} + +#[test] +fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() { + let data = data(&[ + (0, 10., 4_000), + (2, 10.1, 4_000), + (5, 10.2, 4_000), + (10, 10.3, 4_000), + ]); + let decision = intent(AlgoOrderStyle::Twap, 10_000.); + let mut synchronous_account = PortfolioState::new(20_000.); + let reference = broker() + .execute( + limit_test_snapshot().date, + &mut synchronous_account, + &data, + &decision, + ) + .unwrap(); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + let mut fills = Vec::new(); + let mut events = Vec::new(); + let empty = StrategyDecision::default(); + for minute in [0, 2, 5, 10] { + let batch = step( + &broker, + &mut account, + &data, + minute, + if minute == 0 { &decision } else { &empty }, + ); + assert!( + batch + .fill_events + .iter() + .all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute)) + ); + fills.extend(batch.fill_events); + events.extend(batch.order_events); + } + let canonical = |rows: &[crate::events::FillEvent]| { + rows.iter() + .map(|fill| { + ( + fill.quantity, + fill.price.to_bits(), + fill.commission.to_bits(), + fill.stamp_tax.to_bits(), + fill.transfer_fee.to_bits(), + fill.execution_timestamp, + fill.order_id, + ) + }) + .collect::>() + }; + assert_eq!(canonical(&fills), canonical(&reference.fill_events)); + assert_eq!(account.cash(), synchronous_account.cash()); + assert_eq!(fills.iter().map(|fill| fill.quantity).sum::(), 900); + assert_eq!(fills.iter().map(|fill| fill.commission).sum::(), 5.); + assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::() <= 10_000.); + assert!(events.iter().all(|event| event.order_id == Some(1))); + assert_eq!(events.last().unwrap().status, OrderStatus::Filled); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() { + let data = data(&[ + (0, 10., 4_000), + (2, 10., 4_000), + (5, 10., 4_000), + (10, 10., 4_000), + ]); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + step( + &broker, + &mut account, + &data, + 0, + &intent(AlgoOrderStyle::Twap, 10_000.), + ); + assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.)); + let partial = step( + &broker, + &mut account, + &data, + 2, + &StrategyDecision::default(), + ); + assert_eq!( + partial + .fill_events + .iter() + .map(|fill| fill.quantity) + .sum::(), + 100 + ); + let working = broker.open_order_views(); + assert_eq!(working[0].order_id, 1); + assert_eq!(working[0].filled_quantity, 100); + assert_eq!( + working[0].reserved_cash, + Some(10_000. + partial.fill_events[0].net_cash_flow) + ); + let cancel = step( + &broker, + &mut account, + &data, + 3, + &StrategyDecision { + order_intents: vec![OrderIntent::CancelAll { + reason: "explicit-user-cancel".into(), + }], + ..Default::default() + }, + ); + assert!(cancel.fill_events.is_empty()); + assert_eq!( + cancel.order_events.last().unwrap().status, + OrderStatus::Canceled + ); + assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100); + assert!(broker.open_order_views().is_empty()); + assert!( + step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default() + ) + .fill_events + .is_empty() + ); + assert_eq!(account.position("000001.SZ").unwrap().quantity, 100); +} + +#[test] +fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() { + let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + step( + &broker, + &mut account, + &data, + 0, + &intent(AlgoOrderStyle::Twap, 10_000.), + ); + step( + &broker, + &mut account, + &data, + 2, + &StrategyDecision::default(), + ); + assert_eq!( + broker.next_day_order_expiry(limit_test_snapshot().date), + Some(time(10)) + ); + let terminal = step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default(), + ); + assert!(terminal.fill_events.is_empty()); + assert_eq!( + terminal.order_events.last().unwrap().status, + OrderStatus::Expired + ); + assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100); + assert!( + terminal + .process_events + .iter() + .any(|event| event.detail.contains("Expired")), + "{:?}", + terminal.process_events + ); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() { + let data = data(&[ + (0, 10., 4_000), + (1, 10., 4_000), + (2, 10., 4_000), + (10, 10., 4_000), + ]); + let broker = broker(); + let mut account = PortfolioState::new(11_000.); + step( + &broker, + &mut account, + &data, + 0, + &intent(AlgoOrderStyle::Twap, 10_000.), + ); + let other = step( + &broker, + &mut account, + &data, + 1, + &StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: "000001.SZ".into(), + quantity: 1_000, + reason: "separate-buy".into(), + }], + ..Default::default() + }, + ); + assert!( + other.fill_events.is_empty(), + "cash reserved for order 1 was spent: {:?}", + other.fill_events + ); + let final_batch = step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default(), + ); + assert!( + final_batch + .fill_events + .iter() + .all(|fill| fill.order_id == Some(1)) + ); + assert_eq!(account.position("000001.SZ").unwrap().quantity, 900); + assert!(account.cash() >= 1_000.); +} + +#[test] +fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() { + let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)]; + let mut changed = limit_test_snapshot(); + changed.close = 100.; + changed.last_price = 100.; + let run = |data: DataSet| { + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + let initial = step( + &broker, + &mut account, + &data, + 0, + &intent(AlgoOrderStyle::Twap, 10_000.), + ); + assert!(initial.fill_events.is_empty()); + let quantity = broker.open_order_views()[0].requested_quantity; + let final_batch = step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default(), + ); + ( + quantity, + final_batch + .fill_events + .iter() + .map(|fill| { + ( + fill.quantity, + fill.price.to_bits(), + fill.net_cash_flow.to_bits(), + ) + }) + .collect::>(), + ) + }; + assert_eq!( + run(data("es)), + run(data_with_snapshot("es, changed)) + ); +} + +#[test] +fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() { + let data = data(&[ + (0, 10., 400), + (2, 10., 800), + (5, 10., 1_200), + (10, 10., 4_000), + ]); + let decision = intent(AlgoOrderStyle::Vwap, 10_000.); + let mut synchronous_account = PortfolioState::new(20_000.); + let reference = broker() + .execute( + limit_test_snapshot().date, + &mut synchronous_account, + &data, + &decision, + ) + .unwrap(); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + let empty = StrategyDecision::default(); + let mut filled = 0; + let mut commission = 0.; + for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] { + let batch = step( + &broker, + &mut account, + &data, + minute, + if minute == 0 { &decision } else { &empty }, + ); + filled += batch + .fill_events + .iter() + .map(|fill| fill.quantity) + .sum::(); + commission += batch + .fill_events + .iter() + .map(|fill| fill.commission) + .sum::(); + assert_eq!(filled, expected); + assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1) + && fill.execution_timestamp.unwrap().time() <= time(minute))); + } + assert_eq!(account.cash(), synchronous_account.cash()); + assert_eq!( + commission, + reference + .fill_events + .iter() + .map(|fill| fill.commission) + .sum::() + ); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() { + let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]); + let broker = broker().with_matching_type(MatchingType::Vwap); + let mut account = PortfolioState::new(20_000.); + let first = step( + &broker, + &mut account, + &data, + 0, + &StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: "000001.SZ".into(), + quantity: 900, + reason: "configured-vwap".into(), + }], + ..Default::default() + }, + ); + assert_eq!( + first + .fill_events + .iter() + .map(|fill| fill.quantity) + .sum::(), + 100 + ); + assert_eq!( + broker.open_order_views().len(), + 1, + "{:?}", + first.order_events + ); + let second = step( + &broker, + &mut account, + &data, + 2, + &StrategyDecision::default(), + ); + assert_eq!(second.fill_events[0].quantity, 100); + assert_eq!(second.fill_events[0].order_id, Some(1)); + let final_batch = step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default(), + ); + assert_eq!(final_batch.fill_events[0].quantity, 700); + assert_eq!(final_batch.fill_events[0].order_id, Some(1)); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() { + let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]); + let date = limit_test_snapshot().date; + for acquired_today in [false, true] { + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + account.position_mut("000001.SZ").buy( + if acquired_today { + date + } else { + date.pred_opt().unwrap() + }, + 1_000, + 10., + ); + let decision = intent(AlgoOrderStyle::Vwap, -10_000.); + let mut fills = Vec::new(); + let mut events = Vec::new(); + let empty = StrategyDecision::default(); + for minute in [0, 2, 10] { + let batch = step( + &broker, + &mut account, + &data, + minute, + if minute == 0 { &decision } else { &empty }, + ); + fills.extend(batch.fill_events); + events.extend(batch.order_events); + } + assert_eq!( + fills.iter().map(|fill| fill.quantity).sum::(), + if acquired_today { 0 } else { 1_000 } + ); + assert!(events.iter().all(|event| event.order_id == Some(1))); + if !acquired_today { + assert_eq!(events.last().unwrap().status, OrderStatus::Filled); + assert_eq!(events.last().unwrap().requested_quantity, 1_000); + assert_eq!(events.last().unwrap().filled_quantity, 1_000); + } + assert!(broker.open_order_views().is_empty()); + } +} + +#[test] +fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() { + let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]); + for tif in [ + OrderTimeInForce::Ioc, + OrderTimeInForce::Fok, + OrderTimeInForce::Day, + OrderTimeInForce::Gtc, + ] { + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.); + if !decision.order_intents[0].supports_time_in_force(tif) { + decision.order_intents = decision + .order_intents + .into_iter() + .map(|intent| intent.with_time_in_force(tif)) + .collect(); + let error = broker + .execute_between( + limit_test_snapshot().date, + &mut account, + &data, + &decision, + Some(time(0)), + Some(time(0)), + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("is not supported for this order intent") + ); + assert_eq!(account.cash(), 20_000.); + assert!(broker.open_order_views().is_empty()); + continue; + } + decision.order_intents = decision + .order_intents + .into_iter() + .map(|intent| intent.with_time_in_force(tif)) + .collect(); + let first = step(&broker, &mut account, &data, 0, &decision); + let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc); + assert_eq!( + !broker.open_order_views().is_empty(), + persists, + "{tif:?}: {:?}", + first.order_events + ); + if !persists { + assert!( + step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default() + ) + .fill_events + .is_empty() + ); + } + } +} + +#[test] +fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() { + let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]); + let broker = broker(); + let mut account = PortfolioState::new(15_000.); + let mut decision = intent(AlgoOrderStyle::Twap, 10_000.); + decision + .order_intents + .extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents); + step(&broker, &mut account, &data, 0, &decision); + assert_eq!( + broker + .open_order_views() + .iter() + .map(|order| order.reserved_cash.unwrap()) + .collect::>(), + vec![10_000., 5_000.] + ); + let report = step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default(), + ); + assert_eq!( + report + .fill_events + .iter() + .map(|fill| (fill.order_id, fill.quantity)) + .collect::>(), + vec![(Some(1), 900), (Some(2), 500)] + ); + assert!(account.cash() >= 0.); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() { + let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]); + let broker = broker() + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut account = PortfolioState::new(20_000.); + step( + &broker, + &mut account, + &data, + 0, + &intent(AlgoOrderStyle::Twap, 10_000.), + ); + let first = step( + &broker, + &mut account, + &data, + 2, + &StrategyDecision::default(), + ); + let last = step( + &broker, + &mut account, + &data, + 10, + &StrategyDecision::default(), + ); + assert_eq!( + first + .fill_events + .iter() + .map(|fill| fill.quantity) + .sum::(), + 100 + ); + assert_eq!( + last.fill_events + .iter() + .map(|fill| fill.quantity) + .sum::(), + 100 + ); + assert_eq!( + last.order_events.last().unwrap().status, + OrderStatus::Expired + ); + assert_eq!(last.order_events.last().unwrap().filled_quantity, 200); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn non_matching_controls_amend_or_cancel_without_filling_a_crossing_quote() { + let data = data(&[(0, 10., 4_000), (2, 9.4, 4_000)]); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + step( + &broker, + &mut account, + &data, + 0, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000001.SZ".into(), + quantity: 100, + limit_price: 9.5, + reason: "resting".into(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..Default::default() + }, + ); + assert_eq!(broker.open_order_views().len(), 1); + let modify = broker + .execute_controls_without_matching( + limit_test_snapshot().date, + limit_test_snapshot().date, + &mut account, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::ModifyOrder { + order_id: 1, + new_total_quantity: Some(200), + new_limit_price: Some(9.3), + reason: "pre-open-amend".into(), + }], + ..Default::default() + }, + Some(time(2)), + ) + .unwrap(); + assert!(modify.fill_events.is_empty()); + assert_eq!(broker.open_order_views()[0].limit_price, 9.3); + assert_eq!(broker.open_order_views()[0].requested_quantity, 200); + let cancel = broker + .execute_controls_without_matching( + limit_test_snapshot().date, + limit_test_snapshot().date, + &mut account, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::CancelAll { + reason: "pre-open-cancel".into(), + }], + ..Default::default() + }, + Some(time(2)), + ) + .unwrap(); + assert!(cancel.fill_events.is_empty()); + assert_eq!( + cancel.order_events.last().unwrap().status, + OrderStatus::Canceled + ); + assert_eq!(account.cash(), 20_000.); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn control_only_phase_cannot_be_used_to_submit_an_order_or_leave_matching_disabled() { + let data = data(&[(0, 10., 4_000)]); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + let submit = StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: "000001.SZ".into(), + quantity: 100, + reason: "normal-order".into(), + }], + ..Default::default() + }; + assert!( + broker + .execute_controls_without_matching( + limit_test_snapshot().date, + limit_test_snapshot().date, + &mut account, + &data, + &submit, + Some(time(0)) + ) + .is_err() + ); + assert_eq!(account.cash(), 20_000.); + assert_eq!( + step(&broker, &mut account, &data, 0, &submit).fill_events[0].quantity, + 100 + ); +} diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 8f5cb2e..40547f4 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -991,10 +991,11 @@ where decision_date: NaiveDate, decision_index: usize, portfolio: &mut PortfolioState, - open_orders: &[crate::strategy::OpenOrderView], process_events: &mut Vec, decision: &mut crate::strategy::StrategyDecision, directive_report: &mut BrokerExecutionReport, + result: &mut BacktestResult, + callback_datetime: Option, ) -> Result<(), BacktestError> { if decision.order_intents.is_empty() { return Ok(()); @@ -1002,6 +1003,7 @@ where let mut retained = Vec::with_capacity(decision.order_intents.len()); for intent in decision.order_intents.drain(..) { + let current_orders = self.open_order_views(); match intent { crate::strategy::OrderIntent::UpdateUniverse { symbols, reason } => { let symbol_count = symbols.len(); @@ -1018,7 +1020,7 @@ where &self.data, portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1035,6 +1037,7 @@ where symbols.iter().cloned().collect::>().join(",") ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::Subscribe { symbols, reason } => { @@ -1059,7 +1062,7 @@ where &self.data, portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1075,6 +1078,7 @@ where added.join(",") ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } } @@ -1100,7 +1104,7 @@ where &self.data, portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1116,6 +1120,7 @@ where removed.join(",") ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } } @@ -1170,7 +1175,7 @@ where &self.data, &*portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1185,6 +1190,7 @@ where portfolio.cash() ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::FinanceRepay { amount, reason } => { @@ -1216,7 +1222,7 @@ where &self.data, &*portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1232,6 +1238,7 @@ where portfolio.cash_liabilities() ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::SetManagementFeeRate { rate, reason } => { @@ -1250,7 +1257,7 @@ where &self.data, &*portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1265,6 +1272,7 @@ where portfolio.management_fees() ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::CancelOrder { order_id, reason } => { @@ -1322,6 +1330,13 @@ where } other => retained.push(other), } + Self::record_execution_history(result,directive_report,decision_date,execution_date); + if !directive_report.process_events.is_empty() { + let orders = self.open_order_views(); + publish_process_events(&mut self.strategy,&mut self.process_event_bus,execution_date,decision_date,decision_index, + &self.data,portfolio,self.futures_account.as_ref(),&orders,self.dynamic_universe.as_ref(),&self.subscriptions, + process_events,&mut directive_report.process_events,CallbackObservation::from_result(result,callback_datetime))?; + } } decision.order_intents = retained; Ok(()) @@ -1349,6 +1364,7 @@ where avg_price: 0.0, transaction_cost: 0.0, limit_price: order.limit_price, + reserved_cash: None, reason: order.reason.clone(), }) .collect() @@ -2090,6 +2106,320 @@ where report } + #[allow(clippy::too_many_arguments)] + fn execute_day_phase( + &mut self, + timing: (NaiveDate, NaiveDate, usize, Option), + scheduler: &Scheduler<'_>, + coarse_schedule_rules: &[ScheduleRule], + portfolio: &mut PortfolioState, + result: &mut BacktestResult, + process_events: &mut Vec, + directive_report: &mut BrokerExecutionReport, + report: &mut BrokerExecutionReport, + clock: Option, + earlier_target: Option, + ) -> Result { + let (execution_date, decision_date, decision_index, decision_total_equity) = timing; + let logical_time = |stage| { + if execution_date == decision_date { + clock.or_else(|| default_stage_time(stage)) + } else { + default_stage_time(stage) + } + }; + let post_auction_open_orders = self.open_order_views(); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &post_auction_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + execution_date, + ProcessEventKind::PreOnDay, + "on_day:pre", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::OnDay)))), + )?; + let on_day_open_orders = self.open_order_views(); + let decision_quote_times = self.strategy.decision_quote_times(); + if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() { + let decision_quote_symbols = + self.strategy.decision_quote_symbols(&StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio, + futures_account: self.futures_account.as_ref(), + open_orders: &on_day_open_orders, + dynamic_universe: self.dynamic_universe.as_ref(), + subscriptions: &self.subscriptions, + process_events, + active_process_event: None, + active_datetime: stage_datetime( + decision_date, + logical_time(ScheduleStage::OnDay), + ), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), + })?; + self.ensure_execution_quotes_for_symbols_at_times( + execution_date, + &decision_quote_symbols, + &decision_quote_times, + )?; + } + self.ensure_execution_quotes_for_portfolio_times( + execution_date, + portfolio, + &decision_quote_times, + )?; + let mut decision = self.strategy.on_day(&StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio, + futures_account: self.futures_account.as_ref(), + open_orders: &on_day_open_orders, + dynamic_universe: self.dynamic_universe.as_ref(), + subscriptions: &self.subscriptions, + process_events, + active_process_event: None, + active_datetime: stage_datetime(decision_date, logical_time(ScheduleStage::OnDay)), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), + })?; + decision.merge_from(collect_scheduled_decisions_for_stage( + &mut self.strategy, + scheduler, + execution_date, + ScheduleStage::OnDay, + coarse_schedule_rules, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &on_day_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + &mut self.process_event_bus, + result.order_events.as_slice(), + result.fills.as_slice(), + clock.filter(|_| execution_date == decision_date), + clock, + )?); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &on_day_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + execution_date, + ProcessEventKind::OnDay, + "on_day", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::OnDay)))), + )?; + let bar_open_orders = self.open_order_views(); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &bar_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + execution_date, + ProcessEventKind::PreBar, + "bar:pre", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), + )?; + decision.merge_from(collect_scheduled_decisions_for_stage( + &mut self.strategy, + scheduler, + execution_date, + ScheduleStage::Bar, + coarse_schedule_rules, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &bar_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + &mut self.process_event_bus, + result.order_events.as_slice(), + result.fills.as_slice(), + clock.filter(|_| execution_date == decision_date), + clock, + )?); + decision.merge_from(self.strategy.on_bar(&StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio, + futures_account: self.futures_account.as_ref(), + open_orders: &bar_open_orders, + dynamic_universe: self.dynamic_universe.as_ref(), + subscriptions: &self.subscriptions, + process_events, + active_process_event: None, + active_datetime: stage_datetime(decision_date, logical_time(ScheduleStage::Bar)), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), + })?); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &bar_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + execution_date, + ProcessEventKind::Bar, + "bar", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), + )?; + self.apply_strategy_directives( + execution_date, + decision_date, + decision_index, + portfolio, + process_events, + &mut decision, + directive_report, + result, + stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar))), + )?; + + let mut superseded_audits = Vec::new(); + if let Some(mut earlier) = earlier_target { + if decision.rebalance || !decision.order_intents.is_empty() || !decision.exit_symbols.is_empty() { + decision.notes.splice(0..0, earlier.notes); + decision.diagnostics.splice(0..0, earlier.diagnostics); + decision.diagnostics.push("unsubmitted_pre_market_target_superseded".into()); + superseded_audits.append(&mut earlier.risk_decisions); + } else { + earlier.merge_from(decision); + decision = earlier; + } + } + let pre_intraday_execution_orders = self.open_order_views(); + self.ensure_execution_quotes_for_decision( + execution_date, + decision_date, + portfolio, + &pre_intraday_execution_orders, + &decision, + None, + None, + )?; + let mut intraday_report = self.broker.execute_coarse_at_clock( + execution_date, + decision_date, + decision_date, + decision_total_equity, + portfolio, + &self.data, + &decision, + clock, + )?; + Self::record_execution_history(result, directive_report, decision_date, execution_date); + Self::record_execution_history(result, &mut intraday_report, decision_date, execution_date); + let post_intraday_open_orders = self.open_order_views(); + publish_process_events( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &post_intraday_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + &mut intraday_report.process_events, + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), + )?; + report.order_events.extend(intraday_report.order_events); + report.fill_events.extend(intraday_report.fill_events); + report + .position_events + .extend(intraday_report.position_events); + report.account_events.extend(intraday_report.account_events); + report.diagnostics.extend(intraday_report.diagnostics); + decision.risk_decisions.append(&mut superseded_audits); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &post_intraday_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + execution_date, + ProcessEventKind::PostOnDay, + "on_day:post", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::OnDay)))), + )?; + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &post_intraday_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + execution_date, + ProcessEventKind::PostBar, + "bar:post", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), + )?; + Ok(decision) + } + pub fn run(&mut self) -> Result { self.run_with_progress_options(false, false, |_| {}) } @@ -2368,6 +2698,7 @@ where execution_date, ProcessEventKind::PreBeforeTrading, "before_trading:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), )?; self.strategy.before_trading(&StrategyContext { execution_date, @@ -2404,6 +2735,7 @@ where execution_date, ProcessEventKind::BeforeTrading, "before_trading", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), )?; let mut before_trading_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -2423,17 +2755,38 @@ where &mut self.process_event_bus, result.order_events.as_slice(), result.fills.as_slice(), + None, + default_stage_time(ScheduleStage::BeforeTrading), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &pre_open_orders, &mut process_events, &mut before_trading_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading)), )?; + let (controls, deferred): (Vec<_>, Vec<_>) = std::mem::take(&mut before_trading_decision.order_intents) + .into_iter().partition(|intent|matches!(intent.unwrapped(), + OrderIntent::CancelOrder {..}|OrderIntent::CancelSymbol {..}|OrderIntent::CancelAll {..}|OrderIntent::ModifyOrder {..})); + before_trading_decision.order_intents = deferred; + if !controls.is_empty() { + let controls = StrategyDecision {order_intents:controls,buy_denials:before_trading_decision.buy_denials.clone(), + risk_decisions:before_trading_decision.risk_decisions.clone(),..Default::default()}; + let mut control_report = self.broker.execute_controls_without_matching(execution_date,decision_date, + &mut portfolio,&self.data,&controls,default_stage_time(ScheduleStage::BeforeTrading))?; + Self::record_execution_history(&mut result,&mut control_report,decision_date,execution_date); + let orders = self.open_order_views(); + publish_process_events(&mut self.strategy,&mut self.process_event_bus,execution_date,decision_date,decision_index, + &self.data,&portfolio,self.futures_account.as_ref(),&orders,self.dynamic_universe.as_ref(),&self.subscriptions, + &mut process_events,&mut control_report.process_events, + CallbackObservation::from_result(&result,stage_datetime(execution_date,default_stage_time(ScheduleStage::BeforeTrading))))?; + merge_broker_report(&mut directive_report,control_report); + } + let pre_open_orders = self.open_order_views(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -2450,6 +2803,7 @@ where execution_date, ProcessEventKind::PostBeforeTrading, "before_trading:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), )?; publish_phase_event( &mut self.strategy, @@ -2467,6 +2821,7 @@ where execution_date, ProcessEventKind::PreOpenAuction, "open_auction:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; let mut auction_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -2486,6 +2841,8 @@ where &mut self.process_event_bus, result.order_events.as_slice(), result.fills.as_slice(), + None, + default_stage_time(ScheduleStage::OpenAuction), )?; auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { execution_date, @@ -2522,37 +2879,63 @@ where execution_date, ProcessEventKind::OpenAuction, "open_auction", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &pre_open_orders, &mut process_events, &mut auction_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction)), )?; - let pre_auction_execution_orders = self.open_order_views(); - self.ensure_execution_quotes_for_decision( - execution_date, - decision_date, - &portfolio, - &pre_auction_execution_orders, - &auction_decision, - None, - None, - )?; - let mut report = self.broker.execute_with_event_dates_and_decision_equity( + let mut pending_portfolio: Option = None; + let mut pre_day_batches = Vec::new(); + let mut pre_day_telemetry = StrategyDecision::default(); + for mut batch in [before_trading_decision, auction_decision] { + if batch.is_portfolio_target_only() { + if let Some(mut previous) = pending_portfolio.take() { + previous.merge_from(batch); + pending_portfolio = Some(previous); + } else { + pending_portfolio = Some(batch); + } + } else if batch.rebalance || !batch.order_intents.is_empty() || !batch.exit_symbols.is_empty() { + pre_day_batches.push(batch); + } else if let Some(target) = pending_portfolio.as_mut() { + target.merge_from(batch); + } else { + pre_day_telemetry.notes.append(&mut batch.notes); + pre_day_telemetry.diagnostics.append(&mut batch.diagnostics); + pre_day_telemetry.risk_decisions.append(&mut batch.risk_decisions); + } + } + let original_minute_clock = should_run_minute_events(&intraday_schedule_rules, &self.subscriptions); + let mut deferred_etf_time = (self.broker.pending_etf_target_count() > 0) + .then_some(crate::etf_execution::opening_time()); + let mut deferred_day_time = self.broker.intraday_execution_start_time().or_else(|| { + (original_minute_clock || deferred_etf_time.is_some() || pending_portfolio.is_some() || !pre_day_batches.is_empty()).then(|| match self.broker.matching_type() { + MatchingType::CurrentBarClose => NaiveTime::from_hms_opt(15, 0, 0).unwrap(), + _ => NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + }) + }); + let mut deferred_auction = deferred_day_time.map(|_| std::mem::take(&mut pre_day_batches)); + let mut report = if deferred_day_time.is_some() { BrokerExecutionReport::default() } else { self.broker.execute_before_strategy_at_clock( execution_date, decision_date, decision_date, decision_total_equity, &mut portfolio, &self.data, - &auction_decision, - )?; + &StrategyDecision::default(), + None, + )? }; let post_auction_open_orders = self.open_order_views(); + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -2567,6 +2950,7 @@ where &self.subscriptions, &mut process_events, &mut report.process_events, + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; publish_phase_event( &mut self.strategy, @@ -2584,284 +2968,23 @@ where execution_date, ProcessEventKind::PostOpenAuction, "open_auction:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; - let deferred_etfs = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?; - merge_broker_report(&mut report, deferred_etfs); + Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_auction_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PreOnDay, - "on_day:pre", - )?; - let on_day_open_orders = self.open_order_views(); - let decision_quote_times = self.strategy.decision_quote_times(); - if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() { - let decision_quote_symbols = - self.strategy.decision_quote_symbols(&StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - futures_account: self.futures_account.as_ref(), - open_orders: &on_day_open_orders, - dynamic_universe: self.dynamic_universe.as_ref(), - subscriptions: &self.subscriptions, - process_events: &process_events, - active_process_event: None, - active_datetime: stage_datetime( - decision_date, - default_stage_time(ScheduleStage::OnDay), - ), - order_events: result.order_events.as_slice(), - fills: result.fills.as_slice(), - })?; - self.ensure_execution_quotes_for_symbols_at_times( - execution_date, - &decision_quote_symbols, - &decision_quote_times, - )?; - } - self.ensure_execution_quotes_for_portfolio_times( - execution_date, - &portfolio, - &decision_quote_times, - )?; - let mut decision = decision_slot - .map(|(decision_idx, decision_date)| { - self.strategy.on_day(&StrategyContext { - execution_date, - decision_date, - decision_index: decision_idx, - data: &self.data, - portfolio: &portfolio, - futures_account: self.futures_account.as_ref(), - open_orders: &on_day_open_orders, - dynamic_universe: self.dynamic_universe.as_ref(), - subscriptions: &self.subscriptions, - process_events: &process_events, - active_process_event: None, - active_datetime: stage_datetime( - decision_date, - default_stage_time(ScheduleStage::OnDay), - ), - order_events: result.order_events.as_slice(), - fills: result.fills.as_slice(), - }) - }) - .transpose()? - .unwrap_or_default(); - decision.merge_from(collect_scheduled_decisions_for_stage( - &mut self.strategy, - &scheduler, - execution_date, - ScheduleStage::OnDay, - &coarse_schedule_rules, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &on_day_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - &mut self.process_event_bus, - result.order_events.as_slice(), - result.fills.as_slice(), - )?); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &on_day_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::OnDay, - "on_day", - )?; - let bar_open_orders = self.open_order_views(); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &bar_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PreBar, - "bar:pre", - )?; - decision.merge_from(collect_scheduled_decisions_for_stage( - &mut self.strategy, - &scheduler, - execution_date, - ScheduleStage::Bar, - &coarse_schedule_rules, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &bar_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - &mut self.process_event_bus, - result.order_events.as_slice(), - result.fills.as_slice(), - )?); - decision.merge_from(self.strategy.on_bar(&StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - futures_account: self.futures_account.as_ref(), - open_orders: &bar_open_orders, - dynamic_universe: self.dynamic_universe.as_ref(), - subscriptions: &self.subscriptions, - process_events: &process_events, - active_process_event: None, - active_datetime: stage_datetime( - decision_date, - default_stage_time(ScheduleStage::Bar), - ), - order_events: result.order_events.as_slice(), - fills: result.fills.as_slice(), - })?); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &bar_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::Bar, - "bar", - )?; - self.apply_strategy_directives( - execution_date, - decision_date, - decision_index, - &mut portfolio, - &on_day_open_orders, - &mut process_events, - &mut decision, - &mut directive_report, - )?; + let mut decision = if deferred_day_time.is_some() { StrategyDecision::default() } else { self.execute_day_phase( + (execution_date, decision_date, decision_index, decision_total_equity), + &scheduler, &coarse_schedule_rules, &mut portfolio, &mut result, + &mut process_events, &mut directive_report, &mut report, None, pending_portfolio.take(), + )? }; + decision.notes.append(&mut pre_day_telemetry.notes); + decision.diagnostics.append(&mut pre_day_telemetry.diagnostics); + decision.risk_decisions.append(&mut pre_day_telemetry.risk_decisions); - let pre_intraday_execution_orders = self.open_order_views(); - self.ensure_execution_quotes_for_decision( - execution_date, - decision_date, - &portfolio, - &pre_intraday_execution_orders, - &decision, - None, - None, - )?; - let mut intraday_report = self.broker.execute_with_event_dates_and_decision_equity( - execution_date, - decision_date, - decision_date, - decision_total_equity, - &mut portfolio, - &self.data, - &decision, - )?; - let post_intraday_open_orders = self.open_order_views(); - publish_process_events( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_intraday_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - &mut intraday_report.process_events, - )?; - report.order_events.extend(intraday_report.order_events); - report.fill_events.extend(intraday_report.fill_events); - report - .position_events - .extend(intraday_report.position_events); - report.account_events.extend(intraday_report.account_events); - report.diagnostics.extend(intraday_report.diagnostics); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_intraday_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PostOnDay, - "on_day:post", - )?; - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_intraday_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PostBar, - "bar:post", - )?; - - if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) + let mut last_execution_time = self.broker.intraday_execution_start_time(); + if original_minute_clock || deferred_day_time.is_some() || ((self.broker.has_open_orders() || self.broker.has_pending_stock_pool_execution()) && self.broker.drives_resting_quote_clock()) { let unfiltered_minute_stream = self.subscriptions.is_empty(); @@ -2926,13 +3049,16 @@ where let Some(minute_timestamp) = next_minute_event_timestamp( next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp), - next_expiry_timestamp, + next_minute_event_timestamp(next_expiry_timestamp, + next_minute_event_timestamp(deferred_day_time.map(|time| execution_date.and_time(time)), + deferred_etf_time.map(|time|execution_date.and_time(time)))), ) else { break; }; let minute_time = minute_timestamp.time(); last_minute_timestamp = Some(minute_timestamp); + last_execution_time = Some(minute_time); minute_group.clear(); while minute_quotes .peek() @@ -2951,8 +3077,56 @@ where } let schedule_candidate = has_specific_schedule || (minute_schedule_all_times && !minute_group.is_empty()); - if !requires_minute_callbacks - && !has_minute_process_listeners + if deferred_etf_time == Some(minute_time) { + deferred_etf_time = None; + let mut batch = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?; + Self::record_execution_history(&mut result, &mut batch, decision_date, execution_date); + let orders = self.open_order_views(); + publish_process_events(&mut self.strategy, &mut self.process_event_bus, + execution_date, decision_date, decision_index, &self.data, &portfolio, + self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), + &self.subscriptions, &mut process_events, &mut batch.process_events, + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + merge_broker_report(&mut report, batch); + } + Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); + if deferred_day_time == Some(minute_time) { + deferred_day_time = None; + let mut before_batches = deferred_auction.take().unwrap_or_default(); + if before_batches.is_empty() {before_batches.push(StrategyDecision::default());} + for mut auction in before_batches { + let open_orders = self.open_order_views(); + self.ensure_execution_quotes_for_decision(execution_date,decision_date,&portfolio,&open_orders,&auction,Some(minute_time),Some(minute_time))?; + let mut batch = self.broker.execute_before_strategy_at_clock( + execution_date, decision_date, decision_date, decision_total_equity, + &mut portfolio, &self.data, &auction, Some(minute_time), + )?; + Self::record_execution_history(&mut result, &mut batch, decision_date, execution_date); + let orders = self.open_order_views(); + publish_process_events(&mut self.strategy, &mut self.process_event_bus, + execution_date, decision_date, decision_index, &self.data, &portfolio, + self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), + &self.subscriptions, &mut process_events, &mut batch.process_events, + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + merge_broker_report(&mut report, batch); + Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); + decision.notes.append(&mut auction.notes); + decision.diagnostics.append(&mut auction.diagnostics); + decision.risk_decisions.append(&mut auction.risk_decisions); + } + decision.merge_from(self.execute_day_phase( + (execution_date, decision_date, decision_index, decision_total_equity), + &scheduler, &coarse_schedule_rules, &mut portfolio, &mut result, + &mut process_events, &mut directive_report, &mut report, Some(minute_time), pending_portfolio.take(), + )?); + Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); + } + if (minute_group.is_empty() || (!requires_minute_callbacks + && !has_minute_process_listeners)) && !schedule_candidate && !self.has_open_orders() && !self.broker.has_pending_stock_pool_execution() @@ -2976,6 +3150,7 @@ where execution_date, ProcessEventKind::PreMinute, format!("minute:{minute_timestamp}:pre"), + CallbackObservation::from_result(&result, Some(minute_timestamp)), )?; let mut minute_decision = if schedule_candidate { let event_rules = if has_specific_schedule { @@ -3008,13 +3183,14 @@ where Some(minute_time), result.order_events.as_slice(), result.fills.as_slice(), + Some(minute_time), )?); } scheduled } else { crate::strategy::StrategyDecision::default() }; - if requires_minute_callbacks { + if requires_minute_callbacks && (original_minute_clock || !self.subscriptions.is_empty()) { for quote in &minute_group { if !self.subscriptions.is_empty() && !self.subscriptions.contains("e.symbol) { continue; @@ -3056,16 +3232,18 @@ where execution_date, ProcessEventKind::Minute, format!("minute:{minute_timestamp}"), + CallbackObservation::from_result(&result, Some(minute_timestamp)), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &minute_open_orders, &mut process_events, &mut minute_decision, &mut directive_report, + &mut result, + Some(minute_timestamp), )?; let pre_minute_execution_orders = self.open_order_views(); self.ensure_execution_quotes_for_decision( @@ -3090,6 +3268,8 @@ where Some(minute_time), Some(minute_time), )?; + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut minute_report, decision_date, execution_date); let post_minute_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, @@ -3105,6 +3285,7 @@ where &self.subscriptions, &mut process_events, &mut minute_report.process_events, + CallbackObservation::from_result(&result, Some(minute_timestamp)), )?; merge_broker_report(&mut report, minute_report); decision.notes.append(&mut minute_decision.notes); @@ -3130,6 +3311,7 @@ where execution_date, ProcessEventKind::PostMinute, format!("minute:{minute_timestamp}:post"), + CallbackObservation::from_result(&result, Some(minute_timestamp)), )?; // A scheduled strategy need not subscribe to every // minute to keep a DAY/GTC limit order alive. Fetch the @@ -3138,12 +3320,21 @@ where let mut newly_pending = self.broker.open_order_views().into_iter() .map(|order| order.symbol) .chain(self.broker.pending_stock_pool_symbols()) + .chain(self.subscriptions.iter().cloned()) .filter(|symbol| !full_minute_symbols.contains(symbol)) .collect::>(); if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() { full_minute_symbols.extend(newly_pending.iter().cloned()); if self.execution_quote_loader.is_some() { - self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?; + // A post-close order without a minute subscription + // needs only its declared matching session. Keep a + // full session for subscribers' historical queries. + let window = newly_pending.iter().all(|symbol| !self.subscriptions.contains(symbol)) + .then(|| self.broker.post_close_execution_quote_window_for_order( + execution_date, execution_date, Some(minute_time), + )).flatten(); + self.load_missing_execution_quotes(execution_date, + window.map(|window| window.0), window.map(|window| window.1), &mut newly_pending)?; } drop(minute_quotes); quote_data = self.data.clone(); @@ -3162,6 +3353,15 @@ where } self.broker.finish_stock_pool_session(execution_date, &mut report); + let post_close_end = self.broker.post_close_execution_quote_window_for_order( + execution_date, execution_date, last_execution_time, + ).map(|(_, end)| end); + let after_trading_time = default_stage_time(ScheduleStage::AfterTrading) + .into_iter().chain(last_execution_time).chain(post_close_end).max(); + let settlement_time = default_stage_time(ScheduleStage::Settlement) + .into_iter().chain(after_trading_time).max(); + Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); portfolio.update_prices_with_options( execution_date, @@ -3194,6 +3394,7 @@ where execution_date, ProcessEventKind::PreAfterTrading, "after_trading:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; self.strategy.after_trading(&StrategyContext { execution_date, @@ -3209,7 +3410,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::AfterTrading), + after_trading_time, ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), @@ -3230,6 +3431,7 @@ where execution_date, ProcessEventKind::AfterTrading, "after_trading", + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; let mut after_trading_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -3249,18 +3451,23 @@ where &mut self.process_event_bus, result.order_events.as_slice(), result.fills.as_slice(), + after_trading_time, + after_trading_time, )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &post_trade_open_orders, &mut process_events, &mut after_trading_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, after_trading_time), )?; let mut close_report = self.broker.after_trading(execution_date); + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut close_report, decision_date, execution_date); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -3275,6 +3482,7 @@ where &self.subscriptions, &mut process_events, &mut close_report.process_events, + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; report.order_events.extend(close_report.order_events); report.fill_events.extend(close_report.fill_events); @@ -3305,6 +3513,7 @@ where execution_date, ProcessEventKind::PostAfterTrading, "after_trading:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; publish_phase_event( &mut self.strategy, @@ -3322,6 +3531,7 @@ where execution_date, ProcessEventKind::PreSettlement, "settlement:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)), )?; self.strategy.on_settlement(&StrategyContext { execution_date, @@ -3337,7 +3547,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::Settlement), + settlement_time, ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), @@ -3358,6 +3568,7 @@ where execution_date, ProcessEventKind::Settlement, "settlement", + CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)), )?; let mut settlement_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -3377,21 +3588,32 @@ where &mut self.process_event_bus, result.order_events.as_slice(), result.fills.as_slice(), + settlement_time, + settlement_time, )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &post_close_open_orders, &mut process_events, &mut settlement_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, settlement_time), )?; let futures_daily_settlement_report = self.settle_futures_daily(execution_date); merge_broker_report(&mut directive_report, futures_daily_settlement_report); let futures_expiration_report = self.settle_futures_expirations(execution_date); merge_broker_report(&mut directive_report, futures_expiration_report); + Self::record_execution_history(&mut result,&mut directive_report,decision_date,execution_date); + if !directive_report.process_events.is_empty() { + let orders = self.open_order_views(); + publish_process_events(&mut self.strategy,&mut self.process_event_bus,execution_date,decision_date,decision_index, + &self.data,&portfolio,self.futures_account.as_ref(),&orders,self.dynamic_universe.as_ref(),&self.subscriptions, + &mut process_events,&mut directive_report.process_events, + CallbackObservation::from_result(&result,stage_datetime(execution_date,settlement_time)))?; + } let management_fee_report = if portfolio.management_fee_rate() <= 0.0 { BrokerExecutionReport::default() } else { @@ -3411,6 +3633,7 @@ where &mut process_events, result.order_events.as_slice(), result.fills.as_slice(), + settlement_time, )? }; merge_broker_report(&mut directive_report, management_fee_report); @@ -3430,6 +3653,7 @@ where execution_date, ProcessEventKind::PostSettlement, "settlement:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)), )?; merge_broker_report(&mut report, directive_report); broker_diagnostics.append(&mut report.diagnostics); @@ -3572,6 +3796,17 @@ where self.retain_process_events(&mut result.process_events, &mut report.process_events); } + fn record_execution_history( + result: &mut BacktestResult, + report: &mut BrokerExecutionReport, + decision_date: NaiveDate, + execution_date: NaiveDate, + ) { + annotate_broker_report_dates(report, decision_date, decision_date, execution_date); + result.order_events.append(&mut report.order_events); + result.fills.append(&mut report.fill_events); + } + fn retain_process_events( &self, target: &mut Vec, @@ -3980,6 +4215,7 @@ where process_events: &mut Vec, order_events: &[OrderEvent], fills: &[FillEvent], + callback_time: Option, ) -> Result { let rate = portfolio.management_fee_rate(); if rate <= 0.0 { @@ -4003,7 +4239,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::Settlement), + callback_time, ), order_events, fills, @@ -4052,6 +4288,7 @@ where portfolio.management_fees() ), }, + CallbackObservation { datetime: stage_datetime(execution_date, callback_time), orders: order_events, fills }, )?; Ok(report) } @@ -4292,6 +4529,7 @@ fn collect_scheduled_decisions( current_time: Option, order_events: &[OrderEvent], fills: &[FillEvent], + physical_time: Option, ) -> Result { let mut combined = crate::strategy::StrategyDecision::default(); // In lagged modes such as next_bar_open, scheduled callbacks generate @@ -4327,6 +4565,7 @@ fn collect_scheduled_decisions( distinct_timed_minute_count, "pre", ), + CallbackObservation { datetime: stage_datetime(execution_date, physical_time.or(current_time)), orders: order_events, fills }, )?; combined.merge_from(strategy.on_scheduled( &StrategyContext { @@ -4369,6 +4608,7 @@ fn collect_scheduled_decisions( distinct_timed_minute_count, "post", ), + CallbackObservation { datetime: stage_datetime(execution_date, physical_time.or(current_time)), orders: order_events, fills }, )?; } Ok(combined) @@ -4413,6 +4653,8 @@ fn collect_scheduled_decisions_for_stage( process_event_bus: &mut ProcessEventBus, order_events: &[OrderEvent], fills: &[FillEvent], + default_time_override: Option, + physical_time: Option, ) -> Result { let mut times = BTreeSet::new(); for rule in rules.iter().filter(|rule| rule.stage == stage) { @@ -4427,9 +4669,10 @@ fn collect_scheduled_decisions_for_stage( )) })?) } - Some(crate::scheduler::ScheduleTimeRule::BeforeTrading) | None => { + Some(crate::scheduler::ScheduleTimeRule::BeforeTrading) => { default_stage_time(stage) } + None => default_time_override.or_else(|| default_stage_time(stage)), }; times.insert(time); } @@ -4454,11 +4697,25 @@ fn collect_scheduled_decisions_for_stage( time, order_events, fills, + physical_time, )?); } Ok(combined) } +#[derive(Clone, Copy)] +struct CallbackObservation<'a> { + datetime: Option, + orders: &'a [OrderEvent], + fills: &'a [FillEvent], +} + +impl<'a> CallbackObservation<'a> { + fn from_result(result: &'a BacktestResult, datetime: Option) -> Self { + Self { datetime, orders: &result.order_events, fills: &result.fills } + } +} + fn publish_phase_event( strategy: &mut S, process_event_bus: &mut ProcessEventBus, @@ -4475,6 +4732,7 @@ fn publish_phase_event( date: NaiveDate, kind: ProcessEventKind, detail: impl Into, + observation: CallbackObservation<'_>, ) -> Result<(), BacktestError> { let event = ProcessEvent { date, @@ -4498,9 +4756,9 @@ fn publish_phase_event( subscriptions, process_events, active_process_event: Some(&event), - active_datetime: None, - order_events: &[], - fills: &[], + active_datetime: observation.datetime, + order_events: observation.orders, + fills: observation.fills, }; strategy.on_process_event(&event_ctx, &event)?; events.push(event); @@ -4521,6 +4779,7 @@ fn publish_process_events( subscriptions: &BTreeSet, target: &mut Vec, incoming: &mut Vec, + observation: CallbackObservation<'_>, ) -> Result<(), BacktestError> { for event in incoming.drain(..) { process_event_bus.publish(&event); @@ -4537,9 +4796,9 @@ fn publish_process_events( subscriptions, process_events, active_process_event: Some(&event), - active_datetime: None, - order_events: &[], - fills: &[], + active_datetime: observation.datetime, + order_events: observation.orders, + fills: observation.fills, }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); @@ -4561,6 +4820,7 @@ fn publish_custom_process_event( subscriptions: &BTreeSet, target: &mut Vec, event: ProcessEvent, + observation: CallbackObservation<'_>, ) -> Result<(), BacktestError> { process_event_bus.publish(&event); let process_events = target.as_slice(); @@ -4576,9 +4836,9 @@ fn publish_custom_process_event( subscriptions, process_events, active_process_event: Some(&event), - active_datetime: None, - order_events: &[], - fills: &[], + active_datetime: observation.datetime, + order_events: observation.orders, + fills: observation.fills, }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); @@ -6015,6 +6275,1127 @@ mod tests { ); } + #[test] + fn minute_observer_never_sees_a_later_fill_from_a_coarse_phase() { + struct ClockProbe { observed: Rc>> } + impl Strategy for ClockProbe { + fn name(&self) -> &str { "coarse-phase-clock-probe" } + fn initial_subscriptions(&self) -> BTreeSet { [SYMBOL.to_string()].into() } + fn open_auction(&mut self, _: &StrategyContext<'_>) -> Result { + Ok(StrategyDecision { order_intents:vec![OrderIntent::LimitShares { + symbol:SYMBOL.into(), quantity:100, limit_price:10.0, reason:"clock-fenced-limit".into(), + }], ..Default::default() }) + } + fn on_minute(&mut self, ctx:&StrategyContext<'_>, quote:&IntradayExecutionQuote) -> Result { + self.observed.borrow_mut().push((quote.timestamp.time(),ctx.portfolio.position(SYMBOL).map_or(0,|p|p.quantity))); + Ok(StrategyDecision::default()) + } + } + let date=d(2026,6,2); + let previous=d(2026,6,1); + for (delayed, late_window) in [(false,false),(true,false),(false,true)] { + let mut data=dataset_from_market_and_candidates(vec![market(previous,10.2,10.2),market(date,10.2,9.8)],vec![candidate(previous),candidate(date)]); + data.add_execution_quotes([(9,30,10.2),(10,0,10.2),(10,15,if late_window {10.2}else{9.8}),(13,0,9.8),(13,1,9.8)].map(|(h,m,price)| IntradayExecutionQuote { + observation_kind:Default::default(), date,symbol:SYMBOL.into(),timestamp:date.and_hms_opt(h,m,0).unwrap(),last_price:price,bid1:price,ask1:price, + bid1_volume:10_000,ask1_volume:10_000,volume_delta:10_000,amount_delta:price*10_000.,trading_phase:Some("continuous_auction".into()), + }).to_vec()); + let observed=Rc::new(RefCell::new(Vec::new())); + let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks) + .with_matching_type(if delayed {MatchingType::NextBarOpen}else{MatchingType::CurrentBarClose}) + .with_execution_price_field(if delayed {PriceField::Open}else{PriceField::Last}) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(if late_window {13}else{9},if late_window {0}else{30},0).unwrap()) + .with_volume_limit(false).with_liquidity_limit(false).with_inactive_limit(false); + let mut engine=BacktestEngine::new(data,ClockProbe{observed:observed.clone()},broker,BacktestConfig { + initial_cash:100_000.,benchmark_code:"000852.SH".into(),start_date:Some(if delayed {previous}else{date}),end_date:Some(date), + decision_lag_trading_days:usize::from(delayed),execution_price_field:if delayed {PriceField::Open}else{PriceField::Last}, + }); + let result=engine.run().unwrap(); + assert_eq!(result.fills.len(),1,"{delayed}: {:?}",result.order_events); + assert_eq!(result.fills[0].execution_timestamp,if late_window {date.and_hms_opt(13,0,0)}else{date.and_hms_opt(10,15,0)}); + let rows=observed.borrow(); + assert_eq!(rows.iter().find(|(time,_)|*time==NaiveTime::from_hms_opt(10,0,0).unwrap()).map(|(_,qty)|*qty),Some(0),"a 10:00 callback observed a future fill: delayed={delayed}, late_window={late_window}, observations={rows:?}"); + assert_eq!(rows.last().map(|(_,qty)|*qty),Some(100)); + } + } + + fn clock_probe_data(date: NaiveDate, ticks: &[(u32, u32, f64)]) -> DataSet { + let mut data = + dataset_from_market_and_candidates(vec![market(date, 10., 10.)], vec![candidate(date)]); + data.add_execution_quotes( + ticks + .iter() + .map(|&(h, m, price)| IntradayExecutionQuote { + observation_kind: Default::default(), + date, + symbol: SYMBOL.into(), + timestamp: date.and_hms_opt(h, m, 0).unwrap(), + last_price: price, + bid1: price, + ask1: price, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: price * 10_000., + trading_phase: Some( + if h == 15 && m >= 5 { + "post_close_fixed_price" + } else { + "continuous_auction" + } + .into(), + ), + }) + .collect(), + ); + data + } + + #[test] + fn late_day_callbacks_read_the_actual_earlier_trade_and_keep_their_current_clock() { + struct Probe { + observed: Rc>>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "late-day-after-early-action" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily("day-observer", ScheduleStage::OnDay)] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + self.observed.borrow_mut().push(( + "scheduled".into(), + ctx.current_time().unwrap(), + ctx.portfolio.position(SYMBOL).map_or(0, |p| p.quantity), + )); + Ok(StrategyDecision::default()) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + let qty = ctx.portfolio.position(SYMBOL).map_or(0, |p| p.quantity); + self.observed + .borrow_mut() + .push(("day".into(), ctx.current_time().unwrap(), qty)); + Ok(StrategyDecision { + order_intents: if qty == 0 { + vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "late-conditional-buy".into(), + }] + } else { + vec![] + }, + ..Default::default() + }) + } + fn on_minute( + &mut self, + _: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + Ok(StrategyDecision { + order_intents: if quote.timestamp.time() + == NaiveTime::from_hms_opt(10, 0, 0).unwrap() + { + vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "earlier-explicit-action".into(), + }] + } else { + vec![] + }, + ..Default::default() + }) + } + } + let date = d(2026, 6, 2); + let observed = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_execution_price_field(PriceField::Last) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(13, 0, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut engine = BacktestEngine::new( + clock_probe_data( + date, + &[(9, 30, 10.), (10, 0, 10.), (13, 0, 10.), (13, 1, 10.)], + ), + Probe { + observed: observed.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ); + let result = engine.run().unwrap(); + assert_eq!( + result.fills.len(), + 1, + "the original late condition must see the early actual position" + ); + assert_eq!( + result.fills[0].execution_timestamp, + date.and_hms_opt(10, 0, 0) + ); + assert_eq!( + observed.borrow().as_slice(), + &[ + ( + "day".into(), + NaiveTime::from_hms_opt(13, 0, 0).unwrap(), + 100 + ), + ( + "scheduled".into(), + NaiveTime::from_hms_opt(13, 0, 0).unwrap(), + 100 + ) + ] + ); + } + + #[test] + fn post_close_wait_has_no_position_before_the_first_actual_matching_tick() { + struct Probe { + observed: Rc>>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "post-close-clock" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + assert_eq!(ctx.current_time(), NaiveTime::from_hms_opt(15, 0, 0)); + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "post-close-action".into(), + }], + ..Default::default() + }) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + self.observed.borrow_mut().push(( + quote.timestamp.time(), + ctx.portfolio.position(SYMBOL).map_or(0, |p| p.quantity), + )); + Ok(StrategyDecision::default()) + } + } + let date = d(2026, 7, 6); + let observed = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut engine = BacktestEngine::new( + clock_probe_data( + date, + &[ + (14, 59, 10.), + (15, 0, 10.), + (15, 2, 10.), + (15, 5, 10.), + (15, 6, 10.), + ], + ), + Probe { + observed: observed.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ); + let result = engine.run().unwrap(); + assert_eq!(result.fills.len(), 1); + assert_eq!( + result.fills[0].execution_timestamp, + date.and_hms_opt(15, 5, 0) + ); + assert_eq!( + observed + .borrow() + .iter() + .find(|(time, _)| *time == NaiveTime::from_hms_opt(15, 2, 0).unwrap()) + .map(|(_, qty)| *qty), + Some(0) + ); + assert_eq!(observed.borrow().last().map(|(_, qty)| *qty), Some(100)); + } + + #[test] + fn window_algorithm_cannot_publish_future_fill_quantity_to_earlier_callbacks() { + struct Probe { + observed: Rc>>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "algo-clock" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + order_intents: vec![OrderIntent::AlgoValue { + symbol: SYMBOL.into(), + value: 10_000., + style: crate::strategy::AlgoOrderStyle::Twap, + start_time: NaiveTime::from_hms_opt(13, 0, 0), + end_time: NaiveTime::from_hms_opt(13, 5, 0), + reason: "window-algorithm".into(), + }], + ..Default::default() + }) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + self.observed.borrow_mut().push(( + quote.timestamp, + ctx.portfolio.position(SYMBOL).map_or(0, |p| p.quantity), + )); + Ok(StrategyDecision::default()) + } + } + let date = d(2026, 6, 2); + let observed = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_execution_price_field(PriceField::Last) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(13, 0, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut engine = BacktestEngine::new( + clock_probe_data( + date, + &[ + (12, 59, 10.), + (13, 0, 10.), + (13, 1, 10.1), + (13, 5, 10.2), + (13, 6, 10.2), + ], + ), + Probe { + observed: observed.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ); + let result = engine.run().unwrap(); + assert!(!result.fills.is_empty()); + for &(at, qty) in observed.borrow().iter() { + let completed = result + .fills + .iter() + .filter(|fill| fill.execution_timestamp.is_some_and(|time| time <= at)) + .map(|fill| fill.quantity) + .sum::(); + assert!( + qty <= completed, + "{at}: portfolio={qty} but only {completed} shares have a completed fill; fills={:?}", + result.fills + ); + } + } + + #[test] + fn default_close_phase_waits_for_close_and_callbacks_receive_completed_trade_history() { + struct Probe { + observed: Rc>>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "default-close-clock" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + assert_eq!(ctx.current_time(), NaiveTime::from_hms_opt(15, 0, 0)); + assert_eq!(ctx.fills.iter().map(|fill| fill.quantity).sum::(), 100); + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "ordinary-daily-close".into(), + }], + ..Default::default() + }) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + self.observed.borrow_mut().push(( + quote.timestamp.time(), + ctx.portfolio.position(SYMBOL).map_or(0, |p| p.quantity), + ctx.fills.len(), + )); + Ok(StrategyDecision { + order_intents: if quote.timestamp.time() + == NaiveTime::from_hms_opt(10, 0, 0).unwrap() + { + vec![OrderIntent::TimedTargetValue { + symbol: SYMBOL.into(), + target_value: 1_010., + style: crate::strategy::AlgoOrderStyle::Twap, + start_time: Some(quote.timestamp.time()), + end_time: Some(quote.timestamp.time()), + reason: "earlier-explicit-action".into(), + }] + } else { + vec![] + }, + ..Default::default() + }) + } + } + let date = d(2026, 7, 6); + let observed = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut engine = BacktestEngine::new( + clock_probe_data( + date, + &[(9, 30, 10.), (10, 0, 10.), (10, 1, 10.), (15, 0, 10.)], + ), + Probe { + observed: observed.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ); + let result = engine.run().unwrap(); + assert_eq!(result.fills.len(), 2); + assert_eq!( + result.fills[1].execution_timestamp, + date.and_hms_opt(15, 0, 0) + ); + assert_eq!(result.fills[1].reason, "ordinary-daily-close"); + assert_eq!( + observed + .borrow() + .iter() + .find(|(time, _, _)| *time == NaiveTime::from_hms_opt(10, 1, 0).unwrap()) + .map(|(_, qty, count)| (*qty, *count)), + Some((100, 1)) + ); + } + + #[test] + fn process_callbacks_observe_the_completed_minute_trade_and_its_clock() { + use crate::events::{ProcessEvent, ProcessEventKind}; + struct Probe { + observed: + Rc, u32, usize, usize)>>>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "process-callback-observation" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision::default()) + } + fn on_minute( + &mut self, + _: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + Ok(StrategyDecision { + order_intents: if quote.timestamp.time() + == NaiveTime::from_hms_opt(10, 0, 0).unwrap() + { + vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "callback-observed-buy".into(), + }] + } else { + vec![] + }, + ..Default::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if matches!( + event.kind, + ProcessEventKind::Trade | ProcessEventKind::PostMinute + ) { + self.observed.borrow_mut().push(( + event.kind, + ctx.current_datetime(), + ctx.portfolio + .position(SYMBOL) + .map_or(0, |position| position.quantity), + ctx.fills.len(), + ctx.order_events.len(), + )); + } + Ok(()) + } + } + let date = d(2026, 6, 2); + let observed = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut engine = BacktestEngine::new( + clock_probe_data(date, &[(9, 30, 10.), (10, 0, 10.), (10, 1, 10.)]), + Probe { + observed: observed.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ); + let result = engine.run().unwrap(); + assert_eq!(result.fills.len(), 1); + let rows = observed.borrow(); + assert_eq!( + rows.iter() + .filter(|row| row.0 == ProcessEventKind::PostMinute) + .map(|row| (row.1, row.2, row.3)) + .collect::>(), + vec![ + (date.and_hms_opt(9, 30, 0), 0, 0), + (date.and_hms_opt(10, 0, 0), 100, 1), + (date.and_hms_opt(10, 1, 0), 100, 1) + ] + ); + let trade = rows + .iter() + .find(|row| row.0 == ProcessEventKind::Trade) + .unwrap(); + assert_eq!( + (trade.1, trade.2, trade.3), + (date.and_hms_opt(10, 0, 0), 100, 1) + ); + assert!(trade.4 > 0); + } + + #[test] + fn post_close_phase_callbacks_never_rewind_before_completed_fills() { + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "post-close-process-clock" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + order_intents: vec![ + OrderIntent::SetManagementFeeRate { + rate: 0.01, + reason: "fee-clock".into(), + }, + OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "post-close".into(), + }, + ], + ..Default::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &crate::events::ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if let Some(fill_at) = ctx + .fills + .iter() + .filter_map(|fill| fill.execution_timestamp) + .max() + { + assert!( + ctx.current_datetime().is_some_and(|clock| clock >= fill_at), + "event {:?} at {:?} already sees a later fill at {fill_at}", + event.kind, + ctx.current_datetime() + ); + } + Ok(()) + } + } + let date = d(2026, 7, 6); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + clock_probe_data( + date, + &[(15, 0, 10.), (15, 2, 10.), (15, 5, 10.), (15, 6, 10.)], + ), + Probe, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 1); + } + + #[test] + fn pre_trading_orders_wait_for_the_configured_window_and_keep_their_audit() { + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "pre-trading-order-intent" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily( + "pre-market-signal", + ScheduleStage::BeforeTrading, + )] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + assert_eq!(ctx.current_time(), NaiveTime::from_hms_opt(9, 0, 0)); + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "pre-market-signal".into(), + }], + notes: vec!["pre-market-signal-created".into()], + diagnostics: vec!["pre-market-signal-audit".into()], + ..Default::default() + }) + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision::default()) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + if quote.timestamp.time() == NaiveTime::from_hms_opt(9, 15, 0).unwrap() { + assert_eq!( + ctx.portfolio + .position(SYMBOL) + .map_or(0, |position| position.quantity), + 0 + ); + assert!(ctx.fills.is_empty()); + } + Ok(StrategyDecision::default()) + } + } + let date = d(2026, 6, 2); + for window in [ + NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + NaiveTime::from_hms_opt(13, 0, 0).unwrap(), + ] { + let broker = + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(window) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + clock_probe_data(date, &[(9, 15, 10.), (9, 30, 10.), (13, 0, 10.)]), + Probe, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 1); + assert_eq!( + result.fills[0].execution_timestamp, + Some(date.and_time(window)) + ); + assert_eq!(result.fills[0].quantity, 100); + assert_eq!(result.fills[0].reason, "pre-market-signal"); + assert!( + result.equity_curve[0] + .notes + .contains("pre-market-signal-created") + ); + assert!( + result.equity_curve[0] + .diagnostics + .contains("pre-market-signal-audit") + ); + } + } + + #[test] + fn lagged_signal_callbacks_keep_signal_date_but_process_notifications_use_execution_date() { + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "lagged-callback-clocks" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("signal", ScheduleStage::OnDay), + ScheduleRule::daily("after", ScheduleStage::AfterTrading), + ] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + assert_eq!(ctx.current_datetime().unwrap().date(), ctx.decision_date); + Ok(StrategyDecision::default()) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + assert_eq!(ctx.current_datetime().unwrap().date(), ctx.decision_date); + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "lagged-signal".into(), + }], + ..Default::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &crate::events::ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if !ctx.fills.is_empty() { + assert_eq!( + ctx.current_datetime().map(|time| time.date()), + Some(ctx.execution_date), + "event {:?} mixed signal and account observation dates", + event.kind + ); + let latest = ctx + .fills + .iter() + .filter_map(|fill| fill.execution_timestamp) + .max() + .unwrap(); + assert!( + ctx.current_datetime().unwrap() >= latest, + "{:?}", + event.kind + ); + } + Ok(()) + } + } + let prior = d(2026, 6, 1); + let date = d(2026, 6, 2); + let mut data = dataset_from_market_and_candidates( + vec![market(prior, 10., 10.), market(date, 10., 10.)], + vec![candidate(prior), candidate(date)], + ); + data.add_execution_quotes( + clock_probe_data(date, &[(9, 30, 10.), (9, 31, 10.)]) + .snapshot_components() + .execution_quotes, + ); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::NextBarOpen) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + data, + Probe, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(prior), + end_date: Some(date), + decision_lag_trading_days: 1, + execution_price_field: PriceField::Open, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].decision_date, Some(prior)); + assert_eq!(result.fills[0].execution_date, Some(date)); + } + + #[test] + fn pre_market_cancel_is_observed_before_the_next_open_and_new_signal() { + struct Probe { + first: NaiveDate, + second: NaiveDate, + cancelled: Rc>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "pre-market-cancel-before-match" + } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily( + "pre-market", + ScheduleStage::BeforeTrading, + )] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + Ok(if ctx.execution_date == self.second { + StrategyDecision { + order_intents: vec![ + OrderIntent::CancelOrder { + order_id: 1, + reason: "pre-market-cancel".into(), + }, + OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "new-pre-market-signal".into(), + }, + ], + ..Default::default() + } + } else { + StrategyDecision::default() + }) + } + fn open_auction( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date == self.second { + assert!(ctx.open_orders.is_empty()); + assert!(ctx.fills.is_empty()); + } + Ok(StrategyDecision::default()) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + Ok(if ctx.execution_date == self.first { + StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: SYMBOL.into(), + quantity: 100, + limit_price: 9.5, + reason: "original-resting-order".into(), + } + .with_time_in_force(crate::strategy::OrderTimeInForce::Gtc), + ], + ..Default::default() + } + } else { + StrategyDecision::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &crate::events::ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if event.kind == crate::events::ProcessEventKind::OrderCancellationPass { + assert_eq!(ctx.current_datetime(), self.second.and_hms_opt(9, 0, 0)); + assert!(ctx.open_orders.is_empty()); + assert!(ctx.fills.is_empty()); + assert!( + ctx.order_events + .iter() + .any(|order| order.order_id == Some(1) + && order.status == OrderStatus::Canceled) + ); + *self.cancelled.borrow_mut() = true; + } + Ok(()) + } + } + let first = d(2026, 6, 1); + let second = d(2026, 6, 2); + let cancelled = Rc::new(RefCell::new(false)); + let mut data = dataset_from_market_and_candidates( + vec![market(first, 10., 10.), market(second, 9.4, 9.4)], + vec![candidate(first), candidate(second)], + ); + for (date, price) in [(first, 10.), (second, 9.4)] { + data.add_execution_quotes( + clock_probe_data(date, &[(9, 30, price)]) + .snapshot_components() + .execution_quotes, + ); + } + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + data, + Probe { + first, + second, + cancelled: cancelled.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(first), + end_date: Some(second), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .run() + .unwrap(); + assert!(*cancelled.borrow()); + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].order_id, Some(2)); + assert_eq!(result.fills[0].quantity, 100); + assert_eq!( + result.fills[0].execution_timestamp, + second.and_hms_opt(9, 30, 0) + ); + } + + #[test] + fn latest_unsent_complete_portfolio_does_not_union_an_older_pre_market_target() { + const LATEST: &str = "000002.SZ"; + struct Probe(u8); + impl Strategy for Probe { + fn name(&self) -> &str { + "latest-complete-target" + } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily( + "pre-market", + ScheduleStage::BeforeTrading, + )] + } + fn on_scheduled( + &mut self, + _: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + Ok(StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([(SYMBOL.into(), 1.)]), + ..Default::default() + }) + } + fn open_auction( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([(LATEST.into(), 1.)]), + buy_denials: BTreeMap::from([(SYMBOL.into(), "old_auction_condition".into())]), + ..Default::default() + }) + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(match self.0 { + 0 => StrategyDecision::default(), + 1 => StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([(SYMBOL.into(), 1.)]), + ..Default::default() + }, + _ => StrategyDecision { + rebalance: true, + ..Default::default() + }, + }) + } + } + let date = d(2026, 6, 2); + let mut rows = clock_probe_data(date, &[(9, 30, 10.)]).snapshot_components(); + let mut instrument = rows.instruments[0].clone(); + instrument.symbol = LATEST.into(); + rows.instruments.push(instrument); + let mut snapshot = rows.market[0].clone(); + snapshot.symbol = LATEST.into(); + rows.market.push(snapshot); + let mut candidate = rows.candidates[0].clone(); + candidate.symbol = LATEST.into(); + rows.candidates.push(candidate); + let mut quote = rows.execution_quotes[0].clone(); + quote.symbol = LATEST.into(); + rows.execution_quotes.push(quote); + let data = DataSet::from_components_with_actions_and_quotes( + rows.instruments, + rows.market, + rows.factors, + rows.candidates, + rows.benchmarks, + rows.corporate_actions, + rows.execution_quotes, + ) + .unwrap(); + for mode in [0, 1, 2] { + let broker = + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + data.clone(), + Probe(mode), + broker, + BacktestConfig { + initial_cash: 10_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .run() + .unwrap(); + if mode == 2 { + assert!(result.fills.is_empty()); + assert!(result.holdings_summary.is_empty()); + } else { + let expected = if mode == 0 { LATEST } else { SYMBOL }; + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].symbol, expected); + assert_eq!(result.holdings_summary.len(), 1); + assert_eq!(result.holdings_summary[0].symbol, expected); + } + } + } + #[test] fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() { let date = d(2026, 7, 6); @@ -6084,7 +7465,7 @@ mod tests { &[( NaiveTime::from_hms_opt(15, 5, 0), NaiveTime::from_hms_opt(15, 30, 0), - )] + )], "fills={:?}; orders={:?}", result.fills, result.order_events ); assert_eq!(result.fills.len(), 1, "{result:?}"); assert_eq!(result.fills[0].price, 10.0); diff --git a/crates/fidc-core/src/fixed_point.rs b/crates/fidc-core/src/fixed_point.rs index ff309a0..2169ad6 100644 --- a/crates/fidc-core/src/fixed_point.rs +++ b/crates/fidc-core/src/fixed_point.rs @@ -28,6 +28,17 @@ impl FixedMoney { self.0 } + pub fn to_decimal_string(self) -> String { + let magnitude = self.0.unsigned_abs(); + let scale = MONEY_SCALE as u128; + let sign = if self.0 < 0 { "-" } else { "" }; + let width = MONEY_SCALE.ilog10() as usize; + format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } + pub fn from_decimal_str(value: &str) -> Result { let value = value.trim(); if value.is_empty() { diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 274eeeb..c3d8a59 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod fixed_point; pub mod futures; pub mod instrument; pub mod metrics; +pub mod manual_execution; mod numeric_expr_vm; pub mod platform_expr_strategy; pub mod platform_runtime_schema; diff --git a/crates/fidc-core/src/manual_execution.rs b/crates/fidc-core/src/manual_execution.rs new file mode 100644 index 0000000..733f820 --- /dev/null +++ b/crates/fidc-core/src/manual_execution.rs @@ -0,0 +1,534 @@ +//! Confirmed manual fills are external observations, not simulated broker fills. +//! The producer must bind these records to the runtime's durable order/audit facts. + +use std::collections::BTreeSet; + +use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::events::OrderSide; +use crate::{DataSet, FixedMoney, PortfolioState}; +use rust_decimal::prelude::ToPrimitive; + +pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionReplay { + pub schema: String, + pub runtime_id: String, + pub account_id: String, + pub source_contract_sha256: String, + pub content_sha256: String, + pub observation_cutoff: DateTime, + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionAction { + pub action_id: String, + pub source: ManualExecutionSource, + pub audit_event_ids: Vec, + pub confirmed_at: DateTime, + pub outcome: ManualActionOutcome, + pub orders: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManualActionOutcome { + NoOrdersNeeded, + OrdersTerminal, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManualExecutionSource { + ManualSecurityTrade, + ManualPositionAction, + ManualRebalance, + StockPoolAllocation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionOrder { + pub order_id: String, + pub broker_order_id: Option, + pub source_adapter: String, + pub symbol: String, + pub side: OrderSide, + pub quantity: u32, + pub submitted_at: DateTime, + pub terminal_at: DateTime, + pub terminal_status: ManualOrderTerminalStatus, + pub fills: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManualOrderTerminalStatus { + Filled, + Cancelled, + Rejected, + Expired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionFill { + pub trade_id: String, + pub observation_event_id: String, + pub observation_sequence: u64, + pub trade_date: NaiveDate, + pub executed_at: DateTime, + pub observed_at: DateTime, + pub timestamp_precision: ManualTimestampPrecision, + pub quantity: u32, + #[serde(with = "rust_decimal::serde::str")] + pub price: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub commission: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub stamp_tax: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub transfer_fee: Decimal, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManualTimestampPrecision { + Second, + Millisecond, + Microsecond, + Nanosecond, +} + +impl ManualTimestampPrecision { + fn nanoseconds(self) -> i64 { + match self { + Self::Second => 1_000_000_000, + Self::Millisecond => 1_000_000, + Self::Microsecond => 1_000, + Self::Nanosecond => 1, + } + } +} + +impl ManualExecutionFill { + pub fn gross_amount(&self) -> Result { + self.price + .checked_mul(Decimal::from(self.quantity)) + .ok_or_else(|| "manual fill gross amount overflow".into()) + } + + pub fn total_fees(&self) -> Result { + self.commission + .checked_add(self.stamp_tax) + .and_then(|sum| sum.checked_add(self.transfer_fee)) + .ok_or_else(|| "manual fill fees overflow".into()) + } +} + +fn identifier(value: &str) -> Result<(), String> { + if value.is_empty() + || value.trim() != value + || value.len() > 256 + || value.chars().any(char::is_control) + { + return Err("manual execution identity is empty, untrimmed or invalid".into()); + } + Ok(()) +} + +impl ManualExecutionReplay { + pub fn observations(&self) -> Result>, String> { + self.validate()?; + let mut observations = Vec::new(); + for action in &self.actions { + for order in &action.orders { + for fill in &order.fills { + observations.push(ManualFillObservation { + action, + order, + fill, + }); + } + } + } + observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence)); + Ok(observations) + } + pub fn content_digest(&self) -> Result { + let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?; + value + .as_object_mut() + .ok_or("manual replay is not an object")? + .remove("contentSha256"); + let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?; + Ok(format!("{:x}", Sha256::digest(bytes))) + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema != MANUAL_REPLAY_SCHEMA { + return Err("unsupported manual replay schema".into()); + } + identifier(&self.runtime_id)?; + identifier(&self.account_id)?; + if self.source_contract_sha256.len() != 64 + || !self + .source_contract_sha256 + .bytes() + .all(|v| v.is_ascii_hexdigit()) + { + return Err("manual replay source contract hash is invalid".into()); + } + if self.content_digest()? != self.content_sha256 { + return Err("manual replay content digest mismatch".into()); + } + if self.actions.len() > 100_000 { + return Err("manual replay action limit exceeded; trace was not truncated".into()); + } + let shanghai = FixedOffset::east_opt(8 * 3600).unwrap(); + let mut actions = BTreeSet::new(); + let mut audits = BTreeSet::new(); + let mut orders = BTreeSet::new(); + let mut broker_orders = BTreeSet::new(); + let mut trades = BTreeSet::new(); + let mut observation_events = BTreeSet::new(); + let mut observation_sequences = BTreeSet::new(); + for action in &self.actions { + identifier(&action.action_id)?; + if !actions.insert(action.action_id.as_str()) + || action.confirmed_at > self.observation_cutoff + { + return Err("duplicate manual action or confirmation after cutoff".into()); + } + if action.audit_event_ids.is_empty() { + return Err("manual action has no immutable audit binding".into()); + } + if (action.outcome == ManualActionOutcome::NoOrdersNeeded) != action.orders.is_empty() { + return Err("manual action outcome does not prove its order coverage".into()); + } + for id in &action.audit_event_ids { + identifier(id)?; + if !audits.insert(id.as_str()) { + return Err("manual audit event is bound more than once".into()); + } + } + for order in &action.orders { + identifier(&order.order_id)?; + identifier(&order.source_adapter)?; + identifier(&order.symbol)?; + if let Some(id) = &order.broker_order_id { + identifier(id)?; + if !broker_orders.insert(( + order.source_adapter.as_str(), + order.submitted_at.with_timezone(&shanghai).date_naive(), + id.as_str(), + )) { + return Err("manual local orders share one broker order identity".into()); + } + } + if !order.fills.is_empty() + && order.source_adapter != "paper" + && order.broker_order_id.is_none() + { + return Err( + "manual broker fills require their original broker order identity".into(), + ); + } + if !orders.insert(order.order_id.as_str()) + || order.quantity == 0 + || order.quantity > i32::MAX as u32 + { + return Err("duplicate manual order or invalid quantity".into()); + } + if order.submitted_at < action.confirmed_at + || order.terminal_at < order.submitted_at + || order.terminal_at > self.observation_cutoff + { + return Err( + "manual order confirmation/submission/terminal time is inconsistent".into(), + ); + } + let mut filled = 0_u32; + for fill in &order.fills { + identifier(&fill.trade_id)?; + identifier(&fill.observation_event_id)?; + if fill.observation_sequence == 0 + || fill.observation_sequence > i64::MAX as u64 + || !observation_events.insert(fill.observation_event_id.as_str()) + || !observation_sequences.insert(fill.observation_sequence) + { + return Err( + "manual fill requires a unique durable observation event and sequence" + .into(), + ); + } + if !trades.insert((fill.trade_date, fill.trade_id.as_str())) + || fill.quantity == 0 + { + return Err("duplicate manual trade or zero fill quantity".into()); + } + if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date + || fill.observed_at > self.observation_cutoff + || fill.observed_at < order.submitted_at + || fill.observed_at < fill.executed_at + || fill.executed_at > order.terminal_at + { + return Err("manual fill execution/observation time is inconsistent".into()); + } + if i64::from(fill.executed_at.nanosecond()) + % fill.timestamp_precision.nanoseconds() + != 0 + { + return Err( + "broker timestamp contains digits finer than its declared precision" + .into(), + ); + } + let upper = fill + .executed_at + .checked_add_signed(chrono::Duration::nanoseconds( + fill.timestamp_precision.nanoseconds(), + )) + .ok_or("manual execution timestamp overflow")?; + if fill.executed_at < order.submitted_at && order.submitted_at >= upper { + return Err("manual fill predates its submitted order".into()); + } + if fill.price <= Decimal::ZERO + || [fill.commission, fill.stamp_tax, fill.transfer_fee] + .iter() + .any(|fee| *fee < Decimal::ZERO) + { + return Err( + "manual fill requires a positive price and complete nonnegative fees" + .into(), + ); + } + fill.gross_amount()? + .checked_add(fill.total_fees()?) + .ok_or("manual fill cash amount overflow")?; + filled = filled + .checked_add(fill.quantity) + .ok_or("manual cumulative fill quantity overflow")?; + } + if filled > order.quantity + || (order.terminal_status == ManualOrderTerminalStatus::Filled + && filled != order.quantity) + || (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0) + || (matches!( + order.terminal_status, + ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired + ) && filled == order.quantity) + { + return Err("manual terminal status disagrees with cumulative fills".into()); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ManualFillObservation<'a> { + pub action: &'a ManualExecutionAction, + pub order: &'a ManualExecutionOrder, + pub fill: &'a ManualExecutionFill, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AppliedManualFill { + pub gross: FixedMoney, + pub fees: FixedMoney, + pub cash_delta: FixedMoney, + pub quantity_after: u32, +} + +/// One replay owns its immutable trace and progress. Advancing is atomic even +/// if a later receipt in the same step disagrees with the shadow account. +pub struct ManualReplayCursor { + replay: ManualExecutionReplay, + indices: Vec<(usize, usize, usize)>, + cursor: usize, + clock: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ManualReplayApplication { + pub action_id: String, + pub order_id: String, + pub trade_id: String, + pub observation_event_id: String, + pub observation_sequence: u64, + pub observed_at: DateTime, + pub executed_at: DateTime, + pub symbol: String, + pub side: OrderSide, + pub quantity: u32, + pub quantity_after: u32, + pub price: String, + pub commission: String, + pub stamp_tax: String, + pub transfer_fee: String, + pub source_gross_amount: String, + pub ledger_gross_amount: String, + pub ledger_fees: String, + pub cash_delta: String, +} + +impl ManualReplayCursor { + pub fn new(replay: ManualExecutionReplay) -> Result { + replay.validate()?; + let mut indices = Vec::new(); + for (a, action) in replay.actions.iter().enumerate() { + for (o, order) in action.orders.iter().enumerate() { + for f in 0..order.fills.len() { + indices.push((a, o, f)); + } + } + } + indices.sort_by_key(|&(a, o, f)| { + let fill = &replay.actions[a].orders[o].fills[f]; + (fill.observed_at, fill.observation_sequence) + }); + Ok(Self { + replay, + indices, + cursor: 0, + clock: None, + }) + } + + pub fn next_observation_at(&self) -> Option> { + self.indices + .get(self.cursor) + .map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at) + } + + pub fn applied_count(&self) -> usize { + self.cursor + } + + pub fn advance( + &mut self, + at: DateTime, + portfolio: &mut PortfolioState, + data: &DataSet, + has_pending_orders: bool, + ) -> Result, String> { + if at > self.replay.observation_cutoff { + return Err("manual observation clock exceeds the frozen evidence cutoff".into()); + } + if self.clock.is_some_and(|clock| at < clock) { + return Err("manual observation clock moved backwards".into()); + } + let end = self.cursor + + self.indices[self.cursor..] + .iter() + .take_while(|&&(a, o, f)| { + self.replay.actions[a].orders[o].fills[f].observed_at <= at + }) + .count(); + if end == self.cursor { + self.clock = Some(at); + return Ok(vec![]); + } + let mut next = portfolio.clone(); + let mut applications = Vec::with_capacity(end - self.cursor); + for &(a, o, f) in &self.indices[self.cursor..end] { + let action = &self.replay.actions[a]; + let order = &action.orders[o]; + let fill = &order.fills[f]; + let applied = ManualFillObservation { + action, + order, + fill, + } + .apply(&mut next, data, has_pending_orders)?; + applications.push(ManualReplayApplication { + action_id: action.action_id.clone(), + order_id: order.order_id.clone(), + trade_id: fill.trade_id.clone(), + observation_event_id: fill.observation_event_id.clone(), + observation_sequence: fill.observation_sequence, + observed_at: fill.observed_at, + executed_at: fill.executed_at, + symbol: order.symbol.clone(), + side: order.side, + quantity: fill.quantity, + quantity_after: applied.quantity_after, + price: fill.price.to_string(), + commission: fill.commission.to_string(), + stamp_tax: fill.stamp_tax.to_string(), + transfer_fee: fill.transfer_fee.to_string(), + source_gross_amount: fill.gross_amount()?.to_string(), + ledger_gross_amount: applied.gross.to_decimal_string(), + ledger_fees: applied.fees.to_decimal_string(), + cash_delta: applied.cash_delta.to_decimal_string(), + }); + } + *portfolio = next; + self.cursor = end; + self.clock = Some(at); + Ok(applications) + } +} + +impl ManualFillObservation<'_> { + pub(crate) fn apply( + &self, + portfolio: &mut PortfolioState, + data: &DataSet, + has_pending_orders: bool, + ) -> Result { + if has_pending_orders { + return Err("manual observation conflicts with pending shadow orders".into()); + } + let instrument = data + .instrument(&self.order.symbol) + .ok_or("manual observation instrument is absent from frozen source data")?; + if instrument + .dated_market_absence_reason(self.fill.trade_date) + .is_some() + { + return Err("manual execution contradicts the frozen instrument lifecycle".into()); + } + let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?; + let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?; + let price = self + .fill + .price + .to_f64() + .filter(|price| price.is_finite() && *price > 0.) + .ok_or("manual execution price cannot be represented for valuation")?; + // This is the real observed trade price, not a fabricated quote. The + // normal market clock remains responsible for subsequent marks. + let cash_delta = portfolio.apply_observed_manual_fill( + self.fill.trade_date, + &self.order.symbol, + self.order.side, + self.fill.quantity, + price, + price, + gross, + fees, + )?; + Ok(AppliedManualFill { + gross, + fees, + cash_delta, + quantity_after: portfolio + .position(&self.order.symbol) + .map_or(0, |position| position.quantity), + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/fidc-core/src/manual_execution/tests.rs b/crates/fidc-core/src/manual_execution/tests.rs new file mode 100644 index 0000000..0bdb5a2 --- /dev/null +++ b/crates/fidc-core/src/manual_execution/tests.rs @@ -0,0 +1,416 @@ +use super::*; +use serde_json::{Value, json}; + +fn sample() -> ManualExecutionReplay { + let mut input:ManualExecutionReplay=serde_json::from_value(json!({ + "schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1", + "sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z", + "actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"], + "confirmedAt":"2026-09-14T01:30:00.500Z","outcome":"orders_terminal","orders":[{ + "orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100, + "submittedAt":"2026-09-14T01:30:00.600Z","terminalAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled", + "fills":[{"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z", + "observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100, + "price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02"}] + }]}] + })).unwrap(); + reseal(&mut input); + input +} + +fn reseal(input: &mut ManualExecutionReplay) { + input.content_sha256 = input.content_digest().unwrap(); +} +fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> { + let mut input = input.clone(); + reseal(&mut input); + input.validate() +} + +#[test] +fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() { + let input = sample(); + input.validate().unwrap(); + let fill = &input.actions[0].orders[0].fills[0]; + assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100"); + assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001"); + assert_eq!( + serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"], + "10.1234567891" + ); +} + +#[test] +fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() { + let original = serde_json::to_value(sample()).unwrap(); + for field in ["price", "commission", "stampTax", "transferFee"] { + let mut missing = original.clone(); + missing["actions"][0]["orders"][0]["fills"][0] + .as_object_mut() + .unwrap() + .remove(field); + assert!( + serde_json::from_value::(missing).is_err(), + "{field}" + ); + let mut numeric = original.clone(); + numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1); + assert!( + serde_json::from_value::(numeric).is_err(), + "numeric {field}" + ); + } + for mutate in [ + ("schema", json!("unknown")), + ("sourceContractSha256", json!("broken")), + ("accountId", json!(" ")), + ] { + let mut value = original.clone(); + value[mutate.0] = mutate.1; + assert!( + semantic_result(&serde_json::from_value::(value).unwrap()) + .is_err() + ); + } +} + +#[test] +fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() { + let original = sample(); + let mut invalid = original.clone(); + invalid.actions[0].orders[0].quantity = 200; + assert!(semantic_result(&invalid).is_err()); + let mut invalid = original.clone(); + invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected; + assert!(semantic_result(&invalid).is_err()); + let mut invalid = original.clone(); + invalid.actions[0].audit_event_ids.clear(); + assert!(semantic_result(&invalid).is_err()); + let mut invalid = original.clone(); + invalid.actions.push(invalid.actions[0].clone()); + assert!(semantic_result(&invalid).is_err()); + let mut invalid = original.clone(); + let duplicate = invalid.actions[0].orders[0].fills[0].clone(); + invalid.actions[0].orders[0].fills.push(duplicate); + assert!(semantic_result(&invalid).is_err()); + let mut invalid = original.clone(); + invalid.actions[0].orders[0].broker_order_id = None; + assert!(semantic_result(&invalid).is_err()); + invalid.actions[0].orders[0].source_adapter = "paper".into(); + reseal(&mut invalid); + invalid.validate().unwrap(); +} + +#[test] +fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() { + let mut input = sample(); + input.actions[0].orders[0].submitted_at = "2026-09-14T01:30:00.999999Z".parse().unwrap(); + input.actions[0].orders[0].terminal_at = "2026-09-14T01:30:01.500Z".parse().unwrap(); + input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap(); + reseal(&mut input); + input.validate().unwrap(); + input.actions[0].orders[0].submitted_at = "2026-09-14T01:30:01Z".parse().unwrap(); + assert!(semantic_result(&input).is_err()); + let mut input = sample(); + input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap(); + assert!(semantic_result(&input).is_err()); + input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond; + reseal(&mut input); + input.validate().unwrap(); + input.actions[0].orders[0].fills[0].executed_at = + "2026-09-14T01:30:00.800001Z".parse().unwrap(); + assert!(semantic_result(&input).is_err()); +} + +#[test] +fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() { + let mut input = sample(); + input.actions[0].orders.clear(); + assert!(semantic_result(&input).is_err()); + input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded; + reseal(&mut input); + input.validate().unwrap(); + let mut value = serde_json::to_value(input).unwrap(); + value["actions"][0]["outcome"] = json!("result_unknown"); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn raw_timezone_and_cutoff_are_required() { + let mut value = serde_json::to_value(sample()).unwrap(); + value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00"); + assert!(serde_json::from_value::(value).is_err()); + let mut input = sample(); + input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap(); + assert!(semantic_result(&input).is_err()); + let mut value = serde_json::to_value(sample()).unwrap(); + value["actions"][0]["orders"][0]["fills"][0]["commission"] = Value::Null; + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() { + let input = sample(); + let original = input.content_sha256.clone(); + let mut changed = input.clone(); + changed.actions[0].orders[0].fills[0].price += Decimal::ONE; + assert_ne!(changed.content_digest().unwrap(), original); + assert_eq!( + changed.validate().unwrap_err(), + "manual replay content digest mismatch" + ); + let mut changed = input; + changed.account_id = "another-account".into(); + assert_ne!(changed.content_digest().unwrap(), original); + assert!(changed.validate().is_err()); +} + +fn identity_data(listed: NaiveDate) -> DataSet { + DataSet::from_components( + vec![crate::Instrument { + symbol: "000001.SZ".into(), + name: "test".into(), + board: "SZ".into(), + round_lot: 100, + listed_at: Some(listed), + delisted_at: None, + status: "active".into(), + }], + vec![], + vec![], + vec![], + vec![crate::BenchmarkSnapshot { + date: listed, + benchmark: "000300.SH".into(), + open: 100., + close: 100., + prev_close: 100., + volume: 0, + }], + ) + .unwrap() +} + +#[test] +fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() { + let input = sample(); + let observations = input.observations().unwrap(); + let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + let mut account = PortfolioState::new(10_000.); + let applied = observations[0].apply(&mut account, &data, false).unwrap(); + assert_eq!( + applied.gross, + FixedMoney::from_decimal_str("1012.345679").unwrap() + ); + assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap()); + assert_eq!(account.cash(), 8987.534321); + assert_eq!(account.position("000001.SZ").unwrap().quantity, 100); + assert_eq!( + account + .position("000001.SZ") + .unwrap() + .sellable_qty(input.actions[0].orders[0].fills[0].trade_date), + 0 + ); + assert_eq!(account.external_cash_flow_total(), 0.); + assert_eq!(account.starting_cash(), 10_000.); +} + +#[test] +fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() { + let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + let input = sample(); + let observations = input.observations().unwrap(); + let mut poor = PortfolioState::new(10.); + assert!(observations[0].apply(&mut poor, &data, false).is_err()); + assert_eq!(poor.cash(), 10.); + assert!(poor.positions().is_empty()); + let mut account = PortfolioState::new(10_000.); + assert!(observations[0].apply(&mut account, &data, true).is_err()); + assert_eq!(account.cash(), 10_000.); + assert!(account.positions().is_empty()); + observations[0].apply(&mut account, &data, false).unwrap(); + let before = account.cash(); + let mut sell = input.clone(); + sell.actions[0].orders[0].side = OrderSide::Sell; + reseal(&mut sell); + assert!( + sell.observations().unwrap()[0] + .apply(&mut account, &data, false) + .unwrap_err() + .contains("T+1") + ); + assert_eq!(account.cash(), before); + assert_eq!(account.position("000001.SZ").unwrap().quantity, 100); + let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap()); + assert!( + observations[0] + .apply(&mut account, &unlisted, false) + .unwrap_err() + .contains("lifecycle") + ); + assert_eq!(account.cash(), before); +} + +#[test] +fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() { + let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + let input = sample(); + let mut account = PortfolioState::new(10_000.); + input.observations().unwrap()[0] + .apply(&mut account, &data, false) + .unwrap(); + let mut sell = input.clone(); + let order = &mut sell.actions[0].orders[0]; + order.side = OrderSide::Sell; + order.submitted_at += chrono::Duration::days(1); + order.terminal_at += chrono::Duration::days(1); + order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap(); + order.fills[0].executed_at += chrono::Duration::days(1); + order.fills[0].observed_at += chrono::Duration::days(1); + sell.observation_cutoff += chrono::Duration::days(1); + reseal(&mut sell); + let applied = sell.observations().unwrap()[0] + .apply(&mut account, &data, false) + .unwrap(); + assert_eq!(applied.quantity_after, 0); + assert_eq!(account.cash(), 9999.76); + assert_eq!(account.external_cash_flow_total(), 0.); +} + +#[test] +fn observations_follow_durable_receipt_order_and_not_input_array_order() { + let mut input = sample(); + let mut second = input.actions[0].orders[0].fills[0].clone(); + second.trade_id = "trade-2".into(); + second.observation_event_id = "received-2".into(); + second.observation_sequence = 2; + input.actions[0].orders[0].quantity = 200; + input.actions[0].orders[0].fills.insert(0, second); + reseal(&mut input); + assert_eq!( + input + .observations() + .unwrap() + .iter() + .map(|row| row.fill.observation_sequence) + .collect::>(), + vec![1, 2] + ); + let mut invalid = input.clone(); + invalid.actions[0].orders[0].fills[0].observation_sequence = 1; + assert!( + semantic_result(&invalid) + .unwrap_err() + .contains("observation") + ); + let mut invalid = input; + invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into(); + assert!( + semantic_result(&invalid) + .unwrap_err() + .contains("observation") + ); +} + +#[test] +fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() { + let mut input = sample(); + input.actions[0].orders[0].quantity = 200; + input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled; + semantic_result(&input).unwrap(); + input.actions[0].orders[0].quantity = 100; + assert!( + semantic_result(&input) + .unwrap_err() + .contains("terminal status") + ); +} + +#[test] +fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() { + let input = sample(); + let at = input.actions[0].orders[0].fills[0].observed_at; + let mut replay = ManualReplayCursor::new(input).unwrap(); + let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + let mut account = PortfolioState::new(10_000.); + assert_eq!(replay.next_observation_at(), Some(at)); + assert!( + replay + .advance( + at - chrono::Duration::milliseconds(1), + &mut account, + &data, + false + ) + .unwrap() + .is_empty() + ); + assert_eq!(account.cash(), 10_000.); + let records = replay.advance(at, &mut account, &data, false).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].cash_delta, "-1012.465679"); + assert_eq!(replay.applied_count(), 1); + assert_eq!(replay.next_observation_at(), None); + let cash = account.cash(); + assert!( + replay + .advance(at, &mut account, &data, false) + .unwrap() + .is_empty() + ); + assert_eq!(account.cash(), cash); + assert!( + replay + .advance( + at - chrono::Duration::seconds(1), + &mut account, + &data, + false + ) + .unwrap_err() + .contains("backwards") + ); +} + +#[test] +fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() { + let mut input = sample(); + let mut next = input.actions[0].orders[0].fills[0].clone(); + next.trade_id = "trade-2".into(); + next.observation_event_id = "received-2".into(); + next.observation_sequence = 2; + input.actions[0].orders[0].quantity = 200; + input.actions[0].orders[0].fills.push(next); + reseal(&mut input); + let at = input.actions[0].orders[0].fills[0].observed_at; + let mut replay = ManualReplayCursor::new(input).unwrap(); + let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); + let mut account = PortfolioState::new(1_500.); + assert!(replay.advance(at, &mut account, &data, false).is_err()); + assert_eq!(account.cash(), 1_500.); + assert!(account.positions().is_empty()); + assert_eq!(replay.applied_count(), 0); + assert_eq!(replay.next_observation_at(), Some(at)); +} + +#[test] +fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() { + for text in [ + "0", + "100", + "-100", + "0.000001", + "-0.000001", + "12345678901234567890123456.123456", + ] { + assert_eq!( + FixedMoney::from_decimal_str(text) + .unwrap() + .to_decimal_string(), + text + ); + } + let min = FixedMoney::from_raw(i128::MIN); + assert!(min.to_decimal_string().starts_with('-')); +} diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c8cd351..f7315f5 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -36234,6 +36234,7 @@ mod tests { avg_price: 0.0, transaction_cost: 0.0, limit_price: 10.2, + reserved_cash: None, reason: "pending_limit_sell".to_string(), }]; let subscriptions = BTreeSet::new(); @@ -36382,6 +36383,7 @@ mod tests { avg_price: 0.0, transaction_cost: 0.0, limit_price: 9.9, + reserved_cash: None, reason: "pending_limit_buy".to_string(), }, OpenOrderView { @@ -36396,6 +36398,7 @@ mod tests { avg_price: 0.0, transaction_cost: 0.0, limit_price: 10.2, + reserved_cash: None, reason: "pending_limit_sell".to_string(), }, ]; diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 6c786c1..c9de06a 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -138,18 +138,28 @@ impl Position { if quantity == 0 { return; } + let gross_amount = fixed_money_or_panic(execution_price * quantity as f64, "position buy gross amount"); + self.buy_with_fixed_gross(date,quantity,execution_price,mark_price,gross_amount); + } + fn buy_with_fixed_gross( + &mut self, + date: NaiveDate, + quantity: u32, + execution_price: f64, + mark_price: f64, + gross_amount: FixedMoney, + ) { let previous_quantity = self.quantity; - self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date))); + self.last_buy_date = Some( + self.last_buy_date + .map_or(date, |previous| previous.max(date)), + ); if previous_quantity == 0 { self.opened_date = Some(date); } let previous_average_price = self.average_price; let previous_average_cost = self.average_cost; - let gross_amount = fixed_money_or_panic( - execution_price * quantity as f64, - "position buy gross amount", - ); self.lots.push(PositionLot { acquired_date: date, quantity, @@ -200,6 +210,20 @@ impl Position { quantity: u32, execution_price: f64, mark_price: f64, + ) -> Result { + if quantity > self.quantity { + return Err(format!("sell quantity {} exceeds current quantity {} for {}",quantity,self.quantity,self.symbol)); + } + let total_proceeds = fixed_money(execution_price * quantity as f64,"position sell gross amount")?; + self.sell_with_fixed_gross(quantity,execution_price,mark_price,total_proceeds) + } + + fn sell_with_fixed_gross( + &mut self, + quantity: u32, + execution_price: f64, + mark_price: f64, + total_proceeds: FixedMoney, ) -> Result { if quantity > self.quantity { return Err(format!( @@ -208,10 +232,6 @@ impl Position { )); } - let total_proceeds = fixed_money( - execution_price * quantity as f64, - "position sell gross amount", - )?; let mut remaining = quantity; let mut remaining_proceeds = total_proceeds; let mut realized = FixedMoney::ZERO; @@ -796,6 +816,106 @@ impl PortfolioState { Ok(()) } + /// Apply one fully observed external fill atomically. Its money is already + /// quantized from the original decimal amounts, not from a float product. + pub(crate) fn apply_observed_manual_fill( + &mut self, + trade_date: NaiveDate, + symbol: &str, + side: crate::events::OrderSide, + quantity: u32, + price: f64, + mark_price: f64, + gross: FixedMoney, + fees: FixedMoney, + ) -> Result { + use crate::events::OrderSide; + if symbol.trim().is_empty() + || quantity == 0 + || quantity > i32::MAX as u32 + || !price.is_finite() + || price <= 0. + || !mark_price.is_finite() + || mark_price <= 0. + || gross <= FixedMoney::ZERO + || fees < FixedMoney::ZERO + { + return Err("invalid observed manual fill".into()); + } + let mut position = self + .positions + .get(symbol) + .cloned() + .unwrap_or_else(|| Position::new(symbol)); + let delta = match side { + OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg), + OrderSide::Sell => gross.checked_sub(fees), + } + .ok_or("manual fill cash delta overflow")?; + let next_cash = self + .cash + .checked_add(delta) + .filter(|cash| *cash >= FixedMoney::ZERO) + .ok_or("manual fill disagrees with shadow available cash")?; + let next_cost = position + .day_trade_cost + .checked_add(fees) + .ok_or("manual trade cost overflow")?; + match side { + OrderSide::Buy => { + let total_quantity = position + .quantity + .checked_add(quantity) + .ok_or("manual position quantity overflow")?; + FixedMoney::from_f64(mark_price * f64::from(total_quantity)) + .ok_or("manual marked position value overflow")?; + position + .day_buy_quantity + .checked_add(quantity) + .ok_or("manual daily buy quantity overflow")?; + position + .day_trade_quantity_delta + .checked_add(quantity as i32) + .ok_or("manual daily quantity delta overflow")?; + position + .day_buy_value + .checked_add(gross) + .ok_or("manual daily buy value overflow")?; + let total_basis = gross.checked_add(fees).ok_or("manual lot basis overflow")?; + position + .total_cost_basis() + .checked_add(total_basis) + .ok_or("manual aggregate position basis overflow")?; + position.buy_with_fixed_gross(trade_date, quantity, price, mark_price, gross); + position + .lots + .last_mut() + .ok_or("manual buy produced no lot")? + .cost_basis = total_basis; + position.average_cost += fees.to_f64() / f64::from(position.quantity); + } + OrderSide::Sell => { + if quantity > position.sellable_qty(trade_date) { + return Err("manual fill disagrees with shadow sellable holdings or T+1".into()); + } + position + .day_sell_quantity + .checked_add(quantity) + .ok_or("manual daily sell quantity overflow")?; + position + .day_trade_quantity_delta + .checked_sub(quantity as i32) + .ok_or("manual daily quantity delta overflow")?; + position.sell_with_fixed_gross(quantity, price, mark_price, gross)?; + } + } + position.day_trade_cost = next_cost; + position.refresh_day_pnl(); + self.positions.insert(symbol.to_string(), position); + self.cash = next_cash; + Ok(delta) + } + pub fn prune_flat_positions(&mut self) { let mut sold_symbols = Vec::new(); self.positions.retain(|symbol, position| { diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 7907eae..2a91976 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -102,6 +102,7 @@ pub struct OpenOrderView { pub avg_price: f64, pub transaction_cost: f64, pub limit_price: f64, + pub reserved_cash: Option, pub reason: String, } @@ -497,6 +498,7 @@ impl StrategyContext<'_> { .iter() .filter(|order| order.side == OrderSide::Buy) .map(|order| { + if let Some(reserved) = order.reserved_cash { return reserved; } let price = if order.limit_price.is_finite() { order.limit_price.max(0.0) } else { @@ -988,6 +990,15 @@ pub struct StrategyDecision { } impl StrategyDecision { + pub(crate) fn is_portfolio_target_only(&self) -> bool { + (self.rebalance && self.order_intents.is_empty()) + || (self.order_intents.len() == 1 + && matches!( + self.order_intents[0].unwrapped(), + OrderIntent::StockPool { .. } | OrderIntent::TargetPortfolioSmart { .. } + )) + } + pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet { let mut symbols = BTreeSet::new(); if self.rebalance { @@ -1001,9 +1012,24 @@ impl StrategyDecision { } pub fn merge_from(&mut self, mut other: StrategyDecision) { + if self.is_portfolio_target_only() && other.is_portfolio_target_only() { + let mut previous = std::mem::replace(self, other); + previous + .diagnostics + .push("unsubmitted_portfolio_target_superseded".into()); + self.notes.splice(0..0, previous.notes); + self.diagnostics.splice(0..0, previous.diagnostics); + return; + } self.buy_denials.append(&mut other.buy_denials); - self.rebalance |= other.rebalance; - self.target_weights.append(&mut other.target_weights); + if other.rebalance { + // Rebalance targets are a complete portfolio, not an additive + // list. A newer unsent target replaces the earlier allocation. + self.rebalance = true; + self.target_weights = std::mem::take(&mut other.target_weights); + } else { + self.target_weights.append(&mut other.target_weights); + } self.exit_symbols.append(&mut other.exit_symbols); self.order_intents.append(&mut other.order_intents); self.notes.append(&mut other.notes); @@ -1023,6 +1049,52 @@ impl StrategyDecision { } } +#[cfg(test)] +mod decision_merge_tests { + use super::*; + + #[test] + fn newer_complete_target_replaces_old_symbols_without_discarding_explicit_actions() { + let mut earlier = StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([("A".into(), 0.5), ("B".into(), 0.5)]), + exit_symbols: BTreeSet::from(["risk_exit".into()]), + order_intents: vec![OrderIntent::Shares { + symbol: "explicit".into(), + quantity: 100, + reason: "explicit action".into(), + }], + ..Default::default() + }; + earlier.merge_from(StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([("C".into(), 1.)]), + ..Default::default() + }); + assert_eq!(earlier.target_weights, BTreeMap::from([("C".into(), 1.)])); + assert!(earlier.rebalance); + assert!(earlier.exit_symbols.contains("risk_exit")); + assert_eq!(earlier.order_intents.len(), 1); + } + + #[test] + fn explicit_empty_complete_target_replaces_old_allocation_but_empty_callback_does_not() { + let mut decision = StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([("A".into(), 1.)]), + ..Default::default() + }; + decision.merge_from(StrategyDecision::default()); + assert_eq!(decision.target_weights.len(), 1); + decision.merge_from(StrategyDecision { + rebalance: true, + ..Default::default() + }); + assert!(decision.target_weights.is_empty()); + assert!(decision.rebalance); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AlgoOrderStyle { Vwap, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 6c72214..e4268d1 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -1535,6 +1535,90 @@ fn engine_executes_futures_order_intents_against_future_account() { assert!((futures_account.cash() - 355_988.0).abs() < 1e-6); } +#[test] +fn futures_directive_notifications_include_the_actual_recorded_fill() { + struct Observed { + inner: FuturesOrderStrategy, + seen: Rc>>, + } + impl Strategy for Observed { + fn name(&self) -> &str { + "observed-futures-directive" + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.inner.on_day(ctx) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &ProcessEvent, + ) -> Result<(), fidc_core::BacktestError> { + if event.kind == ProcessEventKind::Trade + && event.symbol.as_deref() == Some("IF2501") + { + let id = event.order_id.unwrap(); + assert!( + ctx.fills + .iter() + .any(|fill| fill.order_id == Some(id) && fill.symbol == "IF2501") + ); + assert!( + ctx.order_events + .iter() + .any(|order| order.order_id == Some(id) + && order.status == OrderStatus::Filled) + ); + assert_eq!( + ctx.current_datetime().map(|time| time.date()), + Some(ctx.execution_date) + ); + self.seen.borrow_mut().push(id); + } + Ok(()) + } + } + let seen = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_capacity_mode( + fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit, + ); + let mut engine = BacktestEngine::new( + two_day_futures_data(), + Observed { + inner: FuturesOrderStrategy, + seen: seen.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000300.SH".into(), + start_date: Some(d(2025, 1, 2)), + end_date: Some(d(2025, 1, 3)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_futures_initial_cash(500_000.); + let result = engine.run().unwrap(); + assert_eq!( + *seen.borrow(), + result + .fills + .iter() + .filter(|fill| fill.symbol == "IF2501") + .map(|fill| fill.order_id.unwrap()) + .collect::>() + ); + assert_eq!(seen.borrow().len(), 1); +} + #[test] fn platform_runtime_actions_execute_generic_futures_open_and_close() { let mut cfg = PlatformExprStrategyConfig::generic(); @@ -2748,6 +2832,7 @@ fn strategy_context_exposes_engine_native_account_runtime_view() { avg_price: 0.0, transaction_cost: 0.0, limit_price: 12.0, + reserved_cash: None, reason: "pending_buy".to_string(), }]; let subscriptions = BTreeSet::new(); diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs index 93c38fc..da33319 100644 --- a/crates/fidc-core/tests/stock_pool_execution_contract.rs +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -224,6 +224,117 @@ fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision { } } +#[test] +fn a_fresh_zero_target_prevents_resuming_the_previous_unsubmitted_buy_leg() { + use fidc_core::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext}; + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "fresh-target-before-resume" + } + fn requires_minute_callbacks(&self) -> bool { + false + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("earlier-pool", ScheduleStage::Minute) + .with_time_rule(ScheduleTimeRule::physical_time(9, 30)), + ] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + if ctx.execution_date != day(5) { + return Ok(StrategyDecision::default()); + } + let mut old = contract(day(5), 2, false); + old.out_of_pool_policy = "reduce_to_zero_when_sellable".into(); + old.rule.window_end = "13:30".into(); + old.rule.pricing_mode = POOL_PRICE_FORMULA_LIMIT.into(); + old.generation = "earlier-pool-at-open".into(); + Ok(decision(old)) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date == day(2) { + return Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: code(1), + quantity: 100, + reason: "original-holding".into(), + }], + ..Default::default() + }); + } + assert!(ctx.open_orders.is_empty()); + let mut latest = contract(day(5), 2, false); + latest.out_of_pool_policy = "reduce_to_zero_when_sellable".into(); + latest.rule.window_end = "13:30".into(); + latest.invest_ratio_bps = 0; + latest.generation = "fresh-zero-at-1300".into(); + Ok(decision(latest)) + } + } + let mut rows = data(false).snapshot_components(); + let mut quotes = Vec::new(); + for mut quote in rows.execution_quotes { + if quote.date > day(5) { + continue; + } + let mut afternoon = quote.clone(); + afternoon.timestamp = quote.date.and_hms_opt(13, 0, 0).unwrap(); + quotes.push(afternoon); + if quote.date == day(5) && quote.symbol == code(1) { + quote.volume_delta = 100; + quote.amount_delta = quote.last_price * 100.; + } + quotes.push(quote); + } + rows.execution_quotes = quotes; + let data = DataSet::from_components_with_actions_and_quotes( + rows.instruments, + rows.market, + rows.factors, + rows.candidates, + rows.benchmarks, + rows.corporate_actions, + rows.execution_quotes, + ) + .unwrap(); + let broker = broker(true) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap()); + let result = BacktestEngine::new( + data, + Probe, + broker, + BacktestConfig { + initial_cash: 30_000., + benchmark_code: "000300.SH".into(), + start_date: Some(day(2)), + end_date: Some(day(5)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 3, "{:?}", result.fills); + assert!(result.fills.iter().all(|fill| fill.symbol == code(1))); + assert_eq!(result.fills[1].side, fidc_core::OrderSide::Sell); + assert_eq!( + result.fills[2].execution_timestamp, + day(5).and_hms_opt(13, 0, 0) + ); + assert_eq!(result.fills[1].order_id, result.fills[2].order_id); + assert_eq!(result.fills[1].quantity + result.fills[2].quantity, 100); + assert!(result.holdings_summary.is_empty()); +} + #[test] fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() { let data = data_with_suspension(1_000_000, Some(day(6))); @@ -887,6 +998,42 @@ fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_op assert!(result.terminal_audit.is_clean()); } +#[test] +fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() { + use fidc_core::strategy::{Strategy,StrategyContext}; + use std::{cell::RefCell,rc::Rc}; + struct ObservedPool { inner:EtfPoolSignal, observations:Rc>> } + impl Strategy for ObservedPool { + fn name(&self)->&str {"ETF actual opening clock"} + fn initial_subscriptions(&self)->BTreeSet {BTreeSet::from([code(1)])} + fn decision_quote_times(&self)->Vec {self.inner.decision_quote_times()} + fn decision_quote_symbols(&mut self,ctx:&StrategyContext<'_>)->Result,fidc_core::BacktestError> {self.inner.decision_quote_symbols(ctx)} + fn on_day(&mut self,ctx:&StrategyContext<'_>)->Result {self.inner.on_day(ctx)} + fn on_minute(&mut self,ctx:&StrategyContext<'_>,quote:&IntradayExecutionQuote)->Result { + if quote.date==day(5) {self.observations.borrow_mut().push((quote.timestamp, + ctx.portfolio.position(&code(2)).map_or(0,|position|position.quantity),ctx.fills.iter().filter(|fill|fill.symbol==code(2)).count()));} + Ok(StrategyDecision::default()) + } + } + let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(); + let mut data=etf_fallback_fixture(time); + let quote=data.execution_quotes_on(day(5),&code(1))[0].clone(); + data.add_execution_quotes([(9,15),(9,31)].into_iter().map(|(hour,minute)| { + let mut row=quote.clone();row.timestamp=day(5).and_hms_opt(hour,minute,0).unwrap();row + }).collect()); + let observations=Rc::new(RefCell::new(Vec::new())); + let broker=broker(false).with_matching_type(MatchingType::MinuteLast) + .with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time) + .with_historical_etf_open_fallback(true); + let result=BacktestEngine::new(data,ObservedPool {inner:EtfPoolSignal{at:time,condition:String::new()},observations:observations.clone()},broker,BacktestConfig { + initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last, + }).with_execution_quote_loader(|_|Ok(vec![])).run().unwrap(); + let observations=observations.borrow(); + assert_eq!(observations[0],(day(5).and_hms_opt(9,15,0).unwrap(),0,0)); + assert_eq!(observations[1],(day(5).and_hms_opt(9,31,0).unwrap(),3700,1)); + assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1); +} + #[test] fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() { let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap(); diff --git a/docs/callback-context-and-pending-targets-20260914.md b/docs/callback-context-and-pending-targets-20260914.md new file mode 100644 index 0000000..9c36704 --- /dev/null +++ b/docs/callback-context-and-pending-targets-20260914.md @@ -0,0 +1,55 @@ +# 回报上下文、盘前意图与尚未提交的目标 + +2026-09-14。本轮已配套发布177,annotated tag `v2026.9.14.5`。Engine81acc54 / Service e81bf47 / Trading94f99d2;完整股票池Goal继续,不据本阶段关闭。 + +## 已复现问题 + +1. `on_process_event`总是收到`active_datetime=None`及空委托/成交数组。10:00账本已有100股,但Trade/PostMinute回调的成交数量仍为0;不能靠普通`on_minute`已修复就认为通知链也完整。 +2. 15:05盘后成交后,PreAfterTrading仍被标为15:00;跨日模式的PostOnDay又使用信号日描述执行日已发生的成交。 +3. BeforeTrading调度只处理订阅、账户和期货指令,剩余股票买卖/撤改意图没有后续消费。简单在开盘调用普通broker执行还会让旧挂单先成交再撤单。 +4. 合并完整目标时只追加权重会保留旧证券;更重要的是,不能先提交盘前旧组合,之后才计算同一窗口的新目标,否则T+1可能使错误买入无法纠正。 +5. 策略计算前的空broker调用也会恢复上一目标的未提交买入腿。反例中原持仓100股,09:30卖25股、13:00卖剩余75股;若此时先恢复旧买入,已经准备将新目标设为0%的策略仍会买入另一股票3000股。 + +## 本轮处理 + +- 事件通知显式携带当前可见的委托、成交与回调时钟,移动已完成记录后再通知,不按每个回调复制整段历史。上下文是通知时已完成批次的最新状态,不冒充每一历史通知发生瞬间的账本快照。 +- 信号计算回调保留信号日;账户/委托通知使用实际执行日与物理时钟。默认收盘和结算不早于已处理时刻及当前适用的盘后结束点,管理费回调沿用同一完成时钟。 +- 盘前撤改走明确的非撮合控制阶段,保持原订单ID和实际已成交量;该入口拒绝买卖目标,不会顺带撮合旧单。普通显式买卖按原配置窗口执行,后续回调读取撤改后的真实活动订单。 +- 盘前与集合竞价的显式命令保留各自批次及约束。纯完整组合(完整rebalance或单一StockPool/TargetPortfolioSmart)可以被更新的完整意图替换;空回调不等于清仓,显式空完整目标才清仓。被替换意图的旧买入限制不能污染新完整目标。 +- 尚未提交的完整目标保留到当前窗口日度策略算完;新执行意图优先,只有没有新执行意图时才使用前面的目标。已提交挂单可以先更新实际成交,但策略计算前不恢复旧的未提交买入腿,之后再由正常执行路径处理当前意图。 +- 订阅/账户/直接期货指令通知同样获得完成后的历史;本轮不改变期货成交、会话或费用规则。 + +## 回归证据 + +- 通知链:09:30为空、10:00/10:01均看到100股及1笔实际成交,Trade通知可找到相同订单。 +- 盘后:15:05成交后的默认收盘/结算和管理费通知不倒退;next-open保持独立信号日和执行日。 +- 盘前:09:00生成100股命令,分别只在09:30/13:00配置窗口成交;保留备注/诊断。跨日撤销原GTC订单后,新订单只成交100股,未让旧单先成交。 +- 完整目标:盘前A、集合竞价B、日度A或显式空目标,最终只采用有效最新目标;日度无新信号时保持B。显式逐股命令不会被目标合并丢弃。 +- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。 +- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。 + +精确只读快照在Linux通过Core834及Trading613。旧二进制先独立归档,构建保持1GiB磁盘余量;本轮未再次删除缓存或业务文件。 + +## 发布与真实历史复验 + +已推送annotated tag `v2026.9.14.5`对应Engine `81acc5422878abc855fca72b35766ffad6159200`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `94f99d20f49f6cd1810996706cb94f610c302385`。回测API/Runner于06:15:53 CST切换,五交易单元06:21:09切换,06:22实际运行文件和业务事实复核通过。 + +三组冻结合同共六次独立原生A/B,完整Canonical及equity/orders/trades/holdings逐行一致;再通过生产HTTP各提交一次,结果分别匹配原生候选,旧记录未改写: + +| 案例 | 生产回测ID | 成交 / 持仓 | 期末权益 | +| --- | --- | --- | ---: | +| 手选优先四证券 | btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20 | 10 / 4 | 9706248.648662 | +| 自动优先四证券 | btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237 | 10 / 4 | 9706248.648662 | +| 许总24只原v3 | btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4 | 51 / 21 | 9685563.876924999 | + +重复目标委托0。三条新记录各5个交易日事件落库,持久事件27/18/32条,唯一键计数分别相同;旧流式样本仍27条/5日。仍为原合同下的日终容量审计,不外推实时盘口成交能力;首次Source准备和后续快速返回也不作为性能优化证明。 + +API SHA `dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc`,Runner SHA `b1d93215deb275fbec6217c6b9afbf717d5649600716bf4f3a1bf5d1cfa69731`,运行实现身份 `fed10e9fa61836aa271921f5d58490054210d83da935cad5de11cfacab45c13e`。API发布目录`/srv/fidc/canonical/run/backtest-api/releases/callback-81acc54-7w1zx9fb`,回退目录`/srv/fidc/canonical/run/build/callback-rollback-j7oje2tz`;交易回退`holding-protection-rollback-czuric4r`。 + +六服务实际SHA与manifest吻合,新增ERROR0。3Paper/0Live、配置、旧活动单、3个未确认Paper预览、迁移、影子配置0及disabled未变;发布后Paper/Live新订单0,未发送真实通知、委托或撤单。Source d5/PID1700096与UI6a2/PID3089476未重启,研究/信号暂停不变。177维护中的Engine9a54156完整保留,实际构建使用81acc54/e81bf47及81acc54/94f99d2的只读Git快照。 + +原始回放/HTTP证据`/srv/fidc/canonical/run/research/stock-pool-callback-20260914/`;发布和最终审计`/tmp/fidc-callback-{candidate,api-release,trading-release,final-audit}-20260914.json`;非敏感汇总在`docs/evidence/callback-target-20260914/acceptance.json`。 + +## 继续范围 + +显式逐笔手工影子回放仍未完成,四类手工来源继续拒绝纯比例影子;原始撤单意图时刻不能用网关回报时刻冒充。还需继续检查会话外调度产生的未提交意图、完整阶段日历与其余参数/生命周期/适配器矩阵。Source冻结、研究/信号暂停、现有任务配置和真实路由不改。 diff --git a/docs/evidence/callback-target-20260914/acceptance.json b/docs/evidence/callback-target-20260914/acceptance.json new file mode 100644 index 0000000..211622f --- /dev/null +++ b/docs/evidence/callback-target-20260914/acceptance.json @@ -0,0 +1,191 @@ +{ + "verified_at": "2026-09-13T22:22:38.597836+00:00", + "tag": "v2026.9.14.5", + "processes": { + "fidc-backtest-service-highmem177.service": { + "pid": 3692551, + "sha256": "dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc", + "journal_since": "2026-09-13T22:15:53.225719+00:00", + "journal_lines": 54, + "error_lines": 0 + }, + "fidc-trading-control-highmem177.service": { + "pid": 3697497, + "sha256": "a8f62ba74caf7ce2f5ba9cc6f67f41c844dee3747852611051c8dfb7b36295a3", + "journal_since": "2026-09-13T22:21:09.122548+00:00", + "journal_lines": 5, + "error_lines": 0 + }, + "fidc-market-data-highmem177.service": { + "pid": 3697498, + "sha256": "89395ab4c9e11274f171f4386f88949ce616fe45d15aae9a829216d53ed11db7", + "journal_since": "2026-09-13T22:21:09.122548+00:00", + "journal_lines": 5, + "error_lines": 0 + }, + "fidc-strategy-runtime-highmem177.service": { + "pid": 3697688, + "sha256": "041a0103d2c46c55221d169965ece9fdacee3905abc0d46edd9c8a2a86f6cd54", + "journal_since": "2026-09-13T22:21:09.122548+00:00", + "journal_lines": 5, + "error_lines": 0 + }, + "fidc-paper-trading-highmem177.service": { + "pid": 3697792, + "sha256": "8460008f712810f7d3876b9f2274aef88f82d46cd33e1593dbd0361f6d158b75", + "journal_since": "2026-09-13T22:21:09.122548+00:00", + "journal_lines": 6, + "error_lines": 0 + }, + "fidc-live-trading-highmem177.service": { + "pid": 3697762, + "sha256": "e89d6ba68655a5d4a164793ad67a9003cf2c49733063018497339930feafaac3", + "journal_since": "2026-09-13T22:21:09.122548+00:00", + "journal_lines": 6, + "error_lines": 0 + } + }, + "source": { + "commit": "d5b682c6d097", + "pid": 1700096, + "loaded_at": "2026-09-12T03:57:06.665536+00:00", + "source_stale": false, + "loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352" + }, + "source_checkouts": { + "fidc-backtest-engine": { + "head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17", + "runtime_commit": "81acc5422878abc855fca72b35766ffad6159200", + "tracked_dirty": false + }, + "fidc-backtest-service": { + "head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6", + "runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c", + "tracked_dirty": false + }, + "fidc-trading-platform": { + "head": "94f99d20f49f6cd1810996706cb94f610c302385", + "runtime_commit": "94f99d20f49f6cd1810996706cb94f610c302385", + "tracked_dirty": false + }, + "omniquant": { + "head": "6a2b2604b40505fa754453307c517fef60743426", + "runtime_commit": "6a2b2604b40505fa754453307c517fef60743426", + "tracked_dirty": false + } + }, + "ui_unchanged": { + "commit": "6a2b2604b40505fa754453307c517fef60743426", + "pid": 3089476 + }, + "http_cases": [ + { + "name": "manual_first", + "run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20", + "status": "succeeded", + "canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3", + "trade_count": 10, + "holding_count": 4, + "final_equity": 9706248.648662, + "old_result_unchanged": true + }, + { + "name": "automatic_first", + "run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237", + "status": "succeeded", + "canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7", + "trade_count": 10, + "holding_count": 4, + "final_equity": 9706248.648662, + "old_result_unchanged": true + }, + { + "name": "stock24", + "run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4", + "status": "succeeded", + "canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f", + "trade_count": 51, + "holding_count": 21, + "final_equity": 9685563.876924999, + "old_result_unchanged": true + } + ], + "durable_events": [ + { + "run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20", + "count": 27, + "unique_keys": 27, + "days": 5 + }, + { + "run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237", + "count": 18, + "unique_keys": 18, + "days": 5 + }, + { + "run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4", + "count": 32, + "unique_keys": 32, + "days": 5 + }, + { + "run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4", + "count": 27, + "unique_keys": 27, + "days": 5 + } + ], + "trading_state": { + "paper": { + "configuration": { + "count": 3, + "hash": "93f3224edef59c381164e0236529dacc" + }, + "active": { + "claims": 0, + "orders": 0 + } + }, + "live": { + "configuration": { + "count": 0, + "hash": "d41d8cd98f00b204e9800998ecf8427e" + }, + "active": { + "claims": 0, + "orders": 1, + "today_orders": 0, + "orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8", + "route_mode": "disabled" + } + } + }, + "manual_facts_unchanged": { + "paper": { + "shadow_configurations": 0, + "shadow_runs": 0, + "manual_count": 3, + "manual_hash": "82572901ac0b5fdb4d8b984f71e1763d", + "migrations_hash": "21d711b2ee52d2d66a8be4e99b179190", + "new_orders": 0 + }, + "live": { + "shadow_configurations": 0, + "shadow_runs": 0, + "manual_count": 0, + "manual_hash": "d41d8cd98f00b204e9800998ecf8427e", + "migrations_hash": "610528d4f350309379c9398c4ea43f66", + "new_orders": 0 + } + }, + "broker_submission": false, + "linux_core_tests": { + "passed": 834, + "failed": 0, + "ignored": 9, + "log": "/srv/fidc/canonical/run/fidc-private/evidence/callback-candidate-6qumwky7/linux-core-tests.log" + }, + "scope": "Callback and pending-target release verification; historical simulation only. Full manual shadow replay remains incomplete.", + "native_replays": 6 +} diff --git a/docs/evidence/intraday-clock-20260914/acceptance.json b/docs/evidence/intraday-clock-20260914/acceptance.json new file mode 100644 index 0000000..3087a78 --- /dev/null +++ b/docs/evidence/intraday-clock-20260914/acceptance.json @@ -0,0 +1,258 @@ +{ + "verified_at": "2026-09-13T20:22:24.750379+00:00", + "tag": "v2026.9.14.4", + "processes": { + "fidc-backtest-service-highmem177.service": { + "pid": 3612875, + "sha256": "4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2", + "journal_since": "2026-09-13T20:14:17.444770+00:00", + "journal_lines": 54, + "error_lines": 0 + }, + "fidc-trading-control-highmem177.service": { + "pid": 3617963, + "sha256": "cd587928591fef952f2e98b47aa338a1702def5edf016a7da9751296667f6674", + "journal_since": "2026-09-13T20:19:57.821843+00:00", + "journal_lines": 5, + "error_lines": 0 + }, + "fidc-market-data-highmem177.service": { + "pid": 3617964, + "sha256": "2efb1d3ad7d510cf85e6047dd6d1981d0a30d768ff3d33c842adc52211002bdc", + "journal_since": "2026-09-13T20:19:57.821843+00:00", + "journal_lines": 5, + "error_lines": 0 + }, + "fidc-strategy-runtime-highmem177.service": { + "pid": 3618140, + "sha256": "3d7f3f2e8756e7f3439075344fe9c8bc0b55df33e7e6f251282712274c00339d", + "journal_since": "2026-09-13T20:19:57.821843+00:00", + "journal_lines": 5, + "error_lines": 0 + }, + "fidc-paper-trading-highmem177.service": { + "pid": 3618260, + "sha256": "0383b1d6cc7b3c48c6902dd7fd4760a38698c1916e0eaff63be26fe3f6b1a2ab", + "journal_since": "2026-09-13T20:19:57.821843+00:00", + "journal_lines": 6, + "error_lines": 0 + }, + "fidc-live-trading-highmem177.service": { + "pid": 3618246, + "sha256": "583f52e204aeb416574ee17daa20cebed49e0659a194c81e8072a9249824e48e", + "journal_since": "2026-09-13T20:19:57.821843+00:00", + "journal_lines": 6, + "error_lines": 0 + } + }, + "source": { + "commit": "d5b682c6d097", + "pid": 1700096, + "loaded_at": "2026-09-12T03:57:06.665536+00:00", + "source_stale": false, + "loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352" + }, + "source_checkouts": { + "fidc-backtest-engine": { + "head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17", + "runtime_commit": "237ee15a518a668297959509daffc4b88995f310", + "tracked_dirty": false + }, + "fidc-backtest-service": { + "head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6", + "runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c", + "tracked_dirty": false + }, + "fidc-trading-platform": { + "head": "dab98e0cc09793df15b8c72841a6dc7e9a58a208", + "runtime_commit": "dab98e0cc09793df15b8c72841a6dc7e9a58a208", + "tracked_dirty": false + }, + "omniquant": { + "head": "6a2b2604b40505fa754453307c517fef60743426", + "runtime_commit": "6a2b2604b40505fa754453307c517fef60743426", + "tracked_dirty": false + } + }, + "ui_unchanged": { + "commit": "6a2b2604b40505fa754453307c517fef60743426", + "pid": 3089476 + }, + "http_cases": [ + { + "name": "manual_first", + "run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303", + "status": "succeeded", + "canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3", + "trade_count": 10, + "holding_count": 4, + "final_equity": 9706248.648662, + "old_result_unchanged": true + }, + { + "name": "automatic_first", + "run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054", + "status": "succeeded", + "canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7", + "trade_count": 10, + "holding_count": 4, + "final_equity": 9706248.648662, + "old_result_unchanged": true + }, + { + "name": "stock24", + "run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74", + "status": "succeeded", + "canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f", + "trade_count": 51, + "holding_count": 21, + "final_equity": 9685563.876924999, + "old_result_unchanged": true + } + ], + "durable_events": [ + { + "run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303", + "count": 27, + "unique_keys": 27, + "days": 5 + }, + { + "run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054", + "count": 18, + "unique_keys": 18, + "days": 5 + }, + { + "run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74", + "count": 32, + "unique_keys": 32, + "days": 5 + }, + { + "run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4", + "count": 27, + "unique_keys": 27, + "days": 5 + } + ], + "trading_state": { + "paper": { + "configuration": { + "count": 3, + "hash": "93f3224edef59c381164e0236529dacc" + }, + "active": { + "claims": 0, + "orders": 0 + } + }, + "live": { + "configuration": { + "count": 0, + "hash": "d41d8cd98f00b204e9800998ecf8427e" + }, + "active": { + "claims": 0, + "orders": 1, + "today_orders": 0, + "orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8", + "route_mode": "disabled" + } + } + }, + "manual_facts_unchanged": { + "paper": { + "shadow_configurations": 0, + "shadow_runs": 0, + "manual_count": 3, + "manual_hash": "82572901ac0b5fdb4d8b984f71e1763d", + "migrations_hash": "21d711b2ee52d2d66a8be4e99b179190", + "new_orders": 0 + }, + "live": { + "shadow_configurations": 0, + "shadow_runs": 0, + "manual_count": 0, + "manual_hash": "d41d8cd98f00b204e9800998ecf8427e", + "migrations_hash": "610528d4f350309379c9398c4ea43f66", + "new_orders": 0 + } + }, + "broker_submission": false, + "linux_core_tests": { + "passed": 822, + "failed": 0, + "ignored": 9, + "log": "/srv/fidc/canonical/run/fidc-private/evidence/clock-candidate-gqx8g70l/linux-core-tests.log" + }, + "cleanup": { + "apply": true, + "deleted": [ + { + "path": "/srv/fidc/canonical/build/holding-protection-stage-wywd2682/fidc-trading-platform/debug/incremental", + "kind": "incremental_compiler_state", + "bytes": 9553190912, + "device": 2101, + "inode": 39877787, + "mtime_ns": 1789318996850462500, + "links": 176, + "size_bytes": 12288 + }, + { + "path": "/srv/fidc/canonical/build/target-backtest/debug/incremental", + "kind": "incremental_compiler_state", + "bytes": 2103459840, + "device": 2101, + "inode": 29904450, + "mtime_ns": 1789318494701447400, + "links": 46, + "size_bytes": 4096 + }, + { + "path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_backtest_service-46310ff8aeeb4040", + "kind": "superseded_test_binary", + "bytes": 398401536, + "device": 2101, + "inode": 29934330, + "mtime_ns": 1789117975169420000, + "links": 1, + "size_bytes": 399242488 + }, + { + "path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-18c9b2429fdf6026", + "kind": "superseded_test_binary", + "bytes": 207015936, + "device": 2101, + "inode": 29918792, + "mtime_ns": 1789166943885717800, + "links": 1, + "size_bytes": 207144208 + }, + { + "path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-42f704a330411730", + "kind": "superseded_test_binary", + "bytes": 191205376, + "device": 2101, + "inode": 29933759, + "mtime_ns": 1789117542401109800, + "links": 1, + "size_bytes": 191333360 + } + ], + "reclaimed_allocated_bytes": 12453273600, + "before": { + "total": 1749269057536, + "used": 1659299954688, + "free": 1035599872 + }, + "after": { + "total": 1749269057536, + "used": 1647166930944, + "free": 13168623616 + }, + "observed_free_change": 12133023744 + }, + "scope": "Intraday clock release verification; historical simulation only, not a performance or real broker liquidity acceptance.", + "native_replays": 6 +} diff --git a/docs/intraday-clock-causality-20260914.md b/docs/intraday-clock-causality-20260914.md index 93d7c1a..7fd58ec 100644 --- a/docs/intraday-clock-causality-20260914.md +++ b/docs/intraday-clock-causality-20260914.md @@ -1,6 +1,6 @@ # 日内时钟与手工回放前置问题 -2026-09-14。本轮只有未提交的失败回归,未修改引擎实现,未部署。 +2026-09-14。本轮日内时钟与工作中算法单修复已配套发布177,annotated tag `v2026.9.14.4`。当前Engine237ee15 / Service e81bf47 / Trading dab98e0;完整手工影子回放尚未实现,不据本阶段关闭Goal。 ## 已复现的精确反例 @@ -20,6 +20,51 @@ 需覆盖当前/下一开盘、显式时间和默认收盘、限价/市价/算法单、部分成交及取消、股票池卖后续买、跨日/T+1、0%人工覆盖和恢复。已有真实回放与六类Canonical必须按各自合同核对,不能用收益接近或单个对照替代。 -当前失败回归保留在`crates/fidc-core/src/engine.rs`未提交工作树,属于本任务,不删除、不忽略、不发布成绿色测试。下一步直接修复并扩充该回归,再进入逐笔手工回放;不要重新检查已完成的页头或流式消息。 +上述原失败回归已保留并修复:晚窗口执行与日度回调进入真实日内时钟,不再先写未来持仓。独立信号日及滞后执行的数据合同保留。仅有日内观察或待处理开盘目标时,未显式设时间的日线收盘回调才延至15:00;物理时钟与委托提交时点分离,不能把普通日线收盘撮合误变为15:05盘后委托。 + +## 本轮新增证据 + +- TWAP旧路径在13:00一次消费13:01、13:05报价,导致13:00观察到900股;现在逐时钟消费,同一父订单保留原始总量、已成交量、剩余金额、最低佣金余额和期限,不重新生成订单。 +- 分片时钟继续使用原算法窗口决定TWAP比例及深度约束,不把每个瞬时时钟当作新的不限量算法单;VWAP全局撮合也延续同一工作中订单。 +- 算法定量使用提交时已经可见的报价。改变当日后续收盘价不改变早先订单数量;真正缺报价明确失败,不读未来报价或日线价替代。 +- 当天已完成委托/成交记录及时移动到运行历史,后续分钟、日度与定时策略回调能读取;不逐分钟复制全部历史。 +- ETF下一开盘回退保留真实日线开盘价、3700股及原信号日,入账从早间预处理移到09:30事件;反例09:15原来可见3700股,修复后为0,09:31为3700且仅一笔ETF成交。不合成ETF分钟线。 +- 工作中算法单只预留真实可用现金;两个各10000元意图、15000元账户按顺序预留10000/5000,后续分别成交900/500股,先到订单不被后到订单的超额预留饿死。 +- 已验证部分成交后撤单、无末尾报价到期、T+1、IOC终止及原合同拒绝算法FOK/GTC;未新增不支持的有效期。 +- 同一TWAP与同步参考逐笔数量/价格/时间/订单ID/各项费用完全一致;VWAP逐时钟成交金额与总费用一致。最低佣金只扣一次,成交资金不超过冻结预算。 + +本机Core 822项通过、9项原有ignore;Trading工作区613项通过(外部PG等原有ignore未当通过);最新main的Runner446/API119项通过。同期main风控候选d2aa16a已保留并组合回归。本机测试不代替177不可变构建与真实数据回放。 + +## 发布前置与剩余边界 + +177于03:46只读核对仍为3Paper/0Live,原配置与旧委托摘要不变,Source d5b682c6/PID1700096未变,真实路由disabled。SSD剩约1.6GiB;首次Linux测试在18.02秒触及1GiB余量保护并中止,只停止本次Cargo进程,未重启服务,保留`clock-candidate-cena8gz9/first-attempt.json`及日志,不能算测试通过。 + +初次把清理预览的`reclaimed_allocated_bytes=0`误读为没有候选;完整plan实际已有5项、12,453,273,600字节。正式工具引用/锁/身份复核后仅清理2处闲置debug增量缓存和3个过期测试可执行文件,保留最新测试、全部静态/共享库、release、源码、行情及结果,余量恢复13,168,623,616字节。收据位于`/srv/fidc/canonical/run/fidc-private/evidence/clock-default-cleanup-20260914-0422/`。暂拟的静态库清理选项未执行并已撤回;最终Service脚本5ec8dc8只明确区分计划量与实际回收量,保持原清理边界。 + +代码修复已推送Engine `237ee15a518a668297959509daffc4b88995f310`;官方复用审计确认target-backtest无运行引用,新一轮仍保留1GiB余量保护,并独立保存重建前的旧二进制及SHA。实际构建读取只读Git archive快照237ee15与生产Service e81bf47,不夹带尚未生产验收的并行缓存规划代码,不覆盖维护工作树。 + +Linux精确快照Core822、Trading613通过。首次配套优化构建276.06秒成功,但收据写入因/tmp的跨用户既有文件保护失败;改为原子替换收据后,重新核对同一快照/测试/制品,未把日志缺失或异常算通过。前一轮日志及旧二进制仍保留,最终收据`/tmp/fidc-clock-candidate-20260914.json`。 + +## 发布与真实合同验收 + +Engine `237ee15a518a668297959509daffc4b88995f310`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `dab98e0cc09793df15b8c72841a6dc7e9a58a208`均有已推送annotated tag `v2026.9.14.4`。API/Runner于04:14:17 CST切换,五交易服务于04:19:57切换;04:22只读复验实际SHA、迁移、旧单及配置。 + +| 已冻结原合同 | 原生A/B | 生产HTTP | 成交 / 期末持仓 | 期末权益 | +| --- | --- | --- | --- | ---: | +| 手选优先四证券 | 完整Canonical及四类逐行导出相同 | btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303 | 10 / 4 | 9706248.648662 | +| 自动优先四证券 | 完整Canonical及四类逐行导出相同 | btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054 | 10 / 4 | 9706248.648662 | +| 许总24只原v3 | 完整Canonical及四类逐行导出相同 | btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74 | 51 / 21 | 9685563.876924999 | + +共六次独立原生执行、三次持久幂等HTTP提交,旧请求/旧结果未改写。候选顺序、父订单及卖后续买合同保持;重复目标委托0。三条新记录各有5个交易日事件,持久事件总数27/18/32、唯一键数完全相等;旧流式样本仍27条/5日。上述数据来自原历史合同,仍属日终容量审计,不证明实时盘口容量;1秒样本与首轮12秒Source准备不作为性能提速证据。 + +API二进制SHA `4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2`,Runner `8b98a2ae9a13899e87d9931162d1637de7e9ab81844c284e00135904cda7b0e4`,运行实现身份 `96cf0dcfcec94c6f7e2a9fc64ba8b8e8547b12c869ad6a61a0e492f6c76b5d57`。当前不可变API目录`/srv/fidc/canonical/run/backtest-api/releases/clock-237ee15-c37rs7zq`,回退目录`/srv/fidc/canonical/run/build/clock-rollback-7qnhgco7`;交易回退目录`holding-protection-rollback-dkd1njej`。 + +五交易服务逐一核对实际文件SHA与manifest,新增ERROR日志0;3Paper/0Live、配置、旧活动委托、3个未确认Paper预览、迁移、shadow配置0及disabled均未变化,发布后Paper/Live新订单0。Source d5/PID1700096、UI6a2/PID3089476未重启,研究/信号暂停保持。177维护中的Engine9a54156工作树完整保留,不把该未部署候选冒充本次运行代码;实际编译来自237/e81和237/dab只读快照。 + +完整原始回放与HTTP收据:`/srv/fidc/canonical/run/research/stock-pool-clock-20260914/`。发布/审计收据:`/tmp/fidc-clock-{api-release,trading-release,final-audit}-20260914.json`。非敏感汇总已归档`docs/evidence/intraday-clock-20260914/acceptance.json`。 + +## 下一步 + +通用process-event回调的完整时间/订单观察、盘前调度普通意图、显式手工委托/撤单回放和所有剩余参数矩阵继续跟踪,不以当前阶段声明完整Goal完成。下一轮直接处理这些缺口,不重新做已通过的金额、页头、流式及本轮三组回放;当前仍不解除手工影子拒绝门禁,不修改既有任务配置、Source冻结和研究/信号暂停。 Live取消请求另有待核对项:`CancelRequested`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。 diff --git a/docs/manual-replay-foundation-20260914.md b/docs/manual-replay-foundation-20260914.md new file mode 100644 index 0000000..b2721c4 --- /dev/null +++ b/docs/manual-replay-foundation-20260914.md @@ -0,0 +1,35 @@ +# 手工成交观察回放:基础合同与当前断点 + +2026-09-14。本阶段只完成框架基础与本机验证,未接入Runner/API、未发布。生产最近已验收版本仍为v2026.9.14.5;完整Goal和手工影子回放均未完成。 + +## 已实现 + +`manual_execution`提供`fidc.observed-manual-executions/v1`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。 + +- 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。 +- 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。 +- 明确区分无须生成订单与有终态订单,拒绝不完整、未知、超量、状态不一致、超截止日期的数据。不将空订单列表直接当成功。 +- 金额输入使用十进制字符串,不先经过JSON浮点数。保留原价、原费用、原成交额;账本沿用既有微元精度,真实十进制金额在入口统一量化,并分开返回原值和账本值。 +- 游标按真实观察时间和已持久化事件序号前进,重入同一时点不会重复入账,时间倒退或越过证据截止时间会失败。 +- 资金、持仓及游标在一次advance中原子变更。资金不足、T+1、生命周期冲突或活动影子订单冲突不借股、不借款、不取消原订单,也不留下半笔状态。 +- 人工交易不是出入金,不更改现金流中性单位或初始资金;原始买卖账本入口继续使用原有计算,仅抽出可传固定金额的内部函数。 + +本机Core849项通过(9项原有ignore),其中15项新专项覆盖精度/摘要/关联/时间/顺序/无订单/部分撤单/原子失败/不重复和跨日出售。此结果不代表服务、完整影子请求或生产成交验收。 + +## 已核对的持久化入口 + +Paper `paper_manual_position_actions`保存确认、执行合同SHA、计划与order_ids;`paper_fills`及`paper_event_log.FillReceived`可以提供真实成交及观察事件序号。Live单证券动作在`live_manual_trade_intents`,逐笔事实在`live_broker_trade_facts`,对应`live_event_log.FillReceived`提供recorded_at和序号。事件序号表示持久化观察顺序,不冒充交易所执行顺序。 + +Live整仓的历史审计原来只有confirmation_hash,执行ID在另一个开始事件中;当前候选已将服务端生成的execution_id和所选account_id写入同一仓位审计详情,并校验非空ID和账户范围。旧历史仍只能依据原始审计/事件做唯一关联,不能猜测或重写。 + +费用仍需在读取层核对实际适配器合同:当前Paper账本收取commission+stamp_tax;Live事实的complete也按这两个已声明字段判定。不能仅凭complete名字断言其他费用不存在,不能以默认0补缺失。 + +## 必须继续,不能把本阶段当完成 + +1. 实现全部四类来源的权威PG读取、审计/动作/订单/成交/事件绑定与一致快照;未知/活动状态等待,不能变成空成功。 +2. 在API/Runner传递完整受控合同和源范围,补齐手工证券的历史资料/行情需求。当前没有任何运行入口调用此游标。 +3. 把观察事件与盘前、集合竞价、日度、分钟、收盘/结算阶段按完整时钟合并;跨交易日/会话外观察不可简单塞进on_minute或提前应用。 +4. 输出须区分外部人工成交与策略模拟成交,保留原始执行时间、观察时间、费用和实际投影时间线,不能宣称人工成交被独立验证。 +5. 完成两套隔离PG、真实引擎、完整HTTP和发布验证后,才可解除四类手工来源的纯比例影子拒绝门禁。 + +下一轮直接进行上述读取/引擎/结果链,不能重复15项基础用例或v2026.9.14.5固定三组回放替代集成。Source冻结、研究/信号暂停、现有3Paper/0Live与disabled不变;本轮无生产写入、真实订单或通知。