From 0dca8e0eff07670c3f2b58db125c3900f7ad4933 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 13 Jun 2026 15:26:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=AD=96=E7=95=A5=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E6=89=A7=E8=A1=8C=E4=BB=B7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 245 +++++++- crates/fidc-core/src/instrument.rs | 2 +- .../fidc-core/src/platform_expr_strategy.rs | 531 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 11 + crates/fidc-core/src/risk_control.rs | 6 +- 5 files changed, 744 insertions(+), 51 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index af85e45..7047e00 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -445,6 +445,38 @@ pub struct EligibleUniverseSnapshot { pub free_float_cap_bn: f64, } +pub fn decision_adjusted_cap_bn( + factor_date: NaiveDate, + raw_cap_bn: f64, + market: &DailyMarketSnapshot, +) -> f64 { + if !raw_cap_bn.is_finite() || raw_cap_bn <= 0.0 { + return f64::NAN; + } + if factor_date != market.date { + return raw_cap_bn; + } + if !market.close.is_finite() + || market.close <= 0.0 + || !market.prev_close.is_finite() + || market.prev_close <= 0.0 + { + return f64::NAN; + } + raw_cap_bn * market.prev_close / market.close +} + +pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot, market: &DailyMarketSnapshot) -> f64 { + decision_adjusted_cap_bn(factor.date, factor.market_cap_bn, market) +} + +pub fn decision_free_float_cap_bn( + factor: &DailyFactorSnapshot, + market: &DailyMarketSnapshot, +) -> f64 { + decision_adjusted_cap_bn(factor.date, factor.free_float_cap_bn, market) +} + #[derive(Debug, Clone)] struct SymbolPriceSeries { snapshots: Vec, @@ -597,11 +629,16 @@ impl SymbolPriceSeries { } fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { - let values = self.decision_volume_values(date, lookback)?; - if values.len() < lookback { + if lookback == 0 { return None; } - let sum = values.iter().sum::(); + let end = self.previous_completed_end_index(date)?; + let end_count = *self.unpaused_count_prefix.get(end)?; + if end_count < lookback { + return None; + } + let start_count = end_count - lookback; + let sum = self.unpaused_volume_prefix[end_count] - self.unpaused_volume_prefix[start_count]; Some(sum / lookback as f64) } @@ -691,6 +728,7 @@ struct BenchmarkPriceSeries { dates: Vec, opens: Vec, closes: Vec, + prev_closes: Vec, open_prefix: Vec, close_prefix: Vec, } @@ -702,12 +740,14 @@ impl BenchmarkPriceSeries { let dates = sorted.iter().map(|row| row.date).collect::>(); let opens = sorted.iter().map(|row| row.open).collect::>(); let closes = sorted.iter().map(|row| row.close).collect::>(); + let prev_closes = sorted.iter().map(|row| row.prev_close).collect::>(); let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); Self { dates, opens, closes, + prev_closes, open_prefix, close_prefix, } @@ -717,6 +757,24 @@ impl BenchmarkPriceSeries { self.moving_average_for(date, lookback, PriceField::Close) } + fn decision_close(&self, date: NaiveDate) -> Option { + match self.dates.binary_search(&date) { + Ok(idx) => self + .prev_closes + .get(idx) + .copied() + .filter(|value| value.is_finite() && *value > 0.0) + .or_else(|| { + idx.checked_sub(1) + .and_then(|prev| self.closes.get(prev).copied()) + }), + Err(0) => None, + Err(idx) => idx + .checked_sub(1) + .and_then(|prev| self.closes.get(prev).copied()), + } + } + fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { if lookback == 0 { return None; @@ -734,12 +792,7 @@ impl BenchmarkPriceSeries { Some(sum / lookback as f64) } - fn decision_values_for( - &self, - date: NaiveDate, - lookback: usize, - field: PriceField, - ) -> Vec { + fn decision_values_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec { if lookback == 0 { return Vec::new(); } @@ -2278,6 +2331,10 @@ impl DataSet { self.benchmark_series_cache.moving_average(date, lookback) } + pub fn benchmark_decision_close(&self, date: NaiveDate) -> Option { + self.benchmark_series_cache.decision_close(date) + } + pub fn benchmark_decision_moving_average( &self, date: NaiveDate, @@ -2318,11 +2375,9 @@ impl DataSet { "open" | "day_open" | "dayopen" | "benchmark_open" => self .benchmark_series_cache .trailing_values_for(date, lookback, PriceField::Open), - _ => self.benchmark_series_cache.decision_values_for( - date, - lookback, - PriceField::Close, - ), + _ => self + .benchmark_series_cache + .decision_values_for(date, lookback, PriceField::Close), } } @@ -3409,10 +3464,15 @@ fn build_eligible_universe( { continue; } + let market_cap_bn = decision_market_cap_bn(factor, market); + if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { + continue; + } + let free_float_cap_bn = decision_free_float_cap_bn(factor, market); rows.push(EligibleUniverseSnapshot { symbol: factor.symbol.clone(), - market_cap_bn: factor.market_cap_bn, - free_float_cap_bn: factor.free_float_cap_bn, + market_cap_bn, + free_float_cap_bn, }); } rows.sort_by(|left, right| { @@ -3471,6 +3531,17 @@ mod tests { } } + fn benchmark_row(date: &str, close: f64) -> BenchmarkSnapshot { + BenchmarkSnapshot { + date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), + benchmark: "000852.SH".to_string(), + open: close, + close, + prev_close: close - 1.0, + volume: 1_000_000, + } + } + #[test] fn baseline_selection_uses_structured_instrument_dates_and_status_only() { let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); @@ -3504,6 +3575,14 @@ mod tests { Some(&instrument("正常名称", "delisted", None)), date )); + assert!(instrument_passes_baseline_selection( + Some(&instrument( + "正常名称", + "delisted", + Some(NaiveDate::parse_from_str("2025-04-30", "%Y-%m-%d").unwrap()), + )), + date + )); assert!(!instrument_passes_baseline_selection( Some(&instrument( "正常名称", @@ -3545,6 +3624,28 @@ mod tests { ); } + #[test] + fn decision_close_average_ignores_current_day_close() { + let mut current = market_row("2025-01-06", 12.0, 10_000); + current.close = 9_999.0; + current.last_price = 9_999.0; + let series = SymbolPriceSeries::new(&[ + market_row("2025-01-02", 10.0, 100), + market_row("2025-01-03", 11.0, 200), + current, + ]); + let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); + + assert_eq!( + series.decision_close_moving_average(decision_date, 2), + Some(11.5) + ); + assert_eq!( + series.moving_average(decision_date, 2, PriceField::Close), + Some((11.0 + 9_999.0) / 2.0) + ); + } + #[test] fn decision_volume_average_skips_paused_days_before_counting_window() { let mut paused = market_row("2025-01-03", 11.0, 0); @@ -3572,6 +3673,118 @@ mod tests { ); } + #[test] + fn eligible_universe_uses_decision_market_cap_same_date() { + let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); + let instrument = |symbol: &str| Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: if symbol.ends_with(".SH") { "SH" } else { "SZ" }.to_string(), + round_lot: 100, + listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()), + delisted_at: None, + status: "active".to_string(), + }; + let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-01-06 10:18:00".to_string()), + day_open: prev_close, + open: prev_close, + high: close.max(prev_close), + low: close.min(prev_close), + close, + last_price: prev_close, + bid1: prev_close, + ask1: prev_close, + prev_close, + volume: 100_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: prev_close * 1.1, + lower_limit: prev_close * 0.9, + price_tick: 0.01, + }; + let factor = + |symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn, + free_float_cap_bn, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }; + let candidate = |symbol: &str| CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }; + let data = DataSet::from_components( + vec![instrument("000001.SZ"), instrument("000002.SZ")], + vec![ + market("000001.SZ", 10.0, 20.0), + market("000002.SZ", 10.0, 10.0), + ], + vec![ + factor("000001.SZ", 12.0, 4.0), + factor("000002.SZ", 10.0, 5.0), + ], + vec![candidate("000001.SZ"), candidate("000002.SZ")], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 100.0, + close: 101.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let rows = data.eligible_universe_on(date); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].symbol, "000001.SZ"); + assert!((rows[0].market_cap_bn - 6.0).abs() < 1e-9); + assert!((rows[0].free_float_cap_bn - 2.0).abs() < 1e-9); + assert_eq!(rows[1].symbol, "000002.SZ"); + assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9); + } + + #[test] + fn benchmark_decision_close_windows_exclude_current_close() { + let series = BenchmarkPriceSeries::new(&[ + benchmark_row("2025-01-02", 100.0), + benchmark_row("2025-01-03", 200.0), + benchmark_row("2025-01-06", 9_999.0), + ]); + let decision_date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(); + + assert_eq!(series.decision_close(decision_date), Some(9_998.0)); + assert_eq!( + series.decision_moving_average(decision_date, 2), + Some(150.0) + ); + assert_eq!( + series.decision_values_for(decision_date, 2, PriceField::Close), + vec![100.0, 200.0] + ); + assert_eq!( + series.moving_average(decision_date, 2), + Some((200.0 + 9_999.0) / 2.0) + ); + } + #[test] fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() { let path = temp_csv_path("mixed_factor_maps"); diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index e5c8372..700937f 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -43,7 +43,7 @@ impl Instrument { pub fn is_active_on(&self, date: NaiveDate) -> bool { self.listed_at.is_none_or(|listed_at| listed_at <= date) && !self.is_delisted_before(date) - && !self.status.eq_ignore_ascii_case("inactive") + && !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none()) } } diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 864846e..ec8b3f2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -5,7 +5,10 @@ use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use rhai::{AST, Dynamic, Engine, Map, Scope}; use crate::cost::ChinaAShareCostModel; -use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField}; +use crate::data::{ + DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField, decision_free_float_cap_bn, + decision_market_cap_bn, +}; use crate::engine::BacktestError; use crate::events::OrderSide; use crate::portfolio::PortfolioState; @@ -177,6 +180,7 @@ pub struct PlatformExprStrategyConfig { pub benchmark_symbol: String, pub signal_symbol: String, pub refresh_rate: usize, + pub refresh_rate_expr: String, pub max_positions: usize, pub prelude: String, pub universe_exclude: Vec, @@ -221,6 +225,7 @@ impl PlatformExprStrategyConfig { benchmark_symbol: "000852.SH".to_string(), signal_symbol: "000001.SH".to_string(), refresh_rate: 15, + refresh_rate_expr: String::new(), max_positions: 40, prelude: r#"let stocknum = 40; let ma_ratio = 1.0001; @@ -358,7 +363,7 @@ struct StockExpressionState { market_cap: f64, free_float_cap: f64, pe_ttm: f64, - volume: i64, + volume: f64, tick_volume: i64, bid1_volume: i64, ask1_volume: i64, @@ -1474,42 +1479,37 @@ impl PlatformExprStrategy { .benchmark(date) .ok_or(BacktestError::MissingBenchmark { date })?; let benchmark_open = benchmark.open; - let benchmark_close = benchmark.close; + let benchmark_close = ctx + .data + .benchmark_decision_close(date) + .or_else(|| { + (benchmark.prev_close.is_finite() && benchmark.prev_close > 0.0) + .then_some(benchmark.prev_close) + }) + .unwrap_or(benchmark_open); let benchmark_ma_short = ctx .data .benchmark_decision_moving_average(date, self.config.benchmark_short_ma_days) - .or_else(|| { - ctx.data - .benchmark_moving_average(date, self.config.benchmark_short_ma_days) - }) .unwrap_or(benchmark_close); let benchmark_ma_long = ctx .data .benchmark_decision_moving_average(date, self.config.benchmark_long_ma_days) - .or_else(|| { - ctx.data - .benchmark_moving_average(date, self.config.benchmark_long_ma_days) - }) .unwrap_or(benchmark_ma_short); let benchmark_ma5 = ctx .data .benchmark_decision_moving_average(date, 5) - .or_else(|| ctx.data.benchmark_moving_average(date, 5)) .unwrap_or(benchmark_ma_short); let benchmark_ma10 = ctx .data .benchmark_decision_moving_average(date, 10) - .or_else(|| ctx.data.benchmark_moving_average(date, 10)) .unwrap_or(benchmark_ma_long); let benchmark_ma20 = ctx .data .benchmark_decision_moving_average(date, 20) - .or_else(|| ctx.data.benchmark_moving_average(date, 20)) .unwrap_or(benchmark_ma10); let benchmark_ma30 = ctx .data .benchmark_decision_moving_average(date, 30) - .or_else(|| ctx.data.benchmark_moving_average(date, 30)) .unwrap_or(benchmark_ma20); let signal_ma5 = ctx .data @@ -1620,6 +1620,7 @@ impl PlatformExprStrategy { ) -> Result { let market = ctx.data.require_market(date, symbol)?; let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market); + let intraday_same_day_factor = self.config.aiquant_transaction_cost && factor_date == date; let decision_quote = self.aiquant_scheduled_quote(ctx, date, symbol); let factor = ctx.data.require_factor(factor_date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; @@ -1719,23 +1720,52 @@ impl PlatformExprStrategy { && (market.is_at_lower_limit_price(market.close) || market.is_at_lower_limit_price(market.open) || market.is_at_lower_limit_price(market.day_open)); - let amount = factor.extra_factors.get("amount").copied().unwrap_or(0.0); + let amount = if intraday_same_day_factor { + f64::NAN + } else { + factor.extra_factors.get("amount").copied().unwrap_or(0.0) + }; + let market_cap = decision_market_cap_bn(factor, market); + let free_float_cap = decision_free_float_cap_bn(factor, market); + let expression_high = if intraday_same_day_factor { + f64::NAN + } else { + feature_market.high + }; + let expression_low = if intraday_same_day_factor { + f64::NAN + } else { + feature_market.low + }; + let expression_close = if intraday_same_day_factor + && feature_market.prev_close.is_finite() + && feature_market.prev_close > 0.0 + { + feature_market.prev_close + } else { + feature_market.close + }; + let expression_volume = if intraday_same_day_factor { + f64::NAN + } else { + feature_market.volume as f64 + }; Ok(StockExpressionState { symbol: symbol.to_string(), - market_cap: factor.market_cap_bn, - free_float_cap: factor.free_float_cap_bn, + market_cap, + free_float_cap, pe_ttm: factor.pe_ttm, - volume: feature_market.volume as i64, + volume: expression_volume, tick_volume: market.tick_volume as i64, bid1_volume: market.bid1_volume as i64, ask1_volume: market.ask1_volume as i64, turnover_ratio: factor.turnover_ratio.unwrap_or(0.0), effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0), open: feature_market.day_open, - high: feature_market.high, - low: feature_market.low, - close: feature_market.close, + high: expression_high, + low: expression_low, + close: expression_close, last: decision_quote .and_then(|quote| { (quote.last_price.is_finite() && quote.last_price > 0.0) @@ -3958,6 +3988,25 @@ impl PlatformExprStrategy { Ok(value.round().max(1.0) as usize) } + fn effective_refresh_rate( + &self, + ctx: &StrategyContext<'_>, + day: &DayExpressionState, + ) -> Result { + let expr = self.config.refresh_rate_expr.trim(); + if expr.is_empty() { + return Ok(self.config.refresh_rate.max(1)); + } + let value = self.eval_float(ctx, expr, day, None, None)?; + if !value.is_finite() { + return Err(BacktestError::Execution(format!( + "platform refresh_rate_expr did not produce a finite number: {}", + expr + ))); + } + Ok(value.round().max(1.0) as usize) + } + fn selection_dates(&self, ctx: &StrategyContext<'_>) -> (NaiveDate, NaiveDate) { let decision_date = ctx.decision_date; let factor_date = if self.config.aiquant_transaction_cost { @@ -4956,10 +5005,15 @@ impl PlatformExprStrategy { if !self.stock_passes_universe_exclude(candidate, market) { continue; } + let market_cap_bn = decision_market_cap_bn(factor, market); + if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { + continue; + } + let free_float_cap_bn = decision_free_float_cap_bn(factor, market); rows.push(EligibleUniverseSnapshot { symbol: factor.symbol.clone(), - market_cap_bn: factor.market_cap_bn, - free_float_cap_bn: factor.free_float_cap_bn, + market_cap_bn, + free_float_cap_bn, }); } rows.sort_by(|left, right| { @@ -5026,7 +5080,7 @@ impl PlatformExprStrategy { "market_cap" => Some(stock.market_cap), "free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap), "pe_ttm" => Some(stock.pe_ttm), - "volume" => Some(stock.volume as f64), + "volume" => Some(stock.volume), "tick_volume" => Some(stock.tick_volume as f64), "bid1_volume" => Some(stock.bid1_volume as f64), "ask1_volume" => Some(stock.ask1_volume as f64), @@ -5279,8 +5333,6 @@ impl PlatformExprStrategy { | "touched_lower_limit" | "hit_upper_limit" | "hit_lower_limit" - | "at_upper_limit" - | "at_lower_limit" ) }) } @@ -5657,6 +5709,7 @@ impl Strategy for PlatformExprStrategy { }; let empty_rebalance_retry = self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty(); + let effective_refresh_rate = self.effective_refresh_rate(ctx, &day)?; let periodic_rebalance = if self.config.rotation_enabled { if let Some(schedule) = &self.config.rebalance_schedule { schedule.matches( @@ -5669,12 +5722,12 @@ impl Strategy for PlatformExprStrategy { self.last_rebalance_date .map(|last| { execution_date.signed_duration_since(last).num_days() - >= self.config.refresh_rate as i64 + >= effective_refresh_rate as i64 }) .unwrap_or(true) || empty_rebalance_retry } else { - self.rebalance_day_counter % self.config.refresh_rate == 0 || empty_rebalance_retry + self.rebalance_day_counter % effective_refresh_rate == 0 || empty_rebalance_retry } } else { false @@ -6309,6 +6362,124 @@ mod tests { assert!(!rewritten.contains('?')); } + #[test] + fn platform_day_state_uses_decision_benchmark_close() { + let dates = [d(2025, 1, 2), d(2025, 1, 3)]; + let market_rows = vec![ + DailyMarketSnapshot { + date: dates[0], + symbol: "000001.SH".to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.0, + volume: 1_000_000, + tick_volume: 0, + bid1_volume: 0, + ask1_volume: 0, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: dates[1], + symbol: "000001.SH".to_string(), + timestamp: None, + day_open: 1_000.0, + open: 1_000.0, + high: 1_000.0, + low: 1_000.0, + close: 1_000.0, + last_price: 1_000.0, + bid1: 1_000.0, + ask1: 1_000.0, + prev_close: 10.0, + volume: 1_000_000, + tick_volume: 0, + bid1_volume: 0, + ask1_volume: 0, + trading_phase: None, + paused: false, + upper_limit: 1_100.0, + lower_limit: 900.0, + price_tick: 0.01, + }, + ]; + let benchmark_rows = vec![ + BenchmarkSnapshot { + date: dates[0], + benchmark: "000852.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: dates[1], + benchmark: "000852.SH".to_string(), + open: 150.0, + close: 9_999.0, + prev_close: 100.0, + volume: 1_000_000, + }, + ]; + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SH".to_string(), + name: "SSE Composite".to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(1990, 12, 19)), + delisted_at: None, + status: "active".to_string(), + }], + market_rows, + Vec::new(), + Vec::new(), + benchmark_rows, + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: dates[1], + decision_date: dates[1], + 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: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SH".to_string(); + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + let strategy = PlatformExprStrategy::new(cfg); + + let day = strategy.day_state(&ctx, dates[1]).expect("day state"); + + assert_eq!(day.signal_close, 10.0); + assert_eq!(day.benchmark_open, 150.0); + assert_eq!(day.benchmark_close, 100.0); + assert_eq!(day.benchmark_ma_short, 100.0); + assert_eq!(day.benchmark_ma_long, 100.0); + } + #[test] fn platform_expr_code_number_extracts_stock_digits() { assert_eq!(super::code_number_value("002113.SZ"), 2113); @@ -7116,12 +7287,128 @@ mod tests { assert_eq!(stock.close, 9.9); assert_eq!(stock.prev_close, 9.7); - assert_eq!(stock.volume, 12_300); + assert_eq!(stock.volume, 12_300.0); assert_eq!(stock.last, 19.06); assert_eq!(stock.market_cap, 8.0); assert!(!stock.touched_upper_limit); } + #[test] + fn platform_aiquant_selection_uses_decision_market_cap() { + let date = d(2025, 4, 8); + let symbol = "003008.SZ"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-04-08 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 20.0, + low: 10.0, + close: 20.0, + last_price: 12.0, + bid1: 12.0, + ask1: 12.0, + prev_close: 10.0, + volume: 120_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 22.0, + lower_limit: 8.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 18.0, + free_float_cap_bn: 9.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 40, + 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: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + cfg.signal_symbol = symbol.to_string(); + cfg.stock_filter_expr = "true".to_string(); + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + let strategy = PlatformExprStrategy::new(cfg); + + let universe = strategy.selectable_universe_on(&ctx, date, date); + assert_eq!(universe.len(), 1); + assert!((universe[0].market_cap_bn - 9.0).abs() < 1e-9); + assert!((universe[0].free_float_cap_bn - 4.5).abs() < 1e-9); + + let stock = strategy + .stock_state_with_factor_date(&ctx, date, date, symbol) + .expect("stock state"); + assert!((stock.market_cap - 9.0).abs() < 1e-9); + assert!((stock.free_float_cap - 4.5).abs() < 1e-9); + assert_eq!(stock.close, 10.0); + assert!(stock.high.is_nan()); + assert!(stock.low.is_nan()); + assert!(stock.volume.is_nan()); + + let day = strategy.day_state(&ctx, date).expect("day state"); + let (selected, _) = strategy + .select_symbols(&ctx, date, date, &day, 10.0, 20.0, 1) + .expect("selection"); + assert!(selected.is_empty()); + } + #[test] fn platform_aiquant_stock_expr_uses_scheduled_tick_price_for_limit_check() { let date = d(2024, 1, 30); @@ -7241,6 +7528,142 @@ mod tests { ); } + #[test] + fn platform_aiquant_scheduled_quote_uses_configured_intraday_time() { + let date = d(2025, 1, 6); + let symbol = "603779.SH"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-01-06 10:40:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 22.0, + low: 9.8, + close: 21.5, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 1_000_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 22.0, + lower_limit: 8.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 8.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + Vec::new(), + vec![ + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 40, 0).expect("valid timestamp"), + last_price: 10.0, + bid1: 9.99, + ask1: 10.0, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 100, + amount_delta: 1_000.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(14, 59, 2).expect("valid timestamp"), + last_price: 20.0, + bid1: 19.99, + ask1: 20.0, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 100, + amount_delta: 2_000.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + 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: &[], + }; + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap()); + let strategy = PlatformExprStrategy::new(cfg.clone()); + assert_eq!( + strategy + .aiquant_scheduled_quote(&ctx, date, symbol) + .map(|quote| quote.last_price), + Some(10.0) + ); + + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(14, 59, 0).unwrap()); + let strategy = PlatformExprStrategy::new(cfg); + let quote = strategy + .aiquant_scheduled_quote(&ctx, date, symbol) + .expect("quote at or after 14:59"); + assert_eq!(quote.timestamp, date.and_hms_opt(14, 59, 2).unwrap()); + assert_eq!(quote.last_price, 20.0); + } + #[test] fn platform_aiquant_intraday_selection_filters_limits_on_execution_date() { let factor_date = d(2023, 11, 10); @@ -9541,6 +9964,31 @@ mod tests { third.diagnostics ); + let mut dynamic_cfg = PlatformExprStrategyConfig::microcap_rotation(); + dynamic_cfg.signal_symbol = "000001.SZ".to_string(); + dynamic_cfg.refresh_rate = 20; + dynamic_cfg.refresh_rate_expr = "year >= 2024 ? 5 : 20".to_string(); + dynamic_cfg.max_positions = 2; + dynamic_cfg.benchmark_short_ma_days = 1; + dynamic_cfg.benchmark_long_ma_days = 1; + dynamic_cfg.market_cap_lower_expr = "0".to_string(); + dynamic_cfg.market_cap_upper_expr = "100".to_string(); + dynamic_cfg.selection_limit_expr = "2".to_string(); + dynamic_cfg.stock_filter_expr = "close > 0".to_string(); + let mut dynamic_strategy = PlatformExprStrategy::new(dynamic_cfg); + dynamic_strategy.rebalance_day_counter = 5; + let dynamic_decision = dynamic_strategy + .on_day(&third_ctx) + .expect("dynamic refresh decision"); + assert!( + dynamic_decision + .diagnostics + .iter() + .any(|item| item.contains("periodic_rebalance=true")), + "{:?}", + dynamic_decision.diagnostics + ); + let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation(); no_retry_cfg.signal_symbol = "000001.SZ".to_string(); no_retry_cfg.refresh_rate = 15; @@ -11579,7 +12027,7 @@ mod tests { valuation_prices .as_ref() .and_then(|map| map.get("000002.SZ").copied()), - Some(10.01) + Some(9.99) ); } other => panic!("unexpected explicit target portfolio intent: {other:?}"), @@ -12910,4 +13358,25 @@ mod tests { "rolling helper values must not change cached script identity across dates" ); } + + #[test] + fn daily_limit_state_does_not_require_intraday_selection_quotes() { + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.stock_filter_expr = "close > 1.0 && !at_upper_limit && !at_lower_limit".to_string(); + let strategy = PlatformExprStrategy::new(cfg); + + assert!( + !strategy.stock_filter_uses_intraday_quote_fields(), + "daily limit-state aliases are derived from daily close/limit fields" + ); + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.stock_filter_expr = "last_price > 1.0 && ask1_volume > 0".to_string(); + let strategy = PlatformExprStrategy::new(cfg); + + assert!( + strategy.stock_filter_uses_intraday_quote_fields(), + "actual tick/quote fields still require intraday selection quotes" + ); + } } diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index c4823ff..a51be9d 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -258,6 +258,8 @@ pub struct StrategyExpressionTradingConfig { #[serde(default)] pub stage: Option, #[serde(default)] + pub refresh_rate_expr: Option, + #[serde(default)] pub schedule: Option, #[serde(default)] pub rotation_enabled: Option, @@ -619,6 +621,13 @@ pub fn platform_expr_config_from_spec( } } if let Some(trading) = runtime_expr.trading.as_ref() { + if let Some(expr) = trading + .refresh_rate_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.refresh_rate_expr = expr.clone(); + } if let Some(enabled) = trading.rotation_enabled { cfg.rotation_enabled = enabled; } @@ -1124,6 +1133,7 @@ mod tests { "stockFilterExpr": "stock_ma5 > stock_ma10" }, "trading": { + "refreshRateExpr": "year >= 2024 ? 5 : 20", "rotationEnabled": false, "dailyTopUp": true, "retryEmptyRebalance": true, @@ -1145,6 +1155,7 @@ mod tests { assert_eq!(cfg.strategy_name, "runtime_spec_test"); assert_eq!(cfg.signal_symbol, "000852.SH"); assert_eq!(cfg.selection_limit_expr, "stocknum"); + assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20"); assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]); assert!(!cfg.rotation_enabled); assert!(cfg.daily_top_up_enabled); diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 6f0ce7f..e756216 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -26,11 +26,11 @@ impl ChinaAShareRiskControl { return Some("inactive_or_delisted"); } let status = instrument.status.trim().to_ascii_lowercase(); - if matches!( + let terminal_status = matches!( status.as_str(), "inactive" | "delisted" | "terminated" | "expired" - ) || status.contains("delist") - { + ) || status.contains("delist"); + if terminal_status && instrument.delisted_at.is_none() { return Some("inactive_or_delisted"); } None