1use polyoxide_core::{HttpClient, QueryBuilder, Request};
2use serde::{Deserialize, Serialize};
3
4use crate::{error::DataApiError, types::TimePeriod};
5
6#[derive(Clone)]
8pub struct LeaderboardApi {
9 pub(crate) http_client: HttpClient,
10}
11
12impl LeaderboardApi {
13 pub fn get(&self) -> GetLeaderboard {
15 let request = Request::new(self.http_client.clone(), "/v1/leaderboard");
16 GetLeaderboard { request }
17 }
18}
19
20pub struct GetLeaderboard {
22 request: Request<Vec<TraderRanking>, DataApiError>,
23}
24
25impl GetLeaderboard {
26 pub fn category(mut self, category: LeaderboardCategory) -> Self {
28 self.request = self.request.query("category", category);
29 self
30 }
31
32 pub fn time_period(mut self, period: TimePeriod) -> Self {
37 self.request = self.request.query("timePeriod", period);
38 self
39 }
40
41 pub fn order_by(mut self, order_by: LeaderboardOrderBy) -> Self {
43 self.request = self.request.query("orderBy", order_by);
44 self
45 }
46
47 pub fn limit(mut self, limit: u32) -> Self {
49 self.request = self.request.query("limit", limit);
50 self
51 }
52
53 pub fn offset(mut self, offset: u32) -> Self {
55 self.request = self.request.query("offset", offset);
56 self
57 }
58
59 pub fn user(mut self, address: impl Into<String>) -> Self {
61 self.request = self.request.query("user", address.into());
62 self
63 }
64
65 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 pub async fn send(self) -> Result<Vec<TraderRanking>, DataApiError> {
73 self.request.send().await
74 }
75}
76
77#[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 #[default]
84 Overall,
85 Politics,
87 Sports,
89 Esports,
91 Crypto,
93 Culture,
95 Mentions,
97 Weather,
99 Economics,
101 Tech,
103 Finance,
105}
106
107impl LeaderboardCategory {
108 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#[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 #[default]
152 Pnl,
153 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#[cfg_attr(feature = "specta", derive(specta::Type))]
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct TraderRanking {
171 pub rank: String,
173 pub proxy_wallet: String,
175 pub user_name: Option<String>,
177 pub vol: f64,
179 pub pnl: f64,
181 pub profile_image: Option<String>,
183 pub x_username: Option<String>,
185 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 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 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 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}