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#[repr(u8)]
179#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, DisplaySimple, Deserialize, Serialize)]
180#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
181pub enum ActionType {
182    /// A limit order was deleted
183    LimitOrderDeleted,
184    /// A limit order was filled
185    LimitOrderFilled,
186    /// A limit order was opened
187    LimitOrderOpened,
188    /// A limit order was rolled
189    LimitOrderRolled,
190    /// A position was closed
191    PositionClosed,
192    /// A position was deleted
193    PositionDeleted,
194    /// A position was opened
195    PositionOpened,
196    /// A position was partially closed
197    PositionPartiallyClosed,
198    /// A position was rolled
199    PositionRolled,
200    /// A stop/limit was amended
201    StopLimitAmended,
202    /// A stop order was amended
203    StopOrderAmended,
204    /// A stop order was deleted
205    StopOrderDeleted,
206    /// A stop order was filled
207    StopOrderFilled,
208    /// A stop order was opened
209    StopOrderOpened,
210    /// A stop order was rolled
211    StopOrderRolled,
212    /// Unknown action type
213    Unknown,
214    /// A working order was deleted
215    WorkingOrderDeleted,
216}
217
218/// Action associated with an activity
219#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
220#[serde(rename_all = "camelCase")]
221pub struct ActivityAction {
222    /// Type of action
223    pub action_type: ActionType,
224    /// Deal ID affected by this action
225    pub affected_deal_id: Option<String>,
226}
227
228/// Individual position
229#[derive(DebugPretty, Clone, DisplaySimple, Serialize, Deserialize)]
230pub struct Position {
231    /// Details of the position
232    pub position: PositionDetails,
233    /// Market information for the position
234    pub market: PositionMarket,
235    /// Profit and loss for the position
236    pub pnl: Option<f64>,
237}
238
239impl Position {
240    /// Calculates the profit and loss (PnL) for the current position
241    /// of a trader.
242    ///
243    /// The method determines PnL based on whether it is already cached
244    /// (`self.pnl`) or needs to be calculated from the position and
245    /// market details.
246    ///
247    /// # Returns
248    ///
249    /// A floating-point value that represents the PnL for the position.
250    /// Positive values indicate a profit, and negative values indicate a loss.
251    ///
252    /// # Logic
253    ///
254    /// - If `self.pnl` is available, it directly returns the cached value.
255    /// - If not, the PnL is calculated based on the direction of the position:
256    ///   - For a Buy position:
257    ///     - The PnL is calculated as the difference between the `current_value`
258    ///       (based on the `market.bid` price or fallback value) and the original
259    ///       `value` (based on the position's size and level).
260    ///   - For a Sell position:
261    ///     - The PnL is calculated as the difference between the original
262    ///       `value` and the `current_value` (based on the `market.offer`
263    ///       price or fallback value).
264    ///
265    /// # Assumptions
266    /// - The `market.bid` and `market.offer` values are optional, so fallback
267    ///   to the original position value is used if they are unavailable.
268    /// - `self.position.direction` must be either `Direction::Buy` or
269    ///   `Direction::Sell`.
270    ///
271    #[must_use]
272    pub fn pnl(&self) -> f64 {
273        if let Some(pnl) = self.pnl {
274            pnl
275        } else {
276            match self.position.direction {
277                Direction::Buy => {
278                    let value = self.position.size * self.position.level;
279                    let current_value = self.position.size * self.market.bid.unwrap_or(value);
280                    current_value - value
281                }
282                Direction::Sell => {
283                    let value = self.position.size * self.position.level;
284                    let current_value = self.position.size * self.market.offer.unwrap_or(value);
285                    value - current_value
286                }
287            }
288        }
289    }
290
291    /// Updates the profit and loss (PnL) for the current position in the market.
292    ///
293    /// The method calculates the PnL based on the position's direction (Buy or Sell),
294    /// size, level (entry price), and the current bid or offer price from the market data.
295    /// The result is stored in the `pnl` field.
296    ///
297    /// # Calculation:
298    /// - If the position is a Buy:
299    ///     - Calculate the initial value of the position as `size * level`.
300    ///     - Calculate the current value of the position using the current `bid` price from the market,
301    ///       or use the initial value if the `bid` price is not available.
302    ///     - PnL is the difference between the current value and the initial value.
303    /// - If the position is a Sell:
304    ///     - Calculate the initial value of the position as `size * level`.
305    ///     - Calculate the current value of the position using the current `offer` price from the market,
306    ///       or use the initial value if the `offer` price is not available.
307    ///     - PnL is the difference between the initial value and the current value.
308    ///
309    /// # Fields Updated:
310    /// - `self.pnl`: The calculated profit or loss is updated in this field. If no valid market price
311    ///   (bid/offer) is available, `pnl` will be calculated based on the initial value.
312    ///
313    /// # Panics:
314    /// This function does not explicitly panic but relies on the `unwrap_or` method to handle cases
315    /// where the `bid` or `offer` is unavailable. It assumes that the market or position data are initialized correctly.
316    ///
317    pub fn update_pnl(&mut self) {
318        let pnl = match self.position.direction {
319            Direction::Buy => {
320                let value = self.position.size * self.position.level;
321                let current_value = self.position.size * self.market.bid.unwrap_or(value);
322                current_value - value
323            }
324            Direction::Sell => {
325                let value = self.position.size * self.position.level;
326                let current_value = self.position.size * self.market.offer.unwrap_or(value);
327                value - current_value
328            }
329        };
330        self.pnl = Some(pnl);
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        self.market.instrument_name.contains("CALL")
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        self.market.instrument_name.contains("PUT")
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) = if self.direction != other.direction {
449            (
450                (self.contract_size - other.contract_size).abs(),
451                (self.size - other.size).abs(),
452            )
453        } else {
454            (
455                self.contract_size + other.contract_size,
456                self.size + other.size,
457            )
458        };
459
460        PositionDetails {
461            contract_size,
462            created_date: self.created_date,
463            created_date_utc: self.created_date_utc,
464            deal_id: self.deal_id,
465            deal_reference: self.deal_reference,
466            direction: self.direction,
467            limit_level: other.limit_level.or(self.limit_level),
468            level: (self.level + other.level) / 2.0, // Average level
469            size,
470            stop_level: other.stop_level.or(self.stop_level),
471            trailing_step: other.trailing_step.or(self.trailing_step),
472            trailing_stop_distance: other.trailing_stop_distance.or(self.trailing_stop_distance),
473            currency: self.currency.clone(),
474            controlled_risk: self.controlled_risk || other.controlled_risk,
475            limited_risk_premium: other.limited_risk_premium.or(self.limited_risk_premium),
476        }
477    }
478}
479
480/// Market information for a position
481#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
482pub struct PositionMarket {
483    /// Human-readable name of the instrument
484    #[serde(rename = "instrumentName")]
485    pub instrument_name: String,
486    /// Expiry date of the instrument
487    pub expiry: String,
488    /// Unique identifier for the market
489    pub epic: String,
490    /// Type of the instrument
491    #[serde(rename = "instrumentType")]
492    pub instrument_type: String,
493    /// Size of one lot
494    #[serde(rename = "lotSize")]
495    pub lot_size: f64,
496    /// Highest price of the current trading session
497    pub high: Option<f64>,
498    /// Lowest price of the current trading session
499    pub low: Option<f64>,
500    /// Percentage change in price since previous close
501    #[serde(rename = "percentageChange")]
502    pub percentage_change: f64,
503    /// Net change in price since previous close
504    #[serde(rename = "netChange")]
505    pub net_change: f64,
506    /// Current bid price
507    pub bid: Option<f64>,
508    /// Current offer/ask price
509    pub offer: Option<f64>,
510    /// Time of the last price update
511    #[serde(rename = "updateTime")]
512    pub update_time: String,
513    /// UTC time of the last price update
514    #[serde(rename = "updateTimeUTC")]
515    pub update_time_utc: String,
516    /// Delay time in milliseconds for market data
517    #[serde(rename = "delayTime")]
518    pub delay_time: i64,
519    /// Whether streaming prices are available for this market
520    #[serde(rename = "streamingPricesAvailable")]
521    pub streaming_prices_available: bool,
522    /// Current status of the market (e.g., "OPEN", "CLOSED")
523    #[serde(rename = "marketStatus")]
524    pub market_status: String,
525    /// Factor for scaling prices
526    #[serde(rename = "scalingFactor")]
527    pub scaling_factor: i64,
528}
529
530impl PositionMarket {
531    /// Checks if the current financial instrument is a call option.
532    ///
533    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
534    /// to buy an underlying asset at a specified price within a specified time period. This method checks
535    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
536    /// field.
537    ///
538    /// # Returns
539    ///
540    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
541    /// * `false` otherwise.
542    ///
543    pub fn is_call(&self) -> bool {
544        self.instrument_name.contains("CALL")
545    }
546
547    /// Checks if the financial instrument is a "PUT" option.
548    ///
549    /// This method examines the `instrument_name` field of the struct to determine
550    /// if it contains the substring "PUT". If the substring is found, the method
551    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
552    /// Otherwise, it returns `false`.
553    ///
554    /// # Returns
555    /// * `true` - If `instrument_name` contains the substring "PUT".
556    /// * `false` - If `instrument_name` does not contain the substring "PUT".
557    ///
558    pub fn is_put(&self) -> bool {
559        self.instrument_name.contains("PUT")
560    }
561}
562
563/// Working order
564#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
565pub struct WorkingOrder {
566    /// Details of the working order
567    #[serde(rename = "workingOrderData")]
568    pub working_order_data: WorkingOrderData,
569    /// Market information for the working order
570    #[serde(rename = "marketData")]
571    pub market_data: AccountMarketData,
572}
573
574/// Details of a working order
575#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
576pub struct WorkingOrderData {
577    /// Unique identifier for the deal
578    #[serde(rename = "dealId")]
579    pub deal_id: String,
580    /// Direction of the order (buy or sell)
581    pub direction: Direction,
582    /// Instrument EPIC identifier
583    pub epic: String,
584    /// Size/quantity of the order
585    #[serde(rename = "orderSize")]
586    pub order_size: f64,
587    /// Price level for the order
588    #[serde(rename = "orderLevel")]
589    pub order_level: f64,
590    /// Time in force for the order
591    #[serde(rename = "timeInForce")]
592    pub time_in_force: TimeInForce,
593    /// Expiry date for GTD orders
594    #[serde(rename = "goodTillDate")]
595    pub good_till_date: Option<String>,
596    /// ISO formatted expiry date for GTD orders
597    #[serde(rename = "goodTillDateISO")]
598    pub good_till_date_iso: Option<String>,
599    /// Date and time when the order was created
600    #[serde(rename = "createdDate")]
601    pub created_date: String,
602    /// UTC date and time when the order was created
603    #[serde(rename = "createdDateUTC")]
604    pub created_date_utc: String,
605    /// Whether the order has a guaranteed stop
606    #[serde(rename = "guaranteedStop")]
607    pub guaranteed_stop: bool,
608    /// Type of the order
609    #[serde(rename = "orderType")]
610    pub order_type: OrderType,
611    /// Distance for stop loss
612    #[serde(rename = "stopDistance")]
613    pub stop_distance: Option<f64>,
614    /// Distance for take profit
615    #[serde(rename = "limitDistance")]
616    pub limit_distance: Option<f64>,
617    /// Currency code for the order
618    #[serde(rename = "currencyCode")]
619    pub currency_code: String,
620    /// Whether direct market access is enabled
621    pub dma: bool,
622    /// Premium for limited risk
623    #[serde(rename = "limitedRiskPremium")]
624    pub limited_risk_premium: Option<f64>,
625    /// Price level for take profit
626    #[serde(rename = "limitLevel", default)]
627    pub limit_level: Option<f64>,
628    /// Price level for stop loss
629    #[serde(rename = "stopLevel", default)]
630    pub stop_level: Option<f64>,
631    /// Client-generated reference for the deal
632    #[serde(rename = "dealReference", default)]
633    pub deal_reference: Option<String>,
634}
635
636/// Market data for a working order
637#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
638pub struct AccountMarketData {
639    /// Human-readable name of the instrument
640    #[serde(rename = "instrumentName")]
641    pub instrument_name: String,
642    /// Exchange identifier
643    #[serde(rename = "exchangeId")]
644    pub exchange_id: String,
645    /// Expiry date of the instrument
646    pub expiry: String,
647    /// Current status of the market
648    #[serde(rename = "marketStatus")]
649    pub market_status: MarketState,
650    /// Unique identifier for the market
651    pub epic: String,
652    /// Type of the instrument
653    #[serde(rename = "instrumentType")]
654    pub instrument_type: InstrumentType,
655    /// Size of one lot
656    #[serde(rename = "lotSize")]
657    pub lot_size: f64,
658    /// Highest price of the current trading session
659    pub high: Option<f64>,
660    /// Lowest price of the current trading session
661    pub low: Option<f64>,
662    /// Percentage change in price since previous close
663    #[serde(rename = "percentageChange")]
664    pub percentage_change: f64,
665    /// Net change in price since previous close
666    #[serde(rename = "netChange")]
667    pub net_change: f64,
668    /// Current bid price
669    pub bid: Option<f64>,
670    /// Current offer/ask price
671    pub offer: Option<f64>,
672    /// Time of the last price update
673    #[serde(rename = "updateTime")]
674    pub update_time: String,
675    /// UTC time of the last price update
676    #[serde(rename = "updateTimeUTC")]
677    pub update_time_utc: String,
678    /// Delay time in milliseconds for market data
679    #[serde(rename = "delayTime")]
680    pub delay_time: i64,
681    /// Whether streaming prices are available for this market
682    #[serde(rename = "streamingPricesAvailable")]
683    pub streaming_prices_available: bool,
684    /// Factor for scaling prices
685    #[serde(rename = "scalingFactor")]
686    pub scaling_factor: i64,
687}
688
689impl AccountMarketData {
690    /// Checks if the current financial instrument is a call option.
691    ///
692    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
693    /// to buy an underlying asset at a specified price within a specified time period. This method checks
694    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
695    /// field.
696    ///
697    /// # Returns
698    ///
699    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
700    /// * `false` otherwise.
701    ///
702    pub fn is_call(&self) -> bool {
703        self.instrument_name.contains("CALL")
704    }
705
706    /// Checks if the financial instrument is a "PUT" option.
707    ///
708    /// This method examines the `instrument_name` field of the struct to determine
709    /// if it contains the substring "PUT". If the substring is found, the method
710    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
711    /// Otherwise, it returns `false`.
712    ///
713    /// # Returns
714    /// * `true` - If `instrument_name` contains the substring "PUT".
715    /// * `false` - If `instrument_name` does not contain the substring "PUT".
716    ///
717    pub fn is_put(&self) -> bool {
718        self.instrument_name.contains("PUT")
719    }
720}
721
722/// Transaction metadata
723#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
724pub struct TransactionMetadata {
725    /// Pagination information
726    #[serde(rename = "pageData")]
727    pub page_data: PageData,
728    /// Total number of transactions
729    pub size: i32,
730}
731
732/// Pagination information
733#[derive(DebugPretty, Clone, DisplaySimple, Deserialize, Serialize)]
734pub struct PageData {
735    /// Current page number
736    #[serde(rename = "pageNumber")]
737    pub page_number: i32,
738    /// Number of items per page
739    #[serde(rename = "pageSize")]
740    pub page_size: i32,
741    /// Total number of pages
742    #[serde(rename = "totalPages")]
743    pub total_pages: i32,
744}
745
746/// Individual transaction
747#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
748pub struct AccountTransaction {
749    /// Date and time of the transaction
750    pub date: String,
751    /// UTC date and time of the transaction
752    #[serde(rename = "dateUtc")]
753    pub date_utc: String,
754    /// Represents the date and time in UTC when an event or entity was opened or initiated.
755    #[serde(rename = "openDateUtc")]
756    pub open_date_utc: String,
757    /// Name of the instrument
758    #[serde(rename = "instrumentName")]
759    pub instrument_name: String,
760    /// Time period of the transaction
761    pub period: String,
762    /// Profit or loss amount
763    #[serde(rename = "profitAndLoss")]
764    pub profit_and_loss: String,
765    /// Type of transaction
766    #[serde(rename = "transactionType")]
767    pub transaction_type: String,
768    /// Reference identifier for the transaction
769    pub reference: String,
770    /// Opening price level
771    #[serde(rename = "openLevel")]
772    pub open_level: String,
773    /// Closing price level
774    #[serde(rename = "closeLevel")]
775    pub close_level: String,
776    /// Size/quantity of the transaction
777    pub size: String,
778    /// Currency of the transaction
779    pub currency: String,
780    /// Whether this is a cash transaction
781    #[serde(rename = "cashTransaction")]
782    pub cash_transaction: bool,
783}
784
785impl AccountTransaction {
786    /// Checks if the current financial instrument is a call option.
787    ///
788    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
789    /// to buy an underlying asset at a specified price within a specified time period. This method checks
790    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
791    /// field.
792    ///
793    /// # Returns
794    ///
795    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
796    /// * `false` otherwise.
797    ///
798    pub fn is_call(&self) -> bool {
799        self.instrument_name.contains("CALL")
800    }
801
802    /// Checks if the financial instrument is a "PUT" option.
803    ///
804    /// This method examines the `instrument_name` field of the struct to determine
805    /// if it contains the substring "PUT". If the substring is found, the method
806    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
807    /// Otherwise, it returns `false`.
808    ///
809    /// # Returns
810    /// * `true` - If `instrument_name` contains the substring "PUT".
811    /// * `false` - If `instrument_name` does not contain the substring "PUT".
812    ///
813    pub fn is_put(&self) -> bool {
814        self.instrument_name.contains("PUT")
815    }
816}
817
818/// Representation of account data received from the IG Markets streaming API
819#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
820pub struct AccountData {
821    /// Name of the item this data belongs to
822    pub item_name: String,
823    /// Position of the item in the subscription
824    pub item_pos: i32,
825    /// All account fields
826    pub fields: AccountFields,
827    /// Fields that have changed in this update
828    pub changed_fields: AccountFields,
829    /// Whether this is a snapshot or an update
830    pub is_snapshot: bool,
831}
832
833/// Fields containing account financial information
834#[derive(DebugPretty, DisplaySimple, Clone, Serialize, Deserialize, Default)]
835pub struct AccountFields {
836    #[serde(rename = "PNL")]
837    #[serde(with = "string_as_float_opt")]
838    #[serde(skip_serializing_if = "Option::is_none")]
839    pnl: Option<f64>,
840
841    #[serde(rename = "DEPOSIT")]
842    #[serde(with = "string_as_float_opt")]
843    #[serde(skip_serializing_if = "Option::is_none")]
844    deposit: Option<f64>,
845
846    #[serde(rename = "AVAILABLE_CASH")]
847    #[serde(with = "string_as_float_opt")]
848    #[serde(skip_serializing_if = "Option::is_none")]
849    available_cash: Option<f64>,
850
851    #[serde(rename = "PNL_LR")]
852    #[serde(with = "string_as_float_opt")]
853    #[serde(skip_serializing_if = "Option::is_none")]
854    pnl_lr: Option<f64>,
855
856    #[serde(rename = "PNL_NLR")]
857    #[serde(with = "string_as_float_opt")]
858    #[serde(skip_serializing_if = "Option::is_none")]
859    pnl_nlr: Option<f64>,
860
861    #[serde(rename = "FUNDS")]
862    #[serde(with = "string_as_float_opt")]
863    #[serde(skip_serializing_if = "Option::is_none")]
864    funds: Option<f64>,
865
866    #[serde(rename = "MARGIN")]
867    #[serde(with = "string_as_float_opt")]
868    #[serde(skip_serializing_if = "Option::is_none")]
869    margin: Option<f64>,
870
871    #[serde(rename = "MARGIN_LR")]
872    #[serde(with = "string_as_float_opt")]
873    #[serde(skip_serializing_if = "Option::is_none")]
874    margin_lr: Option<f64>,
875
876    #[serde(rename = "MARGIN_NLR")]
877    #[serde(with = "string_as_float_opt")]
878    #[serde(skip_serializing_if = "Option::is_none")]
879    margin_nlr: Option<f64>,
880
881    #[serde(rename = "AVAILABLE_TO_DEAL")]
882    #[serde(with = "string_as_float_opt")]
883    #[serde(skip_serializing_if = "Option::is_none")]
884    available_to_deal: Option<f64>,
885
886    #[serde(rename = "EQUITY")]
887    #[serde(with = "string_as_float_opt")]
888    #[serde(skip_serializing_if = "Option::is_none")]
889    equity: Option<f64>,
890
891    #[serde(rename = "EQUITY_USED")]
892    #[serde(with = "string_as_float_opt")]
893    #[serde(skip_serializing_if = "Option::is_none")]
894    equity_used: Option<f64>,
895}
896
897impl AccountData {
898    /// Converts an ItemUpdate from the Lightstreamer API to an AccountData object
899    ///
900    /// # Arguments
901    /// * `item_update` - The ItemUpdate received from the Lightstreamer API
902    ///
903    /// # Returns
904    /// * `Result<Self, String>` - The converted AccountData or an error message
905    pub fn from_item_update(item_update: &ItemUpdate) -> Result<Self, String> {
906        // Extract the item_name, defaulting to an empty string if None
907        let item_name = item_update.item_name.clone().unwrap_or_default();
908
909        // Convert item_pos from usize to i32
910        let item_pos = item_update.item_pos as i32;
911
912        // Extract is_snapshot
913        let is_snapshot = item_update.is_snapshot;
914
915        // Convert fields
916        let fields = Self::create_account_fields(&item_update.fields)?;
917
918        // Convert changed_fields by first creating a HashMap<String, Option<String>>
919        let mut changed_fields_map: HashMap<String, Option<String>> = HashMap::new();
920        for (key, value) in &item_update.changed_fields {
921            changed_fields_map.insert(key.clone(), Some(value.clone()));
922        }
923        let changed_fields = Self::create_account_fields(&changed_fields_map)?;
924
925        Ok(AccountData {
926            item_name,
927            item_pos,
928            fields,
929            changed_fields,
930            is_snapshot,
931        })
932    }
933
934    /// Helper method to create AccountFields from a HashMap of field values
935    ///
936    /// # Arguments
937    /// * `fields_map` - HashMap containing field names and their string values
938    ///
939    /// # Returns
940    /// * `Result<AccountFields, String>` - The parsed AccountFields or an error message
941    fn create_account_fields(
942        fields_map: &HashMap<String, Option<String>>,
943    ) -> Result<AccountFields, String> {
944        // Helper function to safely get a field value
945        let get_field = |key: &str| -> Option<String> { fields_map.get(key).cloned().flatten() };
946
947        // Helper function to parse float values
948        let parse_float = |key: &str| -> Result<Option<f64>, String> {
949            match get_field(key) {
950                Some(val) if !val.is_empty() => val
951                    .parse::<f64>()
952                    .map(Some)
953                    .map_err(|_| format!("Failed to parse {key} as float: {val}")),
954                _ => Ok(None),
955            }
956        };
957
958        Ok(AccountFields {
959            pnl: parse_float("PNL")?,
960            deposit: parse_float("DEPOSIT")?,
961            available_cash: parse_float("AVAILABLE_CASH")?,
962            pnl_lr: parse_float("PNL_LR")?,
963            pnl_nlr: parse_float("PNL_NLR")?,
964            funds: parse_float("FUNDS")?,
965            margin: parse_float("MARGIN")?,
966            margin_lr: parse_float("MARGIN_LR")?,
967            margin_nlr: parse_float("MARGIN_NLR")?,
968            available_to_deal: parse_float("AVAILABLE_TO_DEAL")?,
969            equity: parse_float("EQUITY")?,
970            equity_used: parse_float("EQUITY_USED")?,
971        })
972    }
973}
974
975impl From<&ItemUpdate> for AccountData {
976    fn from(item_update: &ItemUpdate) -> Self {
977        Self::from_item_update(item_update).unwrap_or_else(|_| AccountData::default())
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use crate::presentation::order::Direction;
985
986    fn sample_position_details(direction: Direction, level: f64, size: f64) -> PositionDetails {
987        PositionDetails {
988            contract_size: 1.0,
989            created_date: "2025/10/30 18:13:53:000".to_string(),
990            created_date_utc: "2025-10-30T17:13:53".to_string(),
991            deal_id: "DIAAAAVJNQPWZAG".to_string(),
992            deal_reference: "RZ0RQ1K8V1S1JN2".to_string(),
993            direction,
994            limit_level: None,
995            level,
996            size,
997            stop_level: None,
998            trailing_step: None,
999            trailing_stop_distance: None,
1000            currency: "USD".to_string(),
1001            controlled_risk: false,
1002            limited_risk_premium: None,
1003        }
1004    }
1005
1006    fn sample_market(bid: Option<f64>, offer: Option<f64>) -> PositionMarket {
1007        PositionMarket {
1008            instrument_name: "US 500 6910 PUT ($1)".to_string(),
1009            expiry: "DEC-25".to_string(),
1010            epic: "OP.D.OTCSPX3.6910P.IP".to_string(),
1011            instrument_type: "UNKNOWN".to_string(),
1012            lot_size: 1.0,
1013            high: Some(153.43),
1014            low: Some(147.42),
1015            percentage_change: 0.61,
1016            net_change: 6895.38,
1017            bid,
1018            offer,
1019            update_time: "05:55:59".to_string(),
1020            update_time_utc: "05:55:59".to_string(),
1021            delay_time: 0,
1022            streaming_prices_available: true,
1023            market_status: "TRADEABLE".to_string(),
1024            scaling_factor: 1,
1025        }
1026    }
1027
1028    #[test]
1029    fn pnl_sell_uses_offer_and_matches_sample_data() {
1030        // Given the provided sample data (SELL):
1031        // size = 1.0, level = 155.14, offer = 152.82
1032        // value = 155.14, current_value = 152.82 => pnl = 155.14 - 152.82 = 2.32
1033        let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1034        let market = sample_market(Some(151.32), Some(152.82));
1035        let position = Position {
1036            position: details,
1037            market,
1038            pnl: None,
1039        };
1040
1041        let pnl = position.pnl();
1042        assert!((pnl - 2.32).abs() < 1e-9, "expected 2.32, got {}", pnl);
1043    }
1044
1045    #[test]
1046    fn pnl_buy_uses_bid_and_computes_difference() {
1047        // For BUY: pnl = current_value - value
1048        // Using size = 1.0, level = 155.14, bid = 151.32 => pnl = 151.32 - 155.14 = -3.82
1049        let details = sample_position_details(Direction::Buy, 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 + 3.82).abs() < 1e-9, "expected -3.82, got {}", pnl);
1059    }
1060
1061    #[test]
1062    fn pnl_field_overrides_calculation_when_present() {
1063        let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1064        let market = sample_market(Some(151.32), Some(152.82));
1065        // Set explicit pnl different from calculated (which would be 2.32)
1066        let position = Position {
1067            position: details,
1068            market,
1069            pnl: Some(10.0),
1070        };
1071        assert_eq!(position.pnl(), 10.0);
1072    }
1073
1074    #[test]
1075    fn pnl_sell_is_zero_when_offer_missing() {
1076        // When offer is missing for SELL, unwrap_or(value) makes current_value == value => pnl = 0
1077        let details = sample_position_details(Direction::Sell, 155.14, 1.0);
1078        let market = sample_market(Some(151.32), None);
1079        let position = Position {
1080            position: details,
1081            market,
1082            pnl: None,
1083        };
1084        assert!((position.pnl() - 0.0).abs() < 1e-12);
1085    }
1086
1087    #[test]
1088    fn pnl_buy_is_zero_when_bid_missing() {
1089        // When bid is missing for BUY, unwrap_or(value) makes current_value == value => pnl = 0
1090        let details = sample_position_details(Direction::Buy, 155.14, 1.0);
1091        let market = sample_market(None, Some(152.82));
1092        let position = Position {
1093            position: details,
1094            market,
1095            pnl: None,
1096        };
1097        assert!((position.pnl() - 0.0).abs() < 1e-12);
1098    }
1099}