fix: gate bound daily indicator fields by completed session
This commit is contained in:
@@ -649,6 +649,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub matching_type: MatchingType,
|
||||
pub quote_quantity_limit: bool,
|
||||
pub current_day_precomputed_factors: bool,
|
||||
pub completed_session_factor_fields: BTreeSet<String>,
|
||||
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
||||
pub intraday_execution_time: Option<NaiveTime>,
|
||||
pub explicit_action_times: Vec<NaiveTime>,
|
||||
@@ -727,6 +728,7 @@ impl PlatformExprStrategyConfig {
|
||||
matching_type: MatchingType::CurrentBarClose,
|
||||
quote_quantity_limit: true,
|
||||
current_day_precomputed_factors: false,
|
||||
completed_session_factor_fields: BTreeSet::new(),
|
||||
candidate_symbols_by_date: BTreeMap::new(),
|
||||
intraday_execution_time: None,
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
fn market_cap_storage_to_strategy_unit(value: f64) -> f64 {
|
||||
value
|
||||
@@ -4705,6 +4727,23 @@ impl PlatformExprStrategy {
|
||||
} else {
|
||||
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
|
||||
&& (self.stock_extra_factor_map_required
|
||||
|| self
|
||||
@@ -13913,6 +13952,35 @@ mod tests {
|
||||
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]
|
||||
fn target_scale_replenishment_preserves_strategy_cash_allocation() {
|
||||
let scale = 30.0 / 31.0;
|
||||
|
||||
@@ -1785,6 +1785,23 @@ pub fn platform_expr_config_from_spec(
|
||||
let Some(spec) = strategy_spec else {
|
||||
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_long_explicit = false;
|
||||
let mut stock_short_explicit = false;
|
||||
@@ -3110,6 +3127,20 @@ fn symbol_is_kcb(symbol: &str) -> bool {
|
||||
mod tests {
|
||||
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]
|
||||
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() {
|
||||
assert_eq!(normalize_board("688001.SH", None), "KSH");
|
||||
|
||||
Reference in New Issue
Block a user