mirror of
https://github.com/floor-licker/polyfill-rs.git
synced 2026-07-27 20:47:46 +00:00
fix: require exact decimal price conversion
This commit is contained in:
+20
-7
@@ -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
@@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user