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