ig_client/
config.rs

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