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}