diff --git a/src/api/client/orders.rs b/src/api/client/orders.rs index 79fc300b..2bd82a97 100644 --- a/src/api/client/orders.rs +++ b/src/api/client/orders.rs @@ -173,74 +173,106 @@ impl EClient { } /// Parse algo strategy and TagValue params into internal AlgoParams. +/// +/// A key the caller never set defaults the way IB's own algos do (0.0, +/// false, or the documented default enum value). A key the caller *did* +/// set — even to an empty string — is refused if it doesn't parse, instead +/// of silently taking that same default: a typo like `riskAversion="Aggresive"` +/// used to submit a Neutral algo with no error, and `maxPctVol=""` used to +/// submit 0.0. See ibx#263. pub fn parse_algo_params(strategy: &str, params: &[TagValue]) -> Result { - let get = |key: &str| -> String { - params.iter() - .find(|tv| tv.tag == key) - .map(|tv| tv.value.clone()) - .unwrap_or_default() + let get = |key: &str| -> Option { + params.iter().find(|tv| tv.tag == key).map(|tv| tv.value.clone()) }; - let get_f64 = |key: &str| -> f64 { get(key).parse().unwrap_or(0.0) }; - let get_bool = |key: &str| -> bool { - let v = get(key); - v == "1" || v.eq_ignore_ascii_case("true") + let get_str = |key: &str| -> String { get(key).unwrap_or_default() }; + let get_f64 = |key: &str| -> Result { + let raw = match get(key) { + None => return Ok(0.0), + Some(raw) => raw, + }; + let v: f64 = raw.parse().map_err(|_| format!("Invalid {} '{}': expected a number", key, raw))?; + if !v.is_finite() { + return Err(format!("Invalid {} '{}': must be a finite number", key, raw)); + } + Ok(v) + }; + let get_bool = |key: &str| -> Result { + let raw = match get(key) { + None => return Ok(false), + Some(raw) => raw, + }; + match raw.to_lowercase().as_str() { + "0" | "false" => Ok(false), + "1" | "true" => Ok(true), + _ => Err(format!("Invalid {} '{}': expected true/false or 1/0", key, raw)), + } }; match strategy.to_lowercase().as_str() { "vwap" => Ok(AlgoParams::Vwap { - max_pct_vol: get_f64("maxPctVol"), - no_take_liq: get_bool("noTakeLiq"), - allow_past_end_time: get_bool("allowPastEndTime"), - start_time: get("startTime"), - end_time: get("endTime"), + max_pct_vol: get_f64("maxPctVol")?, + no_take_liq: get_bool("noTakeLiq")?, + allow_past_end_time: get_bool("allowPastEndTime")?, + start_time: get_str("startTime"), + end_time: get_str("endTime"), }), "twap" => Ok(AlgoParams::Twap { - allow_past_end_time: get_bool("allowPastEndTime"), - start_time: get("startTime"), - end_time: get("endTime"), + allow_past_end_time: get_bool("allowPastEndTime")?, + start_time: get_str("startTime"), + end_time: get_str("endTime"), }), - "arrivalpx" | "arrival_price" => { - let risk = match get("riskAversion").to_lowercase().as_str() { - "get_done" | "getdone" => RiskAversion::GetDone, - "aggressive" => RiskAversion::Aggressive, - "passive" => RiskAversion::Passive, - _ => RiskAversion::Neutral, - }; - Ok(AlgoParams::ArrivalPx { - max_pct_vol: get_f64("maxPctVol"), - risk_aversion: risk, - allow_past_end_time: get_bool("allowPastEndTime"), - force_completion: get_bool("forceCompletion"), - start_time: get("startTime"), - end_time: get("endTime"), - }) - } - "closepx" | "close_price" => { - let risk = match get("riskAversion").to_lowercase().as_str() { - "get_done" | "getdone" => RiskAversion::GetDone, - "aggressive" => RiskAversion::Aggressive, - "passive" => RiskAversion::Passive, - _ => RiskAversion::Neutral, + "arrivalpx" | "arrival_price" => Ok(AlgoParams::ArrivalPx { + max_pct_vol: get_f64("maxPctVol")?, + risk_aversion: parse_risk_aversion(get("riskAversion").as_deref())?, + allow_past_end_time: get_bool("allowPastEndTime")?, + force_completion: get_bool("forceCompletion")?, + start_time: get_str("startTime"), + end_time: get_str("endTime"), + }), + "closepx" | "close_price" => Ok(AlgoParams::ClosePx { + max_pct_vol: get_f64("maxPctVol")?, + risk_aversion: parse_risk_aversion(get("riskAversion").as_deref())?, + force_completion: get_bool("forceCompletion")?, + start_time: get_str("startTime"), + }), + "darkice" | "dark_ice" => { + let display_size = match get("displaySize") { + None => 100, + Some(raw) => raw.parse().map_err(|_| format!("Invalid displaySize '{}': expected a non-negative integer", raw))?, }; - Ok(AlgoParams::ClosePx { - max_pct_vol: get_f64("maxPctVol"), - risk_aversion: risk, - force_completion: get_bool("forceCompletion"), - start_time: get("startTime"), + Ok(AlgoParams::DarkIce { + allow_past_end_time: get_bool("allowPastEndTime")?, + display_size, + start_time: get_str("startTime"), + end_time: get_str("endTime"), }) } - "darkice" | "dark_ice" => Ok(AlgoParams::DarkIce { - allow_past_end_time: get_bool("allowPastEndTime"), - display_size: get("displaySize").parse().unwrap_or(100), - start_time: get("startTime"), - end_time: get("endTime"), - }), "pctvol" | "pct_vol" => Ok(AlgoParams::PctVol { - pct_vol: get_f64("pctVol"), - no_take_liq: get_bool("noTakeLiq"), - start_time: get("startTime"), - end_time: get("endTime"), + pct_vol: get_f64("pctVol")?, + no_take_liq: get_bool("noTakeLiq")?, + start_time: get_str("startTime"), + end_time: get_str("endTime"), }), _ => Err(format!("Unsupported algo strategy: '{}'", strategy)), } } + +/// Parse a `riskAversion` tag value (used by ArrivalPx and ClosePx). A +/// missing tag defaults to Neutral, matching IB's own algo default; a +/// present value — including an empty string — that isn't a recognized +/// member is refused rather than silently defaulting to Neutral. See ibx#263. +fn parse_risk_aversion(raw: Option<&str>) -> Result { + let raw = match raw { + None => return Ok(RiskAversion::Neutral), + Some(raw) => raw, + }; + match raw.to_lowercase().as_str() { + "neutral" => Ok(RiskAversion::Neutral), + "get_done" | "getdone" => Ok(RiskAversion::GetDone), + "aggressive" => Ok(RiskAversion::Aggressive), + "passive" => Ok(RiskAversion::Passive), + _ => Err(format!( + "Unknown riskAversion '{}': expected Get_Done, Aggressive, Neutral or Passive", raw + )), + } +} diff --git a/src/api/client/tests.rs b/src/api/client/tests.rs index 98f2fe81..eaae814b 100644 --- a/src/api/client/tests.rs +++ b/src/api/client/tests.rs @@ -106,6 +106,110 @@ fn parse_algo_unsupported() { assert!(parse_algo_params("unknown", &[]).is_err()); } +// ── ibx#263: malformed / non-finite algo params must be rejected, not +// silently coerced into a valid-looking default ── + +#[test] +fn parse_algo_vwap_rejects_malformed_max_pct_vol() { + let params = vec![TagValue { tag: "maxPctVol".into(), value: "abc".into() }]; + let err = parse_algo_params("vwap", ¶ms).unwrap_err(); + assert!(err.contains("maxPctVol"), "got: {}", err); +} + +#[test] +fn parse_algo_vwap_rejects_nan_max_pct_vol() { + let params = vec![TagValue { tag: "maxPctVol".into(), value: "NaN".into() }]; + let err = parse_algo_params("vwap", ¶ms).unwrap_err(); + assert!(err.contains("maxPctVol"), "got: {}", err); +} + +#[test] +fn parse_algo_vwap_rejects_infinite_max_pct_vol() { + let params = vec![TagValue { tag: "maxPctVol".into(), value: "inf".into() }]; + let err = parse_algo_params("vwap", ¶ms).unwrap_err(); + assert!(err.contains("maxPctVol"), "got: {}", err); +} + +#[test] +fn parse_algo_vwap_rejects_malformed_bool() { + let params = vec![TagValue { tag: "noTakeLiq".into(), value: "yes".into() }]; + let err = parse_algo_params("vwap", ¶ms).unwrap_err(); + assert!(err.contains("noTakeLiq"), "got: {}", err); +} + +#[test] +fn parse_algo_vwap_rejects_empty_max_pct_vol() { + // A present-but-empty value is a caller who set the tag, not one who + // never set it — it must be refused like any other malformed value, + // not silently coerced into the "absent" default of 0.0. + let params = vec![TagValue { tag: "maxPctVol".into(), value: "".into() }]; + let err = parse_algo_params("vwap", ¶ms).unwrap_err(); + assert!(err.contains("maxPctVol"), "got: {}", err); +} + +#[test] +fn parse_algo_vwap_rejects_empty_bool() { + let params = vec![TagValue { tag: "noTakeLiq".into(), value: "".into() }]; + let err = parse_algo_params("vwap", ¶ms).unwrap_err(); + assert!(err.contains("noTakeLiq"), "got: {}", err); +} + +#[test] +fn parse_algo_arrival_price_rejects_unknown_risk_aversion() { + // The issue's own repro: a typo must be refused, not silently sent as Neutral. + let params = vec![TagValue { tag: "riskAversion".into(), value: "Aggresive".into() }]; + let err = parse_algo_params("arrivalpx", ¶ms).unwrap_err(); + assert!(err.contains("riskAversion"), "got: {}", err); +} + +#[test] +fn parse_algo_arrival_price_defaults_risk_aversion_when_absent() { + let algo = parse_algo_params("arrivalpx", &[]).unwrap(); + match algo { + AlgoParams::ArrivalPx { risk_aversion, .. } => assert!(matches!(risk_aversion, RiskAversion::Neutral)), + _ => panic!("wrong variant"), + } +} + +#[test] +fn parse_algo_arrival_price_rejects_empty_risk_aversion() { + // Present-but-empty is not the same as absent: only a tag the caller + // never set may default to Neutral. + let params = vec![TagValue { tag: "riskAversion".into(), value: "".into() }]; + let err = parse_algo_params("arrivalpx", ¶ms).unwrap_err(); + assert!(err.contains("riskAversion"), "got: {}", err); +} + +#[test] +fn parse_algo_dark_ice_rejects_malformed_display_size() { + let params = vec![TagValue { tag: "displaySize".into(), value: "abc".into() }]; + let err = parse_algo_params("darkice", ¶ms).unwrap_err(); + assert!(err.contains("displaySize"), "got: {}", err); +} + +#[test] +fn parse_algo_dark_ice_rejects_negative_display_size() { + let params = vec![TagValue { tag: "displaySize".into(), value: "-5".into() }]; + let err = parse_algo_params("darkice", ¶ms).unwrap_err(); + assert!(err.contains("displaySize"), "got: {}", err); +} + +#[test] +fn parse_algo_dark_ice_defaults_display_size_when_absent() { + let algo = parse_algo_params("darkice", &[]).unwrap(); + match algo { + AlgoParams::DarkIce { display_size, .. } => assert_eq!(display_size, 100), + _ => panic!("wrong variant"), + } +} + +#[test] +fn parse_algo_dark_ice_rejects_empty_display_size() { + let params = vec![TagValue { tag: "displaySize".into(), value: "".into() }]; + let err = parse_algo_params("darkice", ¶ms).unwrap_err(); + assert!(err.contains("displaySize"), "got: {}", err); +} + // ═══════════════════════════════════════════════════════════════════ // Connection // ═══════════════════════════════════════════════════════════════════ @@ -1180,6 +1284,221 @@ fn lit_order_with_zero_aux_price_is_rejected() { assert!(result.unwrap_err().contains("aux_price")); } +// ═══════════════════════════════════════════════════════════════════ +// Order validation — non-finite / out-of-range numerics (issue #263) +// ═══════════════════════════════════════════════════════════════════ + +#[test] +fn place_order_rejects_nan_lmt_price() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: f64::NAN, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("lmt_price"), "got: {}", err); +} + +#[test] +fn place_order_rejects_infinite_lmt_price() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: f64::INFINITY, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("lmt_price"), "got: {}", err); +} + +#[test] +fn place_order_rejects_lmt_price_that_overflows_the_wire() { + // Finite, but scaling by PRICE_SCALE_F (1e8) overflows the wire's i64 — + // the old code let this saturate to i64::MAX instead of refusing it. + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 1.0e12, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("lmt_price"), "got: {}", err); +} + +#[test] +fn place_order_rejects_lmt_price_at_the_exact_wire_boundary() { + // `i64::MAX as f64` rounds up to 2^63, so this value scales back to + // exactly 2^63 in `require_finite_price` — a `>` comparison against + // that rounded boundary let it through and the cast saturated to + // i64::MAX instead of refusing it. + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: i64::MAX as f64 / PRICE_SCALE_F, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("lmt_price"), "got: {}", err); +} + +#[test] +fn place_order_rejects_nan_aux_price() { + // NaN != 0.0, so the pre-existing "aux_price required" check (which only + // compares against == 0.0) never catches this on its own. + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "SELL".into(), total_quantity: 100.0, order_type: "STP".into(), + aux_price: f64::NAN, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("aux_price"), "got: {}", err); +} + +#[test] +fn place_order_rejects_negative_quantity() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: -100.0, order_type: "MKT".into(), ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("total_quantity"), "got: {}", err); +} + +#[test] +fn place_order_rejects_nan_quantity() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: f64::NAN, order_type: "MKT".into(), ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("total_quantity"), "got: {}", err); +} + +#[test] +fn place_order_rejects_infinite_quantity() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: f64::INFINITY, order_type: "MKT".into(), ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("total_quantity"), "got: {}", err); +} + +#[test] +fn place_order_rejects_negative_display_size() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 150.0, display_size: -5, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("display_size"), "got: {}", err); +} + +#[test] +fn place_order_rejects_negative_min_qty() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 150.0, min_qty: -5, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("min_qty"), "got: {}", err); +} + +#[test] +fn place_order_rejects_negative_parent_id() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 150.0, parent_id: -5, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("parent_id"), "got: {}", err); +} + +#[test] +fn place_order_rejects_negative_trailing_percent() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "SELL".into(), total_quantity: 100.0, order_type: "TRAIL".into(), + trailing_percent: -5.0, ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("trailing_percent"), "got: {}", err); +} + +#[test] +fn place_order_adaptive_rejects_unknown_priority() { + let (client, _rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 150.0, algo_strategy: "Adaptive".into(), + algo_params: vec![TagValue { tag: "adaptivePriority".into(), value: "Aggressive".into() }], + ..Default::default() + }; + let err = client.place_order(1, &spy(), &order).unwrap_err(); + assert!(err.contains("adaptivePriority"), "got: {}", err); +} + +#[test] +fn place_order_adaptive_defaults_priority_when_absent() { + let (client, rx, shared) = test_client(); + shared.market.set_instrument_count(1); + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 150.0, algo_strategy: "Adaptive".into(), ..Default::default() + }; + client.place_order(1, &spy(), &order).unwrap(); + match rx.try_recv().unwrap() { + ControlCommand::Order(OrderRequest::SubmitAdaptive { priority, .. }) => { + assert_eq!(priority, crate::types::AdaptivePriority::Normal); + } + cmd => panic!("expected SubmitAdaptive, got {:?}", cmd), + } +} + +// place_order validates before building: the two tests above go through +// place_order, so either function's check alone makes them pass and neither +// pins down which one is doing the rejecting. validate_order is also the +// only check an order Modify (place_order on an already-tracked order_id) +// runs, since that path never calls build_order_request. These call each +// function directly to prove its own guard independently of the other. + +#[test] +fn validate_order_adaptive_rejects_unknown_priority() { + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 150.0, algo_strategy: "Adaptive".into(), + algo_params: vec![TagValue { tag: "adaptivePriority".into(), value: "Aggressive".into() }], + ..Default::default() + }; + let err = crate::client_core::ClientCore::validate_order(&order).unwrap_err(); + assert!(err.contains("adaptivePriority"), "got: {}", err); +} + +#[test] +fn build_order_request_adaptive_rejects_unknown_priority() { + let order = Order { + action: "BUY".into(), total_quantity: 100.0, order_type: "LMT".into(), + lmt_price: 150.0, algo_strategy: "Adaptive".into(), + algo_params: vec![TagValue { tag: "adaptivePriority".into(), value: "Aggressive".into() }], + ..Default::default() + }; + let err = crate::client_core::ClientCore::build_order_request(&order, 1, 0).unwrap_err(); + assert!(err.contains("adaptivePriority"), "got: {}", err); +} + // ═══════════════════════════════════════════════════════════════════ // Historical data requests // ═══════════════════════════════════════════════════════════════════ diff --git a/src/client_core.rs b/src/client_core.rs index 3aaeb264..3e23df91 100644 --- a/src/client_core.rs +++ b/src/client_core.rs @@ -14,7 +14,7 @@ use crossbeam_channel::Sender; use crate::api::types::{ Contract as ApiContract, CommissionAndFeesReport as ApiCommissionAndFeesReport, Execution as ApiExecution, ExecutionFilter, - Order as ApiOrder, + Order as ApiOrder, TagValue, PRICE_SCALE_F, }; use crate::bridge::SharedState; @@ -277,6 +277,43 @@ pub fn order_status_str(status: OrderStatus) -> &'static str { } } +// ── Order field validation (ibx#263) ── + +/// Reject a price/amount field that a saturating float-to-int cast would +/// otherwise turn into a different, valid-looking number: NaN becomes 0, +/// +/-Infinity becomes i64::MAX/MIN, and a finite value whose fixed-point +/// form overflows i64 saturates the same way. +fn require_finite_price(field: &str, v: f64) -> Result<(), String> { + // `i64::MAX as f64` itself rounds up to 2^63 (f64 cannot represent + // i64::MAX exactly), so a strict `>` lets a scaled value of exactly + // 2^63 through and the subsequent `as i64` cast saturates to i64::MAX + // instead of being refused. `>=` excludes that boundary. + if !v.is_finite() || (v * PRICE_SCALE_F).abs() >= i64::MAX as f64 { + return Err(format!( + "{} must be a finite number representable on the wire, got {}", + field, v + )); + } + Ok(()) +} + +/// Parse the Adaptive algo's `adaptivePriority` tag. A missing tag defaults +/// to Normal (IB's own default); a present-but-unrecognized value is +/// refused instead of silently defaulting to Normal. See ibx#263. +fn adaptive_priority(params: &[TagValue]) -> Result { + match params.iter().find(|tv| tv.tag == "adaptivePriority") { + None => Ok(AdaptivePriority::Normal), + Some(tv) => match tv.value.as_str() { + "Patient" => Ok(AdaptivePriority::Patient), + "Normal" => Ok(AdaptivePriority::Normal), + "Urgent" => Ok(AdaptivePriority::Urgent), + other => Err(format!( + "Unknown adaptivePriority '{}': expected Patient, Normal or Urgent", other + )), + }, + } +} + // ── Execution storage ── /// A stored execution + commission_and_fees pair for `req_executions` replay. @@ -1266,6 +1303,55 @@ impl ClientCore { pub fn validate_order(order: &ApiOrder) -> Result<(), String> { order.side()?; + // Reject non-finite and out-of-range numerics up front, before any + // caller-visible order gets built from a NaN, an Infinity, or a + // magnitude the wire's fixed-point i64 can't hold. See ibx#263. + require_finite_price("lmt_price", order.lmt_price)?; + require_finite_price("aux_price", order.aux_price)?; + require_finite_price("discretionary_amt", order.discretionary_amt)?; + require_finite_price("cash_qty", order.cash_qty)?; + require_finite_price("trigger_price", order.trigger_price)?; + require_finite_price("adjusted_stop_price", order.adjusted_stop_price)?; + require_finite_price("adjusted_stop_limit_price", order.adjusted_stop_limit_price)?; + // f64::MAX is the sentinel for "not set" on these three; any other + // value must be finite and representable. + if order.trail_stop_price != f64::MAX { + require_finite_price("trail_stop_price", order.trail_stop_price)?; + } + if order.lmt_price_offset != f64::MAX { + require_finite_price("lmt_price_offset", order.lmt_price_offset)?; + } + if order.adjusted_trailing_amount != f64::MAX { + require_finite_price("adjusted_trailing_amount", order.adjusted_trailing_amount)?; + } + if !order.trailing_percent.is_finite() + || order.trailing_percent < 0.0 + || order.trailing_percent * 100.0 > u32::MAX as f64 + { + return Err(format!( + "trailing_percent must be a finite, non-negative number, got {}", + order.trailing_percent + )); + } + if !order.total_quantity.is_finite() + || order.total_quantity < 0.0 + || order.total_quantity > u32::MAX as f64 + { + return Err(format!( + "total_quantity must be a finite number between 0 and {}, got {}", + u32::MAX, order.total_quantity + )); + } + if order.display_size < 0 { + return Err(format!("display_size must not be negative, got {}", order.display_size)); + } + if order.min_qty < 0 { + return Err(format!("min_qty must not be negative, got {}", order.min_qty)); + } + if order.parent_id < 0 { + return Err(format!("parent_id must not be negative, got {}", order.parent_id)); + } + // transmit=false cannot be honoured: every order is sent to the // broker immediately when place_order is called; there is no // staging concept. Accepting it would send a "staged" bracket @@ -1305,6 +1391,7 @@ impl ClientCore { } if order.algo_strategy.eq_ignore_ascii_case("Adaptive") { + adaptive_priority(&order.algo_params)?; return Ok(()); } if !order.algo_strategy.is_empty() { @@ -1417,15 +1504,7 @@ impl ClientCore { // Adaptive orders (special-cased before generic algo) if order.algo_strategy.eq_ignore_ascii_case("Adaptive") { let price = (order.lmt_price * PRICE_SCALE_F) as i64; - let priority_str = order.algo_params.iter() - .find(|tv| tv.tag == "adaptivePriority") - .map(|tv| tv.value.as_str()) - .unwrap_or("Normal"); - let priority = match priority_str { - "Patient" => AdaptivePriority::Patient, - "Urgent" => AdaptivePriority::Urgent, - _ => AdaptivePriority::Normal, - }; + let priority = adaptive_priority(&order.algo_params)?; return Ok(ControlCommand::Order(OrderRequest::SubmitAdaptive { order_id, instrument, side, qty, price, priority, })); @@ -1525,6 +1604,12 @@ impl ClientCore { // Optional initial stop trigger (tag 6117); default f64::MAX = unset. let trail_stop = if order.trail_stop_price == f64::MAX { 0 } else { (order.trail_stop_price * PRICE_SCALE_F) as i64 }; if order.trailing_percent > 0.0 { + // Wire granularity is basis points (2 decimal places): a + // trailing_percent with finer precision than that, e.g. + // 1.239, truncates to 1.23. validate_order has already + // confirmed the value is finite, non-negative and fits + // u32 once scaled; this is a documented rounding, not a + // coercion. See ibx#263. let pct = (order.trailing_percent * 100.0) as u32; if extended { OrderRequest::SubmitTrailingStopPctEx {