Skip to main content

polyoxide_gamma/
types.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// Market data from Gamma API
7#[cfg_attr(feature = "specta", derive(specta::Type))]
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "camelCase")]
10pub struct Market {
11    pub id: String,
12    pub condition_id: String,
13    pub question_id: Option<String>,
14    pub slug: Option<String>,
15    #[serde(default)]
16    pub tokens: Vec<MarketToken>,
17    #[cfg_attr(feature = "specta", specta(type = Option<HashMap<String, String>>))]
18    pub rewards: Option<HashMap<String, serde_json::Value>>,
19    pub minimum_order_size: Option<String>,
20    pub minimum_tick_size: Option<String>,
21    pub description: String,
22    pub category: Option<String>,
23    pub end_date_iso: Option<String>,
24    pub start_date_iso: Option<String>,
25    pub question: String,
26    pub min_incentive_size: Option<String>,
27    pub max_incentive_spread: Option<String>,
28    pub submitted_by: Option<String>,
29    #[serde(rename = "volume24hr")] // lowercase 'hr' to match API
30    pub volume_24hr: Option<f64>,
31    #[serde(rename = "volume1wk")] // lowercase 'wk' to match API
32    pub volume_1wk: Option<f64>,
33    #[serde(rename = "volume1mo")] // lowercase 'mo' to match API
34    pub volume_1mo: Option<f64>,
35    #[serde(rename = "volume1yr")] // lowercase 'yr' to match API
36    pub volume_1yr: Option<f64>,
37    pub liquidity: Option<String>,
38    #[serde(default)]
39    pub tags: Vec<Tag>,
40    pub neg_risk: Option<bool>,
41    pub neg_risk_market_id: Option<String>,
42    pub neg_risk_request_id: Option<String>,
43    // Use i64 instead of u64 to prevent sentinel value
44    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
45    pub comment_count: Option<i64>,
46    pub twitter_card_image: Option<String>,
47    pub resolution_source: Option<String>,
48    pub amm_type: Option<String>,
49    pub sponsor_name: Option<String>,
50    pub sponsor_image: Option<String>,
51    pub x_axis_value: Option<String>,
52    pub y_axis_value: Option<String>,
53    #[serde(rename = "denomationToken")]
54    pub denomination_token: Option<String>,
55    pub fee: Option<String>,
56    pub image: Option<String>,
57    pub icon: Option<String>,
58    pub lower_bound: Option<String>,
59    pub upper_bound: Option<String>,
60    pub outcomes: Option<String>,
61    pub outcome_prices: Option<String>,
62    pub volume: Option<String>,
63    pub active: Option<bool>,
64    pub market_type: Option<String>,
65    pub format_type: Option<String>,
66    pub lower_bound_date: Option<String>,
67    pub upper_bound_date: Option<String>,
68    pub closed: Option<bool>,
69    pub market_maker_address: String,
70    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
71    pub created_by: Option<i64>,
72    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
73    pub updated_by: Option<i64>,
74    pub created_at: Option<String>,
75    pub updated_at: Option<String>,
76    pub closed_time: Option<String>,
77    pub wide_format: Option<bool>,
78    pub new: Option<bool>,
79    pub mailchimp_tag: Option<String>,
80    pub featured: Option<bool>,
81    pub archived: Option<bool>,
82    pub resolved_by: Option<String>,
83    pub restricted: Option<bool>,
84    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
85    pub market_group: Option<i64>,
86    pub group_item_title: Option<String>,
87    pub group_item_threshold: Option<String>,
88    pub uma_end_date: Option<String>,
89    pub uma_resolution_status: Option<String>,
90    pub uma_end_date_iso: Option<String>,
91    pub uma_resolution_statuses: Option<String>,
92    pub enable_order_book: Option<bool>,
93    pub order_price_min_tick_size: Option<f64>,
94    pub order_min_size: Option<f64>,
95    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
96    pub curation_order: Option<i64>,
97    pub volume_num: Option<f64>,
98    pub liquidity_num: Option<f64>,
99    pub has_review_dates: Option<bool>,
100    pub ready_for_cron: Option<bool>,
101    pub comments_enabled: Option<bool>,
102    pub game_start_time: Option<String>,
103    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
104    pub seconds_delay: Option<i64>,
105    pub clob_token_ids: Option<String>,
106    pub disqus_thread: Option<String>,
107    pub short_outcomes: Option<String>,
108    pub team_aid: Option<String>,
109    pub team_bid: Option<String>,
110    pub uma_bond: Option<String>,
111    pub uma_reward: Option<String>,
112    pub fpmm_live: Option<bool>,
113    #[serde(rename = "volume24hrAmm")] // Match API field names
114    pub volume_24hr_amm: Option<f64>,
115    #[serde(rename = "volume1wkAmm")]
116    pub volume_1wk_amm: Option<f64>,
117    #[serde(rename = "volume1moAmm")]
118    pub volume_1mo_amm: Option<f64>,
119    #[serde(rename = "volume1yrAmm")]
120    pub volume_1yr_amm: Option<f64>,
121    #[serde(rename = "volume24hrClob")]
122    pub volume_24hr_clob: Option<f64>,
123    #[serde(rename = "volume1wkClob")]
124    pub volume_1wk_clob: Option<f64>,
125    #[serde(rename = "volume1moClob")]
126    pub volume_1mo_clob: Option<f64>,
127    #[serde(rename = "volume1yrClob")]
128    pub volume_1yr_clob: Option<f64>,
129    pub volume_amm: Option<f64>,
130    pub volume_clob: Option<f64>,
131    pub liquidity_amm: Option<f64>,
132    pub liquidity_clob: Option<f64>,
133    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
134    pub maker_base_fee: Option<i64>,
135    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
136    pub taker_base_fee: Option<i64>,
137    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
138    pub custom_liveness: Option<i64>,
139    pub accepting_orders: Option<bool>,
140    pub notifications_enabled: Option<bool>,
141    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
142    pub score: Option<i64>,
143    pub creator: Option<String>,
144    pub ready: Option<bool>,
145    pub funded: Option<bool>,
146    pub past_slugs: Option<String>,
147    pub ready_timestamp: Option<String>,
148    pub funded_timestamp: Option<String>,
149    pub accepting_orders_timestamp: Option<String>,
150    pub competitive: Option<f64>,
151    pub rewards_min_size: Option<f64>,
152    pub rewards_max_spreads: Option<f64>,
153    pub spread: Option<f64>,
154    pub automatically_resolved: Option<bool>,
155    pub automatically_active: Option<bool>,
156    pub one_day_price_change: Option<f64>,
157    pub one_hour_price_change: Option<f64>,
158    pub one_week_price_change: Option<f64>,
159    pub one_month_price_change: Option<f64>,
160    pub one_year_price_change: Option<f64>,
161    pub last_trade_price: Option<f64>,
162    pub best_bid: Option<f64>,
163    pub best_ask: Option<f64>,
164    pub clear_book_on_start: Option<bool>,
165    pub chart_color: Option<String>,
166    pub series_color: Option<String>,
167    pub show_gmp_series: Option<bool>,
168    pub show_gmp_outcome: Option<bool>,
169    pub manual_activation: Option<bool>,
170    pub neg_risk_other: Option<bool>,
171    pub game_id: Option<String>,
172    pub group_item_range: Option<String>,
173    pub sports_market_type: Option<String>,
174    pub line: Option<f64>,
175    pub pending_deployment: Option<bool>,
176    pub deploying: Option<bool>,
177    pub deploying_timestamp: Option<String>,
178    pub schedule_deployment_timestamp: Option<String>,
179    pub rfq_enabled: Option<bool>,
180    pub event_start_time: Option<String>,
181}
182
183/// Market token (outcome)
184#[cfg_attr(feature = "specta", derive(specta::Type))]
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186#[serde(rename_all = "camelCase")]
187pub struct MarketToken {
188    pub token_id: String,
189    pub outcome: String,
190    pub price: Option<String>,
191    pub winner: Option<bool>,
192}
193
194#[cfg_attr(feature = "specta", derive(specta::Type))]
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct Event {
198    pub id: String,
199    pub ticker: Option<String>,
200    pub slug: Option<String>,
201    pub title: Option<String>,
202    pub subtitle: Option<String>,
203    pub description: Option<String>,
204    pub resolution_source: Option<String>,
205    pub start_date: Option<String>,
206    pub creation_date: Option<String>,
207    pub end_date: Option<String>,
208    pub image: Option<String>,
209    pub icon: Option<String>,
210    pub start_date_iso: Option<String>,
211    pub end_date_iso: Option<String>,
212    pub active: Option<bool>,
213    pub closed: Option<bool>,
214    pub archived: Option<bool>,
215    pub new: Option<bool>,
216    pub featured: Option<bool>,
217    pub restricted: Option<bool>,
218    pub liquidity: Option<f64>,
219    pub open_interest: Option<f64>,
220    pub sort_by: Option<String>,
221    pub category: Option<String>,
222    pub subcategory: Option<String>,
223    pub is_template: Option<bool>,
224    pub template_variables: Option<String>,
225    pub published_at: Option<String>,
226    pub created_by: Option<String>,
227    pub updated_by: Option<String>,
228    pub created_at: Option<String>,
229    pub updated_at: Option<String>,
230    pub comments_enabled: Option<bool>,
231    pub competitive: Option<f64>,
232    #[serde(rename = "volume24h")] // API uses '24h' not '24hr' for events
233    pub volume_24hr: Option<f64>,
234    #[serde(rename = "volume1wk")]
235    pub volume_1wk: Option<f64>,
236    #[serde(rename = "volume1mo")]
237    pub volume_1mo: Option<f64>,
238    #[serde(rename = "volume1yr")]
239    pub volume_1yr: Option<f64>,
240    pub featured_image: Option<String>,
241    pub disqus_thread: Option<String>,
242    pub parent_event: Option<String>,
243    pub enable_order_book: Option<bool>,
244    pub liquidity_amm: Option<f64>,
245    pub liquidity_clob: Option<f64>,
246    pub neg_risk: Option<bool>,
247    pub neg_risk_market_id: Option<String>,
248    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
249    pub neg_risk_fee_bips: Option<i64>,
250    #[serde(default)]
251    pub sub_events: Vec<String>,
252    #[serde(default)]
253    pub markets: Vec<Market>,
254    #[serde(default)]
255    pub tags: Vec<Tag>,
256    #[serde(default)]
257    pub series: Vec<SeriesInfo>,
258    pub cyom: Option<bool>,
259    pub closed_time: Option<String>,
260    pub show_all_outcomes: Option<bool>,
261    pub show_market_images: Option<bool>,
262    pub automatically_resolved: Option<bool>,
263    #[serde(rename = "enalbeNegRisk")]
264    pub enable_neg_risk: Option<bool>,
265    pub automatically_active: Option<bool>,
266    pub event_date: Option<String>,
267    pub start_time: Option<String>,
268    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
269    pub event_week: Option<i64>,
270    pub series_slug: Option<String>,
271    pub score: Option<String>,
272    pub elapsed: Option<String>,
273    pub period: Option<String>,
274    pub live: Option<bool>,
275    pub ended: Option<bool>,
276    pub finished_timestamp: Option<String>,
277    pub gmp_chart_mode: Option<String>,
278    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
279    pub tweet_count: Option<i64>,
280    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
281    pub featured_order: Option<i64>,
282    pub estimate_value: Option<bool>,
283    pub cant_estimate: Option<bool>,
284    pub spreads_main_line: Option<f64>,
285    pub totals_main_line: Option<f64>,
286    pub carousel_map: Option<String>,
287    pub pending_deployment: Option<bool>,
288    pub deploying: Option<bool>,
289    pub deploying_timestamp: Option<String>,
290    pub schedule_deployment_timestamp: Option<String>,
291    pub game_status: Option<String>,
292}
293
294/// Series information within an event
295#[cfg_attr(feature = "specta", derive(specta::Type))]
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct SeriesInfo {
299    pub id: String,
300    pub slug: String,
301    pub title: String,
302}
303
304/// Series data (tournament/season grouping)
305#[cfg_attr(feature = "specta", derive(specta::Type))]
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct SeriesData {
309    pub id: String,
310    pub slug: String,
311    pub title: String,
312    pub description: Option<String>,
313    pub image: Option<String>,
314    pub icon: Option<String>,
315    pub active: bool,
316    pub closed: bool,
317    pub archived: bool,
318    #[serde(default)]
319    pub tags: Vec<String>,
320    pub volume: Option<f64>,
321    pub liquidity: Option<f64>,
322    #[serde(default)]
323    pub events: Vec<Event>,
324    pub competitive: Option<String>,
325}
326
327/// Tag for categorizing markets/events
328#[cfg_attr(feature = "specta", derive(specta::Type))]
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
330#[serde(rename_all = "camelCase")]
331pub struct Tag {
332    pub id: String,
333    pub slug: String,
334    pub label: String,
335    pub force_show: Option<bool>,
336    pub published_at: Option<String>,
337    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
338    pub created_by: Option<u64>,
339    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
340    pub updated_by: Option<u64>,
341    pub created_at: Option<String>,
342    pub updated_at: Option<String>,
343    pub force_hide: Option<bool>,
344    pub is_carousel: Option<bool>,
345}
346
347/// Sports metadata
348#[cfg_attr(feature = "specta", derive(specta::Type))]
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct SportMetadata {
352    #[cfg_attr(feature = "specta", specta(type = f64))]
353    pub id: u64,
354    pub sport: String,
355    pub image: Option<String>,
356    pub resolution: Option<String>,
357    pub ordering: Option<String>,
358    pub tags: Option<String>,
359    pub series: Option<String>,
360    pub created_at: Option<String>,
361}
362
363/// Sports team
364#[cfg_attr(feature = "specta", derive(specta::Type))]
365#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct Team {
368    #[cfg_attr(feature = "specta", specta(type = f64))]
369    pub id: i64,
370    pub name: Option<String>,
371    pub league: Option<String>,
372    pub record: Option<String>,
373    pub logo: Option<String>,
374    pub abbreviation: Option<String>,
375    pub alias: Option<String>,
376    pub created_at: Option<DateTime<Utc>>,
377    pub updated_at: Option<DateTime<Utc>>,
378}
379
380/// Comment on a market/event/series
381#[cfg_attr(feature = "specta", derive(specta::Type))]
382#[derive(Debug, Clone, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct Comment {
385    pub id: String,
386    pub body: String,
387    pub created_at: DateTime<Utc>,
388    pub updated_at: DateTime<Utc>,
389    pub deleted_at: Option<DateTime<Utc>>,
390    pub user: CommentUser,
391    pub market_id: Option<String>,
392    pub event_id: Option<String>,
393    pub series_id: Option<String>,
394    pub parent_id: Option<String>,
395    #[serde(default)]
396    pub reactions: Vec<CommentReaction>,
397    #[serde(default)]
398    pub positions: Vec<CommentPosition>,
399    #[cfg_attr(feature = "specta", specta(type = f64))]
400    pub like_count: u32,
401    #[cfg_attr(feature = "specta", specta(type = f64))]
402    pub dislike_count: u32,
403    #[cfg_attr(feature = "specta", specta(type = f64))]
404    pub reply_count: u32,
405}
406
407/// User who created a comment
408#[cfg_attr(feature = "specta", derive(specta::Type))]
409#[derive(Debug, Clone, Serialize, Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct CommentUser {
412    pub id: String,
413    pub name: String,
414    pub avatar: Option<String>,
415}
416
417/// Reaction to a comment
418#[cfg_attr(feature = "specta", derive(specta::Type))]
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct CommentReaction {
422    pub user_id: String,
423    pub reaction_type: String,
424}
425
426/// Position held by comment author
427#[cfg_attr(feature = "specta", derive(specta::Type))]
428#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(rename_all = "camelCase")]
430pub struct CommentPosition {
431    pub token_id: String,
432    pub outcome: String,
433    pub shares: String,
434}
435
436/// Pagination cursor for list operations
437#[cfg_attr(feature = "specta", derive(specta::Type))]
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct Cursor {
441    pub next_cursor: Option<String>,
442}
443
444/// Paginated response wrapper
445#[cfg_attr(feature = "specta", derive(specta::Type))]
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct PaginatedResponse<T> {
449    pub data: Vec<T>,
450    pub next_cursor: Option<String>,
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    // ── MarketToken ─────────────────────────────────────────────
458
459    #[test]
460    fn test_market_token_deserialization() {
461        let json = r#"{
462            "tokenId": "71321045679252212594626385532706912750332728571942532289631379312455583992563",
463            "outcome": "Yes",
464            "price": "0.55",
465            "winner": false
466        }"#;
467        let token: MarketToken = serde_json::from_str(json).unwrap();
468        assert_eq!(token.outcome, "Yes");
469        assert_eq!(token.price.as_deref(), Some("0.55"));
470        assert_eq!(token.winner, Some(false));
471    }
472
473    #[test]
474    fn test_market_token_optional_fields() {
475        let json = r#"{"tokenId": "123", "outcome": "No"}"#;
476        let token: MarketToken = serde_json::from_str(json).unwrap();
477        assert!(token.price.is_none());
478        assert!(token.winner.is_none());
479    }
480
481    // ── Tag ─────────────────────────────────────────────────────
482
483    #[test]
484    fn test_tag_deserialization() {
485        let json = r#"{
486            "id": "42",
487            "slug": "politics",
488            "label": "Politics",
489            "forceShow": true,
490            "publishedAt": "2024-01-01T00:00:00Z",
491            "createdBy": 1,
492            "updatedBy": 2,
493            "createdAt": "2024-01-01T00:00:00Z",
494            "updatedAt": "2024-06-01T00:00:00Z",
495            "forceHide": false,
496            "isCarousel": true
497        }"#;
498        let tag: Tag = serde_json::from_str(json).unwrap();
499        assert_eq!(tag.slug, "politics");
500        assert_eq!(tag.force_show, Some(true));
501        assert_eq!(tag.is_carousel, Some(true));
502    }
503
504    #[test]
505    fn test_tag_minimal() {
506        let json = r#"{"id": "1", "slug": "test", "label": "Test"}"#;
507        let tag: Tag = serde_json::from_str(json).unwrap();
508        assert_eq!(tag.label, "Test");
509        assert!(tag.force_show.is_none());
510        assert!(tag.created_by.is_none());
511    }
512
513    // ── Market ──────────────────────────────────────────────────
514
515    #[test]
516    fn test_market_minimal_deserialization() {
517        let json = r#"{
518            "id": "12345",
519            "conditionId": "0xabc",
520            "description": "Will X happen?",
521            "question": "Will X happen by end of 2025?",
522            "marketMakerAddress": "0x1234567890abcdef"
523        }"#;
524        let market: Market = serde_json::from_str(json).unwrap();
525        assert_eq!(market.id, "12345");
526        assert_eq!(market.condition_id, "0xabc");
527        assert!(market.tokens.is_empty()); // #[serde(default)]
528        assert!(market.tags.is_empty()); // #[serde(default)]
529        assert!(market.slug.is_none());
530        assert!(market.volume_24hr.is_none());
531    }
532
533    #[test]
534    fn test_market_with_tokens() {
535        let json = r#"{
536            "id": "1",
537            "conditionId": "0xcond",
538            "description": "Test",
539            "question": "Test?",
540            "marketMakerAddress": "0xaddr",
541            "tokens": [
542                {"tokenId": "t1", "outcome": "Yes", "price": "0.7", "winner": true},
543                {"tokenId": "t2", "outcome": "No", "price": "0.3", "winner": false}
544            ]
545        }"#;
546        let market: Market = serde_json::from_str(json).unwrap();
547        assert_eq!(market.tokens.len(), 2);
548        assert_eq!(market.tokens[0].outcome, "Yes");
549        assert_eq!(market.tokens[1].price.as_deref(), Some("0.3"));
550    }
551
552    #[test]
553    fn test_market_volume_fields() {
554        let json = r#"{
555            "id": "1",
556            "conditionId": "0xcond",
557            "description": "Test",
558            "question": "Test?",
559            "marketMakerAddress": "0xaddr",
560            "volume24hr": 1500.5,
561            "volume1wk": 10000.0,
562            "volume1mo": 50000.0,
563            "volume1yr": 200000.0,
564            "volume24hrAmm": 100.0,
565            "volume1wkClob": 9900.0
566        }"#;
567        let market: Market = serde_json::from_str(json).unwrap();
568        assert_eq!(market.volume_24hr, Some(1500.5));
569        assert_eq!(market.volume_1wk, Some(10000.0));
570        assert_eq!(market.volume_24hr_amm, Some(100.0));
571        assert_eq!(market.volume_1wk_clob, Some(9900.0));
572    }
573
574    #[test]
575    fn test_market_denomination_token_rename() {
576        // API field is "denomationToken" (typo in Polymarket API)
577        let json = r#"{
578            "id": "1",
579            "conditionId": "0xcond",
580            "description": "Test",
581            "question": "Test?",
582            "marketMakerAddress": "0xaddr",
583            "denomationToken": "USDC"
584        }"#;
585        let market: Market = serde_json::from_str(json).unwrap();
586        assert_eq!(market.denomination_token.as_deref(), Some("USDC"));
587    }
588
589    #[test]
590    fn test_market_rewards_as_map() {
591        let json = r#"{
592            "id": "1",
593            "conditionId": "0xcond",
594            "description": "Test",
595            "question": "Test?",
596            "marketMakerAddress": "0xaddr",
597            "rewards": {"min_size": "100", "max_spread": "0.05"}
598        }"#;
599        let market: Market = serde_json::from_str(json).unwrap();
600        assert!(market.rewards.is_some());
601        let rewards = market.rewards.unwrap();
602        assert_eq!(rewards["min_size"], "100");
603    }
604
605    #[test]
606    fn test_market_null_rewards() {
607        let json = r#"{
608            "id": "1",
609            "conditionId": "0xcond",
610            "description": "Test",
611            "question": "Test?",
612            "marketMakerAddress": "0xaddr",
613            "rewards": null
614        }"#;
615        let market: Market = serde_json::from_str(json).unwrap();
616        assert!(market.rewards.is_none());
617    }
618
619    // ── Event ───────────────────────────────────────────────────
620
621    #[test]
622    fn test_event_minimal() {
623        let json = r#"{"id": "evt-1"}"#;
624        let event: Event = serde_json::from_str(json).unwrap();
625        assert_eq!(event.id, "evt-1");
626        assert!(event.markets.is_empty()); // #[serde(default)]
627        assert!(event.tags.is_empty());
628        assert!(event.series.is_empty());
629        assert!(event.sub_events.is_empty());
630    }
631
632    #[test]
633    fn test_event_with_nested_markets() {
634        let json = r#"{
635            "id": "evt-1",
636            "title": "2025 Election",
637            "markets": [
638                {
639                    "id": "mkt-1",
640                    "conditionId": "0xabc",
641                    "description": "Who wins?",
642                    "question": "Who wins the election?",
643                    "marketMakerAddress": "0xaddr"
644                }
645            ]
646        }"#;
647        let event: Event = serde_json::from_str(json).unwrap();
648        assert_eq!(event.markets.len(), 1);
649        assert_eq!(event.markets[0].id, "mkt-1");
650    }
651
652    #[test]
653    fn test_event_volume_24h_rename() {
654        // Events use "volume24h" not "volume24hr"
655        let json = r#"{
656            "id": "evt-1",
657            "volume24h": 5000.0
658        }"#;
659        let event: Event = serde_json::from_str(json).unwrap();
660        assert_eq!(event.volume_24hr, Some(5000.0));
661    }
662
663    #[test]
664    fn test_event_enable_neg_risk_typo_rename() {
665        // API has typo: "enalbeNegRisk"
666        let json = r#"{
667            "id": "evt-1",
668            "enalbeNegRisk": true
669        }"#;
670        let event: Event = serde_json::from_str(json).unwrap();
671        assert_eq!(event.enable_neg_risk, Some(true));
672    }
673
674    // ── SeriesInfo ──────────────────────────────────────────────
675
676    #[test]
677    fn test_series_info() {
678        let json = r#"{"id": "s1", "slug": "nfl-2025", "title": "NFL 2025"}"#;
679        let si: SeriesInfo = serde_json::from_str(json).unwrap();
680        assert_eq!(si.slug, "nfl-2025");
681        assert_eq!(si.title, "NFL 2025");
682    }
683
684    // ── SeriesData ──────────────────────────────────────────────
685
686    #[test]
687    fn test_series_data_minimal() {
688        let json = r#"{
689            "id": "s1",
690            "slug": "nfl",
691            "title": "NFL",
692            "active": true,
693            "closed": false,
694            "archived": false
695        }"#;
696        let sd: SeriesData = serde_json::from_str(json).unwrap();
697        assert!(sd.active);
698        assert!(!sd.closed);
699        assert!(sd.events.is_empty()); // #[serde(default)]
700        assert!(sd.tags.is_empty());
701    }
702
703    // ── SportMetadata ───────────────────────────────────────────
704
705    #[test]
706    fn test_sport_metadata() {
707        let json = r#"{
708            "id": 1,
709            "sport": "Basketball",
710            "image": "https://example.com/nba.png",
711            "createdAt": "2024-01-01T00:00:00Z"
712        }"#;
713        let sm: SportMetadata = serde_json::from_str(json).unwrap();
714        assert_eq!(sm.id, 1);
715        assert_eq!(sm.sport, "Basketball");
716    }
717
718    // ── Team ────────────────────────────────────────────────────
719
720    #[test]
721    fn test_team() {
722        let json = r#"{
723            "id": 42,
724            "name": "Lakers",
725            "league": "NBA",
726            "abbreviation": "LAL",
727            "createdAt": "2024-01-01T00:00:00Z",
728            "updatedAt": "2024-06-15T12:00:00Z"
729        }"#;
730        let team: Team = serde_json::from_str(json).unwrap();
731        assert_eq!(team.id, 42);
732        assert_eq!(team.name.as_deref(), Some("Lakers"));
733        assert!(team.created_at.is_some());
734    }
735
736    // ── Comment ─────────────────────────────────────────────────
737
738    #[test]
739    fn test_comment_deserialization() {
740        let json = r#"{
741            "id": "c1",
742            "body": "I think this market will resolve yes.",
743            "createdAt": "2024-06-01T10:00:00Z",
744            "updatedAt": "2024-06-01T10:00:00Z",
745            "deletedAt": null,
746            "user": {"id": "u1", "name": "trader1", "avatar": null},
747            "marketId": "mkt-1",
748            "eventId": null,
749            "seriesId": null,
750            "parentId": null,
751            "reactions": [],
752            "positions": [
753                {"tokenId": "t1", "outcome": "Yes", "shares": "100.5"}
754            ],
755            "likeCount": 5,
756            "dislikeCount": 1,
757            "replyCount": 3
758        }"#;
759        let comment: Comment = serde_json::from_str(json).unwrap();
760        assert_eq!(comment.id, "c1");
761        assert_eq!(comment.user.name, "trader1");
762        assert_eq!(comment.like_count, 5);
763        assert_eq!(comment.positions.len(), 1);
764        assert_eq!(comment.positions[0].shares, "100.5");
765        assert!(comment.deleted_at.is_none());
766    }
767
768    // ── UserResponse ────────────────────────────────────────────
769
770    #[test]
771    fn test_user_response() {
772        let json = r#"{
773            "proxyWallet": "0xproxy",
774            "address": "0xsigner",
775            "id": "u1",
776            "name": "polytrader"
777        }"#;
778        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
779        assert_eq!(user.proxy.as_deref(), Some("0xproxy"));
780        assert_eq!(user.name.as_deref(), Some("polytrader"));
781    }
782
783    #[test]
784    fn test_user_response_all_null() {
785        let json = r#"{}"#;
786        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
787        assert!(user.proxy.is_none());
788        assert!(user.address.is_none());
789        assert!(user.id.is_none());
790        assert!(user.name.is_none());
791    }
792
793    // ── Cursor / PaginatedResponse ──────────────────────────────
794
795    #[test]
796    fn test_cursor_with_next() {
797        let json = r#"{"nextCursor": "abc123"}"#;
798        let cursor: Cursor = serde_json::from_str(json).unwrap();
799        assert_eq!(cursor.next_cursor.as_deref(), Some("abc123"));
800    }
801
802    #[test]
803    fn test_cursor_without_next() {
804        let json = r#"{"nextCursor": null}"#;
805        let cursor: Cursor = serde_json::from_str(json).unwrap();
806        assert!(cursor.next_cursor.is_none());
807    }
808
809    #[test]
810    fn test_paginated_response() {
811        let json = r#"{
812            "data": [{"tokenId": "t1", "outcome": "Yes"}],
813            "nextCursor": "page2"
814        }"#;
815        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
816        assert_eq!(resp.data.len(), 1);
817        assert_eq!(resp.next_cursor.as_deref(), Some("page2"));
818    }
819
820    #[test]
821    fn test_paginated_response_empty() {
822        let json = r#"{"data": [], "nextCursor": null}"#;
823        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
824        assert!(resp.data.is_empty());
825        assert!(resp.next_cursor.is_none());
826    }
827
828    // ── Serialization round-trip ────────────────────────────────
829
830    #[test]
831    fn test_market_token_roundtrip() {
832        let token = MarketToken {
833            token_id: "123".into(),
834            outcome: "Yes".into(),
835            price: Some("0.75".into()),
836            winner: Some(true),
837        };
838        let json = serde_json::to_string(&token).unwrap();
839        let back: MarketToken = serde_json::from_str(&json).unwrap();
840        assert_eq!(token, back);
841    }
842
843    #[test]
844    fn test_tag_roundtrip() {
845        let tag = Tag {
846            id: "1".into(),
847            slug: "test".into(),
848            label: "Test".into(),
849            force_show: None,
850            published_at: None,
851            created_by: None,
852            updated_by: None,
853            created_at: None,
854            updated_at: None,
855            force_hide: None,
856            is_carousel: None,
857        };
858        let json = serde_json::to_string(&tag).unwrap();
859        let back: Tag = serde_json::from_str(&json).unwrap();
860        assert_eq!(tag, back);
861    }
862}