From f148ff33288ae014d3fd4a073cd130075748d6e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:56:21 +0000 Subject: [PATCH 1/2] fix(runtime): fail-closed CEX envelope size and slippage Reject missing max_slippage_bps and max_order_notional on CEX pre-execution instead of treating None as allow. Reject cross_spread at require_supported_cex_execution. LiveSmall stays disabled. Co-authored-by: Wild Card --- rust_hft/apps/live/src/deployment_envelope.rs | 9 ++-- .../apps/live/tests/deployment_envelope.rs | 5 +- .../adapters/adapter-binance/src/lib.rs | 1 + .../adapters/adapter-binance/src/usdm.rs | 38 ++++++++++---- .../adapters/adapter-bybit/src/lib.rs | 41 ++++++++++----- .../engine/src/execution_queues.rs | 2 + .../engine/src/execution_worker.rs | 1 + rust_hft/market-core/engine/src/lib.rs | 4 +- .../engine/tests/backpressure_safety_test.rs | 25 +++++++-- rust_hft/market-core/ports/src/events.rs | 52 +++++++++++++++++++ 10 files changed, 145 insertions(+), 33 deletions(-) diff --git a/rust_hft/apps/live/src/deployment_envelope.rs b/rust_hft/apps/live/src/deployment_envelope.rs index 60ff8c7cd..1135f957d 100644 --- a/rust_hft/apps/live/src/deployment_envelope.rs +++ b/rust_hft/apps/live/src/deployment_envelope.rs @@ -243,9 +243,12 @@ fn validate_instrument_catalog( } fn require_supported_cex_execution(costs: &EvaluationCostsV1) -> Result<(), String> { - // Paper uses canonical opposite-side depth for crossing orders. - // ponytail: non-zero funding needs a point-in-time runtime funding feed; admit it only after - // that feed can debit every research bucket deterministically. + // Canonical opposite-side depth is not a supported CEX Paper/Shadow execution + // contract. Non-zero funding still needs a point-in-time runtime funding feed + // that can debit every research bucket deterministically. + if costs.cross_spread { + return Err("CEX Paper/Shadow does not support cross-spread execution".to_string()); + } if costs.funding_bps != 0.0 { return Err("CEX Paper/Shadow does not support non-zero funding costs".to_string()); } diff --git a/rust_hft/apps/live/tests/deployment_envelope.rs b/rust_hft/apps/live/tests/deployment_envelope.rs index 813f7baab..903222ba8 100644 --- a/rust_hft/apps/live/tests/deployment_envelope.rs +++ b/rust_hft/apps/live/tests/deployment_envelope.rs @@ -368,8 +368,7 @@ fn signed_frozen_model_bundle_loads_native_parameters_and_preserves_limits() { .clone(), ) .unwrap(); - let mut protocol = protocol.with_independent_selection(30).unwrap(); - protocol.costs.cross_spread = true; + let protocol = protocol.with_independent_selection(30).unwrap(); let source_ref = serde_json::json!({"id": "test-source", "content_sha256": "a".repeat(64)}); let frozen: FrozenSupervisedCandidateV1 = serde_json::from_value(serde_json::json!({ "schema_version": FROZEN_SUPERVISED_CANDIDATE_SCHEMA, "artifact_id": "", @@ -446,7 +445,7 @@ fn signed_frozen_model_bundle_loads_native_parameters_and_preserves_limits() { assert_eq!(program.factors.len(), 1); assert_eq!(*max_order_notional, rust_decimal::Decimal::from(500)); assert_eq!(execution_contract.venue, hft_core::VenueId::BINANCE_FUTURES); - assert!(execution_contract.cross_spread); + assert!(!execution_contract.cross_spread); assert!(config.venues[0].simulate_execution); assert_eq!( config.engine.intent_max_order_notional, diff --git a/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs b/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs index edc09d56b..af1ae3b76 100644 --- a/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs +++ b/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs @@ -1396,6 +1396,7 @@ mod tests { let mut client = BinanceExecutionClient::new(make_test_config(ExecutionMode::Paper)); let lifecycle = ports::OrderIntentLifecycle { max_slippage_bps: Some(25), + max_order_notional: Some(rust_decimal::Decimal::from(10_000)), ..Default::default() }; let envelope = OrderIntentEnvelope::new( diff --git a/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs b/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs index 5b6b689e5..5e4c7d616 100644 --- a/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs +++ b/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs @@ -1790,6 +1790,29 @@ mod tests { } } + fn cex_bounded_envelope(intent: ports::OrderIntent) -> OrderIntentEnvelope { + let now = hft_core::now_micros(); + let mut envelope = OrderIntentEnvelope::new( + intent.clone(), + OrderIntentLifecycle { + created_ts: now, + max_slippage_bps: Some(25), + max_order_notional: Some(Decimal::from(1_000_000)), + max_latency_us: Some(60_000_000), + ..Default::default() + }, + ); + envelope.price_reference = Some(ports::ExecutionPriceReference { + venue: intent.target_venue.expect("usd-m test intent has a venue"), + symbol: intent.symbol.clone(), + side: intent.side, + price: intent.price.expect("usd-m test intent has a price"), + book_sequence: 1, + received_at: hft_core::LocalReceiveTimestamp::new(now), + }); + envelope + } + fn valid_order() -> BinanceUsdMOrder { BinanceUsdMOrder { symbol: "BTCUSDT".to_string(), @@ -1900,12 +1923,8 @@ mod tests { BinanceCredentials::new("test-key".to_string(), "test-secret".to_string()); cfg.rest_base_url = base_url; let mut client = BinanceUsdMExecutionClient::new(cfg); - let lifecycle = OrderIntentLifecycle { - reduce_only: true, - ..Default::default() - }; - let envelope = - OrderIntentEnvelope::new(perp_intent(), lifecycle).with_client_order_id("client-usdm"); + let mut envelope = cex_bounded_envelope(perp_intent()).with_client_order_id("client-usdm"); + envelope.lifecycle.reduce_only = true; let order_id = client.place_order_envelope(&envelope).await.unwrap(); assert_eq!( @@ -2102,8 +2121,7 @@ mod tests { BinanceCredentials::new("test-key".to_string(), "test-secret".to_string()); cfg.rest_base_url = base_url; let mut client = BinanceUsdMExecutionClient::new(cfg); - let envelope = OrderIntentEnvelope::new(perp_intent(), OrderIntentLifecycle::default()) - .with_client_order_id("client-usdm"); + let envelope = cex_bounded_envelope(perp_intent()).with_client_order_id("client-usdm"); let error = client.place_order_envelope(&envelope).await.unwrap_err(); assert!( @@ -2124,8 +2142,7 @@ mod tests { BinanceCredentials::new("test-key".to_string(), "test-secret".to_string()); cfg.rest_base_url = base_url; let mut client = BinanceUsdMExecutionClient::new(cfg); - let envelope = OrderIntentEnvelope::new(perp_intent(), OrderIntentLifecycle::default()) - .with_client_order_id("client-usdm"); + let envelope = cex_bounded_envelope(perp_intent()).with_client_order_id("client-usdm"); let error = client.place_order_envelope(&envelope).await.unwrap_err(); assert!(matches!(error, HftError::Network(message) if message.contains("outcome unknown"))); @@ -2320,6 +2337,7 @@ mod tests { async fn usd_m_both_envelope_entrypoints_apply_cex_gate_before_submission() { let lifecycle = OrderIntentLifecycle { max_slippage_bps: Some(25), + max_order_notional: Some(Decimal::from(10_000)), ..Default::default() }; let envelope = OrderIntentEnvelope::new(perp_intent(), lifecycle) diff --git a/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs b/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs index fabb6d830..4f55634f2 100644 --- a/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs +++ b/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs @@ -2421,20 +2421,36 @@ mod tests { #[tokio::test] async fn order_envelope_preserves_the_venue_link_id() { let mut client = BybitExecutionClient::new(make_test_config(ExecutionMode::Paper)).unwrap(); - let envelope = OrderIntentEnvelope::new( - OrderIntent::crypto_spot( - Symbol::new("BTCUSDT"), - Side::Buy, - Quantity::from_f64(0.001).unwrap(), - OrderType::Limit, - Some(Price::from_f64(50_000.0).unwrap()), - TimeInForce::GTC, - "test_strategy".to_string(), - None, - ), - OrderIntentLifecycle::default(), + let intent = OrderIntent::crypto_spot( + Symbol::new("BTCUSDT"), + Side::Buy, + Quantity::from_f64(0.001).unwrap(), + OrderType::Limit, + Some(Price::from_f64(50_000.0).unwrap()), + TimeInForce::GTC, + "test_strategy".to_string(), + Some(hft_core::VenueId::BYBIT), + ); + let now = hft_core::now_micros(); + let mut envelope = OrderIntentEnvelope::new( + intent.clone(), + OrderIntentLifecycle { + created_ts: now, + max_slippage_bps: Some(25), + max_order_notional: Some(Decimal::from(1_000_000)), + max_latency_us: Some(60_000_000), + ..Default::default() + }, ) .with_client_order_id("bybit-link-42"); + envelope.price_reference = Some(ports::ExecutionPriceReference { + venue: hft_core::VenueId::BYBIT, + symbol: intent.symbol.clone(), + side: intent.side, + price: intent.price.unwrap(), + book_sequence: 1, + received_at: hft_core::LocalReceiveTimestamp::new(now), + }); assert_eq!( client.place_order_envelope(&envelope).await.unwrap(), @@ -2447,6 +2463,7 @@ mod tests { let mut client = BybitExecutionClient::new(make_test_config(ExecutionMode::Paper)).unwrap(); let lifecycle = OrderIntentLifecycle { max_slippage_bps: Some(25), + max_order_notional: Some(Decimal::from(10_000)), ..Default::default() }; let envelope = OrderIntentEnvelope::new( diff --git a/rust_hft/market-core/engine/src/execution_queues.rs b/rust_hft/market-core/engine/src/execution_queues.rs index 52a8033e0..0db204a85 100644 --- a/rust_hft/market-core/engine/src/execution_queues.rs +++ b/rust_hft/market-core/engine/src/execution_queues.rs @@ -237,6 +237,7 @@ impl EngineQueues { self.stats.intent_max_latency_count += 1; } OrderIntentRejectReason::InvalidMaxOrderNotional { .. } + | OrderIntentRejectReason::MissingMaxOrderNotional | OrderIntentRejectReason::OrderNotionalUnpriceable { .. } | OrderIntentRejectReason::MaxOrderNotionalExceeded { .. } => { self.stats.intent_order_notional_count += 1; @@ -246,6 +247,7 @@ impl EngineQueues { self.stats.intent_order_quantity_count += 1; } OrderIntentRejectReason::InvalidMaxSlippage { .. } + | OrderIntentRejectReason::MissingMaxSlippage | OrderIntentRejectReason::MissingSlippageReference | OrderIntentRejectReason::SlippageReferenceMismatch | OrderIntentRejectReason::MissingSlippageReferenceLifetime diff --git a/rust_hft/market-core/engine/src/execution_worker.rs b/rust_hft/market-core/engine/src/execution_worker.rs index d05db0a1a..41f9e19f2 100644 --- a/rust_hft/market-core/engine/src/execution_worker.rs +++ b/rust_hft/market-core/engine/src/execution_worker.rs @@ -4547,6 +4547,7 @@ mod tests { intent.order_type = OrderType::Limit; let mut life = ports::OrderIntentLifecycle::new(now, now + 1_000_000); life.max_slippage_bps = Some(25); + life.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); let mut envelope = OrderIntentEnvelope::new(intent, life); envelope.price_reference = snapshots.load().execution_price_reference(&envelope.intent); assert!(envelope.validate_pre_execution(now, None).is_ok()); diff --git a/rust_hft/market-core/engine/src/lib.rs b/rust_hft/market-core/engine/src/lib.rs index 19228445f..f7373531d 100644 --- a/rust_hft/market-core/engine/src/lib.rs +++ b/rust_hft/market-core/engine/src/lib.rs @@ -4057,7 +4057,7 @@ mod tests { create_execution_queues(ExecutionQueueConfig::default()); engine.set_execution_queues(engine_queues); engine - .set_intent_execution_limits(Some(25), None, None) + .set_intent_execution_limits(Some(25), Some(rust_decimal::Decimal::from(10_000)), None) .unwrap(); let now = now_micros(); let mut envelope = @@ -4080,7 +4080,7 @@ mod tests { create_execution_queues(ExecutionQueueConfig::default()); engine.set_execution_queues(engine_queues); engine - .set_intent_execution_limits(Some(25), None, None) + .set_intent_execution_limits(Some(25), Some(rust_decimal::Decimal::from(10_000)), None) .unwrap(); let intent = ports::OrderIntent::prediction_market( Symbol::new("prediction-token"), diff --git a/rust_hft/market-core/engine/tests/backpressure_safety_test.rs b/rust_hft/market-core/engine/tests/backpressure_safety_test.rs index 83e7ab1aa..a5b56af57 100644 --- a/rust_hft/market-core/engine/tests/backpressure_safety_test.rs +++ b/rust_hft/market-core/engine/tests/backpressure_safety_test.rs @@ -2,9 +2,13 @@ use engine::dataflow::{BackpressurePolicy, IngestionConfig}; use engine::{create_execution_queues, ExecutionQueueConfig, LifecycleIntentSubmitError}; -use hft_core::{AccountId, OrderType, Quantity, Side, Symbol, TimeInForce}; +use hft_core::{ + AccountId, LocalReceiveTimestamp, OrderType, Price, Quantity, Side, Symbol, TimeInForce, + VenueId, +}; use ports::{ - ExecutionEvent, OrderIntent, OrderIntentEnvelope, OrderIntentLifecycle, OrderIntentRejectReason, + ExecutionEvent, ExecutionPriceReference, OrderIntent, OrderIntentEnvelope, + OrderIntentLifecycle, OrderIntentRejectReason, }; fn test_intent() -> OrderIntent { @@ -63,6 +67,7 @@ fn queued_price_protection_reloads_market_identity_and_freshness() { intent.target_venue = Some(VenueId::MOCK); let mut life = OrderIntentLifecycle::new(1_000, 2_000); life.max_slippage_bps = Some(25); + life.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); let mut envelope = OrderIntentEnvelope::new(intent, life); envelope.price_reference = snapshots.load().execution_price_reference(&envelope.intent); sender.send_lifecycle_intent(envelope, 1_100).unwrap(); @@ -288,9 +293,23 @@ fn test_current_lifecycle_intent_enters_execution_queue() { batch_size: 8, }; let (mut engine_queues, mut worker_queues) = create_execution_queues(config); + let mut intent = test_intent(); + intent.order_type = OrderType::Limit; + intent.price = Some(Price::from_f64(100.0).unwrap()); + intent.target_venue = Some(VenueId::MOCK); let mut lifecycle = OrderIntentLifecycle::new(1_000, 1_100); lifecycle.max_latency_us = Some(99); - let envelope = OrderIntentEnvelope::new(test_intent(), lifecycle); + lifecycle.max_slippage_bps = Some(25); + lifecycle.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); + let mut envelope = OrderIntentEnvelope::new(intent, lifecycle); + envelope.price_reference = Some(ExecutionPriceReference { + venue: VenueId::MOCK, + symbol: Symbol::new("BTCUSDT"), + side: Side::Buy, + price: Price::from_f64(100.0).unwrap(), + book_sequence: 1, + received_at: LocalReceiveTimestamp::new(1_000), + }); assert!(engine_queues.send_lifecycle_intent(envelope, 1_099).is_ok()); diff --git a/rust_hft/market-core/ports/src/events.rs b/rust_hft/market-core/ports/src/events.rs index 58daef92d..b45db5704 100644 --- a/rust_hft/market-core/ports/src/events.rs +++ b/rust_hft/market-core/ports/src/events.rs @@ -670,6 +670,12 @@ impl OrderIntentEnvelope { latest_book_seq: Option, ) -> Result<(), OrderIntentRejectReason> { self.validate_pre_execution(now, latest_book_seq)?; + if self.lifecycle.max_slippage_bps.is_none() { + return Err(OrderIntentRejectReason::MissingMaxSlippage); + } + if self.lifecycle.max_order_notional.is_none() { + return Err(OrderIntentRejectReason::MissingMaxOrderNotional); + } self.validate_slippage_reference(now, self.price_reference.as_ref()) } @@ -792,6 +798,7 @@ pub enum OrderIntentRejectReason { InvalidMaxSlippage { max_slippage_bps: i32, }, + MissingMaxSlippage, MissingSlippageReference, SourceBookUnavailable, SlippageReferenceMismatch, @@ -824,6 +831,7 @@ pub enum OrderIntentRejectReason { InvalidMaxOrderNotional { max_order_notional: rust_decimal::Decimal, }, + MissingMaxOrderNotional, OrderNotionalUnpriceable { max_order_notional: rust_decimal::Decimal, }, @@ -1166,6 +1174,7 @@ mod tests { fn signed_slippage_ceiling_rejects_missing_reference_before_execution() { let mut lifecycle = lifecycle(1_000, 2_000); lifecycle.max_slippage_bps = Some(25); + lifecycle.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); let envelope = OrderIntentEnvelope::new( OrderIntent::crypto_spot( Symbol::new("BTCUSDT"), @@ -1186,9 +1195,52 @@ mod tests { ); } + #[test] + fn cex_envelope_does_not_treat_missing_size_or_slippage_as_allow() { + let mut lifecycle = lifecycle(1_000, 2_000); + let envelope = OrderIntentEnvelope::new( + OrderIntent::crypto_spot( + Symbol::new("BTCUSDT"), + Side::Buy, + Quantity(rust_decimal::Decimal::ONE), + OrderType::Limit, + Some(Price(rust_decimal::Decimal::from(100))), + TimeInForce::IOC, + "unbounded".to_string(), + Some(VenueId::BINANCE_SPOT), + ), + lifecycle, + ); + assert_eq!( + envelope.validate_cex_pre_execution(1_100, None), + Err(OrderIntentRejectReason::MissingMaxSlippage) + ); + + lifecycle.max_slippage_bps = Some(25); + lifecycle.max_order_quantity = Some(rust_decimal::Decimal::from(10)); + let quantity_only = OrderIntentEnvelope::new( + OrderIntent::crypto_spot( + Symbol::new("BTCUSDT"), + Side::Buy, + Quantity(rust_decimal::Decimal::ONE), + OrderType::Limit, + Some(Price(rust_decimal::Decimal::from(100))), + TimeInForce::IOC, + "quantity-only".to_string(), + Some(VenueId::BINANCE_SPOT), + ), + lifecycle, + ); + assert_eq!( + quantity_only.validate_cex_pre_execution(1_100, None), + Err(OrderIntentRejectReason::MissingMaxOrderNotional) + ); + } + fn slippage_envelope(side: Side, price: rust_decimal::Decimal) -> OrderIntentEnvelope { let mut lifecycle = lifecycle(1_000, 2_000); lifecycle.max_slippage_bps = Some(25); + lifecycle.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); let mut envelope = OrderIntentEnvelope::new( OrderIntent::crypto_spot( Symbol::new("BTCUSDT"), From 45ac55fc35cc3e7ca61c7163c80c43430203ed96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 05:08:24 +0000 Subject: [PATCH 2/2] fix(runtime): fail-closed CEX missing order quantity Require signed max_order_quantity at the CEX pre-execution boundary so a missing quantity ceiling is not treated as allow. LiveSmall stays disabled. Co-authored-by: Wild Card --- .../adapters/adapter-binance/src/lib.rs | 1 + .../adapters/adapter-binance/src/usdm.rs | 2 ++ .../adapters/adapter-bybit/src/lib.rs | 2 ++ .../engine/src/execution_queues.rs | 1 + rust_hft/market-core/engine/src/lib.rs | 12 +++++++-- .../engine/tests/backpressure_safety_test.rs | 2 ++ rust_hft/market-core/ports/src/events.rs | 26 +++++++++++++++++++ 7 files changed, 44 insertions(+), 2 deletions(-) diff --git a/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs b/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs index af1ae3b76..67d106aec 100644 --- a/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs +++ b/rust_hft/execution-gateway/adapters/adapter-binance/src/lib.rs @@ -1397,6 +1397,7 @@ mod tests { let lifecycle = ports::OrderIntentLifecycle { max_slippage_bps: Some(25), max_order_notional: Some(rust_decimal::Decimal::from(10_000)), + max_order_quantity: Some(rust_decimal::Decimal::from(10)), ..Default::default() }; let envelope = OrderIntentEnvelope::new( diff --git a/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs b/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs index 5e4c7d616..ab4c75c79 100644 --- a/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs +++ b/rust_hft/execution-gateway/adapters/adapter-binance/src/usdm.rs @@ -1798,6 +1798,7 @@ mod tests { created_ts: now, max_slippage_bps: Some(25), max_order_notional: Some(Decimal::from(1_000_000)), + max_order_quantity: Some(Decimal::from(10)), max_latency_us: Some(60_000_000), ..Default::default() }, @@ -2338,6 +2339,7 @@ mod tests { let lifecycle = OrderIntentLifecycle { max_slippage_bps: Some(25), max_order_notional: Some(Decimal::from(10_000)), + max_order_quantity: Some(Decimal::from(10)), ..Default::default() }; let envelope = OrderIntentEnvelope::new(perp_intent(), lifecycle) diff --git a/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs b/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs index 4f55634f2..18570345e 100644 --- a/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs +++ b/rust_hft/execution-gateway/adapters/adapter-bybit/src/lib.rs @@ -2438,6 +2438,7 @@ mod tests { created_ts: now, max_slippage_bps: Some(25), max_order_notional: Some(Decimal::from(1_000_000)), + max_order_quantity: Some(Decimal::from(10)), max_latency_us: Some(60_000_000), ..Default::default() }, @@ -2464,6 +2465,7 @@ mod tests { let lifecycle = OrderIntentLifecycle { max_slippage_bps: Some(25), max_order_notional: Some(Decimal::from(10_000)), + max_order_quantity: Some(Decimal::from(10)), ..Default::default() }; let envelope = OrderIntentEnvelope::new( diff --git a/rust_hft/market-core/engine/src/execution_queues.rs b/rust_hft/market-core/engine/src/execution_queues.rs index 0db204a85..8d37741bd 100644 --- a/rust_hft/market-core/engine/src/execution_queues.rs +++ b/rust_hft/market-core/engine/src/execution_queues.rs @@ -243,6 +243,7 @@ impl EngineQueues { self.stats.intent_order_notional_count += 1; } OrderIntentRejectReason::InvalidMaxOrderQuantity { .. } + | OrderIntentRejectReason::MissingMaxOrderQuantity | OrderIntentRejectReason::MaxOrderQuantityExceeded { .. } => { self.stats.intent_order_quantity_count += 1; } diff --git a/rust_hft/market-core/engine/src/lib.rs b/rust_hft/market-core/engine/src/lib.rs index f7373531d..b650a0141 100644 --- a/rust_hft/market-core/engine/src/lib.rs +++ b/rust_hft/market-core/engine/src/lib.rs @@ -4057,7 +4057,11 @@ mod tests { create_execution_queues(ExecutionQueueConfig::default()); engine.set_execution_queues(engine_queues); engine - .set_intent_execution_limits(Some(25), Some(rust_decimal::Decimal::from(10_000)), None) + .set_intent_execution_limits( + Some(25), + Some(rust_decimal::Decimal::from(10_000)), + Some(rust_decimal::Decimal::from(10)), + ) .unwrap(); let now = now_micros(); let mut envelope = @@ -4080,7 +4084,11 @@ mod tests { create_execution_queues(ExecutionQueueConfig::default()); engine.set_execution_queues(engine_queues); engine - .set_intent_execution_limits(Some(25), Some(rust_decimal::Decimal::from(10_000)), None) + .set_intent_execution_limits( + Some(25), + Some(rust_decimal::Decimal::from(10_000)), + Some(rust_decimal::Decimal::from(10)), + ) .unwrap(); let intent = ports::OrderIntent::prediction_market( Symbol::new("prediction-token"), diff --git a/rust_hft/market-core/engine/tests/backpressure_safety_test.rs b/rust_hft/market-core/engine/tests/backpressure_safety_test.rs index a5b56af57..2ad9378f0 100644 --- a/rust_hft/market-core/engine/tests/backpressure_safety_test.rs +++ b/rust_hft/market-core/engine/tests/backpressure_safety_test.rs @@ -68,6 +68,7 @@ fn queued_price_protection_reloads_market_identity_and_freshness() { let mut life = OrderIntentLifecycle::new(1_000, 2_000); life.max_slippage_bps = Some(25); life.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); + life.max_order_quantity = Some(rust_decimal::Decimal::from(10)); let mut envelope = OrderIntentEnvelope::new(intent, life); envelope.price_reference = snapshots.load().execution_price_reference(&envelope.intent); sender.send_lifecycle_intent(envelope, 1_100).unwrap(); @@ -301,6 +302,7 @@ fn test_current_lifecycle_intent_enters_execution_queue() { lifecycle.max_latency_us = Some(99); lifecycle.max_slippage_bps = Some(25); lifecycle.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); + lifecycle.max_order_quantity = Some(rust_decimal::Decimal::from(10)); let mut envelope = OrderIntentEnvelope::new(intent, lifecycle); envelope.price_reference = Some(ExecutionPriceReference { venue: VenueId::MOCK, diff --git a/rust_hft/market-core/ports/src/events.rs b/rust_hft/market-core/ports/src/events.rs index b45db5704..0c4f7378a 100644 --- a/rust_hft/market-core/ports/src/events.rs +++ b/rust_hft/market-core/ports/src/events.rs @@ -676,6 +676,9 @@ impl OrderIntentEnvelope { if self.lifecycle.max_order_notional.is_none() { return Err(OrderIntentRejectReason::MissingMaxOrderNotional); } + if self.lifecycle.max_order_quantity.is_none() { + return Err(OrderIntentRejectReason::MissingMaxOrderQuantity); + } self.validate_slippage_reference(now, self.price_reference.as_ref()) } @@ -842,6 +845,7 @@ pub enum OrderIntentRejectReason { InvalidMaxOrderQuantity { max_order_quantity: rust_decimal::Decimal, }, + MissingMaxOrderQuantity, MaxOrderQuantityExceeded { order_quantity: rust_decimal::Decimal, max_order_quantity: rust_decimal::Decimal, @@ -1175,6 +1179,7 @@ mod tests { let mut lifecycle = lifecycle(1_000, 2_000); lifecycle.max_slippage_bps = Some(25); lifecycle.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); + lifecycle.max_order_quantity = Some(rust_decimal::Decimal::from(10)); let envelope = OrderIntentEnvelope::new( OrderIntent::crypto_spot( Symbol::new("BTCUSDT"), @@ -1235,12 +1240,33 @@ mod tests { quantity_only.validate_cex_pre_execution(1_100, None), Err(OrderIntentRejectReason::MissingMaxOrderNotional) ); + + lifecycle.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); + lifecycle.max_order_quantity = None; + let notional_only = OrderIntentEnvelope::new( + OrderIntent::crypto_spot( + Symbol::new("BTCUSDT"), + Side::Buy, + Quantity(rust_decimal::Decimal::ONE), + OrderType::Limit, + Some(Price(rust_decimal::Decimal::from(100))), + TimeInForce::IOC, + "notional-only".to_string(), + Some(VenueId::BINANCE_SPOT), + ), + lifecycle, + ); + assert_eq!( + notional_only.validate_cex_pre_execution(1_100, None), + Err(OrderIntentRejectReason::MissingMaxOrderQuantity) + ); } fn slippage_envelope(side: Side, price: rust_decimal::Decimal) -> OrderIntentEnvelope { let mut lifecycle = lifecycle(1_000, 2_000); lifecycle.max_slippage_bps = Some(25); lifecycle.max_order_notional = Some(rust_decimal::Decimal::from(10_000)); + lifecycle.max_order_quantity = Some(rust_decimal::Decimal::from(10)); let mut envelope = OrderIntentEnvelope::new( OrderIntent::crypto_spot( Symbol::new("BTCUSDT"),