Merge pull request #14 from onsails/batch

Batch orders, GTC/GTD/FOK/FAK order types, typed responses, proxy-wallet support, per-page pagination
This commit is contained in:
floor-licker
2026-02-16 22:37:23 -05:00
committed by GitHub
8 changed files with 915 additions and 181 deletions
+9 -1
View File
@@ -623,11 +623,19 @@ impl PolyfillDemo {
side: Side::BUY,
size: dec!(50.0),
price: dec!(0.75),
status: Some("MATCHED".to_string()),
status: TradeMessageStatus::Matched,
msg_type: None,
last_update: None,
matchtime: None,
timestamp: None,
outcome: None,
owner: None,
trade_owner: None,
taker_order_id: None,
maker_orders: vec![],
fee_rate_bps: None,
transaction_hash: None,
trader_side: None,
}),
];
+403 -152
View File
@@ -9,15 +9,13 @@ use crate::http_config::{
create_colocated_client, create_internet_client, create_optimized_client, prewarm_connections,
};
use crate::types::{OrderOptions, PostOrder, SignedOrderRequest};
use alloy_primitives::U256;
use alloy_primitives::{Address, U256};
use alloy_signer_local::PrivateKeySigner;
use reqwest::header::HeaderName;
use reqwest::Client;
use reqwest::{Method, RequestBuilder};
use rust_decimal::prelude::FromPrimitive;
use rust_decimal::Decimal;
use serde_json::Value;
use std::str::FromStr;
// Re-export types for compatibility
pub use crate::types::{ApiCredentials as ApiCreds, OrderType, Side};
@@ -238,17 +236,23 @@ impl ClobClient {
}
/// Create a client with L2 headers (for API key authentication)
///
/// `sig_type` and `funder` are optional parameters for proxy wallet support.
/// When using a Polymarket proxy wallet, pass `SigType::PolyProxy` and the
/// proxy wallet address as `funder`.
pub fn with_l2_headers(
host: &str,
private_key: &str,
chain_id: u64,
api_creds: ApiCreds,
sig_type: Option<crate::orders::SigType>,
funder: Option<Address>,
) -> Self {
let signer = private_key
.parse::<PrivateKeySigner>()
.expect("Invalid private key");
let order_builder = crate::orders::OrderBuilder::new(signer.clone(), None, None);
let order_builder = crate::orders::OrderBuilder::new(signer.clone(), sig_type, funder);
let http_client = create_optimized_client().unwrap_or_else(|_| {
reqwest::ClientBuilder::new()
@@ -623,18 +627,8 @@ impl ClobClient {
));
}
let tick_size_response: Value = response.json().await?;
let tick_size = tick_size_response["minimum_tick_size"]
.as_str()
.and_then(|s| Decimal::from_str(s).ok())
.or_else(|| {
tick_size_response["minimum_tick_size"]
.as_f64()
.map(|f| Decimal::from_f64(f).unwrap_or(Decimal::ZERO))
})
.ok_or_else(|| PolyfillError::parse("Invalid tick size format", None))?;
Ok(tick_size)
let tick_size_response: TickSizeResponse = response.json().await?;
Ok(tick_size_response.minimum_tick_size)
}
/// Get maker fee rate (in bps) for a token
@@ -658,7 +652,7 @@ impl ClobClient {
.json()
.await
.map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?;
Ok(fee_rate.fee_rate_bps)
Ok(fee_rate.base_fee)
}
/// Create a new API key
@@ -820,12 +814,8 @@ impl ClobClient {
));
}
let neg_risk_response: Value = response.json().await?;
let neg_risk = neg_risk_response["neg_risk"]
.as_bool()
.ok_or_else(|| PolyfillError::parse("Invalid neg risk format", None))?;
Ok(neg_risk)
let neg_risk_response: NegRiskResponse = response.json().await?;
Ok(neg_risk_response.neg_risk)
}
/// Resolve tick size for an order
@@ -953,7 +943,7 @@ impl ClobClient {
.collect(),
};
order_builder.calculate_market_price(&levels, amount)
order_builder.calculate_market_price(side, &levels, amount)
}
/// Create a market order
@@ -974,7 +964,7 @@ impl ClobClient {
let extras = extras.unwrap_or_default();
let price = self
.calculate_market_price(&order_args.token_id, Side::BUY, order_args.amount)
.calculate_market_price(&order_args.token_id, order_args.side, order_args.amount)
.await?;
if !self.is_price_in_range(
@@ -1000,7 +990,7 @@ impl ClobClient {
&self,
order: SignedOrderRequest,
order_type: OrderType,
) -> Result<Value> {
) -> Result<crate::types::PostOrderResponse> {
let signer = self
.signer
.as_ref()
@@ -1029,17 +1019,106 @@ impl ClobClient {
return Err(PolyfillError::api(status, message));
}
Ok(response.json::<Value>().await?)
Ok(response.json::<crate::types::PostOrderResponse>().await?)
}
/// Create and post an order in one call
pub async fn create_and_post_order(&self, order_args: &OrderArgs) -> Result<Value> {
/// Post multiple orders to the exchange in a single request
pub async fn post_orders(
&self,
orders: Vec<SignedOrderRequest>,
order_type: OrderType,
) -> Result<Vec<crate::types::PostOrderResponse>> {
if orders.is_empty() {
return Err(PolyfillError::validation("orders cannot be empty"));
}
if orders.len() > 15 {
return Err(PolyfillError::validation(
"orders cannot exceed 15 items per batch",
));
}
let signer = self
.signer
.as_ref()
.ok_or_else(|| PolyfillError::auth("Signer not set"))?;
let api_creds = self
.api_creds
.as_ref()
.ok_or_else(|| PolyfillError::auth("API credentials not set"))?;
let body: Vec<PostOrder> = orders
.into_iter()
.map(|order| PostOrder::new(order, api_creds.api_key.clone(), order_type))
.collect();
let headers = create_l2_headers(signer, api_creds, "POST", "/orders", Some(&body))?;
let req = self.create_request_with_headers(Method::POST, "/orders", headers.into_iter());
let response = req.json(&body).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
let message = if body.is_empty() {
"Failed to post orders".to_string()
} else {
format!("Failed to post orders: {}", body)
};
return Err(PolyfillError::api(status, message));
}
Ok(response
.json::<Vec<crate::types::PostOrderResponse>>()
.await?)
}
/// Create and post an order in one call with an explicit order type
pub async fn create_and_post_order_with_type(
&self,
order_args: &OrderArgs,
order_type: OrderType,
) -> Result<crate::types::PostOrderResponse> {
let order = self.create_order(order_args, None, None, None).await?;
self.post_order(order, OrderType::GTC).await
self.post_order(order, order_type).await
}
/// Create and post an order in one call (defaults to GTC)
pub async fn create_and_post_order(
&self,
order_args: &OrderArgs,
) -> Result<crate::types::PostOrderResponse> {
self.create_and_post_order_with_type(order_args, OrderType::GTC)
.await
}
/// Create and post multiple orders in one call with an explicit order type
pub async fn create_and_post_orders_with_type(
&self,
order_args: &[OrderArgs],
order_type: OrderType,
) -> Result<Vec<crate::types::PostOrderResponse>> {
if order_args.is_empty() {
return Err(PolyfillError::validation("order_args cannot be empty"));
}
let mut orders = Vec::with_capacity(order_args.len());
for args in order_args {
orders.push(self.create_order(args, None, None, None).await?);
}
self.post_orders(orders, order_type).await
}
/// Create and post multiple orders in one call (defaults to GTC)
pub async fn create_and_post_orders(
&self,
order_args: &[OrderArgs],
) -> Result<Vec<crate::types::PostOrderResponse>> {
self.create_and_post_orders_with_type(order_args, OrderType::GTC)
.await
}
/// Cancel an order
pub async fn cancel(&self, order_id: &str) -> Result<Value> {
pub async fn cancel(&self, order_id: &str) -> Result<crate::types::CancelOrdersResponse> {
let signer = self
.signer
.as_ref()
@@ -1062,11 +1141,16 @@ impl ClobClient {
));
}
Ok(response.json::<Value>().await?)
Ok(response
.json::<crate::types::CancelOrdersResponse>()
.await?)
}
/// Cancel multiple orders
pub async fn cancel_orders(&self, order_ids: &[String]) -> Result<Value> {
pub async fn cancel_orders(
&self,
order_ids: &[String],
) -> Result<crate::types::CancelOrdersResponse> {
let signer = self
.signer
.as_ref()
@@ -1087,11 +1171,13 @@ impl ClobClient {
));
}
Ok(response.json::<Value>().await?)
Ok(response
.json::<crate::types::CancelOrdersResponse>()
.await?)
}
/// Cancel all orders
pub async fn cancel_all(&self) -> Result<Value> {
pub async fn cancel_all(&self) -> Result<crate::types::CancelOrdersResponse> {
let signer = self
.signer
.as_ref()
@@ -1101,7 +1187,7 @@ impl ClobClient {
.as_ref()
.ok_or_else(|| PolyfillError::auth("API credentials not set"))?;
let headers = create_l2_headers::<Value>(signer, api_creds, "DELETE", "/cancel-all", None)?;
let headers = create_l2_headers::<()>(signer, api_creds, "DELETE", "/cancel-all", None)?;
let req =
self.create_request_with_headers(Method::DELETE, "/cancel-all", headers.into_iter());
@@ -1113,22 +1199,20 @@ impl ClobClient {
));
}
Ok(response.json::<Value>().await?)
Ok(response
.json::<crate::types::CancelOrdersResponse>()
.await?)
}
/// Get open orders with optional filtering
/// Fetch a single page of orders.
///
/// This retrieves all open orders for the authenticated user. You can filter by:
/// - Order ID (exact match)
/// - Asset/Token ID (all orders for a specific token)
/// - Market ID (all orders for a specific market)
///
/// The response includes order status, fill information, and timestamps.
pub async fn get_orders(
/// Returns `(page_data, next_cursor)`. `next_cursor` is `None` when there are
/// no more pages.
pub async fn get_orders_page(
&self,
params: Option<&crate::types::OpenOrderParams>,
next_cursor: Option<&str>,
) -> Result<Vec<crate::types::OpenOrder>> {
) -> Result<(Vec<crate::types::OpenOrder>, Option<String>)> {
let signer = self
.signer
.as_ref()
@@ -1148,55 +1232,148 @@ impl ClobClient {
Some(p) => p.to_query_params(),
};
let mut next_cursor = next_cursor.unwrap_or("MA==").to_string(); // INITIAL_CURSOR
let cursor = next_cursor.unwrap_or("MA==");
let req = self
.http_client
.request(method.clone(), format!("{}{}", self.base_url, endpoint))
.query(&query_params)
.query(&[("next_cursor", cursor)]);
let r = headers
.into_iter()
.fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v));
let resp = r
.send()
.await
.map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?
.json::<Value>()
.await
.map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?;
let new_cursor = resp["next_cursor"]
.as_str()
.ok_or_else(|| PolyfillError::parse("Failed to parse next cursor".to_string(), None))?
.to_owned();
let results = resp["data"].clone();
let orders =
serde_json::from_value::<Vec<crate::types::OpenOrder>>(results).map_err(|e| {
PolyfillError::parse(
format!("Failed to parse data from order response: {}", e),
None,
)
})?;
let next = if new_cursor == "LTE=" {
None
} else {
Some(new_cursor)
};
Ok((orders, next))
}
/// Get open orders with optional filtering
///
/// This retrieves all open orders for the authenticated user. You can filter by:
/// - Order ID (exact match)
/// - Asset/Token ID (all orders for a specific token)
/// - Market ID (all orders for a specific market)
///
/// The response includes order status, fill information, and timestamps.
pub async fn get_orders(
&self,
params: Option<&crate::types::OpenOrderParams>,
next_cursor: Option<&str>,
) -> Result<Vec<crate::types::OpenOrder>> {
let mut cursor = next_cursor.map(|s| s.to_owned());
let mut output = Vec::new();
while next_cursor != "LTE=" {
// END_CURSOR
let req = self
.http_client
.request(method.clone(), format!("{}{}", self.base_url, endpoint))
.query(&query_params)
.query(&[("next_cursor", &next_cursor)]);
let r = headers
.clone()
.into_iter()
.fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v));
let resp = r
.send()
.await
.map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?
.json::<Value>()
.await
.map_err(|e| {
PolyfillError::parse(format!("Failed to parse response: {}", e), None)
})?;
let new_cursor = resp["next_cursor"]
.as_str()
.ok_or_else(|| {
PolyfillError::parse("Failed to parse next cursor".to_string(), None)
})?
.to_owned();
next_cursor = new_cursor;
let results = resp["data"].clone();
let orders =
serde_json::from_value::<Vec<crate::types::OpenOrder>>(results).map_err(|e| {
PolyfillError::parse(
format!("Failed to parse data from order response: {}", e),
None,
)
})?;
output.extend(orders);
loop {
let (page, next) = self.get_orders_page(params, cursor.as_deref()).await?;
output.extend(page);
match next {
Some(c) => cursor = Some(c),
None => break,
}
}
Ok(output)
}
/// Fetch a single page of trades.
///
/// Returns `(page_data, next_cursor)`. `next_cursor` is `None` when there are
/// no more pages.
pub async fn get_trades_page(
&self,
params: Option<&crate::types::TradeParams>,
next_cursor: Option<&str>,
) -> Result<(Vec<crate::types::TradeResponse>, Option<String>)> {
let signer = self
.signer
.as_ref()
.ok_or_else(|| PolyfillError::auth("Signer not set"))?;
let api_creds = self
.api_creds
.as_ref()
.ok_or_else(|| PolyfillError::auth("API credentials not set"))?;
let method = Method::GET;
let endpoint = "/data/trades";
let headers =
create_l2_headers::<Value>(signer, api_creds, method.as_str(), endpoint, None)?;
let query_params = match params {
None => Vec::new(),
Some(p) => p.to_query_params(),
};
let cursor = next_cursor.unwrap_or("MA==");
let req = self
.http_client
.request(method.clone(), format!("{}{}", self.base_url, endpoint))
.query(&query_params)
.query(&[("next_cursor", cursor)]);
let r = headers
.into_iter()
.fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v));
let resp = r
.send()
.await
.map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?
.json::<Value>()
.await
.map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?;
let new_cursor = resp["next_cursor"]
.as_str()
.ok_or_else(|| PolyfillError::parse("Failed to parse next cursor".to_string(), None))?
.to_owned();
let results = resp["data"].clone();
let trades =
serde_json::from_value::<Vec<crate::types::TradeResponse>>(results).map_err(|e| {
PolyfillError::parse(
format!("Failed to parse data from trades response: {}", e),
None,
)
})?;
let next = if new_cursor == "LTE=" {
None
} else {
Some(new_cursor)
};
Ok((trades, next))
}
/// Get trade history with optional filtering
///
/// This retrieves historical trades for the authenticated user. You can filter by:
@@ -1211,63 +1388,19 @@ impl ClobClient {
&self,
trade_params: Option<&crate::types::TradeParams>,
next_cursor: Option<&str>,
) -> Result<Vec<Value>> {
let signer = self
.signer
.as_ref()
.ok_or_else(|| PolyfillError::auth("Signer not set"))?;
let api_creds = self
.api_creds
.as_ref()
.ok_or_else(|| PolyfillError::auth("API credentials not set"))?;
let method = Method::GET;
let endpoint = "/data/trades";
let headers =
create_l2_headers::<Value>(signer, api_creds, method.as_str(), endpoint, None)?;
let query_params = match trade_params {
None => Vec::new(),
Some(p) => p.to_query_params(),
};
let mut next_cursor = next_cursor.unwrap_or("MA==").to_string(); // INITIAL_CURSOR
) -> Result<Vec<crate::types::TradeResponse>> {
let mut cursor = next_cursor.map(|s| s.to_owned());
let mut output = Vec::new();
while next_cursor != "LTE=" {
// END_CURSOR
let req = self
.http_client
.request(method.clone(), format!("{}{}", self.base_url, endpoint))
.query(&query_params)
.query(&[("next_cursor", &next_cursor)]);
let r = headers
.clone()
.into_iter()
.fold(req, |r, (k, v)| r.header(HeaderName::from_static(k), v));
let resp = r
.send()
.await
.map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?
.json::<Value>()
.await
.map_err(|e| {
PolyfillError::parse(format!("Failed to parse response: {}", e), None)
})?;
let new_cursor = resp["next_cursor"]
.as_str()
.ok_or_else(|| {
PolyfillError::parse("Failed to parse next cursor".to_string(), None)
})?
.to_owned();
next_cursor = new_cursor;
let results = resp["data"].clone();
output.push(results);
loop {
let (page, next) = self
.get_trades_page(trade_params, cursor.as_deref())
.await?;
output.extend(page);
match next {
Some(c) => cursor = Some(c),
None => break,
}
}
Ok(output)
@@ -1565,7 +1698,7 @@ impl ClobClient {
&self,
market: Option<&str>,
asset_id: Option<&str>,
) -> Result<Value> {
) -> Result<crate::types::CancelOrdersResponse> {
let signer = self
.signer
.as_ref()
@@ -1599,7 +1732,7 @@ impl ClobClient {
.map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?;
response
.json::<Value>()
.json::<crate::types::CancelOrdersResponse>()
.await
.map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))
}
@@ -1721,12 +1854,12 @@ impl ClobClient {
.await
.map_err(|e| PolyfillError::network(format!("Request failed: {}", e), e))?;
let result: Value = response
let result: crate::types::OrderScoringResponse = response
.json()
.await
.map_err(|e| PolyfillError::parse(format!("Failed to parse response: {}", e), None))?;
Ok(result["scoring"].as_bool().unwrap_or(false))
Ok(result.scoring)
}
/// Check if multiple orders are scoring
@@ -2276,9 +2409,11 @@ impl ClobClient {
// Re-export types from the canonical location in types.rs
pub use crate::types::{
ExtraOrderArgs, Market, MarketOrderArgs, MarketsResponse, MidpointResponse, NegRiskResponse,
OrderBookSummary, OrderSummary, PriceResponse, PricesHistoryInterval, PricesHistoryResponse,
Rewards, SpreadResponse, TickSizeResponse, Token,
CancelOrdersResponse, ExtraOrderArgs, MakerOrder, Market, MarketOrderArgs, MarketsResponse,
MidpointResponse, NegRiskResponse, OrderBookSummary, OrderScoringResponse, OrderSummary,
PostOrderResponse, PriceHistoryPoint, PriceResponse, PricesHistoryInterval,
PricesHistoryResponse, Rewards, SpreadResponse, TickSizeResponse, Token, TradeResponse,
TraderSide,
};
// Compatibility types that need to stay in client.rs
@@ -2330,9 +2465,29 @@ mod tests {
"0x1234567890123456789012345678901234567890123456789012345678901234",
137,
api_creds,
None,
None,
)
}
fn sample_signed_order() -> crate::types::SignedOrderRequest {
crate::types::SignedOrderRequest {
salt: 1,
maker: "0x0000000000000000000000000000000000000000".to_string(),
signer: "0x0000000000000000000000000000000000000000".to_string(),
taker: "0x0000000000000000000000000000000000000000".to_string(),
token_id: "123".to_string(),
maker_amount: "100".to_string(),
taker_amount: "50".to_string(),
expiration: "0".to_string(),
nonce: "0".to_string(),
fee_rate_bps: "0".to_string(),
side: "BUY".to_string(),
signature_type: 0,
signature: "0xdeadbeef".to_string(),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_client_creation() {
let client = create_test_client("https://test.example.com");
@@ -2362,6 +2517,8 @@ mod tests {
"0x1234567890123456789012345678901234567890123456789012345678901234",
137,
api_creds.clone(),
None,
None,
);
assert_eq!(client.base_url, "https://test.example.com");
@@ -2616,7 +2773,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
async fn test_get_prices_history_interval_success() {
let mut server = Server::new_async().await;
let mock_response = r#"{"history":[{"t":1}]}"#;
let mock_response = r#"{"history":[{"t":1,"p":"0.52"}]}"#;
let mock = server
.mock("GET", "/prices-history")
@@ -2639,6 +2796,8 @@ mod tests {
mock.assert_async().await;
assert_eq!(response.history.len(), 1);
assert_eq!(response.history[0].t, 1);
assert_eq!(response.history[0].p, Decimal::from_str("0.52").unwrap());
}
#[tokio::test(flavor = "multi_thread")]
@@ -2874,6 +3033,98 @@ mod tests {
assert_eq!(timestamp, 1234567890);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_post_orders_batch_success() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/orders")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"[{"success":true,"orderID":"a"},{"success":true,"orderID":"b"}]"#)
.create_async()
.await;
let client = create_test_client_with_l2_auth(&server.url());
let signed_order = sample_signed_order();
let result = client
.post_orders(
vec![signed_order.clone(), signed_order],
crate::types::OrderType::GTC,
)
.await;
mock.assert_async().await;
assert!(result.is_ok());
let orders = result.unwrap();
assert_eq!(orders.len(), 2);
assert!(orders.iter().all(|o| o.success));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_post_orders_batch_empty_validation() {
let client = create_test_client_with_l2_auth("https://test.example.com");
let result = client
.post_orders(Vec::new(), crate::types::OrderType::GTC)
.await;
assert!(matches!(result, Err(PolyfillError::Validation { .. })));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_post_orders_batch_too_many_validation() {
let client = create_test_client_with_l2_auth("https://test.example.com");
let orders = (0..16).map(|_| sample_signed_order()).collect::<Vec<_>>();
let result = client
.post_orders(orders, crate::types::OrderType::GTC)
.await;
assert!(matches!(result, Err(PolyfillError::Validation { .. })));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_post_order_supports_fak() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/order")
.match_body(Matcher::Regex(r#""orderType":"FAK""#.to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"success":true,"orderID":"a"}"#)
.create_async()
.await;
let client = create_test_client_with_l2_auth(&server.url());
let result = client
.post_order(sample_signed_order(), crate::types::OrderType::FAK)
.await;
mock.assert_async().await;
assert!(result.is_ok());
assert!(result.unwrap().success);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_post_orders_supports_fak() {
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/orders")
.match_body(Matcher::Regex(r#""orderType":"FAK""#.to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"[{"success":true,"orderID":"a"}]"#)
.create_async()
.await;
let client = create_test_client_with_l2_auth(&server.url());
let result = client
.post_orders(vec![sample_signed_order()], crate::types::OrderType::FAK)
.await;
mock.assert_async().await;
assert!(result.is_ok());
assert!(result.unwrap()[0].success);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_create_or_derive_api_key() {
let mut server = Server::new_async().await;
@@ -3035,7 +3286,7 @@ mod tests {
.match_query(Matcher::UrlEncoded("token_id".into(), "123".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"fee_rate_bps":1000}"#)
.with_body(r#"{"base_fee":1000}"#)
.create_async()
.await;
+149
View File
@@ -663,4 +663,153 @@ mod tests {
let results: Vec<serde_json::Value> = decoder.parse_json_stream(data).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_trade_message_full_payload() {
use crate::types::{StreamMessage, TradeMessageStatus, TradeMessageType, TraderSide};
let json = r#"{
"event_type": "trade",
"id": "trade-001",
"market": "0xabc123",
"asset_id": "asset-xyz",
"side": "BUY",
"size": "100.5",
"price": "0.65",
"status": "MATCHED",
"type": "TRADE",
"last_update": "1700000000",
"match_time": "1700000001",
"timestamp": "1700000002",
"outcome": "Yes",
"owner": "owner-key-123",
"trade_owner": "trader-key-456",
"taker_order_id": "taker-order-789",
"maker_orders": [
{
"order_id": "maker-order-1",
"owner": "maker-owner-1",
"matched_amount": "50.25",
"price": "0.65",
"asset_id": "asset-xyz",
"outcome": "Yes"
}
],
"fee_rate_bps": "2.5",
"transaction_hash": "0xdeadbeef",
"trader_side": "TAKER"
}"#;
let msgs = parse_stream_messages(json).unwrap();
assert_eq!(msgs.len(), 1);
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade variant");
};
assert_eq!(trade.id, "trade-001");
assert_eq!(trade.market, "0xabc123");
assert_eq!(trade.asset_id, "asset-xyz");
assert_eq!(trade.side, Side::BUY);
assert_eq!(trade.size, Decimal::from_str("100.5").unwrap());
assert_eq!(trade.price, Decimal::from_str("0.65").unwrap());
assert_eq!(trade.status, TradeMessageStatus::Matched);
assert_eq!(trade.msg_type, Some(TradeMessageType::Trade));
assert_eq!(trade.last_update, Some(1700000000));
assert_eq!(trade.matchtime, Some(1700000001));
assert_eq!(trade.timestamp, Some(1700000002));
assert_eq!(trade.outcome.as_deref(), Some("Yes"));
assert_eq!(trade.owner.as_deref(), Some("owner-key-123"));
assert_eq!(trade.trade_owner.as_deref(), Some("trader-key-456"));
assert_eq!(trade.taker_order_id.as_deref(), Some("taker-order-789"));
assert_eq!(trade.maker_orders.len(), 1);
assert_eq!(trade.maker_orders[0].order_id, "maker-order-1");
assert_eq!(trade.fee_rate_bps, Some(Decimal::from_str("2.5").unwrap()));
assert_eq!(trade.transaction_hash.as_deref(), Some("0xdeadbeef"));
assert_eq!(trade.trader_side, Some(TraderSide::Taker));
}
#[test]
fn test_trade_message_minimal_payload() {
use crate::types::StreamMessage;
// Only mandatory fields — all new optional fields absent.
let json = r#"{
"event_type": "trade",
"id": "trade-minimal",
"market": "0xdef",
"asset_id": "asset-min",
"side": "SELL",
"size": "10",
"price": "0.50"
}"#;
let msgs = parse_stream_messages(json).unwrap();
assert_eq!(msgs.len(), 1);
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade variant");
};
assert_eq!(trade.id, "trade-minimal");
assert_eq!(trade.side, Side::SELL);
// All optional fields should be None / empty / default.
assert!(trade.msg_type.is_none());
assert!(trade.outcome.is_none());
assert!(trade.owner.is_none());
assert!(trade.trade_owner.is_none());
assert!(trade.taker_order_id.is_none());
assert!(trade.maker_orders.is_empty());
assert!(trade.fee_rate_bps.is_none());
assert!(trade.transaction_hash.is_none());
assert!(trade.trader_side.is_none());
}
#[test]
fn test_trade_message_status_lifecycle() {
use crate::types::{StreamMessage, TradeMessageStatus};
for (status_str, expected) in [
("MATCHED", TradeMessageStatus::Matched),
("matched", TradeMessageStatus::Matched),
("Matched", TradeMessageStatus::Matched),
("MINED", TradeMessageStatus::Mined),
("mined", TradeMessageStatus::Mined),
("CONFIRMED", TradeMessageStatus::Confirmed),
("confirmed", TradeMessageStatus::Confirmed),
] {
let json = format!(
r#"{{
"event_type": "trade",
"id": "t1", "market": "m", "asset_id": "a",
"side": "BUY", "size": "1", "price": "0.5",
"status": "{status_str}"
}}"#
);
let msgs = parse_stream_messages(&json).unwrap();
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade");
};
assert_eq!(trade.status, expected, "failed for status_str={status_str}");
}
}
#[test]
fn test_trade_message_null_maker_orders() {
use crate::types::StreamMessage;
// API sometimes sends `null` instead of `[]`.
let json = r#"{
"event_type": "trade",
"id": "t1", "market": "m", "asset_id": "a",
"side": "BUY", "size": "1", "price": "0.5",
"maker_orders": null
}"#;
let msgs = parse_stream_messages(json).unwrap();
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade");
};
assert!(trade.maker_orders.is_empty());
}
}
+14
View File
@@ -97,10 +97,12 @@ pub use crate::types::{
BatchPriceRequest,
BatchPriceResponse,
BookParams,
CancelOrdersResponse,
ClientConfig,
ClientResult,
FeeRateResponse,
FillEvent,
MakerOrder,
Market,
MarketSnapshot,
MarketsResponse,
@@ -114,9 +116,12 @@ pub use crate::types::{
OrderBookSummary,
OrderDelta,
OrderRequest,
OrderScoringResponse,
OrderStatus,
OrderSummary,
OrderType,
PostOrderResponse,
PriceHistoryPoint,
PriceResponse,
PricesHistoryInterval,
PricesHistoryResponse,
@@ -142,7 +147,12 @@ pub use crate::types::{
TickSizeResponse,
Token,
TokenPrice,
TradeMessage,
TradeMessageStatus,
TradeMessageType,
TradeParams,
TradeResponse,
TraderSide,
WssAuth,
WssChannelType,
WssSubscription,
@@ -151,6 +161,10 @@ pub use crate::types::{
// Re-export client
pub use crate::client::{ClobClient, PolyfillClient};
// Re-export order signing types (for proxy wallet support)
pub use crate::orders::SigType;
pub use alloy_primitives::Address;
// Re-export compatibility types (for easy migration from polymarket-rs-client)
pub use crate::client::OrderArgs;
+94 -13
View File
@@ -193,32 +193,48 @@ impl OrderBuilder {
/// Get order amounts for a market order
fn get_market_order_amounts(
&self,
side: Side,
amount: Decimal,
price: Decimal,
round_config: &RoundConfig,
) -> (u32, u32) {
let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero);
let raw_price = price.round_dp_with_strategy(round_config.price, MidpointTowardZero);
let raw_taker_amt = raw_maker_amt / raw_price;
let raw_taker_amt = self.fix_amount_rounding(raw_taker_amt, round_config);
(
decimal_to_token_u32(raw_maker_amt),
decimal_to_token_u32(raw_taker_amt),
)
match side {
Side::BUY => {
let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero);
let raw_taker_amt = raw_maker_amt / raw_price;
let raw_taker_amt = self.fix_amount_rounding(raw_taker_amt, round_config);
(
decimal_to_token_u32(raw_maker_amt),
decimal_to_token_u32(raw_taker_amt),
)
},
Side::SELL => {
let raw_maker_amt = amount.round_dp_with_strategy(round_config.size, ToZero);
let raw_taker_amt = raw_maker_amt * raw_price;
let raw_taker_amt = self.fix_amount_rounding(raw_taker_amt, round_config);
(
decimal_to_token_u32(raw_maker_amt),
decimal_to_token_u32(raw_taker_amt),
)
},
}
}
/// Calculate market price from order book levels
pub fn calculate_market_price(
&self,
side: Side,
positions: &[crate::types::BookLevel],
amount_to_match: Decimal,
) -> Result<Decimal> {
let mut sum = Decimal::ZERO;
for level in positions {
sum += level.size * level.price;
sum += match side {
Side::BUY => level.size * level.price,
Side::SELL => level.size,
};
if sum >= amount_to_match {
return Ok(level.price);
}
@@ -246,8 +262,12 @@ impl OrderBuilder {
.tick_size
.ok_or_else(|| PolyfillError::validation("Cannot create order without tick size"))?;
let (maker_amount, taker_amount) =
self.get_market_order_amounts(order_args.amount, price, &ROUNDING_CONFIG[&tick_size]);
let (maker_amount, taker_amount) = self.get_market_order_amounts(
order_args.side,
order_args.amount,
price,
&ROUNDING_CONFIG[&tick_size],
);
let neg_risk = options
.neg_risk
@@ -262,7 +282,7 @@ impl OrderBuilder {
self.build_signed_order(
order_args.token_id.clone(),
Side::BUY,
order_args.side,
chain_id,
exchange_address,
maker_amount,
@@ -437,4 +457,65 @@ mod tests {
assert!(seed < u64::MAX);
}
}
#[test]
fn test_calculate_market_price_respects_side_amount_semantics() {
let signer: PrivateKeySigner =
"0x1234567890123456789012345678901234567890123456789012345678901234"
.parse()
.unwrap();
let builder = OrderBuilder::new(signer, None, None);
let levels = vec![
crate::types::BookLevel {
price: Decimal::from_str("0.50").unwrap(),
size: Decimal::from_str("10").unwrap(),
},
crate::types::BookLevel {
price: Decimal::from_str("0.55").unwrap(),
size: Decimal::from_str("10").unwrap(),
},
];
// BUY amounts are quote-denominated: need 6 USDC -> first level (10 * 0.50 = 5) is not enough.
let buy_price = builder
.calculate_market_price(Side::BUY, &levels, Decimal::from_str("6").unwrap())
.unwrap();
assert_eq!(buy_price, Decimal::from_str("0.55").unwrap());
// SELL amounts are base-denominated: need 6 tokens -> first level (size 10) is enough.
let sell_price = builder
.calculate_market_price(Side::SELL, &levels, Decimal::from_str("6").unwrap())
.unwrap();
assert_eq!(sell_price, Decimal::from_str("0.50").unwrap());
}
#[test]
fn test_create_market_order_uses_input_side() {
let signer: PrivateKeySigner =
"0x1234567890123456789012345678901234567890123456789012345678901234"
.parse()
.unwrap();
let builder = OrderBuilder::new(signer, None, None);
let order = builder
.create_market_order(
137,
&MarketOrderArgs {
token_id: "123".to_string(),
side: Side::SELL,
amount: Decimal::from_str("5").unwrap(),
},
Decimal::from_str("0.40").unwrap(),
&ExtraOrderArgs::default(),
&OrderOptions {
tick_size: Some(Decimal::from_str("0.01").unwrap()),
neg_risk: Some(false),
fee_rate_bps: None,
},
)
.unwrap();
assert_eq!(order.side, "SELL");
}
}
+241 -10
View File
@@ -191,6 +191,51 @@ pub enum Side {
SELL = 1,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum TraderSide {
Taker,
Maker,
#[serde(untagged)]
Unknown(String),
}
impl Default for TraderSide {
fn default() -> Self {
Self::Unknown("UNKNOWN".to_string())
}
}
/// Trade lifecycle status (Matched → Mined → Confirmed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TradeMessageStatus {
#[serde(alias = "matched", alias = "MATCHED")]
Matched,
#[serde(alias = "mined", alias = "MINED")]
Mined,
#[serde(alias = "confirmed", alias = "CONFIRMED")]
Confirmed,
/// Forward-compatible catch-all for unknown statuses.
#[serde(untagged)]
Unknown(String),
}
impl Default for TradeMessageStatus {
fn default() -> Self {
Self::Unknown("UNKNOWN".to_string())
}
}
/// Trade message type discriminator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TradeMessageType {
#[serde(alias = "trade", alias = "TRADE")]
Trade,
/// Forward-compatible catch-all.
#[serde(untagged)]
Unknown(String),
}
impl Side {
pub fn as_str(&self) -> &'static str {
match self {
@@ -214,6 +259,7 @@ pub enum OrderType {
#[default]
GTC,
FOK,
FAK,
GTD,
}
@@ -222,6 +268,7 @@ impl OrderType {
match self {
OrderType::GTC => "GTC",
OrderType::FOK => "FOK",
OrderType::FAK => "FAK",
OrderType::GTD => "GTD",
}
}
@@ -524,6 +571,8 @@ impl Default for ExtraOrderArgs {
#[derive(Debug, Clone)]
pub struct MarketOrderArgs {
pub token_id: String,
pub side: Side,
/// Quote amount for buys, base token amount for sells.
pub amount: Decimal,
}
@@ -889,7 +938,7 @@ pub struct EventMessage {
pub description: String,
}
/// User trade execution message.
/// User trade execution message (authenticated WebSocket channel).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TradeMessage {
pub id: String,
@@ -898,10 +947,11 @@ pub struct TradeMessage {
pub side: Side,
pub size: Decimal,
pub price: Decimal,
/// Trade lifecycle status (Matched → Mined → Confirmed).
#[serde(default)]
pub status: Option<String>,
pub status: TradeMessageStatus,
#[serde(rename = "type", default)]
pub msg_type: Option<String>,
pub msg_type: Option<TradeMessageType>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
@@ -918,6 +968,36 @@ pub struct TradeMessage {
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
)]
pub timestamp: Option<u64>,
/// Outcome (e.g. "Yes" / "No").
#[serde(default)]
pub outcome: Option<String>,
/// API key of the event owner.
#[serde(default)]
pub owner: Option<String>,
/// API key of the trade owner.
#[serde(default)]
pub trade_owner: Option<String>,
/// Taker order ID.
#[serde(default)]
pub taker_order_id: Option<String>,
/// Maker order details.
#[serde(
default,
deserialize_with = "crate::decode::deserializers::vec_from_null"
)]
pub maker_orders: Vec<MakerOrder>,
/// Fee rate in basis points.
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
)]
pub fee_rate_bps: Option<Decimal>,
/// On-chain transaction hash.
#[serde(default)]
pub transaction_hash: Option<String>,
/// Whether user was maker or taker.
#[serde(default)]
pub trader_side: Option<TraderSide>,
}
/// User order update message.
@@ -1106,6 +1186,131 @@ pub struct OpenOrder {
pub created_at: u64,
}
/// Response from posting a single order.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostOrderResponse {
#[serde(default)]
pub error_msg: Option<String>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string_default_on_error"
)]
pub making_amount: Option<Decimal>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string_default_on_error"
)]
pub taking_amount: Option<Decimal>,
#[serde(rename = "orderID")]
pub order_id: String,
#[serde(default)]
pub status: Option<String>,
pub success: bool,
#[serde(default, alias = "transactionsHashes")]
pub transaction_hashes: Vec<String>,
#[serde(default)]
pub trade_ids: Vec<String>,
}
/// Response from cancel endpoints.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelOrdersResponse {
#[serde(
default,
deserialize_with = "crate::decode::deserializers::vec_from_null"
)]
pub canceled: Vec<String>,
#[serde(default, alias = "not_canceled")]
pub not_canceled: std::collections::HashMap<String, String>,
}
/// Trade item returned by `/data/trades`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TradeResponse {
pub id: String,
#[serde(default)]
pub taker_order_id: Option<String>,
pub market: String,
pub asset_id: String,
pub side: Side,
#[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")]
pub size: Decimal,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
)]
pub fee_rate_bps: Option<Decimal>,
#[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")]
pub price: Decimal,
#[serde(default)]
pub status: Option<String>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
)]
pub match_time: Option<u64>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
)]
pub last_update: Option<u64>,
#[serde(default)]
pub outcome: Option<String>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
)]
pub bucket_index: Option<u64>,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub maker_address: Option<String>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::vec_from_null"
)]
pub maker_orders: Vec<MakerOrder>,
#[serde(default)]
pub transaction_hash: Option<String>,
#[serde(default)]
pub trader_side: TraderSide,
#[serde(default, alias = "err_msg")]
pub error_msg: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MakerOrder {
pub order_id: String,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub maker_address: Option<String>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
)]
pub matched_amount: Option<Decimal>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
)]
pub price: Option<Decimal>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
)]
pub fee_rate_bps: Option<Decimal>,
#[serde(default)]
pub asset_id: Option<String>,
#[serde(default)]
pub outcome: Option<String>,
#[serde(default)]
pub side: Option<Side>,
}
/// Balance allowance information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceAllowance {
@@ -1254,24 +1459,29 @@ impl PricesHistoryInterval {
}
}
/// Raw response from `/prices-history`.
/// A single price-history datapoint from `/prices-history`.
///
/// We intentionally keep `history` entries as `serde_json::Value` because the upstream API has
/// no stable public schema here and currently may return empty history for many markets.
/// Mirrors the official Polymarket SDK shape (`t`, `p`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceHistoryPoint {
pub t: i64,
#[serde(deserialize_with = "crate::decode::deserializers::decimal_from_string")]
pub p: Decimal,
}
/// Response from `/prices-history`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricesHistoryResponse {
pub history: Vec<serde_json::Value>,
pub history: Vec<PriceHistoryPoint>,
}
#[derive(Debug, Deserialize)]
pub struct SpreadResponse {
#[serde(with = "rust_decimal::serde::str")]
pub spread: Decimal,
}
#[derive(Debug, Deserialize)]
pub struct TickSizeResponse {
#[serde(with = "rust_decimal::serde::str")]
pub minimum_tick_size: Decimal,
}
@@ -1280,6 +1490,11 @@ pub struct NegRiskResponse {
pub neg_risk: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderScoringResponse {
pub scoring: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BookParams {
pub token_id: String,
@@ -1374,7 +1589,7 @@ pub struct Rewards {
/// Fee rate in basis points for a given token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeeRateResponse {
pub fee_rate_bps: u32,
pub base_fee: u32,
}
/// Create RFQ request (Requester).
@@ -1657,3 +1872,19 @@ pub type Result<T> = std::result::Result<T, crate::errors::PolyfillError>;
pub type ApiCreds = ApiCredentials;
pub type CreateOrderOptions = OrderOptions;
pub type OrderArgs = OrderRequest;
#[cfg(test)]
mod tests {
use super::OrderType;
#[test]
fn test_order_type_fak_serde_and_string() {
assert_eq!(OrderType::FAK.as_str(), "FAK");
let json = serde_json::to_string(&OrderType::FAK).unwrap();
assert_eq!(json, "\"FAK\"");
let parsed: OrderType = serde_json::from_str("\"FAK\"").unwrap();
assert_eq!(parsed, OrderType::FAK);
}
}
+3 -3
View File
@@ -112,9 +112,9 @@ async fn test_real_api_authenticated_order_flow() {
println!("PASS: Order posted successfully!");
// Step 5: Cancel the order
if let Some(order_id) = response.get("orderID").and_then(|v| v.as_str()) {
println!("Step 5: Canceling order {}...", order_id);
let cancel_result = client.cancel(order_id).await;
if !response.order_id.is_empty() {
println!("Step 5: Canceling order {}...", response.order_id);
let cancel_result = client.cancel(&response.order_id).await;
assert!(
cancel_result.is_ok(),
"Failed to cancel order: {:?}",
+2 -2
View File
@@ -41,9 +41,9 @@ async fn test_post_order_authentication() {
println!(" Response: {:?}", response);
// Try to cancel it if we got an order ID
if let Some(order_id) = response.get("orderID").and_then(|v| v.as_str()) {
if !response.order_id.is_empty() {
println!("\nStep 3: Canceling order...");
match client.cancel(order_id).await {
match client.cancel(&response.order_id).await {
Ok(_) => println!("Order canceled successfully"),
Err(e) => println!("Cancel failed (order might have expired): {:?}", e),
}