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::prelude::{Account, Activity, MarketDetails};
7use crate::presentation::account::{
8    AccountTransaction, ActivityMetadata, Position, TransactionMetadata, WorkingOrder,
9};
10use crate::presentation::instrument::InstrumentType;
11use crate::presentation::market::{
12    HistoricalPrice, MarketData, MarketNavigationNode, MarketNode, PriceAllowance,
13};
14use crate::presentation::order::{Direction, Status};
15use crate::utils::parsing::{deserialize_null_as_empty_vec, deserialize_nullable_status};
16use chrono::{DateTime, Utc};
17use pretty_simple_display::{DebugPretty, DisplaySimple};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21/// Database entry response for market instruments
22#[derive(
23    DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default,
24)]
25pub struct DBEntryResponse {
26    /// The trading symbol identifier
27    pub symbol: String,
28    /// The Epic identifier used by the exchange
29    pub epic: String,
30    /// Human-readable name of the instrument
31    pub name: String,
32    /// Instrument type classification
33    pub instrument_type: InstrumentType,
34    /// The exchange where this instrument is traded
35    pub exchange: String,
36    /// Expiration date and time for the instrument
37    pub expiry: String,
38    /// Timestamp of the last update to this record
39    pub last_update: DateTime<Utc>,
40}
41
42impl From<MarketNode> for DBEntryResponse {
43    fn from(value: MarketNode) -> Self {
44        let mut entry = DBEntryResponse::default();
45        if !value.markets.is_empty() {
46            let market = &value.markets[0];
47            entry.symbol = market
48                .epic
49                .split('.')
50                .nth(2)
51                .unwrap_or_default()
52                .to_string();
53            entry.epic = market.epic.clone();
54            entry.name = market.instrument_name.clone();
55            entry.instrument_type = market.instrument_type;
56            entry.exchange = "IG".to_string();
57            entry.expiry = market.expiry.clone();
58            entry.last_update = Utc::now();
59        }
60        entry
61    }
62}
63
64impl From<MarketData> for DBEntryResponse {
65    fn from(market: MarketData) -> Self {
66        DBEntryResponse {
67            symbol: market
68                .epic
69                .split('.')
70                .nth(2)
71                .unwrap_or_default()
72                .to_string(),
73            epic: market.epic.clone(),
74            name: market.instrument_name.clone(),
75            instrument_type: market.instrument_type,
76            exchange: "IG".to_string(),
77            expiry: market.expiry.clone(),
78            last_update: Utc::now(),
79        }
80    }
81}
82
83impl From<&MarketNode> for DBEntryResponse {
84    fn from(value: &MarketNode) -> Self {
85        DBEntryResponse::from(value.clone())
86    }
87}
88
89impl From<&MarketData> for DBEntryResponse {
90    fn from(market: &MarketData) -> Self {
91        DBEntryResponse::from(market.clone())
92    }
93}
94
95/// Response containing multiple market details
96#[derive(DebugPretty, Clone, Serialize, Deserialize, Default)]
97pub struct MultipleMarketDetailsResponse {
98    /// List of market details
99    #[serde(rename = "marketDetails")]
100    pub market_details: Vec<MarketDetails>,
101}
102
103impl MultipleMarketDetailsResponse {
104    /// Returns the number of market details in the response
105    ///
106    /// # Returns
107    /// Number of market details
108    #[must_use]
109    pub fn len(&self) -> usize {
110        self.market_details.len()
111    }
112
113    /// Returns true if the response contains no market details
114    ///
115    /// # Returns
116    /// True if empty, false otherwise
117    #[must_use]
118    pub fn is_empty(&self) -> bool {
119        self.market_details.is_empty()
120    }
121
122    /// Returns a reference to the market details vector
123    ///
124    /// # Returns
125    /// Reference to the vector of market details
126    #[must_use]
127    pub fn market_details(&self) -> &Vec<MarketDetails> {
128        &self.market_details
129    }
130
131    /// Returns an iterator over the market details
132    ///
133    /// # Returns
134    /// Iterator over market details
135    pub fn iter(&self) -> impl Iterator<Item = &MarketDetails> {
136        self.market_details.iter()
137    }
138}
139
140impl std::fmt::Display for MultipleMarketDetailsResponse {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        use prettytable::format;
143        use prettytable::{Cell, Row, Table};
144
145        let mut table = Table::new();
146
147        // Set table format
148        table.set_format(*format::consts::FORMAT_BOX_CHARS);
149
150        // Add header
151        table.add_row(Row::new(vec![
152            Cell::new("INSTRUMENT NAME"),
153            Cell::new("EPIC"),
154            Cell::new("BID"),
155            Cell::new("OFFER"),
156            Cell::new("MID"),
157            Cell::new("SPREAD"),
158            Cell::new("EXPIRY"),
159            Cell::new("HIGH/LOW"),
160        ]));
161
162        // Sort by instrument name
163        let mut sorted_details = self.market_details.clone();
164        sorted_details.sort_by(|a, b| {
165            a.instrument
166                .name
167                .to_lowercase()
168                .cmp(&b.instrument.name.to_lowercase())
169        });
170
171        // Add rows
172        for details in &sorted_details {
173            let bid = details
174                .snapshot
175                .bid
176                .map(|b| format!("{:.2}", b))
177                .unwrap_or_else(|| "-".to_string());
178
179            let offer = details
180                .snapshot
181                .offer
182                .map(|o| format!("{:.2}", o))
183                .unwrap_or_else(|| "-".to_string());
184
185            let mid = match (details.snapshot.bid, details.snapshot.offer) {
186                (Some(b), Some(o)) => format!("{:.2}", (b + o) / 2.0),
187                _ => "-".to_string(),
188            };
189
190            let spread = match (details.snapshot.bid, details.snapshot.offer) {
191                (Some(b), Some(o)) => format!("{:.2}", o - b),
192                _ => "-".to_string(),
193            };
194
195            // Use expiry directly (shorter than last_dealing_date)
196            let expiry = details
197                .instrument
198                .expiry_details
199                .as_ref()
200                .map(|ed| {
201                    // Extract just the date part (YYYY-MM-DD)
202                    ed.last_dealing_date
203                        .split('T')
204                        .next()
205                        .unwrap_or(&ed.last_dealing_date)
206                        .to_string()
207                })
208                .unwrap_or_else(|| {
209                    details
210                        .instrument
211                        .expiry
212                        .split('T')
213                        .next()
214                        .unwrap_or(&details.instrument.expiry)
215                        .to_string()
216                });
217
218            let high_low = format!(
219                "{}/{}",
220                details
221                    .snapshot
222                    .high
223                    .map(|h| format!("{:.2}", h))
224                    .unwrap_or_else(|| "-".to_string()),
225                details
226                    .snapshot
227                    .low
228                    .map(|l| format!("{:.2}", l))
229                    .unwrap_or_else(|| "-".to_string())
230            );
231
232            // Truncate long names to make room for EPIC
233            let name = if details.instrument.name.len() > 30 {
234                format!("{}...", &details.instrument.name[0..27])
235            } else {
236                details.instrument.name.clone()
237            };
238
239            // Don't truncate EPIC - show it complete
240            let epic = details.instrument.epic.clone();
241
242            table.add_row(Row::new(vec![
243                Cell::new(&name),
244                Cell::new(&epic),
245                Cell::new(&bid),
246                Cell::new(&offer),
247                Cell::new(&mid),
248                Cell::new(&spread),
249                Cell::new(&expiry),
250                Cell::new(&high_low),
251            ]));
252        }
253
254        write!(f, "{}", table)
255    }
256}
257
258/// Model for historical prices
259#[derive(DebugPretty, Clone, Serialize, Deserialize)]
260pub struct HistoricalPricesResponse {
261    /// List of historical price points
262    pub prices: Vec<HistoricalPrice>,
263    /// Type of the instrument
264    #[serde(rename = "instrumentType")]
265    pub instrument_type: InstrumentType,
266    /// API usage allowance information
267    #[serde(rename = "allowance", skip_serializing_if = "Option::is_none", default)]
268    pub allowance: Option<PriceAllowance>,
269}
270
271impl HistoricalPricesResponse {
272    /// Returns the number of price points in the response
273    ///
274    /// # Returns
275    /// Number of price points
276    #[must_use]
277    pub fn len(&self) -> usize {
278        self.prices.len()
279    }
280
281    /// Returns true if the response contains no price points
282    ///
283    /// # Returns
284    /// True if empty, false otherwise
285    #[must_use]
286    pub fn is_empty(&self) -> bool {
287        self.prices.is_empty()
288    }
289
290    /// Returns a reference to the prices vector
291    ///
292    /// # Returns
293    /// Reference to the vector of historical prices
294    #[must_use]
295    pub fn prices(&self) -> &Vec<HistoricalPrice> {
296        &self.prices
297    }
298
299    /// Returns an iterator over the prices
300    ///
301    /// # Returns
302    /// Iterator over historical prices
303    pub fn iter(&self) -> impl Iterator<Item = &HistoricalPrice> {
304        self.prices.iter()
305    }
306}
307
308impl std::fmt::Display for HistoricalPricesResponse {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        use prettytable::format;
311        use prettytable::{Cell, Row, Table};
312
313        let mut table = Table::new();
314        table.set_format(*format::consts::FORMAT_BOX_CHARS);
315
316        // Add header
317        table.add_row(Row::new(vec![
318            Cell::new("SNAPSHOT TIME"),
319            Cell::new("OPEN BID"),
320            Cell::new("OPEN ASK"),
321            Cell::new("HIGH BID"),
322            Cell::new("HIGH ASK"),
323            Cell::new("LOW BID"),
324            Cell::new("LOW ASK"),
325            Cell::new("CLOSE BID"),
326            Cell::new("CLOSE ASK"),
327            Cell::new("VOLUME"),
328        ]));
329
330        // Add rows
331        for price in &self.prices {
332            let open_bid = price
333                .open_price
334                .bid
335                .map(|v| format!("{:.4}", v))
336                .unwrap_or_else(|| "-".to_string());
337
338            let open_ask = price
339                .open_price
340                .ask
341                .map(|v| format!("{:.4}", v))
342                .unwrap_or_else(|| "-".to_string());
343
344            let high_bid = price
345                .high_price
346                .bid
347                .map(|v| format!("{:.4}", v))
348                .unwrap_or_else(|| "-".to_string());
349
350            let high_ask = price
351                .high_price
352                .ask
353                .map(|v| format!("{:.4}", v))
354                .unwrap_or_else(|| "-".to_string());
355
356            let low_bid = price
357                .low_price
358                .bid
359                .map(|v| format!("{:.4}", v))
360                .unwrap_or_else(|| "-".to_string());
361
362            let low_ask = price
363                .low_price
364                .ask
365                .map(|v| format!("{:.4}", v))
366                .unwrap_or_else(|| "-".to_string());
367
368            let close_bid = price
369                .close_price
370                .bid
371                .map(|v| format!("{:.4}", v))
372                .unwrap_or_else(|| "-".to_string());
373
374            let close_ask = price
375                .close_price
376                .ask
377                .map(|v| format!("{:.4}", v))
378                .unwrap_or_else(|| "-".to_string());
379
380            let volume = price
381                .last_traded_volume
382                .map(|v| v.to_string())
383                .unwrap_or_else(|| "-".to_string());
384
385            table.add_row(Row::new(vec![
386                Cell::new(&price.snapshot_time),
387                Cell::new(&open_bid),
388                Cell::new(&open_ask),
389                Cell::new(&high_bid),
390                Cell::new(&high_ask),
391                Cell::new(&low_bid),
392                Cell::new(&low_ask),
393                Cell::new(&close_bid),
394                Cell::new(&close_ask),
395                Cell::new(&volume),
396            ]));
397        }
398
399        // Add summary footer
400        writeln!(f, "{}", table)?;
401        writeln!(f, "\nSummary:")?;
402        writeln!(f, "  Total price points: {}", self.prices.len())?;
403        writeln!(f, "  Instrument type: {:?}", self.instrument_type)?;
404
405        if let Some(allowance) = &self.allowance {
406            writeln!(
407                f,
408                "  Remaining allowance: {}",
409                allowance.remaining_allowance
410            )?;
411            writeln!(f, "  Total allowance: {}", allowance.total_allowance)?;
412        }
413
414        Ok(())
415    }
416}
417
418/// Model for market search results
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct MarketSearchResponse {
421    /// List of markets matching the search criteria
422    pub markets: Vec<MarketData>,
423}
424
425impl MarketSearchResponse {
426    /// Returns the number of markets in the response
427    ///
428    /// # Returns
429    /// Number of markets
430    #[must_use]
431    pub fn len(&self) -> usize {
432        self.markets.len()
433    }
434
435    /// Returns true if the response contains no markets
436    ///
437    /// # Returns
438    /// True if empty, false otherwise
439    #[must_use]
440    pub fn is_empty(&self) -> bool {
441        self.markets.is_empty()
442    }
443
444    /// Returns a reference to the markets vector
445    ///
446    /// # Returns
447    /// Reference to the vector of markets
448    #[must_use]
449    pub fn markets(&self) -> &Vec<MarketData> {
450        &self.markets
451    }
452
453    /// Returns an iterator over the markets
454    ///
455    /// # Returns
456    /// Iterator over markets
457    pub fn iter(&self) -> impl Iterator<Item = &MarketData> {
458        self.markets.iter()
459    }
460}
461
462impl std::fmt::Display for MarketSearchResponse {
463    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464        use prettytable::format;
465        use prettytable::{Cell, Row, Table};
466
467        let mut table = Table::new();
468        table.set_format(*format::consts::FORMAT_BOX_CHARS);
469
470        // Add header
471        table.add_row(Row::new(vec![
472            Cell::new("INSTRUMENT NAME"),
473            Cell::new("EPIC"),
474            Cell::new("BID"),
475            Cell::new("OFFER"),
476            Cell::new("MID"),
477            Cell::new("SPREAD"),
478            Cell::new("EXPIRY"),
479            Cell::new("TYPE"),
480        ]));
481
482        // Sort by instrument name
483        let mut sorted_markets = self.markets.clone();
484        sorted_markets.sort_by(|a, b| {
485            a.instrument_name
486                .to_lowercase()
487                .cmp(&b.instrument_name.to_lowercase())
488        });
489
490        // Add rows
491        for market in &sorted_markets {
492            let bid = market
493                .bid
494                .map(|b| format!("{:.4}", b))
495                .unwrap_or_else(|| "-".to_string());
496
497            let offer = market
498                .offer
499                .map(|o| format!("{:.4}", o))
500                .unwrap_or_else(|| "-".to_string());
501
502            let mid = match (market.bid, market.offer) {
503                (Some(b), Some(o)) => format!("{:.4}", (b + o) / 2.0),
504                _ => "-".to_string(),
505            };
506
507            let spread = match (market.bid, market.offer) {
508                (Some(b), Some(o)) => format!("{:.4}", o - b),
509                _ => "-".to_string(),
510            };
511
512            // Truncate long names
513            let name = if market.instrument_name.len() > 30 {
514                format!("{}...", &market.instrument_name[0..27])
515            } else {
516                market.instrument_name.clone()
517            };
518
519            // Extract date from expiry
520            let expiry = market
521                .expiry
522                .split('T')
523                .next()
524                .unwrap_or(&market.expiry)
525                .to_string();
526
527            let instrument_type = format!("{:?}", market.instrument_type);
528
529            table.add_row(Row::new(vec![
530                Cell::new(&name),
531                Cell::new(&market.epic),
532                Cell::new(&bid),
533                Cell::new(&offer),
534                Cell::new(&mid),
535                Cell::new(&spread),
536                Cell::new(&expiry),
537                Cell::new(&instrument_type),
538            ]));
539        }
540
541        writeln!(f, "{}", table)?;
542        writeln!(f, "\nTotal markets found: {}", self.markets.len())?;
543
544        Ok(())
545    }
546}
547
548/// Response model for market navigation
549#[derive(Debug, Clone, Deserialize, Serialize)]
550pub struct MarketNavigationResponse {
551    /// List of navigation nodes at the current level
552    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
553    pub nodes: Vec<MarketNavigationNode>,
554    /// List of markets at the current level
555    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
556    pub markets: Vec<MarketData>,
557}
558
559/// Response containing user accounts
560#[derive(Debug, Clone, Deserialize)]
561pub struct AccountsResponse {
562    /// List of accounts owned by the user
563    pub accounts: Vec<Account>,
564}
565
566/// Open positions
567#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize, Default)]
568pub struct PositionsResponse {
569    /// List of open positions
570    pub positions: Vec<Position>,
571}
572
573impl PositionsResponse {
574    /// Compact positions by epic, combining positions with the same epic
575    ///
576    /// This method takes a vector of positions and returns a new vector where
577    /// positions with the same epic have been combined into a single position.
578    ///
579    /// # Arguments
580    /// * `positions` - A vector of positions to compact
581    ///
582    /// # Returns
583    /// A vector of positions with unique epics
584    pub fn compact_by_epic(positions: Vec<Position>) -> Vec<Position> {
585        let mut epic_map: HashMap<String, Position> = std::collections::HashMap::new();
586
587        for position in positions {
588            let epic = position.market.epic.clone();
589            epic_map
590                .entry(epic)
591                .and_modify(|existing| {
592                    *existing = existing.clone() + position.clone();
593                })
594                .or_insert(position);
595        }
596
597        epic_map.into_values().collect()
598    }
599}
600
601/// Working orders
602#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
603pub struct WorkingOrdersResponse {
604    /// List of pending working orders
605    #[serde(rename = "workingOrders")]
606    pub working_orders: Vec<WorkingOrder>,
607}
608
609/// Account activity
610#[derive(Debug, Clone, Deserialize)]
611pub struct AccountActivityResponse {
612    /// List of activities on the account
613    pub activities: Vec<Activity>,
614    /// Metadata about pagination
615    pub metadata: Option<ActivityMetadata>,
616}
617
618/// Transaction history
619#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
620pub struct TransactionHistoryResponse {
621    /// List of account transactions
622    pub transactions: Vec<AccountTransaction>,
623    /// Metadata about the transaction list
624    pub metadata: TransactionMetadata,
625}
626
627/// Response to order creation
628#[derive(Debug, Clone, DisplaySimple, Serialize, Deserialize)]
629pub struct CreateOrderResponse {
630    /// Client-generated reference for the deal
631    #[serde(rename = "dealReference")]
632    pub deal_reference: String,
633}
634
635/// Response to closing a position
636#[derive(Debug, Clone, DisplaySimple, Serialize, Deserialize)]
637pub struct ClosePositionResponse {
638    /// Client-generated reference for the closing deal
639    #[serde(rename = "dealReference")]
640    pub deal_reference: String,
641}
642
643/// Response to updating a position
644#[derive(Debug, Clone, DisplaySimple, Serialize, Deserialize)]
645pub struct UpdatePositionResponse {
646    /// Client-generated reference for the update deal
647    #[serde(rename = "dealReference")]
648    pub deal_reference: String,
649}
650
651/// Response to working order creation
652#[derive(Debug, Clone, DisplaySimple, Serialize, Deserialize)]
653pub struct CreateWorkingOrderResponse {
654    /// Client-generated reference for the deal
655    #[serde(rename = "dealReference")]
656    pub deal_reference: String,
657}
658
659/// Details of a confirmed order
660#[derive(Debug, Clone, DisplaySimple, Serialize, Deserialize)]
661pub struct OrderConfirmationResponse {
662    /// Date and time of the confirmation
663    pub date: String,
664    /// Status of the order (accepted, rejected, etc.)
665    /// This can be null in some responses (e.g., when market is closed)
666    #[serde(deserialize_with = "deserialize_nullable_status")]
667    pub status: Status,
668    /// Reason for rejection if applicable
669    pub reason: Option<String>,
670    /// Unique identifier for the deal
671    #[serde(rename = "dealId")]
672    pub deal_id: Option<String>,
673    /// Client-generated reference for the deal
674    #[serde(rename = "dealReference")]
675    pub deal_reference: String,
676    /// Status of the deal
677    #[serde(rename = "dealStatus")]
678    pub deal_status: Option<String>,
679    /// Instrument EPIC identifier
680    pub epic: Option<String>,
681    /// Expiry date for the order
682    #[serde(rename = "expiry")]
683    pub expiry: Option<String>,
684    /// Whether a guaranteed stop was used
685    #[serde(rename = "guaranteedStop")]
686    pub guaranteed_stop: Option<bool>,
687    /// Price level of the order
688    #[serde(rename = "level")]
689    pub level: Option<f64>,
690    /// Distance for take profit
691    #[serde(rename = "limitDistance")]
692    pub limit_distance: Option<f64>,
693    /// Price level for take profit
694    #[serde(rename = "limitLevel")]
695    pub limit_level: Option<f64>,
696    /// Size/quantity of the order
697    pub size: Option<f64>,
698    /// Distance for stop loss
699    #[serde(rename = "stopDistance")]
700    pub stop_distance: Option<f64>,
701    /// Price level for stop loss
702    #[serde(rename = "stopLevel")]
703    pub stop_level: Option<f64>,
704    /// Whether a trailing stop was used
705    #[serde(rename = "trailingStop")]
706    pub trailing_stop: Option<bool>,
707    /// Direction of the order (buy or sell)
708    pub direction: Option<Direction>,
709}