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}