Skip to main content

hypercall_sdk_types/
api_models.rs

1//! Canonical API response types for the Hypercall REST API.
2//!
3//! These types are the server's source of truth for API responses.
4//! Both the server and client crates should use these types to ensure
5//! full type parity for serialization and deserialization.
6
7use chrono::{DateTime, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11use crate::{Side, TradingModes, WalletAddress};
12
13fn decimal_or_zero<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Decimal, D::Error> {
14    Option::<Decimal>::deserialize(d).map(|o| o.unwrap_or_default())
15}
16
17// ---- Generic API response ----
18
19/// Generic envelope for all API responses.
20///
21/// Every endpoint returns `{ "success": bool, "data": T | null, "error": string | null }`.
22/// Both `data` and `error` are always present in the JSON (possibly null).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
25pub struct ApiResponse<T> {
26    /// Whether the request succeeded.
27    pub success: bool,
28    /// Response payload, present on success and null on failure.
29    pub data: Option<T>,
30    /// Human-readable error message, present on failure and null on success.
31    pub error: Option<String>,
32}
33
34impl<T> ApiResponse<T> {
35    /// Build a successful response wrapping `data`.
36    pub fn success(data: T) -> Self {
37        Self {
38            success: true,
39            data: Some(data),
40            error: None,
41        }
42    }
43
44    /// Build a successful response with no data (e.g. resource does not exist yet).
45    pub fn success_empty() -> Self {
46        Self {
47            success: true,
48            data: None,
49            error: None,
50        }
51    }
52
53    /// Build a failure response with the given error message.
54    pub fn error(message: impl Into<String>) -> Self {
55        Self {
56            success: false,
57            data: None,
58            error: Some(message.into()),
59        }
60    }
61}
62
63// ---- Instrument status ----
64
65/// Lifecycle state of a tradable instrument.
66///
67/// Serializes as `SCREAMING_SNAKE_CASE` (e.g. `"EXPIRED_PENDING_PRICE"`).
68#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
69#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
70#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
71pub enum InstrumentStatus {
72    /// Instrument is open for trading.
73    #[default]
74    Active,
75    /// Instrument has expired but the settlement price has not been finalized.
76    ExpiredPendingPrice,
77    /// Instrument has been settled and all PnL realized.
78    Settled,
79}
80
81impl InstrumentStatus {
82    /// Parse from the database string representation (case-insensitive).
83    pub fn from_db_str(s: &str) -> Option<Self> {
84        match s.to_uppercase().as_str() {
85            "ACTIVE" => Some(Self::Active),
86            "EXPIRED_PENDING_PRICE" => Some(Self::ExpiredPendingPrice),
87            "SETTLED" => Some(Self::Settled),
88            _ => None,
89        }
90    }
91
92    /// Returns `true` if the instrument is actively trading.
93    pub fn is_active(&self) -> bool {
94        matches!(self, Self::Active)
95    }
96
97    /// Return the canonical database string for this status.
98    pub fn as_db_str(&self) -> &'static str {
99        match self {
100            Self::Active => "ACTIVE",
101            Self::ExpiredPendingPrice => "EXPIRED_PENDING_PRICE",
102            Self::Settled => "SETTLED",
103        }
104    }
105}
106
107impl std::fmt::Display for InstrumentStatus {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str(self.as_db_str())
110    }
111}
112
113// ---- Position types ----
114
115/// A single open position for one instrument.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
118#[cfg_attr(feature = "database", derive(sqlx::FromRow))]
119pub struct Position {
120    /// Account wallet address (checksummed Ethereum address).
121    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
122    pub wallet_address: WalletAddress,
123    /// Instrument symbol (e.g. `"BTC-20260101-100000-C"`).
124    pub symbol: String,
125    /// Position size in contracts (positive = long, negative = short). Serialized as a string.
126    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
127    pub amount: Decimal,
128    /// Volume-weighted average entry price in USD. Serialized as a string.
129    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
130    pub entry_price: Decimal,
131    /// Margin currently locked against this position in USD. Serialized as a string.
132    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
133    pub margin_posted: Decimal,
134    /// Cumulative realized PnL in USD. Serialized as a string.
135    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
136    pub realized_pnl: Decimal,
137    /// Mark-to-market unrealized PnL in USD. Serialized as a string.
138    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
139    pub unrealized_pnl: Decimal,
140    /// Timestamp of the last position update (ISO 8601).
141    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
142    pub updated_at: DateTime<Utc>,
143}
144
145/// Position enriched with derived risk metrics. Flattened in JSON (no nested `position` key).
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
148pub struct PositionWithMetrics {
149    /// Core position fields, flattened into the top-level JSON object.
150    #[serde(flatten)]
151    pub position: Position,
152    /// Dollar notional value of the position. Serialized as a string.
153    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
154    pub notional_value: Decimal,
155    /// Maintenance margin requirement in USD. Serialized as a string.
156    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
157    pub maintenance_margin: Decimal,
158    /// Estimated liquidation price in USD. Serialized as a string.
159    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
160    pub liquidation_price: Decimal,
161    /// Margin utilization ratio (margin_used / equity). Serialized as a string.
162    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
163    pub margin_ratio: Decimal,
164}
165
166/// USDC balance for a single trading account.
167#[derive(Debug, Serialize, Deserialize)]
168#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
169#[cfg_attr(feature = "database", derive(sqlx::FromRow))]
170pub struct AccountBalance {
171    /// Account wallet address (checksummed Ethereum address).
172    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
173    pub wallet_address: WalletAddress,
174    /// Current USDC balance. Serialized as a string.
175    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
176    pub balance: Decimal,
177    /// Timestamp of the last balance change (ISO 8601).
178    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
179    pub updated_at: DateTime<Utc>,
180}
181
182// ---- Margin types ----
183
184/// SPAN-based portfolio margin breakdown for an account.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
187pub struct SpanMarginSummary {
188    /// Total account equity (balance + unrealized PnL) in USD. Serialized as a string.
189    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
190    pub equity: Decimal,
191    /// Initial margin required for existing positions in USD. Serialized as a string.
192    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
193    pub initial_margin_required: Decimal,
194    /// Maintenance margin required for existing positions in USD. Serialized as a string.
195    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
196    pub maintenance_margin_required: Decimal,
197    /// Initial margin reserved for open (unfilled) orders. Defaults to zero. Serialized as a string.
198    #[serde(default)]
199    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
200    pub open_orders_initial_margin: Decimal,
201    /// Options-specific margin requirement in USD. Serialized as a string.
202    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
203    pub option_margin_required: Decimal,
204    /// SPAN scanning risk (worst-case scenario loss) in USD. Serialized as a string.
205    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
206    pub scanning_risk: Decimal,
207    /// Minimum option margin floor (short option value * factor) in USD. Serialized as a string.
208    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
209    pub option_floor: Decimal,
210    /// Gamma/curvature overlay charge in USD. Serialized as a string.
211    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
212    pub gamma_overlay: Decimal,
213    /// Margin held on HyperCore for perp positions in USD. Serialized as a string.
214    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
215    pub hypercore_margin_required: Decimal,
216}
217
218/// Re-export of the legacy margin summary type from `crate::responses`.
219pub use crate::responses::MarginSummary;
220
221// ---- Portfolio ----
222
223fn default_margin_mode() -> String {
224    "standard".to_string()
225}
226
227/// Full portfolio snapshot for a single account.
228#[derive(Debug, Serialize, Deserialize)]
229#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
230pub struct Portfolio {
231    /// Account wallet address (checksummed Ethereum address).
232    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
233    pub wallet_address: WalletAddress,
234    /// All open positions with enriched risk metrics.
235    pub positions: Vec<PositionWithMetrics>,
236    /// Total margin currently in use across all positions in USD. Serialized as a string.
237    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
238    pub total_margin_used: Decimal,
239    /// Free collateral available for new trades in USD. Serialized as a string.
240    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
241    pub available_balance: Decimal,
242    /// SPAN margin breakdown, present when the account uses portfolio margin.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub span_margin: Option<SpanMarginSummary>,
245    /// Margin mode for this account (`"standard"` or `"portfolio"`). Defaults to `"standard"`.
246    #[serde(default = "default_margin_mode")]
247    pub margin_mode: String,
248    /// Unified margin summary, present when computed.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub margin_summary: Option<MarginSummary>,
251}
252
253// ---- Risk grid ----
254
255/// A single SPAN risk-grid scenario with its computed PnL.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
258pub struct RiskGridScenario {
259    /// Unique scenario identifier (e.g. `"S1"`).
260    pub id: String,
261    /// Spot price shock as a fraction (e.g. `-0.15` = -15%). Serialized as a string.
262    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
263    pub spot_shock_pct: Decimal,
264    /// Implied volatility shock as a fraction (e.g. `0.30` = +30%). Serialized as a string.
265    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
266    pub vol_shock_pct: Decimal,
267    /// Weight applied to this scenario's PnL in the scanning risk calculation. Serialized as a string.
268    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
269    pub pnl_weight: Decimal,
270    /// Whether this is a tail/extreme scenario.
271    pub is_tail: bool,
272    /// Total portfolio PnL under this scenario in USD. Serialized as a string.
273    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
274    pub total_pnl: Decimal,
275}
276
277/// Definition of a risk scenario (without computed PnL).
278#[derive(Debug, Clone, Serialize, Deserialize)]
279#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
280pub struct ScenarioDefinition {
281    /// Unique scenario identifier.
282    pub id: String,
283    /// Spot price shock as a fraction. Serialized as a string.
284    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
285    pub spot_shock_pct: Decimal,
286    /// Implied volatility shock as a fraction. Serialized as a string.
287    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
288    pub vol_shock_pct: Decimal,
289    /// Weight applied to this scenario in the scanning risk calculation. Serialized as a string.
290    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
291    pub pnl_weight: Decimal,
292    /// Whether this is a tail/extreme scenario.
293    pub is_tail: bool,
294}
295
296/// Per-instrument row in the extended risk matrix.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
299pub struct InstrumentRiskRowResponse {
300    /// Instrument symbol.
301    pub symbol: String,
302    /// Underlying asset (e.g. `"BTC"`, `"ETH"`).
303    pub underlying: String,
304    /// Position size in contracts. Serialized as a string.
305    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
306    pub amount: Decimal,
307    /// Position size in base asset units. Serialized as a string.
308    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
309    pub base_amount: Decimal,
310    /// Current mark-to-market value of the position in USD. Serialized as a string.
311    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
312    pub current_value: Decimal,
313    /// PnL under each scenario, ordered to match `ExtendedRiskMatrixResponse::scenarios`. Serialized as strings.
314    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
315    pub scenario_pnls: Vec<Decimal>,
316}
317
318/// Full risk matrix showing per-instrument PnL across all SPAN scenarios.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
321pub struct ExtendedRiskMatrixResponse {
322    /// Scenario definitions (columns of the matrix).
323    pub scenarios: Vec<ScenarioDefinition>,
324    /// Per-instrument risk rows (rows of the matrix).
325    pub instruments: Vec<InstrumentRiskRowResponse>,
326    /// Aggregate portfolio PnL for each scenario. Serialized as strings.
327    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
328    pub total_pnls: Vec<Decimal>,
329    /// Index into `scenarios` of the worst-case scenario.
330    pub worst_scenario_index: usize,
331    /// PnL of the worst-case scenario in USD. Serialized as a string.
332    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
333    pub worst_scenario_pnl: Decimal,
334}
335
336/// Top-level SPAN risk grid response for an account.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
339pub struct RiskGridResponse {
340    /// Total account equity in USD. Serialized as a string.
341    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
342    pub equity: Decimal,
343    /// Initial margin for existing positions in USD. Serialized as a string.
344    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
345    pub position_initial_margin: Decimal,
346    /// Maintenance margin for existing positions in USD. Serialized as a string.
347    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
348    pub position_maintenance_margin: Decimal,
349    /// Initial margin reserved for open orders in USD. Serialized as a string.
350    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
351    pub open_orders_initial_margin: Decimal,
352    /// Combined initial margin (positions + open orders) in USD. Serialized as a string.
353    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
354    pub total_initial_margin: Decimal,
355    /// SPAN scanning risk in USD. Serialized as a string.
356    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
357    pub scanning_risk: Decimal,
358    /// Option margin floor in USD. Serialized as a string.
359    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
360    pub option_floor: Decimal,
361    /// Gamma overlay charge in USD. Serialized as a string.
362    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
363    pub gamma_overlay: Decimal,
364    /// Individual scenario results.
365    pub scenarios: Vec<RiskGridScenario>,
366    /// Detailed per-instrument risk matrix, present when requested.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub extended_risk_matrix: Option<ExtendedRiskMatrixResponse>,
369}
370
371// ---- Trade/Fill responses ----
372
373/// A matched trade between a maker and a taker.
374#[derive(Debug, Serialize, Deserialize)]
375#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
376pub struct TradeApiResponse {
377    /// Unique trade identifier.
378    pub trade_id: i64,
379    /// Instrument symbol that was traded.
380    pub symbol: String,
381    /// Execution price in USD. Serialized as a string.
382    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
383    pub price: Decimal,
384    /// Trade size in contracts. Serialized as a string.
385    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
386    pub size: Decimal,
387    /// Maker wallet address (checksummed Ethereum address).
388    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
389    pub maker_address: WalletAddress,
390    /// Taker wallet address (checksummed Ethereum address).
391    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
392    pub taker_address: WalletAddress,
393    /// Fee charged to the maker in USD. Serialized as a string.
394    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
395    pub maker_fee: Decimal,
396    /// Fee charged to the taker in USD. Serialized as a string.
397    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
398    pub taker_fee: Decimal,
399    /// Trade timestamp in milliseconds since epoch.
400    pub timestamp: i64,
401    /// When the trade was persisted (ISO 8601).
402    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
403    pub created_at: DateTime<Utc>,
404}
405
406/// Paginated list of trades.
407#[derive(Debug, Serialize, Deserialize)]
408#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
409pub struct TradesResponse {
410    /// Whether the request succeeded.
411    pub success: bool,
412    /// Trade records for the current page.
413    pub data: Vec<TradeApiResponse>,
414    /// Pagination metadata.
415    pub pagination: crate::Pagination,
416}
417
418/// A single fill (one side of a trade) for a specific wallet.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
421pub struct FillApiResponse {
422    /// Unique fill identifier.
423    pub fill_id: i64,
424    /// Parent trade identifier that produced this fill.
425    pub trade_id: i64,
426    /// Wallet that received this fill (checksummed Ethereum address).
427    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
428    pub wallet_address: WalletAddress,
429    /// Instrument symbol that was filled.
430    pub symbol: String,
431    /// Execution price in USD. Serialized as a string.
432    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
433    pub price: Decimal,
434    /// Fill size in contracts. Serialized as a string.
435    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
436    pub size: Decimal,
437    /// Fee charged for this fill in USD. Serialized as a string.
438    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
439    pub fee: Decimal,
440    /// Whether this fill was on the taker side.
441    pub is_taker: bool,
442    /// Fill timestamp in milliseconds since epoch.
443    pub timestamp: i64,
444    /// When the fill was persisted (ISO 8601).
445    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
446    pub created_at: DateTime<Utc>,
447    /// Builder/referral code wallet, if a builder code was used (checksummed Ethereum address).
448    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
449    pub builder_code_address: Option<WalletAddress>,
450    /// Fee rebate to the builder code wallet in USD. Serialized as a string.
451    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
452    pub builder_code_fee: Option<Decimal>,
453    /// Realized PnL from this fill in USD, if a position was reduced. Serialized as a string.
454    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
455    pub realized_pnl: Option<Decimal>,
456    /// Link to the on-chain transaction, if settled.
457    pub explorer_url: Option<String>,
458}
459
460/// Paginated list of fills.
461#[derive(Debug, Serialize, Deserialize)]
462#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
463pub struct FillsResponse {
464    /// Whether the request succeeded.
465    pub success: bool,
466    /// Fill records for the current page.
467    pub data: Vec<FillApiResponse>,
468    /// Pagination metadata.
469    pub pagination: crate::Pagination,
470}
471
472// ---- Order ----
473
474/// A resting or historical order on the matching engine.
475#[derive(Debug, Clone, Serialize, Deserialize)]
476#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
477#[cfg_attr(feature = "database", derive(sqlx::FromRow))]
478pub struct Order {
479    /// Unique order identifier assigned by the engine.
480    pub order_id: i64,
481    /// Owner wallet address (checksummed Ethereum address).
482    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
483    pub wallet_address: WalletAddress,
484    /// Instrument symbol the order is placed on.
485    pub symbol: String,
486    /// Order side (`"buy"` or `"sell"`).
487    pub side: String,
488    /// Limit price in USD. Serialized as a string.
489    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
490    pub price: Decimal,
491    /// Order size in contracts. Serialized as a string.
492    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
493    pub size: Decimal,
494    /// Time-in-force policy (e.g. `"GTC"`, `"IOC"`, `"FOK"`).
495    pub tif: String,
496    /// Current order status (e.g. `"open"`, `"filled"`, `"cancelled"`).
497    pub status: Option<String>,
498    /// Order creation timestamp in milliseconds since epoch.
499    pub created_at: i64,
500    /// Timestamp of the last status change (ISO 8601).
501    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
502    pub updated_at: Option<DateTime<Utc>>,
503    /// Cumulative filled size in contracts. Serialized as a string.
504    #[serde(skip_serializing_if = "Option::is_none")]
505    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
506    pub filled_size: Option<Decimal>,
507    /// Whether Market Maker Protection is enabled for this order.
508    #[serde(default)]
509    pub mmp_enabled: bool,
510}
511
512// ---- Instrument (server canonical) ----
513
514/// Server-canonical representation of a tradable option instrument.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
517pub struct Instrument {
518    /// Internal numeric instrument identifier.
519    #[serde(default)]
520    pub instrument_id: i32,
521    /// Human-readable instrument symbol (e.g. `"BTC-20260101-100000-C"`).
522    pub id: String,
523    /// Underlying asset (e.g. `"BTC"`, `"ETH"`).
524    pub underlying: String,
525    /// Strike price in USD. Serialized as a string.
526    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
527    pub strike: Decimal,
528    /// Expiry timestamp in seconds since epoch.
529    pub expiry: u64,
530    /// Option type: `"call"` or `"put"`.
531    pub option_type: String,
532    /// On-chain option token contract address, if deployed (checksummed Ethereum address).
533    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
534    pub option_token_address: Option<WalletAddress>,
535    /// Mark implied volatility as a decimal (e.g. `0.70` = 70%). Serialized as a string.
536    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
537    pub mark_iv: Option<Decimal>,
538    /// Rolling 24-hour traded volume in contracts. Serialized as a string.
539    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
540    pub volume_24h: Decimal,
541    /// Total open interest in contracts. Serialized as a string.
542    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
543    pub open_interest: Decimal,
544    /// Last time this instrument's data was refreshed (ISO 8601).
545    #[serde(default = "chrono::Utc::now")]
546    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
547    pub updated_at: DateTime<Utc>,
548    /// Current lifecycle state of the instrument.
549    #[serde(default)]
550    pub status: InstrumentStatus,
551    /// Trading mode flags for this instrument.
552    #[serde(default)]
553    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
554    pub trading_mode: TradingModes,
555}
556
557// ---- Market data ----
558
559/// Summary of a single expiry's market data for one underlying.
560#[derive(Debug, Serialize, Deserialize)]
561#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
562pub struct MarketInfo {
563    /// Underlying asset (e.g. `"BTC"`).
564    pub underlying: String,
565    /// Expiry timestamp in seconds since epoch.
566    pub expiry: u64,
567    /// Current spot/index price of the underlying in USD. Serialized as a string.
568    /// Defaults to zero if missing or null.
569    #[serde(default, deserialize_with = "decimal_or_zero")]
570    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
571    pub index_price: Decimal,
572    /// At-the-money implied volatility as a decimal, if available. Serialized as a string.
573    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
574    pub atm_vol: Option<Decimal>,
575    /// All instruments listed under this underlying/expiry pair.
576    pub instruments: Vec<Instrument>,
577    /// Aggregate 24-hour traded volume across all instruments in contracts. Serialized as a string.
578    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
579    pub total_volume_24h: Decimal,
580    /// Aggregate open interest across all instruments in contracts. Serialized as a string.
581    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
582    pub total_open_interest: Decimal,
583    /// Previous day's closing index price in USD, if available. Serialized as a string.
584    #[serde(skip_serializing_if = "Option::is_none")]
585    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
586    pub prev_day_price: Option<Decimal>,
587}
588
589/// Response containing all available markets.
590#[derive(Debug, Serialize, Deserialize)]
591#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
592pub struct MarketsResponse {
593    /// Whether the request succeeded.
594    pub success: bool,
595    /// List of markets grouped by underlying and expiry.
596    pub data: Vec<MarketInfo>,
597}
598
599// ---- Options chain ----
600
601/// Absolute (per-contract) option Greeks.
602#[derive(Debug, Clone, Serialize, Deserialize)]
603#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
604#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
605pub struct OptionsChainGreeksAbs {
606    /// Delta: rate of change of option price with respect to underlying price.
607    pub delta: f64,
608    /// Gamma: rate of change of delta with respect to underlying price.
609    pub gamma: f64,
610    /// Theta: time decay per day.
611    pub theta: f64,
612    /// Vega: sensitivity to 1-point change in implied volatility.
613    pub vega: f64,
614}
615
616/// Cash-denominated option Greeks (dollar impact per unit move).
617#[derive(Debug, Clone, Serialize, Deserialize)]
618#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
619#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
620pub struct OptionsChainGreeksCash {
621    /// Dollar PnL for a 1% move in the underlying.
622    pub delta_1pct_usd: f64,
623    /// Dollar gamma impact for a 1% move in the underlying.
624    pub gamma_1pct_usd: f64,
625    /// Dollar theta decay over one day.
626    pub theta_1d_usd: f64,
627    /// Dollar vega for a 1-vol-point move.
628    pub vega_1vol_usd: f64,
629}
630
631/// A single call or put leg in the options chain, including top-of-book quotes and Greeks.
632#[derive(Debug, Clone, Serialize, Deserialize)]
633#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
634#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
635pub struct OptionsChainLeg {
636    /// Instrument symbol (e.g. `"BTC-20260101-100000-C"`).
637    pub symbol: String,
638    /// On-chain option token address, if deployed (checksummed Ethereum address).
639    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
640    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
641    pub option_token_address: Option<WalletAddress>,
642    /// Best bid price in USD.
643    pub bid_price_usd: Option<f64>,
644    /// Implied volatility at the best bid.
645    pub bid_iv: Option<f64>,
646    /// Size at the best bid in contracts.
647    pub bid_size_contracts: Option<f64>,
648    /// Notional value of the best bid in USD.
649    pub bid_size_usd_notional: Option<f64>,
650    /// Best ask price in USD.
651    pub ask_price_usd: Option<f64>,
652    /// Implied volatility at the best ask.
653    pub ask_iv: Option<f64>,
654    /// Size at the best ask in contracts.
655    pub ask_size_contracts: Option<f64>,
656    /// Notional value of the best ask in USD.
657    pub ask_size_usd_notional: Option<f64>,
658    /// Per-contract (absolute) Greeks.
659    pub greeks_abs: Option<OptionsChainGreeksAbs>,
660    /// Cash-denominated Greeks.
661    pub greeks_cash: Option<OptionsChainGreeksCash>,
662}
663
664/// A single strike row in the options chain, pairing call and put legs.
665#[derive(Debug, Clone, Serialize, Deserialize)]
666#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
667#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
668pub struct OptionsChainStrikeRow {
669    /// Strike price in USD.
670    pub strike: f64,
671    /// Call leg at this strike, if listed.
672    #[serde(skip_serializing_if = "Option::is_none")]
673    pub call: Option<OptionsChainLeg>,
674    /// Put leg at this strike, if listed.
675    #[serde(skip_serializing_if = "Option::is_none")]
676    pub put: Option<OptionsChainLeg>,
677}
678
679/// Full options chain snapshot for one underlying and expiry.
680#[derive(Debug, Clone, Serialize, Deserialize)]
681#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
682#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
683pub struct OptionsChainSnapshotResponse {
684    /// Underlying currency (e.g. `"BTC"`).
685    pub currency: String,
686    /// Expiry timestamp in seconds since epoch.
687    pub expiry: u64,
688    /// Strike rows ordered by strike price.
689    pub rows: Vec<OptionsChainStrikeRow>,
690}
691
692// ---- Greeks ----
693
694/// A hypothetical order used for simulating portfolio Greeks impact.
695#[derive(Debug, Clone, Serialize, Deserialize)]
696#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
697pub struct SimulatedGreeksOrder {
698    /// Instrument symbol to simulate.
699    pub symbol: String,
700    /// Order side (buy or sell).
701    pub side: Side,
702    /// Simulated order size in contracts. Serialized as a string.
703    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
704    pub size: Decimal,
705}
706
707/// Greeks for a single position leg in a portfolio.
708#[derive(Debug, Clone, Serialize, Deserialize)]
709#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
710pub struct PositionGreeksLeg {
711    /// Instrument symbol.
712    pub symbol: String,
713    /// Position size in contracts (positive = long, negative = short). Serialized as a string.
714    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
715    pub quantity: Decimal,
716    /// Delta of this leg.
717    pub delta: f64,
718    /// Gamma of this leg.
719    pub gamma: f64,
720    /// Theta (daily time decay) of this leg.
721    pub theta: f64,
722    /// Vega of this leg.
723    pub vega: f64,
724    /// Implied volatility used for this leg's Greeks.
725    pub iv: f64,
726}
727
728/// Aggregate Greeks across all positions in a portfolio.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
731pub struct PortfolioGreeksAggregate {
732    /// Net portfolio delta.
733    pub delta: f64,
734    /// Net portfolio gamma.
735    pub gamma: f64,
736    /// Net portfolio theta (daily).
737    pub theta: f64,
738    /// Net portfolio vega.
739    pub vega: f64,
740    /// Weighted-average implied volatility, if computable.
741    pub iv: Option<f64>,
742}
743
744/// Full portfolio Greeks breakdown: per-leg detail and aggregate totals.
745#[derive(Debug, Clone, Serialize, Deserialize)]
746#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
747pub struct PortfolioGreeksResponse {
748    /// Account wallet address (checksummed Ethereum address).
749    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
750    pub wallet_address: WalletAddress,
751    /// Greeks for each individual position leg.
752    #[serde(default)]
753    pub per_leg: Vec<PositionGreeksLeg>,
754    /// Aggregate Greeks across all legs, if the portfolio is non-empty.
755    #[serde(skip_serializing_if = "Option::is_none")]
756    pub aggregate: Option<PortfolioGreeksAggregate>,
757}
758
759// ---- Health ----
760
761/// Simple health-check response from `GET /health`.
762#[derive(Debug, Serialize, Deserialize)]
763#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
764pub struct HealthResponse {
765    /// Health status string (e.g. `"ok"`).
766    pub status: String,
767}
768
769/// Build version and git metadata returned by `GET /version`.
770#[derive(Debug, Serialize, Deserialize)]
771#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
772pub struct VersionResponse {
773    /// Semantic version of the running binary.
774    pub version: String,
775    /// Short git commit SHA.
776    pub commit: String,
777    /// Git ref (branch or tag) from which the binary was built. Serialized as `"ref"`.
778    #[serde(rename = "ref")]
779    pub git_ref: String,
780    /// Build timestamp string.
781    pub build_time: String,
782    /// Chain ID used for EIP-712 option order signing domain (998 = testnet, 999 = mainnet).
783    /// Absent on older servers; frontend should default to 998 if missing.
784    #[serde(skip_serializing_if = "Option::is_none")]
785    pub signing_chain_id: Option<u64>,
786}
787
788/// Readiness status of one internal component (DB, engine, oracles, etc.).
789#[derive(Debug, Serialize, Deserialize, Clone)]
790#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
791pub struct ReadinessComponentReport {
792    /// Component name.
793    pub name: String,
794    /// Whether the component is ready to serve traffic.
795    pub ready: bool,
796    /// Optional detail string explaining the current state.
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub detail: Option<String>,
799}
800
801/// Aggregated readiness probe response from `GET /ready`.
802#[derive(Debug, Serialize, Deserialize)]
803#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
804pub struct ReadyResponse {
805    /// Overall readiness status (e.g. `"ready"` or `"not_ready"`).
806    pub status: String,
807    /// Human-readable message if not all components are ready.
808    #[serde(skip_serializing_if = "Option::is_none")]
809    pub message: Option<String>,
810    /// Per-component readiness reports.
811    pub components: Vec<ReadinessComponentReport>,
812}
813
814// ---- MMP ----
815
816/// Market Maker Protection (MMP) configuration for a wallet and currency.
817#[derive(Debug, Clone, Serialize, Deserialize)]
818#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
819pub struct MmpConfigData {
820    /// Market maker wallet address (checksummed Ethereum address).
821    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
822    pub wallet_address: WalletAddress,
823    /// Underlying currency this config applies to (e.g. `"BTC"`).
824    pub currency: String,
825    /// Rolling window length in milliseconds for fill monitoring.
826    pub interval_ms: i64,
827    /// Duration in milliseconds to freeze quoting after a trigger.
828    pub frozen_time_ms: i64,
829    /// Maximum filled quantity in contracts within the interval. Serialized as a string.
830    #[serde(skip_serializing_if = "Option::is_none")]
831    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
832    pub qty_limit: Option<Decimal>,
833    /// Maximum net delta filled within the interval. Serialized as a string.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
836    pub delta_limit: Option<Decimal>,
837    /// Maximum net vega filled within the interval. Serialized as a string.
838    #[serde(skip_serializing_if = "Option::is_none")]
839    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
840    pub vega_limit: Option<Decimal>,
841    /// Whether MMP is currently active for this wallet/currency pair.
842    pub enabled: bool,
843}
844
845/// Signed request to create or update an MMP configuration.
846#[derive(Debug, Serialize, Deserialize)]
847#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
848pub struct SetMmpConfigRequest {
849    /// Market maker wallet address (checksummed Ethereum address).
850    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
851    pub wallet: WalletAddress,
852    /// Underlying currency (e.g. `"BTC"`).
853    pub currency: String,
854    /// Rolling window length in milliseconds.
855    pub interval_ms: i64,
856    /// Freeze duration in milliseconds after a trigger.
857    pub frozen_time_ms: i64,
858    /// Maximum filled quantity limit. Serialized as a string.
859    #[serde(skip_serializing_if = "Option::is_none")]
860    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
861    pub qty_limit: Option<Decimal>,
862    /// Maximum net delta limit. Serialized as a string.
863    #[serde(skip_serializing_if = "Option::is_none")]
864    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
865    pub delta_limit: Option<Decimal>,
866    /// Maximum net vega limit. Serialized as a string.
867    #[serde(skip_serializing_if = "Option::is_none")]
868    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
869    pub vega_limit: Option<Decimal>,
870    /// Monotonically increasing nonce for replay protection.
871    pub nonce: u64,
872    /// EIP-712 signature authorizing this request.
873    pub signature: String,
874}
875
876/// Signed request to delete an MMP configuration.
877#[derive(Debug, Serialize, Deserialize)]
878#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
879pub struct DeleteMmpConfigRequest {
880    /// Market maker wallet address (checksummed Ethereum address).
881    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
882    pub wallet: WalletAddress,
883    /// Underlying currency to delete MMP config for.
884    pub currency: String,
885    /// Monotonically increasing nonce for replay protection.
886    pub nonce: u64,
887    /// EIP-712 signature authorizing this request.
888    pub signature: String,
889}
890
891/// Signed request to reset (unfreeze) a triggered MMP state.
892#[derive(Debug, Serialize, Deserialize)]
893#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
894pub struct ResetMmpRequest {
895    /// Market maker wallet address (checksummed Ethereum address).
896    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
897    pub wallet: WalletAddress,
898    /// Underlying currency to reset MMP for.
899    pub currency: String,
900    /// Monotonically increasing nonce for replay protection.
901    pub nonce: u64,
902    /// EIP-712 signature authorizing this request.
903    pub signature: String,
904}
905
906/// Response listing MMP configurations for a wallet.
907#[derive(Debug, Serialize, Deserialize)]
908#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
909pub struct MmpConfigResponse {
910    /// Whether the request succeeded.
911    pub success: bool,
912    /// MMP configurations, one per currency.
913    pub data: Vec<MmpConfigData>,
914}
915
916// ---- User tier ----
917
918/// A wallet's assigned fee/rate-limit tier.
919#[derive(Debug, Clone, Serialize, Deserialize)]
920#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
921pub struct UserTierData {
922    /// Account wallet address (checksummed Ethereum address).
923    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
924    pub wallet_address: WalletAddress,
925    /// Tier name (e.g. `"default"`, `"mm"`, `"vip"`).
926    pub tier: String,
927}
928
929/// Rate and capacity limits for a given tier.
930#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
931#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
932pub struct TradingLimits {
933    /// Maximum number of concurrent open orders.
934    pub max_open_orders: i32,
935    /// Maximum number of concurrent open positions.
936    pub max_open_positions: i32,
937    /// Maximum order submissions per minute.
938    pub orders_per_minute: i32,
939    /// Maximum order cancellations per minute.
940    pub cancels_per_minute: i32,
941    /// Maximum total API requests per minute.
942    pub api_requests_per_minute: i32,
943}
944
945impl Default for TradingLimits {
946    fn default() -> Self {
947        Self {
948            max_open_orders: 100,
949            max_open_positions: 50,
950            orders_per_minute: 60,
951            cancels_per_minute: 120,
952            api_requests_per_minute: 600,
953        }
954    }
955}
956
957/// Admin request to assign a fee/rate-limit tier to a wallet.
958#[derive(Debug, Serialize, Deserialize)]
959#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
960pub struct SetUserTierRequest {
961    /// Target wallet address (checksummed Ethereum address).
962    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
963    pub wallet: WalletAddress,
964    /// Tier to assign.
965    pub tier: String,
966    /// Monotonically increasing nonce for replay protection.
967    pub nonce: u64,
968    /// Admin EIP-712 signature authorizing this request.
969    pub signature: String,
970}
971
972/// Admin request to remove a custom tier assignment (revert to default).
973#[derive(Debug, Serialize, Deserialize)]
974#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
975pub struct DeleteUserTierRequest {
976    /// Target wallet address (checksummed Ethereum address).
977    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
978    pub wallet: WalletAddress,
979    /// Monotonically increasing nonce for replay protection.
980    pub nonce: u64,
981    /// Admin EIP-712 signature authorizing this request.
982    pub signature: String,
983}
984
985/// Response containing a wallet's current tier assignment.
986#[derive(Debug, Serialize, Deserialize)]
987#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
988pub struct UserTierResponse {
989    /// Whether the request succeeded.
990    pub success: bool,
991    /// Tier data for the queried wallet.
992    pub data: UserTierData,
993}
994
995// ---- Margin mode ----
996
997/// Result of a margin mode switch.
998#[derive(Debug, Serialize, Deserialize)]
999#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1000pub struct MarginModeResponse {
1001    /// Wallet address that was updated.
1002    pub wallet: String,
1003    /// New margin mode after the switch.
1004    pub margin_mode: String,
1005    /// Margin mode before the switch.
1006    pub previous_mode: String,
1007}
1008
1009/// API envelope for margin mode operations.
1010#[derive(Debug, Serialize, Deserialize)]
1011#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1012pub struct MarginModeApiResponse {
1013    /// Whether the request succeeded.
1014    pub success: bool,
1015    /// Margin mode change details, present on success.
1016    pub data: Option<MarginModeResponse>,
1017    /// Error message, present on failure.
1018    pub error: Option<String>,
1019}
1020
1021// ---- Monitoring ----
1022
1023/// Admin monitoring summary for a single account.
1024#[derive(Debug, Serialize, Deserialize)]
1025#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1026pub struct MonitoringAccountSummary {
1027    /// Wallet address.
1028    pub wallet: String,
1029    /// Effective margin mode used for this account.
1030    pub margin_mode: String,
1031    /// Account equity in USD, if computable.
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    pub equity: Option<String>,
1034    /// Margin currently in use in USD, if computable.
1035    #[serde(skip_serializing_if = "Option::is_none")]
1036    pub margin_used: Option<String>,
1037    /// Number of open positions.
1038    pub position_count: usize,
1039    /// Sum of all position notional values in USD.
1040    pub total_notional: String,
1041    /// Error string if margin computation failed for this account.
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    pub margin_error: Option<String>,
1044}
1045
1046/// Admin monitoring response listing all accounts.
1047#[derive(Debug, Serialize, Deserialize)]
1048#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1049pub struct MonitoringAccountsResponse {
1050    /// Total number of accounts with positions.
1051    pub account_count: usize,
1052    /// Per-account summaries.
1053    pub accounts: Vec<MonitoringAccountSummary>,
1054}
1055
1056/// A single wallet holding a position in a monitored symbol.
1057#[derive(Debug, Clone, Serialize, Deserialize)]
1058#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1059pub struct MonitoringPositionHolder {
1060    /// Holder wallet address.
1061    pub wallet: String,
1062    /// Position size in contracts.
1063    pub amount: String,
1064    /// Entry price in USD.
1065    pub entry_price: String,
1066    /// Unrealized PnL in USD.
1067    pub unrealized_pnl: String,
1068}
1069
1070/// Aggregated position data for one symbol across all holders (admin monitoring).
1071#[derive(Debug, Serialize, Deserialize)]
1072#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1073pub struct MonitoringSymbolPosition {
1074    /// Instrument symbol.
1075    pub symbol: String,
1076    /// Total long exposure in contracts.
1077    pub total_long: String,
1078    /// Total short exposure in contracts.
1079    pub total_short: String,
1080    /// Net position across all holders.
1081    pub net_position: String,
1082    /// Whether total longs equal total shorts (market integrity check).
1083    pub is_balanced: bool,
1084    /// Number of distinct wallets holding this symbol.
1085    pub holder_count: usize,
1086    /// Individual position holders.
1087    pub holders: Vec<MonitoringPositionHolder>,
1088}
1089
1090/// Admin monitoring response listing positions grouped by symbol.
1091#[derive(Debug, Serialize, Deserialize)]
1092#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1093pub struct MonitoringPositionsResponse {
1094    /// Number of symbols with open positions.
1095    pub symbol_count: usize,
1096    /// Per-symbol position summaries.
1097    pub symbols: Vec<MonitoringSymbolPosition>,
1098}
1099// ---- Competition types (server-only for now) ----
1100
1101/// Lifecycle state of a trading competition. Serialized as lowercase.
1102#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1103#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1104#[serde(rename_all = "lowercase")]
1105pub enum CompetitionStateValue {
1106    /// Competition has not started yet.
1107    Pre,
1108    /// Competition is currently running.
1109    Active,
1110    /// Competition has ended.
1111    Post,
1112}
1113
1114/// Field to sort the leaderboard by. Serialized as lowercase.
1115#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1116#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1117#[serde(rename_all = "lowercase")]
1118pub enum CompetitionSortByValue {
1119    /// Sort by realized PnL.
1120    Pnl,
1121    /// Sort by traded volume.
1122    Volume,
1123    /// Sort by capital efficiency (PnL / margin used).
1124    Efficiency,
1125}
1126
1127/// Sort direction for leaderboard queries. Serialized as lowercase.
1128#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1129#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1130#[serde(rename_all = "lowercase")]
1131pub enum CompetitionSortOrderValue {
1132    /// Ascending order.
1133    Asc,
1134    /// Descending order.
1135    Desc,
1136}
1137
1138/// Metric used to determine competition winners. Serialized as lowercase.
1139#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1140#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1141#[serde(rename_all = "lowercase")]
1142pub enum CompetitionWinConditionValue {
1143    /// Winner determined by highest realized PnL.
1144    Pnl,
1145    /// Winner determined by highest traded volume.
1146    Volume,
1147    /// Winner determined by highest capital efficiency.
1148    Efficiency,
1149}
1150
1151/// Full metadata for a trading competition.
1152#[derive(Debug, Clone, Serialize, Deserialize)]
1153#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1154pub struct CompetitionData {
1155    /// Unique competition identifier.
1156    pub id: i64,
1157    /// Display name of the competition.
1158    pub name: String,
1159    /// Optional short description.
1160    pub description: Option<String>,
1161    /// Optional URL to external rules page.
1162    pub rules_url: Option<String>,
1163    /// Optional inline rules content (Markdown).
1164    pub rules_content: Option<String>,
1165    /// Metrics tracked for ranking (e.g. `["pnl", "volume"]`).
1166    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<CompetitionWinConditionValue>))]
1167    pub win_conditions: Vec<String>,
1168    /// Primary metric used to determine the overall winner.
1169    #[cfg_attr(feature = "utoipa", schema(value_type = CompetitionWinConditionValue))]
1170    pub primary_win_condition: String,
1171    /// Competition start time in milliseconds since epoch.
1172    pub start_ts_ms: i64,
1173    /// Competition end time in milliseconds since epoch.
1174    pub end_ts_ms: i64,
1175    /// Current lifecycle state (`"pre"`, `"active"`, or `"post"`).
1176    #[cfg_attr(feature = "utoipa", schema(value_type = CompetitionStateValue))]
1177    pub state: String,
1178    /// When this competition was created (ISO 8601).
1179    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1180    pub created_at: DateTime<Utc>,
1181    /// When this competition was last updated (ISO 8601).
1182    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1183    pub updated_at: DateTime<Utc>,
1184}
1185
1186/// Paginated list of competitions.
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1189pub struct CompetitionsResponse {
1190    /// Whether the request succeeded.
1191    pub success: bool,
1192    /// Competition records for the current page.
1193    pub data: Vec<CompetitionData>,
1194    /// Pagination metadata.
1195    pub pagination: crate::Pagination,
1196}
1197
1198/// Response containing a single competition.
1199#[derive(Debug, Clone, Serialize, Deserialize)]
1200#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1201pub struct CompetitionResponse {
1202    /// Whether the request succeeded.
1203    pub success: bool,
1204    /// Competition details.
1205    pub data: CompetitionData,
1206}
1207
1208/// A single row on the competition leaderboard.
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1210#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1211pub struct LeaderboardRow {
1212    /// 1-based rank on the leaderboard.
1213    pub rank: usize,
1214    /// Participant wallet address (checksummed Ethereum address).
1215    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1216    pub wallet: WalletAddress,
1217    /// Display username.
1218    pub username: String,
1219    /// Realized PnL during the competition in USD. Serialized as a string.
1220    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1221    pub pnl: Decimal,
1222    /// Total traded volume during the competition in USD. Serialized as a string.
1223    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1224    pub volume: Decimal,
1225    /// Capital efficiency (PnL / margin used). Serialized as a string.
1226    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1227    pub efficiency: Decimal,
1228    /// Medal tier (1 = gold, 2 = silver, 3 = bronze), if awarded.
1229    pub medal: Option<u8>,
1230}
1231
1232/// The requesting (connected) user's own rank on the leaderboard.
1233#[derive(Debug, Clone, Serialize, Deserialize)]
1234#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1235pub struct ConnectedUserRank {
1236    /// Connected user's wallet address (checksummed Ethereum address).
1237    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1238    pub wallet: WalletAddress,
1239    /// Display username.
1240    pub username: String,
1241    /// Current rank, if the user is on the leaderboard.
1242    pub rank: Option<usize>,
1243    /// Realized PnL in USD. Serialized as a string.
1244    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
1245    pub pnl: Option<Decimal>,
1246    /// Traded volume in USD. Serialized as a string.
1247    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
1248    pub volume: Option<Decimal>,
1249    /// Capital efficiency. Serialized as a string.
1250    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
1251    pub efficiency: Option<Decimal>,
1252    /// Medal tier, if awarded.
1253    pub medal: Option<u8>,
1254}
1255
1256/// Paginated competition leaderboard with optional connected-user context.
1257#[derive(Debug, Clone, Serialize, Deserialize)]
1258#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1259pub struct CompetitionLeaderboardResponse {
1260    /// Whether the request succeeded.
1261    pub success: bool,
1262    /// Competition this leaderboard belongs to.
1263    pub competition_id: i64,
1264    /// Field the leaderboard is sorted by.
1265    #[cfg_attr(feature = "utoipa", schema(value_type = CompetitionSortByValue))]
1266    pub sort_by: String,
1267    /// Sort direction.
1268    #[cfg_attr(feature = "utoipa", schema(value_type = CompetitionSortOrderValue))]
1269    pub sort_order: String,
1270    /// Leaderboard rows for the current page.
1271    pub data: Vec<LeaderboardRow>,
1272    /// The connected user's own rank, if authenticated.
1273    pub connected_user: Option<ConnectedUserRank>,
1274    /// Pagination metadata.
1275    pub pagination: crate::Pagination,
1276}
1277
1278/// Realized PnL breakdown for one instrument symbol.
1279#[derive(Debug, Clone, Serialize, Deserialize)]
1280#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1281pub struct RealizedPnlRow {
1282    /// Instrument symbol.
1283    pub symbol: String,
1284    /// Total realized PnL for this symbol in USD. Serialized as a string.
1285    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1286    pub realized_pnl: Decimal,
1287    /// Number of PnL events (fills, settlements) contributing to the total.
1288    pub event_count: i64,
1289}
1290
1291/// Response containing per-symbol realized PnL.
1292#[derive(Debug, Clone, Serialize, Deserialize)]
1293#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1294pub struct RealizedPnlResponse {
1295    /// Whether the request succeeded.
1296    pub success: bool,
1297    /// Per-symbol realized PnL rows.
1298    pub data: Vec<RealizedPnlRow>,
1299}
1300
1301/// Margin statistics shown on a user's profile.
1302#[derive(Debug, Clone, Serialize, Deserialize)]
1303#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1304pub struct ProfileMarginStats {
1305    /// Margin currently locked in positions in USD. Serialized as a string.
1306    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1307    pub in_use: Decimal,
1308    /// Free margin available for new trades in USD. Serialized as a string.
1309    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1310    pub available: Decimal,
1311    /// Total account equity (in_use + available) in USD. Serialized as a string.
1312    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1313    pub total: Decimal,
1314    /// Lifetime deposit total in USD. Serialized as a string.
1315    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1316    pub deposits: Decimal,
1317    /// Lifetime withdrawal total in USD. Serialized as a string.
1318    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1319    pub withdraws: Decimal,
1320}
1321
1322/// PnL statistics shown on a user's profile.
1323#[derive(Debug, Clone, Serialize, Deserialize)]
1324#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1325pub struct ProfilePnlStats {
1326    /// Current mark-to-market unrealized PnL in USD. Serialized as a string.
1327    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1328    pub unrealized: Decimal,
1329    /// Realized PnL over the last 24 hours in USD. Serialized as a string.
1330    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1331    pub pnl_24h: Decimal,
1332    /// Lifetime cumulative realized PnL in USD. Serialized as a string.
1333    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1334    pub lifetime_realized: Decimal,
1335}
1336
1337/// Summary of a user's rank in a specific competition, shown on their profile.
1338#[derive(Debug, Clone, Serialize, Deserialize)]
1339#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1340pub struct ProfileCompetitionRankSummary {
1341    /// Competition identifier.
1342    pub competition_id: i64,
1343    /// Competition display name.
1344    pub competition_name: String,
1345    /// Competition lifecycle state.
1346    #[cfg_attr(feature = "utoipa", schema(value_type = CompetitionStateValue))]
1347    pub competition_state: String,
1348    /// User's rank in this competition.
1349    pub rank: usize,
1350    /// User's realized PnL in this competition in USD. Serialized as a string.
1351    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1352    pub pnl: Decimal,
1353    /// User's traded volume in this competition in USD. Serialized as a string.
1354    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1355    pub volume: Decimal,
1356    /// User's capital efficiency in this competition. Serialized as a string.
1357    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1358    pub efficiency: Decimal,
1359    /// Medal tier, if awarded.
1360    pub medal: Option<u8>,
1361}
1362
1363/// Platform-wide medals earned by a user across all competitions.
1364#[derive(Debug, Clone, Serialize, Deserialize)]
1365#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1366pub struct ProfileMetricMedals {
1367    /// Best PnL medal tier (1 = gold, 2 = silver, 3 = bronze).
1368    pub pnl: Option<u8>,
1369    /// Best volume medal tier.
1370    pub volume: Option<u8>,
1371    /// Best efficiency medal tier.
1372    pub efficiency: Option<u8>,
1373}
1374
1375/// Aggregated profile data for a single user.
1376#[derive(Debug, Clone, Serialize, Deserialize)]
1377#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1378pub struct ProfileData {
1379    /// User's wallet address (checksummed Ethereum address).
1380    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1381    pub wallet: WalletAddress,
1382    /// Display username.
1383    pub username: String,
1384    /// Moderated profile image URL, if set.
1385    pub profile_image_url: Option<String>,
1386    /// Timestamp when the account was first observed (ms since epoch).
1387    pub account_first_seen_ts_ms: Option<i64>,
1388    /// Number of days since the account was first seen.
1389    pub account_age_days: Option<i64>,
1390    /// Margin statistics.
1391    pub margin: ProfileMarginStats,
1392    /// PnL statistics.
1393    pub pnl: ProfilePnlStats,
1394    /// Best overall medal tier across all competitions.
1395    pub medal: Option<u8>,
1396    /// Per-metric best medals across all competitions.
1397    pub platform_medals: ProfileMetricMedals,
1398    /// Rank in the currently active competition, if participating.
1399    #[serde(skip_serializing_if = "Option::is_none")]
1400    pub active_competition_rank: Option<ProfileCompetitionRankSummary>,
1401}
1402
1403/// Response containing a user's full profile.
1404#[derive(Debug, Clone, Serialize, Deserialize)]
1405#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1406pub struct ProfileResponse {
1407    /// Whether the request succeeded.
1408    pub success: bool,
1409    /// Profile data.
1410    pub data: ProfileData,
1411}
1412
1413/// Paginated list of a user's trade fills, shown on their profile.
1414#[derive(Debug, Clone, Serialize, Deserialize)]
1415#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1416pub struct ProfileTradesResponse {
1417    /// Whether the request succeeded.
1418    pub success: bool,
1419    /// Fill records for the current page.
1420    pub data: Vec<FillApiResponse>,
1421    /// Pagination metadata.
1422    pub pagination: crate::Pagination,
1423}
1424
1425/// Admin request to create a new competition.
1426#[derive(Debug, Clone, Serialize, Deserialize)]
1427#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1428pub struct CompetitionUpsertRequest {
1429    /// Competition display name.
1430    pub name: String,
1431    /// Optional short description.
1432    pub description: Option<String>,
1433    /// Optional URL to external rules page.
1434    pub rules_url: Option<String>,
1435    /// Optional inline rules content (Markdown).
1436    pub rules_content: Option<String>,
1437    /// Metrics to track for ranking (at least one required).
1438    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<CompetitionWinConditionValue>, min_items = 1))]
1439    pub win_conditions: Vec<String>,
1440    /// Primary metric for determining the overall winner.
1441    #[cfg_attr(feature = "utoipa", schema(value_type = CompetitionWinConditionValue))]
1442    pub primary_win_condition: String,
1443    /// Competition start time in milliseconds since epoch.
1444    pub start_ts_ms: i64,
1445    /// Competition end time in milliseconds since epoch.
1446    pub end_ts_ms: i64,
1447}
1448
1449/// Admin request to partially update an existing competition.
1450///
1451/// All fields are optional; only provided fields are modified.
1452/// Double-`Option` fields (`Option<Option<T>>`) distinguish "not provided" from "set to null".
1453#[derive(Debug, Clone, Serialize, Deserialize)]
1454#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1455pub struct CompetitionUpdateRequest {
1456    /// New display name, if updating.
1457    pub name: Option<String>,
1458    /// New description: `None` = keep, `Some(None)` = clear, `Some(Some(..))` = set.
1459    pub description: Option<Option<String>>,
1460    /// New rules URL: `None` = keep, `Some(None)` = clear, `Some(Some(..))` = set.
1461    pub rules_url: Option<Option<String>>,
1462    /// New rules content: `None` = keep, `Some(None)` = clear, `Some(Some(..))` = set.
1463    pub rules_content: Option<Option<String>>,
1464    /// New win conditions, if updating.
1465    #[cfg_attr(feature = "utoipa", schema(value_type = Option<Vec<CompetitionWinConditionValue>>, min_items = 1))]
1466    pub win_conditions: Option<Vec<String>>,
1467    /// New primary win condition, if updating.
1468    #[cfg_attr(feature = "utoipa", schema(value_type = Option<CompetitionWinConditionValue>))]
1469    pub primary_win_condition: Option<String>,
1470    /// New start time in milliseconds since epoch, if updating.
1471    pub start_ts_ms: Option<i64>,
1472    /// New end time in milliseconds since epoch, if updating.
1473    pub end_ts_ms: Option<i64>,
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::*;
1479    use rust_decimal_macros::dec;
1480
1481    #[test]
1482    fn api_response_preserves_null_fields() {
1483        let resp: ApiResponse<String> = ApiResponse::error("something broke".to_string());
1484        let json = serde_json::to_value(&resp).unwrap();
1485        assert_eq!(json["success"], false);
1486        assert!(
1487            json.get("data").is_some(),
1488            "data field must be present (null, not omitted)"
1489        );
1490        assert!(json["data"].is_null());
1491        assert_eq!(json["error"], "something broke");
1492    }
1493
1494    #[test]
1495    fn api_response_success_includes_data() {
1496        let resp = ApiResponse::success("hello".to_string());
1497        let json = serde_json::to_value(&resp).unwrap();
1498        assert_eq!(json["success"], true);
1499        assert_eq!(json["data"], "hello");
1500        assert!(json["error"].is_null());
1501    }
1502
1503    #[test]
1504    fn api_response_roundtrip() {
1505        let resp = ApiResponse::success(42u32);
1506        let json_str = serde_json::to_string(&resp).unwrap();
1507        let parsed: ApiResponse<u32> = serde_json::from_str(&json_str).unwrap();
1508        assert!(parsed.success);
1509        assert_eq!(parsed.data, Some(42));
1510    }
1511
1512    #[test]
1513    fn profile_image_url_serde_contract() {
1514        for profile_image_url in [
1515            serde_json::json!("https://example.com/profile.png"),
1516            serde_json::Value::Null,
1517        ] {
1518            let input = serde_json::json!({
1519                "wallet": "0x0000000000000000000000000000000000000000",
1520                "username": "alice",
1521                "profile_image_url": profile_image_url,
1522                "account_first_seen_ts_ms": null,
1523                "account_age_days": null,
1524                "margin": {
1525                    "in_use": "0",
1526                    "available": "0",
1527                    "total": "0",
1528                    "deposits": "0",
1529                    "withdraws": "0"
1530                },
1531                "pnl": {
1532                    "unrealized": "0",
1533                    "pnl_24h": "0",
1534                    "lifetime_realized": "0"
1535                },
1536                "medal": null,
1537                "platform_medals": {
1538                    "pnl": null,
1539                    "volume": null,
1540                    "efficiency": null
1541                },
1542                "active_competition_rank": null
1543            });
1544
1545            let profile: ProfileData = serde_json::from_value(input.clone()).unwrap();
1546            let output = serde_json::to_value(&profile).unwrap();
1547            assert_eq!(output["profile_image_url"], input["profile_image_url"]);
1548        }
1549    }
1550
1551    #[test]
1552    fn decimal_fields_serialize_as_strings() {
1553        let position = Position {
1554            wallet_address: WalletAddress::default(),
1555            symbol: "BTC-20260101-100000-C".to_string(),
1556            amount: dec!(1.5),
1557            entry_price: dec!(2500),
1558            margin_posted: dec!(100),
1559            realized_pnl: dec!(-50),
1560            unrealized_pnl: dec!(200),
1561            updated_at: chrono::Utc::now(),
1562        };
1563        let json = serde_json::to_value(&position).unwrap();
1564        assert_eq!(json["amount"], "1.5");
1565        assert_eq!(json["entry_price"], "2500");
1566        assert_eq!(json["realized_pnl"], "-50");
1567    }
1568
1569    #[test]
1570    fn decimal_fields_deserialize_from_strings() {
1571        let json = serde_json::json!({
1572            "wallet_address": "0x0000000000000000000000000000000000000000",
1573            "symbol": "BTC-20260101-100000-C",
1574            "amount": "1.5",
1575            "entry_price": "2500",
1576            "margin_posted": "100",
1577            "realized_pnl": "-50",
1578            "unrealized_pnl": "200",
1579            "updated_at": "2026-01-01T00:00:00Z"
1580        });
1581        let position: Position = serde_json::from_value(json).unwrap();
1582        assert_eq!(position.amount, dec!(1.5));
1583        assert_eq!(position.entry_price, dec!(2500));
1584    }
1585
1586    #[test]
1587    fn decimal_fields_deserialize_from_serde_json_value() {
1588        let json = serde_json::json!({
1589            "wallet_address": "0x0000000000000000000000000000000000000000",
1590            "symbol": "BTC-20260101-100000-C",
1591            "amount": "1.5",
1592            "entry_price": "2500",
1593            "margin_posted": "100",
1594            "realized_pnl": "-50",
1595            "unrealized_pnl": "200",
1596            "updated_at": "2026-01-01T00:00:00Z"
1597        });
1598        let pos: Position = serde_json::from_value(json).unwrap();
1599        assert_eq!(pos.amount, dec!(1.5));
1600        assert_eq!(pos.entry_price, dec!(2500));
1601    }
1602
1603    #[test]
1604    fn position_with_metrics_flattens_position_fields() {
1605        let pwm = PositionWithMetrics {
1606            position: Position {
1607                wallet_address: WalletAddress::default(),
1608                symbol: "ETH-20260301-5000-P".to_string(),
1609                amount: dec!(-3),
1610                entry_price: dec!(150),
1611                margin_posted: dec!(50),
1612                realized_pnl: dec!(0),
1613                unrealized_pnl: dec!(-10),
1614                updated_at: chrono::Utc::now(),
1615            },
1616            notional_value: dec!(-450),
1617            maintenance_margin: dec!(0),
1618            liquidation_price: dec!(0),
1619            margin_ratio: dec!(0),
1620        };
1621        let json = serde_json::to_value(&pwm).unwrap();
1622        // Flattened: Position fields at top level
1623        assert_eq!(json["symbol"], "ETH-20260301-5000-P");
1624        assert_eq!(json["amount"], "-3");
1625        // PositionWithMetrics fields also at top level
1626        assert_eq!(json["notional_value"], "-450");
1627        // No nested "position" key
1628        assert!(json.get("position").is_none());
1629    }
1630
1631    #[test]
1632    fn position_with_metrics_roundtrip_from_flat_json() {
1633        let json = serde_json::json!({
1634            "wallet_address": "0x0000000000000000000000000000000000000001",
1635            "symbol": "BTC-20260101-100000-C",
1636            "amount": "2",
1637            "entry_price": "3000",
1638            "margin_posted": "100",
1639            "realized_pnl": "0",
1640            "unrealized_pnl": "50",
1641            "updated_at": "2026-01-01T00:00:00Z",
1642            "notional_value": "6000",
1643            "maintenance_margin": "0",
1644            "liquidation_price": "0",
1645            "margin_ratio": "0"
1646        });
1647        let pwm: PositionWithMetrics = serde_json::from_value(json).unwrap();
1648        assert_eq!(pwm.position.symbol, "BTC-20260101-100000-C");
1649        assert_eq!(pwm.position.amount, dec!(2));
1650        assert_eq!(pwm.notional_value, dec!(6000));
1651    }
1652
1653    #[test]
1654    fn instrument_status_serde_roundtrip() {
1655        for status in [
1656            InstrumentStatus::Active,
1657            InstrumentStatus::ExpiredPendingPrice,
1658            InstrumentStatus::Settled,
1659        ] {
1660            let json = serde_json::to_string(&status).unwrap();
1661            let parsed: InstrumentStatus = serde_json::from_str(&json).unwrap();
1662            assert_eq!(parsed, status);
1663        }
1664    }
1665
1666    #[test]
1667    fn instrument_status_serializes_screaming_snake() {
1668        assert_eq!(
1669            serde_json::to_string(&InstrumentStatus::ExpiredPendingPrice).unwrap(),
1670            "\"EXPIRED_PENDING_PRICE\""
1671        );
1672    }
1673
1674    #[test]
1675    fn portfolio_serialization_omits_none_fields() {
1676        let portfolio = Portfolio {
1677            wallet_address: WalletAddress::default(),
1678            positions: vec![],
1679            total_margin_used: dec!(0),
1680            available_balance: dec!(1000),
1681            span_margin: None,
1682            margin_mode: "standard".to_string(),
1683            margin_summary: None,
1684        };
1685        let json = serde_json::to_value(&portfolio).unwrap();
1686        assert!(
1687            json.get("span_margin").is_none(),
1688            "None span_margin should be omitted"
1689        );
1690        assert!(
1691            json.get("margin_summary").is_none(),
1692            "None margin_summary should be omitted"
1693        );
1694        assert_eq!(json["margin_mode"], "standard");
1695    }
1696
1697    #[test]
1698    fn portfolio_deserializes_without_optional_fields() {
1699        let json = serde_json::json!({
1700            "wallet_address": "0x0000000000000000000000000000000000000000",
1701            "positions": [],
1702            "total_margin_used": "0",
1703            "available_balance": "1000"
1704        });
1705        let portfolio: Portfolio = serde_json::from_value(json).unwrap();
1706        assert_eq!(portfolio.margin_mode, "standard");
1707        assert!(portfolio.span_margin.is_none());
1708    }
1709
1710    #[test]
1711    fn markets_response_roundtrip() {
1712        let resp = MarketsResponse {
1713            success: true,
1714            data: vec![MarketInfo {
1715                underlying: "BTC".to_string(),
1716                expiry: 1735689600,
1717                index_price: dec!(95000),
1718                atm_vol: Some(dec!(0.65)),
1719                instruments: vec![Instrument {
1720                    instrument_id: 1,
1721                    id: "BTC-20260101-100000-C".to_string(),
1722                    underlying: "BTC".to_string(),
1723                    strike: dec!(100000),
1724                    expiry: 1735689600,
1725                    option_type: "call".to_string(),
1726                    option_token_address: None,
1727                    mark_iv: Some(dec!(0.70)),
1728                    volume_24h: dec!(1500),
1729                    open_interest: dec!(25000),
1730                    updated_at: chrono::Utc::now(),
1731                    status: InstrumentStatus::Active,
1732                    trading_mode: TradingModes::default(),
1733                }],
1734                total_volume_24h: dec!(1500),
1735                total_open_interest: dec!(25000),
1736                prev_day_price: None,
1737            }],
1738        };
1739        let json_str = serde_json::to_string(&resp).unwrap();
1740        let parsed: MarketsResponse = serde_json::from_str(&json_str).unwrap();
1741        assert!(parsed.success);
1742        assert_eq!(parsed.data.len(), 1);
1743        assert_eq!(parsed.data[0].instruments[0].strike, dec!(100000));
1744        assert_eq!(
1745            parsed.data[0].instruments[0].status,
1746            InstrumentStatus::Active
1747        );
1748    }
1749
1750    #[test]
1751    fn risk_grid_decimal_precision_preserved() {
1752        let scenario = RiskGridScenario {
1753            id: "S1".to_string(),
1754            spot_shock_pct: dec!(-0.15),
1755            vol_shock_pct: dec!(0.30),
1756            pnl_weight: dec!(1.00),
1757            is_tail: false,
1758            total_pnl: dec!(-1234.56789),
1759        };
1760        let json = serde_json::to_value(&scenario).unwrap();
1761        assert_eq!(json["spot_shock_pct"], "-0.15");
1762        assert_eq!(json["total_pnl"], "-1234.56789");
1763
1764        let parsed: RiskGridScenario = serde_json::from_value(json).unwrap();
1765        assert_eq!(parsed.total_pnl, dec!(-1234.56789));
1766    }
1767
1768    #[test]
1769    fn span_margin_summary_open_orders_defaults_to_zero() {
1770        let json = serde_json::json!({
1771            "equity": "10000",
1772            "initial_margin_required": "1500",
1773            "maintenance_margin_required": "1275",
1774            "option_margin_required": "1500",
1775            "scanning_risk": "1200",
1776            "option_floor": "1500",
1777            "gamma_overlay": "250",
1778            "hypercore_margin_required": "0"
1779        });
1780        let summary: SpanMarginSummary = serde_json::from_value(json).unwrap();
1781        assert_eq!(summary.open_orders_initial_margin, dec!(0));
1782    }
1783
1784    #[test]
1785    fn fill_api_response_explorer_url_omission() {
1786        let fill = FillApiResponse {
1787            fill_id: 1,
1788            trade_id: 100,
1789            wallet_address: WalletAddress::default(),
1790            symbol: "BTC-20260101-100000-C".to_string(),
1791            price: dec!(2500),
1792            size: dec!(1),
1793            fee: dec!(2.5),
1794            is_taker: true,
1795            timestamp: 1700000000000,
1796            created_at: chrono::Utc::now(),
1797            builder_code_address: None,
1798            builder_code_fee: None,
1799            realized_pnl: Some(dec!(150)),
1800            explorer_url: None,
1801        };
1802        let json = serde_json::to_value(&fill).unwrap();
1803        assert_eq!(json["price"], "2500");
1804        assert_eq!(json["realized_pnl"], "150");
1805    }
1806
1807    #[test]
1808    fn health_response_roundtrip() {
1809        let resp = HealthResponse {
1810            status: "ok".to_string(),
1811        };
1812        let json_str = serde_json::to_string(&resp).unwrap();
1813        let parsed: HealthResponse = serde_json::from_str(&json_str).unwrap();
1814        assert_eq!(parsed.status, "ok");
1815    }
1816
1817    #[test]
1818    fn competition_enums_serialize_lowercase() {
1819        assert_eq!(
1820            serde_json::to_string(&CompetitionStateValue::Active).unwrap(),
1821            "\"active\""
1822        );
1823        assert_eq!(
1824            serde_json::to_string(&CompetitionSortByValue::Pnl).unwrap(),
1825            "\"pnl\""
1826        );
1827        assert_eq!(
1828            serde_json::to_string(&CompetitionSortOrderValue::Desc).unwrap(),
1829            "\"desc\""
1830        );
1831    }
1832
1833    #[test]
1834    fn options_chain_leg_all_none_fields() {
1835        let leg = OptionsChainLeg {
1836            symbol: "BTC-20260101-100000-C".to_string(),
1837            option_token_address: None,
1838            bid_price_usd: None,
1839            bid_iv: None,
1840            bid_size_contracts: None,
1841            bid_size_usd_notional: None,
1842            ask_price_usd: None,
1843            ask_iv: None,
1844            ask_size_contracts: None,
1845            ask_size_usd_notional: None,
1846            greeks_abs: None,
1847            greeks_cash: None,
1848        };
1849        let json = serde_json::to_value(&leg).unwrap();
1850        assert_eq!(json["symbol"], "BTC-20260101-100000-C");
1851        assert!(json["bid_price_usd"].is_null());
1852    }
1853
1854    #[test]
1855    fn mmp_config_data_roundtrip() {
1856        let config = MmpConfigData {
1857            wallet_address: WalletAddress::default(),
1858            currency: "BTC".to_string(),
1859            interval_ms: 5000,
1860            frozen_time_ms: 10000,
1861            qty_limit: Some(dec!(100)),
1862            delta_limit: None,
1863            vega_limit: Some(dec!(50000)),
1864            enabled: true,
1865        };
1866        let json_str = serde_json::to_string(&config).unwrap();
1867        let parsed: MmpConfigData = serde_json::from_str(&json_str).unwrap();
1868        assert_eq!(parsed.qty_limit, Some(dec!(100)));
1869        assert_eq!(parsed.delta_limit, None);
1870        assert!(parsed.enabled);
1871    }
1872}
1873
1874/// Public exchange configuration for frontend deposit/withdraw integration.
1875#[derive(Debug, Serialize, Deserialize)]
1876#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1877pub struct ExchangeInfoResponse {
1878    /// Exchange contract address on HyperEVM (deposit destination).
1879    pub exchange_address: String,
1880    /// Chain ID for EIP-712 signing (999 = mainnet, 998 = testnet).
1881    pub chain_id: u64,
1882    /// EIP-712 signing domain info.
1883    pub signing_domain: SigningDomainInfo,
1884}
1885
1886/// EIP-712 domain parameters for frontend signing.
1887#[derive(Debug, Serialize, Deserialize)]
1888#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1889pub struct SigningDomainInfo {
1890    /// Domain name ("Hypercall").
1891    pub name: String,
1892    /// Domain version ("1").
1893    pub version: String,
1894}
1895
1896/// Withdrawal history response containing a list of withdrawal directives.
1897#[derive(Debug, Serialize, Deserialize)]
1898#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1899pub struct WithdrawalHistoryResponse {
1900    /// List of withdrawal directives, most recent first.
1901    pub withdrawals: Vec<DirectiveStatusResponse>,
1902}
1903
1904/// Directive delivery status lookup response.
1905#[derive(Debug, Serialize, Deserialize)]
1906#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1907pub struct DirectiveStatusResponse {
1908    /// Unique directive identifier.
1909    pub directive_id: String,
1910    /// Action key (e.g. "system_withdraw_token").
1911    pub action_key: String,
1912    /// Domain-level status (accepted, rejected, pending_chain_effect, completed, failed).
1913    pub domain_status: String,
1914    /// Delivery pipeline status (pending, broadcasted, included, finalized, reverted, expired, dead_lettered).
1915    pub delivery_status: String,
1916    /// On-chain transaction hash, if available.
1917    pub tx_hash: Option<String>,
1918    /// Creation timestamp (ISO-8601).
1919    pub created_at: Option<String>,
1920}