diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 85482e4..9accded 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -4936,6 +4936,7 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus { "tick no volume" | "tick volume limit" | "intraday quote liquidity exhausted" + | "no execution quotes at or before start" | "no execution quotes after start" | "upper_limit" | "lower_limit" @@ -4950,6 +4951,7 @@ fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus { Some(reason) if reason.contains("market liquidity or volume limit") || reason.contains("intraday quote liquidity exhausted") + || reason.contains("no execution quotes at or before start") || reason.contains("no execution quotes after start") || reason.contains("upper_limit") || reason.contains("lower_limit") diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 93b4dc8..c40174a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1769,92 +1769,78 @@ impl PlatformExprStrategy { let factor = ctx.data.require_factor(factor_date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; let instrument = ctx.data.instrument(symbol); - let stock_ma_short = precomputed_stock_rolling_mean( - &factor.extra_factors, - "close", - self.config.stock_short_ma_days, - ) - .or_else(|| { - ctx.data.market_decision_close_moving_average( - date, - symbol, - self.config.stock_short_ma_days, - ) - }) - .unwrap_or(f64::NAN); - let stock_ma_mid = precomputed_stock_rolling_mean( - &factor.extra_factors, - "close", - self.config.stock_mid_ma_days, - ) - .or_else(|| { - ctx.data.market_decision_close_moving_average( - date, - symbol, - self.config.stock_mid_ma_days, - ) - }) - .unwrap_or(f64::NAN); - let stock_ma_long = precomputed_stock_rolling_mean( - &factor.extra_factors, - "close", - self.config.stock_long_ma_days, - ) - .or_else(|| { - ctx.data.market_decision_close_moving_average( - date, - symbol, - self.config.stock_long_ma_days, - ) - }) - .unwrap_or(f64::NAN); - let stock_ma5 = precomputed_stock_rolling_mean(&factor.extra_factors, "close", 5) + let stock_ma_short = ctx + .data + .market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days) .or_else(|| { - ctx.data - .market_decision_close_moving_average(date, symbol, 5) + precomputed_stock_rolling_mean( + &factor.extra_factors, + "close", + self.config.stock_short_ma_days, + ) }) .unwrap_or(f64::NAN); - let stock_ma10 = precomputed_stock_rolling_mean(&factor.extra_factors, "close", 10) + let stock_ma_mid = ctx + .data + .market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days) .or_else(|| { - ctx.data - .market_decision_close_moving_average(date, symbol, 10) + precomputed_stock_rolling_mean( + &factor.extra_factors, + "close", + self.config.stock_mid_ma_days, + ) }) .unwrap_or(f64::NAN); - let stock_ma20 = precomputed_stock_rolling_mean(&factor.extra_factors, "close", 20) + let stock_ma_long = ctx + .data + .market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days) .or_else(|| { - ctx.data - .market_decision_close_moving_average(date, symbol, 20) + precomputed_stock_rolling_mean( + &factor.extra_factors, + "close", + self.config.stock_long_ma_days, + ) }) .unwrap_or(f64::NAN); - let stock_ma30 = precomputed_stock_rolling_mean(&factor.extra_factors, "close", 30) - .or_else(|| { - ctx.data - .market_decision_close_moving_average(date, symbol, 30) - }) + let stock_ma5 = ctx + .data + .market_decision_close_moving_average(date, symbol, 5) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 5)) .unwrap_or(f64::NAN); - let stock_volume_ma5 = precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 5) - .or_else(|| { - ctx.data - .market_decision_volume_moving_average(date, symbol, 5) - }) + let stock_ma10 = ctx + .data + .market_decision_close_moving_average(date, symbol, 10) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 10)) .unwrap_or(f64::NAN); - let stock_volume_ma10 = precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 10) - .or_else(|| { - ctx.data - .market_decision_volume_moving_average(date, symbol, 10) - }) + let stock_ma20 = ctx + .data + .market_decision_close_moving_average(date, symbol, 20) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 20)) .unwrap_or(f64::NAN); - let stock_volume_ma20 = precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 20) - .or_else(|| { - ctx.data - .market_decision_volume_moving_average(date, symbol, 20) - }) + let stock_ma30 = ctx + .data + .market_decision_close_moving_average(date, symbol, 30) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 30)) .unwrap_or(f64::NAN); - let stock_volume_ma60 = precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 60) - .or_else(|| { - ctx.data - .market_decision_volume_moving_average(date, symbol, 60) - }) + let stock_volume_ma5 = ctx + .data + .market_decision_volume_moving_average(date, symbol, 5) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 5)) + .unwrap_or(f64::NAN); + let stock_volume_ma10 = ctx + .data + .market_decision_volume_moving_average(date, symbol, 10) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 10)) + .unwrap_or(f64::NAN); + let stock_volume_ma20 = ctx + .data + .market_decision_volume_moving_average(date, symbol, 20) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 20)) + .unwrap_or(f64::NAN); + let stock_volume_ma60 = ctx + .data + .market_decision_volume_moving_average(date, symbol, 60) + .or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 60)) .unwrap_or(f64::NAN); let touched_upper_limit = !market.paused && (market.is_at_upper_limit_price(market.close) @@ -3557,16 +3543,16 @@ impl PlatformExprStrategy { "rolling_mean(\"{other}\", {lookback}) requires stock context" )) })?; - precomputed_stock_rolling_mean(&stock.extra_factors, other, lookback).or_else( - || { - ctx.data.market_decision_numeric_moving_average( - day.date, - &stock.symbol, - other, - lookback, - ) - }, - ) + ctx.data + .market_decision_numeric_moving_average( + day.date, + &stock.symbol, + other, + lookback, + ) + .or_else(|| { + precomputed_stock_rolling_mean(&stock.extra_factors, other, lookback) + }) } }; value.ok_or_else(|| { @@ -5095,11 +5081,10 @@ impl PlatformExprStrategy { let max_volume_ratio = self .prelude_numeric_constant("max_volume_ratio") .unwrap_or(1.0); - let volume_ma100 = precomputed_stock_rolling_mean(&stock.extra_factors, "volume", 100) - .or_else(|| { - ctx.data - .market_decision_volume_moving_average(day.date, &stock.symbol, 100) - }) + let volume_ma100 = ctx + .data + .market_decision_volume_moving_average(day.date, &stock.symbol, 100) + .or_else(|| precomputed_stock_rolling_mean(&stock.extra_factors, "volume", 100)) .unwrap_or(f64::NAN); Some( @@ -7762,6 +7747,10 @@ mod tests { .stock_state_with_factor_date(&ctx, current, current, symbol) .expect("stock state"); + assert!((stock.stock_ma5 - 9.9).abs() < 1e-9); + assert!((stock.stock_ma10 - 9.9).abs() < 1e-9); + assert!((stock.stock_ma30 - 9.9).abs() < 1e-9); + assert!( !strategy .stock_passes_expr(&ctx, &day, &stock) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index cbbda04..12889ff 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -384,6 +384,115 @@ fn apply_cost_overrides( } } +fn parse_usize_after(text: &str, start: usize) -> Option<(usize, usize)> { + let bytes = text.as_bytes(); + let mut end = start; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + } + if end == start { + return None; + } + text[start..end] + .parse::() + .ok() + .filter(|value| *value > 0) + .map(|value| (value, end)) +} + +fn prefixed_ma_lookbacks(expr: &str, prefix: &str) -> Vec { + let lower = expr.to_ascii_lowercase(); + let mut values = Vec::new(); + let mut cursor = 0; + while let Some(offset) = lower[cursor..].find(prefix) { + let start = cursor + offset + prefix.len(); + if let Some((value, end)) = parse_usize_after(&lower, start) { + values.push(value); + cursor = end; + } else { + cursor = start; + } + } + values +} + +fn compact_ascii_whitespace(value: &str) -> String { + value + .chars() + .filter(|ch| !ch.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase() +} + +fn rolling_mean_lookbacks(expr: &str, field: &str) -> Vec { + let compact = compact_ascii_whitespace(expr); + let patterns = [ + format!("rolling_mean(\"{field}\","), + format!("rolling_mean('{field}',"), + ]; + let mut values = Vec::new(); + for pattern in patterns { + let mut cursor = 0; + while let Some(offset) = compact[cursor..].find(&pattern) { + let start = cursor + offset + pattern.len(); + if let Some((value, end)) = parse_usize_after(&compact, start) { + values.push(value); + cursor = end; + } else { + cursor = start; + } + } + } + values +} + +fn sorted_unique_positive(mut values: Vec) -> Vec { + values.retain(|value| *value > 0); + values.sort_unstable(); + values.dedup(); + values +} + +fn infer_expression_windows( + cfg: &mut PlatformExprStrategyConfig, + benchmark_short_explicit: bool, + benchmark_long_explicit: bool, + stock_short_explicit: bool, + stock_mid_explicit: bool, + stock_long_explicit: bool, +) { + let mut benchmark_days = Vec::new(); + for expr in [&cfg.exposure_expr, &cfg.buy_scale_expr] { + benchmark_days.extend(prefixed_ma_lookbacks(expr, "benchmark_ma")); + benchmark_days.extend(rolling_mean_lookbacks(expr, "benchmark_close")); + } + let benchmark_days = sorted_unique_positive(benchmark_days); + if !benchmark_short_explicit && let Some(short) = benchmark_days.first().copied() { + cfg.benchmark_short_ma_days = short; + } + if !benchmark_long_explicit && let Some(long) = benchmark_days.last().copied() { + cfg.benchmark_long_ma_days = long; + } + + let mut stock_days = Vec::new(); + for expr in [&cfg.stock_filter_expr, &cfg.buy_scale_expr] { + stock_days.extend(prefixed_ma_lookbacks(expr, "stock_ma")); + stock_days.extend(rolling_mean_lookbacks(expr, "close")); + } + let stock_days = sorted_unique_positive(stock_days); + if !stock_short_explicit && let Some(short) = stock_days.first().copied() { + cfg.stock_short_ma_days = short; + } + if !stock_mid_explicit { + if let Some(mid) = stock_days.get(1).copied() { + cfg.stock_mid_ma_days = mid; + } + } + if !stock_long_explicit && let Some(long) = stock_days.last().copied() { + cfg.stock_long_ma_days = long; + } +} + pub fn platform_expr_config_from_spec( strategy_id: &str, signal_symbol: &str, @@ -398,6 +507,11 @@ pub fn platform_expr_config_from_spec( let Some(spec) = strategy_spec else { return cfg; }; + let mut benchmark_short_explicit = false; + let mut benchmark_long_explicit = false; + let mut stock_short_explicit = false; + let mut stock_mid_explicit = false; + let mut stock_long_explicit = false; if let Some(spec_strategy_id) = spec .strategy_id @@ -437,20 +551,25 @@ pub fn platform_expr_config_from_spec( if let Some(stock_ma_filter) = engine.stock_ma_filter.as_ref() { if let Some(days) = stock_ma_filter.short_days.filter(|value| *value > 0) { cfg.stock_short_ma_days = days; + stock_short_explicit = true; } if let Some(days) = stock_ma_filter.mid_days.filter(|value| *value > 0) { cfg.stock_mid_ma_days = days; + stock_mid_explicit = true; } if let Some(days) = stock_ma_filter.long_days.filter(|value| *value > 0) { cfg.stock_long_ma_days = days; + stock_long_explicit = true; } } if let Some(index_throttle) = engine.index_throttle.as_ref() { if let Some(days) = index_throttle.short_days.filter(|value| *value > 0) { cfg.benchmark_short_ma_days = days; + benchmark_short_explicit = true; } if let Some(days) = index_throttle.long_days.filter(|value| *value > 0) { cfg.benchmark_long_ma_days = days; + benchmark_long_explicit = true; } } if !engine.skip_windows.is_empty() { @@ -774,20 +893,42 @@ pub fn platform_expr_config_from_spec( cfg.selection_limit_expr = cfg.max_positions.to_string(); } + infer_expression_windows( + &mut cfg, + benchmark_short_explicit, + benchmark_long_explicit, + stock_short_explicit, + stock_mid_explicit, + stock_long_explicit, + ); + if !cfg.signal_symbol.trim().is_empty() { cfg.signal_symbol = normalize_symbol(&cfg.signal_symbol, None); } if !cfg.benchmark_symbol.trim().is_empty() { cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None); } - if spec + let aiquant_compat = spec .execution .as_ref() .and_then(|execution| execution.compatibility_profile.as_deref()) .map(|value| value.trim().to_ascii_lowercase()) - .is_some_and(|value| value == "aiquant_rqalpha" || value == "aiquant") - { + .is_some_and(|value| value == "aiquant_rqalpha" || value == "aiquant"); + if aiquant_compat { cfg.aiquant_transaction_cost = true; + let trading = spec + .runtime_expressions + .as_ref() + .and_then(|runtime_expr| runtime_expr.trading.as_ref()); + if trading.and_then(|item| item.daily_top_up).is_none() { + cfg.daily_top_up_enabled = true; + } + if trading + .and_then(|item| item.retry_empty_rebalance) + .is_none() + { + cfg.retry_empty_rebalance = true; + } } if let Some(execution) = spec.execution.as_ref() { apply_cost_overrides( @@ -1250,6 +1391,93 @@ mod tests { assert_eq!(cfg.stamp_tax_rate_after_change, Some(0.0005)); } + #[test] + fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() { + let spec = serde_json::json!({ + "execution": { + "compatibilityProfile": "aiquant_rqalpha" + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(cfg.aiquant_transaction_cost); + assert!(cfg.daily_top_up_enabled); + assert!(cfg.retry_empty_rebalance); + + let explicit_off = serde_json::json!({ + "execution": { + "compatibilityProfile": "aiquant_rqalpha" + }, + "runtimeExpressions": { + "trading": { + "dailyTopUp": false, + "retryEmptyRebalance": false + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &explicit_off).expect("config"); + + assert!(!cfg.daily_top_up_enabled); + assert!(!cfg.retry_empty_rebalance); + } + + #[test] + fn runtime_expressions_infer_ma_windows_from_literal_strategy_logic() { + let spec = serde_json::json!({ + "execution": { + "compatibilityProfile": "aiquant_rqalpha" + }, + "runtimeExpressions": { + "selection": { + "stockFilterExpr": "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30)" + }, + "risk": { + "exposureExpr": "benchmark_ma5 > benchmark_ma20 ? 1.0 : weak_market_trade_rate" + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!(cfg.benchmark_short_ma_days, 5); + assert_eq!(cfg.benchmark_long_ma_days, 20); + assert_eq!(cfg.stock_short_ma_days, 5); + assert_eq!(cfg.stock_mid_ma_days, 10); + assert_eq!(cfg.stock_long_ma_days, 30); + + let explicit = serde_json::json!({ + "engineConfig": { + "stockMaFilter": { + "shortDays": 4, + "midDays": 9, + "longDays": 21 + }, + "indexThrottle": { + "shortDays": 3, + "longDays": 13 + } + }, + "runtimeExpressions": { + "selection": { + "stockFilterExpr": "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30)" + }, + "risk": { + "exposureExpr": "benchmark_ma5 > benchmark_ma20 ? 1.0 : 0.5" + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &explicit).expect("config"); + + assert_eq!(cfg.benchmark_short_ma_days, 3); + assert_eq!(cfg.benchmark_long_ma_days, 13); + assert_eq!(cfg.stock_short_ma_days, 4); + assert_eq!(cfg.stock_mid_ma_days, 9); + assert_eq!(cfg.stock_long_ma_days, 21); + } + #[test] fn parses_daily_schedule_time_for_aiquant_execution_quotes() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 866a62a..b72aea0 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -1658,7 +1658,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { vec![IntradayExecutionQuote { date, symbol: "000002.SZ".to_string(), - timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + timestamp: date.and_hms_opt(10, 18, 0).unwrap(), last_price: 10.0, bid1: 9.99, ask1: 10.01, @@ -1804,7 +1804,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() { assert!( report.order_events[0] .reason - .contains("no execution quotes after start") + .contains("no execution quotes at or before start") ); assert!(portfolio.position("000002.SZ").is_none()); } @@ -1993,7 +1993,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted vec![IntradayExecutionQuote { date, symbol: "000002.SZ".to_string(), - timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + timestamp: date.and_hms_opt(10, 18, 0).unwrap(), last_price: 10.02, bid1: 10.01, ask1: 10.03,