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