Skip to main content

polyoxide_data/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// User's total position value
4#[cfg_attr(feature = "specta", derive(specta::Type))]
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct UserValue {
7    /// User address
8    pub user: String,
9    /// Total value of positions
10    pub value: f64,
11}
12
13/// Open interest for a market
14#[cfg_attr(feature = "specta", derive(specta::Type))]
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct OpenInterest {
17    /// Market condition ID
18    pub market: String,
19    /// Open interest value
20    pub value: f64,
21}
22
23/// Sort field options for position queries
24#[cfg_attr(feature = "specta", derive(specta::Type))]
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
27pub enum PositionSortBy {
28    /// Sort by current value
29    Current,
30    /// Sort by initial value
31    Initial,
32    /// Sort by token count
33    Tokens,
34    /// Sort by cash P&L
35    CashPnl,
36    /// Sort by percentage P&L
37    PercentPnl,
38    /// Sort by market title
39    Title,
40    /// Sort by resolving status
41    Resolving,
42    /// Sort by price
43    Price,
44    /// Sort by average price
45    AvgPrice,
46}
47
48impl std::fmt::Display for PositionSortBy {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::Current => write!(f, "CURRENT"),
52            Self::Initial => write!(f, "INITIAL"),
53            Self::Tokens => write!(f, "TOKENS"),
54            Self::CashPnl => write!(f, "CASH_PNL"),
55            Self::PercentPnl => write!(f, "PERCENT_PNL"),
56            Self::Title => write!(f, "TITLE"),
57            Self::Resolving => write!(f, "RESOLVING"),
58            Self::Price => write!(f, "PRICE"),
59            Self::AvgPrice => write!(f, "AVG_PRICE"),
60        }
61    }
62}
63
64/// Sort direction for queries
65#[cfg_attr(feature = "specta", derive(specta::Type))]
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(rename_all = "UPPERCASE")]
68pub enum SortDirection {
69    /// Ascending order
70    Asc,
71    /// Descending order (default)
72    #[default]
73    Desc,
74}
75
76impl std::fmt::Display for SortDirection {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::Asc => write!(f, "ASC"),
80            Self::Desc => write!(f, "DESC"),
81        }
82    }
83}
84
85/// Sort field options for closed position queries
86#[cfg_attr(feature = "specta", derive(specta::Type))]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
88#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
89pub enum ClosedPositionSortBy {
90    /// Sort by realized P&L (default)
91    #[default]
92    RealizedPnl,
93    /// Sort by market title
94    Title,
95    /// Sort by price
96    Price,
97    /// Sort by average price
98    AvgPrice,
99    /// Sort by timestamp
100    Timestamp,
101}
102
103impl std::fmt::Display for ClosedPositionSortBy {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::RealizedPnl => write!(f, "REALIZED_PNL"),
107            Self::Title => write!(f, "TITLE"),
108            Self::Price => write!(f, "PRICE"),
109            Self::AvgPrice => write!(f, "AVG_PRICE"),
110            Self::Timestamp => write!(f, "TIMESTAMP"),
111        }
112    }
113}
114
115/// Closed position record
116#[cfg_attr(feature = "specta", derive(specta::Type))]
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct ClosedPosition {
120    /// Proxy wallet address
121    pub proxy_wallet: String,
122    /// Asset identifier (token ID)
123    pub asset: String,
124    /// Condition ID of the market
125    pub condition_id: String,
126    /// Average entry price
127    pub avg_price: f64,
128    /// Total amount bought
129    pub total_bought: f64,
130    /// Realized profit and loss
131    pub realized_pnl: f64,
132    /// Current market price
133    pub cur_price: f64,
134    /// Timestamp when position was closed
135    #[cfg_attr(feature = "specta", specta(type = f64))]
136    pub timestamp: i64,
137    /// Market title
138    pub title: String,
139    /// Market slug
140    pub slug: String,
141    /// Market icon URL
142    pub icon: Option<String>,
143    /// Event slug
144    pub event_slug: Option<String>,
145    /// Outcome name (e.g., "Yes", "No")
146    pub outcome: String,
147    /// Outcome index (0 or 1 for binary markets)
148    pub outcome_index: u32,
149    /// Opposite outcome name
150    pub opposite_outcome: String,
151    /// Opposite outcome asset ID
152    pub opposite_asset: String,
153    /// Market end date
154    pub end_date: Option<String>,
155}
156
157/// Trade side (buy or sell)
158#[cfg_attr(feature = "specta", derive(specta::Type))]
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "UPPERCASE")]
161pub enum TradeSide {
162    /// Buy order
163    Buy,
164    /// Sell order
165    Sell,
166    /// Unrecognized trade side (forward-compat). `Trade::side` is deserialized
167    /// from live API responses, so an unexpected value falls back here instead
168    /// of failing the whole page. Never construct this to send in a request filter.
169    #[serde(other)]
170    Unknown,
171}
172
173impl std::fmt::Display for TradeSide {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Self::Buy => write!(f, "BUY"),
177            Self::Sell => write!(f, "SELL"),
178            Self::Unknown => write!(f, "UNKNOWN"),
179        }
180    }
181}
182
183/// Filter type for trade queries
184#[cfg_attr(feature = "specta", derive(specta::Type))]
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "UPPERCASE")]
187pub enum TradeFilterType {
188    /// Filter by cash amount
189    Cash,
190    /// Filter by token amount
191    Tokens,
192}
193
194impl std::fmt::Display for TradeFilterType {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        match self {
197            Self::Cash => write!(f, "CASH"),
198            Self::Tokens => write!(f, "TOKENS"),
199        }
200    }
201}
202
203/// Trade record
204#[cfg_attr(feature = "specta", derive(specta::Type))]
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct Trade {
208    /// Proxy wallet address
209    pub proxy_wallet: String,
210    /// Trade side (BUY or SELL)
211    pub side: TradeSide,
212    /// Asset identifier (token ID)
213    pub asset: String,
214    /// Condition ID of the market
215    pub condition_id: String,
216    /// Trade size (number of shares)
217    pub size: f64,
218    /// Trade price
219    pub price: f64,
220    /// Trade timestamp
221    #[cfg_attr(feature = "specta", specta(type = f64))]
222    pub timestamp: i64,
223    /// Market title
224    pub title: String,
225    /// Market slug
226    pub slug: String,
227    /// Market icon URL
228    pub icon: Option<String>,
229    /// Event slug
230    pub event_slug: Option<String>,
231    /// Outcome name (e.g., "Yes", "No")
232    pub outcome: String,
233    /// Outcome index (0 or 1 for binary markets)
234    pub outcome_index: u32,
235    /// User display name
236    pub name: Option<String>,
237    /// User pseudonym
238    pub pseudonym: Option<String>,
239    /// User bio
240    pub bio: Option<String>,
241    /// User profile image URL
242    pub profile_image: Option<String>,
243    /// Optimized profile image URL
244    pub profile_image_optimized: Option<String>,
245    /// Transaction hash
246    pub transaction_hash: Option<String>,
247}
248
249/// Activity type
250#[cfg_attr(feature = "specta", derive(specta::Type))]
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "UPPERCASE")]
253pub enum ActivityType {
254    /// Trade activity
255    Trade,
256    /// Split activity
257    Split,
258    /// Merge activity
259    Merge,
260    /// Redeem activity
261    Redeem,
262    /// Reward activity
263    Reward,
264    /// Conversion activity
265    Conversion,
266    /// Collateral deposit.
267    ///
268    /// Upstream excludes deposit rows by default, so filtering on this alone
269    /// returns nothing. Pair it with
270    /// [`ListActivity::exclude_deposits_withdrawals(false)`](crate::api::users::ListActivity::exclude_deposits_withdrawals).
271    Deposit,
272    /// Collateral withdrawal.
273    ///
274    /// Upstream excludes withdrawal rows by default, so filtering on this alone
275    /// returns nothing. Pair it with
276    /// [`ListActivity::exclude_deposits_withdrawals(false)`](crate::api::users::ListActivity::exclude_deposits_withdrawals).
277    Withdrawal,
278    /// Yield accrual on collateral
279    Yield,
280    /// Maker rebate activity
281    #[serde(rename = "MAKER_REBATE")]
282    MakerRebate,
283    /// Referral reward activity
284    #[serde(rename = "REFERRAL_REWARD")]
285    ReferralReward,
286    /// Taker rebate activity
287    #[serde(rename = "TAKER_REBATE")]
288    TakerRebate,
289    /// Unrecognized activity type (forward-compat). Never construct this to
290    /// send in a request filter; [`super::api::users::ListActivity::activity_type`]
291    /// silently drops it since the upstream API has no matching value to filter on.
292    #[serde(other)]
293    Unknown,
294}
295
296impl std::fmt::Display for ActivityType {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            Self::Trade => write!(f, "TRADE"),
300            Self::Split => write!(f, "SPLIT"),
301            Self::Merge => write!(f, "MERGE"),
302            Self::Redeem => write!(f, "REDEEM"),
303            Self::Reward => write!(f, "REWARD"),
304            Self::Conversion => write!(f, "CONVERSION"),
305            Self::Deposit => write!(f, "DEPOSIT"),
306            Self::Withdrawal => write!(f, "WITHDRAWAL"),
307            Self::Yield => write!(f, "YIELD"),
308            Self::MakerRebate => write!(f, "MAKER_REBATE"),
309            Self::ReferralReward => write!(f, "REFERRAL_REWARD"),
310            Self::TakerRebate => write!(f, "TAKER_REBATE"),
311            Self::Unknown => write!(f, "UNKNOWN"),
312        }
313    }
314}
315
316/// An ERC20 allowance as reported by `/v1/approvals`.
317///
318/// Upstream sends a string that is either the sentinel `"max"` or a decimal
319/// amount in the token's base units. `ERC1155` entries carry no amount at all,
320/// which is represented by `Option::None` on the containing field rather than
321/// by a variant here.
322#[cfg_attr(feature = "specta", derive(specta::Type))]
323#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324#[serde(untagged)]
325pub enum Allowance {
326    /// The unlimited-allowance sentinel (`"max"`).
327    Max,
328    /// A concrete allowance, in the token's base units.
329    Amount(rust_decimal::Decimal),
330    /// A value that is neither `"max"` nor a decimal `rust_decimal` can hold,
331    /// preserved verbatim.
332    ///
333    /// `rust_decimal` tops out near 7.9e28 while a uint256 allowance can reach
334    /// 1.2e77, so an unusually large approval lands here instead of failing
335    /// deserialization of the entire response.
336    Unknown(String),
337}
338
339impl<'de> Deserialize<'de> for Allowance {
340    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
341    where
342        D: serde::Deserializer<'de>,
343    {
344        let raw = String::deserialize(deserializer)?;
345        if raw == "max" {
346            return Ok(Allowance::Max);
347        }
348        Ok(match raw.parse::<rust_decimal::Decimal>() {
349            Ok(amount) => Allowance::Amount(amount),
350            Err(_) => Allowance::Unknown(raw),
351        })
352    }
353}
354
355/// What a tracked approval unlocks.
356///
357/// Upstream's values are lowercase and kebab-cased, unlike the UPPERCASE
358/// enums elsewhere in this API, so each variant renames explicitly.
359#[cfg_attr(feature = "specta", derive(specta::Type))]
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361pub enum ApprovalFeature {
362    /// Order placement and settlement.
363    #[serde(rename = "trading")]
364    Trading,
365    /// Perpetual futures.
366    #[serde(rename = "perps")]
367    Perps,
368    /// Liquidity reward accrual.
369    #[serde(rename = "rewards")]
370    Rewards,
371    /// Automatic redemption of resolved positions.
372    #[serde(rename = "auto-redeem")]
373    AutoRedeem,
374    /// A feature this client does not recognize (forward-compat).
375    #[serde(other)]
376    Unknown,
377}
378
379/// Token standard of a tracked approval.
380#[cfg_attr(feature = "specta", derive(specta::Type))]
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382pub enum ApprovalStandard {
383    /// Carries an allowance amount.
384    #[serde(rename = "ERC20")]
385    Erc20,
386    /// An operator flag with no amount.
387    #[serde(rename = "ERC1155")]
388    Erc1155,
389    /// A standard this client does not recognize (forward-compat).
390    #[serde(other)]
391    Unknown,
392}
393
394/// Approval state for one token and spender pair.
395#[cfg_attr(feature = "specta", derive(specta::Type))]
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[serde(rename_all = "camelCase")]
398pub struct ApprovalContract {
399    /// Stable identifier for the pair, such as `UsdcExchange`.
400    pub id: String,
401    /// What the approval unlocks.
402    pub feature: ApprovalFeature,
403    /// Token contract address.
404    pub token: String,
405    /// Spender contract address.
406    pub spender: String,
407    /// Token standard.
408    pub standard: ApprovalStandard,
409    /// Allowance for `ERC20` entries. Always `None` for `ERC1155`, which is an
410    /// operator flag with no amount — read [`approved`](Self::approved) instead.
411    #[serde(default)]
412    pub amount: Option<Allowance>,
413    /// Whether the approval is sufficient for its feature.
414    pub approved: bool,
415}
416
417/// Token approval state for a wallet, from `GET /v1/approvals`.
418#[cfg_attr(feature = "specta", derive(specta::Type))]
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct ApprovalsResponse {
422    /// The wallet the approvals were read for.
423    pub address: String,
424    /// Chain the approvals were read on.
425    pub chain_id: u64,
426    /// RFC 3339 timestamp of when the response was generated.
427    ///
428    /// Left as a string deliberately: upstream tracks approval state from
429    /// onchain events rather than reading fresh, so parsing this into a
430    /// timestamp type would imply a freshness guarantee it does not carry.
431    pub checked_at: String,
432    /// Every approval Polymarket tracks, in a stable display order.
433    ///
434    /// Pairs the wallet has never approved are still present with `approved`
435    /// false, so the length does not vary with wallet state. Upstream does not
436    /// publish how many entries that is — do not depend on a count.
437    pub contracts: Vec<ApprovalContract>,
438}
439
440/// Sort field options for activity queries
441#[cfg_attr(feature = "specta", derive(specta::Type))]
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
443#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
444pub enum ActivitySortBy {
445    /// Sort by timestamp (default)
446    #[default]
447    Timestamp,
448    /// Sort by token amount
449    Tokens,
450    /// Sort by cash amount
451    Cash,
452}
453
454impl std::fmt::Display for ActivitySortBy {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        match self {
457            Self::Timestamp => write!(f, "TIMESTAMP"),
458            Self::Tokens => write!(f, "TOKENS"),
459            Self::Cash => write!(f, "CASH"),
460        }
461    }
462}
463
464/// User activity record
465#[cfg_attr(feature = "specta", derive(specta::Type))]
466#[derive(Debug, Clone, Serialize, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct Activity {
469    /// Proxy wallet address
470    pub proxy_wallet: String,
471    /// Activity timestamp
472    #[cfg_attr(feature = "specta", specta(type = f64))]
473    pub timestamp: i64,
474    /// Condition ID of the market
475    pub condition_id: String,
476    /// Activity type
477    #[serde(rename = "type")]
478    pub activity_type: ActivityType,
479    /// Token quantity
480    pub size: f64,
481    /// USD value
482    pub usdc_size: f64,
483    /// On-chain transaction hash
484    pub transaction_hash: Option<String>,
485    /// Execution price
486    pub price: Option<f64>,
487    /// Asset identifier (token ID)
488    pub asset: Option<String>,
489    // Deserialize into String because the API can return an empty string
490    /// Trade side (BUY or SELL)
491    pub side: Option<String>,
492    /// Outcome index (0 or 1 for binary markets)
493    pub outcome_index: Option<u32>,
494    /// Market title
495    pub title: Option<String>,
496    /// Market slug
497    pub slug: Option<String>,
498    /// Market icon URL
499    pub icon: Option<String>,
500    /// Outcome name (e.g., "Yes", "No")
501    pub outcome: Option<String>,
502    /// User display name
503    pub name: Option<String>,
504    /// User pseudonym
505    pub pseudonym: Option<String>,
506    /// User bio
507    pub bio: Option<String>,
508    /// User profile image URL
509    pub profile_image: Option<String>,
510    /// Optimized profile image URL
511    pub profile_image_optimized: Option<String>,
512}
513
514/// User position in a market
515#[cfg_attr(feature = "specta", derive(specta::Type))]
516#[derive(Debug, Clone, Serialize, Deserialize)]
517#[serde(rename_all = "camelCase")]
518#[non_exhaustive]
519pub struct Position {
520    /// Proxy wallet address
521    pub proxy_wallet: String,
522    /// Asset identifier (token ID)
523    pub asset: String,
524    /// Condition ID of the market
525    pub condition_id: String,
526    /// Position size (number of shares)
527    pub size: f64,
528    /// Average entry price
529    pub avg_price: f64,
530    /// Initial value of position
531    pub initial_value: f64,
532    /// Remaining entry basis including attributed BUY fees.
533    ///
534    /// [`initial_value`](Self::initial_value) and [`avg_price`](Self::avg_price)
535    /// keep their fee-**exclusive** semantics, so the fee-exclusive basis is
536    /// `gross_initial_value - entry_fees_usdc`. `None` means upstream omitted
537    /// the field — treat that as unavailable, not as zero.
538    #[serde(default)]
539    pub gross_initial_value: Option<f64>,
540    /// Attributed BUY-fee component of [`gross_initial_value`](Self::gross_initial_value).
541    ///
542    /// SELL fees are exit costs and are never included. Upstream returns an
543    /// explicit `0` when the component is zero, so `Some(0.0)` (a measured
544    /// zero) and `None` (no data) are different answers.
545    #[serde(default)]
546    pub entry_fees_usdc: Option<f64>,
547    /// Current value of position
548    pub current_value: f64,
549    /// Cash profit and loss
550    pub cash_pnl: f64,
551    /// Percentage profit and loss
552    pub percent_pnl: f64,
553    /// Total amount bought
554    pub total_bought: f64,
555    /// Realized profit and loss
556    pub realized_pnl: f64,
557    /// Percentage realized P&L
558    pub percent_realized_pnl: f64,
559    /// Current market price
560    pub cur_price: f64,
561    /// Whether position is redeemable
562    pub redeemable: bool,
563    /// Whether position is mergeable
564    pub mergeable: bool,
565    /// Market title
566    pub title: String,
567    /// Market slug
568    pub slug: String,
569    /// Market icon URL
570    pub icon: Option<String>,
571    /// Event slug
572    pub event_slug: Option<String>,
573    /// Outcome name (e.g., "Yes", "No")
574    pub outcome: String,
575    /// Outcome index (0 or 1 for binary markets)
576    pub outcome_index: u32,
577    /// Opposite outcome name
578    pub opposite_outcome: String,
579    /// Opposite outcome asset ID
580    pub opposite_asset: String,
581    /// Market end date
582    pub end_date: Option<String>,
583    /// Whether this is a negative risk market
584    pub negative_risk: bool,
585}
586
587/// A per-user position in a single market, as returned by `/v1/market-positions`.
588///
589/// Field names and types follow the upstream `MarketPositionV1` schema in
590/// `docs/specs/data/openapi.yaml`.
591#[cfg_attr(feature = "specta", derive(specta::Type))]
592#[derive(Debug, Clone, Serialize, Deserialize)]
593#[serde(rename_all = "camelCase")]
594pub struct MarketPositionV1 {
595    /// Proxy wallet address of the position holder
596    pub proxy_wallet: String,
597    /// Display name of the position holder
598    pub name: String,
599    /// Profile image URL of the position holder
600    pub profile_image: Option<String>,
601    /// Whether the holder has a verified badge
602    pub verified: bool,
603    /// Outcome token asset ID
604    pub asset: String,
605    /// Condition ID of the market
606    pub condition_id: String,
607    /// Average entry price
608    pub avg_price: f64,
609    /// Position size (number of shares)
610    pub size: f64,
611    /// Current market price (OpenAPI field: `currPrice`)
612    #[serde(rename = "currPrice")]
613    pub curr_price: f64,
614    /// Current value of the position
615    pub current_value: f64,
616    /// Unrealized cash P&L
617    pub cash_pnl: f64,
618    /// Total amount bought
619    pub total_bought: f64,
620    /// Realized P&L
621    pub realized_pnl: f64,
622    /// Total P&L (cash + realized)
623    pub total_pnl: f64,
624    /// Outcome name (e.g., "Yes", "No")
625    pub outcome: String,
626    /// Outcome index (0 or 1 for binary markets)
627    pub outcome_index: u32,
628}
629
630/// Market positions grouped by outcome token.
631#[cfg_attr(feature = "specta", derive(specta::Type))]
632#[derive(Debug, Clone, Serialize, Deserialize)]
633pub struct MetaMarketPositionV1 {
634    /// Outcome token asset ID
635    pub token: String,
636    /// Positions for this token
637    pub positions: Vec<MarketPositionV1>,
638}
639
640/// Status filter for `/v1/market-positions`.
641#[cfg_attr(feature = "specta", derive(specta::Type))]
642#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
643#[serde(rename_all = "UPPERCASE")]
644pub enum MarketPositionStatus {
645    /// Only positions with size > 0.01
646    Open,
647    /// Only positions with size <= 0.01
648    Closed,
649    /// All positions regardless of size (default)
650    #[default]
651    All,
652}
653
654impl std::fmt::Display for MarketPositionStatus {
655    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656        match self {
657            Self::Open => write!(f, "OPEN"),
658            Self::Closed => write!(f, "CLOSED"),
659            Self::All => write!(f, "ALL"),
660        }
661    }
662}
663
664/// Sort field options for `/v1/market-positions`.
665#[cfg_attr(feature = "specta", derive(specta::Type))]
666#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
667#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
668pub enum MarketPositionSortBy {
669    /// Sort by token count
670    Tokens,
671    /// Sort by unrealized cash P&L
672    CashPnl,
673    /// Sort by realized P&L
674    RealizedPnl,
675    /// Sort by total P&L (cash + realized). Default.
676    #[default]
677    TotalPnl,
678}
679
680impl std::fmt::Display for MarketPositionSortBy {
681    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682        match self {
683            Self::Tokens => write!(f, "TOKENS"),
684            Self::CashPnl => write!(f, "CASH_PNL"),
685            Self::RealizedPnl => write!(f, "REALIZED_PNL"),
686            Self::TotalPnl => write!(f, "TOTAL_PNL"),
687        }
688    }
689}
690
691/// Time period for aggregation
692#[cfg_attr(feature = "specta", derive(specta::Type))]
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
694#[serde(rename_all = "UPPERCASE")]
695pub enum TimePeriod {
696    /// Daily aggregation (default)
697    #[default]
698    Day,
699    /// Weekly aggregation
700    Week,
701    /// Monthly aggregation
702    Month,
703    /// All time aggregation
704    All,
705}
706
707impl std::fmt::Display for TimePeriod {
708    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709        match self {
710            Self::Day => write!(f, "DAY"),
711            Self::Week => write!(f, "WEEK"),
712            Self::Month => write!(f, "MONTH"),
713            Self::All => write!(f, "ALL"),
714        }
715    }
716}
717
718// ---------------------------------------------------------------------------
719// Combinatorial (multi-market) positions and activity
720//
721// Monetary and share fields on these types are kept as `String` rather than
722// `f64` deliberately: upstream documents them as "six-decimal
723// precision-preserving" values and instructs clients to parse them as decimals,
724// never through a float. Round-tripping them through `f64` would silently lose
725// precision on large balances.
726// ---------------------------------------------------------------------------
727
728/// Resolution state of a combinatorial position.
729#[cfg_attr(feature = "specta", derive(specta::Type))]
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
732pub enum ComboStatus {
733    /// No leg has resolved yet.
734    Open,
735    /// Some legs have resolved; the combo is still live.
736    Partial,
737    /// Resolved at a fractional payout (e.g. a leg voided 50/50) — redemption
738    /// pays the fractional value per share.
739    ResolvedPartial,
740    /// Resolved in the holder's favour; shares redeem 1:1 at $1.
741    ResolvedWin,
742    /// Resolved against the holder; shares are worthless.
743    ResolvedLoss,
744    /// Unrecognized status (forward-compat). Never construct this to send in a
745    /// request filter; [`crate::api::combos::ListComboPositions::status`] drops
746    /// it since the upstream API has no matching value to filter on.
747    #[serde(other)]
748    Unknown,
749}
750
751impl std::fmt::Display for ComboStatus {
752    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
753        match self {
754            Self::Open => write!(f, "OPEN"),
755            Self::Partial => write!(f, "PARTIAL"),
756            Self::ResolvedPartial => write!(f, "RESOLVED_PARTIAL"),
757            Self::ResolvedWin => write!(f, "RESOLVED_WIN"),
758            Self::ResolvedLoss => write!(f, "RESOLVED_LOSS"),
759            Self::Unknown => write!(f, "UNKNOWN"),
760        }
761    }
762}
763
764/// Resolution state of a single leg within a combinatorial position.
765#[cfg_attr(feature = "specta", derive(specta::Type))]
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
767#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
768pub enum ComboLegStatus {
769    /// The leg's market has not resolved.
770    Open,
771    /// The leg's market resolved with a fractional payout (e.g. a 50/50 void).
772    ResolvedPartial,
773    /// The leg resolved in the holder's favour.
774    ResolvedWin,
775    /// The leg resolved against the holder.
776    ResolvedLoss,
777    /// Unrecognized leg status (forward-compat).
778    #[serde(other)]
779    Unknown,
780}
781
782impl std::fmt::Display for ComboLegStatus {
783    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
784        match self {
785            Self::Open => write!(f, "OPEN"),
786            Self::ResolvedPartial => write!(f, "RESOLVED_PARTIAL"),
787            Self::ResolvedWin => write!(f, "RESOLVED_WIN"),
788            Self::ResolvedLoss => write!(f, "RESOLVED_LOSS"),
789            Self::Unknown => write!(f, "UNKNOWN"),
790        }
791    }
792}
793
794/// Sort order for [`crate::api::combos::ListComboPositions`].
795#[cfg_attr(feature = "specta", derive(specta::Type))]
796#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
797#[serde(rename_all = "snake_case")]
798pub enum ComboSort {
799    /// Highest current value first (default).
800    #[default]
801    CurrentValueDesc,
802    /// Highest entry cost first.
803    EntryCostDesc,
804    /// Most recently entered first.
805    FirstEntryDesc,
806    /// Most recently resolved first.
807    ResolvedAtDesc,
808    /// Oldest `updated_at` first — the sort to use for incremental sync
809    /// alongside [`crate::api::combos::ListComboPositions::updated_after`].
810    UpdatedAsc,
811}
812
813impl std::fmt::Display for ComboSort {
814    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
815        match self {
816            Self::CurrentValueDesc => write!(f, "current_value_desc"),
817            Self::EntryCostDesc => write!(f, "entry_cost_desc"),
818            Self::FirstEntryDesc => write!(f, "first_entry_desc"),
819            Self::ResolvedAtDesc => write!(f, "resolved_at_desc"),
820            Self::UpdatedAsc => write!(f, "updated_asc"),
821        }
822    }
823}
824
825/// Standard pagination metadata for combo endpoints.
826///
827/// There is no total count; `has_more` is derived from page fullness.
828#[cfg_attr(feature = "specta", derive(specta::Type))]
829#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct Pagination {
831    /// Page size used for this response.
832    pub limit: i64,
833    /// Offset used for this response.
834    pub offset: i64,
835    /// Whether another page is available.
836    pub has_more: bool,
837    /// Opaque signed cursor for the next page; `None` when `has_more` is false.
838    ///
839    /// Pass it back verbatim via `cursor(..)`, keeping the same sort. Never
840    /// parse or construct it. Using the cursor makes deep pagination O(page)
841    /// and stable against concurrent inserts.
842    pub next_cursor: Option<String>,
843}
844
845/// Event metadata attached to a combo leg's market.
846#[cfg_attr(feature = "specta", derive(specta::Type))]
847#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct ComboEvent {
849    /// Event identifier.
850    pub event_id: Option<String>,
851    /// URL slug for the event.
852    pub event_slug: Option<String>,
853    /// Human-readable event title.
854    pub event_title: Option<String>,
855    /// Event image URL.
856    pub event_image: Option<String>,
857}
858
859/// Market metadata attached to a combo leg.
860#[cfg_attr(feature = "specta", derive(specta::Type))]
861#[derive(Debug, Clone, Serialize, Deserialize)]
862pub struct ComboMarket {
863    /// Market identifier.
864    pub market_id: Option<String>,
865    /// URL slug for the market.
866    pub slug: Option<String>,
867    /// Market title.
868    pub title: Option<String>,
869    /// Outcome label this leg refers to.
870    pub outcome: Option<String>,
871    /// Market image URL.
872    pub image_url: Option<String>,
873    /// Market icon URL.
874    pub icon_url: Option<String>,
875    /// Market category.
876    pub category: Option<String>,
877    /// Market subcategory.
878    pub subcategory: Option<String>,
879    /// Tags applied to the market.
880    #[serde(default)]
881    pub tags: Vec<String>,
882    /// Market end date (RFC3339 UTC).
883    pub end_date: Option<String>,
884    /// Parent event metadata.
885    pub event: Option<ComboEvent>,
886}
887
888/// A single leg of a combinatorial position.
889#[cfg_attr(feature = "specta", derive(specta::Type))]
890#[derive(Debug, Clone, Serialize, Deserialize)]
891pub struct ComboLeg {
892    /// Zero-based index of this leg within the combo.
893    pub leg_index: i64,
894    /// Position identifier for the leg.
895    pub leg_position_id: Option<String>,
896    /// The leg market's condition ID (distinct from the combo's).
897    pub leg_condition_id: Option<String>,
898    /// Index of the selected outcome within the leg market.
899    pub leg_outcome_index: Option<i64>,
900    /// Label of the selected outcome.
901    pub leg_outcome_label: Option<String>,
902    /// Live per-leg resolution state, derived from the leg market's on-chain
903    /// payout vector.
904    pub leg_status: Option<ComboLegStatus>,
905    /// RFC3339 UTC. Set once the leg's market resolves on-chain, including
906    /// fractional resolutions that still report `leg_status` `Open`.
907    pub leg_resolved_at: Option<String>,
908    /// Live price for the leg outcome (decimal string, 0–1). `"0"` when no
909    /// price is available.
910    pub leg_current_price: Option<String>,
911    /// Market metadata for the leg.
912    pub market: Option<ComboMarket>,
913}
914
915/// A combinatorial (multi-market) position held by a user.
916#[cfg_attr(feature = "specta", derive(specta::Type))]
917#[derive(Debug, Clone, Serialize, Deserialize)]
918pub struct ComboPosition {
919    /// Combo condition ID (`0x` + 62 hex). Equals the `conditionId` of
920    /// `isCombo` rows on `/activity`.
921    pub combo_condition_id: String,
922    /// Position identifier for the combo.
923    pub combo_position_id: Option<String>,
924    /// Module identifier; `3` is the Combinatorial module.
925    pub module_id: Option<i64>,
926    /// Holder's wallet address.
927    pub user_address: Option<String>,
928    /// Share balance as a precision-preserving decimal string.
929    pub shares_balance: Option<String>,
930    /// Average entry price in USDC, as a decimal string.
931    pub entry_avg_price_usdc: Option<String>,
932    /// *Remaining* cost basis (`entry_avg_price × shares_balance`).
933    ///
934    /// Reads ~0 after a winning combo is redeemed — use
935    /// [`total_cost_usdc`](Self::total_cost_usdc) to display what was paid on
936    /// closed positions.
937    pub entry_cost_usdc: Option<String>,
938    /// Gross redemption proceeds (winning shares redeem 1:1 at $1).
939    ///
940    /// `"0.00"` while open, unredeemed, or resolved-loss; accumulates under
941    /// `Partial`. This is gross payout, not net PnL — net =
942    /// `realized_payout_usdc − total_cost_usdc`.
943    pub realized_payout_usdc: Option<String>,
944    /// Original cost basis, surviving redemption burning the shares. Equals
945    /// [`entry_cost_usdc`](Self::entry_cost_usdc) while open.
946    pub total_cost_usdc: Option<String>,
947    /// Exact gross entry basis including attributed BUY fees, as a six-decimal
948    /// precision-preserving string (e.g. `"8999.997488"`).
949    ///
950    /// Tracks the remaining basis while the position is live and freezes once
951    /// it is terminal. Exact net basis = `gross_entry_cost_usdc −
952    /// entry_fees_usdc`. Parse as a decimal, never through a float.
953    pub gross_entry_cost_usdc: Option<String>,
954    /// BUY-fee portion of the same basis, as a six-decimal precision-preserving
955    /// string. SELL fees are excluded; always ≤ `gross_entry_cost_usdc`.
956    pub entry_fees_usdc: Option<String>,
957    /// Resolution state of the combo.
958    pub status: Option<ComboStatus>,
959    /// First entry time (RFC3339 UTC).
960    pub first_entry_at: Option<String>,
961    /// Resolution time (RFC3339 UTC), or `None` while unresolved.
962    pub resolved_at: Option<String>,
963    /// Last-modified time (UTC, ISO 8601) — the incremental-sync watermark.
964    ///
965    /// Bumps on any recompute of the row (trade, redemption, resolution
966    /// classification). Omitted on responses served by the legacy backend.
967    pub updated_at: Option<String>,
968    /// Total number of legs.
969    pub legs_total: Option<i64>,
970    /// Number of legs that have resolved.
971    pub legs_resolved: Option<i64>,
972    /// Number of legs still pending.
973    pub legs_pending: Option<i64>,
974    /// Per-leg breakdown.
975    #[serde(default)]
976    pub legs: Vec<ComboLeg>,
977}
978
979/// Response envelope for `GET /v1/positions/combos`.
980#[cfg_attr(feature = "specta", derive(specta::Type))]
981#[derive(Debug, Clone, Serialize, Deserialize)]
982pub struct CombosResponse {
983    /// The combo positions in this page.
984    #[serde(default)]
985    pub combos: Vec<ComboPosition>,
986    /// Pagination metadata.
987    pub pagination: Option<Pagination>,
988}
989
990/// A combo lifecycle or redeem event.
991#[cfg_attr(feature = "specta", derive(specta::Type))]
992#[derive(Debug, Clone, Serialize, Deserialize)]
993pub struct ComboActivity {
994    /// Event identifier.
995    pub id: Option<String>,
996    /// Event type (split, merge, convert, compress, wrap, unwrap, redeem).
997    ///
998    /// Upstream documents `type` as the replacement for the deprecated
999    /// [`event_kind`](Self::event_kind) and [`side`](Self::side) fields, but
1000    /// does not yet list it in the published schema — treat it as optional
1001    /// until it appears there.
1002    #[serde(rename = "type")]
1003    pub activity_type: Option<String>,
1004    /// Raw on-chain event name (e.g. `PositionsSplit`).
1005    #[deprecated(note = "upstream deprecated this field; use `activity_type` instead")]
1006    pub event_kind: Option<String>,
1007    /// Normalized rendering label (e.g. `Split`).
1008    #[deprecated(note = "upstream deprecated this field; use `activity_type` instead")]
1009    pub side: Option<String>,
1010    /// Module kind; always `Combinatorial`.
1011    pub module_kind: Option<String>,
1012    /// Holder's wallet address.
1013    pub user_address: Option<String>,
1014    /// Combo condition ID (`0x` + 62 hex).
1015    pub combo_condition_id: Option<String>,
1016    /// Position identifier for the combo.
1017    pub combo_position_id: Option<String>,
1018    /// Module identifier.
1019    pub module_id: Option<i64>,
1020    /// Lifecycle amount in USDC; `None` on redeems.
1021    pub amount_usdc: Option<f64>,
1022    /// Redeem payout in USDC; `None` on lifecycle events.
1023    pub payout_usdc: Option<f64>,
1024    /// Event time as a Unix timestamp (seconds).
1025    pub timestamp: Option<i64>,
1026    /// Transaction time (RFC3339 UTC).
1027    pub tx_dttm: Option<String>,
1028    /// Transaction hash.
1029    pub tx_hash: Option<String>,
1030    /// Log index within the transaction.
1031    pub log_index: Option<i64>,
1032    /// Block number.
1033    pub block_number: Option<i64>,
1034    /// Per-leg breakdown.
1035    #[serde(default)]
1036    pub legs: Vec<ComboLeg>,
1037}
1038
1039/// Response envelope for `GET /v1/activity/combos`.
1040#[cfg_attr(feature = "specta", derive(specta::Type))]
1041#[derive(Debug, Clone, Serialize, Deserialize)]
1042pub struct CombosActivityResponse {
1043    /// The combo activity rows in this page.
1044    #[serde(default)]
1045    pub activity: Vec<ComboActivity>,
1046    /// Pagination metadata.
1047    pub pagination: Option<Pagination>,
1048}
1049
1050// ---------------------------------------------------------------------------
1051// Undocumented sibling hosts (user-pnl-api, lb-api)
1052//
1053// Neither host appears in any published Polymarket OpenAPI spec. The shapes
1054// below were derived from live responses; the enum variants come from the
1055// APIs' own validation errors, which enumerate the accepted values.
1056// ---------------------------------------------------------------------------
1057
1058/// Sampling resolution for a PnL series.
1059///
1060/// Upstream rejects anything outside this set with
1061/// `"the 'fidelity' value is unkonwn. Known values: '1d', '18h', '12h', '3h', '1h'"`
1062/// (typo theirs).
1063#[cfg_attr(feature = "specta", derive(specta::Type))]
1064#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1065pub enum PnlFidelity {
1066    /// One point per day (default).
1067    #[default]
1068    #[serde(rename = "1d")]
1069    OneDay,
1070    /// One point per 18 hours.
1071    #[serde(rename = "18h")]
1072    EighteenHours,
1073    /// One point per 12 hours.
1074    #[serde(rename = "12h")]
1075    TwelveHours,
1076    /// One point per 3 hours.
1077    #[serde(rename = "3h")]
1078    ThreeHours,
1079    /// One point per hour.
1080    #[serde(rename = "1h")]
1081    OneHour,
1082}
1083
1084impl std::fmt::Display for PnlFidelity {
1085    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1086        match self {
1087            Self::OneDay => write!(f, "1d"),
1088            Self::EighteenHours => write!(f, "18h"),
1089            Self::TwelveHours => write!(f, "12h"),
1090            Self::ThreeHours => write!(f, "3h"),
1091            Self::OneHour => write!(f, "1h"),
1092        }
1093    }
1094}
1095
1096/// A single point on a user's PnL curve.
1097#[cfg_attr(feature = "specta", derive(specta::Type))]
1098#[derive(Debug, Clone, Serialize, Deserialize)]
1099pub struct PnlPoint {
1100    /// Unix timestamp in seconds.
1101    #[serde(rename = "t")]
1102    pub timestamp: i64,
1103    /// PnL in USDC at that timestamp. Negative values are losses.
1104    #[serde(rename = "p")]
1105    pub pnl: f64,
1106}
1107
1108/// Ranking window for the rankings host.
1109///
1110/// Upstream rejects anything else with `{"error": "invalid request"}`.
1111#[cfg_attr(feature = "specta", derive(specta::Type))]
1112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1113pub enum RankingWindow {
1114    /// All-time (default).
1115    #[default]
1116    #[serde(rename = "all")]
1117    All,
1118    /// Trailing day.
1119    #[serde(rename = "1d")]
1120    OneDay,
1121    /// Trailing week.
1122    #[serde(rename = "7d")]
1123    SevenDays,
1124    /// Trailing 30 days.
1125    #[serde(rename = "30d")]
1126    ThirtyDays,
1127}
1128
1129impl std::fmt::Display for RankingWindow {
1130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1131        match self {
1132            Self::All => write!(f, "all"),
1133            Self::OneDay => write!(f, "1d"),
1134            Self::SevenDays => write!(f, "7d"),
1135            Self::ThirtyDays => write!(f, "30d"),
1136        }
1137    }
1138}
1139
1140/// One entry in a volume or profit ranking.
1141#[cfg_attr(feature = "specta", derive(specta::Type))]
1142#[derive(Debug, Clone, Serialize, Deserialize)]
1143#[serde(rename_all = "camelCase")]
1144pub struct RankingEntry {
1145    /// Proxy wallet address of the ranked trader.
1146    pub proxy_wallet: Option<String>,
1147    /// Ranked amount in USDC — traded volume or realized profit, depending on
1148    /// which endpoint produced the entry.
1149    pub amount: Option<f64>,
1150    /// Display name.
1151    pub name: Option<String>,
1152    /// Pseudonym, which falls back to an address-derived string.
1153    pub pseudonym: Option<String>,
1154    /// Profile biography.
1155    pub bio: Option<String>,
1156    /// Profile image URL.
1157    pub profile_image: Option<String>,
1158    /// Optimized profile image URL.
1159    pub profile_image_optimized: Option<String>,
1160}
1161
1162/// "Other" outcome size held by a user in an augmented neg-risk event.
1163#[cfg_attr(feature = "specta", derive(specta::Type))]
1164#[derive(Debug, Clone, Serialize, Deserialize)]
1165pub struct OtherSize {
1166    /// Gamma event ID of the augmented neg-risk event.
1167    pub id: Option<i64>,
1168    /// User wallet address.
1169    pub user: Option<String>,
1170    /// Size of the "Other" position.
1171    pub size: Option<f64>,
1172}
1173
1174/// A single moderated revision of a question.
1175#[cfg_attr(feature = "specta", derive(specta::Type))]
1176#[derive(Debug, Clone, Serialize, Deserialize)]
1177pub struct RevisionEntry {
1178    /// Revised question text.
1179    pub revision: Option<String>,
1180    /// Revision time as a Unix timestamp (seconds).
1181    pub timestamp: Option<i64>,
1182}
1183
1184/// Moderated revisions for a question.
1185#[cfg_attr(feature = "specta", derive(specta::Type))]
1186#[derive(Debug, Clone, Serialize, Deserialize)]
1187pub struct RevisionPayload {
1188    /// Question ID (`0x` + 64 hex).
1189    #[serde(rename = "questionID")]
1190    pub question_id: Option<String>,
1191    /// Revisions recorded for the question, oldest first.
1192    #[serde(default)]
1193    pub revisions: Vec<RevisionEntry>,
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199
1200    /// Verify Display matches serde serialization for all PositionSortBy variants.
1201    #[test]
1202    fn position_sort_by_display_matches_serde() {
1203        let variants = [
1204            PositionSortBy::Current,
1205            PositionSortBy::Initial,
1206            PositionSortBy::Tokens,
1207            PositionSortBy::CashPnl,
1208            PositionSortBy::PercentPnl,
1209            PositionSortBy::Title,
1210            PositionSortBy::Resolving,
1211            PositionSortBy::Price,
1212            PositionSortBy::AvgPrice,
1213        ];
1214        for variant in variants {
1215            let serialized = serde_json::to_value(variant).unwrap();
1216            let display = variant.to_string();
1217            assert_eq!(
1218                format!("\"{}\"", display),
1219                serialized.to_string(),
1220                "Display mismatch for {:?}",
1221                variant
1222            );
1223        }
1224    }
1225
1226    /// Verify Display matches serde serialization for all ClosedPositionSortBy variants.
1227    #[test]
1228    fn closed_position_sort_by_display_matches_serde() {
1229        let variants = [
1230            ClosedPositionSortBy::RealizedPnl,
1231            ClosedPositionSortBy::Title,
1232            ClosedPositionSortBy::Price,
1233            ClosedPositionSortBy::AvgPrice,
1234            ClosedPositionSortBy::Timestamp,
1235        ];
1236        for variant in variants {
1237            let serialized = serde_json::to_value(variant).unwrap();
1238            let display = variant.to_string();
1239            assert_eq!(
1240                format!("\"{}\"", display),
1241                serialized.to_string(),
1242                "Display mismatch for {:?}",
1243                variant
1244            );
1245        }
1246    }
1247
1248    #[test]
1249    fn activity_sort_by_display_matches_serde() {
1250        let variants = [
1251            ActivitySortBy::Timestamp,
1252            ActivitySortBy::Tokens,
1253            ActivitySortBy::Cash,
1254        ];
1255        for variant in variants {
1256            let serialized = serde_json::to_value(variant).unwrap();
1257            let display = variant.to_string();
1258            assert_eq!(
1259                format!("\"{}\"", display),
1260                serialized.to_string(),
1261                "Display mismatch for {:?}",
1262                variant
1263            );
1264        }
1265    }
1266
1267    #[test]
1268    fn sort_direction_display_matches_serde() {
1269        let variants = [SortDirection::Asc, SortDirection::Desc];
1270        for variant in variants {
1271            let serialized = serde_json::to_value(variant).unwrap();
1272            let display = variant.to_string();
1273            assert_eq!(
1274                format!("\"{}\"", display),
1275                serialized.to_string(),
1276                "Display mismatch for {:?}",
1277                variant
1278            );
1279        }
1280    }
1281
1282    #[test]
1283    fn trade_side_display_matches_serde() {
1284        let variants = [TradeSide::Buy, TradeSide::Sell, TradeSide::Unknown];
1285        for variant in variants {
1286            let serialized = serde_json::to_value(variant).unwrap();
1287            let display = variant.to_string();
1288            assert_eq!(
1289                format!("\"{}\"", display),
1290                serialized.to_string(),
1291                "Display mismatch for {:?}",
1292                variant
1293            );
1294        }
1295    }
1296
1297    #[test]
1298    fn trade_side_falls_back_to_unknown_for_future_values() {
1299        let result: TradeSide = serde_json::from_str("\"SOME_FUTURE_SIDE\"").unwrap();
1300        assert_eq!(result, TradeSide::Unknown);
1301    }
1302
1303    #[test]
1304    fn trade_filter_type_display_matches_serde() {
1305        let variants = [TradeFilterType::Cash, TradeFilterType::Tokens];
1306        for variant in variants {
1307            let serialized = serde_json::to_value(variant).unwrap();
1308            let display = variant.to_string();
1309            assert_eq!(
1310                format!("\"{}\"", display),
1311                serialized.to_string(),
1312                "Display mismatch for {:?}",
1313                variant
1314            );
1315        }
1316    }
1317
1318    #[test]
1319    fn activity_type_display_matches_serde() {
1320        let variants = [
1321            ActivityType::Trade,
1322            ActivityType::Split,
1323            ActivityType::Merge,
1324            ActivityType::Redeem,
1325            ActivityType::Reward,
1326            ActivityType::Conversion,
1327            ActivityType::MakerRebate,
1328            ActivityType::ReferralReward,
1329            ActivityType::TakerRebate,
1330            ActivityType::Unknown,
1331        ];
1332        for variant in variants {
1333            let serialized = serde_json::to_value(variant).unwrap();
1334            let display = variant.to_string();
1335            assert_eq!(
1336                format!("\"{}\"", display),
1337                serialized.to_string(),
1338                "Display mismatch for {:?}",
1339                variant
1340            );
1341        }
1342    }
1343
1344    #[test]
1345    fn activity_type_roundtrip_serde() {
1346        for variant in [
1347            ActivityType::Trade,
1348            ActivityType::Split,
1349            ActivityType::Merge,
1350            ActivityType::Redeem,
1351            ActivityType::Reward,
1352            ActivityType::Conversion,
1353            ActivityType::MakerRebate,
1354            ActivityType::ReferralReward,
1355            ActivityType::TakerRebate,
1356        ] {
1357            let json = serde_json::to_string(&variant).unwrap();
1358            let deserialized: ActivityType = serde_json::from_str(&json).unwrap();
1359            assert_eq!(variant, deserialized);
1360        }
1361    }
1362
1363    #[test]
1364    fn activity_type_falls_back_to_unknown_for_future_types() {
1365        // A hypothetical future activity type Polymarket hasn't documented yet.
1366        let result: ActivityType = serde_json::from_str("\"SOME_FUTURE_TYPE\"").unwrap();
1367        assert_eq!(result, ActivityType::Unknown);
1368
1369        // Case mismatches also fall back rather than poisoning the whole page.
1370        let result: ActivityType = serde_json::from_str("\"trade\"").unwrap();
1371        assert_eq!(result, ActivityType::Unknown);
1372    }
1373
1374    #[test]
1375    fn sort_direction_default_is_desc() {
1376        assert_eq!(SortDirection::default(), SortDirection::Desc);
1377    }
1378
1379    #[test]
1380    fn closed_position_sort_by_default_is_realized_pnl() {
1381        assert_eq!(
1382            ClosedPositionSortBy::default(),
1383            ClosedPositionSortBy::RealizedPnl
1384        );
1385    }
1386
1387    #[test]
1388    fn activity_sort_by_default_is_timestamp() {
1389        assert_eq!(ActivitySortBy::default(), ActivitySortBy::Timestamp);
1390    }
1391
1392    #[test]
1393    fn position_sort_by_serde_roundtrip() {
1394        for variant in [
1395            PositionSortBy::Current,
1396            PositionSortBy::Initial,
1397            PositionSortBy::Tokens,
1398            PositionSortBy::CashPnl,
1399            PositionSortBy::PercentPnl,
1400            PositionSortBy::Title,
1401            PositionSortBy::Resolving,
1402            PositionSortBy::Price,
1403            PositionSortBy::AvgPrice,
1404        ] {
1405            let json = serde_json::to_string(&variant).unwrap();
1406            let deserialized: PositionSortBy = serde_json::from_str(&json).unwrap();
1407            assert_eq!(variant, deserialized);
1408        }
1409    }
1410
1411    #[test]
1412    fn deserialize_position_from_json() {
1413        let json = r#"{
1414            "proxyWallet": "0xabc123",
1415            "asset": "token123",
1416            "conditionId": "cond456",
1417            "size": 100.5,
1418            "avgPrice": 0.65,
1419            "initialValue": 65.0,
1420            "currentValue": 70.0,
1421            "cashPnl": 5.0,
1422            "percentPnl": 7.69,
1423            "totalBought": 100.5,
1424            "realizedPnl": 2.0,
1425            "percentRealizedPnl": 3.08,
1426            "curPrice": 0.70,
1427            "redeemable": false,
1428            "mergeable": true,
1429            "title": "Will X happen?",
1430            "slug": "will-x-happen",
1431            "icon": "https://example.com/icon.png",
1432            "eventSlug": "x-event",
1433            "outcome": "Yes",
1434            "outcomeIndex": 0,
1435            "oppositeOutcome": "No",
1436            "oppositeAsset": "token789",
1437            "endDate": "2025-12-31",
1438            "negativeRisk": false
1439        }"#;
1440
1441        let pos: Position = serde_json::from_str(json).unwrap();
1442        assert_eq!(pos.proxy_wallet, "0xabc123");
1443        assert_eq!(pos.asset, "token123");
1444        assert_eq!(pos.condition_id, "cond456");
1445        assert!((pos.size - 100.5).abs() < f64::EPSILON);
1446        assert!((pos.avg_price - 0.65).abs() < f64::EPSILON);
1447        assert!((pos.initial_value - 65.0).abs() < f64::EPSILON);
1448        assert!((pos.current_value - 70.0).abs() < f64::EPSILON);
1449        assert!((pos.cash_pnl - 5.0).abs() < f64::EPSILON);
1450        assert!(!pos.redeemable);
1451        assert!(pos.mergeable);
1452        assert_eq!(pos.title, "Will X happen?");
1453        assert_eq!(pos.outcome, "Yes");
1454        assert_eq!(pos.outcome_index, 0);
1455        assert_eq!(pos.opposite_outcome, "No");
1456        assert!(!pos.negative_risk);
1457        assert_eq!(pos.icon, Some("https://example.com/icon.png".to_string()));
1458        assert_eq!(pos.event_slug, Some("x-event".to_string()));
1459    }
1460
1461    #[test]
1462    fn deserialize_position_with_null_optionals() {
1463        let json = r#"{
1464            "proxyWallet": "0xabc123",
1465            "asset": "token123",
1466            "conditionId": "cond456",
1467            "size": 0.0,
1468            "avgPrice": 0.0,
1469            "initialValue": 0.0,
1470            "currentValue": 0.0,
1471            "cashPnl": 0.0,
1472            "percentPnl": 0.0,
1473            "totalBought": 0.0,
1474            "realizedPnl": 0.0,
1475            "percentRealizedPnl": 0.0,
1476            "curPrice": 0.0,
1477            "redeemable": false,
1478            "mergeable": false,
1479            "title": "Test",
1480            "slug": "test",
1481            "icon": null,
1482            "eventSlug": null,
1483            "outcome": "No",
1484            "outcomeIndex": 1,
1485            "oppositeOutcome": "Yes",
1486            "oppositeAsset": "token000",
1487            "endDate": null,
1488            "negativeRisk": true
1489        }"#;
1490
1491        let pos: Position = serde_json::from_str(json).unwrap();
1492        assert!(pos.icon.is_none());
1493        assert!(pos.event_slug.is_none());
1494        assert!(pos.end_date.is_none());
1495        assert!(pos.negative_risk);
1496    }
1497
1498    #[test]
1499    fn deserialize_closed_position_from_json() {
1500        let json = r#"{
1501            "proxyWallet": "0xdef456",
1502            "asset": "token_closed",
1503            "conditionId": "cond_closed",
1504            "avgPrice": 0.45,
1505            "totalBought": 200.0,
1506            "realizedPnl": -10.0,
1507            "curPrice": 0.35,
1508            "timestamp": 1700000000,
1509            "title": "Closed market?",
1510            "slug": "closed-market",
1511            "icon": null,
1512            "eventSlug": "closed-event",
1513            "outcome": "No",
1514            "outcomeIndex": 1,
1515            "oppositeOutcome": "Yes",
1516            "oppositeAsset": "token_opp",
1517            "endDate": "2024-06-30"
1518        }"#;
1519
1520        let closed: ClosedPosition = serde_json::from_str(json).unwrap();
1521        assert_eq!(closed.proxy_wallet, "0xdef456");
1522        assert!((closed.avg_price - 0.45).abs() < f64::EPSILON);
1523        assert!((closed.realized_pnl - (-10.0)).abs() < f64::EPSILON);
1524        assert_eq!(closed.timestamp, 1700000000);
1525        assert_eq!(closed.outcome, "No");
1526        assert_eq!(closed.outcome_index, 1);
1527        assert!(closed.icon.is_none());
1528        assert_eq!(closed.event_slug, Some("closed-event".to_string()));
1529    }
1530
1531    #[test]
1532    fn deserialize_trade_from_json() {
1533        let json = r#"{
1534            "proxyWallet": "0x1234",
1535            "side": "BUY",
1536            "asset": "token_buy",
1537            "conditionId": "cond_trade",
1538            "size": 50.0,
1539            "price": 0.72,
1540            "timestamp": 1700001000,
1541            "title": "Trade market?",
1542            "slug": "trade-market",
1543            "icon": "https://example.com/trade.png",
1544            "eventSlug": null,
1545            "outcome": "Yes",
1546            "outcomeIndex": 0,
1547            "name": "TraderOne",
1548            "pseudonym": "t1",
1549            "bio": "A trader",
1550            "profileImage": null,
1551            "profileImageOptimized": null,
1552            "transactionHash": "0xhash123"
1553        }"#;
1554
1555        let trade: Trade = serde_json::from_str(json).unwrap();
1556        assert_eq!(trade.proxy_wallet, "0x1234");
1557        assert_eq!(trade.side, TradeSide::Buy);
1558        assert!((trade.size - 50.0).abs() < f64::EPSILON);
1559        assert!((trade.price - 0.72).abs() < f64::EPSILON);
1560        assert_eq!(trade.timestamp, 1700001000);
1561        assert_eq!(trade.name, Some("TraderOne".to_string()));
1562        assert_eq!(trade.transaction_hash, Some("0xhash123".to_string()));
1563        assert!(trade.profile_image.is_none());
1564    }
1565
1566    #[test]
1567    fn deserialize_trade_sell_side() {
1568        let json = r#"{
1569            "proxyWallet": "0x5678",
1570            "side": "SELL",
1571            "asset": "token_sell",
1572            "conditionId": "cond_sell",
1573            "size": 25.0,
1574            "price": 0.30,
1575            "timestamp": 1700002000,
1576            "title": "Sell test",
1577            "slug": "sell-test",
1578            "icon": null,
1579            "eventSlug": null,
1580            "outcome": "No",
1581            "outcomeIndex": 1,
1582            "name": null,
1583            "pseudonym": null,
1584            "bio": null,
1585            "profileImage": null,
1586            "profileImageOptimized": null,
1587            "transactionHash": null
1588        }"#;
1589
1590        let trade: Trade = serde_json::from_str(json).unwrap();
1591        assert_eq!(trade.side, TradeSide::Sell);
1592        assert!(trade.name.is_none());
1593        assert!(trade.transaction_hash.is_none());
1594    }
1595
1596    #[test]
1597    fn deserialize_activity_from_json() {
1598        let json = r#"{
1599            "proxyWallet": "0xact123",
1600            "timestamp": 1700003000,
1601            "conditionId": "cond_act",
1602            "type": "TRADE",
1603            "size": 10.0,
1604            "usdcSize": 7.50,
1605            "transactionHash": "0xacthash",
1606            "price": 0.75,
1607            "asset": "token_act",
1608            "side": "BUY",
1609            "outcomeIndex": 0,
1610            "title": "Activity market",
1611            "slug": "activity-market",
1612            "icon": null,
1613            "outcome": "Yes",
1614            "name": null,
1615            "pseudonym": null,
1616            "bio": null,
1617            "profileImage": null,
1618            "profileImageOptimized": null
1619        }"#;
1620
1621        let activity: Activity = serde_json::from_str(json).unwrap();
1622        assert_eq!(activity.proxy_wallet, "0xact123");
1623        assert_eq!(activity.activity_type, ActivityType::Trade);
1624        assert!((activity.size - 10.0).abs() < f64::EPSILON);
1625        assert!((activity.usdc_size - 7.50).abs() < f64::EPSILON);
1626        assert_eq!(activity.side, Some("BUY".to_string()));
1627        assert_eq!(activity.outcome_index, Some(0));
1628    }
1629
1630    #[test]
1631    fn deserialize_activity_merge_type() {
1632        let json = r#"{
1633            "proxyWallet": "0xmerge",
1634            "timestamp": 1700004000,
1635            "conditionId": "cond_merge",
1636            "type": "MERGE",
1637            "size": 5.0,
1638            "usdcSize": 3.0,
1639            "transactionHash": null,
1640            "price": null,
1641            "asset": null,
1642            "side": "",
1643            "outcomeIndex": null,
1644            "title": null,
1645            "slug": null,
1646            "icon": null,
1647            "outcome": null,
1648            "name": null,
1649            "pseudonym": null,
1650            "bio": null,
1651            "profileImage": null,
1652            "profileImageOptimized": null
1653        }"#;
1654
1655        let activity: Activity = serde_json::from_str(json).unwrap();
1656        assert_eq!(activity.activity_type, ActivityType::Merge);
1657        // Side is an empty string from the API, stored as Some("")
1658        assert_eq!(activity.side, Some("".to_string()));
1659        assert!(activity.price.is_none());
1660        assert!(activity.asset.is_none());
1661        assert!(activity.title.is_none());
1662    }
1663
1664    #[test]
1665    fn deserialize_user_value() {
1666        let json = r#"{"user": "0xuser", "value": 1234.56}"#;
1667        let uv: UserValue = serde_json::from_str(json).unwrap();
1668        assert_eq!(uv.user, "0xuser");
1669        assert!((uv.value - 1234.56).abs() < f64::EPSILON);
1670    }
1671
1672    #[test]
1673    fn deserialize_open_interest() {
1674        let json = r#"{"market": "0xcond", "value": 50000.0}"#;
1675        let oi: OpenInterest = serde_json::from_str(json).unwrap();
1676        assert_eq!(oi.market, "0xcond");
1677        assert!((oi.value - 50000.0).abs() < f64::EPSILON);
1678    }
1679
1680    #[test]
1681    fn market_position_status_display_matches_serde() {
1682        for variant in [
1683            MarketPositionStatus::Open,
1684            MarketPositionStatus::Closed,
1685            MarketPositionStatus::All,
1686        ] {
1687            let serialized = serde_json::to_value(variant).unwrap();
1688            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1689        }
1690    }
1691
1692    #[test]
1693    fn market_position_status_default_is_all() {
1694        assert_eq!(MarketPositionStatus::default(), MarketPositionStatus::All);
1695    }
1696
1697    #[test]
1698    fn market_position_sort_by_display_matches_serde() {
1699        for variant in [
1700            MarketPositionSortBy::Tokens,
1701            MarketPositionSortBy::CashPnl,
1702            MarketPositionSortBy::RealizedPnl,
1703            MarketPositionSortBy::TotalPnl,
1704        ] {
1705            let serialized = serde_json::to_value(variant).unwrap();
1706            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1707        }
1708    }
1709
1710    #[test]
1711    fn market_position_sort_by_default_is_total_pnl() {
1712        assert_eq!(
1713            MarketPositionSortBy::default(),
1714            MarketPositionSortBy::TotalPnl
1715        );
1716    }
1717
1718    #[test]
1719    fn deserialize_market_position_v1() {
1720        // Field names lifted from `MarketPositionV1` in docs/specs/data/openapi.yaml.
1721        let json = r#"{
1722            "proxyWallet": "0xabc",
1723            "name": "Alice",
1724            "profileImage": "https://example.com/a.png",
1725            "verified": true,
1726            "asset": "token_a",
1727            "conditionId": "cond_mp",
1728            "avgPrice": 0.42,
1729            "size": 1234.5,
1730            "currPrice": 0.51,
1731            "currentValue": 629.60,
1732            "cashPnl": 110.0,
1733            "totalBought": 520.0,
1734            "realizedPnl": 15.5,
1735            "totalPnl": 125.5,
1736            "outcome": "Yes",
1737            "outcomeIndex": 0
1738        }"#;
1739
1740        let pos: MarketPositionV1 = serde_json::from_str(json).unwrap();
1741        assert_eq!(pos.proxy_wallet, "0xabc");
1742        assert_eq!(pos.name, "Alice");
1743        assert_eq!(
1744            pos.profile_image.as_deref(),
1745            Some("https://example.com/a.png")
1746        );
1747        assert!(pos.verified);
1748        assert_eq!(pos.asset, "token_a");
1749        assert_eq!(pos.condition_id, "cond_mp");
1750        assert!((pos.avg_price - 0.42).abs() < f64::EPSILON);
1751        assert!((pos.size - 1234.5).abs() < f64::EPSILON);
1752        assert!((pos.curr_price - 0.51).abs() < f64::EPSILON);
1753        assert!((pos.current_value - 629.60).abs() < f64::EPSILON);
1754        assert!((pos.cash_pnl - 110.0).abs() < f64::EPSILON);
1755        assert!((pos.total_bought - 520.0).abs() < f64::EPSILON);
1756        assert!((pos.realized_pnl - 15.5).abs() < f64::EPSILON);
1757        assert!((pos.total_pnl - 125.5).abs() < f64::EPSILON);
1758        assert_eq!(pos.outcome, "Yes");
1759        assert_eq!(pos.outcome_index, 0);
1760    }
1761
1762    #[test]
1763    fn market_position_v1_roundtrip() {
1764        let original = MarketPositionV1 {
1765            proxy_wallet: "0xabc".into(),
1766            name: "Alice".into(),
1767            profile_image: None,
1768            verified: false,
1769            asset: "token_a".into(),
1770            condition_id: "cond_mp".into(),
1771            avg_price: 0.5,
1772            size: 10.0,
1773            curr_price: 0.6,
1774            current_value: 6.0,
1775            cash_pnl: 1.0,
1776            total_bought: 5.0,
1777            realized_pnl: 0.0,
1778            total_pnl: 1.0,
1779            outcome: "No".into(),
1780            outcome_index: 1,
1781        };
1782        let json = serde_json::to_string(&original).unwrap();
1783        // Ensure currPrice is used over snake_case in the wire format.
1784        assert!(json.contains("\"currPrice\""));
1785        let back: MarketPositionV1 = serde_json::from_str(&json).unwrap();
1786        assert_eq!(back.proxy_wallet, original.proxy_wallet);
1787        assert_eq!(back.outcome_index, original.outcome_index);
1788        assert!((back.curr_price - original.curr_price).abs() < f64::EPSILON);
1789    }
1790
1791    #[test]
1792    fn deserialize_meta_market_position_v1() {
1793        let json = r#"{
1794            "token": "token_a",
1795            "positions": [
1796                {
1797                    "proxyWallet": "0xabc",
1798                    "name": "Alice",
1799                    "profileImage": null,
1800                    "verified": false,
1801                    "asset": "token_a",
1802                    "conditionId": "cond_mp",
1803                    "avgPrice": 0.42,
1804                    "size": 100.0,
1805                    "currPrice": 0.51,
1806                    "currentValue": 51.0,
1807                    "cashPnl": 9.0,
1808                    "totalBought": 42.0,
1809                    "realizedPnl": 0.0,
1810                    "totalPnl": 9.0,
1811                    "outcome": "Yes",
1812                    "outcomeIndex": 0
1813                }
1814            ]
1815        }"#;
1816
1817        let meta: MetaMarketPositionV1 = serde_json::from_str(json).unwrap();
1818        assert_eq!(meta.token, "token_a");
1819        assert_eq!(meta.positions.len(), 1);
1820        assert_eq!(meta.positions[0].name, "Alice");
1821        assert!(meta.positions[0].profile_image.is_none());
1822    }
1823
1824    #[test]
1825    fn deserialize_position_fee_basis() {
1826        // `entryFeesUsdc: 0` is a *measured* zero and must not collapse to None:
1827        // upstream returns an explicit 0 when the fee component is zero, and
1828        // omits the field entirely when the data is unavailable.
1829        let json = r#"{
1830            "proxyWallet": "0xabc123",
1831            "asset": "token123",
1832            "conditionId": "cond456",
1833            "size": 100.5,
1834            "avgPrice": 0.65,
1835            "initialValue": 65.0,
1836            "grossInitialValue": 65.5,
1837            "entryFeesUsdc": 0,
1838            "currentValue": 70.0,
1839            "cashPnl": 5.0,
1840            "percentPnl": 7.69,
1841            "totalBought": 100.5,
1842            "realizedPnl": 2.0,
1843            "percentRealizedPnl": 3.08,
1844            "curPrice": 0.70,
1845            "redeemable": false,
1846            "mergeable": true,
1847            "title": "Will X happen?",
1848            "slug": "will-x-happen",
1849            "outcome": "Yes",
1850            "outcomeIndex": 0,
1851            "oppositeOutcome": "No",
1852            "oppositeAsset": "token789",
1853            "negativeRisk": false
1854        }"#;
1855
1856        let pos: Position = serde_json::from_str(json).unwrap();
1857        assert_eq!(pos.gross_initial_value, Some(65.5));
1858        assert_eq!(pos.entry_fees_usdc, Some(0.0));
1859        // initialValue keeps fee-exclusive semantics, so it is NOT the gross figure.
1860        assert!((pos.initial_value - 65.0).abs() < f64::EPSILON);
1861    }
1862
1863    #[test]
1864    fn deserialize_position_without_fee_basis_is_none() {
1865        // Older payloads omit both fields; None means "unavailable", not zero.
1866        let json = r#"{
1867            "proxyWallet": "0xabc123",
1868            "asset": "token123",
1869            "conditionId": "cond456",
1870            "size": 100.5,
1871            "avgPrice": 0.65,
1872            "initialValue": 65.0,
1873            "currentValue": 70.0,
1874            "cashPnl": 5.0,
1875            "percentPnl": 7.69,
1876            "totalBought": 100.5,
1877            "realizedPnl": 2.0,
1878            "percentRealizedPnl": 3.08,
1879            "curPrice": 0.70,
1880            "redeemable": false,
1881            "mergeable": true,
1882            "title": "Will X happen?",
1883            "slug": "will-x-happen",
1884            "outcome": "Yes",
1885            "outcomeIndex": 0,
1886            "oppositeOutcome": "No",
1887            "oppositeAsset": "token789",
1888            "negativeRisk": false
1889        }"#;
1890
1891        let pos: Position = serde_json::from_str(json).unwrap();
1892        assert_eq!(pos.gross_initial_value, None);
1893        assert_eq!(pos.entry_fees_usdc, None);
1894    }
1895
1896    #[test]
1897    fn deserialize_allowance_max_sentinel() {
1898        let v: Allowance = serde_json::from_str(r#""max""#).unwrap();
1899        assert_eq!(v, Allowance::Max);
1900    }
1901
1902    #[test]
1903    fn deserialize_allowance_decimal_amount() {
1904        let v: Allowance = serde_json::from_str(r#""1000000""#).unwrap();
1905        assert_eq!(
1906            v,
1907            Allowance::Amount(rust_decimal::Decimal::new(1_000_000, 0))
1908        );
1909    }
1910
1911    #[test]
1912    fn deserialize_allowance_beyond_decimal_range_is_unknown() {
1913        // rust_decimal tops out near 7.9e28; a uint256 allowance can reach
1914        // 1.2e77. Without the Unknown arm this would fail the whole response.
1915        let huge = "1".repeat(40);
1916        let json = format!(r#""{huge}""#);
1917        let v: Allowance = serde_json::from_str(&json).unwrap();
1918        assert_eq!(v, Allowance::Unknown(huge));
1919    }
1920
1921    #[test]
1922    fn deserialize_allowance_unrecognized_sentinel_is_unknown() {
1923        let v: Allowance = serde_json::from_str(r#""unlimited""#).unwrap();
1924        assert_eq!(v, Allowance::Unknown("unlimited".to_string()));
1925    }
1926
1927    #[test]
1928    fn deserialize_approvals_response() {
1929        let json = r#"{
1930            "address": "0xabc123",
1931            "chainId": 137,
1932            "checkedAt": "2026-08-10T12:34:56Z",
1933            "contracts": [
1934                {
1935                    "id": "UsdcExchange",
1936                    "feature": "trading",
1937                    "token": "0xtoken",
1938                    "spender": "0xspender",
1939                    "standard": "ERC20",
1940                    "amount": "max",
1941                    "approved": true
1942                },
1943                {
1944                    "id": "CtfExchangeIsApprovedForAll",
1945                    "feature": "auto-redeem",
1946                    "token": "0xctf",
1947                    "spender": "0xspender2",
1948                    "standard": "ERC1155",
1949                    "approved": false
1950                }
1951            ]
1952        }"#;
1953
1954        let resp: ApprovalsResponse = serde_json::from_str(json).unwrap();
1955        assert_eq!(resp.chain_id, 137);
1956        assert_eq!(resp.contracts.len(), 2);
1957
1958        let erc20 = &resp.contracts[0];
1959        assert_eq!(erc20.feature, ApprovalFeature::Trading);
1960        assert_eq!(erc20.standard, ApprovalStandard::Erc20);
1961        assert_eq!(erc20.amount, Some(Allowance::Max));
1962        assert!(erc20.approved);
1963
1964        // ERC1155 entries carry no amount at all.
1965        let erc1155 = &resp.contracts[1];
1966        assert_eq!(erc1155.feature, ApprovalFeature::AutoRedeem);
1967        assert_eq!(erc1155.standard, ApprovalStandard::Erc1155);
1968        assert_eq!(erc1155.amount, None);
1969        assert!(!erc1155.approved);
1970    }
1971
1972    #[test]
1973    fn deserialize_approval_enums_tolerate_unknown_variants() {
1974        // Upstream adds features over time; an unrecognized value must not
1975        // fail the whole response.
1976        let json = r#"{
1977            "id": "SomethingNew",
1978            "feature": "staking",
1979            "token": "0xtoken",
1980            "spender": "0xspender",
1981            "standard": "ERC721",
1982            "approved": true
1983        }"#;
1984
1985        let c: ApprovalContract = serde_json::from_str(json).unwrap();
1986        assert_eq!(c.feature, ApprovalFeature::Unknown);
1987        assert_eq!(c.standard, ApprovalStandard::Unknown);
1988    }
1989}