use serde::Serialize;
use crate::model::{
RequestValidationError, ValidateRequest, at_least_one, non_empty, one_of, optional_non_empty,
positive_decimal_string, validate_client_request_id,
};
fn optional_signed_decimal_string(
field: &'static str,
value: Option<&str>,
) -> Result<(), RequestValidationError> {
let Some(value) = value else {
return Ok(());
};
non_empty(field, value)?;
let unsigned = value.strip_prefix('-').unwrap_or(value);
let mut dot_seen = false;
let mut digit_seen = false;
for byte in unsigned.bytes() {
match byte {
b'0'..=b'9' => digit_seen = true,
b'.' if !dot_seen => dot_seen = true,
_ => {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "a signed decimal string without exponent notation",
});
}
}
}
if !digit_seen || unsigned.starts_with('.') || unsigned.ends_with('.') {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "a signed decimal string without exponent notation",
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PlaceSpreadOrderRequest {
pub sprd_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cl_ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
pub side: String,
pub ord_type: String,
pub sz: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub px: Option<String>,
}
impl PlaceSpreadOrderRequest {
pub fn new(
sprd_id: impl Into<String>,
side: impl Into<String>,
ord_type: impl Into<String>,
size: impl Into<String>,
) -> Self {
Self {
sprd_id: sprd_id.into(),
cl_ord_id: None,
tag: None,
side: side.into(),
ord_type: ord_type.into(),
sz: size.into(),
px: None,
}
}
pub fn client_order_id(mut self, cl_ord_id: impl Into<String>) -> Self {
self.cl_ord_id = Some(cl_ord_id.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tag = Some(tag.into());
self
}
pub fn price(mut self, price: impl Into<String>) -> Self {
self.px = Some(price.into());
self
}
}
impl ValidateRequest for PlaceSpreadOrderRequest {
fn validate(&self) -> Result<(), RequestValidationError> {
non_empty("sprdId", &self.sprd_id)?;
validate_client_request_id("clOrdId", self.cl_ord_id.as_deref())?;
optional_non_empty("tag", self.tag.as_deref())?;
one_of("side", &self.side, &["buy", "sell"], "buy or sell")?;
one_of(
"ordType",
&self.ord_type,
&["limit", "post_only", "ioc"],
"limit, post_only, or ioc",
)?;
positive_decimal_string("sz", &self.sz)?;
optional_signed_decimal_string("px", self.px.as_deref())?;
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AmendSpreadOrderRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cl_ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub req_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_sz: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_px: Option<String>,
}
impl AmendSpreadOrderRequest {
pub fn by_order_id(ord_id: impl Into<String>) -> Self {
Self {
ord_id: Some(ord_id.into()),
..Self::default()
}
}
pub fn by_client_order_id(cl_ord_id: impl Into<String>) -> Self {
Self {
cl_ord_id: Some(cl_ord_id.into()),
..Self::default()
}
}
pub fn request_id(mut self, req_id: impl Into<String>) -> Self {
self.req_id = Some(req_id.into());
self
}
pub fn new_size(mut self, size: impl Into<String>) -> Self {
self.new_sz = Some(size.into());
self
}
pub fn new_price(mut self, price: impl Into<String>) -> Self {
self.new_px = Some(price.into());
self
}
}
impl ValidateRequest for AmendSpreadOrderRequest {
fn validate(&self) -> Result<(), RequestValidationError> {
at_least_one(
"ordId, clOrdId",
&[self.ord_id.is_some(), self.cl_ord_id.is_some()],
)?;
optional_non_empty("ordId", self.ord_id.as_deref())?;
validate_client_request_id("clOrdId", self.cl_ord_id.as_deref())?;
validate_client_request_id("reqId", self.req_id.as_deref())?;
at_least_one(
"newSz, newPx",
&[self.new_sz.is_some(), self.new_px.is_some()],
)?;
if let Some(value) = self.new_sz.as_deref() {
positive_decimal_string("newSz", value)?;
}
optional_signed_decimal_string("newPx", self.new_px.as_deref())?;
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CancelSpreadOrderRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub ord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cl_ord_id: Option<String>,
}
impl CancelSpreadOrderRequest {
pub fn by_order_id(ord_id: impl Into<String>) -> Self {
Self {
ord_id: Some(ord_id.into()),
cl_ord_id: None,
}
}
pub fn by_client_order_id(cl_ord_id: impl Into<String>) -> Self {
Self {
ord_id: None,
cl_ord_id: Some(cl_ord_id.into()),
}
}
}
impl ValidateRequest for CancelSpreadOrderRequest {
fn validate(&self) -> Result<(), RequestValidationError> {
at_least_one(
"ordId, clOrdId",
&[self.ord_id.is_some(), self.cl_ord_id.is_some()],
)?;
optional_non_empty("ordId", self.ord_id.as_deref())?;
validate_client_request_id("clOrdId", self.cl_ord_id.as_deref())?;
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MassCancelSpreadOrdersRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub sprd_id: Option<String>,
}
impl MassCancelSpreadOrdersRequest {
pub fn all() -> Self {
Self::default()
}
pub fn for_spread(sprd_id: impl Into<String>) -> Self {
Self {
sprd_id: Some(sprd_id.into()),
}
}
}
impl ValidateRequest for MassCancelSpreadOrdersRequest {
fn validate(&self) -> Result<(), RequestValidationError> {
optional_non_empty("sprdId", self.sprd_id.as_deref())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serializes_documented_place_order_example() {
let request = PlaceSpreadOrderRequest::new("BTC-USDT_BTC-USDT-SWAP", "buy", "limit", "2")
.client_order_id("b15")
.price("2.15");
request.validate().unwrap();
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["sprdId"], "BTC-USDT_BTC-USDT-SWAP");
assert_eq!(value["ordType"], "limit");
assert_eq!(value["px"], "2.15");
}
#[test]
fn rejects_incomplete_amend_request() {
let error = AmendSpreadOrderRequest::by_order_id("1")
.validate()
.unwrap_err();
assert!(matches!(
error,
RequestValidationError::AtLeastOneRequired { .. }
));
}
#[test]
fn accepts_negative_spread_prices() {
PlaceSpreadOrderRequest::new("A_B", "sell", "limit", "1")
.price("-2.5")
.validate()
.unwrap();
AmendSpreadOrderRequest::by_order_id("1")
.new_price("-1.25")
.validate()
.unwrap();
}
#[test]
fn mass_cancel_all_serializes_as_empty_object() {
let value = serde_json::to_value(MassCancelSpreadOrdersRequest::all()).unwrap();
assert_eq!(value, serde_json::json!({}));
}
}