Skip to main content

polyoxide_data/api/
leaderboard.rs

1use polyoxide_core::{HttpClient, QueryBuilder, Request};
2use serde::{Deserialize, Serialize};
3
4use crate::{error::DataApiError, types::TimePeriod};
5
6/// Leaderboard namespace for trader leaderboard operations
7#[derive(Clone)]
8pub struct LeaderboardApi {
9    pub(crate) http_client: HttpClient,
10}
11
12impl LeaderboardApi {
13    /// Get the trader leaderboard
14    pub fn get(&self) -> GetLeaderboard {
15        let request = Request::new(self.http_client.clone(), "/v1/leaderboard");
16        GetLeaderboard { request }
17    }
18}
19
20/// Request builder for getting the trader leaderboard
21pub struct GetLeaderboard {
22    request: Request<Vec<TraderRanking>, DataApiError>,
23}
24
25impl GetLeaderboard {
26    /// Filter by category (default: OVERALL)
27    pub fn category(mut self, category: LeaderboardCategory) -> Self {
28        self.request = self.request.query("category", category);
29        self
30    }
31
32    /// Set the aggregation time period (default: DAY).
33    ///
34    /// Verified live on 2026-07-25: omitting the parameter returns the same
35    /// rankings as `timePeriod=DAY`, not `ALL`.
36    pub fn time_period(mut self, period: TimePeriod) -> Self {
37        self.request = self.request.query("timePeriod", period);
38        self
39    }
40
41    /// Set the ordering field (default: PNL)
42    pub fn order_by(mut self, order_by: LeaderboardOrderBy) -> Self {
43        self.request = self.request.query("orderBy", order_by);
44        self
45    }
46
47    /// Set maximum number of results (1-50, default: 25)
48    pub fn limit(mut self, limit: u32) -> Self {
49        self.request = self.request.query("limit", limit);
50        self
51    }
52
53    /// Set pagination offset (0-1000, default: 0)
54    pub fn offset(mut self, offset: u32) -> Self {
55        self.request = self.request.query("offset", offset);
56        self
57    }
58
59    /// Filter by user wallet address
60    pub fn user(mut self, address: impl Into<String>) -> Self {
61        self.request = self.request.query("user", address.into());
62        self
63    }
64
65    /// Filter by username
66    pub fn user_name(mut self, name: impl Into<String>) -> Self {
67        self.request = self.request.query("userName", name.into());
68        self
69    }
70
71    /// Execute the request
72    pub async fn send(self) -> Result<Vec<TraderRanking>, DataApiError> {
73        self.request.send().await
74    }
75}
76
77/// Leaderboard category for filtering
78#[cfg_attr(feature = "specta", derive(specta::Type))]
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(rename_all = "UPPERCASE")]
81pub enum LeaderboardCategory {
82    /// Overall ranking (default)
83    #[default]
84    Overall,
85    /// Politics category
86    Politics,
87    /// Sports category
88    Sports,
89    /// Esports category
90    Esports,
91    /// Crypto category
92    Crypto,
93    /// Culture category
94    Culture,
95    /// Mentions category
96    Mentions,
97    /// Weather category
98    Weather,
99    /// Economics category
100    Economics,
101    /// Technology category
102    Tech,
103    /// Finance category
104    Finance,
105}
106
107impl LeaderboardCategory {
108    /// Every category the endpoint accepts, in upstream's documented order.
109    ///
110    /// `GET /v1/leaderboard` rejects anything outside this set with HTTP 400
111    /// `{"error":"invalid category parameter"}`.
112    pub const ALL: [Self; 11] = [
113        Self::Overall,
114        Self::Politics,
115        Self::Sports,
116        Self::Esports,
117        Self::Crypto,
118        Self::Culture,
119        Self::Mentions,
120        Self::Weather,
121        Self::Economics,
122        Self::Tech,
123        Self::Finance,
124    ];
125}
126
127impl std::fmt::Display for LeaderboardCategory {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::Overall => write!(f, "OVERALL"),
131            Self::Politics => write!(f, "POLITICS"),
132            Self::Sports => write!(f, "SPORTS"),
133            Self::Esports => write!(f, "ESPORTS"),
134            Self::Crypto => write!(f, "CRYPTO"),
135            Self::Culture => write!(f, "CULTURE"),
136            Self::Mentions => write!(f, "MENTIONS"),
137            Self::Weather => write!(f, "WEATHER"),
138            Self::Economics => write!(f, "ECONOMICS"),
139            Self::Tech => write!(f, "TECH"),
140            Self::Finance => write!(f, "FINANCE"),
141        }
142    }
143}
144
145/// Order-by field for leaderboard sorting
146#[cfg_attr(feature = "specta", derive(specta::Type))]
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
148#[serde(rename_all = "UPPERCASE")]
149pub enum LeaderboardOrderBy {
150    /// Order by profit and loss (default)
151    #[default]
152    Pnl,
153    /// Order by volume
154    Vol,
155}
156
157impl std::fmt::Display for LeaderboardOrderBy {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            Self::Pnl => write!(f, "PNL"),
161            Self::Vol => write!(f, "VOL"),
162        }
163    }
164}
165
166/// Trader ranking entry in the leaderboard
167#[cfg_attr(feature = "specta", derive(specta::Type))]
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct TraderRanking {
171    /// Trader's ranking position
172    pub rank: String,
173    /// Proxy wallet address
174    pub proxy_wallet: String,
175    /// Display username
176    pub user_name: Option<String>,
177    /// Trading volume
178    pub vol: f64,
179    /// Profit and loss
180    pub pnl: f64,
181    /// Profile image URL
182    pub profile_image: Option<String>,
183    /// Twitter/X handle
184    pub x_username: Option<String>,
185    /// Verified badge status
186    pub verified_badge: Option<bool>,
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::DataApi;
193
194    fn client() -> DataApi {
195        DataApi::new().unwrap()
196    }
197
198    #[test]
199    fn test_get_leaderboard_full_chain() {
200        let _builder = client()
201            .leaderboard()
202            .get()
203            .category(LeaderboardCategory::Politics)
204            .time_period(TimePeriod::Week)
205            .order_by(LeaderboardOrderBy::Vol)
206            .limit(25)
207            .offset(0)
208            .user("0x1234")
209            .user_name("trader");
210    }
211
212    #[test]
213    fn leaderboard_category_display_matches_serde() {
214        // Drives off ALL rather than a hand-copied list: the hand-copied list
215        // silently omitted ESPORTS and kept passing while the variant was
216        // missing entirely.
217        for variant in LeaderboardCategory::ALL {
218            let serialized = serde_json::to_value(variant).unwrap();
219            let display = variant.to_string();
220            assert_eq!(
221                format!("\"{}\"", display),
222                serialized.to_string(),
223                "Display mismatch for {:?}",
224                variant
225            );
226        }
227    }
228
229    #[test]
230    fn leaderboard_order_by_display_matches_serde() {
231        let variants = [LeaderboardOrderBy::Pnl, LeaderboardOrderBy::Vol];
232        for variant in variants {
233            let serialized = serde_json::to_value(variant).unwrap();
234            let display = variant.to_string();
235            assert_eq!(
236                format!("\"{}\"", display),
237                serialized.to_string(),
238                "Display mismatch for {:?}",
239                variant
240            );
241        }
242    }
243
244    /// Every value `GET /v1/leaderboard?category=` accepts, enumerated live on
245    /// 2026-07-25 by probing each candidate. The endpoint validates strictly —
246    /// an unknown value returns HTTP 400 `{"error":"invalid category parameter"}`
247    /// — so this list is exhaustive rather than merely observed. GEOPOLITICS,
248    /// SCIENCE, BUSINESS, WORLD, ELECTIONS and POP-CULTURE were all rejected.
249    const UPSTREAM_CATEGORIES: [&str; 11] = [
250        "OVERALL",
251        "POLITICS",
252        "SPORTS",
253        "ESPORTS",
254        "CRYPTO",
255        "CULTURE",
256        "MENTIONS",
257        "WEATHER",
258        "ECONOMICS",
259        "TECH",
260        "FINANCE",
261    ];
262
263    #[test]
264    fn category_covers_every_value_upstream_accepts() {
265        for name in UPSTREAM_CATEGORIES {
266            let parsed: LeaderboardCategory = serde_json::from_str(&format!("\"{name}\""))
267                .unwrap_or_else(|e| panic!("upstream accepts {name} but the enum rejects it: {e}"));
268            assert_eq!(
269                parsed.to_string(),
270                name,
271                "{name} must round-trip through Display"
272            );
273        }
274    }
275
276    #[test]
277    fn category_has_no_variant_upstream_rejects() {
278        // The converse guard: sending a variant the venue 400s on would be a
279        // runtime error the type system implies is impossible.
280        for variant in LeaderboardCategory::ALL {
281            let rendered = variant.to_string();
282            assert!(
283                UPSTREAM_CATEGORIES.contains(&rendered.as_str()),
284                "{rendered} is not a category upstream accepts"
285            );
286        }
287        assert_eq!(
288            LeaderboardCategory::ALL.len(),
289            UPSTREAM_CATEGORIES.len(),
290            "ALL and the upstream list have drifted apart"
291        );
292    }
293
294    #[test]
295    fn leaderboard_category_default_is_overall() {
296        assert_eq!(LeaderboardCategory::default(), LeaderboardCategory::Overall);
297    }
298
299    #[test]
300    fn leaderboard_order_by_default_is_pnl() {
301        assert_eq!(LeaderboardOrderBy::default(), LeaderboardOrderBy::Pnl);
302    }
303
304    #[test]
305    fn deserialize_trader_ranking() {
306        let json = r#"{
307            "rank": "1",
308            "proxyWallet": "0xabc123",
309            "userName": "top_trader",
310            "vol": 5000000.50,
311            "pnl": 250000.75,
312            "profileImage": "https://example.com/pic.png",
313            "xUsername": "top_trader_x",
314            "verifiedBadge": true
315        }"#;
316        let ranking: TraderRanking = serde_json::from_str(json).unwrap();
317        assert_eq!(ranking.rank, "1");
318        assert_eq!(ranking.proxy_wallet, "0xabc123");
319        assert_eq!(ranking.user_name.as_deref(), Some("top_trader"));
320        assert!((ranking.vol - 5000000.50).abs() < f64::EPSILON);
321        assert!((ranking.pnl - 250000.75).abs() < f64::EPSILON);
322        assert_eq!(ranking.verified_badge, Some(true));
323    }
324
325    #[test]
326    fn deserialize_trader_ranking_minimal() {
327        let json = r#"{
328            "rank": "50",
329            "proxyWallet": "0xdef456",
330            "userName": null,
331            "vol": 100.0,
332            "pnl": -10.0,
333            "profileImage": null,
334            "xUsername": null,
335            "verifiedBadge": null
336        }"#;
337        let ranking: TraderRanking = serde_json::from_str(json).unwrap();
338        assert_eq!(ranking.rank, "50");
339        assert!(ranking.user_name.is_none());
340        assert!(ranking.profile_image.is_none());
341        assert!((ranking.pnl - (-10.0)).abs() < f64::EPSILON);
342    }
343
344    #[test]
345    fn deserialize_trader_ranking_list() {
346        let json = r#"[
347            {"rank": "1", "proxyWallet": "0xa", "userName": null, "vol": 100.0, "pnl": 50.0, "profileImage": null, "xUsername": null, "verifiedBadge": null},
348            {"rank": "2", "proxyWallet": "0xb", "userName": null, "vol": 80.0, "pnl": 30.0, "profileImage": null, "xUsername": null, "verifiedBadge": null}
349        ]"#;
350        let rankings: Vec<TraderRanking> = serde_json::from_str(json).unwrap();
351        assert_eq!(rankings.len(), 2);
352        assert_eq!(rankings[0].rank, "1");
353        assert_eq!(rankings[1].rank, "2");
354    }
355}