Merge pull request #66 from floor-licker/fix/exact-decimal-price-conversion

fix: require exact decimal price conversion
This commit is contained in:
floor-licker
2026-06-22 20:57:52 -04:00
committed by GitHub
2 changed files with 130 additions and 17 deletions
+20 -7
View File
@@ -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<ParsedBookLevel> {
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;
+110 -10
View File
@@ -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<Price, &'static str> {
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<Price, &'static str> {
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<Price, &'static str> {
// 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<Price, &'static
/// Convert fixed-point ticks back to Decimal price
///
/// This is called when we need to return price data to the API or display to users.
/// It's the inverse of decimal_to_price().
/// It's the inverse of decimal_to_price_exact().
///
/// Examples:
/// - price_to_decimal(6543) = Decimal::from_str("0.6543")
@@ -163,13 +197,13 @@ pub fn qty_to_decimal(units: Qty) -> 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<Self, &'static str> {
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<T> = std::result::Result<T, crate::errors::PolyfillError>;
// 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()
));
}
}