ig_client/
config.rs

1use crate::constants::{DAYS_TO_BACK_LOOK, DEFAULT_PAGE_SIZE, DEFAULT_SLEEP_TIME};
2use crate::impl_json_display;
3use crate::storage::config::DatabaseConfig;
4use crate::utils::rate_limiter::RateLimitType;
5use dotenv::dotenv;
6use serde::{Deserialize, Serialize};
7use sqlx::postgres::PgPoolOptions;
8use std::env;
9use std::fmt::Debug;
10use std::str::FromStr;
11use tracing::error;
12use tracing::log::debug;
13
14#[derive(Debug, Serialize, Deserialize, Clone)]
15/// Authentication credentials for the IG Markets API
16pub struct Credentials {
17    /// Username for the IG Markets account
18    pub username: String,
19    /// Password for the IG Markets account
20    pub password: String,
21    /// Account ID for the IG Markets account
22    pub account_id: String,
23    /// API key for the IG Markets API
24    pub api_key: String,
25    /// Client token for the IG Markets API
26    pub client_token: Option<String>,
27    /// Account token for the IG Markets API
28    pub account_token: Option<String>,
29}
30
31impl_json_display!(Credentials);
32
33#[derive(Debug, Serialize, Deserialize, Clone)]
34/// Main configuration for the IG Markets API client
35pub struct Config {
36    /// Authentication credentials
37    pub credentials: Credentials,
38    /// REST API configuration
39    pub rest_api: RestApiConfig,
40    /// WebSocket API configuration
41    pub websocket: WebSocketConfig,
42    /// Database configuration for data persistence
43    pub database: DatabaseConfig,
44    /// Number of hours between transaction fetching operations
45    pub sleep_hours: u64,
46    /// Number of items to retrieve per page in API requests
47    pub page_size: u32,
48    /// Number of days to look back when fetching historical data
49    pub days_to_look_back: i64,
50    /// Rate limit type to use for API requests
51    pub rate_limit_type: RateLimitType,
52    /// Safety margin for rate limiting (0.0-1.0)
53    pub rate_limit_safety_margin: f64,
54    /// API version to use for authentication (2 or 3). If None, auto-detect based on available tokens
55    pub api_version: Option<u8>,
56}
57
58impl_json_display!(Config);
59
60#[derive(Debug, Serialize, Deserialize, Clone)]
61/// Configuration for the REST API
62pub struct RestApiConfig {
63    /// Base URL for the IG Markets REST API
64    pub base_url: String,
65    /// Timeout in seconds for REST API requests
66    pub timeout: u64,
67}
68
69impl_json_display!(RestApiConfig);
70
71#[derive(Debug, Serialize, Deserialize, Clone)]
72/// Configuration for the WebSocket API
73pub struct WebSocketConfig {
74    /// URL for the IG Markets WebSocket API
75    pub url: String,
76    /// Reconnect interval in seconds for WebSocket connections
77    pub reconnect_interval: u64,
78}
79
80impl_json_display!(WebSocketConfig);
81
82/// Gets an environment variable or returns a default value if not found or cannot be parsed
83///
84/// # Arguments
85///
86/// * `env_var` - The name of the environment variable
87/// * `default` - The default value to use if the environment variable is not found or cannot be parsed
88///
89/// # Returns
90///
91/// The parsed value of the environment variable or the default value
92pub fn get_env_or_default<T: FromStr>(env_var: &str, default: T) -> T
93where
94    <T as FromStr>::Err: Debug,
95{
96    match env::var(env_var) {
97        Ok(val) => val.parse::<T>().unwrap_or_else(|_| {
98            error!("Failed to parse {}: {}, using default", env_var, val);
99            default
100        }),
101        Err(_) => default,
102    }
103}
104
105impl Default for Config {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl Config {
112    /// Creates a new configuration instance from environment variables
113    ///
114    /// Loads configuration from environment variables or .env file.
115    /// Uses default values if environment variables are not found.
116    ///
117    /// # Returns
118    ///
119    /// A new `Config` instance
120    pub fn new() -> Self {
121        Self::with_rate_limit_type(RateLimitType::OnePerSecond, 0.5)
122    }
123
124    /// Creates a new configuration instance with a specific rate limit type
125    ///
126    /// # Arguments
127    ///
128    /// * `rate_limit_type` - The type of rate limit to enforce
129    /// * `safety_margin` - A value between 0.0 and 1.0 representing the percentage of the actual limit to use
130    ///
131    /// # Returns
132    ///
133    /// A new `Config` instance
134    pub fn with_rate_limit_type(rate_limit_type: RateLimitType, safety_margin: f64) -> Self {
135        // Explicitly load the .env file
136        match dotenv() {
137            Ok(_) => debug!("Successfully loaded .env file"),
138            Err(e) => debug!("Failed to load .env file: {e}"),
139        }
140
141        // Check if environment variables are configured
142        let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
143        let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
144        let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
145
146        let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
147        let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
148        let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
149
150        // Check if we are using default values
151        if username == "default_username" {
152            error!("IG_USERNAME not found in environment variables or .env file");
153        }
154        if password == "default_password" {
155            error!("IG_PASSWORD not found in environment variables or .env file");
156        }
157        if api_key == "default_api_key" {
158            error!("IG_API_KEY not found in environment variables or .env file");
159        }
160
161        // Print information about loaded environment variables
162        debug!("Environment variables loaded:");
163        debug!(
164            "  IG_USERNAME: {}",
165            if username == "default_username" {
166                "Not set"
167            } else {
168                "Set"
169            }
170        );
171        debug!(
172            "  IG_PASSWORD: {}",
173            if password == "default_password" {
174                "Not set"
175            } else {
176                "Set"
177            }
178        );
179        debug!(
180            "  IG_API_KEY: {}",
181            if api_key == "default_api_key" {
182                "Not set"
183            } else {
184                "Set"
185            }
186        );
187
188        // Ensure safety margin is within valid range
189        let safety_margin = safety_margin.clamp(0.1, 1.0);
190
191        Config {
192            credentials: Credentials {
193                username,
194                password,
195                account_id: get_env_or_default("IG_ACCOUNT_ID", String::from("default_account_id")),
196                api_key,
197                client_token: None,
198                account_token: None,
199            },
200            rest_api: RestApiConfig {
201                base_url: get_env_or_default(
202                    "IG_REST_BASE_URL",
203                    String::from("https://demo-api.ig.com/gateway/deal"),
204                ),
205                timeout: get_env_or_default("IG_REST_TIMEOUT", 30),
206            },
207            websocket: WebSocketConfig {
208                url: get_env_or_default(
209                    "IG_WS_URL",
210                    String::from("wss://demo-apd.marketdatasystems.com"),
211                ),
212                reconnect_interval: get_env_or_default("IG_WS_RECONNECT_INTERVAL", 5),
213            },
214            database: DatabaseConfig {
215                url: get_env_or_default(
216                    "DATABASE_URL",
217                    String::from("postgres://postgres:postgres@localhost/ig"),
218                ),
219                max_connections: get_env_or_default("DATABASE_MAX_CONNECTIONS", 5),
220            },
221            sleep_hours,
222            page_size,
223            days_to_look_back,
224            rate_limit_type,
225            rate_limit_safety_margin: safety_margin,
226            api_version: env::var("IG_API_VERSION")
227                .ok()
228                .and_then(|v| v.parse::<u8>().ok())
229                .filter(|&v| v == 2 || v == 3)
230                .or(Some(3)), // Default to API v3 (OAuth) if not specified
231        }
232    }
233
234    /// Creates a PostgreSQL connection pool using the database configuration
235    ///
236    /// # Returns
237    ///
238    /// A Result containing either a PostgreSQL connection pool or an error
239    pub async fn pg_pool(&self) -> Result<sqlx::Pool<sqlx::Postgres>, sqlx::Error> {
240        PgPoolOptions::new()
241            .max_connections(self.database.max_connections)
242            .connect(&self.database.url)
243            .await
244    }
245}
246
247#[cfg(test)]
248mod tests_display {
249    use super::*;
250    use assert_json_diff::assert_json_eq;
251    use serde_json::json;
252
253    #[test]
254    fn test_credentials_display() {
255        let credentials = Credentials {
256            username: "user123".to_string(),
257            password: "pass123".to_string(),
258            account_id: "acc456".to_string(),
259            api_key: "key789".to_string(),
260            client_token: Some("ctoken".to_string()),
261            account_token: None,
262        };
263
264        let display_output = credentials.to_string();
265        let expected_json = json!({
266            "username": "user123",
267            "password": "pass123",
268            "account_id": "acc456",
269            "api_key": "key789",
270            "client_token": "ctoken",
271            "account_token": null
272        });
273
274        assert_json_eq!(
275            serde_json::from_str::<serde_json::Value>(&display_output).unwrap(),
276            expected_json
277        );
278    }
279
280    #[test]
281    fn test_rest_api_config_display() {
282        let rest_api_config = RestApiConfig {
283            base_url: "https://api.example.com".to_string(),
284            timeout: 30,
285        };
286
287        let display_output = rest_api_config.to_string();
288        let expected_json = json!({
289            "base_url": "https://api.example.com",
290            "timeout": 30
291        });
292
293        assert_json_eq!(
294            serde_json::from_str::<serde_json::Value>(&display_output).unwrap(),
295            expected_json
296        );
297    }
298
299    #[test]
300    fn test_websocket_config_display() {
301        let websocket_config = WebSocketConfig {
302            url: "wss://ws.example.com".to_string(),
303            reconnect_interval: 5,
304        };
305
306        let display_output = websocket_config.to_string();
307        let expected_json = json!({
308            "url": "wss://ws.example.com",
309            "reconnect_interval": 5
310        });
311
312        assert_json_eq!(
313            serde_json::from_str::<serde_json::Value>(&display_output).unwrap(),
314            expected_json
315        );
316    }
317
318    #[test]
319    fn test_config_display() {
320        let config = Config {
321            credentials: Credentials {
322                username: "user123".to_string(),
323                password: "pass123".to_string(),
324                account_id: "acc456".to_string(),
325                api_key: "key789".to_string(),
326                client_token: Some("ctoken".to_string()),
327                account_token: None,
328            },
329            rest_api: RestApiConfig {
330                base_url: "https://api.example.com".to_string(),
331                timeout: 30,
332            },
333            websocket: WebSocketConfig {
334                url: "wss://ws.example.com".to_string(),
335                reconnect_interval: 5,
336            },
337            database: DatabaseConfig {
338                url: "postgres://user:pass@localhost/ig_db".to_string(),
339                max_connections: 5,
340            },
341            sleep_hours: 0,
342            page_size: 0,
343            days_to_look_back: 0,
344            rate_limit_type: RateLimitType::NonTradingAccount,
345            rate_limit_safety_margin: 0.8,
346            api_version: None,
347        };
348
349        let display_output = config.to_string();
350        let expected_json = json!({
351            "credentials": {
352                "username": "user123",
353                "password": "pass123",
354                "account_id": "acc456",
355                "api_key": "key789",
356                "client_token": "ctoken",
357                "account_token": null
358            },
359            "rest_api": {
360                "base_url": "https://api.example.com",
361                "timeout": 30
362            },
363            "websocket": {
364                "url": "wss://ws.example.com",
365                "reconnect_interval": 5
366            },
367            "database": {
368                "url": "postgres://user:pass@localhost/ig_db",
369                "max_connections": 5
370            },
371            "sleep_hours": 0,
372            "page_size": 0,
373            "days_to_look_back": 0,
374            "rate_limit_type": "NonTradingAccount",
375            "rate_limit_safety_margin": 0.8,
376            "api_version": null
377        });
378
379        assert_json_eq!(
380            serde_json::from_str::<serde_json::Value>(&display_output).unwrap(),
381            expected_json
382        );
383    }
384}