use rust_decimal::Decimal;
use serde::Deserialize;
use crate::api::{comma_joined, range};
use crate::client::RevolutXClient;
use crate::error::{Error, Result};
use crate::model::common::{ClientOrderId, OrderId, Price, Quantity, Side, Symbol, dash_symbol};
use crate::model::orders::{
ExecutionInstruction, LimitOrderConfiguration, MarketOrderConfiguration, Order, OrderAck,
OrderConfiguration, OrderDetails, OrderPlacementRequest, OrderReplacementRequest, OrderStatus,
OrderType,
};
use crate::model::trades::Fill;
use crate::model::{Data, OneOrMany, Page, RawPage};
use crate::transport::{RequestSpec, encode_component};
#[derive(Debug, Clone, Default)]
pub struct ActiveOrdersQuery {
pub symbols: Vec<String>,
pub order_states: Vec<OrderStatus>,
pub order_types: Vec<OrderType>,
pub side: Option<Side>,
pub cursor: Option<String>,
pub limit: Option<u32>,
}
impl ActiveOrdersQuery {
fn to_query(&self) -> Result<Vec<(String, String)>> {
reject_unknown_filters(&self.order_states, &self.order_types)?;
let mut query = comma_joined("symbols", self.symbols.iter().map(|s| dash_symbol(s)));
query.extend(comma_joined(
"order_states",
self.order_states.iter().map(|s| s.as_str()),
));
query.extend(comma_joined(
"order_types",
self.order_types.iter().map(|t| t.as_str()),
));
if let Some(side) = self.side {
query.push(("side".into(), side.as_str().to_string()));
}
if let Some(cursor) = &self.cursor {
query.push(("cursor".into(), cursor.clone()));
}
if let Some(limit) = self.limit {
query.push(("limit".into(), limit.to_string()));
}
Ok(query)
}
}
fn validate_placement(config: &OrderConfiguration) -> Result<()> {
match (&config.limit, &config.market) {
(Some(_), Some(_)) => Err(Error::invalid_request(
"order configuration must set exactly one of `limit` or `market`, not both",
)),
(None, None) => Err(Error::invalid_request(
"order configuration must set one of `limit` or `market`",
)),
(Some(limit), None) => {
validate_one_size(limit.base_size.is_some(), limit.quote_size.is_some())
}
(None, Some(market)) => {
validate_one_size(market.base_size.is_some(), market.quote_size.is_some())
}
}
}
fn validate_one_size(has_base: bool, has_quote: bool) -> Result<()> {
if has_base == has_quote {
return Err(Error::invalid_request(
"order configuration must set exactly one of `base_size` or `quote_size`",
));
}
Ok(())
}
fn build_symbol(raw: &str) -> Result<Symbol> {
Symbol::new(dash_symbol(raw).into_owned())
}
fn checked_instructions(
instructions: Option<Vec<ExecutionInstruction>>,
) -> Result<Option<Vec<ExecutionInstruction>>> {
if let Some(list) = &instructions
&& list.contains(&ExecutionInstruction::Unknown)
{
return Err(Error::invalid_request(
"cannot send an unknown execution instruction",
));
}
Ok(instructions)
}
fn reject_unknown_filters(states: &[OrderStatus], types: &[OrderType]) -> Result<()> {
if states.contains(&OrderStatus::Unknown) {
return Err(Error::invalid_request(
"cannot filter orders by an unknown order state",
));
}
if types.contains(&OrderType::Unknown) {
return Err(Error::invalid_request(
"cannot filter orders by an unknown order type",
));
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct HistoricalOrdersQuery {
pub symbols: Vec<String>,
pub order_states: Vec<OrderStatus>,
pub order_types: Vec<OrderType>,
pub start_date: Option<i64>,
pub end_date: Option<i64>,
pub cursor: Option<String>,
pub limit: Option<u32>,
}
impl HistoricalOrdersQuery {
fn to_query(&self) -> Result<Vec<(String, String)>> {
reject_unknown_filters(&self.order_states, &self.order_types)?;
let mut query = comma_joined("symbols", self.symbols.iter().map(|s| dash_symbol(s)));
query.extend(comma_joined(
"order_states",
self.order_states.iter().map(|s| s.as_str()),
));
query.extend(comma_joined(
"order_types",
self.order_types.iter().map(|t| t.as_str()),
));
if let Some(start) = self.start_date {
query.push(("start_date".into(), start.to_string()));
}
if let Some(end) = self.end_date {
query.push(("end_date".into(), end.to_string()));
}
if let Some(cursor) = &self.cursor {
query.push(("cursor".into(), cursor.clone()));
}
if let Some(limit) = self.limit {
query.push(("limit".into(), limit.to_string()));
}
Ok(query)
}
}
pub struct OrdersApi<'a> {
client: &'a RevolutXClient,
}
impl<'a> OrdersApi<'a> {
pub(crate) const fn new(client: &'a RevolutXClient) -> Self {
Self { client }
}
pub fn limit_buy(
&self,
symbol: impl Into<String>,
size: impl Into<Decimal>,
price: impl Into<Decimal>,
) -> LimitOrderBuilder<'a> {
LimitOrderBuilder::new(self.client, symbol, Side::Buy, SizeKind::Base, size, price)
}
pub fn limit_sell(
&self,
symbol: impl Into<String>,
size: impl Into<Decimal>,
price: impl Into<Decimal>,
) -> LimitOrderBuilder<'a> {
LimitOrderBuilder::new(self.client, symbol, Side::Sell, SizeKind::Base, size, price)
}
pub fn limit_buy_quote(
&self,
symbol: impl Into<String>,
quote_size: impl Into<Decimal>,
price: impl Into<Decimal>,
) -> LimitOrderBuilder<'a> {
LimitOrderBuilder::new(
self.client,
symbol,
Side::Buy,
SizeKind::Quote,
quote_size,
price,
)
}
pub fn limit_sell_quote(
&self,
symbol: impl Into<String>,
quote_size: impl Into<Decimal>,
price: impl Into<Decimal>,
) -> LimitOrderBuilder<'a> {
LimitOrderBuilder::new(
self.client,
symbol,
Side::Sell,
SizeKind::Quote,
quote_size,
price,
)
}
pub fn market_buy(
&self,
symbol: impl Into<String>,
size: impl Into<Decimal>,
) -> MarketOrderBuilder<'a> {
MarketOrderBuilder::new(self.client, symbol, Side::Buy, SizeKind::Base, size)
}
pub fn market_sell(
&self,
symbol: impl Into<String>,
size: impl Into<Decimal>,
) -> MarketOrderBuilder<'a> {
MarketOrderBuilder::new(self.client, symbol, Side::Sell, SizeKind::Base, size)
}
pub fn market_buy_quote(
&self,
symbol: impl Into<String>,
quote_size: impl Into<Decimal>,
) -> MarketOrderBuilder<'a> {
MarketOrderBuilder::new(self.client, symbol, Side::Buy, SizeKind::Quote, quote_size)
}
pub fn market_sell_quote(
&self,
symbol: impl Into<String>,
quote_size: impl Into<Decimal>,
) -> MarketOrderBuilder<'a> {
MarketOrderBuilder::new(self.client, symbol, Side::Sell, SizeKind::Quote, quote_size)
}
pub async fn place(&self, request: &OrderPlacementRequest) -> Result<OrderAck> {
validate_placement(&request.order_configuration)?;
let raw: RawAck = self
.client
.send_json(RequestSpec::post_json("/orders", request)?)
.await?;
raw.into_ack()
}
pub async fn place_raw(&self, body: &serde_json::Value) -> Result<OrderAck> {
let raw: RawAck = self
.client
.send_json(RequestSpec::post_json("/orders", body)?)
.await?;
raw.into_ack()
}
pub async fn replace(
&self,
id: &OrderId,
request: &OrderReplacementRequest,
) -> Result<OrderAck> {
let path = format!("/orders/{}", encode_component(id.as_str()));
let raw: RawAck = self
.client
.send_json(RequestSpec::put_json(path, request)?)
.await?;
raw.into_ack()
}
pub async fn get(&self, id: &OrderId) -> Result<OrderDetails> {
let path = format!("/orders/{}", encode_component(id.as_str()));
let data: Data<OrderDetails> = self.client.send_json(RequestSpec::get(path)).await?;
Ok(data.data)
}
pub async fn active(&self, query: &ActiveOrdersQuery) -> Result<Page<Order>> {
let raw: RawPage<Order> = self
.client
.send_json(RequestSpec::get("/orders/active").with_query(query.to_query()?))
.await?;
Ok(raw.into())
}
pub async fn historical(&self, query: &HistoricalOrdersQuery) -> Result<Page<Order>> {
let raw: RawPage<Order> = self
.client
.send_json(RequestSpec::get("/orders/historical").with_query(query.to_query()?))
.await?;
Ok(raw.into())
}
pub async fn historical_range(&self, query: &HistoricalOrdersQuery) -> Result<Page<Order>> {
let plan = range::plan(
"historical_range",
query.start_date,
query.end_date,
query.cursor.is_some(),
query.limit,
)?;
range::walk(
&plan,
|window_start, window_end, cursor| {
let window_query = HistoricalOrdersQuery {
symbols: query.symbols.clone(),
order_states: query.order_states.clone(),
order_types: query.order_types.clone(),
start_date: Some(window_start),
end_date: Some(window_end),
cursor,
limit: query.limit,
};
async move { self.historical(&window_query).await }
},
|order| order.id.clone(),
|order| order.created_date,
)
.await
}
pub async fn cancel(&self, id: &OrderId) -> Result<()> {
let path = format!("/orders/{}", encode_component(id.as_str()));
self.client.send_no_content(RequestSpec::delete(path)).await
}
pub async fn cancel_all(&self) -> Result<()> {
self.client
.send_no_content(RequestSpec::delete("/orders"))
.await
}
pub async fn fills(&self, id: &OrderId) -> Result<Vec<Fill>> {
let path = format!("/orders/fills/{}", encode_component(id.as_str()));
let data: Data<Vec<Fill>> = self.client.send_json(RequestSpec::get(path)).await?;
Ok(data.data)
}
}
#[derive(Debug, Clone, Copy)]
enum SizeKind {
Base,
Quote,
}
pub struct LimitOrderBuilder<'a> {
client: &'a RevolutXClient,
symbol: String,
side: Side,
size_kind: SizeKind,
size: Decimal,
price: Decimal,
client_order_id: Option<ClientOrderId>,
execution_instructions: Option<Vec<ExecutionInstruction>>,
}
impl<'a> LimitOrderBuilder<'a> {
#[allow(clippy::similar_names)]
fn new(
client: &'a RevolutXClient,
symbol: impl Into<String>,
side: Side,
size_kind: SizeKind,
size: impl Into<Decimal>,
price: impl Into<Decimal>,
) -> Self {
Self {
client,
symbol: symbol.into(),
side,
size_kind,
size: size.into(),
price: price.into(),
client_order_id: None,
execution_instructions: None,
}
}
#[must_use]
pub const fn client_order_id(mut self, id: ClientOrderId) -> Self {
self.client_order_id = Some(id);
self
}
#[must_use]
pub fn execution_instructions(
mut self,
instructions: impl IntoIterator<Item = ExecutionInstruction>,
) -> Self {
self.execution_instructions = Some(instructions.into_iter().collect());
self
}
#[must_use]
pub fn post_only(self) -> Self {
self.execution_instructions([ExecutionInstruction::PostOnly])
}
#[must_use]
pub fn allow_taker(self) -> Self {
self.execution_instructions([ExecutionInstruction::AllowTaker])
}
pub fn build(self) -> Result<OrderPlacementRequest> {
let symbol = build_symbol(&self.symbol)?;
let price = Price::new(self.price)?;
let size = Quantity::new(self.size)?;
let execution_instructions = checked_instructions(self.execution_instructions)?;
let (base_size, quote_size) = match self.size_kind {
SizeKind::Base => (Some(size), None),
SizeKind::Quote => (None, Some(size)),
};
Ok(OrderPlacementRequest {
client_order_id: self.client_order_id.unwrap_or_default(),
symbol,
side: self.side,
order_configuration: OrderConfiguration {
limit: Some(LimitOrderConfiguration {
base_size,
quote_size,
price,
execution_instructions,
}),
market: None,
},
})
}
pub async fn send(self) -> Result<OrderAck> {
let client = self.client;
let request = self.build()?;
OrdersApi::new(client).place(&request).await
}
}
pub struct MarketOrderBuilder<'a> {
client: &'a RevolutXClient,
symbol: String,
side: Side,
size_kind: SizeKind,
size: Decimal,
client_order_id: Option<ClientOrderId>,
}
impl<'a> MarketOrderBuilder<'a> {
#[allow(clippy::similar_names)]
fn new(
client: &'a RevolutXClient,
symbol: impl Into<String>,
side: Side,
size_kind: SizeKind,
size: impl Into<Decimal>,
) -> Self {
Self {
client,
symbol: symbol.into(),
side,
size_kind,
size: size.into(),
client_order_id: None,
}
}
#[must_use]
pub const fn client_order_id(mut self, id: ClientOrderId) -> Self {
self.client_order_id = Some(id);
self
}
pub fn build(self) -> Result<OrderPlacementRequest> {
let symbol = build_symbol(&self.symbol)?;
let size = Quantity::new(self.size)?;
let (base_size, quote_size) = match self.size_kind {
SizeKind::Base => (Some(size), None),
SizeKind::Quote => (None, Some(size)),
};
Ok(OrderPlacementRequest {
client_order_id: self.client_order_id.unwrap_or_default(),
symbol,
side: self.side,
order_configuration: OrderConfiguration {
limit: None,
market: Some(MarketOrderConfiguration {
base_size,
quote_size,
}),
},
})
}
pub async fn send(self) -> Result<OrderAck> {
let client = self.client;
let request = self.build()?;
OrdersApi::new(client).place(&request).await
}
}
#[derive(Deserialize)]
struct RawAck {
data: OneOrMany<OrderAck>,
}
impl RawAck {
fn into_ack(self) -> Result<OrderAck> {
self.data.into_first().ok_or_else(|| Error::Unexpected {
status: 200,
body: "order acknowledgement response contained no data".to_string(),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::client::RevolutXClient;
use std::str::FromStr;
fn client() -> RevolutXClient {
RevolutXClient::builder()
.api_key("k")
.private_key_bytes([9u8; 32])
.build()
.unwrap()
}
#[test]
fn limit_buy_builds_expected_request() {
let c = client();
let request = c
.orders()
.limit_buy(
"BTC-USD",
Decimal::from_str("0.1").unwrap(),
Decimal::from_str("50000.50").unwrap(),
)
.client_order_id("3fa85f64-5717-4562-b3fc-2c963f66afa6".parse().unwrap())
.post_only()
.build()
.unwrap();
let json = serde_json::to_value(&request).unwrap();
let expected = serde_json::json!({
"client_order_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"symbol": "BTC-USD",
"side": "buy",
"order_configuration": {
"limit": {
"base_size": "0.1",
"price": "50000.50",
"execution_instructions": ["post_only"]
}
}
});
assert_eq!(json, expected);
}
#[test]
fn market_sell_quote_builds_expected_request() {
let c = client();
let request = c
.orders()
.market_sell_quote("BTC-USD", Decimal::from_str("0.1").unwrap())
.client_order_id("3fa85f64-5717-4562-b3fc-2c963f66afa6".parse().unwrap())
.build()
.unwrap();
let json = serde_json::to_value(&request).unwrap();
let expected = serde_json::json!({
"client_order_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"symbol": "BTC-USD",
"side": "sell",
"order_configuration": { "market": { "quote_size": "0.1" } }
});
assert_eq!(json, expected);
}
#[test]
fn non_positive_size_is_rejected() {
let c = client();
let err = c
.orders()
.limit_buy("BTC-USD", Decimal::ZERO, Decimal::from_str("100").unwrap())
.build()
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest { .. }));
}
#[test]
fn empty_symbol_is_rejected() {
let c = client();
let err = c
.orders()
.market_buy("", Decimal::from_str("1").unwrap())
.build()
.unwrap_err();
assert!(matches!(err, Error::InvalidRequest { .. }));
}
#[test]
fn random_client_order_id_is_generated_when_unset() {
let c = client();
let request = c
.orders()
.limit_sell(
"ETH-USD",
Decimal::from_str("1").unwrap(),
Decimal::from_str("2000").unwrap(),
)
.build()
.unwrap();
assert_eq!(request.client_order_id.to_string().len(), 36);
}
}