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