fix: gate bound daily indicator fields by completed session

This commit is contained in:
boris
2026-09-07 21:51:51 +08:00
parent f3cc790659
commit a02ac6e853
2 changed files with 99 additions and 0 deletions
@@ -649,6 +649,7 @@ pub struct PlatformExprStrategyConfig {
pub matching_type: MatchingType, pub matching_type: MatchingType,
pub quote_quantity_limit: bool, pub quote_quantity_limit: bool,
pub current_day_precomputed_factors: bool, pub current_day_precomputed_factors: bool,
pub completed_session_factor_fields: BTreeSet<String>,
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>, pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
pub intraday_execution_time: Option<NaiveTime>, pub intraday_execution_time: Option<NaiveTime>,
pub explicit_action_times: Vec<NaiveTime>, pub explicit_action_times: Vec<NaiveTime>,
@@ -727,6 +728,7 @@ impl PlatformExprStrategyConfig {
matching_type: MatchingType::CurrentBarClose, matching_type: MatchingType::CurrentBarClose,
quote_quantity_limit: true, quote_quantity_limit: true,
current_day_precomputed_factors: false, current_day_precomputed_factors: false,
completed_session_factor_fields: BTreeSet::new(),
candidate_symbols_by_date: BTreeMap::new(), candidate_symbols_by_date: BTreeMap::new(),
intraday_execution_time: None, intraday_execution_time: None,
explicit_action_times: Vec::new(), explicit_action_times: Vec::new(),
@@ -1396,6 +1398,26 @@ fn platform_safe_div_default(lhs: f64, rhs: f64) -> f64 {
platform_safe_div(lhs, rhs, 0.0) platform_safe_div(lhs, rhs, 0.0)
} }
fn completed_session_factor_date(
ctx: &StrategyContext<'_>,
date: NaiveDate,
factor_date: NaiveDate,
execution_time: Option<NaiveTime>,
) -> Option<NaiveDate> {
let factor_date = factor_date.min(ctx.decision_date);
if factor_date < date || factor_date < ctx.decision_date {
return Some(factor_date);
}
let time = execution_time.or_else(|| ctx.active_datetime.map(|value| value.time()));
// Native CN stock daily indicator rows become usable only after the
// session closes. Absence of an intraday clock denotes a daily close bar.
if time.is_none_or(|time| time >= NaiveTime::from_hms_opt(15, 0, 0).unwrap()) {
Some(factor_date)
} else {
ctx.data.previous_trading_date(factor_date, 1)
}
}
impl PlatformExprStrategy { impl PlatformExprStrategy {
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 { fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
value value
@@ -4705,6 +4727,23 @@ impl PlatformExprStrategy {
} else { } else {
BTreeMap::new() BTreeMap::new()
}; };
if !self.config.completed_session_factor_fields.is_empty() {
let visible_date = completed_session_factor_date(
ctx, date, factor_date,
execution_time.or(self.config.intraday_execution_time),
);
let visible_factor = visible_date
.and_then(|visible_date| ctx.data.factor_by_symbol_id(visible_date, symbol_id));
for field in &self.config.completed_session_factor_fields {
if self.stock_extra_factor_map_required || self.stock_extra_factor_identifiers.contains(field) {
let value = visible_factor
.and_then(|row| row.extra_factors.get(field.as_str()))
.copied()
.unwrap_or(f64::NAN);
extra_factors.insert(field.clone(), value);
}
}
}
if self.stock_extra_factors_required if self.stock_extra_factors_required
&& (self.stock_extra_factor_map_required && (self.stock_extra_factor_map_required
|| self || self
@@ -13913,6 +13952,35 @@ mod tests {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date") NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
} }
#[test]
fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() {
let prev = d(2025, 1, 2);
let curr = d(2025, 1, 3);
let data = single_symbol_platform_data(&[prev, curr], "000001.SZ");
let portfolio = PortfolioState::new(10_000.0);
let subscriptions = BTreeSet::new();
let mut ctx = StrategyContext {
execution_date: curr, decision_date: curr, decision_index: 1,
data: &data, portfolio: &portfolio, futures_account: None,
open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions,
process_events: &[], active_process_event: None, active_datetime: None,
order_events: &[], fills: &[],
};
for hour in [9, 10, 14] {
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr,
NaiveTime::from_hms_opt(hour, 30, 0)), Some(prev));
}
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr,
NaiveTime::from_hms_opt(15, 0, 0)), Some(curr));
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(curr));
ctx.active_datetime = Some(curr.and_hms_opt(10, 0, 0).unwrap());
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev));
ctx.decision_date = prev;
assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev));
ctx.execution_date = prev;
assert_eq!(super::completed_session_factor_date(&ctx, prev, prev, None), None);
}
#[test] #[test]
fn target_scale_replenishment_preserves_strategy_cash_allocation() { fn target_scale_replenishment_preserves_strategy_cash_allocation() {
let scale = 30.0 / 31.0; let scale = 30.0 / 31.0;
@@ -1785,6 +1785,23 @@ pub fn platform_expr_config_from_spec(
let Some(spec) = strategy_spec else { let Some(spec) = strategy_spec else {
return Ok(cfg); return Ok(cfg);
}; };
if let Some(conditions) = spec.stock_pool_factor_contract.as_ref()
.and_then(|contract| contract.get("conditions"))
.and_then(Value::as_array)
{
for condition in conditions {
let Some(binding) = condition.pointer("/semantic/backtestBinding") else { continue };
let field = binding.get("field").and_then(Value::as_str).unwrap_or("");
let dataset = binding.get("sourceDataset").and_then(Value::as_str).unwrap_or("");
if !dataset.starts_with("indicators_") || field.is_empty()
|| !field.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|| field.as_bytes()[0].is_ascii_digit()
{
return Err("invalid native factor backtest binding".to_string());
}
cfg.completed_session_factor_fields.insert(field.to_string());
}
}
let mut benchmark_short_explicit = false; let mut benchmark_short_explicit = false;
let mut benchmark_long_explicit = false; let mut benchmark_long_explicit = false;
let mut stock_short_explicit = false; let mut stock_short_explicit = false;
@@ -3110,6 +3127,20 @@ fn symbol_is_kcb(symbol: &str) -> bool {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn native_factor_bindings_declare_completed_session_fields() {
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
{"factorRef": "up_days_stock", "semantic": {"backtestBinding": {
"field": "ths_up_days_stock", "sourceDataset": "indicators_up_days_stock"
}}}
]}});
let cfg = platform_expr_config_from_value("test", "000852.SH", &spec).unwrap();
assert_eq!(cfg.completed_session_factor_fields,
BTreeSet::from(["ths_up_days_stock".to_string()]));
let empty = platform_expr_config_from_value("test", "000852.SH", &serde_json::json!({})).unwrap();
assert!(empty.completed_session_factor_fields.is_empty());
}
#[test] #[test]
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() { fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() {
assert_eq!(normalize_board("688001.SH", None), "KSH"); assert_eq!(normalize_board("688001.SH", None), "KSH");