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 = 500;
1249 let from = ensure_zone_designator(from);
1250 let to = ensure_zone_designator(to);
1251 let mut all_entries = Vec::new();
1252 let mut current_page: u32 = 1;
1253 #[allow(unused_assignments)]
1254 let mut last_pagination = None;
1255
1256 loop {
1257 let path = format!(
1258 "indicativecostsandcharges/history/from/{}/to/{}?pageSize={}&pageNumber={}",
1259 from, to, PAGE_SIZE, current_page
1260 );
1261 info!("Getting costs history page {}", current_page);
1262
1263 let result: CostsHistoryResponse = self.http_client.get(&path, Some(1)).await?;
1264
1265 let total_pages = result.pagination.total_pages;
1266 last_pagination = Some(result.pagination);
1267 all_entries.extend(result.costs_and_charges_history);
1268
1269 if i64::from(current_page) >= total_pages {
1270 break;
1271 }
1272 current_page += 1;
1273 }
1274
1275 debug!("Costs history obtained: {} entries", all_entries.len());
1276
1277 Ok(CostsHistoryResponse {
1278 pagination: last_pagination.ok_or_else(|| {
1279 AppError::InvalidInput("Could not retrieve pagination".to_string())
1280 })?,
1281 costs_and_charges_history: all_entries,
1282 })
1283 }
1284
1285 async fn get_durable_medium(
1286 &self,
1287 quote_reference: &str,
1288 ) -> Result<DurableMediumResponse, AppError> {
1289 let path = format!(
1290 "indicativecostsandcharges/durablemedium/{}",
1291 quote_reference
1292 );
1293 info!("Getting durable medium for reference: {}", quote_reference);
1294 let result: DurableMediumResponse = self.http_client.get(&path, Some(1)).await?;
1295 debug!("Durable medium obtained for reference: {}", quote_reference);
1296 Ok(result)
1297 }
1298}
1299
1300#[async_trait]
1305impl OperationsService for Client {
1306 async fn get_client_apps(&self) -> Result<ApplicationDetailsResponse, AppError> {
1307 info!("Getting client applications");
1308 let result: ApplicationDetailsResponse = self
1309 .http_client
1310 .get("operations/application", Some(1))
1311 .await?;
1312 debug!(
1314 name = ?result.name,
1315 status = %result.status,
1316 "Client application obtained"
1317 );
1318 Ok(result)
1319 }
1320
1321 async fn disable_client_app(&self) -> Result<StatusResponse, AppError> {
1322 info!("Disabling current client application");
1323 let result: StatusResponse = self
1324 .http_client
1325 .put(
1326 "operations/application/disable",
1327 &serde_json::json!({}),
1328 Some(1),
1329 )
1330 .await?;
1331 debug!("Client application disabled");
1332 Ok(result)
1333 }
1334}
1335
1336#[cfg(feature = "streaming")]
1362#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
1363pub struct StreamerClient {
1364 account_id: String,
1365 config: ClientConfig,
1370 client: Option<LsClient>,
1373 session_events: Option<SessionEvents>,
1375 shutdown_tx: watch::Sender<bool>,
1379 converter_tasks: Vec<JoinHandle<()>>,
1383 has_market_stream_subs: bool,
1386 has_price_stream_subs: bool,
1387}
1388
1389#[cfg(feature = "streaming")]
1390impl StreamerClient {
1391 pub async fn new() -> Result<Self, AppError> {
1407 let client = Client::try_new()?;
1408 Self::with_client(&client).await
1409 }
1410
1411 pub async fn with_client(client: &Client) -> Result<Self, AppError> {
1425 let ws_info = client.ws_info().await?;
1426
1427 let config = ClientConfig::builder(ServerAddress::try_new(ws_info.server.as_str())?)
1431 .with_credentials(Credentials::new(
1432 ws_info.account_id.as_str(),
1433 ws_info.get_ws_password(),
1434 ))
1435 .build()?;
1436
1437 let (shutdown_tx, _) = watch::channel(false);
1438
1439 Ok(Self {
1440 account_id: ws_info.account_id.clone(),
1441 config,
1442 client: None,
1443 session_events: None,
1444 shutdown_tx,
1445 converter_tasks: Vec::new(),
1446 has_market_stream_subs: false,
1447 has_price_stream_subs: false,
1448 })
1449 }
1450
1451 async fn ensure_session(&mut self) -> Result<&LsClient, AppError> {
1462 if self.client.is_none() {
1463 let (client, events) = LsClient::connect(self.config.clone()).await?;
1464 info!(account_id = %self.account_id, "Lightstreamer session opened");
1465 self.client = Some(client);
1466 self.session_events = Some(events);
1467 }
1468
1469 self.client.as_ref().ok_or_else(|| {
1470 AppError::WebSocketError("streaming session not initialized".to_string())
1471 })
1472 }
1473
1474 async fn subscribe_and_convert<T, C>(
1482 &mut self,
1483 subscription: Subscription,
1484 label: &str,
1485 convert: C,
1486 ) -> Result<mpsc::UnboundedReceiver<T>, AppError>
1487 where
1488 T: Send + 'static,
1489 C: Fn(&StreamingUpdate) -> T + Send + 'static,
1490 {
1491 let updates = self.ensure_session().await?.subscribe(subscription).await?;
1492
1493 let (tx, rx) = mpsc::unbounded_channel();
1494 let mut shutdown = self.shutdown_tx.subscribe();
1495 let label = label.to_owned();
1496
1497 let handle = tokio::spawn(async move {
1498 let mut updates = updates;
1499 loop {
1500 let event = tokio::select! {
1501 _ = shutdown.changed() => {
1502 debug!(subscription = %label, "converter stopped by shutdown signal");
1503 return;
1504 }
1505 event = updates.next() => event,
1506 };
1507
1508 let Some(event) = event else {
1509 debug!(subscription = %label, "converter stopped: subscription stream closed");
1510 return;
1511 };
1512
1513 match event {
1514 SubscriptionEvent::Update(update) => {
1515 let data = convert(&StreamingUpdate::from(update.as_ref()));
1516 if tx.send(data).is_err() {
1517 debug!(subscription = %label, "converter stopped: receiver dropped");
1518 return;
1519 }
1520 }
1521 SubscriptionEvent::Activated {
1522 item_count,
1523 field_count,
1524 ..
1525 } => info!(
1526 subscription = %label,
1527 item_count,
1528 field_count,
1529 "subscription started"
1530 ),
1531 SubscriptionEvent::Rejected(e) => {
1534 error!(subscription = %label, error = %e, "IG refused the subscription");
1535 return;
1536 }
1537 SubscriptionEvent::Unsubscribed => {
1538 info!(subscription = %label, "subscription ended");
1539 return;
1540 }
1541 SubscriptionEvent::Overflow {
1542 item_index,
1543 dropped_count,
1544 } => warn!(
1545 subscription = %label,
1546 item_index,
1547 dropped_count,
1548 "IG dropped updates for this item"
1549 ),
1550 other => debug!(subscription = %label, event = ?other, "subscription event"),
1551 }
1552 }
1553 });
1554 self.converter_tasks.push(handle);
1555
1556 Ok(rx)
1557 }
1558
1559 pub async fn market_subscribe(
1595 &mut self,
1596 epics: Vec<String>,
1597 fields: HashSet<StreamingMarketField>,
1598 ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1599 let epic_count = epics.len();
1600 let items: Vec<String> = epics
1601 .into_iter()
1602 .map(|epic| format!("MARKET:{epic}"))
1603 .collect();
1604 let subscription = Subscription::new(
1605 SubscriptionMode::Merge,
1606 ItemGroup::from_items(items)?,
1607 FieldSchema::from_fields(get_streaming_market_fields(&fields))?,
1608 )
1609 .with_snapshot(Snapshot::On);
1610
1611 let receiver = self
1612 .subscribe_and_convert(subscription, "market", |update| PriceData::from(update))
1613 .await?;
1614 self.has_market_stream_subs = true;
1615
1616 info!("Market subscription created for {epic_count} instruments");
1617 Ok(receiver)
1618 }
1619
1620 pub async fn trade_subscribe(
1648 &mut self,
1649 ) -> Result<mpsc::UnboundedReceiver<TradeFields>, AppError> {
1650 let account_id = self.account_id.clone();
1651 let subscription = Subscription::new(
1652 SubscriptionMode::Distinct,
1653 ItemGroup::from_items([format!("TRADE:{account_id}")])?,
1654 FieldSchema::from_fields(["CONFIRMS", "OPU", "WOU"])?,
1655 )
1656 .with_snapshot(Snapshot::On);
1657
1658 let receiver = self
1659 .subscribe_and_convert(subscription, "trade", |update| {
1660 crate::presentation::trade::TradeData::from(update).fields
1661 })
1662 .await?;
1663 self.has_market_stream_subs = true;
1664
1665 info!(account_id = %account_id, "Trade subscription created");
1666 Ok(receiver)
1667 }
1668
1669 pub async fn account_subscribe(
1702 &mut self,
1703 fields: HashSet<StreamingAccountDataField>,
1704 ) -> Result<mpsc::UnboundedReceiver<AccountFields>, AppError> {
1705 let account_id = self.account_id.clone();
1706 let subscription = Subscription::new(
1707 SubscriptionMode::Merge,
1708 ItemGroup::from_items([format!("ACCOUNT:{account_id}")])?,
1709 FieldSchema::from_fields(get_streaming_account_data_fields(&fields))?,
1710 )
1711 .with_snapshot(Snapshot::On);
1712
1713 let receiver = self
1714 .subscribe_and_convert(subscription, "account", |update| {
1715 crate::presentation::account::AccountData::from(update).fields
1716 })
1717 .await?;
1718 self.has_market_stream_subs = true;
1719
1720 info!(account_id = %account_id, "Account subscription created");
1721 Ok(receiver)
1722 }
1723
1724 pub async fn price_subscribe(
1761 &mut self,
1762 epics: Vec<String>,
1763 fields: HashSet<StreamingPriceField>,
1764 ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1765 let account_id = self.account_id.clone();
1766 let epic_count = epics.len();
1767 let items: Vec<String> = epics
1768 .into_iter()
1769 .map(|epic| format!("PRICE:{account_id}:{epic}"))
1770 .collect();
1771 let field_names = get_streaming_price_fields(&fields);
1772
1773 debug!(?items, ?field_names, "Pricing subscription shape");
1774
1775 let pricing_adapter =
1779 std::env::var("IG_PRICING_ADAPTER").unwrap_or_else(|_| "Pricing".to_string());
1780 debug!(adapter = %pricing_adapter, "Using Pricing data adapter");
1781
1782 let subscription = Subscription::new(
1783 SubscriptionMode::Merge,
1784 ItemGroup::from_items(items)?,
1785 FieldSchema::from_fields(field_names)?,
1786 )
1787 .with_data_adapter(pricing_adapter)
1788 .with_snapshot(Snapshot::On);
1789
1790 let receiver = self
1791 .subscribe_and_convert(subscription, "price", |update| PriceData::from(update))
1792 .await?;
1793 self.has_price_stream_subs = true;
1794
1795 info!(account_id = %account_id, "Price subscription created for {epic_count} instruments");
1796 Ok(receiver)
1797 }
1798
1799 pub async fn chart_subscribe(
1838 &mut self,
1839 epics: Vec<String>,
1840 scale: ChartScale,
1841 fields: HashSet<StreamingChartField>,
1842 ) -> Result<mpsc::UnboundedReceiver<ChartData>, AppError> {
1843 let epic_count = epics.len();
1844 let items: Vec<String> = epics
1845 .into_iter()
1846 .map(|epic| format!("CHART:{epic}:{scale}"))
1847 .collect();
1848
1849 let mode = if matches!(scale, ChartScale::Tick) {
1852 SubscriptionMode::Distinct
1853 } else {
1854 SubscriptionMode::Merge
1855 };
1856
1857 let subscription = Subscription::new(
1858 mode,
1859 ItemGroup::from_items(items)?,
1860 FieldSchema::from_fields(get_streaming_chart_fields(&fields))?,
1861 )
1862 .with_snapshot(Snapshot::On);
1863
1864 let receiver = self
1865 .subscribe_and_convert(subscription, "chart", |update| ChartData::from(update))
1866 .await?;
1867 self.has_market_stream_subs = true;
1868
1869 info!("Chart subscription created for {epic_count} instruments (scale: {scale})");
1870 Ok(receiver)
1871 }
1872
1873 pub async fn connect(&mut self, shutdown_signal: Option<Arc<Notify>>) -> Result<(), AppError> {
1899 let Some(mut events) = self.session_events.take() else {
1900 warn!("No streaming session to run: subscribe first, and call connect once");
1903 return Ok(());
1904 };
1905
1906 info!(
1907 market_subscriptions = self.has_market_stream_subs,
1908 price_subscriptions = self.has_price_stream_subs,
1909 "Streaming session running"
1910 );
1911
1912 let shutdown = wait_for_shutdown(shutdown_signal);
1916 tokio::pin!(shutdown);
1917
1918 loop {
1919 let event = tokio::select! {
1920 () = &mut shutdown => {
1921 info!("Streaming session stopping: shutdown requested");
1922 return Ok(());
1923 }
1924 event = events.next() => event,
1925 };
1926
1927 let Some(event) = event else {
1928 debug!("Session event stream ended");
1931 return Ok(());
1932 };
1933
1934 match event {
1935 SessionEvent::Connected(connected) => match connected.continuity {
1936 Continuity::Replaced { .. } => warn!(
1942 continuity = ?connected.continuity,
1943 "Streaming session replaced: subscriptions re-executed, expect fresh snapshots"
1944 ),
1945 _ => info!(
1946 continuity = ?connected.continuity,
1947 "Streaming session connected"
1948 ),
1949 },
1950 SessionEvent::Resubscribed(subscriptions) => {
1951 info!(
1952 count = subscriptions.len(),
1953 "Subscriptions re-created on a new session"
1954 );
1955 }
1956 SessionEvent::Disconnected { reason, retry_in } => match retry_in {
1957 Some(delay) => warn!(
1958 ?reason,
1959 retry_in_ms = delay.as_millis(),
1960 "Streaming session disconnected, reconnecting"
1961 ),
1962 None => warn!(?reason, "Streaming session disconnected, giving up"),
1963 },
1964 SessionEvent::Closed(reason) => return Self::report_close(&reason),
1965 SessionEvent::RequestRejected(e) => {
1966 warn!(error = %e, "IG refused a streaming control request");
1967 }
1968 SessionEvent::RequestNotSent { reason } => {
1969 warn!(%reason, "A streaming control request never left the client");
1970 }
1971 SessionEvent::Unrecognized { line } => {
1973 trace!(%line, "Unrecognized streaming notification");
1974 }
1975 other => debug!(event = ?other, "Session event"),
1976 }
1977 }
1978 }
1979
1980 fn report_close(reason: &ClosedReason) -> Result<(), AppError> {
1986 match reason {
1987 ClosedReason::ByClient => {
1988 info!("Streaming session closed by this client");
1989 Ok(())
1990 }
1991 ClosedReason::ByServer(e) => {
1992 error!(error = %e, "IG closed the streaming session");
1993 Err(AppError::WebSocketError(format!(
1994 "IG closed the streaming session: {e}"
1995 )))
1996 }
1997 ClosedReason::ReconnectExhausted { attempts, last } => {
1998 error!(attempts, last_reason = ?last, "Streaming reconnection budget exhausted");
1999 Err(AppError::WebSocketError(format!(
2000 "streaming reconnection budget exhausted after {attempts} attempts"
2001 )))
2002 }
2003 ClosedReason::Internal { reason } => {
2004 error!(%reason, "Streaming client failed internally");
2005 Err(AppError::WebSocketError(format!(
2006 "streaming client failed internally: {reason}"
2007 )))
2008 }
2009 other => {
2010 error!(reason = ?other, "Streaming session closed");
2011 Err(AppError::WebSocketError(format!(
2012 "streaming session closed: {other:?}"
2013 )))
2014 }
2015 }
2016 }
2017
2018 pub async fn disconnect(&mut self) -> Result<(), AppError> {
2031 let _ = self.shutdown_tx.send(true);
2034
2035 let converter_count = self.converter_tasks.len();
2036 join_tasks(&mut self.converter_tasks).await;
2037 if converter_count > 0 {
2038 debug!("Stopped {converter_count} converter task(s)");
2039 }
2040
2041 self.session_events = None;
2042
2043 if let Some(client) = self.client.take() {
2044 client.disconnect().await?;
2045 info!("Streaming session closed");
2046 }
2047
2048 Ok(())
2049 }
2050}
2051
2052#[cfg(feature = "streaming")]
2058async fn wait_for_shutdown(signal: Option<Arc<Notify>>) {
2059 if let Some(signal) = signal {
2060 signal.notified().await;
2061 return;
2062 }
2063
2064 #[cfg(unix)]
2065 {
2066 use tokio::signal::unix::{SignalKind, signal};
2067 match (
2070 signal(SignalKind::interrupt()),
2071 signal(SignalKind::terminate()),
2072 ) {
2073 (Ok(mut sigint), Ok(mut sigterm)) => {
2074 tokio::select! {
2075 _ = sigint.recv() => info!("SIGINT received"),
2076 _ = sigterm.recv() => info!("SIGTERM received"),
2077 }
2078 }
2079 (sigint, sigterm) => {
2080 if let Err(e) = sigint {
2081 error!(error = %e, "cannot install the SIGINT handler");
2082 }
2083 if let Err(e) = sigterm {
2084 error!(error = %e, "cannot install the SIGTERM handler");
2085 }
2086 std::future::pending::<()>().await;
2087 }
2088 }
2089 }
2090
2091 #[cfg(not(unix))]
2092 {
2093 if let Err(e) = tokio::signal::ctrl_c().await {
2094 error!(error = %e, "cannot wait for Ctrl-C");
2095 std::future::pending::<()>().await;
2096 }
2097 }
2098}
2099
2100#[cfg(feature = "streaming")]
2101impl Drop for StreamerClient {
2102 fn drop(&mut self) {
2108 let _ = self.shutdown_tx.send(true);
2109 for handle in self.converter_tasks.drain(..) {
2110 handle.abort();
2111 }
2112 }
2113}
2114
2115#[cfg(test)]
2116mod tests {
2117 use super::{ensure_zone_designator, is_transient_confirmation_error};
2118 use crate::error::AppError;
2119 use reqwest::StatusCode;
2120
2121 #[test]
2122 fn test_ensure_zone_designator_appends_z_when_missing() {
2123 assert_eq!(
2124 ensure_zone_designator("2026-01-01T00:00:00"),
2125 "2026-01-01T00:00:00Z"
2126 );
2127 assert_eq!(
2128 ensure_zone_designator(" 2026-01-01T00:00:00 "),
2129 "2026-01-01T00:00:00Z"
2130 );
2131 }
2132
2133 #[test]
2134 fn test_ensure_zone_designator_expands_date_only_to_midnight_utc() {
2135 assert_eq!(ensure_zone_designator("2026-01-01"), "2026-01-01T00:00:00Z");
2136 }
2137
2138 #[test]
2139 fn test_ensure_zone_designator_keeps_existing_designator() {
2140 assert_eq!(
2141 ensure_zone_designator("2026-01-01T00:00:00Z"),
2142 "2026-01-01T00:00:00Z"
2143 );
2144 assert_eq!(
2145 ensure_zone_designator("2026-01-01T00:00:00+01:00"),
2146 "2026-01-01T00:00:00+01:00"
2147 );
2148 assert_eq!(
2149 ensure_zone_designator("2026-01-01T00:00:00-05:00"),
2150 "2026-01-01T00:00:00-05:00"
2151 );
2152 }
2153
2154 #[test]
2155 fn test_confirmation_error_rate_limit_is_transient() {
2156 assert!(is_transient_confirmation_error(
2157 &AppError::RateLimitExceeded
2158 ));
2159 }
2160
2161 #[test]
2162 fn test_confirmation_error_not_found_is_transient() {
2163 assert!(is_transient_confirmation_error(&AppError::NotFound));
2165 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2166 StatusCode::NOT_FOUND
2167 )));
2168 }
2169
2170 #[test]
2171 fn test_confirmation_error_server_error_is_transient() {
2172 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2173 StatusCode::INTERNAL_SERVER_ERROR
2174 )));
2175 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2176 StatusCode::BAD_GATEWAY
2177 )));
2178 }
2179
2180 #[test]
2181 fn test_confirmation_error_invalid_input_is_permanent() {
2182 assert!(!is_transient_confirmation_error(&AppError::InvalidInput(
2183 "bad".to_string()
2184 )));
2185 }
2186
2187 #[test]
2188 fn test_confirmation_error_auth_and_deser_are_permanent() {
2189 assert!(!is_transient_confirmation_error(&AppError::Unauthorized));
2190 assert!(!is_transient_confirmation_error(
2191 &AppError::OAuthTokenExpired
2192 ));
2193 assert!(!is_transient_confirmation_error(
2194 &AppError::Deserialization("bad".to_string())
2195 ));
2196 assert!(!is_transient_confirmation_error(&AppError::Unexpected(
2197 StatusCode::BAD_REQUEST
2198 )));
2199 }
2200}
2201
2202#[cfg(all(test, feature = "streaming"))]
2206mod streaming_tests {
2207 use super::{ClosedReason, StreamerClient, join_tasks};
2208 use crate::error::AppError;
2209 use lightstreamer_rs::ServerError;
2210 use std::time::Duration;
2211 use tokio::sync::watch;
2212 use tokio::task::JoinHandle;
2213
2214 #[test]
2221 fn test_close_by_client_is_success() {
2222 let result = StreamerClient::report_close(&ClosedReason::ByClient);
2223 assert!(
2224 result.is_ok(),
2225 "a close this client asked for is not a failure: {result:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_close_by_server_is_an_error() {
2231 let reason = ClosedReason::ByServer(ServerError::new(-1, "Insufficient permissions"));
2233 let result = StreamerClient::report_close(&reason);
2234 assert!(
2235 matches!(result, Err(AppError::WebSocketError(_))),
2236 "a server-initiated close must surface as an error: {result:?}"
2237 );
2238 }
2239
2240 #[test]
2241 fn test_close_after_exhausted_reconnection_is_an_error() {
2242 let result = StreamerClient::report_close(&ClosedReason::ReconnectExhausted {
2243 attempts: 8,
2244 last: None,
2245 });
2246 match result {
2247 Err(AppError::WebSocketError(message)) => {
2248 assert!(
2249 message.contains('8'),
2250 "the attempt count belongs in the message: {message}"
2251 );
2252 }
2253 other => panic!("expected a websocket error, got {other:?}"),
2254 }
2255 }
2256
2257 #[test]
2258 fn test_close_on_internal_failure_is_an_error() {
2259 let reason = ClosedReason::Internal {
2260 reason: "bug".to_string(),
2261 };
2262 assert!(matches!(
2263 StreamerClient::report_close(&reason),
2264 Err(AppError::WebSocketError(_))
2265 ));
2266 }
2267
2268 #[tokio::test]
2271 async fn test_watch_signal_stops_every_converter() {
2272 let (tx, _) = watch::channel(false);
2278 let mut tasks: Vec<JoinHandle<()>> = Vec::new();
2279 for _ in 0..3 {
2280 let mut shutdown = tx.subscribe();
2281 tasks.push(tokio::spawn(async move {
2282 tokio::select! {
2283 _ = shutdown.changed() => {}
2284 () = std::future::pending::<()>() => {}
2285 }
2286 }));
2287 }
2288
2289 assert!(tx.send(true).is_ok());
2292
2293 let joined = tokio::time::timeout(Duration::from_secs(1), join_tasks(&mut tasks)).await;
2294 assert!(joined.is_ok(), "converters did not observe the shutdown");
2295 assert!(tasks.is_empty(), "join_tasks must drain the list");
2296 }
2297
2298 #[tokio::test]
2299 async fn test_join_tasks_waits_for_completion() {
2300 let mut tasks: Vec<JoinHandle<()>> = vec![tokio::spawn(async {})];
2301 join_tasks(&mut tasks).await;
2302 assert!(tasks.is_empty());
2303 }
2304}