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