use crate::HlError;
use rust_decimal::Decimal;
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
Buy,
Sell,
}
impl Side {
pub fn is_buy(self) -> bool {
matches!(self, Side::Buy)
}
pub fn from_is_buy(is_buy: bool) -> Self {
if is_buy {
Side::Buy
} else {
Side::Sell
}
}
}
impl fmt::Display for Side {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Side::Buy => write!(f, "buy"),
Side::Sell => write!(f, "sell"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Tif {
Gtc,
Ioc,
Alo,
}
impl fmt::Display for Tif {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Tif::Gtc => write!(f, "Gtc"),
Tif::Ioc => write!(f, "Ioc"),
Tif::Alo => write!(f, "Alo"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tpsl {
Sl,
Tp,
}
impl fmt::Display for Tpsl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Tpsl::Sl => write!(f, "sl"),
Tpsl::Tp => write!(f, "tp"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PositionSide {
Long,
Short,
}
impl fmt::Display for PositionSide {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PositionSide::Long => write!(f, "long"),
PositionSide::Short => write!(f, "short"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OrderStatus {
Filled,
Partial,
Open,
Rejected,
TriggerSl,
TriggerTp,
}
impl fmt::Display for OrderStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OrderStatus::Filled => write!(f, "filled"),
OrderStatus::Partial => write!(f, "partial"),
OrderStatus::Open => write!(f, "open"),
OrderStatus::Rejected => write!(f, "rejected"),
OrderStatus::TriggerSl => write!(f, "trigger_sl"),
OrderStatus::TriggerTp => write!(f, "trigger_tp"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OrderWire {
pub asset: u32,
pub is_buy: bool,
pub limit_px: String,
pub sz: String,
pub reduce_only: bool,
pub order_type: OrderTypeWire,
#[serde(skip_serializing_if = "Option::is_none")]
pub cloid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct OrderWireBuilder {
asset: u32,
is_buy: bool,
limit_px: String,
sz: String,
reduce_only: bool,
order_type: OrderTypeWire,
cloid: Option<String>,
}
impl OrderWireBuilder {
pub fn tif(mut self, tif: Tif) -> Self {
if let OrderTypeWire::Limit(ref mut limit) = self.order_type {
limit.tif = tif;
}
self
}
pub fn cloid(mut self, cloid: impl Into<String>) -> Self {
self.cloid = Some(cloid.into());
self
}
pub fn reduce_only(mut self, reduce_only: bool) -> Self {
self.reduce_only = reduce_only;
self
}
pub fn build(self) -> Result<OrderWire, HlError> {
let px: Decimal = self
.limit_px
.parse()
.map_err(|_| HlError::Parse(format!("invalid price: {}", self.limit_px)))?;
if px <= Decimal::ZERO {
return Err(HlError::Parse(format!(
"price must be positive, got: {}",
self.limit_px
)));
}
let sz: Decimal = self
.sz
.parse()
.map_err(|_| HlError::Parse(format!("invalid size: {}", self.sz)))?;
if sz <= Decimal::ZERO {
return Err(HlError::Parse(format!(
"size must be positive, got: {}",
self.sz
)));
}
Ok(OrderWire {
asset: self.asset,
is_buy: self.is_buy,
limit_px: self.limit_px,
sz: self.sz,
reduce_only: self.reduce_only,
order_type: self.order_type,
cloid: self.cloid,
})
}
}
impl OrderWire {
pub fn limit_buy(asset: u32, limit_px: Decimal, sz: Decimal) -> OrderWireBuilder {
OrderWireBuilder {
asset,
is_buy: true,
limit_px: limit_px.normalize().to_string(),
sz: sz.normalize().to_string(),
reduce_only: false,
order_type: OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc }),
cloid: None,
}
}
pub fn limit_sell(asset: u32, limit_px: Decimal, sz: Decimal) -> OrderWireBuilder {
OrderWireBuilder {
asset,
is_buy: false,
limit_px: limit_px.normalize().to_string(),
sz: sz.normalize().to_string(),
reduce_only: false,
order_type: OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc }),
cloid: None,
}
}
pub fn trigger_buy(
asset: u32,
trigger_px: Decimal,
sz: Decimal,
tpsl: Tpsl,
) -> OrderWireBuilder {
let trigger_px_str = trigger_px.normalize().to_string();
OrderWireBuilder {
asset,
is_buy: true,
limit_px: trigger_px_str.clone(),
sz: sz.normalize().to_string(),
reduce_only: true,
order_type: OrderTypeWire::Trigger(TriggerOrderType {
trigger_px: trigger_px_str,
is_market: true,
tpsl,
}),
cloid: None,
}
}
pub fn trigger_sell(
asset: u32,
trigger_px: Decimal,
sz: Decimal,
tpsl: Tpsl,
) -> OrderWireBuilder {
let trigger_px_str = trigger_px.normalize().to_string();
OrderWireBuilder {
asset,
is_buy: false,
limit_px: trigger_px_str.clone(),
sz: sz.normalize().to_string(),
reduce_only: true,
order_type: OrderTypeWire::Trigger(TriggerOrderType {
trigger_px: trigger_px_str,
is_market: true,
tpsl,
}),
cloid: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum OrderTypeWire {
Limit(LimitOrderType),
Trigger(TriggerOrderType),
}
impl OrderTypeWire {
pub fn is_limit(&self) -> bool {
matches!(self, OrderTypeWire::Limit(_))
}
pub fn is_trigger(&self) -> bool {
matches!(self, OrderTypeWire::Trigger(_))
}
}
impl Serialize for OrderTypeWire {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(1))?;
match self {
OrderTypeWire::Limit(limit) => {
map.serialize_entry("limit", limit)?;
}
OrderTypeWire::Trigger(trigger) => {
map.serialize_entry("trigger", trigger)?;
}
}
map.end()
}
}
impl<'de> Deserialize<'de> for OrderTypeWire {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct OrderTypeWireVisitor;
impl<'de> Visitor<'de> for OrderTypeWireVisitor {
type Value = OrderTypeWire;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a map with either a \"limit\" or \"trigger\" key")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let key: String = map
.next_key()?
.ok_or_else(|| de::Error::custom("empty order type object"))?;
match key.as_str() {
"limit" => {
let limit: LimitOrderType = map.next_value()?;
Ok(OrderTypeWire::Limit(limit))
}
"trigger" => {
let trigger: TriggerOrderType = map.next_value()?;
Ok(OrderTypeWire::Trigger(trigger))
}
other => Err(de::Error::unknown_field(other, &["limit", "trigger"])),
}
}
}
deserializer.deserialize_map(OrderTypeWireVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LimitOrderType {
pub tif: Tif,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TriggerOrderType {
pub trigger_px: String,
pub is_market: bool,
pub tpsl: Tpsl,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CancelRequest {
pub asset: u32,
pub oid: u64,
}
impl CancelRequest {
pub fn new(asset: u32, oid: u64) -> Self {
Self { asset, oid }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CancelByCloidRequest {
pub asset: u32,
pub cloid: String,
}
impl CancelByCloidRequest {
pub fn new(asset: u32, cloid: impl Into<String>) -> Self {
Self {
asset,
cloid: cloid.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ModifyRequest {
pub oid: u64,
pub order: OrderWire,
}
impl ModifyRequest {
pub fn new(oid: u64, order: OrderWire) -> Self {
Self { oid, order }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn order_type_wire_limit_serialization() {
let ot = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc });
let json = serde_json::to_string(&ot).unwrap();
assert_eq!(json, r#"{"limit":{"tif":"Gtc"}}"#);
}
#[test]
fn order_type_wire_trigger_serialization() {
let ot = OrderTypeWire::Trigger(TriggerOrderType {
trigger_px: "99.0".into(),
is_market: true,
tpsl: Tpsl::Sl,
});
let json = serde_json::to_string(&ot).unwrap();
assert_eq!(
json,
r#"{"trigger":{"triggerPx":"99.0","isMarket":true,"tpsl":"sl"}}"#
);
}
#[test]
fn order_type_wire_limit_roundtrip() {
let original = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Ioc });
let json = serde_json::to_string(&original).unwrap();
let parsed: OrderTypeWire = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn order_type_wire_trigger_roundtrip() {
let original = OrderTypeWire::Trigger(TriggerOrderType {
trigger_px: "50.5".into(),
is_market: false,
tpsl: Tpsl::Tp,
});
let json = serde_json::to_string(&original).unwrap();
let parsed: OrderTypeWire = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn order_type_wire_is_limit_and_is_trigger() {
let limit = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc });
assert!(limit.is_limit());
assert!(!limit.is_trigger());
let trigger = OrderTypeWire::Trigger(TriggerOrderType {
trigger_px: "1.0".into(),
is_market: true,
tpsl: Tpsl::Sl,
});
assert!(trigger.is_trigger());
assert!(!trigger.is_limit());
}
#[test]
fn order_type_wire_invalid_key_fails() {
let json = r#"{"unknown":{"tif":"Gtc"}}"#;
assert!(serde_json::from_str::<OrderTypeWire>(json).is_err());
}
#[test]
fn order_type_wire_empty_object_fails() {
let json = r#"{}"#;
assert!(serde_json::from_str::<OrderTypeWire>(json).is_err());
}
#[test]
fn builder_limit_buy_defaults() {
let order =
OrderWire::limit_buy(1, Decimal::from(90000), Decimal::from_str("0.001").unwrap())
.build()
.unwrap();
assert_eq!(order.asset, 1);
assert!(order.is_buy);
assert_eq!(order.limit_px, "90000");
assert_eq!(order.sz, "0.001");
assert!(!order.reduce_only);
assert!(order.order_type.is_limit());
assert!(order.cloid.is_none());
if let OrderTypeWire::Limit(ref l) = order.order_type {
assert_eq!(l.tif, Tif::Gtc);
}
}
#[test]
fn builder_limit_sell_with_options() {
let order = OrderWire::limit_sell(5, Decimal::from(3000), Decimal::from(2))
.tif(Tif::Ioc)
.cloid("my-order-1")
.reduce_only(true)
.build()
.unwrap();
assert_eq!(order.asset, 5);
assert!(!order.is_buy);
assert_eq!(order.limit_px, "3000");
assert_eq!(order.sz, "2");
assert!(order.reduce_only);
assert_eq!(order.cloid.as_deref(), Some("my-order-1"));
if let OrderTypeWire::Limit(ref l) = order.order_type {
assert_eq!(l.tif, Tif::Ioc);
} else {
panic!("expected limit order type");
}
}
#[test]
fn builder_trigger_buy() {
let order = OrderWire::trigger_buy(0, Decimal::from(99), Decimal::from(10), Tpsl::Sl)
.cloid("trigger-1")
.build()
.unwrap();
assert_eq!(order.asset, 0);
assert!(order.is_buy);
assert!(order.reduce_only);
assert!(order.order_type.is_trigger());
if let OrderTypeWire::Trigger(ref t) = order.order_type {
assert_eq!(t.trigger_px, "99");
assert!(t.is_market);
assert_eq!(t.tpsl, Tpsl::Sl);
} else {
panic!("expected trigger order type");
}
}
#[test]
fn builder_trigger_sell() {
let order = OrderWire::trigger_sell(2, Decimal::from(150), Decimal::from(5), Tpsl::Tp)
.reduce_only(false)
.build()
.unwrap();
assert_eq!(order.asset, 2);
assert!(!order.is_buy);
assert!(!order.reduce_only); assert!(order.order_type.is_trigger());
if let OrderTypeWire::Trigger(ref t) = order.order_type {
assert_eq!(t.trigger_px, "150");
assert_eq!(t.tpsl, Tpsl::Tp);
} else {
panic!("expected trigger order type");
}
}
#[test]
fn builder_tif_noop_on_trigger() {
let order = OrderWire::trigger_buy(0, Decimal::from(99), Decimal::ONE, Tpsl::Sl)
.tif(Tif::Ioc)
.build()
.unwrap();
assert!(order.order_type.is_trigger());
}
#[test]
fn build_validates_positive_price() {
let result = OrderWire::limit_buy(0, Decimal::ZERO, Decimal::ONE).build();
assert!(result.is_err());
}
#[test]
fn build_validates_positive_size() {
let result = OrderWire::limit_buy(0, Decimal::ONE, Decimal::ZERO).build();
assert!(result.is_err());
}
#[test]
fn build_validates_negative_price() {
let result = OrderWire::limit_buy(0, Decimal::from(-1), Decimal::ONE).build();
assert!(result.is_err());
}
#[test]
fn build_validates_negative_size() {
let result = OrderWire::limit_buy(0, Decimal::ONE, Decimal::from(-1)).build();
assert!(result.is_err());
}
#[test]
fn build_success() {
let result =
OrderWire::limit_buy(0, Decimal::from(90000), Decimal::from_str("0.001").unwrap())
.build();
assert!(result.is_ok());
}
#[test]
fn side_from_is_buy() {
assert_eq!(Side::from_is_buy(true), Side::Buy);
assert_eq!(Side::from_is_buy(false), Side::Sell);
}
#[test]
fn order_wire_limit_serde_roundtrip() {
let order =
OrderWire::limit_buy(1, Decimal::from(50000), Decimal::from_str("0.1").unwrap())
.cloid("test-cloid")
.build()
.unwrap();
let json = serde_json::to_string(&order).unwrap();
let parsed: OrderWire = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.asset, 1);
assert!(parsed.is_buy);
assert_eq!(parsed.limit_px, "50000");
assert_eq!(parsed.sz, "0.1");
assert!(!parsed.reduce_only);
assert_eq!(parsed.cloid.as_deref(), Some("test-cloid"));
assert!(parsed.order_type.is_limit());
}
#[test]
fn order_wire_trigger_serde_roundtrip() {
let order = OrderWire::trigger_buy(0, Decimal::from(100), Decimal::from(10), Tpsl::Tp)
.build()
.unwrap();
let json = serde_json::to_string(&order).unwrap();
let parsed: OrderWire = serde_json::from_str(&json).unwrap();
let trigger = match parsed.order_type {
OrderTypeWire::Trigger(t) => t,
_ => panic!("expected trigger"),
};
assert_eq!(trigger.trigger_px, "100");
assert!(trigger.is_market);
assert_eq!(trigger.tpsl, Tpsl::Tp);
}
#[test]
fn order_wire_camel_case_serialization() {
let order = OrderWire::limit_buy(0, Decimal::ONE, Decimal::ONE)
.build()
.unwrap();
let json = serde_json::to_string(&order).unwrap();
assert!(json.contains("isBuy"));
assert!(json.contains("limitPx"));
assert!(json.contains("reduceOnly"));
assert!(json.contains("orderType"));
assert!(!json.contains("cloid"));
}
#[test]
fn order_wire_with_cloid_roundtrip() {
let order =
OrderWire::limit_sell(5, Decimal::from_str("3000.5").unwrap(), Decimal::from(2))
.reduce_only(true)
.cloid("my-order-123")
.build()
.unwrap();
let json = serde_json::to_string(&order).unwrap();
let parsed: OrderWire = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.cloid.as_deref(), Some("my-order-123"));
assert!(parsed.reduce_only);
assert!(!parsed.is_buy);
}
#[test]
fn wire_format_limit_matches_hyperliquid() {
let ot = OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc });
let json = serde_json::to_value(&ot).unwrap();
assert!(json.get("limit").is_some());
assert_eq!(json["limit"]["tif"], "Gtc");
}
#[test]
fn wire_format_trigger_matches_hyperliquid() {
let ot = OrderTypeWire::Trigger(TriggerOrderType {
trigger_px: "99.0".into(),
is_market: true,
tpsl: Tpsl::Sl,
});
let json = serde_json::to_value(&ot).unwrap();
assert!(json.get("trigger").is_some());
assert_eq!(json["trigger"]["triggerPx"], "99.0");
assert_eq!(json["trigger"]["isMarket"], true);
assert_eq!(json["trigger"]["tpsl"], "sl");
}
#[test]
fn deserialize_from_hyperliquid_limit_json() {
let json = r#"{"limit":{"tif":"Gtc"}}"#;
let ot: OrderTypeWire = serde_json::from_str(json).unwrap();
assert_eq!(ot, OrderTypeWire::Limit(LimitOrderType { tif: Tif::Gtc }));
}
#[test]
fn deserialize_from_hyperliquid_trigger_json() {
let json = r#"{"trigger":{"triggerPx":"99.0","isMarket":true,"tpsl":"sl"}}"#;
let ot: OrderTypeWire = serde_json::from_str(json).unwrap();
assert_eq!(
ot,
OrderTypeWire::Trigger(TriggerOrderType {
trigger_px: "99.0".into(),
is_market: true,
tpsl: Tpsl::Sl,
})
);
}
#[test]
fn tif_serde_wire_format() {
assert_eq!(serde_json::to_string(&Tif::Gtc).unwrap(), "\"Gtc\"");
assert_eq!(serde_json::to_string(&Tif::Ioc).unwrap(), "\"Ioc\"");
assert_eq!(serde_json::to_string(&Tif::Alo).unwrap(), "\"Alo\"");
assert_eq!(serde_json::from_str::<Tif>("\"Gtc\"").unwrap(), Tif::Gtc);
assert_eq!(serde_json::from_str::<Tif>("\"Ioc\"").unwrap(), Tif::Ioc);
assert_eq!(serde_json::from_str::<Tif>("\"Alo\"").unwrap(), Tif::Alo);
}
#[test]
fn tpsl_serde_wire_format() {
assert_eq!(serde_json::to_string(&Tpsl::Sl).unwrap(), "\"sl\"");
assert_eq!(serde_json::to_string(&Tpsl::Tp).unwrap(), "\"tp\"");
assert_eq!(serde_json::from_str::<Tpsl>("\"sl\"").unwrap(), Tpsl::Sl);
assert_eq!(serde_json::from_str::<Tpsl>("\"tp\"").unwrap(), Tpsl::Tp);
}
#[test]
fn side_serde_wire_format() {
assert_eq!(serde_json::to_string(&Side::Buy).unwrap(), "\"buy\"");
assert_eq!(serde_json::to_string(&Side::Sell).unwrap(), "\"sell\"");
assert_eq!(serde_json::from_str::<Side>("\"buy\"").unwrap(), Side::Buy);
assert_eq!(
serde_json::from_str::<Side>("\"sell\"").unwrap(),
Side::Sell
);
}
#[test]
fn side_is_buy() {
assert!(Side::Buy.is_buy());
assert!(!Side::Sell.is_buy());
}
#[test]
fn position_side_serde_wire_format() {
assert_eq!(
serde_json::to_string(&PositionSide::Long).unwrap(),
"\"long\""
);
assert_eq!(
serde_json::to_string(&PositionSide::Short).unwrap(),
"\"short\""
);
assert_eq!(
serde_json::from_str::<PositionSide>("\"long\"").unwrap(),
PositionSide::Long
);
assert_eq!(
serde_json::from_str::<PositionSide>("\"short\"").unwrap(),
PositionSide::Short
);
}
#[test]
fn order_status_serde_wire_format() {
assert_eq!(
serde_json::to_string(&OrderStatus::Filled).unwrap(),
"\"filled\""
);
assert_eq!(
serde_json::to_string(&OrderStatus::Partial).unwrap(),
"\"partial\""
);
assert_eq!(
serde_json::to_string(&OrderStatus::Open).unwrap(),
"\"open\""
);
assert_eq!(
serde_json::to_string(&OrderStatus::TriggerSl).unwrap(),
"\"trigger_sl\""
);
assert_eq!(
serde_json::to_string(&OrderStatus::TriggerTp).unwrap(),
"\"trigger_tp\""
);
assert_eq!(
serde_json::from_str::<OrderStatus>("\"filled\"").unwrap(),
OrderStatus::Filled
);
assert_eq!(
serde_json::from_str::<OrderStatus>("\"trigger_sl\"").unwrap(),
OrderStatus::TriggerSl
);
}
#[test]
fn display_impls() {
assert_eq!(Side::Buy.to_string(), "buy");
assert_eq!(Side::Sell.to_string(), "sell");
assert_eq!(Tif::Gtc.to_string(), "Gtc");
assert_eq!(Tpsl::Sl.to_string(), "sl");
assert_eq!(Tpsl::Tp.to_string(), "tp");
assert_eq!(PositionSide::Long.to_string(), "long");
assert_eq!(PositionSide::Short.to_string(), "short");
assert_eq!(OrderStatus::Filled.to_string(), "filled");
assert_eq!(OrderStatus::TriggerSl.to_string(), "trigger_sl");
}
#[test]
fn invalid_side_deserialization_fails() {
assert!(serde_json::from_str::<Side>("\"BUY\"").is_err());
assert!(serde_json::from_str::<Side>("\"Buy\"").is_err());
}
#[test]
fn invalid_tif_deserialization_fails() {
assert!(serde_json::from_str::<Tif>("\"gtc\"").is_err());
assert!(serde_json::from_str::<Tif>("\"GTC\"").is_err());
}
}