From 672a8215732d7b4ebba47e780cb7bcafe970f57e Mon Sep 17 00:00:00 2001 From: floor-licker Date: Mon, 22 Jun 2026 20:50:05 -0400 Subject: [PATCH] fix: require exact decimal price conversion --- src/book.rs | 27 +++++++++--- src/types.rs | 120 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/book.rs b/src/book.rs index 952e257..501a1e5 100644 --- a/src/book.rs +++ b/src/book.rs @@ -265,7 +265,7 @@ impl OrderBook { /// This tells us the minimum price increment allowed /// We store it in ticks for fast validation without conversion overhead pub fn set_tick_size(&mut self, tick_size: Decimal) -> Result<()> { - let tick_size_ticks = decimal_to_price(tick_size) + let tick_size_ticks = decimal_to_price_exact(tick_size) .map_err(|_| PolyfillError::validation("Invalid tick size"))?; self.tick_size_ticks = Some(tick_size_ticks); Ok(()) @@ -655,7 +655,7 @@ impl OrderBook { #[allow(dead_code)] fn apply_bid_delta(&mut self, price: Decimal, size: Decimal) { // Convert to fixed-point (this should be rare since we use fast path) - let price_ticks = decimal_to_price(price).unwrap_or(0); + let price_ticks = decimal_to_price_lossy(price).unwrap_or(0); let size_units = decimal_to_qty(size).unwrap_or(0); self.apply_bid_delta_fast(price_ticks, size_units); } @@ -667,7 +667,7 @@ impl OrderBook { #[allow(dead_code)] fn apply_ask_delta(&mut self, price: Decimal, size: Decimal) { // Convert to fixed-point (this should be rare since we use fast path) - let price_ticks = decimal_to_price(price).unwrap_or(0); + let price_ticks = decimal_to_price_lossy(price).unwrap_or(0); let size_units = decimal_to_qty(size).unwrap_or(0); self.apply_ask_delta_fast(price_ticks, size_units); } @@ -735,7 +735,7 @@ impl OrderBook { #[inline] fn parse_snapshot_summary(&self, side: Side, level: &OrderSummary) -> Result { - let price_ticks = decimal_to_price(level.price) + let price_ticks = decimal_to_price_exact(level.price) .map_err(|_| PolyfillError::validation("Invalid price"))?; let size_units = decimal_to_qty(level.size).map_err(|_| PolyfillError::validation("Invalid size"))?; @@ -872,7 +872,7 @@ impl OrderBook { /// Tells you how much you can buy/sell at exactly this price pub fn liquidity_at_price(&self, price: Decimal, side: Side) -> Decimal { // Convert decimal price to our internal fixed-point representation - let price_ticks = match decimal_to_price(price) { + let price_ticks = match decimal_to_price_exact(price) { Ok(ticks) => ticks, Err(_) => return Decimal::ZERO, // Invalid price }; @@ -908,11 +908,11 @@ impl OrderBook { side: Side, ) -> Decimal { // Convert decimal prices to our internal fixed-point representation - let min_price_ticks = match decimal_to_price(min_price) { + let min_price_ticks = match decimal_to_price_exact(min_price) { Ok(ticks) => ticks, Err(_) => return Decimal::ZERO, // Invalid price }; - let max_price_ticks = match decimal_to_price(max_price) { + let max_price_ticks = match decimal_to_price_exact(max_price) { Ok(ticks) => ticks, Err(_) => return Decimal::ZERO, // Invalid price }; @@ -1235,6 +1235,19 @@ mod tests { assert_eq!(book.asks.len(), 0); // Should start empty } + #[test] + fn test_set_tick_size_requires_exact_fixed_point_value() { + let mut book = OrderBook::new("test_token".to_string(), 10); + + book.set_tick_size(dec!(0.0001)).unwrap(); + + let err = book.set_tick_size(dec!(0.00005)).unwrap_err(); + assert!(err.to_string().contains("Invalid tick size")); + + let err = book.set_tick_size(Decimal::ZERO).unwrap_err(); + assert!(err.to_string().contains("Invalid tick size")); + } + #[test] fn test_order_book_manager_routes_tokens_to_shards() { let shard_count = 4; diff --git a/src/types.rs b/src/types.rs index b0f5887..27d9abd 100644 --- a/src/types.rs +++ b/src/types.rs @@ -79,17 +79,51 @@ pub const MAX_QTY: Qty = Qty::MAX / 2; // Leave room for intermediate calculatio // and our internal fixed-point representation. They're designed to be fast // and handle edge cases gracefully. -/// Convert a Decimal price to fixed-point ticks +/// Convert a Decimal price to fixed-point ticks exactly. /// -/// This is called when we receive price data from the API or user input. -/// We quantize the price to the nearest tick to ensure all prices are -/// aligned to our internal representation. +/// This rejects values that cannot be represented exactly at our 4-decimal +/// fixed-point scale. Use this for validation and market-data ingress. /// /// Examples: /// - decimal_to_price(Decimal::from_str("0.6543")) = Ok(6543) /// - decimal_to_price(Decimal::from_str("1.0000")) = Ok(10000) -/// - decimal_to_price(Decimal::from_str("0.00005")) = Ok(1) // Rounds up to min tick +/// - decimal_to_price(Decimal::from_str("0.00005")) = Err(...) pub fn decimal_to_price(decimal: Decimal) -> std::result::Result { + decimal_to_price_exact(decimal) +} + +/// Convert a Decimal price to fixed-point ticks exactly. +/// +/// This rejects fractional ticks instead of rounding and rejects values outside +/// the valid price range instead of clamping. +pub fn decimal_to_price_exact(decimal: Decimal) -> std::result::Result { + let scaled = decimal * Decimal::from(SCALE_FACTOR); + + let ticks = scaled + .to_u32() + .ok_or("Price too large, negative, or fractional")?; + + if Decimal::from(ticks) != scaled { + return Err("Price is not exactly representable at 4 decimal places"); + } + if ticks < MIN_PRICE_TICKS { + return Err("Price below minimum"); + } + + Ok(ticks) +} + +/// Convert a Decimal price to fixed-point ticks with rounding and clamping. +/// +/// This is appropriate for UI/ergonomic input paths where quantizing to the +/// nearest internal tick is desired. Do not use it for validation or market-data +/// ingress. +/// +/// Examples: +/// - decimal_to_price_lossy(Decimal::from_str("0.65434")) = Ok(6543) +/// - decimal_to_price_lossy(Decimal::from_str("0.65435")) = Ok(6544) +/// - decimal_to_price_lossy(Decimal::from_str("0.00005")) = Ok(1) +pub fn decimal_to_price_lossy(decimal: Decimal) -> std::result::Result { // Convert to fixed-point by multiplying by scale factor let scaled = decimal * Decimal::from(SCALE_FACTOR); @@ -113,7 +147,7 @@ pub fn decimal_to_price(decimal: Decimal) -> std::result::Result Decimal { /// converts cleanly to our internal representation. pub fn is_price_tick_aligned(decimal: Decimal, tick_size_decimal: Decimal) -> bool { // Convert tick size to our internal representation - let tick_size_ticks = match decimal_to_price(tick_size_decimal) { + let tick_size_ticks = match decimal_to_price_exact(tick_size_decimal) { Ok(ticks) => ticks, Err(_) => return false, }; // Convert the price to ticks - let price_ticks = match decimal_to_price(decimal) { + let price_ticks = match decimal_to_price_exact(decimal) { Ok(ticks) => ticks, Err(_) => return false, }; @@ -304,7 +338,7 @@ impl FastBookLevel { /// Create from external BookLevel (with validation) /// This is called when we receive data from the API pub fn from_book_level(level: &BookLevel) -> std::result::Result { - let price = decimal_to_price(level.price)?; + let price = decimal_to_price_exact(level.price)?; let size = decimal_to_qty(level.size)?; Ok(Self::new(price, size)) } @@ -390,7 +424,7 @@ impl FastOrderDelta { } // Convert to fixed-point with validation - let price = decimal_to_price(delta.price)?; + let price = decimal_to_price_exact(delta.price)?; let size = decimal_to_qty(delta.size)?; // Hash the token_id for fast lookups @@ -1823,3 +1857,69 @@ pub type Result = std::result::Result; // Type aliases for 100% compatibility with baseline implementation pub type ApiCreds = ApiCredentials; + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn decimal_to_price_exact_accepts_representable_prices() { + assert_eq!( + decimal_to_price_exact(Decimal::from_str("0.6543").unwrap()).unwrap(), + 6543 + ); + assert_eq!( + decimal_to_price_exact(Decimal::from_str("1.0000").unwrap()).unwrap(), + 10_000 + ); + assert_eq!( + decimal_to_price(Decimal::from_str("0.0001").unwrap()).unwrap(), + MIN_PRICE_TICKS + ); + } + + #[test] + fn decimal_to_price_exact_rejects_fractional_or_clamped_prices() { + assert!(decimal_to_price_exact(Decimal::from_str("0.00005").unwrap()).is_err()); + assert!(decimal_to_price_exact(Decimal::from_str("0.00009").unwrap()).is_err()); + assert!(decimal_to_price_exact(Decimal::ZERO).is_err()); + assert!(decimal_to_price_exact(Decimal::from_str("-0.01").unwrap()).is_err()); + } + + #[test] + fn decimal_to_price_lossy_preserves_rounding_and_clamping_behavior() { + assert_eq!( + decimal_to_price_lossy(Decimal::from_str("0.65434").unwrap()).unwrap(), + 6543 + ); + assert_eq!( + decimal_to_price_lossy(Decimal::from_str("0.65435").unwrap()).unwrap(), + 6544 + ); + assert_eq!( + decimal_to_price_lossy(Decimal::from_str("0.00005").unwrap()).unwrap(), + MIN_PRICE_TICKS + ); + } + + #[test] + fn price_tick_alignment_uses_exact_conversion() { + assert!(is_price_tick_aligned( + Decimal::from_str("0.5100").unwrap(), + Decimal::from_str("0.0100").unwrap() + )); + assert!(!is_price_tick_aligned( + Decimal::from_str("0.5150").unwrap(), + Decimal::from_str("0.0100").unwrap() + )); + assert!(!is_price_tick_aligned( + Decimal::from_str("0.51005").unwrap(), + Decimal::from_str("0.0100").unwrap() + )); + assert!(!is_price_tick_aligned( + Decimal::from_str("0.5100").unwrap(), + Decimal::from_str("0.00005").unwrap() + )); + } +}