ig_client/application/services/
ig_tx_client.rs1use 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#[async_trait]
14pub trait IgTxFetcher {
15 async fn fetch_range(
25 &self,
26 sess: &IgSession,
27 from: DateTime<Utc>,
28 to: DateTime<Utc>,
29 ) -> Result<Vec<Transaction>, AppError>;
30}
31
32pub struct IgTxClient<'a> {
34 cfg: &'a Config,
36 http: Client,
38}
39
40impl<'a> IgTxClient<'a> {
41 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 #[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 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}