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