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    /// Maker rebate activity
267    #[serde(rename = "MAKER_REBATE")]
268    MakerRebate,
269    /// Referral reward activity
270    #[serde(rename = "REFERRAL_REWARD")]
271    ReferralReward,
272    /// Taker rebate activity
273    #[serde(rename = "TAKER_REBATE")]
274    TakerRebate,
275    /// Unrecognized activity type (forward-compat). Never construct this to
276    /// send in a request filter; [`super::api::users::ListActivity::activity_type`]
277    /// silently drops it since the upstream API has no matching value to filter on.
278    #[serde(other)]
279    Unknown,
280}
281
282impl std::fmt::Display for ActivityType {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            Self::Trade => write!(f, "TRADE"),
286            Self::Split => write!(f, "SPLIT"),
287            Self::Merge => write!(f, "MERGE"),
288            Self::Redeem => write!(f, "REDEEM"),
289            Self::Reward => write!(f, "REWARD"),
290            Self::Conversion => write!(f, "CONVERSION"),
291            Self::MakerRebate => write!(f, "MAKER_REBATE"),
292            Self::ReferralReward => write!(f, "REFERRAL_REWARD"),
293            Self::TakerRebate => write!(f, "TAKER_REBATE"),
294            Self::Unknown => write!(f, "UNKNOWN"),
295        }
296    }
297}
298
299/// Sort field options for activity queries
300#[cfg_attr(feature = "specta", derive(specta::Type))]
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
302#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
303pub enum ActivitySortBy {
304    /// Sort by timestamp (default)
305    #[default]
306    Timestamp,
307    /// Sort by token amount
308    Tokens,
309    /// Sort by cash amount
310    Cash,
311}
312
313impl std::fmt::Display for ActivitySortBy {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        match self {
316            Self::Timestamp => write!(f, "TIMESTAMP"),
317            Self::Tokens => write!(f, "TOKENS"),
318            Self::Cash => write!(f, "CASH"),
319        }
320    }
321}
322
323/// User activity record
324#[cfg_attr(feature = "specta", derive(specta::Type))]
325#[derive(Debug, Clone, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct Activity {
328    /// Proxy wallet address
329    pub proxy_wallet: String,
330    /// Activity timestamp
331    #[cfg_attr(feature = "specta", specta(type = f64))]
332    pub timestamp: i64,
333    /// Condition ID of the market
334    pub condition_id: String,
335    /// Activity type
336    #[serde(rename = "type")]
337    pub activity_type: ActivityType,
338    /// Token quantity
339    pub size: f64,
340    /// USD value
341    pub usdc_size: f64,
342    /// On-chain transaction hash
343    pub transaction_hash: Option<String>,
344    /// Execution price
345    pub price: Option<f64>,
346    /// Asset identifier (token ID)
347    pub asset: Option<String>,
348    // Deserialize into String because the API can return an empty string
349    /// Trade side (BUY or SELL)
350    pub side: Option<String>,
351    /// Outcome index (0 or 1 for binary markets)
352    pub outcome_index: Option<u32>,
353    /// Market title
354    pub title: Option<String>,
355    /// Market slug
356    pub slug: Option<String>,
357    /// Market icon URL
358    pub icon: Option<String>,
359    /// Outcome name (e.g., "Yes", "No")
360    pub outcome: Option<String>,
361    /// User display name
362    pub name: Option<String>,
363    /// User pseudonym
364    pub pseudonym: Option<String>,
365    /// User bio
366    pub bio: Option<String>,
367    /// User profile image URL
368    pub profile_image: Option<String>,
369    /// Optimized profile image URL
370    pub profile_image_optimized: Option<String>,
371}
372
373/// User position in a market
374#[cfg_attr(feature = "specta", derive(specta::Type))]
375#[derive(Debug, Clone, Serialize, Deserialize)]
376#[serde(rename_all = "camelCase")]
377pub struct Position {
378    /// Proxy wallet address
379    pub proxy_wallet: String,
380    /// Asset identifier (token ID)
381    pub asset: String,
382    /// Condition ID of the market
383    pub condition_id: String,
384    /// Position size (number of shares)
385    pub size: f64,
386    /// Average entry price
387    pub avg_price: f64,
388    /// Initial value of position
389    pub initial_value: f64,
390    /// Current value of position
391    pub current_value: f64,
392    /// Cash profit and loss
393    pub cash_pnl: f64,
394    /// Percentage profit and loss
395    pub percent_pnl: f64,
396    /// Total amount bought
397    pub total_bought: f64,
398    /// Realized profit and loss
399    pub realized_pnl: f64,
400    /// Percentage realized P&L
401    pub percent_realized_pnl: f64,
402    /// Current market price
403    pub cur_price: f64,
404    /// Whether position is redeemable
405    pub redeemable: bool,
406    /// Whether position is mergeable
407    pub mergeable: bool,
408    /// Market title
409    pub title: String,
410    /// Market slug
411    pub slug: String,
412    /// Market icon URL
413    pub icon: Option<String>,
414    /// Event slug
415    pub event_slug: Option<String>,
416    /// Outcome name (e.g., "Yes", "No")
417    pub outcome: String,
418    /// Outcome index (0 or 1 for binary markets)
419    pub outcome_index: u32,
420    /// Opposite outcome name
421    pub opposite_outcome: String,
422    /// Opposite outcome asset ID
423    pub opposite_asset: String,
424    /// Market end date
425    pub end_date: Option<String>,
426    /// Whether this is a negative risk market
427    pub negative_risk: bool,
428}
429
430/// A per-user position in a single market, as returned by `/v1/market-positions`.
431///
432/// Field names and types follow the upstream `MarketPositionV1` schema in
433/// `docs/specs/data/openapi.yaml`.
434#[cfg_attr(feature = "specta", derive(specta::Type))]
435#[derive(Debug, Clone, Serialize, Deserialize)]
436#[serde(rename_all = "camelCase")]
437pub struct MarketPositionV1 {
438    /// Proxy wallet address of the position holder
439    pub proxy_wallet: String,
440    /// Display name of the position holder
441    pub name: String,
442    /// Profile image URL of the position holder
443    pub profile_image: Option<String>,
444    /// Whether the holder has a verified badge
445    pub verified: bool,
446    /// Outcome token asset ID
447    pub asset: String,
448    /// Condition ID of the market
449    pub condition_id: String,
450    /// Average entry price
451    pub avg_price: f64,
452    /// Position size (number of shares)
453    pub size: f64,
454    /// Current market price (OpenAPI field: `currPrice`)
455    #[serde(rename = "currPrice")]
456    pub curr_price: f64,
457    /// Current value of the position
458    pub current_value: f64,
459    /// Unrealized cash P&L
460    pub cash_pnl: f64,
461    /// Total amount bought
462    pub total_bought: f64,
463    /// Realized P&L
464    pub realized_pnl: f64,
465    /// Total P&L (cash + realized)
466    pub total_pnl: f64,
467    /// Outcome name (e.g., "Yes", "No")
468    pub outcome: String,
469    /// Outcome index (0 or 1 for binary markets)
470    pub outcome_index: u32,
471}
472
473/// Market positions grouped by outcome token.
474#[cfg_attr(feature = "specta", derive(specta::Type))]
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct MetaMarketPositionV1 {
477    /// Outcome token asset ID
478    pub token: String,
479    /// Positions for this token
480    pub positions: Vec<MarketPositionV1>,
481}
482
483/// Status filter for `/v1/market-positions`.
484#[cfg_attr(feature = "specta", derive(specta::Type))]
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
486#[serde(rename_all = "UPPERCASE")]
487pub enum MarketPositionStatus {
488    /// Only positions with size > 0.01
489    Open,
490    /// Only positions with size <= 0.01
491    Closed,
492    /// All positions regardless of size (default)
493    #[default]
494    All,
495}
496
497impl std::fmt::Display for MarketPositionStatus {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        match self {
500            Self::Open => write!(f, "OPEN"),
501            Self::Closed => write!(f, "CLOSED"),
502            Self::All => write!(f, "ALL"),
503        }
504    }
505}
506
507/// Sort field options for `/v1/market-positions`.
508#[cfg_attr(feature = "specta", derive(specta::Type))]
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
510#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
511pub enum MarketPositionSortBy {
512    /// Sort by token count
513    Tokens,
514    /// Sort by unrealized cash P&L
515    CashPnl,
516    /// Sort by realized P&L
517    RealizedPnl,
518    /// Sort by total P&L (cash + realized). Default.
519    #[default]
520    TotalPnl,
521}
522
523impl std::fmt::Display for MarketPositionSortBy {
524    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
525        match self {
526            Self::Tokens => write!(f, "TOKENS"),
527            Self::CashPnl => write!(f, "CASH_PNL"),
528            Self::RealizedPnl => write!(f, "REALIZED_PNL"),
529            Self::TotalPnl => write!(f, "TOTAL_PNL"),
530        }
531    }
532}
533
534/// Time period for aggregation
535#[cfg_attr(feature = "specta", derive(specta::Type))]
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
537#[serde(rename_all = "UPPERCASE")]
538pub enum TimePeriod {
539    /// Daily aggregation (default)
540    #[default]
541    Day,
542    /// Weekly aggregation
543    Week,
544    /// Monthly aggregation
545    Month,
546    /// All time aggregation
547    All,
548}
549
550impl std::fmt::Display for TimePeriod {
551    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552        match self {
553            Self::Day => write!(f, "DAY"),
554            Self::Week => write!(f, "WEEK"),
555            Self::Month => write!(f, "MONTH"),
556            Self::All => write!(f, "ALL"),
557        }
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    /// Verify Display matches serde serialization for all PositionSortBy variants.
566    #[test]
567    fn position_sort_by_display_matches_serde() {
568        let variants = [
569            PositionSortBy::Current,
570            PositionSortBy::Initial,
571            PositionSortBy::Tokens,
572            PositionSortBy::CashPnl,
573            PositionSortBy::PercentPnl,
574            PositionSortBy::Title,
575            PositionSortBy::Resolving,
576            PositionSortBy::Price,
577            PositionSortBy::AvgPrice,
578        ];
579        for variant in variants {
580            let serialized = serde_json::to_value(variant).unwrap();
581            let display = variant.to_string();
582            assert_eq!(
583                format!("\"{}\"", display),
584                serialized.to_string(),
585                "Display mismatch for {:?}",
586                variant
587            );
588        }
589    }
590
591    /// Verify Display matches serde serialization for all ClosedPositionSortBy variants.
592    #[test]
593    fn closed_position_sort_by_display_matches_serde() {
594        let variants = [
595            ClosedPositionSortBy::RealizedPnl,
596            ClosedPositionSortBy::Title,
597            ClosedPositionSortBy::Price,
598            ClosedPositionSortBy::AvgPrice,
599            ClosedPositionSortBy::Timestamp,
600        ];
601        for variant in variants {
602            let serialized = serde_json::to_value(variant).unwrap();
603            let display = variant.to_string();
604            assert_eq!(
605                format!("\"{}\"", display),
606                serialized.to_string(),
607                "Display mismatch for {:?}",
608                variant
609            );
610        }
611    }
612
613    #[test]
614    fn activity_sort_by_display_matches_serde() {
615        let variants = [
616            ActivitySortBy::Timestamp,
617            ActivitySortBy::Tokens,
618            ActivitySortBy::Cash,
619        ];
620        for variant in variants {
621            let serialized = serde_json::to_value(variant).unwrap();
622            let display = variant.to_string();
623            assert_eq!(
624                format!("\"{}\"", display),
625                serialized.to_string(),
626                "Display mismatch for {:?}",
627                variant
628            );
629        }
630    }
631
632    #[test]
633    fn sort_direction_display_matches_serde() {
634        let variants = [SortDirection::Asc, SortDirection::Desc];
635        for variant in variants {
636            let serialized = serde_json::to_value(variant).unwrap();
637            let display = variant.to_string();
638            assert_eq!(
639                format!("\"{}\"", display),
640                serialized.to_string(),
641                "Display mismatch for {:?}",
642                variant
643            );
644        }
645    }
646
647    #[test]
648    fn trade_side_display_matches_serde() {
649        let variants = [TradeSide::Buy, TradeSide::Sell, TradeSide::Unknown];
650        for variant in variants {
651            let serialized = serde_json::to_value(variant).unwrap();
652            let display = variant.to_string();
653            assert_eq!(
654                format!("\"{}\"", display),
655                serialized.to_string(),
656                "Display mismatch for {:?}",
657                variant
658            );
659        }
660    }
661
662    #[test]
663    fn trade_side_falls_back_to_unknown_for_future_values() {
664        let result: TradeSide = serde_json::from_str("\"SOME_FUTURE_SIDE\"").unwrap();
665        assert_eq!(result, TradeSide::Unknown);
666    }
667
668    #[test]
669    fn trade_filter_type_display_matches_serde() {
670        let variants = [TradeFilterType::Cash, TradeFilterType::Tokens];
671        for variant in variants {
672            let serialized = serde_json::to_value(variant).unwrap();
673            let display = variant.to_string();
674            assert_eq!(
675                format!("\"{}\"", display),
676                serialized.to_string(),
677                "Display mismatch for {:?}",
678                variant
679            );
680        }
681    }
682
683    #[test]
684    fn activity_type_display_matches_serde() {
685        let variants = [
686            ActivityType::Trade,
687            ActivityType::Split,
688            ActivityType::Merge,
689            ActivityType::Redeem,
690            ActivityType::Reward,
691            ActivityType::Conversion,
692            ActivityType::MakerRebate,
693            ActivityType::ReferralReward,
694            ActivityType::TakerRebate,
695            ActivityType::Unknown,
696        ];
697        for variant in variants {
698            let serialized = serde_json::to_value(variant).unwrap();
699            let display = variant.to_string();
700            assert_eq!(
701                format!("\"{}\"", display),
702                serialized.to_string(),
703                "Display mismatch for {:?}",
704                variant
705            );
706        }
707    }
708
709    #[test]
710    fn activity_type_roundtrip_serde() {
711        for variant in [
712            ActivityType::Trade,
713            ActivityType::Split,
714            ActivityType::Merge,
715            ActivityType::Redeem,
716            ActivityType::Reward,
717            ActivityType::Conversion,
718            ActivityType::MakerRebate,
719            ActivityType::ReferralReward,
720            ActivityType::TakerRebate,
721        ] {
722            let json = serde_json::to_string(&variant).unwrap();
723            let deserialized: ActivityType = serde_json::from_str(&json).unwrap();
724            assert_eq!(variant, deserialized);
725        }
726    }
727
728    #[test]
729    fn activity_type_falls_back_to_unknown_for_future_types() {
730        // A hypothetical future activity type Polymarket hasn't documented yet.
731        let result: ActivityType = serde_json::from_str("\"SOME_FUTURE_TYPE\"").unwrap();
732        assert_eq!(result, ActivityType::Unknown);
733
734        // Case mismatches also fall back rather than poisoning the whole page.
735        let result: ActivityType = serde_json::from_str("\"trade\"").unwrap();
736        assert_eq!(result, ActivityType::Unknown);
737    }
738
739    #[test]
740    fn sort_direction_default_is_desc() {
741        assert_eq!(SortDirection::default(), SortDirection::Desc);
742    }
743
744    #[test]
745    fn closed_position_sort_by_default_is_realized_pnl() {
746        assert_eq!(
747            ClosedPositionSortBy::default(),
748            ClosedPositionSortBy::RealizedPnl
749        );
750    }
751
752    #[test]
753    fn activity_sort_by_default_is_timestamp() {
754        assert_eq!(ActivitySortBy::default(), ActivitySortBy::Timestamp);
755    }
756
757    #[test]
758    fn position_sort_by_serde_roundtrip() {
759        for variant in [
760            PositionSortBy::Current,
761            PositionSortBy::Initial,
762            PositionSortBy::Tokens,
763            PositionSortBy::CashPnl,
764            PositionSortBy::PercentPnl,
765            PositionSortBy::Title,
766            PositionSortBy::Resolving,
767            PositionSortBy::Price,
768            PositionSortBy::AvgPrice,
769        ] {
770            let json = serde_json::to_string(&variant).unwrap();
771            let deserialized: PositionSortBy = serde_json::from_str(&json).unwrap();
772            assert_eq!(variant, deserialized);
773        }
774    }
775
776    #[test]
777    fn deserialize_position_from_json() {
778        let json = r#"{
779            "proxyWallet": "0xabc123",
780            "asset": "token123",
781            "conditionId": "cond456",
782            "size": 100.5,
783            "avgPrice": 0.65,
784            "initialValue": 65.0,
785            "currentValue": 70.0,
786            "cashPnl": 5.0,
787            "percentPnl": 7.69,
788            "totalBought": 100.5,
789            "realizedPnl": 2.0,
790            "percentRealizedPnl": 3.08,
791            "curPrice": 0.70,
792            "redeemable": false,
793            "mergeable": true,
794            "title": "Will X happen?",
795            "slug": "will-x-happen",
796            "icon": "https://example.com/icon.png",
797            "eventSlug": "x-event",
798            "outcome": "Yes",
799            "outcomeIndex": 0,
800            "oppositeOutcome": "No",
801            "oppositeAsset": "token789",
802            "endDate": "2025-12-31",
803            "negativeRisk": false
804        }"#;
805
806        let pos: Position = serde_json::from_str(json).unwrap();
807        assert_eq!(pos.proxy_wallet, "0xabc123");
808        assert_eq!(pos.asset, "token123");
809        assert_eq!(pos.condition_id, "cond456");
810        assert!((pos.size - 100.5).abs() < f64::EPSILON);
811        assert!((pos.avg_price - 0.65).abs() < f64::EPSILON);
812        assert!((pos.initial_value - 65.0).abs() < f64::EPSILON);
813        assert!((pos.current_value - 70.0).abs() < f64::EPSILON);
814        assert!((pos.cash_pnl - 5.0).abs() < f64::EPSILON);
815        assert!(!pos.redeemable);
816        assert!(pos.mergeable);
817        assert_eq!(pos.title, "Will X happen?");
818        assert_eq!(pos.outcome, "Yes");
819        assert_eq!(pos.outcome_index, 0);
820        assert_eq!(pos.opposite_outcome, "No");
821        assert!(!pos.negative_risk);
822        assert_eq!(pos.icon, Some("https://example.com/icon.png".to_string()));
823        assert_eq!(pos.event_slug, Some("x-event".to_string()));
824    }
825
826    #[test]
827    fn deserialize_position_with_null_optionals() {
828        let json = r#"{
829            "proxyWallet": "0xabc123",
830            "asset": "token123",
831            "conditionId": "cond456",
832            "size": 0.0,
833            "avgPrice": 0.0,
834            "initialValue": 0.0,
835            "currentValue": 0.0,
836            "cashPnl": 0.0,
837            "percentPnl": 0.0,
838            "totalBought": 0.0,
839            "realizedPnl": 0.0,
840            "percentRealizedPnl": 0.0,
841            "curPrice": 0.0,
842            "redeemable": false,
843            "mergeable": false,
844            "title": "Test",
845            "slug": "test",
846            "icon": null,
847            "eventSlug": null,
848            "outcome": "No",
849            "outcomeIndex": 1,
850            "oppositeOutcome": "Yes",
851            "oppositeAsset": "token000",
852            "endDate": null,
853            "negativeRisk": true
854        }"#;
855
856        let pos: Position = serde_json::from_str(json).unwrap();
857        assert!(pos.icon.is_none());
858        assert!(pos.event_slug.is_none());
859        assert!(pos.end_date.is_none());
860        assert!(pos.negative_risk);
861    }
862
863    #[test]
864    fn deserialize_closed_position_from_json() {
865        let json = r#"{
866            "proxyWallet": "0xdef456",
867            "asset": "token_closed",
868            "conditionId": "cond_closed",
869            "avgPrice": 0.45,
870            "totalBought": 200.0,
871            "realizedPnl": -10.0,
872            "curPrice": 0.35,
873            "timestamp": 1700000000,
874            "title": "Closed market?",
875            "slug": "closed-market",
876            "icon": null,
877            "eventSlug": "closed-event",
878            "outcome": "No",
879            "outcomeIndex": 1,
880            "oppositeOutcome": "Yes",
881            "oppositeAsset": "token_opp",
882            "endDate": "2024-06-30"
883        }"#;
884
885        let closed: ClosedPosition = serde_json::from_str(json).unwrap();
886        assert_eq!(closed.proxy_wallet, "0xdef456");
887        assert!((closed.avg_price - 0.45).abs() < f64::EPSILON);
888        assert!((closed.realized_pnl - (-10.0)).abs() < f64::EPSILON);
889        assert_eq!(closed.timestamp, 1700000000);
890        assert_eq!(closed.outcome, "No");
891        assert_eq!(closed.outcome_index, 1);
892        assert!(closed.icon.is_none());
893        assert_eq!(closed.event_slug, Some("closed-event".to_string()));
894    }
895
896    #[test]
897    fn deserialize_trade_from_json() {
898        let json = r#"{
899            "proxyWallet": "0x1234",
900            "side": "BUY",
901            "asset": "token_buy",
902            "conditionId": "cond_trade",
903            "size": 50.0,
904            "price": 0.72,
905            "timestamp": 1700001000,
906            "title": "Trade market?",
907            "slug": "trade-market",
908            "icon": "https://example.com/trade.png",
909            "eventSlug": null,
910            "outcome": "Yes",
911            "outcomeIndex": 0,
912            "name": "TraderOne",
913            "pseudonym": "t1",
914            "bio": "A trader",
915            "profileImage": null,
916            "profileImageOptimized": null,
917            "transactionHash": "0xhash123"
918        }"#;
919
920        let trade: Trade = serde_json::from_str(json).unwrap();
921        assert_eq!(trade.proxy_wallet, "0x1234");
922        assert_eq!(trade.side, TradeSide::Buy);
923        assert!((trade.size - 50.0).abs() < f64::EPSILON);
924        assert!((trade.price - 0.72).abs() < f64::EPSILON);
925        assert_eq!(trade.timestamp, 1700001000);
926        assert_eq!(trade.name, Some("TraderOne".to_string()));
927        assert_eq!(trade.transaction_hash, Some("0xhash123".to_string()));
928        assert!(trade.profile_image.is_none());
929    }
930
931    #[test]
932    fn deserialize_trade_sell_side() {
933        let json = r#"{
934            "proxyWallet": "0x5678",
935            "side": "SELL",
936            "asset": "token_sell",
937            "conditionId": "cond_sell",
938            "size": 25.0,
939            "price": 0.30,
940            "timestamp": 1700002000,
941            "title": "Sell test",
942            "slug": "sell-test",
943            "icon": null,
944            "eventSlug": null,
945            "outcome": "No",
946            "outcomeIndex": 1,
947            "name": null,
948            "pseudonym": null,
949            "bio": null,
950            "profileImage": null,
951            "profileImageOptimized": null,
952            "transactionHash": null
953        }"#;
954
955        let trade: Trade = serde_json::from_str(json).unwrap();
956        assert_eq!(trade.side, TradeSide::Sell);
957        assert!(trade.name.is_none());
958        assert!(trade.transaction_hash.is_none());
959    }
960
961    #[test]
962    fn deserialize_activity_from_json() {
963        let json = r#"{
964            "proxyWallet": "0xact123",
965            "timestamp": 1700003000,
966            "conditionId": "cond_act",
967            "type": "TRADE",
968            "size": 10.0,
969            "usdcSize": 7.50,
970            "transactionHash": "0xacthash",
971            "price": 0.75,
972            "asset": "token_act",
973            "side": "BUY",
974            "outcomeIndex": 0,
975            "title": "Activity market",
976            "slug": "activity-market",
977            "icon": null,
978            "outcome": "Yes",
979            "name": null,
980            "pseudonym": null,
981            "bio": null,
982            "profileImage": null,
983            "profileImageOptimized": null
984        }"#;
985
986        let activity: Activity = serde_json::from_str(json).unwrap();
987        assert_eq!(activity.proxy_wallet, "0xact123");
988        assert_eq!(activity.activity_type, ActivityType::Trade);
989        assert!((activity.size - 10.0).abs() < f64::EPSILON);
990        assert!((activity.usdc_size - 7.50).abs() < f64::EPSILON);
991        assert_eq!(activity.side, Some("BUY".to_string()));
992        assert_eq!(activity.outcome_index, Some(0));
993    }
994
995    #[test]
996    fn deserialize_activity_merge_type() {
997        let json = r#"{
998            "proxyWallet": "0xmerge",
999            "timestamp": 1700004000,
1000            "conditionId": "cond_merge",
1001            "type": "MERGE",
1002            "size": 5.0,
1003            "usdcSize": 3.0,
1004            "transactionHash": null,
1005            "price": null,
1006            "asset": null,
1007            "side": "",
1008            "outcomeIndex": null,
1009            "title": null,
1010            "slug": null,
1011            "icon": null,
1012            "outcome": null,
1013            "name": null,
1014            "pseudonym": null,
1015            "bio": null,
1016            "profileImage": null,
1017            "profileImageOptimized": null
1018        }"#;
1019
1020        let activity: Activity = serde_json::from_str(json).unwrap();
1021        assert_eq!(activity.activity_type, ActivityType::Merge);
1022        // Side is an empty string from the API, stored as Some("")
1023        assert_eq!(activity.side, Some("".to_string()));
1024        assert!(activity.price.is_none());
1025        assert!(activity.asset.is_none());
1026        assert!(activity.title.is_none());
1027    }
1028
1029    #[test]
1030    fn deserialize_user_value() {
1031        let json = r#"{"user": "0xuser", "value": 1234.56}"#;
1032        let uv: UserValue = serde_json::from_str(json).unwrap();
1033        assert_eq!(uv.user, "0xuser");
1034        assert!((uv.value - 1234.56).abs() < f64::EPSILON);
1035    }
1036
1037    #[test]
1038    fn deserialize_open_interest() {
1039        let json = r#"{"market": "0xcond", "value": 50000.0}"#;
1040        let oi: OpenInterest = serde_json::from_str(json).unwrap();
1041        assert_eq!(oi.market, "0xcond");
1042        assert!((oi.value - 50000.0).abs() < f64::EPSILON);
1043    }
1044
1045    #[test]
1046    fn market_position_status_display_matches_serde() {
1047        for variant in [
1048            MarketPositionStatus::Open,
1049            MarketPositionStatus::Closed,
1050            MarketPositionStatus::All,
1051        ] {
1052            let serialized = serde_json::to_value(variant).unwrap();
1053            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1054        }
1055    }
1056
1057    #[test]
1058    fn market_position_status_default_is_all() {
1059        assert_eq!(MarketPositionStatus::default(), MarketPositionStatus::All);
1060    }
1061
1062    #[test]
1063    fn market_position_sort_by_display_matches_serde() {
1064        for variant in [
1065            MarketPositionSortBy::Tokens,
1066            MarketPositionSortBy::CashPnl,
1067            MarketPositionSortBy::RealizedPnl,
1068            MarketPositionSortBy::TotalPnl,
1069        ] {
1070            let serialized = serde_json::to_value(variant).unwrap();
1071            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1072        }
1073    }
1074
1075    #[test]
1076    fn market_position_sort_by_default_is_total_pnl() {
1077        assert_eq!(
1078            MarketPositionSortBy::default(),
1079            MarketPositionSortBy::TotalPnl
1080        );
1081    }
1082
1083    #[test]
1084    fn deserialize_market_position_v1() {
1085        // Field names lifted from `MarketPositionV1` in docs/specs/data/openapi.yaml.
1086        let json = r#"{
1087            "proxyWallet": "0xabc",
1088            "name": "Alice",
1089            "profileImage": "https://example.com/a.png",
1090            "verified": true,
1091            "asset": "token_a",
1092            "conditionId": "cond_mp",
1093            "avgPrice": 0.42,
1094            "size": 1234.5,
1095            "currPrice": 0.51,
1096            "currentValue": 629.60,
1097            "cashPnl": 110.0,
1098            "totalBought": 520.0,
1099            "realizedPnl": 15.5,
1100            "totalPnl": 125.5,
1101            "outcome": "Yes",
1102            "outcomeIndex": 0
1103        }"#;
1104
1105        let pos: MarketPositionV1 = serde_json::from_str(json).unwrap();
1106        assert_eq!(pos.proxy_wallet, "0xabc");
1107        assert_eq!(pos.name, "Alice");
1108        assert_eq!(
1109            pos.profile_image.as_deref(),
1110            Some("https://example.com/a.png")
1111        );
1112        assert!(pos.verified);
1113        assert_eq!(pos.asset, "token_a");
1114        assert_eq!(pos.condition_id, "cond_mp");
1115        assert!((pos.avg_price - 0.42).abs() < f64::EPSILON);
1116        assert!((pos.size - 1234.5).abs() < f64::EPSILON);
1117        assert!((pos.curr_price - 0.51).abs() < f64::EPSILON);
1118        assert!((pos.current_value - 629.60).abs() < f64::EPSILON);
1119        assert!((pos.cash_pnl - 110.0).abs() < f64::EPSILON);
1120        assert!((pos.total_bought - 520.0).abs() < f64::EPSILON);
1121        assert!((pos.realized_pnl - 15.5).abs() < f64::EPSILON);
1122        assert!((pos.total_pnl - 125.5).abs() < f64::EPSILON);
1123        assert_eq!(pos.outcome, "Yes");
1124        assert_eq!(pos.outcome_index, 0);
1125    }
1126
1127    #[test]
1128    fn market_position_v1_roundtrip() {
1129        let original = MarketPositionV1 {
1130            proxy_wallet: "0xabc".into(),
1131            name: "Alice".into(),
1132            profile_image: None,
1133            verified: false,
1134            asset: "token_a".into(),
1135            condition_id: "cond_mp".into(),
1136            avg_price: 0.5,
1137            size: 10.0,
1138            curr_price: 0.6,
1139            current_value: 6.0,
1140            cash_pnl: 1.0,
1141            total_bought: 5.0,
1142            realized_pnl: 0.0,
1143            total_pnl: 1.0,
1144            outcome: "No".into(),
1145            outcome_index: 1,
1146        };
1147        let json = serde_json::to_string(&original).unwrap();
1148        // Ensure currPrice is used over snake_case in the wire format.
1149        assert!(json.contains("\"currPrice\""));
1150        let back: MarketPositionV1 = serde_json::from_str(&json).unwrap();
1151        assert_eq!(back.proxy_wallet, original.proxy_wallet);
1152        assert_eq!(back.outcome_index, original.outcome_index);
1153        assert!((back.curr_price - original.curr_price).abs() < f64::EPSILON);
1154    }
1155
1156    #[test]
1157    fn deserialize_meta_market_position_v1() {
1158        let json = r#"{
1159            "token": "token_a",
1160            "positions": [
1161                {
1162                    "proxyWallet": "0xabc",
1163                    "name": "Alice",
1164                    "profileImage": null,
1165                    "verified": false,
1166                    "asset": "token_a",
1167                    "conditionId": "cond_mp",
1168                    "avgPrice": 0.42,
1169                    "size": 100.0,
1170                    "currPrice": 0.51,
1171                    "currentValue": 51.0,
1172                    "cashPnl": 9.0,
1173                    "totalBought": 42.0,
1174                    "realizedPnl": 0.0,
1175                    "totalPnl": 9.0,
1176                    "outcome": "Yes",
1177                    "outcomeIndex": 0
1178                }
1179            ]
1180        }"#;
1181
1182        let meta: MetaMarketPositionV1 = serde_json::from_str(json).unwrap();
1183        assert_eq!(meta.token, "token_a");
1184        assert_eq!(meta.positions.len(), 1);
1185        assert_eq!(meta.positions[0].name, "Alice");
1186        assert!(meta.positions[0].profile_image.is_none());
1187    }
1188}