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