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