Skip to main content

ig_client/model/
responses.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/10/25
5******************************************************************************/
6use crate::presentation::account::{
7    Account, AccountTransaction, Activity, ActivityMetadata, Position, TransactionMetadata,
8    WorkingOrder,
9};
10use crate::presentation::instrument::InstrumentType;
11use crate::presentation::market::{
12    Category, CategoryInstrument, CategoryInstrumentsMetadata, HistoricalPrice, MarketData,
13    MarketDetails, MarketNavigationNode, MarketNode, PriceAllowance,
14};
15use crate::presentation::order::{Direction, Status};
16use crate::utils::parsing::{deserialize_null_as_empty_vec, deserialize_nullable_status};
17use chrono::{DateTime, Utc};
18use pretty_simple_display::{DebugPretty, DisplaySimple};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21
22/// Database entry response for market instruments
23#[derive(
24    DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default,
25)]
26pub struct DBEntryResponse {
27    /// The trading symbol identifier
28    pub symbol: String,
29    /// The Epic identifier used by the exchange
30    pub epic: String,
31    /// Human-readable name of the instrument
32    pub name: String,
33    /// Instrument type classification
34    pub instrument_type: InstrumentType,
35    /// The exchange where this instrument is traded
36    pub exchange: String,
37    /// Expiration date and time for the instrument
38    pub expiry: String,
39    /// Timestamp of the last update to this record
40    pub last_update: DateTime<Utc>,
41}
42
43impl From<MarketNode> for DBEntryResponse {
44    fn from(value: MarketNode) -> Self {
45        let mut entry = DBEntryResponse::default();
46        if !value.markets.is_empty() {
47            let market = &value.markets[0];
48            entry.symbol = market
49                .epic
50                .split('.')
51                .nth(2)
52                .unwrap_or_default()
53                .to_string();
54            entry.epic = market.epic.clone();
55            entry.name = market.instrument_name.clone();
56            entry.instrument_type = market.instrument_type;
57            entry.exchange = "IG".to_string();
58            entry.expiry = market.expiry.clone();
59            entry.last_update = Utc::now();
60        }
61        entry
62    }
63}
64
65impl From<MarketData> for DBEntryResponse {
66    fn from(market: MarketData) -> Self {
67        DBEntryResponse {
68            symbol: market
69                .epic
70                .split('.')
71                .nth(2)
72                .unwrap_or_default()
73                .to_string(),
74            epic: market.epic.clone(),
75            name: market.instrument_name.clone(),
76            instrument_type: market.instrument_type,
77            exchange: "IG".to_string(),
78            expiry: market.expiry.clone(),
79            last_update: Utc::now(),
80        }
81    }
82}
83
84impl From<&MarketNode> for DBEntryResponse {
85    fn from(value: &MarketNode) -> Self {
86        DBEntryResponse::from(value.clone())
87    }
88}
89
90impl From<&MarketData> for DBEntryResponse {
91    fn from(market: &MarketData) -> Self {
92        DBEntryResponse::from(market.clone())
93    }
94}
95
96/// Response containing multiple market details
97#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
98pub struct MultipleMarketDetailsResponse {
99    /// List of market details
100    #[serde(rename = "marketDetails")]
101    pub market_details: Vec<MarketDetails>,
102}
103
104impl MultipleMarketDetailsResponse {
105    /// Returns the number of market details in the response
106    ///
107    /// # Returns
108    /// Number of market details
109    #[must_use]
110    pub fn len(&self) -> usize {
111        self.market_details.len()
112    }
113
114    /// Returns true if the response contains no market details
115    ///
116    /// # Returns
117    /// True if empty, false otherwise
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.market_details.is_empty()
121    }
122
123    /// Returns a reference to the market details vector
124    ///
125    /// # Returns
126    /// Reference to the vector of market details
127    #[must_use]
128    pub fn market_details(&self) -> &Vec<MarketDetails> {
129        &self.market_details
130    }
131
132    /// Returns an iterator over the market details
133    ///
134    /// # Returns
135    /// Iterator over market details
136    pub fn iter(&self) -> impl Iterator<Item = &MarketDetails> {
137        self.market_details.iter()
138    }
139}
140
141/// Model for historical prices
142#[derive(DebugPretty, Clone, Serialize, Deserialize)]
143pub struct HistoricalPricesResponse {
144    /// List of historical price points
145    pub prices: Vec<HistoricalPrice>,
146    /// Type of the instrument
147    #[serde(rename = "instrumentType")]
148    pub instrument_type: InstrumentType,
149    /// API usage allowance information
150    #[serde(rename = "allowance", skip_serializing_if = "Option::is_none", default)]
151    pub allowance: Option<PriceAllowance>,
152}
153
154impl HistoricalPricesResponse {
155    /// Returns the number of price points in the response
156    ///
157    /// # Returns
158    /// Number of price points
159    #[must_use]
160    pub fn len(&self) -> usize {
161        self.prices.len()
162    }
163
164    /// Returns true if the response contains no price points
165    ///
166    /// # Returns
167    /// True if empty, false otherwise
168    #[must_use]
169    pub fn is_empty(&self) -> bool {
170        self.prices.is_empty()
171    }
172
173    /// Returns a reference to the prices vector
174    ///
175    /// # Returns
176    /// Reference to the vector of historical prices
177    #[must_use]
178    pub fn prices(&self) -> &Vec<HistoricalPrice> {
179        &self.prices
180    }
181
182    /// Returns an iterator over the prices
183    ///
184    /// # Returns
185    /// Iterator over historical prices
186    pub fn iter(&self) -> impl Iterator<Item = &HistoricalPrice> {
187        self.prices.iter()
188    }
189}
190
191/// Model for market search results
192#[derive(DebugPretty, Clone, Serialize, Deserialize)]
193pub struct MarketSearchResponse {
194    /// List of markets matching the search criteria
195    pub markets: Vec<MarketData>,
196}
197
198impl MarketSearchResponse {
199    /// Returns the number of markets in the response
200    ///
201    /// # Returns
202    /// Number of markets
203    #[must_use]
204    pub fn len(&self) -> usize {
205        self.markets.len()
206    }
207
208    /// Returns true if the response contains no markets
209    ///
210    /// # Returns
211    /// True if empty, false otherwise
212    #[must_use]
213    pub fn is_empty(&self) -> bool {
214        self.markets.is_empty()
215    }
216
217    /// Returns a reference to the markets vector
218    ///
219    /// # Returns
220    /// Reference to the vector of markets
221    #[must_use]
222    pub fn markets(&self) -> &Vec<MarketData> {
223        &self.markets
224    }
225
226    /// Returns an iterator over the markets
227    ///
228    /// # Returns
229    /// Iterator over markets
230    pub fn iter(&self) -> impl Iterator<Item = &MarketData> {
231        self.markets.iter()
232    }
233}
234
235/// Response model for market navigation
236#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
237pub struct MarketNavigationResponse {
238    /// List of navigation nodes at the current level
239    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
240    pub nodes: Vec<MarketNavigationNode>,
241    /// List of markets at the current level
242    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
243    pub markets: Vec<MarketData>,
244}
245
246/// Response containing all categories of instruments enabled for the IG account
247#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize, Default)]
248pub struct CategoriesResponse {
249    /// List of categories
250    pub categories: Vec<Category>,
251}
252
253impl CategoriesResponse {
254    /// Returns the number of categories in the response
255    ///
256    /// # Returns
257    /// Number of categories
258    #[must_use]
259    pub fn len(&self) -> usize {
260        self.categories.len()
261    }
262
263    /// Returns true if the response contains no categories
264    ///
265    /// # Returns
266    /// True if empty, false otherwise
267    #[must_use]
268    pub fn is_empty(&self) -> bool {
269        self.categories.is_empty()
270    }
271
272    /// Returns a reference to the categories vector
273    ///
274    /// # Returns
275    /// Reference to the vector of categories
276    #[must_use]
277    pub fn categories(&self) -> &Vec<Category> {
278        &self.categories
279    }
280
281    /// Returns an iterator over the categories
282    ///
283    /// # Returns
284    /// Iterator over categories
285    pub fn iter(&self) -> impl Iterator<Item = &Category> {
286        self.categories.iter()
287    }
288}
289
290/// Response containing instruments for a specific category
291#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize, Default)]
292pub struct CategoryInstrumentsResponse {
293    /// List of instruments in the category
294    pub instruments: Vec<CategoryInstrument>,
295    /// Paging metadata
296    pub metadata: Option<CategoryInstrumentsMetadata>,
297}
298
299impl CategoryInstrumentsResponse {
300    /// Returns the number of instruments in the response
301    ///
302    /// # Returns
303    /// Number of instruments
304    #[must_use]
305    pub fn len(&self) -> usize {
306        self.instruments.len()
307    }
308
309    /// Returns true if the response contains no instruments
310    ///
311    /// # Returns
312    /// True if empty, false otherwise
313    #[must_use]
314    pub fn is_empty(&self) -> bool {
315        self.instruments.is_empty()
316    }
317
318    /// Returns a reference to the instruments vector
319    ///
320    /// # Returns
321    /// Reference to the vector of instruments
322    #[must_use]
323    pub fn instruments(&self) -> &Vec<CategoryInstrument> {
324        &self.instruments
325    }
326
327    /// Returns an iterator over the instruments
328    ///
329    /// # Returns
330    /// Iterator over instruments
331    pub fn iter(&self) -> impl Iterator<Item = &CategoryInstrument> {
332        self.instruments.iter()
333    }
334}
335
336/// Response containing user accounts
337#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize, Default)]
338pub struct AccountsResponse {
339    /// List of accounts owned by the user
340    pub accounts: Vec<Account>,
341}
342
343/// Open positions
344#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize, Default)]
345pub struct PositionsResponse {
346    /// List of open positions
347    pub positions: Vec<Position>,
348}
349
350impl PositionsResponse {
351    /// Compact positions by epic, combining positions with the same epic
352    ///
353    /// This method takes a vector of positions and returns a new vector where
354    /// positions with the same epic have been combined into a single position.
355    ///
356    /// # Arguments
357    /// * `positions` - A vector of positions to compact
358    ///
359    /// # Returns
360    /// A vector of positions with unique epics
361    #[must_use]
362    pub fn compact_by_epic(positions: Vec<Position>) -> Vec<Position> {
363        let mut epic_map: HashMap<String, Position> = std::collections::HashMap::new();
364
365        for position in positions {
366            let epic = position.market.epic.clone();
367            epic_map
368                .entry(epic)
369                .and_modify(|existing| {
370                    *existing = existing.clone() + position.clone();
371                })
372                .or_insert(position);
373        }
374
375        epic_map.into_values().collect()
376    }
377}
378
379/// Working orders
380#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
381pub struct WorkingOrdersResponse {
382    /// List of pending working orders
383    #[serde(rename = "workingOrders")]
384    pub working_orders: Vec<WorkingOrder>,
385}
386
387/// Account activity
388#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
389pub struct AccountActivityResponse {
390    /// List of activities on the account
391    pub activities: Vec<Activity>,
392    /// Metadata about pagination
393    pub metadata: Option<ActivityMetadata>,
394}
395
396/// Transaction history
397#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
398pub struct TransactionHistoryResponse {
399    /// List of account transactions
400    pub transactions: Vec<AccountTransaction>,
401    /// Metadata about the transaction list
402    pub metadata: TransactionMetadata,
403}
404
405/// Response to order creation
406#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
407pub struct CreateOrderResponse {
408    /// Client-generated reference for the deal
409    #[serde(rename = "dealReference")]
410    pub deal_reference: String,
411}
412
413/// Response to closing a position
414#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
415pub struct ClosePositionResponse {
416    /// Client-generated reference for the closing deal
417    #[serde(rename = "dealReference")]
418    pub deal_reference: String,
419}
420
421/// Response to updating a position
422#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
423pub struct UpdatePositionResponse {
424    /// Client-generated reference for the update deal
425    #[serde(rename = "dealReference")]
426    pub deal_reference: String,
427}
428
429/// Response to working order creation
430#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
431pub struct CreateWorkingOrderResponse {
432    /// Client-generated reference for the deal
433    #[serde(rename = "dealReference")]
434    pub deal_reference: String,
435}
436
437/// Outcome of a deal as reported by the order confirmation endpoint.
438///
439/// Returned by `GET /confirms/{dealReference}` in the top-level `dealStatus`
440/// field, for which IG documents `ACCEPTED` and `REJECTED`. This is distinct
441/// from [`AffectedDeal::status`], which reports a per-deal lifecycle status
442/// (e.g. `FULLY_CLOSED`, `PARTIALLY_CLOSED`, `OPENED`).
443#[repr(u8)]
444#[derive(Debug, Clone, Copy, DisplaySimple, Serialize, Deserialize, PartialEq, Eq, Hash)]
445#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
446pub enum DealStatus {
447    /// The deal was accepted by IG.
448    Accepted,
449    /// The deal was rejected by IG.
450    Rejected,
451}
452
453/// A deal affected by an order confirmation.
454///
455/// IG returns one entry per deal touched by the confirmed order — for example
456/// the individual deals partially closed to fill a closing order.
457#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
458pub struct AffectedDeal {
459    /// Identifier of the affected deal.
460    #[serde(rename = "dealId")]
461    pub deal_id: String,
462    /// Per-deal lifecycle status — a different domain from the top-level
463    /// [`DealStatus`]; IG returns values such as `FULLY_CLOSED`,
464    /// `PARTIALLY_CLOSED` or `OPENED`. Kept as a `String` because the set is
465    /// broader and less stable than the accept/reject outcome.
466    #[serde(rename = "status")]
467    pub status: String,
468}
469
470/// Details of a confirmed order
471#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
472pub struct OrderConfirmationResponse {
473    /// Date and time of the confirmation
474    pub date: String,
475    /// Status of the order (accepted, rejected, etc.)
476    /// This can be null in some responses (e.g., when market is closed)
477    #[serde(deserialize_with = "deserialize_nullable_status")]
478    pub status: Status,
479    /// Reason for rejection if applicable
480    pub reason: Option<String>,
481    /// Unique identifier for the deal
482    #[serde(rename = "dealId")]
483    pub deal_id: Option<String>,
484    /// Client-generated reference for the deal
485    #[serde(rename = "dealReference")]
486    pub deal_reference: String,
487    /// Status of the deal (accepted or rejected)
488    #[serde(rename = "dealStatus")]
489    #[serde(default)]
490    pub deal_status: Option<DealStatus>,
491    /// Instrument EPIC identifier
492    pub epic: Option<String>,
493    /// Expiry date for the order
494    #[serde(rename = "expiry")]
495    pub expiry: Option<String>,
496    /// Whether a guaranteed stop was used
497    #[serde(rename = "guaranteedStop")]
498    pub guaranteed_stop: Option<bool>,
499    /// Price level of the order
500    #[serde(rename = "level")]
501    pub level: Option<f64>,
502    /// Distance for take profit
503    #[serde(rename = "limitDistance")]
504    pub limit_distance: Option<f64>,
505    /// Price level for take profit
506    #[serde(rename = "limitLevel")]
507    pub limit_level: Option<f64>,
508    /// Size/quantity of the order
509    pub size: Option<f64>,
510    /// Distance for stop loss
511    #[serde(rename = "stopDistance")]
512    pub stop_distance: Option<f64>,
513    /// Price level for stop loss
514    #[serde(rename = "stopLevel")]
515    pub stop_level: Option<f64>,
516    /// Whether a trailing stop was used
517    #[serde(rename = "trailingStop")]
518    pub trailing_stop: Option<bool>,
519    /// Direction of the order (buy or sell)
520    pub direction: Option<Direction>,
521    /// Deals affected by this confirmation (empty when IG omits the field)
522    #[serde(rename = "affectedDeals")]
523    #[serde(default)]
524    pub affected_deals: Vec<AffectedDeal>,
525    /// Realised profit or loss for the confirmed deal, in `profit_currency`
526    #[serde(rename = "profit")]
527    #[serde(default)]
528    pub profit: Option<f64>,
529    /// Currency in which `profit` is denominated (ISO code, for example `GBP`)
530    #[serde(rename = "profitCurrency")]
531    #[serde(default)]
532    pub profit_currency: Option<String>,
533}
534
535impl std::fmt::Display for MultipleMarketDetailsResponse {
536    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537        use prettytable::format;
538        use prettytable::{Cell, Row, Table};
539
540        let mut table = Table::new();
541
542        // Set table format
543        table.set_format(*format::consts::FORMAT_BOX_CHARS);
544
545        // Add header
546        table.add_row(Row::new(vec![
547            Cell::new("INSTRUMENT NAME"),
548            Cell::new("EPIC"),
549            Cell::new("BID"),
550            Cell::new("OFFER"),
551            Cell::new("MID"),
552            Cell::new("SPREAD"),
553            Cell::new("EXPIRY"),
554            Cell::new("HIGH/LOW"),
555        ]));
556
557        // Sort by instrument name
558        let mut sorted_details = self.market_details.clone();
559        sorted_details.sort_by(|a, b| {
560            a.instrument
561                .name
562                .to_lowercase()
563                .cmp(&b.instrument.name.to_lowercase())
564        });
565
566        // Add rows
567        for details in &sorted_details {
568            let bid = details
569                .snapshot
570                .bid
571                .map(|b| format!("{:.2}", b))
572                .unwrap_or_else(|| "-".to_string());
573
574            let offer = details
575                .snapshot
576                .offer
577                .map(|o| format!("{:.2}", o))
578                .unwrap_or_else(|| "-".to_string());
579
580            let mid = match (details.snapshot.bid, details.snapshot.offer) {
581                (Some(b), Some(o)) => format!("{:.2}", (b + o) / 2.0),
582                _ => "-".to_string(),
583            };
584
585            let spread = match (details.snapshot.bid, details.snapshot.offer) {
586                (Some(b), Some(o)) => format!("{:.2}", o - b),
587                _ => "-".to_string(),
588            };
589
590            // Use expiry directly (shorter than last_dealing_date)
591            let expiry = details
592                .instrument
593                .expiry_details
594                .as_ref()
595                .map(|ed| {
596                    // Extract just the date part (YYYY-MM-DD)
597                    ed.last_dealing_date
598                        .split('T')
599                        .next()
600                        .unwrap_or(&ed.last_dealing_date)
601                        .to_string()
602                })
603                .unwrap_or_else(|| {
604                    details
605                        .instrument
606                        .expiry
607                        .split('T')
608                        .next()
609                        .unwrap_or(&details.instrument.expiry)
610                        .to_string()
611                });
612
613            let high_low = format!(
614                "{}/{}",
615                details
616                    .snapshot
617                    .high
618                    .map(|h| format!("{:.2}", h))
619                    .unwrap_or_else(|| "-".to_string()),
620                details
621                    .snapshot
622                    .low
623                    .map(|l| format!("{:.2}", l))
624                    .unwrap_or_else(|| "-".to_string())
625            );
626
627            // Truncate long names to make room for EPIC
628            let name = if details.instrument.name.len() > 30 {
629                format!("{}...", &details.instrument.name[0..27])
630            } else {
631                details.instrument.name.clone()
632            };
633
634            // Don't truncate EPIC - show it complete
635            let epic = details.instrument.epic.clone();
636
637            table.add_row(Row::new(vec![
638                Cell::new(&name),
639                Cell::new(&epic),
640                Cell::new(&bid),
641                Cell::new(&offer),
642                Cell::new(&mid),
643                Cell::new(&spread),
644                Cell::new(&expiry),
645                Cell::new(&high_low),
646            ]));
647        }
648
649        write!(f, "{}", table)
650    }
651}
652
653impl std::fmt::Display for HistoricalPricesResponse {
654    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655        use prettytable::format;
656        use prettytable::{Cell, Row, Table};
657
658        let mut table = Table::new();
659        table.set_format(*format::consts::FORMAT_BOX_CHARS);
660
661        // Add header
662        table.add_row(Row::new(vec![
663            Cell::new("SNAPSHOT TIME"),
664            Cell::new("OPEN BID"),
665            Cell::new("OPEN ASK"),
666            Cell::new("HIGH BID"),
667            Cell::new("HIGH ASK"),
668            Cell::new("LOW BID"),
669            Cell::new("LOW ASK"),
670            Cell::new("CLOSE BID"),
671            Cell::new("CLOSE ASK"),
672            Cell::new("VOLUME"),
673        ]));
674
675        // Add rows
676        for price in &self.prices {
677            let open_bid = price
678                .open_price
679                .bid
680                .map(|v| format!("{:.4}", v))
681                .unwrap_or_else(|| "-".to_string());
682
683            let open_ask = price
684                .open_price
685                .ask
686                .map(|v| format!("{:.4}", v))
687                .unwrap_or_else(|| "-".to_string());
688
689            let high_bid = price
690                .high_price
691                .bid
692                .map(|v| format!("{:.4}", v))
693                .unwrap_or_else(|| "-".to_string());
694
695            let high_ask = price
696                .high_price
697                .ask
698                .map(|v| format!("{:.4}", v))
699                .unwrap_or_else(|| "-".to_string());
700
701            let low_bid = price
702                .low_price
703                .bid
704                .map(|v| format!("{:.4}", v))
705                .unwrap_or_else(|| "-".to_string());
706
707            let low_ask = price
708                .low_price
709                .ask
710                .map(|v| format!("{:.4}", v))
711                .unwrap_or_else(|| "-".to_string());
712
713            let close_bid = price
714                .close_price
715                .bid
716                .map(|v| format!("{:.4}", v))
717                .unwrap_or_else(|| "-".to_string());
718
719            let close_ask = price
720                .close_price
721                .ask
722                .map(|v| format!("{:.4}", v))
723                .unwrap_or_else(|| "-".to_string());
724
725            let volume = price
726                .last_traded_volume
727                .map(|v| v.to_string())
728                .unwrap_or_else(|| "-".to_string());
729
730            table.add_row(Row::new(vec![
731                Cell::new(&price.snapshot_time),
732                Cell::new(&open_bid),
733                Cell::new(&open_ask),
734                Cell::new(&high_bid),
735                Cell::new(&high_ask),
736                Cell::new(&low_bid),
737                Cell::new(&low_ask),
738                Cell::new(&close_bid),
739                Cell::new(&close_ask),
740                Cell::new(&volume),
741            ]));
742        }
743
744        // Add summary footer
745        writeln!(f, "{}", table)?;
746        writeln!(f, "\nSummary:")?;
747        writeln!(f, "  Total price points: {}", self.prices.len())?;
748        writeln!(f, "  Instrument type: {:?}", self.instrument_type)?;
749
750        if let Some(allowance) = &self.allowance {
751            writeln!(
752                f,
753                "  Remaining allowance: {}",
754                allowance.remaining_allowance
755            )?;
756            writeln!(f, "  Total allowance: {}", allowance.total_allowance)?;
757        }
758
759        Ok(())
760    }
761}
762
763impl std::fmt::Display for MarketSearchResponse {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        use prettytable::format;
766        use prettytable::{Cell, Row, Table};
767
768        let mut table = Table::new();
769        table.set_format(*format::consts::FORMAT_BOX_CHARS);
770
771        // Add header
772        table.add_row(Row::new(vec![
773            Cell::new("INSTRUMENT NAME"),
774            Cell::new("EPIC"),
775            Cell::new("BID"),
776            Cell::new("OFFER"),
777            Cell::new("MID"),
778            Cell::new("SPREAD"),
779            Cell::new("EXPIRY"),
780            Cell::new("TYPE"),
781        ]));
782
783        // Sort by instrument name
784        let mut sorted_markets = self.markets.clone();
785        sorted_markets.sort_by(|a, b| {
786            a.instrument_name
787                .to_lowercase()
788                .cmp(&b.instrument_name.to_lowercase())
789        });
790
791        // Add rows
792        for market in &sorted_markets {
793            let bid = market
794                .bid
795                .map(|b| format!("{:.4}", b))
796                .unwrap_or_else(|| "-".to_string());
797
798            let offer = market
799                .offer
800                .map(|o| format!("{:.4}", o))
801                .unwrap_or_else(|| "-".to_string());
802
803            let mid = match (market.bid, market.offer) {
804                (Some(b), Some(o)) => format!("{:.4}", (b + o) / 2.0),
805                _ => "-".to_string(),
806            };
807
808            let spread = match (market.bid, market.offer) {
809                (Some(b), Some(o)) => format!("{:.4}", o - b),
810                _ => "-".to_string(),
811            };
812
813            // Truncate long names
814            let name = if market.instrument_name.len() > 30 {
815                format!("{}...", &market.instrument_name[0..27])
816            } else {
817                market.instrument_name.clone()
818            };
819
820            // Extract date from expiry
821            let expiry = market
822                .expiry
823                .split('T')
824                .next()
825                .unwrap_or(&market.expiry)
826                .to_string();
827
828            let instrument_type = format!("{:?}", market.instrument_type);
829
830            table.add_row(Row::new(vec![
831                Cell::new(&name),
832                Cell::new(&market.epic),
833                Cell::new(&bid),
834                Cell::new(&offer),
835                Cell::new(&mid),
836                Cell::new(&spread),
837                Cell::new(&expiry),
838                Cell::new(&instrument_type),
839            ]));
840        }
841
842        writeln!(f, "{}", table)?;
843        writeln!(f, "\nTotal markets found: {}", self.markets.len())?;
844
845        Ok(())
846    }
847}
848
849// ============================================================================
850// WATCHLIST RESPONSES
851// ============================================================================
852
853/// Response containing all watchlists for the active account
854#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
855pub struct WatchlistsResponse {
856    /// List of watchlists
857    pub watchlists: Vec<Watchlist>,
858}
859
860/// A watchlist containing instruments
861#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
862pub struct Watchlist {
863    /// Watchlist identifier
864    pub id: String,
865    /// Watchlist name
866    pub name: String,
867    /// Whether the watchlist can be edited
868    pub editable: bool,
869    /// Whether the watchlist can be deleted
870    pub deleteable: bool,
871    /// Whether this is a default system watchlist
872    #[serde(rename = "defaultSystemWatchlist")]
873    pub default_system_watchlist: bool,
874}
875
876/// Response when creating a new watchlist
877#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
878pub struct CreateWatchlistResponse {
879    /// The ID of the created watchlist
880    #[serde(rename = "watchlistId")]
881    pub watchlist_id: String,
882    /// Status of the operation
883    pub status: String,
884}
885
886/// Response containing markets in a watchlist
887#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
888pub struct WatchlistMarketsResponse {
889    /// List of markets in the watchlist
890    pub markets: Vec<MarketData>,
891}
892
893/// Generic status response for operations
894#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
895pub struct StatusResponse {
896    /// Status of the operation (e.g., "SUCCESS")
897    pub status: String,
898}
899
900// ============================================================================
901// CLIENT SENTIMENT RESPONSES
902// ============================================================================
903
904/// Response containing client sentiment for multiple markets
905#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
906pub struct ClientSentimentResponse {
907    /// List of client sentiments
908    #[serde(rename = "clientSentiments")]
909    pub client_sentiments: Vec<MarketSentiment>,
910}
911
912/// Client sentiment data for a single market
913#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
914pub struct MarketSentiment {
915    /// Market identifier
916    #[serde(rename = "marketId")]
917    pub market_id: String,
918    /// Percentage of clients with long positions
919    #[serde(rename = "longPositionPercentage")]
920    pub long_position_percentage: f64,
921    /// Percentage of clients with short positions
922    #[serde(rename = "shortPositionPercentage")]
923    pub short_position_percentage: f64,
924}
925
926// ============================================================================
927// INDICATIVE COSTS RESPONSES
928// ============================================================================
929
930/// Response containing indicative costs and charges
931#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
932pub struct IndicativeCostsResponse {
933    /// Reference for the indicative quote
934    #[serde(rename = "indicativeQuoteReference")]
935    pub indicative_quote_reference: String,
936    /// Costs and charges breakdown
937    #[serde(rename = "costsAndCharges")]
938    pub costs_and_charges: CostsAndCharges,
939}
940
941/// Breakdown of costs and charges
942#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
943pub struct CostsAndCharges {
944    /// Total cost percentage
945    #[serde(rename = "totalCostPercentage")]
946    pub total_cost_percentage: Option<f64>,
947    /// Total cost amount
948    #[serde(rename = "totalCostAmount")]
949    pub total_cost_amount: Option<f64>,
950    /// Currency
951    pub currency: Option<String>,
952    /// One-off costs
953    #[serde(rename = "oneOffCosts")]
954    pub one_off_costs: Option<CostBreakdown>,
955    /// Ongoing costs
956    #[serde(rename = "ongoingCosts")]
957    pub ongoing_costs: Option<CostBreakdown>,
958    /// Transaction costs
959    #[serde(rename = "transactionCosts")]
960    pub transaction_costs: Option<CostBreakdown>,
961    /// Incidental costs
962    #[serde(rename = "incidentalCosts")]
963    pub incidental_costs: Option<CostBreakdown>,
964}
965
966/// Breakdown of a specific cost category
967#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
968pub struct CostBreakdown {
969    /// Percentage value
970    pub percentage: Option<f64>,
971    /// Monetary amount
972    pub amount: Option<f64>,
973}
974
975/// Response containing paginated costs and charges history
976///
977/// Captured shape (demo, 2026-07-23): `{"pagination": {...},
978/// "costsAndChargesHistory": [...]}`. Entries carry **no amounts inline** —
979/// each references its disclosure document via `indicativeQuoteReference`,
980/// retrievable with `get_durable_medium`.
981#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
982pub struct CostsHistoryResponse {
983    /// Pagination metadata
984    #[serde(default)]
985    pub pagination: CostsHistoryPagination,
986    /// Costs and charges entries for the requested window
987    #[serde(rename = "costsAndChargesHistory", default)]
988    pub costs_and_charges_history: Vec<CostsHistoryEntry>,
989}
990
991/// Pagination metadata of the costs history response
992#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
993pub struct CostsHistoryPagination {
994    /// Page size requested
995    #[serde(rename = "pageSize", default)]
996    pub page_size: i64,
997    /// Current page number (1-based)
998    #[serde(rename = "pageNumber", default)]
999    pub page_number: i64,
1000    /// Total number of pages available
1001    #[serde(rename = "totalPages", default)]
1002    pub total_pages: i64,
1003    /// Total number of entries across all pages
1004    #[serde(rename = "totalElements", default)]
1005    pub total_elements: i64,
1006}
1007
1008/// One costs and charges history entry
1009#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
1010pub struct CostsHistoryEntry {
1011    /// Entry type (e.g. `TRADE`)
1012    #[serde(rename = "type", default)]
1013    pub entry_type: Option<String>,
1014    /// Deal direction (`BUY` / `SELL`)
1015    #[serde(default)]
1016    pub direction: Option<String>,
1017    /// Instrument name (may be empty)
1018    #[serde(rename = "instrumentName", default)]
1019    pub instrument_name: Option<String>,
1020    /// Creation timestamp (ISO-8601, no zone designator)
1021    #[serde(rename = "createdTimestamp", default)]
1022    pub created_timestamp: Option<String>,
1023    /// Reference of the disclosure document holding the actual amounts
1024    /// (retrieve with `get_durable_medium`)
1025    #[serde(rename = "indicativeQuoteReference", default)]
1026    pub indicative_quote_reference: Option<String>,
1027}
1028
1029/// Response containing a durable medium document
1030#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
1031pub struct DurableMediumResponse {
1032    /// The durable medium document content (typically HTML or PDF)
1033    pub document: String,
1034}
1035
1036// ============================================================================
1037// ACCOUNT PREFERENCES RESPONSES
1038// ============================================================================
1039
1040/// Response containing account preferences
1041#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
1042pub struct AccountPreferencesResponse {
1043    /// Whether trailing stops are enabled
1044    #[serde(rename = "trailingStopsEnabled")]
1045    pub trailing_stops_enabled: bool,
1046}
1047
1048// ============================================================================
1049// OPERATIONS/APPLICATION RESPONSES
1050// ============================================================================
1051
1052/// Response containing application details (a single application)
1053#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
1054pub struct ApplicationDetailsResponse {
1055    /// API key
1056    #[serde(rename = "apiKey")]
1057    pub api_key: String,
1058    /// Application name
1059    pub name: Option<String>,
1060    /// Application status
1061    pub status: String,
1062    /// Overall allowance for the account
1063    #[serde(rename = "allowanceAccountOverall")]
1064    pub allowance_account_overall: Option<i64>,
1065    /// Trading allowance for the account
1066    #[serde(rename = "allowanceAccountTrading")]
1067    pub allowance_account_trading: Option<i64>,
1068    /// Concurrent connections allowance
1069    #[serde(rename = "concurrentSubscriptionsLimit")]
1070    pub concurrent_subscriptions_limit: Option<i64>,
1071    /// Creation date
1072    #[serde(rename = "createdDate")]
1073    pub created_date: Option<String>,
1074}
1075
1076// ============================================================================
1077// SINGLE POSITION RESPONSE
1078// ============================================================================
1079
1080/// Response containing a single position
1081#[derive(DebugPretty, Clone, Serialize, Deserialize)]
1082pub struct SinglePositionResponse {
1083    /// Position details
1084    pub position: Position,
1085    /// Market data for the position
1086    pub market: MarketData,
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092    use crate::model::auth::{SessionResponse, V3Response};
1093    use crate::presentation::account::ActivityType;
1094
1095    /// Round-trips a DTO through `serialize -> deserialize` and returns the
1096    /// re-parsed value. The DTOs under test do not all derive `PartialEq`, so
1097    /// callers assert the load-bearing fields on the result instead of comparing
1098    /// whole structs. This pins each custom `serialize_with` helper to its
1099    /// `deserialize_with` counterpart.
1100    fn roundtrip<T>(value: &T) -> T
1101    where
1102        T: serde::Serialize + serde::de::DeserializeOwned,
1103    {
1104        let json = serde_json::to_string(value).expect("serialize failed");
1105        serde_json::from_str(&json).expect("re-deserialize failed")
1106    }
1107
1108    #[test]
1109    fn test_accounts_response_deserialize_and_roundtrip() {
1110        let json = r#"{
1111            "accounts": [
1112                {
1113                    "accountId": "ABC12",
1114                    "accountName": "Demo CFD",
1115                    "accountType": "CFD",
1116                    "balance": {
1117                        "balance": 10000.0,
1118                        "deposit": 2000.0,
1119                        "profitLoss": 150.5,
1120                        "available": 8000.0
1121                    },
1122                    "currency": "EUR",
1123                    "status": "ENABLED",
1124                    "preferred": true
1125                }
1126            ]
1127        }"#;
1128
1129        let resp: AccountsResponse = serde_json::from_str(json).expect("deserialize failed");
1130        assert_eq!(resp.accounts.len(), 1);
1131        let acc = &resp.accounts[0];
1132        assert_eq!(acc.account_id, "ABC12");
1133        assert_eq!(acc.account_type, "CFD");
1134        assert!((acc.balance.available - 8000.0).abs() < 1e-9);
1135        assert!(acc.preferred);
1136
1137        let re = roundtrip(&resp);
1138        assert_eq!(re.accounts[0].account_id, "ABC12");
1139        assert_eq!(re.accounts[0].currency, "EUR");
1140    }
1141
1142    #[test]
1143    fn test_positions_response_deserialize_and_roundtrip() {
1144        let json = r#"{
1145            "positions": [
1146                {
1147                    "position": {
1148                        "contractSize": 1.0,
1149                        "createdDate": "2025/07/01 10:00:00:000",
1150                        "createdDateUTC": "2025-07-01T08:00:00",
1151                        "dealId": "DIFAKE111",
1152                        "dealReference": "REFFAKE111",
1153                        "direction": "BUY",
1154                        "limitLevel": null,
1155                        "level": 100.5,
1156                        "size": 2.0,
1157                        "stopLevel": null,
1158                        "trailingStep": null,
1159                        "trailingStopDistance": null,
1160                        "currency": "GBP",
1161                        "controlledRisk": false,
1162                        "limitedRiskPremium": null
1163                    },
1164                    "market": {
1165                        "instrumentName": "FTSE 100",
1166                        "expiry": "-",
1167                        "epic": "IX.D.FTSE.DAILY.IP",
1168                        "instrumentType": "INDICES",
1169                        "lotSize": 1.0,
1170                        "high": 7600.0,
1171                        "low": 7500.0,
1172                        "percentageChange": 0.5,
1173                        "netChange": 30.0,
1174                        "bid": 7550.0,
1175                        "offer": 7551.0,
1176                        "updateTime": "10:00:00",
1177                        "updateTimeUTC": "08:00:00",
1178                        "delayTime": 0,
1179                        "streamingPricesAvailable": true,
1180                        "marketStatus": "TRADEABLE",
1181                        "scalingFactor": 1
1182                    },
1183                    "pnl": null
1184                }
1185            ]
1186        }"#;
1187
1188        let resp: PositionsResponse = serde_json::from_str(json).expect("deserialize failed");
1189        assert_eq!(resp.positions.len(), 1);
1190        let pos = &resp.positions[0];
1191        assert_eq!(pos.position.deal_id, "DIFAKE111");
1192        assert_eq!(pos.position.direction, Direction::Buy);
1193        assert!((pos.position.size - 2.0).abs() < 1e-9);
1194        assert_eq!(pos.market.epic, "IX.D.FTSE.DAILY.IP");
1195        assert_eq!(pos.market.bid, Some(7550.0));
1196        assert!(pos.pnl.is_none());
1197
1198        let re = roundtrip(&resp);
1199        assert_eq!(re.positions[0].position.deal_id, "DIFAKE111");
1200        assert_eq!(re.positions[0].market.epic, "IX.D.FTSE.DAILY.IP");
1201    }
1202
1203    #[test]
1204    fn test_working_orders_response_deserialize_and_roundtrip() {
1205        // Inline, sanitized replacement for the former file-dependent test that
1206        // required an uncommitted `Data/working_orders.json`. Runs fully offline.
1207        let json = r#"{
1208            "workingOrders": [
1209                {
1210                    "workingOrderData": {
1211                        "dealId": "DIFAKEWO1",
1212                        "direction": "SELL",
1213                        "epic": "CS.D.EURUSD.MINI.IP",
1214                        "orderSize": 1.5,
1215                        "orderLevel": 1.2345,
1216                        "timeInForce": "GOOD_TILL_CANCELLED",
1217                        "goodTillDate": null,
1218                        "goodTillDateISO": null,
1219                        "createdDate": "2025/07/01 09:30:00:000",
1220                        "createdDateUTC": "2025-07-01T07:30:00",
1221                        "guaranteedStop": false,
1222                        "orderType": "LIMIT",
1223                        "stopDistance": null,
1224                        "limitDistance": null,
1225                        "currencyCode": "USD",
1226                        "dma": false,
1227                        "limitedRiskPremium": null,
1228                        "limitLevel": null,
1229                        "stopLevel": null,
1230                        "dealReference": "REFFAKEWO1"
1231                    },
1232                    "marketData": {
1233                        "instrumentName": "EUR/USD Mini",
1234                        "exchangeId": "FX",
1235                        "expiry": "-",
1236                        "marketStatus": "TRADEABLE",
1237                        "epic": "CS.D.EURUSD.MINI.IP",
1238                        "instrumentType": "CURRENCIES",
1239                        "lotSize": 1.0,
1240                        "high": 1.24,
1241                        "low": 1.23,
1242                        "percentageChange": 0.1,
1243                        "netChange": 0.001,
1244                        "bid": 1.2344,
1245                        "offer": 1.2346,
1246                        "updateTime": "09:30:00",
1247                        "updateTimeUTC": "07:30:00",
1248                        "delayTime": 0,
1249                        "streamingPricesAvailable": true,
1250                        "scalingFactor": 1
1251                    }
1252                }
1253            ]
1254        }"#;
1255
1256        let resp: WorkingOrdersResponse = serde_json::from_str(json).expect("deserialize failed");
1257        assert_eq!(resp.working_orders.len(), 1);
1258        let order = &resp.working_orders[0];
1259        assert_eq!(order.working_order_data.epic, "CS.D.EURUSD.MINI.IP");
1260        assert_eq!(order.working_order_data.direction, Direction::Sell);
1261        assert!((order.working_order_data.order_size - 1.5).abs() < 1e-9);
1262        assert!((order.working_order_data.order_level - 1.2345).abs() < 1e-9);
1263        assert_eq!(
1264            order.market_data.instrument_type,
1265            InstrumentType::Currencies
1266        );
1267
1268        let re = roundtrip(&resp);
1269        assert_eq!(re.working_orders[0].working_order_data.deal_id, "DIFAKEWO1");
1270        assert_eq!(re.working_orders[0].market_data.epic, "CS.D.EURUSD.MINI.IP");
1271    }
1272
1273    #[test]
1274    fn test_account_activity_response_deserialize_and_roundtrip() {
1275        let json = r#"{
1276            "activities": [
1277                {
1278                    "date": "2025-07-01T09:00:00",
1279                    "dealId": "DIFAKEACT1",
1280                    "epic": "IX.D.FTSE.DAILY.IP",
1281                    "period": "DAY",
1282                    "dealReference": "REFFAKEACT1",
1283                    "type": "POSITION",
1284                    "status": "ACCEPTED",
1285                    "description": "Position opened",
1286                    "channel": "WEB",
1287                    "currency": "GBP",
1288                    "level": "7550.0"
1289                }
1290            ],
1291            "metadata": { "paging": { "size": 50, "next": null } }
1292        }"#;
1293
1294        let resp: AccountActivityResponse = serde_json::from_str(json).expect("deserialize failed");
1295        assert_eq!(resp.activities.len(), 1);
1296        let act = &resp.activities[0];
1297        assert_eq!(act.deal_id.as_deref(), Some("DIFAKEACT1"));
1298        assert_eq!(act.activity_type, ActivityType::Position);
1299        assert_eq!(act.status, Some(Status::Accepted));
1300        assert!(resp.metadata.is_some());
1301
1302        let re = roundtrip(&resp);
1303        assert_eq!(re.activities[0].activity_type, ActivityType::Position);
1304        assert_eq!(
1305            re.activities[0].deal_reference.as_deref(),
1306            Some("REFFAKEACT1")
1307        );
1308    }
1309
1310    #[test]
1311    fn test_transaction_history_response_deserialize_and_roundtrip() {
1312        let json = r#"{
1313            "transactions": [
1314                {
1315                    "date": "01/07/25",
1316                    "dateUtc": "2025-07-01T08:00:00",
1317                    "openDateUtc": "2025-06-30T08:00:00",
1318                    "instrumentName": "FTSE 100",
1319                    "period": "DAY",
1320                    "profitAndLoss": "E150.50",
1321                    "transactionType": "DEAL",
1322                    "reference": "REFFAKETX1",
1323                    "openLevel": "7500.0",
1324                    "closeLevel": "7550.0",
1325                    "size": "2",
1326                    "currency": "GBP",
1327                    "cashTransaction": false
1328                }
1329            ],
1330            "metadata": {
1331                "pageData": { "pageNumber": 1, "pageSize": 20, "totalPages": 1 },
1332                "size": 1
1333            }
1334        }"#;
1335
1336        let resp: TransactionHistoryResponse =
1337            serde_json::from_str(json).expect("deserialize failed");
1338        assert_eq!(resp.transactions.len(), 1);
1339        assert_eq!(resp.transactions[0].reference, "REFFAKETX1");
1340        assert_eq!(resp.transactions[0].profit_and_loss, "E150.50");
1341        assert_eq!(resp.metadata.size, 1);
1342        assert_eq!(resp.metadata.page_data.page_number, 1);
1343
1344        let re = roundtrip(&resp);
1345        assert_eq!(re.transactions[0].reference, "REFFAKETX1");
1346        assert_eq!(re.metadata.page_data.total_pages, 1);
1347    }
1348
1349    #[test]
1350    fn test_watchlists_response_deserialize_and_roundtrip() {
1351        let json = r#"{
1352            "watchlists": [
1353                {
1354                    "id": "WL1",
1355                    "name": "My Watchlist",
1356                    "editable": true,
1357                    "deleteable": true,
1358                    "defaultSystemWatchlist": false
1359                }
1360            ]
1361        }"#;
1362
1363        let resp: WatchlistsResponse = serde_json::from_str(json).expect("deserialize failed");
1364        assert_eq!(resp.watchlists.len(), 1);
1365        let wl = &resp.watchlists[0];
1366        assert_eq!(wl.id, "WL1");
1367        assert_eq!(wl.name, "My Watchlist");
1368        assert!(wl.editable);
1369        assert!(!wl.default_system_watchlist);
1370
1371        let re = roundtrip(&resp);
1372        assert_eq!(re.watchlists[0].id, "WL1");
1373        assert!(re.watchlists[0].deleteable);
1374    }
1375
1376    #[test]
1377    fn test_client_sentiment_response_deserialize_and_roundtrip() {
1378        let json = r#"{
1379            "clientSentiments": [
1380                {
1381                    "marketId": "EURUSD",
1382                    "longPositionPercentage": 62.5,
1383                    "shortPositionPercentage": 37.5
1384                }
1385            ]
1386        }"#;
1387
1388        let resp: ClientSentimentResponse = serde_json::from_str(json).expect("deserialize failed");
1389        assert_eq!(resp.client_sentiments.len(), 1);
1390        let s = &resp.client_sentiments[0];
1391        assert_eq!(s.market_id, "EURUSD");
1392        assert!((s.long_position_percentage - 62.5).abs() < 1e-9);
1393        assert!((s.short_position_percentage - 37.5).abs() < 1e-9);
1394
1395        let re = roundtrip(&resp);
1396        assert_eq!(re.client_sentiments[0].market_id, "EURUSD");
1397    }
1398
1399    #[test]
1400    fn test_indicative_costs_response_deserialize_and_roundtrip() {
1401        let json = r#"{
1402            "indicativeQuoteReference": "QREF-FAKE-1",
1403            "costsAndCharges": {
1404                "totalCostPercentage": 0.12,
1405                "totalCostAmount": 3.45,
1406                "currency": "GBP",
1407                "oneOffCosts": { "percentage": 0.05, "amount": 1.0 },
1408                "ongoingCosts": { "percentage": 0.02, "amount": 0.5 },
1409                "transactionCosts": { "percentage": 0.03, "amount": 1.2 },
1410                "incidentalCosts": { "percentage": 0.02, "amount": 0.75 }
1411            }
1412        }"#;
1413
1414        let resp: IndicativeCostsResponse = serde_json::from_str(json).expect("deserialize failed");
1415        assert_eq!(resp.indicative_quote_reference, "QREF-FAKE-1");
1416        assert_eq!(resp.costs_and_charges.total_cost_amount, Some(3.45));
1417        assert_eq!(resp.costs_and_charges.currency.as_deref(), Some("GBP"));
1418        let one_off = resp
1419            .costs_and_charges
1420            .one_off_costs
1421            .as_ref()
1422            .expect("oneOffCosts present");
1423        assert_eq!(one_off.amount, Some(1.0));
1424
1425        let re = roundtrip(&resp);
1426        assert_eq!(re.indicative_quote_reference, "QREF-FAKE-1");
1427        assert_eq!(re.costs_and_charges.total_cost_percentage, Some(0.12));
1428    }
1429
1430    #[test]
1431    fn test_costs_and_charges_deserialize_and_roundtrip() {
1432        let json = r#"{
1433            "totalCostPercentage": 0.20,
1434            "totalCostAmount": 5.0,
1435            "currency": "USD",
1436            "transactionCosts": { "percentage": 0.10, "amount": 2.5 }
1437        }"#;
1438
1439        let costs: CostsAndCharges = serde_json::from_str(json).expect("deserialize failed");
1440        assert_eq!(costs.total_cost_amount, Some(5.0));
1441        assert_eq!(costs.currency.as_deref(), Some("USD"));
1442        // Absent cost categories default to None (not `deny_unknown_fields`).
1443        assert!(costs.one_off_costs.is_none());
1444        assert!(costs.ongoing_costs.is_none());
1445        let txn = costs
1446            .transaction_costs
1447            .as_ref()
1448            .expect("transactionCosts present");
1449        assert_eq!(txn.percentage, Some(0.10));
1450
1451        let re = roundtrip(&costs);
1452        assert_eq!(re.total_cost_amount, Some(5.0));
1453    }
1454
1455    #[test]
1456    fn test_costs_history_response_deserialize_and_roundtrip() {
1457        // Captured from demo indicativecostsandcharges/history (2026-07-23),
1458        // truncated to one entry; quote reference shape-preservingly redacted.
1459        let json = r#"{
1460            "pagination": {"pageSize": 10, "pageNumber": 1, "totalPages": 23, "totalElements": 224},
1461            "costsAndChargesHistory": [
1462                {
1463                    "type": "TRADE",
1464                    "direction": "SELL",
1465                    "instrumentName": "",
1466                    "createdTimestamp": "2026-01-12T14:38:46.401",
1467                    "indicativeQuoteReference": "00000000-2a79-4710-aa44-000000000000"
1468                }
1469            ]
1470        }"#;
1471
1472        let resp: CostsHistoryResponse = serde_json::from_str(json).expect("deserialize failed");
1473        assert_eq!(resp.pagination.page_size, 10);
1474        assert_eq!(resp.pagination.total_pages, 23);
1475        assert_eq!(resp.pagination.total_elements, 224);
1476        assert_eq!(resp.costs_and_charges_history.len(), 1);
1477        let entry = &resp.costs_and_charges_history[0];
1478        assert_eq!(entry.entry_type.as_deref(), Some("TRADE"));
1479        assert_eq!(entry.direction.as_deref(), Some("SELL"));
1480        assert_eq!(entry.instrument_name.as_deref(), Some(""));
1481        assert_eq!(
1482            entry.created_timestamp.as_deref(),
1483            Some("2026-01-12T14:38:46.401")
1484        );
1485        assert_eq!(
1486            entry.indicative_quote_reference.as_deref(),
1487            Some("00000000-2a79-4710-aa44-000000000000")
1488        );
1489
1490        let re = roundtrip(&resp);
1491        assert_eq!(re.pagination.total_elements, 224);
1492        assert_eq!(
1493            re.costs_and_charges_history[0].entry_type.as_deref(),
1494            Some("TRADE")
1495        );
1496    }
1497
1498    #[test]
1499    fn test_costs_history_response_minimal_payload_defaults() {
1500        // An empty-window response and a fully minimal object must both parse.
1501        let empty_window: CostsHistoryResponse = serde_json::from_str(
1502            r#"{"pagination":{"pageSize":20,"pageNumber":1,"totalPages":0,"totalElements":0},"costsAndChargesHistory":[]}"#,
1503        )
1504        .expect("empty-window payload must deserialize");
1505        assert!(empty_window.costs_and_charges_history.is_empty());
1506        assert_eq!(empty_window.pagination.total_pages, 0);
1507
1508        let minimal: CostsHistoryResponse =
1509            serde_json::from_str("{}").expect("minimal payload must deserialize");
1510        assert!(minimal.costs_and_charges_history.is_empty());
1511        assert_eq!(minimal.pagination.page_number, 0);
1512
1513        let minimal_entry: CostsHistoryEntry =
1514            serde_json::from_str("{}").expect("minimal entry must deserialize");
1515        assert_eq!(minimal_entry.entry_type, None);
1516        assert_eq!(minimal_entry.indicative_quote_reference, None);
1517    }
1518
1519    #[test]
1520    fn test_durable_medium_response_deserialize_and_roundtrip() {
1521        let json = r#"{ "document": "<html>Terms and Conditions</html>" }"#;
1522
1523        let resp: DurableMediumResponse = serde_json::from_str(json).expect("deserialize failed");
1524        assert_eq!(resp.document, "<html>Terms and Conditions</html>");
1525
1526        let re = roundtrip(&resp);
1527        assert_eq!(re.document, "<html>Terms and Conditions</html>");
1528    }
1529
1530    #[test]
1531    fn test_account_preferences_response_deserialize_and_roundtrip() {
1532        let json = r#"{ "trailingStopsEnabled": true }"#;
1533
1534        let resp: AccountPreferencesResponse =
1535            serde_json::from_str(json).expect("deserialize failed");
1536        assert!(resp.trailing_stops_enabled);
1537
1538        let re = roundtrip(&resp);
1539        assert!(re.trailing_stops_enabled);
1540    }
1541
1542    #[test]
1543    fn test_application_details_response_deserialize_and_roundtrip() {
1544        // `allowanceAccountOverall` is deliberately > i32::MAX (2_147_483_647)
1545        // to prove the field widening to i64 — an i32 field would overflow here.
1546        let json = r#"{
1547            "apiKey": "FAKE-API-KEY",
1548            "name": "My App",
1549            "status": "ENABLED",
1550            "allowanceAccountOverall": 3000000000,
1551            "allowanceAccountTrading": 1000,
1552            "concurrentSubscriptionsLimit": 40,
1553            "createdDate": "2025-01-01"
1554        }"#;
1555
1556        let resp: ApplicationDetailsResponse =
1557            serde_json::from_str(json).expect("deserialize failed");
1558        assert_eq!(resp.api_key, "FAKE-API-KEY");
1559        assert_eq!(resp.name.as_deref(), Some("My App"));
1560        assert_eq!(resp.status, "ENABLED");
1561        assert!(resp.allowance_account_overall > Some(i64::from(i32::MAX)));
1562        assert_eq!(resp.allowance_account_overall, Some(3_000_000_000));
1563        assert_eq!(resp.concurrent_subscriptions_limit, Some(40));
1564
1565        let re = roundtrip(&resp);
1566        assert_eq!(re.api_key, "FAKE-API-KEY");
1567        assert_eq!(re.allowance_account_overall, Some(3_000_000_000));
1568        assert_eq!(re.allowance_account_trading, Some(1000));
1569    }
1570
1571    #[test]
1572    fn test_categories_response_deserialize_and_roundtrip() {
1573        let json = r#"{
1574            "categories": [
1575                { "code": "INDICES", "nonTradeable": false },
1576                { "code": "CRYPTOCURRENCY", "nonTradeable": true }
1577            ]
1578        }"#;
1579
1580        let resp: CategoriesResponse = serde_json::from_str(json).expect("deserialize failed");
1581        assert_eq!(resp.len(), 2);
1582        assert_eq!(resp.categories[0].code, "INDICES");
1583        assert!(!resp.categories[0].non_tradeable);
1584        assert!(resp.categories[1].non_tradeable);
1585
1586        let re = roundtrip(&resp);
1587        assert_eq!(re.categories[1].code, "CRYPTOCURRENCY");
1588    }
1589
1590    #[test]
1591    fn test_historical_prices_response_deserialize_and_roundtrip() {
1592        let json = r#"{
1593            "prices": [
1594                {
1595                    "snapshotTime": "2025:07:01-09:00:00",
1596                    "openPrice": { "bid": 1.2340, "ask": 1.2342, "lastTraded": null },
1597                    "highPrice": { "bid": 1.2350, "ask": 1.2352, "lastTraded": null },
1598                    "lowPrice": { "bid": 1.2330, "ask": 1.2332, "lastTraded": null },
1599                    "closePrice": { "bid": 1.2345, "ask": 1.2347, "lastTraded": null },
1600                    "lastTradedVolume": 1234
1601                }
1602            ],
1603            "instrumentType": "CURRENCIES",
1604            "allowance": {
1605                "remainingAllowance": 9950,
1606                "totalAllowance": 10000,
1607                "allowanceExpiry": 604800
1608            }
1609        }"#;
1610
1611        let resp: HistoricalPricesResponse =
1612            serde_json::from_str(json).expect("deserialize failed");
1613        assert_eq!(resp.len(), 1);
1614        assert_eq!(resp.prices[0].snapshot_time, "2025:07:01-09:00:00");
1615        assert_eq!(resp.prices[0].close_price.bid, Some(1.2345));
1616        assert_eq!(resp.prices[0].last_traded_volume, Some(1234));
1617        assert_eq!(resp.instrument_type, InstrumentType::Currencies);
1618        let allowance = resp.allowance.as_ref().expect("allowance present");
1619        assert_eq!(allowance.remaining_allowance, 9950);
1620        assert_eq!(allowance.total_allowance, 10000);
1621
1622        let re = roundtrip(&resp);
1623        assert_eq!(re.prices[0].open_price.ask, Some(1.2342));
1624        assert_eq!(
1625            re.allowance.as_ref().map(|a| a.allowance_expiry),
1626            Some(604800)
1627        );
1628    }
1629
1630    #[test]
1631    fn test_v3_login_response_deserialize_and_roundtrip() {
1632        // Raw v3 (OAuth) login payload. `oauthToken.created_at` is derived at
1633        // parse time (serde `skip`), so it is intentionally absent from the wire
1634        // payload. No real tokens: obvious `FAKE-*` placeholders only.
1635        let json = r#"{
1636            "clientId": "FAKE-CLIENT-101",
1637            "accountId": "ABC12",
1638            "timezoneOffset": 1,
1639            "lightstreamerEndpoint": "https://demo-apd.marketdatasystems.com",
1640            "oauthToken": {
1641                "access_token": "FAKE-ACCESS-TOKEN",
1642                "refresh_token": "FAKE-REFRESH-TOKEN",
1643                "scope": "profile",
1644                "token_type": "Bearer",
1645                "expires_in": "60"
1646            }
1647        }"#;
1648
1649        let resp: V3Response = serde_json::from_str(json).expect("deserialize failed");
1650        assert_eq!(resp.client_id, "FAKE-CLIENT-101");
1651        assert_eq!(resp.account_id, "ABC12");
1652        assert_eq!(resp.oauth_token.access_token, "FAKE-ACCESS-TOKEN");
1653        assert_eq!(resp.oauth_token.refresh_token, "FAKE-REFRESH-TOKEN");
1654        assert_eq!(resp.oauth_token.token_type, "Bearer");
1655        assert_eq!(resp.oauth_token.expires_in, "60");
1656
1657        // The untagged `SessionResponse` must land on the V3 variant.
1658        let session_resp: SessionResponse =
1659            serde_json::from_str(json).expect("session deserialize failed");
1660        assert!(session_resp.is_v3());
1661        assert!(session_resp.get_session().is_oauth());
1662
1663        let re = roundtrip(&resp);
1664        assert_eq!(re.oauth_token.access_token, "FAKE-ACCESS-TOKEN");
1665        assert_eq!(re.account_id, "ABC12");
1666    }
1667}