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