Skip to main content

polyoxide_data/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// User's total position value
4#[cfg_attr(feature = "specta", derive(specta::Type))]
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct UserValue {
7    /// User address
8    pub user: String,
9    /// Total value of positions
10    pub value: f64,
11}
12
13/// Open interest for a market
14#[cfg_attr(feature = "specta", derive(specta::Type))]
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct OpenInterest {
17    /// Market condition ID
18    pub market: String,
19    /// Open interest value
20    pub value: f64,
21}
22
23/// Sort field options for position queries
24#[cfg_attr(feature = "specta", derive(specta::Type))]
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
27pub enum PositionSortBy {
28    /// Sort by current value
29    Current,
30    /// Sort by initial value
31    Initial,
32    /// Sort by token count
33    Tokens,
34    /// Sort by cash P&L
35    CashPnl,
36    /// Sort by percentage P&L
37    PercentPnl,
38    /// Sort by market title
39    Title,
40    /// Sort by resolving status
41    Resolving,
42    /// Sort by price
43    Price,
44    /// Sort by average price
45    AvgPrice,
46}
47
48impl std::fmt::Display for PositionSortBy {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::Current => write!(f, "CURRENT"),
52            Self::Initial => write!(f, "INITIAL"),
53            Self::Tokens => write!(f, "TOKENS"),
54            Self::CashPnl => write!(f, "CASH_PNL"),
55            Self::PercentPnl => write!(f, "PERCENT_PNL"),
56            Self::Title => write!(f, "TITLE"),
57            Self::Resolving => write!(f, "RESOLVING"),
58            Self::Price => write!(f, "PRICE"),
59            Self::AvgPrice => write!(f, "AVG_PRICE"),
60        }
61    }
62}
63
64/// Sort direction for queries
65#[cfg_attr(feature = "specta", derive(specta::Type))]
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
67#[serde(rename_all = "UPPERCASE")]
68pub enum SortDirection {
69    /// Ascending order
70    Asc,
71    /// Descending order (default)
72    #[default]
73    Desc,
74}
75
76impl std::fmt::Display for SortDirection {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::Asc => write!(f, "ASC"),
80            Self::Desc => write!(f, "DESC"),
81        }
82    }
83}
84
85/// Sort field options for closed position queries
86#[cfg_attr(feature = "specta", derive(specta::Type))]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
88#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
89pub enum ClosedPositionSortBy {
90    /// Sort by realized P&L (default)
91    #[default]
92    RealizedPnl,
93    /// Sort by market title
94    Title,
95    /// Sort by price
96    Price,
97    /// Sort by average price
98    AvgPrice,
99    /// Sort by timestamp
100    Timestamp,
101}
102
103impl std::fmt::Display for ClosedPositionSortBy {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::RealizedPnl => write!(f, "REALIZED_PNL"),
107            Self::Title => write!(f, "TITLE"),
108            Self::Price => write!(f, "PRICE"),
109            Self::AvgPrice => write!(f, "AVG_PRICE"),
110            Self::Timestamp => write!(f, "TIMESTAMP"),
111        }
112    }
113}
114
115/// Closed position record
116#[cfg_attr(feature = "specta", derive(specta::Type))]
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct ClosedPosition {
120    /// Proxy wallet address
121    pub proxy_wallet: String,
122    /// Asset identifier (token ID)
123    pub asset: String,
124    /// Condition ID of the market
125    pub condition_id: String,
126    /// Average entry price
127    pub avg_price: f64,
128    /// Total amount bought
129    pub total_bought: f64,
130    /// Realized profit and loss
131    pub realized_pnl: f64,
132    /// Current market price
133    pub cur_price: f64,
134    /// Timestamp when position was closed
135    #[cfg_attr(feature = "specta", specta(type = f64))]
136    pub timestamp: i64,
137    /// Market title
138    pub title: String,
139    /// Market slug
140    pub slug: String,
141    /// Market icon URL
142    pub icon: Option<String>,
143    /// Event slug
144    pub event_slug: Option<String>,
145    /// Outcome name (e.g., "Yes", "No")
146    pub outcome: String,
147    /// Outcome index (0 or 1 for binary markets)
148    pub outcome_index: u32,
149    /// Opposite outcome name
150    pub opposite_outcome: String,
151    /// Opposite outcome asset ID
152    pub opposite_asset: String,
153    /// Market end date
154    pub end_date: Option<String>,
155}
156
157/// Trade side (buy or sell)
158#[cfg_attr(feature = "specta", derive(specta::Type))]
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "UPPERCASE")]
161pub enum TradeSide {
162    /// Buy order
163    Buy,
164    /// Sell order
165    Sell,
166    /// Unrecognized trade side (forward-compat). `Trade::side` is deserialized
167    /// from live API responses, so an unexpected value falls back here instead
168    /// of failing the whole page. Never construct this to send in a request filter.
169    #[serde(other)]
170    Unknown,
171}
172
173impl std::fmt::Display for TradeSide {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Self::Buy => write!(f, "BUY"),
177            Self::Sell => write!(f, "SELL"),
178            Self::Unknown => write!(f, "UNKNOWN"),
179        }
180    }
181}
182
183/// Filter type for trade queries
184#[cfg_attr(feature = "specta", derive(specta::Type))]
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "UPPERCASE")]
187pub enum TradeFilterType {
188    /// Filter by cash amount
189    Cash,
190    /// Filter by token amount
191    Tokens,
192}
193
194impl std::fmt::Display for TradeFilterType {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        match self {
197            Self::Cash => write!(f, "CASH"),
198            Self::Tokens => write!(f, "TOKENS"),
199        }
200    }
201}
202
203/// Trade record
204#[cfg_attr(feature = "specta", derive(specta::Type))]
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct Trade {
208    /// Proxy wallet address
209    pub proxy_wallet: String,
210    /// Trade side (BUY or SELL)
211    pub side: TradeSide,
212    /// Asset identifier (token ID)
213    pub asset: String,
214    /// Condition ID of the market
215    pub condition_id: String,
216    /// Trade size (number of shares)
217    pub size: f64,
218    /// Trade price
219    pub price: f64,
220    /// Trade timestamp
221    #[cfg_attr(feature = "specta", specta(type = f64))]
222    pub timestamp: i64,
223    /// Market title
224    pub title: String,
225    /// Market slug
226    pub slug: String,
227    /// Market icon URL
228    pub icon: Option<String>,
229    /// Event slug
230    pub event_slug: Option<String>,
231    /// Outcome name (e.g., "Yes", "No")
232    pub outcome: String,
233    /// Outcome index (0 or 1 for binary markets)
234    pub outcome_index: u32,
235    /// User display name
236    pub name: Option<String>,
237    /// User pseudonym
238    pub pseudonym: Option<String>,
239    /// User bio
240    pub bio: Option<String>,
241    /// User profile image URL
242    pub profile_image: Option<String>,
243    /// Optimized profile image URL
244    pub profile_image_optimized: Option<String>,
245    /// Transaction hash
246    pub transaction_hash: Option<String>,
247}
248
249/// Activity type
250#[cfg_attr(feature = "specta", derive(specta::Type))]
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "UPPERCASE")]
253pub enum ActivityType {
254    /// Trade activity
255    Trade,
256    /// Split activity
257    Split,
258    /// Merge activity
259    Merge,
260    /// Redeem activity
261    Redeem,
262    /// Reward activity
263    Reward,
264    /// Conversion activity
265    Conversion,
266    /// Collateral deposit
267    Deposit,
268    /// Collateral withdrawal
269    Withdrawal,
270    /// Yield accrual on collateral
271    Yield,
272    /// Maker rebate activity
273    #[serde(rename = "MAKER_REBATE")]
274    MakerRebate,
275    /// Referral reward activity
276    #[serde(rename = "REFERRAL_REWARD")]
277    ReferralReward,
278    /// Taker rebate activity
279    #[serde(rename = "TAKER_REBATE")]
280    TakerRebate,
281    /// Unrecognized activity type (forward-compat). Never construct this to
282    /// send in a request filter; [`super::api::users::ListActivity::activity_type`]
283    /// silently drops it since the upstream API has no matching value to filter on.
284    #[serde(other)]
285    Unknown,
286}
287
288impl std::fmt::Display for ActivityType {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        match self {
291            Self::Trade => write!(f, "TRADE"),
292            Self::Split => write!(f, "SPLIT"),
293            Self::Merge => write!(f, "MERGE"),
294            Self::Redeem => write!(f, "REDEEM"),
295            Self::Reward => write!(f, "REWARD"),
296            Self::Conversion => write!(f, "CONVERSION"),
297            Self::Deposit => write!(f, "DEPOSIT"),
298            Self::Withdrawal => write!(f, "WITHDRAWAL"),
299            Self::Yield => write!(f, "YIELD"),
300            Self::MakerRebate => write!(f, "MAKER_REBATE"),
301            Self::ReferralReward => write!(f, "REFERRAL_REWARD"),
302            Self::TakerRebate => write!(f, "TAKER_REBATE"),
303            Self::Unknown => write!(f, "UNKNOWN"),
304        }
305    }
306}
307
308/// Sort field options for activity queries
309#[cfg_attr(feature = "specta", derive(specta::Type))]
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
311#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
312pub enum ActivitySortBy {
313    /// Sort by timestamp (default)
314    #[default]
315    Timestamp,
316    /// Sort by token amount
317    Tokens,
318    /// Sort by cash amount
319    Cash,
320}
321
322impl std::fmt::Display for ActivitySortBy {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        match self {
325            Self::Timestamp => write!(f, "TIMESTAMP"),
326            Self::Tokens => write!(f, "TOKENS"),
327            Self::Cash => write!(f, "CASH"),
328        }
329    }
330}
331
332/// User activity record
333#[cfg_attr(feature = "specta", derive(specta::Type))]
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct Activity {
337    /// Proxy wallet address
338    pub proxy_wallet: String,
339    /// Activity timestamp
340    #[cfg_attr(feature = "specta", specta(type = f64))]
341    pub timestamp: i64,
342    /// Condition ID of the market
343    pub condition_id: String,
344    /// Activity type
345    #[serde(rename = "type")]
346    pub activity_type: ActivityType,
347    /// Token quantity
348    pub size: f64,
349    /// USD value
350    pub usdc_size: f64,
351    /// On-chain transaction hash
352    pub transaction_hash: Option<String>,
353    /// Execution price
354    pub price: Option<f64>,
355    /// Asset identifier (token ID)
356    pub asset: Option<String>,
357    // Deserialize into String because the API can return an empty string
358    /// Trade side (BUY or SELL)
359    pub side: Option<String>,
360    /// Outcome index (0 or 1 for binary markets)
361    pub outcome_index: Option<u32>,
362    /// Market title
363    pub title: Option<String>,
364    /// Market slug
365    pub slug: Option<String>,
366    /// Market icon URL
367    pub icon: Option<String>,
368    /// Outcome name (e.g., "Yes", "No")
369    pub outcome: Option<String>,
370    /// User display name
371    pub name: Option<String>,
372    /// User pseudonym
373    pub pseudonym: Option<String>,
374    /// User bio
375    pub bio: Option<String>,
376    /// User profile image URL
377    pub profile_image: Option<String>,
378    /// Optimized profile image URL
379    pub profile_image_optimized: Option<String>,
380}
381
382/// User position in a market
383#[cfg_attr(feature = "specta", derive(specta::Type))]
384#[derive(Debug, Clone, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct Position {
387    /// Proxy wallet address
388    pub proxy_wallet: String,
389    /// Asset identifier (token ID)
390    pub asset: String,
391    /// Condition ID of the market
392    pub condition_id: String,
393    /// Position size (number of shares)
394    pub size: f64,
395    /// Average entry price
396    pub avg_price: f64,
397    /// Initial value of position
398    pub initial_value: f64,
399    /// Current value of position
400    pub current_value: f64,
401    /// Cash profit and loss
402    pub cash_pnl: f64,
403    /// Percentage profit and loss
404    pub percent_pnl: f64,
405    /// Total amount bought
406    pub total_bought: f64,
407    /// Realized profit and loss
408    pub realized_pnl: f64,
409    /// Percentage realized P&L
410    pub percent_realized_pnl: f64,
411    /// Current market price
412    pub cur_price: f64,
413    /// Whether position is redeemable
414    pub redeemable: bool,
415    /// Whether position is mergeable
416    pub mergeable: bool,
417    /// Market title
418    pub title: String,
419    /// Market slug
420    pub slug: String,
421    /// Market icon URL
422    pub icon: Option<String>,
423    /// Event slug
424    pub event_slug: Option<String>,
425    /// Outcome name (e.g., "Yes", "No")
426    pub outcome: String,
427    /// Outcome index (0 or 1 for binary markets)
428    pub outcome_index: u32,
429    /// Opposite outcome name
430    pub opposite_outcome: String,
431    /// Opposite outcome asset ID
432    pub opposite_asset: String,
433    /// Market end date
434    pub end_date: Option<String>,
435    /// Whether this is a negative risk market
436    pub negative_risk: bool,
437}
438
439/// A per-user position in a single market, as returned by `/v1/market-positions`.
440///
441/// Field names and types follow the upstream `MarketPositionV1` schema in
442/// `docs/specs/data/openapi.yaml`.
443#[cfg_attr(feature = "specta", derive(specta::Type))]
444#[derive(Debug, Clone, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct MarketPositionV1 {
447    /// Proxy wallet address of the position holder
448    pub proxy_wallet: String,
449    /// Display name of the position holder
450    pub name: String,
451    /// Profile image URL of the position holder
452    pub profile_image: Option<String>,
453    /// Whether the holder has a verified badge
454    pub verified: bool,
455    /// Outcome token asset ID
456    pub asset: String,
457    /// Condition ID of the market
458    pub condition_id: String,
459    /// Average entry price
460    pub avg_price: f64,
461    /// Position size (number of shares)
462    pub size: f64,
463    /// Current market price (OpenAPI field: `currPrice`)
464    #[serde(rename = "currPrice")]
465    pub curr_price: f64,
466    /// Current value of the position
467    pub current_value: f64,
468    /// Unrealized cash P&L
469    pub cash_pnl: f64,
470    /// Total amount bought
471    pub total_bought: f64,
472    /// Realized P&L
473    pub realized_pnl: f64,
474    /// Total P&L (cash + realized)
475    pub total_pnl: f64,
476    /// Outcome name (e.g., "Yes", "No")
477    pub outcome: String,
478    /// Outcome index (0 or 1 for binary markets)
479    pub outcome_index: u32,
480}
481
482/// Market positions grouped by outcome token.
483#[cfg_attr(feature = "specta", derive(specta::Type))]
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct MetaMarketPositionV1 {
486    /// Outcome token asset ID
487    pub token: String,
488    /// Positions for this token
489    pub positions: Vec<MarketPositionV1>,
490}
491
492/// Status filter for `/v1/market-positions`.
493#[cfg_attr(feature = "specta", derive(specta::Type))]
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
495#[serde(rename_all = "UPPERCASE")]
496pub enum MarketPositionStatus {
497    /// Only positions with size > 0.01
498    Open,
499    /// Only positions with size <= 0.01
500    Closed,
501    /// All positions regardless of size (default)
502    #[default]
503    All,
504}
505
506impl std::fmt::Display for MarketPositionStatus {
507    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508        match self {
509            Self::Open => write!(f, "OPEN"),
510            Self::Closed => write!(f, "CLOSED"),
511            Self::All => write!(f, "ALL"),
512        }
513    }
514}
515
516/// Sort field options for `/v1/market-positions`.
517#[cfg_attr(feature = "specta", derive(specta::Type))]
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
519#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
520pub enum MarketPositionSortBy {
521    /// Sort by token count
522    Tokens,
523    /// Sort by unrealized cash P&L
524    CashPnl,
525    /// Sort by realized P&L
526    RealizedPnl,
527    /// Sort by total P&L (cash + realized). Default.
528    #[default]
529    TotalPnl,
530}
531
532impl std::fmt::Display for MarketPositionSortBy {
533    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534        match self {
535            Self::Tokens => write!(f, "TOKENS"),
536            Self::CashPnl => write!(f, "CASH_PNL"),
537            Self::RealizedPnl => write!(f, "REALIZED_PNL"),
538            Self::TotalPnl => write!(f, "TOTAL_PNL"),
539        }
540    }
541}
542
543/// Time period for aggregation
544#[cfg_attr(feature = "specta", derive(specta::Type))]
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
546#[serde(rename_all = "UPPERCASE")]
547pub enum TimePeriod {
548    /// Daily aggregation (default)
549    #[default]
550    Day,
551    /// Weekly aggregation
552    Week,
553    /// Monthly aggregation
554    Month,
555    /// All time aggregation
556    All,
557}
558
559impl std::fmt::Display for TimePeriod {
560    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        match self {
562            Self::Day => write!(f, "DAY"),
563            Self::Week => write!(f, "WEEK"),
564            Self::Month => write!(f, "MONTH"),
565            Self::All => write!(f, "ALL"),
566        }
567    }
568}
569
570// ---------------------------------------------------------------------------
571// Combinatorial (multi-market) positions and activity
572//
573// Monetary and share fields on these types are kept as `String` rather than
574// `f64` deliberately: upstream documents them as "six-decimal
575// precision-preserving" values and instructs clients to parse them as decimals,
576// never through a float. Round-tripping them through `f64` would silently lose
577// precision on large balances.
578// ---------------------------------------------------------------------------
579
580/// Resolution state of a combinatorial position.
581#[cfg_attr(feature = "specta", derive(specta::Type))]
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
583#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
584pub enum ComboStatus {
585    /// No leg has resolved yet.
586    Open,
587    /// Some legs have resolved; the combo is still live.
588    Partial,
589    /// Resolved at a fractional payout (e.g. a leg voided 50/50) — redemption
590    /// pays the fractional value per share.
591    ResolvedPartial,
592    /// Resolved in the holder's favour; shares redeem 1:1 at $1.
593    ResolvedWin,
594    /// Resolved against the holder; shares are worthless.
595    ResolvedLoss,
596    /// Unrecognized status (forward-compat). Never construct this to send in a
597    /// request filter; [`crate::api::combos::ListComboPositions::status`] drops
598    /// it since the upstream API has no matching value to filter on.
599    #[serde(other)]
600    Unknown,
601}
602
603impl std::fmt::Display for ComboStatus {
604    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        match self {
606            Self::Open => write!(f, "OPEN"),
607            Self::Partial => write!(f, "PARTIAL"),
608            Self::ResolvedPartial => write!(f, "RESOLVED_PARTIAL"),
609            Self::ResolvedWin => write!(f, "RESOLVED_WIN"),
610            Self::ResolvedLoss => write!(f, "RESOLVED_LOSS"),
611            Self::Unknown => write!(f, "UNKNOWN"),
612        }
613    }
614}
615
616/// Resolution state of a single leg within a combinatorial position.
617#[cfg_attr(feature = "specta", derive(specta::Type))]
618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
619#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
620pub enum ComboLegStatus {
621    /// The leg's market has not resolved.
622    Open,
623    /// The leg's market resolved with a fractional payout (e.g. a 50/50 void).
624    ResolvedPartial,
625    /// The leg resolved in the holder's favour.
626    ResolvedWin,
627    /// The leg resolved against the holder.
628    ResolvedLoss,
629    /// Unrecognized leg status (forward-compat).
630    #[serde(other)]
631    Unknown,
632}
633
634impl std::fmt::Display for ComboLegStatus {
635    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
636        match self {
637            Self::Open => write!(f, "OPEN"),
638            Self::ResolvedPartial => write!(f, "RESOLVED_PARTIAL"),
639            Self::ResolvedWin => write!(f, "RESOLVED_WIN"),
640            Self::ResolvedLoss => write!(f, "RESOLVED_LOSS"),
641            Self::Unknown => write!(f, "UNKNOWN"),
642        }
643    }
644}
645
646/// Sort order for [`crate::api::combos::ListComboPositions`].
647#[cfg_attr(feature = "specta", derive(specta::Type))]
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
649#[serde(rename_all = "snake_case")]
650pub enum ComboSort {
651    /// Highest current value first (default).
652    #[default]
653    CurrentValueDesc,
654    /// Highest entry cost first.
655    EntryCostDesc,
656    /// Most recently entered first.
657    FirstEntryDesc,
658    /// Most recently resolved first.
659    ResolvedAtDesc,
660    /// Oldest `updated_at` first — the sort to use for incremental sync
661    /// alongside [`crate::api::combos::ListComboPositions::updated_after`].
662    UpdatedAsc,
663}
664
665impl std::fmt::Display for ComboSort {
666    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667        match self {
668            Self::CurrentValueDesc => write!(f, "current_value_desc"),
669            Self::EntryCostDesc => write!(f, "entry_cost_desc"),
670            Self::FirstEntryDesc => write!(f, "first_entry_desc"),
671            Self::ResolvedAtDesc => write!(f, "resolved_at_desc"),
672            Self::UpdatedAsc => write!(f, "updated_asc"),
673        }
674    }
675}
676
677/// Standard pagination metadata for combo endpoints.
678///
679/// There is no total count; `has_more` is derived from page fullness.
680#[cfg_attr(feature = "specta", derive(specta::Type))]
681#[derive(Debug, Clone, Serialize, Deserialize)]
682pub struct Pagination {
683    /// Page size used for this response.
684    pub limit: i64,
685    /// Offset used for this response.
686    pub offset: i64,
687    /// Whether another page is available.
688    pub has_more: bool,
689    /// Opaque signed cursor for the next page; `None` when `has_more` is false.
690    ///
691    /// Pass it back verbatim via `cursor(..)`, keeping the same sort. Never
692    /// parse or construct it. Using the cursor makes deep pagination O(page)
693    /// and stable against concurrent inserts.
694    pub next_cursor: Option<String>,
695}
696
697/// Event metadata attached to a combo leg's market.
698#[cfg_attr(feature = "specta", derive(specta::Type))]
699#[derive(Debug, Clone, Serialize, Deserialize)]
700pub struct ComboEvent {
701    /// Event identifier.
702    pub event_id: Option<String>,
703    /// URL slug for the event.
704    pub event_slug: Option<String>,
705    /// Human-readable event title.
706    pub event_title: Option<String>,
707    /// Event image URL.
708    pub event_image: Option<String>,
709}
710
711/// Market metadata attached to a combo leg.
712#[cfg_attr(feature = "specta", derive(specta::Type))]
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct ComboMarket {
715    /// Market identifier.
716    pub market_id: Option<String>,
717    /// URL slug for the market.
718    pub slug: Option<String>,
719    /// Market title.
720    pub title: Option<String>,
721    /// Outcome label this leg refers to.
722    pub outcome: Option<String>,
723    /// Market image URL.
724    pub image_url: Option<String>,
725    /// Market icon URL.
726    pub icon_url: Option<String>,
727    /// Market category.
728    pub category: Option<String>,
729    /// Market subcategory.
730    pub subcategory: Option<String>,
731    /// Tags applied to the market.
732    #[serde(default)]
733    pub tags: Vec<String>,
734    /// Market end date (RFC3339 UTC).
735    pub end_date: Option<String>,
736    /// Parent event metadata.
737    pub event: Option<ComboEvent>,
738}
739
740/// A single leg of a combinatorial position.
741#[cfg_attr(feature = "specta", derive(specta::Type))]
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct ComboLeg {
744    /// Zero-based index of this leg within the combo.
745    pub leg_index: i64,
746    /// Position identifier for the leg.
747    pub leg_position_id: Option<String>,
748    /// The leg market's condition ID (distinct from the combo's).
749    pub leg_condition_id: Option<String>,
750    /// Index of the selected outcome within the leg market.
751    pub leg_outcome_index: Option<i64>,
752    /// Label of the selected outcome.
753    pub leg_outcome_label: Option<String>,
754    /// Live per-leg resolution state, derived from the leg market's on-chain
755    /// payout vector.
756    pub leg_status: Option<ComboLegStatus>,
757    /// RFC3339 UTC. Set once the leg's market resolves on-chain, including
758    /// fractional resolutions that still report `leg_status` `Open`.
759    pub leg_resolved_at: Option<String>,
760    /// Live price for the leg outcome (decimal string, 0–1). `"0"` when no
761    /// price is available.
762    pub leg_current_price: Option<String>,
763    /// Market metadata for the leg.
764    pub market: Option<ComboMarket>,
765}
766
767/// A combinatorial (multi-market) position held by a user.
768#[cfg_attr(feature = "specta", derive(specta::Type))]
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct ComboPosition {
771    /// Combo condition ID (`0x` + 62 hex). Equals the `conditionId` of
772    /// `isCombo` rows on `/activity`.
773    pub combo_condition_id: String,
774    /// Position identifier for the combo.
775    pub combo_position_id: Option<String>,
776    /// Module identifier; `3` is the Combinatorial module.
777    pub module_id: Option<i64>,
778    /// Holder's wallet address.
779    pub user_address: Option<String>,
780    /// Share balance as a precision-preserving decimal string.
781    pub shares_balance: Option<String>,
782    /// Average entry price in USDC, as a decimal string.
783    pub entry_avg_price_usdc: Option<String>,
784    /// *Remaining* cost basis (`entry_avg_price × shares_balance`).
785    ///
786    /// Reads ~0 after a winning combo is redeemed — use
787    /// [`total_cost_usdc`](Self::total_cost_usdc) to display what was paid on
788    /// closed positions.
789    pub entry_cost_usdc: Option<String>,
790    /// Gross redemption proceeds (winning shares redeem 1:1 at $1).
791    ///
792    /// `"0.00"` while open, unredeemed, or resolved-loss; accumulates under
793    /// `Partial`. This is gross payout, not net PnL — net =
794    /// `realized_payout_usdc − total_cost_usdc`.
795    pub realized_payout_usdc: Option<String>,
796    /// Original cost basis, surviving redemption burning the shares. Equals
797    /// [`entry_cost_usdc`](Self::entry_cost_usdc) while open.
798    pub total_cost_usdc: Option<String>,
799    /// Exact gross entry basis including attributed BUY fees, as a six-decimal
800    /// precision-preserving string (e.g. `"8999.997488"`).
801    ///
802    /// Tracks the remaining basis while the position is live and freezes once
803    /// it is terminal. Exact net basis = `gross_entry_cost_usdc −
804    /// entry_fees_usdc`. Parse as a decimal, never through a float.
805    pub gross_entry_cost_usdc: Option<String>,
806    /// BUY-fee portion of the same basis, as a six-decimal precision-preserving
807    /// string. SELL fees are excluded; always ≤ `gross_entry_cost_usdc`.
808    pub entry_fees_usdc: Option<String>,
809    /// Resolution state of the combo.
810    pub status: Option<ComboStatus>,
811    /// First entry time (RFC3339 UTC).
812    pub first_entry_at: Option<String>,
813    /// Resolution time (RFC3339 UTC), or `None` while unresolved.
814    pub resolved_at: Option<String>,
815    /// Last-modified time (UTC, ISO 8601) — the incremental-sync watermark.
816    ///
817    /// Bumps on any recompute of the row (trade, redemption, resolution
818    /// classification). Omitted on responses served by the legacy backend.
819    pub updated_at: Option<String>,
820    /// Total number of legs.
821    pub legs_total: Option<i64>,
822    /// Number of legs that have resolved.
823    pub legs_resolved: Option<i64>,
824    /// Number of legs still pending.
825    pub legs_pending: Option<i64>,
826    /// Per-leg breakdown.
827    #[serde(default)]
828    pub legs: Vec<ComboLeg>,
829}
830
831/// Response envelope for `GET /v1/positions/combos`.
832#[cfg_attr(feature = "specta", derive(specta::Type))]
833#[derive(Debug, Clone, Serialize, Deserialize)]
834pub struct CombosResponse {
835    /// The combo positions in this page.
836    #[serde(default)]
837    pub combos: Vec<ComboPosition>,
838    /// Pagination metadata.
839    pub pagination: Option<Pagination>,
840}
841
842/// A combo lifecycle or redeem event.
843#[cfg_attr(feature = "specta", derive(specta::Type))]
844#[derive(Debug, Clone, Serialize, Deserialize)]
845pub struct ComboActivity {
846    /// Event identifier.
847    pub id: Option<String>,
848    /// Event type (split, merge, convert, compress, wrap, unwrap, redeem).
849    ///
850    /// Upstream documents `type` as the replacement for the deprecated
851    /// [`event_kind`](Self::event_kind) and [`side`](Self::side) fields, but
852    /// does not yet list it in the published schema — treat it as optional
853    /// until it appears there.
854    #[serde(rename = "type")]
855    pub activity_type: Option<String>,
856    /// Raw on-chain event name (e.g. `PositionsSplit`).
857    #[deprecated(note = "upstream deprecated this field; use `activity_type` instead")]
858    pub event_kind: Option<String>,
859    /// Normalized rendering label (e.g. `Split`).
860    #[deprecated(note = "upstream deprecated this field; use `activity_type` instead")]
861    pub side: Option<String>,
862    /// Module kind; always `Combinatorial`.
863    pub module_kind: Option<String>,
864    /// Holder's wallet address.
865    pub user_address: Option<String>,
866    /// Combo condition ID (`0x` + 62 hex).
867    pub combo_condition_id: Option<String>,
868    /// Position identifier for the combo.
869    pub combo_position_id: Option<String>,
870    /// Module identifier.
871    pub module_id: Option<i64>,
872    /// Lifecycle amount in USDC; `None` on redeems.
873    pub amount_usdc: Option<f64>,
874    /// Redeem payout in USDC; `None` on lifecycle events.
875    pub payout_usdc: Option<f64>,
876    /// Event time as a Unix timestamp (seconds).
877    pub timestamp: Option<i64>,
878    /// Transaction time (RFC3339 UTC).
879    pub tx_dttm: Option<String>,
880    /// Transaction hash.
881    pub tx_hash: Option<String>,
882    /// Log index within the transaction.
883    pub log_index: Option<i64>,
884    /// Block number.
885    pub block_number: Option<i64>,
886    /// Per-leg breakdown.
887    #[serde(default)]
888    pub legs: Vec<ComboLeg>,
889}
890
891/// Response envelope for `GET /v1/activity/combos`.
892#[cfg_attr(feature = "specta", derive(specta::Type))]
893#[derive(Debug, Clone, Serialize, Deserialize)]
894pub struct CombosActivityResponse {
895    /// The combo activity rows in this page.
896    #[serde(default)]
897    pub activity: Vec<ComboActivity>,
898    /// Pagination metadata.
899    pub pagination: Option<Pagination>,
900}
901
902// ---------------------------------------------------------------------------
903// Undocumented sibling hosts (user-pnl-api, lb-api)
904//
905// Neither host appears in any published Polymarket OpenAPI spec. The shapes
906// below were derived from live responses; the enum variants come from the
907// APIs' own validation errors, which enumerate the accepted values.
908// ---------------------------------------------------------------------------
909
910/// Sampling resolution for a PnL series.
911///
912/// Upstream rejects anything outside this set with
913/// `"the 'fidelity' value is unkonwn. Known values: '1d', '18h', '12h', '3h', '1h'"`
914/// (typo theirs).
915#[cfg_attr(feature = "specta", derive(specta::Type))]
916#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
917pub enum PnlFidelity {
918    /// One point per day (default).
919    #[default]
920    #[serde(rename = "1d")]
921    OneDay,
922    /// One point per 18 hours.
923    #[serde(rename = "18h")]
924    EighteenHours,
925    /// One point per 12 hours.
926    #[serde(rename = "12h")]
927    TwelveHours,
928    /// One point per 3 hours.
929    #[serde(rename = "3h")]
930    ThreeHours,
931    /// One point per hour.
932    #[serde(rename = "1h")]
933    OneHour,
934}
935
936impl std::fmt::Display for PnlFidelity {
937    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
938        match self {
939            Self::OneDay => write!(f, "1d"),
940            Self::EighteenHours => write!(f, "18h"),
941            Self::TwelveHours => write!(f, "12h"),
942            Self::ThreeHours => write!(f, "3h"),
943            Self::OneHour => write!(f, "1h"),
944        }
945    }
946}
947
948/// A single point on a user's PnL curve.
949#[cfg_attr(feature = "specta", derive(specta::Type))]
950#[derive(Debug, Clone, Serialize, Deserialize)]
951pub struct PnlPoint {
952    /// Unix timestamp in seconds.
953    #[serde(rename = "t")]
954    pub timestamp: i64,
955    /// PnL in USDC at that timestamp. Negative values are losses.
956    #[serde(rename = "p")]
957    pub pnl: f64,
958}
959
960/// Ranking window for the rankings host.
961///
962/// Upstream rejects anything else with `{"error": "invalid request"}`.
963#[cfg_attr(feature = "specta", derive(specta::Type))]
964#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
965pub enum RankingWindow {
966    /// All-time (default).
967    #[default]
968    #[serde(rename = "all")]
969    All,
970    /// Trailing day.
971    #[serde(rename = "1d")]
972    OneDay,
973    /// Trailing week.
974    #[serde(rename = "7d")]
975    SevenDays,
976    /// Trailing 30 days.
977    #[serde(rename = "30d")]
978    ThirtyDays,
979}
980
981impl std::fmt::Display for RankingWindow {
982    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
983        match self {
984            Self::All => write!(f, "all"),
985            Self::OneDay => write!(f, "1d"),
986            Self::SevenDays => write!(f, "7d"),
987            Self::ThirtyDays => write!(f, "30d"),
988        }
989    }
990}
991
992/// One entry in a volume or profit ranking.
993#[cfg_attr(feature = "specta", derive(specta::Type))]
994#[derive(Debug, Clone, Serialize, Deserialize)]
995#[serde(rename_all = "camelCase")]
996pub struct RankingEntry {
997    /// Proxy wallet address of the ranked trader.
998    pub proxy_wallet: Option<String>,
999    /// Ranked amount in USDC — traded volume or realized profit, depending on
1000    /// which endpoint produced the entry.
1001    pub amount: Option<f64>,
1002    /// Display name.
1003    pub name: Option<String>,
1004    /// Pseudonym, which falls back to an address-derived string.
1005    pub pseudonym: Option<String>,
1006    /// Profile biography.
1007    pub bio: Option<String>,
1008    /// Profile image URL.
1009    pub profile_image: Option<String>,
1010    /// Optimized profile image URL.
1011    pub profile_image_optimized: Option<String>,
1012}
1013
1014/// "Other" outcome size held by a user in an augmented neg-risk event.
1015#[cfg_attr(feature = "specta", derive(specta::Type))]
1016#[derive(Debug, Clone, Serialize, Deserialize)]
1017pub struct OtherSize {
1018    /// Gamma event ID of the augmented neg-risk event.
1019    pub id: Option<i64>,
1020    /// User wallet address.
1021    pub user: Option<String>,
1022    /// Size of the "Other" position.
1023    pub size: Option<f64>,
1024}
1025
1026/// A single moderated revision of a question.
1027#[cfg_attr(feature = "specta", derive(specta::Type))]
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct RevisionEntry {
1030    /// Revised question text.
1031    pub revision: Option<String>,
1032    /// Revision time as a Unix timestamp (seconds).
1033    pub timestamp: Option<i64>,
1034}
1035
1036/// Moderated revisions for a question.
1037#[cfg_attr(feature = "specta", derive(specta::Type))]
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1039pub struct RevisionPayload {
1040    /// Question ID (`0x` + 64 hex).
1041    #[serde(rename = "questionID")]
1042    pub question_id: Option<String>,
1043    /// Revisions recorded for the question, oldest first.
1044    #[serde(default)]
1045    pub revisions: Vec<RevisionEntry>,
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051
1052    /// Verify Display matches serde serialization for all PositionSortBy variants.
1053    #[test]
1054    fn position_sort_by_display_matches_serde() {
1055        let variants = [
1056            PositionSortBy::Current,
1057            PositionSortBy::Initial,
1058            PositionSortBy::Tokens,
1059            PositionSortBy::CashPnl,
1060            PositionSortBy::PercentPnl,
1061            PositionSortBy::Title,
1062            PositionSortBy::Resolving,
1063            PositionSortBy::Price,
1064            PositionSortBy::AvgPrice,
1065        ];
1066        for variant in variants {
1067            let serialized = serde_json::to_value(variant).unwrap();
1068            let display = variant.to_string();
1069            assert_eq!(
1070                format!("\"{}\"", display),
1071                serialized.to_string(),
1072                "Display mismatch for {:?}",
1073                variant
1074            );
1075        }
1076    }
1077
1078    /// Verify Display matches serde serialization for all ClosedPositionSortBy variants.
1079    #[test]
1080    fn closed_position_sort_by_display_matches_serde() {
1081        let variants = [
1082            ClosedPositionSortBy::RealizedPnl,
1083            ClosedPositionSortBy::Title,
1084            ClosedPositionSortBy::Price,
1085            ClosedPositionSortBy::AvgPrice,
1086            ClosedPositionSortBy::Timestamp,
1087        ];
1088        for variant in variants {
1089            let serialized = serde_json::to_value(variant).unwrap();
1090            let display = variant.to_string();
1091            assert_eq!(
1092                format!("\"{}\"", display),
1093                serialized.to_string(),
1094                "Display mismatch for {:?}",
1095                variant
1096            );
1097        }
1098    }
1099
1100    #[test]
1101    fn activity_sort_by_display_matches_serde() {
1102        let variants = [
1103            ActivitySortBy::Timestamp,
1104            ActivitySortBy::Tokens,
1105            ActivitySortBy::Cash,
1106        ];
1107        for variant in variants {
1108            let serialized = serde_json::to_value(variant).unwrap();
1109            let display = variant.to_string();
1110            assert_eq!(
1111                format!("\"{}\"", display),
1112                serialized.to_string(),
1113                "Display mismatch for {:?}",
1114                variant
1115            );
1116        }
1117    }
1118
1119    #[test]
1120    fn sort_direction_display_matches_serde() {
1121        let variants = [SortDirection::Asc, SortDirection::Desc];
1122        for variant in variants {
1123            let serialized = serde_json::to_value(variant).unwrap();
1124            let display = variant.to_string();
1125            assert_eq!(
1126                format!("\"{}\"", display),
1127                serialized.to_string(),
1128                "Display mismatch for {:?}",
1129                variant
1130            );
1131        }
1132    }
1133
1134    #[test]
1135    fn trade_side_display_matches_serde() {
1136        let variants = [TradeSide::Buy, TradeSide::Sell, TradeSide::Unknown];
1137        for variant in variants {
1138            let serialized = serde_json::to_value(variant).unwrap();
1139            let display = variant.to_string();
1140            assert_eq!(
1141                format!("\"{}\"", display),
1142                serialized.to_string(),
1143                "Display mismatch for {:?}",
1144                variant
1145            );
1146        }
1147    }
1148
1149    #[test]
1150    fn trade_side_falls_back_to_unknown_for_future_values() {
1151        let result: TradeSide = serde_json::from_str("\"SOME_FUTURE_SIDE\"").unwrap();
1152        assert_eq!(result, TradeSide::Unknown);
1153    }
1154
1155    #[test]
1156    fn trade_filter_type_display_matches_serde() {
1157        let variants = [TradeFilterType::Cash, TradeFilterType::Tokens];
1158        for variant in variants {
1159            let serialized = serde_json::to_value(variant).unwrap();
1160            let display = variant.to_string();
1161            assert_eq!(
1162                format!("\"{}\"", display),
1163                serialized.to_string(),
1164                "Display mismatch for {:?}",
1165                variant
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn activity_type_display_matches_serde() {
1172        let variants = [
1173            ActivityType::Trade,
1174            ActivityType::Split,
1175            ActivityType::Merge,
1176            ActivityType::Redeem,
1177            ActivityType::Reward,
1178            ActivityType::Conversion,
1179            ActivityType::MakerRebate,
1180            ActivityType::ReferralReward,
1181            ActivityType::TakerRebate,
1182            ActivityType::Unknown,
1183        ];
1184        for variant in variants {
1185            let serialized = serde_json::to_value(variant).unwrap();
1186            let display = variant.to_string();
1187            assert_eq!(
1188                format!("\"{}\"", display),
1189                serialized.to_string(),
1190                "Display mismatch for {:?}",
1191                variant
1192            );
1193        }
1194    }
1195
1196    #[test]
1197    fn activity_type_roundtrip_serde() {
1198        for variant in [
1199            ActivityType::Trade,
1200            ActivityType::Split,
1201            ActivityType::Merge,
1202            ActivityType::Redeem,
1203            ActivityType::Reward,
1204            ActivityType::Conversion,
1205            ActivityType::MakerRebate,
1206            ActivityType::ReferralReward,
1207            ActivityType::TakerRebate,
1208        ] {
1209            let json = serde_json::to_string(&variant).unwrap();
1210            let deserialized: ActivityType = serde_json::from_str(&json).unwrap();
1211            assert_eq!(variant, deserialized);
1212        }
1213    }
1214
1215    #[test]
1216    fn activity_type_falls_back_to_unknown_for_future_types() {
1217        // A hypothetical future activity type Polymarket hasn't documented yet.
1218        let result: ActivityType = serde_json::from_str("\"SOME_FUTURE_TYPE\"").unwrap();
1219        assert_eq!(result, ActivityType::Unknown);
1220
1221        // Case mismatches also fall back rather than poisoning the whole page.
1222        let result: ActivityType = serde_json::from_str("\"trade\"").unwrap();
1223        assert_eq!(result, ActivityType::Unknown);
1224    }
1225
1226    #[test]
1227    fn sort_direction_default_is_desc() {
1228        assert_eq!(SortDirection::default(), SortDirection::Desc);
1229    }
1230
1231    #[test]
1232    fn closed_position_sort_by_default_is_realized_pnl() {
1233        assert_eq!(
1234            ClosedPositionSortBy::default(),
1235            ClosedPositionSortBy::RealizedPnl
1236        );
1237    }
1238
1239    #[test]
1240    fn activity_sort_by_default_is_timestamp() {
1241        assert_eq!(ActivitySortBy::default(), ActivitySortBy::Timestamp);
1242    }
1243
1244    #[test]
1245    fn position_sort_by_serde_roundtrip() {
1246        for variant in [
1247            PositionSortBy::Current,
1248            PositionSortBy::Initial,
1249            PositionSortBy::Tokens,
1250            PositionSortBy::CashPnl,
1251            PositionSortBy::PercentPnl,
1252            PositionSortBy::Title,
1253            PositionSortBy::Resolving,
1254            PositionSortBy::Price,
1255            PositionSortBy::AvgPrice,
1256        ] {
1257            let json = serde_json::to_string(&variant).unwrap();
1258            let deserialized: PositionSortBy = serde_json::from_str(&json).unwrap();
1259            assert_eq!(variant, deserialized);
1260        }
1261    }
1262
1263    #[test]
1264    fn deserialize_position_from_json() {
1265        let json = r#"{
1266            "proxyWallet": "0xabc123",
1267            "asset": "token123",
1268            "conditionId": "cond456",
1269            "size": 100.5,
1270            "avgPrice": 0.65,
1271            "initialValue": 65.0,
1272            "currentValue": 70.0,
1273            "cashPnl": 5.0,
1274            "percentPnl": 7.69,
1275            "totalBought": 100.5,
1276            "realizedPnl": 2.0,
1277            "percentRealizedPnl": 3.08,
1278            "curPrice": 0.70,
1279            "redeemable": false,
1280            "mergeable": true,
1281            "title": "Will X happen?",
1282            "slug": "will-x-happen",
1283            "icon": "https://example.com/icon.png",
1284            "eventSlug": "x-event",
1285            "outcome": "Yes",
1286            "outcomeIndex": 0,
1287            "oppositeOutcome": "No",
1288            "oppositeAsset": "token789",
1289            "endDate": "2025-12-31",
1290            "negativeRisk": false
1291        }"#;
1292
1293        let pos: Position = serde_json::from_str(json).unwrap();
1294        assert_eq!(pos.proxy_wallet, "0xabc123");
1295        assert_eq!(pos.asset, "token123");
1296        assert_eq!(pos.condition_id, "cond456");
1297        assert!((pos.size - 100.5).abs() < f64::EPSILON);
1298        assert!((pos.avg_price - 0.65).abs() < f64::EPSILON);
1299        assert!((pos.initial_value - 65.0).abs() < f64::EPSILON);
1300        assert!((pos.current_value - 70.0).abs() < f64::EPSILON);
1301        assert!((pos.cash_pnl - 5.0).abs() < f64::EPSILON);
1302        assert!(!pos.redeemable);
1303        assert!(pos.mergeable);
1304        assert_eq!(pos.title, "Will X happen?");
1305        assert_eq!(pos.outcome, "Yes");
1306        assert_eq!(pos.outcome_index, 0);
1307        assert_eq!(pos.opposite_outcome, "No");
1308        assert!(!pos.negative_risk);
1309        assert_eq!(pos.icon, Some("https://example.com/icon.png".to_string()));
1310        assert_eq!(pos.event_slug, Some("x-event".to_string()));
1311    }
1312
1313    #[test]
1314    fn deserialize_position_with_null_optionals() {
1315        let json = r#"{
1316            "proxyWallet": "0xabc123",
1317            "asset": "token123",
1318            "conditionId": "cond456",
1319            "size": 0.0,
1320            "avgPrice": 0.0,
1321            "initialValue": 0.0,
1322            "currentValue": 0.0,
1323            "cashPnl": 0.0,
1324            "percentPnl": 0.0,
1325            "totalBought": 0.0,
1326            "realizedPnl": 0.0,
1327            "percentRealizedPnl": 0.0,
1328            "curPrice": 0.0,
1329            "redeemable": false,
1330            "mergeable": false,
1331            "title": "Test",
1332            "slug": "test",
1333            "icon": null,
1334            "eventSlug": null,
1335            "outcome": "No",
1336            "outcomeIndex": 1,
1337            "oppositeOutcome": "Yes",
1338            "oppositeAsset": "token000",
1339            "endDate": null,
1340            "negativeRisk": true
1341        }"#;
1342
1343        let pos: Position = serde_json::from_str(json).unwrap();
1344        assert!(pos.icon.is_none());
1345        assert!(pos.event_slug.is_none());
1346        assert!(pos.end_date.is_none());
1347        assert!(pos.negative_risk);
1348    }
1349
1350    #[test]
1351    fn deserialize_closed_position_from_json() {
1352        let json = r#"{
1353            "proxyWallet": "0xdef456",
1354            "asset": "token_closed",
1355            "conditionId": "cond_closed",
1356            "avgPrice": 0.45,
1357            "totalBought": 200.0,
1358            "realizedPnl": -10.0,
1359            "curPrice": 0.35,
1360            "timestamp": 1700000000,
1361            "title": "Closed market?",
1362            "slug": "closed-market",
1363            "icon": null,
1364            "eventSlug": "closed-event",
1365            "outcome": "No",
1366            "outcomeIndex": 1,
1367            "oppositeOutcome": "Yes",
1368            "oppositeAsset": "token_opp",
1369            "endDate": "2024-06-30"
1370        }"#;
1371
1372        let closed: ClosedPosition = serde_json::from_str(json).unwrap();
1373        assert_eq!(closed.proxy_wallet, "0xdef456");
1374        assert!((closed.avg_price - 0.45).abs() < f64::EPSILON);
1375        assert!((closed.realized_pnl - (-10.0)).abs() < f64::EPSILON);
1376        assert_eq!(closed.timestamp, 1700000000);
1377        assert_eq!(closed.outcome, "No");
1378        assert_eq!(closed.outcome_index, 1);
1379        assert!(closed.icon.is_none());
1380        assert_eq!(closed.event_slug, Some("closed-event".to_string()));
1381    }
1382
1383    #[test]
1384    fn deserialize_trade_from_json() {
1385        let json = r#"{
1386            "proxyWallet": "0x1234",
1387            "side": "BUY",
1388            "asset": "token_buy",
1389            "conditionId": "cond_trade",
1390            "size": 50.0,
1391            "price": 0.72,
1392            "timestamp": 1700001000,
1393            "title": "Trade market?",
1394            "slug": "trade-market",
1395            "icon": "https://example.com/trade.png",
1396            "eventSlug": null,
1397            "outcome": "Yes",
1398            "outcomeIndex": 0,
1399            "name": "TraderOne",
1400            "pseudonym": "t1",
1401            "bio": "A trader",
1402            "profileImage": null,
1403            "profileImageOptimized": null,
1404            "transactionHash": "0xhash123"
1405        }"#;
1406
1407        let trade: Trade = serde_json::from_str(json).unwrap();
1408        assert_eq!(trade.proxy_wallet, "0x1234");
1409        assert_eq!(trade.side, TradeSide::Buy);
1410        assert!((trade.size - 50.0).abs() < f64::EPSILON);
1411        assert!((trade.price - 0.72).abs() < f64::EPSILON);
1412        assert_eq!(trade.timestamp, 1700001000);
1413        assert_eq!(trade.name, Some("TraderOne".to_string()));
1414        assert_eq!(trade.transaction_hash, Some("0xhash123".to_string()));
1415        assert!(trade.profile_image.is_none());
1416    }
1417
1418    #[test]
1419    fn deserialize_trade_sell_side() {
1420        let json = r#"{
1421            "proxyWallet": "0x5678",
1422            "side": "SELL",
1423            "asset": "token_sell",
1424            "conditionId": "cond_sell",
1425            "size": 25.0,
1426            "price": 0.30,
1427            "timestamp": 1700002000,
1428            "title": "Sell test",
1429            "slug": "sell-test",
1430            "icon": null,
1431            "eventSlug": null,
1432            "outcome": "No",
1433            "outcomeIndex": 1,
1434            "name": null,
1435            "pseudonym": null,
1436            "bio": null,
1437            "profileImage": null,
1438            "profileImageOptimized": null,
1439            "transactionHash": null
1440        }"#;
1441
1442        let trade: Trade = serde_json::from_str(json).unwrap();
1443        assert_eq!(trade.side, TradeSide::Sell);
1444        assert!(trade.name.is_none());
1445        assert!(trade.transaction_hash.is_none());
1446    }
1447
1448    #[test]
1449    fn deserialize_activity_from_json() {
1450        let json = r#"{
1451            "proxyWallet": "0xact123",
1452            "timestamp": 1700003000,
1453            "conditionId": "cond_act",
1454            "type": "TRADE",
1455            "size": 10.0,
1456            "usdcSize": 7.50,
1457            "transactionHash": "0xacthash",
1458            "price": 0.75,
1459            "asset": "token_act",
1460            "side": "BUY",
1461            "outcomeIndex": 0,
1462            "title": "Activity market",
1463            "slug": "activity-market",
1464            "icon": null,
1465            "outcome": "Yes",
1466            "name": null,
1467            "pseudonym": null,
1468            "bio": null,
1469            "profileImage": null,
1470            "profileImageOptimized": null
1471        }"#;
1472
1473        let activity: Activity = serde_json::from_str(json).unwrap();
1474        assert_eq!(activity.proxy_wallet, "0xact123");
1475        assert_eq!(activity.activity_type, ActivityType::Trade);
1476        assert!((activity.size - 10.0).abs() < f64::EPSILON);
1477        assert!((activity.usdc_size - 7.50).abs() < f64::EPSILON);
1478        assert_eq!(activity.side, Some("BUY".to_string()));
1479        assert_eq!(activity.outcome_index, Some(0));
1480    }
1481
1482    #[test]
1483    fn deserialize_activity_merge_type() {
1484        let json = r#"{
1485            "proxyWallet": "0xmerge",
1486            "timestamp": 1700004000,
1487            "conditionId": "cond_merge",
1488            "type": "MERGE",
1489            "size": 5.0,
1490            "usdcSize": 3.0,
1491            "transactionHash": null,
1492            "price": null,
1493            "asset": null,
1494            "side": "",
1495            "outcomeIndex": null,
1496            "title": null,
1497            "slug": null,
1498            "icon": null,
1499            "outcome": null,
1500            "name": null,
1501            "pseudonym": null,
1502            "bio": null,
1503            "profileImage": null,
1504            "profileImageOptimized": null
1505        }"#;
1506
1507        let activity: Activity = serde_json::from_str(json).unwrap();
1508        assert_eq!(activity.activity_type, ActivityType::Merge);
1509        // Side is an empty string from the API, stored as Some("")
1510        assert_eq!(activity.side, Some("".to_string()));
1511        assert!(activity.price.is_none());
1512        assert!(activity.asset.is_none());
1513        assert!(activity.title.is_none());
1514    }
1515
1516    #[test]
1517    fn deserialize_user_value() {
1518        let json = r#"{"user": "0xuser", "value": 1234.56}"#;
1519        let uv: UserValue = serde_json::from_str(json).unwrap();
1520        assert_eq!(uv.user, "0xuser");
1521        assert!((uv.value - 1234.56).abs() < f64::EPSILON);
1522    }
1523
1524    #[test]
1525    fn deserialize_open_interest() {
1526        let json = r#"{"market": "0xcond", "value": 50000.0}"#;
1527        let oi: OpenInterest = serde_json::from_str(json).unwrap();
1528        assert_eq!(oi.market, "0xcond");
1529        assert!((oi.value - 50000.0).abs() < f64::EPSILON);
1530    }
1531
1532    #[test]
1533    fn market_position_status_display_matches_serde() {
1534        for variant in [
1535            MarketPositionStatus::Open,
1536            MarketPositionStatus::Closed,
1537            MarketPositionStatus::All,
1538        ] {
1539            let serialized = serde_json::to_value(variant).unwrap();
1540            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1541        }
1542    }
1543
1544    #[test]
1545    fn market_position_status_default_is_all() {
1546        assert_eq!(MarketPositionStatus::default(), MarketPositionStatus::All);
1547    }
1548
1549    #[test]
1550    fn market_position_sort_by_display_matches_serde() {
1551        for variant in [
1552            MarketPositionSortBy::Tokens,
1553            MarketPositionSortBy::CashPnl,
1554            MarketPositionSortBy::RealizedPnl,
1555            MarketPositionSortBy::TotalPnl,
1556        ] {
1557            let serialized = serde_json::to_value(variant).unwrap();
1558            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1559        }
1560    }
1561
1562    #[test]
1563    fn market_position_sort_by_default_is_total_pnl() {
1564        assert_eq!(
1565            MarketPositionSortBy::default(),
1566            MarketPositionSortBy::TotalPnl
1567        );
1568    }
1569
1570    #[test]
1571    fn deserialize_market_position_v1() {
1572        // Field names lifted from `MarketPositionV1` in docs/specs/data/openapi.yaml.
1573        let json = r#"{
1574            "proxyWallet": "0xabc",
1575            "name": "Alice",
1576            "profileImage": "https://example.com/a.png",
1577            "verified": true,
1578            "asset": "token_a",
1579            "conditionId": "cond_mp",
1580            "avgPrice": 0.42,
1581            "size": 1234.5,
1582            "currPrice": 0.51,
1583            "currentValue": 629.60,
1584            "cashPnl": 110.0,
1585            "totalBought": 520.0,
1586            "realizedPnl": 15.5,
1587            "totalPnl": 125.5,
1588            "outcome": "Yes",
1589            "outcomeIndex": 0
1590        }"#;
1591
1592        let pos: MarketPositionV1 = serde_json::from_str(json).unwrap();
1593        assert_eq!(pos.proxy_wallet, "0xabc");
1594        assert_eq!(pos.name, "Alice");
1595        assert_eq!(
1596            pos.profile_image.as_deref(),
1597            Some("https://example.com/a.png")
1598        );
1599        assert!(pos.verified);
1600        assert_eq!(pos.asset, "token_a");
1601        assert_eq!(pos.condition_id, "cond_mp");
1602        assert!((pos.avg_price - 0.42).abs() < f64::EPSILON);
1603        assert!((pos.size - 1234.5).abs() < f64::EPSILON);
1604        assert!((pos.curr_price - 0.51).abs() < f64::EPSILON);
1605        assert!((pos.current_value - 629.60).abs() < f64::EPSILON);
1606        assert!((pos.cash_pnl - 110.0).abs() < f64::EPSILON);
1607        assert!((pos.total_bought - 520.0).abs() < f64::EPSILON);
1608        assert!((pos.realized_pnl - 15.5).abs() < f64::EPSILON);
1609        assert!((pos.total_pnl - 125.5).abs() < f64::EPSILON);
1610        assert_eq!(pos.outcome, "Yes");
1611        assert_eq!(pos.outcome_index, 0);
1612    }
1613
1614    #[test]
1615    fn market_position_v1_roundtrip() {
1616        let original = MarketPositionV1 {
1617            proxy_wallet: "0xabc".into(),
1618            name: "Alice".into(),
1619            profile_image: None,
1620            verified: false,
1621            asset: "token_a".into(),
1622            condition_id: "cond_mp".into(),
1623            avg_price: 0.5,
1624            size: 10.0,
1625            curr_price: 0.6,
1626            current_value: 6.0,
1627            cash_pnl: 1.0,
1628            total_bought: 5.0,
1629            realized_pnl: 0.0,
1630            total_pnl: 1.0,
1631            outcome: "No".into(),
1632            outcome_index: 1,
1633        };
1634        let json = serde_json::to_string(&original).unwrap();
1635        // Ensure currPrice is used over snake_case in the wire format.
1636        assert!(json.contains("\"currPrice\""));
1637        let back: MarketPositionV1 = serde_json::from_str(&json).unwrap();
1638        assert_eq!(back.proxy_wallet, original.proxy_wallet);
1639        assert_eq!(back.outcome_index, original.outcome_index);
1640        assert!((back.curr_price - original.curr_price).abs() < f64::EPSILON);
1641    }
1642
1643    #[test]
1644    fn deserialize_meta_market_position_v1() {
1645        let json = r#"{
1646            "token": "token_a",
1647            "positions": [
1648                {
1649                    "proxyWallet": "0xabc",
1650                    "name": "Alice",
1651                    "profileImage": null,
1652                    "verified": false,
1653                    "asset": "token_a",
1654                    "conditionId": "cond_mp",
1655                    "avgPrice": 0.42,
1656                    "size": 100.0,
1657                    "currPrice": 0.51,
1658                    "currentValue": 51.0,
1659                    "cashPnl": 9.0,
1660                    "totalBought": 42.0,
1661                    "realizedPnl": 0.0,
1662                    "totalPnl": 9.0,
1663                    "outcome": "Yes",
1664                    "outcomeIndex": 0
1665                }
1666            ]
1667        }"#;
1668
1669        let meta: MetaMarketPositionV1 = serde_json::from_str(json).unwrap();
1670        assert_eq!(meta.token, "token_a");
1671        assert_eq!(meta.positions.len(), 1);
1672        assert_eq!(meta.positions[0].name, "Alice");
1673        assert!(meta.positions[0].profile_image.is_none());
1674    }
1675}