diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index ec93cb4..c64468b 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -884,6 +884,32 @@ impl PlatformExprStrategy { } fn is_reserved_scope_name(name: &str) -> bool { + if let Some(field) = name + .strip_prefix("decision_") + .or_else(|| name.strip_prefix("execution_")) + { + if matches!( + field, + "day_open" + | "open" + | "high" + | "low" + | "close" + | "last_price" + | "prev_close" + | "upper_limit" + | "lower_limit" + | "volume" + | "minute_volume" + | "bid1" + | "ask1" + | "bid1_volume" + | "ask1_volume" + | "price_tick" + ) { + return true; + } + } matches!( name, "signal_close" @@ -3166,6 +3192,7 @@ impl PlatformExprStrategy { day: &DayExpressionState, stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, + identifiers: &BTreeSet, include_day_factors: bool, include_factors_map: bool, include_process_event_counts: bool, @@ -3183,6 +3210,41 @@ impl PlatformExprStrategy { scope.push("signal_close", day.signal_close); scope.push("benchmark_open", day.benchmark_open); scope.push("benchmark_close", day.benchmark_close); + for field in [ + "day_open", + "open", + "high", + "low", + "close", + "last_price", + "prev_close", + "upper_limit", + "lower_limit", + "volume", + "minute_volume", + "bid1", + "ask1", + "bid1_volume", + "ask1_volume", + "price_tick", + ] { + self.push_market_scope_map( + &mut scope, + ctx, + ctx.decision_date, + "decision", + field, + identifiers, + ); + self.push_market_scope_map( + &mut scope, + ctx, + ctx.execution_date, + "execution", + field, + identifiers, + ); + } scope.push("signal_ma5", day.signal_ma5); scope.push("signal_ma10", day.signal_ma10); scope.push("signal_ma20", day.signal_ma20); @@ -3823,6 +3885,7 @@ impl PlatformExprStrategy { day, stock, position, + &normalized_identifiers, include_day_factors, include_factors_map, include_process_event_counts, @@ -5670,14 +5733,15 @@ impl PlatformExprStrategy { if trimmed.is_empty() { return Ok(BTreeMap::new()); } - let inner = trimmed + let Some(inner) = trimmed .strip_prefix('{') .and_then(|value| value.strip_suffix('}')) - .ok_or_else(|| { - BacktestError::Execution(format!( - "platform float map expr must use {{...}} object literal syntax: {trimmed}" - )) - })?; + else { + return Self::dynamic_to_float_map( + self.eval_dynamic(ctx, trimmed, day, stock, position)?, + trimmed, + ); + }; let mut output = BTreeMap::new(); for entry in Self::split_top_level_args(inner) { let Some((raw_key, raw_value)) = Self::split_top_level_key_value(&entry) else { @@ -5711,6 +5775,11 @@ impl PlatformExprStrategy { self.eval_float_map_expr(ctx, trimmed, day, stock, position)?, )); } + if !trimmed.contains('(') { + return Ok(TargetPortfolioOrderPricing::LimitPrices( + self.eval_float_map_expr(ctx, trimmed, day, stock, position)?, + )); + } self.eval_algo_order_pricing_expr(ctx, trimmed, day, stock, position) } @@ -5850,6 +5919,82 @@ impl PlatformExprStrategy { ))) } + fn dynamic_to_float_map( + value: Dynamic, + expr: &str, + ) -> Result, BacktestError> { + let Some(map) = value.try_cast::() else { + return Err(BacktestError::Execution(format!( + "platform float map expr must evaluate to a map or use {{...}} object literal syntax: {expr}" + ))); + }; + let mut output = BTreeMap::new(); + for (key, value) in map { + let number = if let Some(number) = value.clone().try_cast::() { + number + } else if let Some(number) = value.clone().try_cast::() { + number as f64 + } else if let Some(boolean) = value.try_cast::() { + if boolean { 1.0 } else { 0.0 } + } else { + return Err(BacktestError::Execution(format!( + "platform float map expr produced non-numeric value for key {key}: {expr}" + ))); + }; + output.insert(key.to_string(), number); + } + Ok(output) + } + + fn market_scope_value(snapshot: &DailyMarketSnapshot, field: &str) -> Option { + let value = match field { + "day_open" => snapshot.day_open, + "open" => snapshot.open, + "high" => snapshot.high, + "low" => snapshot.low, + "close" => snapshot.close, + "last_price" => snapshot.last_price, + "prev_close" => snapshot.prev_close, + "upper_limit" => snapshot.upper_limit, + "lower_limit" => snapshot.lower_limit, + "volume" => snapshot.volume as f64, + "minute_volume" => snapshot.minute_volume as f64, + "bid1" => snapshot.bid1, + "ask1" => snapshot.ask1, + "bid1_volume" => snapshot.bid1_volume as f64, + "ask1_volume" => snapshot.ask1_volume as f64, + "price_tick" => snapshot.price_tick, + _ => return None, + }; + value.is_finite().then_some(value) + } + + fn scope_identifier_requested(&self, identifiers: &BTreeSet, name: &str) -> bool { + identifiers.contains(name) || self.prelude_identifier_candidates.contains(name) + } + + fn push_market_scope_map( + &self, + scope: &mut Scope<'_>, + ctx: &StrategyContext<'_>, + date: NaiveDate, + prefix: &str, + field: &str, + identifiers: &BTreeSet, + ) { + let name = format!("{prefix}_{field}"); + if !self.scope_identifier_requested(identifiers, &name) { + return; + } + let mut map = Map::new(); + for snapshot in ctx.data.market_snapshots_on(date) { + if let Some(value) = Self::market_scope_value(snapshot, field) { + map.insert(snapshot.symbol.clone().into(), Dynamic::from(value)); + } + } + scope.push_dynamic(name, Dynamic::from(map)); + } + fn action_stock_state( &self, ctx: &StrategyContext<'_>, @@ -27077,6 +27222,52 @@ mod tests { lower_limit: 900.9, price_tick: 0.01, }, + DailyMarketSnapshot { + date: execution_date, + symbol: "000521.SZ".to_string(), + timestamp: Some("2023-01-04 09:30:00".to_string()), + day_open: 12.34, + open: 12.31, + high: 12.80, + low: 12.10, + close: 12.70, + last_price: 12.70, + bid1: 12.69, + ask1: 12.71, + prev_close: 12.00, + volume: 100_000, + minute_volume: 5_000, + bid1_volume: 2_500, + ask1_volume: 2_500, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 13.20, + lower_limit: 10.80, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: execution_date, + symbol: "000333.SZ".to_string(), + timestamp: Some("2023-01-04 09:30:00".to_string()), + day_open: 55.60, + open: 55.50, + high: 56.20, + low: 55.00, + close: 56.00, + last_price: 56.00, + bid1: 55.99, + ask1: 56.01, + prev_close: 54.90, + volume: 100_000, + minute_volume: 5_000, + bid1_volume: 2_500, + ask1_volume: 2_500, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 60.39, + lower_limit: 49.41, + price_tick: 0.01, + }, ], vec![], vec![], @@ -27131,8 +27322,8 @@ mod tests { "}" ) .to_string(), - order_prices_expr: None, - valuation_prices_expr: None, + order_prices_expr: Some("execution_day_open".to_string()), + valuation_prices_expr: Some("execution_day_open".to_string()), when_expr: Some( "decision_date == \"2023-01-03\" && execution_date == \"2023-01-04\"" .to_string(), @@ -27145,9 +27336,27 @@ mod tests { assert_eq!(decision.order_intents.len(), 1); match &decision.order_intents[0] { - crate::strategy::OrderIntent::TargetPortfolioSmart { target_weights, .. } => { + crate::strategy::OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices, + valuation_prices, + .. + } => { assert_eq!(target_weights.get("000521.SZ").copied(), Some(0.025)); assert_eq!(target_weights.get("000333.SZ").copied(), Some(0.05)); + match order_prices { + Some(TargetPortfolioOrderPricing::LimitPrices(prices)) => { + assert_eq!(prices.get("000521.SZ").copied(), Some(12.34)); + assert_eq!(prices.get("000333.SZ").copied(), Some(55.60)); + } + other => panic!("unexpected order pricing: {other:?}"), + } + assert_eq!( + valuation_prices + .as_ref() + .and_then(|prices| prices.get("000521.SZ").copied()), + Some(12.34) + ); } other => panic!("unexpected explicit target portfolio intent: {other:?}"), }