From 0f1d49bf6302faa997bcd05affb8656744170452 Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 6 Sep 2026 02:45:39 +0800 Subject: [PATCH] feat: execute factor position target rules --- crates/fidc-core/src/lib.rs | 6 +- .../fidc-core/src/platform_expr_strategy.rs | 329 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 101 +++++- 3 files changed, 419 insertions(+), 17 deletions(-) diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 77aaba3..f77456b 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -62,9 +62,9 @@ pub use metrics::{ pub use platform_expr_strategy::{ PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, - PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, - PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction, - PlatformUniverseActionKind, + PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule, + PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, + PlatformTradeAction, PlatformUniverseActionKind, }; pub use platform_runtime_schema::{ PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 6788702..8883704 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -371,6 +371,13 @@ pub enum PlatformStopTakeReferencePriceMode { SignalDayPostAdjustedClose, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformPositionTargetRule { + pub when_expr: String, + pub remaining_position_bps: u32, + pub reason: String, +} + #[derive(Debug, Clone)] pub struct PlatformExprStrategyConfig { pub strategy_name: String, @@ -394,6 +401,7 @@ pub struct PlatformExprStrategyConfig { pub portfolio_drawdown_control: Option, pub stop_loss_expr: String, pub take_profit_expr: String, + pub position_target_rules: Vec, pub stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode, pub rank_by: String, pub rank_expr: String, @@ -467,6 +475,7 @@ impl PlatformExprStrategyConfig { portfolio_drawdown_control: None, stop_loss_expr: String::new(), take_profit_expr: String::new(), + position_target_rules: Vec::new(), stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode::PositionCostBasis, rank_by: "market_cap".to_string(), rank_expr: String::new(), @@ -1562,6 +1571,12 @@ impl PlatformExprStrategy { ), ("rank_expr".to_string(), self.config.rank_expr.as_str()), ]; + for (index, rule) in self.config.position_target_rules.iter().enumerate() { + expressions.push(( + format!("position_target_rules[{index}].when_expr"), + rule.when_expr.as_str(), + )); + } for (index, action) in self.config.explicit_actions.iter().enumerate() { match action { PlatformTradeAction::Order { @@ -9395,6 +9410,41 @@ impl PlatformExprStrategy { Ok(symbols) } + fn current_position_target_rules( + &self, + ctx: &StrategyContext<'_>, + signal_date: NaiveDate, + factor_date: NaiveDate, + day: &DayExpressionState, + ) -> Result, BacktestError> { + let mut targets = BTreeMap::new(); + if self.config.position_target_rules.is_empty() { + return Ok(targets); + } + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 { + continue; + } + let stock = + self.stock_state_with_factor_date(ctx, signal_date, factor_date, &position.symbol)?; + for rule in &self.config.position_target_rules { + if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? { + continue; + } + let replace = targets + .get(&position.symbol) + .map_or(true, |(bps, _)| rule.remaining_position_bps < *bps); + if replace { + targets.insert( + position.symbol.clone(), + (rule.remaining_position_bps, rule.reason.clone()), + ); + } + } + } + Ok(targets) + } + fn explicit_action_decision( &self, ctx: &StrategyContext<'_>, @@ -10414,6 +10464,15 @@ impl PlatformExprStrategy { Self::require_stock_rollings_for_identifiers(&mut requirements, config, &normalized); Self::require_stock_rollings_for_helper_calls(&mut requirements, &normalized); } + for rule in &config.position_target_rules { + let normalized = Self::normalize_expr(&rule.when_expr); + if Self::extract_identifier_candidates(&normalized).contains("factors") { + requirements.require_all(); + return requirements; + } + Self::require_stock_rollings_for_identifiers(&mut requirements, config, &normalized); + Self::require_stock_rollings_for_helper_calls(&mut requirements, &normalized); + } requirements } @@ -10438,6 +10497,17 @@ impl PlatformExprStrategy { return true; } } + for rule in &config.position_target_rules { + let compact = Self::compact_expr(&Self::normalize_expr(&rule.when_expr)); + Self::require_stock_rollings_for_named_helper( + &mut requirements, + &compact, + "rolling_mean_current", + ); + if !requirements.fields.is_empty() { + return true; + } + } false } @@ -10469,6 +10539,11 @@ impl PlatformExprStrategy { expr, ))); } + for rule in &config.position_target_rules { + identifiers.extend(Self::extract_identifier_candidates(&Self::normalize_expr( + &rule.when_expr, + ))); + } StockSnapshotFieldRequirements { amount: identifiers.contains("amount"), touched_upper_limit: identifiers.contains("touched_upper_limit") @@ -10496,6 +10571,11 @@ impl PlatformExprStrategy { ) { return true; } + if config.position_target_rules.iter().any(|rule| { + Self::expr_requires_stock_extra_factors(&rule.when_expr, prelude_declared_identifiers) + }) { + return true; + } [ config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), @@ -10528,6 +10608,11 @@ impl PlatformExprStrategy { .map(Self::normalize_expr) .map(|expr| Self::extract_identifier_candidates(&expr)) .any(|identifiers| identifiers.contains("factors") || identifiers.contains("factor")) + || config.position_target_rules.iter().any(|rule| { + let identifiers = + Self::extract_identifier_candidates(&Self::normalize_expr(&rule.when_expr)); + identifiers.contains("factors") || identifiers.contains("factor") + }) } fn stock_extra_factor_identifiers_for_config( @@ -10558,6 +10643,13 @@ impl PlatformExprStrategy { prelude_declared_identifiers, ); } + for rule in &config.position_target_rules { + Self::collect_stock_extra_factor_identifiers( + &mut identifiers, + &rule.when_expr, + prelude_declared_identifiers, + ); + } identifiers } @@ -10600,6 +10692,9 @@ impl PlatformExprStrategy { ] .into_iter() .any(|expr| Self::expr_may_use_stock_text_factors(expr, prelude_declared_identifiers)) + || config.position_target_rules.iter().any(|rule| { + Self::expr_may_use_stock_text_factors(&rule.when_expr, prelude_declared_identifiers) + }) } fn has_stock_explicit_actions(config: &PlatformExprStrategyConfig) -> bool { @@ -11469,8 +11564,12 @@ impl Strategy for PlatformExprStrategy { let in_skip_window = self.config.in_skip_window(signal_date); let day = self.day_state(ctx, decision_date)?; + let (selection_market_date, selection_universe_factor_date, selection_factor_date) = + self.selection_dates(ctx); let current_stop_take_exit_symbols = self.current_stop_take_exit_symbols(ctx, signal_date, &day)?; + let factor_position_targets = + self.current_position_target_rules(ctx, signal_date, selection_factor_date, &day)?; let mut model_only_lifecycle_exit_symbols = current_stop_take_exit_symbols .iter() .filter(|symbol| { @@ -11497,8 +11596,6 @@ impl Strategy for PlatformExprStrategy { self.forget_position_entry_date(symbol); } } - let (selection_market_date, selection_universe_factor_date, selection_factor_date) = - self.selection_dates(ctx); let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window && self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay && self.config.explicit_action_schedule.is_none() @@ -12045,6 +12142,77 @@ impl Strategy for PlatformExprStrategy { } } + let mut factor_position_action_symbols = BTreeSet::new(); + for (symbol, (remaining_bps, rule_reason)) in &factor_position_targets { + if delayed_sold_symbols.contains(symbol) + || unresolved_delisted_symbols.contains(symbol) + || current_stop_take_exit_symbols.contains(symbol) + || exit_symbols.contains(symbol) + || carried_full_close_symbols.contains(symbol) + { + continue; + } + let Some(position) = ctx.portfolio.position(symbol) else { + continue; + }; + let order_step = self.projected_order_step_size(ctx, symbol).max(1); + let raw_target = (u64::from(position.quantity) * u64::from(*remaining_bps) / 10_000) + .min(u64::from(u32::MAX)) as u32; + let target_quantity = (raw_target / order_step) * order_step; + if target_quantity >= position.quantity { + continue; + } + factor_position_action_symbols.insert(symbol.clone()); + let reason = format!("factor_position_target:{rule_reason}"); + let target_quantity_i32 = i32::try_from(target_quantity).map_err(|_| { + BacktestError::Execution(format!( + "factor position target quantity exceeds i32 symbol={symbol} quantity={target_quantity}" + )) + })?; + order_intents.push(OrderIntent::TargetShares { + symbol: symbol.clone(), + target_quantity: target_quantity_i32, + reason: reason.clone(), + }); + if target_quantity == 0 { + exit_symbols.insert(symbol.clone()); + self.forget_position_entry_date(symbol); + self.project_target_zero( + ctx, + &mut projected, + projection_date, + symbol, + &mut projected_execution_state, + ); + } else { + let current_value = self.projected_position_value_at_execution_price( + ctx, + &projected, + projection_date, + symbol, + ); + let target_value = + current_value * f64::from(target_quantity) / f64::from(position.quantity); + self.project_target_value( + ctx, + &mut projected, + projection_date, + symbol, + target_value, + &mut projected_execution_state, + ); + } + self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected); + if Self::projected_position_is_flat(&projected, symbol) { + same_day_sold_symbols.insert(symbol.clone()); + slot_working_symbols.remove(symbol); + } + selection_notes.push(format!( + "factor_position_target symbol={} remaining_bps={} target_quantity={} reason={}", + symbol, remaining_bps, target_quantity, rule_reason + )); + } + let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone(); if self.config.rotation_enabled @@ -12063,6 +12231,7 @@ impl Strategy for PlatformExprStrategy { if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) || unresolved_delisted_symbols.contains(&position.symbol) + || factor_position_action_symbols.contains(&position.symbol) { continue; } @@ -12230,6 +12399,7 @@ impl Strategy for PlatformExprStrategy { for position in ctx.portfolio.positions().values() { if delayed_sold_symbols.contains(&position.symbol) || unresolved_delisted_symbols.contains(&position.symbol) + || factor_position_action_symbols.contains(&position.symbol) { continue; } @@ -12598,6 +12768,7 @@ impl Strategy for PlatformExprStrategy { .position_entry_dates .keys() .filter(|symbol| !exit_symbols.contains(*symbol)) + .filter(|symbol| !factor_position_action_symbols.contains(*symbol)) .cloned() .collect::>(); for symbol in &stock_list { @@ -12718,7 +12889,9 @@ impl Strategy for PlatformExprStrategy { .cloned() .collect::>(); for symbol in pre_rebalance_symbols.iter() { - if unresolved_delisted_symbols.contains(symbol) { + if unresolved_delisted_symbols.contains(symbol) + || factor_position_action_symbols.contains(symbol) + { continue; } if stock_list.iter().any(|candidate| candidate == symbol) { @@ -12772,7 +12945,8 @@ impl Strategy for PlatformExprStrategy { if unresolved_delisted_symbols.contains(symbol) { continue; } - if exit_symbols.contains(symbol) { + if exit_symbols.contains(symbol) || factor_position_action_symbols.contains(symbol) + { continue; } let decision_stock = self.stock_state_with_factor_date( @@ -13064,11 +13238,11 @@ mod tests { CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig, - PlatformPortfolioDrawdownController, PlatformRebalanceSchedule, PlatformScheduleFrequency, - PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, - RuntimeHelperResolution, SelectionRiskDeferral, StockFilterQuoteUsage, StockRollingField, - StockSnapshotFieldRequirements, framework_stock_rolling_factor_requirement, - scheduled_position_exposure, + PlatformPortfolioDrawdownController, PlatformPositionTargetRule, PlatformRebalanceSchedule, + PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, + PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral, + StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements, + framework_stock_rolling_factor_requirement, scheduled_position_exposure, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -34706,6 +34880,143 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0; )); } + #[test] + fn factor_position_target_reduces_once_without_daily_rebalance_override() { + let previous_date = d(2025, 5, 13); + let date = d(2025, 5, 14); + let symbol = "600778.SH"; + let data = DataSet::from_components( + 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: None, + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + 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: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::from([("reduce_signal".into(), 1.0)]), + }], + vec![CandidateEligibility { + date, + symbol: 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: 1_000.0, + close: 1_000.0, + prev_close: 1_000.0, + volume: 1_000_000, + }], + ) + .expect("factor reduction dataset"); + let mut portfolio = PortfolioState::new(100_000.0); + portfolio + .position_mut(symbol) + .buy(previous_date, 1_000, 10.0); + let subscriptions = BTreeSet::new(); + let context = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 20, + 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 config = PlatformExprStrategyConfig::generic(); + config.signal_symbol = symbol.to_string(); + config.benchmark_symbol = "000852.SH".to_string(); + config.max_positions = 1; + config.selection_limit_expr = "1".to_string(); + config.market_cap_lower_expr = "0".to_string(); + config.market_cap_upper_expr = "100".to_string(); + config.stock_filter_expr = "close > 0 && factors[\"reduce_signal\"] != 1".to_string(); + config.daily_position_target_adjust_enabled = true; + config.target_portfolio_daily_enabled = true; + config.rebalance_existing_positions = true; + config.hold_until_exit_enabled = true; + config.position_target_rules = vec![PlatformPositionTargetRule { + when_expr: "factors[\"reduce_signal\"] == 1".to_string(), + remaining_position_bps: 5_000, + reason: "factor_reduce_position".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(config); + + let decision = strategy + .on_day(&context) + .expect("factor reduction decision"); + + assert_eq!( + decision.order_intents.len(), + 1, + "{:?}", + decision.order_intents + ); + assert!(matches!( + &decision.order_intents[0], + OrderIntent::TargetShares { + symbol: intent_symbol, + target_quantity: 500, + reason, + } if intent_symbol == symbol && reason == "factor_position_target:factor_reduce_position" + )); + assert!( + decision + .diagnostics + .iter() + .any(|line| line.contains("remaining_bps=5000 target_quantity=500")) + ); + } + #[test] fn portfolio_drawdown_control_is_idempotent_and_rearms_after_cooldown() { let mut controller = diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 6e2b331..340c7ad 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -7,10 +7,10 @@ use serde_json::Value; use crate::{ DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig, - PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, - PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, - RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection, - futures::FuturesPositionEffect, strategy::OrderTimeInForce, + PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule, + PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, + PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel, + futures::FuturesDirection, futures::FuturesPositionEffect, strategy::OrderTimeInForce, }; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -42,6 +42,8 @@ pub struct StrategyRuntimeSpec { pub metadata: Option, #[serde(default, alias = "factor_value_bindings")] pub factor_value_bindings: Vec, + #[serde(default, alias = "stock_pool_factor_contract")] + pub stock_pool_factor_contract: Option, #[serde(default)] pub parameters: Option, #[serde(default)] @@ -888,6 +890,8 @@ pub struct StrategyExpressionSelectionConfig { pub market_cap_upper_expr: Option, #[serde(default)] pub stock_filter_expr: Option, + #[serde(default, alias = "current_day_precomputed_factors")] + pub current_day_precomputed_factors: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -910,6 +914,8 @@ pub struct StrategyExpressionRiskConfig { pub stop_loss_expr: Option, #[serde(default)] pub take_profit_expr: Option, + #[serde(default, alias = "position_target_rules")] + pub position_target_rules: Vec, #[serde( default, alias = "referencePriceMode", @@ -918,6 +924,17 @@ pub struct StrategyExpressionRiskConfig { pub stop_take_reference_price_mode: Option, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyPositionTargetRule { + #[serde(alias = "when_expr")] + pub when_expr: String, + #[serde(alias = "remaining_position_bps", alias = "remainingBps")] + pub remaining_position_bps: u32, + #[serde(default)] + pub reason: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyPositionExposureSchedulePoint { @@ -2020,6 +2037,9 @@ pub fn platform_expr_config_from_spec( { cfg.stock_filter_expr = expr.clone(); } + if let Some(enabled) = selection.current_day_precomputed_factors { + cfg.current_day_precomputed_factors = enabled; + } } if let Some(allocation) = runtime_expr.allocation.as_ref() && let Some(expr) = allocation @@ -2125,6 +2145,37 @@ pub fn platform_expr_config_from_spec( { cfg.take_profit_expr = expr.clone(); } + let mut position_target_identities = BTreeSet::new(); + for (index, rule) in risk.position_target_rules.iter().enumerate() { + let when_expr = rule.when_expr.trim(); + if when_expr.is_empty() { + return Err(format!( + "runtimeExpressions.risk.positionTargetRules[{index}].whenExpr cannot be empty" + )); + } + if rule.remaining_position_bps >= 10_000 { + return Err(format!( + "runtimeExpressions.risk.positionTargetRules[{index}].remainingPositionBps must be between 0 and 9999" + )); + } + let identity = (when_expr.to_string(), rule.remaining_position_bps); + if !position_target_identities.insert(identity) { + return Err(format!( + "runtimeExpressions.risk.positionTargetRules[{index}] is duplicated" + )); + } + cfg.position_target_rules.push(PlatformPositionTargetRule { + when_expr: when_expr.to_string(), + remaining_position_bps: rule.remaining_position_bps, + reason: rule + .reason + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("factor_position_target") + .to_string(), + }); + } if let Some(mode) = risk .stop_take_reference_price_mode .as_deref() @@ -3005,7 +3056,8 @@ mod tests { "limitExpr": "stocknum", "marketCapLowerExpr": "3", "marketCapUpperExpr": "28", - "stockFilterExpr": "stock_ma5 > stock_ma10" + "stockFilterExpr": "stock_ma5 > stock_ma10", + "currentDayPrecomputedFactors": true }, "trading": { "refreshRateExpr": "year >= 2024 ? 5 : 20", @@ -3036,6 +3088,7 @@ mod tests { assert!(!cfg.rotation_enabled); assert!(cfg.daily_top_up_enabled); assert!(cfg.retry_empty_rebalance); + assert!(cfg.current_day_precomputed_factors); assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1)); assert!(!cfg.calendar_rebalance_interval); assert_eq!(cfg.explicit_actions.len(), 1); @@ -3045,6 +3098,44 @@ mod tests { ); } + #[test] + fn parses_and_rejects_invalid_position_target_rules() { + let spec = serde_json::json!({ + "strategyId": "factor_reduction", + "runtimeExpressions": { + "risk": { + "positionTargetRules": [{ + "whenExpr": "factors[\"reduce_signal\"] == 1", + "remainingPositionBps": 5000, + "reason": "factor_reduce_position" + }] + } + } + }); + let cfg = platform_expr_config_from_value("", "", &spec).expect("position rule config"); + assert_eq!( + cfg.position_target_rules, + vec![PlatformPositionTargetRule { + when_expr: "factors[\"reduce_signal\"] == 1".to_string(), + remaining_position_bps: 5000, + reason: "factor_reduce_position".to_string(), + }] + ); + + let invalid_bps = serde_json::json!({ + "runtimeExpressions": {"risk": {"positionTargetRules": [{ + "whenExpr": "true", + "remainingPositionBps": 10000 + }]}} + }); + assert!( + platform_expr_config_from_value("", "", &invalid_bps) + .unwrap_err() + .to_string() + .contains("must be between 0 and 9999") + ); + } + #[test] fn parses_minute_stage_schedule_and_initial_subscriptions() { let spec = serde_json::json!({