Skip to main content

hypercall_competition/
lib.rs

1//! Competition business logic (`hypercall-competition` crate).
2//!
3//! Owns the competition domain: lifecycle validation (window/win-condition
4//! checks, post-competition immutability, pre-only deletion), leaderboard
5//! computation (FIFO lot PnL engine), profile stats, and the record-to-API
6//! mappers. Both HTTP crates depend on it: `hypercall-api` serves the public
7//! read routes and `hypercall-admin` serves the operator write routes, each
8//! holding an `Arc<CompetitionService>` directly. Persistence goes through
9//! the ORM-free `hypercall_db::CompetitionWriter` trait; market inputs come
10//! from the `hypercall_runtime_api` boundary ports.
11
12use axum::http::StatusCode;
13use chrono::{DateTime, Utc};
14pub use hypercall_db::{CompetitionRecord, CompetitionUpdateInput, CompetitionUpsertInput};
15use hypercall_types::api_models::CompetitionData;
16use hypercall_types::WalletAddress;
17use rust_decimal::Decimal;
18use serde::Deserialize;
19
20use hypercall_runtime_api::error::ApiError;
21
22mod recorder;
23mod service;
24pub use recorder::CompetitionFillRecorder;
25pub use service::*;
26
27/// Default page size for competition listings.
28pub const DEFAULT_LIMIT: usize = 50;
29/// Maximum page size for competition listings.
30pub const MAX_LIMIT: usize = 200;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CompetitionState {
34    Pre,
35    Active,
36    Post,
37}
38
39impl CompetitionState {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Pre => "pre",
43            Self::Active => "active",
44            Self::Post => "post",
45        }
46    }
47
48    pub fn parse(input: &str) -> Option<Self> {
49        match input {
50            "pre" => Some(Self::Pre),
51            "active" => Some(Self::Active),
52            "post" => Some(Self::Post),
53            _ => None,
54        }
55    }
56
57    pub fn from_bounds(start_ts_ms: i64, end_ts_ms: i64, now_ts_ms: i64) -> Self {
58        if now_ts_ms < start_ts_ms {
59            Self::Pre
60        } else if now_ts_ms < end_ts_ms {
61            Self::Active
62        } else {
63            Self::Post
64        }
65    }
66}
67
68/// Determine the competition state based on timestamps.
69pub fn competition_state(record: &CompetitionRecord, now_ts_ms: i64) -> CompetitionState {
70    CompetitionState::from_bounds(record.start_ts_ms, record.end_ts_ms, now_ts_ms)
71}
72
73pub fn competition_to_api(row: CompetitionRecord, now_ts_ms: i64) -> CompetitionData {
74    let state = competition_state(&row, now_ts_ms).as_str().to_string();
75    CompetitionData {
76        id: row.id,
77        name: row.name,
78        description: row.description,
79        rules_url: row.rules_url,
80        rules_content: row.rules_content,
81        win_conditions: row.win_conditions,
82        primary_win_condition: row.primary_win_condition,
83        start_ts_ms: row.start_ts_ms,
84        end_ts_ms: row.end_ts_ms,
85        state,
86        created_at: row.created_at,
87        updated_at: row.updated_at,
88    }
89}
90
91pub fn clamp_limit(limit: Option<usize>) -> usize {
92    limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT)
93}
94
95/// A profile trade-history fill row, validated from the persistence layer.
96#[derive(Debug, Deserialize)]
97pub struct Fill {
98    pub fill_id: i64,
99    pub trade_id: i64,
100    pub wallet_address: WalletAddress,
101    pub symbol: String,
102    pub price: Decimal,
103    pub size: Decimal,
104    pub fee: Decimal,
105    pub is_taker: bool,
106    pub timestamp: i64,
107    pub builder_code_address: Option<WalletAddress>,
108    pub builder_code_fee: Option<Decimal>,
109    pub realized_pnl: Option<Decimal>,
110    pub created_at: DateTime<Utc>,
111}
112
113impl Fill {
114    pub fn to_api_response(&self) -> hypercall_types::api_models::FillApiResponse {
115        let converted_size = hypercall_types::to_human_readable_decimal(&self.symbol, self.size);
116        hypercall_types::api_models::FillApiResponse {
117            fill_id: self.fill_id,
118            trade_id: self.trade_id,
119            wallet_address: self.wallet_address,
120            symbol: self.symbol.clone(),
121            price: self.price,
122            size: converted_size,
123            fee: self.fee,
124            is_taker: self.is_taker,
125            timestamp: self.timestamp,
126            created_at: self.created_at,
127            builder_code_address: self.builder_code_address,
128            builder_code_fee: self.builder_code_fee,
129            realized_pnl: self.realized_pnl,
130            explorer_url: None,
131        }
132    }
133}
134
135/// Result alias for competition operations.
136pub type Result<T, E = CompetitionError> = std::result::Result<T, E>;
137
138/// Typed error for the competition domain.
139///
140/// Variants map one-to-one onto HTTP semantics via `From<CompetitionError>
141/// for ApiError`. Unclassified failures (DB plumbing, market-input reads)
142/// convert from `anyhow::Error` into [`CompetitionError::Internal`] and are
143/// served as 500 without leaking details.
144#[derive(Debug, thiserror::Error)]
145pub enum CompetitionError {
146    #[error("{0}")]
147    BadRequest(String),
148    #[error("{0}")]
149    NotFound(String),
150    #[error("{0}")]
151    Conflict(String),
152    #[error("{0}")]
153    Unavailable(String),
154    #[error(transparent)]
155    Internal(#[from] anyhow::Error),
156}
157
158impl From<CompetitionError> for ApiError {
159    fn from(err: CompetitionError) -> Self {
160        match err {
161            CompetitionError::BadRequest(msg) => ApiError::bad_request(msg),
162            CompetitionError::NotFound(msg) => ApiError::not_found(msg),
163            CompetitionError::Conflict(msg) => ApiError::new(StatusCode::CONFLICT, "conflict", msg),
164            CompetitionError::Unavailable(msg) => {
165                ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", msg)
166            }
167            CompetitionError::Internal(inner) => {
168                tracing::error!("competition service internal error: {:#}", inner);
169                ApiError::internal_error("internal server error")
170            }
171        }
172    }
173}