1use super::AppState;
2use crate::error::ApiError;
3use crate::models::{
4 CompetitionLeaderboardResponse, CompetitionResponse, CompetitionSortByValue,
5 CompetitionSortOrderValue, CompetitionStateValue, CompetitionsResponse, ConnectedUserRank,
6 LeaderboardRow, Pagination, ProfileCompetitionRankSummary, ProfileData, ProfileMarginStats,
7 ProfileMetricMedals, ProfilePnlStats, ProfileResponse, ProfileTradesResponse,
8 RealizedPnlResponse, RealizedPnlRow,
9};
10use axum::{
11 extract::{Path, Query, State},
12 http::StatusCode,
13 Json,
14};
15use chrono::Utc;
16use hypercall_competition::{
17 competition_state, CompetitionState, LeaderboardQuery, LeaderboardSortBy, SortOrder,
18};
19use hypercall_competition::{clamp_limit, competition_to_api};
23use hypercall_types::WalletAddress;
24use serde::Deserialize;
25use utoipa::IntoParams;
26
27#[derive(Debug, Deserialize, IntoParams)]
28pub struct CompetitionListQuery {
29 pub state: Option<CompetitionStateValue>,
30 pub from_ts_ms: Option<i64>,
31 pub to_ts_ms: Option<i64>,
32 pub limit: Option<usize>,
33 pub offset: Option<usize>,
34}
35
36#[derive(Debug, Deserialize, IntoParams)]
37pub struct CompetitionLeaderboardQuery {
38 pub competition_id: i64,
39 pub sort_by: Option<CompetitionSortByValue>,
40 pub sort_order: Option<CompetitionSortOrderValue>,
41 pub limit: Option<usize>,
42 pub offset: Option<usize>,
43 pub wallet: Option<WalletAddress>,
44}
45
46#[derive(Debug, Deserialize, IntoParams)]
47pub struct ProfileQuery {
48 pub wallet: WalletAddress,
49}
50
51#[derive(Debug, Deserialize, IntoParams)]
52pub struct ProfileTradesQuery {
53 pub wallet: WalletAddress,
54 pub limit: Option<usize>,
55 pub offset: Option<usize>,
56 pub competition_id: Option<i64>,
57 pub from_ts_ms: Option<i64>,
58 pub to_ts_ms: Option<i64>,
59 pub symbol: Option<String>,
60}
61
62#[derive(Debug, Deserialize, IntoParams)]
63pub struct RealizedPnlQuery {
64 pub wallet: WalletAddress,
65 pub competition_id: Option<i64>,
66}
67
68#[utoipa::path(
70 get,
71 path = "/competitions",
72 params(CompetitionListQuery),
73 responses((status = 200, body = CompetitionsResponse)),
74 tag = "Competition"
75)]
76pub async fn list_competitions(
77 State(state): State<AppState>,
78 Query(query): Query<CompetitionListQuery>,
79) -> Result<Json<CompetitionsResponse>, ApiError> {
80 let now_ts_ms = Utc::now().timestamp_millis();
81 let limit = clamp_limit(query.limit);
82 let offset = query.offset.unwrap_or(0);
83
84 let state_filter = query.state.map(|state| match state {
85 CompetitionStateValue::Pre => CompetitionState::Pre,
86 CompetitionStateValue::Active => CompetitionState::Active,
87 CompetitionStateValue::Post => CompetitionState::Post,
88 });
89
90 let rows = state
91 .competition_service
92 .list_competitions(
93 state_filter,
94 query.from_ts_ms,
95 query.to_ts_ms,
96 limit,
97 offset,
98 now_ts_ms,
99 )
100 .await
101 .map_err(ApiError::from)?;
102
103 let data = rows
104 .into_iter()
105 .map(|row| competition_to_api(row, now_ts_ms))
106 .collect::<Vec<_>>();
107
108 Ok(Json(CompetitionsResponse {
109 success: true,
110 pagination: Pagination {
111 limit,
112 offset,
113 count: data.len(),
114 },
115 data,
116 }))
117}
118
119#[utoipa::path(
121 get,
122 path = "/competitions/{id}",
123 params(("id" = i64, Path, description = "Competition id")),
124 responses((status = 200, body = CompetitionResponse)),
125 tag = "Competition"
126)]
127pub async fn get_competition(
128 State(state): State<AppState>,
129 Path(id): Path<i64>,
130) -> Result<Json<CompetitionResponse>, ApiError> {
131 let now_ts_ms = Utc::now().timestamp_millis();
132 let row = state
133 .competition_service
134 .get_competition(id)
135 .await
136 .map_err(ApiError::from)?;
137
138 Ok(Json(CompetitionResponse {
139 success: true,
140 data: competition_to_api(row, now_ts_ms),
141 }))
142}
143
144#[utoipa::path(
146 get,
147 path = "/competitions/leaderboard",
148 params(CompetitionLeaderboardQuery),
149 responses((status = 200, body = CompetitionLeaderboardResponse)),
150 tag = "Competition"
151)]
152pub async fn get_competition_leaderboard(
153 State(state): State<AppState>,
154 Query(query): Query<CompetitionLeaderboardQuery>,
155) -> Result<Json<CompetitionLeaderboardResponse>, ApiError> {
156 let now_ts_ms = Utc::now().timestamp_millis();
157 let limit = clamp_limit(query.limit);
158 let offset = query.offset.unwrap_or(0);
159 let sort_by = match query.sort_by.unwrap_or(CompetitionSortByValue::Pnl) {
160 CompetitionSortByValue::Pnl => LeaderboardSortBy::Pnl,
161 CompetitionSortByValue::Volume => LeaderboardSortBy::Volume,
162 CompetitionSortByValue::Efficiency => LeaderboardSortBy::Efficiency,
163 };
164 let sort_order = match query.sort_order.unwrap_or(CompetitionSortOrderValue::Desc) {
165 CompetitionSortOrderValue::Asc => SortOrder::Asc,
166 CompetitionSortOrderValue::Desc => SortOrder::Desc,
167 };
168
169 let result = state
170 .competition_service
171 .get_leaderboard(
172 LeaderboardQuery {
173 competition_id: query.competition_id,
174 sort_by,
175 sort_order,
176 limit,
177 offset,
178 wallet: query.wallet,
179 },
180 now_ts_ms,
181 )
182 .await
183 .map_err(ApiError::from)?;
184
185 let data = result
186 .rows
187 .into_iter()
188 .map(|entry| LeaderboardRow {
189 rank: entry.rank,
190 wallet: entry.wallet,
191 username: entry.username,
192 pnl: entry.pnl,
193 volume: entry.volume,
194 efficiency: entry.efficiency,
195 medal: entry.medal,
196 })
197 .collect::<Vec<_>>();
198
199 let connected_user = match query.wallet {
200 Some(wallet) => {
201 if let Some(me) = result.connected_user {
202 Some(ConnectedUserRank {
203 wallet: me.wallet,
204 username: me.username,
205 rank: Some(me.rank),
206 pnl: Some(me.pnl),
207 volume: Some(me.volume),
208 efficiency: Some(me.efficiency),
209 medal: me.medal,
210 })
211 } else {
212 let username = state
213 .competition_service
214 .get_display_username(wallet)
215 .await
216 .map_err(ApiError::from)?;
217 Some(ConnectedUserRank {
218 wallet,
219 username,
220 rank: None,
221 pnl: None,
222 volume: None,
223 efficiency: None,
224 medal: None,
225 })
226 }
227 }
228 None => None,
229 };
230
231 let count = data.len();
232
233 Ok(Json(CompetitionLeaderboardResponse {
234 success: true,
235 competition_id: query.competition_id,
236 sort_by: match query.sort_by.unwrap_or(CompetitionSortByValue::Pnl) {
237 CompetitionSortByValue::Pnl => "pnl".to_string(),
238 CompetitionSortByValue::Volume => "volume".to_string(),
239 CompetitionSortByValue::Efficiency => "efficiency".to_string(),
240 },
241 sort_order: match query.sort_order.unwrap_or(CompetitionSortOrderValue::Desc) {
242 CompetitionSortOrderValue::Asc => "asc".to_string(),
243 CompetitionSortOrderValue::Desc => "desc".to_string(),
244 },
245 data,
246 connected_user,
247 pagination: Pagination {
248 limit,
249 offset,
250 count,
251 },
252 }))
253}
254
255#[utoipa::path(
257 get,
258 path = "/profile",
259 params(ProfileQuery),
260 responses((status = 200, body = ProfileResponse)),
261 security(("wallet_query" = [])),
262 tag = "Portfolio"
263)]
264pub async fn get_profile(
265 State(state): State<AppState>,
266 Query(query): Query<ProfileQuery>,
267) -> Result<Json<ProfileResponse>, ApiError> {
268 let now_ts_ms = Utc::now().timestamp_millis();
269 let wallet = query.wallet;
270
271 let username = state
272 .competition_service
273 .get_display_username(wallet)
274 .await
275 .map_err(ApiError::from)?;
276 let profile_image_url = state
277 .competition_service
278 .get_profile_image_url(wallet)
279 .await
280 .map_err(ApiError::from)?;
281
282 let margin_snapshot = state
283 .portfolio_cache
284 .compute_wallet_margin_snapshot(&wallet)
285 .await
286 .map_err(|e| {
287 ApiError::new(
288 StatusCode::SERVICE_UNAVAILABLE,
289 "service_unavailable",
290 format!("margin snapshot unavailable: {}", e),
291 )
292 })?;
293
294 let cash_balance = state
295 .balance_provider
296 .get_balance(&wallet)
297 .await
298 .map_err(|e| {
299 ApiError::internal_error(format!(
300 "failed to read engine balance for {}: {}",
301 wallet, e
302 ))
303 })?;
304
305 let unrealized = margin_snapshot.margin_summary.equity - cash_balance;
306 let (deposits, withdraws, lifetime_realized, pnl_24h) = state
307 .competition_service
308 .compute_ledger_profile_stats(wallet, now_ts_ms)
309 .await
310 .map_err(ApiError::from)?;
311 let account_first_seen_ts_ms = state
312 .competition_service
313 .get_account_first_seen_ts_ms(wallet)
314 .await
315 .map_err(ApiError::from)?;
316 let account_age_days =
317 account_first_seen_ts_ms.map(|first_seen| (now_ts_ms - first_seen) / (24 * 60 * 60 * 1000));
318 let platform_medals = state
319 .competition_service
320 .get_platform_metric_medals(wallet)
321 .await
322 .map_err(ApiError::from)?;
323
324 let active_competition = state
325 .competition_service
326 .get_active_competition(now_ts_ms)
327 .await
328 .map_err(ApiError::from)?;
329
330 let mut medal = None;
331 let mut active_competition_rank = None;
332
333 if let Some(active) = &active_competition {
334 let active_sort_by = sort_by_from_primary_win_condition(&active.primary_win_condition);
335 let leaderboard = state
336 .competition_service
337 .get_leaderboard(
338 LeaderboardQuery {
339 competition_id: active.id,
340 sort_by: active_sort_by,
341 sort_order: SortOrder::Desc,
342 limit: 1,
343 offset: 0,
344 wallet: Some(wallet),
345 },
346 now_ts_ms,
347 )
348 .await
349 .map_err(ApiError::from)?;
350
351 if let Some(me) = leaderboard.connected_user {
352 medal = me.medal;
353 active_competition_rank = Some(ProfileCompetitionRankSummary {
354 competition_id: active.id,
355 competition_name: active.name.clone(),
356 competition_state: competition_state(&active, now_ts_ms).as_str().to_string(),
357 rank: me.rank,
358 pnl: me.pnl,
359 volume: me.volume,
360 efficiency: me.efficiency,
361 medal: me.medal,
362 });
363 }
364 } else if let Some(completed) = state
365 .competition_service
366 .get_latest_completed_competition(now_ts_ms)
367 .await
368 .map_err(ApiError::from)?
369 {
370 let completed_sort_by =
371 sort_by_from_primary_win_condition(&completed.primary_win_condition);
372 let leaderboard = state
373 .competition_service
374 .get_leaderboard(
375 LeaderboardQuery {
376 competition_id: completed.id,
377 sort_by: completed_sort_by,
378 sort_order: SortOrder::Desc,
379 limit: 1,
380 offset: 0,
381 wallet: Some(wallet),
382 },
383 now_ts_ms,
384 )
385 .await
386 .map_err(ApiError::from)?;
387 medal = leaderboard.connected_user.and_then(|me| me.medal);
388 }
389
390 Ok(Json(ProfileResponse {
391 success: true,
392 data: ProfileData {
393 wallet,
394 username,
395 profile_image_url,
396 account_first_seen_ts_ms,
397 account_age_days,
398 margin: ProfileMarginStats {
399 in_use: margin_snapshot.total_margin_used,
400 available: margin_snapshot.available_balance,
401 total: margin_snapshot.margin_summary.equity,
402 deposits,
403 withdraws,
404 },
405 pnl: ProfilePnlStats {
406 unrealized,
407 pnl_24h,
408 lifetime_realized,
409 },
410 medal,
411 platform_medals: ProfileMetricMedals {
412 pnl: platform_medals.pnl,
413 volume: platform_medals.volume,
414 efficiency: platform_medals.efficiency,
415 },
416 active_competition_rank,
417 },
418 }))
419}
420
421#[utoipa::path(
423 get,
424 path = "/profile/trades",
425 params(ProfileTradesQuery),
426 responses((status = 200, body = ProfileTradesResponse)),
427 security(("wallet_query" = [])),
428 tag = "Portfolio"
429)]
430pub async fn get_profile_trades(
431 State(state): State<AppState>,
432 Query(query): Query<ProfileTradesQuery>,
433) -> Result<Json<ProfileTradesResponse>, ApiError> {
434 let limit = clamp_limit(query.limit);
435 let offset = query.offset.unwrap_or(0);
436
437 let fills = state
438 .competition_service
439 .get_profile_trade_history(
440 query.wallet,
441 query.competition_id,
442 query.from_ts_ms,
443 query.to_ts_ms,
444 query.symbol.as_deref(),
445 limit,
446 offset,
447 )
448 .await
449 .map_err(ApiError::from)?;
450
451 let data = fills
452 .into_iter()
453 .map(|fill| {
454 let mut row = fill.to_api_response();
455 row.explorer_url = trade_explorer_url(&state, fill.trade_id);
456 row
457 })
458 .collect::<Vec<_>>();
459
460 Ok(Json(ProfileTradesResponse {
461 success: true,
462 pagination: Pagination {
463 limit,
464 offset,
465 count: data.len(),
466 },
467 data,
468 }))
469}
470
471#[utoipa::path(
473 get,
474 path = "/profile/realized-pnl",
475 params(RealizedPnlQuery),
476 responses((status = 200, body = RealizedPnlResponse)),
477 tag = "Portfolio"
478)]
479pub async fn get_realized_pnl(
480 State(state): State<AppState>,
481 Query(query): Query<RealizedPnlQuery>,
482) -> Result<Json<RealizedPnlResponse>, ApiError> {
483 let entries = state
484 .competition_service
485 .get_realized_pnl_by_symbol(query.wallet, query.competition_id)
486 .await
487 .map_err(ApiError::from)?;
488
489 let data = entries
490 .into_iter()
491 .map(|e| RealizedPnlRow {
492 symbol: e.symbol,
493 realized_pnl: e.realized_pnl,
494 event_count: e.event_count,
495 })
496 .collect();
497
498 Ok(Json(RealizedPnlResponse {
499 success: true,
500 data,
501 }))
502}
503
504fn trade_explorer_url(app_state: &crate::handlers::AppState, trade_id: i64) -> Option<String> {
505 let template = app_state
506 .runtime_config
507 .trade_explorer_url_template
508 .clone()?;
509 if template.is_empty() {
510 return None;
511 }
512 Some(template.replace("{trade_id}", &trade_id.to_string()))
513}
514
515fn sort_by_from_primary_win_condition(primary_win_condition: &str) -> LeaderboardSortBy {
516 match primary_win_condition {
517 "volume" => LeaderboardSortBy::Volume,
518 "efficiency" => LeaderboardSortBy::Efficiency,
519 _ => LeaderboardSortBy::Pnl,
520 }
521}