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