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 ws_info(&self) -> Result<WebsocketInfo, AppError> {
281 self.http_client.ws_info().await
282 }
283
284 #[deprecated(
289 note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
290 )]
291 pub async fn get_ws_info(&self) -> WebsocketInfo {
292 self.ws_info().await.unwrap_or_default()
293 }
294}
295
296#[async_trait]
297impl MarketService for Client {
298 async fn search_markets(&self, search_term: &str) -> Result<MarketSearchResponse, AppError> {
299 let path = format!("markets?searchTerm={}", search_term);
300 info!("Searching markets with term: {}", search_term);
301 let result: MarketSearchResponse = self.http_client.get(&path, Some(1)).await?;
302 debug!("{} markets found", result.markets.len());
303 Ok(result)
304 }
305
306 async fn get_market_details(&self, epic: &str) -> Result<MarketDetails, AppError> {
307 let path = format!("markets/{epic}");
308 info!("Getting market details: {}", epic);
309 let market_details: MarketDetails = self.http_client.get(&path, Some(3)).await?;
313 debug!("Market details obtained for: {}", epic);
314 Ok(market_details)
315 }
316
317 async fn get_multiple_market_details(
318 &self,
319 epics: &[String],
320 ) -> Result<MultipleMarketDetailsResponse, AppError> {
321 if epics.is_empty() {
322 return Ok(MultipleMarketDetailsResponse::default());
323 } else if epics.len() > 50 {
324 return Err(AppError::InvalidInput(
325 "The maximum number of EPICs is 50".to_string(),
326 ));
327 }
328
329 let epics_str = epics.join(",");
330 let path = format!("markets?epics={}", epics_str);
331 debug!(
332 "Getting market details for {} EPICs in a batch",
333 epics.len()
334 );
335
336 let response: MultipleMarketDetailsResponse = self.http_client.get(&path, Some(2)).await?;
337
338 Ok(response)
339 }
340
341 async fn get_historical_prices(
342 &self,
343 epic: &str,
344 resolution: &str,
345 from: &str,
346 to: &str,
347 ) -> Result<HistoricalPricesResponse, AppError> {
348 let path = format!(
349 "prices/{}?resolution={}&from={}&to={}",
350 epic, resolution, from, to
351 );
352 info!("Getting historical prices for: {}", epic);
353 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(3)).await?;
354 debug!("Historical prices obtained for: {}", epic);
355 Ok(result)
356 }
357
358 async fn get_historical_prices_by_date_range(
359 &self,
360 epic: &str,
361 resolution: &str,
362 start_date: &str,
363 end_date: &str,
364 ) -> Result<HistoricalPricesResponse, AppError> {
365 let path = format!("prices/{}/{}/{}/{}", epic, resolution, start_date, end_date);
366 info!(
367 "Getting historical prices for epic: {}, resolution: {}, from: {} to: {}",
368 epic, resolution, start_date, end_date
369 );
370 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(2)).await?;
371 debug!(
372 "Historical prices obtained for epic: {}, {} data points",
373 epic,
374 result.prices.len()
375 );
376 Ok(result)
377 }
378
379 async fn get_recent_prices(
380 &self,
381 params: &RecentPricesRequest<'_>,
382 ) -> Result<HistoricalPricesResponse, AppError> {
383 let mut query_params = Vec::new();
384
385 if let Some(res) = params.resolution {
386 query_params.push(format!("resolution={}", res));
387 }
388 if let Some(f) = params.from {
389 query_params.push(format!("from={}", f));
390 }
391 if let Some(t) = params.to {
392 query_params.push(format!("to={}", t));
393 }
394 if let Some(max) = params.max_points {
395 query_params.push(format!("max={}", max));
396 }
397 if let Some(size) = params.page_size {
398 query_params.push(format!("pageSize={}", size));
399 }
400 if let Some(num) = params.page_number {
401 query_params.push(format!("pageNumber={}", num));
402 }
403
404 let query_string = if query_params.is_empty() {
405 String::new()
406 } else {
407 format!("?{}", query_params.join("&"))
408 };
409
410 let path = format!("prices/{}{}", params.epic, query_string);
411 info!("Getting recent prices for epic: {}", params.epic);
412 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(3)).await?;
413 debug!(
414 "Recent prices obtained for epic: {}, {} data points",
415 params.epic,
416 result.prices.len()
417 );
418 Ok(result)
419 }
420
421 async fn get_historical_prices_by_count_v1(
422 &self,
423 epic: &str,
424 resolution: &str,
425 num_points: u32,
426 ) -> Result<HistoricalPricesResponse, AppError> {
427 let path = format!("prices/{}/{}/{}", epic, resolution, num_points);
428 info!(
429 "Getting historical prices (v1) for epic: {}, resolution: {}, points: {}",
430 epic, resolution, num_points
431 );
432 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(1)).await?;
433 debug!(
434 "Historical prices (v1) obtained for epic: {}, {} data points",
435 epic,
436 result.prices.len()
437 );
438 Ok(result)
439 }
440
441 async fn get_historical_prices_by_count_v2(
442 &self,
443 epic: &str,
444 resolution: &str,
445 num_points: u32,
446 ) -> Result<HistoricalPricesResponse, AppError> {
447 let path = format!("prices/{}/{}/{}", epic, resolution, num_points);
448 info!(
449 "Getting historical prices (v2) for epic: {}, resolution: {}, points: {}",
450 epic, resolution, num_points
451 );
452 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(2)).await?;
453 debug!(
454 "Historical prices (v2) obtained for epic: {}, {} data points",
455 epic,
456 result.prices.len()
457 );
458 Ok(result)
459 }
460
461 async fn get_market_navigation(&self) -> Result<MarketNavigationResponse, AppError> {
462 let path = "marketnavigation";
463 info!("Getting top-level market navigation nodes");
464 let result: MarketNavigationResponse = self.http_client.get(path, Some(1)).await?;
465 debug!("{} navigation nodes found", result.nodes.len());
466 debug!("{} markets found at root level", result.markets.len());
467 Ok(result)
468 }
469
470 async fn get_market_navigation_node(
471 &self,
472 node_id: &str,
473 ) -> Result<MarketNavigationResponse, AppError> {
474 let path = format!("marketnavigation/{}", node_id);
475 info!("Getting market navigation node: {}", node_id);
476 let result: MarketNavigationResponse = self.http_client.get(&path, Some(1)).await?;
477 debug!("{} child nodes found", result.nodes.len());
478 debug!("{} markets found in node {}", result.markets.len(), node_id);
479 Ok(result)
480 }
481
482 async fn get_all_markets(&self) -> Result<Vec<MarketData>, AppError> {
483 let max_depth = 6;
484 info!(
485 "Starting comprehensive market hierarchy traversal (max {} levels)",
486 max_depth
487 );
488
489 let root_response = self.get_market_navigation().await?;
490 info!(
491 "Root navigation: {} nodes, {} markets at top level",
492 root_response.nodes.len(),
493 root_response.markets.len()
494 );
495
496 let mut seen_epics: HashSet<String> = HashSet::new();
500 let mut all_markets: Vec<MarketData> = Vec::new();
501 for market in root_response.markets {
502 if seen_epics.insert(market.epic.clone()) {
503 all_markets.push(market);
504 }
505 }
506 let mut nodes_to_process = root_response.nodes;
507 let mut processed_levels = 0;
508
509 while !nodes_to_process.is_empty() && processed_levels < max_depth {
510 let mut next_level_nodes = Vec::new();
511 let mut level_market_count = 0;
512
513 info!(
514 "Processing level {} with {} nodes",
515 processed_levels,
516 nodes_to_process.len()
517 );
518
519 for node in &nodes_to_process {
520 match self.get_market_navigation_node(&node.id).await {
521 Ok(node_response) => {
522 let node_markets = node_response.markets.len();
523 let node_children = node_response.nodes.len();
524
525 if node_markets > 0 || node_children > 0 {
526 debug!(
527 "Node '{}' (level {}): {} markets, {} child nodes",
528 node.name, processed_levels, node_markets, node_children
529 );
530 }
531
532 for market in node_response.markets {
535 if seen_epics.insert(market.epic.clone()) {
536 all_markets.push(market);
537 level_market_count += 1;
538 }
539 }
540 next_level_nodes.extend(node_response.nodes);
541 }
542 Err(e) => {
543 tracing::error!(
544 "Failed to get markets for node '{}' at level {}: {:?}",
545 node.name,
546 processed_levels,
547 e
548 );
549 }
550 }
551 }
552
553 info!(
554 "Level {} completed: {} markets found, {} nodes for next level",
555 processed_levels,
556 level_market_count,
557 next_level_nodes.len()
558 );
559
560 nodes_to_process = next_level_nodes;
561 processed_levels += 1;
562 }
563
564 info!(
565 "Market hierarchy traversal completed: {} total markets found across {} levels",
566 all_markets.len(),
567 processed_levels
568 );
569
570 Ok(all_markets)
571 }
572
573 async fn get_vec_db_entries(&self) -> Result<Vec<DBEntryResponse>, AppError> {
574 info!("Getting all markets from hierarchy for DB entries");
575
576 let all_markets = self.get_all_markets().await?;
577 info!("Collected {} markets from hierarchy", all_markets.len());
578
579 let mut vec_db_entries: Vec<DBEntryResponse> = all_markets
580 .iter()
581 .map(DBEntryResponse::from)
582 .filter(|entry| !entry.epic.is_empty())
583 .collect();
584
585 info!("Created {} DB entries from markets", vec_db_entries.len());
586
587 let mut symbol_info: std::collections::HashMap<String, (String, String)> =
593 std::collections::HashMap::new();
594 for entry in &vec_db_entries {
595 if entry.symbol.is_empty() || entry.epic.is_empty() {
596 continue;
597 }
598 symbol_info
599 .entry(entry.symbol.clone())
600 .or_insert_with(|| (entry.epic.clone(), entry.expiry.clone()));
601 }
602
603 info!(
604 "Found {} unique symbols to fetch expiry dates for",
605 symbol_info.len()
606 );
607
608 let symbol_expiry_map: std::collections::HashMap<String, String> =
612 futures::stream::iter(symbol_info)
613 .map(|(symbol, (epic, fallback_expiry))| async move {
614 match self.get_market_details(&epic).await {
615 Ok(market_details) => {
616 let expiry_date = market_details
617 .instrument
618 .expiry_details
619 .as_ref()
620 .map(|details| details.last_dealing_date.clone())
621 .unwrap_or_else(|| market_details.instrument.expiry.clone());
622
623 info!(
624 symbol = %symbol,
625 expiry = %expiry_date,
626 "fetched expiry date for symbol"
627 );
628 (symbol, expiry_date)
629 }
630 Err(e) => {
631 tracing::error!(
632 "Failed to get market details for epic {} (symbol {}): {:?}",
633 epic,
634 symbol,
635 e
636 );
637 (symbol, fallback_expiry)
638 }
639 }
640 })
641 .buffer_unordered(MARKET_DETAILS_CONCURRENCY)
642 .collect()
643 .await;
644
645 for entry in &mut vec_db_entries {
646 if let Some(expiry_date) = symbol_expiry_map.get(&entry.symbol) {
647 entry.expiry = expiry_date.clone();
648 }
649 }
650
651 info!("Updated expiry dates for {} entries", vec_db_entries.len());
652 Ok(vec_db_entries)
653 }
654
655 async fn get_categories(&self) -> Result<CategoriesResponse, AppError> {
656 info!("Getting all categories of instruments");
657 let result: CategoriesResponse = self.http_client.get("categories", Some(1)).await?;
658 debug!("{} categories found", result.categories.len());
659 Ok(result)
660 }
661
662 async fn get_category_instruments(
663 &self,
664 category_id: &str,
665 page_number: Option<u32>,
666 page_size: Option<u32>,
667 ) -> Result<CategoryInstrumentsResponse, AppError> {
668 let mut path = format!("categories/{}/instruments", category_id);
669
670 let mut query_params = Vec::new();
671 if let Some(page) = page_number {
672 query_params.push(format!("pageNumber={}", page));
673 }
674 if let Some(size) = page_size {
675 if size > 1000 {
676 return Err(AppError::InvalidInput(
677 "pageSize cannot exceed 1000".to_string(),
678 ));
679 }
680 query_params.push(format!("pageSize={}", size));
681 }
682
683 if !query_params.is_empty() {
684 path = format!("{}?{}", path, query_params.join("&"));
685 }
686
687 info!(
688 "Getting instruments for category: {} (page: {:?}, size: {:?})",
689 category_id, page_number, page_size
690 );
691 let result: CategoryInstrumentsResponse = self.http_client.get(&path, Some(1)).await?;
692 debug!(
693 "{} instruments found in category {}",
694 result.instruments.len(),
695 category_id
696 );
697 Ok(result)
698 }
699}
700
701#[async_trait]
702impl AccountService for Client {
703 async fn get_accounts(&self) -> Result<AccountsResponse, AppError> {
704 info!("Getting account information");
705 let result: AccountsResponse = self.http_client.get("accounts", Some(1)).await?;
706 debug!(
707 "Account information obtained: {} accounts",
708 result.accounts.len()
709 );
710 Ok(result)
711 }
712
713 async fn get_positions(&self) -> Result<PositionsResponse, AppError> {
714 debug!("Getting open positions");
715 let result: PositionsResponse = self.http_client.get("positions", Some(2)).await?;
716 debug!("Positions obtained: {} positions", result.positions.len());
717 Ok(result)
718 }
719
720 async fn get_positions_w_filter(&self, filter: &str) -> Result<PositionsResponse, AppError> {
721 debug!("Getting open positions with filter: {}", filter);
722 let mut positions = self.get_positions().await?;
723
724 positions
725 .positions
726 .retain(|position| position.market.epic.contains(filter));
727
728 debug!(
729 "Positions obtained after filtering: {} positions",
730 positions.positions.len()
731 );
732 Ok(positions)
733 }
734
735 async fn get_working_orders(&self) -> Result<WorkingOrdersResponse, AppError> {
736 info!("Getting working orders");
737 let result: WorkingOrdersResponse = self.http_client.get("workingorders", Some(2)).await?;
738 debug!(
739 "Working orders obtained: {} orders",
740 result.working_orders.len()
741 );
742 Ok(result)
743 }
744
745 async fn get_activity(
746 &self,
747 from: &str,
748 to: &str,
749 ) -> Result<AccountActivityResponse, AppError> {
750 let path = format!("history/activity?from={}&to={}&pageSize=500", from, to);
751 info!("Getting account activity");
752 let result: AccountActivityResponse = self.http_client.get(&path, Some(3)).await?;
753 debug!(
754 "Account activity obtained: {} activities",
755 result.activities.len()
756 );
757 Ok(result)
758 }
759
760 async fn get_activity_with_details(
761 &self,
762 from: &str,
763 to: &str,
764 ) -> Result<AccountActivityResponse, AppError> {
765 let path = format!(
766 "history/activity?from={}&to={}&detailed=true&pageSize=500",
767 from, to
768 );
769 info!("Getting detailed account activity");
770 let result: AccountActivityResponse = self.http_client.get(&path, Some(3)).await?;
771 debug!(
772 "Detailed account activity obtained: {} activities",
773 result.activities.len()
774 );
775 Ok(result)
776 }
777
778 async fn get_transactions(
779 &self,
780 from: &str,
781 to: &str,
782 ) -> Result<TransactionHistoryResponse, AppError> {
783 const PAGE_SIZE: u32 = 200;
784 let mut all_transactions = Vec::new();
785 let mut current_page = 1;
786 #[allow(unused_assignments)]
787 let mut last_metadata = None;
788
789 loop {
790 let path = format!(
791 "history/transactions?from={}&to={}&pageSize={}&pageNumber={}",
792 from, to, PAGE_SIZE, current_page
793 );
794 info!("Getting transaction history page {}", current_page);
795
796 let result: TransactionHistoryResponse = self.http_client.get(&path, Some(2)).await?;
797
798 let total_pages = result.metadata.page_data.total_pages as u32;
799 last_metadata = Some(result.metadata);
800 all_transactions.extend(result.transactions);
801
802 if current_page >= total_pages {
803 break;
804 }
805 current_page += 1;
806 }
807
808 debug!(
809 "Total transaction history obtained: {} transactions",
810 all_transactions.len()
811 );
812
813 Ok(TransactionHistoryResponse {
814 transactions: all_transactions,
815 metadata: last_metadata
816 .ok_or_else(|| AppError::InvalidInput("Could not retrieve metadata".to_string()))?,
817 })
818 }
819
820 async fn get_preferences(&self) -> Result<AccountPreferencesResponse, AppError> {
821 info!("Getting account preferences");
822 let result: AccountPreferencesResponse = self
823 .http_client
824 .get("accounts/preferences", Some(1))
825 .await?;
826 debug!(
827 "Account preferences obtained: trailing_stops_enabled={}",
828 result.trailing_stops_enabled
829 );
830 Ok(result)
831 }
832
833 async fn update_preferences(&self, trailing_stops_enabled: bool) -> Result<(), AppError> {
834 info!(
835 "Updating account preferences: trailing_stops_enabled={}",
836 trailing_stops_enabled
837 );
838 let request = serde_json::json!({
839 "trailingStopsEnabled": trailing_stops_enabled
840 });
841 let _: serde_json::Value = self
842 .http_client
843 .put("accounts/preferences", &request, Some(1))
844 .await?;
845 debug!("Account preferences updated");
846 Ok(())
847 }
848
849 async fn get_activity_by_period(
850 &self,
851 period_ms: u64,
852 ) -> Result<AccountActivityResponse, AppError> {
853 let path = format!("history/activity/{}", period_ms);
854 info!("Getting account activity for period: {} ms", period_ms);
855 let result: AccountActivityResponse = self.http_client.get(&path, Some(1)).await?;
856 debug!(
857 "Account activity obtained: {} activities",
858 result.activities.len()
859 );
860 Ok(result)
861 }
862}
863
864#[async_trait]
865impl OrderService for Client {
866 async fn create_order(
867 &self,
868 order: &CreateOrderRequest,
869 ) -> Result<CreateOrderResponse, AppError> {
870 info!("Creating order for: {}", order.epic);
871 let result: CreateOrderResponse = self
872 .http_client
873 .post("positions/otc", order, Some(2))
874 .await?;
875 debug!("Order created with reference: {}", result.deal_reference);
876 Ok(result)
877 }
878
879 async fn get_order_confirmation(
880 &self,
881 deal_reference: &str,
882 ) -> Result<OrderConfirmationResponse, AppError> {
883 let path = format!("confirms/{}", deal_reference);
884 info!("Getting confirmation for order: {}", deal_reference);
885 let result: OrderConfirmationResponse = self.http_client.get(&path, Some(1)).await?;
886 debug!("Confirmation obtained for order: {}", deal_reference);
887 Ok(result)
888 }
889
890 async fn get_order_confirmation_w_retry(
891 &self,
892 deal_reference: &str,
893 retries: u64,
894 delay_ms: u64,
895 ) -> Result<OrderConfirmationResponse, AppError> {
896 let base = Duration::from_millis(delay_ms);
899 let mut attempt: u32 = 0;
900 loop {
901 match self.get_order_confirmation(deal_reference).await {
902 Ok(response) => return Ok(response),
903 Err(e) => {
904 if !is_transient_confirmation_error(&e) {
907 return Err(e);
908 }
909 if u64::from(attempt) >= retries {
910 return Err(e);
911 }
912 let delay = backoff_delay(base, attempt);
913 let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
914 let next_attempt = attempt.checked_add(1).ok_or_else(|| {
915 AppError::Generic("retry attempt counter overflow".to_string())
916 })?;
917 warn!(
918 deal_reference = %deal_reference,
919 attempt = next_attempt,
920 max_retries = retries,
921 delay_ms,
922 "retrying order confirmation after transient error"
923 );
924 sleep(delay).await;
925 attempt = next_attempt;
926 }
927 }
928 }
929 }
930
931 async fn update_position(
932 &self,
933 deal_id: &str,
934 update: &UpdatePositionRequest,
935 ) -> Result<UpdatePositionResponse, AppError> {
936 let path = format!("positions/otc/{}", deal_id);
937 info!("Updating position: {}", deal_id);
938 let result: UpdatePositionResponse = self.http_client.put(&path, update, Some(2)).await?;
939 debug!(
940 "Position updated: {} with deal reference: {}",
941 deal_id, result.deal_reference
942 );
943 Ok(result)
944 }
945
946 async fn update_level_in_position(
947 &self,
948 deal_id: &str,
949 limit_level: Option<f64>,
950 ) -> Result<UpdatePositionResponse, AppError> {
951 let path = format!("positions/otc/{}", deal_id);
952 info!("Updating position: {}", deal_id);
953 let limit_level = limit_level.unwrap_or(0.0);
954
955 let update: UpdatePositionRequest = UpdatePositionRequest {
956 guaranteed_stop: None,
957 limit_level: Some(limit_level),
958 stop_level: None,
959 trailing_stop: None,
960 trailing_stop_distance: None,
961 trailing_stop_increment: None,
962 };
963 let result: UpdatePositionResponse = self.http_client.put(&path, update, Some(2)).await?;
964 debug!(
965 "Position updated: {} with deal reference: {}",
966 deal_id, result.deal_reference
967 );
968 Ok(result)
969 }
970
971 async fn close_position(
972 &self,
973 close_request: &ClosePositionRequest,
974 ) -> Result<ClosePositionResponse, AppError> {
975 info!("Closing position");
976
977 let result: ClosePositionResponse = self
980 .http_client
981 .post_with_delete_method("positions/otc", close_request, Some(1))
982 .await?;
983
984 debug!("Position closed with reference: {}", result.deal_reference);
985 Ok(result)
986 }
987
988 async fn create_working_order(
989 &self,
990 order: &CreateWorkingOrderRequest,
991 ) -> Result<CreateWorkingOrderResponse, AppError> {
992 info!("Creating working order for: {}", order.epic);
993 let result: CreateWorkingOrderResponse = self
994 .http_client
995 .post("workingorders/otc", order, Some(2))
996 .await?;
997 debug!(
998 "Working order created with reference: {}",
999 result.deal_reference
1000 );
1001 Ok(result)
1002 }
1003
1004 async fn delete_working_order(&self, deal_id: &str) -> Result<(), AppError> {
1005 let path = format!("workingorders/otc/{}", deal_id);
1006 let result: CreateWorkingOrderResponse =
1007 self.http_client.delete(path.as_str(), Some(2)).await?;
1008 debug!(
1009 "Working order created with reference: {}",
1010 result.deal_reference
1011 );
1012 Ok(())
1013 }
1014
1015 async fn get_position(&self, deal_id: &str) -> Result<SinglePositionResponse, AppError> {
1016 let path = format!("positions/{}", deal_id);
1017 info!("Getting position: {}", deal_id);
1018 let result: SinglePositionResponse = self.http_client.get(&path, Some(2)).await?;
1019 debug!("Position obtained for deal: {}", deal_id);
1020 Ok(result)
1021 }
1022
1023 async fn update_working_order(
1024 &self,
1025 deal_id: &str,
1026 update: &UpdateWorkingOrderRequest,
1027 ) -> Result<CreateWorkingOrderResponse, AppError> {
1028 let path = format!("workingorders/otc/{}", deal_id);
1029 info!("Updating working order: {}", deal_id);
1030 let result: CreateWorkingOrderResponse =
1031 self.http_client.put(&path, update, Some(2)).await?;
1032 debug!(
1033 "Working order updated: {} with reference: {}",
1034 deal_id, result.deal_reference
1035 );
1036 Ok(result)
1037 }
1038}
1039
1040#[async_trait]
1045impl WatchlistService for Client {
1046 async fn get_watchlists(&self) -> Result<WatchlistsResponse, AppError> {
1047 info!("Getting all watchlists");
1048 let result: WatchlistsResponse = self.http_client.get("watchlists", Some(1)).await?;
1049 debug!(
1050 "Watchlists obtained: {} watchlists",
1051 result.watchlists.len()
1052 );
1053 Ok(result)
1054 }
1055
1056 async fn create_watchlist(
1057 &self,
1058 name: &str,
1059 epics: Option<&[String]>,
1060 ) -> Result<CreateWatchlistResponse, AppError> {
1061 info!("Creating watchlist: {}", name);
1062 let request = CreateWatchlistRequest {
1063 name: name.to_string(),
1064 epics: epics.map(|e| e.to_vec()),
1065 };
1066 let result: CreateWatchlistResponse = self
1067 .http_client
1068 .post("watchlists", &request, Some(1))
1069 .await?;
1070 debug!(
1071 "Watchlist created: {} with ID: {}",
1072 name, result.watchlist_id
1073 );
1074 Ok(result)
1075 }
1076
1077 async fn get_watchlist(
1078 &self,
1079 watchlist_id: &str,
1080 ) -> Result<WatchlistMarketsResponse, AppError> {
1081 let path = format!("watchlists/{}", watchlist_id);
1082 info!("Getting watchlist: {}", watchlist_id);
1083 let result: WatchlistMarketsResponse = self.http_client.get(&path, Some(1)).await?;
1084 debug!(
1085 "Watchlist obtained: {} with {} markets",
1086 watchlist_id,
1087 result.markets.len()
1088 );
1089 Ok(result)
1090 }
1091
1092 async fn delete_watchlist(&self, watchlist_id: &str) -> Result<StatusResponse, AppError> {
1093 let path = format!("watchlists/{}", watchlist_id);
1094 info!("Deleting watchlist: {}", watchlist_id);
1095 let result: StatusResponse = self.http_client.delete(&path, Some(1)).await?;
1096 debug!("Watchlist deleted: {}", watchlist_id);
1097 Ok(result)
1098 }
1099
1100 async fn add_to_watchlist(
1101 &self,
1102 watchlist_id: &str,
1103 epic: &str,
1104 ) -> Result<StatusResponse, AppError> {
1105 let path = format!("watchlists/{}", watchlist_id);
1106 info!("Adding {} to watchlist: {}", epic, watchlist_id);
1107 let request = AddToWatchlistRequest {
1108 epic: epic.to_string(),
1109 };
1110 let result: StatusResponse = self.http_client.put(&path, &request, Some(1)).await?;
1111 debug!("Added {} to watchlist: {}", epic, watchlist_id);
1112 Ok(result)
1113 }
1114
1115 async fn remove_from_watchlist(
1116 &self,
1117 watchlist_id: &str,
1118 epic: &str,
1119 ) -> Result<StatusResponse, AppError> {
1120 let path = format!("watchlists/{}/{}", watchlist_id, epic);
1121 info!("Removing {} from watchlist: {}", epic, watchlist_id);
1122 let result: StatusResponse = self.http_client.delete(&path, Some(1)).await?;
1123 debug!("Removed {} from watchlist: {}", epic, watchlist_id);
1124 Ok(result)
1125 }
1126}
1127
1128#[async_trait]
1133impl SentimentService for Client {
1134 async fn get_client_sentiment(
1135 &self,
1136 market_ids: &[String],
1137 ) -> Result<ClientSentimentResponse, AppError> {
1138 let market_ids_str = market_ids.join(",");
1139 let path = format!("clientsentiment?marketIds={}", market_ids_str);
1140 info!("Getting client sentiment for {} markets", market_ids.len());
1141 let result: ClientSentimentResponse = self.http_client.get(&path, Some(1)).await?;
1142 debug!(
1143 "Client sentiment obtained for {} markets",
1144 result.client_sentiments.len()
1145 );
1146 Ok(result)
1147 }
1148
1149 async fn get_client_sentiment_by_market(
1150 &self,
1151 market_id: &str,
1152 ) -> Result<MarketSentiment, AppError> {
1153 let path = format!("clientsentiment/{}", market_id);
1154 info!("Getting client sentiment for market: {}", market_id);
1155 let result: MarketSentiment = self.http_client.get(&path, Some(1)).await?;
1156 debug!(
1157 "Client sentiment for {}: {}% long, {}% short",
1158 market_id, result.long_position_percentage, result.short_position_percentage
1159 );
1160 Ok(result)
1161 }
1162
1163 async fn get_related_sentiment(
1164 &self,
1165 market_id: &str,
1166 ) -> Result<ClientSentimentResponse, AppError> {
1167 let path = format!("clientsentiment/related/{}", market_id);
1168 info!("Getting related sentiment for market: {}", market_id);
1169 let result: ClientSentimentResponse = self.http_client.get(&path, Some(1)).await?;
1170 debug!(
1171 "Related sentiment obtained: {} markets",
1172 result.client_sentiments.len()
1173 );
1174 Ok(result)
1175 }
1176}
1177
1178#[async_trait]
1183impl CostsService for Client {
1184 async fn get_indicative_costs_open(
1185 &self,
1186 request: &OpenCostsRequest,
1187 ) -> Result<IndicativeCostsResponse, AppError> {
1188 info!(
1189 "Getting indicative costs for opening position on: {}",
1190 request.epic
1191 );
1192 let result: IndicativeCostsResponse = self
1193 .http_client
1194 .post("indicativecostsandcharges/open", request, Some(1))
1195 .await?;
1196 debug!(
1197 "Indicative costs obtained, reference: {}",
1198 result.indicative_quote_reference
1199 );
1200 Ok(result)
1201 }
1202
1203 async fn get_indicative_costs_close(
1204 &self,
1205 request: &CloseCostsRequest,
1206 ) -> Result<IndicativeCostsResponse, AppError> {
1207 info!(
1208 "Getting indicative costs for closing position: {}",
1209 request.deal_id
1210 );
1211 let result: IndicativeCostsResponse = self
1212 .http_client
1213 .post("indicativecostsandcharges/close", request, Some(1))
1214 .await?;
1215 debug!(
1216 "Indicative costs obtained, reference: {}",
1217 result.indicative_quote_reference
1218 );
1219 Ok(result)
1220 }
1221
1222 async fn get_indicative_costs_edit(
1223 &self,
1224 request: &EditCostsRequest,
1225 ) -> Result<IndicativeCostsResponse, AppError> {
1226 info!(
1227 "Getting indicative costs for editing position: {}",
1228 request.deal_id
1229 );
1230 let result: IndicativeCostsResponse = self
1231 .http_client
1232 .post("indicativecostsandcharges/edit", request, Some(1))
1233 .await?;
1234 debug!(
1235 "Indicative costs obtained, reference: {}",
1236 result.indicative_quote_reference
1237 );
1238 Ok(result)
1239 }
1240
1241 async fn get_costs_history(
1242 &self,
1243 from: &str,
1244 to: &str,
1245 ) -> Result<CostsHistoryResponse, AppError> {
1246 const PAGE_SIZE: u32 = 50;
1253 let from = ensure_zone_designator(from);
1254 let to = ensure_zone_designator(to);
1255 let mut all_entries = Vec::new();
1256 let mut current_page: u32 = 1;
1257 #[allow(unused_assignments)]
1258 let mut last_pagination = None;
1259
1260 loop {
1261 let path = format!(
1262 "indicativecostsandcharges/history/from/{}/to/{}?pageSize={}&pageNumber={}",
1263 from, to, PAGE_SIZE, current_page
1264 );
1265 info!("Getting costs history page {}", current_page);
1266
1267 let result: CostsHistoryResponse = self.http_client.get(&path, Some(1)).await?;
1268
1269 let total_pages = result.pagination.total_pages;
1270 last_pagination = Some(result.pagination);
1271 all_entries.extend(result.costs_and_charges_history);
1272
1273 if i64::from(current_page) >= total_pages {
1274 break;
1275 }
1276 current_page += 1;
1277 }
1278
1279 debug!("Costs history obtained: {} entries", all_entries.len());
1280
1281 Ok(CostsHistoryResponse {
1282 pagination: last_pagination.ok_or_else(|| {
1283 AppError::InvalidInput("Could not retrieve pagination".to_string())
1284 })?,
1285 costs_and_charges_history: all_entries,
1286 })
1287 }
1288
1289 async fn get_durable_medium(
1290 &self,
1291 quote_reference: &str,
1292 ) -> Result<DurableMediumResponse, AppError> {
1293 let path = format!(
1294 "indicativecostsandcharges/durablemedium/{}",
1295 quote_reference
1296 );
1297 info!("Getting durable medium for reference: {}", quote_reference);
1298 let result: DurableMediumResponse = self.http_client.get(&path, Some(1)).await?;
1299 debug!("Durable medium obtained for reference: {}", quote_reference);
1300 Ok(result)
1301 }
1302}
1303
1304#[async_trait]
1309impl OperationsService for Client {
1310 async fn get_client_apps(&self) -> Result<ApplicationDetailsResponse, AppError> {
1311 info!("Getting client applications");
1312 let result: ApplicationDetailsResponse = self
1313 .http_client
1314 .get("operations/application", Some(1))
1315 .await?;
1316 debug!(
1318 name = ?result.name,
1319 status = %result.status,
1320 "Client application obtained"
1321 );
1322 Ok(result)
1323 }
1324
1325 async fn disable_client_app(&self) -> Result<StatusResponse, AppError> {
1326 info!("Disabling current client application");
1327 let result: StatusResponse = self
1328 .http_client
1329 .put(
1330 "operations/application/disable",
1331 &serde_json::json!({}),
1332 Some(1),
1333 )
1334 .await?;
1335 debug!("Client application disabled");
1336 Ok(result)
1337 }
1338}
1339
1340#[cfg(feature = "streaming")]
1366#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
1367pub struct StreamerClient {
1368 account_id: String,
1369 config: ClientConfig,
1374 client: Option<LsClient>,
1377 session_events: Option<SessionEvents>,
1379 shutdown_tx: watch::Sender<bool>,
1383 converter_tasks: Vec<JoinHandle<()>>,
1387 has_market_stream_subs: bool,
1390 has_price_stream_subs: bool,
1391}
1392
1393#[cfg(feature = "streaming")]
1394impl StreamerClient {
1395 pub async fn new() -> Result<Self, AppError> {
1411 let client = Client::try_new()?;
1412 Self::with_client(&client).await
1413 }
1414
1415 pub async fn with_client(client: &Client) -> Result<Self, AppError> {
1429 let ws_info = client.ws_info().await?;
1430
1431 let config = ClientConfig::builder(ServerAddress::try_new(ws_info.server.as_str())?)
1435 .with_credentials(Credentials::new(
1436 ws_info.account_id.as_str(),
1437 ws_info.get_ws_password(),
1438 ))
1439 .build()?;
1440
1441 let (shutdown_tx, _) = watch::channel(false);
1442
1443 Ok(Self {
1444 account_id: ws_info.account_id.clone(),
1445 config,
1446 client: None,
1447 session_events: None,
1448 shutdown_tx,
1449 converter_tasks: Vec::new(),
1450 has_market_stream_subs: false,
1451 has_price_stream_subs: false,
1452 })
1453 }
1454
1455 async fn ensure_session(&mut self) -> Result<&LsClient, AppError> {
1466 if self.client.is_none() {
1467 let (client, events) = LsClient::connect(self.config.clone()).await?;
1468 info!(account_id = %self.account_id, "Lightstreamer session opened");
1469 self.client = Some(client);
1470 self.session_events = Some(events);
1471 }
1472
1473 self.client.as_ref().ok_or_else(|| {
1474 AppError::WebSocketError("streaming session not initialized".to_string())
1475 })
1476 }
1477
1478 async fn subscribe_and_convert<T, C>(
1486 &mut self,
1487 subscription: Subscription,
1488 label: &str,
1489 convert: C,
1490 ) -> Result<mpsc::UnboundedReceiver<T>, AppError>
1491 where
1492 T: Send + 'static,
1493 C: Fn(&StreamingUpdate) -> T + Send + 'static,
1494 {
1495 let updates = self.ensure_session().await?.subscribe(subscription).await?;
1496
1497 let (tx, rx) = mpsc::unbounded_channel();
1498 let mut shutdown = self.shutdown_tx.subscribe();
1499 let label = label.to_owned();
1500
1501 let handle = tokio::spawn(async move {
1502 let mut updates = updates;
1503 loop {
1504 let event = tokio::select! {
1505 _ = shutdown.changed() => {
1506 debug!(subscription = %label, "converter stopped by shutdown signal");
1507 return;
1508 }
1509 event = updates.next() => event,
1510 };
1511
1512 let Some(event) = event else {
1513 debug!(subscription = %label, "converter stopped: subscription stream closed");
1514 return;
1515 };
1516
1517 match event {
1518 SubscriptionEvent::Update(update) => {
1519 let data = convert(&StreamingUpdate::from(update.as_ref()));
1520 if tx.send(data).is_err() {
1521 debug!(subscription = %label, "converter stopped: receiver dropped");
1522 return;
1523 }
1524 }
1525 SubscriptionEvent::Activated {
1526 item_count,
1527 field_count,
1528 ..
1529 } => info!(
1530 subscription = %label,
1531 item_count,
1532 field_count,
1533 "subscription started"
1534 ),
1535 SubscriptionEvent::Rejected(e) => {
1538 error!(subscription = %label, error = %e, "IG refused the subscription");
1539 return;
1540 }
1541 SubscriptionEvent::Unsubscribed => {
1542 info!(subscription = %label, "subscription ended");
1543 return;
1544 }
1545 SubscriptionEvent::Overflow {
1546 item_index,
1547 dropped_count,
1548 } => warn!(
1549 subscription = %label,
1550 item_index,
1551 dropped_count,
1552 "IG dropped updates for this item"
1553 ),
1554 other => debug!(subscription = %label, event = ?other, "subscription event"),
1555 }
1556 }
1557 });
1558 self.converter_tasks.push(handle);
1559
1560 Ok(rx)
1561 }
1562
1563 pub async fn market_subscribe(
1599 &mut self,
1600 epics: Vec<String>,
1601 fields: HashSet<StreamingMarketField>,
1602 ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1603 let epic_count = epics.len();
1604 let items: Vec<String> = epics
1605 .into_iter()
1606 .map(|epic| format!("MARKET:{epic}"))
1607 .collect();
1608 let subscription = Subscription::new(
1609 SubscriptionMode::Merge,
1610 ItemGroup::from_items(items)?,
1611 FieldSchema::from_fields(get_streaming_market_fields(&fields))?,
1612 )
1613 .with_snapshot(Snapshot::On);
1614
1615 let receiver = self
1616 .subscribe_and_convert(subscription, "market", |update| PriceData::from(update))
1617 .await?;
1618 self.has_market_stream_subs = true;
1619
1620 info!("Market subscription created for {epic_count} instruments");
1621 Ok(receiver)
1622 }
1623
1624 pub async fn trade_subscribe(
1652 &mut self,
1653 ) -> Result<mpsc::UnboundedReceiver<TradeFields>, AppError> {
1654 let account_id = self.account_id.clone();
1655 let subscription = Subscription::new(
1656 SubscriptionMode::Distinct,
1657 ItemGroup::from_items([format!("TRADE:{account_id}")])?,
1658 FieldSchema::from_fields(["CONFIRMS", "OPU", "WOU"])?,
1659 )
1660 .with_snapshot(Snapshot::On);
1661
1662 let receiver = self
1663 .subscribe_and_convert(subscription, "trade", |update| {
1664 crate::presentation::trade::TradeData::from(update).fields
1665 })
1666 .await?;
1667 self.has_market_stream_subs = true;
1668
1669 info!(account_id = %account_id, "Trade subscription created");
1670 Ok(receiver)
1671 }
1672
1673 pub async fn account_subscribe(
1706 &mut self,
1707 fields: HashSet<StreamingAccountDataField>,
1708 ) -> Result<mpsc::UnboundedReceiver<AccountFields>, AppError> {
1709 let account_id = self.account_id.clone();
1710 let subscription = Subscription::new(
1711 SubscriptionMode::Merge,
1712 ItemGroup::from_items([format!("ACCOUNT:{account_id}")])?,
1713 FieldSchema::from_fields(get_streaming_account_data_fields(&fields))?,
1714 )
1715 .with_snapshot(Snapshot::On);
1716
1717 let receiver = self
1718 .subscribe_and_convert(subscription, "account", |update| {
1719 crate::presentation::account::AccountData::from(update).fields
1720 })
1721 .await?;
1722 self.has_market_stream_subs = true;
1723
1724 info!(account_id = %account_id, "Account subscription created");
1725 Ok(receiver)
1726 }
1727
1728 pub async fn price_subscribe(
1765 &mut self,
1766 epics: Vec<String>,
1767 fields: HashSet<StreamingPriceField>,
1768 ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1769 let account_id = self.account_id.clone();
1770 let epic_count = epics.len();
1771 let items: Vec<String> = epics
1772 .into_iter()
1773 .map(|epic| format!("PRICE:{account_id}:{epic}"))
1774 .collect();
1775 let field_names = get_streaming_price_fields(&fields);
1776
1777 debug!(?items, ?field_names, "Pricing subscription shape");
1778
1779 let pricing_adapter =
1783 std::env::var("IG_PRICING_ADAPTER").unwrap_or_else(|_| "Pricing".to_string());
1784 debug!(adapter = %pricing_adapter, "Using Pricing data adapter");
1785
1786 let subscription = Subscription::new(
1787 SubscriptionMode::Merge,
1788 ItemGroup::from_items(items)?,
1789 FieldSchema::from_fields(field_names)?,
1790 )
1791 .with_data_adapter(pricing_adapter)
1792 .with_snapshot(Snapshot::On);
1793
1794 let receiver = self
1795 .subscribe_and_convert(subscription, "price", |update| PriceData::from(update))
1796 .await?;
1797 self.has_price_stream_subs = true;
1798
1799 info!(account_id = %account_id, "Price subscription created for {epic_count} instruments");
1800 Ok(receiver)
1801 }
1802
1803 pub async fn chart_subscribe(
1842 &mut self,
1843 epics: Vec<String>,
1844 scale: ChartScale,
1845 fields: HashSet<StreamingChartField>,
1846 ) -> Result<mpsc::UnboundedReceiver<ChartData>, AppError> {
1847 let epic_count = epics.len();
1848 let items: Vec<String> = epics
1849 .into_iter()
1850 .map(|epic| format!("CHART:{epic}:{scale}"))
1851 .collect();
1852
1853 let mode = if matches!(scale, ChartScale::Tick) {
1856 SubscriptionMode::Distinct
1857 } else {
1858 SubscriptionMode::Merge
1859 };
1860
1861 let subscription = Subscription::new(
1862 mode,
1863 ItemGroup::from_items(items)?,
1864 FieldSchema::from_fields(get_streaming_chart_fields(&fields))?,
1865 )
1866 .with_snapshot(Snapshot::On);
1867
1868 let receiver = self
1869 .subscribe_and_convert(subscription, "chart", |update| ChartData::from(update))
1870 .await?;
1871 self.has_market_stream_subs = true;
1872
1873 info!("Chart subscription created for {epic_count} instruments (scale: {scale})");
1874 Ok(receiver)
1875 }
1876
1877 pub async fn connect(&mut self, shutdown_signal: Option<Arc<Notify>>) -> Result<(), AppError> {
1903 let Some(mut events) = self.session_events.take() else {
1904 warn!("No streaming session to run: subscribe first, and call connect once");
1907 return Ok(());
1908 };
1909
1910 info!(
1911 market_subscriptions = self.has_market_stream_subs,
1912 price_subscriptions = self.has_price_stream_subs,
1913 "Streaming session running"
1914 );
1915
1916 let shutdown = wait_for_shutdown(shutdown_signal);
1920 tokio::pin!(shutdown);
1921
1922 loop {
1923 let event = tokio::select! {
1924 () = &mut shutdown => {
1925 info!("Streaming session stopping: shutdown requested");
1926 return Ok(());
1927 }
1928 event = events.next() => event,
1929 };
1930
1931 let Some(event) = event else {
1932 debug!("Session event stream ended");
1935 return Ok(());
1936 };
1937
1938 match event {
1939 SessionEvent::Connected(connected) => match connected.continuity {
1940 Continuity::Replaced { .. } => warn!(
1946 continuity = ?connected.continuity,
1947 "Streaming session replaced: subscriptions re-executed, expect fresh snapshots"
1948 ),
1949 _ => info!(
1950 continuity = ?connected.continuity,
1951 "Streaming session connected"
1952 ),
1953 },
1954 SessionEvent::Resubscribed(subscriptions) => {
1955 info!(
1956 count = subscriptions.len(),
1957 "Subscriptions re-created on a new session"
1958 );
1959 }
1960 SessionEvent::Disconnected { reason, retry_in } => match retry_in {
1961 Some(delay) => warn!(
1962 ?reason,
1963 retry_in_ms = delay.as_millis(),
1964 "Streaming session disconnected, reconnecting"
1965 ),
1966 None => warn!(?reason, "Streaming session disconnected, giving up"),
1967 },
1968 SessionEvent::Closed(reason) => return Self::report_close(&reason),
1969 SessionEvent::RequestRejected(e) => {
1970 warn!(error = %e, "IG refused a streaming control request");
1971 }
1972 SessionEvent::RequestNotSent { reason } => {
1973 warn!(%reason, "A streaming control request never left the client");
1974 }
1975 SessionEvent::Unrecognized { line } => {
1977 trace!(%line, "Unrecognized streaming notification");
1978 }
1979 other => debug!(event = ?other, "Session event"),
1980 }
1981 }
1982 }
1983
1984 fn report_close(reason: &ClosedReason) -> Result<(), AppError> {
1990 match reason {
1991 ClosedReason::ByClient => {
1992 info!("Streaming session closed by this client");
1993 Ok(())
1994 }
1995 ClosedReason::ByServer(e) => {
1996 error!(error = %e, "IG closed the streaming session");
1997 Err(AppError::WebSocketError(format!(
1998 "IG closed the streaming session: {e}"
1999 )))
2000 }
2001 ClosedReason::ReconnectExhausted { attempts, last } => {
2002 error!(attempts, last_reason = ?last, "Streaming reconnection budget exhausted");
2003 Err(AppError::WebSocketError(format!(
2004 "streaming reconnection budget exhausted after {attempts} attempts"
2005 )))
2006 }
2007 ClosedReason::Internal { reason } => {
2008 error!(%reason, "Streaming client failed internally");
2009 Err(AppError::WebSocketError(format!(
2010 "streaming client failed internally: {reason}"
2011 )))
2012 }
2013 other => {
2014 error!(reason = ?other, "Streaming session closed");
2015 Err(AppError::WebSocketError(format!(
2016 "streaming session closed: {other:?}"
2017 )))
2018 }
2019 }
2020 }
2021
2022 pub async fn disconnect(&mut self) -> Result<(), AppError> {
2035 let _ = self.shutdown_tx.send(true);
2038
2039 let converter_count = self.converter_tasks.len();
2040 join_tasks(&mut self.converter_tasks).await;
2041 if converter_count > 0 {
2042 debug!("Stopped {converter_count} converter task(s)");
2043 }
2044
2045 self.session_events = None;
2046
2047 if let Some(client) = self.client.take() {
2048 client.disconnect().await?;
2049 info!("Streaming session closed");
2050 }
2051
2052 Ok(())
2053 }
2054}
2055
2056#[cfg(feature = "streaming")]
2062async fn wait_for_shutdown(signal: Option<Arc<Notify>>) {
2063 if let Some(signal) = signal {
2064 signal.notified().await;
2065 return;
2066 }
2067
2068 #[cfg(unix)]
2069 {
2070 use tokio::signal::unix::{SignalKind, signal};
2071 match (
2074 signal(SignalKind::interrupt()),
2075 signal(SignalKind::terminate()),
2076 ) {
2077 (Ok(mut sigint), Ok(mut sigterm)) => {
2078 tokio::select! {
2079 _ = sigint.recv() => info!("SIGINT received"),
2080 _ = sigterm.recv() => info!("SIGTERM received"),
2081 }
2082 }
2083 (sigint, sigterm) => {
2084 if let Err(e) = sigint {
2085 error!(error = %e, "cannot install the SIGINT handler");
2086 }
2087 if let Err(e) = sigterm {
2088 error!(error = %e, "cannot install the SIGTERM handler");
2089 }
2090 std::future::pending::<()>().await;
2091 }
2092 }
2093 }
2094
2095 #[cfg(not(unix))]
2096 {
2097 if let Err(e) = tokio::signal::ctrl_c().await {
2098 error!(error = %e, "cannot wait for Ctrl-C");
2099 std::future::pending::<()>().await;
2100 }
2101 }
2102}
2103
2104#[cfg(feature = "streaming")]
2105impl Drop for StreamerClient {
2106 fn drop(&mut self) {
2112 let _ = self.shutdown_tx.send(true);
2113 for handle in self.converter_tasks.drain(..) {
2114 handle.abort();
2115 }
2116 }
2117}
2118
2119#[cfg(test)]
2120mod tests {
2121 use super::{ensure_zone_designator, is_transient_confirmation_error};
2122 use crate::error::AppError;
2123 use reqwest::StatusCode;
2124
2125 #[test]
2126 fn test_ensure_zone_designator_appends_z_when_missing() {
2127 assert_eq!(
2128 ensure_zone_designator("2026-01-01T00:00:00"),
2129 "2026-01-01T00:00:00Z"
2130 );
2131 assert_eq!(
2132 ensure_zone_designator(" 2026-01-01T00:00:00 "),
2133 "2026-01-01T00:00:00Z"
2134 );
2135 }
2136
2137 #[test]
2138 fn test_ensure_zone_designator_expands_date_only_to_midnight_utc() {
2139 assert_eq!(ensure_zone_designator("2026-01-01"), "2026-01-01T00:00:00Z");
2140 }
2141
2142 #[test]
2143 fn test_ensure_zone_designator_keeps_existing_designator() {
2144 assert_eq!(
2145 ensure_zone_designator("2026-01-01T00:00:00Z"),
2146 "2026-01-01T00:00:00Z"
2147 );
2148 assert_eq!(
2149 ensure_zone_designator("2026-01-01T00:00:00+01:00"),
2150 "2026-01-01T00:00:00+01:00"
2151 );
2152 assert_eq!(
2153 ensure_zone_designator("2026-01-01T00:00:00-05:00"),
2154 "2026-01-01T00:00:00-05:00"
2155 );
2156 }
2157
2158 #[test]
2159 fn test_confirmation_error_rate_limit_is_transient() {
2160 assert!(is_transient_confirmation_error(
2161 &AppError::RateLimitExceeded
2162 ));
2163 }
2164
2165 #[test]
2166 fn test_confirmation_error_not_found_is_transient() {
2167 assert!(is_transient_confirmation_error(&AppError::NotFound));
2169 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2170 StatusCode::NOT_FOUND
2171 )));
2172 }
2173
2174 #[test]
2175 fn test_confirmation_error_server_error_is_transient() {
2176 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2177 StatusCode::INTERNAL_SERVER_ERROR
2178 )));
2179 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2180 StatusCode::BAD_GATEWAY
2181 )));
2182 }
2183
2184 #[test]
2185 fn test_confirmation_error_invalid_input_is_permanent() {
2186 assert!(!is_transient_confirmation_error(&AppError::InvalidInput(
2187 "bad".to_string()
2188 )));
2189 }
2190
2191 #[test]
2192 fn test_confirmation_error_auth_and_deser_are_permanent() {
2193 assert!(!is_transient_confirmation_error(&AppError::Unauthorized));
2194 assert!(!is_transient_confirmation_error(
2195 &AppError::OAuthTokenExpired
2196 ));
2197 assert!(!is_transient_confirmation_error(
2198 &AppError::Deserialization("bad".to_string())
2199 ));
2200 assert!(!is_transient_confirmation_error(&AppError::Unexpected(
2201 StatusCode::BAD_REQUEST
2202 )));
2203 }
2204}
2205
2206#[cfg(all(test, feature = "streaming"))]
2210mod streaming_tests {
2211 use super::{ClosedReason, StreamerClient, join_tasks};
2212 use crate::error::AppError;
2213 use lightstreamer_rs::ServerError;
2214 use std::time::Duration;
2215 use tokio::sync::watch;
2216 use tokio::task::JoinHandle;
2217
2218 #[test]
2225 fn test_close_by_client_is_success() {
2226 let result = StreamerClient::report_close(&ClosedReason::ByClient);
2227 assert!(
2228 result.is_ok(),
2229 "a close this client asked for is not a failure: {result:?}"
2230 );
2231 }
2232
2233 #[test]
2234 fn test_close_by_server_is_an_error() {
2235 let reason = ClosedReason::ByServer(ServerError::new(-1, "Insufficient permissions"));
2237 let result = StreamerClient::report_close(&reason);
2238 assert!(
2239 matches!(result, Err(AppError::WebSocketError(_))),
2240 "a server-initiated close must surface as an error: {result:?}"
2241 );
2242 }
2243
2244 #[test]
2245 fn test_close_after_exhausted_reconnection_is_an_error() {
2246 let result = StreamerClient::report_close(&ClosedReason::ReconnectExhausted {
2247 attempts: 8,
2248 last: None,
2249 });
2250 match result {
2251 Err(AppError::WebSocketError(message)) => {
2252 assert!(
2253 message.contains('8'),
2254 "the attempt count belongs in the message: {message}"
2255 );
2256 }
2257 other => panic!("expected a websocket error, got {other:?}"),
2258 }
2259 }
2260
2261 #[test]
2262 fn test_close_on_internal_failure_is_an_error() {
2263 let reason = ClosedReason::Internal {
2264 reason: "bug".to_string(),
2265 };
2266 assert!(matches!(
2267 StreamerClient::report_close(&reason),
2268 Err(AppError::WebSocketError(_))
2269 ));
2270 }
2271
2272 #[tokio::test]
2275 async fn test_watch_signal_stops_every_converter() {
2276 let (tx, _) = watch::channel(false);
2282 let mut tasks: Vec<JoinHandle<()>> = Vec::new();
2283 for _ in 0..3 {
2284 let mut shutdown = tx.subscribe();
2285 tasks.push(tokio::spawn(async move {
2286 tokio::select! {
2287 _ = shutdown.changed() => {}
2288 () = std::future::pending::<()>() => {}
2289 }
2290 }));
2291 }
2292
2293 assert!(tx.send(true).is_ok());
2296
2297 let joined = tokio::time::timeout(Duration::from_secs(1), join_tasks(&mut tasks)).await;
2298 assert!(joined.is_ok(), "converters did not observe the shutdown");
2299 assert!(tasks.is_empty(), "join_tasks must drain the list");
2300 }
2301
2302 #[tokio::test]
2303 async fn test_join_tasks_waits_for_completion() {
2304 let mut tasks: Vec<JoinHandle<()>> = vec![tokio::spawn(async {})];
2305 join_tasks(&mut tasks).await;
2306 assert!(tasks.is_empty());
2307 }
2308}