规范化策略规格别名避免运行时重复字段

This commit is contained in:
boris
2026-08-27 09:06:37 +08:00
parent 801a27dace
commit ce4d17c293
@@ -615,6 +615,142 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
Ok(()) 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::<f64>()
.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<String, Value>,
) -> 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)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DynamicRangeConfig { pub struct DynamicRangeConfig {
@@ -907,6 +1043,7 @@ pub fn platform_expr_config_from_value(
} }
reject_removed_compatibility_fields(value).map_err(platform_config_error)?; reject_removed_compatibility_fields(value).map_err(platform_config_error)?;
let mut value = value.clone(); 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)?; normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?;
let spec = serde_json::from_value::<StrategyRuntimeSpec>(value)?; let spec = serde_json::from_value::<StrategyRuntimeSpec>(value)?;
platform_expr_config_from_spec(strategy_id, signal_symbol, Some(&spec)) 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] #[test]
fn parses_execution_slippage_overrides_into_platform_config() { fn parses_execution_slippage_overrides_into_platform_config() {
let spec = serde_json::json!({ let spec = serde_json::json!({