Skip to main content

ig_client/presentation/
account.rs

1use crate::presentation::instrument::InstrumentType;
2use crate::presentation::market::MarketState;
3use crate::presentation::order::{Direction, OrderType, Status, TimeInForce};
4use crate::presentation::serialization::string_as_float_opt;
5use pretty_simple_display::{DebugPretty, DisplaySimple};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::ops::Add;
9
10/// Returns `true` if an instrument name denotes a call option.
11///
12/// IG encodes the option right in the human-readable `instrumentName` (for
13/// example `"...CALL..."`). The check is a case-sensitive substring match on
14/// the upper-case token IG uses. Shared by every `is_call` accessor in this
15/// module so the classification stays in one place.
16#[must_use]
17#[inline]
18fn is_call_name(name: &str) -> bool {
19    name.contains("CALL")
20}
21
22/// Returns `true` if an instrument name denotes a put option.
23///
24/// Counterpart to [`is_call_name`]: a case-sensitive substring match on the
25/// upper-case `"PUT"` token IG places in `instrumentName`. Shared by every
26/// `is_put` accessor in this module.
27#[must_use]
28#[inline]
29fn is_put_name(name: &str) -> bool {
30    name.contains("PUT")
31}
32
33/// Account information
34#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
35pub struct AccountInfo {
36    /// List of accounts owned by the user
37    pub accounts: Vec<Account>,
38}
39
40/// Details of a specific account
41#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
42pub struct Account {
43    /// Unique identifier for the account
44    #[serde(rename = "accountId")]
45    pub account_id: String,
46    /// Name of the account
47    #[serde(rename = "accountName")]
48    pub account_name: String,
49    /// Type of the account (e.g., CFD, Spread bet)
50    #[serde(rename = "accountType")]
51    pub account_type: String,
52    /// Balance information for the account
53    pub balance: AccountBalance,
54    /// Base currency of the account
55    pub currency: String,
56    /// Current status of the account
57    pub status: String,
58    /// Whether this is the preferred account
59    pub preferred: bool,
60}
61
62/// Account balance information
63#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
64pub struct AccountBalance {
65    /// Total balance of the account
66    pub balance: f64,
67    /// Deposit amount
68    pub deposit: f64,
69    /// Current profit or loss
70    #[serde(rename = "profitLoss")]
71    pub profit_loss: f64,
72    /// Available funds for trading
73    pub available: f64,
74}
75
76/// Metadata for activity pagination
77#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
78pub struct ActivityMetadata {
79    /// Paging information
80    pub paging: Option<ActivityPaging>,
81}
82
83/// Paging information for activities
84#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
85pub struct ActivityPaging {
86    /// Number of items per page
87    pub size: Option<i64>,
88    /// URL for the next page of results
89    pub next: Option<String>,
90}
91
92/// Type of account activity
93#[repr(u8)]
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, DisplaySimple, Deserialize, Serialize)]
95#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
96pub enum ActivityType {
97    /// Activity related to editing stop and limit orders
98    EditStopAndLimit,
99    /// Activity related to positions
100    Position,
101    /// System-generated activity
102    System,
103    /// Activity related to working orders
104    WorkingOrder,
105}
106
107/// Individual activity record
108#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
109pub struct Activity {
110    /// Date and time of the activity
111    pub date: String,
112    /// Unique identifier for the deal
113    #[serde(rename = "dealId", default)]
114    pub deal_id: Option<String>,
115    /// Instrument EPIC identifier
116    #[serde(default)]
117    pub epic: Option<String>,
118    /// Time period of the activity
119    #[serde(default)]
120    pub period: Option<String>,
121    /// Client-generated reference for the deal
122    #[serde(rename = "dealReference", default)]
123    pub deal_reference: Option<String>,
124    /// Type of activity
125    #[serde(rename = "type")]
126    pub activity_type: ActivityType,
127    /// Status of the activity
128    #[serde(default)]
129    pub status: Option<Status>,
130    /// Description of the activity
131    #[serde(default)]
132    pub description: Option<String>,
133    /// Additional details about the activity
134    /// This is a string when detailed=false, and an object when detailed=true
135    #[serde(default)]
136    pub details: Option<ActivityDetails>,
137    /// Channel the activity occurred on (e.g., "WEB" or "Mobile")
138    #[serde(default)]
139    pub channel: Option<String>,
140    /// The currency, e.g., a pound symbol
141    #[serde(default)]
142    pub currency: Option<String>,
143    /// Price level
144    #[serde(default)]
145    pub level: Option<String>,
146}
147
148/// Detailed information about an activity
149/// Only available when using the detailed=true parameter
150#[derive(Debug, Clone, DisplaySimple, Deserialize, Serialize)]
151pub struct ActivityDetails {
152    /// Client-generated reference for the deal
153    #[serde(rename = "dealReference", default)]
154    pub deal_reference: Option<String>,
155    /// List of actions associated with this activity
156    #[serde(default)]
157    pub actions: Vec<ActivityAction>,
158    /// Name of the market
159    #[serde(rename = "marketName", default)]
160    pub market_name: Option<String>,
161    /// Date until which the order is valid
162    #[serde(rename = "goodTillDate", default)]
163    pub good_till_date: Option<String>,
164    /// Currency of the transaction
165    #[serde(default)]
166    pub currency: Option<String>,
167    /// Size/quantity of the transaction
168    #[serde(default)]
169    pub size: Option<f64>,
170    /// Direction of the transaction (BUY or SELL)
171    #[serde(default)]
172    pub direction: Option<Direction>,
173    /// Price level
174    #[serde(default)]
175    pub level: Option<f64>,
176    /// Stop level price
177    #[serde(rename = "stopLevel", default)]
178    pub stop_level: Option<f64>,
179    /// Distance for the stop
180    #[serde(rename = "stopDistance", default)]
181    pub stop_distance: Option<f64>,
182    /// Whether the stop is guaranteed
183    #[serde(rename = "guaranteedStop", default)]
184    pub guaranteed_stop: Option<bool>,
185    /// Distance for the trailing stop
186    #[serde(rename = "trailingStopDistance", default)]
187    pub trailing_stop_distance: Option<f64>,
188    /// Step size for the trailing stop
189    #[serde(rename = "trailingStep", default)]
190    pub trailing_step: Option<f64>,
191    /// Limit level price
192    #[serde(rename = "limitLevel", default)]
193    pub limit_level: Option<f64>,
194    /// Distance for the limit
195    #[serde(rename = "limitDistance", default)]
196    pub limit_distance: Option<f64>,
197}
198
199/// Types of actions that can be performed on an activity
200#[repr(u8)]
201#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, DisplaySimple, Deserialize, Serialize)]
202#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
203pub enum ActionType {
204    /// A limit order was deleted
205    LimitOrderDeleted,
206    /// A limit order was filled
207    LimitOrderFilled,
208    /// A limit order was opened
209    LimitOrderOpened,
210    /// A limit order was rolled
211    LimitOrderRolled,
212    /// A position was closed
213    PositionClosed,
214    /// A position was deleted
215    PositionDeleted,
216    /// A position was opened
217    PositionOpened,
218    /// A position was partially closed
219    PositionPartiallyClosed,
220    /// A position was rolled
221    PositionRolled,
222    /// A stop/limit was amended
223    StopLimitAmended,
224    /// A stop order was amended
225    StopOrderAmended,
226    /// A stop order was deleted
227    StopOrderDeleted,
228    /// A stop order was filled
229    StopOrderFilled,
230    /// A stop order was opened
231    StopOrderOpened,
232    /// A stop order was rolled
233    StopOrderRolled,
234    /// Unknown action type
235    Unknown,
236    /// A working order was deleted
237    WorkingOrderDeleted,
238}
239
240/// Action associated with an activity
241#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
242#[serde(rename_all = "camelCase")]
243pub struct ActivityAction {
244    /// Type of action
245    pub action_type: ActionType,
246    /// Deal ID affected by this action
247    pub affected_deal_id: Option<String>,
248}
249
250/// Individual position
251#[derive(DebugPretty, Clone, DisplaySimple, Serialize, Deserialize)]
252pub struct Position {
253    /// Details of the position
254    pub position: PositionDetails,
255    /// Market information for the position
256    pub market: PositionMarket,
257    /// Profit and loss for the position
258    pub pnl: Option<f64>,
259}
260
261impl Position {
262    /// Calculates the profit and loss (PnL) for the current position
263    /// of a trader.
264    ///
265    /// The method determines PnL based on whether it is already cached
266    /// (`self.pnl`) or needs to be calculated from the position and
267    /// market details.
268    ///
269    /// # Returns
270    ///
271    /// A floating-point value that represents the PnL for the position.
272    /// Positive values indicate a profit, and negative values indicate a loss.
273    ///
274    /// # Logic
275    ///
276    /// - If `self.pnl` is set, the cached value is returned directly.
277    /// - Otherwise it delegates to [`Position::pnl_checked`], which marks a Buy
278    ///   against `market.bid` and a Sell against `market.offer`. When the
279    ///   direction's market price is unavailable there is no basis for a P&L, so
280    ///   this returns `0.0` (use [`Position::pnl_checked`] to distinguish the
281    ///   unknown case as `None`).
282    ///
283    #[must_use]
284    pub fn pnl(&self) -> f64 {
285        self.pnl
286            .unwrap_or_else(|| self.pnl_checked().unwrap_or(0.0))
287    }
288
289    /// Computes P&L from current market prices, or `None` when the required
290    /// price for the position's direction is unavailable.
291    ///
292    /// This is the single source of truth for position P&L: a Buy is marked
293    /// against `market.bid`, a Sell against `market.offer`, as
294    /// `(price - level) * size` (sign flipped for a Sell). When that price is
295    /// missing there is no basis for a P&L, so this returns `None` rather than
296    /// fabricating a value — callers that need a number use `pnl()`, which
297    /// treats the unknown case as `0.0`. Unlike the pre-`pnl()` field override,
298    /// this ignores any cached `self.pnl`.
299    ///
300    /// # Returns
301    /// `Some(pnl)` when the direction's market price is present, else `None`.
302    #[must_use]
303    pub fn pnl_checked(&self) -> Option<f64> {
304        let current_price = match self.position.direction {
305            Direction::Buy => self.market.bid?,
306            Direction::Sell => self.market.offer?,
307        };
308        let price_diff = match self.position.direction {
309            Direction::Buy => current_price - self.position.level,
310            Direction::Sell => self.position.level - current_price,
311        };
312        Some(price_diff * self.position.size)
313    }
314
315    /// Updates the cached profit and loss (PnL) for the current position from
316    /// current market prices.
317    ///
318    /// Delegates to [`Position::pnl_checked`] (a Buy marked against `market.bid`,
319    /// a Sell against `market.offer`) and stores the result in `self.pnl`. When
320    /// the direction's market price is unavailable there is no basis for a P&L,
321    /// so `0.0` is stored.
322    ///
323    /// # Fields Updated
324    /// - `self.pnl`: set to `Some(pnl)`, or `Some(0.0)` when the required market
325    ///   price is missing.
326    ///
327    pub fn update_pnl(&mut self) {
328        // Store the market-derived P&L (0.0 when the direction's price is
329        // unavailable), through the single `pnl_checked` implementation.
330        self.pnl = Some(self.pnl_checked().unwrap_or(0.0));
331    }
332}
333
334impl Position {
335    /// Checks if the current financial instrument is a call option.
336    ///
337    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
338    /// to buy an underlying asset at a specified price within a specified time period. This method checks
339    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
340    /// field.
341    ///
342    /// # Returns
343    ///
344    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
345    /// * `false` otherwise.
346    ///
347    #[must_use]
348    #[inline]
349    pub fn is_call(&self) -> bool {
350        is_call_name(&self.market.instrument_name)
351    }
352
353    /// Checks if the financial instrument is a "PUT" option.
354    ///
355    /// This method examines the `instrument_name` field of the struct to determine
356    /// if it contains the substring "PUT". If the substring is found, the method
357    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
358    /// Otherwise, it returns `false`.
359    ///
360    /// # Returns
361    /// * `true` - If `instrument_name` contains the substring "PUT".
362    /// * `false` - If `instrument_name` does not contain the substring "PUT".
363    ///
364    #[must_use]
365    #[inline]
366    pub fn is_put(&self) -> bool {
367        is_put_name(&self.market.instrument_name)
368    }
369}
370
371impl Add for Position {
372    type Output = Position;
373
374    /// Adds two positions together.
375    ///
376    /// # Invariants
377    /// Both positions must belong to the same market (same EPIC).
378    /// In debug builds, adding positions from different markets will panic.
379    /// In release builds, the left-hand side market is used.
380    fn add(self, other: Position) -> Position {
381        debug_assert_eq!(
382            self.market.epic, other.market.epic,
383            "cannot add positions from different markets"
384        );
385        Position {
386            position: self.position + other.position,
387            market: self.market,
388            pnl: match (self.pnl, other.pnl) {
389                (Some(a), Some(b)) => Some(a + b),
390                (Some(a), None) => Some(a),
391                (None, Some(b)) => Some(b),
392                (None, None) => None,
393            },
394        }
395    }
396}
397
398/// Details of a position
399#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
400pub struct PositionDetails {
401    /// Size of one contract
402    #[serde(rename = "contractSize")]
403    pub contract_size: f64,
404    /// Date and time when the position was created
405    #[serde(rename = "createdDate")]
406    pub created_date: String,
407    /// UTC date and time when the position was created
408    #[serde(rename = "createdDateUTC")]
409    pub created_date_utc: String,
410    /// Unique identifier for the deal
411    #[serde(rename = "dealId")]
412    pub deal_id: String,
413    /// Client-generated reference for the deal
414    #[serde(rename = "dealReference")]
415    pub deal_reference: String,
416    /// Direction of the position (buy or sell)
417    pub direction: Direction,
418    /// Price level for take profit
419    #[serde(rename = "limitLevel")]
420    pub limit_level: Option<f64>,
421    /// Opening price level of the position
422    pub level: f64,
423    /// Size/quantity of the position
424    pub size: f64,
425    /// Price level for stop loss
426    #[serde(rename = "stopLevel")]
427    pub stop_level: Option<f64>,
428    /// Step size for trailing stop
429    #[serde(rename = "trailingStep")]
430    pub trailing_step: Option<f64>,
431    /// Distance for trailing stop
432    #[serde(rename = "trailingStopDistance")]
433    pub trailing_stop_distance: Option<f64>,
434    /// Currency of the position
435    pub currency: String,
436    /// Whether the position has controlled risk
437    #[serde(rename = "controlledRisk")]
438    pub controlled_risk: bool,
439    /// Premium paid for limited risk
440    #[serde(rename = "limitedRiskPremium")]
441    pub limited_risk_premium: Option<f64>,
442}
443
444impl Add for PositionDetails {
445    type Output = PositionDetails;
446
447    fn add(self, other: PositionDetails) -> PositionDetails {
448        let (contract_size, size, direction) = if self.direction != other.direction {
449            // Netting opposite directions: the surviving position takes the
450            // direction of the larger size (e.g. Buy 2 + Sell 3 -> Sell 1). On a
451            // tie the net is flat and the direction is immaterial; keep `self`.
452            let direction = if other.size > self.size {
453                other.direction
454            } else {
455                self.direction
456            };
457            (
458                (self.contract_size - other.contract_size).abs(),
459                (self.size - other.size).abs(),
460                direction,
461            )
462        } else {
463            (
464                self.contract_size + other.contract_size,
465                self.size + other.size,
466                self.direction,
467            )
468        };
469
470        PositionDetails {
471            contract_size,
472            created_date: self.created_date,
473            created_date_utc: self.created_date_utc,
474            deal_id: self.deal_id,
475            deal_reference: self.deal_reference,
476            direction,
477            limit_level: other.limit_level.or(self.limit_level),
478            level: (self.level + other.level) / 2.0, // Average level
479            size,
480            stop_level: other.stop_level.or(self.stop_level),
481            trailing_step: other.trailing_step.or(self.trailing_step),
482            trailing_stop_distance: other.trailing_stop_distance.or(self.trailing_stop_distance),
483            currency: self.currency.clone(),
484            controlled_risk: self.controlled_risk || other.controlled_risk,
485            limited_risk_premium: other.limited_risk_premium.or(self.limited_risk_premium),
486        }
487    }
488}
489
490/// Market information for a position
491#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
492pub struct PositionMarket {
493    /// Human-readable name of the instrument
494    #[serde(rename = "instrumentName")]
495    pub instrument_name: String,
496    /// Expiry date of the instrument
497    pub expiry: String,
498    /// Unique identifier for the market
499    pub epic: String,
500    /// Type of the instrument
501    #[serde(rename = "instrumentType")]
502    pub instrument_type: InstrumentType,
503    /// Size of one lot
504    #[serde(rename = "lotSize")]
505    pub lot_size: f64,
506    /// Highest price of the current trading session
507    pub high: Option<f64>,
508    /// Lowest price of the current trading session
509    pub low: Option<f64>,
510    /// Percentage change in price since previous close
511    #[serde(rename = "percentageChange")]
512    pub percentage_change: f64,
513    /// Net change in price since previous close
514    #[serde(rename = "netChange")]
515    pub net_change: f64,
516    /// Current bid price
517    pub bid: Option<f64>,
518    /// Current offer/ask price
519    pub offer: Option<f64>,
520    /// Time of the last price update
521    #[serde(rename = "updateTime")]
522    pub update_time: String,
523    /// UTC time of the last price update
524    #[serde(rename = "updateTimeUTC")]
525    pub update_time_utc: String,
526    /// Delay time in minutes for market data
527    #[serde(rename = "delayTime")]
528    pub delay_time: i64,
529    /// Whether streaming prices are available for this market
530    #[serde(rename = "streamingPricesAvailable")]
531    pub streaming_prices_available: bool,
532    /// Current status of the market (e.g., `TRADEABLE`, `CLOSED`)
533    #[serde(rename = "marketStatus")]
534    pub market_status: MarketState,
535    /// Factor for scaling prices
536    #[serde(rename = "scalingFactor")]
537    pub scaling_factor: i64,
538}
539
540impl PositionMarket {
541    /// Checks if the current financial instrument is a call option.
542    ///
543    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
544    /// to buy an underlying asset at a specified price within a specified time period. This method checks
545    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
546    /// field.
547    ///
548    /// # Returns
549    ///
550    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
551    /// * `false` otherwise.
552    ///
553    #[must_use]
554    #[inline]
555    pub fn is_call(&self) -> bool {
556        is_call_name(&self.instrument_name)
557    }
558
559    /// Checks if the financial instrument is a "PUT" option.
560    ///
561    /// This method examines the `instrument_name` field of the struct to determine
562    /// if it contains the substring "PUT". If the substring is found, the method
563    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
564    /// Otherwise, it returns `false`.
565    ///
566    /// # Returns
567    /// * `true` - If `instrument_name` contains the substring "PUT".
568    /// * `false` - If `instrument_name` does not contain the substring "PUT".
569    ///
570    #[must_use]
571    #[inline]
572    pub fn is_put(&self) -> bool {
573        is_put_name(&self.instrument_name)
574    }
575}
576
577/// Working order
578#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
579pub struct WorkingOrder {
580    /// Details of the working order
581    #[serde(rename = "workingOrderData")]
582    pub working_order_data: WorkingOrderData,
583    /// Market information for the working order
584    #[serde(rename = "marketData")]
585    pub market_data: AccountMarketData,
586}
587
588/// Details of a working order
589#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
590pub struct WorkingOrderData {
591    /// Unique identifier for the deal
592    #[serde(rename = "dealId")]
593    pub deal_id: String,
594    /// Direction of the order (buy or sell)
595    pub direction: Direction,
596    /// Instrument EPIC identifier
597    pub epic: String,
598    /// Size/quantity of the order
599    #[serde(rename = "orderSize")]
600    pub order_size: f64,
601    /// Price level for the order
602    #[serde(rename = "orderLevel")]
603    pub order_level: f64,
604    /// Time in force for the order
605    #[serde(rename = "timeInForce")]
606    pub time_in_force: TimeInForce,
607    /// Expiry date for GTD orders
608    #[serde(rename = "goodTillDate")]
609    pub good_till_date: Option<String>,
610    /// ISO formatted expiry date for GTD orders
611    #[serde(rename = "goodTillDateISO")]
612    pub good_till_date_iso: Option<String>,
613    /// Date and time when the order was created
614    #[serde(rename = "createdDate")]
615    pub created_date: String,
616    /// UTC date and time when the order was created
617    #[serde(rename = "createdDateUTC")]
618    pub created_date_utc: String,
619    /// Whether the order has a guaranteed stop
620    #[serde(rename = "guaranteedStop")]
621    pub guaranteed_stop: bool,
622    /// Type of the order
623    #[serde(rename = "orderType")]
624    pub order_type: OrderType,
625    /// Distance for stop loss
626    #[serde(rename = "stopDistance")]
627    pub stop_distance: Option<f64>,
628    /// Distance for take profit
629    #[serde(rename = "limitDistance")]
630    pub limit_distance: Option<f64>,
631    /// Currency code for the order
632    #[serde(rename = "currencyCode")]
633    pub currency_code: String,
634    /// Whether direct market access is enabled
635    pub dma: bool,
636    /// Premium for limited risk
637    #[serde(rename = "limitedRiskPremium")]
638    pub limited_risk_premium: Option<f64>,
639    /// Price level for take profit
640    #[serde(rename = "limitLevel", default)]
641    pub limit_level: Option<f64>,
642    /// Price level for stop loss
643    #[serde(rename = "stopLevel", default)]
644    pub stop_level: Option<f64>,
645    /// Client-generated reference for the deal
646    #[serde(rename = "dealReference", default)]
647    pub deal_reference: Option<String>,
648}
649
650/// Market data for a working order
651#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
652pub struct AccountMarketData {
653    /// Human-readable name of the instrument
654    #[serde(rename = "instrumentName")]
655    pub instrument_name: String,
656    /// Exchange identifier
657    #[serde(rename = "exchangeId")]
658    pub exchange_id: String,
659    /// Expiry date of the instrument
660    pub expiry: String,
661    /// Current status of the market
662    #[serde(rename = "marketStatus")]
663    pub market_status: MarketState,
664    /// Unique identifier for the market
665    pub epic: String,
666    /// Type of the instrument
667    #[serde(rename = "instrumentType")]
668    pub instrument_type: InstrumentType,
669    /// Size of one lot
670    #[serde(rename = "lotSize")]
671    pub lot_size: f64,
672    /// Highest price of the current trading session
673    pub high: Option<f64>,
674    /// Lowest price of the current trading session
675    pub low: Option<f64>,
676    /// Percentage change in price since previous close
677    #[serde(rename = "percentageChange")]
678    pub percentage_change: f64,
679    /// Net change in price since previous close
680    #[serde(rename = "netChange")]
681    pub net_change: f64,
682    /// Current bid price
683    pub bid: Option<f64>,
684    /// Current offer/ask price
685    pub offer: Option<f64>,
686    /// Time of the last price update
687    #[serde(rename = "updateTime")]
688    pub update_time: String,
689    /// UTC time of the last price update
690    #[serde(rename = "updateTimeUTC")]
691    pub update_time_utc: String,
692    /// Delay time in minutes for market data
693    #[serde(rename = "delayTime")]
694    pub delay_time: i64,
695    /// Whether streaming prices are available for this market
696    #[serde(rename = "streamingPricesAvailable")]
697    pub streaming_prices_available: bool,
698    /// Factor for scaling prices
699    #[serde(rename = "scalingFactor")]
700    pub scaling_factor: i64,
701}
702
703impl AccountMarketData {
704    /// Checks if the current financial instrument is a call option.
705    ///
706    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
707    /// to buy an underlying asset at a specified price within a specified time period. This method checks
708    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
709    /// field.
710    ///
711    /// # Returns
712    ///
713    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
714    /// * `false` otherwise.
715    ///
716    #[must_use]
717    #[inline]
718    pub fn is_call(&self) -> bool {
719        is_call_name(&self.instrument_name)
720    }
721
722    /// Checks if the financial instrument is a "PUT" option.
723    ///
724    /// This method examines the `instrument_name` field of the struct to determine
725    /// if it contains the substring "PUT". If the substring is found, the method
726    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
727    /// Otherwise, it returns `false`.
728    ///
729    /// # Returns
730    /// * `true` - If `instrument_name` contains the substring "PUT".
731    /// * `false` - If `instrument_name` does not contain the substring "PUT".
732    ///
733    #[must_use]
734    #[inline]
735    pub fn is_put(&self) -> bool {
736        is_put_name(&self.instrument_name)
737    }
738}
739
740/// Transaction metadata
741#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
742pub struct TransactionMetadata {
743    /// Pagination information
744    #[serde(rename = "pageData")]
745    pub page_data: PageData,
746    /// Total number of transactions
747    pub size: i64,
748}
749
750/// Pagination information
751#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
752pub struct PageData {
753    /// Current page number
754    #[serde(rename = "pageNumber")]
755    pub page_number: i64,
756    /// Number of items per page
757    #[serde(rename = "pageSize")]
758    pub page_size: i64,
759    /// Total number of pages
760    #[serde(rename = "totalPages")]
761    pub total_pages: i64,
762}
763
764/// Individual transaction
765#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
766pub struct AccountTransaction {
767    /// Date and time of the transaction
768    pub date: String,
769    /// UTC date and time of the transaction
770    #[serde(rename = "dateUtc")]
771    pub date_utc: String,
772    /// Represents the date and time in UTC when an event or entity was opened or initiated.
773    #[serde(rename = "openDateUtc")]
774    pub open_date_utc: String,
775    /// Name of the instrument
776    #[serde(rename = "instrumentName")]
777    pub instrument_name: String,
778    /// Time period of the transaction
779    pub period: String,
780    /// Profit or loss amount
781    #[serde(rename = "profitAndLoss")]
782    pub profit_and_loss: String,
783    /// Type of transaction
784    #[serde(rename = "transactionType")]
785    pub transaction_type: String,
786    /// Reference identifier for the transaction
787    pub reference: String,
788    /// Opening price level
789    #[serde(rename = "openLevel")]
790    pub open_level: String,
791    /// Closing price level
792    #[serde(rename = "closeLevel")]
793    pub close_level: String,
794    /// Size/quantity of the transaction
795    pub size: String,
796    /// Currency of the transaction
797    pub currency: String,
798    /// Whether this is a cash transaction
799    #[serde(rename = "cashTransaction")]
800    pub cash_transaction: bool,
801}
802
803impl AccountTransaction {
804    /// Checks if the current financial instrument is a call option.
805    ///
806    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
807    /// to buy an underlying asset at a specified price within a specified time period. This method checks
808    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
809    /// field.
810    ///
811    /// # Returns
812    ///
813    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
814    /// * `false` otherwise.
815    ///
816    #[must_use]
817    #[inline]
818    pub fn is_call(&self) -> bool {
819        is_call_name(&self.instrument_name)
820    }
821
822    /// Checks if the financial instrument is a "PUT" option.
823    ///
824    /// This method examines the `instrument_name` field of the struct to determine
825    /// if it contains the substring "PUT". If the substring is found, the method
826    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
827    /// Otherwise, it returns `false`.
828    ///
829    /// # Returns
830    /// * `true` - If `instrument_name` contains the substring "PUT".
831    /// * `false` - If `instrument_name` does not contain the substring "PUT".
832    ///
833    #[must_use]
834    #[inline]
835    pub fn is_put(&self) -> bool {
836        is_put_name(&self.instrument_name)
837    }
838}
839
840/// Representation of account data received from the IG Markets streaming API
841#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
842pub struct AccountData {
843    /// Name of the item this data belongs to
844    pub item_name: String,
845    /// Position of the item in the subscription
846    pub item_pos: usize,
847    /// All account fields
848    pub fields: AccountFields,
849    /// Fields that have changed in this update
850    pub changed_fields: AccountFields,
851    /// Whether this is a snapshot or an update
852    pub is_snapshot: bool,
853}
854
855/// Fields containing account financial information
856#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
857pub struct AccountFields {
858    #[serde(rename = "PNL")]
859    #[serde(with = "string_as_float_opt")]
860    #[serde(skip_serializing_if = "Option::is_none")]
861    pnl: Option<f64>,
862
863    #[serde(rename = "DEPOSIT")]
864    #[serde(with = "string_as_float_opt")]
865    #[serde(skip_serializing_if = "Option::is_none")]
866    deposit: Option<f64>,
867
868    #[serde(rename = "AVAILABLE_CASH")]
869    #[serde(with = "string_as_float_opt")]
870    #[serde(skip_serializing_if = "Option::is_none")]
871    available_cash: Option<f64>,
872
873    #[serde(rename = "PNL_LR")]
874    #[serde(with = "string_as_float_opt")]
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pnl_lr: Option<f64>,
877
878    #[serde(rename = "PNL_NLR")]
879    #[serde(with = "string_as_float_opt")]
880    #[serde(skip_serializing_if = "Option::is_none")]
881    pnl_nlr: Option<f64>,
882
883    #[serde(rename = "FUNDS")]
884    #[serde(with = "string_as_float_opt")]
885    #[serde(skip_serializing_if = "Option::is_none")]
886    funds: Option<f64>,
887
888    #[serde(rename = "MARGIN")]
889    #[serde(with = "string_as_float_opt")]
890    #[serde(skip_serializing_if = "Option::is_none")]
891    margin: Option<f64>,
892
893    #[serde(rename = "MARGIN_LR")]
894    #[serde(with = "string_as_float_opt")]
895    #[serde(skip_serializing_if = "Option::is_none")]
896    margin_lr: Option<f64>,
897
898    #[serde(rename = "MARGIN_NLR")]
899    #[serde(with = "string_as_float_opt")]
900    #[serde(skip_serializing_if = "Option::is_none")]
901    margin_nlr: Option<f64>,
902
903    #[serde(rename = "AVAILABLE_TO_DEAL")]
904    #[serde(with = "string_as_float_opt")]
905    #[serde(skip_serializing_if = "Option::is_none")]
906    available_to_deal: Option<f64>,
907
908    #[serde(rename = "EQUITY")]
909    #[serde(with = "string_as_float_opt")]
910    #[serde(skip_serializing_if = "Option::is_none")]
911    equity: Option<f64>,
912
913    #[serde(rename = "EQUITY_USED")]
914    #[serde(with = "string_as_float_opt")]
915    #[serde(skip_serializing_if = "Option::is_none")]
916    equity_used: Option<f64>,
917}
918
919impl AccountData {
920    /// Builds an [`AccountData`] from pre-extracted streaming fields.
921    ///
922    /// This is transport-agnostic: it takes plain field maps rather than a
923    /// Lightstreamer `ItemUpdate`, so the presentation layer carries no
924    /// dependency on the streaming transport. The `ItemUpdate` adapter lives in
925    /// [`crate::application::streaming_convert`].
926    ///
927    /// # Arguments
928    /// * `item_name` - Subscription item name (`None` when subscribed by position)
929    /// * `item_pos` - 1-based position of the item in the subscription
930    /// * `is_snapshot` - Whether this update is a snapshot
931    /// * `fields` - Current field values for the item
932    /// * `changed_fields` - Field values that changed in this update
933    ///
934    /// # Returns
935    /// * `Result<Self, String>` - The converted AccountData or an error message
936    pub fn from_fields(
937        item_name: Option<&str>,
938        item_pos: usize,
939        is_snapshot: bool,
940        fields: &HashMap<String, Option<String>>,
941        changed_fields: &HashMap<String, Option<String>>,
942    ) -> Result<Self, String> {
943        let fields = Self::create_account_fields(fields)?;
944        let changed_fields = Self::create_account_fields(changed_fields)?;
945
946        Ok(AccountData {
947            item_name: item_name.unwrap_or_default().to_string(),
948            item_pos,
949            fields,
950            changed_fields,
951            is_snapshot,
952        })
953    }
954
955    /// Helper method to create AccountFields from a HashMap of field values
956    ///
957    /// # Arguments
958    /// * `fields_map` - HashMap containing field names and their string values
959    ///
960    /// # Returns
961    /// * `Result<AccountFields, String>` - The parsed AccountFields or an error message
962    fn create_account_fields(
963        fields_map: &HashMap<String, Option<String>>,
964    ) -> Result<AccountFields, String> {
965        // Helper function to safely get a field value
966        let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
967
968        // Helper function to parse float values
969        let parse_float = |key: &str| -> Result<Option<f64>, String> {
970            match get_field(key) {
971                Some(val) if !val.is_empty() => val
972                    .parse::<f64>()
973                    .map(Some)
974                    .map_err(|_| format!("Failed to parse {key} as float: {val}")),
975                _ => Ok(None),
976            }
977        };
978
979        Ok(AccountFields {
980            pnl: parse_float("PNL")?,
981            deposit: parse_float("DEPOSIT")?,
982            available_cash: parse_float("AVAILABLE_CASH")?,
983            pnl_lr: parse_float("PNL_LR")?,
984            pnl_nlr: parse_float("PNL_NLR")?,
985            funds: parse_float("FUNDS")?,
986            margin: parse_float("MARGIN")?,
987            margin_lr: parse_float("MARGIN_LR")?,
988            margin_nlr: parse_float("MARGIN_NLR")?,
989            available_to_deal: parse_float("AVAILABLE_TO_DEAL")?,
990            equity: parse_float("EQUITY")?,
991            equity_used: parse_float("EQUITY_USED")?,
992        })
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999    use crate::presentation::order::Direction;
1000    use crate::utils::finance::calculate_pnl;
1001
1002    fn sample_position_details(direction: Direction, level: f64, size: f64) -> PositionDetails {
1003        PositionDetails {
1004            contract_size: 1.0,
1005            created_date: "2025/10/30 18:13:53:000".to_string(),
1006            created_date_utc: "2025-10-30T17:13:53".to_string(),
1007            deal_id: "DIAAAAVJNQPWZAG".to_string(),
1008            deal_reference: "RZ0RQ1K8V1S1JN2".to_string(),
1009            direction,
1010            limit_level: None,
1011            level,
1012            size,
1013            stop_level: None,
1014            trailing_step: None,
1015            trailing_stop_distance: None,
1016            currency: "USD".to_string(),
1017            controlled_risk: false,
1018            limited_risk_premium: None,
1019        }
1020    }
1021
1022    fn sample_market(bid: Option<f64>, offer: Option<f64>) -> PositionMarket {
1023        PositionMarket {
1024            instrument_name: "US 500 6910 PUT ($1)".to_string(),
1025            expiry: "DEC-25".to_string(),
1026            epic: "OP.D.OTCSPX3.6910P.IP".to_string(),
1027            instrument_type: InstrumentType::Unknown,
1028            lot_size: 1.0,
1029            high: Some(153.43),
1030            low: Some(147.42),
1031            percentage_change: 0.61,
1032            net_change: 6895.38,
1033            bid,
1034            offer,
1035            update_time: "05:55:59".to_string(),
1036            update_time_utc: "05:55:59".to_string(),
1037            delay_time: 0,
1038            streaming_prices_available: true,
1039            market_status: MarketState::Tradeable,
1040            scaling_factor: 1,
1041        }
1042    }
1043
1044    #[test]
1045    fn pnl_sell_uses_offer_and_matches_sample_data() {
1046        // Given the provided sample data (SELL):
1047        // size = 1.0, level = 155.14, offer = 152.82
1048        // value = 155.14, current_value = 152.82 => pnl = 155.14 - 152.82 = 2.32
1049        let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1050        let market = sample_market(Some(151.32), Some(152.82));
1051        let position = Position {
1052            position: details,
1053            market,
1054            pnl: None,
1055        };
1056
1057        let pnl = position.pnl();
1058        assert!((pnl - 2.32).abs() < 1e-9, "expected 2.32, got {}", pnl);
1059    }
1060
1061    #[test]
1062    fn pnl_buy_uses_bid_and_computes_difference() {
1063        // For BUY: pnl = current_value - value
1064        // Using size = 1.0, level = 155.14, bid = 151.32 => pnl = 151.32 - 155.14 = -3.82
1065        let details = sample_position_details(Direction::Buy, 155.14, 1.0);
1066        let market = sample_market(Some(151.32), Some(152.82));
1067        let position = Position {
1068            position: details,
1069            market,
1070            pnl: None,
1071        };
1072
1073        let pnl = position.pnl();
1074        assert!((pnl + 3.82).abs() < 1e-9, "expected -3.82, got {}", pnl);
1075    }
1076
1077    #[test]
1078    fn pnl_field_overrides_calculation_when_present() {
1079        let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1080        let market = sample_market(Some(151.32), Some(152.82));
1081        // Set explicit pnl different from calculated (which would be 2.32)
1082        let position = Position {
1083            position: details,
1084            market,
1085            pnl: Some(10.0),
1086        };
1087        assert_eq!(position.pnl(), 10.0);
1088    }
1089
1090    #[test]
1091    fn pnl_sell_is_zero_when_offer_missing() {
1092        // When offer is missing for SELL, unwrap_or(value) makes current_value == value => pnl = 0
1093        let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1094        let market = sample_market(Some(151.32), None);
1095        let position = Position {
1096            position: details,
1097            market,
1098            pnl: None,
1099        };
1100        assert!((position.pnl() - 0.0).abs() < 1e-12);
1101    }
1102
1103    #[test]
1104    fn pnl_buy_is_zero_when_bid_missing() {
1105        // When bid is missing for BUY, unwrap_or(value) makes current_value == value => pnl = 0
1106        let details = sample_position_details(Direction::Buy, 155.14, 1.0);
1107        let market = sample_market(None, Some(152.82));
1108        let position = Position {
1109            position: details,
1110            market,
1111            pnl: None,
1112        };
1113        assert!((position.pnl() - 0.0).abs() < 1e-12);
1114    }
1115
1116    #[test]
1117    fn pnl_missing_price_with_size_two_is_zero_not_inflated() {
1118        // Regression: the old fallback substituted the notional (size*level) as
1119        // the price, giving size^2*level - size*level. With size != 1 that is
1120        // non-zero garbage; the correct answer with no market price is 0.
1121        for direction in [Direction::Buy, Direction::Sell] {
1122            let details = sample_position_details(direction, 155.14, 2.0);
1123            let market = sample_market(None, None);
1124            let position = Position {
1125                position: details,
1126                market,
1127                pnl: None,
1128            };
1129            assert!(position.pnl_checked().is_none());
1130            assert!(
1131                position.pnl().abs() < 1e-12,
1132                "size-2 missing-price pnl must be 0, got {}",
1133                position.pnl()
1134            );
1135        }
1136    }
1137
1138    #[test]
1139    fn pnl_size_two_with_price_scales_with_size() {
1140        // Buy 2 @ 100, bid 105 => (105-100)*2 = 10.
1141        let details = sample_position_details(Direction::Buy, 100.0, 2.0);
1142        let market = sample_market(Some(105.0), None);
1143        let position = Position {
1144            position: details,
1145            market,
1146            pnl: None,
1147        };
1148        assert!((position.pnl() - 10.0).abs() < 1e-12);
1149        assert!((calculate_pnl(&position).expect("has bid") - 10.0).abs() < 1e-12);
1150    }
1151
1152    #[test]
1153    fn netting_opposite_directions_takes_larger_side() {
1154        // Buy 2 + Sell 3 => Sell 1 (net short).
1155        let buy = sample_position_details(Direction::Buy, 100.0, 2.0);
1156        let sell = sample_position_details(Direction::Sell, 102.0, 3.0);
1157        let net = buy.clone() + sell.clone();
1158        assert_eq!(net.direction, Direction::Sell);
1159        assert!((net.size - 1.0).abs() < 1e-12);
1160
1161        // Symmetric: Sell 3 + Buy 2 => Sell 1 as well.
1162        let net2 = sell + buy;
1163        assert_eq!(net2.direction, Direction::Sell);
1164        assert!((net2.size - 1.0).abs() < 1e-12);
1165    }
1166
1167    #[test]
1168    fn test_transaction_metadata_deserialize_and_roundtrip() {
1169        // Sanitized `metadata` object from `history/transactions`. `size` and
1170        // all `pageData` fields are i64.
1171        let json = r#"{
1172            "pageData": { "pageNumber": 1, "pageSize": 20, "totalPages": 3 },
1173            "size": 42
1174        }"#;
1175
1176        let meta: TransactionMetadata = serde_json::from_str(json).expect("deserialize failed");
1177        assert_eq!(meta.size, 42);
1178        assert_eq!(meta.page_data.page_number, 1);
1179        assert_eq!(meta.page_data.page_size, 20);
1180        assert_eq!(meta.page_data.total_pages, 3);
1181
1182        let serialized = serde_json::to_string(&meta).expect("serialize failed");
1183        let re: TransactionMetadata =
1184            serde_json::from_str(&serialized).expect("re-deserialize failed");
1185        assert_eq!(re.size, 42);
1186        assert_eq!(re.page_data.total_pages, 3);
1187        assert!(serialized.contains("\"pageNumber\":1"));
1188        assert!(serialized.contains("\"pageSize\":20"));
1189        assert!(serialized.contains("\"totalPages\":3"));
1190    }
1191
1192    #[test]
1193    fn test_activity_paging_deserialize_and_roundtrip() {
1194        // Sanitized `paging` object from `history/activity`. `size` is i64.
1195        let json = r#"{ "size": 10, "next": "/history/activity?from=X&to=Y" }"#;
1196
1197        let paging: ActivityPaging = serde_json::from_str(json).expect("deserialize failed");
1198        assert_eq!(paging.size, Some(10));
1199        assert_eq!(
1200            paging.next.as_deref(),
1201            Some("/history/activity?from=X&to=Y")
1202        );
1203
1204        let serialized = serde_json::to_string(&paging).expect("serialize failed");
1205        let re: ActivityPaging = serde_json::from_str(&serialized).expect("re-deserialize failed");
1206        assert_eq!(re.size, Some(10));
1207        assert_eq!(re.next.as_deref(), Some("/history/activity?from=X&to=Y"));
1208    }
1209}