Skip to main content

ig_client/application/
client.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/10/25
5******************************************************************************/
6use crate::application::auth::WebsocketInfo;
7use crate::application::config::Config;
8use crate::application::http::HttpClient;
9use crate::application::interfaces::account::AccountService;
10use crate::application::interfaces::costs::CostsService;
11use crate::application::interfaces::market::MarketService;
12use crate::application::interfaces::operations::OperationsService;
13use crate::application::interfaces::order::OrderService;
14use crate::application::interfaces::sentiment::SentimentService;
15use crate::application::interfaces::watchlist::WatchlistService;
16use crate::error::AppError;
17use crate::model::requests::RecentPricesRequest;
18use crate::model::requests::{
19    AddToWatchlistRequest, CloseCostsRequest, CreateWatchlistRequest, EditCostsRequest,
20    OpenCostsRequest, UpdateWorkingOrderRequest,
21};
22use crate::model::requests::{
23    ClosePositionRequest, CreateOrderRequest, CreateWorkingOrderRequest, UpdatePositionRequest,
24};
25use crate::model::responses::{
26    AccountActivityResponse, AccountsResponse, OrderConfirmationResponse, PositionsResponse,
27    TransactionHistoryResponse, WorkingOrdersResponse,
28};
29use crate::model::responses::{
30    AccountPreferencesResponse, ApplicationDetailsResponse, CategoriesResponse,
31    CategoryInstrumentsResponse, ClientSentimentResponse, CostsHistoryResponse,
32    CreateWatchlistResponse, DBEntryResponse, DurableMediumResponse, HistoricalPricesResponse,
33    IndicativeCostsResponse, MarketNavigationResponse, MarketSearchResponse, MarketSentiment,
34    MultipleMarketDetailsResponse, SinglePositionResponse, StatusResponse,
35    WatchlistMarketsResponse, WatchlistsResponse,
36};
37use crate::model::responses::{
38    ClosePositionResponse, CreateOrderResponse, CreateWorkingOrderResponse, UpdatePositionResponse,
39};
40use crate::model::retry::backoff_delay;
41#[cfg(feature = "streaming")]
42use crate::model::streaming::{
43    StreamingAccountDataField, StreamingChartField, StreamingMarketField, StreamingPriceField,
44    get_streaming_account_data_fields, get_streaming_chart_fields, get_streaming_market_fields,
45    get_streaming_price_fields,
46};
47#[cfg(feature = "streaming")]
48use crate::presentation::account::AccountFields;
49#[cfg(feature = "streaming")]
50use crate::presentation::chart::{ChartData, ChartScale};
51use crate::presentation::market::{MarketData, MarketDetails};
52#[cfg(feature = "streaming")]
53use crate::presentation::price::PriceData;
54#[cfg(feature = "streaming")]
55use crate::presentation::trade::TradeFields;
56use async_trait::async_trait;
57use futures::StreamExt;
58#[cfg(feature = "streaming")]
59use lightstreamer_rs::client::{LightstreamerClient, LogType, Transport};
60#[cfg(feature = "streaming")]
61use lightstreamer_rs::subscription::{
62    ChannelSubscriptionListener, Snapshot, Subscription, SubscriptionMode,
63};
64#[cfg(feature = "streaming")]
65use lightstreamer_rs::utils::{LightstreamerError, setup_signal_hook};
66use reqwest::StatusCode;
67use std::collections::HashSet;
68use std::sync::Arc;
69use std::time::Duration;
70#[cfg(feature = "streaming")]
71use tokio::sync::{Mutex, Notify, mpsc};
72#[cfg(feature = "streaming")]
73use tokio::task::JoinHandle;
74use tokio::time::sleep;
75#[cfg(feature = "streaming")]
76use tracing::error;
77use tracing::{debug, info, warn};
78
79#[cfg(feature = "streaming")]
80const MAX_CONNECTION_ATTEMPTS: u64 = 3;
81
82/// Maximum number of concurrent `get_market_details` requests issued while
83/// resolving per-symbol expiry dates in [`Client::get_vec_db_entries`].
84///
85/// Kept small so the shared rate limiter stays in control: this only overlaps
86/// network latency, it does not widen the request budget.
87const MARKET_DETAILS_CONCURRENCY: usize = 6;
88
89/// Server-supplied close reason IG sends on a streaming session once it has no
90/// active subscriptions left to serve.
91///
92/// This is not a dedicated error discriminant: `lightstreamer-rs` surfaces it
93/// as free-form text embedded in a server error payload, so it can only be
94/// matched best-effort (see [`is_graceful_close`]).
95#[cfg(feature = "streaming")]
96const GRACEFUL_CLOSE_MARKER: &str = "No more requests to fulfill";
97
98/// Returns `true` if `error` represents a graceful, server-initiated close
99/// rather than a real connection failure.
100///
101/// IG closes a streaming session with the server reason
102/// "No more requests to fulfill" once it has no active subscriptions. The
103/// upstream [`LightstreamerError`] exposes no graceful-close discriminant — the
104/// reason arrives as free-form text embedded in a server error message
105/// (`conerr` is wrapped into [`LightstreamerError::Connection`]). We therefore
106/// classify the typed error on the variants that can carry a server-supplied
107/// reason and match [`GRACEFUL_CLOSE_MARKER`] against the variant's message
108/// payload directly — never against the `Debug` representation.
109#[cfg(feature = "streaming")]
110#[must_use]
111#[inline]
112fn is_graceful_close(error: &LightstreamerError) -> bool {
113    match error {
114        LightstreamerError::Connection(msg)
115        | LightstreamerError::Protocol(msg)
116        | LightstreamerError::InvalidState(msg) => msg.contains(GRACEFUL_CLOSE_MARKER),
117        _ => false,
118    }
119}
120
121/// Spawns a task that waits for `source` to be notified once and then wakes
122/// every signal in `targets`.
123///
124/// This fans a single shutdown signal out to each per-connection signal so that
125/// *all* streaming connections stop. It works around `Notify::notify_one`
126/// waking only a single waiter: a shared `Notify` cannot shut down both the
127/// market and price connections, so each connection gets its own dedicated
128/// signal woken here.
129///
130/// Each per-connection signal has exactly one waiter, so `notify_one` (which
131/// stores a permit when no waiter is currently parked) wakes it reliably even
132/// if the connection is momentarily busy processing a frame.
133///
134/// The returned [`JoinHandle`] must be aborted once all connections have
135/// finished so the forwarder does not outlive them.
136#[cfg(feature = "streaming")]
137#[must_use]
138fn spawn_shutdown_fanout(source: Arc<Notify>, targets: Vec<Arc<Notify>>) -> JoinHandle<()> {
139    tokio::spawn(async move {
140        source.notified().await;
141        for target in &targets {
142            target.notify_one();
143        }
144    })
145}
146
147/// Aborts every task in `tasks` and awaits its termination, then clears the
148/// list.
149///
150/// Awaiting after `abort` guarantees each task has fully stopped before we
151/// return; the [`tokio::task::JoinError`] produced by cancellation is expected
152/// and ignored.
153#[cfg(feature = "streaming")]
154async fn abort_and_drain_tasks(tasks: &mut Vec<JoinHandle<()>>) {
155    for handle in tasks.drain(..) {
156        handle.abort();
157        // Ignore the cancellation `JoinError`; we only need the task to stop.
158        let _ = handle.await;
159    }
160}
161
162/// Returns `true` if an error from the order-confirmation endpoint is transient
163/// and worth polling again.
164///
165/// The IG `GET /confirms/{dealReference}` endpoint returns `404 Not Found` until
166/// the deal has been processed, so a not-found result means "not yet available"
167/// rather than a permanent failure. Transient cases retried by
168/// [`Client::get_order_confirmation_w_retry`]:
169///
170/// - [`AppError::RateLimitExceeded`] — rate limited.
171/// - [`AppError::Network`] — connection / transport error.
172/// - [`AppError::NotFound`] / [`AppError::Unexpected`] with a `404` or `5xx`
173///   status — confirmation not yet available or a server error.
174///
175/// Everything else (auth failures, invalid input, deserialization, 4xx client
176/// errors) is permanent and returned to the caller immediately.
177#[must_use]
178fn is_transient_confirmation_error(err: &AppError) -> bool {
179    match err {
180        AppError::RateLimitExceeded | AppError::Network(_) | AppError::NotFound => true,
181        AppError::Unexpected(status) => {
182            *status == StatusCode::NOT_FOUND || status.is_server_error()
183        }
184        _ => false,
185    }
186}
187
188/// Main client for interacting with IG Markets API
189///
190/// This client provides a unified interface for all IG Markets API operations,
191/// including market data, account management, and order execution.
192pub struct Client {
193    http_client: Arc<HttpClient>,
194}
195
196impl Client {
197    /// Creates a new client instance from the environment, without performing
198    /// initial authentication, returning an error if the underlying HTTP client
199    /// cannot be constructed.
200    ///
201    /// This is the environment convenience path: the configuration comes from
202    /// [`Config::default`], which loads a local `.env` file and reads the
203    /// `IG_*` environment namespace. Embedders that supply their own
204    /// configuration should use [`Client::with_config`] instead, which touches
205    /// neither.
206    ///
207    /// # Returns
208    /// * `Ok(Client)` - A client ready to use with the default configuration.
209    /// * `Err(AppError)` - If the underlying HTTP client cannot be constructed.
210    ///
211    /// # Errors
212    /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
213    /// be built (e.g. the system TLS backend fails to initialize).
214    pub fn try_new() -> Result<Self, AppError> {
215        let http_client = Arc::new(HttpClient::new_lazy(Config::default())?);
216        Ok(Self { http_client })
217    }
218
219    /// Creates a new client instance from a caller-supplied [`Config`], without
220    /// reading a `.env` file or the `IG_*` environment namespace.
221    ///
222    /// This is the injection path for applications that own their
223    /// configuration source (their own namespaced environment variables, a
224    /// config file, a secrets manager) and must not have the crate reach for
225    /// globals. Build the `Config` with
226    /// [`Config::from_credentials`](crate::application::config::Config::from_credentials),
227    /// which is likewise env-free; [`Client::try_new`] remains the `.env`
228    /// convenience path.
229    ///
230    /// As with [`try_new`](Self::try_new), no authentication is performed here:
231    /// session login and token refresh happen transparently on the first API
232    /// call.
233    ///
234    /// Two knobs live outside [`Config`] and are still resolved from the
235    /// process environment on this path: the retry policy (`MAX_RETRY_COUNT` /
236    /// `RETRY_DELAY_SECS`, read per request by
237    /// [`RetryConfig::default`](crate::model::retry::RetryConfig)) and
238    /// `IG_PRICING_ADAPTER` (the Lightstreamer price adapter name). Neither
239    /// carries a credential and both have safe defaults, so an embedder that
240    /// sets neither is unaffected.
241    ///
242    /// For streaming, pair this with
243    /// `StreamerClient::with_client`:
244    /// `StreamerClient::new`
245    /// builds its own client via [`try_new`](Self::try_new) and would go back
246    /// to the `.env` / `IG_*` path.
247    ///
248    /// ```rust,no_run
249    /// use ig_client::prelude::*;
250    ///
251    /// // Fail fast on a missing variable: an empty credential would only
252    /// // surface later as a confusing authentication failure.
253    /// fn required_var(name: &str) -> Result<String, AppError> {
254    ///     std::env::var(name).map_err(|_| AppError::InvalidInput(format!("{name} is not set")))
255    /// }
256    ///
257    /// # fn main() -> Result<(), AppError> {
258    /// let credentials = Credentials::new(
259    ///     required_var("MYAPP_IG_USERNAME")?,
260    ///     required_var("MYAPP_IG_PASSWORD")?,
261    ///     required_var("MYAPP_IG_ACCOUNT_ID")?,
262    ///     required_var("MYAPP_IG_API_KEY")?,
263    /// );
264    /// let client = Client::with_config(Config::from_credentials(credentials))?;
265    /// # let _ = client;
266    /// # Ok(())
267    /// # }
268    /// ```
269    ///
270    /// # Arguments
271    /// * `config` - The configuration the client and its session layer will use.
272    ///
273    /// # Returns
274    /// * `Ok(Client)` - A client ready to use with `config`.
275    /// * `Err(AppError)` - If the underlying HTTP client cannot be constructed.
276    ///
277    /// # Errors
278    /// Returns [`AppError::Network`] if the underlying `reqwest` client cannot
279    /// be built (e.g. the system TLS backend fails to initialize).
280    pub fn with_config(config: Config) -> Result<Self, AppError> {
281        let http_client = Arc::new(HttpClient::new_lazy(config)?);
282        Ok(Self { http_client })
283    }
284
285    /// Returns the configuration this client was built with.
286    ///
287    /// Useful to confirm which environment the client is pointed at (e.g.
288    /// `client.config().rest_api.base_url`). `Config`'s `Debug` / `Display`
289    /// redact credentials and the database URL, so rendering it that way is
290    /// safe. Its `Serialize` impl does **not** redact — never serialize a
291    /// `Config` into logs, telemetry or an error payload.
292    #[inline]
293    #[must_use]
294    pub fn config(&self) -> &Config {
295        self.http_client.config()
296    }
297
298    /// Gets WebSocket connection information for Lightstreamer, reusing the
299    /// cached session.
300    ///
301    /// Delegates to [`HttpClient::ws_info`], which returns the cached session
302    /// when it is valid and only logs in when needed.
303    ///
304    /// # Returns
305    /// * `Ok(WebsocketInfo)` - Server endpoint, authentication tokens, and
306    ///   account ID for the current session.
307    /// * `Err(AppError)` - If session retrieval (login / refresh) fails.
308    ///
309    /// # Errors
310    /// Returns [`AppError`] when the session cannot be retrieved.
311    pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
312        self.http_client.ws_info().await
313    }
314
315    /// Gets WebSocket connection information for Lightstreamer
316    ///
317    /// # Returns
318    /// * `WebsocketInfo` containing server endpoint, authentication tokens, and account ID
319    #[deprecated(
320        note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
321    )]
322    pub async fn get_ws_info(&self) -> WebsocketInfo {
323        self.ws_info().await.unwrap_or_default()
324    }
325}
326
327#[async_trait]
328impl MarketService for Client {
329    async fn search_markets(&self, search_term: &str) -> Result<MarketSearchResponse, AppError> {
330        let path = format!("markets?searchTerm={}", search_term);
331        info!("Searching markets with term: {}", search_term);
332        let result: MarketSearchResponse = self.http_client.get(&path, Some(1)).await?;
333        debug!("{} markets found", result.markets.len());
334        Ok(result)
335    }
336
337    async fn get_market_details(&self, epic: &str) -> Result<MarketDetails, AppError> {
338        let path = format!("markets/{epic}");
339        info!("Getting market details: {}", epic);
340        // Deserialize straight into the typed DTO: the previous
341        // `serde_json::Value` -> `from_value` hop allocated the whole JSON tree
342        // twice and dropped the epic from any deserialization error.
343        let market_details: MarketDetails = self.http_client.get(&path, Some(3)).await?;
344        debug!("Market details obtained for: {}", epic);
345        Ok(market_details)
346    }
347
348    async fn get_multiple_market_details(
349        &self,
350        epics: &[String],
351    ) -> Result<MultipleMarketDetailsResponse, AppError> {
352        if epics.is_empty() {
353            return Ok(MultipleMarketDetailsResponse::default());
354        } else if epics.len() > 50 {
355            return Err(AppError::InvalidInput(
356                "The maximum number of EPICs is 50".to_string(),
357            ));
358        }
359
360        let epics_str = epics.join(",");
361        let path = format!("markets?epics={}", epics_str);
362        debug!(
363            "Getting market details for {} EPICs in a batch",
364            epics.len()
365        );
366
367        let response: MultipleMarketDetailsResponse = self.http_client.get(&path, Some(2)).await?;
368
369        Ok(response)
370    }
371
372    async fn get_historical_prices(
373        &self,
374        epic: &str,
375        resolution: &str,
376        from: &str,
377        to: &str,
378    ) -> Result<HistoricalPricesResponse, AppError> {
379        let path = format!(
380            "prices/{}?resolution={}&from={}&to={}",
381            epic, resolution, from, to
382        );
383        info!("Getting historical prices for: {}", epic);
384        let result: HistoricalPricesResponse = self.http_client.get(&path, Some(3)).await?;
385        debug!("Historical prices obtained for: {}", epic);
386        Ok(result)
387    }
388
389    async fn get_historical_prices_by_date_range(
390        &self,
391        epic: &str,
392        resolution: &str,
393        start_date: &str,
394        end_date: &str,
395    ) -> Result<HistoricalPricesResponse, AppError> {
396        let path = format!("prices/{}/{}/{}/{}", epic, resolution, start_date, end_date);
397        info!(
398            "Getting historical prices for epic: {}, resolution: {}, from: {} to: {}",
399            epic, resolution, start_date, end_date
400        );
401        let result: HistoricalPricesResponse = self.http_client.get(&path, Some(2)).await?;
402        debug!(
403            "Historical prices obtained for epic: {}, {} data points",
404            epic,
405            result.prices.len()
406        );
407        Ok(result)
408    }
409
410    async fn get_recent_prices(
411        &self,
412        params: &RecentPricesRequest<'_>,
413    ) -> Result<HistoricalPricesResponse, AppError> {
414        let mut query_params = Vec::new();
415
416        if let Some(res) = params.resolution {
417            query_params.push(format!("resolution={}", res));
418        }
419        if let Some(f) = params.from {
420            query_params.push(format!("from={}", f));
421        }
422        if let Some(t) = params.to {
423            query_params.push(format!("to={}", t));
424        }
425        if let Some(max) = params.max_points {
426            query_params.push(format!("max={}", max));
427        }
428        if let Some(size) = params.page_size {
429            query_params.push(format!("pageSize={}", size));
430        }
431        if let Some(num) = params.page_number {
432            query_params.push(format!("pageNumber={}", num));
433        }
434
435        let query_string = if query_params.is_empty() {
436            String::new()
437        } else {
438            format!("?{}", query_params.join("&"))
439        };
440
441        let path = format!("prices/{}{}", params.epic, query_string);
442        info!("Getting recent prices for epic: {}", params.epic);
443        let result: HistoricalPricesResponse = self.http_client.get(&path, Some(3)).await?;
444        debug!(
445            "Recent prices obtained for epic: {}, {} data points",
446            params.epic,
447            result.prices.len()
448        );
449        Ok(result)
450    }
451
452    async fn get_historical_prices_by_count_v1(
453        &self,
454        epic: &str,
455        resolution: &str,
456        num_points: u32,
457    ) -> Result<HistoricalPricesResponse, AppError> {
458        let path = format!("prices/{}/{}/{}", epic, resolution, num_points);
459        info!(
460            "Getting historical prices (v1) for epic: {}, resolution: {}, points: {}",
461            epic, resolution, num_points
462        );
463        let result: HistoricalPricesResponse = self.http_client.get(&path, Some(1)).await?;
464        debug!(
465            "Historical prices (v1) obtained for epic: {}, {} data points",
466            epic,
467            result.prices.len()
468        );
469        Ok(result)
470    }
471
472    async fn get_historical_prices_by_count_v2(
473        &self,
474        epic: &str,
475        resolution: &str,
476        num_points: u32,
477    ) -> Result<HistoricalPricesResponse, AppError> {
478        let path = format!("prices/{}/{}/{}", epic, resolution, num_points);
479        info!(
480            "Getting historical prices (v2) for epic: {}, resolution: {}, points: {}",
481            epic, resolution, num_points
482        );
483        let result: HistoricalPricesResponse = self.http_client.get(&path, Some(2)).await?;
484        debug!(
485            "Historical prices (v2) obtained for epic: {}, {} data points",
486            epic,
487            result.prices.len()
488        );
489        Ok(result)
490    }
491
492    async fn get_market_navigation(&self) -> Result<MarketNavigationResponse, AppError> {
493        let path = "marketnavigation";
494        info!("Getting top-level market navigation nodes");
495        let result: MarketNavigationResponse = self.http_client.get(path, Some(1)).await?;
496        debug!("{} navigation nodes found", result.nodes.len());
497        debug!("{} markets found at root level", result.markets.len());
498        Ok(result)
499    }
500
501    async fn get_market_navigation_node(
502        &self,
503        node_id: &str,
504    ) -> Result<MarketNavigationResponse, AppError> {
505        let path = format!("marketnavigation/{}", node_id);
506        info!("Getting market navigation node: {}", node_id);
507        let result: MarketNavigationResponse = self.http_client.get(&path, Some(1)).await?;
508        debug!("{} child nodes found", result.nodes.len());
509        debug!("{} markets found in node {}", result.markets.len(), node_id);
510        Ok(result)
511    }
512
513    async fn get_all_markets(&self) -> Result<Vec<MarketData>, AppError> {
514        let max_depth = 6;
515        info!(
516            "Starting comprehensive market hierarchy traversal (max {} levels)",
517            max_depth
518        );
519
520        let root_response = self.get_market_navigation().await?;
521        info!(
522            "Root navigation: {} nodes, {} markets at top level",
523            root_response.nodes.len(),
524            root_response.markets.len()
525        );
526
527        // Move the root response fields out instead of cloning the (potentially
528        // large) DTO. The same market epic can appear under multiple navigation
529        // nodes, so track seen epics and keep only the first occurrence.
530        let mut seen_epics: HashSet<String> = HashSet::new();
531        let mut all_markets: Vec<MarketData> = Vec::new();
532        for market in root_response.markets {
533            if seen_epics.insert(market.epic.clone()) {
534                all_markets.push(market);
535            }
536        }
537        let mut nodes_to_process = root_response.nodes;
538        let mut processed_levels = 0;
539
540        while !nodes_to_process.is_empty() && processed_levels < max_depth {
541            let mut next_level_nodes = Vec::new();
542            let mut level_market_count = 0;
543
544            info!(
545                "Processing level {} with {} nodes",
546                processed_levels,
547                nodes_to_process.len()
548            );
549
550            for node in &nodes_to_process {
551                match self.get_market_navigation_node(&node.id).await {
552                    Ok(node_response) => {
553                        let node_markets = node_response.markets.len();
554                        let node_children = node_response.nodes.len();
555
556                        if node_markets > 0 || node_children > 0 {
557                            debug!(
558                                "Node '{}' (level {}): {} markets, {} child nodes",
559                                node.name, processed_levels, node_markets, node_children
560                            );
561                        }
562
563                        // Deduplicate by epic across nodes to avoid storing the
564                        // same market many times.
565                        for market in node_response.markets {
566                            if seen_epics.insert(market.epic.clone()) {
567                                all_markets.push(market);
568                                level_market_count += 1;
569                            }
570                        }
571                        next_level_nodes.extend(node_response.nodes);
572                    }
573                    Err(e) => {
574                        tracing::error!(
575                            "Failed to get markets for node '{}' at level {}: {:?}",
576                            node.name,
577                            processed_levels,
578                            e
579                        );
580                    }
581                }
582            }
583
584            info!(
585                "Level {} completed: {} markets found, {} nodes for next level",
586                processed_levels,
587                level_market_count,
588                next_level_nodes.len()
589            );
590
591            nodes_to_process = next_level_nodes;
592            processed_levels += 1;
593        }
594
595        info!(
596            "Market hierarchy traversal completed: {} total markets found across {} levels",
597            all_markets.len(),
598            processed_levels
599        );
600
601        Ok(all_markets)
602    }
603
604    async fn get_vec_db_entries(&self) -> Result<Vec<DBEntryResponse>, AppError> {
605        info!("Getting all markets from hierarchy for DB entries");
606
607        let all_markets = self.get_all_markets().await?;
608        info!("Collected {} markets from hierarchy", all_markets.len());
609
610        let mut vec_db_entries: Vec<DBEntryResponse> = all_markets
611            .iter()
612            .map(DBEntryResponse::from)
613            .filter(|entry| !entry.epic.is_empty())
614            .collect();
615
616        info!("Created {} DB entries from markets", vec_db_entries.len());
617
618        // Build `symbol -> (representative epic, fallback expiry)` in ONE pass
619        // instead of re-scanning the full entries Vec per unique symbol
620        // (previously O(symbols x entries)). The first entry seen for a symbol
621        // supplies both the epic to query and the fallback expiry, matching the
622        // previous `find`-first behaviour.
623        let mut symbol_info: std::collections::HashMap<String, (String, String)> =
624            std::collections::HashMap::new();
625        for entry in &vec_db_entries {
626            if entry.symbol.is_empty() || entry.epic.is_empty() {
627                continue;
628            }
629            symbol_info
630                .entry(entry.symbol.clone())
631                .or_insert_with(|| (entry.epic.clone(), entry.expiry.clone()));
632        }
633
634        info!(
635            "Found {} unique symbols to fetch expiry dates for",
636            symbol_info.len()
637        );
638
639        // Fetch market details with bounded concurrency. The shared `RateLimiter`
640        // still paces the underlying requests; `buffer_unordered` just overlaps
641        // the network latency instead of issuing one request at a time.
642        let symbol_expiry_map: std::collections::HashMap<String, String> =
643            futures::stream::iter(symbol_info)
644                .map(|(symbol, (epic, fallback_expiry))| async move {
645                    match self.get_market_details(&epic).await {
646                        Ok(market_details) => {
647                            let expiry_date = market_details
648                                .instrument
649                                .expiry_details
650                                .as_ref()
651                                .map(|details| details.last_dealing_date.clone())
652                                .unwrap_or_else(|| market_details.instrument.expiry.clone());
653
654                            info!(
655                                symbol = %symbol,
656                                expiry = %expiry_date,
657                                "fetched expiry date for symbol"
658                            );
659                            (symbol, expiry_date)
660                        }
661                        Err(e) => {
662                            tracing::error!(
663                                "Failed to get market details for epic {} (symbol {}): {:?}",
664                                epic,
665                                symbol,
666                                e
667                            );
668                            (symbol, fallback_expiry)
669                        }
670                    }
671                })
672                .buffer_unordered(MARKET_DETAILS_CONCURRENCY)
673                .collect()
674                .await;
675
676        for entry in &mut vec_db_entries {
677            if let Some(expiry_date) = symbol_expiry_map.get(&entry.symbol) {
678                entry.expiry = expiry_date.clone();
679            }
680        }
681
682        info!("Updated expiry dates for {} entries", vec_db_entries.len());
683        Ok(vec_db_entries)
684    }
685
686    async fn get_categories(&self) -> Result<CategoriesResponse, AppError> {
687        info!("Getting all categories of instruments");
688        let result: CategoriesResponse = self.http_client.get("categories", Some(1)).await?;
689        debug!("{} categories found", result.categories.len());
690        Ok(result)
691    }
692
693    async fn get_category_instruments(
694        &self,
695        category_id: &str,
696        page_number: Option<u32>,
697        page_size: Option<u32>,
698    ) -> Result<CategoryInstrumentsResponse, AppError> {
699        let mut path = format!("categories/{}/instruments", category_id);
700
701        let mut query_params = Vec::new();
702        if let Some(page) = page_number {
703            query_params.push(format!("pageNumber={}", page));
704        }
705        if let Some(size) = page_size {
706            if size > 1000 {
707                return Err(AppError::InvalidInput(
708                    "pageSize cannot exceed 1000".to_string(),
709                ));
710            }
711            query_params.push(format!("pageSize={}", size));
712        }
713
714        if !query_params.is_empty() {
715            path = format!("{}?{}", path, query_params.join("&"));
716        }
717
718        info!(
719            "Getting instruments for category: {} (page: {:?}, size: {:?})",
720            category_id, page_number, page_size
721        );
722        let result: CategoryInstrumentsResponse = self.http_client.get(&path, Some(1)).await?;
723        debug!(
724            "{} instruments found in category {}",
725            result.instruments.len(),
726            category_id
727        );
728        Ok(result)
729    }
730}
731
732#[async_trait]
733impl AccountService for Client {
734    async fn get_accounts(&self) -> Result<AccountsResponse, AppError> {
735        info!("Getting account information");
736        let result: AccountsResponse = self.http_client.get("accounts", Some(1)).await?;
737        debug!(
738            "Account information obtained: {} accounts",
739            result.accounts.len()
740        );
741        Ok(result)
742    }
743
744    async fn get_positions(&self) -> Result<PositionsResponse, AppError> {
745        debug!("Getting open positions");
746        let result: PositionsResponse = self.http_client.get("positions", Some(2)).await?;
747        debug!("Positions obtained: {} positions", result.positions.len());
748        Ok(result)
749    }
750
751    async fn get_positions_w_filter(&self, filter: &str) -> Result<PositionsResponse, AppError> {
752        debug!("Getting open positions with filter: {}", filter);
753        let mut positions = self.get_positions().await?;
754
755        positions
756            .positions
757            .retain(|position| position.market.epic.contains(filter));
758
759        debug!(
760            "Positions obtained after filtering: {} positions",
761            positions.positions.len()
762        );
763        Ok(positions)
764    }
765
766    async fn get_working_orders(&self) -> Result<WorkingOrdersResponse, AppError> {
767        info!("Getting working orders");
768        let result: WorkingOrdersResponse = self.http_client.get("workingorders", Some(2)).await?;
769        debug!(
770            "Working orders obtained: {} orders",
771            result.working_orders.len()
772        );
773        Ok(result)
774    }
775
776    async fn get_activity(
777        &self,
778        from: &str,
779        to: &str,
780    ) -> Result<AccountActivityResponse, AppError> {
781        let path = format!("history/activity?from={}&to={}&pageSize=500", from, to);
782        info!("Getting account activity");
783        let result: AccountActivityResponse = self.http_client.get(&path, Some(3)).await?;
784        debug!(
785            "Account activity obtained: {} activities",
786            result.activities.len()
787        );
788        Ok(result)
789    }
790
791    async fn get_activity_with_details(
792        &self,
793        from: &str,
794        to: &str,
795    ) -> Result<AccountActivityResponse, AppError> {
796        let path = format!(
797            "history/activity?from={}&to={}&detailed=true&pageSize=500",
798            from, to
799        );
800        info!("Getting detailed account activity");
801        let result: AccountActivityResponse = self.http_client.get(&path, Some(3)).await?;
802        debug!(
803            "Detailed account activity obtained: {} activities",
804            result.activities.len()
805        );
806        Ok(result)
807    }
808
809    async fn get_transactions(
810        &self,
811        from: &str,
812        to: &str,
813    ) -> Result<TransactionHistoryResponse, AppError> {
814        const PAGE_SIZE: u32 = 200;
815        let mut all_transactions = Vec::new();
816        let mut current_page = 1;
817        #[allow(unused_assignments)]
818        let mut last_metadata = None;
819
820        loop {
821            let path = format!(
822                "history/transactions?from={}&to={}&pageSize={}&pageNumber={}",
823                from, to, PAGE_SIZE, current_page
824            );
825            info!("Getting transaction history page {}", current_page);
826
827            let result: TransactionHistoryResponse = self.http_client.get(&path, Some(2)).await?;
828
829            let total_pages = result.metadata.page_data.total_pages as u32;
830            last_metadata = Some(result.metadata);
831            all_transactions.extend(result.transactions);
832
833            if current_page >= total_pages {
834                break;
835            }
836            current_page += 1;
837        }
838
839        debug!(
840            "Total transaction history obtained: {} transactions",
841            all_transactions.len()
842        );
843
844        Ok(TransactionHistoryResponse {
845            transactions: all_transactions,
846            metadata: last_metadata
847                .ok_or_else(|| AppError::InvalidInput("Could not retrieve metadata".to_string()))?,
848        })
849    }
850
851    async fn get_preferences(&self) -> Result<AccountPreferencesResponse, AppError> {
852        info!("Getting account preferences");
853        let result: AccountPreferencesResponse = self
854            .http_client
855            .get("accounts/preferences", Some(1))
856            .await?;
857        debug!(
858            "Account preferences obtained: trailing_stops_enabled={}",
859            result.trailing_stops_enabled
860        );
861        Ok(result)
862    }
863
864    async fn update_preferences(&self, trailing_stops_enabled: bool) -> Result<(), AppError> {
865        info!(
866            "Updating account preferences: trailing_stops_enabled={}",
867            trailing_stops_enabled
868        );
869        let request = serde_json::json!({
870            "trailingStopsEnabled": trailing_stops_enabled
871        });
872        let _: serde_json::Value = self
873            .http_client
874            .put("accounts/preferences", &request, Some(1))
875            .await?;
876        debug!("Account preferences updated");
877        Ok(())
878    }
879
880    async fn get_activity_by_period(
881        &self,
882        period_ms: u64,
883    ) -> Result<AccountActivityResponse, AppError> {
884        let path = format!("history/activity/{}", period_ms);
885        info!("Getting account activity for period: {} ms", period_ms);
886        let result: AccountActivityResponse = self.http_client.get(&path, Some(1)).await?;
887        debug!(
888            "Account activity obtained: {} activities",
889            result.activities.len()
890        );
891        Ok(result)
892    }
893}
894
895#[async_trait]
896impl OrderService for Client {
897    async fn create_order(
898        &self,
899        order: &CreateOrderRequest,
900    ) -> Result<CreateOrderResponse, AppError> {
901        info!("Creating order for: {}", order.epic);
902        let result: CreateOrderResponse = self
903            .http_client
904            .post("positions/otc", order, Some(2))
905            .await?;
906        debug!("Order created with reference: {}", result.deal_reference);
907        Ok(result)
908    }
909
910    async fn get_order_confirmation(
911        &self,
912        deal_reference: &str,
913    ) -> Result<OrderConfirmationResponse, AppError> {
914        let path = format!("confirms/{}", deal_reference);
915        info!("Getting confirmation for order: {}", deal_reference);
916        let result: OrderConfirmationResponse = self.http_client.get(&path, Some(1)).await?;
917        debug!("Confirmation obtained for order: {}", deal_reference);
918        Ok(result)
919    }
920
921    async fn get_order_confirmation_w_retry(
922        &self,
923        deal_reference: &str,
924        retries: u64,
925        delay_ms: u64,
926    ) -> Result<OrderConfirmationResponse, AppError> {
927        // `delay_ms` is the backoff base; the actual per-attempt wait grows
928        // exponentially (with jitter) via the shared `RetryConfig` policy.
929        let base = Duration::from_millis(delay_ms);
930        let mut attempt: u32 = 0;
931        loop {
932            match self.get_order_confirmation(deal_reference).await {
933                Ok(response) => return Ok(response),
934                Err(e) => {
935                    // Only poll again on transient errors; permanent failures
936                    // (auth, invalid input, deserialization) return immediately.
937                    if !is_transient_confirmation_error(&e) {
938                        return Err(e);
939                    }
940                    if u64::from(attempt) >= retries {
941                        return Err(e);
942                    }
943                    let delay = backoff_delay(base, attempt);
944                    let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
945                    let next_attempt = attempt.checked_add(1).ok_or_else(|| {
946                        AppError::Generic("retry attempt counter overflow".to_string())
947                    })?;
948                    warn!(
949                        deal_reference = %deal_reference,
950                        attempt = next_attempt,
951                        max_retries = retries,
952                        delay_ms,
953                        "retrying order confirmation after transient error"
954                    );
955                    sleep(delay).await;
956                    attempt = next_attempt;
957                }
958            }
959        }
960    }
961
962    async fn update_position(
963        &self,
964        deal_id: &str,
965        update: &UpdatePositionRequest,
966    ) -> Result<UpdatePositionResponse, AppError> {
967        let path = format!("positions/otc/{}", deal_id);
968        info!("Updating position: {}", deal_id);
969        let result: UpdatePositionResponse = self.http_client.put(&path, update, Some(2)).await?;
970        debug!(
971            "Position updated: {} with deal reference: {}",
972            deal_id, result.deal_reference
973        );
974        Ok(result)
975    }
976
977    async fn update_level_in_position(
978        &self,
979        deal_id: &str,
980        limit_level: Option<f64>,
981    ) -> Result<UpdatePositionResponse, AppError> {
982        let path = format!("positions/otc/{}", deal_id);
983        info!("Updating position: {}", deal_id);
984        let limit_level = limit_level.unwrap_or(0.0);
985
986        let update: UpdatePositionRequest = UpdatePositionRequest {
987            guaranteed_stop: None,
988            limit_level: Some(limit_level),
989            stop_level: None,
990            trailing_stop: None,
991            trailing_stop_distance: None,
992            trailing_stop_increment: None,
993        };
994        let result: UpdatePositionResponse = self.http_client.put(&path, update, Some(2)).await?;
995        debug!(
996            "Position updated: {} with deal reference: {}",
997            deal_id, result.deal_reference
998        );
999        Ok(result)
1000    }
1001
1002    async fn close_position(
1003        &self,
1004        close_request: &ClosePositionRequest,
1005    ) -> Result<ClosePositionResponse, AppError> {
1006        info!("Closing position");
1007
1008        // IG API requires POST with _method: DELETE header for closing positions
1009        // This is a workaround for HTTP client limitations with DELETE + body
1010        let result: ClosePositionResponse = self
1011            .http_client
1012            .post_with_delete_method("positions/otc", close_request, Some(1))
1013            .await?;
1014
1015        debug!("Position closed with reference: {}", result.deal_reference);
1016        Ok(result)
1017    }
1018
1019    async fn create_working_order(
1020        &self,
1021        order: &CreateWorkingOrderRequest,
1022    ) -> Result<CreateWorkingOrderResponse, AppError> {
1023        info!("Creating working order for: {}", order.epic);
1024        let result: CreateWorkingOrderResponse = self
1025            .http_client
1026            .post("workingorders/otc", order, Some(2))
1027            .await?;
1028        debug!(
1029            "Working order created with reference: {}",
1030            result.deal_reference
1031        );
1032        Ok(result)
1033    }
1034
1035    async fn delete_working_order(&self, deal_id: &str) -> Result<(), AppError> {
1036        let path = format!("workingorders/otc/{}", deal_id);
1037        let result: CreateWorkingOrderResponse =
1038            self.http_client.delete(path.as_str(), Some(2)).await?;
1039        debug!(
1040            "Working order created with reference: {}",
1041            result.deal_reference
1042        );
1043        Ok(())
1044    }
1045
1046    async fn get_position(&self, deal_id: &str) -> Result<SinglePositionResponse, AppError> {
1047        let path = format!("positions/{}", deal_id);
1048        info!("Getting position: {}", deal_id);
1049        let result: SinglePositionResponse = self.http_client.get(&path, Some(2)).await?;
1050        debug!("Position obtained for deal: {}", deal_id);
1051        Ok(result)
1052    }
1053
1054    async fn update_working_order(
1055        &self,
1056        deal_id: &str,
1057        update: &UpdateWorkingOrderRequest,
1058    ) -> Result<CreateWorkingOrderResponse, AppError> {
1059        let path = format!("workingorders/otc/{}", deal_id);
1060        info!("Updating working order: {}", deal_id);
1061        let result: CreateWorkingOrderResponse =
1062            self.http_client.put(&path, update, Some(2)).await?;
1063        debug!(
1064            "Working order updated: {} with reference: {}",
1065            deal_id, result.deal_reference
1066        );
1067        Ok(result)
1068    }
1069}
1070
1071// ============================================================================
1072// WATCHLIST SERVICE IMPLEMENTATION
1073// ============================================================================
1074
1075#[async_trait]
1076impl WatchlistService for Client {
1077    async fn get_watchlists(&self) -> Result<WatchlistsResponse, AppError> {
1078        info!("Getting all watchlists");
1079        let result: WatchlistsResponse = self.http_client.get("watchlists", Some(1)).await?;
1080        debug!(
1081            "Watchlists obtained: {} watchlists",
1082            result.watchlists.len()
1083        );
1084        Ok(result)
1085    }
1086
1087    async fn create_watchlist(
1088        &self,
1089        name: &str,
1090        epics: Option<&[String]>,
1091    ) -> Result<CreateWatchlistResponse, AppError> {
1092        info!("Creating watchlist: {}", name);
1093        let request = CreateWatchlistRequest {
1094            name: name.to_string(),
1095            epics: epics.map(|e| e.to_vec()),
1096        };
1097        let result: CreateWatchlistResponse = self
1098            .http_client
1099            .post("watchlists", &request, Some(1))
1100            .await?;
1101        debug!(
1102            "Watchlist created: {} with ID: {}",
1103            name, result.watchlist_id
1104        );
1105        Ok(result)
1106    }
1107
1108    async fn get_watchlist(
1109        &self,
1110        watchlist_id: &str,
1111    ) -> Result<WatchlistMarketsResponse, AppError> {
1112        let path = format!("watchlists/{}", watchlist_id);
1113        info!("Getting watchlist: {}", watchlist_id);
1114        let result: WatchlistMarketsResponse = self.http_client.get(&path, Some(1)).await?;
1115        debug!(
1116            "Watchlist obtained: {} with {} markets",
1117            watchlist_id,
1118            result.markets.len()
1119        );
1120        Ok(result)
1121    }
1122
1123    async fn delete_watchlist(&self, watchlist_id: &str) -> Result<StatusResponse, AppError> {
1124        let path = format!("watchlists/{}", watchlist_id);
1125        info!("Deleting watchlist: {}", watchlist_id);
1126        let result: StatusResponse = self.http_client.delete(&path, Some(1)).await?;
1127        debug!("Watchlist deleted: {}", watchlist_id);
1128        Ok(result)
1129    }
1130
1131    async fn add_to_watchlist(
1132        &self,
1133        watchlist_id: &str,
1134        epic: &str,
1135    ) -> Result<StatusResponse, AppError> {
1136        let path = format!("watchlists/{}", watchlist_id);
1137        info!("Adding {} to watchlist: {}", epic, watchlist_id);
1138        let request = AddToWatchlistRequest {
1139            epic: epic.to_string(),
1140        };
1141        let result: StatusResponse = self.http_client.put(&path, &request, Some(1)).await?;
1142        debug!("Added {} to watchlist: {}", epic, watchlist_id);
1143        Ok(result)
1144    }
1145
1146    async fn remove_from_watchlist(
1147        &self,
1148        watchlist_id: &str,
1149        epic: &str,
1150    ) -> Result<StatusResponse, AppError> {
1151        let path = format!("watchlists/{}/{}", watchlist_id, epic);
1152        info!("Removing {} from watchlist: {}", epic, watchlist_id);
1153        let result: StatusResponse = self.http_client.delete(&path, Some(1)).await?;
1154        debug!("Removed {} from watchlist: {}", epic, watchlist_id);
1155        Ok(result)
1156    }
1157}
1158
1159// ============================================================================
1160// SENTIMENT SERVICE IMPLEMENTATION
1161// ============================================================================
1162
1163#[async_trait]
1164impl SentimentService for Client {
1165    async fn get_client_sentiment(
1166        &self,
1167        market_ids: &[String],
1168    ) -> Result<ClientSentimentResponse, AppError> {
1169        let market_ids_str = market_ids.join(",");
1170        let path = format!("clientsentiment?marketIds={}", market_ids_str);
1171        info!("Getting client sentiment for {} markets", market_ids.len());
1172        let result: ClientSentimentResponse = self.http_client.get(&path, Some(1)).await?;
1173        debug!(
1174            "Client sentiment obtained for {} markets",
1175            result.client_sentiments.len()
1176        );
1177        Ok(result)
1178    }
1179
1180    async fn get_client_sentiment_by_market(
1181        &self,
1182        market_id: &str,
1183    ) -> Result<MarketSentiment, AppError> {
1184        let path = format!("clientsentiment/{}", market_id);
1185        info!("Getting client sentiment for market: {}", market_id);
1186        let result: MarketSentiment = self.http_client.get(&path, Some(1)).await?;
1187        debug!(
1188            "Client sentiment for {}: {}% long, {}% short",
1189            market_id, result.long_position_percentage, result.short_position_percentage
1190        );
1191        Ok(result)
1192    }
1193
1194    async fn get_related_sentiment(
1195        &self,
1196        market_id: &str,
1197    ) -> Result<ClientSentimentResponse, AppError> {
1198        let path = format!("clientsentiment/related/{}", market_id);
1199        info!("Getting related sentiment for market: {}", market_id);
1200        let result: ClientSentimentResponse = self.http_client.get(&path, Some(1)).await?;
1201        debug!(
1202            "Related sentiment obtained: {} markets",
1203            result.client_sentiments.len()
1204        );
1205        Ok(result)
1206    }
1207}
1208
1209// ============================================================================
1210// COSTS SERVICE IMPLEMENTATION
1211// ============================================================================
1212
1213#[async_trait]
1214impl CostsService for Client {
1215    async fn get_indicative_costs_open(
1216        &self,
1217        request: &OpenCostsRequest,
1218    ) -> Result<IndicativeCostsResponse, AppError> {
1219        info!(
1220            "Getting indicative costs for opening position on: {}",
1221            request.epic
1222        );
1223        let result: IndicativeCostsResponse = self
1224            .http_client
1225            .post("indicativecostsandcharges/open", request, Some(1))
1226            .await?;
1227        debug!(
1228            "Indicative costs obtained, reference: {}",
1229            result.indicative_quote_reference
1230        );
1231        Ok(result)
1232    }
1233
1234    async fn get_indicative_costs_close(
1235        &self,
1236        request: &CloseCostsRequest,
1237    ) -> Result<IndicativeCostsResponse, AppError> {
1238        info!(
1239            "Getting indicative costs for closing position: {}",
1240            request.deal_id
1241        );
1242        let result: IndicativeCostsResponse = self
1243            .http_client
1244            .post("indicativecostsandcharges/close", request, Some(1))
1245            .await?;
1246        debug!(
1247            "Indicative costs obtained, reference: {}",
1248            result.indicative_quote_reference
1249        );
1250        Ok(result)
1251    }
1252
1253    async fn get_indicative_costs_edit(
1254        &self,
1255        request: &EditCostsRequest,
1256    ) -> Result<IndicativeCostsResponse, AppError> {
1257        info!(
1258            "Getting indicative costs for editing position: {}",
1259            request.deal_id
1260        );
1261        let result: IndicativeCostsResponse = self
1262            .http_client
1263            .post("indicativecostsandcharges/edit", request, Some(1))
1264            .await?;
1265        debug!(
1266            "Indicative costs obtained, reference: {}",
1267            result.indicative_quote_reference
1268        );
1269        Ok(result)
1270    }
1271
1272    async fn get_costs_history(
1273        &self,
1274        from: &str,
1275        to: &str,
1276    ) -> Result<CostsHistoryResponse, AppError> {
1277        let path = format!("indicativecostsandcharges/history/from/{}/to/{}", from, to);
1278        info!("Getting costs history from {} to {}", from, to);
1279        let result: CostsHistoryResponse = self.http_client.get(&path, Some(1)).await?;
1280        debug!("Costs history obtained: {} entries", result.costs.len());
1281        Ok(result)
1282    }
1283
1284    async fn get_durable_medium(
1285        &self,
1286        quote_reference: &str,
1287    ) -> Result<DurableMediumResponse, AppError> {
1288        let path = format!(
1289            "indicativecostsandcharges/durablemedium/{}",
1290            quote_reference
1291        );
1292        info!("Getting durable medium for reference: {}", quote_reference);
1293        let result: DurableMediumResponse = self.http_client.get(&path, Some(1)).await?;
1294        debug!("Durable medium obtained for reference: {}", quote_reference);
1295        Ok(result)
1296    }
1297}
1298
1299// ============================================================================
1300// OPERATIONS SERVICE IMPLEMENTATION
1301// ============================================================================
1302
1303#[async_trait]
1304impl OperationsService for Client {
1305    async fn get_client_apps(&self) -> Result<ApplicationDetailsResponse, AppError> {
1306        info!("Getting client applications");
1307        let result: ApplicationDetailsResponse = self
1308            .http_client
1309            .get("operations/application", Some(1))
1310            .await?;
1311        // Never log `api_key`: it is a live credential.
1312        debug!(
1313            name = ?result.name,
1314            status = %result.status,
1315            "Client application obtained"
1316        );
1317        Ok(result)
1318    }
1319
1320    async fn disable_client_app(&self) -> Result<StatusResponse, AppError> {
1321        info!("Disabling current client application");
1322        let result: StatusResponse = self
1323            .http_client
1324            .put(
1325                "operations/application/disable",
1326                &serde_json::json!({}),
1327                Some(1),
1328            )
1329            .await?;
1330        debug!("Client application disabled");
1331        Ok(result)
1332    }
1333}
1334
1335/// Streaming client for IG Markets real-time data.
1336///
1337/// This client manages two Lightstreamer connections for different data types:
1338/// - **Market streamer**: Handles market data (prices, market state), trade updates (CONFIRMS, OPU, WOU),
1339///   and account updates (positions, orders, balance). Uses the default adapter.
1340/// - **Price streamer**: Handles detailed price data (bid/ask levels, sizes, multiple currencies).
1341///   Uses the "Pricing" adapter.
1342///
1343/// Each connection type can be managed independently and runs in parallel.
1344#[cfg(feature = "streaming")]
1345#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
1346pub struct StreamerClient {
1347    account_id: String,
1348    market_streamer_client: Option<Arc<Mutex<LightstreamerClient>>>,
1349    price_streamer_client: Option<Arc<Mutex<LightstreamerClient>>>,
1350    // Flags indicating whether there is at least one active subscription for each client
1351    has_market_stream_subs: bool,
1352    has_price_stream_subs: bool,
1353    // Handles for the per-subscription `ItemUpdate` -> DTO converter tasks. Each
1354    // `*_subscribe` call spawns one; `disconnect` aborts and drains them so they
1355    // do not idle for the process lifetime. This field is private and only ever
1356    // populated inside this type's methods, so adding it does not change the
1357    // constructed-via-`new` public surface.
1358    converter_tasks: Vec<JoinHandle<()>>,
1359}
1360
1361#[cfg(feature = "streaming")]
1362impl StreamerClient {
1363    /// Creates a new streaming client instance with its own REST session.
1364    ///
1365    /// This builds a fresh [`Client`], logs in to obtain the Lightstreamer
1366    /// connection details, and initializes both streaming clients (market and
1367    /// price). No connection is established yet — that happens on `connect()`.
1368    ///
1369    /// When the caller already holds a [`Client`] with an active REST session,
1370    /// prefer [`with_client`](Self::with_client) to reuse that session instead
1371    /// of performing a second login.
1372    ///
1373    /// # Errors
1374    ///
1375    /// Returns [`AppError`] if the login / session lookup or Lightstreamer
1376    /// client initialization fails.
1377    pub async fn new() -> Result<Self, AppError> {
1378        let client = Client::try_new()?;
1379        Self::with_client(&client).await
1380    }
1381
1382    /// Creates a new streaming client that reuses the caller's existing REST
1383    /// session.
1384    ///
1385    /// Unlike [`new`](Self::new), this does not build a second HTTP client or
1386    /// perform a second login: it reuses `client`'s cached session (via
1387    /// [`Client::ws_info`]) to obtain the Lightstreamer endpoint and
1388    /// credentials. Both streaming clients (market and price) are initialized
1389    /// but no connection is established until `connect()` is called.
1390    ///
1391    /// # Errors
1392    ///
1393    /// Returns [`AppError`] if the session lookup or Lightstreamer client
1394    /// initialization fails.
1395    pub async fn with_client(client: &Client) -> Result<Self, AppError> {
1396        let ws_info = client.ws_info().await?;
1397        let password = ws_info.get_ws_password();
1398
1399        // Market data client (no adapter specified - uses default)
1400        let market_streamer_client = Arc::new(Mutex::new(LightstreamerClient::new(
1401            Some(ws_info.server.as_str()),
1402            None,
1403            Some(&ws_info.account_id),
1404            Some(&password),
1405        )?));
1406
1407        let price_streamer_client = Arc::new(Mutex::new(LightstreamerClient::new(
1408            Some(ws_info.server.as_str()),
1409            None,
1410            Some(&ws_info.account_id),
1411            Some(&password),
1412        )?));
1413
1414        // Force WebSocket streaming transport on both clients to satisfy IG requirements
1415        // and configure logging to use tracing levels for proper log propagation
1416        {
1417            let mut streamer = market_streamer_client.lock().await;
1418            streamer
1419                .connection_options
1420                .set_forced_transport(Some(Transport::WsStreaming));
1421            streamer.set_logging_type(LogType::TracingLogs);
1422        }
1423        {
1424            let mut streamer = price_streamer_client.lock().await;
1425            streamer
1426                .connection_options
1427                .set_forced_transport(Some(Transport::WsStreaming));
1428            streamer.set_logging_type(LogType::TracingLogs);
1429        }
1430
1431        Ok(Self {
1432            account_id: ws_info.account_id.clone(),
1433            market_streamer_client: Some(market_streamer_client),
1434            price_streamer_client: Some(price_streamer_client),
1435            has_market_stream_subs: false,
1436            has_price_stream_subs: false,
1437            converter_tasks: Vec::new(),
1438        })
1439    }
1440
1441    /// Subscribes to market data updates for the specified instruments.
1442    ///
1443    /// This method creates a subscription to receive real-time market data updates
1444    /// for the given EPICs and returns a channel receiver for consuming the updates.
1445    ///
1446    /// # Arguments
1447    ///
1448    /// * `epics` - List of instrument EPICs to subscribe to
1449    /// * `fields` - Set of market data fields to receive (e.g., BID, OFFER, etc.)
1450    ///
1451    /// # Returns
1452    ///
1453    /// Returns a receiver channel for `PriceData` updates, or an error if
1454    /// the subscription setup failed.
1455    ///
1456    /// # Examples
1457    ///
1458    /// ```ignore
1459    /// let mut receiver = client.market_subscribe(
1460    ///     vec!["IX.D.DAX.DAILY.IP".to_string()],
1461    ///     fields
1462    /// ).await?;
1463    ///
1464    /// tokio::spawn(async move {
1465    ///     while let Some(price_data) = receiver.recv().await {
1466    ///         println!("Price update: {:?}", price_data);
1467    ///     }
1468    /// });
1469    /// ```
1470    pub async fn market_subscribe(
1471        &mut self,
1472        epics: Vec<String>,
1473        fields: HashSet<StreamingMarketField>,
1474    ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1475        // Mark that we have at least one subscription on the market streamer
1476        self.has_market_stream_subs = true;
1477
1478        let fields = get_streaming_market_fields(&fields);
1479        let market_epics: Vec<String> = epics
1480            .iter()
1481            .map(|epic| "MARKET:".to_string() + epic)
1482            .collect();
1483        let mut subscription =
1484            Subscription::new(SubscriptionMode::Merge, Some(market_epics), Some(fields))?;
1485
1486        subscription.set_data_adapter(None)?;
1487        subscription.set_requested_snapshot(Some(Snapshot::Yes))?;
1488
1489        // Create channel listener that converts ItemUpdate to PriceData
1490        let (listener, item_receiver) = ChannelSubscriptionListener::create_channel();
1491        subscription.add_listener(Box::new(listener));
1492
1493        // Configure client and add subscription
1494        let client = self.market_streamer_client.as_ref().ok_or_else(|| {
1495            AppError::WebSocketError("market streamer client not initialized".to_string())
1496        })?;
1497
1498        {
1499            let mut client = client.lock().await;
1500            client
1501                .connection_options
1502                .set_forced_transport(Some(Transport::WsStreaming));
1503            LightstreamerClient::subscribe(client.subscription_sender.clone(), subscription)
1504                .await?;
1505        }
1506
1507        // Create a channel for PriceData and spawn a task to convert ItemUpdate to PriceData
1508        let (price_tx, price_rx) = mpsc::unbounded_channel();
1509        let handle = tokio::spawn(async move {
1510            let mut receiver = item_receiver;
1511            while let Some(item_update) = receiver.recv().await {
1512                let price_data = PriceData::from(&item_update);
1513                if price_tx.send(price_data).is_err() {
1514                    tracing::debug!("Price channel receiver dropped");
1515                    break;
1516                }
1517            }
1518        });
1519        // Track the converter task so `disconnect` can tear it down.
1520        self.converter_tasks.push(handle);
1521
1522        info!(
1523            "Market subscription created for {} instruments",
1524            epics.len()
1525        );
1526        Ok(price_rx)
1527    }
1528
1529    /// Subscribes to trade updates for the account.
1530    ///
1531    /// This method creates a subscription to receive real-time trade confirmations,
1532    /// order updates (OPU), and working order updates (WOU) for the account,
1533    /// and returns a channel receiver for consuming the updates.
1534    ///
1535    /// # Returns
1536    ///
1537    /// Returns a receiver channel for `TradeFields` updates, or an error if
1538    /// the subscription setup failed.
1539    ///
1540    /// # Examples
1541    ///
1542    /// ```ignore
1543    /// let mut receiver = client.trade_subscribe().await?;
1544    ///
1545    /// tokio::spawn(async move {
1546    ///     while let Some(trade_fields) = receiver.recv().await {
1547    ///         println!("Trade update: {:?}", trade_fields);
1548    ///     }
1549    /// });
1550    /// ```
1551    pub async fn trade_subscribe(
1552        &mut self,
1553    ) -> Result<mpsc::UnboundedReceiver<TradeFields>, AppError> {
1554        // Mark that we have at least one subscription on the market streamer
1555        self.has_market_stream_subs = true;
1556
1557        let account_id = self.account_id.clone();
1558        let fields = Some(vec![
1559            "CONFIRMS".to_string(),
1560            "OPU".to_string(),
1561            "WOU".to_string(),
1562        ]);
1563        let trade_items = vec![format!("TRADE:{account_id}")];
1564
1565        let mut subscription =
1566            Subscription::new(SubscriptionMode::Distinct, Some(trade_items), fields)?;
1567
1568        subscription.set_data_adapter(None)?;
1569        subscription.set_requested_snapshot(Some(Snapshot::Yes))?;
1570
1571        // Create channel listener
1572        let (listener, item_receiver) = ChannelSubscriptionListener::create_channel();
1573        subscription.add_listener(Box::new(listener));
1574
1575        // Configure client and add subscription (reusing market_streamer_client)
1576        let client = self.market_streamer_client.as_ref().ok_or_else(|| {
1577            AppError::WebSocketError("market streamer client not initialized".to_string())
1578        })?;
1579
1580        {
1581            let mut client = client.lock().await;
1582            client
1583                .connection_options
1584                .set_forced_transport(Some(Transport::WsStreaming));
1585            LightstreamerClient::subscribe(client.subscription_sender.clone(), subscription)
1586                .await?;
1587        }
1588
1589        // Create a channel for TradeFields and spawn a task to convert ItemUpdate to TradeFields
1590        let (trade_tx, trade_rx) = mpsc::unbounded_channel();
1591        let handle = tokio::spawn(async move {
1592            let mut receiver = item_receiver;
1593            while let Some(item_update) = receiver.recv().await {
1594                let trade_data = crate::presentation::trade::TradeData::from(&item_update);
1595                if trade_tx.send(trade_data.fields).is_err() {
1596                    tracing::debug!("Trade channel receiver dropped");
1597                    break;
1598                }
1599            }
1600        });
1601        // Track the converter task so `disconnect` can tear it down.
1602        self.converter_tasks.push(handle);
1603
1604        info!("Trade subscription created for account: {}", account_id);
1605        Ok(trade_rx)
1606    }
1607
1608    /// Subscribes to account data updates.
1609    ///
1610    /// This method creates a subscription to receive real-time account updates including
1611    /// profit/loss, margin, equity, available funds, and other account metrics,
1612    /// and returns a channel receiver for consuming the updates.
1613    ///
1614    /// # Arguments
1615    ///
1616    /// * `fields` - Set of account data fields to receive (e.g., PNL, MARGIN, EQUITY, etc.)
1617    ///
1618    /// # Returns
1619    ///
1620    /// Returns a receiver channel for `AccountFields` updates, or an error if
1621    /// the subscription setup failed.
1622    ///
1623    /// # Examples
1624    ///
1625    /// ```ignore
1626    /// let mut receiver = client.account_subscribe(fields).await?;
1627    ///
1628    /// tokio::spawn(async move {
1629    ///     while let Some(account_fields) = receiver.recv().await {
1630    ///         println!("Account update: {:?}", account_fields);
1631    ///     }
1632    /// });
1633    /// ```
1634    pub async fn account_subscribe(
1635        &mut self,
1636        fields: HashSet<StreamingAccountDataField>,
1637    ) -> Result<mpsc::UnboundedReceiver<AccountFields>, AppError> {
1638        // Mark that we have at least one subscription on the market streamer
1639        self.has_market_stream_subs = true;
1640
1641        let fields = get_streaming_account_data_fields(&fields);
1642        let account_id = self.account_id.clone();
1643        let account_items = vec![format!("ACCOUNT:{account_id}")];
1644
1645        let mut subscription =
1646            Subscription::new(SubscriptionMode::Merge, Some(account_items), Some(fields))?;
1647
1648        subscription.set_data_adapter(None)?;
1649        subscription.set_requested_snapshot(Some(Snapshot::Yes))?;
1650
1651        // Create channel listener
1652        let (listener, item_receiver) = ChannelSubscriptionListener::create_channel();
1653        subscription.add_listener(Box::new(listener));
1654
1655        // Configure client and add subscription (reusing market_streamer_client)
1656        let client = self.market_streamer_client.as_ref().ok_or_else(|| {
1657            AppError::WebSocketError("market streamer client not initialized".to_string())
1658        })?;
1659
1660        {
1661            let mut client = client.lock().await;
1662            client
1663                .connection_options
1664                .set_forced_transport(Some(Transport::WsStreaming));
1665            LightstreamerClient::subscribe(client.subscription_sender.clone(), subscription)
1666                .await?;
1667        }
1668
1669        // Create a channel for AccountFields and spawn a task to convert ItemUpdate to AccountFields
1670        let (account_tx, account_rx) = mpsc::unbounded_channel();
1671        let handle = tokio::spawn(async move {
1672            let mut receiver = item_receiver;
1673            while let Some(item_update) = receiver.recv().await {
1674                let account_data = crate::presentation::account::AccountData::from(&item_update);
1675                if account_tx.send(account_data.fields).is_err() {
1676                    tracing::debug!("Account channel receiver dropped");
1677                    break;
1678                }
1679            }
1680        });
1681        // Track the converter task so `disconnect` can tear it down.
1682        self.converter_tasks.push(handle);
1683
1684        info!("Account subscription created for account: {}", account_id);
1685        Ok(account_rx)
1686    }
1687
1688    /// Subscribes to price data updates for the specified instruments.
1689    ///
1690    /// This method creates a subscription to receive real-time price updates including
1691    /// bid/ask prices, sizes, and multiple currency levels for the given EPICs,
1692    /// and returns a channel receiver for consuming the updates.
1693    ///
1694    /// # Arguments
1695    ///
1696    /// * `epics` - List of instrument EPICs to subscribe to
1697    /// * `fields` - Set of price data fields to receive (e.g., BID_PRICE1, ASK_PRICE1, etc.)
1698    ///
1699    /// # Returns
1700    ///
1701    /// Returns a receiver channel for `PriceData` updates, or an error if
1702    /// the subscription setup failed.
1703    ///
1704    /// # Examples
1705    ///
1706    /// ```ignore
1707    /// let mut receiver = client.price_subscribe(
1708    ///     vec!["IX.D.DAX.DAILY.IP".to_string()],
1709    ///     fields
1710    /// ).await?;
1711    ///
1712    /// tokio::spawn(async move {
1713    ///     while let Some(price_data) = receiver.recv().await {
1714    ///         println!("Price update: {:?}", price_data);
1715    ///     }
1716    /// });
1717    /// ```
1718    pub async fn price_subscribe(
1719        &mut self,
1720        epics: Vec<String>,
1721        fields: HashSet<StreamingPriceField>,
1722    ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1723        // Mark that we have at least one subscription on the price streamer
1724        self.has_price_stream_subs = true;
1725
1726        let fields = get_streaming_price_fields(&fields);
1727        let account_id = self.account_id.clone();
1728        let price_epics: Vec<String> = epics
1729            .iter()
1730            .map(|epic| format!("PRICE:{account_id}:{epic}"))
1731            .collect();
1732
1733        // Debug what we are about to subscribe to (items and fields)
1734        tracing::debug!("Pricing subscribe items: {:?}", price_epics);
1735        tracing::debug!("Pricing subscribe fields: {:?}", fields);
1736
1737        let mut subscription =
1738            Subscription::new(SubscriptionMode::Merge, Some(price_epics), Some(fields))?;
1739
1740        // Allow overriding the Pricing adapter name via env var to match server config
1741        let pricing_adapter =
1742            std::env::var("IG_PRICING_ADAPTER").unwrap_or_else(|_| "Pricing".to_string());
1743        tracing::debug!("Using Pricing data adapter: {}", pricing_adapter);
1744        subscription.set_data_adapter(Some(pricing_adapter))?;
1745        subscription.set_requested_snapshot(Some(Snapshot::Yes))?;
1746
1747        // Create channel listener
1748        let (listener, item_receiver) = ChannelSubscriptionListener::create_channel();
1749        subscription.add_listener(Box::new(listener));
1750
1751        // Configure client and add subscription
1752        let client = self.price_streamer_client.as_ref().ok_or_else(|| {
1753            AppError::WebSocketError("price streamer client not initialized".to_string())
1754        })?;
1755
1756        {
1757            let mut client = client.lock().await;
1758            client
1759                .connection_options
1760                .set_forced_transport(Some(Transport::WsStreaming));
1761            LightstreamerClient::subscribe(client.subscription_sender.clone(), subscription)
1762                .await?;
1763        }
1764
1765        // Create a channel for PriceData and spawn a task to convert ItemUpdate to PriceData
1766        let (price_tx, price_rx) = mpsc::unbounded_channel();
1767        let handle = tokio::spawn(async move {
1768            let mut receiver = item_receiver;
1769            while let Some(item_update) = receiver.recv().await {
1770                let price_data = PriceData::from(&item_update);
1771                if price_tx.send(price_data).is_err() {
1772                    tracing::debug!("Price channel receiver dropped");
1773                    break;
1774                }
1775            }
1776        });
1777        // Track the converter task so `disconnect` can tear it down.
1778        self.converter_tasks.push(handle);
1779
1780        info!(
1781            "Price subscription created for {} instruments (account: {})",
1782            epics.len(),
1783            account_id
1784        );
1785        Ok(price_rx)
1786    }
1787
1788    /// Subscribes to chart data updates for the specified instruments and scale.
1789    ///
1790    /// This method creates a subscription to receive real-time chart updates including
1791    /// OHLC data, volume, and other chart metrics for the given EPICs and chart scale,
1792    /// and returns a channel receiver for consuming the updates.
1793    ///
1794    /// # Arguments
1795    ///
1796    /// * `epics` - List of instrument EPICs to subscribe to.
1797    /// * `scale` - Chart scale (e.g., Tick, 1Min, 5Min, etc.).
1798    /// * `fields` - Set of chart data fields to receive (e.g., OPEN, HIGH, LOW, CLOSE, VOLUME).
1799    ///
1800    /// # Returns
1801    ///
1802    /// Returns a receiver channel for `ChartData` updates, or an error if
1803    /// the subscription setup failed.
1804    ///
1805    /// # Examples
1806    ///
1807    /// ```ignore
1808    /// let mut receiver = client.chart_subscribe(
1809    ///     vec!["IX.D.DAX.DAILY.IP".to_string()],
1810    ///     ChartScale::OneMin,
1811    ///     fields
1812    /// ).await?;
1813    ///
1814    /// tokio::spawn(async move {
1815    ///     while let Some(chart_data) = receiver.recv().await {
1816    ///         println!("Chart update: {:?}", chart_data);
1817    ///     }
1818    /// });
1819    /// ```
1820    pub async fn chart_subscribe(
1821        &mut self,
1822        epics: Vec<String>,
1823        scale: ChartScale,
1824        fields: HashSet<StreamingChartField>,
1825    ) -> Result<mpsc::UnboundedReceiver<ChartData>, AppError> {
1826        // Mark that we have at least one subscription on the market streamer
1827        self.has_market_stream_subs = true;
1828
1829        let fields = get_streaming_chart_fields(&fields);
1830
1831        let chart_items: Vec<String> = epics
1832            .iter()
1833            .map(|epic| format!("CHART:{epic}:{scale}",))
1834            .collect();
1835
1836        // Candle data uses MERGE mode, tick data uses DISTINCT
1837        let mode = if matches!(scale, ChartScale::Tick) {
1838            SubscriptionMode::Distinct
1839        } else {
1840            SubscriptionMode::Merge
1841        };
1842
1843        let mut subscription = Subscription::new(mode, Some(chart_items), Some(fields))?;
1844
1845        subscription.set_data_adapter(None)?;
1846        subscription.set_requested_snapshot(Some(Snapshot::Yes))?;
1847
1848        // Create channel listener
1849        let (listener, item_receiver) = ChannelSubscriptionListener::create_channel();
1850        subscription.add_listener(Box::new(listener));
1851
1852        // Configure client and add subscription (reusing market_streamer_client)
1853        let client = self.market_streamer_client.as_ref().ok_or_else(|| {
1854            AppError::WebSocketError("market streamer client not initialized".to_string())
1855        })?;
1856
1857        {
1858            let mut client = client.lock().await;
1859            client
1860                .connection_options
1861                .set_forced_transport(Some(Transport::WsStreaming));
1862            LightstreamerClient::subscribe(client.subscription_sender.clone(), subscription)
1863                .await?;
1864        }
1865
1866        // Create a channel for ChartData and spawn a task to convert ItemUpdate to ChartData
1867        let (chart_tx, chart_rx) = mpsc::unbounded_channel();
1868        let handle = tokio::spawn(async move {
1869            let mut receiver = item_receiver;
1870            while let Some(item_update) = receiver.recv().await {
1871                let chart_data = ChartData::from(&item_update);
1872                if chart_tx.send(chart_data).is_err() {
1873                    tracing::debug!("Chart channel receiver dropped");
1874                    break;
1875                }
1876            }
1877        });
1878        // Track the converter task so `disconnect` can tear it down.
1879        self.converter_tasks.push(handle);
1880
1881        info!(
1882            "Chart subscription created for {} instruments (scale: {})",
1883            epics.len(),
1884            scale
1885        );
1886
1887        Ok(chart_rx)
1888    }
1889
1890    /// Connects all active Lightstreamer clients and maintains the connections.
1891    ///
1892    /// This method establishes connections for all streaming clients that have active
1893    /// subscriptions (market and price). Each client runs in its own task and
1894    /// all connections are maintained until a shutdown signal is received.
1895    ///
1896    /// # Arguments
1897    ///
1898    /// * `shutdown_signal` - Optional signal to gracefully shutdown all connections.
1899    ///   If None, a default signal handler for SIGINT/SIGTERM will be created.
1900    ///
1901    /// # Returns
1902    ///
1903    /// Returns `Ok(())` when all connections are closed gracefully, or an error if
1904    /// any connection fails after maximum retry attempts.
1905    ///
1906    pub async fn connect(&mut self, shutdown_signal: Option<Arc<Notify>>) -> Result<(), AppError> {
1907        // Use provided signal or create a new one with signal hooks
1908        let signal = if let Some(sig) = shutdown_signal {
1909            sig
1910        } else {
1911            let sig = Arc::new(Notify::new());
1912            setup_signal_hook(Arc::clone(&sig)).await;
1913            sig
1914        };
1915
1916        let mut tasks = Vec::new();
1917        // Each connection gets its own dedicated shutdown signal. A single shared
1918        // `Notify` cannot stop both connections: `notify_one` wakes only one
1919        // waiter, so the other connection would never observe the shutdown. The
1920        // external `signal` is fanned out to these per-connection signals below.
1921        let mut connection_signals: Vec<Arc<Notify>> = Vec::new();
1922
1923        // Connect market streamer only if there are active subscriptions
1924        if self.has_market_stream_subs {
1925            if let Some(client) = self.market_streamer_client.as_ref() {
1926                let client = Arc::clone(client);
1927                let conn_signal = Arc::new(Notify::new());
1928                connection_signals.push(Arc::clone(&conn_signal));
1929                let task = tokio::spawn(async move {
1930                    Self::connect_client(client, conn_signal, "Market").await
1931                });
1932                tasks.push(task);
1933            }
1934        } else {
1935            info!("Skipping Market streamer connection: no active subscriptions");
1936        }
1937
1938        // Connect price streamer only if there are active subscriptions
1939        if self.has_price_stream_subs {
1940            if let Some(client) = self.price_streamer_client.as_ref() {
1941                let client = Arc::clone(client);
1942                let conn_signal = Arc::new(Notify::new());
1943                connection_signals.push(Arc::clone(&conn_signal));
1944                let task = tokio::spawn(async move {
1945                    Self::connect_client(client, conn_signal, "Price").await
1946                });
1947                tasks.push(task);
1948            }
1949        } else {
1950            info!("Skipping Price streamer connection: no active subscriptions");
1951        }
1952
1953        if tasks.is_empty() {
1954            warn!("No streaming clients selected for connection (no active subscriptions)");
1955            return Ok(());
1956        }
1957
1958        info!("Connecting {} streaming client(s)...", tasks.len());
1959
1960        // Fan the single external shutdown signal out to every per-connection
1961        // signal so all connections stop together. Aborted once every connection
1962        // has finished so the forwarder cannot outlive them.
1963        let fanout = spawn_shutdown_fanout(Arc::clone(&signal), connection_signals);
1964
1965        // Wait for all tasks to complete
1966        let results = futures::future::join_all(tasks).await;
1967
1968        // All connections finished (via shutdown or on their own): the fan-out
1969        // forwarder is no longer needed.
1970        fanout.abort();
1971
1972        // Check if any task failed
1973        let mut has_error = false;
1974        for (idx, result) in results.iter().enumerate() {
1975            match result {
1976                Ok(Ok(_)) => {
1977                    debug!("Streaming client {} completed successfully", idx);
1978                }
1979                Ok(Err(e)) => {
1980                    error!("Streaming client {} failed: {:?}", idx, e);
1981                    has_error = true;
1982                }
1983                Err(e) => {
1984                    error!("Streaming client {} task panicked: {:?}", idx, e);
1985                    has_error = true;
1986                }
1987            }
1988        }
1989
1990        if has_error {
1991            return Err(AppError::WebSocketError(
1992                "one or more streaming connections failed".to_string(),
1993            ));
1994        }
1995
1996        info!("All streaming connections closed gracefully");
1997        Ok(())
1998    }
1999
2000    /// Internal helper to connect a single Lightstreamer client with retry logic.
2001    async fn connect_client(
2002        client: Arc<Mutex<LightstreamerClient>>,
2003        signal: Arc<Notify>,
2004        client_type: &str,
2005    ) -> Result<(), AppError> {
2006        let mut retry_interval_millis: u64 = 0;
2007        let mut retry_counter: u64 = 0;
2008
2009        while retry_counter < MAX_CONNECTION_ATTEMPTS {
2010            let connect_result = {
2011                let mut client = client.lock().await;
2012                client.connect_direct(Arc::clone(&signal)).await
2013            };
2014
2015            match connect_result {
2016                Ok(()) => {
2017                    info!("{} streamer connected successfully", client_type);
2018                    break;
2019                }
2020                Err(e) => {
2021                    // Classify on the typed error BEFORE stringifying: IG closes
2022                    // the session with the server reason "No more requests to
2023                    // fulfill" once it has no active subscriptions. That is a
2024                    // graceful close, not a failure.
2025                    if is_graceful_close(&e) {
2026                        info!(
2027                            "{} streamer closed gracefully: no active subscriptions (server reason: {})",
2028                            client_type, GRACEFUL_CLOSE_MARKER
2029                        );
2030                        return Ok(());
2031                    }
2032
2033                    // Not graceful: log the `Display` form (never `Debug`;
2034                    // `LightstreamerError` carries no credentials) and schedule a
2035                    // bounded retry.
2036                    let error_msg = e.to_string();
2037                    error!("{} streamer connection failed: {}", client_type, error_msg);
2038
2039                    if retry_counter < MAX_CONNECTION_ATTEMPTS - 1 {
2040                        sleep(Duration::from_millis(retry_interval_millis)).await;
2041                        retry_interval_millis =
2042                            (retry_interval_millis + (200 * retry_counter)).min(5000);
2043                        retry_counter += 1;
2044                        warn!(
2045                            "{} streamer retrying (attempt {}/{}) in {:.2} seconds...",
2046                            client_type,
2047                            retry_counter + 1,
2048                            MAX_CONNECTION_ATTEMPTS,
2049                            retry_interval_millis as f64 / 1000.0
2050                        );
2051                    } else {
2052                        retry_counter += 1;
2053                    }
2054                }
2055            }
2056        }
2057
2058        if retry_counter >= MAX_CONNECTION_ATTEMPTS {
2059            error!(
2060                "{} streamer failed after {} attempts",
2061                client_type, MAX_CONNECTION_ATTEMPTS
2062            );
2063            return Err(AppError::WebSocketError(format!(
2064                "{} streamer: maximum connection attempts ({}) exceeded",
2065                client_type, MAX_CONNECTION_ATTEMPTS
2066            )));
2067        }
2068
2069        info!("{} streamer connection closed gracefully", client_type);
2070        Ok(())
2071    }
2072
2073    /// Disconnects all active Lightstreamer clients.
2074    ///
2075    /// This method gracefully closes all streaming connections (market and
2076    /// price) and tears down the per-subscription converter tasks so they do
2077    /// not idle for the process lifetime. Closing the Lightstreamer session
2078    /// closes the item channels feeding the converters, so they would exit on
2079    /// their own; aborting and awaiting them here guarantees a deterministic,
2080    /// leak-free shutdown. Calling `disconnect` more than once is safe: the
2081    /// converter list is drained and the client `disconnect` is idempotent.
2082    ///
2083    /// # Returns
2084    ///
2085    /// Returns `Ok(())` if all disconnections were successful.
2086    pub async fn disconnect(&mut self) -> Result<(), AppError> {
2087        let mut disconnected = 0;
2088
2089        if let Some(client) = self.market_streamer_client.as_ref() {
2090            let mut client = client.lock().await;
2091            client.disconnect().await;
2092            info!("Market streamer disconnected");
2093            disconnected += 1;
2094        }
2095
2096        if let Some(client) = self.price_streamer_client.as_ref() {
2097            let mut client = client.lock().await;
2098            client.disconnect().await;
2099            info!("Price streamer disconnected");
2100            disconnected += 1;
2101        }
2102
2103        // Tear down the converter tasks now that their upstream item channels
2104        // are closed.
2105        let converter_count = self.converter_tasks.len();
2106        abort_and_drain_tasks(&mut self.converter_tasks).await;
2107        if converter_count > 0 {
2108            debug!("Aborted {} converter task(s)", converter_count);
2109        }
2110
2111        info!("Disconnected {} streaming client(s)", disconnected);
2112        Ok(())
2113    }
2114}
2115
2116#[cfg(feature = "streaming")]
2117impl Drop for StreamerClient {
2118    /// Aborts any converter tasks that were not already torn down by
2119    /// [`StreamerClient::disconnect`], so dropping the client never orphans a
2120    /// spawned task. Abort is synchronous, so no runtime is required here.
2121    fn drop(&mut self) {
2122        for handle in self.converter_tasks.drain(..) {
2123            handle.abort();
2124        }
2125    }
2126}
2127
2128#[cfg(test)]
2129mod tests {
2130    use super::is_transient_confirmation_error;
2131    use crate::error::AppError;
2132    use reqwest::StatusCode;
2133
2134    #[test]
2135    fn test_confirmation_error_rate_limit_is_transient() {
2136        assert!(is_transient_confirmation_error(
2137            &AppError::RateLimitExceeded
2138        ));
2139    }
2140
2141    #[test]
2142    fn test_confirmation_error_not_found_is_transient() {
2143        // Confirmation not yet available: IG returns 404 until the deal settles.
2144        assert!(is_transient_confirmation_error(&AppError::NotFound));
2145        assert!(is_transient_confirmation_error(&AppError::Unexpected(
2146            StatusCode::NOT_FOUND
2147        )));
2148    }
2149
2150    #[test]
2151    fn test_confirmation_error_server_error_is_transient() {
2152        assert!(is_transient_confirmation_error(&AppError::Unexpected(
2153            StatusCode::INTERNAL_SERVER_ERROR
2154        )));
2155        assert!(is_transient_confirmation_error(&AppError::Unexpected(
2156            StatusCode::BAD_GATEWAY
2157        )));
2158    }
2159
2160    #[test]
2161    fn test_confirmation_error_invalid_input_is_permanent() {
2162        assert!(!is_transient_confirmation_error(&AppError::InvalidInput(
2163            "bad".to_string()
2164        )));
2165    }
2166
2167    #[test]
2168    fn test_confirmation_error_auth_and_deser_are_permanent() {
2169        assert!(!is_transient_confirmation_error(&AppError::Unauthorized));
2170        assert!(!is_transient_confirmation_error(
2171            &AppError::OAuthTokenExpired
2172        ));
2173        assert!(!is_transient_confirmation_error(
2174            &AppError::Deserialization("bad".to_string())
2175        ));
2176        assert!(!is_transient_confirmation_error(&AppError::Unexpected(
2177            StatusCode::BAD_REQUEST
2178        )));
2179    }
2180}
2181
2182// The streaming helpers these tests exercise only exist with the `streaming`
2183// feature, so they live in their own gated module rather than behind per-test
2184// attributes.
2185#[cfg(all(test, feature = "streaming"))]
2186mod streaming_tests {
2187    use super::{
2188        GRACEFUL_CLOSE_MARKER, abort_and_drain_tasks, is_graceful_close, spawn_shutdown_fanout,
2189    };
2190    use lightstreamer_rs::utils::LightstreamerError;
2191    use std::sync::Arc;
2192    use std::time::Duration;
2193    use tokio::sync::Notify;
2194    use tokio::task::JoinHandle;
2195
2196    // --- Task 3: graceful-close classification -----------------------------
2197
2198    #[test]
2199    fn test_is_graceful_close_connection_variant_with_marker_is_true() {
2200        // "No more requests to fulfill" arrives wrapped in a server `conerr`
2201        // message, which `lightstreamer-rs` surfaces as `Connection`.
2202        let err = LightstreamerError::Connection(format!(
2203            "connection error from server: conerr,-2,{GRACEFUL_CLOSE_MARKER}"
2204        ));
2205        assert!(is_graceful_close(&err));
2206    }
2207
2208    #[test]
2209    fn test_is_graceful_close_protocol_and_invalid_state_with_marker_is_true() {
2210        let protocol = LightstreamerError::Protocol(format!("closing: {GRACEFUL_CLOSE_MARKER}"));
2211        let invalid_state =
2212            LightstreamerError::InvalidState(format!("state: {GRACEFUL_CLOSE_MARKER}"));
2213        assert!(is_graceful_close(&protocol));
2214        assert!(is_graceful_close(&invalid_state));
2215    }
2216
2217    #[test]
2218    fn test_is_graceful_close_connection_variant_without_marker_is_false() {
2219        let err = LightstreamerError::Connection("no message received within 5000 ms".to_string());
2220        assert!(!is_graceful_close(&err));
2221    }
2222
2223    #[test]
2224    fn test_is_graceful_close_other_variant_with_marker_is_false() {
2225        // Variants that never carry a server close reason must not match, even
2226        // if the marker text somehow appears in them.
2227        let err = LightstreamerError::Timeout(GRACEFUL_CLOSE_MARKER.to_string());
2228        assert!(!is_graceful_close(&err));
2229    }
2230
2231    // --- Task 5: one shutdown signal must wake both connections ------------
2232
2233    #[tokio::test]
2234    async fn test_shutdown_fanout_wakes_all_connection_waiters() {
2235        // Two dedicated per-connection signals stand in for the market and
2236        // price connections, both waiting to be shut down.
2237        let source = Arc::new(Notify::new());
2238        let market_signal = Arc::new(Notify::new());
2239        let price_signal = Arc::new(Notify::new());
2240
2241        let fanout = spawn_shutdown_fanout(
2242            Arc::clone(&source),
2243            vec![Arc::clone(&market_signal), Arc::clone(&price_signal)],
2244        );
2245
2246        let market_waiter = tokio::spawn(async move { market_signal.notified().await });
2247        let price_waiter = tokio::spawn(async move { price_signal.notified().await });
2248
2249        // A single `notify_one` on the shared source (as `setup_signal_hook`
2250        // and `DynamicMarketStreamer` do) must reach BOTH connections. Because
2251        // each per-connection signal has a single waiter and `notify_one`
2252        // stores a permit, the wake is race-free.
2253        source.notify_one();
2254
2255        assert!(
2256            tokio::time::timeout(Duration::from_secs(1), market_waiter)
2257                .await
2258                .is_ok(),
2259            "market connection did not observe the shutdown signal"
2260        );
2261        assert!(
2262            tokio::time::timeout(Duration::from_secs(1), price_waiter)
2263                .await
2264                .is_ok(),
2265            "price connection did not observe the shutdown signal"
2266        );
2267
2268        fanout.abort();
2269    }
2270
2271    // --- Task 2: converter-task teardown ----------------------------------
2272
2273    #[tokio::test]
2274    async fn test_abort_and_drain_tasks_stops_and_clears() {
2275        let mut tasks: Vec<JoinHandle<()>> = Vec::new();
2276        for _ in 0..3 {
2277            // A task that never completes on its own; only an abort stops it.
2278            tasks.push(tokio::spawn(async { std::future::pending::<()>().await }));
2279        }
2280        assert!(tasks.iter().all(|h| !h.is_finished()));
2281
2282        abort_and_drain_tasks(&mut tasks).await;
2283
2284        // The helper awaits each aborted task, so returning proves every task
2285        // terminated; the list is drained.
2286        assert!(tasks.is_empty());
2287    }
2288
2289    #[tokio::test]
2290    async fn test_abort_makes_pending_task_finish() {
2291        let handle: JoinHandle<()> = tokio::spawn(async { std::future::pending::<()>().await });
2292        assert!(!handle.is_finished());
2293        handle.abort();
2294        let join_result = handle.await;
2295        assert!(
2296            join_result.is_err(),
2297            "aborted task should yield a cancellation JoinError"
2298        );
2299    }
2300}