use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AssetClass {
#[default]
CryptoPerp,
CryptoSpot,
Fx,
Future,
Equity,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct InstrumentSpec {
pub asset_class: AssetClass,
pub contract_value: f64,
pub tick_size: f64,
pub lot_size: f64,
pub min_notional: f64,
}
impl Default for InstrumentSpec {
fn default() -> Self {
Self::spot_default()
}
}
impl InstrumentSpec {
#[must_use]
pub const fn spot_default() -> Self {
Self {
asset_class: AssetClass::CryptoSpot,
contract_value: 1.0,
tick_size: 0.0,
lot_size: 0.0,
min_notional: 0.0,
}
}
#[must_use]
pub const fn from_contract_value(contract_value: f64) -> Self {
Self {
asset_class: AssetClass::CryptoPerp,
contract_value,
tick_size: 0.0,
lot_size: 0.0,
min_notional: 0.0,
}
}
#[must_use]
pub fn round_price(&self, price: f64) -> f64 {
round_to_increment(price, self.tick_size)
}
#[must_use]
pub fn round_qty_down(&self, qty: f64) -> f64 {
if self.lot_size <= 0.0 || !self.lot_size.is_finite() {
return qty;
}
(qty / self.lot_size).floor() * self.lot_size
}
#[must_use]
pub fn meets_min_notional(&self, notional: f64) -> bool {
notional >= self.min_notional
}
}
fn round_to_increment(value: f64, increment: f64) -> f64 {
if increment <= 0.0 || !increment.is_finite() {
return value;
}
(value / increment).round() * increment
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_permissive() {
let s = InstrumentSpec::default();
assert_eq!(s.contract_value, 1.0);
assert_eq!(s.asset_class, AssetClass::CryptoSpot);
assert_eq!(s.round_price(1234.5678), 1234.5678);
assert_eq!(s.round_qty_down(3.7), 3.7);
assert!(s.meets_min_notional(0.0));
}
#[test]
fn from_contract_value_keeps_value_and_is_perp() {
let s = InstrumentSpec::from_contract_value(0.001);
assert_eq!(s.contract_value, 0.001);
assert_eq!(s.asset_class, AssetClass::CryptoPerp);
}
#[test]
fn round_price_snaps_to_tick() {
let s = InstrumentSpec {
tick_size: 0.5,
..InstrumentSpec::default()
};
assert_eq!(s.round_price(100.24), 100.0);
assert_eq!(s.round_price(100.25), 100.5); assert_eq!(s.round_price(100.74), 100.5);
assert_eq!(s.round_price(100.75), 101.0);
}
#[test]
fn round_qty_rounds_down_to_lot() {
let s = InstrumentSpec {
lot_size: 0.1,
..InstrumentSpec::default()
};
assert!((s.round_qty_down(3.79) - 3.7).abs() < 1e-9);
assert!((s.round_qty_down(3.70) - 3.7).abs() < 1e-9);
let c = InstrumentSpec {
lot_size: 1.0,
..InstrumentSpec::default()
};
assert_eq!(c.round_qty_down(4.9), 4.0);
}
#[test]
fn min_notional_gate() {
let s = InstrumentSpec {
min_notional: 10.0,
..InstrumentSpec::default()
};
assert!(!s.meets_min_notional(9.99));
assert!(s.meets_min_notional(10.0));
assert!(s.meets_min_notional(25.0));
}
#[test]
fn asset_class_serdes_snake_case() {
let json = serde_json::to_string(&AssetClass::CryptoPerp).unwrap();
assert_eq!(json, "\"crypto_perp\"");
let back: AssetClass = serde_json::from_str("\"fx\"").unwrap();
assert_eq!(back, AssetClass::Fx);
}
}