use rust_decimal::Decimal;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::RwLock;
pub fn safe_price(price: f64, tick_size: &str) -> String {
let d = Decimal::from_str(&price.to_string()).unwrap_or_default();
let tick = Decimal::from_str(tick_size).unwrap_or(Decimal::ONE);
if tick.is_zero() {
return d.normalize().to_string();
}
let steps = (d / tick).round();
let rounded = steps * tick;
rounded.normalize().to_string()
}
pub fn safe_qty(qty: f64, step_size: &str) -> String {
let d = Decimal::from_str(&qty.to_string()).unwrap_or_default();
let step = Decimal::from_str(step_size).unwrap_or(Decimal::ONE);
if step.is_zero() {
return d.normalize().to_string();
}
let steps = (d / step).floor();
let rounded = steps * step;
rounded.normalize().to_string()
}
pub fn format_price(price: f64, tick_size: &str) -> String {
let d = Decimal::from_str(&price.to_string()).unwrap_or_default();
let tick = Decimal::from_str(tick_size).unwrap_or(Decimal::ONE);
if tick.is_zero() {
return d.normalize().to_string();
}
let decimals = decimal_places(tick_size);
let steps = (d / tick).round();
let rounded = steps * tick;
format!("{:.prec$}", rounded, prec = decimals)
}
pub fn format_qty(qty: f64, step_size: &str) -> String {
let d = Decimal::from_str(&qty.to_string()).unwrap_or_default();
let step = Decimal::from_str(step_size).unwrap_or(Decimal::ONE);
if step.is_zero() {
return d.normalize().to_string();
}
let decimals = decimal_places(step_size);
let steps = (d / step).floor();
let rounded = steps * step;
format!("{:.prec$}", rounded, prec = decimals)
}
fn decimal_places(s: &str) -> usize {
s.find('.').map(|dot| s.len() - dot - 1).unwrap_or(0)
}
fn precision_to_tick(digits: u8) -> String {
if digits == 0 {
return "1".to_string();
}
let mut s = "0.".to_string();
for _ in 0..(digits - 1) {
s.push('0');
}
s.push('1');
s
}
#[derive(Clone, Debug)]
pub struct PrecisionInfo {
pub tick_size: String,
pub step_size: String,
}
pub struct PrecisionCache {
cache: RwLock<HashMap<String, PrecisionInfo>>,
}
impl PrecisionCache {
pub fn new() -> Self {
Self {
cache: RwLock::new(HashMap::new()),
}
}
pub fn load_from_symbols(&self, symbols: &[crate::core::types::SymbolInfo]) {
let mut cache = self.cache.write().unwrap();
for s in symbols {
let tick = match s.tick_size {
Some(t) if t > 0.0 => t.to_string(),
_ => precision_to_tick(s.price_precision),
};
let step = match s.step_size {
Some(t) if t > 0.0 => t.to_string(),
_ => precision_to_tick(s.quantity_precision),
};
cache.insert(s.symbol.clone(), PrecisionInfo {
tick_size: tick,
step_size: step,
});
}
}
pub fn price(&self, symbol: &str, price: f64) -> String {
if let Some(info) = self.cache.read().unwrap().get(symbol) {
safe_price(price, &info.tick_size)
} else {
price.to_string()
}
}
pub fn qty(&self, symbol: &str, qty: f64) -> String {
if let Some(info) = self.cache.read().unwrap().get(symbol) {
safe_qty(qty, &info.step_size)
} else {
qty.to_string()
}
}
pub fn formatted_price(&self, symbol: &str, price: f64) -> String {
if let Some(info) = self.cache.read().unwrap().get(symbol) {
format_price(price, &info.tick_size)
} else {
price.to_string()
}
}
pub fn formatted_qty(&self, symbol: &str, qty: f64) -> String {
if let Some(info) = self.cache.read().unwrap().get(symbol) {
format_qty(qty, &info.step_size)
} else {
qty.to_string()
}
}
pub fn has_symbol(&self, symbol: &str) -> bool {
self.cache.read().unwrap().contains_key(symbol)
}
pub fn len(&self) -> usize {
self.cache.read().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.cache.read().unwrap().is_empty()
}
}
impl Default for PrecisionCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_price_basic() {
assert_eq!(safe_price(100.05, "0.01"), "100.05");
assert_eq!(safe_price(50000.0, "0.01"), "50000");
assert_eq!(safe_price(1.23456, "0.001"), "1.235");
}
#[test]
fn test_safe_price_sub_tick_drift() {
assert_eq!(safe_price(100.05, "0.01"), "100.05");
assert_eq!(safe_price(0.1 + 0.2, "0.01"), "0.3");
}
#[test]
fn test_safe_price_round_nearest() {
assert_eq!(safe_price(100.054, "0.01"), "100.05");
assert_eq!(safe_price(100.055, "0.01"), "100.06"); assert_eq!(safe_price(100.056, "0.01"), "100.06");
}
#[test]
fn test_safe_price_btc() {
assert_eq!(safe_price(67543.25, "0.01"), "67543.25");
assert_eq!(safe_price(67543.251, "0.01"), "67543.25");
assert_eq!(safe_price(67543.255, "0.01"), "67543.26");
}
#[test]
fn test_safe_price_large_tick() {
assert_eq!(safe_price(100.3, "0.5"), "100.5");
assert_eq!(safe_price(100.2, "0.5"), "100");
assert_eq!(safe_price(100.0, "1"), "100");
assert_eq!(safe_price(100.6, "1"), "101");
}
#[test]
fn test_safe_qty_basic() {
assert_eq!(safe_qty(1.999, "0.01"), "1.99"); assert_eq!(safe_qty(0.12345, "0.001"), "0.123");
}
#[test]
fn test_safe_qty_never_exceed() {
assert_eq!(safe_qty(0.999, "0.01"), "0.99");
assert_eq!(safe_qty(1.0, "0.01"), "1");
assert_eq!(safe_qty(0.12399, "0.001"), "0.123");
}
#[test]
fn test_safe_qty_btc() {
assert_eq!(safe_qty(0.00012345, "0.00001"), "0.00012");
assert_eq!(safe_qty(1.5, "0.001"), "1.5");
}
#[test]
fn test_format_price_trailing_zeros() {
assert_eq!(format_price(100.5, "0.01"), "100.50");
assert_eq!(format_price(100.0, "0.01"), "100.00");
assert_eq!(format_price(67543.2, "0.01"), "67543.20");
}
#[test]
fn test_format_qty_trailing_zeros() {
assert_eq!(format_qty(1.5, "0.001"), "1.500");
assert_eq!(format_qty(0.1, "0.00001"), "0.10000");
}
#[test]
fn test_zero_price() {
assert_eq!(safe_price(0.0, "0.01"), "0");
assert_eq!(safe_qty(0.0, "0.001"), "0");
}
#[test]
fn test_very_small_values() {
assert_eq!(safe_qty(0.00000001, "0.00000001"), "0.00000001");
assert_eq!(safe_price(0.00000001, "0.00000001"), "0.00000001");
}
#[test]
fn test_precision_to_tick() {
assert_eq!(precision_to_tick(0), "1");
assert_eq!(precision_to_tick(1), "0.1");
assert_eq!(precision_to_tick(2), "0.01");
assert_eq!(precision_to_tick(4), "0.0001");
assert_eq!(precision_to_tick(8), "0.00000001");
}
#[test]
fn test_cache_fallback_before_load() {
let cache = PrecisionCache::new();
assert_eq!(cache.price("BTCUSDT", 67543.251), "67543.251");
assert_eq!(cache.qty("BTCUSDT", 0.123456), "0.123456");
assert!(cache.is_empty());
}
#[test]
fn test_cache_with_tick_step() {
let cache = PrecisionCache::new();
let symbols = vec![
crate::core::types::SymbolInfo {
symbol: "BTCUSDT".to_string(),
base_asset: "BTC".to_string(),
quote_asset: "USDT".to_string(),
status: "TRADING".to_string(),
price_precision: 2,
quantity_precision: 5,
tick_size: Some(0.01),
step_size: Some(0.00001),
min_quantity: None,
max_quantity: None,
min_notional: None,
account_type: Default::default(),
},
];
cache.load_from_symbols(&symbols);
assert_eq!(cache.len(), 1);
assert!(cache.has_symbol("BTCUSDT"));
assert_eq!(cache.price("BTCUSDT", 67543.251), "67543.25");
assert_eq!(cache.price("BTCUSDT", 67543.255), "67543.26");
assert_eq!(cache.qty("BTCUSDT", 0.123456), "0.12345");
assert_eq!(cache.qty("BTCUSDT", 0.123459), "0.12345");
assert_eq!(cache.price("UNKNOWN", 100.123), "100.123");
}
#[test]
fn test_cache_digits_fallback() {
let cache = PrecisionCache::new();
let symbols = vec![
crate::core::types::SymbolInfo {
symbol: "ETHUSDT".to_string(),
base_asset: "ETH".to_string(),
quote_asset: "USDT".to_string(),
status: "TRADING".to_string(),
price_precision: 2,
quantity_precision: 3,
tick_size: None,
step_size: None,
min_quantity: None,
max_quantity: None,
min_notional: None,
account_type: Default::default(),
},
];
cache.load_from_symbols(&symbols);
assert_eq!(cache.price("ETHUSDT", 3456.789), "3456.79");
assert_eq!(cache.qty("ETHUSDT", 1.2345), "1.234");
}
#[test]
fn test_cache_formatted_trailing_zeros() {
let cache = PrecisionCache::new();
let symbols = vec![
crate::core::types::SymbolInfo {
symbol: "BTCUSDT".to_string(),
base_asset: "BTC".to_string(),
quote_asset: "USDT".to_string(),
status: "TRADING".to_string(),
price_precision: 2,
quantity_precision: 3,
tick_size: Some(0.01),
step_size: Some(0.001),
min_quantity: None,
max_quantity: None,
min_notional: None,
account_type: Default::default(),
},
];
cache.load_from_symbols(&symbols);
assert_eq!(cache.formatted_price("BTCUSDT", 100.5), "100.50");
assert_eq!(cache.formatted_qty("BTCUSDT", 1.5), "1.500");
}
}