Expose strategy runtime data APIs
This commit is contained in:
@@ -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<NaiveDate> {
|
||||
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<NaiveDate> {
|
||||
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<NaiveDate> {
|
||||
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<NaiveDate> {
|
||||
let Some(end_idx) = self.index_of(end) else {
|
||||
return Vec::new();
|
||||
|
||||
@@ -776,6 +776,12 @@ impl DataSet {
|
||||
&self.instruments
|
||||
}
|
||||
|
||||
pub fn all_instruments(&self) -> Vec<&Instrument> {
|
||||
let mut instruments = self.instruments.values().collect::<Vec<_>>();
|
||||
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<f64> {
|
||||
self.history_bars_at(date, None, symbol, bar_count, frequency, field, include_now)
|
||||
}
|
||||
|
||||
pub fn history_bars_at(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
active_datetime: Option<NaiveDateTime>,
|
||||
symbol: &str,
|
||||
bar_count: usize,
|
||||
frequency: &str,
|
||||
field: &str,
|
||||
include_now: bool,
|
||||
) -> Vec<f64> {
|
||||
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<DailyMarketSnapshot> {
|
||||
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::<Vec<_>>();
|
||||
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<IntradayExecutionQuote> {
|
||||
self.history_intraday_quotes_at(date, None, symbol, bar_count, include_now)
|
||||
}
|
||||
|
||||
pub fn history_intraday_quotes_at(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
active_datetime: Option<NaiveDateTime>,
|
||||
symbol: &str,
|
||||
bar_count: usize,
|
||||
include_now: bool,
|
||||
) -> Vec<IntradayExecutionQuote> {
|
||||
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::<Vec<_>>();
|
||||
quotes.sort_by_key(|quote| quote.timestamp);
|
||||
take_last(quotes, bar_count)
|
||||
}
|
||||
|
||||
pub fn trading_dates(&self, start: NaiveDate, end: NaiveDate) -> Vec<NaiveDate> {
|
||||
self.calendar.trading_dates(start, end)
|
||||
}
|
||||
|
||||
pub fn previous_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
|
||||
self.calendar.previous_trading_date(date, n)
|
||||
}
|
||||
|
||||
pub fn next_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
|
||||
self.calendar.next_trading_date(date, n)
|
||||
}
|
||||
|
||||
pub fn price(&self, date: NaiveDate, symbol: &str, field: PriceField) -> Option<f64> {
|
||||
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<f64> {
|
||||
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<NaiveDateTime>,
|
||||
symbol: &str,
|
||||
bar_count: usize,
|
||||
field: &str,
|
||||
include_now: bool,
|
||||
) -> Vec<f64> {
|
||||
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<f64> {
|
||||
self.market_series_by_symbol
|
||||
.get(symbol)
|
||||
@@ -1170,6 +1317,88 @@ fn factor_numeric_value(snapshot: &DailyFactorSnapshot, field: &str) -> Option<f
|
||||
}
|
||||
}
|
||||
|
||||
fn daily_market_numeric_value(snapshot: &DailyMarketSnapshot, field: &str) -> Option<f64> {
|
||||
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<f64> {
|
||||
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<NaiveDateTime>,
|
||||
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<String> {
|
||||
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<T>(mut rows: Vec<T>, count: usize) -> Vec<T> {
|
||||
if rows.len() <= count {
|
||||
return rows;
|
||||
}
|
||||
rows.split_off(rows.len() - count)
|
||||
}
|
||||
|
||||
fn read_candidates(path: &Path) -> Result<Vec<CandidateEligibility>, DataSetError> {
|
||||
let rows = read_rows(path)?;
|
||||
let mut snapshots = Vec::new();
|
||||
|
||||
@@ -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<S: Strategy>(
|
||||
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<S: Strategy>(
|
||||
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<S: Strategy>(
|
||||
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<S: Strategy>(
|
||||
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<chrono::NaiveTime>,
|
||||
) -> Option<chrono::NaiveDateTime> {
|
||||
time.map(|value| date.and_time(value))
|
||||
}
|
||||
|
||||
fn should_run_tick_events(rules: &[ScheduleRule], subscriptions: &BTreeSet<String>) -> bool {
|
||||
!subscriptions.is_empty() || rules.iter().any(|rule| rule.stage == ScheduleStage::Tick)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
pub process_events: &'a [ProcessEvent],
|
||||
pub active_process_event: Option<&'a ProcessEvent>,
|
||||
pub active_datetime: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
impl StrategyContext<'_> {
|
||||
pub fn current_datetime(&self) -> Option<NaiveDateTime> {
|
||||
self.active_datetime
|
||||
}
|
||||
|
||||
pub fn current_time(&self) -> Option<NaiveTime> {
|
||||
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<f64> {
|
||||
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<DailyMarketSnapshot> {
|
||||
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<IntradayExecutionQuote> {
|
||||
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<NaiveDate> {
|
||||
self.data.trading_dates(start, end)
|
||||
}
|
||||
|
||||
pub fn get_previous_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
|
||||
self.data.previous_trading_date(date, n)
|
||||
}
|
||||
|
||||
pub fn get_next_trading_date(&self, date: NaiveDate, n: usize) -> Option<NaiveDate> {
|
||||
self.data.next_trading_date(date, n)
|
||||
}
|
||||
|
||||
pub fn has_subscriptions(&self) -> bool {
|
||||
!self.subscriptions.is_empty()
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -144,6 +144,11 @@ struct TickProbeStrategy {
|
||||
ordered: bool,
|
||||
}
|
||||
|
||||
struct DataApiProbeStrategy {
|
||||
target_date: NaiveDate,
|
||||
snapshots: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Strategy for ScheduledProbeStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"scheduled-probe"
|
||||
@@ -325,8 +330,20 @@ impl Strategy for TickProbeStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
quote: &IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let visible_last = ctx
|
||||
.history_bars("e.symbol, 9, "tick", "last", true)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let previous_last = ctx
|
||||
.history_bars("e.symbol, 9, "tick", "last", false)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.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<StrategyDecision, fidc_core::BacktestError> {
|
||||
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::<Vec<_>>()
|
||||
.join(",");
|
||||
let previous_close = ctx
|
||||
.history_bars("000001.SZ", 2, "daily", "close", false)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let tick_last = ctx
|
||||
.history_bars("000001.SZ", 2, "1m", "last", true)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let previous_tick_last = ctx
|
||||
.history_bars("000001.SZ", 2, "1m", "last", false)
|
||||
.iter()
|
||||
.map(|value| format!("{value:.2}"))
|
||||
.collect::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user