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}
261
262impl std::fmt::Display for ActivityType {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        match self {
265            Self::Trade => write!(f, "TRADE"),
266            Self::Split => write!(f, "SPLIT"),
267            Self::Merge => write!(f, "MERGE"),
268            Self::Redeem => write!(f, "REDEEM"),
269            Self::Reward => write!(f, "REWARD"),
270            Self::Conversion => write!(f, "CONVERSION"),
271        }
272    }
273}
274
275/// Sort field options for activity queries
276#[cfg_attr(feature = "specta", derive(specta::Type))]
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
278#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
279pub enum ActivitySortBy {
280    /// Sort by timestamp (default)
281    #[default]
282    Timestamp,
283    /// Sort by token amount
284    Tokens,
285    /// Sort by cash amount
286    Cash,
287}
288
289impl std::fmt::Display for ActivitySortBy {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        match self {
292            Self::Timestamp => write!(f, "TIMESTAMP"),
293            Self::Tokens => write!(f, "TOKENS"),
294            Self::Cash => write!(f, "CASH"),
295        }
296    }
297}
298
299/// User activity record
300#[cfg_attr(feature = "specta", derive(specta::Type))]
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct Activity {
304    /// Proxy wallet address
305    pub proxy_wallet: String,
306    /// Activity timestamp
307    #[cfg_attr(feature = "specta", specta(type = f64))]
308    pub timestamp: i64,
309    /// Condition ID of the market
310    pub condition_id: String,
311    /// Activity type
312    #[serde(rename = "type")]
313    pub activity_type: ActivityType,
314    /// Token quantity
315    pub size: f64,
316    /// USD value
317    pub usdc_size: f64,
318    /// On-chain transaction hash
319    pub transaction_hash: Option<String>,
320    /// Execution price
321    pub price: Option<f64>,
322    /// Asset identifier (token ID)
323    pub asset: Option<String>,
324    // ! Deserialize into String because the API can return an empty string
325    /// Trade side (BUY or SELL)
326    pub side: Option<String>,
327    /// Outcome index (0 or 1 for binary markets)
328    pub outcome_index: Option<u32>,
329    /// Market title
330    pub title: Option<String>,
331    /// Market slug
332    pub slug: Option<String>,
333    /// Market icon URL
334    pub icon: Option<String>,
335    /// Outcome name (e.g., "Yes", "No")
336    pub outcome: Option<String>,
337    /// User display name
338    pub name: Option<String>,
339    /// User pseudonym
340    pub pseudonym: Option<String>,
341    /// User bio
342    pub bio: Option<String>,
343    /// User profile image URL
344    pub profile_image: Option<String>,
345    /// Optimized profile image URL
346    pub profile_image_optimized: Option<String>,
347}
348
349/// User position in a market
350#[cfg_attr(feature = "specta", derive(specta::Type))]
351#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase")]
353pub struct Position {
354    /// Proxy wallet address
355    pub proxy_wallet: String,
356    /// Asset identifier (token ID)
357    pub asset: String,
358    /// Condition ID of the market
359    pub condition_id: String,
360    /// Position size (number of shares)
361    pub size: f64,
362    /// Average entry price
363    pub avg_price: f64,
364    /// Initial value of position
365    pub initial_value: f64,
366    /// Current value of position
367    pub current_value: f64,
368    /// Cash profit and loss
369    pub cash_pnl: f64,
370    /// Percentage profit and loss
371    pub percent_pnl: f64,
372    /// Total amount bought
373    pub total_bought: f64,
374    /// Realized profit and loss
375    pub realized_pnl: f64,
376    /// Percentage realized P&L
377    pub percent_realized_pnl: f64,
378    /// Current market price
379    pub cur_price: f64,
380    /// Whether position is redeemable
381    pub redeemable: bool,
382    /// Whether position is mergeable
383    pub mergeable: bool,
384    /// Market title
385    pub title: String,
386    /// Market slug
387    pub slug: String,
388    /// Market icon URL
389    pub icon: Option<String>,
390    /// Event slug
391    pub event_slug: Option<String>,
392    /// Outcome name (e.g., "Yes", "No")
393    pub outcome: String,
394    /// Outcome index (0 or 1 for binary markets)
395    pub outcome_index: u32,
396    /// Opposite outcome name
397    pub opposite_outcome: String,
398    /// Opposite outcome asset ID
399    pub opposite_asset: String,
400    /// Market end date
401    pub end_date: Option<String>,
402    /// Whether this is a negative risk market
403    pub negative_risk: bool,
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    /// Verify Display matches serde serialization for all PositionSortBy variants.
411    #[test]
412    fn position_sort_by_display_matches_serde() {
413        let variants = [
414            PositionSortBy::Current,
415            PositionSortBy::Initial,
416            PositionSortBy::Tokens,
417            PositionSortBy::CashPnl,
418            PositionSortBy::PercentPnl,
419            PositionSortBy::Title,
420            PositionSortBy::Resolving,
421            PositionSortBy::Price,
422            PositionSortBy::AvgPrice,
423        ];
424        for variant in variants {
425            let serialized = serde_json::to_value(variant).unwrap();
426            let display = variant.to_string();
427            assert_eq!(
428                format!("\"{}\"", display),
429                serialized.to_string(),
430                "Display mismatch for {:?}",
431                variant
432            );
433        }
434    }
435
436    /// Verify Display matches serde serialization for all ClosedPositionSortBy variants.
437    #[test]
438    fn closed_position_sort_by_display_matches_serde() {
439        let variants = [
440            ClosedPositionSortBy::RealizedPnl,
441            ClosedPositionSortBy::Title,
442            ClosedPositionSortBy::Price,
443            ClosedPositionSortBy::AvgPrice,
444            ClosedPositionSortBy::Timestamp,
445        ];
446        for variant in variants {
447            let serialized = serde_json::to_value(variant).unwrap();
448            let display = variant.to_string();
449            assert_eq!(
450                format!("\"{}\"", display),
451                serialized.to_string(),
452                "Display mismatch for {:?}",
453                variant
454            );
455        }
456    }
457
458    #[test]
459    fn activity_sort_by_display_matches_serde() {
460        let variants = [
461            ActivitySortBy::Timestamp,
462            ActivitySortBy::Tokens,
463            ActivitySortBy::Cash,
464        ];
465        for variant in variants {
466            let serialized = serde_json::to_value(variant).unwrap();
467            let display = variant.to_string();
468            assert_eq!(
469                format!("\"{}\"", display),
470                serialized.to_string(),
471                "Display mismatch for {:?}",
472                variant
473            );
474        }
475    }
476
477    #[test]
478    fn sort_direction_display_matches_serde() {
479        let variants = [SortDirection::Asc, SortDirection::Desc];
480        for variant in variants {
481            let serialized = serde_json::to_value(variant).unwrap();
482            let display = variant.to_string();
483            assert_eq!(
484                format!("\"{}\"", display),
485                serialized.to_string(),
486                "Display mismatch for {:?}",
487                variant
488            );
489        }
490    }
491
492    #[test]
493    fn trade_side_display_matches_serde() {
494        let variants = [TradeSide::Buy, TradeSide::Sell];
495        for variant in variants {
496            let serialized = serde_json::to_value(variant).unwrap();
497            let display = variant.to_string();
498            assert_eq!(
499                format!("\"{}\"", display),
500                serialized.to_string(),
501                "Display mismatch for {:?}",
502                variant
503            );
504        }
505    }
506
507    #[test]
508    fn trade_filter_type_display_matches_serde() {
509        let variants = [TradeFilterType::Cash, TradeFilterType::Tokens];
510        for variant in variants {
511            let serialized = serde_json::to_value(variant).unwrap();
512            let display = variant.to_string();
513            assert_eq!(
514                format!("\"{}\"", display),
515                serialized.to_string(),
516                "Display mismatch for {:?}",
517                variant
518            );
519        }
520    }
521
522    #[test]
523    fn activity_type_display_matches_serde() {
524        let variants = [
525            ActivityType::Trade,
526            ActivityType::Split,
527            ActivityType::Merge,
528            ActivityType::Redeem,
529            ActivityType::Reward,
530            ActivityType::Conversion,
531        ];
532        for variant in variants {
533            let serialized = serde_json::to_value(variant).unwrap();
534            let display = variant.to_string();
535            assert_eq!(
536                format!("\"{}\"", display),
537                serialized.to_string(),
538                "Display mismatch for {:?}",
539                variant
540            );
541        }
542    }
543
544    #[test]
545    fn activity_type_roundtrip_serde() {
546        for variant in [
547            ActivityType::Trade,
548            ActivityType::Split,
549            ActivityType::Merge,
550            ActivityType::Redeem,
551            ActivityType::Reward,
552            ActivityType::Conversion,
553        ] {
554            let json = serde_json::to_string(&variant).unwrap();
555            let deserialized: ActivityType = serde_json::from_str(&json).unwrap();
556            assert_eq!(variant, deserialized);
557        }
558    }
559
560    #[test]
561    fn activity_type_rejects_unknown_variant() {
562        let result = serde_json::from_str::<ActivityType>("\"UNKNOWN\"");
563        assert!(result.is_err(), "should reject unknown activity type");
564    }
565
566    #[test]
567    fn activity_type_rejects_lowercase() {
568        let result = serde_json::from_str::<ActivityType>("\"trade\"");
569        assert!(result.is_err(), "should reject lowercase activity type");
570    }
571
572    #[test]
573    fn sort_direction_default_is_desc() {
574        assert_eq!(SortDirection::default(), SortDirection::Desc);
575    }
576
577    #[test]
578    fn closed_position_sort_by_default_is_realized_pnl() {
579        assert_eq!(
580            ClosedPositionSortBy::default(),
581            ClosedPositionSortBy::RealizedPnl
582        );
583    }
584
585    #[test]
586    fn activity_sort_by_default_is_timestamp() {
587        assert_eq!(ActivitySortBy::default(), ActivitySortBy::Timestamp);
588    }
589
590    #[test]
591    fn position_sort_by_serde_roundtrip() {
592        for variant in [
593            PositionSortBy::Current,
594            PositionSortBy::Initial,
595            PositionSortBy::Tokens,
596            PositionSortBy::CashPnl,
597            PositionSortBy::PercentPnl,
598            PositionSortBy::Title,
599            PositionSortBy::Resolving,
600            PositionSortBy::Price,
601            PositionSortBy::AvgPrice,
602        ] {
603            let json = serde_json::to_string(&variant).unwrap();
604            let deserialized: PositionSortBy = serde_json::from_str(&json).unwrap();
605            assert_eq!(variant, deserialized);
606        }
607    }
608
609    #[test]
610    fn deserialize_position_from_json() {
611        let json = r#"{
612            "proxyWallet": "0xabc123",
613            "asset": "token123",
614            "conditionId": "cond456",
615            "size": 100.5,
616            "avgPrice": 0.65,
617            "initialValue": 65.0,
618            "currentValue": 70.0,
619            "cashPnl": 5.0,
620            "percentPnl": 7.69,
621            "totalBought": 100.5,
622            "realizedPnl": 2.0,
623            "percentRealizedPnl": 3.08,
624            "curPrice": 0.70,
625            "redeemable": false,
626            "mergeable": true,
627            "title": "Will X happen?",
628            "slug": "will-x-happen",
629            "icon": "https://example.com/icon.png",
630            "eventSlug": "x-event",
631            "outcome": "Yes",
632            "outcomeIndex": 0,
633            "oppositeOutcome": "No",
634            "oppositeAsset": "token789",
635            "endDate": "2025-12-31",
636            "negativeRisk": false
637        }"#;
638
639        let pos: Position = serde_json::from_str(json).unwrap();
640        assert_eq!(pos.proxy_wallet, "0xabc123");
641        assert_eq!(pos.asset, "token123");
642        assert_eq!(pos.condition_id, "cond456");
643        assert!((pos.size - 100.5).abs() < f64::EPSILON);
644        assert!((pos.avg_price - 0.65).abs() < f64::EPSILON);
645        assert!((pos.initial_value - 65.0).abs() < f64::EPSILON);
646        assert!((pos.current_value - 70.0).abs() < f64::EPSILON);
647        assert!((pos.cash_pnl - 5.0).abs() < f64::EPSILON);
648        assert!(!pos.redeemable);
649        assert!(pos.mergeable);
650        assert_eq!(pos.title, "Will X happen?");
651        assert_eq!(pos.outcome, "Yes");
652        assert_eq!(pos.outcome_index, 0);
653        assert_eq!(pos.opposite_outcome, "No");
654        assert!(!pos.negative_risk);
655        assert_eq!(pos.icon, Some("https://example.com/icon.png".to_string()));
656        assert_eq!(pos.event_slug, Some("x-event".to_string()));
657    }
658
659    #[test]
660    fn deserialize_position_with_null_optionals() {
661        let json = r#"{
662            "proxyWallet": "0xabc123",
663            "asset": "token123",
664            "conditionId": "cond456",
665            "size": 0.0,
666            "avgPrice": 0.0,
667            "initialValue": 0.0,
668            "currentValue": 0.0,
669            "cashPnl": 0.0,
670            "percentPnl": 0.0,
671            "totalBought": 0.0,
672            "realizedPnl": 0.0,
673            "percentRealizedPnl": 0.0,
674            "curPrice": 0.0,
675            "redeemable": false,
676            "mergeable": false,
677            "title": "Test",
678            "slug": "test",
679            "icon": null,
680            "eventSlug": null,
681            "outcome": "No",
682            "outcomeIndex": 1,
683            "oppositeOutcome": "Yes",
684            "oppositeAsset": "token000",
685            "endDate": null,
686            "negativeRisk": true
687        }"#;
688
689        let pos: Position = serde_json::from_str(json).unwrap();
690        assert!(pos.icon.is_none());
691        assert!(pos.event_slug.is_none());
692        assert!(pos.end_date.is_none());
693        assert!(pos.negative_risk);
694    }
695
696    #[test]
697    fn deserialize_closed_position_from_json() {
698        let json = r#"{
699            "proxyWallet": "0xdef456",
700            "asset": "token_closed",
701            "conditionId": "cond_closed",
702            "avgPrice": 0.45,
703            "totalBought": 200.0,
704            "realizedPnl": -10.0,
705            "curPrice": 0.35,
706            "timestamp": 1700000000,
707            "title": "Closed market?",
708            "slug": "closed-market",
709            "icon": null,
710            "eventSlug": "closed-event",
711            "outcome": "No",
712            "outcomeIndex": 1,
713            "oppositeOutcome": "Yes",
714            "oppositeAsset": "token_opp",
715            "endDate": "2024-06-30"
716        }"#;
717
718        let closed: ClosedPosition = serde_json::from_str(json).unwrap();
719        assert_eq!(closed.proxy_wallet, "0xdef456");
720        assert!((closed.avg_price - 0.45).abs() < f64::EPSILON);
721        assert!((closed.realized_pnl - (-10.0)).abs() < f64::EPSILON);
722        assert_eq!(closed.timestamp, 1700000000);
723        assert_eq!(closed.outcome, "No");
724        assert_eq!(closed.outcome_index, 1);
725        assert!(closed.icon.is_none());
726        assert_eq!(closed.event_slug, Some("closed-event".to_string()));
727    }
728
729    #[test]
730    fn deserialize_trade_from_json() {
731        let json = r#"{
732            "proxyWallet": "0x1234",
733            "side": "BUY",
734            "asset": "token_buy",
735            "conditionId": "cond_trade",
736            "size": 50.0,
737            "price": 0.72,
738            "timestamp": 1700001000,
739            "title": "Trade market?",
740            "slug": "trade-market",
741            "icon": "https://example.com/trade.png",
742            "eventSlug": null,
743            "outcome": "Yes",
744            "outcomeIndex": 0,
745            "name": "TraderOne",
746            "pseudonym": "t1",
747            "bio": "A trader",
748            "profileImage": null,
749            "profileImageOptimized": null,
750            "transactionHash": "0xhash123"
751        }"#;
752
753        let trade: Trade = serde_json::from_str(json).unwrap();
754        assert_eq!(trade.proxy_wallet, "0x1234");
755        assert_eq!(trade.side, TradeSide::Buy);
756        assert!((trade.size - 50.0).abs() < f64::EPSILON);
757        assert!((trade.price - 0.72).abs() < f64::EPSILON);
758        assert_eq!(trade.timestamp, 1700001000);
759        assert_eq!(trade.name, Some("TraderOne".to_string()));
760        assert_eq!(trade.transaction_hash, Some("0xhash123".to_string()));
761        assert!(trade.profile_image.is_none());
762    }
763
764    #[test]
765    fn deserialize_trade_sell_side() {
766        let json = r#"{
767            "proxyWallet": "0x5678",
768            "side": "SELL",
769            "asset": "token_sell",
770            "conditionId": "cond_sell",
771            "size": 25.0,
772            "price": 0.30,
773            "timestamp": 1700002000,
774            "title": "Sell test",
775            "slug": "sell-test",
776            "icon": null,
777            "eventSlug": null,
778            "outcome": "No",
779            "outcomeIndex": 1,
780            "name": null,
781            "pseudonym": null,
782            "bio": null,
783            "profileImage": null,
784            "profileImageOptimized": null,
785            "transactionHash": null
786        }"#;
787
788        let trade: Trade = serde_json::from_str(json).unwrap();
789        assert_eq!(trade.side, TradeSide::Sell);
790        assert!(trade.name.is_none());
791        assert!(trade.transaction_hash.is_none());
792    }
793
794    #[test]
795    fn deserialize_activity_from_json() {
796        let json = r#"{
797            "proxyWallet": "0xact123",
798            "timestamp": 1700003000,
799            "conditionId": "cond_act",
800            "type": "TRADE",
801            "size": 10.0,
802            "usdcSize": 7.50,
803            "transactionHash": "0xacthash",
804            "price": 0.75,
805            "asset": "token_act",
806            "side": "BUY",
807            "outcomeIndex": 0,
808            "title": "Activity market",
809            "slug": "activity-market",
810            "icon": null,
811            "outcome": "Yes",
812            "name": null,
813            "pseudonym": null,
814            "bio": null,
815            "profileImage": null,
816            "profileImageOptimized": null
817        }"#;
818
819        let activity: Activity = serde_json::from_str(json).unwrap();
820        assert_eq!(activity.proxy_wallet, "0xact123");
821        assert_eq!(activity.activity_type, ActivityType::Trade);
822        assert!((activity.size - 10.0).abs() < f64::EPSILON);
823        assert!((activity.usdc_size - 7.50).abs() < f64::EPSILON);
824        assert_eq!(activity.side, Some("BUY".to_string()));
825        assert_eq!(activity.outcome_index, Some(0));
826    }
827
828    #[test]
829    fn deserialize_activity_merge_type() {
830        let json = r#"{
831            "proxyWallet": "0xmerge",
832            "timestamp": 1700004000,
833            "conditionId": "cond_merge",
834            "type": "MERGE",
835            "size": 5.0,
836            "usdcSize": 3.0,
837            "transactionHash": null,
838            "price": null,
839            "asset": null,
840            "side": "",
841            "outcomeIndex": null,
842            "title": null,
843            "slug": null,
844            "icon": null,
845            "outcome": null,
846            "name": null,
847            "pseudonym": null,
848            "bio": null,
849            "profileImage": null,
850            "profileImageOptimized": null
851        }"#;
852
853        let activity: Activity = serde_json::from_str(json).unwrap();
854        assert_eq!(activity.activity_type, ActivityType::Merge);
855        // Side is an empty string from the API, stored as Some("")
856        assert_eq!(activity.side, Some("".to_string()));
857        assert!(activity.price.is_none());
858        assert!(activity.asset.is_none());
859        assert!(activity.title.is_none());
860    }
861
862    #[test]
863    fn deserialize_user_value() {
864        let json = r#"{"user": "0xuser", "value": 1234.56}"#;
865        let uv: UserValue = serde_json::from_str(json).unwrap();
866        assert_eq!(uv.user, "0xuser");
867        assert!((uv.value - 1234.56).abs() < f64::EPSILON);
868    }
869
870    #[test]
871    fn deserialize_open_interest() {
872        let json = r#"{"market": "0xcond", "value": 50000.0}"#;
873        let oi: OpenInterest = serde_json::from_str(json).unwrap();
874        assert_eq!(oi.market, "0xcond");
875        assert!((oi.value - 50000.0).abs() < f64::EPSILON);
876    }
877}