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: ALL)
33    pub fn time_period(mut self, period: TimePeriod) -> Self {
34        self.request = self.request.query("timePeriod", period);
35        self
36    }
37
38    /// Set the ordering field (default: PNL)
39    pub fn order_by(mut self, order_by: LeaderboardOrderBy) -> Self {
40        self.request = self.request.query("orderBy", order_by);
41        self
42    }
43
44    /// Set maximum number of results (1-50, default: 25)
45    pub fn limit(mut self, limit: u32) -> Self {
46        self.request = self.request.query("limit", limit);
47        self
48    }
49
50    /// Set pagination offset (0-1000, default: 0)
51    pub fn offset(mut self, offset: u32) -> Self {
52        self.request = self.request.query("offset", offset);
53        self
54    }
55
56    /// Filter by user wallet address
57    pub fn user(mut self, address: impl Into<String>) -> Self {
58        self.request = self.request.query("user", address.into());
59        self
60    }
61
62    /// Filter by username
63    pub fn user_name(mut self, name: impl Into<String>) -> Self {
64        self.request = self.request.query("userName", name.into());
65        self
66    }
67
68    /// Execute the request
69    pub async fn send(self) -> Result<Vec<TraderRanking>, DataApiError> {
70        self.request.send().await
71    }
72}
73
74/// Leaderboard category for filtering
75#[cfg_attr(feature = "specta", derive(specta::Type))]
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
77#[serde(rename_all = "UPPERCASE")]
78pub enum LeaderboardCategory {
79    /// Overall ranking (default)
80    #[default]
81    Overall,
82    /// Politics category
83    Politics,
84    /// Sports category
85    Sports,
86    /// Crypto category
87    Crypto,
88    /// Culture category
89    Culture,
90    /// Mentions category
91    Mentions,
92    /// Weather category
93    Weather,
94    /// Economics category
95    Economics,
96    /// Technology category
97    Tech,
98    /// Finance category
99    Finance,
100}
101
102impl std::fmt::Display for LeaderboardCategory {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            Self::Overall => write!(f, "OVERALL"),
106            Self::Politics => write!(f, "POLITICS"),
107            Self::Sports => write!(f, "SPORTS"),
108            Self::Crypto => write!(f, "CRYPTO"),
109            Self::Culture => write!(f, "CULTURE"),
110            Self::Mentions => write!(f, "MENTIONS"),
111            Self::Weather => write!(f, "WEATHER"),
112            Self::Economics => write!(f, "ECONOMICS"),
113            Self::Tech => write!(f, "TECH"),
114            Self::Finance => write!(f, "FINANCE"),
115        }
116    }
117}
118
119/// Order-by field for leaderboard sorting
120#[cfg_attr(feature = "specta", derive(specta::Type))]
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
122#[serde(rename_all = "UPPERCASE")]
123pub enum LeaderboardOrderBy {
124    /// Order by profit and loss (default)
125    #[default]
126    Pnl,
127    /// Order by volume
128    Vol,
129}
130
131impl std::fmt::Display for LeaderboardOrderBy {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        match self {
134            Self::Pnl => write!(f, "PNL"),
135            Self::Vol => write!(f, "VOL"),
136        }
137    }
138}
139
140/// Trader ranking entry in the leaderboard
141#[cfg_attr(feature = "specta", derive(specta::Type))]
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct TraderRanking {
145    /// Trader's ranking position
146    pub rank: String,
147    /// Proxy wallet address
148    pub proxy_wallet: String,
149    /// Display username
150    pub user_name: Option<String>,
151    /// Trading volume
152    pub vol: f64,
153    /// Profit and loss
154    pub pnl: f64,
155    /// Profile image URL
156    pub profile_image: Option<String>,
157    /// Twitter/X handle
158    pub x_username: Option<String>,
159    /// Verified badge status
160    pub verified_badge: Option<bool>,
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::DataApi;
167
168    fn client() -> DataApi {
169        DataApi::new().unwrap()
170    }
171
172    #[test]
173    fn test_get_leaderboard_full_chain() {
174        let _builder = client()
175            .leaderboard()
176            .get()
177            .category(LeaderboardCategory::Politics)
178            .time_period(TimePeriod::Week)
179            .order_by(LeaderboardOrderBy::Vol)
180            .limit(25)
181            .offset(0)
182            .user("0x1234")
183            .user_name("trader");
184    }
185
186    #[test]
187    fn leaderboard_category_display_matches_serde() {
188        let variants = [
189            LeaderboardCategory::Overall,
190            LeaderboardCategory::Politics,
191            LeaderboardCategory::Sports,
192            LeaderboardCategory::Crypto,
193            LeaderboardCategory::Culture,
194            LeaderboardCategory::Mentions,
195            LeaderboardCategory::Weather,
196            LeaderboardCategory::Economics,
197            LeaderboardCategory::Tech,
198            LeaderboardCategory::Finance,
199        ];
200        for variant in variants {
201            let serialized = serde_json::to_value(variant).unwrap();
202            let display = variant.to_string();
203            assert_eq!(
204                format!("\"{}\"", display),
205                serialized.to_string(),
206                "Display mismatch for {:?}",
207                variant
208            );
209        }
210    }
211
212    #[test]
213    fn leaderboard_order_by_display_matches_serde() {
214        let variants = [LeaderboardOrderBy::Pnl, LeaderboardOrderBy::Vol];
215        for variant in variants {
216            let serialized = serde_json::to_value(variant).unwrap();
217            let display = variant.to_string();
218            assert_eq!(
219                format!("\"{}\"", display),
220                serialized.to_string(),
221                "Display mismatch for {:?}",
222                variant
223            );
224        }
225    }
226
227    #[test]
228    fn leaderboard_category_default_is_overall() {
229        assert_eq!(LeaderboardCategory::default(), LeaderboardCategory::Overall);
230    }
231
232    #[test]
233    fn leaderboard_order_by_default_is_pnl() {
234        assert_eq!(LeaderboardOrderBy::default(), LeaderboardOrderBy::Pnl);
235    }
236
237    #[test]
238    fn deserialize_trader_ranking() {
239        let json = r#"{
240            "rank": "1",
241            "proxyWallet": "0xabc123",
242            "userName": "top_trader",
243            "vol": 5000000.50,
244            "pnl": 250000.75,
245            "profileImage": "https://example.com/pic.png",
246            "xUsername": "top_trader_x",
247            "verifiedBadge": true
248        }"#;
249        let ranking: TraderRanking = serde_json::from_str(json).unwrap();
250        assert_eq!(ranking.rank, "1");
251        assert_eq!(ranking.proxy_wallet, "0xabc123");
252        assert_eq!(ranking.user_name.as_deref(), Some("top_trader"));
253        assert!((ranking.vol - 5000000.50).abs() < f64::EPSILON);
254        assert!((ranking.pnl - 250000.75).abs() < f64::EPSILON);
255        assert_eq!(ranking.verified_badge, Some(true));
256    }
257
258    #[test]
259    fn deserialize_trader_ranking_minimal() {
260        let json = r#"{
261            "rank": "50",
262            "proxyWallet": "0xdef456",
263            "userName": null,
264            "vol": 100.0,
265            "pnl": -10.0,
266            "profileImage": null,
267            "xUsername": null,
268            "verifiedBadge": null
269        }"#;
270        let ranking: TraderRanking = serde_json::from_str(json).unwrap();
271        assert_eq!(ranking.rank, "50");
272        assert!(ranking.user_name.is_none());
273        assert!(ranking.profile_image.is_none());
274        assert!((ranking.pnl - (-10.0)).abs() < f64::EPSILON);
275    }
276
277    #[test]
278    fn deserialize_trader_ranking_list() {
279        let json = r#"[
280            {"rank": "1", "proxyWallet": "0xa", "userName": null, "vol": 100.0, "pnl": 50.0, "profileImage": null, "xUsername": null, "verifiedBadge": null},
281            {"rank": "2", "proxyWallet": "0xb", "userName": null, "vol": 80.0, "pnl": 30.0, "profileImage": null, "xUsername": null, "verifiedBadge": null}
282        ]"#;
283        let rankings: Vec<TraderRanking> = serde_json::from_str(json).unwrap();
284        assert_eq!(rankings.len(), 2);
285        assert_eq!(rankings[0].rank, "1");
286        assert_eq!(rankings[1].rank, "2");
287    }
288}