Compare commits

...

26 Commits

Author SHA1 Message Date
boris 5482c8a52d 合并177回测引擎运行历史
# Conflicts:
#	crates/fidc-core/src/data.rs
2026-08-25 05:36:43 +08:00
boris 2a6bbb82a6 支持原生回测事实存储 2026-08-25 05:32:18 +08:00
boris 24e4ac9284 线性合并分钟行情窗口 2026-08-25 04:17:53 +08:00
boris 81d70f18b3 跳过无业务分钟回调 2026-08-25 04:02:33 +08:00
boris 85c9d03b99 校验分钟订阅行情覆盖 2026-08-25 03:07:56 +08:00
boris a147c495af 重构分钟线事件流与订阅加载 2026-08-25 01:41:50 +08:00
boris 4cf0224d2d 移除DataSet行级Arc分配 2026-08-24 21:53:00 +08:00
boris 7503dc8517 共享回测只读数据索引 2026-08-24 19:48:14 +08:00
boris 1c04318ecf 增加定点金额精度验收模型 2026-08-24 17:25:53 +08:00
boris 4b577517a9 增加数值表达式字节码虚拟机 2026-08-24 13:46:45 +08:00
boris c52478708f 用快速哈希优化回测内部索引 2026-08-24 12:09:09 +08:00
boris 1d7ac19886 移除回测稠密索引性能回归 2026-08-24 11:53:12 +08:00
boris 0686532be0 用稠密行索引和滚动游标加速回测 2026-08-24 11:46:55 +08:00
boris 911074ae95 优化日线候选和成交量窗口索引 2026-08-24 11:34:20 +08:00
boris 555f2ab9bd 按证券索引优化表达式数据访问 2026-08-24 11:21:39 +08:00
boris a79077af17 按表达式依赖裁剪策略前置声明 2026-08-24 10:05:46 +08:00
boris 61a4172bd4 统一策略表达式执行与默认配置 2026-08-24 09:28:33 +08:00
boris 589f94e5b2 增加逐日紧凑证券索引 2026-08-24 04:04:03 +08:00
boris 8254ebbb47 压缩类型化因子并减少运行分配 2026-08-24 03:55:03 +08:00
boris ea79fdae46 减少滚动窗口重复索引开销 2026-08-24 03:36:07 +08:00
boris 2013314e4f 区分指数与股票滚动复权口径 2026-08-24 03:19:16 +08:00
boris 869c14e2b0 改用真实行情验证滚动风控 2026-08-24 02:57:42 +08:00
boris cea079a770 统一复权滚动因子计算口径 2026-08-24 02:51:58 +08:00
boris 9a7e5c7903 前置校验策略表达式语法 2026-08-23 22:58:33 +08:00
boris 279d6a100f 统一成交量滚动有效样本口径 2026-08-23 13:10:46 +08:00
boris 7afb72dca8 统一成交量滚动有效样本口径 2026-08-23 13:09:18 +08:00
15 changed files with 4959 additions and 971 deletions
Generated
+1
View File
@@ -146,6 +146,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
name = "fidc-core" name = "fidc-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ahash",
"chrono", "chrono",
"indexmap", "indexmap",
"rayon", "rayon",
+1
View File
@@ -11,6 +11,7 @@ version = "0.1.0"
authors = ["OpenAI Codex"] authors = ["OpenAI Codex"]
[workspace.dependencies] [workspace.dependencies]
ahash = "=0.8.12"
chrono = { version = "=0.4.44", features = ["serde"] } chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] } indexmap = { version = "=2.11.4", features = ["serde"] }
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
ahash.workspace = true
chrono.workspace = true chrono.workspace = true
indexmap.workspace = true indexmap.workspace = true
rayon.workspace = true rayon.workspace = true
+4
View File
@@ -384,6 +384,10 @@ impl<C, R> BrokerSimulator<C, R> {
}) })
.collect() .collect()
} }
pub fn has_open_orders(&self) -> bool {
!self.open_orders.borrow().is_empty()
}
} }
impl<C, R> BrokerSimulator<C, R> impl<C, R> BrokerSimulator<C, R>
File diff suppressed because it is too large Load Diff
+211 -28
View File
@@ -1,7 +1,7 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Timelike};
use serde::Serialize; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType}; use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
@@ -71,7 +71,7 @@ impl Default for FuturesValidationConfig {
} }
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyEquityPoint { pub struct DailyEquityPoint {
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
@@ -578,6 +578,9 @@ where
if self.execution_quote_request_cache.contains(&request_key) { if self.execution_quote_request_cache.contains(&request_key) {
return false; return false;
} }
if start_time.is_none() && end_time.is_none() {
return true;
}
if start_time.is_some() && end_time.is_none() { if start_time.is_some() && end_time.is_none() {
return !has_execution_quote_near_start_time( return !has_execution_quote_near_start_time(
&self.data, &self.data,
@@ -604,7 +607,19 @@ where
.as_mut() .as_mut()
.expect("checked execution quote loader") .expect("checked execution quote loader")
.as_mut()(request)?; .as_mut()(request)?;
let requested_symbol_set = requested_symbols.iter().cloned().collect::<BTreeSet<_>>();
if let Some(quote) = quotes.iter().find(|quote| {
quote.date != execution_date || !requested_symbol_set.contains(&quote.symbol)
}) {
return Err(BacktestError::Execution(format!(
"execution quote loader returned a row outside the request: requested_date={} actual_date={} symbol={}",
execution_date, quote.date, quote.symbol
)));
}
self.data.add_execution_quotes(quotes); self.data.add_execution_quotes(quotes);
if start_time.is_none() && end_time.is_none() {
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
}
for symbol in requested_symbols { for symbol in requested_symbols {
self.execution_quote_request_cache.insert(( self.execution_quote_request_cache.insert((
execution_date, execution_date,
@@ -616,6 +631,48 @@ where
Ok(()) Ok(())
} }
fn validate_full_day_execution_quote_coverage(
&self,
execution_date: NaiveDate,
requested_symbols: &[String],
) -> Result<(), BacktestError> {
let mut missing_active = Vec::new();
let mut paused_with_quotes = Vec::new();
let mut missing_daily_market = Vec::new();
for symbol in requested_symbols {
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
continue;
};
let Some(market) = self.data.market(execution_date, symbol) else {
missing_daily_market.push(symbol.clone());
continue;
};
let has_quotes = !self
.data
.execution_quotes_on(execution_date, symbol)
.is_empty();
if market.paused {
if has_quotes {
paused_with_quotes.push(symbol.clone());
}
continue;
}
if market.volume > 0 && !has_quotes {
missing_active.push(symbol.clone());
}
}
if missing_daily_market.is_empty()
&& missing_active.is_empty()
&& paused_with_quotes.is_empty()
{
return Ok(());
}
Err(BacktestError::Execution(format!(
"full-minute subscription coverage mismatch on {}: missing_daily_market={:?}, missing_active_minute_bars={:?}, paused_with_minute_bars={:?}",
execution_date, missing_daily_market, missing_active, paused_with_quotes
)))
}
fn ensure_execution_quotes_for_portfolio_times( fn ensure_execution_quotes_for_portfolio_times(
&mut self, &mut self,
execution_date: NaiveDate, execution_date: NaiveDate,
@@ -1029,6 +1086,10 @@ where
views views
} }
fn has_open_orders(&self) -> bool {
self.broker.has_open_orders() || !self.futures_open_orders.is_empty()
}
fn aggregate_initial_cash(&self) -> f64 { fn aggregate_initial_cash(&self) -> f64 {
self.config.initial_cash self.config.initial_cash
+ self + self
@@ -1666,6 +1727,7 @@ where
F: FnMut(&BacktestDayProgress), F: FnMut(&BacktestDayProgress),
{ {
let mut portfolio = PortfolioState::new(self.config.initial_cash); let mut portfolio = PortfolioState::new(self.config.initial_cash);
self.subscriptions = self.strategy.initial_subscriptions();
let scheduler_calendar = self.data.calendar().clone(); let scheduler_calendar = self.data.calendar().clone();
let scheduler = Scheduler::new(&scheduler_calendar); let scheduler = Scheduler::new(&scheduler_calendar);
let calendar_dates = self let calendar_dates = self
@@ -2410,6 +2472,15 @@ where
)?; )?;
if should_run_minute_events(&schedule_rules, &self.subscriptions) { if should_run_minute_events(&schedule_rules, &self.subscriptions) {
if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() {
let mut minute_symbols = self.subscriptions.clone();
self.load_missing_execution_quotes(
execution_date,
None,
None,
&mut minute_symbols,
)?;
}
let filter_by_subscription = !self.subscriptions.is_empty(); let filter_by_subscription = !self.subscriptions.is_empty();
let minute_quotes = self let minute_quotes = self
.data .data
@@ -2419,8 +2490,42 @@ where
!filter_by_subscription || self.subscriptions.contains(&quote.symbol) !filter_by_subscription || self.subscriptions.contains(&quote.symbol)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for quote in minute_quotes { let requires_minute_callbacks = self.strategy.requires_minute_callbacks();
let minute_time = quote.timestamp.time(); let has_minute_process_listeners = self.process_event_bus.has_listeners_for(&[
ProcessEventKind::PreMinute,
ProcessEventKind::Minute,
ProcessEventKind::PostMinute,
]);
let minute_schedule_all_times = schedule_rules
.iter()
.any(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none());
let minute_schedule_minutes = schedule_rules
.iter()
.filter(|rule| rule.stage == ScheduleStage::Minute)
.filter_map(|rule| rule.time_rule.as_ref()?.minute_of_day())
.collect::<BTreeSet<_>>();
let mut minute_cursor = 0usize;
while minute_cursor < minute_quotes.len() {
let minute_timestamp = minute_quotes[minute_cursor].timestamp;
let minute_time = minute_timestamp.time();
let mut minute_end = minute_cursor + 1;
while minute_end < minute_quotes.len()
&& minute_quotes[minute_end].timestamp == minute_timestamp
{
minute_end += 1;
}
let minute_group = &minute_quotes[minute_cursor..minute_end];
let schedule_candidate = minute_schedule_all_times
|| minute_schedule_minutes
.contains(&(minute_time.hour() * 60 + minute_time.minute()));
if !requires_minute_callbacks
&& !has_minute_process_listeners
&& !schedule_candidate
&& !self.has_open_orders()
{
minute_cursor = minute_end;
continue;
}
let minute_open_orders = self.open_order_views(); let minute_open_orders = self.open_order_views();
publish_phase_event( publish_phase_event(
&mut self.strategy, &mut self.strategy,
@@ -2437,7 +2542,7 @@ where
&mut process_events, &mut process_events,
execution_date, execution_date,
ProcessEventKind::PreMinute, ProcessEventKind::PreMinute,
format!("minute:{}:{}:pre", quote.symbol, quote.timestamp), format!("minute:{minute_timestamp}:pre"),
)?; )?;
let mut minute_decision = collect_scheduled_decisions( let mut minute_decision = collect_scheduled_decisions(
&mut self.strategy, &mut self.strategy,
@@ -2459,25 +2564,29 @@ where
result.order_events.as_slice(), result.order_events.as_slice(),
result.fills.as_slice(), result.fills.as_slice(),
)?; )?;
minute_decision.merge_from(self.strategy.on_minute( if requires_minute_callbacks {
&StrategyContext { for quote in minute_group {
execution_date, minute_decision.merge_from(self.strategy.on_minute(
decision_date, &StrategyContext {
decision_index, execution_date,
data: &self.data, decision_date,
portfolio: &portfolio, decision_index,
futures_account: self.futures_account.as_ref(), data: &self.data,
open_orders: &minute_open_orders, portfolio: &portfolio,
dynamic_universe: self.dynamic_universe.as_ref(), futures_account: self.futures_account.as_ref(),
subscriptions: &self.subscriptions, open_orders: &minute_open_orders,
process_events: &process_events, dynamic_universe: self.dynamic_universe.as_ref(),
active_process_event: None, subscriptions: &self.subscriptions,
active_datetime: Some(quote.timestamp), process_events: &process_events,
order_events: result.order_events.as_slice(), active_process_event: None,
fills: result.fills.as_slice(), active_datetime: Some(minute_timestamp),
}, order_events: result.order_events.as_slice(),
&quote, fills: result.fills.as_slice(),
)?); },
quote,
)?);
}
}
publish_phase_event( publish_phase_event(
&mut self.strategy, &mut self.strategy,
&mut self.process_event_bus, &mut self.process_event_bus,
@@ -2493,7 +2602,7 @@ where
&mut process_events, &mut process_events,
execution_date, execution_date,
ProcessEventKind::Minute, ProcessEventKind::Minute,
format!("minute:{}:{}", quote.symbol, quote.timestamp), format!("minute:{minute_timestamp}"),
)?; )?;
self.apply_strategy_directives( self.apply_strategy_directives(
execution_date, execution_date,
@@ -2559,9 +2668,11 @@ where
&mut process_events, &mut process_events,
execution_date, execution_date,
ProcessEventKind::PostMinute, ProcessEventKind::PostMinute,
format!("minute:{}:{}:post", quote.symbol, quote.timestamp), format!("minute:{minute_timestamp}:post"),
)?; )?;
minute_cursor = minute_end;
} }
self.data.remove_execution_quotes_on_date(execution_date);
} }
portfolio.update_prices_with_options( portfolio.update_prices_with_options(
@@ -4106,7 +4217,7 @@ fn futures_cancel_report(
mod date_format { mod date_format {
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::Serializer; use serde::{Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d"; const FORMAT: &str = "%Y-%m-%d";
@@ -4116,6 +4227,14 @@ mod date_format {
{ {
serializer.serialize_str(&date.format(FORMAT).to_string()) serializer.serialize_str(&date.format(FORMAT).to_string())
} }
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(&value, FORMAT).map_err(serde::de::Error::custom)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -4789,6 +4908,70 @@ mod tests {
.expect("backtest run") .expect("backtest run")
} }
fn full_day_coverage_engine(
data: DataSet,
date: NaiveDate,
) -> BacktestEngine<BuyWhenDecisionDateStrategy, ChinaAShareCostModel, ChinaEquityRuleHooks>
{
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::MinuteLast)
.with_volume_limit(false)
.with_liquidity_limit(false);
BacktestEngine::new(
data,
BuyWhenDecisionDateStrategy {
decision_date: date,
},
broker,
BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
}
#[test]
fn full_minute_coverage_rejects_missing_active_bars_but_allows_paused_or_zero_volume() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let active = full_day_coverage_engine(dataset(), first);
let error = active
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
.expect_err("active stock with daily volume requires minute bars");
assert!(
error.to_string().contains("missing_active_minute_bars"),
"{error}"
);
let paused_data = dataset_with(
market_with_state(first, 10.0, 10.0, true, 11.0, 9.0),
market(second, 10.0, 10.0),
candidate_with_state(first, true, false),
candidate(second),
);
full_day_coverage_engine(paused_data, first)
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
.expect("paused stock may have no minute bars");
let zero_volume_data = dataset_with(
market_with_volume(first, 10.0, 10.0, 0),
market(second, 10.0, 10.0),
candidate(first),
candidate(second),
);
full_day_coverage_engine(zero_volume_data, first)
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
.expect("zero-volume stock may have no minute bars");
}
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult { fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
run_scheduled_next_open_with_dataset_and_broker( run_scheduled_next_open_with_dataset_and_broker(
dataset, dataset,
+9
View File
@@ -125,6 +125,15 @@ impl ProcessEventBus {
loader.install_enabled(self, enabled_names) loader.install_enabled(self, enabled_names)
} }
pub fn has_listeners_for(&self, kinds: &[ProcessEventKind]) -> bool {
!self.any_listeners.is_empty()
|| kinds.iter().any(|kind| {
self.listeners
.get(kind)
.is_some_and(|listeners| !listeners.is_empty())
})
}
pub fn publish(&mut self, event: &ProcessEvent) { pub fn publish(&mut self, event: &ProcessEvent) {
if let Some(listeners) = self.listeners.get_mut(&event.kind) { if let Some(listeners) = self.listeners.get_mut(&event.kind) {
for listener in listeners { for listener in listeners {
+531
View File
@@ -0,0 +1,531 @@
//! Independent fixed-point acceptance model for money and fee arithmetic.
//!
//! The execution kernel still exposes f64 because prices and source rows are
//! represented that way today. This module is deliberately separate: it is a
//! deterministic shadow model used to prove that cash, fees, budget checks,
//! FIFO PnL, and external cash flows do not depend on binary floating-point
//! accumulation.
use std::collections::{BTreeMap, VecDeque};
use chrono::NaiveDate;
use crate::events::OrderSide;
pub const MONEY_SCALE: i128 = 1_000_000;
const MONEY_SCALE_F64: f64 = MONEY_SCALE as f64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct FixedMoney(i128);
impl FixedMoney {
pub const ZERO: Self = Self(0);
pub const fn from_raw(raw: i128) -> Self {
Self(raw)
}
pub const fn raw(self) -> i128 {
self.0
}
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
let value = value.trim();
if value.is_empty() {
return Err("fixed money value is empty".to_string());
}
let (negative, unsigned) = match value.as_bytes()[0] {
b'-' => (true, &value[1..]),
b'+' => (false, &value[1..]),
_ => (false, value),
};
let mut parts = unsigned.split('.');
let whole = parts.next().unwrap_or_default();
let fractional = parts.next().unwrap_or_default();
if parts.next().is_some()
|| whole.is_empty()
|| !whole.bytes().all(|byte| byte.is_ascii_digit())
|| !fractional.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(format!("invalid fixed money decimal: {value}"));
}
let whole = whole
.parse::<i128>()
.map_err(|_| format!("fixed money whole part is out of range: {value}"))?;
let mut fractional_digits = fractional.as_bytes().to_vec();
let round_up = fractional_digits.len() > 6 && fractional_digits[6] >= b'5';
fractional_digits.truncate(6);
while fractional_digits.len() < 6 {
fractional_digits.push(b'0');
}
let fractional = if fractional_digits.is_empty() {
0
} else {
std::str::from_utf8(&fractional_digits)
.expect("fractional digits are ASCII")
.parse::<i128>()
.map_err(|_| format!("fixed money fractional part is invalid: {value}"))?
};
let mut raw = whole
.checked_mul(MONEY_SCALE)
.and_then(|raw| raw.checked_add(fractional))
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
if round_up {
raw = raw
.checked_add(1)
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
}
Ok(Self(if negative { -raw } else { raw }))
}
pub fn from_f64(value: f64) -> Option<Self> {
if !value.is_finite() {
return None;
}
let raw = (value * MONEY_SCALE_F64).round();
if !raw.is_finite() || raw < i128::MIN as f64 || raw > i128::MAX as f64 {
return None;
}
Some(Self(raw as i128))
}
pub fn to_f64(self) -> f64 {
self.0 as f64 / MONEY_SCALE_F64
}
pub fn checked_add(self, other: Self) -> Option<Self> {
self.0.checked_add(other.0).map(Self)
}
pub fn checked_sub(self, other: Self) -> Option<Self> {
self.0.checked_sub(other.0).map(Self)
}
pub fn checked_mul_quantity(self, quantity: u64) -> Option<Self> {
self.0.checked_mul(i128::from(quantity)).map(Self)
}
pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
let product = self.0.checked_mul(rate.0)?;
let half = MONEY_SCALE / 2;
let rounded = if product >= 0 {
product.checked_add(half)? / MONEY_SCALE
} else {
product.checked_sub(half)? / MONEY_SCALE
};
Some(Self(rounded))
}
pub fn abs(self) -> Self {
Self(self.0.abs())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FixedTradingCost {
pub commission: FixedMoney,
pub stamp_tax: FixedMoney,
pub transfer_fee: FixedMoney,
}
impl FixedTradingCost {
pub fn total(self) -> FixedMoney {
FixedMoney::from_raw(self.commission.raw() + self.stamp_tax.raw() + self.transfer_fee.raw())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedChinaAShareCostModel {
pub commission_rate: FixedMoney,
pub stamp_tax_rate_before_change: FixedMoney,
pub stamp_tax_rate_after_change: FixedMoney,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: FixedMoney,
pub transfer_fee_rate: FixedMoney,
}
impl FixedChinaAShareCostModel {
pub fn commission_for(self, gross_amount: FixedMoney) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
raw.max(self.minimum_commission)
}
pub fn stamp_tax_rate_for(self, date: NaiveDate) -> FixedMoney {
if date < self.stamp_tax_change_date {
self.stamp_tax_rate_before_change
} else {
self.stamp_tax_rate_after_change
}
}
pub fn stamp_tax_for(
self,
date: NaiveDate,
side: OrderSide,
gross_amount: FixedMoney,
) -> FixedMoney {
if gross_amount.raw() <= 0 || side == OrderSide::Buy {
return FixedMoney::ZERO;
}
gross_amount
.checked_mul_rate(self.stamp_tax_rate_for(date))
.expect("fixed stamp tax multiplication overflow")
}
pub fn transfer_fee_for(self, gross_amount: FixedMoney) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
gross_amount
.checked_mul_rate(self.transfer_fee_rate)
.expect("fixed transfer fee multiplication overflow")
}
pub fn calculate(
self,
date: NaiveDate,
side: OrderSide,
gross_amount: FixedMoney,
) -> FixedTradingCost {
FixedTradingCost {
commission: self.commission_for(gross_amount),
stamp_tax: self.stamp_tax_for(date, side, gross_amount),
transfer_fee: self.transfer_fee_for(gross_amount),
}
}
pub fn commission_for_order_fill(
self,
gross_amount: FixedMoney,
order_id: Option<u64>,
commission_state: &mut BTreeMap<u64, FixedMoney>,
) -> FixedMoney {
if gross_amount.raw() <= 0 {
return FixedMoney::ZERO;
}
let raw = gross_amount
.checked_mul_rate(self.commission_rate)
.expect("fixed commission multiplication overflow");
let Some(order_id) = order_id else {
return raw.max(self.minimum_commission);
};
let remaining = commission_state
.entry(order_id)
.or_insert(self.minimum_commission);
if raw > *remaining {
let charged = if *remaining == self.minimum_commission {
raw
} else {
raw.checked_sub(*remaining)
.expect("fixed remaining commission underflow")
};
*remaining = FixedMoney::ZERO;
charged
} else {
let charged = if *remaining == self.minimum_commission {
self.minimum_commission
} else {
FixedMoney::ZERO
};
*remaining = remaining
.checked_sub(raw)
.expect("fixed remaining commission underflow");
charged
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedLot {
pub acquired_date: NaiveDate,
pub quantity: u64,
pub entry_price: FixedMoney,
}
#[derive(Debug, Clone, Default)]
pub struct FixedLotBook {
lots: VecDeque<FixedLot>,
pub realized_pnl: FixedMoney,
pub quantity: u64,
}
impl FixedLotBook {
pub fn buy(&mut self, date: NaiveDate, quantity: u64, price: FixedMoney) {
if quantity == 0 {
return;
}
self.lots.push_back(FixedLot {
acquired_date: date,
quantity,
entry_price: price,
});
self.quantity = self.quantity.saturating_add(quantity);
}
pub fn sell(&mut self, quantity: u64, price: FixedMoney) -> Result<FixedMoney, String> {
if quantity > self.quantity {
return Err(format!(
"fixed sell quantity {} exceeds current quantity {}",
quantity, self.quantity
));
}
let mut remaining = quantity;
let mut realized = FixedMoney::ZERO;
while remaining > 0 {
let Some(mut lot) = self.lots.pop_front() else {
return Err("fixed lot book is empty while selling".to_string());
};
let sold = remaining.min(lot.quantity);
let price_delta = price
.checked_sub(lot.entry_price)
.and_then(|delta| delta.checked_mul_quantity(sold))
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
realized = realized
.checked_add(price_delta)
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
lot.quantity -= sold;
remaining -= sold;
if lot.quantity > 0 {
self.lots.push_front(lot);
}
}
self.quantity -= quantity;
self.realized_pnl = self
.realized_pnl
.checked_add(realized)
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
Ok(realized)
}
pub fn market_value(&self, mark_price: FixedMoney) -> FixedMoney {
mark_price
.checked_mul_quantity(self.quantity)
.expect("fixed market value overflow")
}
pub fn unrealized_pnl(&self, mark_price: FixedMoney) -> FixedMoney {
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
let delta = mark_price
.checked_sub(lot.entry_price)
.and_then(|value| value.checked_mul_quantity(lot.quantity))
.expect("fixed unrealized PnL overflow");
total
.checked_add(delta)
.expect("fixed unrealized PnL overflow")
})
}
}
#[derive(Debug, Clone)]
pub struct FixedAccount {
pub cash: FixedMoney,
pub units: FixedMoney,
pub external_cash_flow_total: FixedMoney,
}
impl FixedAccount {
pub fn new(initial_cash: FixedMoney) -> Self {
Self {
cash: initial_cash,
units: initial_cash,
external_cash_flow_total: FixedMoney::ZERO,
}
}
pub fn apply_external_cash_flow(
&mut self,
amount: FixedMoney,
unit_nav: FixedMoney,
) -> Result<(), String> {
if unit_nav.raw() <= 0 {
return Err("fixed unit NAV must be positive".to_string());
}
let exact_units_raw = amount
.raw()
.checked_mul(MONEY_SCALE)
.and_then(|value| value.checked_div(unit_nav.raw()))
.ok_or_else(|| "fixed external flow unit conversion overflow".to_string())?;
self.cash = self
.cash
.checked_add(amount)
.ok_or_else(|| "fixed cash overflow".to_string())?;
self.units = self
.units
.checked_add(FixedMoney::from_raw(exact_units_raw))
.ok_or_else(|| "fixed units overflow".to_string())?;
self.external_cash_flow_total = self
.external_cash_flow_total
.checked_add(amount)
.ok_or_else(|| "fixed external flow overflow".to_string())?;
Ok(())
}
pub fn unit_nav(&self, total_equity: FixedMoney) -> Result<FixedMoney, String> {
if self.units.raw() <= 0 {
return Err("fixed account has no units".to_string());
}
let raw = total_equity
.raw()
.checked_mul(MONEY_SCALE)
.and_then(|value| value.checked_div(self.units.raw()))
.ok_or_else(|| "fixed unit NAV overflow".to_string())?;
Ok(FixedMoney::from_raw(raw))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::{ChinaAShareCostModel, CostModel};
use crate::risk_control::TradingConstraintConfig;
fn fixed_model() -> FixedChinaAShareCostModel {
let config = TradingConstraintConfig::default();
FixedChinaAShareCostModel {
commission_rate: FixedMoney::from_f64(config.commission_rate).unwrap(),
stamp_tax_rate_before_change: FixedMoney::from_f64(config.stamp_tax_rate_before_change)
.unwrap(),
stamp_tax_rate_after_change: FixedMoney::from_f64(config.stamp_tax_rate_after_change)
.unwrap(),
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: FixedMoney::from_f64(config.minimum_commission).unwrap(),
transfer_fee_rate: FixedMoney::from_f64(config.transfer_fee_rate).unwrap(),
}
}
#[test]
fn decimal_parser_rounds_only_beyond_money_scale() {
assert_eq!(
FixedMoney::from_decimal_str("1.234567").unwrap().raw(),
1_234_567
);
assert_eq!(
FixedMoney::from_decimal_str("1.2345675").unwrap().raw(),
1_234_568
);
assert_eq!(
FixedMoney::from_decimal_str("-0.0000014").unwrap().raw(),
-1
);
}
#[test]
fn fixed_cost_matches_float_cost_model_within_one_micro_yuan() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let dates = [
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
];
for gross in [0.01, 10.0, 16_666.67, 248_059.812, 1_000_000.01] {
let fixed_gross = FixedMoney::from_f64(gross).unwrap();
for date in dates {
for side in [OrderSide::Buy, OrderSide::Sell] {
let expected = float.calculate(date, side, gross);
let actual = fixed.calculate(date, side, fixed_gross);
for (actual, expected) in [
(actual.commission, expected.commission),
(actual.stamp_tax, expected.stamp_tax),
(actual.transfer_fee, expected.transfer_fee),
] {
assert!(
(actual.to_f64() - expected).abs() <= 1.0 / MONEY_SCALE_F64,
"fixed={} float={} gross={} date={date} side={side:?}",
actual.to_f64(),
expected,
gross
);
}
}
}
}
}
#[test]
fn fixed_order_commission_state_matches_float_order_split() {
let fixed = fixed_model();
let float = ChinaAShareCostModel::default();
let mut fixed_state = BTreeMap::new();
let mut float_state = BTreeMap::new();
let mut fixed_total = FixedMoney::ZERO;
let mut float_total = 0.0;
for gross in [1000.0, 2000.0, 4000.0, 40_000.0] {
let fixed_fee = fixed.commission_for_order_fill(
FixedMoney::from_f64(gross).unwrap(),
Some(42),
&mut fixed_state,
);
let float_fee = float.commission_for_order_fill(gross, Some(42), &mut float_state);
fixed_total = fixed_total.checked_add(fixed_fee).unwrap();
float_total += float_fee;
}
assert!((fixed_total.to_f64() - float_total).abs() <= 4.0 / MONEY_SCALE_F64);
}
#[test]
fn fixed_budget_never_exceeds_cash_after_cost() {
let model = fixed_model();
let date = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
let cash = FixedMoney::from_decimal_str("99880.00").unwrap();
let price = FixedMoney::from_decimal_str("19.9731").unwrap();
let mut quantity = 5_000u64;
while quantity > 0 {
let gross = price.checked_mul_quantity(quantity).unwrap();
if gross
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
.unwrap()
<= cash
{
break;
}
quantity -= 100;
}
let gross = price.checked_mul_quantity(quantity).unwrap();
let total = gross
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
.unwrap();
assert!(total <= cash);
assert!(quantity < 5_000);
}
#[test]
fn fixed_fifo_pnl_and_external_flow_are_deterministic() {
let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let day_two = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut book = FixedLotBook::default();
book.buy(day_one, 100, FixedMoney::from_decimal_str("10.01").unwrap());
book.buy(day_two, 100, FixedMoney::from_decimal_str("10.03").unwrap());
let realized = book
.sell(150, FixedMoney::from_decimal_str("10.11").unwrap())
.unwrap();
assert_eq!(realized.raw(), 14_000_000);
assert_eq!(book.quantity, 50);
assert_eq!(
book.unrealized_pnl(FixedMoney::from_decimal_str("10.20").unwrap())
.raw(),
8_500_000
);
let mut account = FixedAccount::new(FixedMoney::from_decimal_str("100.00").unwrap());
account
.apply_external_cash_flow(
FixedMoney::from_decimal_str("50.00").unwrap(),
FixedMoney::from_decimal_str("1.00").unwrap(),
)
.unwrap();
assert_eq!(account.units.raw(), 150 * MONEY_SCALE);
assert_eq!(
account
.unit_nav(FixedMoney::from_decimal_str("150.00").unwrap())
.unwrap()
.raw(),
MONEY_SCALE
);
assert_eq!(account.external_cash_flow_total.raw(), 50 * MONEY_SCALE);
}
}
+6
View File
@@ -5,9 +5,11 @@ pub mod data;
pub mod engine; pub mod engine;
pub mod event_bus; pub mod event_bus;
pub mod events; pub mod events;
pub mod fixed_point;
pub mod futures; pub mod futures;
pub mod instrument; pub mod instrument;
pub mod metrics; pub mod metrics;
mod numeric_expr_vm;
pub mod platform_expr_strategy; pub mod platform_expr_strategy;
pub mod platform_runtime_schema; pub mod platform_runtime_schema;
pub mod platform_strategy_spec; pub mod platform_strategy_spec;
@@ -42,6 +44,10 @@ pub use events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind, ProcessEventKind,
}; };
pub use fixed_point::{
FixedAccount, FixedChinaAShareCostModel, FixedLotBook, FixedMoney, FixedTradingCost,
MONEY_SCALE,
};
pub use futures::{ pub use futures::{
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect, FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+99 -2
View File
@@ -756,6 +756,8 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)] #[serde(default)]
pub subscription_guard_required: Option<bool>, pub subscription_guard_required: Option<bool>,
#[serde(default)] #[serde(default)]
pub subscriptions: Vec<String>,
#[serde(default)]
pub actions: Vec<StrategyExpressionActionConfig>, pub actions: Vec<StrategyExpressionActionConfig>,
} }
@@ -1389,7 +1391,7 @@ pub fn platform_expr_config_from_spec(
signal_symbol: &str, signal_symbol: &str,
strategy_spec: Option<&StrategyRuntimeSpec>, strategy_spec: Option<&StrategyRuntimeSpec>,
) -> Result<PlatformExprStrategyConfig, String> { ) -> Result<PlatformExprStrategyConfig, String> {
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::generic();
cfg.strategy_name = strategy_id.to_string(); cfg.strategy_name = strategy_id.to_string();
if !signal_symbol.trim().is_empty() { if !signal_symbol.trim().is_empty() {
cfg.signal_symbol = signal_symbol.trim().to_string(); cfg.signal_symbol = signal_symbol.trim().to_string();
@@ -1620,6 +1622,17 @@ pub fn platform_expr_config_from_spec(
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
{ {
cfg.selection_limit_expr = expr.clone(); cfg.selection_limit_expr = expr.clone();
if let Ok(limit) = expr.trim().parse::<usize>()
&& limit > 0
&& spec
.engine_config
.as_ref()
.and_then(|engine| engine.rank_limit)
.filter(|value| *value > 0)
.is_none()
{
cfg.max_positions = limit;
}
} }
if let Some(expr) = selection if let Some(expr) = selection
.candidate_limit_expr .candidate_limit_expr
@@ -1844,9 +1857,16 @@ pub fn platform_expr_config_from_spec(
if let Some(required) = trading.subscription_guard_required { if let Some(required) = trading.subscription_guard_required {
cfg.subscription_guard_required = required; cfg.subscription_guard_required = required;
} }
cfg.initial_subscriptions = trading
.subscriptions
.iter()
.map(|symbol| symbol.trim().to_ascii_uppercase())
.filter(|symbol| !symbol.is_empty())
.collect();
if let Some(stage) = trading.stage.as_deref().map(str::trim) { if let Some(stage) = trading.stage.as_deref().map(str::trim) {
cfg.explicit_action_stage = match stage.to_ascii_lowercase().as_str() { cfg.explicit_action_stage = match stage.to_ascii_lowercase().as_str() {
"open_auction" | "open-auction" => PlatformExplicitActionStage::OpenAuction, "open_auction" | "open-auction" => PlatformExplicitActionStage::OpenAuction,
"minute" | "on_minute" | "on-minute" => PlatformExplicitActionStage::Minute,
_ => PlatformExplicitActionStage::OnDay, _ => PlatformExplicitActionStage::OnDay,
}; };
} }
@@ -2006,6 +2026,10 @@ fn parse_platform_rebalance_schedule(
let frequency = schedule.frequency.as_deref()?.trim().to_ascii_lowercase(); let frequency = schedule.frequency.as_deref()?.trim().to_ascii_lowercase();
let time_rule = parse_schedule_time_rule(schedule); let time_rule = parse_schedule_time_rule(schedule);
match frequency.as_str() { match frequency.as_str() {
"daily" => Some(PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Daily,
time_rule,
}),
"weekly" => Some(PlatformRebalanceSchedule { "weekly" => Some(PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Weekly { frequency: PlatformScheduleFrequency::Weekly {
weekday: schedule.weekday, weekday: schedule.weekday,
@@ -2491,6 +2515,73 @@ mod tests {
); );
} }
#[test]
fn parses_minute_stage_schedule_and_initial_subscriptions() {
let spec = serde_json::json!({
"strategyId": "minute_runtime_strategy",
"signalSymbol": "000300.SH",
"benchmark": {"instrumentId": "000300.SH"},
"runtimeExpressions": {
"selection": {"limitExpr": "1"},
"trading": {
"stage": "minute",
"subscriptions": ["000001.sz", "000002.SZ"],
"schedule": {"frequency": "daily", "time": "10:18"},
"actions": [
{
"kind": "target_percent",
"symbol": "000001.SZ",
"amountExpr": "0.5",
"reason": "minute_target"
}
]
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("minute config");
assert_eq!(
cfg.explicit_action_stage,
PlatformExplicitActionStage::Minute
);
assert_eq!(
cfg.initial_subscriptions,
BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()])
);
let schedule = cfg.explicit_action_schedule.expect("minute schedule");
assert_eq!(schedule.frequency, PlatformScheduleFrequency::Daily);
assert_eq!(
schedule.time_rule,
Some(ScheduleTimeRule::physical_time(10, 18))
);
assert_eq!(cfg.explicit_actions.len(), 1);
}
#[test]
fn runtime_expression_parser_does_not_inherit_microcap_template_defaults() {
let spec = serde_json::json!({
"strategyId": "generic_runtime_strategy",
"signalSymbol": "000300.SH",
"benchmark": { "instrumentId": "000300.SH" },
"runtimeExpressions": {
"selection": { "limitExpr": "7" },
"trading": { "rotationEnabled": true }
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("generic config");
assert_eq!(cfg.strategy_name, "generic_runtime_strategy");
assert_eq!(cfg.max_positions, 7);
assert_eq!(cfg.selection_limit_expr, "7");
assert_eq!(cfg.market_cap_lower_expr, "0.0");
assert_eq!(cfg.market_cap_upper_expr, "1.0e30");
assert!(cfg.stock_filter_expr.is_empty());
assert!(cfg.prelude.is_empty());
assert_eq!(cfg.refresh_rate, 1);
assert!(!cfg.daily_position_target_adjust_enabled);
}
#[test] #[test]
fn engine_config_parses_weak_market_shrink_overweight_threshold() { fn engine_config_parses_weak_market_shrink_overweight_threshold() {
let spec = serde_json::json!({ let spec = serde_json::json!({
@@ -3164,7 +3255,13 @@ mod tests {
let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.rebalance_schedule, None); assert_eq!(
cfg.rebalance_schedule,
Some(PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Daily,
time_rule: Some(ScheduleTimeRule::MinuteOfDay(9 * 60 + 33)),
})
);
assert_eq!( assert_eq!(
cfg.intraday_execution_time, cfg.intraday_execution_time,
Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()) Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap())
+11 -3
View File
@@ -1,6 +1,6 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use indexmap::IndexMap; use indexmap::IndexMap;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use crate::data::{DataSet, DataSetError, PriceField}; use crate::data::{DataSet, DataSetError, PriceField};
@@ -1692,7 +1692,7 @@ mod tests {
} }
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldingSummary { pub struct HoldingSummary {
#[serde(with = "date_format")] #[serde(with = "date_format")]
pub date: NaiveDate, pub date: NaiveDate,
@@ -1730,7 +1730,7 @@ pub struct CashReceivable {
mod date_format { mod date_format {
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::Serializer; use serde::{Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d"; const FORMAT: &str = "%Y-%m-%d";
@@ -1740,6 +1740,14 @@ mod date_format {
{ {
serializer.serialize_str(&date.format(FORMAT).to_string()) serializer.serialize_str(&date.format(FORMAT).to_string())
} }
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(&value, FORMAT).map_err(serde::de::Error::custom)
}
} }
fn round_half_up_u32(value: f64) -> u32 { fn round_half_up_u32(value: f64) -> u32 {
+6
View File
@@ -19,6 +19,12 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
pub trait Strategy { pub trait Strategy {
fn name(&self) -> &str; fn name(&self) -> &str;
fn initial_subscriptions(&self) -> BTreeSet<String> {
BTreeSet::new()
}
fn requires_minute_callbacks(&self) -> bool {
true
}
fn management_fee( fn management_fee(
&mut self, &mut self,
_ctx: &StrategyContext<'_>, _ctx: &StrategyContext<'_>,
+150 -9
View File
@@ -1,18 +1,19 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc; use std::rc::Rc;
use std::sync::{Arc, Mutex};
use chrono::{NaiveDate, NaiveDateTime}; use chrono::{NaiveDate, NaiveDateTime};
use fidc_core::{ use fidc_core::{
BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader, BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader,
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesAccountState, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest,
FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
FuturesTradingParameter, FuturesValidationConfig, Instrument, IntradayExecutionQuote, FuturesOrderIntent, FuturesTradingParameter, FuturesValidationConfig, Instrument,
IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent,
PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState, PriceField, ProcessEvent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState,
ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage,
StrategyContext, StrategyDecision, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision,
}; };
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -634,6 +635,9 @@ struct UniverseDirectiveStrategy {
struct MinuteProbeStrategy { struct MinuteProbeStrategy {
seen_ticks: Rc<RefCell<Vec<String>>>, seen_ticks: Rc<RefCell<Vec<String>>>,
scheduled_count: Rc<RefCell<usize>>,
subscribe_symbols: BTreeSet<String>,
minute_callbacks: bool,
ordered: bool, ordered: bool,
} }
@@ -809,6 +813,26 @@ impl Strategy for MinuteProbeStrategy {
"minute-probe" "minute-probe"
} }
fn requires_minute_callbacks(&self) -> bool {
self.minute_callbacks
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![
ScheduleRule::daily("minute_barrier", ScheduleStage::Minute)
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
]
}
fn on_scheduled(
&mut self,
_ctx: &StrategyContext<'_>,
_rule: &ScheduleRule,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
*self.scheduled_count.borrow_mut() += 1;
Ok(StrategyDecision::default())
}
fn on_day( fn on_day(
&mut self, &mut self,
_ctx: &StrategyContext<'_>, _ctx: &StrategyContext<'_>,
@@ -818,7 +842,7 @@ impl Strategy for MinuteProbeStrategy {
target_weights: BTreeMap::new(), target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(), exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Subscribe { order_intents: vec![OrderIntent::Subscribe {
symbols: BTreeSet::from(["000001.SZ".to_string()]), symbols: self.subscribe_symbols.clone(),
reason: "subscribe_minute_probe".to_string(), reason: "subscribe_minute_probe".to_string(),
}], }],
notes: Vec::new(), notes: Vec::new(),
@@ -2011,6 +2035,19 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
amount_delta: 10_200.0, amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote {
date,
symbol: "000002.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0),
last_price: 20.4,
bid1: 20.3,
ask1: 20.4,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 1_000,
amount_delta: 20_400.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote { IntradayExecutionQuote {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
@@ -2029,8 +2066,12 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
.expect("dataset"); .expect("dataset");
let seen_ticks = Rc::new(RefCell::new(Vec::new())); let seen_ticks = Rc::new(RefCell::new(Vec::new()));
let scheduled_count = Rc::new(RefCell::new(0usize));
let strategy = MinuteProbeStrategy { let strategy = MinuteProbeStrategy {
seen_ticks: seen_ticks.clone(), seen_ticks: seen_ticks.clone(),
scheduled_count: scheduled_count.clone(),
subscribe_symbols: BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()]),
minute_callbacks: true,
ordered: false, ordered: false,
}; };
let broker = BrokerSimulator::new_with_execution_price( let broker = BrokerSimulator::new_with_execution_price(
@@ -2038,6 +2079,8 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Last, PriceField::Last,
); );
let loader_requests = Arc::new(Mutex::new(Vec::<ExecutionQuoteRequest>::new()));
let loader_requests_for_callback = Arc::clone(&loader_requests);
let mut engine = BacktestEngine::new( let mut engine = BacktestEngine::new(
data, data,
strategy, strategy,
@@ -2050,7 +2093,11 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
decision_lag_trading_days: 0, decision_lag_trading_days: 0,
execution_price_field: PriceField::Last, execution_price_field: PriceField::Last,
}, },
); )
.with_execution_quote_loader(move |request| {
loader_requests_for_callback.lock().unwrap().push(request);
Ok(Vec::new())
});
let result = engine.run().expect("backtest run"); let result = engine.run().expect("backtest run");
@@ -2058,9 +2105,19 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
seen_ticks.borrow().as_slice(), seen_ticks.borrow().as_slice(),
[ [
"000001.SZ:10:18:00:true:visible=10.20:previous=", "000001.SZ:10:18:00:true:visible=10.20:previous=",
"000002.SZ:10:18:00:true:visible=20.40:previous=",
"000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20" "000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20"
] ]
); );
assert_eq!(*scheduled_count.borrow(), 1);
let loader_requests = loader_requests.lock().unwrap();
assert_eq!(loader_requests.len(), 1);
assert_eq!(loader_requests[0].start_time, None);
assert_eq!(loader_requests[0].end_time, None);
assert_eq!(
loader_requests[0].symbols,
BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()])
);
assert_eq!(result.fills.len(), 1); assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].reason, "minute_buy"); assert_eq!(result.fills[0].reason, "minute_buy");
assert_eq!(result.fills[0].quantity, 100); assert_eq!(result.fills[0].quantity, 100);
@@ -2082,6 +2139,90 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
.iter() .iter()
.any(|event| event.kind == ProcessEventKind::PostMinute) .any(|event| event.kind == ProcessEventKind::PostMinute)
); );
assert_eq!(
result
.process_events
.iter()
.filter(|event| event.kind == ProcessEventKind::PreMinute)
.count(),
2
);
}
#[test]
fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
let date = d(2025, 1, 2);
let mut data = single_day_anchor_data(date);
data.add_execution_quotes(vec![
IntradayExecutionQuote {
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0),
last_price: 10.2,
bid1: 10.1,
ask1: 10.2,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 1_000,
amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0),
last_price: 10.3,
bid1: 10.2,
ask1: 10.3,
bid1_volume: 1_000,
ask1_volume: 1_000,
volume_delta: 1_000,
amount_delta: 10_300.0,
trading_phase: Some("continuous".to_string()),
},
]);
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
let scheduled_count = Rc::new(RefCell::new(0usize));
let strategy = MinuteProbeStrategy {
seen_ticks: seen_ticks.clone(),
scheduled_count: scheduled_count.clone(),
subscribe_symbols: BTreeSet::from(["000001.SZ".to_string()]),
minute_callbacks: false,
ordered: false,
};
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Last,
);
let mut engine = BacktestEngine::new(
data,
strategy,
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(Vec::new()));
let result = engine.run().expect("scheduled-only minute run");
assert!(seen_ticks.borrow().is_empty());
assert_eq!(*scheduled_count.borrow(), 1);
assert!(result.fills.is_empty());
assert_eq!(
result
.process_events
.iter()
.filter(|event| event.kind == ProcessEventKind::PreMinute)
.count(),
1
);
} }
#[test] #[test]