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    /// Optional: the reduced `POST /markets/abridged` payload omits this field.
23    pub description: Option<String>,
24    pub category: Option<String>,
25    pub end_date_iso: Option<String>,
26    pub start_date_iso: Option<String>,
27    pub question: String,
28    pub min_incentive_size: Option<String>,
29    pub max_incentive_spread: Option<String>,
30    #[serde(rename = "submitted_by")]
31    pub submitted_by: Option<String>,
32    #[serde(rename = "volume24hr")] // lowercase 'hr' to match API
33    pub volume_24hr: Option<f64>,
34    #[serde(rename = "volume1wk")] // lowercase 'wk' to match API
35    pub volume_1wk: Option<f64>,
36    #[serde(rename = "volume1mo")] // lowercase 'mo' to match API
37    pub volume_1mo: Option<f64>,
38    #[serde(rename = "volume1yr")] // lowercase 'yr' to match API
39    pub volume_1yr: Option<f64>,
40    pub liquidity: Option<String>,
41    #[serde(default)]
42    pub tags: Vec<Tag>,
43    pub neg_risk: Option<bool>,
44    pub neg_risk_market_id: Option<String>,
45    pub neg_risk_request_id: Option<String>,
46    // Use i64 instead of u64 to prevent sentinel value
47    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
48    pub comment_count: Option<i64>,
49    pub twitter_card_image: Option<String>,
50    pub resolution_source: Option<String>,
51    pub amm_type: Option<String>,
52    pub sponsor_name: Option<String>,
53    pub sponsor_image: Option<String>,
54    pub x_axis_value: Option<String>,
55    pub y_axis_value: Option<String>,
56    #[serde(rename = "denomationToken")]
57    pub denomination_token: Option<String>,
58    pub fee: Option<String>,
59    pub image: Option<String>,
60    pub icon: Option<String>,
61    pub lower_bound: Option<String>,
62    pub upper_bound: Option<String>,
63    pub outcomes: Option<String>,
64    pub outcome_prices: Option<String>,
65    pub volume: Option<String>,
66    pub active: Option<bool>,
67    pub market_type: Option<String>,
68    pub format_type: Option<String>,
69    pub lower_bound_date: Option<String>,
70    pub upper_bound_date: Option<String>,
71    pub closed: Option<bool>,
72    pub market_maker_address: String,
73    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
74    pub created_by: Option<i64>,
75    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
76    pub updated_by: Option<i64>,
77    pub created_at: Option<String>,
78    pub updated_at: Option<String>,
79    pub closed_time: Option<String>,
80    pub wide_format: Option<bool>,
81    pub new: Option<bool>,
82    pub mailchimp_tag: Option<String>,
83    pub featured: Option<bool>,
84    pub archived: Option<bool>,
85    pub resolved_by: Option<String>,
86    pub restricted: Option<bool>,
87    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
88    pub market_group: Option<i64>,
89    pub group_item_title: Option<String>,
90    pub group_item_threshold: Option<String>,
91    pub uma_end_date: Option<String>,
92    pub uma_resolution_status: Option<String>,
93    pub uma_end_date_iso: Option<String>,
94    pub uma_resolution_statuses: Option<String>,
95    pub enable_order_book: Option<bool>,
96    pub order_price_min_tick_size: Option<f64>,
97    pub order_min_size: Option<f64>,
98    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
99    pub curation_order: Option<i64>,
100    pub volume_num: Option<f64>,
101    pub liquidity_num: Option<f64>,
102    pub has_reviewed_dates: Option<bool>,
103    pub ready_for_cron: Option<bool>,
104    pub comments_enabled: Option<bool>,
105    pub game_start_time: Option<String>,
106    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
107    pub seconds_delay: Option<i64>,
108    pub clob_token_ids: Option<String>,
109    pub disqus_thread: Option<String>,
110    pub short_outcomes: Option<String>,
111    pub team_aid: Option<String>,
112    pub team_bid: Option<String>,
113    pub uma_bond: Option<String>,
114    pub uma_reward: Option<String>,
115    pub fpmm_live: Option<bool>,
116    #[serde(rename = "volume24hrAmm")] // Match API field names
117    pub volume_24hr_amm: Option<f64>,
118    #[serde(rename = "volume1wkAmm")]
119    pub volume_1wk_amm: Option<f64>,
120    #[serde(rename = "volume1moAmm")]
121    pub volume_1mo_amm: Option<f64>,
122    #[serde(rename = "volume1yrAmm")]
123    pub volume_1yr_amm: Option<f64>,
124    #[serde(rename = "volume24hrClob")]
125    pub volume_24hr_clob: Option<f64>,
126    #[serde(rename = "volume1wkClob")]
127    pub volume_1wk_clob: Option<f64>,
128    #[serde(rename = "volume1moClob")]
129    pub volume_1mo_clob: Option<f64>,
130    #[serde(rename = "volume1yrClob")]
131    pub volume_1yr_clob: Option<f64>,
132    pub volume_amm: Option<f64>,
133    pub volume_clob: Option<f64>,
134    pub liquidity_amm: Option<f64>,
135    pub liquidity_clob: Option<f64>,
136    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
137    pub maker_base_fee: Option<i64>,
138    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
139    pub taker_base_fee: Option<i64>,
140    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
141    pub custom_liveness: Option<i64>,
142    pub accepting_orders: Option<bool>,
143    pub notifications_enabled: Option<bool>,
144    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
145    pub score: Option<i64>,
146    pub creator: Option<String>,
147    pub ready: Option<bool>,
148    pub funded: Option<bool>,
149    pub past_slugs: Option<String>,
150    pub ready_timestamp: Option<String>,
151    pub funded_timestamp: Option<String>,
152    pub accepting_orders_timestamp: Option<String>,
153    pub competitive: Option<f64>,
154    pub rewards_min_size: Option<f64>,
155    pub rewards_max_spread: Option<f64>,
156    pub spread: Option<f64>,
157    pub automatically_resolved: Option<bool>,
158    pub automatically_active: Option<bool>,
159    pub one_day_price_change: Option<f64>,
160    pub one_hour_price_change: Option<f64>,
161    pub one_week_price_change: Option<f64>,
162    pub one_month_price_change: Option<f64>,
163    pub one_year_price_change: Option<f64>,
164    pub last_trade_price: Option<f64>,
165    pub best_bid: Option<f64>,
166    pub best_ask: Option<f64>,
167    pub clear_book_on_start: Option<bool>,
168    pub chart_color: Option<String>,
169    pub series_color: Option<String>,
170    pub show_gmp_series: Option<bool>,
171    pub show_gmp_outcome: Option<bool>,
172    pub manual_activation: Option<bool>,
173    pub neg_risk_other: Option<bool>,
174    pub game_id: Option<String>,
175    pub group_item_range: Option<String>,
176    pub sports_market_type: Option<String>,
177    pub line: Option<f64>,
178    pub pending_deployment: Option<bool>,
179    pub deploying: Option<bool>,
180    pub deploying_timestamp: Option<String>,
181    pub schedule_deployment_timestamp: Option<String>,
182    pub rfq_enabled: Option<bool>,
183    pub event_start_time: Option<String>,
184}
185
186/// Market token (outcome)
187#[cfg_attr(feature = "specta", derive(specta::Type))]
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189#[serde(rename_all = "camelCase")]
190pub struct MarketToken {
191    pub token_id: String,
192    pub outcome: String,
193    pub price: Option<String>,
194    pub winner: Option<bool>,
195}
196
197/// Event data from the Gamma API (a group of related markets).
198#[cfg_attr(feature = "specta", derive(specta::Type))]
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct Event {
202    pub id: String,
203    pub ticker: Option<String>,
204    pub slug: Option<String>,
205    pub title: Option<String>,
206    pub subtitle: Option<String>,
207    pub description: Option<String>,
208    pub resolution_source: Option<String>,
209    pub start_date: Option<String>,
210    pub creation_date: Option<String>,
211    pub end_date: Option<String>,
212    pub image: Option<String>,
213    pub icon: Option<String>,
214    pub start_date_iso: Option<String>,
215    pub end_date_iso: Option<String>,
216    pub active: Option<bool>,
217    pub closed: Option<bool>,
218    pub archived: Option<bool>,
219    pub new: Option<bool>,
220    pub featured: Option<bool>,
221    pub restricted: Option<bool>,
222    pub liquidity: Option<f64>,
223    pub open_interest: Option<f64>,
224    pub sort_by: Option<String>,
225    pub category: Option<String>,
226    pub subcategory: Option<String>,
227    pub is_template: Option<bool>,
228    pub template_variables: Option<String>,
229    #[serde(rename = "published_at")]
230    pub published_at: Option<String>,
231    pub created_by: Option<String>,
232    pub updated_by: Option<String>,
233    pub created_at: Option<String>,
234    pub updated_at: Option<String>,
235    pub comments_enabled: Option<bool>,
236    pub competitive: Option<f64>,
237    #[serde(rename = "volume24hr")]
238    pub volume_24hr: Option<f64>,
239    #[serde(rename = "volume1wk")]
240    pub volume_1wk: Option<f64>,
241    #[serde(rename = "volume1mo")]
242    pub volume_1mo: Option<f64>,
243    #[serde(rename = "volume1yr")]
244    pub volume_1yr: Option<f64>,
245    pub featured_image: Option<String>,
246    pub disqus_thread: Option<String>,
247    pub parent_event: Option<String>,
248    pub enable_order_book: Option<bool>,
249    pub liquidity_amm: Option<f64>,
250    pub liquidity_clob: Option<f64>,
251    pub neg_risk: Option<bool>,
252    pub neg_risk_market_id: Option<String>,
253    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
254    pub neg_risk_fee_bips: Option<i64>,
255    #[serde(default)]
256    pub sub_events: Vec<String>,
257    #[serde(default)]
258    pub markets: Vec<Market>,
259    #[serde(default)]
260    pub tags: Vec<Tag>,
261    #[serde(default)]
262    pub series: Vec<SeriesInfo>,
263    pub cyom: Option<bool>,
264    pub closed_time: Option<String>,
265    pub show_all_outcomes: Option<bool>,
266    pub show_market_images: Option<bool>,
267    pub automatically_resolved: Option<bool>,
268    #[serde(rename = "enableNegRisk")]
269    pub enable_neg_risk: Option<bool>,
270    pub automatically_active: Option<bool>,
271    pub event_date: Option<String>,
272    pub start_time: Option<String>,
273    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
274    pub event_week: Option<i64>,
275    pub series_slug: Option<String>,
276    pub score: Option<String>,
277    pub elapsed: Option<String>,
278    pub period: Option<String>,
279    pub live: Option<bool>,
280    pub ended: Option<bool>,
281    pub finished_timestamp: Option<String>,
282    pub gmp_chart_mode: Option<String>,
283    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
284    pub tweet_count: Option<i64>,
285    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
286    pub featured_order: Option<i64>,
287    pub estimate_value: Option<bool>,
288    pub cant_estimate: Option<bool>,
289    pub spreads_main_line: Option<f64>,
290    pub totals_main_line: Option<f64>,
291    pub carousel_map: Option<String>,
292    pub pending_deployment: Option<bool>,
293    pub deploying: Option<bool>,
294    pub deploying_timestamp: Option<String>,
295    pub schedule_deployment_timestamp: Option<String>,
296    pub game_status: Option<String>,
297}
298
299/// Series information within an event
300#[cfg_attr(feature = "specta", derive(specta::Type))]
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct SeriesInfo {
304    pub id: String,
305    pub slug: String,
306    pub title: String,
307    pub ticker: Option<String>,
308    pub series_type: Option<String>,
309    pub recurrence: Option<String>,
310    pub image: Option<String>,
311    pub icon: Option<String>,
312    pub layout: Option<String>,
313    pub active: Option<bool>,
314    pub closed: Option<bool>,
315    pub archived: Option<bool>,
316    pub new: Option<bool>,
317    pub featured: Option<bool>,
318    pub restricted: Option<bool>,
319    pub published_at: Option<String>,
320    pub created_by: Option<String>,
321    pub updated_by: Option<String>,
322    pub created_at: Option<String>,
323    pub updated_at: Option<String>,
324    pub comments_enabled: Option<bool>,
325    pub competitive: Option<String>,
326    #[serde(rename = "volume24hr")]
327    pub volume_24hr: Option<f64>,
328    pub start_date: Option<String>,
329    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
330    pub comment_count: Option<i64>,
331    pub requires_translation: Option<bool>,
332}
333
334/// Series data (tournament/season grouping)
335#[cfg_attr(feature = "specta", derive(specta::Type))]
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct SeriesData {
339    pub id: String,
340    pub slug: String,
341    pub title: String,
342    pub description: Option<String>,
343    pub image: Option<String>,
344    pub icon: Option<String>,
345    pub active: bool,
346    pub closed: bool,
347    pub archived: bool,
348    #[serde(default)]
349    pub tags: Vec<String>,
350    pub volume: Option<f64>,
351    pub liquidity: Option<f64>,
352    #[serde(default)]
353    pub events: Vec<Event>,
354    pub competitive: Option<String>,
355}
356
357/// Abridged series payload returned from `/series-summary/*`.
358///
359/// Note the mixed casing: `eventDates` / `eventWeeks` are camelCase while
360/// `earliest_open_week` / `earliest_open_date` are snake_case in the upstream
361/// response, so this struct does not use `#[serde(rename_all = "camelCase")]`
362/// and instead spells each field out explicitly.
363#[cfg_attr(feature = "specta", derive(specta::Type))]
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
365pub struct SeriesSummary {
366    pub id: String,
367    pub title: Option<String>,
368    pub slug: Option<String>,
369    #[serde(rename = "eventDates", default)]
370    pub event_dates: Vec<String>,
371    #[cfg_attr(feature = "specta", specta(type = Vec<f64>))]
372    #[serde(rename = "eventWeeks", default)]
373    pub event_weeks: Vec<i64>,
374    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
375    pub earliest_open_week: Option<i64>,
376    pub earliest_open_date: Option<String>,
377}
378
379/// Profile returned from `/profiles/user_address/{user_address}`.
380///
381/// All fields except `id` are optional; upstream frequently omits UTM
382/// attribution and certification fields.
383#[cfg_attr(feature = "specta", derive(specta::Type))]
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
385#[serde(rename_all = "camelCase")]
386pub struct Profile {
387    pub id: String,
388    pub name: Option<String>,
389    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
390    pub user: Option<i64>,
391    pub referral: Option<String>,
392    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
393    pub created_by: Option<i64>,
394    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
395    pub updated_by: Option<i64>,
396    pub created_at: Option<String>,
397    pub updated_at: Option<String>,
398    pub utm_source: Option<String>,
399    pub utm_medium: Option<String>,
400    pub utm_campaign: Option<String>,
401    pub utm_content: Option<String>,
402    pub utm_term: Option<String>,
403    pub wallet_activated: Option<bool>,
404    pub pseudonym: Option<String>,
405    pub display_username_public: Option<bool>,
406    pub profile_image: Option<String>,
407    pub bio: Option<String>,
408    pub proxy_wallet: Option<String>,
409    /// ImageOptimization payload; kept as raw JSON since the upstream shape
410    /// is not yet modelled in this crate.
411    #[cfg_attr(feature = "specta", specta(skip))]
412    pub profile_image_optimized: Option<serde_json::Value>,
413    pub is_close_only: Option<bool>,
414    pub is_cert_req: Option<bool>,
415    pub cert_req_date: Option<String>,
416}
417
418/// Tag for categorizing markets/events
419#[cfg_attr(feature = "specta", derive(specta::Type))]
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421#[serde(rename_all = "camelCase")]
422pub struct Tag {
423    pub id: String,
424    pub slug: String,
425    pub label: String,
426    pub force_show: Option<bool>,
427    pub published_at: Option<String>,
428    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
429    pub created_by: Option<u64>,
430    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
431    pub updated_by: Option<u64>,
432    pub created_at: Option<String>,
433    pub updated_at: Option<String>,
434    pub force_hide: Option<bool>,
435    pub is_carousel: Option<bool>,
436}
437
438/// Sports metadata
439#[cfg_attr(feature = "specta", derive(specta::Type))]
440#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct SportMetadata {
443    #[cfg_attr(feature = "specta", specta(type = f64))]
444    pub id: u64,
445    pub sport: String,
446    pub image: Option<String>,
447    pub resolution: Option<String>,
448    pub ordering: Option<String>,
449    pub tags: Option<String>,
450    pub series: Option<String>,
451    pub created_at: Option<String>,
452}
453
454/// Sports team
455#[cfg_attr(feature = "specta", derive(specta::Type))]
456#[derive(Debug, Clone, Serialize, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct Team {
459    #[cfg_attr(feature = "specta", specta(type = f64))]
460    pub id: i64,
461    pub name: Option<String>,
462    pub league: Option<String>,
463    pub record: Option<String>,
464    pub logo: Option<String>,
465    pub abbreviation: Option<String>,
466    pub alias: Option<String>,
467    pub created_at: Option<DateTime<Utc>>,
468    pub updated_at: Option<DateTime<Utc>>,
469}
470
471/// Comment on a market/event/series
472#[cfg_attr(feature = "specta", derive(specta::Type))]
473#[derive(Debug, Clone, Serialize, Deserialize)]
474#[serde(rename_all = "camelCase")]
475pub struct Comment {
476    pub id: String,
477    pub body: String,
478    pub created_at: DateTime<Utc>,
479    pub updated_at: DateTime<Utc>,
480    pub deleted_at: Option<DateTime<Utc>>,
481    pub user: CommentUser,
482    pub market_id: Option<String>,
483    pub event_id: Option<String>,
484    pub series_id: Option<String>,
485    pub parent_id: Option<String>,
486    #[serde(default)]
487    pub reactions: Vec<CommentReaction>,
488    #[serde(default)]
489    pub positions: Vec<CommentPosition>,
490    #[cfg_attr(feature = "specta", specta(type = f64))]
491    pub like_count: u32,
492    #[cfg_attr(feature = "specta", specta(type = f64))]
493    pub dislike_count: u32,
494    #[cfg_attr(feature = "specta", specta(type = f64))]
495    pub reply_count: u32,
496}
497
498/// User who created a comment
499#[cfg_attr(feature = "specta", derive(specta::Type))]
500#[derive(Debug, Clone, Serialize, Deserialize)]
501#[serde(rename_all = "camelCase")]
502pub struct CommentUser {
503    pub id: String,
504    pub name: String,
505    pub avatar: Option<String>,
506}
507
508/// Reaction to a comment
509#[cfg_attr(feature = "specta", derive(specta::Type))]
510#[derive(Debug, Clone, Serialize, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct CommentReaction {
513    pub user_id: String,
514    pub reaction_type: String,
515}
516
517/// Position held by comment author
518#[cfg_attr(feature = "specta", derive(specta::Type))]
519#[derive(Debug, Clone, Serialize, Deserialize)]
520#[serde(rename_all = "camelCase")]
521pub struct CommentPosition {
522    pub token_id: String,
523    pub outcome: String,
524    pub shares: String,
525}
526
527/// Generic count response (used for tweet count, comment count, etc.)
528#[cfg_attr(feature = "specta", derive(specta::Type))]
529#[derive(Debug, Clone, Serialize, Deserialize)]
530#[serde(rename_all = "camelCase")]
531pub struct CountResponse {
532    #[cfg_attr(feature = "specta", specta(type = f64))]
533    pub count: u64,
534}
535
536/// Pagination cursor for list operations
537#[cfg_attr(feature = "specta", derive(specta::Type))]
538#[derive(Debug, Clone, Serialize, Deserialize)]
539#[serde(rename_all = "camelCase")]
540pub struct Cursor {
541    pub next_cursor: Option<String>,
542}
543
544/// Paginated response wrapper
545#[cfg_attr(feature = "specta", derive(specta::Type))]
546#[derive(Debug, Clone, Serialize, Deserialize)]
547#[serde(rename_all = "camelCase")]
548pub struct PaginatedResponse<T> {
549    pub data: Vec<T>,
550    pub next_cursor: Option<String>,
551}
552
553/// Event creator metadata returned from `/events/creators*` endpoints.
554#[cfg_attr(feature = "specta", derive(specta::Type))]
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
556#[serde(rename_all = "camelCase")]
557pub struct EventCreator {
558    pub id: String,
559    pub creator_name: Option<String>,
560    pub creator_handle: Option<String>,
561    pub creator_url: Option<String>,
562    pub creator_image: Option<String>,
563    pub created_at: Option<String>,
564    pub updated_at: Option<String>,
565}
566
567/// Offset-style pagination metadata accompanying `EventsPagination`.
568#[cfg_attr(feature = "specta", derive(specta::Type))]
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
570#[serde(rename_all = "camelCase")]
571pub struct Pagination {
572    pub has_more: Option<bool>,
573    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
574    pub total_results: Option<i64>,
575}
576
577/// Paginated response from `GET /events/pagination`.
578#[cfg_attr(feature = "specta", derive(specta::Type))]
579#[derive(Debug, Clone, Serialize, Deserialize)]
580#[serde(rename_all = "camelCase")]
581pub struct EventsPagination {
582    #[serde(default)]
583    pub data: Vec<Event>,
584    pub pagination: Option<Pagination>,
585}
586
587/// Keyset-paginated events response from `GET /events/keyset`.
588///
589/// `next_cursor` is `None` on the last page. The upstream JSON key is
590/// `next_cursor` (snake_case), not `nextCursor`.
591#[cfg_attr(feature = "specta", derive(specta::Type))]
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct KeysetEventsResponse {
594    #[serde(default)]
595    pub events: Vec<Event>,
596    pub next_cursor: Option<String>,
597}
598
599/// Response body from `GET /markets/{id}/description`.
600///
601/// The endpoint returns `{ "description": "..." }`. The field is modelled as
602/// `Option<String>` so markets without a description still deserialize.
603#[cfg_attr(feature = "specta", derive(specta::Type))]
604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
605#[serde(rename_all = "camelCase")]
606pub struct MarketDescription {
607    pub description: Option<String>,
608}
609
610/// JSON request body for `POST /markets/information` and `POST /markets/abridged`.
611///
612/// Fields are all optional; only set the filters you need. `Vec` fields are
613/// omitted from the serialized payload when empty, and `Option` fields when
614/// `None`, so a default-constructed body serializes to `{}`.
615#[cfg_attr(feature = "specta", derive(specta::Type))]
616#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
617#[serde(rename_all = "camelCase")]
618pub struct MarketsInformationBody {
619    /// Filter by market numeric IDs.
620    #[cfg_attr(feature = "specta", specta(type = Vec<f64>))]
621    #[serde(default, skip_serializing_if = "Vec::is_empty")]
622    pub id: Vec<i64>,
623    /// Filter by market slugs.
624    #[serde(default, skip_serializing_if = "Vec::is_empty")]
625    pub slug: Vec<String>,
626    /// When set, restrict to markets with this closed flag.
627    #[serde(skip_serializing_if = "Option::is_none")]
628    pub closed: Option<bool>,
629    /// Filter by CLOB token IDs.
630    #[serde(default, skip_serializing_if = "Vec::is_empty")]
631    pub clob_token_ids: Vec<String>,
632    /// Filter by condition IDs.
633    #[serde(default, skip_serializing_if = "Vec::is_empty")]
634    pub condition_ids: Vec<String>,
635    /// Filter by market-maker contract addresses.
636    #[serde(default, skip_serializing_if = "Vec::is_empty")]
637    pub market_maker_address: Vec<String>,
638    /// Minimum liquidity.
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub liquidity_num_min: Option<f64>,
641    /// Maximum liquidity.
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub liquidity_num_max: Option<f64>,
644    /// Minimum volume.
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub volume_num_min: Option<f64>,
647    /// Maximum volume.
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub volume_num_max: Option<f64>,
650    /// Minimum start date (ISO-8601).
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub start_date_min: Option<String>,
653    /// Maximum start date (ISO-8601).
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub start_date_max: Option<String>,
656    /// Minimum end date (ISO-8601).
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub end_date_min: Option<String>,
659    /// Maximum end date (ISO-8601).
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub end_date_max: Option<String>,
662    /// Include related-tag matches when filtering by `tag_id`.
663    #[serde(skip_serializing_if = "Option::is_none")]
664    pub related_tags: Option<bool>,
665    /// Tag numeric id to filter by.
666    #[cfg_attr(feature = "specta", specta(type = Option<f64>))]
667    #[serde(skip_serializing_if = "Option::is_none")]
668    pub tag_id: Option<i64>,
669    /// Filter to "create your own market" markets.
670    #[serde(skip_serializing_if = "Option::is_none")]
671    pub cyom: Option<bool>,
672    /// UMA resolution status filter.
673    #[serde(skip_serializing_if = "Option::is_none")]
674    pub uma_resolution_status: Option<String>,
675    /// Game ID filter.
676    #[serde(skip_serializing_if = "Option::is_none")]
677    pub game_id: Option<String>,
678    /// Restrict to these sports market types.
679    #[serde(default, skip_serializing_if = "Vec::is_empty")]
680    pub sports_market_types: Vec<String>,
681    /// Minimum reward size.
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub rewards_min_size: Option<f64>,
684    /// Filter by question IDs.
685    #[serde(default, skip_serializing_if = "Vec::is_empty")]
686    pub question_ids: Vec<String>,
687    /// Include tags in response.
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub include_tags: Option<bool>,
690}
691
692/// Keyset-paginated markets response from `GET /markets/keyset`.
693///
694/// `next_cursor` is `None` on the last page. Like `KeysetEventsResponse`, the
695/// upstream JSON key is `next_cursor` (snake_case), not `nextCursor`.
696#[cfg_attr(feature = "specta", derive(specta::Type))]
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct KeysetMarketsResponse {
699    #[serde(default)]
700    pub markets: Vec<Market>,
701    pub next_cursor: Option<String>,
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    // ── MarketToken ─────────────────────────────────────────────
709
710    #[test]
711    fn test_market_token_deserialization() {
712        let json = r#"{
713            "tokenId": "71321045679252212594626385532706912750332728571942532289631379312455583992563",
714            "outcome": "Yes",
715            "price": "0.55",
716            "winner": false
717        }"#;
718        let token: MarketToken = serde_json::from_str(json).unwrap();
719        assert_eq!(token.outcome, "Yes");
720        assert_eq!(token.price.as_deref(), Some("0.55"));
721        assert_eq!(token.winner, Some(false));
722    }
723
724    #[test]
725    fn test_market_token_optional_fields() {
726        let json = r#"{"tokenId": "123", "outcome": "No"}"#;
727        let token: MarketToken = serde_json::from_str(json).unwrap();
728        assert!(token.price.is_none());
729        assert!(token.winner.is_none());
730    }
731
732    // ── Tag ─────────────────────────────────────────────────────
733
734    #[test]
735    fn test_tag_deserialization() {
736        let json = r#"{
737            "id": "42",
738            "slug": "politics",
739            "label": "Politics",
740            "forceShow": true,
741            "publishedAt": "2024-01-01T00:00:00Z",
742            "createdBy": 1,
743            "updatedBy": 2,
744            "createdAt": "2024-01-01T00:00:00Z",
745            "updatedAt": "2024-06-01T00:00:00Z",
746            "forceHide": false,
747            "isCarousel": true
748        }"#;
749        let tag: Tag = serde_json::from_str(json).unwrap();
750        assert_eq!(tag.slug, "politics");
751        assert_eq!(tag.force_show, Some(true));
752        assert_eq!(tag.is_carousel, Some(true));
753    }
754
755    #[test]
756    fn test_tag_minimal() {
757        let json = r#"{"id": "1", "slug": "test", "label": "Test"}"#;
758        let tag: Tag = serde_json::from_str(json).unwrap();
759        assert_eq!(tag.label, "Test");
760        assert!(tag.force_show.is_none());
761        assert!(tag.created_by.is_none());
762    }
763
764    // ── Market ──────────────────────────────────────────────────
765
766    #[test]
767    fn test_market_minimal_deserialization() {
768        let json = r#"{
769            "id": "12345",
770            "conditionId": "0xabc",
771            "description": "Will X happen?",
772            "question": "Will X happen by end of 2025?",
773            "marketMakerAddress": "0x1234567890abcdef"
774        }"#;
775        let market: Market = serde_json::from_str(json).unwrap();
776        assert_eq!(market.id, "12345");
777        assert_eq!(market.condition_id, "0xabc");
778        assert!(market.tokens.is_empty()); // #[serde(default)]
779        assert!(market.tags.is_empty()); // #[serde(default)]
780        assert!(market.slug.is_none());
781        assert!(market.volume_24hr.is_none());
782    }
783
784    #[test]
785    fn test_market_abridged_without_description_deserializes() {
786        // POST /markets/abridged returns a reduced payload that omits
787        // `description`. Regression test for that schema: `description` must be
788        // optional so the abridged market list still deserializes into `Market`.
789        let json = r#"{
790            "id": "12345",
791            "conditionId": "0xabc",
792            "question": "Will X happen by end of 2025?",
793            "marketMakerAddress": "0x1234567890abcdef",
794            "slug": "will-x-happen",
795            "closed": false
796        }"#;
797        let market: Market = serde_json::from_str(json)
798            .expect("abridged market without description should deserialize");
799        assert_eq!(market.id, "12345");
800        assert_eq!(market.question, "Will X happen by end of 2025?");
801        assert!(market.description.is_none());
802    }
803
804    #[test]
805    fn test_market_with_tokens() {
806        let json = r#"{
807            "id": "1",
808            "conditionId": "0xcond",
809            "description": "Test",
810            "question": "Test?",
811            "marketMakerAddress": "0xaddr",
812            "tokens": [
813                {"tokenId": "t1", "outcome": "Yes", "price": "0.7", "winner": true},
814                {"tokenId": "t2", "outcome": "No", "price": "0.3", "winner": false}
815            ]
816        }"#;
817        let market: Market = serde_json::from_str(json).unwrap();
818        assert_eq!(market.tokens.len(), 2);
819        assert_eq!(market.tokens[0].outcome, "Yes");
820        assert_eq!(market.tokens[1].price.as_deref(), Some("0.3"));
821    }
822
823    #[test]
824    fn test_market_volume_fields() {
825        let json = r#"{
826            "id": "1",
827            "conditionId": "0xcond",
828            "description": "Test",
829            "question": "Test?",
830            "marketMakerAddress": "0xaddr",
831            "volume24hr": 1500.5,
832            "volume1wk": 10000.0,
833            "volume1mo": 50000.0,
834            "volume1yr": 200000.0,
835            "volume24hrAmm": 100.0,
836            "volume1wkClob": 9900.0
837        }"#;
838        let market: Market = serde_json::from_str(json).unwrap();
839        assert_eq!(market.volume_24hr, Some(1500.5));
840        assert_eq!(market.volume_1wk, Some(10000.0));
841        assert_eq!(market.volume_24hr_amm, Some(100.0));
842        assert_eq!(market.volume_1wk_clob, Some(9900.0));
843    }
844
845    #[test]
846    fn test_market_denomination_token_rename() {
847        // API field is "denomationToken" (typo in Polymarket API)
848        let json = r#"{
849            "id": "1",
850            "conditionId": "0xcond",
851            "description": "Test",
852            "question": "Test?",
853            "marketMakerAddress": "0xaddr",
854            "denomationToken": "USDC"
855        }"#;
856        let market: Market = serde_json::from_str(json).unwrap();
857        assert_eq!(market.denomination_token.as_deref(), Some("USDC"));
858    }
859
860    #[test]
861    fn test_market_rewards_as_map() {
862        let json = r#"{
863            "id": "1",
864            "conditionId": "0xcond",
865            "description": "Test",
866            "question": "Test?",
867            "marketMakerAddress": "0xaddr",
868            "rewards": {"min_size": "100", "max_spread": "0.05"}
869        }"#;
870        let market: Market = serde_json::from_str(json).unwrap();
871        assert!(market.rewards.is_some());
872        let rewards = market.rewards.unwrap();
873        assert_eq!(rewards["min_size"], "100");
874    }
875
876    #[test]
877    fn test_market_null_rewards() {
878        let json = r#"{
879            "id": "1",
880            "conditionId": "0xcond",
881            "description": "Test",
882            "question": "Test?",
883            "marketMakerAddress": "0xaddr",
884            "rewards": null
885        }"#;
886        let market: Market = serde_json::from_str(json).unwrap();
887        assert!(market.rewards.is_none());
888    }
889
890    // ── Event ───────────────────────────────────────────────────
891
892    #[test]
893    fn test_event_minimal() {
894        let json = r#"{"id": "evt-1"}"#;
895        let event: Event = serde_json::from_str(json).unwrap();
896        assert_eq!(event.id, "evt-1");
897        assert!(event.markets.is_empty()); // #[serde(default)]
898        assert!(event.tags.is_empty());
899        assert!(event.series.is_empty());
900        assert!(event.sub_events.is_empty());
901    }
902
903    #[test]
904    fn test_event_with_nested_markets() {
905        let json = r#"{
906            "id": "evt-1",
907            "title": "2025 Election",
908            "markets": [
909                {
910                    "id": "mkt-1",
911                    "conditionId": "0xabc",
912                    "description": "Who wins?",
913                    "question": "Who wins the election?",
914                    "marketMakerAddress": "0xaddr"
915                }
916            ]
917        }"#;
918        let event: Event = serde_json::from_str(json).unwrap();
919        assert_eq!(event.markets.len(), 1);
920        assert_eq!(event.markets[0].id, "mkt-1");
921    }
922
923    #[test]
924    fn test_event_volume_24h_rename() {
925        let json = r#"{
926            "id": "evt-1",
927            "volume24hr": 5000.0
928        }"#;
929        let event: Event = serde_json::from_str(json).unwrap();
930        assert_eq!(event.volume_24hr, Some(5000.0));
931    }
932
933    #[test]
934    fn test_event_volume_24h_old_key_ignored() {
935        // The old key "volume24h" should NOT deserialize into volume_24hr
936        let json = r#"{
937            "id": "evt-1",
938            "volume24h": 5000.0
939        }"#;
940        let event: Event = serde_json::from_str(json).unwrap();
941        assert_eq!(event.volume_24hr, None);
942    }
943
944    #[test]
945    fn test_event_enable_neg_risk_rename() {
946        let json = r#"{
947            "id": "evt-1",
948            "enableNegRisk": true
949        }"#;
950        let event: Event = serde_json::from_str(json).unwrap();
951        assert_eq!(event.enable_neg_risk, Some(true));
952    }
953
954    #[test]
955    fn test_event_published_at_snake_case() {
956        let json = r#"{
957            "id": "evt-1",
958            "published_at": "2024-01-01T00:00:00Z"
959        }"#;
960        let event: Event = serde_json::from_str(json).unwrap();
961        assert_eq!(event.published_at.as_deref(), Some("2024-01-01T00:00:00Z"));
962    }
963
964    #[test]
965    fn test_market_question_id_capital() {
966        let json = r#"{
967            "id": "1",
968            "conditionId": "0xcond",
969            "description": "Test",
970            "question": "Test?",
971            "marketMakerAddress": "0xaddr",
972            "questionID": "0xabc123"
973        }"#;
974        let market: Market = serde_json::from_str(json).unwrap();
975        assert_eq!(market.question_id.as_deref(), Some("0xabc123"));
976    }
977
978    #[test]
979    fn test_market_has_reviewed_dates() {
980        let json = r#"{
981            "id": "1",
982            "conditionId": "0xcond",
983            "description": "Test",
984            "question": "Test?",
985            "marketMakerAddress": "0xaddr",
986            "hasReviewedDates": true
987        }"#;
988        let market: Market = serde_json::from_str(json).unwrap();
989        assert_eq!(market.has_reviewed_dates, Some(true));
990    }
991
992    #[test]
993    fn test_market_rewards_max_spread_singular() {
994        let json = r#"{
995            "id": "1",
996            "conditionId": "0xcond",
997            "description": "Test",
998            "question": "Test?",
999            "marketMakerAddress": "0xaddr",
1000            "rewardsMaxSpread": 0.05
1001        }"#;
1002        let market: Market = serde_json::from_str(json).unwrap();
1003        assert_eq!(market.rewards_max_spread, Some(0.05));
1004    }
1005
1006    #[test]
1007    fn test_market_submitted_by_snake_case() {
1008        let json = r#"{
1009            "id": "1",
1010            "conditionId": "0xcond",
1011            "description": "Test",
1012            "question": "Test?",
1013            "marketMakerAddress": "0xaddr",
1014            "submitted_by": "0xdeadbeef"
1015        }"#;
1016        let market: Market = serde_json::from_str(json).unwrap();
1017        assert_eq!(market.submitted_by.as_deref(), Some("0xdeadbeef"));
1018    }
1019
1020    // ── SeriesInfo ──────────────────────────────────────────────
1021
1022    #[test]
1023    fn test_series_info_minimal() {
1024        let json = r#"{"id": "s1", "slug": "nfl-2025", "title": "NFL 2025"}"#;
1025        let si: SeriesInfo = serde_json::from_str(json).unwrap();
1026        assert_eq!(si.slug, "nfl-2025");
1027        assert_eq!(si.title, "NFL 2025");
1028        assert!(si.ticker.is_none());
1029        assert!(si.active.is_none());
1030    }
1031
1032    #[test]
1033    fn test_series_info_full() {
1034        let json = r#"{
1035            "id": "2",
1036            "ticker": "nba",
1037            "slug": "nba",
1038            "title": "NBA",
1039            "seriesType": "single",
1040            "recurrence": "daily",
1041            "image": "https://example.com/nba.png",
1042            "icon": "https://example.com/nba-icon.png",
1043            "layout": "default",
1044            "active": true,
1045            "closed": false,
1046            "archived": false,
1047            "new": false,
1048            "featured": false,
1049            "restricted": true,
1050            "publishedAt": "2023-01-30T17:13:39Z",
1051            "createdBy": "15",
1052            "updatedBy": "15",
1053            "createdAt": "2022-10-13T00:36:01Z",
1054            "updatedAt": "2026-03-04T12:03:42Z",
1055            "commentsEnabled": false,
1056            "competitive": "0",
1057            "volume24hr": 11.07,
1058            "startDate": "2021-01-01T17:00:00Z",
1059            "commentCount": 6274,
1060            "requiresTranslation": false
1061        }"#;
1062        let si: SeriesInfo = serde_json::from_str(json).unwrap();
1063        assert_eq!(si.ticker.as_deref(), Some("nba"));
1064        assert_eq!(si.series_type.as_deref(), Some("single"));
1065        assert_eq!(si.active, Some(true));
1066        assert_eq!(si.closed, Some(false));
1067        assert_eq!(si.volume_24hr, Some(11.07));
1068        assert_eq!(si.comment_count, Some(6274));
1069        assert_eq!(si.requires_translation, Some(false));
1070    }
1071
1072    // ── Negative tests: old/wrong field names are rejected ─────
1073
1074    #[test]
1075    fn test_market_old_question_id_ignored() {
1076        // camelCase "questionId" should NOT match — API uses "questionID"
1077        let json = r#"{
1078            "id": "1",
1079            "conditionId": "0xcond",
1080            "description": "Test",
1081            "question": "Test?",
1082            "marketMakerAddress": "0xaddr",
1083            "questionId": "0xwrong"
1084        }"#;
1085        let market: Market = serde_json::from_str(json).unwrap();
1086        assert!(market.question_id.is_none());
1087    }
1088
1089    #[test]
1090    fn test_market_old_has_review_dates_ignored() {
1091        // "hasReviewDates" should NOT match — API uses "hasReviewedDates"
1092        let json = r#"{
1093            "id": "1",
1094            "conditionId": "0xcond",
1095            "description": "Test",
1096            "question": "Test?",
1097            "marketMakerAddress": "0xaddr",
1098            "hasReviewDates": true
1099        }"#;
1100        let market: Market = serde_json::from_str(json).unwrap();
1101        assert!(market.has_reviewed_dates.is_none());
1102    }
1103
1104    #[test]
1105    fn test_market_old_rewards_max_spreads_ignored() {
1106        // "rewardsMaxSpreads" (plural) should NOT match — API uses "rewardsMaxSpread"
1107        let json = r#"{
1108            "id": "1",
1109            "conditionId": "0xcond",
1110            "description": "Test",
1111            "question": "Test?",
1112            "marketMakerAddress": "0xaddr",
1113            "rewardsMaxSpreads": 0.05
1114        }"#;
1115        let market: Market = serde_json::from_str(json).unwrap();
1116        assert!(market.rewards_max_spread.is_none());
1117    }
1118
1119    #[test]
1120    fn test_event_old_enalbe_neg_risk_ignored() {
1121        // Old typo "enalbeNegRisk" should NOT match after fix
1122        let json = r#"{
1123            "id": "evt-1",
1124            "enalbeNegRisk": true
1125        }"#;
1126        let event: Event = serde_json::from_str(json).unwrap();
1127        assert!(event.enable_neg_risk.is_none());
1128    }
1129
1130    #[test]
1131    fn test_event_camel_published_at_ignored() {
1132        // camelCase "publishedAt" should NOT match — Event API uses "published_at"
1133        let json = r#"{
1134            "id": "evt-1",
1135            "publishedAt": "2024-01-01T00:00:00Z"
1136        }"#;
1137        let event: Event = serde_json::from_str(json).unwrap();
1138        assert!(event.published_at.is_none());
1139    }
1140
1141    #[test]
1142    fn test_market_camel_submitted_by_ignored() {
1143        // camelCase "submittedBy" should NOT match — API uses "submitted_by"
1144        let json = r#"{
1145            "id": "1",
1146            "conditionId": "0xcond",
1147            "description": "Test",
1148            "question": "Test?",
1149            "marketMakerAddress": "0xaddr",
1150            "submittedBy": "0xwrong"
1151        }"#;
1152        let market: Market = serde_json::from_str(json).unwrap();
1153        assert!(market.submitted_by.is_none());
1154    }
1155
1156    // ── SeriesData ──────────────────────────────────────────────
1157
1158    #[test]
1159    fn test_series_data_minimal() {
1160        let json = r#"{
1161            "id": "s1",
1162            "slug": "nfl",
1163            "title": "NFL",
1164            "active": true,
1165            "closed": false,
1166            "archived": false
1167        }"#;
1168        let sd: SeriesData = serde_json::from_str(json).unwrap();
1169        assert!(sd.active);
1170        assert!(!sd.closed);
1171        assert!(sd.events.is_empty()); // #[serde(default)]
1172        assert!(sd.tags.is_empty());
1173    }
1174
1175    // ── SportMetadata ───────────────────────────────────────────
1176
1177    #[test]
1178    fn test_sport_metadata() {
1179        let json = r#"{
1180            "id": 1,
1181            "sport": "Basketball",
1182            "image": "https://example.com/nba.png",
1183            "createdAt": "2024-01-01T00:00:00Z"
1184        }"#;
1185        let sm: SportMetadata = serde_json::from_str(json).unwrap();
1186        assert_eq!(sm.id, 1);
1187        assert_eq!(sm.sport, "Basketball");
1188    }
1189
1190    // ── Team ────────────────────────────────────────────────────
1191
1192    #[test]
1193    fn test_team() {
1194        let json = r#"{
1195            "id": 42,
1196            "name": "Lakers",
1197            "league": "NBA",
1198            "abbreviation": "LAL",
1199            "createdAt": "2024-01-01T00:00:00Z",
1200            "updatedAt": "2024-06-15T12:00:00Z"
1201        }"#;
1202        let team: Team = serde_json::from_str(json).unwrap();
1203        assert_eq!(team.id, 42);
1204        assert_eq!(team.name.as_deref(), Some("Lakers"));
1205        assert!(team.created_at.is_some());
1206    }
1207
1208    // ── Comment ─────────────────────────────────────────────────
1209
1210    #[test]
1211    fn test_comment_deserialization() {
1212        let json = r#"{
1213            "id": "c1",
1214            "body": "I think this market will resolve yes.",
1215            "createdAt": "2024-06-01T10:00:00Z",
1216            "updatedAt": "2024-06-01T10:00:00Z",
1217            "deletedAt": null,
1218            "user": {"id": "u1", "name": "trader1", "avatar": null},
1219            "marketId": "mkt-1",
1220            "eventId": null,
1221            "seriesId": null,
1222            "parentId": null,
1223            "reactions": [],
1224            "positions": [
1225                {"tokenId": "t1", "outcome": "Yes", "shares": "100.5"}
1226            ],
1227            "likeCount": 5,
1228            "dislikeCount": 1,
1229            "replyCount": 3
1230        }"#;
1231        let comment: Comment = serde_json::from_str(json).unwrap();
1232        assert_eq!(comment.id, "c1");
1233        assert_eq!(comment.user.name, "trader1");
1234        assert_eq!(comment.like_count, 5);
1235        assert_eq!(comment.positions.len(), 1);
1236        assert_eq!(comment.positions[0].shares, "100.5");
1237        assert!(comment.deleted_at.is_none());
1238    }
1239
1240    // ── UserResponse ────────────────────────────────────────────
1241
1242    #[test]
1243    fn test_user_response() {
1244        let json = r#"{
1245            "proxyWallet": "0xproxy",
1246            "address": "0xsigner",
1247            "id": "u1",
1248            "name": "polytrader"
1249        }"#;
1250        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1251        assert_eq!(user.proxy.as_deref(), Some("0xproxy"));
1252        assert_eq!(user.name.as_deref(), Some("polytrader"));
1253    }
1254
1255    #[test]
1256    fn test_user_response_all_null() {
1257        let json = r#"{}"#;
1258        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1259        assert!(user.proxy.is_none());
1260        assert!(user.address.is_none());
1261        assert!(user.id.is_none());
1262        assert!(user.name.is_none());
1263        assert!(user.created_at.is_none());
1264        assert!(user.profile_image.is_none());
1265        assert!(user.display_username_public.is_none());
1266        assert!(user.bio.is_none());
1267        assert!(user.pseudonym.is_none());
1268        assert!(user.x_username.is_none());
1269        assert!(user.verified_badge.is_none());
1270        assert!(user.users.is_empty());
1271    }
1272
1273    #[test]
1274    fn test_user_response_full_profile() {
1275        let json = r#"{
1276            "proxyWallet": "0xproxy",
1277            "address": "0xsigner",
1278            "id": "u1",
1279            "name": "polytrader",
1280            "createdAt": "2024-01-15T10:00:00Z",
1281            "profileImage": "https://example.com/avatar.png",
1282            "displayUsernamePublic": true,
1283            "bio": "DeFi enthusiast",
1284            "pseudonym": "poly_anon",
1285            "xUsername": "polytrader_x",
1286            "verifiedBadge": true,
1287            "users": [
1288                {"id": "uid-1", "creator": true, "mod": false},
1289                {"id": "uid-2", "creator": false, "mod": true}
1290            ]
1291        }"#;
1292        let user: crate::api::user::UserResponse = serde_json::from_str(json).unwrap();
1293        assert_eq!(user.proxy.as_deref(), Some("0xproxy"));
1294        assert_eq!(user.name.as_deref(), Some("polytrader"));
1295        assert_eq!(user.created_at.as_deref(), Some("2024-01-15T10:00:00Z"));
1296        assert_eq!(
1297            user.profile_image.as_deref(),
1298            Some("https://example.com/avatar.png")
1299        );
1300        assert_eq!(user.display_username_public, Some(true));
1301        assert_eq!(user.bio.as_deref(), Some("DeFi enthusiast"));
1302        assert_eq!(user.pseudonym.as_deref(), Some("poly_anon"));
1303        assert_eq!(user.x_username.as_deref(), Some("polytrader_x"));
1304        assert_eq!(user.verified_badge, Some(true));
1305        assert_eq!(user.users.len(), 2);
1306        assert!(user.users[0].creator);
1307        assert!(!user.users[0].moderator);
1308        assert!(!user.users[1].creator);
1309        assert!(user.users[1].moderator);
1310    }
1311
1312    #[test]
1313    fn test_user_info_deserialization() {
1314        let json = r#"{"id": "uid-1", "creator": true, "mod": false}"#;
1315        let info: crate::api::user::UserInfo = serde_json::from_str(json).unwrap();
1316        assert_eq!(info.id.as_deref(), Some("uid-1"));
1317        assert!(info.creator);
1318        assert!(!info.moderator);
1319    }
1320
1321    #[test]
1322    fn test_user_info_defaults() {
1323        let json = r#"{}"#;
1324        let info: crate::api::user::UserInfo = serde_json::from_str(json).unwrap();
1325        assert!(info.id.is_none());
1326        assert!(!info.creator);
1327        assert!(!info.moderator);
1328    }
1329
1330    // ── CountResponse ────────────────────────────────────────────
1331
1332    #[test]
1333    fn test_count_response() {
1334        let json = r#"{"count": 42}"#;
1335        let resp: CountResponse = serde_json::from_str(json).unwrap();
1336        assert_eq!(resp.count, 42);
1337    }
1338
1339    // ── Cursor / PaginatedResponse ──────────────────────────────
1340
1341    #[test]
1342    fn test_cursor_with_next() {
1343        let json = r#"{"nextCursor": "abc123"}"#;
1344        let cursor: Cursor = serde_json::from_str(json).unwrap();
1345        assert_eq!(cursor.next_cursor.as_deref(), Some("abc123"));
1346    }
1347
1348    #[test]
1349    fn test_cursor_without_next() {
1350        let json = r#"{"nextCursor": null}"#;
1351        let cursor: Cursor = serde_json::from_str(json).unwrap();
1352        assert!(cursor.next_cursor.is_none());
1353    }
1354
1355    #[test]
1356    fn test_paginated_response() {
1357        let json = r#"{
1358            "data": [{"tokenId": "t1", "outcome": "Yes"}],
1359            "nextCursor": "page2"
1360        }"#;
1361        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
1362        assert_eq!(resp.data.len(), 1);
1363        assert_eq!(resp.next_cursor.as_deref(), Some("page2"));
1364    }
1365
1366    #[test]
1367    fn test_paginated_response_empty() {
1368        let json = r#"{"data": [], "nextCursor": null}"#;
1369        let resp: PaginatedResponse<MarketToken> = serde_json::from_str(json).unwrap();
1370        assert!(resp.data.is_empty());
1371        assert!(resp.next_cursor.is_none());
1372    }
1373
1374    // ── Serialization round-trip ────────────────────────────────
1375
1376    #[test]
1377    fn test_market_token_roundtrip() {
1378        let token = MarketToken {
1379            token_id: "123".into(),
1380            outcome: "Yes".into(),
1381            price: Some("0.75".into()),
1382            winner: Some(true),
1383        };
1384        let json = serde_json::to_string(&token).unwrap();
1385        let back: MarketToken = serde_json::from_str(&json).unwrap();
1386        assert_eq!(token, back);
1387    }
1388
1389    // ── EventCreator ────────────────────────────────────────────
1390
1391    #[test]
1392    fn test_event_creator_full() {
1393        let json = r#"{
1394            "id": "7",
1395            "creatorName": "Polymarket Sports",
1396            "creatorHandle": "poly_sports",
1397            "creatorUrl": "https://example.com",
1398            "creatorImage": "https://example.com/a.png",
1399            "createdAt": "2024-01-01T00:00:00Z",
1400            "updatedAt": "2024-06-01T00:00:00Z"
1401        }"#;
1402        let c: EventCreator = serde_json::from_str(json).unwrap();
1403        assert_eq!(c.id, "7");
1404        assert_eq!(c.creator_name.as_deref(), Some("Polymarket Sports"));
1405        assert_eq!(c.creator_handle.as_deref(), Some("poly_sports"));
1406        assert_eq!(c.creator_url.as_deref(), Some("https://example.com"));
1407    }
1408
1409    #[test]
1410    fn test_event_creator_minimal() {
1411        let json = r#"{"id": "1"}"#;
1412        let c: EventCreator = serde_json::from_str(json).unwrap();
1413        assert_eq!(c.id, "1");
1414        assert!(c.creator_name.is_none());
1415        assert!(c.creator_handle.is_none());
1416        assert!(c.created_at.is_none());
1417    }
1418
1419    #[test]
1420    fn test_event_creator_roundtrip() {
1421        let c = EventCreator {
1422            id: "9".into(),
1423            creator_name: Some("Poly".into()),
1424            creator_handle: Some("poly".into()),
1425            creator_url: None,
1426            creator_image: None,
1427            created_at: None,
1428            updated_at: None,
1429        };
1430        let json = serde_json::to_string(&c).unwrap();
1431        let back: EventCreator = serde_json::from_str(&json).unwrap();
1432        assert_eq!(c, back);
1433    }
1434
1435    // ── Pagination / EventsPagination ───────────────────────────
1436
1437    #[test]
1438    fn test_pagination_deserialization() {
1439        let json = r#"{"hasMore": true, "totalResults": 124}"#;
1440        let p: Pagination = serde_json::from_str(json).unwrap();
1441        assert_eq!(p.has_more, Some(true));
1442        assert_eq!(p.total_results, Some(124));
1443    }
1444
1445    #[test]
1446    fn test_events_pagination_deserialization() {
1447        let json = r#"{
1448            "data": [{"id": "e1"}, {"id": "e2"}],
1449            "pagination": {"hasMore": false, "totalResults": 2}
1450        }"#;
1451        let ep: EventsPagination = serde_json::from_str(json).unwrap();
1452        assert_eq!(ep.data.len(), 2);
1453        assert_eq!(ep.data[0].id, "e1");
1454        let p = ep.pagination.unwrap();
1455        assert_eq!(p.has_more, Some(false));
1456        assert_eq!(p.total_results, Some(2));
1457    }
1458
1459    #[test]
1460    fn test_events_pagination_empty_data() {
1461        let json = r#"{"pagination": {"hasMore": false, "totalResults": 0}}"#;
1462        let ep: EventsPagination = serde_json::from_str(json).unwrap();
1463        assert!(ep.data.is_empty());
1464    }
1465
1466    // ── KeysetEventsResponse ────────────────────────────────────
1467
1468    #[test]
1469    fn test_keyset_events_response_with_cursor() {
1470        // Upstream uses snake_case next_cursor (not nextCursor).
1471        let json = r#"{
1472            "events": [{"id": "e1", "title": "Test"}],
1473            "next_cursor": "cursor-123"
1474        }"#;
1475        let resp: KeysetEventsResponse = serde_json::from_str(json).unwrap();
1476        assert_eq!(resp.events.len(), 1);
1477        assert_eq!(resp.next_cursor.as_deref(), Some("cursor-123"));
1478    }
1479
1480    #[test]
1481    fn test_keyset_events_response_last_page() {
1482        let json = r#"{"events": []}"#;
1483        let resp: KeysetEventsResponse = serde_json::from_str(json).unwrap();
1484        assert!(resp.events.is_empty());
1485        assert!(resp.next_cursor.is_none());
1486    }
1487
1488    #[test]
1489    fn test_tag_roundtrip() {
1490        let tag = Tag {
1491            id: "1".into(),
1492            slug: "test".into(),
1493            label: "Test".into(),
1494            force_show: None,
1495            published_at: None,
1496            created_by: None,
1497            updated_by: None,
1498            created_at: None,
1499            updated_at: None,
1500            force_hide: None,
1501            is_carousel: None,
1502        };
1503        let json = serde_json::to_string(&tag).unwrap();
1504        let back: Tag = serde_json::from_str(&json).unwrap();
1505        assert_eq!(tag, back);
1506    }
1507
1508    // ── MarketDescription ───────────────────────────────────────
1509
1510    #[test]
1511    fn test_market_description_deserialization() {
1512        let json = r#"{"description": "Will X happen?"}"#;
1513        let md: MarketDescription = serde_json::from_str(json).unwrap();
1514        assert_eq!(md.description.as_deref(), Some("Will X happen?"));
1515    }
1516
1517    #[test]
1518    fn test_market_description_null() {
1519        let json = r#"{"description": null}"#;
1520        let md: MarketDescription = serde_json::from_str(json).unwrap();
1521        assert!(md.description.is_none());
1522    }
1523
1524    // ── MarketsInformationBody ──────────────────────────────────
1525
1526    #[test]
1527    fn test_markets_information_body_default_serializes_empty() {
1528        let body = MarketsInformationBody::default();
1529        let json = serde_json::to_string(&body).unwrap();
1530        assert_eq!(json, "{}");
1531    }
1532
1533    #[test]
1534    fn test_markets_information_body_populated_roundtrip() {
1535        let body = MarketsInformationBody {
1536            id: vec![1, 2, 3],
1537            slug: vec!["will-x".into(), "will-y".into()],
1538            closed: Some(true),
1539            clob_token_ids: vec!["tok-a".into()],
1540            condition_ids: vec!["0xcond".into()],
1541            market_maker_address: vec!["0xmm".into()],
1542            liquidity_num_min: Some(100.0),
1543            liquidity_num_max: Some(10_000.0),
1544            volume_num_min: Some(50.0),
1545            volume_num_max: None,
1546            start_date_min: Some("2025-01-01T00:00:00Z".into()),
1547            start_date_max: None,
1548            end_date_min: None,
1549            end_date_max: Some("2026-01-01T00:00:00Z".into()),
1550            related_tags: Some(true),
1551            tag_id: Some(42),
1552            cyom: Some(false),
1553            uma_resolution_status: Some("resolved".into()),
1554            game_id: Some("game-7".into()),
1555            sports_market_types: vec!["moneyline".into(), "spread".into()],
1556            rewards_min_size: Some(10.0),
1557            question_ids: vec!["q1".into()],
1558            include_tags: Some(true),
1559        };
1560        let json = serde_json::to_string(&body).unwrap();
1561        // Field names are camelCase (from openapi)
1562        assert!(json.contains("\"clobTokenIds\""));
1563        assert!(json.contains("\"marketMakerAddress\""));
1564        assert!(json.contains("\"rewardsMinSize\""));
1565        let back: MarketsInformationBody = serde_json::from_str(&json).unwrap();
1566        assert_eq!(body, back);
1567    }
1568
1569    #[test]
1570    fn test_markets_information_body_partial_omits_empty() {
1571        let body = MarketsInformationBody {
1572            closed: Some(true),
1573            ..Default::default()
1574        };
1575        let json = serde_json::to_string(&body).unwrap();
1576        assert_eq!(json, r#"{"closed":true}"#);
1577    }
1578
1579    // ── KeysetMarketsResponse ───────────────────────────────────
1580
1581    #[test]
1582    fn test_keyset_markets_response_full() {
1583        let json = r#"{
1584            "markets": [{
1585                "id": "1",
1586                "conditionId": "0xcond",
1587                "description": "desc",
1588                "question": "q?",
1589                "marketMakerAddress": "0xmm"
1590            }],
1591            "next_cursor": "abc"
1592        }"#;
1593        let resp: KeysetMarketsResponse = serde_json::from_str(json).unwrap();
1594        assert_eq!(resp.markets.len(), 1);
1595        assert_eq!(resp.markets[0].id, "1");
1596        assert_eq!(resp.next_cursor.as_deref(), Some("abc"));
1597    }
1598
1599    #[test]
1600    fn test_keyset_markets_response_last_page() {
1601        let json = r#"{"markets": []}"#;
1602        let resp: KeysetMarketsResponse = serde_json::from_str(json).unwrap();
1603        assert!(resp.markets.is_empty());
1604        assert!(resp.next_cursor.is_none());
1605    }
1606
1607    // ── SeriesSummary ───────────────────────────────────────────
1608
1609    #[test]
1610    fn test_series_summary_full() {
1611        let json = r#"{
1612            "id": "s-1",
1613            "title": "NFL 2025",
1614            "slug": "nfl-2025",
1615            "eventDates": ["2025-09-01", "2025-09-08"],
1616            "eventWeeks": [1, 2],
1617            "earliest_open_week": 1,
1618            "earliest_open_date": "2025-09-01"
1619        }"#;
1620        let s: SeriesSummary = serde_json::from_str(json).unwrap();
1621        assert_eq!(s.id, "s-1");
1622        assert_eq!(s.title.as_deref(), Some("NFL 2025"));
1623        assert_eq!(s.event_dates.len(), 2);
1624        assert_eq!(s.event_weeks, vec![1, 2]);
1625        assert_eq!(s.earliest_open_week, Some(1));
1626    }
1627
1628    #[test]
1629    fn test_series_summary_minimal() {
1630        let json = r#"{"id": "s-1"}"#;
1631        let s: SeriesSummary = serde_json::from_str(json).unwrap();
1632        assert_eq!(s.id, "s-1");
1633        assert!(s.title.is_none());
1634        assert!(s.event_dates.is_empty());
1635        assert!(s.event_weeks.is_empty());
1636    }
1637
1638    #[test]
1639    fn test_series_summary_roundtrip_preserves_mixed_casing() {
1640        let s = SeriesSummary {
1641            id: "s-1".into(),
1642            title: Some("T".into()),
1643            slug: Some("slug".into()),
1644            event_dates: vec!["2025-09-01".into()],
1645            event_weeks: vec![1],
1646            earliest_open_week: Some(1),
1647            earliest_open_date: Some("2025-09-01".into()),
1648        };
1649        let json = serde_json::to_string(&s).unwrap();
1650        // eventDates camelCase, earliest_open_* snake_case
1651        assert!(json.contains("\"eventDates\""));
1652        assert!(json.contains("\"eventWeeks\""));
1653        assert!(json.contains("\"earliest_open_week\""));
1654        assert!(json.contains("\"earliest_open_date\""));
1655        let back: SeriesSummary = serde_json::from_str(&json).unwrap();
1656        assert_eq!(s, back);
1657    }
1658
1659    // ── Profile ─────────────────────────────────────────────────
1660
1661    #[test]
1662    fn test_profile_minimal() {
1663        let json = r#"{"id": "p-1"}"#;
1664        let p: Profile = serde_json::from_str(json).unwrap();
1665        assert_eq!(p.id, "p-1");
1666        assert!(p.name.is_none());
1667        assert!(p.proxy_wallet.is_none());
1668    }
1669
1670    #[test]
1671    fn test_profile_full() {
1672        let json = r#"{
1673            "id": "p-1",
1674            "name": "Alice",
1675            "user": 42,
1676            "proxyWallet": "0xdead",
1677            "utmSource": "direct",
1678            "walletActivated": true,
1679            "isCloseOnly": false,
1680            "profileImageOptimized": {"medium": "https://cdn/p/m.png"}
1681        }"#;
1682        let p: Profile = serde_json::from_str(json).unwrap();
1683        assert_eq!(p.name.as_deref(), Some("Alice"));
1684        assert_eq!(p.user, Some(42));
1685        assert_eq!(p.proxy_wallet.as_deref(), Some("0xdead"));
1686        assert_eq!(p.utm_source.as_deref(), Some("direct"));
1687        assert_eq!(p.wallet_activated, Some(true));
1688        assert_eq!(p.is_close_only, Some(false));
1689        assert!(p.profile_image_optimized.is_some());
1690    }
1691
1692    #[test]
1693    fn test_profile_roundtrip() {
1694        let p = Profile {
1695            id: "p-1".into(),
1696            name: Some("A".into()),
1697            user: Some(1),
1698            referral: None,
1699            created_by: None,
1700            updated_by: None,
1701            created_at: None,
1702            updated_at: None,
1703            utm_source: None,
1704            utm_medium: None,
1705            utm_campaign: None,
1706            utm_content: None,
1707            utm_term: None,
1708            wallet_activated: Some(true),
1709            pseudonym: None,
1710            display_username_public: None,
1711            profile_image: None,
1712            bio: None,
1713            proxy_wallet: Some("0xpw".into()),
1714            profile_image_optimized: None,
1715            is_close_only: None,
1716            is_cert_req: None,
1717            cert_req_date: None,
1718        };
1719        let json = serde_json::to_string(&p).unwrap();
1720        assert!(json.contains("\"proxyWallet\""));
1721        assert!(json.contains("\"walletActivated\""));
1722        let back: Profile = serde_json::from_str(&json).unwrap();
1723        assert_eq!(p, back);
1724    }
1725}