shindo_coding_utils 0.3.8

A utils crates which will be used in various micro-services
Documentation
use async_trait::async_trait;
use reqwest::{Client, header};
use std::error::Error;
use std::time::Duration as StdDuration;
use tracing::{error, warn};

use crate::errors::CustomError;
use crate::types::{IntradayTrading, VpsForeignTradingData, VpsIntradayTradingResponseV2};

#[derive(thiserror::Error, Debug)]
pub enum AppError {
    #[error("HTTP error: {0}")]
    HttpError(#[from] reqwest::Error),
}

// Implement conversion from AppError to CustomError.
impl From<AppError> for CustomError {
    fn from(err: AppError) -> Self {
        CustomError {
            message: err.to_string(),
        }
    }
}

#[async_trait]
pub trait VpsServiceTrait: Send + Sync {
    async fn get_list_stock_trade_v2(
        &self,
        ticker: String,
    ) -> Result<Vec<IntradayTrading>, reqwest::Error>;

    async fn fetch_stock_trade_list_by_ticker_code(
        &self,
        ticker: String,
    ) -> Result<Vec<IntradayTrading>, reqwest::Error>;

    async fn fetch_latest_stock_trade_item_by_ticker_code(
        &self,
        ticker: String,
    ) -> Option<IntradayTrading>;

    async fn fetch_foreign_trading_data(
        &self,
        ticker: String,
    ) -> Result<VpsForeignTradingData, CustomError>;
}

pub struct VpsService {
    base_url: String,
    client: Client,
}

impl VpsService {
    pub fn new() -> Self {
        // Create a reqwest client with default configuration
        let client = Client::builder()
            .timeout(StdDuration::from_secs(30))
            .build()
            .expect("Failed to create HTTP client");

        Self {
            base_url: "https://bgapidatafeed.vps.com.vn".to_string(),
            client,
        }
    }
}

#[async_trait]
impl VpsServiceTrait for VpsService {
    async fn get_list_stock_trade_v2(
        &self,
        ticker: String,
    ) -> Result<Vec<IntradayTrading>, reqwest::Error> {
        let mut next_idx = -1;
        let num_page = 5000;
        let mut results: Vec<IntradayTrading> = Vec::new();
        loop {
            let url = format!(
                "{}/getliststocktrade_cs/{}?numpage={}&nextidx={}",
                self.base_url, ticker, num_page, next_idx
            );
            let client = self.client.clone();

            let response = client
                .get(&url)
                .header(header::CONTENT_TYPE, "application/json; charset=utf-8")
                .send()
                .await?;

            let result = response
                .json::<Option<VpsIntradayTradingResponseV2>>()
                .await;

            match result {
                Ok(intraday_trading_data_v2) => {
                    let stock_trade_list = intraday_trading_data_v2.unwrap();
                    next_idx = stock_trade_list.next_idx;
                    let intraday_trading_data_v1 =
                        stock_trade_list
                            .clone()
                            .data
                            .into_iter()
                            .map(|item| IntradayTrading {
                                 id: 0, // or use another known default/meaningful value
                                 ticker: ticker.clone(),
                                 last_price: item.last_price,
                                 open_price: 0, // no value in V2; fill as needed
                                 high_price: 0, // no value in V2; fill as needed
                                 low_price: 0,  // no value in V2; fill as needed
                                 change: item.change,
                                 last_vol: item.last_vol,
                                 total_vol: item.total_vol,
                                 trading_timestamp: item.trading_timestamp,
                                 side: item.side,
                             });
                    results.extend(intraday_trading_data_v1);
                    if stock_trade_list.data.is_empty() || stock_trade_list.next_idx == 0 {
                        break;
                    }
                }
                Err(e) => {
                    warn!("Error getliststocktrade_cs: {:?}", e);
                    // An empty response body causes a decode error. We treat this as an empty list.
                    if e.is_decode() {
                        warn!("Decode error encountered, treating as empty list.");
                        break;
                    } else {
                        return Err(e);
                    }
                }
            }
        }

        Ok(results)
    }

    async fn fetch_stock_trade_list_by_ticker_code(
        &self,
        ticker: String,
    ) -> Result<Vec<IntradayTrading>, reqwest::Error> {
        let url = format!("{}/getliststocktrade/{}", self.base_url, ticker);
        let client = self.client.clone();

        let response = client
            .get(&url)
            .header(header::CONTENT_TYPE, "application/json; charset=utf-8")
            .send()
            .await?;

        let result = response.json::<Option<Vec<IntradayTrading>>>().await;

        match result {
            Ok(list_option) => {
                let stock_trade_list = list_option.unwrap_or_default();
                Ok(stock_trade_list)
            }
            Err(e) => {
                // An empty response body causes a decode error. We treat this as an empty list.
                if e.is_decode() {
                    Ok(Vec::new())
                } else {
                    Err(e)
                }
            }
        }
    }

    /// Fetch the ongoing trading data -- Realtime
    async fn fetch_foreign_trading_data(
        &self,
        ticker: String,
    ) -> Result<VpsForeignTradingData, CustomError> {
        let url = format!("{}/getliststockdata/{}", self.base_url, ticker);
        let client = self.client.clone();

        // Get the reqwest::Response. If sending fails, convert the error.
        let response = client
            .get(&url)
            .header(header::CONTENT_TYPE, "application/json; charset=utf-8")
            .send()
            .await
            .map_err(|err| CustomError::from(AppError::from(err)))?;

        // Deserialize the response. Assuming the endpoint returns an array.
        let data: Vec<VpsForeignTradingData> = response.json().await.map_err(|err| {
            println!("Error {:?}", err.source());
            CustomError::from(AppError::from(err))
        })?;

        // Find the first record; if missing return a custom error.
        let record = data.into_iter().next().ok_or_else(|| CustomError {
            message: "No trading data found".to_string(),
        })?;

        Ok(record)
    }

    async fn fetch_latest_stock_trade_item_by_ticker_code(
        &self,
        ticker: String,
    ) -> Option<IntradayTrading> {
        let result: Option<IntradayTrading> = None;

        let stock_trade_list = self.get_list_stock_trade_v2(ticker).await;
        match stock_trade_list {
            Ok(list) => list.into_iter().last(),
            Err(err) => {
                error!(
                    "fetch_latest_stock_trade_item_by_ticker_code error: {:?}",
                    err
                );
                result
            } // If there's an error, return None
        }
    }
}