1use 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
79const MARKET_DETAILS_CONCURRENCY: usize = 6;
85
86#[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#[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#[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 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
157pub struct Client {
162 http_client: Arc<HttpClient>,
163}
164
165impl Client {
166 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 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 #[inline]
262 #[must_use]
263 pub fn config(&self) -> &Config {
264 self.http_client.config()
265 }
266
267 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 pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
306 self.http_client.ws_info().await
307 }
308
309 #[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 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 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 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 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 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 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 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 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#[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#[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#[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 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#[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 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#[cfg(feature = "streaming")]
1391#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
1392pub struct StreamerClient {
1393 account_id: String,
1394 config: ClientConfig,
1399 client: Option<LsClient>,
1402 session_events: Option<SessionEvents>,
1404 shutdown_tx: watch::Sender<bool>,
1408 converter_tasks: Vec<JoinHandle<()>>,
1412 has_market_stream_subs: bool,
1415 has_price_stream_subs: bool,
1416}
1417
1418#[cfg(feature = "streaming")]
1419impl StreamerClient {
1420 pub async fn new() -> Result<Self, AppError> {
1436 let client = Client::try_new()?;
1437 Self::with_client(&client).await
1438 }
1439
1440 pub async fn with_client(client: &Client) -> Result<Self, AppError> {
1454 let ws_info = client.ws_info().await?;
1455
1456 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 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 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 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 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 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 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 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 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 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 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 pub async fn connect(&mut self, shutdown_signal: Option<Arc<Notify>>) -> Result<(), AppError> {
1928 let Some(mut events) = self.session_events.take() else {
1929 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 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 debug!("Session event stream ended");
1960 return Ok(());
1961 };
1962
1963 match event {
1964 SessionEvent::Connected(connected) => match connected.continuity {
1965 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 SessionEvent::Unrecognized { line } => {
2002 trace!(%line, "Unrecognized streaming notification");
2003 }
2004 other => debug!(event = ?other, "Session event"),
2005 }
2006 }
2007 }
2008
2009 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 pub async fn disconnect(&mut self) -> Result<(), AppError> {
2060 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#[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 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 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 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#[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 #[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 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 #[tokio::test]
2300 async fn test_watch_signal_stops_every_converter() {
2301 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 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}