Skip to main content

ig_client/presentation/
market.rs

1use crate::presentation::instrument::InstrumentType;
2use crate::presentation::serialization::{string_as_bool_opt, string_as_float_opt};
3use pretty_simple_display::{DebugPretty, DisplaySimple};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Model for a market instrument with enhanced deserialization
8#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq)]
9pub struct Instrument {
10    /// Unique identifier for the instrument
11    pub epic: String,
12    /// Human-readable name of the instrument
13    pub name: String,
14    /// Expiry date of the instrument
15    pub expiry: String,
16    /// Size of one contract
17    #[serde(rename = "contractSize")]
18    pub contract_size: String,
19    /// Size of one lot
20    #[serde(rename = "lotSize")]
21    pub lot_size: Option<f64>,
22    /// Upper price limit for the instrument
23    #[serde(rename = "highLimitPrice")]
24    pub high_limit_price: Option<f64>,
25    /// Lower price limit for the instrument
26    #[serde(rename = "lowLimitPrice")]
27    pub low_limit_price: Option<f64>,
28    /// Margin factor for the instrument
29    #[serde(rename = "marginFactor")]
30    pub margin_factor: Option<f64>,
31    /// Unit for the margin factor
32    #[serde(rename = "marginFactorUnit")]
33    pub margin_factor_unit: Option<String>,
34    /// Available currencies for trading this instrument
35    pub currencies: Option<Vec<Currency>>,
36    #[serde(rename = "valueOfOnePip")]
37    /// Value of one pip for this instrument
38    pub value_of_one_pip: String,
39    /// Type of the instrument
40    #[serde(rename = "instrumentType")]
41    pub instrument_type: Option<InstrumentType>,
42    /// Expiry details including last dealing date
43    #[serde(rename = "expiryDetails")]
44    pub expiry_details: Option<ExpiryDetails>,
45    #[serde(rename = "slippageFactor")]
46    /// Slippage factor for the instrument
47    pub slippage_factor: Option<StepDistance>,
48    #[serde(rename = "limitedRiskPremium")]
49    /// Premium for limited risk trades
50    pub limited_risk_premium: Option<StepDistance>,
51    #[serde(rename = "newsCode")]
52    /// Code used for news related to this instrument
53    pub news_code: Option<String>,
54    #[serde(rename = "chartCode")]
55    /// Code used for charting this instrument
56    pub chart_code: Option<String>,
57}
58
59/// Model for an instrument's currency
60#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq)]
61pub struct Currency {
62    /// Currency code (e.g., "USD", "EUR")
63    pub code: String,
64    /// Currency symbol (e.g., "$", "€")
65    pub symbol: Option<String>,
66    /// Base exchange rate for the currency
67    #[serde(rename = "baseExchangeRate")]
68    pub base_exchange_rate: Option<f64>,
69    /// Current exchange rate
70    #[serde(rename = "exchangeRate")]
71    pub exchange_rate: Option<f64>,
72    /// Whether this is the default currency for the instrument
73    #[serde(rename = "isDefault")]
74    pub is_default: Option<bool>,
75}
76
77/// Model for market data with enhanced deserialization
78#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
79pub struct MarketDetails {
80    /// Detailed information about the instrument
81    pub instrument: Instrument,
82    /// Current market snapshot with prices
83    pub snapshot: MarketSnapshot,
84    /// Trading rules for the market
85    #[serde(rename = "dealingRules")]
86    pub dealing_rules: DealingRules,
87}
88
89/// Trading rules for a market with enhanced deserialization
90#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
91pub struct DealingRules {
92    /// Minimum step distance
93    #[serde(rename = "minStepDistance")]
94    pub min_step_distance: Option<StepDistance>,
95
96    /// Minimum deal size allowed
97    #[serde(rename = "minDealSize")]
98    pub min_deal_size: Option<StepDistance>,
99
100    /// Minimum distance for controlled risk stop
101    #[serde(rename = "minControlledRiskStopDistance")]
102    pub min_controlled_risk_stop_distance: Option<StepDistance>,
103
104    /// Minimum distance for normal stop or limit orders
105    #[serde(rename = "minNormalStopOrLimitDistance")]
106    pub min_normal_stop_or_limit_distance: Option<StepDistance>,
107
108    /// Maximum distance for stop or limit orders
109    #[serde(rename = "maxStopOrLimitDistance")]
110    pub max_stop_or_limit_distance: Option<StepDistance>,
111
112    /// Controlled risk spacing
113    #[serde(rename = "controlledRiskSpacing")]
114    pub controlled_risk_spacing: Option<StepDistance>,
115
116    /// Market order preference setting
117    #[serde(rename = "marketOrderPreference")]
118    pub market_order_preference: String,
119
120    /// Trailing stops preference setting
121    #[serde(rename = "trailingStopsPreference")]
122    pub trailing_stops_preference: String,
123
124    #[serde(rename = "maxDealSize")]
125    /// Maximum deal size allowed
126    pub max_deal_size: Option<f64>,
127}
128
129/// Market snapshot with enhanced deserialization
130#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
131pub struct MarketSnapshot {
132    /// Current status of the market (e.g., "OPEN", "CLOSED")
133    #[serde(rename = "marketStatus")]
134    pub market_status: String,
135
136    /// Net change in price since previous close
137    #[serde(rename = "netChange")]
138    pub net_change: Option<f64>,
139
140    /// Percentage change in price since previous close
141    #[serde(rename = "percentageChange")]
142    pub percentage_change: Option<f64>,
143
144    /// Time of the last price update
145    #[serde(rename = "updateTime")]
146    pub update_time: Option<String>,
147
148    /// Delay time in minutes for market data
149    #[serde(rename = "delayTime")]
150    pub delay_time: Option<i64>,
151
152    /// Current bid price
153    pub bid: Option<f64>,
154
155    /// Current offer/ask price
156    pub offer: Option<f64>,
157
158    /// Highest price of the current trading session
159    pub high: Option<f64>,
160
161    /// Lowest price of the current trading session
162    pub low: Option<f64>,
163
164    /// Odds for binary markets
165    #[serde(rename = "binaryOdds")]
166    pub binary_odds: Option<f64>,
167
168    /// Factor for decimal places in price display
169    #[serde(rename = "decimalPlacesFactor")]
170    pub decimal_places_factor: Option<i64>,
171
172    /// Factor for scaling prices
173    #[serde(rename = "scalingFactor")]
174    pub scaling_factor: Option<i64>,
175
176    /// Extra spread for controlled risk trades
177    #[serde(rename = "controlledRiskExtraSpread")]
178    pub controlled_risk_extra_spread: Option<f64>,
179}
180
181/// Basic market data
182#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
183pub struct MarketData {
184    /// Unique identifier for the market
185    pub epic: String,
186    /// Human-readable name of the instrument
187    #[serde(rename = "instrumentName")]
188    pub instrument_name: String,
189    /// Type of the instrument
190    #[serde(rename = "instrumentType")]
191    pub instrument_type: InstrumentType,
192    /// Expiry date of the instrument
193    pub expiry: String,
194    /// Upper price limit for the market
195    #[serde(rename = "highLimitPrice")]
196    pub high_limit_price: Option<f64>,
197    /// Lower price limit for the market
198    #[serde(rename = "lowLimitPrice")]
199    pub low_limit_price: Option<f64>,
200    /// Current status of the market
201    #[serde(rename = "marketStatus")]
202    pub market_status: String,
203    /// Net change in price since previous close
204    #[serde(rename = "netChange")]
205    pub net_change: Option<f64>,
206    /// Percentage change in price since previous close
207    #[serde(rename = "percentageChange")]
208    pub percentage_change: Option<f64>,
209    /// Time of the last price update
210    #[serde(rename = "updateTime")]
211    pub update_time: Option<String>,
212    /// Time of the last price update in UTC
213    #[serde(rename = "updateTimeUTC")]
214    pub update_time_utc: Option<String>,
215    /// Current bid price
216    pub bid: Option<f64>,
217    /// Current offer/ask price
218    pub offer: Option<f64>,
219}
220
221impl MarketData {
222    /// Checks if the current financial instrument is a call option.
223    ///
224    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
225    /// to buy an underlying asset at a specified price within a specified time period. This method checks
226    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
227    /// field.
228    ///
229    /// # Returns
230    ///
231    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
232    /// * `false` otherwise.
233    ///
234    #[must_use]
235    #[inline]
236    pub fn is_call(&self) -> bool {
237        self.instrument_name.contains("CALL")
238    }
239
240    /// Checks if the financial instrument is a "PUT" option.
241    ///
242    /// This method examines the `instrument_name` field of the struct to determine
243    /// if it contains the substring "PUT". If the substring is found, the method
244    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
245    /// Otherwise, it returns `false`.
246    ///
247    /// # Returns
248    /// * `true` - If `instrument_name` contains the substring "PUT".
249    /// * `false` - If `instrument_name` does not contain the substring "PUT".
250    ///
251    #[must_use]
252    #[inline]
253    pub fn is_put(&self) -> bool {
254        self.instrument_name.contains("PUT")
255    }
256}
257
258/// Historical price data point
259#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
260pub struct HistoricalPrice {
261    /// Timestamp of the price data point, in the account's timezone
262    /// (e.g. `"2024/01/15 14:30:00"`). Prefer [`snapshot_time_utc`] for storage.
263    ///
264    /// [`snapshot_time_utc`]: HistoricalPrice::snapshot_time_utc
265    #[serde(rename = "snapshotTime")]
266    pub snapshot_time: String,
267    /// UTC timestamp of the price data point (`"2024-01-15T14:30:00"`), as IG
268    /// returns it in `snapshotTimeUTC`. Authoritative for persistence — the
269    /// account-timezone `snapshot_time` shifts stored values by the account's
270    /// offset. `None` on older payloads that omit the field.
271    #[serde(rename = "snapshotTimeUTC", default)]
272    pub snapshot_time_utc: Option<String>,
273    /// Opening price for the period
274    #[serde(rename = "openPrice")]
275    pub open_price: PricePoint,
276    /// Highest price for the period
277    #[serde(rename = "highPrice")]
278    pub high_price: PricePoint,
279    /// Lowest price for the period
280    #[serde(rename = "lowPrice")]
281    pub low_price: PricePoint,
282    /// Closing price for the period
283    #[serde(rename = "closePrice")]
284    pub close_price: PricePoint,
285    /// Volume traded during the period
286    #[serde(rename = "lastTradedVolume")]
287    pub last_traded_volume: Option<i64>,
288}
289
290/// Price point with bid, ask and last traded prices
291#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
292pub struct PricePoint {
293    /// Bid price at this point
294    pub bid: Option<f64>,
295    /// Ask/offer price at this point
296    pub ask: Option<f64>,
297    /// Last traded price at this point
298    #[serde(rename = "lastTraded")]
299    pub last_traded: Option<f64>,
300}
301
302/// Information about API usage allowance for price data
303#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
304pub struct PriceAllowance {
305    /// Remaining API calls allowed in the current period
306    #[serde(rename = "remainingAllowance")]
307    pub remaining_allowance: i64,
308    /// Total API calls allowed per period
309    #[serde(rename = "totalAllowance")]
310    pub total_allowance: i64,
311    /// Time until the allowance resets
312    #[serde(rename = "allowanceExpiry")]
313    pub allowance_expiry: i64,
314}
315
316/// Details about instrument expiry
317#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq)]
318pub struct ExpiryDetails {
319    /// The last dealing date and time for the instrument
320    #[serde(rename = "lastDealingDate")]
321    pub last_dealing_date: String,
322
323    /// Information about settlement
324    #[serde(rename = "settlementInfo")]
325    pub settlement_info: Option<String>,
326}
327
328#[repr(u8)]
329#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
330/// Unit for step distances in trading rules
331pub enum StepUnit {
332    #[serde(rename = "POINTS")]
333    /// Points (price movement units)
334    Points,
335    #[serde(rename = "PERCENTAGE")]
336    /// Percentage value
337    Percentage,
338    #[serde(rename = "pct")]
339    /// Alternative representation for percentage
340    Pct,
341}
342
343/// A struct to handle the minStepDistance value which can be a complex object
344#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, PartialEq)]
345pub struct StepDistance {
346    /// Unit type for the distance
347    pub unit: Option<StepUnit>,
348    /// Numeric value of the distance
349    pub value: Option<f64>,
350}
351
352/// Node in the market navigation hierarchy
353#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
354pub struct MarketNavigationNode {
355    /// Unique identifier for the node
356    pub id: String,
357    /// Display name of the node
358    pub name: String,
359}
360
361/// Structure representing a node in the market hierarchy
362#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize)]
363pub struct MarketNode {
364    /// Node ID
365    pub id: String,
366    /// Node name
367    pub name: String,
368    /// Child nodes
369    #[serde(skip_serializing_if = "Vec::is_empty", default)]
370    pub children: Vec<MarketNode>,
371    /// Markets in this node
372    #[serde(skip_serializing_if = "Vec::is_empty", default)]
373    pub markets: Vec<MarketData>,
374}
375
376/// Represents the current state of a market
377#[repr(u8)]
378#[derive(
379    DebugPretty, DisplaySimple, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, Default,
380)]
381#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
382pub enum MarketState {
383    /// Market is closed for trading
384    Closed,
385    /// Market is offline and not available
386    #[default]
387    Offline,
388    /// Market is open and available for trading
389    Tradeable,
390    /// Market is in edit mode
391    Edit,
392    /// Market is in edit mode only (no new positions, only edits allowed)
393    EditsOnly,
394    /// Market is in auction phase
395    Auction,
396    /// Market is in auction phase but editing is not allowed
397    AuctionNoEdit,
398    /// Market is temporarily suspended
399    Suspended,
400    /// Market is in auction phase
401    OnAuction,
402    /// Market is in auction phase but editing is not allowed
403    OnAuctionNoEdits,
404}
405
406/// Representation of market data received from the IG Markets streaming API
407#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
408pub struct PresentationMarketData {
409    /// Name of the item this data belongs to
410    pub item_name: String,
411    /// Position of the item in the subscription
412    pub item_pos: usize,
413    /// All market fields
414    pub fields: MarketFields,
415    /// Fields that have changed in this update
416    pub changed_fields: MarketFields,
417    /// Whether this is a snapshot or an update
418    pub is_snapshot: bool,
419}
420
421impl PresentationMarketData {
422    /// Builds a [`PresentationMarketData`] from pre-extracted streaming fields.
423    ///
424    /// This is transport-agnostic: it takes plain field maps rather than a
425    /// Lightstreamer `ItemUpdate`, so the presentation layer carries no
426    /// dependency on the streaming transport. The `ItemUpdate` adapter lives in
427    /// [`crate::application::streaming_convert`].
428    ///
429    /// # Arguments
430    /// * `item_name` - Subscription item name (`None` when subscribed by position)
431    /// * `item_pos` - 1-based position of the item in the subscription
432    /// * `is_snapshot` - Whether this update is a snapshot
433    /// * `fields` - Current field values for the item
434    /// * `changed_fields` - Field values that changed in this update
435    ///
436    /// # Returns
437    /// * `Result<Self, String>` - The converted MarketData or an error message
438    pub fn from_fields(
439        item_name: Option<&str>,
440        item_pos: usize,
441        is_snapshot: bool,
442        fields: &HashMap<String, Option<String>>,
443        changed_fields: &HashMap<String, Option<String>>,
444    ) -> Result<Self, String> {
445        let fields = Self::create_market_fields(fields)?;
446        let changed_fields = Self::create_market_fields(changed_fields)?;
447
448        Ok(PresentationMarketData {
449            item_name: item_name.unwrap_or_default().to_string(),
450            item_pos,
451            fields,
452            changed_fields,
453            is_snapshot,
454        })
455    }
456
457    /// Helper method to create MarketFields from a HashMap of field values
458    ///
459    /// # Arguments
460    /// * `fields_map` - HashMap containing field names and their string values
461    ///
462    /// # Returns
463    /// * `Result<MarketFields, String>` - The parsed MarketFields or an error message
464    fn create_market_fields(
465        fields_map: &HashMap<String, Option<String>>,
466    ) -> Result<MarketFields, String> {
467        // Helper function to safely get a field value
468        let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
469
470        // Parse market state
471        let market_state = match get_field("MARKET_STATE").as_deref() {
472            Some("closed") => Some(MarketState::Closed),
473            Some("offline") => Some(MarketState::Offline),
474            Some("tradeable") => Some(MarketState::Tradeable),
475            Some("edit") => Some(MarketState::Edit),
476            Some("auction") => Some(MarketState::Auction),
477            Some("auction_no_edit") => Some(MarketState::AuctionNoEdit),
478            Some("suspended") => Some(MarketState::Suspended),
479            Some("on_auction") => Some(MarketState::OnAuction),
480            Some("on_auction_no_edit") => Some(MarketState::OnAuctionNoEdits),
481            Some(unknown) => return Err(format!("Unknown market state: {unknown}")),
482            None => None,
483        };
484
485        // Parse boolean field
486        let market_delay = match get_field("MARKET_DELAY").as_deref() {
487            Some("0") => Some(false),
488            Some("1") => Some(true),
489            Some(val) => return Err(format!("Invalid MARKET_DELAY value: {val}")),
490            None => None,
491        };
492
493        // Helper function to parse float values
494        let parse_float = |key: &str| -> Result<Option<f64>, String> {
495            match get_field(key) {
496                Some(val) if !val.is_empty() => val
497                    .parse::<f64>()
498                    .map(Some)
499                    .map_err(|_| format!("Failed to parse {key} as float: {val}")),
500                _ => Ok(None),
501            }
502        };
503
504        Ok(MarketFields {
505            mid_open: parse_float("MID_OPEN")?,
506            high: parse_float("HIGH")?,
507            offer: parse_float("OFFER")?,
508            change: parse_float("CHANGE")?,
509            market_delay,
510            low: parse_float("LOW")?,
511            bid: parse_float("BID")?,
512            change_pct: parse_float("CHANGE_PCT")?,
513            market_state,
514            update_time: get_field("UPDATE_TIME"),
515        })
516    }
517}
518
519/// Represents a category of instruments in the IG Markets API
520#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default, PartialEq)]
521pub struct Category {
522    /// Category code identifier
523    pub code: String,
524    /// True if the category is non-tradeable
525    #[serde(rename = "nonTradeable")]
526    pub non_tradeable: bool,
527}
528
529/// Market status for category instruments
530#[repr(u8)]
531#[derive(
532    DebugPretty, DisplaySimple, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, Hash,
533)]
534#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
535pub enum CategoryMarketStatus {
536    /// Market is offline
537    #[default]
538    Offline,
539    /// Market is closed
540    Closed,
541    /// Market is suspended
542    Suspended,
543    /// Market is in auction mode
544    OnAuction,
545    /// Market is in no-edits mode
546    OnAuctionNoEdits,
547    /// Market is open for edits only
548    EditsOnly,
549    /// Market allows closings only
550    ClosingsOnly,
551    /// Market allows deals but not edits
552    DealNoEdit,
553    /// Market is open for trades
554    Tradeable,
555}
556
557/// Represents an instrument within a category
558#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default, PartialEq)]
559pub struct CategoryInstrument {
560    /// Unique instrument identifier (EPIC)
561    pub epic: String,
562    /// Name of the instrument
563    #[serde(rename = "instrumentName")]
564    pub instrument_name: String,
565    /// Expiry date of the instrument
566    pub expiry: String,
567    /// Type of the instrument
568    #[serde(rename = "instrumentType")]
569    pub instrument_type: InstrumentType,
570    /// Size of an instrument lot
571    #[serde(rename = "lotSize")]
572    pub lot_size: Option<f64>,
573    /// True if the instrument can be traded OTC
574    #[serde(rename = "otcTradeable")]
575    pub otc_tradeable: bool,
576    /// Current status of the market
577    #[serde(rename = "marketStatus")]
578    pub market_status: CategoryMarketStatus,
579    /// Price delay time for market data in minutes
580    #[serde(rename = "delayTime")]
581    pub delay_time: Option<i64>,
582    /// Current bid price
583    pub bid: Option<f64>,
584    /// Current offer price
585    pub offer: Option<f64>,
586    /// Highest price for the current session
587    pub high: Option<f64>,
588    /// Lowest price for the current session
589    pub low: Option<f64>,
590    /// Net change in price
591    #[serde(rename = "netChange")]
592    pub net_change: Option<f64>,
593    /// Percentage change in price
594    #[serde(rename = "percentageChange")]
595    pub percentage_change: Option<f64>,
596    /// Time of last price update
597    #[serde(rename = "updateTime")]
598    pub update_time: Option<String>,
599    /// Multiplying factor to determine actual pip value
600    #[serde(rename = "scalingFactor")]
601    pub scaling_factor: Option<i64>,
602    /// Real expiry instant as Unix epoch in milliseconds (UTC).
603    ///
604    /// IG returns this as `expiryTimestamp` on the category-instruments list
605    /// endpoint. It is the authoritative expiry moment (date **and** time) and
606    /// is always UTC, so consumers should prefer it over parsing the
607    /// human-readable `expiry` string, which carries no time-of-day.
608    #[serde(rename = "expiryTimestamp")]
609    pub expiry_timestamp: Option<i64>,
610    /// Name of the underlying asset (e.g. "AUD/USD" for an FX option)
611    #[serde(rename = "underlyingName")]
612    pub underlying_name: Option<String>,
613    /// Popularity ranking value; can exceed `i32` range
614    pub popularity: Option<i64>,
615    /// Market type of the underlying (e.g. "FX_PAIR")
616    #[serde(rename = "marketType")]
617    pub market_type: Option<String>,
618    /// Market subtype of the underlying (e.g. "Fiat")
619    #[serde(rename = "marketSubtype")]
620    pub market_subtype: Option<String>,
621}
622
623/// Paging metadata for category instruments response
624#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default, PartialEq)]
625pub struct CategoryInstrumentsMetadata {
626    /// Current page number
627    #[serde(rename = "pageNumber")]
628    pub page_number: i64,
629    /// Number of items per page
630    #[serde(rename = "pageSize")]
631    pub page_size: i64,
632}
633
634/// Fields containing market price and status information
635#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default, PartialEq)]
636pub struct MarketFields {
637    /// The mid-open price of the market
638    #[serde(rename = "MID_OPEN")]
639    #[serde(with = "string_as_float_opt")]
640    #[serde(default)]
641    pub mid_open: Option<f64>,
642
643    /// The highest price reached by the market in the current trading session
644    #[serde(rename = "HIGH")]
645    #[serde(with = "string_as_float_opt")]
646    #[serde(default)]
647    pub high: Option<f64>,
648
649    /// The current offer (ask) price of the market
650    #[serde(rename = "OFFER")]
651    #[serde(with = "string_as_float_opt")]
652    #[serde(default)]
653    pub offer: Option<f64>,
654
655    /// The absolute price change since the previous close
656    #[serde(rename = "CHANGE")]
657    #[serde(with = "string_as_float_opt")]
658    #[serde(default)]
659    pub change: Option<f64>,
660
661    /// Indicates if there is a delay in market data
662    #[serde(rename = "MARKET_DELAY")]
663    #[serde(with = "string_as_bool_opt")]
664    #[serde(default)]
665    pub market_delay: Option<bool>,
666
667    /// The lowest price reached by the market in the current trading session
668    #[serde(rename = "LOW")]
669    #[serde(with = "string_as_float_opt")]
670    #[serde(default)]
671    pub low: Option<f64>,
672
673    /// The current bid price of the market
674    #[serde(rename = "BID")]
675    #[serde(with = "string_as_float_opt")]
676    #[serde(default)]
677    pub bid: Option<f64>,
678
679    /// The percentage price change since the previous close
680    #[serde(rename = "CHANGE_PCT")]
681    #[serde(with = "string_as_float_opt")]
682    #[serde(default)]
683    pub change_pct: Option<f64>,
684
685    /// The current state of the market (e.g., Tradeable, Closed, etc.)
686    #[serde(rename = "MARKET_STATE")]
687    #[serde(default)]
688    pub market_state: Option<MarketState>,
689
690    /// The timestamp of the last market update
691    #[serde(rename = "UPDATE_TIME")]
692    #[serde(default)]
693    pub update_time: Option<String>,
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn test_historical_price_captures_snapshot_time_utc() {
702        // IG returns both snapshotTime (account tz) and snapshotTimeUTC.
703        let json = r#"{
704            "snapshotTime": "2024/01/15 15:30:00",
705            "snapshotTimeUTC": "2024-01-15T14:30:00",
706            "openPrice": { "bid": 1.1, "ask": 1.2, "lastTraded": null },
707            "highPrice": { "bid": 1.3, "ask": 1.4, "lastTraded": null },
708            "lowPrice": { "bid": 1.0, "ask": 1.05, "lastTraded": null },
709            "closePrice": { "bid": 1.25, "ask": 1.26, "lastTraded": null },
710            "lastTradedVolume": 42
711        }"#;
712        let hp: HistoricalPrice = serde_json::from_str(json).expect("deserialize");
713        assert_eq!(hp.snapshot_time, "2024/01/15 15:30:00");
714        assert_eq!(hp.snapshot_time_utc.as_deref(), Some("2024-01-15T14:30:00"));
715
716        // Round-trips through serialize -> deserialize.
717        let round: HistoricalPrice =
718            serde_json::from_str(&serde_json::to_string(&hp).expect("ser")).expect("de");
719        assert_eq!(
720            round.snapshot_time_utc.as_deref(),
721            Some("2024-01-15T14:30:00")
722        );
723
724        // Older payloads without the field default to None.
725        let legacy = r#"{
726            "snapshotTime": "2024/01/15 15:30:00",
727            "openPrice": { "bid": 1.1, "ask": 1.2, "lastTraded": null },
728            "highPrice": { "bid": 1.3, "ask": 1.4, "lastTraded": null },
729            "lowPrice": { "bid": 1.0, "ask": 1.05, "lastTraded": null },
730            "closePrice": { "bid": 1.25, "ask": 1.26, "lastTraded": null },
731            "lastTradedVolume": null
732        }"#;
733        let hp2: HistoricalPrice = serde_json::from_str(legacy).expect("deserialize legacy");
734        assert!(hp2.snapshot_time_utc.is_none());
735    }
736
737    #[test]
738    fn test_market_data_is_call_returns_true_for_call_option() {
739        let market = MarketData {
740            epic: "test".to_string(),
741            instrument_name: "DAX CALL 18000".to_string(),
742            instrument_type: InstrumentType::default(),
743            expiry: "-".to_string(),
744            high_limit_price: None,
745            low_limit_price: None,
746            market_status: "TRADEABLE".to_string(),
747            net_change: None,
748            percentage_change: None,
749            update_time: None,
750            update_time_utc: None,
751            bid: Some(100.0),
752            offer: Some(101.0),
753        };
754        assert!(market.is_call());
755        assert!(!market.is_put());
756    }
757
758    #[test]
759    fn test_market_data_is_put_returns_true_for_put_option() {
760        let market = MarketData {
761            epic: "test".to_string(),
762            instrument_name: "DAX PUT 17000".to_string(),
763            instrument_type: InstrumentType::default(),
764            expiry: "-".to_string(),
765            high_limit_price: None,
766            low_limit_price: None,
767            market_status: "TRADEABLE".to_string(),
768            net_change: None,
769            percentage_change: None,
770            update_time: None,
771            update_time_utc: None,
772            bid: Some(50.0),
773            offer: Some(51.0),
774        };
775        assert!(market.is_put());
776        assert!(!market.is_call());
777    }
778
779    #[test]
780    fn test_market_data_neither_call_nor_put() {
781        let market = MarketData {
782            epic: "IX.D.DAX.DAILY.IP".to_string(),
783            instrument_name: "Germany 40".to_string(),
784            instrument_type: InstrumentType::default(),
785            expiry: "-".to_string(),
786            high_limit_price: None,
787            low_limit_price: None,
788            market_status: "TRADEABLE".to_string(),
789            net_change: None,
790            percentage_change: None,
791            update_time: None,
792            update_time_utc: None,
793            bid: Some(18000.0),
794            offer: Some(18001.0),
795        };
796        assert!(!market.is_call());
797        assert!(!market.is_put());
798    }
799
800    #[test]
801    fn test_category_instruments_metadata_deserialize_and_roundtrip() {
802        // Sanitized shape of the `metadata` object IG returns from
803        // `categories/{id}/instruments`. `pageNumber` / `pageSize` are i64.
804        let json = r#"{ "pageNumber": 2, "pageSize": 1000 }"#;
805
806        let meta: CategoryInstrumentsMetadata =
807            serde_json::from_str(json).expect("deserialize failed");
808        assert_eq!(meta.page_number, 2);
809        assert_eq!(meta.page_size, 1000);
810
811        let serialized = serde_json::to_string(&meta).expect("serialize failed");
812        let re: CategoryInstrumentsMetadata =
813            serde_json::from_str(&serialized).expect("re-deserialize failed");
814        assert_eq!(re, meta);
815        assert!(serialized.contains("\"pageNumber\":2"));
816        assert!(serialized.contains("\"pageSize\":1000"));
817    }
818
819    #[test]
820    fn test_market_state_default() {
821        let state = MarketState::default();
822        assert_eq!(state, MarketState::Offline);
823    }
824
825    #[test]
826    fn test_category_market_status_default() {
827        let status = CategoryMarketStatus::default();
828        assert_eq!(status, CategoryMarketStatus::Offline);
829    }
830
831    #[test]
832    fn test_step_unit_serialization() {
833        let points = StepUnit::Points;
834        let json = serde_json::to_string(&points).expect("serialize failed");
835        assert_eq!(json, "\"POINTS\"");
836
837        let pct = StepUnit::Percentage;
838        let json = serde_json::to_string(&pct).expect("serialize failed");
839        assert_eq!(json, "\"PERCENTAGE\"");
840    }
841
842    #[test]
843    fn test_step_distance_creation() {
844        let distance = StepDistance {
845            unit: Some(StepUnit::Points),
846            value: Some(1.5),
847        };
848        assert_eq!(distance.unit, Some(StepUnit::Points));
849        assert_eq!(distance.value, Some(1.5));
850    }
851
852    #[test]
853    fn test_market_fields_default() {
854        let fields = MarketFields::default();
855        assert!(fields.mid_open.is_none());
856        assert!(fields.high.is_none());
857        assert!(fields.offer.is_none());
858        assert!(fields.change.is_none());
859        assert!(fields.market_delay.is_none());
860        assert!(fields.low.is_none());
861        assert!(fields.bid.is_none());
862        assert!(fields.change_pct.is_none());
863        assert!(fields.market_state.is_none());
864        assert!(fields.update_time.is_none());
865    }
866
867    #[test]
868    fn test_presentation_market_data_default() {
869        let data = PresentationMarketData::default();
870        assert!(data.item_name.is_empty());
871        assert_eq!(data.item_pos, 0);
872        assert!(!data.is_snapshot);
873    }
874
875    #[test]
876    fn test_category_default() {
877        let cat = Category::default();
878        assert!(cat.code.is_empty());
879        assert!(!cat.non_tradeable);
880    }
881
882    #[test]
883    fn test_category_instrument_default() {
884        let inst = CategoryInstrument::default();
885        assert!(inst.epic.is_empty());
886        assert!(inst.instrument_name.is_empty());
887        assert_eq!(inst.market_status, CategoryMarketStatus::Offline);
888        assert!(inst.expiry_timestamp.is_none());
889        assert!(inst.underlying_name.is_none());
890        assert!(inst.popularity.is_none());
891        assert!(inst.market_type.is_none());
892        assert!(inst.market_subtype.is_none());
893    }
894
895    #[test]
896    fn test_category_instrument_deserialization_full_payload() {
897        let json = r#"{
898            "epic": "OD.D.OTCWK2AUD.28.IP",
899            "instrumentName": "Weekly AUDUSD ($1) 7200 Call",
900            "expiry": "17-JUL-26",
901            "instrumentType": "OPT_CURRENCIES",
902            "lotSize": 1.0,
903            "otcTradeable": true,
904            "marketStatus": "EDITS_ONLY",
905            "delayTime": 0,
906            "bid": 0.0,
907            "offer": 4.0,
908            "high": 4.0,
909            "low": 0.0,
910            "netChange": 6936.2,
911            "percentageChange": 0.0,
912            "updateTime": "06:53:51",
913            "scalingFactor": 1,
914            "underlyingName": "AUD/USD",
915            "popularity": 91783089620,
916            "marketType": "FX_PAIR",
917            "marketSubtype": "Fiat",
918            "expiryTimestamp": 1784300400000
919        }"#;
920
921        let inst: CategoryInstrument = serde_json::from_str(json).expect("deserialize failed");
922        assert_eq!(inst.epic, "OD.D.OTCWK2AUD.28.IP");
923        assert_eq!(inst.instrument_name, "Weekly AUDUSD ($1) 7200 Call");
924        assert_eq!(inst.expiry, "17-JUL-26");
925        assert_eq!(inst.market_status, CategoryMarketStatus::EditsOnly);
926        assert_eq!(inst.expiry_timestamp, Some(1784300400000));
927        assert_eq!(inst.underlying_name.as_deref(), Some("AUD/USD"));
928        assert_eq!(inst.popularity, Some(91783089620));
929        assert_eq!(inst.market_type.as_deref(), Some("FX_PAIR"));
930        assert_eq!(inst.market_subtype.as_deref(), Some("Fiat"));
931    }
932
933    #[test]
934    fn test_category_instrument_deserialization_without_optional_fields() {
935        let json = r#"{
936            "epic": "IX.D.FTSE.DAILY.IP",
937            "instrumentName": "FTSE 100",
938            "expiry": "-",
939            "instrumentType": "INDICES",
940            "otcTradeable": false,
941            "marketStatus": "TRADEABLE"
942        }"#;
943
944        let inst: CategoryInstrument = serde_json::from_str(json).expect("deserialize failed");
945        assert_eq!(inst.epic, "IX.D.FTSE.DAILY.IP");
946        assert!(inst.expiry_timestamp.is_none());
947        assert!(inst.underlying_name.is_none());
948        assert!(inst.popularity.is_none());
949        assert!(inst.market_type.is_none());
950        assert!(inst.market_subtype.is_none());
951    }
952
953    #[test]
954    fn test_market_state_serialization() {
955        let tradeable = MarketState::Tradeable;
956        let json = serde_json::to_string(&tradeable).expect("serialize failed");
957        assert_eq!(json, "\"TRADEABLE\"");
958
959        let closed = MarketState::Closed;
960        let json = serde_json::to_string(&closed).expect("serialize failed");
961        assert_eq!(json, "\"CLOSED\"");
962    }
963
964    #[test]
965    fn test_price_point_creation() {
966        let point = PricePoint {
967            bid: Some(100.5),
968            ask: Some(101.0),
969            last_traded: Some(100.75),
970        };
971        assert_eq!(point.bid, Some(100.5));
972        assert_eq!(point.ask, Some(101.0));
973        assert_eq!(point.last_traded, Some(100.75));
974    }
975
976    #[test]
977    fn test_price_allowance_creation() {
978        let allowance = PriceAllowance {
979            remaining_allowance: 100,
980            total_allowance: 1000,
981            allowance_expiry: 3600,
982        };
983        assert_eq!(allowance.remaining_allowance, 100);
984        assert_eq!(allowance.total_allowance, 1000);
985        assert_eq!(allowance.allowance_expiry, 3600);
986    }
987
988    #[test]
989    fn test_expiry_details_creation() {
990        let expiry = ExpiryDetails {
991            last_dealing_date: "2024-12-31".to_string(),
992            settlement_info: Some("Cash settlement".to_string()),
993        };
994        assert_eq!(expiry.last_dealing_date, "2024-12-31");
995        assert_eq!(expiry.settlement_info, Some("Cash settlement".to_string()));
996    }
997
998    #[test]
999    fn test_market_navigation_node_creation() {
1000        let node = MarketNavigationNode {
1001            id: "12345".to_string(),
1002            name: "Indices".to_string(),
1003        };
1004        assert_eq!(node.id, "12345");
1005        assert_eq!(node.name, "Indices");
1006    }
1007
1008    #[test]
1009    fn test_market_node_creation() {
1010        let node = MarketNode {
1011            id: "node1".to_string(),
1012            name: "Test Node".to_string(),
1013            children: Vec::new(),
1014            markets: Vec::new(),
1015        };
1016        assert_eq!(node.id, "node1");
1017        assert_eq!(node.name, "Test Node");
1018        assert!(node.children.is_empty());
1019        assert!(node.markets.is_empty());
1020    }
1021
1022    #[test]
1023    fn test_currency_creation() {
1024        let currency = Currency {
1025            code: "USD".to_string(),
1026            symbol: Some("$".to_string()),
1027            base_exchange_rate: Some(1.0),
1028            exchange_rate: Some(1.0),
1029            is_default: Some(true),
1030        };
1031        assert_eq!(currency.code, "USD");
1032        assert_eq!(currency.symbol, Some("$".to_string()));
1033        assert_eq!(currency.is_default, Some(true));
1034    }
1035
1036    #[test]
1037    fn test_create_market_fields_with_valid_data() {
1038        let mut fields_map: HashMap<String, Option<String>> = HashMap::new();
1039        fields_map.insert("BID".to_string(), Some("100.5".to_string()));
1040        fields_map.insert("OFFER".to_string(), Some("101.0".to_string()));
1041        fields_map.insert("HIGH".to_string(), Some("102.0".to_string()));
1042        fields_map.insert("LOW".to_string(), Some("99.0".to_string()));
1043        fields_map.insert("CHANGE".to_string(), Some("1.5".to_string()));
1044        fields_map.insert("CHANGE_PCT".to_string(), Some("1.5".to_string()));
1045        fields_map.insert("MARKET_STATE".to_string(), Some("tradeable".to_string()));
1046        fields_map.insert("MARKET_DELAY".to_string(), Some("0".to_string()));
1047        fields_map.insert("UPDATE_TIME".to_string(), Some("12:30:00".to_string()));
1048
1049        let result = PresentationMarketData::create_market_fields(&fields_map);
1050        assert!(result.is_ok());
1051
1052        let fields = result.expect("should parse");
1053        assert_eq!(fields.bid, Some(100.5));
1054        assert_eq!(fields.offer, Some(101.0));
1055        assert_eq!(fields.high, Some(102.0));
1056        assert_eq!(fields.low, Some(99.0));
1057        assert_eq!(fields.change, Some(1.5));
1058        assert_eq!(fields.market_state, Some(MarketState::Tradeable));
1059        assert_eq!(fields.market_delay, Some(false));
1060        assert_eq!(fields.update_time, Some("12:30:00".to_string()));
1061    }
1062
1063    #[test]
1064    fn test_create_market_fields_with_empty_map() {
1065        let fields_map: HashMap<String, Option<String>> = HashMap::new();
1066        let result = PresentationMarketData::create_market_fields(&fields_map);
1067        assert!(result.is_ok());
1068
1069        let fields = result.expect("should parse");
1070        assert!(fields.bid.is_none());
1071        assert!(fields.offer.is_none());
1072    }
1073
1074    #[test]
1075    fn test_create_market_fields_invalid_market_state() {
1076        let mut fields_map: HashMap<String, Option<String>> = HashMap::new();
1077        fields_map.insert(
1078            "MARKET_STATE".to_string(),
1079            Some("invalid_state".to_string()),
1080        );
1081
1082        let result = PresentationMarketData::create_market_fields(&fields_map);
1083        assert!(result.is_err());
1084    }
1085
1086    #[test]
1087    fn test_create_market_fields_invalid_market_delay() {
1088        let mut fields_map: HashMap<String, Option<String>> = HashMap::new();
1089        fields_map.insert("MARKET_DELAY".to_string(), Some("invalid".to_string()));
1090
1091        let result = PresentationMarketData::create_market_fields(&fields_map);
1092        assert!(result.is_err());
1093    }
1094
1095    #[test]
1096    fn test_create_market_fields_all_market_states() {
1097        let states = vec![
1098            ("closed", MarketState::Closed),
1099            ("offline", MarketState::Offline),
1100            ("tradeable", MarketState::Tradeable),
1101            ("edit", MarketState::Edit),
1102            ("auction", MarketState::Auction),
1103            ("auction_no_edit", MarketState::AuctionNoEdit),
1104            ("suspended", MarketState::Suspended),
1105            ("on_auction", MarketState::OnAuction),
1106            ("on_auction_no_edit", MarketState::OnAuctionNoEdits),
1107        ];
1108
1109        for (state_str, expected_state) in states {
1110            let mut fields_map: HashMap<String, Option<String>> = HashMap::new();
1111            fields_map.insert("MARKET_STATE".to_string(), Some(state_str.to_string()));
1112
1113            let result = PresentationMarketData::create_market_fields(&fields_map);
1114            assert!(result.is_ok(), "Failed for state: {}", state_str);
1115            let fields = result.expect("should parse");
1116            assert_eq!(fields.market_state, Some(expected_state));
1117        }
1118    }
1119
1120    #[test]
1121    fn test_market_delay_values() {
1122        let mut fields_map: HashMap<String, Option<String>> = HashMap::new();
1123        fields_map.insert("MARKET_DELAY".to_string(), Some("1".to_string()));
1124
1125        let result = PresentationMarketData::create_market_fields(&fields_map);
1126        assert!(result.is_ok());
1127        let fields = result.expect("should parse");
1128        assert_eq!(fields.market_delay, Some(true));
1129    }
1130}