From c3ef0bd49a1b7d840dc25609f2ad09e10480f788 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 19:29:12 -0700 Subject: [PATCH] Expose strategy runtime data APIs --- crates/fidc-core/src/calendar.rs | 37 +++ crates/fidc-core/src/data.rs | 229 +++++++++++++++ crates/fidc-core/src/engine.rs | 36 +++ .../fidc-core/src/platform_expr_strategy.rs | 12 + crates/fidc-core/src/strategy.rs | 87 +++++- crates/fidc-core/src/strategy_ai.rs | 4 + crates/fidc-core/tests/engine_hooks.rs | 260 +++++++++++++++++- crates/fidc-core/tests/strategy_selection.rs | 2 + docs/rqalpha-gap-roadmap.md | 17 +- 9 files changed, 678 insertions(+), 6 deletions(-) diff --git a/crates/fidc-core/src/calendar.rs b/crates/fidc-core/src/calendar.rs index 309668d..b8aaf3f 100644 --- a/crates/fidc-core/src/calendar.rs +++ b/crates/fidc-core/src/calendar.rs @@ -49,6 +49,43 @@ impl TradingCalendar { .and_then(|prev| self.days.get(prev).copied()) } + pub fn previous_trading_date(&self, date: NaiveDate, n: usize) -> Option { + if n == 0 { + return None; + } + let before_count = match self.days.binary_search(&date) { + Ok(idx) => idx, + Err(idx) => idx, + }; + before_count + .checked_sub(n) + .and_then(|idx| self.days.get(idx).copied()) + } + + pub fn next_trading_date(&self, date: NaiveDate, n: usize) -> Option { + if n == 0 { + return None; + } + let first_after = match self.days.binary_search(&date) { + Ok(idx) => idx.saturating_add(1), + Err(idx) => idx, + }; + first_after + .checked_add(n.saturating_sub(1)) + .and_then(|idx| self.days.get(idx).copied()) + } + + pub fn trading_dates(&self, start: NaiveDate, end: NaiveDate) -> Vec { + if start > end { + return Vec::new(); + } + self.days + .iter() + .copied() + .filter(|date| *date >= start && *date <= end) + .collect() + } + pub fn trailing_days(&self, end: NaiveDate, lookback: usize) -> Vec { let Some(end_idx) = self.index_of(end) else { return Vec::new(); diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 54a113f..cc5f758 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -776,6 +776,12 @@ impl DataSet { &self.instruments } + pub fn all_instruments(&self) -> Vec<&Instrument> { + let mut instruments = self.instruments.values().collect::>(); + instruments.sort_by(|left, right| left.symbol.cmp(&right.symbol)); + instruments + } + pub fn instrument(&self, symbol: &str) -> Option<&Instrument> { self.instruments.get(symbol) } @@ -829,6 +835,118 @@ impl DataSet { self.benchmark_by_date.values().cloned().collect() } + pub fn history_bars( + &self, + date: NaiveDate, + symbol: &str, + bar_count: usize, + frequency: &str, + field: &str, + include_now: bool, + ) -> Vec { + self.history_bars_at(date, None, symbol, bar_count, frequency, field, include_now) + } + + pub fn history_bars_at( + &self, + date: NaiveDate, + active_datetime: Option, + symbol: &str, + bar_count: usize, + frequency: &str, + field: &str, + include_now: bool, + ) -> Vec { + if bar_count == 0 { + return Vec::new(); + } + match normalize_history_frequency(frequency).as_deref() { + Some("1d") => self.history_daily_values(date, symbol, bar_count, field, include_now), + Some("1m") | Some("tick") => self.history_intraday_values( + date, + active_datetime, + symbol, + bar_count, + field, + include_now, + ), + _ => Vec::new(), + } + } + + pub fn history_daily_snapshots( + &self, + date: NaiveDate, + symbol: &str, + bar_count: usize, + include_now: bool, + ) -> Vec { + if bar_count == 0 { + return Vec::new(); + } + let mut snapshots = self + .market_by_date + .iter() + .filter(|(day, _)| { + if include_now { + **day <= date + } else { + **day < date + } + }) + .flat_map(|(_, rows)| rows.iter()) + .filter(|row| row.symbol == symbol) + .cloned() + .collect::>(); + snapshots.sort_by_key(|row| row.date); + take_last(snapshots, bar_count) + } + + pub fn history_intraday_quotes( + &self, + date: NaiveDate, + symbol: &str, + bar_count: usize, + include_now: bool, + ) -> Vec { + self.history_intraday_quotes_at(date, None, symbol, bar_count, include_now) + } + + pub fn history_intraday_quotes_at( + &self, + date: NaiveDate, + active_datetime: Option, + symbol: &str, + bar_count: usize, + include_now: bool, + ) -> Vec { + if bar_count == 0 { + return Vec::new(); + } + let mut quotes = self + .execution_quotes_index + .iter() + .filter(|((_, quote_symbol), _)| quote_symbol == symbol) + .flat_map(|(_, rows)| rows.iter()) + .filter(|quote| intraday_quote_visible(quote, date, active_datetime, include_now)) + .cloned() + .collect::>(); + quotes.sort_by_key(|quote| quote.timestamp); + take_last(quotes, bar_count) + } + + pub fn trading_dates(&self, start: NaiveDate, end: NaiveDate) -> Vec { + self.calendar.trading_dates(start, end) + } + + pub fn previous_trading_date(&self, date: NaiveDate, n: usize) -> Option { + self.calendar.previous_trading_date(date, n) + } + + pub fn next_trading_date(&self, date: NaiveDate, n: usize) -> Option { + self.calendar.next_trading_date(date, n) + } + pub fn price(&self, date: NaiveDate, symbol: &str, field: PriceField) -> Option { let snapshot = self.market(date, symbol)?; Some(snapshot.price(field)) @@ -900,6 +1018,35 @@ impl DataSet { .unwrap_or_default() } + fn history_daily_values( + &self, + date: NaiveDate, + symbol: &str, + bar_count: usize, + field: &str, + include_now: bool, + ) -> Vec { + self.history_daily_snapshots(date, symbol, bar_count, include_now) + .into_iter() + .filter_map(|row| daily_market_numeric_value(&row, field)) + .collect() + } + + fn history_intraday_values( + &self, + date: NaiveDate, + active_datetime: Option, + symbol: &str, + bar_count: usize, + field: &str, + include_now: bool, + ) -> Vec { + self.history_intraday_quotes_at(date, active_datetime, symbol, bar_count, include_now) + .into_iter() + .filter_map(|row| intraday_quote_numeric_value(&row, field)) + .collect() + } + pub fn market_decision_close(&self, date: NaiveDate, symbol: &str) -> Option { self.market_series_by_symbol .get(symbol) @@ -1170,6 +1317,88 @@ fn factor_numeric_value(snapshot: &DailyFactorSnapshot, field: &str) -> Option Option { + match normalize_field(field).as_str() { + "day_open" | "dayopen" => Some(snapshot.day_open), + "open" => Some(snapshot.open), + "high" => Some(snapshot.high), + "low" => Some(snapshot.low), + "close" | "price" => Some(snapshot.close), + "last" | "last_price" => Some(snapshot.last_price), + "prev_close" | "pre_close" => Some(snapshot.prev_close), + "volume" => Some(snapshot.volume as f64), + "tick_volume" => Some(snapshot.tick_volume as f64), + "bid1" => Some(snapshot.bid1), + "ask1" => Some(snapshot.ask1), + "bid1_volume" => Some(snapshot.bid1_volume as f64), + "ask1_volume" => Some(snapshot.ask1_volume as f64), + "upper_limit" => Some(snapshot.upper_limit), + "lower_limit" => Some(snapshot.lower_limit), + "price_tick" => Some(snapshot.price_tick), + _ => None, + } +} + +fn intraday_quote_numeric_value(snapshot: &IntradayExecutionQuote, field: &str) -> Option { + match normalize_field(field).as_str() { + "last" | "last_price" | "close" | "price" => Some(snapshot.last_price), + "bid1" => Some(snapshot.bid1), + "ask1" => Some(snapshot.ask1), + "bid1_volume" => Some(snapshot.bid1_volume as f64), + "ask1_volume" => Some(snapshot.ask1_volume as f64), + "volume" | "volume_delta" => Some(snapshot.volume_delta as f64), + "amount" | "amount_delta" | "total_turnover" => Some(snapshot.amount_delta), + _ => None, + } +} + +fn intraday_quote_visible( + quote: &IntradayExecutionQuote, + date: NaiveDate, + active_datetime: Option, + include_now: bool, +) -> bool { + if quote.date < date { + return true; + } + if quote.date > date { + return false; + } + let Some(active_datetime) = active_datetime.filter(|value| value.date() == date) else { + return include_now; + }; + if include_now { + quote.timestamp <= active_datetime + } else { + quote.timestamp < active_datetime + } +} + +fn normalize_field(field: &str) -> String { + field + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_ascii_lowercase() +} + +fn normalize_history_frequency(frequency: &str) -> Option { + let normalized = normalize_field(frequency); + match normalized.as_str() { + "1d" | "d" | "day" | "daily" => Some("1d".to_string()), + "1m" | "m" | "minute" | "min" => Some("1m".to_string()), + "tick" | "t" => Some("tick".to_string()), + _ => None, + } +} + +fn take_last(mut rows: Vec, count: usize) -> Vec { + if rows.len() <= count { + return rows; + } + rows.split_off(rows.len() - count) +} + fn read_candidates(path: &Path) -> Result, DataSetError> { let rows = read_rows(path)?; let mut snapshots = Vec::new(); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 8326d73..be36030 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -406,6 +406,10 @@ where subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: stage_datetime( + execution_date, + default_stage_time(ScheduleStage::BeforeTrading), + ), })?; publish_phase_event( &mut self.strategy, @@ -509,6 +513,10 @@ where subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: stage_datetime( + execution_date, + default_stage_time(ScheduleStage::OpenAuction), + ), })?); publish_phase_event( &mut self.strategy, @@ -603,6 +611,10 @@ where subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: stage_datetime( + execution_date, + default_stage_time(ScheduleStage::OnDay), + ), }) }) .transpose()? @@ -685,6 +697,10 @@ where subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: stage_datetime( + execution_date, + default_stage_time(ScheduleStage::Bar), + ), })?); publish_phase_event( &mut self.strategy, @@ -828,6 +844,7 @@ where subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: Some(quote.timestamp), }, "e, )?); @@ -929,6 +946,10 @@ where subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: stage_datetime( + execution_date, + default_stage_time(ScheduleStage::AfterTrading), + ), })?; publish_phase_event( &mut self.strategy, @@ -1036,6 +1057,10 @@ where subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: stage_datetime( + execution_date, + default_stage_time(ScheduleStage::Settlement), + ), })?; publish_phase_event( &mut self.strategy, @@ -1626,6 +1651,7 @@ fn collect_scheduled_decisions( subscriptions, process_events: process_events.as_slice(), active_process_event: None, + active_datetime: stage_datetime(execution_date, current_time), }, rule, )?); @@ -1686,6 +1712,7 @@ fn publish_phase_event( subscriptions, process_events, active_process_event: Some(&event), + active_datetime: None, }; strategy.on_process_event(&event_ctx, &event)?; events.push(event); @@ -1720,6 +1747,7 @@ fn publish_process_events( subscriptions, process_events, active_process_event: Some(&event), + active_datetime: None, }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); @@ -1754,6 +1782,7 @@ fn publish_custom_process_event( subscriptions, process_events, active_process_event: Some(&event), + active_datetime: None, }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); @@ -1772,6 +1801,13 @@ fn stage_label(stage: ScheduleStage) -> &'static str { } } +fn stage_datetime( + date: NaiveDate, + time: Option, +) -> Option { + time.map(|value| date.and_time(value)) +} + fn should_run_tick_events(rules: &[ScheduleRule], subscriptions: &BTreeSet) -> bool { !subscriptions.is_empty() || rules.iter().any(|rule| rule.stage == ScheduleStage::Tick) } diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 34adc4a..a55ddb8 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3791,6 +3791,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -3928,6 +3929,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4043,6 +4045,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4163,6 +4166,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SH".to_string(); @@ -4266,6 +4270,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SH".to_string(); @@ -4364,6 +4369,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SH".to_string(); @@ -4480,6 +4486,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4599,6 +4606,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4719,6 +4727,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4845,6 +4854,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -4951,6 +4961,7 @@ mod tests { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }; let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.signal_symbol = "000001.SZ".to_string(); @@ -5081,6 +5092,7 @@ mod tests { subscriptions: &subscriptions, process_events: &process_events, active_process_event: None, + active_datetime: None, }; 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 b059ea8..7aa7815 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -7,9 +7,10 @@ use std::sync::OnceLock; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::ChinaAShareCostModel; -use crate::data::{DataSet, IntradayExecutionQuote, PriceField}; +use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceField}; use crate::engine::BacktestError; use crate::events::{OrderSide, ProcessEvent}; +use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::scheduler::ScheduleRule; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; @@ -86,9 +87,18 @@ pub struct StrategyContext<'a> { pub subscriptions: &'a BTreeSet, pub process_events: &'a [ProcessEvent], pub active_process_event: Option<&'a ProcessEvent>, + pub active_datetime: Option, } impl StrategyContext<'_> { + pub fn current_datetime(&self) -> Option { + self.active_datetime + } + + pub fn current_time(&self) -> Option { + self.active_datetime.map(|value| value.time()) + } + pub fn has_open_orders(&self) -> bool { !self.open_orders.is_empty() } @@ -200,6 +210,81 @@ impl StrategyContext<'_> { } } + pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> { + self.data.market(self.execution_date, symbol) + } + + pub fn history_bars( + &self, + symbol: &str, + bar_count: usize, + frequency: &str, + field: &str, + include_now: bool, + ) -> Vec { + self.data.history_bars_at( + self.execution_date, + self.active_datetime, + symbol, + bar_count, + frequency, + field, + include_now, + ) + } + + pub fn history_daily_snapshots( + &self, + symbol: &str, + bar_count: usize, + include_now: bool, + ) -> Vec { + self.data + .history_daily_snapshots(self.execution_date, symbol, bar_count, include_now) + } + + pub fn history_intraday_quotes( + &self, + symbol: &str, + bar_count: usize, + include_now: bool, + ) -> Vec { + self.data.history_intraday_quotes_at( + self.execution_date, + self.active_datetime, + symbol, + bar_count, + include_now, + ) + } + + pub fn instrument(&self, symbol: &str) -> Option<&Instrument> { + self.data.instrument(symbol) + } + + pub fn instruments(&self, symbols: &[&str]) -> Vec<&Instrument> { + symbols + .iter() + .filter_map(|symbol| self.data.instrument(symbol)) + .collect() + } + + pub fn all_instruments(&self) -> Vec<&Instrument> { + self.data.all_instruments() + } + + pub fn get_trading_dates(&self, start: NaiveDate, end: NaiveDate) -> Vec { + self.data.trading_dates(start, end) + } + + pub fn get_previous_trading_date(&self, date: NaiveDate, n: usize) -> Option { + self.data.previous_trading_date(date, n) + } + + pub fn get_next_trading_date(&self, date: NaiveDate, n: usize) -> Option { + self.data.next_trading_date(date, n) + } + pub fn has_subscriptions(&self) -> bool { !self.subscriptions.is_empty() } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index f650edb..41be802 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -191,6 +191,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { functions: vec![ ManualFunction { name: "factor".to_string(), signature: "factor(\"column_name\")".to_string(), detail: "读取当前股票的数据库因子列。".to_string() }, ManualFunction { name: "day_factor".to_string(), signature: "day_factor(\"field_name\")".to_string(), detail: "读取日级/指数级字段映射。".to_string() }, + ManualFunction { name: "history_bars".to_string(), signature: "ctx.history_bars(symbol, count, \"1d\" | \"1m\" | \"tick\", \"close\", include_now)".to_string(), detail: "回测内核策略上下文数据 API,返回指定证券最近 N 条数值序列。日线字段支持 open/high/low/close/last/prev_close/volume/upper_limit/lower_limit;分钟或 tick 字段支持 last/bid1/ask1/volume_delta/amount_delta。日线 include_now=false 排除当前交易日;分钟/tick 会按当前 on_bar、on_tick 或调度时刻截断,include_now=false 排除当前 bar/tick,避免未来函数。".to_string() }, + ManualFunction { name: "current_snapshot".to_string(), signature: "ctx.current_snapshot(symbol)".to_string(), detail: "读取当前交易日指定证券的日级快照,可用于获得当日 open/close/last/upper_limit/lower_limit 等字段。".to_string() }, + ManualFunction { name: "instrument/instruments/all_instruments".to_string(), signature: "ctx.instrument(symbol)".to_string(), detail: "读取证券元数据,包括名称、板块、上市日期、退市日期、最小下单量、整手、最小价位等;all_instruments 按证券代码稳定排序返回全量证券。".to_string() }, + 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: "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 5a415ad..69c0803 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -144,6 +144,11 @@ struct TickProbeStrategy { ordered: bool, } +struct DataApiProbeStrategy { + target_date: NaiveDate, + snapshots: Rc>>, +} + impl Strategy for ScheduledProbeStrategy { fn name(&self) -> &str { "scheduled-probe" @@ -325,8 +330,20 @@ impl Strategy for TickProbeStrategy { ctx: &StrategyContext<'_>, quote: &IntradayExecutionQuote, ) -> Result { + let visible_last = ctx + .history_bars("e.symbol, 9, "tick", "last", true) + .iter() + .map(|value| format!("{value:.2}")) + .collect::>() + .join(","); + let previous_last = ctx + .history_bars("e.symbol, 9, "tick", "last", false) + .iter() + .map(|value| format!("{value:.2}")) + .collect::>() + .join(","); self.seen_ticks.borrow_mut().push(format!( - "{}:{}:{}", + "{}:{}:{}:visible={visible_last}:previous={previous_last}", quote.symbol, quote.timestamp.time(), ctx.is_subscribed("e.symbol) @@ -350,6 +367,68 @@ impl Strategy for TickProbeStrategy { } } +impl Strategy for DataApiProbeStrategy { + fn name(&self) -> &str { + "data-api-probe" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date == self.target_date { + let daily_close = ctx + .history_bars("000001.SZ", 2, "1d", "close", true) + .iter() + .map(|value| format!("{value:.2}")) + .collect::>() + .join(","); + let previous_close = ctx + .history_bars("000001.SZ", 2, "daily", "close", false) + .iter() + .map(|value| format!("{value:.2}")) + .collect::>() + .join(","); + let tick_last = ctx + .history_bars("000001.SZ", 2, "1m", "last", true) + .iter() + .map(|value| format!("{value:.2}")) + .collect::>() + .join(","); + let previous_tick_last = ctx + .history_bars("000001.SZ", 2, "1m", "last", false) + .iter() + .map(|value| format!("{value:.2}")) + .collect::>() + .join(","); + let current_close = ctx + .current_snapshot("000001.SZ") + .map(|snapshot| format!("{:.2}", snapshot.close)) + .unwrap_or_default(); + let instrument_name = ctx + .instrument("000001.SZ") + .map(|instrument| instrument.name.clone()) + .unwrap_or_default(); + let prev_date = ctx + .get_previous_trading_date(ctx.execution_date, 1) + .map(|date| date.to_string()) + .unwrap_or_default(); + let next_date = ctx + .get_next_trading_date(d(2025, 1, 3), 1) + .map(|date| date.to_string()) + .unwrap_or_default(); + let trading_date_count = ctx + .get_trading_dates(d(2025, 1, 2), ctx.execution_date) + .len(); + self.snapshots.borrow_mut().push(format!( + "daily={daily_close};previous={previous_close};tick={tick_last};previous_tick={previous_tick_last};current={current_close};instrument={instrument_name};all={};range={trading_date_count};prev={prev_date};next={next_date}", + ctx.all_instruments().len() + )); + } + Ok(StrategyDecision::default()) + } +} + #[test] fn engine_runs_strategy_hooks_in_daily_order() { let date1 = d(2025, 1, 2); @@ -769,7 +848,10 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() { assert_eq!( seen_ticks.borrow().as_slice(), - ["000001.SZ:10:18:00:true", "000001.SZ:10:19:00:true"] + [ + "000001.SZ:10:18:00:true:visible=10.20:previous=", + "000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20" + ] ); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].reason, "tick_buy"); @@ -794,6 +876,180 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() { ); } +#[test] +fn strategy_context_exposes_rqalpha_style_data_helpers() { + let date1 = d(2025, 1, 2); + let date2 = d(2025, 1, 3); + let date3 = d(2025, 1, 6); + let instrument = 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(), + }; + let market = [ + (date1, 10.0, 10.0, 10.0, 100_000), + (date2, 10.1, 10.1, 10.0, 110_000), + (date3, 10.2, 10.2, 10.1, 120_000), + ] + .into_iter() + .map( + |(date, open, close, prev_close, volume)| DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some(format!("{date} 10:18:00")), + day_open: open, + open, + high: close + 0.2, + low: close - 0.2, + close, + last_price: close, + bid1: close - 0.01, + ask1: close + 0.01, + prev_close, + volume, + tick_volume: volume, + bid1_volume: volume, + ask1_volume: volume, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: prev_close * 1.1, + lower_limit: prev_close * 0.9, + price_tick: 0.01, + }, + ) + .collect::>(); + let factors = [date1, date2, date3] + .into_iter() + .map(|date| 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(), + }) + .collect::>(); + let candidates = [date1, date2, date3] + .into_iter() + .map(|date| 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, + }) + .collect::>(); + let benchmarks = [ + (date1, 100.0, 99.0), + (date2, 101.0, 100.0), + (date3, 102.0, 101.0), + ] + .into_iter() + .map(|(date, close, prev_close)| BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: close, + close, + prev_close, + volume: 1_000_000, + }) + .collect::>(); + let quotes = vec![ + IntradayExecutionQuote { + date: date2, + symbol: "000001.SZ".to_string(), + timestamp: dt(2025, 1, 3, 14, 30, 0), + last_price: 10.15, + bid1: 10.14, + ask1: 10.15, + bid1_volume: 1000, + ask1_volume: 1000, + volume_delta: 1000, + amount_delta: 10_150.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date: date3, + symbol: "000001.SZ".to_string(), + timestamp: dt(2025, 1, 6, 10, 18, 0), + last_price: 10.25, + bid1: 10.24, + ask1: 10.25, + bid1_volume: 1000, + ask1_volume: 1000, + volume_delta: 1000, + amount_delta: 10_250.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date: date3, + symbol: "000001.SZ".to_string(), + timestamp: dt(2025, 1, 6, 10, 19, 0), + last_price: 10.26, + bid1: 10.25, + ask1: 10.26, + bid1_volume: 1000, + ask1_volume: 1000, + volume_delta: 1000, + amount_delta: 10_260.0, + trading_phase: Some("continuous".to_string()), + }, + ]; + let data = DataSet::from_components_with_actions_and_quotes( + vec![instrument], + market, + factors, + candidates, + benchmarks, + Vec::new(), + quotes, + ) + .expect("dataset"); + + let snapshots = Rc::new(RefCell::new(Vec::new())); + let strategy = DataApiProbeStrategy { + target_date: date3, + snapshots: snapshots.clone(), + }; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + data, + strategy, + broker, + BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date3), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ); + + engine.run().expect("backtest run"); + + assert_eq!( + snapshots.borrow().as_slice(), + [ + "daily=10.10,10.20;previous=10.00,10.10;tick=10.15,10.25;previous_tick=10.15;current=10.20;instrument=Anchor;all=1;range=3;prev=2025-01-03;next=2025-01-06" + ] + ); +} + #[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 2e8730d..6a61675 100644 --- a/crates/fidc-core/tests/strategy_selection.rs +++ b/crates/fidc-core/tests/strategy_selection.rs @@ -32,6 +32,7 @@ fn strategy_emits_target_weights_and_diagnostics() { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }) .expect("decision"); @@ -75,6 +76,7 @@ fn jq_strategy_emits_same_day_decision() { subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, }) .expect("jq decision"); diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 2562950..d131b01 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -46,6 +46,16 @@ current alignment pass. - [x] `dividend_receivable` - [ ] richer position lifecycle fields exposed to strategy runtime +### Phase 6: Strategy data API parity + +- [x] `history_bars` numeric helper for daily, intraday, and tick fields +- [x] `current_snapshot` +- [x] `instrument` / `instruments` / `all_instruments` +- [x] `get_trading_dates` / `get_previous_trading_date` / + `get_next_trading_date` +- [x] phase-aware minute/tick history cursor semantics matching the active + bar or tick callback + ## Execution Order 1. Close the explicit order API gap with target-shares / `order_to` parity. @@ -54,9 +64,10 @@ current alignment pass. 4. Add dynamic universe APIs. 5. Add algo-order styles. 6. Finish position accounting parity. +7. Expose richer position lifecycle fields to strategy runtime. ## Current Step -Active implementation target: Phase 5 follow-up plus strategy data API parity: -expose richer position lifecycle fields and RQAlpha-style data helpers such as -`history_bars`, `current_snapshot`, instruments, and trading-date access. +Active implementation target: Phase 5 follow-up: expose richer position +lifecycle fields to strategy runtime beyond quantity, sellable quantity, +average cost, trading pnl, position pnl, and dividend receivable.