Merge remote-tracking branch 'origin/main'

This commit is contained in:
boris
2026-08-31 09:44:52 +08:00
7 changed files with 787 additions and 43 deletions
+223
View File
@@ -1335,6 +1335,55 @@ pub struct DataSet {
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
}
struct DailySymbolRows<'a, T> {
rows: &'a [T],
symbol_ids: &'a [u32],
row_positions: Option<&'a [u32]>,
}
impl<'a, T> DailySymbolRows<'a, T> {
fn get(&self, symbol_id: u32) -> Option<&'a T> {
if let Some(positions) = self.row_positions {
let position = positions.get(symbol_id as usize).copied()?;
if position == MISSING_ROW_POSITION {
return None;
}
return self.rows.get(position as usize);
}
find_by_symbol_id(self.rows, self.symbol_ids, symbol_id)
}
}
/// Borrowed, immutable snapshots for one trading date.
///
/// A strategy evaluates thousands of symbols for the same date. Resolving the
/// date in three BTreeMaps for every symbol is unnecessary; this view freezes
/// the already indexed slices once and keeps all lookups read-only.
pub(crate) struct DailySnapshotView<'a> {
market: DailySymbolRows<'a, DailyMarketSnapshot>,
factor_rows: &'a [DailyFactorSnapshot],
factor_symbol_ids: &'a [u32],
candidates: DailySymbolRows<'a, CandidateEligibility>,
}
impl<'a> DailySnapshotView<'a> {
pub(crate) fn market(&self, symbol_id: u32) -> Option<&'a DailyMarketSnapshot> {
self.market.get(symbol_id)
}
pub(crate) fn candidate(&self, symbol_id: u32) -> Option<&'a CandidateEligibility> {
self.candidates.get(symbol_id)
}
pub(crate) fn factor_rows(&self) -> &'a [DailyFactorSnapshot] {
self.factor_rows
}
pub(crate) fn factor_symbol_ids(&self) -> &'a [u32] {
self.factor_symbol_ids
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct StandardRollingMeans {
pub close: [Option<f64>; 7],
@@ -1868,6 +1917,52 @@ impl DataSet {
)
}
pub(crate) fn daily_snapshot_view(&self, date: NaiveDate) -> DailySnapshotView<'_> {
fn rows_on<'a, T>(
date: NaiveDate,
rows_by_date: &'a BTreeMap<NaiveDate, Vec<T>>,
symbol_ids_by_date: &'a BTreeMap<NaiveDate, Vec<u32>>,
row_positions_by_date: &'a Option<DenseRowPositionIndex>,
) -> DailySymbolRows<'a, T> {
DailySymbolRows {
rows: rows_by_date.get(&date).map(Vec::as_slice).unwrap_or(&[]),
symbol_ids: symbol_ids_by_date
.get(&date)
.map(Vec::as_slice)
.unwrap_or(&[]),
row_positions: row_positions_by_date
.as_ref()
.and_then(|positions| positions.get(&date))
.map(Vec::as_slice),
}
}
DailySnapshotView {
market: rows_on(
date,
&self.market_by_date,
&self.market_symbol_ids_by_date,
&self.market_row_positions_by_date,
),
factor_rows: self
.factor_by_date
.get(&date)
.map(Vec::as_slice)
.unwrap_or(&[]),
factor_symbol_ids: self
.factor_symbol_ids_by_date
.get(&date)
.map(Vec::as_slice)
.unwrap_or(&[]),
candidates: rows_on(
date,
&self.candidate_by_date,
&self.candidate_symbol_ids_by_date,
&self.candidate_row_positions_by_date,
),
}
}
fn market_series(&self, symbol: &str) -> Option<&SymbolPriceSeries> {
self.market_series_by_symbol.get(symbol).map(Arc::as_ref)
}
@@ -4775,11 +4870,16 @@ mod tests {
for symbol in ["000001.SZ", "600000.SH"] {
let symbol_id = data.symbol_id(symbol).unwrap();
let day = data.daily_snapshot_view(date);
assert_eq!(
data.market_by_symbol_id(date, symbol_id)
.map(|row| row.symbol.as_str()),
Some(symbol)
);
assert_eq!(
day.market(symbol_id).map(|row| row.symbol.as_str()),
Some(symbol)
);
assert_eq!(
data.factor_by_symbol_id(date, symbol_id)
.map(|row| row.symbol.as_str()),
@@ -4790,15 +4890,138 @@ mod tests {
.map(|row| row.symbol.as_str()),
Some(symbol)
);
assert_eq!(
day.candidate(symbol_id).map(|row| row.symbol.as_str()),
Some(symbol)
);
}
let signal_id = data.symbol_id("000300.SH").unwrap();
let day = data.daily_snapshot_view(date);
assert_eq!(
data.market_by_symbol_id(date, signal_id).map(|row| row.symbol.as_str()),
Some("000300.SH")
);
assert!(data.factor_by_symbol_id(date, signal_id).is_none());
assert!(data.candidate_by_symbol_id(date, signal_id).is_none());
assert!(day.candidate(signal_id).is_none());
}
#[test]
#[ignore = "manual component benchmark"]
fn benchmark_daily_snapshot_view_lookup() {
use std::hint::black_box;
use std::time::Instant;
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let symbol_count = 6_000usize;
let symbols = (0..symbol_count)
.map(|index| format!("{index:06}.SZ"))
.collect::<Vec<_>>();
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: symbol.clone(),
name: symbol.clone(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let market = symbols
.iter()
.enumerate()
.map(|(index, symbol)| {
let mut row = market_row(
"2025-01-02",
10.0 + index as f64 / 1000.0,
1_000_000,
);
row.symbol = symbol.clone();
row
})
.collect::<Vec<_>>();
let factors = symbols
.iter()
.enumerate()
.map(|(index, symbol)| DailyFactorSnapshot {
date,
symbol: symbol.clone(),
market_cap_bn: 10.0 + index as f64 / 1000.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: NumericFactorMap::new(),
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
date,
symbol: symbol.clone(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
})
.collect::<Vec<_>>();
let data = DataSet::from_components(
instruments,
market,
factors,
candidates,
vec![benchmark_row("2025-01-02", 20.0)],
)
.unwrap();
let symbol_ids = symbols
.iter()
.map(|symbol| data.symbol_id(symbol).unwrap())
.collect::<Vec<_>>();
let rounds = 200usize;
let started = Instant::now();
let mut baseline_sum = 0.0;
for _ in 0..rounds {
for symbol_id in symbol_ids.iter().copied() {
baseline_sum += black_box(
data.market_by_symbol_id(date, symbol_id).unwrap().close
+ data
.candidate_by_symbol_id(date, symbol_id)
.unwrap()
.allow_buy as u8 as f64,
);
}
}
let baseline = started.elapsed();
let day = data.daily_snapshot_view(date);
let started = Instant::now();
let mut view_sum = 0.0;
for _ in 0..rounds {
for symbol_id in symbol_ids.iter().copied() {
view_sum += black_box(
day.market(symbol_id).unwrap().close
+ day.candidate(symbol_id).unwrap().allow_buy as u8 as f64,
);
}
}
let view = started.elapsed();
assert_eq!(baseline_sum, view_sum);
println!(
"daily_snapshot_view rows={} rounds={} baseline_seconds={:.6} view_seconds={:.6}",
symbol_count,
rounds,
baseline.as_secs_f64(),
view.as_secs_f64(),
);
}
#[test]
+348 -11
View File
@@ -942,6 +942,49 @@ enum RuntimeExpressionSegment {
struct RuntimeHelperBinding {
name: String,
args: Vec<String>,
compiled_args: Option<CompiledRuntimeHelperArgs>,
}
#[derive(Clone)]
enum CompiledRuntimeHelperArgs {
RollingMean {
field: String,
lookback: usize,
current: bool,
},
RollingMaxCurrent {
field: String,
lookback: usize,
},
RollingReturnStddevCurrent {
field: String,
return_count: usize,
},
VolumeMean {
lookback: usize,
},
RollingAggregate {
field: String,
lookback: usize,
operation: CompiledRollingOperation,
},
PctChange {
field: String,
lookback: usize,
},
FactorValue {
field: String,
lookback: usize,
},
}
#[derive(Clone, Copy)]
enum CompiledRollingOperation {
Sum,
Min,
Max,
Stddev,
Zscore,
}
/// Typed result of resolving a runtime helper.
@@ -986,6 +1029,7 @@ pub struct PlatformExprStrategy {
prelude_identifier_candidates: BTreeSet<String>,
prelude_declared_identifiers: BTreeSet<String>,
stock_filter_quote_usage: StockFilterQuoteUsage,
stock_filter_expr_present: bool,
selection_quote_usage: StockFilterQuoteUsage,
stock_rolling_requirements: StockRollingRequirements,
stock_extra_factors_required: bool,
@@ -1259,6 +1303,7 @@ impl PlatformExprStrategy {
Self::extract_identifier_candidates(&normalized_prelude);
let prelude_declared_identifiers = Self::declared_prelude_identifiers(&config.prelude);
let normalized_stock_filter_expr = Self::normalize_expr(&config.stock_filter_expr);
let stock_filter_expr_present = !normalized_stock_filter_expr.is_empty();
let stock_filter_quote_usage =
Self::stock_filter_quote_usage_for_expr(&normalized_stock_filter_expr);
let selection_quote_usage =
@@ -1302,6 +1347,7 @@ impl PlatformExprStrategy {
prelude_identifier_candidates,
prelude_declared_identifiers,
stock_filter_quote_usage,
stock_filter_expr_present,
selection_quote_usage,
stock_rolling_requirements,
stock_extra_factors_required,
@@ -4891,8 +4937,11 @@ impl PlatformExprStrategy {
binding: &RuntimeHelperBinding,
expected_type: NumericVmValueType,
) -> Result<NumericVmValue, BacktestError> {
let resolved =
self.resolve_runtime_helper(ctx, day, stock, &binding.name, &binding.args)?;
let resolved = if let Some(compiled_args) = binding.compiled_args.as_ref() {
self.resolve_compiled_runtime_helper(ctx, day, stock, &binding.name, compiled_args)?
} else {
self.resolve_runtime_helper(ctx, day, stock, &binding.name, &binding.args)?
};
match (expected_type, resolved) {
(NumericVmValueType::Number, RuntimeHelperResolution::Number(value)) => {
Ok(NumericVmValue::Number(value))
@@ -4928,6 +4977,112 @@ impl PlatformExprStrategy {
}
}
fn resolve_compiled_runtime_helper(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
helper: &str,
args: &CompiledRuntimeHelperArgs,
) -> Result<RuntimeHelperResolution, BacktestError> {
match args {
CompiledRuntimeHelperArgs::RollingMean {
field,
lookback,
current,
} => {
let value = if *current {
self.resolve_current_rolling_mean(ctx, day, stock, field, *lookback)?
} else {
self.resolve_rolling_mean(ctx, day, stock, field, *lookback)?
};
Ok(RuntimeHelperResolution::Number(value))
}
CompiledRuntimeHelperArgs::RollingMaxCurrent { field, lookback } => {
let values =
self.resolve_current_rolling_values(ctx, day, stock, field, *lookback)?;
let value = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Ok(Self::normalized_runtime_number(value))
}
CompiledRuntimeHelperArgs::RollingReturnStddevCurrent {
field,
return_count,
} => {
let values = self.resolve_current_rolling_values(
ctx,
day,
stock,
field,
return_count.saturating_add(1),
)?;
let returns = values
.windows(2)
.map(|pair| pair[1] / pair[0] - 1.0)
.collect::<Vec<_>>();
if returns.iter().any(|value| !value.is_finite()) {
return Err(BacktestError::Execution(format!(
"invalid current rolling return for field {field} with count {return_count}"
)));
}
Ok(Self::normalized_runtime_number(rolling_sample_stddev(
&returns,
)))
}
CompiledRuntimeHelperArgs::VolumeMean { lookback } => {
let value = self.resolve_rolling_mean(ctx, day, stock, "volume", *lookback)?;
Ok(RuntimeHelperResolution::Number(value))
}
CompiledRuntimeHelperArgs::RollingAggregate {
field,
lookback,
operation,
} => {
let values = self.resolve_rolling_values(ctx, day, stock, field, *lookback)?;
let value = match operation {
CompiledRollingOperation::Sum => values.iter().sum::<f64>(),
CompiledRollingOperation::Min => {
values.iter().copied().fold(f64::INFINITY, f64::min)
}
CompiledRollingOperation::Max => {
values.iter().copied().fold(f64::NEG_INFINITY, f64::max)
}
CompiledRollingOperation::Stddev => rolling_stddev(&values),
CompiledRollingOperation::Zscore => rolling_zscore(&values),
};
Ok(Self::normalized_runtime_number(value))
}
CompiledRuntimeHelperArgs::PctChange { field, lookback } => {
let values = self.resolve_rolling_values(
ctx,
day,
stock,
field,
lookback.saturating_add(1),
)?;
let first = values.first().copied().unwrap_or_default();
let last = values.last().copied().unwrap_or_default();
let value = if first.abs() <= f64::EPSILON {
0.0
} else {
last / first - 1.0
};
Ok(Self::normalized_runtime_number(value))
}
CompiledRuntimeHelperArgs::FactorValue { field, lookback } => {
let stock = stock.ok_or_else(|| {
BacktestError::Execution(format!("{helper} requires stock context"))
})?;
let start = self.helper_start_date(ctx, day.date, *lookback);
let value = ctx
.get_factor(&stock.symbol, start, day.date, field)
.last()
.map(|row| row.value)
.unwrap_or(0.0);
Ok(Self::normalized_runtime_number(value))
}
}
}
fn numeric_vm_identifier_value(
&self,
ctx: &StrategyContext<'_>,
@@ -5885,6 +6040,7 @@ impl PlatformExprStrategy {
let binding = RuntimeHelperBinding {
name: name.clone(),
args: args.clone(),
compiled_args: Self::compile_runtime_helper_args(name, args),
};
if let Some(existing) = bindings.get(scope_name)
&& (existing.name != binding.name || existing.args != binding.args)
@@ -5899,6 +6055,81 @@ impl PlatformExprStrategy {
Ok(output)
}
fn compile_runtime_helper_args(
helper: &str,
args: &[String],
) -> Option<CompiledRuntimeHelperArgs> {
let field_lookback = || {
if args.len() != 2 {
return None;
}
Some((
Self::parse_string_or_identifier(&args[0]).ok()?,
Self::parse_positive_usize(&args[1]).ok()?,
))
};
match helper {
"rolling_mean" | "sma" | "ma" => {
let (field, lookback) = field_lookback()?;
Some(CompiledRuntimeHelperArgs::RollingMean {
field,
lookback,
current: false,
})
}
"rolling_mean_current" => {
let (field, lookback) = field_lookback()?;
Some(CompiledRuntimeHelperArgs::RollingMean {
field,
lookback,
current: true,
})
}
"rolling_max_current" => {
let (field, lookback) = field_lookback()?;
Some(CompiledRuntimeHelperArgs::RollingMaxCurrent { field, lookback })
}
"rolling_return_stddev_current" => {
let (field, return_count) = field_lookback()?;
Some(CompiledRuntimeHelperArgs::RollingReturnStddevCurrent {
field,
return_count,
})
}
"vma" if args.len() == 1 => Some(CompiledRuntimeHelperArgs::VolumeMean {
lookback: Self::parse_positive_usize(&args[0]).ok()?,
}),
"rolling_sum" | "rolling_min" | "rolling_max" | "rolling_stddev" | "stddev"
| "rolling_zscore" => {
let (field, lookback) = field_lookback()?;
let operation = match helper {
"rolling_sum" => CompiledRollingOperation::Sum,
"rolling_min" => CompiledRollingOperation::Min,
"rolling_max" => CompiledRollingOperation::Max,
"rolling_stddev" | "stddev" => CompiledRollingOperation::Stddev,
"rolling_zscore" => CompiledRollingOperation::Zscore,
_ => return None,
};
Some(CompiledRuntimeHelperArgs::RollingAggregate {
field,
lookback,
operation,
})
}
"pct_change" => {
let (field, lookback) = field_lookback()?;
Some(CompiledRuntimeHelperArgs::PctChange { field, lookback })
}
"factor_value" | "get_factor_value" if (1..=2).contains(&args.len()) => {
Some(CompiledRuntimeHelperArgs::FactorValue {
field: Self::parse_string_or_identifier(&args[0]).ok()?,
lookback: Self::parse_optional_positive_usize(args.get(1), 1).ok()?,
})
}
_ => None,
}
}
fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> {
match helper {
"has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
@@ -6040,6 +6271,15 @@ impl PlatformExprStrategy {
helper: &str,
args: &[String],
) -> Result<RuntimeHelperResolution, BacktestError> {
if let Some(compiled_args) = Self::compile_runtime_helper_args(helper, args) {
return self.resolve_compiled_runtime_helper(
ctx,
day,
stock,
helper,
&compiled_args,
);
}
match helper {
"factor" => {
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
@@ -8820,7 +9060,7 @@ impl PlatformExprStrategy {
day: &DayExpressionState,
stock: &StockExpressionState,
) -> Result<bool, BacktestError> {
if self.config.stock_filter_expr.trim().is_empty() {
if !self.stock_filter_expr_present {
return Ok(true);
}
match self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) {
@@ -8883,8 +9123,10 @@ impl PlatformExprStrategy {
) -> (Vec<EligibleUniverseSnapshot>, Vec<FidcRiskDecisionAudit>) {
let mut rows = Vec::new();
let mut decisions = Vec::new();
let factor_rows = ctx.data.factor_snapshot_rows_on(factor_date);
let factor_symbol_ids = ctx.data.factor_symbol_ids_on(factor_date);
let execution_day = ctx.data.daily_snapshot_view(date);
let factor_day = ctx.data.daily_snapshot_view(factor_date);
let factor_rows = factor_day.factor_rows();
let factor_symbol_ids = factor_day.factor_symbol_ids();
debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len());
for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) {
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
@@ -8892,14 +9134,14 @@ impl PlatformExprStrategy {
}
let synthetic_candidate;
let candidate =
if let Some(candidate) = ctx.data.candidate_by_symbol_id(date, symbol_id) {
if let Some(candidate) = execution_day.candidate(symbol_id) {
candidate
} else {
synthetic_candidate =
crate::data::missing_candidate_risk_state(date, &factor.symbol);
&synthetic_candidate
};
let Some(market) = ctx.data.market_by_symbol_id(date, symbol_id) else {
let Some(market) = execution_day.market(symbol_id) else {
continue;
};
let (reject_from_universe, selection_decision) = if collect_risk_decisions {
@@ -9064,6 +9306,9 @@ impl PlatformExprStrategy {
}
fn universe_exclude_reason(excludes: &[String], symbol: &str) -> Option<&'static str> {
if excludes.is_empty() {
return None;
}
let normalized_symbol = symbol.trim().to_ascii_lowercase();
if normalized_symbol.is_empty() {
return None;
@@ -9085,8 +9330,15 @@ impl PlatformExprStrategy {
}
fn symbol_is_bjse(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase();
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
let normalized = symbol.trim();
normalized
.get(normalized.len().saturating_sub(3)..)
.is_some_and(|suffix| {
suffix.eq_ignore_ascii_case(".BJ") || suffix.eq_ignore_ascii_case(".BE")
})
|| normalized
.get(normalized.len().saturating_sub(4)..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case(".BSE"))
}
fn stock_numeric_field_value(
@@ -12207,8 +12459,9 @@ mod tests {
use chrono::{NaiveDate, NaiveTime};
use super::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy,
PlatformExprStrategyConfig,
PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution,
@@ -12257,6 +12510,20 @@ mod tests {
PlatformExprStrategy::universe_exclude_reason(&excludes, "300001.SZ"),
None
);
assert_eq!(
PlatformExprStrategy::universe_exclude_reason(&[], " 920508.bj "),
None
);
}
#[test]
fn bjse_symbol_detection_is_case_insensitive_without_normalizing_all_symbols() {
assert!(PlatformExprStrategy::symbol_is_bjse("920508.BJ"));
assert!(PlatformExprStrategy::symbol_is_bjse(" 920508.bj "));
assert!(PlatformExprStrategy::symbol_is_bjse("430001.BSE"));
assert!(PlatformExprStrategy::symbol_is_bjse("430001.be"));
assert!(!PlatformExprStrategy::symbol_is_bjse("688001.SH"));
assert!(!PlatformExprStrategy::symbol_is_bjse("BJ"));
}
#[test]
@@ -32891,6 +33158,76 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0;
);
}
#[test]
fn numeric_vm_compiles_static_runtime_helper_arguments() {
let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
let plan = strategy.expression_eval_plan(
"rolling_mean(\"close\", 5) + rolling_mean_current(\"volume\", 10) + \
rolling_max_current(\"close\", 20) + rolling_return_stddev_current(\"close\", 30) + \
vma(60) + rolling_sum(\"amount\", 5) + rolling_min(\"close\", 10) + \
rolling_max(\"close\", 10) + rolling_stddev(\"close\", 20) + \
rolling_zscore(\"close\", 20) + pct_change(\"close\", 5) + \
factor_value(\"quality_score\", 1)",
);
let vm = plan.numeric_vm.as_ref().expect("numeric VM plan");
let bindings = vm
.helper_bindings
.iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(bindings.len(), 12);
assert!(
bindings
.iter()
.all(|binding| binding.compiled_args.is_some()),
"all static numeric helper arguments must be parsed once at plan compilation"
);
}
#[test]
#[ignore = "manual release-mode runtime helper binding benchmark"]
fn benchmark_compiled_runtime_helper_arguments() {
let args = vec!["\"close\"".to_string(), "20".to_string()];
let compiled = PlatformExprStrategy::compile_runtime_helper_args("rolling_mean", &args)
.expect("compiled helper arguments");
let iterations = 2_000_000usize;
let generic_started = std::time::Instant::now();
let mut generic_checksum = 0usize;
for _ in 0..iterations {
let field = PlatformExprStrategy::parse_string_or_identifier(std::hint::black_box(
&args[0],
))
.expect("field");
let lookback = PlatformExprStrategy::parse_positive_usize(std::hint::black_box(
&args[1],
))
.expect("lookback");
generic_checksum = generic_checksum.wrapping_add(field.len() + lookback);
}
let generic_seconds = generic_started.elapsed().as_secs_f64();
let compiled_started = std::time::Instant::now();
let mut compiled_checksum = 0usize;
for _ in 0..iterations {
let CompiledRuntimeHelperArgs::RollingMean {
field, lookback, ..
} = std::hint::black_box(&compiled)
else {
panic!("unexpected compiled helper binding");
};
compiled_checksum = compiled_checksum.wrapping_add(field.len() + *lookback);
}
let compiled_seconds = compiled_started.elapsed().as_secs_f64();
assert_eq!(generic_checksum, compiled_checksum);
println!(
"runtime_helper_binding_benchmark iterations={iterations} generic_seconds={generic_seconds:.6} compiled_seconds={compiled_seconds:.6} speedup={:.3} checksum={generic_checksum}",
generic_seconds / compiled_seconds.max(f64::EPSILON),
);
}
#[test]
fn numeric_vm_reuses_rolling_helper_program_across_dates() {
let dates = [d(2025, 2, 3), d(2025, 2, 4)];
+62 -18
View File
@@ -336,6 +336,15 @@ pub struct StrategyRiskPolicySpec {
alias = "blacklist"
)]
pub blacklisted_symbols: Vec<String>,
/// Account- and strategy-scoped blacklist facts are injected by the
/// trading platform before runtime execution. They are typed here so the
/// shared contract validator accepts the context without silently
/// discarding malformed values; the trading risk layer applies the
/// account/strategy match with the actual execution identity.
#[serde(default, alias = "accountBlacklistedInstruments")]
pub account_blacklisted_instruments: BTreeMap<String, BTreeSet<String>>,
#[serde(default, alias = "strategyBlacklistedInstruments")]
pub strategy_blacklisted_instruments: BTreeMap<String, BTreeSet<String>>,
#[serde(
default,
alias = "volume_limit_enabled",
@@ -675,8 +684,14 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
("matchingType", &["matching_type"]),
("slippageModel", &["slippage_model"]),
("slippageValue", &["slippage_value"]),
("slippageImpactCoefficient", &["slippage_impact_coefficient"]),
("slippageVolatilityCoefficient", &["slippage_volatility_coefficient"]),
(
"slippageImpactCoefficient",
&["slippage_impact_coefficient"],
),
(
"slippageVolatilityCoefficient",
&["slippage_volatility_coefficient"],
),
(
"slippageMaxValue",
&["slippage_max_value", "slippage_max_rate"],
@@ -688,7 +703,10 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
),
("transferFeeRate", &["transfer_fee_rate", "transferFeeRate"]),
("stampTaxRate", &["stamp_tax_rate"]),
("stampTaxRateBeforeChange", &["stamp_tax_rate_before_change"]),
(
"stampTaxRateBeforeChange",
&["stamp_tax_rate_before_change"],
),
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
("stampTaxChangeDate", &["stamp_tax_change_date"]),
("volumeLimit", &["volume_limit"]),
@@ -1080,6 +1098,10 @@ pub fn validate_strategy_risk_policy_fields(value: &Value) -> Result<(), String>
"blacklistedInstruments",
"blacklisted_instruments",
"blacklist",
"accountBlacklistedInstruments",
"account_blacklisted_instruments",
"strategyBlacklistedInstruments",
"strategy_blacklisted_instruments",
// Legacy execution aliases are accepted by StrategyExecutionSpec and
// normalized into the same shared switches.
"volumeLimit",
@@ -1295,18 +1317,12 @@ fn apply_risk_policy_overrides(
let Some(policy) = policy else {
return Ok(());
};
let max_order_quantity = valid_positive_limit(
policy.max_order_quantity,
"riskPolicy.maxOrderQuantity",
)?;
let max_order_notional = valid_positive_limit(
policy.max_order_notional,
"riskPolicy.maxOrderNotional",
)?;
let max_symbol_position = valid_positive_limit(
policy.max_symbol_position,
"riskPolicy.maxSymbolPosition",
)?;
let max_order_quantity =
valid_positive_limit(policy.max_order_quantity, "riskPolicy.maxOrderQuantity")?;
let max_order_notional =
valid_positive_limit(policy.max_order_notional, "riskPolicy.maxOrderNotional")?;
let max_symbol_position =
valid_positive_limit(policy.max_symbol_position, "riskPolicy.maxSymbolPosition")?;
if let Some(value) = max_order_quantity {
cfg.risk_config.trading_constraints.max_order_quantity = value;
}
@@ -3433,9 +3449,18 @@ mod tests {
);
assert!(cfg.risk_config.trading_constraints.volume_limit_enabled);
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
assert_eq!(cfg.risk_config.trading_constraints.max_order_quantity, 8000.0);
assert_eq!(cfg.risk_config.trading_constraints.max_order_notional, 2_000_000.0);
assert_eq!(cfg.risk_config.trading_constraints.max_symbol_position, 12_000.0);
assert_eq!(
cfg.risk_config.trading_constraints.max_order_quantity,
8000.0
);
assert_eq!(
cfg.risk_config.trading_constraints.max_order_notional,
2_000_000.0
);
assert_eq!(
cfg.risk_config.trading_constraints.max_symbol_position,
12_000.0
);
assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
assert_eq!(
cfg.risk_config
@@ -3647,6 +3672,25 @@ mod tests {
assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0);
}
#[test]
fn accepts_scoped_blacklist_context_in_runtime_risk_policy() {
let spec = serde_json::json!({
"execution": {
"riskPolicy": {
"account_blacklisted_instruments": {
"2075773": ["000001.SZ"]
},
"strategyBlacklistedInstruments": {
"live-gt-2075773-20260829": ["600000.SH"]
}
}
}
});
platform_expr_config_from_value("105", "932000.CSI", &spec)
.expect("scoped blacklist context is part of the shared runtime contract");
}
#[test]
fn rejects_conflicting_risk_policy_alias_values() {
let bool_conflict = serde_json::json!({
+18 -12
View File
@@ -1263,18 +1263,24 @@ fn engine_runs_strategy_hooks_in_daily_order() {
)
.with_process_event_retention(ProcessEventRetention::Business);
let compact_result = compact_engine.run().expect("compact backtest succeeds");
assert!(compact_result
.process_events
.iter()
.all(|event| event.kind.is_business_lifecycle()));
assert!(compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::OnDay));
assert!(!compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading));
assert!(
compact_result
.process_events
.iter()
.all(|event| event.kind.is_business_lifecycle())
);
assert!(
compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::OnDay)
);
assert!(
!compact_result
.process_events
.iter()
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading)
);
assert_eq!(
result.process_events[..18]
.iter()
+10 -2
View File
@@ -10,7 +10,7 @@ The roadmap focuses on making the engine complete enough for editable platform
strategies, long-range A-share backtests, futures strategies, intraday order
simulation, AI-generated strategy code, and service-level result downloads.
## Re-Audit Findings (2026-04-24)
## Re-Audit Findings (2026-08-31)
The latest re-audit focused on the engine's execution model, account model,
order lifecycle, data helper surface, analyzer output, extension hooks, and
@@ -39,7 +39,8 @@ futures path. Confirmed aligned areas:
| P0 | Futures intraday matching | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. |
| P0 | Futures open-order lifecycle | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires extra intermediate event names. |
| P0 | Combined multi-account NAV | Closed. `DailyEquityPoint`, progress events, and metrics use aggregate stock + futures initial cash and total equity. | None. |
| P0 | Fixed-point execution money | Stock execution now freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows and account units. Market indicators and return statistics remain `f64` outside the execution boundary. | Migrate position cost/PnL and the standalone futures cash ledger only after independent artifact and performance A/B gates. |
| P0 | Fixed-point execution money | Closed. Stock execution freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows, account units, position lot cost and realized PnL. The standalone futures account uses the same fixed-point money boundary for cash, margin, transaction cost and daily PnL. Market indicators and return statistics remain `f64` outside the execution boundary. | None. |
| P0 | Bounded minute-data processing | Closed for the engine data model. Intraday history uses a sorted date index and scans backward only until the requested bar count is satisfied. Daily minute processing consumes a borrowed timestamp-ordered k-way merge and does not clone/materialize the full selected quote day before event dispatch. | Keep Source Lake and service clients batch-streamed; do not reintroduce whole-window row materialization. |
| P1 | Futures trading parameter data source | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
| P1 | Futures transaction cost decider | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. |
| P1 | Futures settlement price mode | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. |
@@ -58,6 +59,10 @@ futures path. Confirmed aligned areas:
- [x] Fine-grained daily and minute execution quote strategy entrypoints.
- [x] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan
fixed-point execution primitive; one-micro over-budget orders fail.
- [x] Stock position lots, realized/unrealized PnL, dividends and external cash
flows preserve fixed-point value conservation.
- [x] Futures cash, margin, transaction cost and daily realized/position PnL use
the fixed-point ledger.
- [x] Scheduled actions evaluated against explicit intraday times.
- [x] `update_universe`, `subscribe`, and `unsubscribe`.
- [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.
@@ -73,6 +78,9 @@ futures path. Confirmed aligned areas:
- [x] Trading-date range, previous-date, and next-date helpers.
- [x] Phase-aware minute history cursor semantics matching the active bar or
intraday execution quote callback.
- [x] Bounded intraday history lookup and borrowed minute quote streaming avoid
full-history scans and full-day quote clones while preserving timestamp
order and visibility boundaries.
- [x] Suspension, ST, date-range price, active instrument, and instrument
history helpers.
- [x] Open-order status, unfilled quantity, final order lookup, average fill
@@ -0,0 +1,75 @@
# Fixed-Point and Minute-Stream Acceptance
Acceptance date: 2026-08-31
Engine commit: `cd116bc3ae77cac0989eb80185bb04d7440b8834`
## Scope
This acceptance separates execution precision from minute-data throughput. It
does not use a strategy-specific shortcut and does not change strategy,
matching, risk, slippage, commission, tax, or future-data semantics.
## Fixed-Point Boundary
- Execution money is signed micro-yuan `i128`.
- Stock gross amount, commission, stamp tax, transfer fee, cash, liabilities,
external cash flow, account units, position lot cost and PnL are fixed-point.
- Futures cash, margin, transaction cost, realized PnL and position PnL are
fixed-point.
- Market indicators and return statistics remain `f64`; conversion occurs only
at the execution-money boundary.
- One-micro-yuan budget overruns fail instead of being hidden by float epsilon.
Verification command:
```bash
cargo test -p fidc-core fixed -- --nocapture
```
Result: 8 passed, 0 failed.
## Minute Data Boundary
- `history_intraday_quotes_at` uses a sorted execution-date index and scans
backward until the requested bar count is satisfied.
- The active timestamp and `include_now` flag control visibility; later bars are
never visible.
- Daily minute execution consumes a borrowed k-way merge ordered by timestamp
and symbol. It does not clone the complete selected quote day before engine
dispatch.
- Execution quotes are released by trading date after the day finishes.
Release benchmark command:
```bash
cargo test -p fidc-core --test intraday_history_performance --release -- --ignored --nocapture
```
Observed on the local acceptance host:
| Case | Workload | Result |
| --- | --- | --- |
| Bounded history | 200 queries over 60,000 rows | 0.000227 seconds, checksum 351450348000 |
| Full-day materialization | 5,000 iterations x 240 rows | 0.049361 seconds |
| Borrowed quote stream | 5,000 iterations x 240 rows | 0.012232 seconds |
The materialized and streamed timestamp checksums were both
`2108693484000000`. The observed component speedup was about 4.04x. These
numbers are component evidence only and are not an end-to-end SLA.
## Regression
```bash
cargo test -p fidc-core --all-targets
```
Result: 528 passed, 0 failed, 1 ignored manual benchmark. This includes
execution-day risk, next-open open-price limits, minute timestamp visibility,
slippage, minimum commission, stamp tax, volume limits, corporate actions,
external cash-flow NAV treatment and futures account precision.
## Deployment Gate
This documentation-only correction does not require a service restart. Any
future Source Lake or engine deployment still requires the official managed
entrypoint and must fail closed while FIDC-managed factor work is active.
@@ -0,0 +1,51 @@
# Market Day View Component Benchmark
Date: 2026-08-31
## Scope
The platform-expression selection loop already iterates one factor slice for a
single trading date. The previous implementation still resolved the same date
in the market and candidate `BTreeMap` for every symbol. `DailySnapshotView`
borrows the existing immutable market/factor/candidate slices and dense row
position arrays once per date, then performs only `symbol_id -> row` lookups.
The view does not copy snapshots, cache strategy results, share account state,
or change missing-row behavior. The optimization is independent of strategy
text, thresholds, rolling windows, execution mode and portfolio size.
## Release Component A/B
Contract:
- 6,000 symbols;
- 200 complete lookup rounds;
- each lookup reads market close and candidate `allow_buy`;
- baseline and view checksums must be exactly equal;
- `cargo test --release`, system allocator, local macOS host.
| Round | Baseline seconds | Day view seconds |
| ---: | ---: | ---: |
| 1 | 0.009000 | 0.002939 |
| 2 | 0.004370 | 0.001555 |
| 3 | 0.004274 | 0.001578 |
Median component time changed from `0.004370s` to `0.001578s`, an observed
reduction of about `63.9%` (`2.77x`). This is a component result only and is
not a complete backtest SLA.
## Correctness Gates
- sparse market-only symbols remain absent from factor/candidate views;
- dense and binary-search fallback lookup semantics remain unchanged;
- full engine suite: 529 passed, 3 ignored manual benchmarks;
- next-open execution-day risk, minute matching, fees, slippage, volume limits,
corporate actions, delisting and futures tests all passed.
## Deployment Status
Not deployed. The 177 FIDC-managed Boris factor task is still active, so no
Source Lake, backtest service or engine restart is allowed. After the task
ends naturally, acceptance must use the same frozen bundle and compare daily
selection, orders, fills, holdings, NAV, risk facts and canonical digest for
multiple daily/minute and fixed/dynamic-universe strategies.