Skip to main content

hypercall_api/handlers/
competition.rs

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};
19// The admin write handlers (create/update/delete competition) live in
20// `hypercall-admin`; the business logic and these shared helpers live in the
21// `hypercall-competition` crate so both HTTP crates use one copy.
22use 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/// List competitions.
69#[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/// Get competition by id.
120#[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/// Get competition leaderboard.
145#[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/// Get profile stats.
256#[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/// Get profile trade history.
422#[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/// Get realized PnL breakdown by symbol for a wallet.
472#[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}