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..67d106aec 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,8 @@ 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)), + 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 5b6b689e5..ab4c75c79 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,30 @@ 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_order_quantity: Some(Decimal::from(10)), + 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 +1924,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 +2122,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 +2143,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 +2338,8 @@ 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)), + 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 fabb6d830..18570345e 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,37 @@ 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_order_quantity: Some(Decimal::from(10)), + 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 +2464,8 @@ 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)), + 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 52a8033e0..8d37741bd 100644 --- a/rust_hft/market-core/engine/src/execution_queues.rs +++ b/rust_hft/market-core/engine/src/execution_queues.rs @@ -237,15 +237,18 @@ impl EngineQueues { self.stats.intent_max_latency_count += 1; } OrderIntentRejectReason::InvalidMaxOrderNotional { .. } + | OrderIntentRejectReason::MissingMaxOrderNotional | OrderIntentRejectReason::OrderNotionalUnpriceable { .. } | OrderIntentRejectReason::MaxOrderNotionalExceeded { .. } => { self.stats.intent_order_notional_count += 1; } OrderIntentRejectReason::InvalidMaxOrderQuantity { .. } + | OrderIntentRejectReason::MissingMaxOrderQuantity | OrderIntentRejectReason::MaxOrderQuantityExceeded { .. } => { 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..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), None, 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), None, 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 83e7ab1aa..2ad9378f0 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,8 @@ 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)); + 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(); @@ -288,9 +294,24 @@ 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)); + 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, + 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..0c4f7378a 100644 --- a/rust_hft/market-core/ports/src/events.rs +++ b/rust_hft/market-core/ports/src/events.rs @@ -670,6 +670,15 @@ 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); + } + if self.lifecycle.max_order_quantity.is_none() { + return Err(OrderIntentRejectReason::MissingMaxOrderQuantity); + } self.validate_slippage_reference(now, self.price_reference.as_ref()) } @@ -792,6 +801,7 @@ pub enum OrderIntentRejectReason { InvalidMaxSlippage { max_slippage_bps: i32, }, + MissingMaxSlippage, MissingSlippageReference, SourceBookUnavailable, SlippageReferenceMismatch, @@ -824,6 +834,7 @@ pub enum OrderIntentRejectReason { InvalidMaxOrderNotional { max_order_notional: rust_decimal::Decimal, }, + MissingMaxOrderNotional, OrderNotionalUnpriceable { max_order_notional: rust_decimal::Decimal, }, @@ -834,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, @@ -1166,6 +1178,8 @@ 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)); + lifecycle.max_order_quantity = Some(rust_decimal::Decimal::from(10)); let envelope = OrderIntentEnvelope::new( OrderIntent::crypto_spot( Symbol::new("BTCUSDT"), @@ -1186,9 +1200,73 @@ 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) + ); + + 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"),