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/// Time period for aggregation
407#[cfg_attr(feature = "specta", derive(specta::Type))]
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
409#[serde(rename_all = "UPPERCASE")]
410pub enum TimePeriod {
411    /// Daily aggregation (default)
412    #[default]
413    Day,
414    /// Weekly aggregation
415    Week,
416    /// Monthly aggregation
417    Month,
418    /// All time aggregation
419    All,
420}
421
422impl std::fmt::Display for TimePeriod {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        match self {
425            Self::Day => write!(f, "DAY"),
426            Self::Week => write!(f, "WEEK"),
427            Self::Month => write!(f, "MONTH"),
428            Self::All => write!(f, "ALL"),
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    /// Verify Display matches serde serialization for all PositionSortBy variants.
438    #[test]
439    fn position_sort_by_display_matches_serde() {
440        let variants = [
441            PositionSortBy::Current,
442            PositionSortBy::Initial,
443            PositionSortBy::Tokens,
444            PositionSortBy::CashPnl,
445            PositionSortBy::PercentPnl,
446            PositionSortBy::Title,
447            PositionSortBy::Resolving,
448            PositionSortBy::Price,
449            PositionSortBy::AvgPrice,
450        ];
451        for variant in variants {
452            let serialized = serde_json::to_value(variant).unwrap();
453            let display = variant.to_string();
454            assert_eq!(
455                format!("\"{}\"", display),
456                serialized.to_string(),
457                "Display mismatch for {:?}",
458                variant
459            );
460        }
461    }
462
463    /// Verify Display matches serde serialization for all ClosedPositionSortBy variants.
464    #[test]
465    fn closed_position_sort_by_display_matches_serde() {
466        let variants = [
467            ClosedPositionSortBy::RealizedPnl,
468            ClosedPositionSortBy::Title,
469            ClosedPositionSortBy::Price,
470            ClosedPositionSortBy::AvgPrice,
471            ClosedPositionSortBy::Timestamp,
472        ];
473        for variant in variants {
474            let serialized = serde_json::to_value(variant).unwrap();
475            let display = variant.to_string();
476            assert_eq!(
477                format!("\"{}\"", display),
478                serialized.to_string(),
479                "Display mismatch for {:?}",
480                variant
481            );
482        }
483    }
484
485    #[test]
486    fn activity_sort_by_display_matches_serde() {
487        let variants = [
488            ActivitySortBy::Timestamp,
489            ActivitySortBy::Tokens,
490            ActivitySortBy::Cash,
491        ];
492        for variant in variants {
493            let serialized = serde_json::to_value(variant).unwrap();
494            let display = variant.to_string();
495            assert_eq!(
496                format!("\"{}\"", display),
497                serialized.to_string(),
498                "Display mismatch for {:?}",
499                variant
500            );
501        }
502    }
503
504    #[test]
505    fn sort_direction_display_matches_serde() {
506        let variants = [SortDirection::Asc, SortDirection::Desc];
507        for variant in variants {
508            let serialized = serde_json::to_value(variant).unwrap();
509            let display = variant.to_string();
510            assert_eq!(
511                format!("\"{}\"", display),
512                serialized.to_string(),
513                "Display mismatch for {:?}",
514                variant
515            );
516        }
517    }
518
519    #[test]
520    fn trade_side_display_matches_serde() {
521        let variants = [TradeSide::Buy, TradeSide::Sell];
522        for variant in variants {
523            let serialized = serde_json::to_value(variant).unwrap();
524            let display = variant.to_string();
525            assert_eq!(
526                format!("\"{}\"", display),
527                serialized.to_string(),
528                "Display mismatch for {:?}",
529                variant
530            );
531        }
532    }
533
534    #[test]
535    fn trade_filter_type_display_matches_serde() {
536        let variants = [TradeFilterType::Cash, TradeFilterType::Tokens];
537        for variant in variants {
538            let serialized = serde_json::to_value(variant).unwrap();
539            let display = variant.to_string();
540            assert_eq!(
541                format!("\"{}\"", display),
542                serialized.to_string(),
543                "Display mismatch for {:?}",
544                variant
545            );
546        }
547    }
548
549    #[test]
550    fn activity_type_display_matches_serde() {
551        let variants = [
552            ActivityType::Trade,
553            ActivityType::Split,
554            ActivityType::Merge,
555            ActivityType::Redeem,
556            ActivityType::Reward,
557            ActivityType::Conversion,
558        ];
559        for variant in variants {
560            let serialized = serde_json::to_value(variant).unwrap();
561            let display = variant.to_string();
562            assert_eq!(
563                format!("\"{}\"", display),
564                serialized.to_string(),
565                "Display mismatch for {:?}",
566                variant
567            );
568        }
569    }
570
571    #[test]
572    fn activity_type_roundtrip_serde() {
573        for variant in [
574            ActivityType::Trade,
575            ActivityType::Split,
576            ActivityType::Merge,
577            ActivityType::Redeem,
578            ActivityType::Reward,
579            ActivityType::Conversion,
580        ] {
581            let json = serde_json::to_string(&variant).unwrap();
582            let deserialized: ActivityType = serde_json::from_str(&json).unwrap();
583            assert_eq!(variant, deserialized);
584        }
585    }
586
587    #[test]
588    fn activity_type_rejects_unknown_variant() {
589        let result = serde_json::from_str::<ActivityType>("\"UNKNOWN\"");
590        assert!(result.is_err(), "should reject unknown activity type");
591    }
592
593    #[test]
594    fn activity_type_rejects_lowercase() {
595        let result = serde_json::from_str::<ActivityType>("\"trade\"");
596        assert!(result.is_err(), "should reject lowercase activity type");
597    }
598
599    #[test]
600    fn sort_direction_default_is_desc() {
601        assert_eq!(SortDirection::default(), SortDirection::Desc);
602    }
603
604    #[test]
605    fn closed_position_sort_by_default_is_realized_pnl() {
606        assert_eq!(
607            ClosedPositionSortBy::default(),
608            ClosedPositionSortBy::RealizedPnl
609        );
610    }
611
612    #[test]
613    fn activity_sort_by_default_is_timestamp() {
614        assert_eq!(ActivitySortBy::default(), ActivitySortBy::Timestamp);
615    }
616
617    #[test]
618    fn position_sort_by_serde_roundtrip() {
619        for variant in [
620            PositionSortBy::Current,
621            PositionSortBy::Initial,
622            PositionSortBy::Tokens,
623            PositionSortBy::CashPnl,
624            PositionSortBy::PercentPnl,
625            PositionSortBy::Title,
626            PositionSortBy::Resolving,
627            PositionSortBy::Price,
628            PositionSortBy::AvgPrice,
629        ] {
630            let json = serde_json::to_string(&variant).unwrap();
631            let deserialized: PositionSortBy = serde_json::from_str(&json).unwrap();
632            assert_eq!(variant, deserialized);
633        }
634    }
635
636    #[test]
637    fn deserialize_position_from_json() {
638        let json = r#"{
639            "proxyWallet": "0xabc123",
640            "asset": "token123",
641            "conditionId": "cond456",
642            "size": 100.5,
643            "avgPrice": 0.65,
644            "initialValue": 65.0,
645            "currentValue": 70.0,
646            "cashPnl": 5.0,
647            "percentPnl": 7.69,
648            "totalBought": 100.5,
649            "realizedPnl": 2.0,
650            "percentRealizedPnl": 3.08,
651            "curPrice": 0.70,
652            "redeemable": false,
653            "mergeable": true,
654            "title": "Will X happen?",
655            "slug": "will-x-happen",
656            "icon": "https://example.com/icon.png",
657            "eventSlug": "x-event",
658            "outcome": "Yes",
659            "outcomeIndex": 0,
660            "oppositeOutcome": "No",
661            "oppositeAsset": "token789",
662            "endDate": "2025-12-31",
663            "negativeRisk": false
664        }"#;
665
666        let pos: Position = serde_json::from_str(json).unwrap();
667        assert_eq!(pos.proxy_wallet, "0xabc123");
668        assert_eq!(pos.asset, "token123");
669        assert_eq!(pos.condition_id, "cond456");
670        assert!((pos.size - 100.5).abs() < f64::EPSILON);
671        assert!((pos.avg_price - 0.65).abs() < f64::EPSILON);
672        assert!((pos.initial_value - 65.0).abs() < f64::EPSILON);
673        assert!((pos.current_value - 70.0).abs() < f64::EPSILON);
674        assert!((pos.cash_pnl - 5.0).abs() < f64::EPSILON);
675        assert!(!pos.redeemable);
676        assert!(pos.mergeable);
677        assert_eq!(pos.title, "Will X happen?");
678        assert_eq!(pos.outcome, "Yes");
679        assert_eq!(pos.outcome_index, 0);
680        assert_eq!(pos.opposite_outcome, "No");
681        assert!(!pos.negative_risk);
682        assert_eq!(pos.icon, Some("https://example.com/icon.png".to_string()));
683        assert_eq!(pos.event_slug, Some("x-event".to_string()));
684    }
685
686    #[test]
687    fn deserialize_position_with_null_optionals() {
688        let json = r#"{
689            "proxyWallet": "0xabc123",
690            "asset": "token123",
691            "conditionId": "cond456",
692            "size": 0.0,
693            "avgPrice": 0.0,
694            "initialValue": 0.0,
695            "currentValue": 0.0,
696            "cashPnl": 0.0,
697            "percentPnl": 0.0,
698            "totalBought": 0.0,
699            "realizedPnl": 0.0,
700            "percentRealizedPnl": 0.0,
701            "curPrice": 0.0,
702            "redeemable": false,
703            "mergeable": false,
704            "title": "Test",
705            "slug": "test",
706            "icon": null,
707            "eventSlug": null,
708            "outcome": "No",
709            "outcomeIndex": 1,
710            "oppositeOutcome": "Yes",
711            "oppositeAsset": "token000",
712            "endDate": null,
713            "negativeRisk": true
714        }"#;
715
716        let pos: Position = serde_json::from_str(json).unwrap();
717        assert!(pos.icon.is_none());
718        assert!(pos.event_slug.is_none());
719        assert!(pos.end_date.is_none());
720        assert!(pos.negative_risk);
721    }
722
723    #[test]
724    fn deserialize_closed_position_from_json() {
725        let json = r#"{
726            "proxyWallet": "0xdef456",
727            "asset": "token_closed",
728            "conditionId": "cond_closed",
729            "avgPrice": 0.45,
730            "totalBought": 200.0,
731            "realizedPnl": -10.0,
732            "curPrice": 0.35,
733            "timestamp": 1700000000,
734            "title": "Closed market?",
735            "slug": "closed-market",
736            "icon": null,
737            "eventSlug": "closed-event",
738            "outcome": "No",
739            "outcomeIndex": 1,
740            "oppositeOutcome": "Yes",
741            "oppositeAsset": "token_opp",
742            "endDate": "2024-06-30"
743        }"#;
744
745        let closed: ClosedPosition = serde_json::from_str(json).unwrap();
746        assert_eq!(closed.proxy_wallet, "0xdef456");
747        assert!((closed.avg_price - 0.45).abs() < f64::EPSILON);
748        assert!((closed.realized_pnl - (-10.0)).abs() < f64::EPSILON);
749        assert_eq!(closed.timestamp, 1700000000);
750        assert_eq!(closed.outcome, "No");
751        assert_eq!(closed.outcome_index, 1);
752        assert!(closed.icon.is_none());
753        assert_eq!(closed.event_slug, Some("closed-event".to_string()));
754    }
755
756    #[test]
757    fn deserialize_trade_from_json() {
758        let json = r#"{
759            "proxyWallet": "0x1234",
760            "side": "BUY",
761            "asset": "token_buy",
762            "conditionId": "cond_trade",
763            "size": 50.0,
764            "price": 0.72,
765            "timestamp": 1700001000,
766            "title": "Trade market?",
767            "slug": "trade-market",
768            "icon": "https://example.com/trade.png",
769            "eventSlug": null,
770            "outcome": "Yes",
771            "outcomeIndex": 0,
772            "name": "TraderOne",
773            "pseudonym": "t1",
774            "bio": "A trader",
775            "profileImage": null,
776            "profileImageOptimized": null,
777            "transactionHash": "0xhash123"
778        }"#;
779
780        let trade: Trade = serde_json::from_str(json).unwrap();
781        assert_eq!(trade.proxy_wallet, "0x1234");
782        assert_eq!(trade.side, TradeSide::Buy);
783        assert!((trade.size - 50.0).abs() < f64::EPSILON);
784        assert!((trade.price - 0.72).abs() < f64::EPSILON);
785        assert_eq!(trade.timestamp, 1700001000);
786        assert_eq!(trade.name, Some("TraderOne".to_string()));
787        assert_eq!(trade.transaction_hash, Some("0xhash123".to_string()));
788        assert!(trade.profile_image.is_none());
789    }
790
791    #[test]
792    fn deserialize_trade_sell_side() {
793        let json = r#"{
794            "proxyWallet": "0x5678",
795            "side": "SELL",
796            "asset": "token_sell",
797            "conditionId": "cond_sell",
798            "size": 25.0,
799            "price": 0.30,
800            "timestamp": 1700002000,
801            "title": "Sell test",
802            "slug": "sell-test",
803            "icon": null,
804            "eventSlug": null,
805            "outcome": "No",
806            "outcomeIndex": 1,
807            "name": null,
808            "pseudonym": null,
809            "bio": null,
810            "profileImage": null,
811            "profileImageOptimized": null,
812            "transactionHash": null
813        }"#;
814
815        let trade: Trade = serde_json::from_str(json).unwrap();
816        assert_eq!(trade.side, TradeSide::Sell);
817        assert!(trade.name.is_none());
818        assert!(trade.transaction_hash.is_none());
819    }
820
821    #[test]
822    fn deserialize_activity_from_json() {
823        let json = r#"{
824            "proxyWallet": "0xact123",
825            "timestamp": 1700003000,
826            "conditionId": "cond_act",
827            "type": "TRADE",
828            "size": 10.0,
829            "usdcSize": 7.50,
830            "transactionHash": "0xacthash",
831            "price": 0.75,
832            "asset": "token_act",
833            "side": "BUY",
834            "outcomeIndex": 0,
835            "title": "Activity market",
836            "slug": "activity-market",
837            "icon": null,
838            "outcome": "Yes",
839            "name": null,
840            "pseudonym": null,
841            "bio": null,
842            "profileImage": null,
843            "profileImageOptimized": null
844        }"#;
845
846        let activity: Activity = serde_json::from_str(json).unwrap();
847        assert_eq!(activity.proxy_wallet, "0xact123");
848        assert_eq!(activity.activity_type, ActivityType::Trade);
849        assert!((activity.size - 10.0).abs() < f64::EPSILON);
850        assert!((activity.usdc_size - 7.50).abs() < f64::EPSILON);
851        assert_eq!(activity.side, Some("BUY".to_string()));
852        assert_eq!(activity.outcome_index, Some(0));
853    }
854
855    #[test]
856    fn deserialize_activity_merge_type() {
857        let json = r#"{
858            "proxyWallet": "0xmerge",
859            "timestamp": 1700004000,
860            "conditionId": "cond_merge",
861            "type": "MERGE",
862            "size": 5.0,
863            "usdcSize": 3.0,
864            "transactionHash": null,
865            "price": null,
866            "asset": null,
867            "side": "",
868            "outcomeIndex": null,
869            "title": null,
870            "slug": null,
871            "icon": null,
872            "outcome": null,
873            "name": null,
874            "pseudonym": null,
875            "bio": null,
876            "profileImage": null,
877            "profileImageOptimized": null
878        }"#;
879
880        let activity: Activity = serde_json::from_str(json).unwrap();
881        assert_eq!(activity.activity_type, ActivityType::Merge);
882        // Side is an empty string from the API, stored as Some("")
883        assert_eq!(activity.side, Some("".to_string()));
884        assert!(activity.price.is_none());
885        assert!(activity.asset.is_none());
886        assert!(activity.title.is_none());
887    }
888
889    #[test]
890    fn deserialize_user_value() {
891        let json = r#"{"user": "0xuser", "value": 1234.56}"#;
892        let uv: UserValue = serde_json::from_str(json).unwrap();
893        assert_eq!(uv.user, "0xuser");
894        assert!((uv.value - 1234.56).abs() < f64::EPSILON);
895    }
896
897    #[test]
898    fn deserialize_open_interest() {
899        let json = r#"{"market": "0xcond", "value": 50000.0}"#;
900        let oi: OpenInterest = serde_json::from_str(json).unwrap();
901        assert_eq!(oi.market, "0xcond");
902        assert!((oi.value - 50000.0).abs() < f64::EPSILON);
903    }
904}