diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index df947d4..5d49047 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -364,6 +364,55 @@ fn parse_policy_bool(canonical: &str, value: Value) -> Result { } } +fn policy_value_semantically_equals(canonical: &str, left: &Value, right: &Value) -> bool { + if left == right { + return true; + } + if let (Some(left_number), Some(right_number)) = + (policy_value_as_number(left), policy_value_as_number(right)) + { + if canonical == "volumePercent" { + return (normalize_percent_ratio_for_alias_compare(left_number) + - normalize_percent_ratio_for_alias_compare(right_number)) + .abs() + < 1e-12; + } + return (left_number - right_number).abs() < 1e-12; + } + if let (Some(left_text), Some(right_text)) = (left.as_str(), right.as_str()) { + return left_text.trim() == right_text.trim(); + } + false +} + +fn normalize_percent_ratio_for_alias_compare(value: f64) -> f64 { + if value > 1.0 { value / 100.0 } else { value } +} + +fn policy_value_as_number(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_f64(), + Value::String(text) => text.trim().parse::().ok(), + _ => None, + } +} + +fn reject_conflicting_policy_values(canonical: &str, values: &[Value]) -> Result<(), String> { + let Some(first) = values.first() else { + return Ok(()); + }; + if values + .iter() + .skip(1) + .any(|value| !policy_value_semantically_equals(canonical, first, value)) + { + return Err(format!( + "riskPolicy.{canonical} has conflicting alias values" + )); + } + Ok(()) +} + fn push_blacklist_symbols_from_str( raw: &str, symbols: &mut Vec, @@ -384,17 +433,25 @@ fn normalize_risk_policy_object_aliases( for (canonical, aliases) in RISK_POLICY_BOOL_ALIAS_GROUPS { let values = remove_policy_alias_values(object, canonical, aliases); if !values.is_empty() { - let mut merged = false; + let mut parsed_values = Vec::with_capacity(values.len()); for value in values { - merged |= parse_policy_bool(canonical, value)?; + parsed_values.push(parse_policy_bool(canonical, value)?); } - object.insert((*canonical).to_string(), Value::Bool(merged)); + let first = parsed_values[0]; + if parsed_values.iter().any(|value| *value != first) { + return Err(format!( + "riskPolicy.{canonical} has conflicting alias values" + )); + } + object.insert((*canonical).to_string(), Value::Bool(first)); } } for (canonical, aliases) in RISK_POLICY_VALUE_ALIAS_GROUPS { let values = remove_policy_alias_values(object, canonical, aliases); - if let Some(value) = values.into_iter().next() { + if !values.is_empty() { + reject_conflicting_policy_values(canonical, &values)?; + let value = values.into_iter().next().expect("non-empty values"); object.insert((*canonical).to_string(), value); } } @@ -2423,6 +2480,63 @@ mod tests { ); } + #[test] + fn accepts_equivalent_risk_policy_alias_values() { + let spec = serde_json::json!({ + "engineConfig": { + "riskPolicy": { + "rejectStBuy": false, + "reject_st_buy": 0, + "volumePercent": 0.25, + "volume_percent": 25, + "minimumCommission": 5, + "minimum_commission": "5" + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(!cfg.risk_config.static_rules.reject_st_buy); + assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12); + assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0); + } + + #[test] + fn rejects_conflicting_risk_policy_alias_values() { + let bool_conflict = serde_json::json!({ + "engineConfig": { + "riskPolicy": { + "rejectStBuy": false, + "reject_st_buy": true + } + } + }); + let bool_error = platform_expr_config_from_value("", "", &bool_conflict) + .expect_err("conflicting bool aliases must fail"); + assert!( + bool_error + .to_string() + .contains("riskPolicy.rejectStBuy has conflicting alias values") + ); + + let value_conflict = serde_json::json!({ + "engineConfig": { + "riskPolicy": { + "minimumCommission": 5, + "minimum_commission": 6 + } + } + }); + let value_error = platform_expr_config_from_value("", "", &value_conflict) + .expect_err("conflicting value aliases must fail"); + assert!( + value_error + .to_string() + .contains("riskPolicy.minimumCommission has conflicting alias values") + ); + } + #[test] fn parses_execution_slippage_overrides_into_platform_config() { let spec = serde_json::json!({