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