Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -1335,6 +1335,55 @@ pub struct DataSet {
|
|||||||
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
|
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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub(crate) struct StandardRollingMeans {
|
pub(crate) struct StandardRollingMeans {
|
||||||
pub close: [Option<f64>; 7],
|
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> {
|
fn market_series(&self, symbol: &str) -> Option<&SymbolPriceSeries> {
|
||||||
self.market_series_by_symbol.get(symbol).map(Arc::as_ref)
|
self.market_series_by_symbol.get(symbol).map(Arc::as_ref)
|
||||||
}
|
}
|
||||||
@@ -4775,11 +4870,16 @@ mod tests {
|
|||||||
|
|
||||||
for symbol in ["000001.SZ", "600000.SH"] {
|
for symbol in ["000001.SZ", "600000.SH"] {
|
||||||
let symbol_id = data.symbol_id(symbol).unwrap();
|
let symbol_id = data.symbol_id(symbol).unwrap();
|
||||||
|
let day = data.daily_snapshot_view(date);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.market_by_symbol_id(date, symbol_id)
|
data.market_by_symbol_id(date, symbol_id)
|
||||||
.map(|row| row.symbol.as_str()),
|
.map(|row| row.symbol.as_str()),
|
||||||
Some(symbol)
|
Some(symbol)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
day.market(symbol_id).map(|row| row.symbol.as_str()),
|
||||||
|
Some(symbol)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.factor_by_symbol_id(date, symbol_id)
|
data.factor_by_symbol_id(date, symbol_id)
|
||||||
.map(|row| row.symbol.as_str()),
|
.map(|row| row.symbol.as_str()),
|
||||||
@@ -4790,15 +4890,138 @@ mod tests {
|
|||||||
.map(|row| row.symbol.as_str()),
|
.map(|row| row.symbol.as_str()),
|
||||||
Some(symbol)
|
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 signal_id = data.symbol_id("000300.SH").unwrap();
|
||||||
|
let day = data.daily_snapshot_view(date);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.market_by_symbol_id(date, signal_id).map(|row| row.symbol.as_str()),
|
data.market_by_symbol_id(date, signal_id).map(|row| row.symbol.as_str()),
|
||||||
Some("000300.SH")
|
Some("000300.SH")
|
||||||
);
|
);
|
||||||
assert!(data.factor_by_symbol_id(date, signal_id).is_none());
|
assert!(data.factor_by_symbol_id(date, signal_id).is_none());
|
||||||
assert!(data.candidate_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]
|
#[test]
|
||||||
|
|||||||
@@ -942,6 +942,49 @@ enum RuntimeExpressionSegment {
|
|||||||
struct RuntimeHelperBinding {
|
struct RuntimeHelperBinding {
|
||||||
name: String,
|
name: String,
|
||||||
args: Vec<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.
|
/// Typed result of resolving a runtime helper.
|
||||||
@@ -986,6 +1029,7 @@ pub struct PlatformExprStrategy {
|
|||||||
prelude_identifier_candidates: BTreeSet<String>,
|
prelude_identifier_candidates: BTreeSet<String>,
|
||||||
prelude_declared_identifiers: BTreeSet<String>,
|
prelude_declared_identifiers: BTreeSet<String>,
|
||||||
stock_filter_quote_usage: StockFilterQuoteUsage,
|
stock_filter_quote_usage: StockFilterQuoteUsage,
|
||||||
|
stock_filter_expr_present: bool,
|
||||||
selection_quote_usage: StockFilterQuoteUsage,
|
selection_quote_usage: StockFilterQuoteUsage,
|
||||||
stock_rolling_requirements: StockRollingRequirements,
|
stock_rolling_requirements: StockRollingRequirements,
|
||||||
stock_extra_factors_required: bool,
|
stock_extra_factors_required: bool,
|
||||||
@@ -1259,6 +1303,7 @@ impl PlatformExprStrategy {
|
|||||||
Self::extract_identifier_candidates(&normalized_prelude);
|
Self::extract_identifier_candidates(&normalized_prelude);
|
||||||
let prelude_declared_identifiers = Self::declared_prelude_identifiers(&config.prelude);
|
let prelude_declared_identifiers = Self::declared_prelude_identifiers(&config.prelude);
|
||||||
let normalized_stock_filter_expr = Self::normalize_expr(&config.stock_filter_expr);
|
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 =
|
let stock_filter_quote_usage =
|
||||||
Self::stock_filter_quote_usage_for_expr(&normalized_stock_filter_expr);
|
Self::stock_filter_quote_usage_for_expr(&normalized_stock_filter_expr);
|
||||||
let selection_quote_usage =
|
let selection_quote_usage =
|
||||||
@@ -1302,6 +1347,7 @@ impl PlatformExprStrategy {
|
|||||||
prelude_identifier_candidates,
|
prelude_identifier_candidates,
|
||||||
prelude_declared_identifiers,
|
prelude_declared_identifiers,
|
||||||
stock_filter_quote_usage,
|
stock_filter_quote_usage,
|
||||||
|
stock_filter_expr_present,
|
||||||
selection_quote_usage,
|
selection_quote_usage,
|
||||||
stock_rolling_requirements,
|
stock_rolling_requirements,
|
||||||
stock_extra_factors_required,
|
stock_extra_factors_required,
|
||||||
@@ -4891,8 +4937,11 @@ impl PlatformExprStrategy {
|
|||||||
binding: &RuntimeHelperBinding,
|
binding: &RuntimeHelperBinding,
|
||||||
expected_type: NumericVmValueType,
|
expected_type: NumericVmValueType,
|
||||||
) -> Result<NumericVmValue, BacktestError> {
|
) -> Result<NumericVmValue, BacktestError> {
|
||||||
let resolved =
|
let resolved = if let Some(compiled_args) = binding.compiled_args.as_ref() {
|
||||||
self.resolve_runtime_helper(ctx, day, stock, &binding.name, &binding.args)?;
|
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) {
|
match (expected_type, resolved) {
|
||||||
(NumericVmValueType::Number, RuntimeHelperResolution::Number(value)) => {
|
(NumericVmValueType::Number, RuntimeHelperResolution::Number(value)) => {
|
||||||
Ok(NumericVmValue::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(
|
fn numeric_vm_identifier_value(
|
||||||
&self,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
@@ -5885,6 +6040,7 @@ impl PlatformExprStrategy {
|
|||||||
let binding = RuntimeHelperBinding {
|
let binding = RuntimeHelperBinding {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
args: args.clone(),
|
args: args.clone(),
|
||||||
|
compiled_args: Self::compile_runtime_helper_args(name, args),
|
||||||
};
|
};
|
||||||
if let Some(existing) = bindings.get(scope_name)
|
if let Some(existing) = bindings.get(scope_name)
|
||||||
&& (existing.name != binding.name || existing.args != binding.args)
|
&& (existing.name != binding.name || existing.args != binding.args)
|
||||||
@@ -5899,6 +6055,81 @@ impl PlatformExprStrategy {
|
|||||||
Ok(output)
|
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> {
|
fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> {
|
||||||
match helper {
|
match helper {
|
||||||
"has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
|
"has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
|
||||||
@@ -6040,6 +6271,15 @@ impl PlatformExprStrategy {
|
|||||||
helper: &str,
|
helper: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
) -> Result<RuntimeHelperResolution, BacktestError> {
|
) -> 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 {
|
match helper {
|
||||||
"factor" => {
|
"factor" => {
|
||||||
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
|
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
|
||||||
@@ -8820,7 +9060,7 @@ impl PlatformExprStrategy {
|
|||||||
day: &DayExpressionState,
|
day: &DayExpressionState,
|
||||||
stock: &StockExpressionState,
|
stock: &StockExpressionState,
|
||||||
) -> Result<bool, BacktestError> {
|
) -> Result<bool, BacktestError> {
|
||||||
if self.config.stock_filter_expr.trim().is_empty() {
|
if !self.stock_filter_expr_present {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
match self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) {
|
match self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) {
|
||||||
@@ -8883,8 +9123,10 @@ impl PlatformExprStrategy {
|
|||||||
) -> (Vec<EligibleUniverseSnapshot>, Vec<FidcRiskDecisionAudit>) {
|
) -> (Vec<EligibleUniverseSnapshot>, Vec<FidcRiskDecisionAudit>) {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut decisions = Vec::new();
|
let mut decisions = Vec::new();
|
||||||
let factor_rows = ctx.data.factor_snapshot_rows_on(factor_date);
|
let execution_day = ctx.data.daily_snapshot_view(date);
|
||||||
let factor_symbol_ids = ctx.data.factor_symbol_ids_on(factor_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());
|
debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len());
|
||||||
for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) {
|
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) {
|
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
|
||||||
@@ -8892,14 +9134,14 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
let synthetic_candidate;
|
let synthetic_candidate;
|
||||||
let 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
|
candidate
|
||||||
} else {
|
} else {
|
||||||
synthetic_candidate =
|
synthetic_candidate =
|
||||||
crate::data::missing_candidate_risk_state(date, &factor.symbol);
|
crate::data::missing_candidate_risk_state(date, &factor.symbol);
|
||||||
&synthetic_candidate
|
&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;
|
continue;
|
||||||
};
|
};
|
||||||
let (reject_from_universe, selection_decision) = if collect_risk_decisions {
|
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> {
|
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();
|
let normalized_symbol = symbol.trim().to_ascii_lowercase();
|
||||||
if normalized_symbol.is_empty() {
|
if normalized_symbol.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -9085,8 +9330,15 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn symbol_is_bjse(symbol: &str) -> bool {
|
fn symbol_is_bjse(symbol: &str) -> bool {
|
||||||
let normalized = symbol.trim().to_ascii_uppercase();
|
let normalized = symbol.trim();
|
||||||
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
|
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(
|
fn stock_numeric_field_value(
|
||||||
@@ -12207,8 +12459,9 @@ mod tests {
|
|||||||
use chrono::{NaiveDate, NaiveTime};
|
use chrono::{NaiveDate, NaiveTime};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy,
|
||||||
|
PlatformExprStrategyConfig,
|
||||||
PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController,
|
PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController,
|
||||||
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
|
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode,
|
||||||
PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution,
|
PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution,
|
||||||
@@ -12257,6 +12510,20 @@ mod tests {
|
|||||||
PlatformExprStrategy::universe_exclude_reason(&excludes, "300001.SZ"),
|
PlatformExprStrategy::universe_exclude_reason(&excludes, "300001.SZ"),
|
||||||
None
|
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]
|
#[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]
|
#[test]
|
||||||
fn numeric_vm_reuses_rolling_helper_program_across_dates() {
|
fn numeric_vm_reuses_rolling_helper_program_across_dates() {
|
||||||
let dates = [d(2025, 2, 3), d(2025, 2, 4)];
|
let dates = [d(2025, 2, 3), d(2025, 2, 4)];
|
||||||
|
|||||||
@@ -336,6 +336,15 @@ pub struct StrategyRiskPolicySpec {
|
|||||||
alias = "blacklist"
|
alias = "blacklist"
|
||||||
)]
|
)]
|
||||||
pub blacklisted_symbols: Vec<String>,
|
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(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
alias = "volume_limit_enabled",
|
alias = "volume_limit_enabled",
|
||||||
@@ -675,8 +684,14 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
("matchingType", &["matching_type"]),
|
("matchingType", &["matching_type"]),
|
||||||
("slippageModel", &["slippage_model"]),
|
("slippageModel", &["slippage_model"]),
|
||||||
("slippageValue", &["slippage_value"]),
|
("slippageValue", &["slippage_value"]),
|
||||||
("slippageImpactCoefficient", &["slippage_impact_coefficient"]),
|
(
|
||||||
("slippageVolatilityCoefficient", &["slippage_volatility_coefficient"]),
|
"slippageImpactCoefficient",
|
||||||
|
&["slippage_impact_coefficient"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slippageVolatilityCoefficient",
|
||||||
|
&["slippage_volatility_coefficient"],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"slippageMaxValue",
|
"slippageMaxValue",
|
||||||
&["slippage_max_value", "slippage_max_rate"],
|
&["slippage_max_value", "slippage_max_rate"],
|
||||||
@@ -688,7 +703,10 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
),
|
),
|
||||||
("transferFeeRate", &["transfer_fee_rate", "transferFeeRate"]),
|
("transferFeeRate", &["transfer_fee_rate", "transferFeeRate"]),
|
||||||
("stampTaxRate", &["stamp_tax_rate"]),
|
("stampTaxRate", &["stamp_tax_rate"]),
|
||||||
("stampTaxRateBeforeChange", &["stamp_tax_rate_before_change"]),
|
(
|
||||||
|
"stampTaxRateBeforeChange",
|
||||||
|
&["stamp_tax_rate_before_change"],
|
||||||
|
),
|
||||||
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
||||||
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
||||||
("volumeLimit", &["volume_limit"]),
|
("volumeLimit", &["volume_limit"]),
|
||||||
@@ -1080,6 +1098,10 @@ pub fn validate_strategy_risk_policy_fields(value: &Value) -> Result<(), String>
|
|||||||
"blacklistedInstruments",
|
"blacklistedInstruments",
|
||||||
"blacklisted_instruments",
|
"blacklisted_instruments",
|
||||||
"blacklist",
|
"blacklist",
|
||||||
|
"accountBlacklistedInstruments",
|
||||||
|
"account_blacklisted_instruments",
|
||||||
|
"strategyBlacklistedInstruments",
|
||||||
|
"strategy_blacklisted_instruments",
|
||||||
// Legacy execution aliases are accepted by StrategyExecutionSpec and
|
// Legacy execution aliases are accepted by StrategyExecutionSpec and
|
||||||
// normalized into the same shared switches.
|
// normalized into the same shared switches.
|
||||||
"volumeLimit",
|
"volumeLimit",
|
||||||
@@ -1295,18 +1317,12 @@ fn apply_risk_policy_overrides(
|
|||||||
let Some(policy) = policy else {
|
let Some(policy) = policy else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let max_order_quantity = valid_positive_limit(
|
let max_order_quantity =
|
||||||
policy.max_order_quantity,
|
valid_positive_limit(policy.max_order_quantity, "riskPolicy.maxOrderQuantity")?;
|
||||||
"riskPolicy.maxOrderQuantity",
|
let max_order_notional =
|
||||||
)?;
|
valid_positive_limit(policy.max_order_notional, "riskPolicy.maxOrderNotional")?;
|
||||||
let max_order_notional = valid_positive_limit(
|
let max_symbol_position =
|
||||||
policy.max_order_notional,
|
valid_positive_limit(policy.max_symbol_position, "riskPolicy.maxSymbolPosition")?;
|
||||||
"riskPolicy.maxOrderNotional",
|
|
||||||
)?;
|
|
||||||
let max_symbol_position = valid_positive_limit(
|
|
||||||
policy.max_symbol_position,
|
|
||||||
"riskPolicy.maxSymbolPosition",
|
|
||||||
)?;
|
|
||||||
if let Some(value) = max_order_quantity {
|
if let Some(value) = max_order_quantity {
|
||||||
cfg.risk_config.trading_constraints.max_order_quantity = value;
|
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.volume_limit_enabled);
|
||||||
assert!(cfg.risk_config.trading_constraints.liquidity_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!(
|
||||||
assert_eq!(cfg.risk_config.trading_constraints.max_order_notional, 2_000_000.0);
|
cfg.risk_config.trading_constraints.max_order_quantity,
|
||||||
assert_eq!(cfg.risk_config.trading_constraints.max_symbol_position, 12_000.0);
|
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!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.risk_config
|
cfg.risk_config
|
||||||
@@ -3647,6 +3672,25 @@ mod tests {
|
|||||||
assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0);
|
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]
|
#[test]
|
||||||
fn rejects_conflicting_risk_policy_alias_values() {
|
fn rejects_conflicting_risk_policy_alias_values() {
|
||||||
let bool_conflict = serde_json::json!({
|
let bool_conflict = serde_json::json!({
|
||||||
|
|||||||
@@ -1263,18 +1263,24 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
|||||||
)
|
)
|
||||||
.with_process_event_retention(ProcessEventRetention::Business);
|
.with_process_event_retention(ProcessEventRetention::Business);
|
||||||
let compact_result = compact_engine.run().expect("compact backtest succeeds");
|
let compact_result = compact_engine.run().expect("compact backtest succeeds");
|
||||||
assert!(compact_result
|
assert!(
|
||||||
|
compact_result
|
||||||
.process_events
|
.process_events
|
||||||
.iter()
|
.iter()
|
||||||
.all(|event| event.kind.is_business_lifecycle()));
|
.all(|event| event.kind.is_business_lifecycle())
|
||||||
assert!(compact_result
|
);
|
||||||
|
assert!(
|
||||||
|
compact_result
|
||||||
.process_events
|
.process_events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == ProcessEventKind::OnDay));
|
.any(|event| event.kind == ProcessEventKind::OnDay)
|
||||||
assert!(!compact_result
|
);
|
||||||
|
assert!(
|
||||||
|
!compact_result
|
||||||
.process_events
|
.process_events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading));
|
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.process_events[..18]
|
result.process_events[..18]
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -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
|
strategies, long-range A-share backtests, futures strategies, intraday order
|
||||||
simulation, AI-generated strategy code, and service-level result downloads.
|
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,
|
The latest re-audit focused on the engine's execution model, account model,
|
||||||
order lifecycle, data helper surface, analyzer output, extension hooks, and
|
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 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 | 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 | 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 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 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. |
|
| 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] Fine-grained daily and minute execution quote strategy entrypoints.
|
||||||
- [x] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan
|
- [x] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan
|
||||||
fixed-point execution primitive; one-micro over-budget orders fail.
|
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] Scheduled actions evaluated against explicit intraday times.
|
||||||
- [x] `update_universe`, `subscribe`, and `unsubscribe`.
|
- [x] `update_universe`, `subscribe`, and `unsubscribe`.
|
||||||
- [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.
|
- [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] Trading-date range, previous-date, and next-date helpers.
|
||||||
- [x] Phase-aware minute history cursor semantics matching the active bar or
|
- [x] Phase-aware minute history cursor semantics matching the active bar or
|
||||||
intraday execution quote callback.
|
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
|
- [x] Suspension, ST, date-range price, active instrument, and instrument
|
||||||
history helpers.
|
history helpers.
|
||||||
- [x] Open-order status, unfilled quantity, final order lookup, average fill
|
- [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.
|
||||||
Reference in New Issue
Block a user