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        // pageSize is silently capped: above ~50 IG returns correct
1249        // pagination metadata (totalElements/totalPages) but an EMPTY
1250        // costsAndChargesHistory list (observed on demo with 100 and 500;
1251        // 50 returns entries). Keep the page size at 50.
1252        const PAGE_SIZE: u32 = 50;
1253        let from = ensure_zone_designator(from);
1254        let to = ensure_zone_designator(to);
1255        let mut all_entries = Vec::new();
1256        let mut current_page: u32 = 1;
1257        #[allow(unused_assignments)]
1258        let mut last_pagination = None;
1259
1260        loop {
1261            let path = format!(
1262                "indicativecostsandcharges/history/from/{}/to/{}?pageSize={}&pageNumber={}",
1263                from, to, PAGE_SIZE, current_page
1264            );
1265            info!("Getting costs history page {}", current_page);
1266
1267            let result: CostsHistoryResponse = self.http_client.get(&path, Some(1)).await?;
1268
1269            let total_pages = result.pagination.total_pages;
1270            last_pagination = Some(result.pagination);
1271            all_entries.extend(result.costs_and_charges_history);
1272
1273            if i64::from(current_page) >= total_pages {
1274                break;
1275            }
1276            current_page += 1;
1277        }
1278
1279        debug!("Costs history obtained: {} entries", all_entries.len());
1280
1281        Ok(CostsHistoryResponse {
1282            pagination: last_pagination.ok_or_else(|| {
1283                AppError::InvalidInput("Could not retrieve pagination".to_string())
1284            })?,
1285            costs_and_charges_history: all_entries,
1286        })
1287    }
1288
1289    async fn get_durable_medium(
1290        &self,
1291        quote_reference: &str,
1292    ) -> Result<DurableMediumResponse, AppError> {
1293        let path = format!(
1294            "indicativecostsandcharges/durablemedium/{}",
1295            quote_reference
1296        );
1297        info!("Getting durable medium for reference: {}", quote_reference);
1298        let result: DurableMediumResponse = self.http_client.get(&path, Some(1)).await?;
1299        debug!("Durable medium obtained for reference: {}", quote_reference);
1300        Ok(result)
1301    }
1302}
1303
1304// ============================================================================
1305// OPERATIONS SERVICE IMPLEMENTATION
1306// ============================================================================
1307
1308#[async_trait]
1309impl OperationsService for Client {
1310    async fn get_client_apps(&self) -> Result<ApplicationDetailsResponse, AppError> {
1311        info!("Getting client applications");
1312        let result: ApplicationDetailsResponse = self
1313            .http_client
1314            .get("operations/application", Some(1))
1315            .await?;
1316        // Never log `api_key`: it is a live credential.
1317        debug!(
1318            name = ?result.name,
1319            status = %result.status,
1320            "Client application obtained"
1321        );
1322        Ok(result)
1323    }
1324
1325    async fn disable_client_app(&self) -> Result<StatusResponse, AppError> {
1326        info!("Disabling current client application");
1327        let result: StatusResponse = self
1328            .http_client
1329            .put(
1330                "operations/application/disable",
1331                &serde_json::json!({}),
1332                Some(1),
1333            )
1334            .await?;
1335        debug!("Client application disabled");
1336        Ok(result)
1337    }
1338}
1339
1340/// Streaming client for IG Markets real-time data.
1341///
1342/// One Lightstreamer session carries every IG channel: market data
1343/// (`MARKET:`), detailed prices (`PRICE:`, served by the `Pricing` data
1344/// adapter), trade confirmations (`TRADE:`), account balances (`ACCOUNT:`) and
1345/// candles (`CHART:`). The data adapter is a property of the *subscription*, so
1346/// one session is enough — the pair of connections this type used to open was a
1347/// workaround for the previous client library.
1348///
1349/// # Lifecycle
1350///
1351/// The session is opened lazily by the first `*_subscribe` call and lives until
1352/// [`disconnect`](Self::disconnect) or `Drop`. [`connect`](Self::connect) does
1353/// not open it; it consumes the session event stream and blocks until the
1354/// shutdown signal fires or the session ends for good, which is what makes it
1355/// usable as the "run until stopped" body of a streaming binary.
1356///
1357/// # Channels
1358///
1359/// Each `*_subscribe` returns an unbounded receiver of decoded DTOs. The sender
1360/// is owned by a converter task spawned per subscription; when the caller drops
1361/// the receiver that task logs and exits, and when the session ends the
1362/// subscription stream closes and the task exits. Every one of those tasks is
1363/// tracked and joined by [`disconnect`](Self::disconnect), so none outlives the
1364/// client.
1365#[cfg(feature = "streaming")]
1366#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
1367pub struct StreamerClient {
1368    account_id: String,
1369    /// The validated session configuration, used to open the session on the
1370    /// first subscription. It carries the Lightstreamer password (the IG
1371    /// session token), so this type deliberately has no `Debug` impl of its
1372    /// own; the upstream `Credentials` redacts the password in its own.
1373    config: ClientConfig,
1374    /// The live session, `None` until the first subscription and again after
1375    /// `disconnect`. `Client::subscribe` takes `&self`, so no lock is needed.
1376    client: Option<LsClient>,
1377    /// The session event stream, taken by `connect`.
1378    session_events: Option<SessionEvents>,
1379    /// Shutdown signal for the converter tasks. `watch` rather than `Notify`:
1380    /// it is level-triggered, so a task busy converting an update when the
1381    /// signal fires still observes it.
1382    shutdown_tx: watch::Sender<bool>,
1383    /// Handles for the per-subscription update -> DTO converter tasks. Each
1384    /// `*_subscribe` call spawns one; `disconnect` signals and joins them so
1385    /// they do not idle for the process lifetime.
1386    converter_tasks: Vec<JoinHandle<()>>,
1387    // Flags indicating whether there is at least one active subscription of
1388    // each kind, so `connect` can report what it is actually waiting on.
1389    has_market_stream_subs: bool,
1390    has_price_stream_subs: bool,
1391}
1392
1393#[cfg(feature = "streaming")]
1394impl StreamerClient {
1395    /// Creates a new streaming client instance with its own REST session.
1396    ///
1397    /// This builds a fresh [`Client`] and logs in to obtain the Lightstreamer
1398    /// endpoint and credentials. No connection is established yet — the session
1399    /// opens on the first subscription.
1400    ///
1401    /// When the caller already holds a [`Client`] with an active REST session,
1402    /// prefer [`with_client`](Self::with_client) to reuse that session instead
1403    /// of performing a second login.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Returns [`AppError`] if the login / session lookup fails, or
1408    /// [`AppError::InvalidInput`] if IG returned an endpoint the Lightstreamer
1409    /// client rejects.
1410    pub async fn new() -> Result<Self, AppError> {
1411        let client = Client::try_new()?;
1412        Self::with_client(&client).await
1413    }
1414
1415    /// Creates a new streaming client that reuses the caller's existing REST
1416    /// session.
1417    ///
1418    /// Unlike [`new`](Self::new), this does not build a second HTTP client or
1419    /// perform a second login: it reuses `client`'s cached session (via
1420    /// [`Client::ws_info`]) to obtain the Lightstreamer endpoint and
1421    /// credentials.
1422    ///
1423    /// # Errors
1424    ///
1425    /// Returns [`AppError`] if the session lookup fails, or
1426    /// [`AppError::InvalidInput`] if IG returned an endpoint the Lightstreamer
1427    /// client rejects.
1428    pub async fn with_client(client: &Client) -> Result<Self, AppError> {
1429        let ws_info = client.ws_info().await?;
1430
1431        // The Lightstreamer password IS the IG session token pair
1432        // (`CST-…|XST-…`). It goes into `Credentials`, whose `Debug` redacts
1433        // it, and is never logged or echoed anywhere on this path.
1434        let config = ClientConfig::builder(ServerAddress::try_new(ws_info.server.as_str())?)
1435            .with_credentials(Credentials::new(
1436                ws_info.account_id.as_str(),
1437                ws_info.get_ws_password(),
1438            ))
1439            .build()?;
1440
1441        let (shutdown_tx, _) = watch::channel(false);
1442
1443        Ok(Self {
1444            account_id: ws_info.account_id.clone(),
1445            config,
1446            client: None,
1447            session_events: None,
1448            shutdown_tx,
1449            converter_tasks: Vec::new(),
1450            has_market_stream_subs: false,
1451            has_price_stream_subs: false,
1452        })
1453    }
1454
1455    /// Opens the Lightstreamer session if it is not open yet, and returns it.
1456    ///
1457    /// Called by every `*_subscribe`: the session cannot be opened in the
1458    /// constructor because `lightstreamer-rs` connects eagerly, and connecting
1459    /// before there is anything to subscribe to would open a socket that is
1460    /// only ever closed again.
1461    ///
1462    /// The configuration is kept rather than consumed, so subscribing again
1463    /// after [`disconnect`](Self::disconnect) opens a fresh session with the
1464    /// same endpoint and credentials.
1465    async fn ensure_session(&mut self) -> Result<&LsClient, AppError> {
1466        if self.client.is_none() {
1467            let (client, events) = LsClient::connect(self.config.clone()).await?;
1468            info!(account_id = %self.account_id, "Lightstreamer session opened");
1469            self.client = Some(client);
1470            self.session_events = Some(events);
1471        }
1472
1473        self.client.as_ref().ok_or_else(|| {
1474            AppError::WebSocketError("streaming session not initialized".to_string())
1475        })
1476    }
1477
1478    /// Subscribes and spawns the converter task that turns the subscription's
1479    /// event stream into a channel of decoded DTOs.
1480    ///
1481    /// The converter owns the `Updates` stream, so dropping it (when the task
1482    /// ends) unsubscribes. It stops on the shutdown signal, on the receiver
1483    /// being dropped, or on the stream closing — never on a decode failure,
1484    /// which the `From` impls degrade to a default.
1485    async fn subscribe_and_convert<T, C>(
1486        &mut self,
1487        subscription: Subscription,
1488        label: &str,
1489        convert: C,
1490    ) -> Result<mpsc::UnboundedReceiver<T>, AppError>
1491    where
1492        T: Send + 'static,
1493        C: Fn(&StreamingUpdate) -> T + Send + 'static,
1494    {
1495        let updates = self.ensure_session().await?.subscribe(subscription).await?;
1496
1497        let (tx, rx) = mpsc::unbounded_channel();
1498        let mut shutdown = self.shutdown_tx.subscribe();
1499        let label = label.to_owned();
1500
1501        let handle = tokio::spawn(async move {
1502            let mut updates = updates;
1503            loop {
1504                let event = tokio::select! {
1505                    _ = shutdown.changed() => {
1506                        debug!(subscription = %label, "converter stopped by shutdown signal");
1507                        return;
1508                    }
1509                    event = updates.next() => event,
1510                };
1511
1512                let Some(event) = event else {
1513                    debug!(subscription = %label, "converter stopped: subscription stream closed");
1514                    return;
1515                };
1516
1517                match event {
1518                    SubscriptionEvent::Update(update) => {
1519                        let data = convert(&StreamingUpdate::from(update.as_ref()));
1520                        if tx.send(data).is_err() {
1521                            debug!(subscription = %label, "converter stopped: receiver dropped");
1522                            return;
1523                        }
1524                    }
1525                    SubscriptionEvent::Activated {
1526                        item_count,
1527                        field_count,
1528                        ..
1529                    } => info!(
1530                        subscription = %label,
1531                        item_count,
1532                        field_count,
1533                        "subscription started"
1534                    ),
1535                    // Terminal for this subscription. The server's own code and
1536                    // message; never a credential.
1537                    SubscriptionEvent::Rejected(e) => {
1538                        error!(subscription = %label, error = %e, "IG refused the subscription");
1539                        return;
1540                    }
1541                    SubscriptionEvent::Unsubscribed => {
1542                        info!(subscription = %label, "subscription ended");
1543                        return;
1544                    }
1545                    SubscriptionEvent::Overflow {
1546                        item_index,
1547                        dropped_count,
1548                    } => warn!(
1549                        subscription = %label,
1550                        item_index,
1551                        dropped_count,
1552                        "IG dropped updates for this item"
1553                    ),
1554                    other => debug!(subscription = %label, event = ?other, "subscription event"),
1555                }
1556            }
1557        });
1558        self.converter_tasks.push(handle);
1559
1560        Ok(rx)
1561    }
1562
1563    /// Subscribes to market data updates for the specified instruments.
1564    ///
1565    /// This method creates a subscription to receive real-time market data updates
1566    /// for the given EPICs and returns a channel receiver for consuming the updates.
1567    ///
1568    /// # Arguments
1569    ///
1570    /// * `epics` - List of instrument EPICs to subscribe to
1571    /// * `fields` - Set of market data fields to receive (e.g., BID, OFFER, etc.)
1572    ///
1573    /// # Returns
1574    ///
1575    /// Returns a receiver channel for `PriceData` updates, or an error if
1576    /// the subscription setup failed.
1577    ///
1578    /// # Errors
1579    ///
1580    /// Returns [`AppError::InvalidInput`] if `epics` or `fields` is empty or
1581    /// contains a name Lightstreamer rejects, and [`AppError::WebSocketError`]
1582    /// if the session cannot be opened or the subscription cannot be sent.
1583    ///
1584    /// # Examples
1585    ///
1586    /// ```ignore
1587    /// let mut receiver = client.market_subscribe(
1588    ///     vec!["IX.D.DAX.DAILY.IP".to_string()],
1589    ///     fields
1590    /// ).await?;
1591    ///
1592    /// tokio::spawn(async move {
1593    ///     while let Some(price_data) = receiver.recv().await {
1594    ///         println!("Price update: {:?}", price_data);
1595    ///     }
1596    /// });
1597    /// ```
1598    pub async fn market_subscribe(
1599        &mut self,
1600        epics: Vec<String>,
1601        fields: HashSet<StreamingMarketField>,
1602    ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1603        let epic_count = epics.len();
1604        let items: Vec<String> = epics
1605            .into_iter()
1606            .map(|epic| format!("MARKET:{epic}"))
1607            .collect();
1608        let subscription = Subscription::new(
1609            SubscriptionMode::Merge,
1610            ItemGroup::from_items(items)?,
1611            FieldSchema::from_fields(get_streaming_market_fields(&fields))?,
1612        )
1613        .with_snapshot(Snapshot::On);
1614
1615        let receiver = self
1616            .subscribe_and_convert(subscription, "market", |update| PriceData::from(update))
1617            .await?;
1618        self.has_market_stream_subs = true;
1619
1620        info!("Market subscription created for {epic_count} instruments");
1621        Ok(receiver)
1622    }
1623
1624    /// Subscribes to trade updates for the account.
1625    ///
1626    /// This method creates a subscription to receive real-time trade confirmations,
1627    /// order updates (OPU), and working order updates (WOU) for the account,
1628    /// and returns a channel receiver for consuming the updates.
1629    ///
1630    /// # Returns
1631    ///
1632    /// Returns a receiver channel for `TradeFields` updates, or an error if
1633    /// the subscription setup failed.
1634    ///
1635    /// # Errors
1636    ///
1637    /// Returns [`AppError::WebSocketError`] if the session cannot be opened or
1638    /// the subscription cannot be sent.
1639    ///
1640    /// # Examples
1641    ///
1642    /// ```ignore
1643    /// let mut receiver = client.trade_subscribe().await?;
1644    ///
1645    /// tokio::spawn(async move {
1646    ///     while let Some(trade_fields) = receiver.recv().await {
1647    ///         println!("Trade update: {:?}", trade_fields);
1648    ///     }
1649    /// });
1650    /// ```
1651    pub async fn trade_subscribe(
1652        &mut self,
1653    ) -> Result<mpsc::UnboundedReceiver<TradeFields>, AppError> {
1654        let account_id = self.account_id.clone();
1655        let subscription = Subscription::new(
1656            SubscriptionMode::Distinct,
1657            ItemGroup::from_items([format!("TRADE:{account_id}")])?,
1658            FieldSchema::from_fields(["CONFIRMS", "OPU", "WOU"])?,
1659        )
1660        .with_snapshot(Snapshot::On);
1661
1662        let receiver = self
1663            .subscribe_and_convert(subscription, "trade", |update| {
1664                crate::presentation::trade::TradeData::from(update).fields
1665            })
1666            .await?;
1667        self.has_market_stream_subs = true;
1668
1669        info!(account_id = %account_id, "Trade subscription created");
1670        Ok(receiver)
1671    }
1672
1673    /// Subscribes to account data updates.
1674    ///
1675    /// This method creates a subscription to receive real-time account updates including
1676    /// profit/loss, margin, equity, available funds, and other account metrics,
1677    /// and returns a channel receiver for consuming the updates.
1678    ///
1679    /// # Arguments
1680    ///
1681    /// * `fields` - Set of account data fields to receive (e.g., PNL, MARGIN, EQUITY, etc.)
1682    ///
1683    /// # Returns
1684    ///
1685    /// Returns a receiver channel for `AccountFields` updates, or an error if
1686    /// the subscription setup failed.
1687    ///
1688    /// # Errors
1689    ///
1690    /// Returns [`AppError::InvalidInput`] if `fields` is empty or contains a
1691    /// name Lightstreamer rejects, and [`AppError::WebSocketError`] if the
1692    /// session cannot be opened or the subscription cannot be sent.
1693    ///
1694    /// # Examples
1695    ///
1696    /// ```ignore
1697    /// let mut receiver = client.account_subscribe(fields).await?;
1698    ///
1699    /// tokio::spawn(async move {
1700    ///     while let Some(account_fields) = receiver.recv().await {
1701    ///         println!("Account update: {:?}", account_fields);
1702    ///     }
1703    /// });
1704    /// ```
1705    pub async fn account_subscribe(
1706        &mut self,
1707        fields: HashSet<StreamingAccountDataField>,
1708    ) -> Result<mpsc::UnboundedReceiver<AccountFields>, AppError> {
1709        let account_id = self.account_id.clone();
1710        let subscription = Subscription::new(
1711            SubscriptionMode::Merge,
1712            ItemGroup::from_items([format!("ACCOUNT:{account_id}")])?,
1713            FieldSchema::from_fields(get_streaming_account_data_fields(&fields))?,
1714        )
1715        .with_snapshot(Snapshot::On);
1716
1717        let receiver = self
1718            .subscribe_and_convert(subscription, "account", |update| {
1719                crate::presentation::account::AccountData::from(update).fields
1720            })
1721            .await?;
1722        self.has_market_stream_subs = true;
1723
1724        info!(account_id = %account_id, "Account subscription created");
1725        Ok(receiver)
1726    }
1727
1728    /// Subscribes to price data updates for the specified instruments.
1729    ///
1730    /// This method creates a subscription to receive real-time price updates including
1731    /// bid/ask prices, sizes, and multiple currency levels for the given EPICs,
1732    /// and returns a channel receiver for consuming the updates.
1733    ///
1734    /// # Arguments
1735    ///
1736    /// * `epics` - List of instrument EPICs to subscribe to
1737    /// * `fields` - Set of price data fields to receive (e.g., BID_PRICE1, ASK_PRICE1, etc.)
1738    ///
1739    /// # Returns
1740    ///
1741    /// Returns a receiver channel for `PriceData` updates, or an error if
1742    /// the subscription setup failed.
1743    ///
1744    /// # Errors
1745    ///
1746    /// Returns [`AppError::InvalidInput`] if `epics` or `fields` is empty or
1747    /// contains a name Lightstreamer rejects, and [`AppError::WebSocketError`]
1748    /// if the session cannot be opened or the subscription cannot be sent.
1749    ///
1750    /// # Examples
1751    ///
1752    /// ```ignore
1753    /// let mut receiver = client.price_subscribe(
1754    ///     vec!["IX.D.DAX.DAILY.IP".to_string()],
1755    ///     fields
1756    /// ).await?;
1757    ///
1758    /// tokio::spawn(async move {
1759    ///     while let Some(price_data) = receiver.recv().await {
1760    ///         println!("Price update: {:?}", price_data);
1761    ///     }
1762    /// });
1763    /// ```
1764    pub async fn price_subscribe(
1765        &mut self,
1766        epics: Vec<String>,
1767        fields: HashSet<StreamingPriceField>,
1768    ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1769        let account_id = self.account_id.clone();
1770        let epic_count = epics.len();
1771        let items: Vec<String> = epics
1772            .into_iter()
1773            .map(|epic| format!("PRICE:{account_id}:{epic}"))
1774            .collect();
1775        let field_names = get_streaming_price_fields(&fields);
1776
1777        debug!(?items, ?field_names, "Pricing subscription shape");
1778
1779        // The `Pricing` data adapter name is a server-side configuration
1780        // detail; it is overridable so a differently-configured IG environment
1781        // does not need a code change.
1782        let pricing_adapter =
1783            std::env::var("IG_PRICING_ADAPTER").unwrap_or_else(|_| "Pricing".to_string());
1784        debug!(adapter = %pricing_adapter, "Using Pricing data adapter");
1785
1786        let subscription = Subscription::new(
1787            SubscriptionMode::Merge,
1788            ItemGroup::from_items(items)?,
1789            FieldSchema::from_fields(field_names)?,
1790        )
1791        .with_data_adapter(pricing_adapter)
1792        .with_snapshot(Snapshot::On);
1793
1794        let receiver = self
1795            .subscribe_and_convert(subscription, "price", |update| PriceData::from(update))
1796            .await?;
1797        self.has_price_stream_subs = true;
1798
1799        info!(account_id = %account_id, "Price subscription created for {epic_count} instruments");
1800        Ok(receiver)
1801    }
1802
1803    /// Subscribes to chart data updates for the specified instruments and scale.
1804    ///
1805    /// This method creates a subscription to receive real-time chart updates including
1806    /// OHLC data, volume, and other chart metrics for the given EPICs and chart scale,
1807    /// and returns a channel receiver for consuming the updates.
1808    ///
1809    /// # Arguments
1810    ///
1811    /// * `epics` - List of instrument EPICs to subscribe to.
1812    /// * `scale` - Chart scale (e.g., Tick, 1Min, 5Min, etc.).
1813    /// * `fields` - Set of chart data fields to receive (e.g., OPEN, HIGH, LOW, CLOSE, VOLUME).
1814    ///
1815    /// # Returns
1816    ///
1817    /// Returns a receiver channel for `ChartData` updates, or an error if
1818    /// the subscription setup failed.
1819    ///
1820    /// # Errors
1821    ///
1822    /// Returns [`AppError::InvalidInput`] if `epics` or `fields` is empty or
1823    /// contains a name Lightstreamer rejects, and [`AppError::WebSocketError`]
1824    /// if the session cannot be opened or the subscription cannot be sent.
1825    ///
1826    /// # Examples
1827    ///
1828    /// ```ignore
1829    /// let mut receiver = client.chart_subscribe(
1830    ///     vec!["IX.D.DAX.DAILY.IP".to_string()],
1831    ///     ChartScale::OneMin,
1832    ///     fields
1833    /// ).await?;
1834    ///
1835    /// tokio::spawn(async move {
1836    ///     while let Some(chart_data) = receiver.recv().await {
1837    ///         println!("Chart update: {:?}", chart_data);
1838    ///     }
1839    /// });
1840    /// ```
1841    pub async fn chart_subscribe(
1842        &mut self,
1843        epics: Vec<String>,
1844        scale: ChartScale,
1845        fields: HashSet<StreamingChartField>,
1846    ) -> Result<mpsc::UnboundedReceiver<ChartData>, AppError> {
1847        let epic_count = epics.len();
1848        let items: Vec<String> = epics
1849            .into_iter()
1850            .map(|epic| format!("CHART:{epic}:{scale}"))
1851            .collect();
1852
1853        // Candle data is a running value (MERGE); tick data is a sequence of
1854        // independent events (DISTINCT).
1855        let mode = if matches!(scale, ChartScale::Tick) {
1856            SubscriptionMode::Distinct
1857        } else {
1858            SubscriptionMode::Merge
1859        };
1860
1861        let subscription = Subscription::new(
1862            mode,
1863            ItemGroup::from_items(items)?,
1864            FieldSchema::from_fields(get_streaming_chart_fields(&fields))?,
1865        )
1866        .with_snapshot(Snapshot::On);
1867
1868        let receiver = self
1869            .subscribe_and_convert(subscription, "chart", |update| ChartData::from(update))
1870            .await?;
1871        self.has_market_stream_subs = true;
1872
1873        info!("Chart subscription created for {epic_count} instruments (scale: {scale})");
1874        Ok(receiver)
1875    }
1876
1877    /// Consumes the session event stream and blocks until shutdown.
1878    ///
1879    /// The Lightstreamer session is already open by the time this is called
1880    /// (the first subscription opened it) and reconnection is handled by
1881    /// `lightstreamer-rs` itself, with bounded jittered backoff. What this
1882    /// method adds is observation: it reports what every reconnection *meant*
1883    /// — in particular a session that was replaced rather than preserved, after
1884    /// which every subscription has been re-executed and a fresh snapshot is on
1885    /// its way — and it returns when the session ends for good.
1886    ///
1887    /// # Arguments
1888    ///
1889    /// * `shutdown_signal` - Signalled by the caller to stop. When `None`, this
1890    ///   waits for `SIGINT` / `SIGTERM` instead.
1891    ///
1892    /// # Returns
1893    ///
1894    /// `Ok(())` when the shutdown signal fired or the session was closed by
1895    /// this client.
1896    ///
1897    /// # Errors
1898    ///
1899    /// Returns [`AppError::WebSocketError`] when the session ended for a reason
1900    /// this client did not ask for: refused by IG, reconnection budget
1901    /// exhausted, or an internal failure in the streaming crate.
1902    pub async fn connect(&mut self, shutdown_signal: Option<Arc<Notify>>) -> Result<(), AppError> {
1903        let Some(mut events) = self.session_events.take() else {
1904            // Either nothing was subscribed (so no session was ever opened) or
1905            // the events were already consumed by an earlier `connect`.
1906            warn!("No streaming session to run: subscribe first, and call connect once");
1907            return Ok(());
1908        };
1909
1910        info!(
1911            market_subscriptions = self.has_market_stream_subs,
1912            price_subscriptions = self.has_price_stream_subs,
1913            "Streaming session running"
1914        );
1915
1916        // Built once and polled across every iteration. Re-creating it inside
1917        // the loop would re-register the signal handlers on every event and
1918        // could drop a signal that arrived between two of them.
1919        let shutdown = wait_for_shutdown(shutdown_signal);
1920        tokio::pin!(shutdown);
1921
1922        loop {
1923            let event = tokio::select! {
1924                () = &mut shutdown => {
1925                    info!("Streaming session stopping: shutdown requested");
1926                    return Ok(());
1927                }
1928                event = events.next() => event,
1929            };
1930
1931            let Some(event) = event else {
1932                // The stream ended without a `Closed` event, which only happens
1933                // if the client was dropped underneath us.
1934                debug!("Session event stream ended");
1935                return Ok(());
1936            };
1937
1938            match event {
1939                SessionEvent::Connected(connected) => match connected.continuity {
1940                    // Only a *replaced* session invalidates derived state: it
1941                    // re-executes every subscription, so anything computed from
1942                    // the previous one is stale. New / Preserved / Recovered all
1943                    // keep it — a first connect is not a replacement to warn
1944                    // about, which the old is_preserved() split got wrong.
1945                    Continuity::Replaced { .. } => warn!(
1946                        continuity = ?connected.continuity,
1947                        "Streaming session replaced: subscriptions re-executed, expect fresh snapshots"
1948                    ),
1949                    _ => info!(
1950                        continuity = ?connected.continuity,
1951                        "Streaming session connected"
1952                    ),
1953                },
1954                SessionEvent::Resubscribed(subscriptions) => {
1955                    info!(
1956                        count = subscriptions.len(),
1957                        "Subscriptions re-created on a new session"
1958                    );
1959                }
1960                SessionEvent::Disconnected { reason, retry_in } => match retry_in {
1961                    Some(delay) => warn!(
1962                        ?reason,
1963                        retry_in_ms = delay.as_millis(),
1964                        "Streaming session disconnected, reconnecting"
1965                    ),
1966                    None => warn!(?reason, "Streaming session disconnected, giving up"),
1967                },
1968                SessionEvent::Closed(reason) => return Self::report_close(&reason),
1969                SessionEvent::RequestRejected(e) => {
1970                    warn!(error = %e, "IG refused a streaming control request");
1971                }
1972                SessionEvent::RequestNotSent { reason } => {
1973                    warn!(%reason, "A streaming control request never left the client");
1974                }
1975                // The raw line can carry market data, so it stays at TRACE.
1976                SessionEvent::Unrecognized { line } => {
1977                    trace!(%line, "Unrecognized streaming notification");
1978                }
1979                other => debug!(event = ?other, "Session event"),
1980            }
1981        }
1982    }
1983
1984    /// Turns a terminal [`ClosedReason`] into this crate's result.
1985    ///
1986    /// A close this client asked for is success. Everything else is a failure
1987    /// carrying IG's own reason — there is no message-sniffing here: 1.0 has a
1988    /// discriminant for a clean shutdown and this is it.
1989    fn report_close(reason: &ClosedReason) -> Result<(), AppError> {
1990        match reason {
1991            ClosedReason::ByClient => {
1992                info!("Streaming session closed by this client");
1993                Ok(())
1994            }
1995            ClosedReason::ByServer(e) => {
1996                error!(error = %e, "IG closed the streaming session");
1997                Err(AppError::WebSocketError(format!(
1998                    "IG closed the streaming session: {e}"
1999                )))
2000            }
2001            ClosedReason::ReconnectExhausted { attempts, last } => {
2002                error!(attempts, last_reason = ?last, "Streaming reconnection budget exhausted");
2003                Err(AppError::WebSocketError(format!(
2004                    "streaming reconnection budget exhausted after {attempts} attempts"
2005                )))
2006            }
2007            ClosedReason::Internal { reason } => {
2008                error!(%reason, "Streaming client failed internally");
2009                Err(AppError::WebSocketError(format!(
2010                    "streaming client failed internally: {reason}"
2011                )))
2012            }
2013            other => {
2014                error!(reason = ?other, "Streaming session closed");
2015                Err(AppError::WebSocketError(format!(
2016                    "streaming session closed: {other:?}"
2017                )))
2018            }
2019        }
2020    }
2021
2022    /// Disconnects the Lightstreamer session and tears down every converter
2023    /// task.
2024    ///
2025    /// The order matters: the converters are signalled and joined first, which
2026    /// drops their subscription streams and so unsubscribes, and only then is
2027    /// the session closed. Calling this more than once is safe — the task list
2028    /// is drained and the session handle is taken.
2029    ///
2030    /// # Errors
2031    ///
2032    /// Returns [`AppError::WebSocketError`] if closing the session failed. The
2033    /// converter tasks are stopped either way.
2034    pub async fn disconnect(&mut self) -> Result<(), AppError> {
2035        // Ignore the send error: it only means every converter has already
2036        // exited, which is precisely the state we are asking for.
2037        let _ = self.shutdown_tx.send(true);
2038
2039        let converter_count = self.converter_tasks.len();
2040        join_tasks(&mut self.converter_tasks).await;
2041        if converter_count > 0 {
2042            debug!("Stopped {converter_count} converter task(s)");
2043        }
2044
2045        self.session_events = None;
2046
2047        if let Some(client) = self.client.take() {
2048            client.disconnect().await?;
2049            info!("Streaming session closed");
2050        }
2051
2052        Ok(())
2053    }
2054}
2055
2056/// Waits for the caller's shutdown signal, or for `SIGINT` / `SIGTERM` when
2057/// there is none.
2058///
2059/// `lightstreamer-rs` 1.0 deliberately does not install signal handlers — that
2060/// is not a protocol client's job — so the wait lives here.
2061#[cfg(feature = "streaming")]
2062async fn wait_for_shutdown(signal: Option<Arc<Notify>>) {
2063    if let Some(signal) = signal {
2064        signal.notified().await;
2065        return;
2066    }
2067
2068    #[cfg(unix)]
2069    {
2070        use tokio::signal::unix::{SignalKind, signal};
2071        // A handler that cannot be installed must not silently disable
2072        // shutdown, so fall back to waiting forever only after saying so.
2073        match (
2074            signal(SignalKind::interrupt()),
2075            signal(SignalKind::terminate()),
2076        ) {
2077            (Ok(mut sigint), Ok(mut sigterm)) => {
2078                tokio::select! {
2079                    _ = sigint.recv() => info!("SIGINT received"),
2080                    _ = sigterm.recv() => info!("SIGTERM received"),
2081                }
2082            }
2083            (sigint, sigterm) => {
2084                if let Err(e) = sigint {
2085                    error!(error = %e, "cannot install the SIGINT handler");
2086                }
2087                if let Err(e) = sigterm {
2088                    error!(error = %e, "cannot install the SIGTERM handler");
2089                }
2090                std::future::pending::<()>().await;
2091            }
2092        }
2093    }
2094
2095    #[cfg(not(unix))]
2096    {
2097        if let Err(e) = tokio::signal::ctrl_c().await {
2098            error!(error = %e, "cannot wait for Ctrl-C");
2099            std::future::pending::<()>().await;
2100        }
2101    }
2102}
2103
2104#[cfg(feature = "streaming")]
2105impl Drop for StreamerClient {
2106    /// Signals and then abandons any converter task that
2107    /// [`StreamerClient::disconnect`] did not already join, so dropping the
2108    /// client never leaves one running. Dropping the session handle closes the
2109    /// Lightstreamer session; neither step can await, which is why
2110    /// `disconnect` is still the way to observe the close completing.
2111    fn drop(&mut self) {
2112        let _ = self.shutdown_tx.send(true);
2113        for handle in self.converter_tasks.drain(..) {
2114            handle.abort();
2115        }
2116    }
2117}
2118
2119#[cfg(test)]
2120mod tests {
2121    use super::{ensure_zone_designator, is_transient_confirmation_error};
2122    use crate::error::AppError;
2123    use reqwest::StatusCode;
2124
2125    #[test]
2126    fn test_ensure_zone_designator_appends_z_when_missing() {
2127        assert_eq!(
2128            ensure_zone_designator("2026-01-01T00:00:00"),
2129            "2026-01-01T00:00:00Z"
2130        );
2131        assert_eq!(
2132            ensure_zone_designator(" 2026-01-01T00:00:00 "),
2133            "2026-01-01T00:00:00Z"
2134        );
2135    }
2136
2137    #[test]
2138    fn test_ensure_zone_designator_expands_date_only_to_midnight_utc() {
2139        assert_eq!(ensure_zone_designator("2026-01-01"), "2026-01-01T00:00:00Z");
2140    }
2141
2142    #[test]
2143    fn test_ensure_zone_designator_keeps_existing_designator() {
2144        assert_eq!(
2145            ensure_zone_designator("2026-01-01T00:00:00Z"),
2146            "2026-01-01T00:00:00Z"
2147        );
2148        assert_eq!(
2149            ensure_zone_designator("2026-01-01T00:00:00+01:00"),
2150            "2026-01-01T00:00:00+01:00"
2151        );
2152        assert_eq!(
2153            ensure_zone_designator("2026-01-01T00:00:00-05:00"),
2154            "2026-01-01T00:00:00-05:00"
2155        );
2156    }
2157
2158    #[test]
2159    fn test_confirmation_error_rate_limit_is_transient() {
2160        assert!(is_transient_confirmation_error(
2161            &AppError::RateLimitExceeded
2162        ));
2163    }
2164
2165    #[test]
2166    fn test_confirmation_error_not_found_is_transient() {
2167        // Confirmation not yet available: IG returns 404 until the deal settles.
2168        assert!(is_transient_confirmation_error(&AppError::NotFound));
2169        assert!(is_transient_confirmation_error(&AppError::Unexpected(
2170            StatusCode::NOT_FOUND
2171        )));
2172    }
2173
2174    #[test]
2175    fn test_confirmation_error_server_error_is_transient() {
2176        assert!(is_transient_confirmation_error(&AppError::Unexpected(
2177            StatusCode::INTERNAL_SERVER_ERROR
2178        )));
2179        assert!(is_transient_confirmation_error(&AppError::Unexpected(
2180            StatusCode::BAD_GATEWAY
2181        )));
2182    }
2183
2184    #[test]
2185    fn test_confirmation_error_invalid_input_is_permanent() {
2186        assert!(!is_transient_confirmation_error(&AppError::InvalidInput(
2187            "bad".to_string()
2188        )));
2189    }
2190
2191    #[test]
2192    fn test_confirmation_error_auth_and_deser_are_permanent() {
2193        assert!(!is_transient_confirmation_error(&AppError::Unauthorized));
2194        assert!(!is_transient_confirmation_error(
2195            &AppError::OAuthTokenExpired
2196        ));
2197        assert!(!is_transient_confirmation_error(
2198            &AppError::Deserialization("bad".to_string())
2199        ));
2200        assert!(!is_transient_confirmation_error(&AppError::Unexpected(
2201            StatusCode::BAD_REQUEST
2202        )));
2203    }
2204}
2205
2206// The streaming helpers these tests exercise only exist with the `streaming`
2207// feature, so they live in their own gated module rather than behind per-test
2208// attributes.
2209#[cfg(all(test, feature = "streaming"))]
2210mod streaming_tests {
2211    use super::{ClosedReason, StreamerClient, join_tasks};
2212    use crate::error::AppError;
2213    use lightstreamer_rs::ServerError;
2214    use std::time::Duration;
2215    use tokio::sync::watch;
2216    use tokio::task::JoinHandle;
2217
2218    // --- Terminal close classification -------------------------------------
2219    //
2220    // These replace the previous `is_graceful_close` tests, which matched a
2221    // marker string inside an error message because the 0.3 error type had no
2222    // graceful-close discriminant. 1.0 has one.
2223
2224    #[test]
2225    fn test_close_by_client_is_success() {
2226        let result = StreamerClient::report_close(&ClosedReason::ByClient);
2227        assert!(
2228            result.is_ok(),
2229            "a close this client asked for is not a failure: {result:?}"
2230        );
2231    }
2232
2233    #[test]
2234    fn test_close_by_server_is_an_error() {
2235        // IG's Metadata Adapter refuses with a code below the protocol's range.
2236        let reason = ClosedReason::ByServer(ServerError::new(-1, "Insufficient permissions"));
2237        let result = StreamerClient::report_close(&reason);
2238        assert!(
2239            matches!(result, Err(AppError::WebSocketError(_))),
2240            "a server-initiated close must surface as an error: {result:?}"
2241        );
2242    }
2243
2244    #[test]
2245    fn test_close_after_exhausted_reconnection_is_an_error() {
2246        let result = StreamerClient::report_close(&ClosedReason::ReconnectExhausted {
2247            attempts: 8,
2248            last: None,
2249        });
2250        match result {
2251            Err(AppError::WebSocketError(message)) => {
2252                assert!(
2253                    message.contains('8'),
2254                    "the attempt count belongs in the message: {message}"
2255                );
2256            }
2257            other => panic!("expected a websocket error, got {other:?}"),
2258        }
2259    }
2260
2261    #[test]
2262    fn test_close_on_internal_failure_is_an_error() {
2263        let reason = ClosedReason::Internal {
2264            reason: "bug".to_string(),
2265        };
2266        assert!(matches!(
2267            StreamerClient::report_close(&reason),
2268            Err(AppError::WebSocketError(_))
2269        ));
2270    }
2271
2272    // --- Converter shutdown ------------------------------------------------
2273
2274    #[tokio::test]
2275    async fn test_watch_signal_stops_every_converter() {
2276        // One `watch` sender stands in for `StreamerClient::shutdown_tx`, and
2277        // three tasks for the converters. Unlike `Notify::notify_one`, a
2278        // `watch` send reaches every one of them, and unlike
2279        // `Notify::notify_waiters` it is level-triggered, so a task that is not
2280        // parked yet still observes it.
2281        let (tx, _) = watch::channel(false);
2282        let mut tasks: Vec<JoinHandle<()>> = Vec::new();
2283        for _ in 0..3 {
2284            let mut shutdown = tx.subscribe();
2285            tasks.push(tokio::spawn(async move {
2286                tokio::select! {
2287                    _ = shutdown.changed() => {}
2288                    () = std::future::pending::<()>() => {}
2289                }
2290            }));
2291        }
2292
2293        // Sent before any task is necessarily parked: the signal must not be
2294        // missed.
2295        assert!(tx.send(true).is_ok());
2296
2297        let joined = tokio::time::timeout(Duration::from_secs(1), join_tasks(&mut tasks)).await;
2298        assert!(joined.is_ok(), "converters did not observe the shutdown");
2299        assert!(tasks.is_empty(), "join_tasks must drain the list");
2300    }
2301
2302    #[tokio::test]
2303    async fn test_join_tasks_waits_for_completion() {
2304        let mut tasks: Vec<JoinHandle<()>> = vec![tokio::spawn(async {})];
2305        join_tasks(&mut tasks).await;
2306        assert!(tasks.is_empty());
2307    }
2308}