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    #[serde(rename = "questionID")]
14    pub question_id: Option<String>,
15    pub slug: Option<String>,
16    #[serde(default)]
17    pub tokens: Vec<MarketToken>,
18    #[cfg_attr(feature = "specta", specta(type = Option<HashMap<String, String>>))]
19    pub rewards: Option<HashMap<String, serde_json::Value>>,
20    pub minimum_order_size: Option<String>,
21    pub minimum_tick_size: Option<String>,
22    pub description: String,
23    pub category: Option<String>,
24    pub end_date_iso: Option<String>,
25    pub start_date_iso: Option<String>,
26    pub question: String,
27    pub min_incentive_size: Option<String>,
28    pub max_incentive_spread: Option<String>,
29    #[serde(rename = "submitted_by")]
30    pub submitted_by: Option<String>,
31    #[serde(rename = "volume24hr")] // lowercase 'hr' to match API
32    pub volume_24hr: Option<f64>,
33    #[serde(rename = "volume1wk")] // lowercase 'wk' to match API
34    pub volume_1wk: Option<f64>,
35    #[serde(rename = "volume1mo")] // lowercase 'mo' to match API
36    pub volume_1mo: Option<f64>,
37    #[serde(rename = "volume1yr")] // lowercase 'yr' to match API
38    pub volume_1yr: Option<f64>,
39    pub liquidity: Option<String>,
40    #[serde(default)]
41    pub tags: Vec<Tag>,
42    pub neg_risk: Option<bool>,
43    pub neg_risk_market_id: Option<String>,
44    pub neg_risk_request_id: Option<String>,
45    // Use i64 instead of u64 to prevent sentinel value
46    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
47    pub comment_count: Option<i64>,
48    pub twitter_card_image: Option<String>,
49    pub resolution_source: Option<String>,
50    pub amm_type: Option<String>,
51    pub sponsor_name: Option<String>,
52    pub sponsor_image: Option<String>,
53    pub x_axis_value: Option<String>,
54    pub y_axis_value: Option<String>,
55    #[serde(rename = "denomationToken")]
56    pub denomination_token: Option<String>,
57    pub fee: Option<String>,
58    pub image: Option<String>,
59    pub icon: Option<String>,
60    pub lower_bound: Option<String>,
61    pub upper_bound: Option<String>,
62    pub outcomes: Option<String>,
63    pub outcome_prices: Option<String>,
64    pub volume: Option<String>,
65    pub active: Option<bool>,
66    pub market_type: Option<String>,
67    pub format_type: Option<String>,
68    pub lower_bound_date: Option<String>,
69    pub upper_bound_date: Option<String>,
70    pub closed: Option<bool>,
71    pub market_maker_address: String,
72    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
73    pub created_by: Option<i64>,
74    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
75    pub updated_by: Option<i64>,
76    pub created_at: Option<String>,
77    pub updated_at: Option<String>,
78    pub closed_time: Option<String>,
79    pub wide_format: Option<bool>,
80    pub new: Option<bool>,
81    pub mailchimp_tag: Option<String>,
82    pub featured: Option<bool>,
83    pub archived: Option<bool>,
84    pub resolved_by: Option<String>,
85    pub restricted: Option<bool>,
86    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
87    pub market_group: Option<i64>,
88    pub group_item_title: Option<String>,
89    pub group_item_threshold: Option<String>,
90    pub uma_end_date: Option<String>,
91    pub uma_resolution_status: Option<String>,
92    pub uma_end_date_iso: Option<String>,
93    pub uma_resolution_statuses: Option<String>,
94    pub enable_order_book: Option<bool>,
95    pub order_price_min_tick_size: Option<f64>,
96    pub order_min_size: Option<f64>,
97    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
98    pub curation_order: Option<i64>,
99    pub volume_num: Option<f64>,
100    pub liquidity_num: Option<f64>,
101    pub has_reviewed_dates: Option<bool>,
102    pub ready_for_cron: Option<bool>,
103    pub comments_enabled: Option<bool>,
104    pub game_start_time: Option<String>,
105    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
106    pub seconds_delay: Option<i64>,
107    pub clob_token_ids: Option<String>,
108    pub disqus_thread: Option<String>,
109    pub short_outcomes: Option<String>,
110    pub team_aid: Option<String>,
111    pub team_bid: Option<String>,
112    pub uma_bond: Option<String>,
113    pub uma_reward: Option<String>,
114    pub fpmm_live: Option<bool>,
115    #[serde(rename = "volume24hrAmm")] // Match API field names
116    pub volume_24hr_amm: Option<f64>,
117    #[serde(rename = "volume1wkAmm")]
118    pub volume_1wk_amm: Option<f64>,
119    #[serde(rename = "volume1moAmm")]
120    pub volume_1mo_amm: Option<f64>,
121    #[serde(rename = "volume1yrAmm")]
122    pub volume_1yr_amm: Option<f64>,
123    #[serde(rename = "volume24hrClob")]
124    pub volume_24hr_clob: Option<f64>,
125    #[serde(rename = "volume1wkClob")]
126    pub volume_1wk_clob: Option<f64>,
127    #[serde(rename = "volume1moClob")]
128    pub volume_1mo_clob: Option<f64>,
129    #[serde(rename = "volume1yrClob")]
130    pub volume_1yr_clob: Option<f64>,
131    pub volume_amm: Option<f64>,
132    pub volume_clob: Option<f64>,
133    pub liquidity_amm: Option<f64>,
134    pub liquidity_clob: Option<f64>,
135    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
136    pub maker_base_fee: Option<i64>,
137    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
138    pub taker_base_fee: Option<i64>,
139    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
140    pub custom_liveness: Option<i64>,
141    pub accepting_orders: Option<bool>,
142    pub notifications_enabled: Option<bool>,
143    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
144    pub score: Option<i64>,
145    pub creator: Option<String>,
146    pub ready: Option<bool>,
147    pub funded: Option<bool>,
148    pub past_slugs: Option<String>,
149    pub ready_timestamp: Option<String>,
150    pub funded_timestamp: Option<String>,
151    pub accepting_orders_timestamp: Option<String>,
152    pub competitive: Option<f64>,
153    pub rewards_min_size: Option<f64>,
154    pub rewards_max_spread: Option<f64>,
155    pub spread: Option<f64>,
156    pub automatically_resolved: Option<bool>,
157    pub automatically_active: Option<bool>,
158    pub one_day_price_change: Option<f64>,
159    pub one_hour_price_change: Option<f64>,
160    pub one_week_price_change: Option<f64>,
161    pub one_month_price_change: Option<f64>,
162    pub one_year_price_change: Option<f64>,
163    pub last_trade_price: Option<f64>,
164    pub best_bid: Option<f64>,
165    pub best_ask: Option<f64>,
166    pub clear_book_on_start: Option<bool>,
167    pub chart_color: Option<String>,
168    pub series_color: Option<String>,
169    pub show_gmp_series: Option<bool>,
170    pub show_gmp_outcome: Option<bool>,
171    pub manual_activation: Option<bool>,
172    pub neg_risk_other: Option<bool>,
173    pub game_id: Option<String>,
174    pub group_item_range: Option<String>,
175    pub sports_market_type: Option<String>,
176    pub line: Option<f64>,
177    pub pending_deployment: Option<bool>,
178    pub deploying: Option<bool>,
179    pub deploying_timestamp: Option<String>,
180    pub schedule_deployment_timestamp: Option<String>,
181    pub rfq_enabled: Option<bool>,
182    pub event_start_time: Option<String>,
183}
184
185/// Market token (outcome)
186#[cfg_attr(feature = "specta", derive(specta::Type))]
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
188#[serde(rename_all = "camelCase")]
189pub struct MarketToken {
190    pub token_id: String,
191    pub outcome: String,
192    pub price: Option<String>,
193    pub winner: Option<bool>,
194}
195
196#[cfg_attr(feature = "specta", derive(specta::Type))]
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct Event {
200    pub id: String,
201    pub ticker: Option<String>,
202    pub slug: Option<String>,
203    pub title: Option<String>,
204    pub subtitle: Option<String>,
205    pub description: Option<String>,
206    pub resolution_source: Option<String>,
207    pub start_date: Option<String>,
208    pub creation_date: Option<String>,
209    pub end_date: Option<String>,
210    pub image: Option<String>,
211    pub icon: Option<String>,
212    pub start_date_iso: Option<String>,
213    pub end_date_iso: Option<String>,
214    pub active: Option<bool>,
215    pub closed: Option<bool>,
216    pub archived: Option<bool>,
217    pub new: Option<bool>,
218    pub featured: Option<bool>,
219    pub restricted: Option<bool>,
220    pub liquidity: Option<f64>,
221    pub open_interest: Option<f64>,
222    pub sort_by: Option<String>,
223    pub category: Option<String>,
224    pub subcategory: Option<String>,
225    pub is_template: Option<bool>,
226    pub template_variables: Option<String>,
227    #[serde(rename = "published_at")]
228    pub published_at: Option<String>,
229    pub created_by: Option<String>,
230    pub updated_by: Option<String>,
231    pub created_at: Option<String>,
232    pub updated_at: Option<String>,
233    pub comments_enabled: Option<bool>,
234    pub competitive: Option<f64>,
235    #[serde(rename = "volume24hr")]
236    pub volume_24hr: Option<f64>,
237    #[serde(rename = "volume1wk")]
238    pub volume_1wk: Option<f64>,
239    #[serde(rename = "volume1mo")]
240    pub volume_1mo: Option<f64>,
241    #[serde(rename = "volume1yr")]
242    pub volume_1yr: Option<f64>,
243    pub featured_image: Option<String>,
244    pub disqus_thread: Option<String>,
245    pub parent_event: Option<String>,
246    pub enable_order_book: Option<bool>,
247    pub liquidity_amm: Option<f64>,
248    pub liquidity_clob: Option<f64>,
249    pub neg_risk: Option<bool>,
250    pub neg_risk_market_id: Option<String>,
251    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
252    pub neg_risk_fee_bips: Option<i64>,
253    #[serde(default)]
254    pub sub_events: Vec<String>,
255    #[serde(default)]
256    pub markets: Vec<Market>,
257    #[serde(default)]
258    pub tags: Vec<Tag>,
259    #[serde(default)]
260    pub series: Vec<SeriesInfo>,
261    pub cyom: Option<bool>,
262    pub closed_time: Option<String>,
263    pub show_all_outcomes: Option<bool>,
264    pub show_market_images: Option<bool>,
265    pub automatically_resolved: Option<bool>,
266    #[serde(rename = "enableNegRisk")]
267    pub enable_neg_risk: Option<bool>,
268    pub automatically_active: Option<bool>,
269    pub event_date: Option<String>,
270    pub start_time: Option<String>,
271    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
272    pub event_week: Option<i64>,
273    pub series_slug: Option<String>,
274    pub score: Option<String>,
275    pub elapsed: Option<String>,
276    pub period: Option<String>,
277    pub live: Option<bool>,
278    pub ended: Option<bool>,
279    pub finished_timestamp: Option<String>,
280    pub gmp_chart_mode: Option<String>,
281    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
282    pub tweet_count: Option<i64>,
283    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
284    pub featured_order: Option<i64>,
285    pub estimate_value: Option<bool>,
286    pub cant_estimate: Option<bool>,
287    pub spreads_main_line: Option<f64>,
288    pub totals_main_line: Option<f64>,
289    pub carousel_map: Option<String>,
290    pub pending_deployment: Option<bool>,
291    pub deploying: Option<bool>,
292    pub deploying_timestamp: Option<String>,
293    pub schedule_deployment_timestamp: Option<String>,
294    pub game_status: Option<String>,
295}
296
297/// Series information within an event
298#[cfg_attr(feature = "specta", derive(specta::Type))]
299#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct SeriesInfo {
302    pub id: String,
303    pub slug: String,
304    pub title: String,
305    pub ticker: Option<String>,
306    pub series_type: Option<String>,
307    pub recurrence: Option<String>,
308    pub image: Option<String>,
309    pub icon: Option<String>,
310    pub layout: Option<String>,
311    pub active: Option<bool>,
312    pub closed: Option<bool>,
313    pub archived: Option<bool>,
314    pub new: Option<bool>,
315    pub featured: Option<bool>,
316    pub restricted: Option<bool>,
317    pub published_at: Option<String>,
318    pub created_by: Option<String>,
319    pub updated_by: Option<String>,
320    pub created_at: Option<String>,
321    pub updated_at: Option<String>,
322    pub comments_enabled: Option<bool>,
323    pub competitive: Option<String>,
324    #[serde(rename = "volume24hr")]
325    pub volume_24hr: Option<f64>,
326    pub start_date: Option<String>,
327    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
328    pub comment_count: Option<i64>,
329    pub requires_translation: Option<bool>,
330}
331
332/// Series data (tournament/season grouping)
333#[cfg_attr(feature = "specta", derive(specta::Type))]
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct SeriesData {
337    pub id: String,
338    pub slug: String,
339    pub title: String,
340    pub description: Option<String>,
341    pub image: Option<String>,
342    pub icon: Option<String>,
343    pub active: bool,
344    pub closed: bool,
345    pub archived: bool,
346    #[serde(default)]
347    pub tags: Vec<String>,
348    pub volume: Option<f64>,
349    pub liquidity: Option<f64>,
350    #[serde(default)]
351    pub events: Vec<Event>,
352    pub competitive: Option<String>,
353}
354
355/// Tag for categorizing markets/events
356#[cfg_attr(feature = "specta", derive(specta::Type))]
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
358#[serde(rename_all = "camelCase")]
359pub struct Tag {
360    pub id: String,
361    pub slug: String,
362    pub label: String,
363    pub force_show: Option<bool>,
364    pub published_at: Option<String>,
365    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
366    pub created_by: Option<u64>,
367    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
368    pub updated_by: Option<u64>,
369    pub created_at: Option<String>,
370    pub updated_at: Option<String>,
371    pub force_hide: Option<bool>,
372    pub is_carousel: Option<bool>,
373}
374
375/// Sports metadata
376#[cfg_attr(feature = "specta", derive(specta::Type))]
377#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct SportMetadata {
380    #[cfg_attr(feature = "specta", specta(type = f64))]
381    pub id: u64,
382    pub sport: String,
383    pub image: Option<String>,
384    pub resolution: Option<String>,
385    pub ordering: Option<String>,
386    pub tags: Option<String>,
387    pub series: Option<String>,
388    pub created_at: Option<String>,
389}
390
391/// Sports team
392#[cfg_attr(feature = "specta", derive(specta::Type))]
393#[derive(Debug, Clone, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct Team {
396    #[cfg_attr(feature = "specta", specta(type = f64))]
397    pub id: i64,
398    pub name: Option<String>,
399    pub league: Option<String>,
400    pub record: Option<String>,
401    pub logo: Option<String>,
402    pub abbreviation: Option<String>,
403    pub alias: Option<String>,
404    pub created_at: Option<DateTime<Utc>>,
405    pub updated_at: Option<DateTime<Utc>>,
406}
407
408/// Comment on a market/event/series
409#[cfg_attr(feature = "specta", derive(specta::Type))]
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct Comment {
413    pub id: String,
414    pub body: String,
415    pub created_at: DateTime<Utc>,
416    pub updated_at: DateTime<Utc>,
417    pub deleted_at: Option<DateTime<Utc>>,
418    pub user: CommentUser,
419    pub market_id: Option<String>,
420    pub event_id: Option<String>,
421    pub series_id: Option<String>,
422    pub parent_id: Option<String>,
423    #[serde(default)]
424    pub reactions: Vec<CommentReaction>,
425    #[serde(default)]
426    pub positions: Vec<CommentPosition>,
427    #[cfg_attr(feature = "specta", specta(type = f64))]
428    pub like_count: u32,
429    #[cfg_attr(feature = "specta", specta(type = f64))]
430    pub dislike_count: u32,
431    #[cfg_attr(feature = "specta", specta(type = f64))]
432    pub reply_count: u32,
433}
434
435/// User who created a comment
436#[cfg_attr(feature = "specta", derive(specta::Type))]
437#[derive(Debug, Clone, Serialize, Deserialize)]
438#[serde(rename_all = "camelCase")]
439pub struct CommentUser {
440    pub id: String,
441    pub name: String,
442    pub avatar: Option<String>,
443}
444
445/// Reaction to a comment
446#[cfg_attr(feature = "specta", derive(specta::Type))]
447#[derive(Debug, Clone, Serialize, Deserialize)]
448#[serde(rename_all = "camelCase")]
449pub struct CommentReaction {
450    pub user_id: String,
451    pub reaction_type: String,
452}
453
454/// Position held by comment author
455#[cfg_attr(feature = "specta", derive(specta::Type))]
456#[derive(Debug, Clone, Serialize, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct CommentPosition {
459    pub token_id: String,
460    pub outcome: String,
461    pub shares: String,
462}
463
464/// Generic count response (used for tweet count, comment count, etc.)
465#[cfg_attr(feature = "specta", derive(specta::Type))]
466#[derive(Debug, Clone, Serialize, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct CountResponse {
469    #[cfg_attr(feature = "specta", specta(type = f64))]
470    pub count: u64,
471}
472
473/// Pagination cursor for list operations
474#[cfg_attr(feature = "specta", derive(specta::Type))]
475#[derive(Debug, Clone, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477pub struct Cursor {
478    pub next_cursor: Option<String>,
479}
480
481/// Paginated response wrapper
482#[cfg_attr(feature = "specta", derive(specta::Type))]
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(rename_all = "camelCase")]
485pub struct PaginatedResponse<T> {
486    pub data: Vec<T>,
487    pub next_cursor: Option<String>,
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    // ── MarketToken ─────────────────────────────────────────────
495
496    #[test]
497    fn test_market_token_deserialization() {
498        let json = r#"{
499            "tokenId": "71321045679252212594626385532706912750332728571942532289631379312455583992563",
500            "outcome": "Yes",
501            "price": "0.55",
502            "winner": false
503        }"#;
504        let token: MarketToken = serde_json::from_str(json).unwrap();
505        assert_eq!(token.outcome, "Yes");
506        assert_eq!(token.price.as_deref(), Some("0.55"));
507        assert_eq!(token.winner, Some(false));
508    }
509
510    #[test]
511    fn test_market_token_optional_fields() {
512        let json = r#"{"tokenId": "123", "outcome": "No"}"#;
513        let token: MarketToken = serde_json::from_str(json).unwrap();
514        assert!(token.price.is_none());
515        assert!(token.winner.is_none());
516    }
517
518    // ── Tag ─────────────────────────────────────────────────────
519
520    #[test]
521    fn test_tag_deserialization() {
522        let json = r#"{
523            "id": "42",
524            "slug": "politics",
525            "label": "Politics",
526            "forceShow": true,
527            "publishedAt": "2024-01-01T00:00:00Z",
528            "createdBy": 1,
529            "updatedBy": 2,
530            "createdAt": "2024-01-01T00:00:00Z",
531            "updatedAt": "2024-06-01T00:00:00Z",
532            "forceHide": false,
533            "isCarousel": true
534        }"#;
535        let tag: Tag = serde_json::from_str(json).unwrap();
536        assert_eq!(tag.slug, "politics");
537        assert_eq!(tag.force_show, Some(true));
538        assert_eq!(tag.is_carousel, Some(true));
539    }
540
541    #[test]
542    fn test_tag_minimal() {
543        let json = r#"{"id": "1", "slug": "test", "label": "Test"}"#;
544        let tag: Tag = serde_json::from_str(json).unwrap();
545        assert_eq!(tag.label, "Test");
546        assert!(tag.force_show.is_none());
547        assert!(tag.created_by.is_none());
548    }
549
550    // ── Market ──────────────────────────────────────────────────
551
552    #[test]
553    fn test_market_minimal_deserialization() {
554        let json = r#"{
555            "id": "12345",
556            "conditionId": "0xabc",
557            "description": "Will X happen?",
558            "question": "Will X happen by end of 2025?",
559            "marketMakerAddress": "0x1234567890abcdef"
560        }"#;
561        let market: Market = serde_json::from_str(json).unwrap();
562        assert_eq!(market.id, "12345");
563        assert_eq!(market.condition_id, "0xabc");
564        assert!(market.tokens.is_empty()); // #[serde(default)]
565        assert!(market.tags.is_empty()); // #[serde(default)]
566        assert!(market.slug.is_none());
567        assert!(market.volume_24hr.is_none());
568    }
569
570    #[test]
571    fn test_market_with_tokens() {
572        let json = r#"{
573            "id": "1",
574            "conditionId": "0xcond",
575            "description": "Test",
576            "question": "Test?",
577            "marketMakerAddress": "0xaddr",
578            "tokens": [
579                {"tokenId": "t1", "outcome": "Yes", "price": "0.7", "winner": true},
580                {"tokenId": "t2", "outcome": "No", "price": "0.3", "winner": false}
581            ]
582        }"#;
583        let market: Market = serde_json::from_str(json).unwrap();
584        assert_eq!(market.tokens.len(), 2);
585        assert_eq!(market.tokens[0].outcome, "Yes");
586        assert_eq!(market.tokens[1].price.as_deref(), Some("0.3"));
587    }
588
589    #[test]
590    fn test_market_volume_fields() {
591        let json = r#"{
592            "id": "1",
593            "conditionId": "0xcond",
594            "description": "Test",
595            "question": "Test?",
596            "marketMakerAddress": "0xaddr",
597            "volume24hr": 1500.5,
598            "volume1wk": 10000.0,
599            "volume1mo": 50000.0,
600            "volume1yr": 200000.0,
601            "volume24hrAmm": 100.0,
602            "volume1wkClob": 9900.0
603        }"#;
604        let market: Market = serde_json::from_str(json).unwrap();
605        assert_eq!(market.volume_24hr, Some(1500.5));
606        assert_eq!(market.volume_1wk, Some(10000.0));
607        assert_eq!(market.volume_24hr_amm, Some(100.0));
608        assert_eq!(market.volume_1wk_clob, Some(9900.0));
609    }
610
611    #[test]
612    fn test_market_denomination_token_rename() {
613        // API field is "denomationToken" (typo in Polymarket API)
614        let json = r#"{
615            "id": "1",
616            "conditionId": "0xcond",
617            "description": "Test",
618            "question": "Test?",
619            "marketMakerAddress": "0xaddr",
620            "denomationToken": "USDC"
621        }"#;
622        let market: Market = serde_json::from_str(json).unwrap();
623        assert_eq!(market.denomination_token.as_deref(), Some("USDC"));
624    }
625
626    #[test]
627    fn test_market_rewards_as_map() {
628        let json = r#"{
629            "id": "1",
630            "conditionId": "0xcond",
631            "description": "Test",
632            "question": "Test?",
633            "marketMakerAddress": "0xaddr",
634            "rewards": {"min_size": "100", "max_spread": "0.05"}
635        }"#;
636        let market: Market = serde_json::from_str(json).unwrap();
637        assert!(market.rewards.is_some());
638        let rewards = market.rewards.unwrap();
639        assert_eq!(rewards["min_size"], "100");
640    }
641
642    #[test]
643    fn test_market_null_rewards() {
644        let json = r#"{
645            "id": "1",
646            "conditionId": "0xcond",
647            "description": "Test",
648            "question": "Test?",
649            "marketMakerAddress": "0xaddr",
650            "rewards": null
651        }"#;
652        let market: Market = serde_json::from_str(json).unwrap();
653        assert!(market.rewards.is_none());
654    }
655
656    // ── Event ───────────────────────────────────────────────────
657
658    #[test]
659    fn test_event_minimal() {
660        let json = r#"{"id": "evt-1"}"#;
661        let event: Event = serde_json::from_str(json).unwrap();
662        assert_eq!(event.id, "evt-1");
663        assert!(event.markets.is_empty()); // #[serde(default)]
664        assert!(event.tags.is_empty());
665        assert!(event.series.is_empty());
666        assert!(event.sub_events.is_empty());
667    }
668
669    #[test]
670    fn test_event_with_nested_markets() {
671        let json = r#"{
672            "id": "evt-1",
673            "title": "2025 Election",
674            "markets": [
675                {
676                    "id": "mkt-1",
677                    "conditionId": "0xabc",
678                    "description": "Who wins?",
679                    "question": "Who wins the election?",
680                    "marketMakerAddress": "0xaddr"
681                }
682            ]
683        }"#;
684        let event: Event = serde_json::from_str(json).unwrap();
685        assert_eq!(event.markets.len(), 1);
686        assert_eq!(event.markets[0].id, "mkt-1");
687    }
688
689    #[test]
690    fn test_event_volume_24h_rename() {
691        let json = r#"{
692            "id": "evt-1",
693            "volume24hr": 5000.0
694        }"#;
695        let event: Event = serde_json::from_str(json).unwrap();
696        assert_eq!(event.volume_24hr, Some(5000.0));
697    }
698
699    #[test]
700    fn test_event_volume_24h_old_key_ignored() {
701        // The old key "volume24h" should NOT deserialize into volume_24hr
702        let json = r#"{
703            "id": "evt-1",
704            "volume24h": 5000.0
705        }"#;
706        let event: Event = serde_json::from_str(json).unwrap();
707        assert_eq!(event.volume_24hr, None);
708    }
709
710    #[test]
711    fn test_event_enable_neg_risk_rename() {
712        let json = r#"{
713            "id": "evt-1",
714            "enableNegRisk": true
715        }"#;
716        let event: Event = serde_json::from_str(json).unwrap();
717        assert_eq!(event.enable_neg_risk, Some(true));
718    }
719
720    #[test]
721    fn test_event_published_at_snake_case() {
722        let json = r#"{
723            "id": "evt-1",
724            "published_at": "2024-01-01T00:00:00Z"
725        }"#;
726        let event: Event = serde_json::from_str(json).unwrap();
727        assert_eq!(event.published_at.as_deref(), Some("2024-01-01T00:00:00Z"));
728    }
729
730    #[test]
731    fn test_market_question_id_capital() {
732        let json = r#"{
733            "id": "1",
734            "conditionId": "0xcond",
735            "description": "Test",
736            "question": "Test?",
737            "marketMakerAddress": "0xaddr",
738            "questionID": "0xabc123"
739        }"#;
740        let market: Market = serde_json::from_str(json).unwrap();
741        assert_eq!(market.question_id.as_deref(), Some("0xabc123"));
742    }
743
744    #[test]
745    fn test_market_has_reviewed_dates() {
746        let json = r#"{
747            "id": "1",
748            "conditionId": "0xcond",
749            "description": "Test",
750            "question": "Test?",
751            "marketMakerAddress": "0xaddr",
752            "hasReviewedDates": true
753        }"#;
754        let market: Market = serde_json::from_str(json).unwrap();
755        assert_eq!(market.has_reviewed_dates, Some(true));
756    }
757
758    #[test]
759    fn test_market_rewards_max_spread_singular() {
760        let json = r#"{
761            "id": "1",
762            "conditionId": "0xcond",
763            "description": "Test",
764            "question": "Test?",
765            "marketMakerAddress": "0xaddr",
766            "rewardsMaxSpread": 0.05
767        }"#;
768        let market: Market = serde_json::from_str(json).unwrap();
769        assert_eq!(market.rewards_max_spread, Some(0.05));
770    }
771
772    #[test]
773    fn test_market_submitted_by_snake_case() {
774        let json = r#"{
775            "id": "1",
776            "conditionId": "0xcond",
777            "description": "Test",
778            "question": "Test?",
779            "marketMakerAddress": "0xaddr",
780            "submitted_by": "0xdeadbeef"
781        }"#;
782        let market: Market = serde_json::from_str(json).unwrap();
783        assert_eq!(market.submitted_by.as_deref(), Some("0xdeadbeef"));
784    }
785
786    // ── SeriesInfo ──────────────────────────────────────────────
787
788    #[test]
789    fn test_series_info_minimal() {
790        let json = r#"{"id": "s1", "slug": "nfl-2025", "title": "NFL 2025"}"#;
791        let si: SeriesInfo = serde_json::from_str(json).unwrap();
792        assert_eq!(si.slug, "nfl-2025");
793        assert_eq!(si.title, "NFL 2025");
794        assert!(si.ticker.is_none());
795        assert!(si.active.is_none());
796    }
797
798    #[test]
799    fn test_series_info_full() {
800        let json = r#"{
801            "id": "2",
802            "ticker": "nba",
803            "slug": "nba",
804            "title": "NBA",
805            "seriesType": "single",
806            "recurrence": "daily",
807            "image": "https://example.com/nba.png",
808            "icon": "https://example.com/nba-icon.png",
809            "layout": "default",
810            "active": true,
811            "closed": false,
812            "archived": false,
813            "new": false,
814            "featured": false,
815            "restricted": true,
816            "publishedAt": "2023-01-30T17:13:39Z",
817            "createdBy": "15",
818            "updatedBy": "15",
819            "createdAt": "2022-10-13T00:36:01Z",
820            "updatedAt": "2026-03-04T12:03:42Z",
821            "commentsEnabled": false,
822            "competitive": "0",
823            "volume24hr": 11.07,
824            "startDate": "2021-01-01T17:00:00Z",
825            "commentCount": 6274,
826            "requiresTranslation": false
827        }"#;
828        let si: SeriesInfo = serde_json::from_str(json).unwrap();
829        assert_eq!(si.ticker.as_deref(), Some("nba"));
830        assert_eq!(si.series_type.as_deref(), Some("single"));
831        assert_eq!(si.active, Some(true));
832        assert_eq!(si.closed, Some(false));
833        assert_eq!(si.volume_24hr, Some(11.07));
834        assert_eq!(si.comment_count, Some(6274));
835        assert_eq!(si.requires_translation, Some(false));
836    }
837
838    // ── Negative tests: old/wrong field names are rejected ─────
839
840    #[test]
841    fn test_market_old_question_id_ignored() {
842        // camelCase "questionId" should NOT match — API uses "questionID"
843        let json = r#"{
844            "id": "1",
845            "conditionId": "0xcond",
846            "description": "Test",
847            "question": "Test?",
848            "marketMakerAddress": "0xaddr",
849            "questionId": "0xwrong"
850        }"#;
851        let market: Market = serde_json::from_str(json).unwrap();
852        assert!(market.question_id.is_none());
853    }
854
855    #[test]
856    fn test_market_old_has_review_dates_ignored() {
857        // "hasReviewDates" should NOT match — API uses "hasReviewedDates"
858        let json = r#"{
859            "id": "1",
860            "conditionId": "0xcond",
861            "description": "Test",
862            "question": "Test?",
863            "marketMakerAddress": "0xaddr",
864            "hasReviewDates": true
865        }"#;
866        let market: Market = serde_json::from_str(json).unwrap();
867        assert!(market.has_reviewed_dates.is_none());
868    }
869
870    #[test]
871    fn test_market_old_rewards_max_spreads_ignored() {
872        // "rewardsMaxSpreads" (plural) should NOT match — API uses "rewardsMaxSpread"
873        let json = r#"{
874            "id": "1",
875            "conditionId": "0xcond",
876            "description": "Test",
877            "question": "Test?",
878            "marketMakerAddress": "0xaddr",
879            "rewardsMaxSpreads": 0.05
880        }"#;
881        let market: Market = serde_json::from_str(json).unwrap();
882        assert!(market.rewards_max_spread.is_none());
883    }
884
885    #[test]
886    fn test_event_old_enalbe_neg_risk_ignored() {
887        // Old typo "enalbeNegRisk" should NOT match after fix
888        let json = r#"{
889            "id": "evt-1",
890            "enalbeNegRisk": true
891        }"#;
892        let event: Event = serde_json::from_str(json).unwrap();
893        assert!(event.enable_neg_risk.is_none());
894    }
895
896    #[test]
897    fn test_event_camel_published_at_ignored() {
898        // camelCase "publishedAt" should NOT match — Event API uses "published_at"
899        let json = r#"{
900            "id": "evt-1",
901            "publishedAt": "2024-01-01T00:00:00Z"
902        }"#;
903        let event: Event = serde_json::from_str(json).unwrap();
904        assert!(event.published_at.is_none());
905    }
906
907    #[test]
908    fn test_market_camel_submitted_by_ignored() {
909        // camelCase "submittedBy" should NOT match — API uses "submitted_by"
910        let json = r#"{
911            "id": "1",
912            "conditionId": "0xcond",
913            "description": "Test",
914            "question": "Test?",
915            "marketMakerAddress": "0xaddr",
916            "submittedBy": "0xwrong"
917        }"#;
918        let market: Market = serde_json::from_str(json).unwrap();
919        assert!(market.submitted_by.is_none());
920    }
921
922    // ── SeriesData ──────────────────────────────────────────────
923
924    #[test]
925    fn test_series_data_minimal() {
926        let json = r#"{
927            "id": "s1",
928            "slug": "nfl",
929            "title": "NFL",
930            "active": true,
931            "closed": false,
932            "archived": false
933        }"#;
934        let sd: SeriesData = serde_json::from_str(json).unwrap();
935        assert!(sd.active);
936        assert!(!sd.closed);
937        assert!(sd.events.is_empty()); // #[serde(default)]
938        assert!(sd.tags.is_empty());
939    }
940
941    // ── SportMetadata ───────────────────────────────────────────
942
943    #[test]
944    fn test_sport_metadata() {
945        let json = r#"{
946            "id": 1,
947            "sport": "Basketball",
948            "image": "https://example.com/nba.png",
949            "createdAt": "2024-01-01T00:00:00Z"
950        }"#;
951        let sm: SportMetadata = serde_json::from_str(json).unwrap();
952        assert_eq!(sm.id, 1);
953        assert_eq!(sm.sport, "Basketball");
954    }
955
956    // ── Team ────────────────────────────────────────────────────
957
958    #[test]
959    fn test_team() {
960        let json = r#"{
961            "id": 42,
962            "name": "Lakers",
963            "league": "NBA",
964            "abbreviation": "LAL",
965            "createdAt": "2024-01-01T00:00:00Z",
966            "updatedAt": "2024-06-15T12:00:00Z"
967        }"#;
968        let team: Team = serde_json::from_str(json).unwrap();
969        assert_eq!(team.id, 42);
970        assert_eq!(team.name.as_deref(), Some("Lakers"));
971        assert!(team.created_at.is_some());
972    }
973
974    // ── Comment ─────────────────────────────────────────────────
975
976    #[test]
977    fn test_comment_deserialization() {
978        let json = r#"{
979            "id": "c1",
980            "body": "I think this market will resolve yes.",
981            "createdAt": "2024-06-01T10:00:00Z",
982            "updatedAt": "2024-06-01T10:00:00Z",
983            "deletedAt": null,
984            "user": {"id": "u1", "name": "trader1", "avatar": null},
985            "marketId": "mkt-1",
986            "eventId": null,
987            "seriesId": null,
988            "parentId": null,
989            "reactions": [],
990            "positions": [
991                {"tokenId": "t1", "outcome": "Yes", "shares": "100.5"}
992            ],
993            "likeCount": 5,
994            "dislikeCount": 1,
995            "replyCount": 3
996        }"#;
997        let comment: Comment = serde_json::from_str(json).unwrap();
998        assert_eq!(comment.id, "c1");
999        assert_eq!(comment.user.name, "trader1");
1000        assert_eq!(comment.like_count, 5);
1001        assert_eq!(comment.positions.len(), 1);
1002        assert_eq!(comment.positions[0].shares, "100.5");
1003        assert!(comment.deleted_at.is_none());
1004    }
1005
1006    // ── UserResponse ────────────────────────────────────────────
1007
1008    #[test]
1009    fn test_user_response() {
1010        let json = r#"{
1011            "proxyWallet": "0xproxy",
1012            "address": "0xsigner",
1013            "id": "u1",
1014            "name": "polytrader"
1015        }"#;
1016        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1017        assert_eq!(user.proxy.as_deref(), Some("0xproxy"));
1018        assert_eq!(user.name.as_deref(), Some("polytrader"));
1019    }
1020
1021    #[test]
1022    fn test_user_response_all_null() {
1023        let json = r#"{}"#;
1024        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1025        assert!(user.proxy.is_none());
1026        assert!(user.address.is_none());
1027        assert!(user.id.is_none());
1028        assert!(user.name.is_none());
1029        assert!(user.created_at.is_none());
1030        assert!(user.profile_image.is_none());
1031        assert!(user.display_username_public.is_none());
1032        assert!(user.bio.is_none());
1033        assert!(user.pseudonym.is_none());
1034        assert!(user.x_username.is_none());
1035        assert!(user.verified_badge.is_none());
1036        assert!(user.users.is_empty());
1037    }
1038
1039    #[test]
1040    fn test_user_response_full_profile() {
1041        let json = r#"{
1042            "proxyWallet": "0xproxy",
1043            "address": "0xsigner",
1044            "id": "u1",
1045            "name": "polytrader",
1046            "createdAt": "2024-01-15T10:00:00Z",
1047            "profileImage": "https://example.com/avatar.png",
1048            "displayUsernamePublic": true,
1049            "bio": "DeFi enthusiast",
1050            "pseudonym": "poly_anon",
1051            "xUsername": "polytrader_x",
1052            "verifiedBadge": true,
1053            "users": [
1054                {"id": "uid-1", "creator": true, "mod": false},
1055                {"id": "uid-2", "creator": false, "mod": true}
1056            ]
1057        }"#;
1058        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1059        assert_eq!(user.proxy.as_deref(), Some("0xproxy"));
1060        assert_eq!(user.name.as_deref(), Some("polytrader"));
1061        assert_eq!(user.created_at.as_deref(), Some("2024-01-15T10:00:00Z"));
1062        assert_eq!(
1063            user.profile_image.as_deref(),
1064            Some("https://example.com/avatar.png")
1065        );
1066        assert_eq!(user.display_username_public, Some(true));
1067        assert_eq!(user.bio.as_deref(), Some("DeFi enthusiast"));
1068        assert_eq!(user.pseudonym.as_deref(), Some("poly_anon"));
1069        assert_eq!(user.x_username.as_deref(), Some("polytrader_x"));
1070        assert_eq!(user.verified_badge, Some(true));
1071        assert_eq!(user.users.len(), 2);
1072        assert!(user.users[0].creator);
1073        assert!(!user.users[0].moderator);
1074        assert!(!user.users[1].creator);
1075        assert!(user.users[1].moderator);
1076    }
1077
1078    #[test]
1079    fn test_user_info_deserialization() {
1080        let json = r#"{"id": "uid-1", "creator": true, "mod": false}"#;
1081        let info: crate::api::user::UserInfo = serde_json::from_str(json).unwrap();
1082        assert_eq!(info.id.as_deref(), Some("uid-1"));
1083        assert!(info.creator);
1084        assert!(!info.moderator);
1085    }
1086
1087    #[test]
1088    fn test_user_info_defaults() {
1089        let json = r#"{}"#;
1090        let info: crate::api::user::UserInfo = serde_json::from_str(json).unwrap();
1091        assert!(info.id.is_none());
1092        assert!(!info.creator);
1093        assert!(!info.moderator);
1094    }
1095
1096    // ── CountResponse ────────────────────────────────────────────
1097
1098    #[test]
1099    fn test_count_response() {
1100        let json = r#"{"count": 42}"#;
1101        let resp: CountResponse = serde_json::from_str(json).unwrap();
1102        assert_eq!(resp.count, 42);
1103    }
1104
1105    // ── Cursor / PaginatedResponse ──────────────────────────────
1106
1107    #[test]
1108    fn test_cursor_with_next() {
1109        let json = r#"{"nextCursor": "abc123"}"#;
1110        let cursor: Cursor = serde_json::from_str(json).unwrap();
1111        assert_eq!(cursor.next_cursor.as_deref(), Some("abc123"));
1112    }
1113
1114    #[test]
1115    fn test_cursor_without_next() {
1116        let json = r#"{"nextCursor": null}"#;
1117        let cursor: Cursor = serde_json::from_str(json).unwrap();
1118        assert!(cursor.next_cursor.is_none());
1119    }
1120
1121    #[test]
1122    fn test_paginated_response() {
1123        let json = r#"{
1124            "data": [{"tokenId": "t1", "outcome": "Yes"}],
1125            "nextCursor": "page2"
1126        }"#;
1127        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
1128        assert_eq!(resp.data.len(), 1);
1129        assert_eq!(resp.next_cursor.as_deref(), Some("page2"));
1130    }
1131
1132    #[test]
1133    fn test_paginated_response_empty() {
1134        let json = r#"{"data": [], "nextCursor": null}"#;
1135        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
1136        assert!(resp.data.is_empty());
1137        assert!(resp.next_cursor.is_none());
1138    }
1139
1140    // ── Serialization round-trip ────────────────────────────────
1141
1142    #[test]
1143    fn test_market_token_roundtrip() {
1144        let token = MarketToken {
1145            token_id: "123".into(),
1146            outcome: "Yes".into(),
1147            price: Some("0.75".into()),
1148            winner: Some(true),
1149        };
1150        let json = serde_json::to_string(&token).unwrap();
1151        let back: MarketToken = serde_json::from_str(&json).unwrap();
1152        assert_eq!(token, back);
1153    }
1154
1155    #[test]
1156    fn test_tag_roundtrip() {
1157        let tag = Tag {
1158            id: "1".into(),
1159            slug: "test".into(),
1160            label: "Test".into(),
1161            force_show: None,
1162            published_at: None,
1163            created_by: None,
1164            updated_by: None,
1165            created_at: None,
1166            updated_at: None,
1167            force_hide: None,
1168            is_carousel: None,
1169        };
1170        let json = serde_json::to_string(&tag).unwrap();
1171        let back: Tag = serde_json::from_str(&json).unwrap();
1172        assert_eq!(tag, back);
1173    }
1174}