From 2857f72d843592f64101f37ecd5f0251682a2700 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 05:12:07 -0700 Subject: [PATCH] Expose process event context to strategy runtime --- crates/bt-demo/src/main.rs | 2 + crates/fidc-core/src/engine.rs | 321 +++++++++++++----- crates/fidc-core/src/events.rs | 41 +++ .../fidc-core/src/platform_expr_strategy.rs | 232 ++++++++++++- crates/fidc-core/src/strategy.rs | 99 ++++++ crates/fidc-core/src/strategy_ai.rs | 3 + crates/fidc-core/tests/engine_hooks.rs | 132 +++++++ crates/fidc-core/tests/strategy_selection.rs | 4 + 8 files changed, 742 insertions(+), 92 deletions(-) diff --git a/crates/bt-demo/src/main.rs b/crates/bt-demo/src/main.rs index 36b3922..8e83328 100644 --- a/crates/bt-demo/src/main.rs +++ b/crates/bt-demo/src/main.rs @@ -124,6 +124,8 @@ fn main() -> Result<(), Box> { data: &data, portfolio: &PortfolioState::new(10_000_000.0), open_orders: &[], + process_events: &[], + active_process_event: None, })?; eprintln!("DEBUG notes={:?}", decision.notes); eprintln!("DEBUG diagnostics={:?}", decision.diagnostics); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6232502..3acba64 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -232,31 +232,42 @@ where .map(|decision_idx| (decision_idx, execution_dates[decision_idx])); let (decision_index, decision_date) = decision_slot.unwrap_or((execution_idx, execution_date)); + let mut process_events = Vec::new(); let pre_open_orders = self.broker.open_order_views(); - let daily_context = StrategyContext { + let schedule_rules = self.strategy.schedule_rules(); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &pre_open_orders, + &mut process_events, + execution_date, + ProcessEventKind::PreBeforeTrading, + "before_trading:pre", + )?; + self.strategy.before_trading(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, open_orders: &pre_open_orders, - }; - let schedule_rules = self.strategy.schedule_rules(); - let mut process_events = Vec::new(); + process_events: &process_events, + active_process_event: None, + })?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &daily_context, - &mut process_events, execution_date, - ProcessEventKind::PreBeforeTrading, - "before_trading:pre", - )?; - self.strategy.before_trading(&daily_context)?; - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - &daily_context, + decision_date, + decision_index, + &self.data, + &portfolio, + &pre_open_orders, &mut process_events, execution_date, ProcessEventKind::BeforeTrading, @@ -265,7 +276,12 @@ where publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &daily_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &pre_open_orders, &mut process_events, execution_date, ProcessEventKind::PostBeforeTrading, @@ -274,7 +290,12 @@ where publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &daily_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &pre_open_orders, &mut process_events, execution_date, ProcessEventKind::PreOpenAuction, @@ -286,15 +307,33 @@ where execution_date, ScheduleStage::OpenAuction, &schedule_rules, - &daily_context, + decision_date, + decision_index, + &self.data, + &portfolio, + &pre_open_orders, &mut process_events, &mut self.process_event_bus, )?; - auction_decision.merge_from(self.strategy.open_auction(&daily_context)?); + auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + open_orders: &pre_open_orders, + process_events: &process_events, + active_process_event: None, + })?); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &daily_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &pre_open_orders, &mut process_events, execution_date, ProcessEventKind::OpenAuction, @@ -307,25 +346,27 @@ where &auction_decision, )?; let post_auction_open_orders = self.broker.open_order_views(); - let post_auction_context = StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - open_orders: &post_auction_open_orders, - }; publish_process_events( &mut self.strategy, &mut self.process_event_bus, - &post_auction_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_auction_open_orders, &mut process_events, &mut report.process_events, )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_auction_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_auction_open_orders, &mut process_events, execution_date, ProcessEventKind::PostOpenAuction, @@ -335,7 +376,12 @@ where publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_auction_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_auction_open_orders, &mut process_events, execution_date, ProcessEventKind::PreOnDay, @@ -351,6 +397,8 @@ where data: &self.data, portfolio: &portfolio, open_orders: &on_day_open_orders, + process_events: &process_events, + active_process_event: None, }) }) .transpose()? @@ -361,29 +409,23 @@ where execution_date, ScheduleStage::OnDay, &schedule_rules, - &StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - open_orders: &on_day_open_orders, - }, + decision_date, + decision_index, + &self.data, + &portfolio, + &on_day_open_orders, &mut process_events, &mut self.process_event_bus, )?); - let on_day_context = StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - open_orders: &on_day_open_orders, - }; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &on_day_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &on_day_open_orders, &mut process_events, execution_date, ProcessEventKind::OnDay, @@ -394,18 +436,15 @@ where self.broker .execute(execution_date, &mut portfolio, &self.data, &decision)?; let post_intraday_open_orders = self.broker.open_order_views(); - let post_intraday_context = StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - open_orders: &post_intraday_open_orders, - }; publish_process_events( &mut self.strategy, &mut self.process_event_bus, - &post_intraday_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_intraday_open_orders, &mut process_events, &mut intraday_report.process_events, )?; @@ -419,7 +458,12 @@ where publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_intraday_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_intraday_open_orders, &mut process_events, execution_date, ProcessEventKind::PostOnDay, @@ -429,28 +473,39 @@ where portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; let post_trade_open_orders = self.broker.open_order_views(); - let post_trade_context = StrategyContext { + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_trade_open_orders, + &mut process_events, + execution_date, + ProcessEventKind::PreAfterTrading, + "after_trading:pre", + )?; + self.strategy.after_trading(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, open_orders: &post_trade_open_orders, - }; + process_events: &process_events, + active_process_event: None, + })?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_trade_context, - &mut process_events, execution_date, - ProcessEventKind::PreAfterTrading, - "after_trading:pre", - )?; - self.strategy.after_trading(&post_trade_context)?; - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - &post_trade_context, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_trade_open_orders, &mut process_events, execution_date, ProcessEventKind::AfterTrading, @@ -460,7 +515,12 @@ where publish_process_events( &mut self.strategy, &mut self.process_event_bus, - &post_trade_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_trade_open_orders, &mut process_events, &mut close_report.process_events, )?; @@ -470,18 +530,15 @@ 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 post_close_context = StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - open_orders: &post_close_open_orders, - }; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_close_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_close_open_orders, &mut process_events, execution_date, ProcessEventKind::PostAfterTrading, @@ -490,17 +547,36 @@ where publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_close_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_close_open_orders, &mut process_events, execution_date, ProcessEventKind::PreSettlement, "settlement:pre", )?; - self.strategy.on_settlement(&post_close_context)?; + self.strategy.on_settlement(&StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + open_orders: &post_close_open_orders, + process_events: &process_events, + active_process_event: None, + })?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_close_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_close_open_orders, &mut process_events, execution_date, ProcessEventKind::Settlement, @@ -509,7 +585,12 @@ where publish_phase_event( &mut self.strategy, &mut self.process_event_bus, - &post_close_context, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + &post_close_open_orders, &mut process_events, execution_date, ProcessEventKind::PostSettlement, @@ -1005,7 +1086,11 @@ fn collect_scheduled_decisions( execution_date: NaiveDate, stage: ScheduleStage, rules: &[ScheduleRule], - ctx: &StrategyContext<'_>, + decision_date: NaiveDate, + decision_index: usize, + data: &crate::data::DataSet, + portfolio: &PortfolioState, + open_orders: &[crate::strategy::OpenOrderView], process_events: &mut Vec, process_event_bus: &mut ProcessEventBus, ) -> Result { @@ -1014,17 +1099,39 @@ fn collect_scheduled_decisions( publish_phase_event( strategy, process_event_bus, - ctx, + execution_date, + decision_date, + decision_index, + data, + portfolio, + open_orders, process_events, execution_date, ProcessEventKind::PreScheduled, format!("scheduled:{}:{}:pre", rule.name, stage_label(stage)), )?; - combined.merge_from(strategy.on_scheduled(ctx, rule)?); + combined.merge_from(strategy.on_scheduled( + &StrategyContext { + execution_date, + decision_date, + decision_index, + data, + portfolio, + open_orders, + process_events: process_events.as_slice(), + active_process_event: None, + }, + rule, + )?); publish_phase_event( strategy, process_event_bus, - ctx, + execution_date, + decision_date, + decision_index, + data, + portfolio, + open_orders, process_events, execution_date, ProcessEventKind::PostScheduled, @@ -1037,7 +1144,12 @@ fn collect_scheduled_decisions( fn publish_phase_event( strategy: &mut S, process_event_bus: &mut ProcessEventBus, - ctx: &StrategyContext<'_>, + execution_date: NaiveDate, + decision_date: NaiveDate, + decision_index: usize, + data: &crate::data::DataSet, + portfolio: &PortfolioState, + open_orders: &[crate::strategy::OpenOrderView], events: &mut Vec, date: NaiveDate, kind: ProcessEventKind, @@ -1052,7 +1164,18 @@ fn publish_phase_event( detail: detail.into(), }; process_event_bus.publish(&event); - strategy.on_process_event(ctx, &event)?; + let process_events = events.as_slice(); + let event_ctx = StrategyContext { + execution_date, + decision_date, + decision_index, + data, + portfolio, + open_orders, + process_events, + active_process_event: Some(&event), + }; + strategy.on_process_event(&event_ctx, &event)?; events.push(event); Ok(()) } @@ -1060,13 +1183,29 @@ fn publish_phase_event( fn publish_process_events( strategy: &mut S, process_event_bus: &mut ProcessEventBus, - ctx: &StrategyContext<'_>, + execution_date: NaiveDate, + decision_date: NaiveDate, + decision_index: usize, + data: &crate::data::DataSet, + portfolio: &PortfolioState, + open_orders: &[crate::strategy::OpenOrderView], target: &mut Vec, incoming: &mut Vec, ) -> Result<(), BacktestError> { for event in incoming.drain(..) { process_event_bus.publish(&event); - strategy.on_process_event(ctx, &event)?; + let process_events = target.as_slice(); + let event_ctx = StrategyContext { + execution_date, + decision_date, + decision_index, + data, + portfolio, + open_orders, + process_events, + active_process_event: Some(&event), + }; + strategy.on_process_event(&event_ctx, &event)?; target.push(event); } Ok(()) diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 36e861d..6b3f062 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -29,6 +29,15 @@ pub enum OrderSide { Sell, } +impl OrderSide { + pub fn as_str(&self) -> &'static str { + match self { + Self::Buy => "buy", + Self::Sell => "sell", + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum OrderStatus { Pending, @@ -120,6 +129,38 @@ pub enum ProcessEventKind { Trade, } +impl ProcessEventKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::PreBeforeTrading => "pre_before_trading", + Self::BeforeTrading => "before_trading", + Self::PostBeforeTrading => "post_before_trading", + Self::PreOpenAuction => "pre_open_auction", + Self::OpenAuction => "open_auction", + Self::PostOpenAuction => "post_open_auction", + Self::PreScheduled => "pre_scheduled", + Self::PostScheduled => "post_scheduled", + Self::PreOnDay => "pre_on_day", + Self::OnDay => "on_day", + Self::PostOnDay => "post_on_day", + Self::PreAfterTrading => "pre_after_trading", + Self::AfterTrading => "after_trading", + Self::PostAfterTrading => "post_after_trading", + Self::PreSettlement => "pre_settlement", + Self::Settlement => "settlement", + Self::PostSettlement => "post_settlement", + Self::OrderPendingNew => "order_pending_new", + Self::OrderCreationPass => "order_creation_pass", + Self::OrderCreationReject => "order_creation_reject", + Self::OrderPendingCancel => "order_pending_cancel", + Self::OrderCancellationPass => "order_cancellation_pass", + Self::OrderCancellationReject => "order_cancellation_reject", + Self::OrderUnsolicitedUpdate => "order_unsolicited_update", + Self::Trade => "trade", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProcessEvent { #[serde(with = "date_format")] diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2d20932..c19f593 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -418,6 +418,19 @@ impl PlatformExprStrategy { "weekday", "is_month_start", "is_month_end", + "has_process_events", + "process_event_count", + "current_process_kind", + "current_process_order_id", + "current_process_symbol", + "current_process_side", + "current_process_detail", + "latest_process_kind", + "latest_process_order_id", + "latest_process_symbol", + "latest_process_side", + "latest_process_detail", + "process_event_counts", "day_factors", "symbol", "market_cap", @@ -1156,6 +1169,53 @@ impl PlatformExprStrategy { scope.push("open_buy_qty", ctx.open_buy_quantity() as i64); scope.push("open_sell_qty", ctx.open_sell_quantity() as i64); scope.push("latest_open_order_id", ctx.latest_open_order_id() as i64); + scope.push("has_process_events", ctx.has_process_events()); + scope.push("process_event_count", ctx.process_event_count() as i64); + scope.push( + "current_process_kind", + ctx.current_process_event_kind().to_string(), + ); + scope.push( + "current_process_order_id", + ctx.current_process_event_order_id() as i64, + ); + scope.push( + "current_process_symbol", + ctx.current_process_event_symbol().to_string(), + ); + scope.push( + "current_process_side", + ctx.current_process_event_side().to_string(), + ); + scope.push( + "current_process_detail", + ctx.current_process_event_detail().to_string(), + ); + scope.push( + "latest_process_kind", + ctx.latest_process_event_kind().to_string(), + ); + scope.push( + "latest_process_order_id", + ctx.latest_process_event_order_id() as i64, + ); + scope.push( + "latest_process_symbol", + ctx.latest_process_event_symbol().to_string(), + ); + scope.push( + "latest_process_side", + ctx.latest_process_event_side().to_string(), + ); + scope.push( + "latest_process_detail", + ctx.latest_process_event_detail().to_string(), + ); + let mut process_event_counts = Map::new(); + for (key, value) in ctx.process_event_counts() { + process_event_counts.insert(key.into(), Dynamic::from(value)); + } + scope.push("process_event_counts", process_event_counts.clone()); let mut day_factors = Map::new(); day_factors.insert("signal_open".into(), Dynamic::from(day.signal_open)); day_factors.insert("signal_close".into(), Dynamic::from(day.signal_close)); @@ -1224,6 +1284,58 @@ impl PlatformExprStrategy { "latest_open_order_id".into(), Dynamic::from(ctx.latest_open_order_id() as i64), ); + day_factors.insert( + "has_process_events".into(), + Dynamic::from(ctx.has_process_events()), + ); + day_factors.insert( + "process_event_count".into(), + Dynamic::from(ctx.process_event_count() as i64), + ); + day_factors.insert( + "current_process_kind".into(), + Dynamic::from(ctx.current_process_event_kind().to_string()), + ); + day_factors.insert( + "current_process_order_id".into(), + Dynamic::from(ctx.current_process_event_order_id() as i64), + ); + day_factors.insert( + "current_process_symbol".into(), + Dynamic::from(ctx.current_process_event_symbol().to_string()), + ); + day_factors.insert( + "current_process_side".into(), + Dynamic::from(ctx.current_process_event_side().to_string()), + ); + day_factors.insert( + "current_process_detail".into(), + Dynamic::from(ctx.current_process_event_detail().to_string()), + ); + day_factors.insert( + "latest_process_kind".into(), + Dynamic::from(ctx.latest_process_event_kind().to_string()), + ); + day_factors.insert( + "latest_process_order_id".into(), + Dynamic::from(ctx.latest_process_event_order_id() as i64), + ); + day_factors.insert( + "latest_process_symbol".into(), + Dynamic::from(ctx.latest_process_event_symbol().to_string()), + ); + day_factors.insert( + "latest_process_side".into(), + Dynamic::from(ctx.latest_process_event_side().to_string()), + ); + day_factors.insert( + "latest_process_detail".into(), + Dynamic::from(ctx.latest_process_event_detail().to_string()), + ); + day_factors.insert( + "process_event_counts".into(), + Dynamic::from(process_event_counts), + ); scope.push("day_factors", day_factors); if let Some(stock) = stock { let at_upper_limit = @@ -2965,7 +3077,8 @@ mod tests { }; use crate::{ BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - Instrument, OpenOrderView, PortfolioState, Strategy, StrategyContext, TradingCalendar, + Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, Strategy, + StrategyContext, TradingCalendar, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -3080,6 +3193,8 @@ mod tests { data: &data, portfolio: &portfolio, open_orders: &[], + process_events: &[], + active_process_event: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -3210,6 +3325,8 @@ mod tests { data: &data, portfolio: &portfolio, open_orders: &[], + process_events: &[], + active_process_event: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -3321,6 +3438,8 @@ mod tests { data: &data, portfolio: &portfolio, open_orders: &[], + process_events: &[], + active_process_event: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -3433,6 +3552,8 @@ mod tests { data: &data, portfolio: &portfolio, open_orders: &open_orders, + process_events: &[], + active_process_event: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -3552,6 +3673,8 @@ mod tests { data: &data, portfolio: &portfolio, open_orders: &open_orders, + process_events: &[], + active_process_event: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -3577,4 +3700,111 @@ mod tests { other => panic!("unexpected cancel intent: {other:?}"), } } + + #[test] + fn platform_strategy_exposes_process_event_runtime_fields() { + let date = d(2025, 2, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Ping An Bank".to_string(), + board: "SZSE".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.9, + close: 10.1, + last_price: 10.05, + bid1: 10.04, + ask1: 10.05, + prev_close: 9.95, + volume: 1_000_000, + tick_volume: 5_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.94, + lower_limit: 8.96, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(22.0), + effective_turnover_ratio: Some(18.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: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let process_events = vec![ProcessEvent { + date, + kind: ProcessEventKind::OrderCreationReject, + order_id: Some(55), + symbol: Some("000001.SZ".to_string()), + side: Some(crate::OrderSide::Buy), + detail: "open at or above upper limit".to_string(), + }]; + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + open_orders: &[], + process_events: &process_events, + active_process_event: None, + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::Value, + symbol: "000001.SZ".to_string(), + amount_expr: "cash * 0.1".to_string(), + limit_price_expr: None, + when_expr: Some( + "has_process_events && process_event_count == 1 && latest_process_kind == \"order_creation_reject\" && latest_process_order_id == 55 && latest_process_symbol == \"000001.SZ\" && latest_process_side == \"buy\" && process_event_counts[\"order_creation_reject\"] == 1".to_string(), + ), + reason: "process_event_aware_entry".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + assert_eq!(decision.order_intents.len(), 1); + } } diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 0ae90af..d4f3a28 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -70,6 +70,8 @@ pub struct StrategyContext<'a> { pub data: &'a DataSet, pub portfolio: &'a PortfolioState, pub open_orders: &'a [OpenOrderView], + pub process_events: &'a [ProcessEvent], + pub active_process_event: Option<&'a ProcessEvent>, } impl StrategyContext<'_> { @@ -154,6 +156,103 @@ impl StrategyContext<'_> { pub fn available_sellable_qty(&self, symbol: &str, raw_sellable_qty: u32) -> u32 { raw_sellable_qty.saturating_sub(self.symbol_open_sell_quantity(symbol)) } + + pub fn has_process_events(&self) -> bool { + !self.process_events.is_empty() || self.active_process_event.is_some() + } + + pub fn process_event_count(&self) -> usize { + self.process_events.len() + usize::from(self.active_process_event.is_some()) + } + + pub fn process_event_count_by_kind(&self, kind: crate::events::ProcessEventKind) -> usize { + self.process_events + .iter() + .filter(|event| event.kind == kind) + .count() + + usize::from( + self.active_process_event + .is_some_and(|event| event.kind == kind), + ) + } + + pub fn latest_process_event(&self) -> Option<&ProcessEvent> { + self.active_process_event + .or_else(|| self.process_events.last()) + } + + pub fn latest_process_event_kind(&self) -> &'static str { + self.latest_process_event() + .map(|event| event.kind.as_str()) + .unwrap_or("") + } + + pub fn latest_process_event_order_id(&self) -> u64 { + self.latest_process_event() + .and_then(|event| event.order_id) + .unwrap_or(0) + } + + pub fn latest_process_event_symbol(&self) -> &str { + self.latest_process_event() + .and_then(|event| event.symbol.as_deref()) + .unwrap_or("") + } + + pub fn latest_process_event_side(&self) -> &'static str { + self.latest_process_event() + .and_then(|event| event.side.as_ref()) + .map(OrderSide::as_str) + .unwrap_or("") + } + + pub fn latest_process_event_detail(&self) -> &str { + self.latest_process_event() + .map(|event| event.detail.as_str()) + .unwrap_or("") + } + + pub fn current_process_event_kind(&self) -> &'static str { + self.active_process_event + .map(|event| event.kind.as_str()) + .unwrap_or("") + } + + pub fn current_process_event_order_id(&self) -> u64 { + self.active_process_event + .and_then(|event| event.order_id) + .unwrap_or(0) + } + + pub fn current_process_event_symbol(&self) -> &str { + self.active_process_event + .and_then(|event| event.symbol.as_deref()) + .unwrap_or("") + } + + pub fn current_process_event_side(&self) -> &'static str { + self.active_process_event + .and_then(|event| event.side.as_ref()) + .map(OrderSide::as_str) + .unwrap_or("") + } + + pub fn current_process_event_detail(&self) -> &str { + self.active_process_event + .map(|event| event.detail.as_str()) + .unwrap_or("") + } + + pub fn process_event_counts(&self) -> BTreeMap { + let mut counts = BTreeMap::::new(); + for event in self.process_events { + *counts.entry(event.kind.as_str().to_string()).or_insert(0) += 1; + } + if let Some(event) = self.active_process_event { + *counts.entry(event.kind.as_str().to_string()).or_insert(0) += 1; + } + counts + } } #[derive(Debug, Clone, Default)] diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 454daef..220c39d 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -140,6 +140,9 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualField { name: "position_count/max_positions/refresh_rate".to_string(), field_type: "int".to_string(), detail: "仓位计数与调仓周期。".to_string() }, ManualField { name: "has_open_orders/open_order_count/open_buy_order_count/open_sell_order_count".to_string(), field_type: "bool/int".to_string(), detail: "当前阶段挂单簿摘要。".to_string() }, ManualField { name: "open_buy_qty/open_sell_qty/latest_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前阶段未成交买卖挂单的剩余数量汇总,以及最近一笔挂单 id。".to_string() }, + ManualField { name: "has_process_events/process_event_count/process_event_counts".to_string(), field_type: "bool/int/map".to_string(), detail: "当前阶段可见的过程事件摘要;process_event_counts[\"trade\"] 这类写法可直接读取当天事件计数。".to_string() }, + ManualField { name: "current_process_kind/current_process_order_id/current_process_symbol/current_process_side/current_process_detail".to_string(), field_type: "string/int".to_string(), detail: "当前正在回调的过程事件上下文;没有活动事件时为空字符串或 0。".to_string() }, + ManualField { name: "latest_process_kind/latest_process_order_id/latest_process_symbol/latest_process_side/latest_process_detail".to_string(), field_type: "string/int".to_string(), detail: "当前阶段最近一条过程事件的摘要,可用于让 on_day/open_auction 逻辑响应 earlier lifecycle 或订单事件。".to_string() }, ManualField { name: "year/month/quarter/day_of_month/day_of_year/week_of_year/weekday".to_string(), field_type: "int".to_string(), detail: "日期维度字段。".to_string() }, ManualField { name: "is_month_start/is_month_end".to_string(), field_type: "bool".to_string(), detail: "月初/月末标记。".to_string() }, ], diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index e1826ff..a4df8a2 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -121,6 +121,10 @@ struct ScheduledProbeStrategy { process_log: Rc>>, } +struct ProcessContextProbeStrategy { + snapshots: Rc>>, +} + struct LimitCarryStrategy { issued: bool, } @@ -197,6 +201,33 @@ impl Strategy for LimitCarryStrategy { } } +impl Strategy for ProcessContextProbeStrategy { + fn name(&self) -> &str { + "process-context-probe" + } + + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + _event: &fidc_core::ProcessEvent, + ) -> Result<(), fidc_core::BacktestError> { + self.snapshots.borrow_mut().push(format!( + "{}:{}:{}", + ctx.current_process_event_kind(), + ctx.latest_process_event_kind(), + ctx.process_event_count() + )); + Ok(()) + } + + fn on_day( + &mut self, + _ctx: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision::default()) + } +} + #[test] fn engine_runs_strategy_hooks_in_daily_order() { let date1 = d(2025, 1, 2); @@ -1133,3 +1164,104 @@ fn engine_dispatches_process_events_to_external_bus_listeners() { .any(|item| { item == "PostScheduled:scheduled:first_trading_day_on_day:on_day:post" }) ); } + +#[test] +fn engine_exposes_current_process_context_to_strategies() { + 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.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + 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 snapshots = Rc::new(RefCell::new(Vec::new())); + let strategy = ProcessContextProbeStrategy { + snapshots: snapshots.clone(), + }; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ); + let mut engine = BacktestEngine::new( + data, + strategy, + broker, + BacktestConfig { + initial_cash: 100_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::Last, + }, + ); + + engine.run().expect("backtest run"); + + let snapshots = snapshots.borrow(); + assert_eq!( + snapshots.first().map(String::as_str), + Some("pre_before_trading:pre_before_trading:1") + ); + assert!(snapshots.iter().any(|item| item == "on_day:on_day:8")); +} diff --git a/crates/fidc-core/tests/strategy_selection.rs b/crates/fidc-core/tests/strategy_selection.rs index 527229d..2fc2706 100644 --- a/crates/fidc-core/tests/strategy_selection.rs +++ b/crates/fidc-core/tests/strategy_selection.rs @@ -26,6 +26,8 @@ fn strategy_emits_target_weights_and_diagnostics() { data: &data, portfolio: &portfolio, open_orders: &[], + process_events: &[], + active_process_event: None, }) .expect("decision"); @@ -64,6 +66,8 @@ fn jq_strategy_emits_same_day_decision() { data: &data, portfolio: &portfolio, open_orders: &[], + process_events: &[], + active_process_event: None, }) .expect("jq decision");