Skip to main content

hypercall_api/
models.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7use hypercall_types::{to_human_readable_decimal, WalletAddress};
8
9// Re-export canonical API types from hypercall-types.
10// These are the single source of truth for API request/response shapes.
11pub use hypercall_types::api_models::{
12    AccountBalance, DeleteMmpConfigRequest, ExchangeInfoResponse, ExtendedRiskMatrixResponse,
13    FillApiResponse, FillsResponse, HealthResponse, InstrumentRiskRowResponse, InstrumentStatus,
14    MarginModeApiResponse, MarginModeResponse, MarginSummary, MmpConfigData, MmpConfigResponse,
15    MonitoringAccountSummary, MonitoringAccountsResponse, MonitoringPositionHolder,
16    MonitoringPositionsResponse, MonitoringSymbolPosition, OptionsChainGreeksAbs,
17    OptionsChainGreeksCash, OptionsChainLeg, OptionsChainSnapshotResponse, OptionsChainStrikeRow,
18    Order, Portfolio, PortfolioGreeksAggregate, PortfolioGreeksResponse, Position,
19    PositionGreeksLeg, PositionWithMetrics, ReadinessComponentReport, ReadyResponse,
20    ResetMmpRequest, RiskGridResponse, RiskGridScenario, ScenarioDefinition, SetMmpConfigRequest,
21    SigningDomainInfo, SimulatedGreeksOrder, SpanMarginSummary, TradeApiResponse, TradesResponse,
22    TradingLimits, VersionResponse,
23};
24
25// Re-export types that also exist in hypercall_types::responses (keeping server version canonical).
26// Note: Instrument, MarketInfo, MarketsResponse use the server's Decimal-based definitions
27// from api_models, aliased here to avoid conflicts with the client's f64-based versions.
28pub use hypercall_types::api_models::Instrument;
29pub use hypercall_types::api_models::MarketInfo;
30pub use hypercall_types::api_models::MarketsResponse;
31
32// Re-export from api_models (server canonical — no skip_serializing_if on data/error)
33pub use hypercall_types::api_models::ApiResponse;
34// Pagination is identical in both, use responses version
35pub use hypercall_types::responses::Pagination;
36
37#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
38pub struct OrdersResponse {
39    pub success: bool,
40    pub data: Vec<Order>,
41    pub pagination: Pagination,
42}
43
44// User tier types
45pub use hypercall_types::api_models::{
46    DeleteUserTierRequest, SetUserTierRequest, UserTierData, UserTierResponse,
47};
48
49// SetMarginModeRequest is in requests.rs
50pub use hypercall_types::requests::SetMarginModeRequest;
51
52// Query types (server-only, used by axum extractors)
53#[derive(Debug, Serialize, Deserialize)]
54pub struct GetMmpConfigQuery {
55    pub wallet: WalletAddress,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub currency: Option<String>,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61pub struct GetUserTierQuery {
62    pub wallet: WalletAddress,
63}
64
65// ---- Database row types (server-only, not in hypercall-types) ----
66
67#[derive(Debug, Clone, Deserialize)]
68pub struct Trade {
69    pub trade_id: i64,
70    pub symbol: String,
71    pub price: Decimal,
72    pub size: Decimal,
73    pub maker_address: WalletAddress,
74    pub taker_address: WalletAddress,
75    pub maker_fee: Decimal,
76    pub taker_fee: Decimal,
77    pub timestamp: i64,
78    pub created_at: DateTime<Utc>,
79}
80
81impl Trade {
82    pub fn to_api_response(&self) -> TradeApiResponse {
83        TradeApiResponse {
84            trade_id: self.trade_id,
85            symbol: self.symbol.clone(),
86            price: self.price,
87            size: to_human_readable_decimal(&self.symbol, self.size),
88            maker_address: self.maker_address,
89            taker_address: self.taker_address,
90            maker_fee: self.maker_fee,
91            taker_fee: self.taker_fee,
92            timestamp: self.timestamp,
93            created_at: self.created_at,
94        }
95    }
96}
97
98pub trait PositionMetrics {
99    fn calculate_metrics(&self) -> PositionWithMetrics;
100}
101
102impl PositionMetrics for Position {
103    fn calculate_metrics(&self) -> PositionWithMetrics {
104        let notional_value = self.amount * self.entry_price;
105        let liquidation_price = dec!(0);
106        let margin_ratio = if notional_value.abs() > dec!(0) {
107            self.margin_posted / notional_value.abs()
108        } else {
109            dec!(0)
110        };
111
112        PositionWithMetrics {
113            position: Position {
114                wallet_address: self.wallet_address,
115                symbol: self.symbol.clone(),
116                amount: self.amount,
117                entry_price: self.entry_price,
118                margin_posted: self.margin_posted,
119                realized_pnl: self.realized_pnl,
120                unrealized_pnl: self.unrealized_pnl,
121                updated_at: self.updated_at,
122            },
123            notional_value,
124            maintenance_margin: dec!(0),
125            liquidation_price,
126            margin_ratio,
127        }
128    }
129}
130
131#[derive(Debug, Serialize, Deserialize, ToSchema)]
132pub struct Market {
133    pub underlying: String,
134    pub expiry: i64,
135    #[schema(value_type = String)]
136    pub created_at: DateTime<Utc>,
137}
138
139// Re-export competition and profile types from hypercall-types
140pub use hypercall_types::api_models::{
141    CompetitionData, CompetitionLeaderboardResponse, CompetitionResponse, CompetitionSortByValue,
142    CompetitionSortOrderValue, CompetitionStateValue, CompetitionUpdateRequest,
143    CompetitionUpsertRequest, CompetitionWinConditionValue, CompetitionsResponse,
144    ConnectedUserRank, LeaderboardRow, ProfileCompetitionRankSummary, ProfileData,
145    ProfileMarginStats, ProfileMetricMedals, ProfilePnlStats, ProfileResponse,
146    ProfileTradesResponse, RealizedPnlResponse, RealizedPnlRow,
147};
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use rust_decimal_macros::dec;
153
154    #[test]
155    fn risk_grid_serialization_uses_combined_pm_fields() {
156        let response = RiskGridResponse {
157            equity: dec!(10000),
158            position_initial_margin: dec!(1500),
159            position_maintenance_margin: dec!(1275),
160            open_orders_initial_margin: dec!(250),
161            total_initial_margin: dec!(1750),
162            scanning_risk: dec!(1200),
163            option_floor: dec!(1500),
164            gamma_overlay: dec!(250),
165            scenarios: vec![RiskGridScenario {
166                id: "T1".to_string(),
167                spot_shock_pct: dec!(-0.25),
168                vol_shock_pct: dec!(0.70),
169                pnl_weight: dec!(0.60),
170                is_tail: true,
171                total_pnl: dec!(-1400),
172            }],
173            extended_risk_matrix: Some(ExtendedRiskMatrixResponse {
174                scenarios: vec![ScenarioDefinition {
175                    id: "T1".to_string(),
176                    spot_shock_pct: dec!(-0.25),
177                    vol_shock_pct: dec!(0.70),
178                    pnl_weight: dec!(0.60),
179                    is_tail: true,
180                }],
181                instruments: vec![InstrumentRiskRowResponse {
182                    symbol: "BTC-20991231-100000-C".to_string(),
183                    underlying: "BTC".to_string(),
184                    amount: dec!(-1),
185                    base_amount: dec!(-1),
186                    current_value: dec!(-500),
187                    scenario_pnls: vec![dec!(-1400)],
188                }],
189                total_pnls: vec![dec!(-1400)],
190                worst_scenario_index: 0,
191                worst_scenario_pnl: dec!(-840),
192            }),
193        };
194
195        let json = serde_json::to_value(response).unwrap();
196        assert_eq!(json["scanning_risk"], "1200");
197        assert_eq!(json["option_floor"], "1500");
198        assert_eq!(json["gamma_overlay"], "250");
199        assert_eq!(json["scenarios"][0]["spot_shock_pct"], "-0.25");
200        assert_eq!(json["scenarios"][0]["vol_shock_pct"], "0.70");
201        assert_eq!(json["scenarios"][0]["pnl_weight"], "0.60");
202        assert_eq!(json["scenarios"][0]["is_tail"], true);
203        assert!(json["scenarios"][0].get("scenario_type").is_none());
204        assert!(json["scenarios"][0].get("value").is_none());
205        assert_eq!(json["extended_risk_matrix"]["scenarios"][0]["id"], "T1");
206        assert_eq!(json["extended_risk_matrix"]["worst_scenario_pnl"], "-840");
207    }
208
209    #[test]
210    fn position_calculate_metrics_zero_amount() {
211        let pos = Position {
212            wallet_address: hypercall_types::WalletAddress::default(),
213            symbol: "BTC-20260101-100000-C".to_string(),
214            amount: dec!(0),
215            entry_price: dec!(100),
216            margin_posted: dec!(0),
217            realized_pnl: dec!(0),
218            unrealized_pnl: dec!(0),
219            updated_at: chrono::Utc::now(),
220        };
221        let metrics = pos.calculate_metrics();
222        assert_eq!(metrics.notional_value, dec!(0));
223        assert_eq!(metrics.margin_ratio, dec!(0));
224    }
225
226    #[test]
227    fn position_calculate_metrics_short() {
228        let pos = Position {
229            wallet_address: hypercall_types::WalletAddress::default(),
230            symbol: "ETH-20260301-5000-P".to_string(),
231            amount: dec!(-2),
232            entry_price: dec!(150),
233            margin_posted: dec!(60),
234            realized_pnl: dec!(0),
235            unrealized_pnl: dec!(-20),
236            updated_at: chrono::Utc::now(),
237        };
238        let metrics = pos.calculate_metrics();
239        assert_eq!(metrics.notional_value, dec!(-300));
240        assert_eq!(metrics.margin_ratio, dec!(0.2)); // 60 / 300
241        assert_eq!(metrics.liquidation_price, dec!(0));
242    }
243
244    #[test]
245    fn trade_to_api_response_converts_size() {
246        let trade = Trade {
247            trade_id: 1,
248            symbol: "BTC-20260101-100000-C".to_string(),
249            price: dec!(2500),
250            size: dec!(100000000), // 1 contract in raw units
251            maker_address: hypercall_types::WalletAddress::default(),
252            taker_address: hypercall_types::WalletAddress::default(),
253            maker_fee: dec!(2.5),
254            taker_fee: dec!(5),
255            timestamp: 1700000000000,
256            created_at: chrono::Utc::now(),
257        };
258        let api = trade.to_api_response();
259        assert_eq!(api.price, dec!(2500));
260        assert!(
261            api.size < trade.size,
262            "API size should be human-readable (smaller than raw)"
263        );
264    }
265}