ig_client/application/services/
ig_tx_client.rs

1use crate::application::models::transaction::{RawTransaction, Transaction};
2use crate::config::Config;
3use crate::error::AppError;
4use crate::session::interface::IgSession;
5use crate::utils::parsing::{InstrumentInfo, parse_instrument_name};
6use async_trait::async_trait;
7use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
8use reqwest::{Client, StatusCode};
9use std::str::FromStr;
10use tracing::debug;
11
12/// Interface for fetching transaction data from IG Markets
13#[async_trait]
14pub trait IgTxFetcher {
15    /// Fetches transactions within a date range
16    ///
17    /// # Arguments
18    /// * `sess` - Active IG session
19    /// * `from` - Start date for the transaction range
20    /// * `to` - End date for the transaction range
21    ///
22    /// # Returns
23    /// * `Result<Vec<Transaction>, AppError>` - List of transactions or an error
24    async fn fetch_range(
25        &self,
26        sess: &IgSession,
27        from: DateTime<Utc>,
28        to: DateTime<Utc>,
29    ) -> Result<Vec<Transaction>, AppError>;
30}
31
32/// Client for fetching transaction data from IG Markets API
33pub struct IgTxClient<'a> {
34    /// Configuration for the IG API
35    cfg: &'a Config,
36    /// HTTP client for making requests
37    http: Client,
38}
39
40impl<'a> IgTxClient<'a> {
41    /// Creates a new IG transaction client
42    ///
43    /// # Arguments
44    /// * `cfg` - Configuration for the IG API
45    ///
46    /// # Returns
47    /// * A new IgTxClient instance
48    pub fn new(cfg: &'a Config) -> Self {
49        Self {
50            cfg,
51            http: Client::builder()
52                .user_agent("ig-rs/0.1")
53                .build()
54                .expect("reqwest"),
55        }
56    }
57
58    /// Constructs a REST API URL from the base URL and path
59    ///
60    /// # Arguments
61    /// * `path` - API endpoint path
62    ///
63    /// # Returns
64    /// * Complete URL string
65    #[allow(dead_code)]
66    fn rest_url(&self, path: &str) -> String {
67        format!(
68            "{}/{}",
69            self.cfg.rest_api.base_url.trim_end_matches('/'),
70            path
71        )
72    }
73
74    /// Converts a raw transaction from the API to a structured Transaction
75    ///
76    /// # Arguments
77    /// * `raw` - Raw transaction data from the API
78    ///
79    /// # Returns
80    /// * `Result<Transaction, AppError>` - Converted transaction or an error
81    fn convert(&self, raw: RawTransaction) -> Result<Transaction, AppError> {
82        let instrument_info: InstrumentInfo = parse_instrument_name(&raw.instrument_name)?;
83        let underlying = instrument_info.underlying;
84        let strike = instrument_info.strike;
85        let option_type = instrument_info.option_type;
86
87        let deal_date = NaiveDateTime::parse_from_str(&raw.date_utc, "%Y-%m-%dT%H:%M:%S")
88            .map(|naive| naive.and_utc())
89            .unwrap_or_else(|_| Utc::now());
90
91        let pnl_eur = raw
92            .pnl_raw
93            .trim_start_matches('E')
94            .parse::<f64>()
95            .unwrap_or(0.0);
96
97        let expiry = raw.period.split_once('-').and_then(|(mon, yy)| {
98            chrono::Month::from_str(mon).ok().and_then(|m| {
99                NaiveDate::from_ymd_opt(2000 + yy.parse::<i32>().ok()?, m.number_from_month(), 1)
100            })
101        });
102
103        let is_fee = raw.transaction_type == "WITH" && pnl_eur.abs() < 1.0;
104
105        Ok(Transaction {
106            deal_date,
107            underlying,
108            strike,
109            option_type,
110            expiry,
111            transaction_type: raw.transaction_type.clone(),
112            pnl_eur,
113            reference: raw.reference.clone(),
114            is_fee,
115            raw_json: raw.to_string(),
116        })
117    }
118}
119
120#[async_trait]
121impl IgTxFetcher for IgTxClient<'_> {
122    async fn fetch_range(
123        &self,
124        sess: &IgSession,
125        from: DateTime<Utc>,
126        to: DateTime<Utc>,
127    ) -> Result<Vec<Transaction>, AppError> {
128        let mut page = 1;
129        let mut out = Vec::new();
130
131        loop {
132            let url = format!(
133                "{}/history/transactions?from={}&to={}&pageNumber={}&pageSize=200",
134                self.cfg.rest_api.base_url,
135                from.format("%Y-%m-%dT%H:%M:%S"),
136                to.format("%Y-%m-%dT%H:%M:%S"),
137                page
138            );
139            debug!("🔗 Fetching IG txs from URL: {}", url);
140
141            let resp = self
142                .http
143                .get(&url)
144                .header("X-IG-API-KEY", &self.cfg.credentials.api_key)
145                .header("CST", &sess.cst)
146                .header("X-SECURITY-TOKEN", &sess.token)
147                .header("Version", "2")
148                .header("Accept", "application/json; charset=UTF-8")
149                .send()
150                .await?;
151
152            if resp.status() != StatusCode::OK {
153                return Err(AppError::Unexpected(resp.status()));
154            }
155
156            let json: serde_json::Value = resp.json().await?;
157            let raws: Vec<RawTransaction> =
158                serde_json::from_value(json["transactions"].clone()).unwrap_or_default();
159
160            if raws.is_empty() {
161                break;
162            }
163
164            out.extend(raws.into_iter().map(|r| self.convert(r).unwrap()));
165
166            let meta = &json["metadata"]["pageData"];
167            let total_pages = meta["totalPages"].as_u64().unwrap_or(1);
168            if page >= total_pages {
169                break;
170            }
171            page += 1;
172        }
173
174        Ok(out)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::application::models::transaction::RawTransaction;
182    use crate::config::Config;
183
184    #[test]
185    fn test_rest_url() {
186        unsafe {
187            std::env::set_var("IG_REST_BASE_URL", "https://api.example.com");
188            std::env::set_var("IG_REST_TIMEOUT", "60");
189            std::env::set_var("IG_WS_URL", "wss://ws.example.com");
190            std::env::set_var("IG_WS_RECONNECT_INTERVAL", "10");
191        }
192        let config = Config::new();
193        let client = IgTxClient::new(&config);
194        assert_eq!(client.rest_url("path"), "https://api.example.com/path");
195    }
196
197    #[test]
198    fn test_convert_basic() {
199        let config = Config::new();
200        let client = IgTxClient::new(&config);
201        let raw = RawTransaction {
202            date: "".to_string(),
203            date_utc: "2024-01-01T12:00:00".to_string(),
204            open_date_utc: "".to_string(),
205            instrument_name: "EURUSD".to_string(),
206            period: "".to_string(),
207            pnl_raw: "E1000".to_string(),
208            transaction_type: "DEAL".to_string(),
209            reference: "REF123".to_string(),
210            open_level: "".to_string(),
211            close_level: "".to_string(),
212            size: "".to_string(),
213            currency: "".to_string(),
214            cash_transaction: false,
215        };
216        let tx = client.convert(raw.clone()).unwrap();
217        assert_eq!(tx.transaction_type, raw.transaction_type);
218        assert_eq!(tx.reference, raw.reference);
219        assert_eq!(tx.pnl_eur, 1000.0);
220        assert!(!tx.is_fee);
221        assert_eq!(
222            tx.deal_date.timestamp(),
223            NaiveDateTime::parse_from_str(&raw.date_utc, "%Y-%m-%dT%H:%M:%S")
224                .unwrap()
225                .and_utc()
226                .timestamp()
227        );
228        assert!(tx.raw_json.contains(&raw.reference));
229    }
230
231    #[test]
232    fn test_convert_fee() {
233        let config = Config::new();
234        let client = IgTxClient::new(&config);
235        let raw = RawTransaction {
236            date: "".to_string(),
237            date_utc: "2024-01-02T00:00:00".to_string(),
238            open_date_utc: "".to_string(),
239            instrument_name: "".to_string(),
240            period: "".to_string(),
241            pnl_raw: "E0.5".to_string(),
242            transaction_type: "WITH".to_string(),
243            reference: "FEE".to_string(),
244            open_level: "".to_string(),
245            close_level: "".to_string(),
246            size: "".to_string(),
247            currency: "".to_string(),
248            cash_transaction: false,
249        };
250        let tx = client.convert(raw.clone()).unwrap();
251        assert_eq!(tx.pnl_eur, 0.5);
252        assert!(tx.is_fee);
253    }
254}