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
128pub struct Client {
133 http_client: Arc<HttpClient>,
134}
135
136impl Client {
137 pub fn try_new() -> Result<Self, AppError> {
155 let http_client = Arc::new(HttpClient::new_lazy(Config::default())?);
156 Ok(Self { http_client })
157 }
158
159 pub fn with_config(config: Config) -> Result<Self, AppError> {
221 let http_client = Arc::new(HttpClient::new_lazy(config)?);
222 Ok(Self { http_client })
223 }
224
225 #[inline]
233 #[must_use]
234 pub fn config(&self) -> &Config {
235 self.http_client.config()
236 }
237
238 pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
252 self.http_client.ws_info().await
253 }
254
255 #[deprecated(
260 note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
261 )]
262 pub async fn get_ws_info(&self) -> WebsocketInfo {
263 self.ws_info().await.unwrap_or_default()
264 }
265}
266
267#[async_trait]
268impl MarketService for Client {
269 async fn search_markets(&self, search_term: &str) -> Result<MarketSearchResponse, AppError> {
270 let path = format!("markets?searchTerm={}", search_term);
271 info!("Searching markets with term: {}", search_term);
272 let result: MarketSearchResponse = self.http_client.get(&path, Some(1)).await?;
273 debug!("{} markets found", result.markets.len());
274 Ok(result)
275 }
276
277 async fn get_market_details(&self, epic: &str) -> Result<MarketDetails, AppError> {
278 let path = format!("markets/{epic}");
279 info!("Getting market details: {}", epic);
280 let market_details: MarketDetails = self.http_client.get(&path, Some(3)).await?;
284 debug!("Market details obtained for: {}", epic);
285 Ok(market_details)
286 }
287
288 async fn get_multiple_market_details(
289 &self,
290 epics: &[String],
291 ) -> Result<MultipleMarketDetailsResponse, AppError> {
292 if epics.is_empty() {
293 return Ok(MultipleMarketDetailsResponse::default());
294 } else if epics.len() > 50 {
295 return Err(AppError::InvalidInput(
296 "The maximum number of EPICs is 50".to_string(),
297 ));
298 }
299
300 let epics_str = epics.join(",");
301 let path = format!("markets?epics={}", epics_str);
302 debug!(
303 "Getting market details for {} EPICs in a batch",
304 epics.len()
305 );
306
307 let response: MultipleMarketDetailsResponse = self.http_client.get(&path, Some(2)).await?;
308
309 Ok(response)
310 }
311
312 async fn get_historical_prices(
313 &self,
314 epic: &str,
315 resolution: &str,
316 from: &str,
317 to: &str,
318 ) -> Result<HistoricalPricesResponse, AppError> {
319 let path = format!(
320 "prices/{}?resolution={}&from={}&to={}",
321 epic, resolution, from, to
322 );
323 info!("Getting historical prices for: {}", epic);
324 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(3)).await?;
325 debug!("Historical prices obtained for: {}", epic);
326 Ok(result)
327 }
328
329 async fn get_historical_prices_by_date_range(
330 &self,
331 epic: &str,
332 resolution: &str,
333 start_date: &str,
334 end_date: &str,
335 ) -> Result<HistoricalPricesResponse, AppError> {
336 let path = format!("prices/{}/{}/{}/{}", epic, resolution, start_date, end_date);
337 info!(
338 "Getting historical prices for epic: {}, resolution: {}, from: {} to: {}",
339 epic, resolution, start_date, end_date
340 );
341 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(2)).await?;
342 debug!(
343 "Historical prices obtained for epic: {}, {} data points",
344 epic,
345 result.prices.len()
346 );
347 Ok(result)
348 }
349
350 async fn get_recent_prices(
351 &self,
352 params: &RecentPricesRequest<'_>,
353 ) -> Result<HistoricalPricesResponse, AppError> {
354 let mut query_params = Vec::new();
355
356 if let Some(res) = params.resolution {
357 query_params.push(format!("resolution={}", res));
358 }
359 if let Some(f) = params.from {
360 query_params.push(format!("from={}", f));
361 }
362 if let Some(t) = params.to {
363 query_params.push(format!("to={}", t));
364 }
365 if let Some(max) = params.max_points {
366 query_params.push(format!("max={}", max));
367 }
368 if let Some(size) = params.page_size {
369 query_params.push(format!("pageSize={}", size));
370 }
371 if let Some(num) = params.page_number {
372 query_params.push(format!("pageNumber={}", num));
373 }
374
375 let query_string = if query_params.is_empty() {
376 String::new()
377 } else {
378 format!("?{}", query_params.join("&"))
379 };
380
381 let path = format!("prices/{}{}", params.epic, query_string);
382 info!("Getting recent prices for epic: {}", params.epic);
383 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(3)).await?;
384 debug!(
385 "Recent prices obtained for epic: {}, {} data points",
386 params.epic,
387 result.prices.len()
388 );
389 Ok(result)
390 }
391
392 async fn get_historical_prices_by_count_v1(
393 &self,
394 epic: &str,
395 resolution: &str,
396 num_points: u32,
397 ) -> Result<HistoricalPricesResponse, AppError> {
398 let path = format!("prices/{}/{}/{}", epic, resolution, num_points);
399 info!(
400 "Getting historical prices (v1) for epic: {}, resolution: {}, points: {}",
401 epic, resolution, num_points
402 );
403 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(1)).await?;
404 debug!(
405 "Historical prices (v1) obtained for epic: {}, {} data points",
406 epic,
407 result.prices.len()
408 );
409 Ok(result)
410 }
411
412 async fn get_historical_prices_by_count_v2(
413 &self,
414 epic: &str,
415 resolution: &str,
416 num_points: u32,
417 ) -> Result<HistoricalPricesResponse, AppError> {
418 let path = format!("prices/{}/{}/{}", epic, resolution, num_points);
419 info!(
420 "Getting historical prices (v2) for epic: {}, resolution: {}, points: {}",
421 epic, resolution, num_points
422 );
423 let result: HistoricalPricesResponse = self.http_client.get(&path, Some(2)).await?;
424 debug!(
425 "Historical prices (v2) obtained for epic: {}, {} data points",
426 epic,
427 result.prices.len()
428 );
429 Ok(result)
430 }
431
432 async fn get_market_navigation(&self) -> Result<MarketNavigationResponse, AppError> {
433 let path = "marketnavigation";
434 info!("Getting top-level market navigation nodes");
435 let result: MarketNavigationResponse = self.http_client.get(path, Some(1)).await?;
436 debug!("{} navigation nodes found", result.nodes.len());
437 debug!("{} markets found at root level", result.markets.len());
438 Ok(result)
439 }
440
441 async fn get_market_navigation_node(
442 &self,
443 node_id: &str,
444 ) -> Result<MarketNavigationResponse, AppError> {
445 let path = format!("marketnavigation/{}", node_id);
446 info!("Getting market navigation node: {}", node_id);
447 let result: MarketNavigationResponse = self.http_client.get(&path, Some(1)).await?;
448 debug!("{} child nodes found", result.nodes.len());
449 debug!("{} markets found in node {}", result.markets.len(), node_id);
450 Ok(result)
451 }
452
453 async fn get_all_markets(&self) -> Result<Vec<MarketData>, AppError> {
454 let max_depth = 6;
455 info!(
456 "Starting comprehensive market hierarchy traversal (max {} levels)",
457 max_depth
458 );
459
460 let root_response = self.get_market_navigation().await?;
461 info!(
462 "Root navigation: {} nodes, {} markets at top level",
463 root_response.nodes.len(),
464 root_response.markets.len()
465 );
466
467 let mut seen_epics: HashSet<String> = HashSet::new();
471 let mut all_markets: Vec<MarketData> = Vec::new();
472 for market in root_response.markets {
473 if seen_epics.insert(market.epic.clone()) {
474 all_markets.push(market);
475 }
476 }
477 let mut nodes_to_process = root_response.nodes;
478 let mut processed_levels = 0;
479
480 while !nodes_to_process.is_empty() && processed_levels < max_depth {
481 let mut next_level_nodes = Vec::new();
482 let mut level_market_count = 0;
483
484 info!(
485 "Processing level {} with {} nodes",
486 processed_levels,
487 nodes_to_process.len()
488 );
489
490 for node in &nodes_to_process {
491 match self.get_market_navigation_node(&node.id).await {
492 Ok(node_response) => {
493 let node_markets = node_response.markets.len();
494 let node_children = node_response.nodes.len();
495
496 if node_markets > 0 || node_children > 0 {
497 debug!(
498 "Node '{}' (level {}): {} markets, {} child nodes",
499 node.name, processed_levels, node_markets, node_children
500 );
501 }
502
503 for market in node_response.markets {
506 if seen_epics.insert(market.epic.clone()) {
507 all_markets.push(market);
508 level_market_count += 1;
509 }
510 }
511 next_level_nodes.extend(node_response.nodes);
512 }
513 Err(e) => {
514 tracing::error!(
515 "Failed to get markets for node '{}' at level {}: {:?}",
516 node.name,
517 processed_levels,
518 e
519 );
520 }
521 }
522 }
523
524 info!(
525 "Level {} completed: {} markets found, {} nodes for next level",
526 processed_levels,
527 level_market_count,
528 next_level_nodes.len()
529 );
530
531 nodes_to_process = next_level_nodes;
532 processed_levels += 1;
533 }
534
535 info!(
536 "Market hierarchy traversal completed: {} total markets found across {} levels",
537 all_markets.len(),
538 processed_levels
539 );
540
541 Ok(all_markets)
542 }
543
544 async fn get_vec_db_entries(&self) -> Result<Vec<DBEntryResponse>, AppError> {
545 info!("Getting all markets from hierarchy for DB entries");
546
547 let all_markets = self.get_all_markets().await?;
548 info!("Collected {} markets from hierarchy", all_markets.len());
549
550 let mut vec_db_entries: Vec<DBEntryResponse> = all_markets
551 .iter()
552 .map(DBEntryResponse::from)
553 .filter(|entry| !entry.epic.is_empty())
554 .collect();
555
556 info!("Created {} DB entries from markets", vec_db_entries.len());
557
558 let mut symbol_info: std::collections::HashMap<String, (String, String)> =
564 std::collections::HashMap::new();
565 for entry in &vec_db_entries {
566 if entry.symbol.is_empty() || entry.epic.is_empty() {
567 continue;
568 }
569 symbol_info
570 .entry(entry.symbol.clone())
571 .or_insert_with(|| (entry.epic.clone(), entry.expiry.clone()));
572 }
573
574 info!(
575 "Found {} unique symbols to fetch expiry dates for",
576 symbol_info.len()
577 );
578
579 let symbol_expiry_map: std::collections::HashMap<String, String> =
583 futures::stream::iter(symbol_info)
584 .map(|(symbol, (epic, fallback_expiry))| async move {
585 match self.get_market_details(&epic).await {
586 Ok(market_details) => {
587 let expiry_date = market_details
588 .instrument
589 .expiry_details
590 .as_ref()
591 .map(|details| details.last_dealing_date.clone())
592 .unwrap_or_else(|| market_details.instrument.expiry.clone());
593
594 info!(
595 symbol = %symbol,
596 expiry = %expiry_date,
597 "fetched expiry date for symbol"
598 );
599 (symbol, expiry_date)
600 }
601 Err(e) => {
602 tracing::error!(
603 "Failed to get market details for epic {} (symbol {}): {:?}",
604 epic,
605 symbol,
606 e
607 );
608 (symbol, fallback_expiry)
609 }
610 }
611 })
612 .buffer_unordered(MARKET_DETAILS_CONCURRENCY)
613 .collect()
614 .await;
615
616 for entry in &mut vec_db_entries {
617 if let Some(expiry_date) = symbol_expiry_map.get(&entry.symbol) {
618 entry.expiry = expiry_date.clone();
619 }
620 }
621
622 info!("Updated expiry dates for {} entries", vec_db_entries.len());
623 Ok(vec_db_entries)
624 }
625
626 async fn get_categories(&self) -> Result<CategoriesResponse, AppError> {
627 info!("Getting all categories of instruments");
628 let result: CategoriesResponse = self.http_client.get("categories", Some(1)).await?;
629 debug!("{} categories found", result.categories.len());
630 Ok(result)
631 }
632
633 async fn get_category_instruments(
634 &self,
635 category_id: &str,
636 page_number: Option<u32>,
637 page_size: Option<u32>,
638 ) -> Result<CategoryInstrumentsResponse, AppError> {
639 let mut path = format!("categories/{}/instruments", category_id);
640
641 let mut query_params = Vec::new();
642 if let Some(page) = page_number {
643 query_params.push(format!("pageNumber={}", page));
644 }
645 if let Some(size) = page_size {
646 if size > 1000 {
647 return Err(AppError::InvalidInput(
648 "pageSize cannot exceed 1000".to_string(),
649 ));
650 }
651 query_params.push(format!("pageSize={}", size));
652 }
653
654 if !query_params.is_empty() {
655 path = format!("{}?{}", path, query_params.join("&"));
656 }
657
658 info!(
659 "Getting instruments for category: {} (page: {:?}, size: {:?})",
660 category_id, page_number, page_size
661 );
662 let result: CategoryInstrumentsResponse = self.http_client.get(&path, Some(1)).await?;
663 debug!(
664 "{} instruments found in category {}",
665 result.instruments.len(),
666 category_id
667 );
668 Ok(result)
669 }
670}
671
672#[async_trait]
673impl AccountService for Client {
674 async fn get_accounts(&self) -> Result<AccountsResponse, AppError> {
675 info!("Getting account information");
676 let result: AccountsResponse = self.http_client.get("accounts", Some(1)).await?;
677 debug!(
678 "Account information obtained: {} accounts",
679 result.accounts.len()
680 );
681 Ok(result)
682 }
683
684 async fn get_positions(&self) -> Result<PositionsResponse, AppError> {
685 debug!("Getting open positions");
686 let result: PositionsResponse = self.http_client.get("positions", Some(2)).await?;
687 debug!("Positions obtained: {} positions", result.positions.len());
688 Ok(result)
689 }
690
691 async fn get_positions_w_filter(&self, filter: &str) -> Result<PositionsResponse, AppError> {
692 debug!("Getting open positions with filter: {}", filter);
693 let mut positions = self.get_positions().await?;
694
695 positions
696 .positions
697 .retain(|position| position.market.epic.contains(filter));
698
699 debug!(
700 "Positions obtained after filtering: {} positions",
701 positions.positions.len()
702 );
703 Ok(positions)
704 }
705
706 async fn get_working_orders(&self) -> Result<WorkingOrdersResponse, AppError> {
707 info!("Getting working orders");
708 let result: WorkingOrdersResponse = self.http_client.get("workingorders", Some(2)).await?;
709 debug!(
710 "Working orders obtained: {} orders",
711 result.working_orders.len()
712 );
713 Ok(result)
714 }
715
716 async fn get_activity(
717 &self,
718 from: &str,
719 to: &str,
720 ) -> Result<AccountActivityResponse, AppError> {
721 let path = format!("history/activity?from={}&to={}&pageSize=500", from, to);
722 info!("Getting account activity");
723 let result: AccountActivityResponse = self.http_client.get(&path, Some(3)).await?;
724 debug!(
725 "Account activity obtained: {} activities",
726 result.activities.len()
727 );
728 Ok(result)
729 }
730
731 async fn get_activity_with_details(
732 &self,
733 from: &str,
734 to: &str,
735 ) -> Result<AccountActivityResponse, AppError> {
736 let path = format!(
737 "history/activity?from={}&to={}&detailed=true&pageSize=500",
738 from, to
739 );
740 info!("Getting detailed account activity");
741 let result: AccountActivityResponse = self.http_client.get(&path, Some(3)).await?;
742 debug!(
743 "Detailed account activity obtained: {} activities",
744 result.activities.len()
745 );
746 Ok(result)
747 }
748
749 async fn get_transactions(
750 &self,
751 from: &str,
752 to: &str,
753 ) -> Result<TransactionHistoryResponse, AppError> {
754 const PAGE_SIZE: u32 = 200;
755 let mut all_transactions = Vec::new();
756 let mut current_page = 1;
757 #[allow(unused_assignments)]
758 let mut last_metadata = None;
759
760 loop {
761 let path = format!(
762 "history/transactions?from={}&to={}&pageSize={}&pageNumber={}",
763 from, to, PAGE_SIZE, current_page
764 );
765 info!("Getting transaction history page {}", current_page);
766
767 let result: TransactionHistoryResponse = self.http_client.get(&path, Some(2)).await?;
768
769 let total_pages = result.metadata.page_data.total_pages as u32;
770 last_metadata = Some(result.metadata);
771 all_transactions.extend(result.transactions);
772
773 if current_page >= total_pages {
774 break;
775 }
776 current_page += 1;
777 }
778
779 debug!(
780 "Total transaction history obtained: {} transactions",
781 all_transactions.len()
782 );
783
784 Ok(TransactionHistoryResponse {
785 transactions: all_transactions,
786 metadata: last_metadata
787 .ok_or_else(|| AppError::InvalidInput("Could not retrieve metadata".to_string()))?,
788 })
789 }
790
791 async fn get_preferences(&self) -> Result<AccountPreferencesResponse, AppError> {
792 info!("Getting account preferences");
793 let result: AccountPreferencesResponse = self
794 .http_client
795 .get("accounts/preferences", Some(1))
796 .await?;
797 debug!(
798 "Account preferences obtained: trailing_stops_enabled={}",
799 result.trailing_stops_enabled
800 );
801 Ok(result)
802 }
803
804 async fn update_preferences(&self, trailing_stops_enabled: bool) -> Result<(), AppError> {
805 info!(
806 "Updating account preferences: trailing_stops_enabled={}",
807 trailing_stops_enabled
808 );
809 let request = serde_json::json!({
810 "trailingStopsEnabled": trailing_stops_enabled
811 });
812 let _: serde_json::Value = self
813 .http_client
814 .put("accounts/preferences", &request, Some(1))
815 .await?;
816 debug!("Account preferences updated");
817 Ok(())
818 }
819
820 async fn get_activity_by_period(
821 &self,
822 period_ms: u64,
823 ) -> Result<AccountActivityResponse, AppError> {
824 let path = format!("history/activity/{}", period_ms);
825 info!("Getting account activity for period: {} ms", period_ms);
826 let result: AccountActivityResponse = self.http_client.get(&path, Some(1)).await?;
827 debug!(
828 "Account activity obtained: {} activities",
829 result.activities.len()
830 );
831 Ok(result)
832 }
833}
834
835#[async_trait]
836impl OrderService for Client {
837 async fn create_order(
838 &self,
839 order: &CreateOrderRequest,
840 ) -> Result<CreateOrderResponse, AppError> {
841 info!("Creating order for: {}", order.epic);
842 let result: CreateOrderResponse = self
843 .http_client
844 .post("positions/otc", order, Some(2))
845 .await?;
846 debug!("Order created with reference: {}", result.deal_reference);
847 Ok(result)
848 }
849
850 async fn get_order_confirmation(
851 &self,
852 deal_reference: &str,
853 ) -> Result<OrderConfirmationResponse, AppError> {
854 let path = format!("confirms/{}", deal_reference);
855 info!("Getting confirmation for order: {}", deal_reference);
856 let result: OrderConfirmationResponse = self.http_client.get(&path, Some(1)).await?;
857 debug!("Confirmation obtained for order: {}", deal_reference);
858 Ok(result)
859 }
860
861 async fn get_order_confirmation_w_retry(
862 &self,
863 deal_reference: &str,
864 retries: u64,
865 delay_ms: u64,
866 ) -> Result<OrderConfirmationResponse, AppError> {
867 let base = Duration::from_millis(delay_ms);
870 let mut attempt: u32 = 0;
871 loop {
872 match self.get_order_confirmation(deal_reference).await {
873 Ok(response) => return Ok(response),
874 Err(e) => {
875 if !is_transient_confirmation_error(&e) {
878 return Err(e);
879 }
880 if u64::from(attempt) >= retries {
881 return Err(e);
882 }
883 let delay = backoff_delay(base, attempt);
884 let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
885 let next_attempt = attempt.checked_add(1).ok_or_else(|| {
886 AppError::Generic("retry attempt counter overflow".to_string())
887 })?;
888 warn!(
889 deal_reference = %deal_reference,
890 attempt = next_attempt,
891 max_retries = retries,
892 delay_ms,
893 "retrying order confirmation after transient error"
894 );
895 sleep(delay).await;
896 attempt = next_attempt;
897 }
898 }
899 }
900 }
901
902 async fn update_position(
903 &self,
904 deal_id: &str,
905 update: &UpdatePositionRequest,
906 ) -> Result<UpdatePositionResponse, AppError> {
907 let path = format!("positions/otc/{}", deal_id);
908 info!("Updating position: {}", deal_id);
909 let result: UpdatePositionResponse = self.http_client.put(&path, update, Some(2)).await?;
910 debug!(
911 "Position updated: {} with deal reference: {}",
912 deal_id, result.deal_reference
913 );
914 Ok(result)
915 }
916
917 async fn update_level_in_position(
918 &self,
919 deal_id: &str,
920 limit_level: Option<f64>,
921 ) -> Result<UpdatePositionResponse, AppError> {
922 let path = format!("positions/otc/{}", deal_id);
923 info!("Updating position: {}", deal_id);
924 let limit_level = limit_level.unwrap_or(0.0);
925
926 let update: UpdatePositionRequest = UpdatePositionRequest {
927 guaranteed_stop: None,
928 limit_level: Some(limit_level),
929 stop_level: None,
930 trailing_stop: None,
931 trailing_stop_distance: None,
932 trailing_stop_increment: None,
933 };
934 let result: UpdatePositionResponse = self.http_client.put(&path, update, Some(2)).await?;
935 debug!(
936 "Position updated: {} with deal reference: {}",
937 deal_id, result.deal_reference
938 );
939 Ok(result)
940 }
941
942 async fn close_position(
943 &self,
944 close_request: &ClosePositionRequest,
945 ) -> Result<ClosePositionResponse, AppError> {
946 info!("Closing position");
947
948 let result: ClosePositionResponse = self
951 .http_client
952 .post_with_delete_method("positions/otc", close_request, Some(1))
953 .await?;
954
955 debug!("Position closed with reference: {}", result.deal_reference);
956 Ok(result)
957 }
958
959 async fn create_working_order(
960 &self,
961 order: &CreateWorkingOrderRequest,
962 ) -> Result<CreateWorkingOrderResponse, AppError> {
963 info!("Creating working order for: {}", order.epic);
964 let result: CreateWorkingOrderResponse = self
965 .http_client
966 .post("workingorders/otc", order, Some(2))
967 .await?;
968 debug!(
969 "Working order created with reference: {}",
970 result.deal_reference
971 );
972 Ok(result)
973 }
974
975 async fn delete_working_order(&self, deal_id: &str) -> Result<(), AppError> {
976 let path = format!("workingorders/otc/{}", deal_id);
977 let result: CreateWorkingOrderResponse =
978 self.http_client.delete(path.as_str(), Some(2)).await?;
979 debug!(
980 "Working order created with reference: {}",
981 result.deal_reference
982 );
983 Ok(())
984 }
985
986 async fn get_position(&self, deal_id: &str) -> Result<SinglePositionResponse, AppError> {
987 let path = format!("positions/{}", deal_id);
988 info!("Getting position: {}", deal_id);
989 let result: SinglePositionResponse = self.http_client.get(&path, Some(2)).await?;
990 debug!("Position obtained for deal: {}", deal_id);
991 Ok(result)
992 }
993
994 async fn update_working_order(
995 &self,
996 deal_id: &str,
997 update: &UpdateWorkingOrderRequest,
998 ) -> Result<CreateWorkingOrderResponse, AppError> {
999 let path = format!("workingorders/otc/{}", deal_id);
1000 info!("Updating working order: {}", deal_id);
1001 let result: CreateWorkingOrderResponse =
1002 self.http_client.put(&path, update, Some(2)).await?;
1003 debug!(
1004 "Working order updated: {} with reference: {}",
1005 deal_id, result.deal_reference
1006 );
1007 Ok(result)
1008 }
1009}
1010
1011#[async_trait]
1016impl WatchlistService for Client {
1017 async fn get_watchlists(&self) -> Result<WatchlistsResponse, AppError> {
1018 info!("Getting all watchlists");
1019 let result: WatchlistsResponse = self.http_client.get("watchlists", Some(1)).await?;
1020 debug!(
1021 "Watchlists obtained: {} watchlists",
1022 result.watchlists.len()
1023 );
1024 Ok(result)
1025 }
1026
1027 async fn create_watchlist(
1028 &self,
1029 name: &str,
1030 epics: Option<&[String]>,
1031 ) -> Result<CreateWatchlistResponse, AppError> {
1032 info!("Creating watchlist: {}", name);
1033 let request = CreateWatchlistRequest {
1034 name: name.to_string(),
1035 epics: epics.map(|e| e.to_vec()),
1036 };
1037 let result: CreateWatchlistResponse = self
1038 .http_client
1039 .post("watchlists", &request, Some(1))
1040 .await?;
1041 debug!(
1042 "Watchlist created: {} with ID: {}",
1043 name, result.watchlist_id
1044 );
1045 Ok(result)
1046 }
1047
1048 async fn get_watchlist(
1049 &self,
1050 watchlist_id: &str,
1051 ) -> Result<WatchlistMarketsResponse, AppError> {
1052 let path = format!("watchlists/{}", watchlist_id);
1053 info!("Getting watchlist: {}", watchlist_id);
1054 let result: WatchlistMarketsResponse = self.http_client.get(&path, Some(1)).await?;
1055 debug!(
1056 "Watchlist obtained: {} with {} markets",
1057 watchlist_id,
1058 result.markets.len()
1059 );
1060 Ok(result)
1061 }
1062
1063 async fn delete_watchlist(&self, watchlist_id: &str) -> Result<StatusResponse, AppError> {
1064 let path = format!("watchlists/{}", watchlist_id);
1065 info!("Deleting watchlist: {}", watchlist_id);
1066 let result: StatusResponse = self.http_client.delete(&path, Some(1)).await?;
1067 debug!("Watchlist deleted: {}", watchlist_id);
1068 Ok(result)
1069 }
1070
1071 async fn add_to_watchlist(
1072 &self,
1073 watchlist_id: &str,
1074 epic: &str,
1075 ) -> Result<StatusResponse, AppError> {
1076 let path = format!("watchlists/{}", watchlist_id);
1077 info!("Adding {} to watchlist: {}", epic, watchlist_id);
1078 let request = AddToWatchlistRequest {
1079 epic: epic.to_string(),
1080 };
1081 let result: StatusResponse = self.http_client.put(&path, &request, Some(1)).await?;
1082 debug!("Added {} to watchlist: {}", epic, watchlist_id);
1083 Ok(result)
1084 }
1085
1086 async fn remove_from_watchlist(
1087 &self,
1088 watchlist_id: &str,
1089 epic: &str,
1090 ) -> Result<StatusResponse, AppError> {
1091 let path = format!("watchlists/{}/{}", watchlist_id, epic);
1092 info!("Removing {} from watchlist: {}", epic, watchlist_id);
1093 let result: StatusResponse = self.http_client.delete(&path, Some(1)).await?;
1094 debug!("Removed {} from watchlist: {}", epic, watchlist_id);
1095 Ok(result)
1096 }
1097}
1098
1099#[async_trait]
1104impl SentimentService for Client {
1105 async fn get_client_sentiment(
1106 &self,
1107 market_ids: &[String],
1108 ) -> Result<ClientSentimentResponse, AppError> {
1109 let market_ids_str = market_ids.join(",");
1110 let path = format!("clientsentiment?marketIds={}", market_ids_str);
1111 info!("Getting client sentiment for {} markets", market_ids.len());
1112 let result: ClientSentimentResponse = self.http_client.get(&path, Some(1)).await?;
1113 debug!(
1114 "Client sentiment obtained for {} markets",
1115 result.client_sentiments.len()
1116 );
1117 Ok(result)
1118 }
1119
1120 async fn get_client_sentiment_by_market(
1121 &self,
1122 market_id: &str,
1123 ) -> Result<MarketSentiment, AppError> {
1124 let path = format!("clientsentiment/{}", market_id);
1125 info!("Getting client sentiment for market: {}", market_id);
1126 let result: MarketSentiment = self.http_client.get(&path, Some(1)).await?;
1127 debug!(
1128 "Client sentiment for {}: {}% long, {}% short",
1129 market_id, result.long_position_percentage, result.short_position_percentage
1130 );
1131 Ok(result)
1132 }
1133
1134 async fn get_related_sentiment(
1135 &self,
1136 market_id: &str,
1137 ) -> Result<ClientSentimentResponse, AppError> {
1138 let path = format!("clientsentiment/related/{}", market_id);
1139 info!("Getting related sentiment for market: {}", market_id);
1140 let result: ClientSentimentResponse = self.http_client.get(&path, Some(1)).await?;
1141 debug!(
1142 "Related sentiment obtained: {} markets",
1143 result.client_sentiments.len()
1144 );
1145 Ok(result)
1146 }
1147}
1148
1149#[async_trait]
1154impl CostsService for Client {
1155 async fn get_indicative_costs_open(
1156 &self,
1157 request: &OpenCostsRequest,
1158 ) -> Result<IndicativeCostsResponse, AppError> {
1159 info!(
1160 "Getting indicative costs for opening position on: {}",
1161 request.epic
1162 );
1163 let result: IndicativeCostsResponse = self
1164 .http_client
1165 .post("indicativecostsandcharges/open", request, Some(1))
1166 .await?;
1167 debug!(
1168 "Indicative costs obtained, reference: {}",
1169 result.indicative_quote_reference
1170 );
1171 Ok(result)
1172 }
1173
1174 async fn get_indicative_costs_close(
1175 &self,
1176 request: &CloseCostsRequest,
1177 ) -> Result<IndicativeCostsResponse, AppError> {
1178 info!(
1179 "Getting indicative costs for closing position: {}",
1180 request.deal_id
1181 );
1182 let result: IndicativeCostsResponse = self
1183 .http_client
1184 .post("indicativecostsandcharges/close", request, Some(1))
1185 .await?;
1186 debug!(
1187 "Indicative costs obtained, reference: {}",
1188 result.indicative_quote_reference
1189 );
1190 Ok(result)
1191 }
1192
1193 async fn get_indicative_costs_edit(
1194 &self,
1195 request: &EditCostsRequest,
1196 ) -> Result<IndicativeCostsResponse, AppError> {
1197 info!(
1198 "Getting indicative costs for editing position: {}",
1199 request.deal_id
1200 );
1201 let result: IndicativeCostsResponse = self
1202 .http_client
1203 .post("indicativecostsandcharges/edit", request, Some(1))
1204 .await?;
1205 debug!(
1206 "Indicative costs obtained, reference: {}",
1207 result.indicative_quote_reference
1208 );
1209 Ok(result)
1210 }
1211
1212 async fn get_costs_history(
1213 &self,
1214 from: &str,
1215 to: &str,
1216 ) -> Result<CostsHistoryResponse, AppError> {
1217 let path = format!("indicativecostsandcharges/history/from/{}/to/{}", from, to);
1218 info!("Getting costs history from {} to {}", from, to);
1219 let result: CostsHistoryResponse = self.http_client.get(&path, Some(1)).await?;
1220 debug!("Costs history obtained: {} entries", result.costs.len());
1221 Ok(result)
1222 }
1223
1224 async fn get_durable_medium(
1225 &self,
1226 quote_reference: &str,
1227 ) -> Result<DurableMediumResponse, AppError> {
1228 let path = format!(
1229 "indicativecostsandcharges/durablemedium/{}",
1230 quote_reference
1231 );
1232 info!("Getting durable medium for reference: {}", quote_reference);
1233 let result: DurableMediumResponse = self.http_client.get(&path, Some(1)).await?;
1234 debug!("Durable medium obtained for reference: {}", quote_reference);
1235 Ok(result)
1236 }
1237}
1238
1239#[async_trait]
1244impl OperationsService for Client {
1245 async fn get_client_apps(&self) -> Result<ApplicationDetailsResponse, AppError> {
1246 info!("Getting client applications");
1247 let result: ApplicationDetailsResponse = self
1248 .http_client
1249 .get("operations/application", Some(1))
1250 .await?;
1251 debug!(
1253 name = ?result.name,
1254 status = %result.status,
1255 "Client application obtained"
1256 );
1257 Ok(result)
1258 }
1259
1260 async fn disable_client_app(&self) -> Result<StatusResponse, AppError> {
1261 info!("Disabling current client application");
1262 let result: StatusResponse = self
1263 .http_client
1264 .put(
1265 "operations/application/disable",
1266 &serde_json::json!({}),
1267 Some(1),
1268 )
1269 .await?;
1270 debug!("Client application disabled");
1271 Ok(result)
1272 }
1273}
1274
1275#[cfg(feature = "streaming")]
1301#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
1302pub struct StreamerClient {
1303 account_id: String,
1304 config: ClientConfig,
1309 client: Option<LsClient>,
1312 session_events: Option<SessionEvents>,
1314 shutdown_tx: watch::Sender<bool>,
1318 converter_tasks: Vec<JoinHandle<()>>,
1322 has_market_stream_subs: bool,
1325 has_price_stream_subs: bool,
1326}
1327
1328#[cfg(feature = "streaming")]
1329impl StreamerClient {
1330 pub async fn new() -> Result<Self, AppError> {
1346 let client = Client::try_new()?;
1347 Self::with_client(&client).await
1348 }
1349
1350 pub async fn with_client(client: &Client) -> Result<Self, AppError> {
1364 let ws_info = client.ws_info().await?;
1365
1366 let config = ClientConfig::builder(ServerAddress::try_new(ws_info.server.as_str())?)
1370 .with_credentials(Credentials::new(
1371 ws_info.account_id.as_str(),
1372 ws_info.get_ws_password(),
1373 ))
1374 .build()?;
1375
1376 let (shutdown_tx, _) = watch::channel(false);
1377
1378 Ok(Self {
1379 account_id: ws_info.account_id.clone(),
1380 config,
1381 client: None,
1382 session_events: None,
1383 shutdown_tx,
1384 converter_tasks: Vec::new(),
1385 has_market_stream_subs: false,
1386 has_price_stream_subs: false,
1387 })
1388 }
1389
1390 async fn ensure_session(&mut self) -> Result<&LsClient, AppError> {
1401 if self.client.is_none() {
1402 let (client, events) = LsClient::connect(self.config.clone()).await?;
1403 info!(account_id = %self.account_id, "Lightstreamer session opened");
1404 self.client = Some(client);
1405 self.session_events = Some(events);
1406 }
1407
1408 self.client.as_ref().ok_or_else(|| {
1409 AppError::WebSocketError("streaming session not initialized".to_string())
1410 })
1411 }
1412
1413 async fn subscribe_and_convert<T, C>(
1421 &mut self,
1422 subscription: Subscription,
1423 label: &str,
1424 convert: C,
1425 ) -> Result<mpsc::UnboundedReceiver<T>, AppError>
1426 where
1427 T: Send + 'static,
1428 C: Fn(&StreamingUpdate) -> T + Send + 'static,
1429 {
1430 let updates = self.ensure_session().await?.subscribe(subscription).await?;
1431
1432 let (tx, rx) = mpsc::unbounded_channel();
1433 let mut shutdown = self.shutdown_tx.subscribe();
1434 let label = label.to_owned();
1435
1436 let handle = tokio::spawn(async move {
1437 let mut updates = updates;
1438 loop {
1439 let event = tokio::select! {
1440 _ = shutdown.changed() => {
1441 debug!(subscription = %label, "converter stopped by shutdown signal");
1442 return;
1443 }
1444 event = updates.next() => event,
1445 };
1446
1447 let Some(event) = event else {
1448 debug!(subscription = %label, "converter stopped: subscription stream closed");
1449 return;
1450 };
1451
1452 match event {
1453 SubscriptionEvent::Update(update) => {
1454 let data = convert(&StreamingUpdate::from(update.as_ref()));
1455 if tx.send(data).is_err() {
1456 debug!(subscription = %label, "converter stopped: receiver dropped");
1457 return;
1458 }
1459 }
1460 SubscriptionEvent::Activated {
1461 item_count,
1462 field_count,
1463 ..
1464 } => info!(
1465 subscription = %label,
1466 item_count,
1467 field_count,
1468 "subscription started"
1469 ),
1470 SubscriptionEvent::Rejected(e) => {
1473 error!(subscription = %label, error = %e, "IG refused the subscription");
1474 return;
1475 }
1476 SubscriptionEvent::Unsubscribed => {
1477 info!(subscription = %label, "subscription ended");
1478 return;
1479 }
1480 SubscriptionEvent::Overflow {
1481 item_index,
1482 dropped_count,
1483 } => warn!(
1484 subscription = %label,
1485 item_index,
1486 dropped_count,
1487 "IG dropped updates for this item"
1488 ),
1489 other => debug!(subscription = %label, event = ?other, "subscription event"),
1490 }
1491 }
1492 });
1493 self.converter_tasks.push(handle);
1494
1495 Ok(rx)
1496 }
1497
1498 pub async fn market_subscribe(
1534 &mut self,
1535 epics: Vec<String>,
1536 fields: HashSet<StreamingMarketField>,
1537 ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1538 let epic_count = epics.len();
1539 let items: Vec<String> = epics
1540 .into_iter()
1541 .map(|epic| format!("MARKET:{epic}"))
1542 .collect();
1543 let subscription = Subscription::new(
1544 SubscriptionMode::Merge,
1545 ItemGroup::from_items(items)?,
1546 FieldSchema::from_fields(get_streaming_market_fields(&fields))?,
1547 )
1548 .with_snapshot(Snapshot::On);
1549
1550 let receiver = self
1551 .subscribe_and_convert(subscription, "market", |update| PriceData::from(update))
1552 .await?;
1553 self.has_market_stream_subs = true;
1554
1555 info!("Market subscription created for {epic_count} instruments");
1556 Ok(receiver)
1557 }
1558
1559 pub async fn trade_subscribe(
1587 &mut self,
1588 ) -> Result<mpsc::UnboundedReceiver<TradeFields>, AppError> {
1589 let account_id = self.account_id.clone();
1590 let subscription = Subscription::new(
1591 SubscriptionMode::Distinct,
1592 ItemGroup::from_items([format!("TRADE:{account_id}")])?,
1593 FieldSchema::from_fields(["CONFIRMS", "OPU", "WOU"])?,
1594 )
1595 .with_snapshot(Snapshot::On);
1596
1597 let receiver = self
1598 .subscribe_and_convert(subscription, "trade", |update| {
1599 crate::presentation::trade::TradeData::from(update).fields
1600 })
1601 .await?;
1602 self.has_market_stream_subs = true;
1603
1604 info!(account_id = %account_id, "Trade subscription created");
1605 Ok(receiver)
1606 }
1607
1608 pub async fn account_subscribe(
1641 &mut self,
1642 fields: HashSet<StreamingAccountDataField>,
1643 ) -> Result<mpsc::UnboundedReceiver<AccountFields>, AppError> {
1644 let account_id = self.account_id.clone();
1645 let subscription = Subscription::new(
1646 SubscriptionMode::Merge,
1647 ItemGroup::from_items([format!("ACCOUNT:{account_id}")])?,
1648 FieldSchema::from_fields(get_streaming_account_data_fields(&fields))?,
1649 )
1650 .with_snapshot(Snapshot::On);
1651
1652 let receiver = self
1653 .subscribe_and_convert(subscription, "account", |update| {
1654 crate::presentation::account::AccountData::from(update).fields
1655 })
1656 .await?;
1657 self.has_market_stream_subs = true;
1658
1659 info!(account_id = %account_id, "Account subscription created");
1660 Ok(receiver)
1661 }
1662
1663 pub async fn price_subscribe(
1700 &mut self,
1701 epics: Vec<String>,
1702 fields: HashSet<StreamingPriceField>,
1703 ) -> Result<mpsc::UnboundedReceiver<PriceData>, AppError> {
1704 let account_id = self.account_id.clone();
1705 let epic_count = epics.len();
1706 let items: Vec<String> = epics
1707 .into_iter()
1708 .map(|epic| format!("PRICE:{account_id}:{epic}"))
1709 .collect();
1710 let field_names = get_streaming_price_fields(&fields);
1711
1712 debug!(?items, ?field_names, "Pricing subscription shape");
1713
1714 let pricing_adapter =
1718 std::env::var("IG_PRICING_ADAPTER").unwrap_or_else(|_| "Pricing".to_string());
1719 debug!(adapter = %pricing_adapter, "Using Pricing data adapter");
1720
1721 let subscription = Subscription::new(
1722 SubscriptionMode::Merge,
1723 ItemGroup::from_items(items)?,
1724 FieldSchema::from_fields(field_names)?,
1725 )
1726 .with_data_adapter(pricing_adapter)
1727 .with_snapshot(Snapshot::On);
1728
1729 let receiver = self
1730 .subscribe_and_convert(subscription, "price", |update| PriceData::from(update))
1731 .await?;
1732 self.has_price_stream_subs = true;
1733
1734 info!(account_id = %account_id, "Price subscription created for {epic_count} instruments");
1735 Ok(receiver)
1736 }
1737
1738 pub async fn chart_subscribe(
1777 &mut self,
1778 epics: Vec<String>,
1779 scale: ChartScale,
1780 fields: HashSet<StreamingChartField>,
1781 ) -> Result<mpsc::UnboundedReceiver<ChartData>, AppError> {
1782 let epic_count = epics.len();
1783 let items: Vec<String> = epics
1784 .into_iter()
1785 .map(|epic| format!("CHART:{epic}:{scale}"))
1786 .collect();
1787
1788 let mode = if matches!(scale, ChartScale::Tick) {
1791 SubscriptionMode::Distinct
1792 } else {
1793 SubscriptionMode::Merge
1794 };
1795
1796 let subscription = Subscription::new(
1797 mode,
1798 ItemGroup::from_items(items)?,
1799 FieldSchema::from_fields(get_streaming_chart_fields(&fields))?,
1800 )
1801 .with_snapshot(Snapshot::On);
1802
1803 let receiver = self
1804 .subscribe_and_convert(subscription, "chart", |update| ChartData::from(update))
1805 .await?;
1806 self.has_market_stream_subs = true;
1807
1808 info!("Chart subscription created for {epic_count} instruments (scale: {scale})");
1809 Ok(receiver)
1810 }
1811
1812 pub async fn connect(&mut self, shutdown_signal: Option<Arc<Notify>>) -> Result<(), AppError> {
1838 let Some(mut events) = self.session_events.take() else {
1839 warn!("No streaming session to run: subscribe first, and call connect once");
1842 return Ok(());
1843 };
1844
1845 info!(
1846 market_subscriptions = self.has_market_stream_subs,
1847 price_subscriptions = self.has_price_stream_subs,
1848 "Streaming session running"
1849 );
1850
1851 let shutdown = wait_for_shutdown(shutdown_signal);
1855 tokio::pin!(shutdown);
1856
1857 loop {
1858 let event = tokio::select! {
1859 () = &mut shutdown => {
1860 info!("Streaming session stopping: shutdown requested");
1861 return Ok(());
1862 }
1863 event = events.next() => event,
1864 };
1865
1866 let Some(event) = event else {
1867 debug!("Session event stream ended");
1870 return Ok(());
1871 };
1872
1873 match event {
1874 SessionEvent::Connected(connected) => match connected.continuity {
1875 Continuity::Replaced { .. } => warn!(
1881 continuity = ?connected.continuity,
1882 "Streaming session replaced: subscriptions re-executed, expect fresh snapshots"
1883 ),
1884 _ => info!(
1885 continuity = ?connected.continuity,
1886 "Streaming session connected"
1887 ),
1888 },
1889 SessionEvent::Resubscribed(subscriptions) => {
1890 info!(
1891 count = subscriptions.len(),
1892 "Subscriptions re-created on a new session"
1893 );
1894 }
1895 SessionEvent::Disconnected { reason, retry_in } => match retry_in {
1896 Some(delay) => warn!(
1897 ?reason,
1898 retry_in_ms = delay.as_millis(),
1899 "Streaming session disconnected, reconnecting"
1900 ),
1901 None => warn!(?reason, "Streaming session disconnected, giving up"),
1902 },
1903 SessionEvent::Closed(reason) => return Self::report_close(&reason),
1904 SessionEvent::RequestRejected(e) => {
1905 warn!(error = %e, "IG refused a streaming control request");
1906 }
1907 SessionEvent::RequestNotSent { reason } => {
1908 warn!(%reason, "A streaming control request never left the client");
1909 }
1910 SessionEvent::Unrecognized { line } => {
1912 trace!(%line, "Unrecognized streaming notification");
1913 }
1914 other => debug!(event = ?other, "Session event"),
1915 }
1916 }
1917 }
1918
1919 fn report_close(reason: &ClosedReason) -> Result<(), AppError> {
1925 match reason {
1926 ClosedReason::ByClient => {
1927 info!("Streaming session closed by this client");
1928 Ok(())
1929 }
1930 ClosedReason::ByServer(e) => {
1931 error!(error = %e, "IG closed the streaming session");
1932 Err(AppError::WebSocketError(format!(
1933 "IG closed the streaming session: {e}"
1934 )))
1935 }
1936 ClosedReason::ReconnectExhausted { attempts, last } => {
1937 error!(attempts, last_reason = ?last, "Streaming reconnection budget exhausted");
1938 Err(AppError::WebSocketError(format!(
1939 "streaming reconnection budget exhausted after {attempts} attempts"
1940 )))
1941 }
1942 ClosedReason::Internal { reason } => {
1943 error!(%reason, "Streaming client failed internally");
1944 Err(AppError::WebSocketError(format!(
1945 "streaming client failed internally: {reason}"
1946 )))
1947 }
1948 other => {
1949 error!(reason = ?other, "Streaming session closed");
1950 Err(AppError::WebSocketError(format!(
1951 "streaming session closed: {other:?}"
1952 )))
1953 }
1954 }
1955 }
1956
1957 pub async fn disconnect(&mut self) -> Result<(), AppError> {
1970 let _ = self.shutdown_tx.send(true);
1973
1974 let converter_count = self.converter_tasks.len();
1975 join_tasks(&mut self.converter_tasks).await;
1976 if converter_count > 0 {
1977 debug!("Stopped {converter_count} converter task(s)");
1978 }
1979
1980 self.session_events = None;
1981
1982 if let Some(client) = self.client.take() {
1983 client.disconnect().await?;
1984 info!("Streaming session closed");
1985 }
1986
1987 Ok(())
1988 }
1989}
1990
1991#[cfg(feature = "streaming")]
1997async fn wait_for_shutdown(signal: Option<Arc<Notify>>) {
1998 if let Some(signal) = signal {
1999 signal.notified().await;
2000 return;
2001 }
2002
2003 #[cfg(unix)]
2004 {
2005 use tokio::signal::unix::{SignalKind, signal};
2006 match (
2009 signal(SignalKind::interrupt()),
2010 signal(SignalKind::terminate()),
2011 ) {
2012 (Ok(mut sigint), Ok(mut sigterm)) => {
2013 tokio::select! {
2014 _ = sigint.recv() => info!("SIGINT received"),
2015 _ = sigterm.recv() => info!("SIGTERM received"),
2016 }
2017 }
2018 (sigint, sigterm) => {
2019 if let Err(e) = sigint {
2020 error!(error = %e, "cannot install the SIGINT handler");
2021 }
2022 if let Err(e) = sigterm {
2023 error!(error = %e, "cannot install the SIGTERM handler");
2024 }
2025 std::future::pending::<()>().await;
2026 }
2027 }
2028 }
2029
2030 #[cfg(not(unix))]
2031 {
2032 if let Err(e) = tokio::signal::ctrl_c().await {
2033 error!(error = %e, "cannot wait for Ctrl-C");
2034 std::future::pending::<()>().await;
2035 }
2036 }
2037}
2038
2039#[cfg(feature = "streaming")]
2040impl Drop for StreamerClient {
2041 fn drop(&mut self) {
2047 let _ = self.shutdown_tx.send(true);
2048 for handle in self.converter_tasks.drain(..) {
2049 handle.abort();
2050 }
2051 }
2052}
2053
2054#[cfg(test)]
2055mod tests {
2056 use super::is_transient_confirmation_error;
2057 use crate::error::AppError;
2058 use reqwest::StatusCode;
2059
2060 #[test]
2061 fn test_confirmation_error_rate_limit_is_transient() {
2062 assert!(is_transient_confirmation_error(
2063 &AppError::RateLimitExceeded
2064 ));
2065 }
2066
2067 #[test]
2068 fn test_confirmation_error_not_found_is_transient() {
2069 assert!(is_transient_confirmation_error(&AppError::NotFound));
2071 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2072 StatusCode::NOT_FOUND
2073 )));
2074 }
2075
2076 #[test]
2077 fn test_confirmation_error_server_error_is_transient() {
2078 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2079 StatusCode::INTERNAL_SERVER_ERROR
2080 )));
2081 assert!(is_transient_confirmation_error(&AppError::Unexpected(
2082 StatusCode::BAD_GATEWAY
2083 )));
2084 }
2085
2086 #[test]
2087 fn test_confirmation_error_invalid_input_is_permanent() {
2088 assert!(!is_transient_confirmation_error(&AppError::InvalidInput(
2089 "bad".to_string()
2090 )));
2091 }
2092
2093 #[test]
2094 fn test_confirmation_error_auth_and_deser_are_permanent() {
2095 assert!(!is_transient_confirmation_error(&AppError::Unauthorized));
2096 assert!(!is_transient_confirmation_error(
2097 &AppError::OAuthTokenExpired
2098 ));
2099 assert!(!is_transient_confirmation_error(
2100 &AppError::Deserialization("bad".to_string())
2101 ));
2102 assert!(!is_transient_confirmation_error(&AppError::Unexpected(
2103 StatusCode::BAD_REQUEST
2104 )));
2105 }
2106}
2107
2108#[cfg(all(test, feature = "streaming"))]
2112mod streaming_tests {
2113 use super::{ClosedReason, StreamerClient, join_tasks};
2114 use crate::error::AppError;
2115 use lightstreamer_rs::ServerError;
2116 use std::time::Duration;
2117 use tokio::sync::watch;
2118 use tokio::task::JoinHandle;
2119
2120 #[test]
2127 fn test_close_by_client_is_success() {
2128 let result = StreamerClient::report_close(&ClosedReason::ByClient);
2129 assert!(
2130 result.is_ok(),
2131 "a close this client asked for is not a failure: {result:?}"
2132 );
2133 }
2134
2135 #[test]
2136 fn test_close_by_server_is_an_error() {
2137 let reason = ClosedReason::ByServer(ServerError::new(-1, "Insufficient permissions"));
2139 let result = StreamerClient::report_close(&reason);
2140 assert!(
2141 matches!(result, Err(AppError::WebSocketError(_))),
2142 "a server-initiated close must surface as an error: {result:?}"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_close_after_exhausted_reconnection_is_an_error() {
2148 let result = StreamerClient::report_close(&ClosedReason::ReconnectExhausted {
2149 attempts: 8,
2150 last: None,
2151 });
2152 match result {
2153 Err(AppError::WebSocketError(message)) => {
2154 assert!(
2155 message.contains('8'),
2156 "the attempt count belongs in the message: {message}"
2157 );
2158 }
2159 other => panic!("expected a websocket error, got {other:?}"),
2160 }
2161 }
2162
2163 #[test]
2164 fn test_close_on_internal_failure_is_an_error() {
2165 let reason = ClosedReason::Internal {
2166 reason: "bug".to_string(),
2167 };
2168 assert!(matches!(
2169 StreamerClient::report_close(&reason),
2170 Err(AppError::WebSocketError(_))
2171 ));
2172 }
2173
2174 #[tokio::test]
2177 async fn test_watch_signal_stops_every_converter() {
2178 let (tx, _) = watch::channel(false);
2184 let mut tasks: Vec<JoinHandle<()>> = Vec::new();
2185 for _ in 0..3 {
2186 let mut shutdown = tx.subscribe();
2187 tasks.push(tokio::spawn(async move {
2188 tokio::select! {
2189 _ = shutdown.changed() => {}
2190 () = std::future::pending::<()>() => {}
2191 }
2192 }));
2193 }
2194
2195 assert!(tx.send(true).is_ok());
2198
2199 let joined = tokio::time::timeout(Duration::from_secs(1), join_tasks(&mut tasks)).await;
2200 assert!(joined.is_ok(), "converters did not observe the shutdown");
2201 assert!(tasks.is_empty(), "join_tasks must drain the list");
2202 }
2203
2204 #[tokio::test]
2205 async fn test_join_tasks_waits_for_completion() {
2206 let mut tasks: Vec<JoinHandle<()>> = vec![tokio::spawn(async {})];
2207 join_tasks(&mut tasks).await;
2208 assert!(tasks.is_empty());
2209 }
2210}