1use polyoxide_core::{
2 HttpClient, HttpClientBuilder, RateLimiter, RetryConfig, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
3};
4
5use crate::{
6 account::{Account, Credentials},
7 api::{
8 account::AccountApi,
9 auth::Auth,
10 notifications::Notifications,
11 orders::OrderResponse,
12 rewards::{PublicRewards, Rewards},
13 Health, Markets, Orders,
14 },
15 core::chain::Chain,
16 error::ClobError,
17 request::{AuthMode, Request},
18 types::*,
19 utils::{
20 calculate_market_order_amounts, calculate_market_price, calculate_order_amounts,
21 generate_salt,
22 },
23};
24use alloy::primitives::{Address, B256};
25#[cfg(feature = "gamma")]
26use polyoxide_gamma::Gamma;
27
28const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com";
29
30#[derive(Clone)]
36pub struct Clob {
37 pub(crate) http_client: HttpClient,
38 pub(crate) chain_id: u64,
39 pub(crate) signature_type: SignatureType,
40 pub(crate) builder_code: B256,
43 pub(crate) account: Option<Account>,
44 #[cfg(feature = "gamma")]
45 pub(crate) gamma: Gamma,
46}
47
48impl Clob {
49 pub fn new(
51 private_key: impl Into<String>,
52 credentials: Credentials,
53 ) -> Result<Self, ClobError> {
54 Self::builder(private_key, credentials)?.build()
55 }
56
57 pub fn public() -> Self {
59 ClobBuilder::new().build().unwrap() }
61
62 pub fn builder(
64 private_key: impl Into<String>,
65 credentials: Credentials,
66 ) -> Result<ClobBuilder, ClobError> {
67 let account = Account::new(private_key, credentials)?;
68 Ok(ClobBuilder::new().with_account(account))
69 }
70
71 pub fn from_account(account: Account) -> Result<Self, ClobError> {
73 ClobBuilder::new().with_account(account).build()
74 }
75
76 pub fn account(&self) -> Option<&Account> {
78 self.account.as_ref()
79 }
80
81 pub fn markets(&self) -> Markets {
83 Markets {
84 http_client: self.http_client.clone(),
85 chain_id: self.chain_id,
86 }
87 }
88
89 pub fn health(&self) -> Health {
91 Health {
92 http_client: self.http_client.clone(),
93 chain_id: self.chain_id,
94 }
95 }
96
97 pub fn orders(&self) -> Result<Orders, ClobError> {
99 let account = self
100 .account
101 .as_ref()
102 .ok_or_else(|| ClobError::validation("Account required for orders API"))?;
103
104 Ok(Orders {
105 http_client: self.http_client.clone(),
106 wallet: account.wallet().clone(),
107 credentials: account.credentials().clone(),
108 signer: account.signer().clone(),
109 chain_id: self.chain_id,
110 })
111 }
112
113 pub fn account_api(&self) -> Result<AccountApi, ClobError> {
115 let account = self
116 .account
117 .as_ref()
118 .ok_or_else(|| ClobError::validation("Account required for account API"))?;
119
120 Ok(AccountApi {
121 http_client: self.http_client.clone(),
122 wallet: account.wallet().clone(),
123 credentials: account.credentials().clone(),
124 signer: account.signer().clone(),
125 chain_id: self.chain_id,
126 signature_type: self.signature_type,
127 })
128 }
129
130 pub fn notifications(&self) -> Result<Notifications, ClobError> {
132 let account = self
133 .account
134 .as_ref()
135 .ok_or_else(|| ClobError::validation("Account required for notifications API"))?;
136
137 Ok(Notifications {
138 http_client: self.http_client.clone(),
139 wallet: account.wallet().clone(),
140 credentials: account.credentials().clone(),
141 signer: account.signer().clone(),
142 chain_id: self.chain_id,
143 signature_type: self.signature_type,
144 })
145 }
146
147 pub fn public_rewards(&self) -> PublicRewards {
154 PublicRewards {
155 http_client: self.http_client.clone(),
156 chain_id: self.chain_id,
157 }
158 }
159
160 pub fn rewards(&self) -> Result<Rewards, ClobError> {
162 let account = self
163 .account
164 .as_ref()
165 .ok_or_else(|| ClobError::validation("Account required for rewards API"))?;
166
167 Ok(Rewards {
168 http_client: self.http_client.clone(),
169 wallet: account.wallet().clone(),
170 credentials: account.credentials().clone(),
171 signer: account.signer().clone(),
172 chain_id: self.chain_id,
173 signature_type: self.signature_type,
174 })
175 }
176
177 pub fn auth(&self) -> Result<Auth, ClobError> {
179 let account = self
180 .account
181 .as_ref()
182 .ok_or_else(|| ClobError::validation("Account required for auth API"))?;
183
184 Ok(Auth {
185 http_client: self.http_client.clone(),
186 wallet: account.wallet().clone(),
187 credentials: account.credentials().clone(),
188 signer: account.signer().clone(),
189 chain_id: self.chain_id,
190 })
191 }
192
193 pub async fn create_order(
195 &self,
196 params: &CreateOrderParams,
197 options: Option<PartialCreateOrderOptions>,
198 ) -> Result<Order, ClobError> {
199 let account = self
200 .account
201 .as_ref()
202 .ok_or_else(|| ClobError::validation("Account required to create order"))?;
203
204 params.validate()?;
205
206 let signature_type = params.signature_type.unwrap_or_default();
209 if signature_type == SignatureType::Poly1271 {
210 return Err(ClobError::validation(
211 "Poly1271 (EIP-1271) signing is not yet supported; use EOA/PolyProxy/PolyGnosisSafe",
212 ));
213 }
214
215 let (neg_risk, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
217
218 let (maker_amount, taker_amount) =
220 calculate_order_amounts(params.price, params.size, params.side, tick_size);
221
222 let maker = self
224 .resolve_maker_address(params.funder, signature_type, account)
225 .await?;
226
227 let timestamp_ms = std::time::SystemTime::now()
228 .duration_since(std::time::UNIX_EPOCH)
229 .map(|d| d.as_millis())
230 .unwrap_or(0);
231
232 Ok(Self::build_order_v2(
234 params.token_id.clone(),
235 maker,
236 account.address(),
237 maker_amount,
238 taker_amount,
239 params.side,
240 signature_type,
241 neg_risk,
242 params.expiration,
243 self.builder_code,
244 B256::ZERO,
245 timestamp_ms,
246 ))
247 }
248
249 pub async fn create_market_order(
251 &self,
252 params: &MarketOrderArgs,
253 options: Option<PartialCreateOrderOptions>,
254 ) -> Result<Order, ClobError> {
255 let account = self
256 .account
257 .as_ref()
258 .ok_or_else(|| ClobError::validation("Account required to create order"))?;
259
260 if !params.amount.is_finite() {
261 return Err(ClobError::validation(
262 "Amount must be finite (no NaN or infinity)",
263 ));
264 }
265 if params.amount <= 0.0 {
266 return Err(ClobError::validation(format!(
267 "Amount must be positive, got {}",
268 params.amount
269 )));
270 }
271 if let Some(p) = params.price {
272 if !p.is_finite() || p <= 0.0 || p > 1.0 {
273 return Err(ClobError::validation(format!(
274 "Price must be finite and between 0.0 and 1.0, got {}",
275 p
276 )));
277 }
278 }
279
280 let signature_type = params.signature_type.unwrap_or_default();
283 if signature_type == SignatureType::Poly1271 {
284 return Err(ClobError::validation(
285 "Poly1271 (EIP-1271) signing is not yet supported; use EOA/PolyProxy/PolyGnosisSafe",
286 ));
287 }
288
289 let (neg_risk, tick_size) = self.get_market_metadata(¶ms.token_id, options).await?;
291
292 let price = if let Some(p) = params.price {
294 p
295 } else {
296 let book = self
298 .markets()
299 .order_book(params.token_id.clone())
300 .send()
301 .await?;
302
303 let levels = match params.side {
304 OrderSide::Buy => book.asks,
305 OrderSide::Sell => book.bids,
306 };
307
308 calculate_market_price(&levels, params.amount, params.side)
309 .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
310 };
311
312 let (maker_amount, taker_amount) =
314 calculate_market_order_amounts(params.amount, price, params.side, tick_size);
315
316 let maker = self
318 .resolve_maker_address(params.funder, signature_type, account)
319 .await?;
320
321 let timestamp_ms = std::time::SystemTime::now()
322 .duration_since(std::time::UNIX_EPOCH)
323 .map(|d| d.as_millis())
324 .unwrap_or(0);
325
326 Ok(Self::build_order_v2(
328 params.token_id.clone(),
329 maker,
330 account.address(),
331 maker_amount,
332 taker_amount,
333 params.side,
334 signature_type,
335 neg_risk,
336 Some(0),
337 self.builder_code,
338 B256::ZERO,
339 timestamp_ms,
340 ))
341 }
342 pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
344 let account = self
345 .account
346 .as_ref()
347 .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
348 account.sign_order(order, self.chain_id).await
349 }
350
351 async fn get_market_metadata(
355 &self,
356 token_id: &str,
357 options: Option<PartialCreateOrderOptions>,
358 ) -> Result<(bool, TickSize), ClobError> {
359 let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
361 neg_risk
362 } else {
363 let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
364 neg_risk_resp.neg_risk
365 };
366
367 let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
369 tick_size
370 } else {
371 let tick_size_resp = self
372 .markets()
373 .tick_size(token_id.to_string())
374 .send()
375 .await?;
376 let tick_size_val = tick_size_resp
377 .minimum_tick_size
378 .parse::<f64>()
379 .map_err(|e| {
380 ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
381 })?;
382 TickSize::try_from(tick_size_val)?
383 };
384
385 Ok((neg_risk, tick_size))
386 }
387
388 async fn resolve_maker_address(
390 &self,
391 funder: Option<Address>,
392 signature_type: SignatureType,
393 account: &Account,
394 ) -> Result<Address, ClobError> {
395 if let Some(funder) = funder {
396 Ok(funder)
397 } else if signature_type.is_proxy() {
398 #[cfg(feature = "gamma")]
399 {
400 let profile = self
402 .gamma
403 .user()
404 .get(account.address().to_string())
405 .send()
406 .await
407 .map_err(|e| {
408 ClobError::service(format!("Failed to fetch user profile: {}", e))
409 })?;
410
411 profile
412 .proxy
413 .ok_or_else(|| {
414 ClobError::validation(format!(
415 "Signature type {:?} requires proxy, but none found for {}",
416 signature_type,
417 account.address()
418 ))
419 })?
420 .parse::<Address>()
421 .map_err(|e| {
422 ClobError::validation(format!(
423 "Invalid proxy address format from Gamma: {}",
424 e
425 ))
426 })
427 }
428 #[cfg(not(feature = "gamma"))]
429 {
430 Err(ClobError::validation(format!(
431 "Signature type {:?} requires the `gamma` feature to resolve proxy address; \
432 enable `polyoxide-clob/gamma` or provide an explicit `funder` address",
433 signature_type
434 )))
435 }
436 } else {
437 Ok(account.address())
438 }
439 }
440
441 #[allow(clippy::too_many_arguments)]
451 fn build_order_v2(
452 token_id: String,
453 maker: Address,
454 signer: Address,
455 maker_amount: String,
456 taker_amount: String,
457 side: OrderSide,
458 signature_type: SignatureType,
459 neg_risk: bool,
460 expiration: Option<u64>,
461 builder: B256,
462 metadata: B256,
463 timestamp_ms: u128,
464 ) -> Order {
465 Order {
466 salt: generate_salt(),
467 maker,
468 signer,
469 token_id,
470 maker_amount,
471 taker_amount,
472 side,
473 expiration: expiration.unwrap_or(0).to_string(),
474 signature_type,
475 timestamp: timestamp_ms.to_string(),
476 metadata,
477 builder,
478 neg_risk,
479 }
480 }
481
482 fn order_submit_payload(
487 signed_order: &SignedOrder,
488 order_type: OrderKind,
489 post_only: bool,
490 owner_key: &str,
491 ) -> serde_json::Value {
492 serde_json::json!({
493 "order": signed_order,
494 "owner": owner_key,
495 "orderType": order_type,
496 "postOnly": post_only,
497 })
498 }
499
500 pub async fn post_orders(
502 &self,
503 orders: &[SignedOrderPayload],
504 ) -> Result<Vec<OrderResponse>, ClobError> {
505 let account = self
506 .account
507 .as_ref()
508 .ok_or_else(|| ClobError::validation("Account required to post orders"))?;
509
510 let auth = AuthMode::L2 {
511 address: account.address(),
512 credentials: account.credentials().clone(),
513 signer: account.signer().clone(),
514 };
515
516 let payload: Vec<_> = orders
517 .iter()
518 .map(|o| {
519 Self::order_submit_payload(
520 &o.order,
521 o.order_type,
522 o.post_only,
523 &account.credentials().key,
524 )
525 })
526 .collect();
527
528 Request::post(
529 self.http_client.clone(),
530 "/orders".to_string(),
531 auth,
532 self.chain_id,
533 )
534 .body(&payload)?
535 .send()
536 .await
537 }
538
539 pub async fn post_order(
541 &self,
542 signed_order: &SignedOrder,
543 order_type: OrderKind,
544 post_only: bool,
545 ) -> Result<OrderResponse, ClobError> {
546 let account = self
547 .account
548 .as_ref()
549 .ok_or_else(|| ClobError::validation("Account required to post order"))?;
550
551 let auth = AuthMode::L2 {
552 address: account.address(),
553 credentials: account.credentials().clone(),
554 signer: account.signer().clone(),
555 };
556
557 let payload = Self::order_submit_payload(
559 signed_order,
560 order_type,
561 post_only,
562 &account.credentials().key,
563 );
564
565 Request::post(
566 self.http_client.clone(),
567 "/order".to_string(),
568 auth,
569 self.chain_id,
570 )
571 .body(&payload)?
572 .send()
573 .await
574 }
575
576 pub async fn place_order(
578 &self,
579 params: &CreateOrderParams,
580 options: Option<PartialCreateOrderOptions>,
581 ) -> Result<OrderResponse, ClobError> {
582 let order = self.create_order(params, options).await?;
583 let signed_order = self.sign_order(&order).await?;
584 self.post_order(&signed_order, params.order_type, params.post_only)
585 .await
586 }
587
588 pub async fn place_market_order(
590 &self,
591 params: &MarketOrderArgs,
592 options: Option<PartialCreateOrderOptions>,
593 ) -> Result<OrderResponse, ClobError> {
594 let order = self.create_market_order(params, options).await?;
595 let signed_order = self.sign_order(&order).await?;
596
597 let order_type = params.order_type.unwrap_or(OrderKind::Fok);
598 self.post_order(&signed_order, order_type, false) .await
602 }
603}
604
605#[derive(Debug, Clone)]
611pub struct CreateOrderParams {
612 pub token_id: String,
613 pub price: f64,
614 pub size: f64,
615 pub side: OrderSide,
616 pub order_type: OrderKind,
617 pub post_only: bool,
618 pub expiration: Option<u64>,
619 pub funder: Option<Address>,
620 pub signature_type: Option<SignatureType>,
621}
622
623impl CreateOrderParams {
624 pub fn validate(&self) -> Result<(), ClobError> {
626 if !self.price.is_finite() || !self.size.is_finite() {
627 return Err(ClobError::validation(
628 "Price and size must be finite (no NaN or infinity)",
629 ));
630 }
631 if self.price <= 0.0 || self.price > 1.0 {
632 return Err(ClobError::validation(format!(
633 "Price must be between 0.0 and 1.0, got {}",
634 self.price
635 )));
636 }
637 if self.size <= 0.0 {
638 return Err(ClobError::validation(format!(
639 "Size must be positive, got {}",
640 self.size
641 )));
642 }
643 Ok(())
644 }
645}
646
647#[derive(Debug, Clone)]
649pub struct SignedOrderPayload {
650 pub order: SignedOrder,
651 pub order_type: OrderKind,
652 pub post_only: bool,
653}
654
655pub struct ClobBuilder {
657 base_url: String,
658 timeout_ms: u64,
659 pool_size: usize,
660 chain: Chain,
661 signature_type: SignatureType,
662 builder_code: B256,
663 account: Option<Account>,
664 #[cfg(feature = "gamma")]
665 gamma: Option<Gamma>,
666 retry_config: Option<RetryConfig>,
667 max_concurrent: Option<usize>,
668}
669
670impl ClobBuilder {
671 pub fn new() -> Self {
673 Self {
674 base_url: DEFAULT_BASE_URL.to_string(),
675 timeout_ms: DEFAULT_TIMEOUT_MS,
676 pool_size: DEFAULT_POOL_SIZE,
677 chain: Chain::PolygonMainnet,
678 signature_type: SignatureType::Eoa,
679 builder_code: B256::ZERO,
680 account: None,
681 #[cfg(feature = "gamma")]
682 gamma: None,
683 retry_config: None,
684 max_concurrent: None,
685 }
686 }
687
688 pub fn with_account(mut self, account: Account) -> Self {
690 self.account = Some(account);
691 self
692 }
693
694 pub fn base_url(mut self, url: impl Into<String>) -> Self {
696 self.base_url = url.into();
697 self
698 }
699
700 pub fn timeout_ms(mut self, timeout: u64) -> Self {
702 self.timeout_ms = timeout;
703 self
704 }
705
706 pub fn pool_size(mut self, size: usize) -> Self {
708 self.pool_size = size;
709 self
710 }
711
712 pub fn chain(mut self, chain: Chain) -> Self {
714 self.chain = chain;
715 self
716 }
717
718 pub fn signature_type(mut self, signature_type: SignatureType) -> Self {
726 self.signature_type = signature_type;
727 self
728 }
729
730 pub fn builder_code(mut self, code: B256) -> Self {
757 self.builder_code = code;
758 self
759 }
760
761 #[cfg(feature = "gamma")]
763 pub fn gamma(mut self, gamma: Gamma) -> Self {
764 self.gamma = Some(gamma);
765 self
766 }
767
768 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
770 self.retry_config = Some(config);
771 self
772 }
773
774 pub fn max_concurrent(mut self, max: usize) -> Self {
778 self.max_concurrent = Some(max);
779 self
780 }
781
782 pub fn build(self) -> Result<Clob, ClobError> {
784 let mut builder = HttpClientBuilder::new(&self.base_url)
785 .timeout_ms(self.timeout_ms)
786 .pool_size(self.pool_size)
787 .with_rate_limiter(RateLimiter::clob_default())
788 .with_max_concurrent(self.max_concurrent.unwrap_or(8));
789 if let Some(config) = self.retry_config {
790 builder = builder.with_retry_config(config);
791 }
792 let http_client = builder.build()?;
793
794 #[cfg(feature = "gamma")]
795 let gamma = if let Some(gamma) = self.gamma {
796 gamma
797 } else {
798 polyoxide_gamma::Gamma::builder()
799 .timeout_ms(self.timeout_ms)
800 .pool_size(self.pool_size)
801 .build()
802 .map_err(|e| {
803 ClobError::service(format!("Failed to build default Gamma client: {}", e))
804 })?
805 };
806
807 Ok(Clob {
808 http_client,
809 chain_id: self.chain.chain_id(),
810 signature_type: self.signature_type,
811 builder_code: self.builder_code,
812 account: self.account,
813 #[cfg(feature = "gamma")]
814 gamma,
815 })
816 }
817}
818
819impl Default for ClobBuilder {
820 fn default() -> Self {
821 Self::new()
822 }
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 #[test]
830 fn build_order_v2_sets_builder_and_timestamp() {
831 let code = alloy::primitives::B256::from([0x11u8; 32]);
832 let order = Clob::build_order_v2(
833 "100".to_string(),
834 alloy::primitives::Address::ZERO,
835 alloy::primitives::Address::ZERO,
836 "1000000".to_string(),
837 "5000000".to_string(),
838 OrderSide::Buy,
839 SignatureType::PolyProxy,
840 false,
841 Some(0),
842 code,
843 alloy::primitives::B256::ZERO,
844 1_700_000_000_000,
845 );
846 assert_eq!(order.builder, code);
847 assert_eq!(order.metadata, alloy::primitives::B256::ZERO);
848 assert_eq!(order.timestamp, "1700000000000");
849 assert_eq!(order.expiration, "0");
850 }
851
852 #[test]
853 fn post_order_payload_is_v2_wrapper() {
854 let signed = SignedOrder {
855 order: Order {
856 salt: "1".into(),
857 maker: Address::ZERO,
858 signer: Address::ZERO,
859 token_id: "100".into(),
860 maker_amount: "1".into(),
861 taker_amount: "1".into(),
862 side: OrderSide::Buy,
863 expiration: "0".into(),
864 signature_type: SignatureType::PolyProxy,
865 timestamp: "1700000000000".into(),
866 metadata: alloy::primitives::B256::ZERO,
867 builder: alloy::primitives::B256::from([0x11u8; 32]),
868 neg_risk: false,
869 },
870 signature: "0xabc".into(),
871 };
872 let payload = Clob::order_submit_payload(&signed, OrderKind::Gtc, false, "owner-key");
873 assert_eq!(payload["owner"], "owner-key");
874 assert_eq!(payload["orderType"], "GTC");
875 assert_eq!(payload["postOnly"], false);
876 assert_eq!(payload["order"]["builder"].as_str().unwrap().len(), 66);
877 assert!(payload["order"]["timestamp"].is_string());
878 }
879
880 #[test]
881 fn test_builder_custom_max_concurrent() {
882 let builder = ClobBuilder::new().max_concurrent(16);
883 assert_eq!(builder.max_concurrent, Some(16));
884 }
885
886 #[tokio::test]
887 async fn test_default_concurrency_limit_is_8() {
888 let clob = Clob::public();
889 let mut permits = Vec::new();
890 for _ in 0..8 {
891 permits.push(clob.http_client.acquire_concurrency().await);
892 }
893 assert!(permits.iter().all(|p| p.is_some()));
894
895 let result = tokio::time::timeout(
896 std::time::Duration::from_millis(50),
897 clob.http_client.acquire_concurrency(),
898 )
899 .await;
900 assert!(
901 result.is_err(),
902 "9th permit should block with default limit of 8"
903 );
904 }
905
906 #[test]
907 fn test_builder_custom_retry_config() {
908 let config = RetryConfig {
909 max_retries: 5,
910 initial_backoff_ms: 1000,
911 max_backoff_ms: 30_000,
912 };
913 let builder = ClobBuilder::new().with_retry_config(config);
914 let config = builder.retry_config.unwrap();
915 assert_eq!(config.max_retries, 5);
916 assert_eq!(config.initial_backoff_ms, 1000);
917 }
918
919 fn make_params(price: f64, size: f64) -> CreateOrderParams {
920 CreateOrderParams {
921 token_id: "test".to_string(),
922 price,
923 size,
924 side: OrderSide::Buy,
925 order_type: OrderKind::Gtc,
926 post_only: false,
927 expiration: None,
928 funder: None,
929 signature_type: None,
930 }
931 }
932
933 #[test]
934 fn test_validate_rejects_nan_price() {
935 let params = make_params(f64::NAN, 100.0);
936 let err = params.validate().unwrap_err();
937 assert!(err.to_string().contains("finite"));
938 }
939
940 #[test]
941 fn test_validate_rejects_nan_size() {
942 let params = make_params(0.5, f64::NAN);
943 let err = params.validate().unwrap_err();
944 assert!(err.to_string().contains("finite"));
945 }
946
947 #[test]
948 fn test_validate_rejects_infinite_price() {
949 let params = make_params(f64::INFINITY, 100.0);
950 let err = params.validate().unwrap_err();
951 assert!(err.to_string().contains("finite"));
952 }
953
954 #[test]
955 fn test_validate_rejects_infinite_size() {
956 let params = make_params(0.5, f64::INFINITY);
957 let err = params.validate().unwrap_err();
958 assert!(err.to_string().contains("finite"));
959 }
960
961 #[test]
962 fn test_validate_rejects_neg_infinity_size() {
963 let params = make_params(0.5, f64::NEG_INFINITY);
964 let err = params.validate().unwrap_err();
965 assert!(err.to_string().contains("finite"));
966 }
967
968 #[test]
969 fn test_validate_rejects_price_out_of_range() {
970 let params = make_params(1.5, 100.0);
971 let err = params.validate().unwrap_err();
972 assert!(err.to_string().contains("between 0.0 and 1.0"));
973 }
974
975 #[test]
976 fn test_validate_rejects_zero_price() {
977 let params = make_params(0.0, 100.0);
978 let err = params.validate().unwrap_err();
979 assert!(err.to_string().contains("between 0.0 and 1.0"));
980 }
981
982 #[test]
983 fn test_validate_rejects_negative_size() {
984 let params = make_params(0.5, -10.0);
985 let err = params.validate().unwrap_err();
986 assert!(err.to_string().contains("positive"));
987 }
988
989 #[test]
990 fn test_validate_accepts_valid_params() {
991 let params = make_params(0.5, 100.0);
992 assert!(params.validate().is_ok());
993 }
994
995 #[test]
996 fn test_validate_accepts_boundary_price() {
997 let params = make_params(1.0, 100.0);
999 assert!(params.validate().is_ok());
1000 }
1001}