diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index eb956dc..e645036 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -274,7 +274,7 @@ fn band_low(index_close) { round((index_close - 2000) * 4 / 500 + 7) }"# .to_string(), - universe_exclude: vec!["bjse".to_string()], + universe_exclude: Vec::new(), market_cap_field: "market_cap".to_string(), market_cap_lower_expr: "band_low(signal_close)".to_string(), market_cap_upper_expr: "band_low(signal_close) + 10".to_string(), @@ -439,7 +439,9 @@ struct StockExpressionState { order_step_size: i64, paused: bool, is_st: bool, + is_star_st: bool, is_kcb: bool, + is_bjse: bool, is_one_yuan: bool, is_new_listing: bool, allow_buy: bool, @@ -936,7 +938,9 @@ impl PlatformExprStrategy { | "round_lot" | "paused" | "is_st" + | "is_star_st" | "is_kcb" + | "is_bjse" | "is_one_yuan" | "is_new_listing" | "allow_buy" @@ -2829,7 +2833,9 @@ impl PlatformExprStrategy { order_step_size: instrument.map(|item| item.order_step_size()).unwrap_or(100) as i64, paused: market.paused || candidate.is_paused, is_st: candidate.is_st, + is_star_st: candidate.is_star_st, is_kcb: candidate.is_kcb, + is_bjse: Self::symbol_is_bjse(symbol), is_one_yuan: candidate.is_one_yuan || market.day_open <= 1.0, is_new_listing: candidate.is_new_listing, allow_buy: candidate.allow_buy, @@ -3234,7 +3240,9 @@ impl PlatformExprStrategy { scope.push("order_step_size", stock.order_step_size); scope.push("paused", stock.paused); scope.push("is_st", stock.is_st); + scope.push("is_star_st", stock.is_star_st); scope.push("is_kcb", stock.is_kcb); + scope.push("is_bjse", stock.is_bjse); scope.push("is_one_yuan", stock.is_one_yuan); scope.push("is_new_listing", stock.is_new_listing); scope.push("allow_buy", stock.allow_buy); @@ -3344,7 +3352,9 @@ impl PlatformExprStrategy { ); factors.insert("paused".into(), Dynamic::from(stock.paused)); factors.insert("is_st".into(), Dynamic::from(stock.is_st)); + factors.insert("is_star_st".into(), Dynamic::from(stock.is_star_st)); factors.insert("is_kcb".into(), Dynamic::from(stock.is_kcb)); + factors.insert("is_bjse".into(), Dynamic::from(stock.is_bjse)); factors.insert("is_one_yuan".into(), Dynamic::from(stock.is_one_yuan)); factors.insert("is_new_listing".into(), Dynamic::from(stock.is_new_listing)); factors.insert("allow_buy".into(), Dynamic::from(stock.allow_buy)); @@ -6444,6 +6454,11 @@ impl PlatformExprStrategy { None } + fn symbol_is_bjse(symbol: &str) -> bool { + let normalized = symbol.trim().to_ascii_uppercase(); + normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE") + } + fn stock_numeric_field_value( &self, candidate: &EligibleUniverseSnapshot, @@ -6501,7 +6516,11 @@ impl PlatformExprStrategy { } "paused" => Some(if stock.paused { 1.0 } else { 0.0 }), "is_st" => Some(if stock.is_st { 1.0 } else { 0.0 }), + "is_star_st" | "star_st" => Some(if stock.is_star_st { 1.0 } else { 0.0 }), "is_kcb" => Some(if stock.is_kcb { 1.0 } else { 0.0 }), + "is_bjse" | "bjse" | "bse" | "beijing_exchange" | "north_exchange" => { + Some(if stock.is_bjse { 1.0 } else { 0.0 }) + } "is_one_yuan" => Some(if stock.is_one_yuan { 1.0 } else { 0.0 }), "is_new_listing" => Some(if stock.is_new_listing { 1.0 } else { 0.0 }), "candidate_market_cap" => Some(Self::market_cap_storage_to_strategy_unit( @@ -7135,7 +7154,9 @@ impl PlatformExprStrategy { | "hit_lower_limit" | "paused" | "is_st" + | "is_star_st" | "is_kcb" + | "is_bjse" | "is_one_yuan" | "is_new_listing" | "candidate_market_cap" @@ -9095,11 +9116,12 @@ mod tests { }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, - DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FactorTextValue, FuturesCommissionType, - FuturesTradingParameter, Instrument, IntradayExecutionQuote, MatchingType, OpenOrderView, - OrderIntent, OrderSide, PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode, - ScheduleStage, ScheduleTimeRule, SlippageModel, Strategy, StrategyContext, - TargetPortfolioOrderPricing, TradingCalendar, default_stage_time, + DailyFactorSnapshot, DailyMarketSnapshot, DataSet, EligibleUniverseSnapshot, + FactorTextValue, FuturesCommissionType, FuturesTradingParameter, Instrument, + IntradayExecutionQuote, MatchingType, OpenOrderView, OrderIntent, OrderSide, + PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode, ScheduleStage, + ScheduleTimeRule, SlippageModel, Strategy, StrategyContext, TargetPortfolioOrderPricing, + TradingCalendar, default_stage_time, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -13707,6 +13729,200 @@ mod tests { ); } + #[test] + fn platform_stock_expr_exposes_star_st_and_bjse_fields_without_default_selection_risk() { + let date = d(2025, 11, 10); + let signal = "000001.SH"; + let star_st_symbol = "300268.SZ"; + let bjse_symbol = "920508.BJ"; + let normal_symbol = "301167.SZ"; + let market = |symbol: &str, close: f64| DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 15:00:00")), + day_open: close, + open: close, + high: close, + low: close, + close, + last_price: close, + bid1: close, + ask1: close, + prev_close: close, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: close * 1.1, + lower_limit: close * 0.9, + price_tick: 0.01, + }; + let symbols = [signal, star_st_symbol, bjse_symbol, normal_symbol]; + let data = DataSet::from_components( + symbols + .into_iter() + .map(|symbol| Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: if symbol.ends_with(".BJ") { + "BJ".to_string() + } else if symbol.ends_with(".SH") { + "SH".to_string() + } else { + "SZ".to_string() + }, + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .into_iter() + .map(|symbol| market(symbol, 10.0)) + .collect(), + [star_st_symbol, bjse_symbol, normal_symbol] + .into_iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 1.0 + index as f64, + free_float_cap_bn: 1.0 + index as f64, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + vec![ + CandidateEligibility { + date, + symbol: star_st_symbol.to_string(), + is_st: true, + is_star_st: true, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }, + CandidateEligibility { + date, + symbol: bjse_symbol.to_string(), + 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, + }, + CandidateEligibility { + date, + symbol: normal_symbol.to_string(), + 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, + }, + ], + 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: 0, + 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 = signal.to_string(); + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "is_star_st || is_bjse".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, date).expect("day state"); + + assert_eq!( + strategy + .selectable_universe_on(&ctx, date, date) + .iter() + .map(|row| row.symbol.as_str()) + .collect::>(), + vec![star_st_symbol, bjse_symbol, normal_symbol] + ); + + let (selected, diagnostics, risk_decisions) = strategy + .select_symbols(&ctx, date, date, date, &day, 0.0, 100.0, 2) + .expect("select symbols"); + + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert!(risk_decisions.is_empty()); + assert_eq!( + selected, + vec![star_st_symbol.to_string(), bjse_symbol.to_string()] + ); + + let star_stock = strategy + .selection_stock_state_with_factor_date(&ctx, date, date, star_st_symbol) + .expect("star st state"); + let bjse_stock = strategy + .selection_stock_state_with_factor_date(&ctx, date, date, bjse_symbol) + .expect("bjse state"); + let star_candidate = EligibleUniverseSnapshot { + symbol: star_st_symbol.to_string(), + market_cap_bn: 1.0, + free_float_cap_bn: 1.0, + }; + let bjse_candidate = EligibleUniverseSnapshot { + symbol: bjse_symbol.to_string(), + market_cap_bn: 2.0, + free_float_cap_bn: 2.0, + }; + assert_eq!( + strategy.stock_numeric_field_value(&star_candidate, &star_stock, "is_star_st"), + Some(1.0) + ); + assert_eq!( + strategy.stock_numeric_field_value(&bjse_candidate, &bjse_stock, "is_bjse"), + Some(1.0) + ); + } + #[test] fn platform_next_open_select_symbols_defers_decision_day_limit_state() { let decision_date = d(2025, 1, 2); diff --git a/crates/fidc-core/src/platform_runtime_schema.rs b/crates/fidc-core/src/platform_runtime_schema.rs index cc9fda1..48d3d42 100644 --- a/crates/fidc-core/src/platform_runtime_schema.rs +++ b/crates/fidc-core/src/platform_runtime_schema.rs @@ -157,6 +157,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[ "is_st", "is_star_st", "is_kcb", + "is_bjse", "is_one_yuan", "is_new_listing", "allow_buy",