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/// "Other" outcome size held by a user in an augmented neg-risk event.
903#[cfg_attr(feature = "specta", derive(specta::Type))]
904#[derive(Debug, Clone, Serialize, Deserialize)]
905pub struct OtherSize {
906    /// Gamma event ID of the augmented neg-risk event.
907    pub id: Option<i64>,
908    /// User wallet address.
909    pub user: Option<String>,
910    /// Size of the "Other" position.
911    pub size: Option<f64>,
912}
913
914/// A single moderated revision of a question.
915#[cfg_attr(feature = "specta", derive(specta::Type))]
916#[derive(Debug, Clone, Serialize, Deserialize)]
917pub struct RevisionEntry {
918    /// Revised question text.
919    pub revision: Option<String>,
920    /// Revision time as a Unix timestamp (seconds).
921    pub timestamp: Option<i64>,
922}
923
924/// Moderated revisions for a question.
925#[cfg_attr(feature = "specta", derive(specta::Type))]
926#[derive(Debug, Clone, Serialize, Deserialize)]
927pub struct RevisionPayload {
928    /// Question ID (`0x` + 64 hex).
929    #[serde(rename = "questionID")]
930    pub question_id: Option<String>,
931    /// Revisions recorded for the question, oldest first.
932    #[serde(default)]
933    pub revisions: Vec<RevisionEntry>,
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    /// Verify Display matches serde serialization for all PositionSortBy variants.
941    #[test]
942    fn position_sort_by_display_matches_serde() {
943        let variants = [
944            PositionSortBy::Current,
945            PositionSortBy::Initial,
946            PositionSortBy::Tokens,
947            PositionSortBy::CashPnl,
948            PositionSortBy::PercentPnl,
949            PositionSortBy::Title,
950            PositionSortBy::Resolving,
951            PositionSortBy::Price,
952            PositionSortBy::AvgPrice,
953        ];
954        for variant in variants {
955            let serialized = serde_json::to_value(variant).unwrap();
956            let display = variant.to_string();
957            assert_eq!(
958                format!("\"{}\"", display),
959                serialized.to_string(),
960                "Display mismatch for {:?}",
961                variant
962            );
963        }
964    }
965
966    /// Verify Display matches serde serialization for all ClosedPositionSortBy variants.
967    #[test]
968    fn closed_position_sort_by_display_matches_serde() {
969        let variants = [
970            ClosedPositionSortBy::RealizedPnl,
971            ClosedPositionSortBy::Title,
972            ClosedPositionSortBy::Price,
973            ClosedPositionSortBy::AvgPrice,
974            ClosedPositionSortBy::Timestamp,
975        ];
976        for variant in variants {
977            let serialized = serde_json::to_value(variant).unwrap();
978            let display = variant.to_string();
979            assert_eq!(
980                format!("\"{}\"", display),
981                serialized.to_string(),
982                "Display mismatch for {:?}",
983                variant
984            );
985        }
986    }
987
988    #[test]
989    fn activity_sort_by_display_matches_serde() {
990        let variants = [
991            ActivitySortBy::Timestamp,
992            ActivitySortBy::Tokens,
993            ActivitySortBy::Cash,
994        ];
995        for variant in variants {
996            let serialized = serde_json::to_value(variant).unwrap();
997            let display = variant.to_string();
998            assert_eq!(
999                format!("\"{}\"", display),
1000                serialized.to_string(),
1001                "Display mismatch for {:?}",
1002                variant
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn sort_direction_display_matches_serde() {
1009        let variants = [SortDirection::Asc, SortDirection::Desc];
1010        for variant in variants {
1011            let serialized = serde_json::to_value(variant).unwrap();
1012            let display = variant.to_string();
1013            assert_eq!(
1014                format!("\"{}\"", display),
1015                serialized.to_string(),
1016                "Display mismatch for {:?}",
1017                variant
1018            );
1019        }
1020    }
1021
1022    #[test]
1023    fn trade_side_display_matches_serde() {
1024        let variants = [TradeSide::Buy, TradeSide::Sell, TradeSide::Unknown];
1025        for variant in variants {
1026            let serialized = serde_json::to_value(variant).unwrap();
1027            let display = variant.to_string();
1028            assert_eq!(
1029                format!("\"{}\"", display),
1030                serialized.to_string(),
1031                "Display mismatch for {:?}",
1032                variant
1033            );
1034        }
1035    }
1036
1037    #[test]
1038    fn trade_side_falls_back_to_unknown_for_future_values() {
1039        let result: TradeSide = serde_json::from_str("\"SOME_FUTURE_SIDE\"").unwrap();
1040        assert_eq!(result, TradeSide::Unknown);
1041    }
1042
1043    #[test]
1044    fn trade_filter_type_display_matches_serde() {
1045        let variants = [TradeFilterType::Cash, TradeFilterType::Tokens];
1046        for variant in variants {
1047            let serialized = serde_json::to_value(variant).unwrap();
1048            let display = variant.to_string();
1049            assert_eq!(
1050                format!("\"{}\"", display),
1051                serialized.to_string(),
1052                "Display mismatch for {:?}",
1053                variant
1054            );
1055        }
1056    }
1057
1058    #[test]
1059    fn activity_type_display_matches_serde() {
1060        let variants = [
1061            ActivityType::Trade,
1062            ActivityType::Split,
1063            ActivityType::Merge,
1064            ActivityType::Redeem,
1065            ActivityType::Reward,
1066            ActivityType::Conversion,
1067            ActivityType::MakerRebate,
1068            ActivityType::ReferralReward,
1069            ActivityType::TakerRebate,
1070            ActivityType::Unknown,
1071        ];
1072        for variant in variants {
1073            let serialized = serde_json::to_value(variant).unwrap();
1074            let display = variant.to_string();
1075            assert_eq!(
1076                format!("\"{}\"", display),
1077                serialized.to_string(),
1078                "Display mismatch for {:?}",
1079                variant
1080            );
1081        }
1082    }
1083
1084    #[test]
1085    fn activity_type_roundtrip_serde() {
1086        for variant in [
1087            ActivityType::Trade,
1088            ActivityType::Split,
1089            ActivityType::Merge,
1090            ActivityType::Redeem,
1091            ActivityType::Reward,
1092            ActivityType::Conversion,
1093            ActivityType::MakerRebate,
1094            ActivityType::ReferralReward,
1095            ActivityType::TakerRebate,
1096        ] {
1097            let json = serde_json::to_string(&variant).unwrap();
1098            let deserialized: ActivityType = serde_json::from_str(&json).unwrap();
1099            assert_eq!(variant, deserialized);
1100        }
1101    }
1102
1103    #[test]
1104    fn activity_type_falls_back_to_unknown_for_future_types() {
1105        // A hypothetical future activity type Polymarket hasn't documented yet.
1106        let result: ActivityType = serde_json::from_str("\"SOME_FUTURE_TYPE\"").unwrap();
1107        assert_eq!(result, ActivityType::Unknown);
1108
1109        // Case mismatches also fall back rather than poisoning the whole page.
1110        let result: ActivityType = serde_json::from_str("\"trade\"").unwrap();
1111        assert_eq!(result, ActivityType::Unknown);
1112    }
1113
1114    #[test]
1115    fn sort_direction_default_is_desc() {
1116        assert_eq!(SortDirection::default(), SortDirection::Desc);
1117    }
1118
1119    #[test]
1120    fn closed_position_sort_by_default_is_realized_pnl() {
1121        assert_eq!(
1122            ClosedPositionSortBy::default(),
1123            ClosedPositionSortBy::RealizedPnl
1124        );
1125    }
1126
1127    #[test]
1128    fn activity_sort_by_default_is_timestamp() {
1129        assert_eq!(ActivitySortBy::default(), ActivitySortBy::Timestamp);
1130    }
1131
1132    #[test]
1133    fn position_sort_by_serde_roundtrip() {
1134        for variant in [
1135            PositionSortBy::Current,
1136            PositionSortBy::Initial,
1137            PositionSortBy::Tokens,
1138            PositionSortBy::CashPnl,
1139            PositionSortBy::PercentPnl,
1140            PositionSortBy::Title,
1141            PositionSortBy::Resolving,
1142            PositionSortBy::Price,
1143            PositionSortBy::AvgPrice,
1144        ] {
1145            let json = serde_json::to_string(&variant).unwrap();
1146            let deserialized: PositionSortBy = serde_json::from_str(&json).unwrap();
1147            assert_eq!(variant, deserialized);
1148        }
1149    }
1150
1151    #[test]
1152    fn deserialize_position_from_json() {
1153        let json = r#"{
1154            "proxyWallet": "0xabc123",
1155            "asset": "token123",
1156            "conditionId": "cond456",
1157            "size": 100.5,
1158            "avgPrice": 0.65,
1159            "initialValue": 65.0,
1160            "currentValue": 70.0,
1161            "cashPnl": 5.0,
1162            "percentPnl": 7.69,
1163            "totalBought": 100.5,
1164            "realizedPnl": 2.0,
1165            "percentRealizedPnl": 3.08,
1166            "curPrice": 0.70,
1167            "redeemable": false,
1168            "mergeable": true,
1169            "title": "Will X happen?",
1170            "slug": "will-x-happen",
1171            "icon": "https://example.com/icon.png",
1172            "eventSlug": "x-event",
1173            "outcome": "Yes",
1174            "outcomeIndex": 0,
1175            "oppositeOutcome": "No",
1176            "oppositeAsset": "token789",
1177            "endDate": "2025-12-31",
1178            "negativeRisk": false
1179        }"#;
1180
1181        let pos: Position = serde_json::from_str(json).unwrap();
1182        assert_eq!(pos.proxy_wallet, "0xabc123");
1183        assert_eq!(pos.asset, "token123");
1184        assert_eq!(pos.condition_id, "cond456");
1185        assert!((pos.size - 100.5).abs() < f64::EPSILON);
1186        assert!((pos.avg_price - 0.65).abs() < f64::EPSILON);
1187        assert!((pos.initial_value - 65.0).abs() < f64::EPSILON);
1188        assert!((pos.current_value - 70.0).abs() < f64::EPSILON);
1189        assert!((pos.cash_pnl - 5.0).abs() < f64::EPSILON);
1190        assert!(!pos.redeemable);
1191        assert!(pos.mergeable);
1192        assert_eq!(pos.title, "Will X happen?");
1193        assert_eq!(pos.outcome, "Yes");
1194        assert_eq!(pos.outcome_index, 0);
1195        assert_eq!(pos.opposite_outcome, "No");
1196        assert!(!pos.negative_risk);
1197        assert_eq!(pos.icon, Some("https://example.com/icon.png".to_string()));
1198        assert_eq!(pos.event_slug, Some("x-event".to_string()));
1199    }
1200
1201    #[test]
1202    fn deserialize_position_with_null_optionals() {
1203        let json = r#"{
1204            "proxyWallet": "0xabc123",
1205            "asset": "token123",
1206            "conditionId": "cond456",
1207            "size": 0.0,
1208            "avgPrice": 0.0,
1209            "initialValue": 0.0,
1210            "currentValue": 0.0,
1211            "cashPnl": 0.0,
1212            "percentPnl": 0.0,
1213            "totalBought": 0.0,
1214            "realizedPnl": 0.0,
1215            "percentRealizedPnl": 0.0,
1216            "curPrice": 0.0,
1217            "redeemable": false,
1218            "mergeable": false,
1219            "title": "Test",
1220            "slug": "test",
1221            "icon": null,
1222            "eventSlug": null,
1223            "outcome": "No",
1224            "outcomeIndex": 1,
1225            "oppositeOutcome": "Yes",
1226            "oppositeAsset": "token000",
1227            "endDate": null,
1228            "negativeRisk": true
1229        }"#;
1230
1231        let pos: Position = serde_json::from_str(json).unwrap();
1232        assert!(pos.icon.is_none());
1233        assert!(pos.event_slug.is_none());
1234        assert!(pos.end_date.is_none());
1235        assert!(pos.negative_risk);
1236    }
1237
1238    #[test]
1239    fn deserialize_closed_position_from_json() {
1240        let json = r#"{
1241            "proxyWallet": "0xdef456",
1242            "asset": "token_closed",
1243            "conditionId": "cond_closed",
1244            "avgPrice": 0.45,
1245            "totalBought": 200.0,
1246            "realizedPnl": -10.0,
1247            "curPrice": 0.35,
1248            "timestamp": 1700000000,
1249            "title": "Closed market?",
1250            "slug": "closed-market",
1251            "icon": null,
1252            "eventSlug": "closed-event",
1253            "outcome": "No",
1254            "outcomeIndex": 1,
1255            "oppositeOutcome": "Yes",
1256            "oppositeAsset": "token_opp",
1257            "endDate": "2024-06-30"
1258        }"#;
1259
1260        let closed: ClosedPosition = serde_json::from_str(json).unwrap();
1261        assert_eq!(closed.proxy_wallet, "0xdef456");
1262        assert!((closed.avg_price - 0.45).abs() < f64::EPSILON);
1263        assert!((closed.realized_pnl - (-10.0)).abs() < f64::EPSILON);
1264        assert_eq!(closed.timestamp, 1700000000);
1265        assert_eq!(closed.outcome, "No");
1266        assert_eq!(closed.outcome_index, 1);
1267        assert!(closed.icon.is_none());
1268        assert_eq!(closed.event_slug, Some("closed-event".to_string()));
1269    }
1270
1271    #[test]
1272    fn deserialize_trade_from_json() {
1273        let json = r#"{
1274            "proxyWallet": "0x1234",
1275            "side": "BUY",
1276            "asset": "token_buy",
1277            "conditionId": "cond_trade",
1278            "size": 50.0,
1279            "price": 0.72,
1280            "timestamp": 1700001000,
1281            "title": "Trade market?",
1282            "slug": "trade-market",
1283            "icon": "https://example.com/trade.png",
1284            "eventSlug": null,
1285            "outcome": "Yes",
1286            "outcomeIndex": 0,
1287            "name": "TraderOne",
1288            "pseudonym": "t1",
1289            "bio": "A trader",
1290            "profileImage": null,
1291            "profileImageOptimized": null,
1292            "transactionHash": "0xhash123"
1293        }"#;
1294
1295        let trade: Trade = serde_json::from_str(json).unwrap();
1296        assert_eq!(trade.proxy_wallet, "0x1234");
1297        assert_eq!(trade.side, TradeSide::Buy);
1298        assert!((trade.size - 50.0).abs() < f64::EPSILON);
1299        assert!((trade.price - 0.72).abs() < f64::EPSILON);
1300        assert_eq!(trade.timestamp, 1700001000);
1301        assert_eq!(trade.name, Some("TraderOne".to_string()));
1302        assert_eq!(trade.transaction_hash, Some("0xhash123".to_string()));
1303        assert!(trade.profile_image.is_none());
1304    }
1305
1306    #[test]
1307    fn deserialize_trade_sell_side() {
1308        let json = r#"{
1309            "proxyWallet": "0x5678",
1310            "side": "SELL",
1311            "asset": "token_sell",
1312            "conditionId": "cond_sell",
1313            "size": 25.0,
1314            "price": 0.30,
1315            "timestamp": 1700002000,
1316            "title": "Sell test",
1317            "slug": "sell-test",
1318            "icon": null,
1319            "eventSlug": null,
1320            "outcome": "No",
1321            "outcomeIndex": 1,
1322            "name": null,
1323            "pseudonym": null,
1324            "bio": null,
1325            "profileImage": null,
1326            "profileImageOptimized": null,
1327            "transactionHash": null
1328        }"#;
1329
1330        let trade: Trade = serde_json::from_str(json).unwrap();
1331        assert_eq!(trade.side, TradeSide::Sell);
1332        assert!(trade.name.is_none());
1333        assert!(trade.transaction_hash.is_none());
1334    }
1335
1336    #[test]
1337    fn deserialize_activity_from_json() {
1338        let json = r#"{
1339            "proxyWallet": "0xact123",
1340            "timestamp": 1700003000,
1341            "conditionId": "cond_act",
1342            "type": "TRADE",
1343            "size": 10.0,
1344            "usdcSize": 7.50,
1345            "transactionHash": "0xacthash",
1346            "price": 0.75,
1347            "asset": "token_act",
1348            "side": "BUY",
1349            "outcomeIndex": 0,
1350            "title": "Activity market",
1351            "slug": "activity-market",
1352            "icon": null,
1353            "outcome": "Yes",
1354            "name": null,
1355            "pseudonym": null,
1356            "bio": null,
1357            "profileImage": null,
1358            "profileImageOptimized": null
1359        }"#;
1360
1361        let activity: Activity = serde_json::from_str(json).unwrap();
1362        assert_eq!(activity.proxy_wallet, "0xact123");
1363        assert_eq!(activity.activity_type, ActivityType::Trade);
1364        assert!((activity.size - 10.0).abs() < f64::EPSILON);
1365        assert!((activity.usdc_size - 7.50).abs() < f64::EPSILON);
1366        assert_eq!(activity.side, Some("BUY".to_string()));
1367        assert_eq!(activity.outcome_index, Some(0));
1368    }
1369
1370    #[test]
1371    fn deserialize_activity_merge_type() {
1372        let json = r#"{
1373            "proxyWallet": "0xmerge",
1374            "timestamp": 1700004000,
1375            "conditionId": "cond_merge",
1376            "type": "MERGE",
1377            "size": 5.0,
1378            "usdcSize": 3.0,
1379            "transactionHash": null,
1380            "price": null,
1381            "asset": null,
1382            "side": "",
1383            "outcomeIndex": null,
1384            "title": null,
1385            "slug": null,
1386            "icon": null,
1387            "outcome": null,
1388            "name": null,
1389            "pseudonym": null,
1390            "bio": null,
1391            "profileImage": null,
1392            "profileImageOptimized": null
1393        }"#;
1394
1395        let activity: Activity = serde_json::from_str(json).unwrap();
1396        assert_eq!(activity.activity_type, ActivityType::Merge);
1397        // Side is an empty string from the API, stored as Some("")
1398        assert_eq!(activity.side, Some("".to_string()));
1399        assert!(activity.price.is_none());
1400        assert!(activity.asset.is_none());
1401        assert!(activity.title.is_none());
1402    }
1403
1404    #[test]
1405    fn deserialize_user_value() {
1406        let json = r#"{"user": "0xuser", "value": 1234.56}"#;
1407        let uv: UserValue = serde_json::from_str(json).unwrap();
1408        assert_eq!(uv.user, "0xuser");
1409        assert!((uv.value - 1234.56).abs() < f64::EPSILON);
1410    }
1411
1412    #[test]
1413    fn deserialize_open_interest() {
1414        let json = r#"{"market": "0xcond", "value": 50000.0}"#;
1415        let oi: OpenInterest = serde_json::from_str(json).unwrap();
1416        assert_eq!(oi.market, "0xcond");
1417        assert!((oi.value - 50000.0).abs() < f64::EPSILON);
1418    }
1419
1420    #[test]
1421    fn market_position_status_display_matches_serde() {
1422        for variant in [
1423            MarketPositionStatus::Open,
1424            MarketPositionStatus::Closed,
1425            MarketPositionStatus::All,
1426        ] {
1427            let serialized = serde_json::to_value(variant).unwrap();
1428            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1429        }
1430    }
1431
1432    #[test]
1433    fn market_position_status_default_is_all() {
1434        assert_eq!(MarketPositionStatus::default(), MarketPositionStatus::All);
1435    }
1436
1437    #[test]
1438    fn market_position_sort_by_display_matches_serde() {
1439        for variant in [
1440            MarketPositionSortBy::Tokens,
1441            MarketPositionSortBy::CashPnl,
1442            MarketPositionSortBy::RealizedPnl,
1443            MarketPositionSortBy::TotalPnl,
1444        ] {
1445            let serialized = serde_json::to_value(variant).unwrap();
1446            assert_eq!(format!("\"{}\"", variant), serialized.to_string());
1447        }
1448    }
1449
1450    #[test]
1451    fn market_position_sort_by_default_is_total_pnl() {
1452        assert_eq!(
1453            MarketPositionSortBy::default(),
1454            MarketPositionSortBy::TotalPnl
1455        );
1456    }
1457
1458    #[test]
1459    fn deserialize_market_position_v1() {
1460        // Field names lifted from `MarketPositionV1` in docs/specs/data/openapi.yaml.
1461        let json = r#"{
1462            "proxyWallet": "0xabc",
1463            "name": "Alice",
1464            "profileImage": "https://example.com/a.png",
1465            "verified": true,
1466            "asset": "token_a",
1467            "conditionId": "cond_mp",
1468            "avgPrice": 0.42,
1469            "size": 1234.5,
1470            "currPrice": 0.51,
1471            "currentValue": 629.60,
1472            "cashPnl": 110.0,
1473            "totalBought": 520.0,
1474            "realizedPnl": 15.5,
1475            "totalPnl": 125.5,
1476            "outcome": "Yes",
1477            "outcomeIndex": 0
1478        }"#;
1479
1480        let pos: MarketPositionV1 = serde_json::from_str(json).unwrap();
1481        assert_eq!(pos.proxy_wallet, "0xabc");
1482        assert_eq!(pos.name, "Alice");
1483        assert_eq!(
1484            pos.profile_image.as_deref(),
1485            Some("https://example.com/a.png")
1486        );
1487        assert!(pos.verified);
1488        assert_eq!(pos.asset, "token_a");
1489        assert_eq!(pos.condition_id, "cond_mp");
1490        assert!((pos.avg_price - 0.42).abs() < f64::EPSILON);
1491        assert!((pos.size - 1234.5).abs() < f64::EPSILON);
1492        assert!((pos.curr_price - 0.51).abs() < f64::EPSILON);
1493        assert!((pos.current_value - 629.60).abs() < f64::EPSILON);
1494        assert!((pos.cash_pnl - 110.0).abs() < f64::EPSILON);
1495        assert!((pos.total_bought - 520.0).abs() < f64::EPSILON);
1496        assert!((pos.realized_pnl - 15.5).abs() < f64::EPSILON);
1497        assert!((pos.total_pnl - 125.5).abs() < f64::EPSILON);
1498        assert_eq!(pos.outcome, "Yes");
1499        assert_eq!(pos.outcome_index, 0);
1500    }
1501
1502    #[test]
1503    fn market_position_v1_roundtrip() {
1504        let original = MarketPositionV1 {
1505            proxy_wallet: "0xabc".into(),
1506            name: "Alice".into(),
1507            profile_image: None,
1508            verified: false,
1509            asset: "token_a".into(),
1510            condition_id: "cond_mp".into(),
1511            avg_price: 0.5,
1512            size: 10.0,
1513            curr_price: 0.6,
1514            current_value: 6.0,
1515            cash_pnl: 1.0,
1516            total_bought: 5.0,
1517            realized_pnl: 0.0,
1518            total_pnl: 1.0,
1519            outcome: "No".into(),
1520            outcome_index: 1,
1521        };
1522        let json = serde_json::to_string(&original).unwrap();
1523        // Ensure currPrice is used over snake_case in the wire format.
1524        assert!(json.contains("\"currPrice\""));
1525        let back: MarketPositionV1 = serde_json::from_str(&json).unwrap();
1526        assert_eq!(back.proxy_wallet, original.proxy_wallet);
1527        assert_eq!(back.outcome_index, original.outcome_index);
1528        assert!((back.curr_price - original.curr_price).abs() < f64::EPSILON);
1529    }
1530
1531    #[test]
1532    fn deserialize_meta_market_position_v1() {
1533        let json = r#"{
1534            "token": "token_a",
1535            "positions": [
1536                {
1537                    "proxyWallet": "0xabc",
1538                    "name": "Alice",
1539                    "profileImage": null,
1540                    "verified": false,
1541                    "asset": "token_a",
1542                    "conditionId": "cond_mp",
1543                    "avgPrice": 0.42,
1544                    "size": 100.0,
1545                    "currPrice": 0.51,
1546                    "currentValue": 51.0,
1547                    "cashPnl": 9.0,
1548                    "totalBought": 42.0,
1549                    "realizedPnl": 0.0,
1550                    "totalPnl": 9.0,
1551                    "outcome": "Yes",
1552                    "outcomeIndex": 0
1553                }
1554            ]
1555        }"#;
1556
1557        let meta: MetaMarketPositionV1 = serde_json::from_str(json).unwrap();
1558        assert_eq!(meta.token, "token_a");
1559        assert_eq!(meta.positions.len(), 1);
1560        assert_eq!(meta.positions[0].name, "Alice");
1561        assert!(meta.positions[0].profile_image.is_none());
1562    }
1563}