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