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/// Abridged series payload returned from `/series-summary/*`.
356///
357/// Note the mixed casing: `eventDates` / `eventWeeks` are camelCase while
358/// `earliest_open_week` / `earliest_open_date` are snake_case in the upstream
359/// response, so this struct does not use `#[serde(rename_all = "camelCase")]`
360/// and instead spells each field out explicitly.
361#[cfg_attr(feature = "specta", derive(specta::Type))]
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363pub struct SeriesSummary {
364    pub id: String,
365    pub title: Option<String>,
366    pub slug: Option<String>,
367    #[serde(rename = "eventDates", default)]
368    pub event_dates: Vec<String>,
369    #[cfg_attr(feature = "specta", specta(type = Vec<f64>))]
370    #[serde(rename = "eventWeeks", default)]
371    pub event_weeks: Vec<i64>,
372    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
373    pub earliest_open_week: Option<i64>,
374    pub earliest_open_date: Option<String>,
375}
376
377/// Profile returned from `/profiles/user_address/{user_address}`.
378///
379/// All fields except `id` are optional; upstream frequently omits UTM
380/// attribution and certification fields.
381#[cfg_attr(feature = "specta", derive(specta::Type))]
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
383#[serde(rename_all = "camelCase")]
384pub struct Profile {
385    pub id: String,
386    pub name: Option<String>,
387    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
388    pub user: Option<i64>,
389    pub referral: Option<String>,
390    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
391    pub created_by: Option<i64>,
392    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
393    pub updated_by: Option<i64>,
394    pub created_at: Option<String>,
395    pub updated_at: Option<String>,
396    pub utm_source: Option<String>,
397    pub utm_medium: Option<String>,
398    pub utm_campaign: Option<String>,
399    pub utm_content: Option<String>,
400    pub utm_term: Option<String>,
401    pub wallet_activated: Option<bool>,
402    pub pseudonym: Option<String>,
403    pub display_username_public: Option<bool>,
404    pub profile_image: Option<String>,
405    pub bio: Option<String>,
406    pub proxy_wallet: Option<String>,
407    /// ImageOptimization payload; kept as raw JSON since the upstream shape
408    /// is not yet modelled in this crate.
409    #[cfg_attr(feature = "specta", specta(skip))]
410    pub profile_image_optimized: Option<serde_json::Value>,
411    pub is_close_only: Option<bool>,
412    pub is_cert_req: Option<bool>,
413    pub cert_req_date: Option<String>,
414}
415
416/// Tag for categorizing markets/events
417#[cfg_attr(feature = "specta", derive(specta::Type))]
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
419#[serde(rename_all = "camelCase")]
420pub struct Tag {
421    pub id: String,
422    pub slug: String,
423    pub label: String,
424    pub force_show: Option<bool>,
425    pub published_at: Option<String>,
426    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
427    pub created_by: Option<u64>,
428    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
429    pub updated_by: Option<u64>,
430    pub created_at: Option<String>,
431    pub updated_at: Option<String>,
432    pub force_hide: Option<bool>,
433    pub is_carousel: Option<bool>,
434}
435
436/// Sports metadata
437#[cfg_attr(feature = "specta", derive(specta::Type))]
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct SportMetadata {
441    #[cfg_attr(feature = "specta", specta(type = f64))]
442    pub id: u64,
443    pub sport: String,
444    pub image: Option<String>,
445    pub resolution: Option<String>,
446    pub ordering: Option<String>,
447    pub tags: Option<String>,
448    pub series: Option<String>,
449    pub created_at: Option<String>,
450}
451
452/// Sports team
453#[cfg_attr(feature = "specta", derive(specta::Type))]
454#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(rename_all = "camelCase")]
456pub struct Team {
457    #[cfg_attr(feature = "specta", specta(type = f64))]
458    pub id: i64,
459    pub name: Option<String>,
460    pub league: Option<String>,
461    pub record: Option<String>,
462    pub logo: Option<String>,
463    pub abbreviation: Option<String>,
464    pub alias: Option<String>,
465    pub created_at: Option<DateTime<Utc>>,
466    pub updated_at: Option<DateTime<Utc>>,
467}
468
469/// Comment on a market/event/series
470#[cfg_attr(feature = "specta", derive(specta::Type))]
471#[derive(Debug, Clone, Serialize, Deserialize)]
472#[serde(rename_all = "camelCase")]
473pub struct Comment {
474    pub id: String,
475    pub body: String,
476    pub created_at: DateTime<Utc>,
477    pub updated_at: DateTime<Utc>,
478    pub deleted_at: Option<DateTime<Utc>>,
479    pub user: CommentUser,
480    pub market_id: Option<String>,
481    pub event_id: Option<String>,
482    pub series_id: Option<String>,
483    pub parent_id: Option<String>,
484    #[serde(default)]
485    pub reactions: Vec<CommentReaction>,
486    #[serde(default)]
487    pub positions: Vec<CommentPosition>,
488    #[cfg_attr(feature = "specta", specta(type = f64))]
489    pub like_count: u32,
490    #[cfg_attr(feature = "specta", specta(type = f64))]
491    pub dislike_count: u32,
492    #[cfg_attr(feature = "specta", specta(type = f64))]
493    pub reply_count: u32,
494}
495
496/// User who created a comment
497#[cfg_attr(feature = "specta", derive(specta::Type))]
498#[derive(Debug, Clone, Serialize, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct CommentUser {
501    pub id: String,
502    pub name: String,
503    pub avatar: Option<String>,
504}
505
506/// Reaction to a comment
507#[cfg_attr(feature = "specta", derive(specta::Type))]
508#[derive(Debug, Clone, Serialize, Deserialize)]
509#[serde(rename_all = "camelCase")]
510pub struct CommentReaction {
511    pub user_id: String,
512    pub reaction_type: String,
513}
514
515/// Position held by comment author
516#[cfg_attr(feature = "specta", derive(specta::Type))]
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct CommentPosition {
520    pub token_id: String,
521    pub outcome: String,
522    pub shares: String,
523}
524
525/// Generic count response (used for tweet count, comment count, etc.)
526#[cfg_attr(feature = "specta", derive(specta::Type))]
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct CountResponse {
530    #[cfg_attr(feature = "specta", specta(type = f64))]
531    pub count: u64,
532}
533
534/// Pagination cursor for list operations
535#[cfg_attr(feature = "specta", derive(specta::Type))]
536#[derive(Debug, Clone, Serialize, Deserialize)]
537#[serde(rename_all = "camelCase")]
538pub struct Cursor {
539    pub next_cursor: Option<String>,
540}
541
542/// Paginated response wrapper
543#[cfg_attr(feature = "specta", derive(specta::Type))]
544#[derive(Debug, Clone, Serialize, Deserialize)]
545#[serde(rename_all = "camelCase")]
546pub struct PaginatedResponse<T> {
547    pub data: Vec<T>,
548    pub next_cursor: Option<String>,
549}
550
551/// Event creator metadata returned from `/events/creators*` endpoints.
552#[cfg_attr(feature = "specta", derive(specta::Type))]
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
554#[serde(rename_all = "camelCase")]
555pub struct EventCreator {
556    pub id: String,
557    pub creator_name: Option<String>,
558    pub creator_handle: Option<String>,
559    pub creator_url: Option<String>,
560    pub creator_image: Option<String>,
561    pub created_at: Option<String>,
562    pub updated_at: Option<String>,
563}
564
565/// Offset-style pagination metadata accompanying `EventsPagination`.
566#[cfg_attr(feature = "specta", derive(specta::Type))]
567#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
568#[serde(rename_all = "camelCase")]
569pub struct Pagination {
570    pub has_more: Option<bool>,
571    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
572    pub total_results: Option<i64>,
573}
574
575/// Paginated response from `GET /events/pagination`.
576#[cfg_attr(feature = "specta", derive(specta::Type))]
577#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(rename_all = "camelCase")]
579pub struct EventsPagination {
580    #[serde(default)]
581    pub data: Vec<Event>,
582    pub pagination: Option<Pagination>,
583}
584
585/// Keyset-paginated events response from `GET /events/keyset`.
586///
587/// `next_cursor` is `None` on the last page. The upstream JSON key is
588/// `next_cursor` (snake_case), not `nextCursor`.
589#[cfg_attr(feature = "specta", derive(specta::Type))]
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct KeysetEventsResponse {
592    #[serde(default)]
593    pub events: Vec<Event>,
594    pub next_cursor: Option<String>,
595}
596
597/// Response body from `GET /markets/{id}/description`.
598///
599/// The endpoint returns `{ "description": "..." }`. The field is modelled as
600/// `Option<String>` so markets without a description still deserialize.
601#[cfg_attr(feature = "specta", derive(specta::Type))]
602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
603#[serde(rename_all = "camelCase")]
604pub struct MarketDescription {
605    pub description: Option<String>,
606}
607
608/// JSON request body for `POST /markets/information` and `POST /markets/abridged`.
609///
610/// Fields are all optional; only set the filters you need. `Vec` fields are
611/// omitted from the serialized payload when empty, and `Option` fields when
612/// `None`, so a default-constructed body serializes to `{}`.
613#[cfg_attr(feature = "specta", derive(specta::Type))]
614#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
615#[serde(rename_all = "camelCase")]
616pub struct MarketsInformationBody {
617    /// Filter by market numeric IDs.
618    #[cfg_attr(feature = "specta", specta(type = Vec<f64>))]
619    #[serde(default, skip_serializing_if = "Vec::is_empty")]
620    pub id: Vec<i64>,
621    /// Filter by market slugs.
622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
623    pub slug: Vec<String>,
624    /// When set, restrict to markets with this closed flag.
625    #[serde(skip_serializing_if = "Option::is_none")]
626    pub closed: Option<bool>,
627    /// Filter by CLOB token IDs.
628    #[serde(default, skip_serializing_if = "Vec::is_empty")]
629    pub clob_token_ids: Vec<String>,
630    /// Filter by condition IDs.
631    #[serde(default, skip_serializing_if = "Vec::is_empty")]
632    pub condition_ids: Vec<String>,
633    /// Filter by market-maker contract addresses.
634    #[serde(default, skip_serializing_if = "Vec::is_empty")]
635    pub market_maker_address: Vec<String>,
636    /// Minimum liquidity.
637    #[serde(skip_serializing_if = "Option::is_none")]
638    pub liquidity_num_min: Option<f64>,
639    /// Maximum liquidity.
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub liquidity_num_max: Option<f64>,
642    /// Minimum volume.
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub volume_num_min: Option<f64>,
645    /// Maximum volume.
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub volume_num_max: Option<f64>,
648    /// Minimum start date (ISO-8601).
649    #[serde(skip_serializing_if = "Option::is_none")]
650    pub start_date_min: Option<String>,
651    /// Maximum start date (ISO-8601).
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub start_date_max: Option<String>,
654    /// Minimum end date (ISO-8601).
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub end_date_min: Option<String>,
657    /// Maximum end date (ISO-8601).
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub end_date_max: Option<String>,
660    /// Include related-tag matches when filtering by `tag_id`.
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub related_tags: Option<bool>,
663    /// Tag numeric id to filter by.
664    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
665    #[serde(skip_serializing_if = "Option::is_none")]
666    pub tag_id: Option<i64>,
667    /// Filter to "create your own market" markets.
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub cyom: Option<bool>,
670    /// UMA resolution status filter.
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub uma_resolution_status: Option<String>,
673    /// Game ID filter.
674    #[serde(skip_serializing_if = "Option::is_none")]
675    pub game_id: Option<String>,
676    /// Restrict to these sports market types.
677    #[serde(default, skip_serializing_if = "Vec::is_empty")]
678    pub sports_market_types: Vec<String>,
679    /// Minimum reward size.
680    #[serde(skip_serializing_if = "Option::is_none")]
681    pub rewards_min_size: Option<f64>,
682    /// Filter by question IDs.
683    #[serde(default, skip_serializing_if = "Vec::is_empty")]
684    pub question_ids: Vec<String>,
685    /// Include tags in response.
686    #[serde(skip_serializing_if = "Option::is_none")]
687    pub include_tags: Option<bool>,
688}
689
690/// Keyset-paginated markets response from `GET /markets/keyset`.
691///
692/// `next_cursor` is `None` on the last page. Like `KeysetEventsResponse`, the
693/// upstream JSON key is `next_cursor` (snake_case), not `nextCursor`.
694#[cfg_attr(feature = "specta", derive(specta::Type))]
695#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct KeysetMarketsResponse {
697    #[serde(default)]
698    pub markets: Vec<Market>,
699    pub next_cursor: Option<String>,
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    // ── MarketToken ─────────────────────────────────────────────
707
708    #[test]
709    fn test_market_token_deserialization() {
710        let json = r#"{
711            "tokenId": "71321045679252212594626385532706912750332728571942532289631379312455583992563",
712            "outcome": "Yes",
713            "price": "0.55",
714            "winner": false
715        }"#;
716        let token: MarketToken = serde_json::from_str(json).unwrap();
717        assert_eq!(token.outcome, "Yes");
718        assert_eq!(token.price.as_deref(), Some("0.55"));
719        assert_eq!(token.winner, Some(false));
720    }
721
722    #[test]
723    fn test_market_token_optional_fields() {
724        let json = r#"{"tokenId": "123", "outcome": "No"}"#;
725        let token: MarketToken = serde_json::from_str(json).unwrap();
726        assert!(token.price.is_none());
727        assert!(token.winner.is_none());
728    }
729
730    // ── Tag ─────────────────────────────────────────────────────
731
732    #[test]
733    fn test_tag_deserialization() {
734        let json = r#"{
735            "id": "42",
736            "slug": "politics",
737            "label": "Politics",
738            "forceShow": true,
739            "publishedAt": "2024-01-01T00:00:00Z",
740            "createdBy": 1,
741            "updatedBy": 2,
742            "createdAt": "2024-01-01T00:00:00Z",
743            "updatedAt": "2024-06-01T00:00:00Z",
744            "forceHide": false,
745            "isCarousel": true
746        }"#;
747        let tag: Tag = serde_json::from_str(json).unwrap();
748        assert_eq!(tag.slug, "politics");
749        assert_eq!(tag.force_show, Some(true));
750        assert_eq!(tag.is_carousel, Some(true));
751    }
752
753    #[test]
754    fn test_tag_minimal() {
755        let json = r#"{"id": "1", "slug": "test", "label": "Test"}"#;
756        let tag: Tag = serde_json::from_str(json).unwrap();
757        assert_eq!(tag.label, "Test");
758        assert!(tag.force_show.is_none());
759        assert!(tag.created_by.is_none());
760    }
761
762    // ── Market ──────────────────────────────────────────────────
763
764    #[test]
765    fn test_market_minimal_deserialization() {
766        let json = r#"{
767            "id": "12345",
768            "conditionId": "0xabc",
769            "description": "Will X happen?",
770            "question": "Will X happen by end of 2025?",
771            "marketMakerAddress": "0x1234567890abcdef"
772        }"#;
773        let market: Market = serde_json::from_str(json).unwrap();
774        assert_eq!(market.id, "12345");
775        assert_eq!(market.condition_id, "0xabc");
776        assert!(market.tokens.is_empty()); // #[serde(default)]
777        assert!(market.tags.is_empty()); // #[serde(default)]
778        assert!(market.slug.is_none());
779        assert!(market.volume_24hr.is_none());
780    }
781
782    #[test]
783    fn test_market_with_tokens() {
784        let json = r#"{
785            "id": "1",
786            "conditionId": "0xcond",
787            "description": "Test",
788            "question": "Test?",
789            "marketMakerAddress": "0xaddr",
790            "tokens": [
791                {"tokenId": "t1", "outcome": "Yes", "price": "0.7", "winner": true},
792                {"tokenId": "t2", "outcome": "No", "price": "0.3", "winner": false}
793            ]
794        }"#;
795        let market: Market = serde_json::from_str(json).unwrap();
796        assert_eq!(market.tokens.len(), 2);
797        assert_eq!(market.tokens[0].outcome, "Yes");
798        assert_eq!(market.tokens[1].price.as_deref(), Some("0.3"));
799    }
800
801    #[test]
802    fn test_market_volume_fields() {
803        let json = r#"{
804            "id": "1",
805            "conditionId": "0xcond",
806            "description": "Test",
807            "question": "Test?",
808            "marketMakerAddress": "0xaddr",
809            "volume24hr": 1500.5,
810            "volume1wk": 10000.0,
811            "volume1mo": 50000.0,
812            "volume1yr": 200000.0,
813            "volume24hrAmm": 100.0,
814            "volume1wkClob": 9900.0
815        }"#;
816        let market: Market = serde_json::from_str(json).unwrap();
817        assert_eq!(market.volume_24hr, Some(1500.5));
818        assert_eq!(market.volume_1wk, Some(10000.0));
819        assert_eq!(market.volume_24hr_amm, Some(100.0));
820        assert_eq!(market.volume_1wk_clob, Some(9900.0));
821    }
822
823    #[test]
824    fn test_market_denomination_token_rename() {
825        // API field is "denomationToken" (typo in Polymarket API)
826        let json = r#"{
827            "id": "1",
828            "conditionId": "0xcond",
829            "description": "Test",
830            "question": "Test?",
831            "marketMakerAddress": "0xaddr",
832            "denomationToken": "USDC"
833        }"#;
834        let market: Market = serde_json::from_str(json).unwrap();
835        assert_eq!(market.denomination_token.as_deref(), Some("USDC"));
836    }
837
838    #[test]
839    fn test_market_rewards_as_map() {
840        let json = r#"{
841            "id": "1",
842            "conditionId": "0xcond",
843            "description": "Test",
844            "question": "Test?",
845            "marketMakerAddress": "0xaddr",
846            "rewards": {"min_size": "100", "max_spread": "0.05"}
847        }"#;
848        let market: Market = serde_json::from_str(json).unwrap();
849        assert!(market.rewards.is_some());
850        let rewards = market.rewards.unwrap();
851        assert_eq!(rewards["min_size"], "100");
852    }
853
854    #[test]
855    fn test_market_null_rewards() {
856        let json = r#"{
857            "id": "1",
858            "conditionId": "0xcond",
859            "description": "Test",
860            "question": "Test?",
861            "marketMakerAddress": "0xaddr",
862            "rewards": null
863        }"#;
864        let market: Market = serde_json::from_str(json).unwrap();
865        assert!(market.rewards.is_none());
866    }
867
868    // ── Event ───────────────────────────────────────────────────
869
870    #[test]
871    fn test_event_minimal() {
872        let json = r#"{"id": "evt-1"}"#;
873        let event: Event = serde_json::from_str(json).unwrap();
874        assert_eq!(event.id, "evt-1");
875        assert!(event.markets.is_empty()); // #[serde(default)]
876        assert!(event.tags.is_empty());
877        assert!(event.series.is_empty());
878        assert!(event.sub_events.is_empty());
879    }
880
881    #[test]
882    fn test_event_with_nested_markets() {
883        let json = r#"{
884            "id": "evt-1",
885            "title": "2025 Election",
886            "markets": [
887                {
888                    "id": "mkt-1",
889                    "conditionId": "0xabc",
890                    "description": "Who wins?",
891                    "question": "Who wins the election?",
892                    "marketMakerAddress": "0xaddr"
893                }
894            ]
895        }"#;
896        let event: Event = serde_json::from_str(json).unwrap();
897        assert_eq!(event.markets.len(), 1);
898        assert_eq!(event.markets[0].id, "mkt-1");
899    }
900
901    #[test]
902    fn test_event_volume_24h_rename() {
903        let json = r#"{
904            "id": "evt-1",
905            "volume24hr": 5000.0
906        }"#;
907        let event: Event = serde_json::from_str(json).unwrap();
908        assert_eq!(event.volume_24hr, Some(5000.0));
909    }
910
911    #[test]
912    fn test_event_volume_24h_old_key_ignored() {
913        // The old key "volume24h" should NOT deserialize into volume_24hr
914        let json = r#"{
915            "id": "evt-1",
916            "volume24h": 5000.0
917        }"#;
918        let event: Event = serde_json::from_str(json).unwrap();
919        assert_eq!(event.volume_24hr, None);
920    }
921
922    #[test]
923    fn test_event_enable_neg_risk_rename() {
924        let json = r#"{
925            "id": "evt-1",
926            "enableNegRisk": true
927        }"#;
928        let event: Event = serde_json::from_str(json).unwrap();
929        assert_eq!(event.enable_neg_risk, Some(true));
930    }
931
932    #[test]
933    fn test_event_published_at_snake_case() {
934        let json = r#"{
935            "id": "evt-1",
936            "published_at": "2024-01-01T00:00:00Z"
937        }"#;
938        let event: Event = serde_json::from_str(json).unwrap();
939        assert_eq!(event.published_at.as_deref(), Some("2024-01-01T00:00:00Z"));
940    }
941
942    #[test]
943    fn test_market_question_id_capital() {
944        let json = r#"{
945            "id": "1",
946            "conditionId": "0xcond",
947            "description": "Test",
948            "question": "Test?",
949            "marketMakerAddress": "0xaddr",
950            "questionID": "0xabc123"
951        }"#;
952        let market: Market = serde_json::from_str(json).unwrap();
953        assert_eq!(market.question_id.as_deref(), Some("0xabc123"));
954    }
955
956    #[test]
957    fn test_market_has_reviewed_dates() {
958        let json = r#"{
959            "id": "1",
960            "conditionId": "0xcond",
961            "description": "Test",
962            "question": "Test?",
963            "marketMakerAddress": "0xaddr",
964            "hasReviewedDates": true
965        }"#;
966        let market: Market = serde_json::from_str(json).unwrap();
967        assert_eq!(market.has_reviewed_dates, Some(true));
968    }
969
970    #[test]
971    fn test_market_rewards_max_spread_singular() {
972        let json = r#"{
973            "id": "1",
974            "conditionId": "0xcond",
975            "description": "Test",
976            "question": "Test?",
977            "marketMakerAddress": "0xaddr",
978            "rewardsMaxSpread": 0.05
979        }"#;
980        let market: Market = serde_json::from_str(json).unwrap();
981        assert_eq!(market.rewards_max_spread, Some(0.05));
982    }
983
984    #[test]
985    fn test_market_submitted_by_snake_case() {
986        let json = r#"{
987            "id": "1",
988            "conditionId": "0xcond",
989            "description": "Test",
990            "question": "Test?",
991            "marketMakerAddress": "0xaddr",
992            "submitted_by": "0xdeadbeef"
993        }"#;
994        let market: Market = serde_json::from_str(json).unwrap();
995        assert_eq!(market.submitted_by.as_deref(), Some("0xdeadbeef"));
996    }
997
998    // ── SeriesInfo ──────────────────────────────────────────────
999
1000    #[test]
1001    fn test_series_info_minimal() {
1002        let json = r#"{"id": "s1", "slug": "nfl-2025", "title": "NFL 2025"}"#;
1003        let si: SeriesInfo = serde_json::from_str(json).unwrap();
1004        assert_eq!(si.slug, "nfl-2025");
1005        assert_eq!(si.title, "NFL 2025");
1006        assert!(si.ticker.is_none());
1007        assert!(si.active.is_none());
1008    }
1009
1010    #[test]
1011    fn test_series_info_full() {
1012        let json = r#"{
1013            "id": "2",
1014            "ticker": "nba",
1015            "slug": "nba",
1016            "title": "NBA",
1017            "seriesType": "single",
1018            "recurrence": "daily",
1019            "image": "https://example.com/nba.png",
1020            "icon": "https://example.com/nba-icon.png",
1021            "layout": "default",
1022            "active": true,
1023            "closed": false,
1024            "archived": false,
1025            "new": false,
1026            "featured": false,
1027            "restricted": true,
1028            "publishedAt": "2023-01-30T17:13:39Z",
1029            "createdBy": "15",
1030            "updatedBy": "15",
1031            "createdAt": "2022-10-13T00:36:01Z",
1032            "updatedAt": "2026-03-04T12:03:42Z",
1033            "commentsEnabled": false,
1034            "competitive": "0",
1035            "volume24hr": 11.07,
1036            "startDate": "2021-01-01T17:00:00Z",
1037            "commentCount": 6274,
1038            "requiresTranslation": false
1039        }"#;
1040        let si: SeriesInfo = serde_json::from_str(json).unwrap();
1041        assert_eq!(si.ticker.as_deref(), Some("nba"));
1042        assert_eq!(si.series_type.as_deref(), Some("single"));
1043        assert_eq!(si.active, Some(true));
1044        assert_eq!(si.closed, Some(false));
1045        assert_eq!(si.volume_24hr, Some(11.07));
1046        assert_eq!(si.comment_count, Some(6274));
1047        assert_eq!(si.requires_translation, Some(false));
1048    }
1049
1050    // ── Negative tests: old/wrong field names are rejected ─────
1051
1052    #[test]
1053    fn test_market_old_question_id_ignored() {
1054        // camelCase "questionId" should NOT match — API uses "questionID"
1055        let json = r#"{
1056            "id": "1",
1057            "conditionId": "0xcond",
1058            "description": "Test",
1059            "question": "Test?",
1060            "marketMakerAddress": "0xaddr",
1061            "questionId": "0xwrong"
1062        }"#;
1063        let market: Market = serde_json::from_str(json).unwrap();
1064        assert!(market.question_id.is_none());
1065    }
1066
1067    #[test]
1068    fn test_market_old_has_review_dates_ignored() {
1069        // "hasReviewDates" should NOT match — API uses "hasReviewedDates"
1070        let json = r#"{
1071            "id": "1",
1072            "conditionId": "0xcond",
1073            "description": "Test",
1074            "question": "Test?",
1075            "marketMakerAddress": "0xaddr",
1076            "hasReviewDates": true
1077        }"#;
1078        let market: Market = serde_json::from_str(json).unwrap();
1079        assert!(market.has_reviewed_dates.is_none());
1080    }
1081
1082    #[test]
1083    fn test_market_old_rewards_max_spreads_ignored() {
1084        // "rewardsMaxSpreads" (plural) should NOT match — API uses "rewardsMaxSpread"
1085        let json = r#"{
1086            "id": "1",
1087            "conditionId": "0xcond",
1088            "description": "Test",
1089            "question": "Test?",
1090            "marketMakerAddress": "0xaddr",
1091            "rewardsMaxSpreads": 0.05
1092        }"#;
1093        let market: Market = serde_json::from_str(json).unwrap();
1094        assert!(market.rewards_max_spread.is_none());
1095    }
1096
1097    #[test]
1098    fn test_event_old_enalbe_neg_risk_ignored() {
1099        // Old typo "enalbeNegRisk" should NOT match after fix
1100        let json = r#"{
1101            "id": "evt-1",
1102            "enalbeNegRisk": true
1103        }"#;
1104        let event: Event = serde_json::from_str(json).unwrap();
1105        assert!(event.enable_neg_risk.is_none());
1106    }
1107
1108    #[test]
1109    fn test_event_camel_published_at_ignored() {
1110        // camelCase "publishedAt" should NOT match — Event API uses "published_at"
1111        let json = r#"{
1112            "id": "evt-1",
1113            "publishedAt": "2024-01-01T00:00:00Z"
1114        }"#;
1115        let event: Event = serde_json::from_str(json).unwrap();
1116        assert!(event.published_at.is_none());
1117    }
1118
1119    #[test]
1120    fn test_market_camel_submitted_by_ignored() {
1121        // camelCase "submittedBy" should NOT match — API uses "submitted_by"
1122        let json = r#"{
1123            "id": "1",
1124            "conditionId": "0xcond",
1125            "description": "Test",
1126            "question": "Test?",
1127            "marketMakerAddress": "0xaddr",
1128            "submittedBy": "0xwrong"
1129        }"#;
1130        let market: Market = serde_json::from_str(json).unwrap();
1131        assert!(market.submitted_by.is_none());
1132    }
1133
1134    // ── SeriesData ──────────────────────────────────────────────
1135
1136    #[test]
1137    fn test_series_data_minimal() {
1138        let json = r#"{
1139            "id": "s1",
1140            "slug": "nfl",
1141            "title": "NFL",
1142            "active": true,
1143            "closed": false,
1144            "archived": false
1145        }"#;
1146        let sd: SeriesData = serde_json::from_str(json).unwrap();
1147        assert!(sd.active);
1148        assert!(!sd.closed);
1149        assert!(sd.events.is_empty()); // #[serde(default)]
1150        assert!(sd.tags.is_empty());
1151    }
1152
1153    // ── SportMetadata ───────────────────────────────────────────
1154
1155    #[test]
1156    fn test_sport_metadata() {
1157        let json = r#"{
1158            "id": 1,
1159            "sport": "Basketball",
1160            "image": "https://example.com/nba.png",
1161            "createdAt": "2024-01-01T00:00:00Z"
1162        }"#;
1163        let sm: SportMetadata = serde_json::from_str(json).unwrap();
1164        assert_eq!(sm.id, 1);
1165        assert_eq!(sm.sport, "Basketball");
1166    }
1167
1168    // ── Team ────────────────────────────────────────────────────
1169
1170    #[test]
1171    fn test_team() {
1172        let json = r#"{
1173            "id": 42,
1174            "name": "Lakers",
1175            "league": "NBA",
1176            "abbreviation": "LAL",
1177            "createdAt": "2024-01-01T00:00:00Z",
1178            "updatedAt": "2024-06-15T12:00:00Z"
1179        }"#;
1180        let team: Team = serde_json::from_str(json).unwrap();
1181        assert_eq!(team.id, 42);
1182        assert_eq!(team.name.as_deref(), Some("Lakers"));
1183        assert!(team.created_at.is_some());
1184    }
1185
1186    // ── Comment ─────────────────────────────────────────────────
1187
1188    #[test]
1189    fn test_comment_deserialization() {
1190        let json = r#"{
1191            "id": "c1",
1192            "body": "I think this market will resolve yes.",
1193            "createdAt": "2024-06-01T10:00:00Z",
1194            "updatedAt": "2024-06-01T10:00:00Z",
1195            "deletedAt": null,
1196            "user": {"id": "u1", "name": "trader1", "avatar": null},
1197            "marketId": "mkt-1",
1198            "eventId": null,
1199            "seriesId": null,
1200            "parentId": null,
1201            "reactions": [],
1202            "positions": [
1203                {"tokenId": "t1", "outcome": "Yes", "shares": "100.5"}
1204            ],
1205            "likeCount": 5,
1206            "dislikeCount": 1,
1207            "replyCount": 3
1208        }"#;
1209        let comment: Comment = serde_json::from_str(json).unwrap();
1210        assert_eq!(comment.id, "c1");
1211        assert_eq!(comment.user.name, "trader1");
1212        assert_eq!(comment.like_count, 5);
1213        assert_eq!(comment.positions.len(), 1);
1214        assert_eq!(comment.positions[0].shares, "100.5");
1215        assert!(comment.deleted_at.is_none());
1216    }
1217
1218    // ── UserResponse ────────────────────────────────────────────
1219
1220    #[test]
1221    fn test_user_response() {
1222        let json = r#"{
1223            "proxyWallet": "0xproxy",
1224            "address": "0xsigner",
1225            "id": "u1",
1226            "name": "polytrader"
1227        }"#;
1228        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1229        assert_eq!(user.proxy.as_deref(), Some("0xproxy"));
1230        assert_eq!(user.name.as_deref(), Some("polytrader"));
1231    }
1232
1233    #[test]
1234    fn test_user_response_all_null() {
1235        let json = r#"{}"#;
1236        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1237        assert!(user.proxy.is_none());
1238        assert!(user.address.is_none());
1239        assert!(user.id.is_none());
1240        assert!(user.name.is_none());
1241        assert!(user.created_at.is_none());
1242        assert!(user.profile_image.is_none());
1243        assert!(user.display_username_public.is_none());
1244        assert!(user.bio.is_none());
1245        assert!(user.pseudonym.is_none());
1246        assert!(user.x_username.is_none());
1247        assert!(user.verified_badge.is_none());
1248        assert!(user.users.is_empty());
1249    }
1250
1251    #[test]
1252    fn test_user_response_full_profile() {
1253        let json = r#"{
1254            "proxyWallet": "0xproxy",
1255            "address": "0xsigner",
1256            "id": "u1",
1257            "name": "polytrader",
1258            "createdAt": "2024-01-15T10:00:00Z",
1259            "profileImage": "https://example.com/avatar.png",
1260            "displayUsernamePublic": true,
1261            "bio": "DeFi enthusiast",
1262            "pseudonym": "poly_anon",
1263            "xUsername": "polytrader_x",
1264            "verifiedBadge": true,
1265            "users": [
1266                {"id": "uid-1", "creator": true, "mod": false},
1267                {"id": "uid-2", "creator": false, "mod": true}
1268            ]
1269        }"#;
1270        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1271        assert_eq!(user.proxy.as_deref(), Some("0xproxy"));
1272        assert_eq!(user.name.as_deref(), Some("polytrader"));
1273        assert_eq!(user.created_at.as_deref(), Some("2024-01-15T10:00:00Z"));
1274        assert_eq!(
1275            user.profile_image.as_deref(),
1276            Some("https://example.com/avatar.png")
1277        );
1278        assert_eq!(user.display_username_public, Some(true));
1279        assert_eq!(user.bio.as_deref(), Some("DeFi enthusiast"));
1280        assert_eq!(user.pseudonym.as_deref(), Some("poly_anon"));
1281        assert_eq!(user.x_username.as_deref(), Some("polytrader_x"));
1282        assert_eq!(user.verified_badge, Some(true));
1283        assert_eq!(user.users.len(), 2);
1284        assert!(user.users[0].creator);
1285        assert!(!user.users[0].moderator);
1286        assert!(!user.users[1].creator);
1287        assert!(user.users[1].moderator);
1288    }
1289
1290    #[test]
1291    fn test_user_info_deserialization() {
1292        let json = r#"{"id": "uid-1", "creator": true, "mod": false}"#;
1293        let info: crate::api::user::UserInfo = serde_json::from_str(json).unwrap();
1294        assert_eq!(info.id.as_deref(), Some("uid-1"));
1295        assert!(info.creator);
1296        assert!(!info.moderator);
1297    }
1298
1299    #[test]
1300    fn test_user_info_defaults() {
1301        let json = r#"{}"#;
1302        let info: crate::api::user::UserInfo = serde_json::from_str(json).unwrap();
1303        assert!(info.id.is_none());
1304        assert!(!info.creator);
1305        assert!(!info.moderator);
1306    }
1307
1308    // ── CountResponse ────────────────────────────────────────────
1309
1310    #[test]
1311    fn test_count_response() {
1312        let json = r#"{"count": 42}"#;
1313        let resp: CountResponse = serde_json::from_str(json).unwrap();
1314        assert_eq!(resp.count, 42);
1315    }
1316
1317    // ── Cursor / PaginatedResponse ──────────────────────────────
1318
1319    #[test]
1320    fn test_cursor_with_next() {
1321        let json = r#"{"nextCursor": "abc123"}"#;
1322        let cursor: Cursor = serde_json::from_str(json).unwrap();
1323        assert_eq!(cursor.next_cursor.as_deref(), Some("abc123"));
1324    }
1325
1326    #[test]
1327    fn test_cursor_without_next() {
1328        let json = r#"{"nextCursor": null}"#;
1329        let cursor: Cursor = serde_json::from_str(json).unwrap();
1330        assert!(cursor.next_cursor.is_none());
1331    }
1332
1333    #[test]
1334    fn test_paginated_response() {
1335        let json = r#"{
1336            "data": [{"tokenId": "t1", "outcome": "Yes"}],
1337            "nextCursor": "page2"
1338        }"#;
1339        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
1340        assert_eq!(resp.data.len(), 1);
1341        assert_eq!(resp.next_cursor.as_deref(), Some("page2"));
1342    }
1343
1344    #[test]
1345    fn test_paginated_response_empty() {
1346        let json = r#"{"data": [], "nextCursor": null}"#;
1347        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
1348        assert!(resp.data.is_empty());
1349        assert!(resp.next_cursor.is_none());
1350    }
1351
1352    // ── Serialization round-trip ────────────────────────────────
1353
1354    #[test]
1355    fn test_market_token_roundtrip() {
1356        let token = MarketToken {
1357            token_id: "123".into(),
1358            outcome: "Yes".into(),
1359            price: Some("0.75".into()),
1360            winner: Some(true),
1361        };
1362        let json = serde_json::to_string(&token).unwrap();
1363        let back: MarketToken = serde_json::from_str(&json).unwrap();
1364        assert_eq!(token, back);
1365    }
1366
1367    // ── EventCreator ────────────────────────────────────────────
1368
1369    #[test]
1370    fn test_event_creator_full() {
1371        let json = r#"{
1372            "id": "7",
1373            "creatorName": "Polymarket Sports",
1374            "creatorHandle": "poly_sports",
1375            "creatorUrl": "https://example.com",
1376            "creatorImage": "https://example.com/a.png",
1377            "createdAt": "2024-01-01T00:00:00Z",
1378            "updatedAt": "2024-06-01T00:00:00Z"
1379        }"#;
1380        let c: EventCreator = serde_json::from_str(json).unwrap();
1381        assert_eq!(c.id, "7");
1382        assert_eq!(c.creator_name.as_deref(), Some("Polymarket Sports"));
1383        assert_eq!(c.creator_handle.as_deref(), Some("poly_sports"));
1384        assert_eq!(c.creator_url.as_deref(), Some("https://example.com"));
1385    }
1386
1387    #[test]
1388    fn test_event_creator_minimal() {
1389        let json = r#"{"id": "1"}"#;
1390        let c: EventCreator = serde_json::from_str(json).unwrap();
1391        assert_eq!(c.id, "1");
1392        assert!(c.creator_name.is_none());
1393        assert!(c.creator_handle.is_none());
1394        assert!(c.created_at.is_none());
1395    }
1396
1397    #[test]
1398    fn test_event_creator_roundtrip() {
1399        let c = EventCreator {
1400            id: "9".into(),
1401            creator_name: Some("Poly".into()),
1402            creator_handle: Some("poly".into()),
1403            creator_url: None,
1404            creator_image: None,
1405            created_at: None,
1406            updated_at: None,
1407        };
1408        let json = serde_json::to_string(&c).unwrap();
1409        let back: EventCreator = serde_json::from_str(&json).unwrap();
1410        assert_eq!(c, back);
1411    }
1412
1413    // ── Pagination / EventsPagination ───────────────────────────
1414
1415    #[test]
1416    fn test_pagination_deserialization() {
1417        let json = r#"{"hasMore": true, "totalResults": 124}"#;
1418        let p: Pagination = serde_json::from_str(json).unwrap();
1419        assert_eq!(p.has_more, Some(true));
1420        assert_eq!(p.total_results, Some(124));
1421    }
1422
1423    #[test]
1424    fn test_events_pagination_deserialization() {
1425        let json = r#"{
1426            "data": [{"id": "e1"}, {"id": "e2"}],
1427            "pagination": {"hasMore": false, "totalResults": 2}
1428        }"#;
1429        let ep: EventsPagination = serde_json::from_str(json).unwrap();
1430        assert_eq!(ep.data.len(), 2);
1431        assert_eq!(ep.data[0].id, "e1");
1432        let p = ep.pagination.unwrap();
1433        assert_eq!(p.has_more, Some(false));
1434        assert_eq!(p.total_results, Some(2));
1435    }
1436
1437    #[test]
1438    fn test_events_pagination_empty_data() {
1439        let json = r#"{"pagination": {"hasMore": false, "totalResults": 0}}"#;
1440        let ep: EventsPagination = serde_json::from_str(json).unwrap();
1441        assert!(ep.data.is_empty());
1442    }
1443
1444    // ── KeysetEventsResponse ────────────────────────────────────
1445
1446    #[test]
1447    fn test_keyset_events_response_with_cursor() {
1448        // Upstream uses snake_case next_cursor (not nextCursor).
1449        let json = r#"{
1450            "events": [{"id": "e1", "title": "Test"}],
1451            "next_cursor": "cursor-123"
1452        }"#;
1453        let resp: KeysetEventsResponse = serde_json::from_str(json).unwrap();
1454        assert_eq!(resp.events.len(), 1);
1455        assert_eq!(resp.next_cursor.as_deref(), Some("cursor-123"));
1456    }
1457
1458    #[test]
1459    fn test_keyset_events_response_last_page() {
1460        let json = r#"{"events": []}"#;
1461        let resp: KeysetEventsResponse = serde_json::from_str(json).unwrap();
1462        assert!(resp.events.is_empty());
1463        assert!(resp.next_cursor.is_none());
1464    }
1465
1466    #[test]
1467    fn test_tag_roundtrip() {
1468        let tag = Tag {
1469            id: "1".into(),
1470            slug: "test".into(),
1471            label: "Test".into(),
1472            force_show: None,
1473            published_at: None,
1474            created_by: None,
1475            updated_by: None,
1476            created_at: None,
1477            updated_at: None,
1478            force_hide: None,
1479            is_carousel: None,
1480        };
1481        let json = serde_json::to_string(&tag).unwrap();
1482        let back: Tag = serde_json::from_str(&json).unwrap();
1483        assert_eq!(tag, back);
1484    }
1485
1486    // ── MarketDescription ───────────────────────────────────────
1487
1488    #[test]
1489    fn test_market_description_deserialization() {
1490        let json = r#"{"description": "Will X happen?"}"#;
1491        let md: MarketDescription = serde_json::from_str(json).unwrap();
1492        assert_eq!(md.description.as_deref(), Some("Will X happen?"));
1493    }
1494
1495    #[test]
1496    fn test_market_description_null() {
1497        let json = r#"{"description": null}"#;
1498        let md: MarketDescription = serde_json::from_str(json).unwrap();
1499        assert!(md.description.is_none());
1500    }
1501
1502    // ── MarketsInformationBody ──────────────────────────────────
1503
1504    #[test]
1505    fn test_markets_information_body_default_serializes_empty() {
1506        let body = MarketsInformationBody::default();
1507        let json = serde_json::to_string(&body).unwrap();
1508        assert_eq!(json, "{}");
1509    }
1510
1511    #[test]
1512    fn test_markets_information_body_populated_roundtrip() {
1513        let body = MarketsInformationBody {
1514            id: vec![1, 2, 3],
1515            slug: vec!["will-x".into(), "will-y".into()],
1516            closed: Some(true),
1517            clob_token_ids: vec!["tok-a".into()],
1518            condition_ids: vec!["0xcond".into()],
1519            market_maker_address: vec!["0xmm".into()],
1520            liquidity_num_min: Some(100.0),
1521            liquidity_num_max: Some(10_000.0),
1522            volume_num_min: Some(50.0),
1523            volume_num_max: None,
1524            start_date_min: Some("2025-01-01T00:00:00Z".into()),
1525            start_date_max: None,
1526            end_date_min: None,
1527            end_date_max: Some("2026-01-01T00:00:00Z".into()),
1528            related_tags: Some(true),
1529            tag_id: Some(42),
1530            cyom: Some(false),
1531            uma_resolution_status: Some("resolved".into()),
1532            game_id: Some("game-7".into()),
1533            sports_market_types: vec!["moneyline".into(), "spread".into()],
1534            rewards_min_size: Some(10.0),
1535            question_ids: vec!["q1".into()],
1536            include_tags: Some(true),
1537        };
1538        let json = serde_json::to_string(&body).unwrap();
1539        // Field names are camelCase (from openapi)
1540        assert!(json.contains("\"clobTokenIds\""));
1541        assert!(json.contains("\"marketMakerAddress\""));
1542        assert!(json.contains("\"rewardsMinSize\""));
1543        let back: MarketsInformationBody = serde_json::from_str(&json).unwrap();
1544        assert_eq!(body, back);
1545    }
1546
1547    #[test]
1548    fn test_markets_information_body_partial_omits_empty() {
1549        let body = MarketsInformationBody {
1550            closed: Some(true),
1551            ..Default::default()
1552        };
1553        let json = serde_json::to_string(&body).unwrap();
1554        assert_eq!(json, r#"{"closed":true}"#);
1555    }
1556
1557    // ── KeysetMarketsResponse ───────────────────────────────────
1558
1559    #[test]
1560    fn test_keyset_markets_response_full() {
1561        let json = r#"{
1562            "markets": [{
1563                "id": "1",
1564                "conditionId": "0xcond",
1565                "description": "desc",
1566                "question": "q?",
1567                "marketMakerAddress": "0xmm"
1568            }],
1569            "next_cursor": "abc"
1570        }"#;
1571        let resp: KeysetMarketsResponse = serde_json::from_str(json).unwrap();
1572        assert_eq!(resp.markets.len(), 1);
1573        assert_eq!(resp.markets[0].id, "1");
1574        assert_eq!(resp.next_cursor.as_deref(), Some("abc"));
1575    }
1576
1577    #[test]
1578    fn test_keyset_markets_response_last_page() {
1579        let json = r#"{"markets": []}"#;
1580        let resp: KeysetMarketsResponse = serde_json::from_str(json).unwrap();
1581        assert!(resp.markets.is_empty());
1582        assert!(resp.next_cursor.is_none());
1583    }
1584
1585    // ── SeriesSummary ───────────────────────────────────────────
1586
1587    #[test]
1588    fn test_series_summary_full() {
1589        let json = r#"{
1590            "id": "s-1",
1591            "title": "NFL 2025",
1592            "slug": "nfl-2025",
1593            "eventDates": ["2025-09-01", "2025-09-08"],
1594            "eventWeeks": [1, 2],
1595            "earliest_open_week": 1,
1596            "earliest_open_date": "2025-09-01"
1597        }"#;
1598        let s: SeriesSummary = serde_json::from_str(json).unwrap();
1599        assert_eq!(s.id, "s-1");
1600        assert_eq!(s.title.as_deref(), Some("NFL 2025"));
1601        assert_eq!(s.event_dates.len(), 2);
1602        assert_eq!(s.event_weeks, vec![1, 2]);
1603        assert_eq!(s.earliest_open_week, Some(1));
1604    }
1605
1606    #[test]
1607    fn test_series_summary_minimal() {
1608        let json = r#"{"id": "s-1"}"#;
1609        let s: SeriesSummary = serde_json::from_str(json).unwrap();
1610        assert_eq!(s.id, "s-1");
1611        assert!(s.title.is_none());
1612        assert!(s.event_dates.is_empty());
1613        assert!(s.event_weeks.is_empty());
1614    }
1615
1616    #[test]
1617    fn test_series_summary_roundtrip_preserves_mixed_casing() {
1618        let s = SeriesSummary {
1619            id: "s-1".into(),
1620            title: Some("T".into()),
1621            slug: Some("slug".into()),
1622            event_dates: vec!["2025-09-01".into()],
1623            event_weeks: vec![1],
1624            earliest_open_week: Some(1),
1625            earliest_open_date: Some("2025-09-01".into()),
1626        };
1627        let json = serde_json::to_string(&s).unwrap();
1628        // eventDates camelCase, earliest_open_* snake_case
1629        assert!(json.contains("\"eventDates\""));
1630        assert!(json.contains("\"eventWeeks\""));
1631        assert!(json.contains("\"earliest_open_week\""));
1632        assert!(json.contains("\"earliest_open_date\""));
1633        let back: SeriesSummary = serde_json::from_str(&json).unwrap();
1634        assert_eq!(s, back);
1635    }
1636
1637    // ── Profile ─────────────────────────────────────────────────
1638
1639    #[test]
1640    fn test_profile_minimal() {
1641        let json = r#"{"id": "p-1"}"#;
1642        let p: Profile = serde_json::from_str(json).unwrap();
1643        assert_eq!(p.id, "p-1");
1644        assert!(p.name.is_none());
1645        assert!(p.proxy_wallet.is_none());
1646    }
1647
1648    #[test]
1649    fn test_profile_full() {
1650        let json = r#"{
1651            "id": "p-1",
1652            "name": "Alice",
1653            "user": 42,
1654            "proxyWallet": "0xdead",
1655            "utmSource": "direct",
1656            "walletActivated": true,
1657            "isCloseOnly": false,
1658            "profileImageOptimized": {"medium": "https://cdn/p/m.png"}
1659        }"#;
1660        let p: Profile = serde_json::from_str(json).unwrap();
1661        assert_eq!(p.name.as_deref(), Some("Alice"));
1662        assert_eq!(p.user, Some(42));
1663        assert_eq!(p.proxy_wallet.as_deref(), Some("0xdead"));
1664        assert_eq!(p.utm_source.as_deref(), Some("direct"));
1665        assert_eq!(p.wallet_activated, Some(true));
1666        assert_eq!(p.is_close_only, Some(false));
1667        assert!(p.profile_image_optimized.is_some());
1668    }
1669
1670    #[test]
1671    fn test_profile_roundtrip() {
1672        let p = Profile {
1673            id: "p-1".into(),
1674            name: Some("A".into()),
1675            user: Some(1),
1676            referral: None,
1677            created_by: None,
1678            updated_by: None,
1679            created_at: None,
1680            updated_at: None,
1681            utm_source: None,
1682            utm_medium: None,
1683            utm_campaign: None,
1684            utm_content: None,
1685            utm_term: None,
1686            wallet_activated: Some(true),
1687            pseudonym: None,
1688            display_username_public: None,
1689            profile_image: None,
1690            bio: None,
1691            proxy_wallet: Some("0xpw".into()),
1692            profile_image_optimized: None,
1693            is_close_only: None,
1694            is_cert_req: None,
1695            cert_req_date: None,
1696        };
1697        let json = serde_json::to_string(&p).unwrap();
1698        assert!(json.contains("\"proxyWallet\""));
1699        assert!(json.contains("\"walletActivated\""));
1700        let back: Profile = serde_json::from_str(&json).unwrap();
1701        assert_eq!(p, back);
1702    }
1703}