Skip to main content

limitless/
lclient.rs

1//! Unified client — the one-stop entry point for the Limitless Exchange API.
2//!
3//! `LimitlessClient` exposes every API method directly, so you never need
4//! to reach through intermediary managers. It also implements the
5//! [`Limitless`] trait for consistent construction.
6//!
7//! # Quick Start
8//!
9//! ```no_run
10//! use limitless::prelude::*;
11//!
12//! #[tokio::main]
13//! async fn main() -> Result<(), LimitlessError> {
14//!     // Reads LIMITLESS_API_KEY + LIMITLESS_API_SECRET from the environment
15//!     let api = LimitlessClient::builder().build()?;
16//!
17//!     // Public: browse markets (no auth)
18//!     let active = api.browse_active(None, None, Some(10), None, None, None).await?;
19//!
20//!     // Public: get orderbook
21//!     let ob = api.get_orderbook("btc-above-100k").await?;
22//!
23//!     // Authenticated: positions
24//!     let positions = api.get_positions().await?;
25//!
26//!     // Authenticated: place a limit buy — one call does it all
27//!     api.buy_gtc(private_key, "btc-above-100k", token_id, 0.51, 10.0, owner_id).await?;
28//!
29//!     Ok(())
30//! }
31//! ```
32
33use crate::prelude::*;
34use crate::retry::RetryConfig;
35
36/// The primary entry point for all Limitless Exchange API operations.
37///
38/// Every REST endpoint and convenience method is available directly on this
39/// struct. The internal manager types ([`Markets`], [`Trader`], [`Portfolio`],
40/// [`Navigation`], [`Stream`]) are still available through accessor methods
41/// when you need them, but for the common case you never have to think about
42/// them.
43///
44/// # Authentication
45///
46/// Credentials are read automatically from environment variables
47/// `LIMITLESS_API_KEY` and `LIMITLESS_API_SECRET` when not explicitly set
48/// on the builder.
49#[derive(Clone)]
50pub struct LimitlessClient {
51    /// Underlying HTTP/WS client (shared by all managers).
52    client: Client,
53    /// Configuration (endpoints, recv window).
54    config: Config,
55    /// Retry policy.
56    retry_config: RetryConfig,
57}
58
59// ═══════════════════════════════════════════════════════════════════════════
60//  Construction — Limitless trait + builder
61// ═══════════════════════════════════════════════════════════════════════════
62
63impl Limitless for LimitlessClient {
64    fn new(api_key: Option<String>, secret: Option<String>) -> Self {
65        Self::new_with_config(&Config::default(), api_key, secret)
66    }
67
68    fn new_with_config(config: &Config, api_key: Option<String>, secret: Option<String>) -> Self {
69        Self {
70            client: Client::new(
71                api_key.clone(),
72                secret.clone(),
73                config.rest_api_endpoint.to_string(),
74            ),
75            config: config.clone(),
76            retry_config: RetryConfig::default(),
77        }
78    }
79}
80
81impl LimitlessClient {
82    /// Create a new [`LimitlessClientBuilder`].
83    pub fn builder() -> LimitlessClientBuilder {
84        LimitlessClientBuilder::default()
85    }
86
87    /// Access the underlying HTTP client for custom / advanced requests.
88    pub fn raw_client(&self) -> &Client {
89        &self.client
90    }
91
92    /// Update credentials at runtime.
93    pub fn set_credentials(&mut self, api_key: Option<String>, secret_key: Option<String>) {
94        self.client = Client::new(
95            api_key.clone(),
96            secret_key.clone(),
97            self.config.rest_api_endpoint.to_string(),
98        );
99    }
100
101    /// The current retry configuration.
102    pub fn retry_config(&self) -> &RetryConfig {
103        &self.retry_config
104    }
105
106    /// The current REST API base URL.
107    pub fn base_url(&self) -> &str {
108        &self.config.rest_api_endpoint
109    }
110
111    // ── Sub-manager accessors (for advanced / standalone use) ──────────
112
113    /// Access the raw [`Markets`] manager.
114    pub fn markets(&self) -> Markets {
115        Markets::new_with_config(
116            &self.config,
117            self.client.api_key.clone(),
118            self.client.secret_key.clone(),
119        )
120    }
121
122    /// Access the raw [`Trader`] manager.
123    pub fn trader(&self) -> Trader {
124        Trader::new_with_config(
125            &self.config,
126            self.client.api_key.clone(),
127            self.client.secret_key.clone(),
128        )
129    }
130
131    /// Access the raw [`Portfolio`] manager.
132    pub fn portfolio(&self) -> Portfolio {
133        Portfolio::new_with_config(
134            &self.config,
135            self.client.api_key.clone(),
136            self.client.secret_key.clone(),
137        )
138    }
139
140    /// Access the raw [`Navigation`] manager.
141    pub fn navigation(&self) -> Navigation {
142        Navigation::new_with_config(
143            &self.config,
144            self.client.api_key.clone(),
145            self.client.secret_key.clone(),
146        )
147    }
148
149    /// Access the raw [`Stream`] manager for WebSocket subscriptions.
150    pub fn stream(&self) -> Stream {
151        Stream::new_with_config(
152            &self.config,
153            self.client.api_key.clone(),
154            self.client.secret_key.clone(),
155        )
156    }
157
158    // ═══════════════════════════════════════════════════════════════════
159    //  Markets — public market data
160    // ═══════════════════════════════════════════════════════════════════
161
162    /// Browse all active (unresolved) markets with optional filters.
163    pub async fn browse_active(
164        &self,
165        category_id: Option<u64>,
166        page: Option<u64>,
167        limit: Option<u64>,
168        sort_by: Option<String>,
169        trade_type: Option<String>,
170        automation_type: Option<String>,
171    ) -> Result<ActiveMarketsResponse, LimitlessError> {
172        self.markets()
173            .browse_active(
174                category_id,
175                page,
176                limit,
177                sort_by,
178                trade_type,
179                automation_type,
180            )
181            .await
182    }
183
184    /// Get the count of active markets per category.
185    pub async fn get_category_counts(&self) -> Result<CategoryCountResponse, LimitlessError> {
186        self.markets().get_category_counts().await
187    }
188
189    /// Get all active market slugs with metadata.
190    pub async fn get_active_slugs(&self) -> Result<Vec<ActiveSlug>, LimitlessError> {
191        self.markets().get_active_slugs().await
192    }
193
194    /// Get detailed market information by address or slug.
195    pub async fn get_market(&self, address_or_slug: &str) -> Result<MarketDetail, LimitlessError> {
196        self.markets().get_market(address_or_slug).await
197    }
198
199    /// Get Chainlink oracle candlestick data for a market.
200    pub async fn get_oracle_candles(
201        &self,
202        address_or_slug: &str,
203        interval: Option<&str>,
204        from: Option<u64>,
205        to: Option<u64>,
206    ) -> Result<OracleCandlesResponse, LimitlessError> {
207        self.markets()
208            .get_oracle_candles(address_or_slug, interval, from, to)
209            .await
210    }
211
212    /// Get feed events (trades, orders, liquidity) for a market.
213    pub async fn get_feed_events(
214        &self,
215        slug: &str,
216        page: Option<u64>,
217        limit: Option<u64>,
218    ) -> Result<FeedEventsResponse, LimitlessError> {
219        self.markets().get_feed_events(slug, page, limit).await
220    }
221
222    /// Semantic search for markets using natural language queries.
223    pub async fn search_markets(
224        &self,
225        query: &str,
226        limit: Option<u64>,
227        page: Option<u64>,
228        similarity_threshold: Option<f64>,
229    ) -> Result<SearchResponse, LimitlessError> {
230        self.markets()
231            .search(query, limit, page, similarity_threshold)
232            .await
233    }
234
235    // ═══════════════════════════════════════════════════════════════════
236    //  Trading — orders, orderbook, cancels
237    // ═══════════════════════════════════════════════════════════════════
238
239    /// Create a new order from a raw JSON body.
240    pub async fn create_order(
241        &self,
242        order_request: &str,
243    ) -> Result<CreateOrderResponse, LimitlessError> {
244        self.trader().create_order(order_request).await
245    }
246
247    /// Fetch statuses for multiple orders in batch.
248    pub async fn order_status_batch(
249        &self,
250        request_body: &str,
251    ) -> Result<OrderStatusBatchResponse, LimitlessError> {
252        self.trader().order_status_batch(request_body).await
253    }
254
255    /// Cancel a single order by orderId or clientOrderId.
256    pub async fn cancel_combined(
257        &self,
258        request_body: &str,
259    ) -> Result<CancelOrderResponse, LimitlessError> {
260        self.trader().cancel_combined(request_body).await
261    }
262
263    /// Cancel multiple orders by internal orderIds.
264    pub async fn cancel_batch(
265        &self,
266        request_body: &str,
267    ) -> Result<CancelBatchResponse, LimitlessError> {
268        self.trader().cancel_batch(request_body).await
269    }
270
271    /// Cancel a single order by internal orderId.
272    pub async fn cancel_order_by_id(
273        &self,
274        order_id: &str,
275    ) -> Result<CancelOrderResponse, LimitlessError> {
276        self.trader().cancel_order_by_id(order_id).await
277    }
278
279    /// Cancel all orders for the authenticated user in a specific market.
280    pub async fn cancel_all_in_market(
281        &self,
282        slug: &str,
283    ) -> Result<CancelAllResponse, LimitlessError> {
284        self.trader().cancel_all_in_market(slug).await
285    }
286
287    /// Get the current orderbook for a market.
288    pub async fn get_orderbook(&self, slug: &str) -> Result<OrderbookResponse, LimitlessError> {
289        self.trader().get_orderbook(slug).await
290    }
291
292    /// Get historical price data for a market.
293    pub async fn get_historical_prices(
294        &self,
295        slug: &str,
296        interval: Option<&str>,
297    ) -> Result<Vec<HistoricalPriceData>, LimitlessError> {
298        self.trader().get_historical_prices(slug, interval).await
299    }
300
301    /// Get the amount of funds locked in open orders.
302    pub async fn get_locked_balance(
303        &self,
304        slug: &str,
305    ) -> Result<LockedBalanceResponse, LimitlessError> {
306        self.trader().get_locked_balance(slug).await
307    }
308
309    /// Get all orders placed by the authenticated user in a market.
310    pub async fn get_user_orders(
311        &self,
312        slug: &str,
313        statuses: Option<&[&str]>,
314        limit: Option<u64>,
315    ) -> Result<UserOrdersResponse, LimitlessError> {
316        self.trader().get_user_orders(slug, statuses, limit).await
317    }
318
319    /// Get recent market events (trades, orders, liquidity changes).
320    pub async fn get_market_events(
321        &self,
322        slug: &str,
323        page: Option<u64>,
324        limit: Option<u64>,
325    ) -> Result<MarketEventsResponse, LimitlessError> {
326        self.trader().get_market_events(slug, page, limit).await
327    }
328
329    // ── High-level order placement ────────────────────────────────────
330
331    /// Place a GTC buy limit order — one call does it all.
332    ///
333    /// Automatically fetches the venue contract, builds, signs, and submits.
334    pub async fn buy_gtc(
335        &self,
336        private_key: &str,
337        market_slug: &str,
338        token_id: &str,
339        price: f64,
340        size: f64,
341        owner_id: u64,
342    ) -> Result<CreateOrderResponse, LimitlessError> {
343        self.trader()
344            .buy_gtc(private_key, market_slug, token_id, price, size, owner_id)
345            .await
346    }
347
348    /// Place a GTC sell limit order — one call does it all.
349    pub async fn sell_gtc(
350        &self,
351        private_key: &str,
352        market_slug: &str,
353        token_id: &str,
354        price: f64,
355        size: f64,
356        owner_id: u64,
357    ) -> Result<CreateOrderResponse, LimitlessError> {
358        self.trader()
359            .sell_gtc(private_key, market_slug, token_id, price, size, owner_id)
360            .await
361    }
362
363    /// Place a FOK buy market order — one call does it all.
364    pub async fn buy_fok(
365        &self,
366        private_key: &str,
367        market_slug: &str,
368        token_id: &str,
369        usdc_amount: f64,
370        owner_id: u64,
371    ) -> Result<CreateOrderResponse, LimitlessError> {
372        self.trader()
373            .buy_fok(private_key, market_slug, token_id, usdc_amount, owner_id)
374            .await
375    }
376
377    /// Place a FOK sell market order — one call does it all.
378    pub async fn sell_fok(
379        &self,
380        private_key: &str,
381        market_slug: &str,
382        token_id: &str,
383        share_amount: f64,
384        owner_id: u64,
385    ) -> Result<CreateOrderResponse, LimitlessError> {
386        self.trader()
387            .sell_fok(private_key, market_slug, token_id, share_amount, owner_id)
388            .await
389    }
390
391    /// Cancel all open orders in a market (convenience alias).
392    pub async fn cancel_all(&self, slug: &str) -> Result<CancelAllResponse, LimitlessError> {
393        self.trader().cancel_all(slug).await
394    }
395
396    // ═══════════════════════════════════════════════════════════════════
397    //  Portfolio — profile, positions, PnL, history
398    // ═══════════════════════════════════════════════════════════════════
399
400    /// Get your own profile by wallet address.
401    pub async fn get_profile(&self, account: &str) -> Result<ProfileResponse, LimitlessError> {
402        self.portfolio().get_profile(account).await
403    }
404
405    /// Retrieve all AMM trades for the authenticated user.
406    pub async fn get_trades(&self) -> Result<Vec<TradeEntry>, LimitlessError> {
407        self.portfolio().get_trades().await
408    }
409
410    /// Retrieve all active positions with P&L and market values.
411    pub async fn get_positions(&self) -> Result<PositionsResponse, LimitlessError> {
412        self.portfolio().get_positions().await
413    }
414
415    /// Get PnL chart data.
416    pub async fn get_pnl_chart(
417        &self,
418        timeframe: Option<&str>,
419    ) -> Result<PnlChartResponse, LimitlessError> {
420        self.portfolio().get_pnl_chart(timeframe).await
421    }
422
423    /// Get points breakdown for the authenticated user.
424    pub async fn get_points(&self) -> Result<PointsResponse, LimitlessError> {
425        self.portfolio().get_points().await
426    }
427
428    /// Get cursor-paginated portfolio history.
429    pub async fn get_history(
430        &self,
431        cursor: Option<&str>,
432        limit: Option<u64>,
433    ) -> Result<HistoryResponse, LimitlessError> {
434        self.portfolio().get_history(cursor, limit).await
435    }
436
437    /// Check USDC allowance for CLOB or NegRisk trading.
438    pub async fn get_allowance(
439        &self,
440        allowance_type: &str,
441        spender: Option<&str>,
442    ) -> Result<AllowanceResponse, LimitlessError> {
443        self.portfolio()
444            .get_allowance(allowance_type, spender)
445            .await
446    }
447
448    // ═══════════════════════════════════════════════════════════════════
449    //  Navigation — market discovery
450    // ═══════════════════════════════════════════════════════════════════
451
452    /// Get the full hierarchical navigation tree.
453    pub async fn get_navigation_tree(&self) -> Result<Vec<NavigationNode>, LimitlessError> {
454        self.navigation().get_navigation_tree().await
455    }
456
457    /// Resolve a URL path to a market page configuration.
458    pub async fn get_page_by_path(&self, path: &str) -> Result<MarketPage, LimitlessError> {
459        self.navigation().get_page_by_path(path).await
460    }
461
462    /// List markets belonging to a specific market page.
463    pub async fn list_page_markets(
464        &self,
465        page_id: &str,
466        cursor: Option<&str>,
467        page: Option<u64>,
468        limit: Option<u64>,
469        sort_by: Option<&str>,
470        filters: Option<&BTreeMap<String, String>>,
471    ) -> Result<PageMarketsResponse, LimitlessError> {
472        self.navigation()
473            .list_page_markets(page_id, cursor, page, limit, sort_by, filters)
474            .await
475    }
476
477    /// List all property keys with their options.
478    pub async fn list_property_keys(&self) -> Result<Vec<PropertyKey>, LimitlessError> {
479        self.navigation().list_property_keys().await
480    }
481
482    /// Get a specific property key by ID.
483    pub async fn get_property_key(&self, key_id: &str) -> Result<PropertyKey, LimitlessError> {
484        self.navigation().get_property_key(key_id).await
485    }
486
487    /// List options for a specific property key.
488    pub async fn list_property_options(
489        &self,
490        key_id: &str,
491        parent_id: Option<&str>,
492    ) -> Result<Vec<PropertyOption>, LimitlessError> {
493        self.navigation()
494            .list_property_options(key_id, parent_id)
495            .await
496    }
497
498    // ═══════════════════════════════════════════════════════════════════
499    //  WebSocket
500    // ═══════════════════════════════════════════════════════════════════
501
502    /// Test WebSocket connectivity with a ping/pong.
503    pub async fn ws_ping(&self) -> Result<(), LimitlessError> {
504        self.stream().ws_ping().await
505    }
506
507    /// Subscribe to WebSocket events with a handler callback.
508    pub async fn ws_subscribe<F>(&self, handler: F) -> Result<(), LimitlessError>
509    where
510        F: FnMut(Value) -> Result<(), LimitlessError> + 'static + Send,
511    {
512        self.stream().ws_subscribe(handler).await
513    }
514
515    /// Subscribe to WebSocket events with dynamic command support.
516    pub async fn ws_subscribe_with_commands<F>(
517        &self,
518        cmd_receiver: tokio::sync::mpsc::UnboundedReceiver<String>,
519        handler: F,
520    ) -> Result<(), LimitlessError>
521    where
522        F: FnMut(Value) -> Result<(), LimitlessError> + 'static + Send,
523    {
524        self.stream()
525            .ws_subscribe_with_commands(cmd_receiver, handler)
526            .await
527    }
528
529    /// Subscribe to WebSocket events with dynamic command support **and authentication**.
530    ///
531    /// Enables private channels: `subscribe_positions`, `subscribe_order_events`.
532    pub async fn ws_subscribe_authenticated_with_commands<F>(
533        &self,
534        cmd_receiver: tokio::sync::mpsc::UnboundedReceiver<String>,
535        handler: F,
536    ) -> Result<(), LimitlessError>
537    where
538        F: FnMut(Value) -> Result<(), LimitlessError> + 'static + Send,
539    {
540        self.stream()
541            .ws_subscribe_authenticated_with_commands(cmd_receiver, handler)
542            .await
543    }
544
545    /// Subscribe to typed WebSocket events with authentication.
546    ///
547    /// Enables private channels: `positions`, `orderEvent`.
548    pub async fn ws_subscribe_authenticated_events<F>(
549        &self,
550        handler: F,
551    ) -> Result<(), LimitlessError>
552    where
553        F: FnMut(WsEventKind) -> Result<(), LimitlessError> + 'static + Send,
554    {
555        self.stream()
556            .ws_subscribe_authenticated_events(handler)
557            .await
558    }
559}
560
561// ═══════════════════════════════════════════════════════════════════════════
562//  Builder
563// ═══════════════════════════════════════════════════════════════════════════
564
565/// Builder for [`LimitlessClient`].
566///
567/// # Example
568///
569/// ```no_run
570/// use limitless::prelude::*;
571///
572/// let api = LimitlessClient::builder()
573///     .api_key("lmts_sk_...")
574///     .secret("base64_secret")
575///     .build()
576///     .unwrap();
577/// ```
578#[derive(Default)]
579pub struct LimitlessClientBuilder {
580    api_key: Option<String>,
581    secret_key: Option<String>,
582    rest_endpoint: Option<String>,
583    ws_endpoint: Option<String>,
584    recv_window: Option<u64>,
585    retry_config: Option<RetryConfig>,
586}
587
588impl LimitlessClientBuilder {
589    /// Set the API key (token ID for scoped HMAC tokens, or legacy API key).
590    pub fn api_key(mut self, key: impl Into<String>) -> Self {
591        self.api_key = Some(key.into());
592        self
593    }
594
595    /// Set the API secret (base64-encoded HMAC secret for scoped tokens).
596    pub fn secret(mut self, secret: impl Into<String>) -> Self {
597        self.secret_key = Some(secret.into());
598        self
599    }
600
601    /// Use a custom REST API endpoint.
602    pub fn rest_endpoint(mut self, url: impl Into<String>) -> Self {
603        self.rest_endpoint = Some(url.into());
604        self
605    }
606
607    /// Use a custom WebSocket endpoint.
608    pub fn ws_endpoint(mut self, url: impl Into<String>) -> Self {
609        self.ws_endpoint = Some(url.into());
610        self
611    }
612
613    /// Set the receive window for HMAC request validation (milliseconds).
614    pub fn recv_window(mut self, ms: u64) -> Self {
615        self.recv_window = Some(ms);
616        self
617    }
618
619    /// Use Base Sepolia testnet endpoints.
620    pub fn testnet(mut self, use_testnet: bool) -> Self {
621        if use_testnet {
622            self.rest_endpoint = Some("https://api.testnet.limitless.exchange".into());
623            self.ws_endpoint = Some("wss://ws.testnet.limitless.exchange/markets".into());
624        }
625        self
626    }
627
628    /// Configure retry behavior.
629    pub fn retry_config(mut self, config: RetryConfig) -> Self {
630        self.retry_config = Some(config);
631        self
632    }
633
634    /// Disable automatic retries.
635    pub fn no_retry(mut self) -> Self {
636        self.retry_config = Some(RetryConfig::none());
637        self
638    }
639
640    /// Build the [`LimitlessClient`].
641    ///
642    /// Falls back to `LIMITLESS_API_KEY` and `LIMITLESS_API_SECRET` env vars.
643    pub fn build(self) -> Result<LimitlessClient, LimitlessError> {
644        let api_key = self
645            .api_key
646            .or_else(|| std::env::var("LIMITLESS_API_KEY").ok())
647            .filter(|k| !k.is_empty());
648
649        let secret_key = self
650            .secret_key
651            .or_else(|| std::env::var("LIMITLESS_API_SECRET").ok())
652            .filter(|s| !s.is_empty());
653
654        let rest_endpoint = self
655            .rest_endpoint
656            .unwrap_or_else(|| Config::DEFAULT_REST_API_ENDPOINT.into());
657
658        let ws_endpoint = self
659            .ws_endpoint
660            .unwrap_or_else(|| Config::DEFAULT_WS_ENDPOINT.into());
661
662        let recv_window = self.recv_window.unwrap_or(5000);
663
664        let config = Config::new(rest_endpoint, ws_endpoint, recv_window);
665        let retry_config = self.retry_config.unwrap_or_default();
666
667        if api_key.is_none() && secret_key.is_none() {
668            log::warn!(
669                "No API credentials provided — authenticated endpoints will fail. \
670                 Set LIMITLESS_API_KEY + LIMITLESS_API_SECRET environment variables \
671                 or pass credentials to the builder."
672            );
673        }
674
675        Ok(LimitlessClient {
676            client: Client::new(
677                api_key.clone(),
678                secret_key.clone(),
679                config.rest_api_endpoint.to_string(),
680            ),
681            config,
682            retry_config,
683        })
684    }
685}
686
687// ── Convenience: default client from env vars ──
688
689impl Default for LimitlessClient {
690    fn default() -> Self {
691        Self::builder()
692            .build()
693            .expect("Failed to create default LimitlessClient")
694    }
695}