From e13d883a96c6373d3bee1f7e9039240eb1e7c867 Mon Sep 17 00:00:00 2001 From: userFRM Date: Fri, 31 Jul 2026 13:53:57 +0200 Subject: [PATCH] orders: reject non-finite, out-of-range, and malformed order parameters (ibx#263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_algo_params read every algo key through a lookup that returned an empty string when a tag was absent, then parsed with unwrap_or fallbacks: a malformed numeric became 0.0, a malformed boolean became false, an unrecognized riskAversion became Neutral, and a malformed DarkIce displaySize became 100. The Adaptive branch in client_core.rs parsed adaptivePriority the same way. Because "NaN" and "inf" parse successfully as f64, a caller who passed either string as an algo parameter got a silently substituted value instead of an error, and a typo like riskAversion="Aggresive" submitted a Neutral arrival-price algo with no diagnostic. validate_order never checked any numeric order field for finiteness or range before the wire encoding cast it with as i64 or as u32. Rust's float to int cast saturates instead of panicking, so a NaN limit price encoded as 0, an infinite one as i64::MAX, and a negative total_quantity, display_size, min_qty, parent_id, or trailing_percent got clamped or reinterpreted instead of refused. None of those paths distinguished a malformed value from a legitimate one. parse_algo_params and the new parse_risk_aversion now return Err once the caller has actually supplied a value that fails to parse, is non-finite, or names an unrecognized enum member, keeping the existing default only for a key that was never set at all — a key present with an empty value is refused the same as any other value that fails to parse, not treated as absent. validate_order gained a set of checks, run once before any algo, adaptive, or what-if branch, that reject non-finite or out-of-range values for every price and amount field and negative values for the fields cast to an unsigned wire type. A shared require_finite_price helper also rejects a finite but oversized magnitude that would overflow the fixed-point i64 on the wire, reachable well within double precision given a PRICE_SCALE of 1e8: i64::MAX itself rounds up to 2^63 once represented as f64, so the check excludes that rounded value too, not just magnitudes strictly above it, or the largest representable price would pass the check and then saturate on the cast that follows. adaptive_priority centralizes the same fail-fast behavior for the Adaptive algo's priority tag and is called from both validate_order and build_order_request, so a bad value is caught before the instrument is registered. trailing_percent's basis-point wire granularity is documented at the truncating cast rather than changed, since a value between two representable basis points is a rounding rather than a coercion into a different value. Closes #263. --- src/api/client/orders.rs | 140 ++++++++++------- src/api/client/tests.rs | 319 +++++++++++++++++++++++++++++++++++++++ src/client_core.rs | 105 +++++++++++-- 3 files changed, 500 insertions(+), 64 deletions(-) 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 {