Skip to main content

ig_client/presentation/
transaction.rs

1use crate::presentation::account::AccountTransaction;
2use crate::utils::parsing::{ParsedOptionInfo, parse_instrument_name};
3use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, Utc, Weekday};
4use pretty_simple_display::{DebugPretty, DisplaySimple};
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8/// Absolute P&L magnitude, in EUR, below which a `WITH` (withdrawal / adjustment)
9/// transaction is classified as a fee rather than a realised trade result.
10///
11/// IG reports commissions, overnight funding and similar small charges as `WITH`
12/// transactions carrying a P&L close to zero, whereas a genuine cash movement of
13/// that type has a larger magnitude. Any `WITH` transaction whose absolute
14/// `pnl_eur` is strictly below this threshold is treated as a fee. Denominated in
15/// EUR because `pnl_eur` is normalised to EUR upstream.
16const FEE_THRESHOLD_EUR: f64 = 1.0;
17
18/// Represents a processed transaction from IG Markets with parsed fields
19#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, PartialEq, Clone, Default)]
20pub struct StoreTransaction {
21    /// Date and time when the transaction was executed
22    pub deal_date: DateTime<Utc>,
23    /// Underlying asset or instrument (e.g., "GOLD", "US500")
24    pub underlying: Option<String>,
25    /// Strike price for options
26    pub strike: Option<f64>,
27    /// Type of option ("CALL" or "PUT")
28    pub option_type: Option<String>,
29    /// Expiration date for options
30    pub expiry: Option<NaiveDate>,
31    /// Type of transaction (e.g., "DEAL", "WITH")
32    pub transaction_type: String,
33    /// Profit and loss in EUR
34    pub pnl_eur: f64,
35    /// Unique reference for the transaction
36    pub reference: String,
37    /// Whether this transaction is a fee
38    pub is_fee: bool,
39    /// Original JSON string of the transaction
40    pub raw_json: String,
41}
42
43impl From<AccountTransaction> for StoreTransaction {
44    fn from(raw: AccountTransaction) -> Self {
45        fn parse_period(period: &str) -> Option<NaiveDate> {
46            // For format "DD-MON-YY"
47            if let Some((day_str, rest)) = period.split_once('-')
48                && let Some((mon_str, year_str)) = rest.split_once('-')
49            {
50                // Try to parse the day
51                if let Ok(day) = day_str.parse::<u32>() {
52                    let month = chrono::Month::from_str(mon_str).ok()?;
53                    let year = 2000 + year_str.parse::<i32>().ok()?;
54
55                    // Return the exact date
56                    return NaiveDate::from_ymd_opt(year, month.number_from_month(), day);
57                }
58            }
59
60            // For format "MON-YY"
61            if let Some((mon_str, year_str)) = period.split_once('-') {
62                let month = chrono::Month::from_str(mon_str).ok()?;
63                let year = 2000 + year_str.parse::<i32>().ok()?;
64
65                // Get the first day of the month
66                let first_of_month = NaiveDate::from_ymd_opt(year, month.number_from_month(), 1)?;
67
68                // Get the first day of the previous month
69                let prev_month = if month.number_from_month() == 1 {
70                    // If January, go to December of previous year
71                    NaiveDate::from_ymd_opt(year - 1, 12, 1)?
72                } else {
73                    // Otherwise, just go to previous month
74                    NaiveDate::from_ymd_opt(year, month.number_from_month() - 1, 1)?
75                };
76
77                // Find the last day of the previous month
78                let last_day_of_prev_month = if prev_month.month() == 12 {
79                    // December has 31 days
80                    NaiveDate::from_ymd_opt(prev_month.year(), 12, 31)?
81                } else {
82                    // For other months, the last day is one day before the first of current month
83                    first_of_month - Duration::days(1)
84                };
85
86                // Calculate how many days to go back to find the last Wednesday
87                let days_back = (last_day_of_prev_month.weekday().num_days_from_monday() + 7
88                    - Weekday::Wed.num_days_from_monday())
89                    % 7;
90
91                // Get the last Wednesday
92                return Some(last_day_of_prev_month - Duration::days(days_back as i64));
93            }
94
95            None
96        }
97
98        let instrument_info: ParsedOptionInfo = parse_instrument_name(&raw.instrument_name);
99        let underlying = Some(instrument_info.asset_name);
100        let strike = match instrument_info {
101            ParsedOptionInfo {
102                strike: Some(s), ..
103            } => Some(s.parse::<f64>().ok()).flatten(),
104            _ => None,
105        };
106        let option_type = instrument_info.option_type;
107        let deal_date = NaiveDateTime::parse_from_str(&raw.date_utc, "%Y-%m-%dT%H:%M:%S")
108            .map(|naive| naive.and_utc())
109            .unwrap_or_else(|_| Utc::now());
110        // An unparseable P&L string falls back to 0.0; combined with the fee
111        // classification below, a `WITH` transaction whose amount cannot be parsed
112        // is therefore treated as a fee (0.0 is below `FEE_THRESHOLD_EUR`).
113        let pnl_eur = raw
114            .profit_and_loss
115            .trim_start_matches('E')
116            .replace(',', "") // Remove comma separators for thousands
117            .parse::<f64>()
118            .unwrap_or(0.0);
119
120        let expiry = parse_period(&raw.period);
121
122        let is_fee = raw.transaction_type == "WITH" && pnl_eur.abs() < FEE_THRESHOLD_EUR;
123
124        StoreTransaction {
125            deal_date,
126            underlying,
127            strike,
128            option_type,
129            expiry,
130            transaction_type: raw.transaction_type.clone(),
131            pnl_eur,
132            reference: raw.reference.clone(),
133            is_fee,
134            raw_json: raw.to_string(),
135        }
136    }
137}
138
139impl From<&AccountTransaction> for StoreTransaction {
140    fn from(raw: &AccountTransaction) -> Self {
141        StoreTransaction::from(raw.clone())
142    }
143}
144
145/// Collection of processed transactions from IG Markets
146///
147/// This struct is a wrapper around a vector of `StoreTransaction` objects
148/// and provides convenient methods for accessing and converting transaction data.
149pub struct TransactionList(pub Vec<StoreTransaction>);
150
151impl AsRef<[StoreTransaction]> for TransactionList {
152    fn as_ref(&self) -> &[StoreTransaction] {
153        &self.0[..]
154    }
155}
156
157impl From<&Vec<AccountTransaction>> for TransactionList {
158    fn from(raw: &Vec<AccountTransaction>) -> Self {
159        TransactionList(
160            raw.iter() // Use iter() instead of into_iter() for references
161                .map(StoreTransaction::from) // This assumes there is an impl From<&AccountTransaction> for StoreTransaction
162                .collect(),
163        )
164    }
165}