From ce4d17c293a05c1626242f0431cd33e44652003b Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 09:06:37 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=8C=96=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E8=A7=84=E6=A0=BC=E5=88=AB=E5=90=8D=E9=81=BF=E5=85=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=87=8D=E5=A4=8D=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_strategy_spec.rs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 4d42d5c..f008412 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -615,6 +615,142 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin Ok(()) } +/// Serde aliases are intentionally strict: a JSON object containing both a +/// camelCase field and its snake_case alias is reported as a duplicate field. +/// Runtime payloads are assembled by several services, so the same strategy +/// contract can legitimately arrive with both spellings. Canonicalise those +/// pairs once at the boundary, while rejecting conflicting values instead of +/// silently choosing one. +fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> { + normalize_strategy_aliases_in_value_inner(value, false) +} + +fn normalize_strategy_aliases_in_value_inner( + value: &mut Value, + in_risk_policy: bool, +) -> Result<(), String> { + match value { + Value::Object(object) => { + if !in_risk_policy { + normalize_strategy_object_aliases(object)?; + } + for (key, child) in object.iter_mut() { + normalize_strategy_aliases_in_value_inner( + child, + in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"), + )?; + } + } + Value::Array(items) => { + for item in items { + normalize_strategy_aliases_in_value_inner(item, in_risk_policy)?; + } + } + _ => {} + } + Ok(()) +} + +const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[ + ("strategyId", &["strategy_id"]), + ("tradeTimes", &["trade_times"]), + ("signalSymbol", &["signal_symbol"]), + ("engineConfig", &["engine_config"]), + ("runtimeExpressions", &["runtime_expressions"]), + ("rebalanceSchedule", &["rebalance_schedule"]), + ("skipWindows", &["skip_windows"]), + ("dynamicRange", &["dynamic_range"]), + ("stockMaFilter", &["stock_ma_filter"]), + ("indexThrottle", &["index_throttle"]), + ("benchmarkSymbol", &["benchmark_symbol"]), + ("matchingType", &["matching_type"]), + ("slippageModel", &["slippage_model"]), + ("slippageValue", &["slippage_value"]), + ("slippageImpactCoefficient", &["slippage_impact_coefficient"]), + ("slippageVolatilityCoefficient", &["slippage_volatility_coefficient"]), + ( + "slippageMaxValue", + &["slippage_max_value", "slippage_max_rate"], + ), + ("commissionRate", &["commission_rate"]), + ( + "minimumCommission", + &["minimum_commission", "min_commission", "minCommission"], + ), + ("transferFeeRate", &["transfer_fee_rate", "transferFeeRate"]), + ("stampTaxRate", &["stamp_tax_rate"]), + ("stampTaxRateBeforeChange", &["stamp_tax_rate_before_change"]), + ("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]), + ("stampTaxChangeDate", &["stamp_tax_change_date"]), + ("volumeLimit", &["volume_limit"]), + ("volumeLimitEnabled", &["volume_limit_enabled"]), + ("liquidityLimit", &["liquidity_limit"]), + ("liquidityLimitEnabled", &["liquidity_limit_enabled"]), + ("volumePercent", &["volume_percent"]), + ("riskPolicy", &["risk_policy"]), + ("strictValueBudget", &["strict_value_budget"]), + ("rebalanceCashMode", &["rebalance_cash_mode"]), + ( + "sellThenBuyDelaySlippageRate", + &["sell_then_buy_delay_slippage_rate"], + ), + ( + "maxHoldingDays", + &["max_hold_days", "max_holding_days", "maxHoldDays"], + ), + ( + "referencePriceMode", + &["reference_price_mode", "stop_take_reference_price_mode"], + ), +]; + +fn strategy_alias_values_semantically_equal(left: &Value, right: &Value) -> bool { + if left == right { + return true; + } + match (left, right) { + (Value::String(left), Value::String(right)) => left.trim() == right.trim(), + (Value::String(left), Value::Number(right)) + | (Value::Number(right), Value::String(left)) => left + .trim() + .parse::() + .ok() + .zip(right.as_f64()) + .is_some_and(|(left, right)| (left - right).abs() < 1e-12), + _ => false, + } +} + +fn normalize_strategy_object_aliases( + object: &mut serde_json::Map, +) -> Result<(), String> { + for (canonical, aliases) in STRATEGY_ALIAS_GROUPS { + let mut values = Vec::new(); + if let Some(value) = object.remove(*canonical).filter(|value| !value.is_null()) { + values.push(value); + } + for alias in *aliases { + if let Some(value) = object.remove(*alias).filter(|value| !value.is_null()) { + values.push(value); + } + } + let Some(first) = values.first().cloned() else { + continue; + }; + if values + .iter() + .skip(1) + .any(|value| !strategy_alias_values_semantically_equal(&first, value)) + { + return Err(format!( + "strategy field {canonical} has conflicting alias values" + )); + } + object.insert((*canonical).to_string(), first); + } + Ok(()) +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DynamicRangeConfig { @@ -907,6 +1043,7 @@ pub fn platform_expr_config_from_value( } reject_removed_compatibility_fields(value).map_err(platform_config_error)?; let mut value = value.clone(); + normalize_strategy_aliases_in_value(&mut value).map_err(platform_config_error)?; normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?; let spec = serde_json::from_value::(value)?; platform_expr_config_from_spec(strategy_id, signal_symbol, Some(&spec)) @@ -3384,6 +3521,44 @@ mod tests { ); } + #[test] + fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() { + let spec = serde_json::json!({ + "execution": { + "matchingType": "next_bar_open", + "matching_type": "next_bar_open", + "slippageModel": "price_ratio", + "slippage_model": "price_ratio", + "slippageValue": 0.001, + "slippage_value": "0.001" + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!(cfg.matching_type, MatchingType::NextBarOpen); + assert_eq!(cfg.slippage_model, SlippageModel::PriceRatio(0.001)); + } + + #[test] + fn rejects_conflicting_non_policy_strategy_aliases() { + let spec = serde_json::json!({ + "execution": { + "matchingType": "next_bar_open", + "matching_type": "current_bar_close" + } + }); + + let error = platform_expr_config_from_value("", "", &spec) + .expect_err("conflicting execution aliases must fail"); + + assert!( + error + .to_string() + .contains("strategy field matchingType has conflicting alias values") + ); + } + #[test] fn parses_execution_slippage_overrides_into_platform_config() { let spec = serde_json::json!({