Skip to main content

polyoxide_clob/
client.rs

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, rfq::Rfq, 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;
21#[cfg(feature = "gamma")]
22use polyoxide_gamma::Gamma;
23
24const DEFAULT_BASE_URL: &str = "https://clob.polymarket.com";
25
26/// CLOB (Central Limit Order Book) trading client for Polymarket.
27///
28/// Provides authenticated order creation, signing, and submission, plus read-only
29/// market data and order book access. Use [`Clob::public()`] for unauthenticated
30/// read-only access, or [`Clob::builder()`] for full trading capabilities.
31#[derive(Clone)]
32pub struct Clob {
33    pub(crate) http_client: HttpClient,
34    pub(crate) chain_id: u64,
35    pub(crate) account: Option<Account>,
36    #[cfg(feature = "gamma")]
37    pub(crate) gamma: Gamma,
38}
39
40impl Clob {
41    /// Create a new CLOB client with default configuration
42    pub fn new(
43        private_key: impl Into<String>,
44        credentials: Credentials,
45    ) -> Result<Self, ClobError> {
46        Self::builder(private_key, credentials)?.build()
47    }
48
49    /// Create a new public CLOB client (read-only)
50    pub fn public() -> Self {
51        ClobBuilder::new().build().unwrap() // unwrap safe because default build never fails
52    }
53
54    /// Create a new CLOB client builder with required authentication
55    pub fn builder(
56        private_key: impl Into<String>,
57        credentials: Credentials,
58    ) -> Result<ClobBuilder, ClobError> {
59        let account = Account::new(private_key, credentials)?;
60        Ok(ClobBuilder::new().with_account(account))
61    }
62
63    /// Create a new CLOB client from an Account
64    pub fn from_account(account: Account) -> Result<Self, ClobError> {
65        ClobBuilder::new().with_account(account).build()
66    }
67
68    /// Get a reference to the account
69    pub fn account(&self) -> Option<&Account> {
70        self.account.as_ref()
71    }
72
73    /// Get markets namespace
74    pub fn markets(&self) -> Markets {
75        Markets {
76            http_client: self.http_client.clone(),
77            chain_id: self.chain_id,
78        }
79    }
80
81    /// Get health namespace for latency and health checks
82    pub fn health(&self) -> Health {
83        Health {
84            http_client: self.http_client.clone(),
85            chain_id: self.chain_id,
86        }
87    }
88
89    /// Get orders namespace
90    pub fn orders(&self) -> Result<Orders, ClobError> {
91        let account = self
92            .account
93            .as_ref()
94            .ok_or_else(|| ClobError::validation("Account required for orders API"))?;
95
96        Ok(Orders {
97            http_client: self.http_client.clone(),
98            wallet: account.wallet().clone(),
99            credentials: account.credentials().clone(),
100            signer: account.signer().clone(),
101            chain_id: self.chain_id,
102        })
103    }
104
105    /// Get account API namespace
106    pub fn account_api(&self) -> Result<AccountApi, ClobError> {
107        let account = self
108            .account
109            .as_ref()
110            .ok_or_else(|| ClobError::validation("Account required for account API"))?;
111
112        Ok(AccountApi {
113            http_client: self.http_client.clone(),
114            wallet: account.wallet().clone(),
115            credentials: account.credentials().clone(),
116            signer: account.signer().clone(),
117            chain_id: self.chain_id,
118        })
119    }
120
121    /// Get notifications namespace
122    pub fn notifications(&self) -> Result<Notifications, ClobError> {
123        let account = self
124            .account
125            .as_ref()
126            .ok_or_else(|| ClobError::validation("Account required for notifications API"))?;
127
128        Ok(Notifications {
129            http_client: self.http_client.clone(),
130            wallet: account.wallet().clone(),
131            credentials: account.credentials().clone(),
132            signer: account.signer().clone(),
133            chain_id: self.chain_id,
134        })
135    }
136
137    /// Get RFQ namespace for request-for-quote operations
138    pub fn rfq(&self) -> Result<Rfq, ClobError> {
139        let account = self
140            .account
141            .as_ref()
142            .ok_or_else(|| ClobError::validation("Account required for RFQ API"))?;
143
144        Ok(Rfq {
145            http_client: self.http_client.clone(),
146            wallet: account.wallet().clone(),
147            credentials: account.credentials().clone(),
148            signer: account.signer().clone(),
149            chain_id: self.chain_id,
150        })
151    }
152
153    /// Get rewards namespace for liquidity reward operations
154    pub fn rewards(&self) -> Result<Rewards, ClobError> {
155        let account = self
156            .account
157            .as_ref()
158            .ok_or_else(|| ClobError::validation("Account required for rewards API"))?;
159
160        Ok(Rewards {
161            http_client: self.http_client.clone(),
162            wallet: account.wallet().clone(),
163            credentials: account.credentials().clone(),
164            signer: account.signer().clone(),
165            chain_id: self.chain_id,
166        })
167    }
168
169    /// Get auth namespace for API key management
170    pub fn auth(&self) -> Result<Auth, ClobError> {
171        let account = self
172            .account
173            .as_ref()
174            .ok_or_else(|| ClobError::validation("Account required for auth API"))?;
175
176        Ok(Auth {
177            http_client: self.http_client.clone(),
178            wallet: account.wallet().clone(),
179            credentials: account.credentials().clone(),
180            signer: account.signer().clone(),
181            chain_id: self.chain_id,
182        })
183    }
184
185    /// Create an unsigned order from parameters
186    pub async fn create_order(
187        &self,
188        params: &CreateOrderParams,
189        options: Option<PartialCreateOrderOptions>,
190    ) -> Result<Order, ClobError> {
191        let account = self
192            .account
193            .as_ref()
194            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
195
196        params.validate()?;
197
198        // Fetch market metadata (neg_risk and tick_size)
199        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
200
201        // Get fee rate
202        let fee_rate_bps = self.get_fee_rate(&params.token_id).await?;
203
204        // Calculate amounts
205        let (maker_amount, taker_amount) =
206            calculate_order_amounts(params.price, params.size, params.side, tick_size);
207
208        // Resolve maker address
209        let signature_type = params.signature_type.unwrap_or_default();
210        let maker = self
211            .resolve_maker_address(params.funder, signature_type, account)
212            .await?;
213
214        // Build order
215        Ok(Self::build_order(
216            params.token_id.clone(),
217            maker,
218            account.address(),
219            maker_amount,
220            taker_amount,
221            fee_rate_bps,
222            params.side,
223            signature_type,
224            neg_risk,
225            params.expiration,
226        ))
227    }
228
229    /// Create an unsigned market order from parameters
230    pub async fn create_market_order(
231        &self,
232        params: &MarketOrderArgs,
233        options: Option<PartialCreateOrderOptions>,
234    ) -> Result<Order, ClobError> {
235        let account = self
236            .account
237            .as_ref()
238            .ok_or_else(|| ClobError::validation("Account required to create order"))?;
239
240        if !params.amount.is_finite() {
241            return Err(ClobError::validation(
242                "Amount must be finite (no NaN or infinity)",
243            ));
244        }
245        if params.amount <= 0.0 {
246            return Err(ClobError::validation(format!(
247                "Amount must be positive, got {}",
248                params.amount
249            )));
250        }
251        if let Some(p) = params.price {
252            if !p.is_finite() || p <= 0.0 || p > 1.0 {
253                return Err(ClobError::validation(format!(
254                    "Price must be finite and between 0.0 and 1.0, got {}",
255                    p
256                )));
257            }
258        }
259
260        // Fetch market metadata (neg_risk and tick_size)
261        let (neg_risk, tick_size) = self.get_market_metadata(&params.token_id, options).await?;
262
263        // Determine price
264        let price = if let Some(p) = params.price {
265            p
266        } else {
267            // Fetch orderbook and calculate price
268            let book = self
269                .markets()
270                .order_book(params.token_id.clone())
271                .send()
272                .await?;
273
274            let levels = match params.side {
275                OrderSide::Buy => book.asks,
276                OrderSide::Sell => book.bids,
277            };
278
279            calculate_market_price(&levels, params.amount, params.side)
280                .ok_or_else(|| ClobError::validation("Not enough liquidity to fill market order"))?
281        };
282
283        // Get fee rate
284        let fee_rate_bps = self.get_fee_rate(&params.token_id).await?;
285
286        // Calculate amounts
287        let (maker_amount, taker_amount) =
288            calculate_market_order_amounts(params.amount, price, params.side, tick_size);
289
290        // Resolve maker address
291        let signature_type = params.signature_type.unwrap_or_default();
292        let maker = self
293            .resolve_maker_address(params.funder, signature_type, account)
294            .await?;
295
296        // Build order with expiration set to 0 for market orders
297        Ok(Self::build_order(
298            params.token_id.clone(),
299            maker,
300            account.address(),
301            maker_amount,
302            taker_amount,
303            fee_rate_bps,
304            params.side,
305            signature_type,
306            neg_risk,
307            Some(0),
308        ))
309    }
310    /// Sign an order using the configured account's EIP-712 signer.
311    pub async fn sign_order(&self, order: &Order) -> Result<SignedOrder, ClobError> {
312        let account = self
313            .account
314            .as_ref()
315            .ok_or_else(|| ClobError::validation("Account required to sign order"))?;
316        account.sign_order(order, self.chain_id).await
317    }
318
319    // Helper methods for order creation
320
321    /// Fetch market metadata (neg_risk and tick_size) for a token
322    async fn get_market_metadata(
323        &self,
324        token_id: &str,
325        options: Option<PartialCreateOrderOptions>,
326    ) -> Result<(bool, TickSize), ClobError> {
327        // Fetch or use provided neg_risk status
328        let neg_risk = if let Some(neg_risk) = options.and_then(|o| o.neg_risk) {
329            neg_risk
330        } else {
331            let neg_risk_resp = self.markets().neg_risk(token_id.to_string()).send().await?;
332            neg_risk_resp.neg_risk
333        };
334
335        // Fetch or use provided tick size
336        let tick_size = if let Some(tick_size) = options.and_then(|o| o.tick_size) {
337            tick_size
338        } else {
339            let tick_size_resp = self
340                .markets()
341                .tick_size(token_id.to_string())
342                .send()
343                .await?;
344            let tick_size_val = tick_size_resp
345                .minimum_tick_size
346                .parse::<f64>()
347                .map_err(|e| {
348                    ClobError::validation(format!("Invalid minimum_tick_size field: {}", e))
349                })?;
350            TickSize::try_from(tick_size_val)?
351        };
352
353        Ok((neg_risk, tick_size))
354    }
355
356    /// Fetch the current fee rate for a token from the API
357    async fn get_fee_rate(&self, token_id: &str) -> Result<String, ClobError> {
358        let resp = self.markets().fee_rate(token_id).send().await?;
359        Ok(resp.base_fee.to_string())
360    }
361
362    /// Resolve the maker address based on funder and signature type
363    async fn resolve_maker_address(
364        &self,
365        funder: Option<Address>,
366        signature_type: SignatureType,
367        account: &Account,
368    ) -> Result<Address, ClobError> {
369        if let Some(funder) = funder {
370            Ok(funder)
371        } else if signature_type.is_proxy() {
372            #[cfg(feature = "gamma")]
373            {
374                // Fetch proxy from Gamma
375                let profile = self
376                    .gamma
377                    .user()
378                    .get(account.address().to_string())
379                    .send()
380                    .await
381                    .map_err(|e| {
382                        ClobError::service(format!("Failed to fetch user profile: {}", e))
383                    })?;
384
385                profile
386                    .proxy
387                    .ok_or_else(|| {
388                        ClobError::validation(format!(
389                            "Signature type {:?} requires proxy, but none found for {}",
390                            signature_type,
391                            account.address()
392                        ))
393                    })?
394                    .parse::<Address>()
395                    .map_err(|e| {
396                        ClobError::validation(format!(
397                            "Invalid proxy address format from Gamma: {}",
398                            e
399                        ))
400                    })
401            }
402            #[cfg(not(feature = "gamma"))]
403            {
404                Err(ClobError::validation(format!(
405                    "Signature type {:?} requires the `gamma` feature to resolve proxy address; \
406                     enable `polyoxide-clob/gamma` or provide an explicit `funder` address",
407                    signature_type
408                )))
409            }
410        } else {
411            Ok(account.address())
412        }
413    }
414
415    /// Build an Order struct from the provided parameters
416    #[allow(clippy::too_many_arguments)]
417    fn build_order(
418        token_id: String,
419        maker: Address,
420        signer: Address,
421        maker_amount: String,
422        taker_amount: String,
423        fee_rate_bps: String,
424        side: OrderSide,
425        signature_type: SignatureType,
426        neg_risk: bool,
427        expiration: Option<u64>,
428    ) -> Order {
429        Order {
430            salt: generate_salt(),
431            maker,
432            signer,
433            taker: alloy::primitives::Address::ZERO,
434            token_id,
435            maker_amount,
436            taker_amount,
437            expiration: expiration.unwrap_or(0).to_string(),
438            nonce: "0".to_string(),
439            fee_rate_bps,
440            side,
441            signature_type,
442            neg_risk,
443        }
444    }
445
446    /// Post multiple signed orders (up to 15)
447    pub async fn post_orders(
448        &self,
449        orders: &[SignedOrderPayload],
450    ) -> Result<Vec<OrderResponse>, ClobError> {
451        let account = self
452            .account
453            .as_ref()
454            .ok_or_else(|| ClobError::validation("Account required to post orders"))?;
455
456        let auth = AuthMode::L2 {
457            address: account.address(),
458            credentials: account.credentials().clone(),
459            signer: account.signer().clone(),
460        };
461
462        let payload: Vec<_> = orders
463            .iter()
464            .map(|o| {
465                serde_json::json!({
466                    "order": o.order,
467                    "owner": account.credentials().key,
468                    "orderType": o.order_type,
469                    "postOnly": o.post_only,
470                })
471            })
472            .collect();
473
474        Request::post(
475            self.http_client.clone(),
476            "/orders".to_string(),
477            auth,
478            self.chain_id,
479        )
480        .body(&payload)?
481        .send()
482        .await
483    }
484
485    /// Post a signed order
486    pub async fn post_order(
487        &self,
488        signed_order: &SignedOrder,
489        order_type: OrderKind,
490        post_only: bool,
491    ) -> Result<OrderResponse, ClobError> {
492        let account = self
493            .account
494            .as_ref()
495            .ok_or_else(|| ClobError::validation("Account required to post order"))?;
496
497        let auth = AuthMode::L2 {
498            address: account.address(),
499            credentials: account.credentials().clone(),
500            signer: account.signer().clone(),
501        };
502
503        // Create the payload wrapping the signed order
504        let payload = serde_json::json!({
505            "order": signed_order,
506            "owner": account.credentials().key,
507            "orderType": order_type,
508            "postOnly": post_only,
509        });
510
511        Request::post(
512            self.http_client.clone(),
513            "/order".to_string(),
514            auth,
515            self.chain_id,
516        )
517        .body(&payload)?
518        .send()
519        .await
520    }
521
522    /// Create, sign, and post an order (convenience method)
523    pub async fn place_order(
524        &self,
525        params: &CreateOrderParams,
526        options: Option<PartialCreateOrderOptions>,
527    ) -> Result<OrderResponse, ClobError> {
528        let order = self.create_order(params, options).await?;
529        let signed_order = self.sign_order(&order).await?;
530        self.post_order(&signed_order, params.order_type, params.post_only)
531            .await
532    }
533
534    /// Create, sign, and post a market order (convenience method)
535    pub async fn place_market_order(
536        &self,
537        params: &MarketOrderArgs,
538        options: Option<PartialCreateOrderOptions>,
539    ) -> Result<OrderResponse, ClobError> {
540        let order = self.create_market_order(params, options).await?;
541        let signed_order = self.sign_order(&order).await?;
542
543        let order_type = params.order_type.unwrap_or(OrderKind::Fok);
544        // Market orders are usually FOK
545
546        self.post_order(&signed_order, order_type, false) // Market orders cannot be post_only
547            .await
548    }
549}
550
551/// Parameters for creating an order
552#[derive(Debug, Clone)]
553pub struct CreateOrderParams {
554    pub token_id: String,
555    pub price: f64,
556    pub size: f64,
557    pub side: OrderSide,
558    pub order_type: OrderKind,
559    pub post_only: bool,
560    pub expiration: Option<u64>,
561    pub funder: Option<Address>,
562    pub signature_type: Option<SignatureType>,
563}
564
565impl CreateOrderParams {
566    /// Validate price and size are finite and within expected ranges.
567    pub fn validate(&self) -> Result<(), ClobError> {
568        if !self.price.is_finite() || !self.size.is_finite() {
569            return Err(ClobError::validation(
570                "Price and size must be finite (no NaN or infinity)",
571            ));
572        }
573        if self.price <= 0.0 || self.price > 1.0 {
574            return Err(ClobError::validation(format!(
575                "Price must be between 0.0 and 1.0, got {}",
576                self.price
577            )));
578        }
579        if self.size <= 0.0 {
580            return Err(ClobError::validation(format!(
581                "Size must be positive, got {}",
582                self.size
583            )));
584        }
585        Ok(())
586    }
587}
588
589/// Payload for batch order submission via [`Clob::post_orders`]
590#[derive(Debug, Clone)]
591pub struct SignedOrderPayload {
592    pub order: SignedOrder,
593    pub order_type: OrderKind,
594    pub post_only: bool,
595}
596
597/// Builder for CLOB client
598pub struct ClobBuilder {
599    base_url: String,
600    timeout_ms: u64,
601    pool_size: usize,
602    chain: Chain,
603    account: Option<Account>,
604    #[cfg(feature = "gamma")]
605    gamma: Option<Gamma>,
606    retry_config: Option<RetryConfig>,
607    max_concurrent: Option<usize>,
608}
609
610impl ClobBuilder {
611    /// Create a new builder with default configuration
612    pub fn new() -> Self {
613        Self {
614            base_url: DEFAULT_BASE_URL.to_string(),
615            timeout_ms: DEFAULT_TIMEOUT_MS,
616            pool_size: DEFAULT_POOL_SIZE,
617            chain: Chain::PolygonMainnet,
618            account: None,
619            #[cfg(feature = "gamma")]
620            gamma: None,
621            retry_config: None,
622            max_concurrent: None,
623        }
624    }
625
626    /// Set account for the client
627    pub fn with_account(mut self, account: Account) -> Self {
628        self.account = Some(account);
629        self
630    }
631
632    /// Set base URL for the API
633    pub fn base_url(mut self, url: impl Into<String>) -> Self {
634        self.base_url = url.into();
635        self
636    }
637
638    /// Set request timeout in milliseconds
639    pub fn timeout_ms(mut self, timeout: u64) -> Self {
640        self.timeout_ms = timeout;
641        self
642    }
643
644    /// Set connection pool size
645    pub fn pool_size(mut self, size: usize) -> Self {
646        self.pool_size = size;
647        self
648    }
649
650    /// Set chain
651    pub fn chain(mut self, chain: Chain) -> Self {
652        self.chain = chain;
653        self
654    }
655
656    /// Set Gamma client
657    #[cfg(feature = "gamma")]
658    pub fn gamma(mut self, gamma: Gamma) -> Self {
659        self.gamma = Some(gamma);
660        self
661    }
662
663    /// Set retry configuration for 429 responses
664    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
665        self.retry_config = Some(config);
666        self
667    }
668
669    /// Set the maximum number of concurrent in-flight requests.
670    ///
671    /// Default: 8. Prevents Cloudflare 1015 errors from request bursts.
672    pub fn max_concurrent(mut self, max: usize) -> Self {
673        self.max_concurrent = Some(max);
674        self
675    }
676
677    /// Build the CLOB client
678    pub fn build(self) -> Result<Clob, ClobError> {
679        let mut builder = HttpClientBuilder::new(&self.base_url)
680            .timeout_ms(self.timeout_ms)
681            .pool_size(self.pool_size)
682            .with_rate_limiter(RateLimiter::clob_default())
683            .with_max_concurrent(self.max_concurrent.unwrap_or(8));
684        if let Some(config) = self.retry_config {
685            builder = builder.with_retry_config(config);
686        }
687        let http_client = builder.build()?;
688
689        #[cfg(feature = "gamma")]
690        let gamma = if let Some(gamma) = self.gamma {
691            gamma
692        } else {
693            polyoxide_gamma::Gamma::builder()
694                .timeout_ms(self.timeout_ms)
695                .pool_size(self.pool_size)
696                .build()
697                .map_err(|e| {
698                    ClobError::service(format!("Failed to build default Gamma client: {}", e))
699                })?
700        };
701
702        Ok(Clob {
703            http_client,
704            chain_id: self.chain.chain_id(),
705            account: self.account,
706            #[cfg(feature = "gamma")]
707            gamma,
708        })
709    }
710}
711
712impl Default for ClobBuilder {
713    fn default() -> Self {
714        Self::new()
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn test_builder_custom_max_concurrent() {
724        let builder = ClobBuilder::new().max_concurrent(16);
725        assert_eq!(builder.max_concurrent, Some(16));
726    }
727
728    #[tokio::test]
729    async fn test_default_concurrency_limit_is_8() {
730        let clob = Clob::public();
731        let mut permits = Vec::new();
732        for _ in 0..8 {
733            permits.push(clob.http_client.acquire_concurrency().await);
734        }
735        assert!(permits.iter().all(|p| p.is_some()));
736
737        let result = tokio::time::timeout(
738            std::time::Duration::from_millis(50),
739            clob.http_client.acquire_concurrency(),
740        )
741        .await;
742        assert!(
743            result.is_err(),
744            "9th permit should block with default limit of 8"
745        );
746    }
747
748    #[test]
749    fn test_builder_custom_retry_config() {
750        let config = RetryConfig {
751            max_retries: 5,
752            initial_backoff_ms: 1000,
753            max_backoff_ms: 30_000,
754        };
755        let builder = ClobBuilder::new().with_retry_config(config);
756        let config = builder.retry_config.unwrap();
757        assert_eq!(config.max_retries, 5);
758        assert_eq!(config.initial_backoff_ms, 1000);
759    }
760
761    fn make_params(price: f64, size: f64) -> CreateOrderParams {
762        CreateOrderParams {
763            token_id: "test".to_string(),
764            price,
765            size,
766            side: OrderSide::Buy,
767            order_type: OrderKind::Gtc,
768            post_only: false,
769            expiration: None,
770            funder: None,
771            signature_type: None,
772        }
773    }
774
775    #[test]
776    fn test_validate_rejects_nan_price() {
777        let params = make_params(f64::NAN, 100.0);
778        let err = params.validate().unwrap_err();
779        assert!(err.to_string().contains("finite"));
780    }
781
782    #[test]
783    fn test_validate_rejects_nan_size() {
784        let params = make_params(0.5, f64::NAN);
785        let err = params.validate().unwrap_err();
786        assert!(err.to_string().contains("finite"));
787    }
788
789    #[test]
790    fn test_validate_rejects_infinite_price() {
791        let params = make_params(f64::INFINITY, 100.0);
792        let err = params.validate().unwrap_err();
793        assert!(err.to_string().contains("finite"));
794    }
795
796    #[test]
797    fn test_validate_rejects_infinite_size() {
798        let params = make_params(0.5, f64::INFINITY);
799        let err = params.validate().unwrap_err();
800        assert!(err.to_string().contains("finite"));
801    }
802
803    #[test]
804    fn test_validate_rejects_neg_infinity_size() {
805        let params = make_params(0.5, f64::NEG_INFINITY);
806        let err = params.validate().unwrap_err();
807        assert!(err.to_string().contains("finite"));
808    }
809
810    #[test]
811    fn test_validate_rejects_price_out_of_range() {
812        let params = make_params(1.5, 100.0);
813        let err = params.validate().unwrap_err();
814        assert!(err.to_string().contains("between 0.0 and 1.0"));
815    }
816
817    #[test]
818    fn test_validate_rejects_zero_price() {
819        let params = make_params(0.0, 100.0);
820        let err = params.validate().unwrap_err();
821        assert!(err.to_string().contains("between 0.0 and 1.0"));
822    }
823
824    #[test]
825    fn test_validate_rejects_negative_size() {
826        let params = make_params(0.5, -10.0);
827        let err = params.validate().unwrap_err();
828        assert!(err.to_string().contains("positive"));
829    }
830
831    #[test]
832    fn test_validate_accepts_valid_params() {
833        let params = make_params(0.5, 100.0);
834        assert!(params.validate().is_ok());
835    }
836
837    #[test]
838    fn test_validate_accepts_boundary_price() {
839        // Price exactly 1.0 should be valid
840        let params = make_params(1.0, 100.0);
841        assert!(params.validate().is_ok());
842    }
843}