diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index d6693bd..4a0920f 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -28,6 +28,7 @@ pub struct PlatformExprStrategyConfig { pub stop_loss_expr: String, pub take_profit_expr: String, pub rank_by: String, + pub rank_expr: String, pub rank_desc: bool, pub benchmark_short_ma_days: usize, pub benchmark_long_ma_days: usize, @@ -71,6 +72,7 @@ fn band_low(index_close) { stop_loss_expr: "0.93".to_string(), take_profit_expr: "1.07".to_string(), rank_by: "market_cap".to_string(), + rank_expr: String::new(), rank_desc: false, benchmark_short_ma_days: 5, benchmark_long_ma_days: 10, @@ -130,6 +132,7 @@ struct DayExpressionState { #[derive(Debug, Clone)] struct StockExpressionState { + symbol: String, market_cap: f64, free_float_cap: f64, pe_ttm: f64, @@ -626,6 +629,7 @@ impl PlatformExprStrategy { .unwrap_or(stock_ma_long); Ok(StockExpressionState { + symbol: symbol.to_string(), market_cap: factor.market_cap_bn, free_float_cap: factor.free_float_cap_bn, pe_ttm: factor.pe_ttm, @@ -686,6 +690,7 @@ impl PlatformExprStrategy { scope.push("day_of_month", day.day_of_month); scope.push("weekday", day.weekday); if let Some(stock) = stock { + scope.push("symbol", stock.symbol.clone()); scope.push("market_cap", stock.market_cap); scope.push("free_float_cap", stock.free_float_cap); scope.push("pe_ttm", stock.pe_ttm); @@ -894,13 +899,66 @@ impl PlatformExprStrategy { } } - fn rank_value(&self, row: &EligibleUniverseSnapshot) -> f64 { - match self.config.rank_by.as_str() { - "free_float_cap" | "free_float_market_cap" => row.free_float_cap_bn, - _ => row.market_cap_bn, + fn stock_numeric_field_value( + &self, + candidate: &EligibleUniverseSnapshot, + stock: &StockExpressionState, + field: &str, + ) -> Option { + match field { + "market_cap" => Some(stock.market_cap), + "free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap), + "pe_ttm" => Some(stock.pe_ttm), + "turnover_ratio" => Some(stock.turnover_ratio), + "effective_turnover_ratio" => Some(stock.effective_turnover_ratio), + "open" => Some(stock.open), + "close" => Some(stock.close), + "last" | "last_price" => Some(stock.last), + "prev_close" => Some(stock.prev_close), + "upper_limit" => Some(stock.upper_limit), + "lower_limit" => Some(stock.lower_limit), + "price_tick" => Some(stock.price_tick), + "round_lot" => Some(stock.round_lot as f64), + "listed_days" => Some(stock.listed_days as f64), + "stock_ma_short" | "stock_ma5" => Some(stock.stock_ma_short), + "stock_ma_mid" | "stock_ma10" => Some(stock.stock_ma_mid), + "stock_ma_long" | "stock_ma20" => Some(stock.stock_ma_long), + "allow_buy" => Some(if stock.allow_buy { 1.0 } else { 0.0 }), + "allow_sell" => Some(if stock.allow_sell { 1.0 } else { 0.0 }), + "paused" => Some(if stock.paused { 1.0 } else { 0.0 }), + "is_st" => Some(if stock.is_st { 1.0 } else { 0.0 }), + "is_kcb" => Some(if stock.is_kcb { 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(candidate.market_cap_bn), + "candidate_free_float_cap" => Some(candidate.free_float_cap_bn), + _ => None, } } + fn selection_field_value( + &self, + candidate: &EligibleUniverseSnapshot, + stock: &StockExpressionState, + ) -> f64 { + self.stock_numeric_field_value(candidate, stock, self.config.market_cap_field.as_str()) + .unwrap_or_else(|| self.field_value(candidate)) + } + + fn rank_value( + &self, + day: &DayExpressionState, + candidate: &EligibleUniverseSnapshot, + stock: &StockExpressionState, + ) -> Result { + if !self.config.rank_expr.trim().is_empty() { + return self.eval_float(&self.config.rank_expr, day, Some(stock), None); + } + Ok(self + .stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str()) + .unwrap_or_else(|| self.field_value(candidate))) + } + fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool { let Some(position) = ctx.portfolio.position(symbol) else { return false; @@ -974,18 +1032,20 @@ impl PlatformExprStrategy { ) -> Result<(Vec, Vec), BacktestError> { let universe = ctx.data.eligible_universe_on(date); let mut diagnostics = Vec::new(); - let mut candidates = universe - .iter() - .filter(|candidate| { - let field_value = self.field_value(candidate); - field_value >= band_low && field_value <= band_high - }) - .cloned() - .collect::>(); + let mut candidates = Vec::new(); + for candidate in universe.iter().cloned() { + let stock = self.stock_state(ctx, date, &candidate.symbol)?; + let field_value = self.selection_field_value(&candidate, &stock); + if field_value < band_low || field_value > band_high { + continue; + } + let rank_value = self.rank_value(day, &candidate, &stock)?; + candidates.push((candidate, stock, rank_value)); + } candidates.sort_by(|lhs, rhs| { - let lhs_value = self.rank_value(lhs); - let rhs_value = self.rank_value(rhs); - if self.config.rank_desc { + let lhs_value = lhs.2; + let rhs_value = rhs.2; + let ordering = if self.config.rank_desc { rhs_value .partial_cmp(&lhs_value) .unwrap_or(std::cmp::Ordering::Equal) @@ -993,12 +1053,16 @@ impl PlatformExprStrategy { lhs_value .partial_cmp(&rhs_value) .unwrap_or(std::cmp::Ordering::Equal) + }; + if ordering == std::cmp::Ordering::Equal { + lhs.0.symbol.cmp(&rhs.0.symbol) + } else { + ordering } }); let mut selected = Vec::new(); - for candidate in candidates { - let stock = self.stock_state(ctx, date, &candidate.symbol)?; + for (candidate, stock, _) in candidates { if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol, &stock)? { if diagnostics.len() < 12 { diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));