规范化策略规格别名避免运行时重复字段
This commit is contained in:
@@ -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::<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)]
|
||||
#[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::<StrategyRuntimeSpec>(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!({
|
||||
|
||||
Reference in New Issue
Block a user