diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index be36030..f456fe1 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -410,6 +410,8 @@ where execution_date, default_stage_time(ScheduleStage::BeforeTrading), ), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), })?; publish_phase_event( &mut self.strategy, @@ -443,6 +445,8 @@ where &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::BeforeTrading), + result.order_events.as_slice(), + result.fills.as_slice(), )?; self.apply_strategy_directives( execution_date, @@ -501,6 +505,8 @@ where &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::OpenAuction), + result.order_events.as_slice(), + result.fills.as_slice(), )?; auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { execution_date, @@ -517,6 +523,8 @@ where execution_date, default_stage_time(ScheduleStage::OpenAuction), ), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), })?); publish_phase_event( &mut self.strategy, @@ -615,6 +623,8 @@ where execution_date, default_stage_time(ScheduleStage::OnDay), ), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), }) }) .transpose()? @@ -635,6 +645,8 @@ where &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::OnDay), + result.order_events.as_slice(), + result.fills.as_slice(), )?); publish_phase_event( &mut self.strategy, @@ -685,6 +697,8 @@ where &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::Bar), + result.order_events.as_slice(), + result.fills.as_slice(), )?); decision.merge_from(self.strategy.on_bar(&StrategyContext { execution_date, @@ -701,6 +715,8 @@ where execution_date, default_stage_time(ScheduleStage::Bar), ), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), })?); publish_phase_event( &mut self.strategy, @@ -831,6 +847,8 @@ where &mut process_events, &mut self.process_event_bus, Some(tick_time), + result.order_events.as_slice(), + result.fills.as_slice(), )?; tick_decision.merge_from(self.strategy.on_tick( &StrategyContext { @@ -845,6 +863,8 @@ where process_events: &process_events, active_process_event: None, active_datetime: Some(quote.timestamp), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), }, "e, )?); @@ -919,6 +939,18 @@ where portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; let post_trade_open_orders = self.broker.open_order_views(); + let visible_order_events = result + .order_events + .iter() + .cloned() + .chain(report.order_events.iter().cloned()) + .collect::>(); + let visible_fills = result + .fills + .iter() + .cloned() + .chain(report.fill_events.iter().cloned()) + .collect::>(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -950,6 +982,8 @@ where execution_date, default_stage_time(ScheduleStage::AfterTrading), ), + order_events: visible_order_events.as_slice(), + fills: visible_fills.as_slice(), })?; publish_phase_event( &mut self.strategy, @@ -983,6 +1017,8 @@ where &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::AfterTrading), + visible_order_events.as_slice(), + visible_fills.as_slice(), )?; self.apply_strategy_directives( execution_date, @@ -1014,6 +1050,18 @@ where report.account_events.extend(close_report.account_events); report.diagnostics.extend(close_report.diagnostics); let post_close_open_orders = self.broker.open_order_views(); + let visible_order_events_after_close = result + .order_events + .iter() + .cloned() + .chain(report.order_events.iter().cloned()) + .collect::>(); + let visible_fills_after_close = result + .fills + .iter() + .cloned() + .chain(report.fill_events.iter().cloned()) + .collect::>(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -1061,6 +1109,8 @@ where execution_date, default_stage_time(ScheduleStage::Settlement), ), + order_events: visible_order_events_after_close.as_slice(), + fills: visible_fills_after_close.as_slice(), })?; publish_phase_event( &mut self.strategy, @@ -1094,6 +1144,8 @@ where &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::Settlement), + visible_order_events_after_close.as_slice(), + visible_fills_after_close.as_slice(), )?; self.apply_strategy_directives( execution_date, @@ -1620,6 +1672,8 @@ fn collect_scheduled_decisions( process_events: &mut Vec, process_event_bus: &mut ProcessEventBus, current_time: Option, + order_events: &[OrderEvent], + fills: &[FillEvent], ) -> Result { let mut combined = crate::strategy::StrategyDecision::default(); for rule in scheduler.triggered_rules_at(execution_date, stage, current_time, rules) { @@ -1652,6 +1706,8 @@ fn collect_scheduled_decisions( process_events: process_events.as_slice(), active_process_event: None, active_datetime: stage_datetime(execution_date, current_time), + order_events, + fills, }, rule, )?); @@ -1713,6 +1769,8 @@ fn publish_phase_event( process_events, active_process_event: Some(&event), active_datetime: None, + order_events: &[], + fills: &[], }; strategy.on_process_event(&event_ctx, &event)?; events.push(event); @@ -1748,6 +1806,8 @@ fn publish_process_events( process_events, active_process_event: Some(&event), active_datetime: None, + order_events: &[], + fills: &[], }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); @@ -1783,6 +1843,8 @@ fn publish_custom_process_event( process_events, active_process_event: Some(&event), active_datetime: None, + order_events: &[], + fills: &[], }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index b97583f..0715138 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -46,8 +46,8 @@ pub use scheduler::{ }; pub use strategy::{ AlgoOrderStyle, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, - JqMicroCapStrategy, OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision, - TargetPortfolioOrderPricing, + JqMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, Strategy, StrategyContext, + StrategyDecision, TargetPortfolioOrderPricing, }; pub use strategy_ai::{ ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 116a6b1..270fdad 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3912,6 +3912,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4050,6 +4052,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4166,6 +4170,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4287,6 +4293,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SH".to_string(); @@ -4391,6 +4399,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SH".to_string(); @@ -4490,6 +4500,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SH".to_string(); @@ -4607,6 +4619,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4727,6 +4741,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4852,6 +4868,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4987,6 +5005,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -5094,6 +5114,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -5229,6 +5251,8 @@ mod tests { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -5346,6 +5370,8 @@ mod tests { process_events: &process_events, active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index ab725b3..1842df1 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -9,7 +9,7 @@ use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::ChinaAShareCostModel; use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceBar, PriceField}; use crate::engine::BacktestError; -use crate::events::{OrderSide, OrderStatus, ProcessEvent}; +use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::scheduler::ScheduleRule; @@ -80,6 +80,21 @@ pub struct OpenOrderView { pub reason: String, } +#[derive(Debug, Clone)] +pub struct OrderRuntimeView { + pub order_id: u64, + pub symbol: String, + pub side: OrderSide, + pub requested_quantity: u32, + pub filled_quantity: u32, + pub unfilled_quantity: u32, + pub status: OrderStatus, + pub avg_price: f64, + pub transaction_cost: f64, + pub limit_price: f64, + pub reason: String, +} + pub struct StrategyContext<'a> { pub execution_date: NaiveDate, pub decision_date: NaiveDate, @@ -92,6 +107,8 @@ pub struct StrategyContext<'a> { pub process_events: &'a [ProcessEvent], pub active_process_event: Option<&'a ProcessEvent>, pub active_datetime: Option, + pub order_events: &'a [OrderEvent], + pub fills: &'a [FillEvent], } impl StrategyContext<'_> { @@ -215,6 +232,97 @@ impl StrategyContext<'_> { .unwrap_or(0) } + pub fn order(&self, order_id: u64) -> Option { + let fills = self + .fills + .iter() + .filter(|fill| fill.order_id == Some(order_id)) + .collect::>(); + let filled_quantity = fills.iter().map(|fill| fill.quantity).sum::(); + let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::(); + let transaction_cost = fills + .iter() + .map(|fill| fill.commission + fill.stamp_tax) + .sum::(); + let avg_price = if filled_quantity == 0 { + 0.0 + } else { + gross_amount / filled_quantity as f64 + }; + + if let Some(order) = self + .open_orders + .iter() + .find(|order| order.order_id == order_id) + { + let filled_quantity = order.filled_quantity.max(filled_quantity); + return Some(OrderRuntimeView { + order_id, + symbol: order.symbol.clone(), + side: order.side, + requested_quantity: order.requested_quantity, + filled_quantity, + unfilled_quantity: order + .unfilled_quantity + .min(order.requested_quantity.saturating_sub(filled_quantity)), + status: order.status, + avg_price: if avg_price > 0.0 { + avg_price + } else { + order.avg_price + }, + transaction_cost: if transaction_cost > 0.0 { + transaction_cost + } else { + order.transaction_cost + }, + limit_price: order.limit_price, + reason: order.reason.clone(), + }); + } + + let latest_event = self + .order_events + .iter() + .rev() + .filter(|event| event.order_id == Some(order_id)) + .next()?; + let filled_quantity = latest_event.filled_quantity.max(filled_quantity); + Some(OrderRuntimeView { + order_id, + symbol: latest_event.symbol.clone(), + side: latest_event.side, + requested_quantity: latest_event.requested_quantity, + filled_quantity, + unfilled_quantity: latest_event + .requested_quantity + .saturating_sub(filled_quantity), + status: latest_event.status, + avg_price, + transaction_cost, + limit_price: 0.0, + reason: latest_event.reason.clone(), + }) + } + + pub fn order_status(&self, order_id: u64) -> &'static str { + self.order(order_id) + .map(|order| order.status.as_str()) + .unwrap_or("") + } + + pub fn order_avg_price(&self, order_id: u64) -> f64 { + self.order(order_id) + .map(|order| order.avg_price) + .unwrap_or(0.0) + } + + pub fn order_transaction_cost(&self, order_id: u64) -> f64 { + self.order(order_id) + .map(|order| order.transaction_cost) + .unwrap_or(0.0) + } + pub fn available_sellable_qty(&self, symbol: &str, raw_sellable_qty: u32) -> u32 { raw_sellable_qty.saturating_sub(self.symbol_open_sell_quantity(symbol)) } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 9e0226b..a085669 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -204,6 +204,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualFunction { name: "get_trading_dates/get_previous_trading_date/get_next_trading_date".to_string(), signature: "ctx.get_previous_trading_date(date, n)".to_string(), detail: "交易日历 API。get_trading_dates 返回闭区间交易日;previous/next 返回相对某日向前或向后的第 n 个交易日,当前日自身不计入。".to_string() }, ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前交易日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应 RQAlpha 的 is_suspended/is_st_stock 数据源能力。".to_string() }, ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\" | \"tick\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟或 tick 返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() }, + ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 RQAlpha Order 的核心属性。".to_string() }, ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() }, ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".to_string() }, ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() }, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 4c47610..2819647 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -157,6 +157,10 @@ struct DataApiProbeStrategy { snapshots: Rc>>, } +struct OrderInspectionStrategy { + observed: Rc>>, +} + impl Strategy for ScheduledProbeStrategy { fn name(&self) -> &str { "scheduled-probe" @@ -448,6 +452,45 @@ impl Strategy for DataApiProbeStrategy { } } +impl Strategy for OrderInspectionStrategy { + fn name(&self) -> &str { + "order-inspection" + } + + fn on_day( + &mut self, + _ctx: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Shares { + symbol: "000001.SZ".to_string(), + quantity: 100, + reason: "inspect_buy".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }) + } + + fn after_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> { + let order = ctx.order(1).expect("order 1 visible after trading"); + self.observed.borrow_mut().push(format!( + "status={};filled={};unfilled={};avg={:.2};cost={:.2};symbol={};side={}", + order.status.as_str(), + order.filled_quantity, + order.unfilled_quantity, + order.avg_price, + order.transaction_cost, + order.symbol, + order.side.as_str() + )); + Ok(()) + } +} + #[test] fn engine_runs_strategy_hooks_in_daily_order() { let date1 = d(2025, 1, 2); @@ -1084,6 +1127,105 @@ fn strategy_context_exposes_rqalpha_style_data_helpers() { ); } +#[test] +fn strategy_context_exposes_final_order_runtime_view() { + let date = d(2025, 1, 2); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-02 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.4, + low: 9.9, + close: 10.2, + last_price: 10.2, + bid1: 10.19, + ask1: 10.2, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let observed = Rc::new(RefCell::new(Vec::new())); + let strategy = OrderInspectionStrategy { + observed: observed.clone(), + }; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Close, + ); + let mut engine = BacktestEngine::new( + data, + strategy, + broker, + BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ); + + engine.run().expect("backtest run"); + + assert_eq!( + observed.borrow().as_slice(), + ["status=filled;filled=100;unfilled=0;avg=10.20;cost=5.00;symbol=000001.SZ;side=buy"] + ); +} + #[test] fn engine_rejects_pending_limit_orders_at_market_close() { let date1 = d(2025, 1, 2); diff --git a/crates/fidc-core/tests/strategy_selection.rs b/crates/fidc-core/tests/strategy_selection.rs index 6a61675..9422c63 100644 --- a/crates/fidc-core/tests/strategy_selection.rs +++ b/crates/fidc-core/tests/strategy_selection.rs @@ -33,6 +33,8 @@ fn strategy_emits_target_weights_and_diagnostics() { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }) .expect("decision"); @@ -77,6 +79,8 @@ fn jq_strategy_emits_same_day_decision() { process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], + fills: &[], }) .expect("jq decision"); diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index f35d802..b8e33f5 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -67,8 +67,8 @@ current alignment pass. ### Phase 8: Order object API parity - [x] open-order status and unfilled quantity exposed to strategy runtime -- [ ] final order object lookup by order id -- [ ] order average fill price and transaction cost aggregation +- [x] final order object lookup by order id +- [x] order average fill price and transaction cost aggregation ## Execution Order @@ -84,6 +84,6 @@ current alignment pass. ## Current Step -Active implementation target: continue order object API parity after exposing -open-order status and unfilled quantity; next gaps are final order lookup and -average fill price / transaction cost aggregation by order id. +Active implementation target: continue parity audit for remaining account APIs +after order object lookup, status, unfilled quantity, average fill price, and +transaction cost aggregation are covered.