Skip to main content

polyoxide_data/api/
users.rs

1use polyoxide_core::{HttpClient, QueryBuilder, Request};
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    error::DataApiError,
6    types::{
7        Activity, ActivitySortBy, ActivityType, ClosedPosition, ClosedPositionSortBy, Position,
8        PositionSortBy, SortDirection, Trade, TradeFilterType, TradeSide, UserValue,
9    },
10};
11
12/// User namespace for user-related operations
13#[derive(Clone)]
14pub struct UserApi {
15    pub(crate) http_client: HttpClient,
16    pub(crate) user_address: String,
17}
18
19impl UserApi {
20    /// List positions for this user
21    pub fn list_positions(&self) -> ListPositions {
22        let mut request = Request::new(self.http_client.clone(), "/positions");
23        request = request.query("user", &self.user_address);
24
25        ListPositions { request }
26    }
27
28    /// Get total value of this user's positions
29    pub fn positions_value(&self) -> GetPositionValue {
30        let mut request = Request::new(self.http_client.clone(), "/value");
31        request = request.query("user", &self.user_address);
32
33        GetPositionValue { request }
34    }
35
36    /// List closed positions for this user
37    pub fn closed_positions(&self) -> ListClosedPositions {
38        let mut request = Request::new(self.http_client.clone(), "/closed-positions");
39        request = request.query("user", &self.user_address);
40
41        ListClosedPositions { request }
42    }
43
44    /// List trades for this user
45    pub fn trades(&self) -> ListUserTrades {
46        let mut request = Request::new(self.http_client.clone(), "/trades");
47        request = request.query("user", &self.user_address);
48
49        ListUserTrades { request }
50    }
51
52    /// List activity for this user
53    pub fn activity(&self) -> ListActivity {
54        let mut request = Request::new(self.http_client.clone(), "/activity");
55        request = request.query("user", &self.user_address);
56
57        ListActivity { request }
58    }
59
60    /// Get total markets traded by this user
61    pub async fn traded(&self) -> Result<UserTraded, DataApiError> {
62        Request::<UserTraded, DataApiError>::new(self.http_client.clone(), "/traded")
63            .query("user", &self.user_address)
64            .send()
65            .await
66    }
67}
68
69/// User's total markets traded count
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct UserTraded {
72    /// User address
73    pub user: String,
74    /// Total count of distinct markets traded
75    pub traded: u64,
76}
77
78/// Request builder for listing user positions
79pub struct ListPositions {
80    request: Request<Vec<Position>, DataApiError>,
81}
82
83impl ListPositions {
84    /// Filter by specific market condition IDs (comma-separated)
85    pub fn market(mut self, condition_ids: impl IntoIterator<Item = impl ToString>) -> Self {
86        let ids: Vec<String> = condition_ids.into_iter().map(|s| s.to_string()).collect();
87        if !ids.is_empty() {
88            self.request = self.request.query("market", ids.join(","));
89        }
90        self
91    }
92
93    /// Filter by event IDs (comma-separated)
94    pub fn event_id(mut self, event_ids: impl IntoIterator<Item = impl ToString>) -> Self {
95        let ids: Vec<String> = event_ids.into_iter().map(|s| s.to_string()).collect();
96        if !ids.is_empty() {
97            self.request = self.request.query("eventId", ids.join(","));
98        }
99        self
100    }
101
102    /// Set minimum position size filter (default: 1)
103    pub fn size_threshold(mut self, threshold: f64) -> Self {
104        self.request = self.request.query("sizeThreshold", threshold);
105        self
106    }
107
108    /// Filter for redeemable positions only
109    pub fn redeemable(mut self, redeemable: bool) -> Self {
110        self.request = self.request.query("redeemable", redeemable);
111        self
112    }
113
114    /// Filter for mergeable positions only
115    pub fn mergeable(mut self, mergeable: bool) -> Self {
116        self.request = self.request.query("mergeable", mergeable);
117        self
118    }
119
120    /// Set maximum number of results (0-500, default: 100)
121    pub fn limit(mut self, limit: u32) -> Self {
122        self.request = self.request.query("limit", limit);
123        self
124    }
125
126    /// Set pagination offset (0-10000, default: 0)
127    pub fn offset(mut self, offset: u32) -> Self {
128        self.request = self.request.query("offset", offset);
129        self
130    }
131
132    /// Set sort field
133    pub fn sort_by(mut self, sort_by: PositionSortBy) -> Self {
134        self.request = self.request.query("sortBy", sort_by);
135        self
136    }
137
138    /// Set sort direction (default: DESC)
139    pub fn sort_direction(mut self, direction: SortDirection) -> Self {
140        self.request = self.request.query("sortDirection", direction);
141        self
142    }
143
144    /// Filter by market title (max 100 chars)
145    pub fn title(mut self, title: impl Into<String>) -> Self {
146        self.request = self.request.query("title", title.into());
147        self
148    }
149
150    /// Execute the request
151    pub async fn send(self) -> Result<Vec<Position>, DataApiError> {
152        self.request.send().await
153    }
154}
155
156/// Request builder for getting total position value
157pub struct GetPositionValue {
158    request: Request<Vec<UserValue>, DataApiError>,
159}
160
161impl GetPositionValue {
162    /// Filter by specific market condition IDs (comma-separated)
163    pub fn market(mut self, condition_ids: impl IntoIterator<Item = impl ToString>) -> Self {
164        let ids: Vec<String> = condition_ids.into_iter().map(|s| s.to_string()).collect();
165        if !ids.is_empty() {
166            self.request = self.request.query("market", ids.join(","));
167        }
168        self
169    }
170
171    /// Execute the request
172    pub async fn send(self) -> Result<Vec<UserValue>, DataApiError> {
173        self.request.send().await
174    }
175}
176
177/// Request builder for listing closed positions
178pub struct ListClosedPositions {
179    request: Request<Vec<ClosedPosition>, DataApiError>,
180}
181
182impl ListClosedPositions {
183    /// Filter by specific market condition IDs (comma-separated)
184    pub fn market(mut self, condition_ids: impl IntoIterator<Item = impl ToString>) -> Self {
185        let ids: Vec<String> = condition_ids.into_iter().map(|s| s.to_string()).collect();
186        if !ids.is_empty() {
187            self.request = self.request.query("market", ids.join(","));
188        }
189        self
190    }
191
192    /// Filter by event IDs (comma-separated)
193    pub fn event_id(mut self, event_ids: impl IntoIterator<Item = impl ToString>) -> Self {
194        let ids: Vec<String> = event_ids.into_iter().map(|s| s.to_string()).collect();
195        if !ids.is_empty() {
196            self.request = self.request.query("eventId", ids.join(","));
197        }
198        self
199    }
200
201    /// Filter by market title (max 100 chars)
202    pub fn title(mut self, title: impl Into<String>) -> Self {
203        self.request = self.request.query("title", title.into());
204        self
205    }
206
207    /// Set maximum number of results (0-50, default: 10).
208    ///
209    /// Unlike most other `limit()` builders in this crate, this cap is
210    /// strictly enforced server-side: passing a value above 50 fails the
211    /// request with a 400 ("max closed positions limit of 50 exceeded")
212    /// rather than being clamped or paginated by the API.
213    pub fn limit(mut self, limit: u32) -> Self {
214        self.request = self.request.query("limit", limit);
215        self
216    }
217
218    /// Set pagination offset (0-100000, default: 0)
219    pub fn offset(mut self, offset: u32) -> Self {
220        self.request = self.request.query("offset", offset);
221        self
222    }
223
224    /// Set sort field (default: REALIZED_PNL)
225    pub fn sort_by(mut self, sort_by: ClosedPositionSortBy) -> Self {
226        self.request = self.request.query("sortBy", sort_by);
227        self
228    }
229
230    /// Set sort direction (default: DESC)
231    pub fn sort_direction(mut self, direction: SortDirection) -> Self {
232        self.request = self.request.query("sortDirection", direction);
233        self
234    }
235
236    /// Execute the request
237    pub async fn send(self) -> Result<Vec<ClosedPosition>, DataApiError> {
238        self.request.send().await
239    }
240}
241
242/// Request builder for listing user trades
243pub struct ListUserTrades {
244    request: Request<Vec<Trade>, DataApiError>,
245}
246
247impl ListUserTrades {
248    /// Filter by market condition IDs (comma-separated)
249    /// Note: Mutually exclusive with `event_id`
250    pub fn market(mut self, condition_ids: impl IntoIterator<Item = impl ToString>) -> Self {
251        let ids: Vec<String> = condition_ids.into_iter().map(|s| s.to_string()).collect();
252        if !ids.is_empty() {
253            self.request = self.request.query("market", ids.join(","));
254        }
255        self
256    }
257
258    /// Filter by event IDs (comma-separated)
259    /// Note: Mutually exclusive with `market`
260    pub fn event_id(mut self, event_ids: impl IntoIterator<Item = impl ToString>) -> Self {
261        let ids: Vec<String> = event_ids.into_iter().map(|s| s.to_string()).collect();
262        if !ids.is_empty() {
263            self.request = self.request.query("eventId", ids.join(","));
264        }
265        self
266    }
267
268    /// Filter by trade side (BUY or SELL)
269    pub fn side(mut self, side: TradeSide) -> Self {
270        self.request = self.request.query("side", side);
271        self
272    }
273
274    /// Filter for taker trades only (default: true)
275    pub fn taker_only(mut self, taker_only: bool) -> Self {
276        self.request = self.request.query("takerOnly", taker_only);
277        self
278    }
279
280    /// Set filter type (must be paired with `filter_amount`)
281    pub fn filter_type(mut self, filter_type: TradeFilterType) -> Self {
282        self.request = self.request.query("filterType", filter_type);
283        self
284    }
285
286    /// Set filter amount (must be paired with `filter_type`)
287    pub fn filter_amount(mut self, amount: f64) -> Self {
288        self.request = self.request.query("filterAmount", amount);
289        self
290    }
291
292    /// Set maximum number of results (0-10000, default: 100)
293    pub fn limit(mut self, limit: u32) -> Self {
294        self.request = self.request.query("limit", limit);
295        self
296    }
297
298    /// Set pagination offset (0-10000, default: 0)
299    pub fn offset(mut self, offset: u32) -> Self {
300        self.request = self.request.query("offset", offset);
301        self
302    }
303
304    /// Execute the request
305    pub async fn send(self) -> Result<Vec<Trade>, DataApiError> {
306        self.request.send().await
307    }
308}
309
310/// Request builder for listing user activity
311pub struct ListActivity {
312    request: Request<Vec<Activity>, DataApiError>,
313}
314
315impl ListActivity {
316    /// Filter by market condition IDs (comma-separated)
317    pub fn market(mut self, condition_ids: impl IntoIterator<Item = impl ToString>) -> Self {
318        let ids: Vec<String> = condition_ids.into_iter().map(|s| s.to_string()).collect();
319        if !ids.is_empty() {
320            self.request = self.request.query("market", ids.join(","));
321        }
322        self
323    }
324
325    /// Filter by event IDs (comma-separated)
326    pub fn event_id(mut self, event_ids: impl IntoIterator<Item = impl ToString>) -> Self {
327        let ids: Vec<String> = event_ids.into_iter().map(|s| s.to_string()).collect();
328        if !ids.is_empty() {
329            self.request = self.request.query("eventId", ids.join(","));
330        }
331        self
332    }
333
334    /// Filter by activity types (comma-separated). `ActivityType::Unknown` is
335    /// silently dropped since the upstream API has no matching value to filter on.
336    pub fn activity_type(mut self, types: impl IntoIterator<Item = ActivityType>) -> Self {
337        let type_strs: Vec<String> = types
338            .into_iter()
339            .filter(|t| *t != ActivityType::Unknown)
340            .map(|t| t.to_string())
341            .collect();
342        if !type_strs.is_empty() {
343            self.request = self.request.query("type", type_strs.join(","));
344        }
345        self
346    }
347
348    /// Filter by trade side (BUY or SELL)
349    pub fn side(mut self, side: TradeSide) -> Self {
350        self.request = self.request.query("side", side);
351        self
352    }
353
354    /// Lower-bound timestamp (epoch seconds) for the activity window.
355    ///
356    /// Omit or pass `0` for the default window (most recent ~3 years); pass a
357    /// positive epoch (e.g. `1`) to retrieve full history. With
358    /// [`sort_direction(SortDirection::Asc)`](Self::sort_direction), omitting
359    /// `start` anchors paging to the default window's floor.
360    pub fn start(mut self, timestamp: i64) -> Self {
361        self.request = self.request.query("start", timestamp);
362        self
363    }
364
365    /// Upper-bound timestamp (epoch seconds) for the activity window.
366    ///
367    /// Omit for the default (current time); rows newer than `end` are excluded.
368    pub fn end(mut self, timestamp: i64) -> Self {
369        self.request = self.request.query("end", timestamp);
370        self
371    }
372
373    /// Set maximum number of results (0-500, default: 100)
374    ///
375    /// Values above the maximum are clamped to 500 server-side.
376    pub fn limit(mut self, limit: u32) -> Self {
377        self.request = self.request.query("limit", limit);
378        self
379    }
380
381    /// Set pagination offset (0-5000, default: 0)
382    ///
383    /// Requests past the cap are rejected with a 400 rather than silently
384    /// clamped. To read history deeper than offset 5000, page inside successive
385    /// [`start`](Self::start)/[`end`](Self::end) windows — each window has its
386    /// own offset budget.
387    pub fn offset(mut self, offset: u32) -> Self {
388        self.request = self.request.query("offset", offset);
389        self
390    }
391
392    /// Set sort field (default: TIMESTAMP)
393    pub fn sort_by(mut self, sort_by: ActivitySortBy) -> Self {
394        self.request = self.request.query("sortBy", sort_by);
395        self
396    }
397
398    /// Set sort direction (default: DESC)
399    pub fn sort_direction(mut self, direction: SortDirection) -> Self {
400        self.request = self.request.query("sortDirection", direction);
401        self
402    }
403
404    /// Execute the request
405    pub async fn send(self) -> Result<Vec<Activity>, DataApiError> {
406        self.request.send().await
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn deserialize_user_traded() {
416        let json = r#"{"user": "0xabcdef1234567890", "traded": 42}"#;
417        let ut: UserTraded = serde_json::from_str(json).unwrap();
418        assert_eq!(ut.user, "0xabcdef1234567890");
419        assert_eq!(ut.traded, 42);
420    }
421
422    #[test]
423    fn deserialize_user_traded_zero() {
424        let json = r#"{"user": "0x0000000000000000000000000000000000000001", "traded": 0}"#;
425        let ut: UserTraded = serde_json::from_str(json).unwrap();
426        assert_eq!(ut.traded, 0);
427    }
428
429    #[test]
430    fn user_traded_roundtrip() {
431        let original = UserTraded {
432            user: "0x1234".to_string(),
433            traded: 100,
434        };
435        let json = serde_json::to_string(&original).unwrap();
436        let deserialized: UserTraded = serde_json::from_str(&json).unwrap();
437        assert_eq!(deserialized.user, original.user);
438        assert_eq!(deserialized.traded, original.traded);
439    }
440}