From 2615a7e7388d3433ac8050db57824ad235703da8 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 4 Nov 2025 11:44:19 +0700 Subject: [PATCH 01/35] add band source --- Cargo.lock | 23 +++ Cargo.toml | 1 + bothan-api/server-cli/src/commands/start.rs | 2 + bothan-api/server/Cargo.toml | 1 + .../src/config/manager/crypto_info/sources.rs | 6 + bothan-band/Cargo.toml | 31 ++++ bothan-band/src/api.rs | 26 ++++ bothan-band/src/api/builder.rs | 104 ++++++++++++++ bothan-band/src/api/error.rs | 65 +++++++++ bothan-band/src/api/rest.rs | 135 ++++++++++++++++++ bothan-band/src/api/types.rs | 19 +++ bothan-band/src/lib.rs | 5 + bothan-band/src/worker.rs | 107 ++++++++++++++ bothan-band/src/worker/error.rs | 16 +++ bothan-band/src/worker/opts.rs | 84 +++++++++++ bothan-core/Cargo.toml | 1 + .../src/manager/crypto_asset_info/worker.rs | 5 + .../manager/crypto_asset_info/worker/opts.rs | 8 ++ 18 files changed, 639 insertions(+) create mode 100644 bothan-band/Cargo.toml create mode 100644 bothan-band/src/api.rs create mode 100644 bothan-band/src/api/builder.rs create mode 100644 bothan-band/src/api/error.rs create mode 100644 bothan-band/src/api/rest.rs create mode 100644 bothan-band/src/api/types.rs create mode 100644 bothan-band/src/lib.rs create mode 100644 bothan-band/src/worker.rs create mode 100644 bothan-band/src/worker/error.rs create mode 100644 bothan-band/src/worker/opts.rs diff --git a/Cargo.lock b/Cargo.lock index 0777d87c..eeef1c75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,7 @@ name = "bothan-api" version = "0.0.1" dependencies = [ "async-trait", + "bothan-band", "bothan-binance", "bothan-bitfinex", "bothan-bybit", @@ -489,6 +490,27 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "bothan-band" +version = "0.0.1" +dependencies = [ + "async-trait", + "bothan-lib", + "chrono", + "humantime-serde", + "itertools 0.14.0", + "mockito", + "reqwest", + "rust_decimal", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", + "url", +] + [[package]] name = "bothan-binance" version = "0.0.1" @@ -627,6 +649,7 @@ dependencies = [ "async-trait", "axum 0.8.4", "bincode", + "bothan-band", "bothan-binance", "bothan-bitfinex", "bothan-bybit", diff --git a/Cargo.toml b/Cargo.toml index 101be36e..76f49707 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ bothan-coinmarketcap = { path = "bothan-coinmarketcap", version = "0.0.1" } bothan-htx = { path = "bothan-htx", version = "0.0.1" } bothan-kraken = { path = "bothan-kraken", version = "0.0.1" } bothan-okx = { path = "bothan-okx", version = "0.0.1" } +bothan-band = { path = "bothan-band", version = "0.0.1" } anyhow = "1.0.86" async-trait = "0.1.77" diff --git a/bothan-api/server-cli/src/commands/start.rs b/bothan-api/server-cli/src/commands/start.rs index f508fc27..96c2c4c6 100644 --- a/bothan-api/server-cli/src/commands/start.rs +++ b/bothan-api/server-cli/src/commands/start.rs @@ -251,6 +251,8 @@ async fn init_crypto_opts( add_worker_opts(&mut worker_opts, &source.htx).await?; add_worker_opts(&mut worker_opts, &source.kraken).await?; add_worker_opts(&mut worker_opts, &source.okx).await?; + add_worker_opts(&mut worker_opts, &source.band1).await?; + add_worker_opts(&mut worker_opts, &source.band2).await?; Ok(worker_opts) } diff --git a/bothan-api/server/Cargo.toml b/bothan-api/server/Cargo.toml index 5c0d10dc..166f8920 100644 --- a/bothan-api/server/Cargo.toml +++ b/bothan-api/server/Cargo.toml @@ -18,6 +18,7 @@ bothan-coinmarketcap = { workspace = true } bothan-htx = { workspace = true } bothan-kraken = { workspace = true } bothan-okx = { workspace = true } +bothan-band = {workspace = true} async-trait = { workspace = true } chrono = { workspace = true } diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 6268bd36..2f83b248 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -32,6 +32,10 @@ pub struct CryptoSourceConfigs { pub kraken: Option, /// OKX worker options. pub okx: Option, + /// Band1 worker options. + pub band1: Option, + /// Band2 worker options. + pub band2: Option, } impl CryptoSourceConfigs { @@ -47,6 +51,8 @@ impl CryptoSourceConfigs { htx: Some(bothan_htx::WorkerOpts::default()), kraken: Some(bothan_kraken::WorkerOpts::default()), okx: Some(bothan_okx::WorkerOpts::default()), + band1: Some(bothan_band::WorkerOpts::default()), + band2: Some(bothan_band::WorkerOpts::default()), } } } diff --git a/bothan-band/Cargo.toml b/bothan-band/Cargo.toml new file mode 100644 index 00000000..206a3ae2 --- /dev/null +++ b/bothan-band/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "bothan-band" +version = "0.0.1" +description = "Rust client for the Band source with Bothan integration" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +bothan-lib = { workspace = true } + +async-trait = { workspace = true } +chrono = { workspace = true } +humantime-serde = { workspace = true } +itertools = { workspace = true } +reqwest = { workspace = true } +rust_decimal = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +mockito = { workspace = true } + + +[package.metadata.cargo-machete] +ignored = ["humantime-serde"] diff --git a/bothan-band/src/api.rs b/bothan-band/src/api.rs new file mode 100644 index 00000000..0f36be5d --- /dev/null +++ b/bothan-band/src/api.rs @@ -0,0 +1,26 @@ +//! Band REST API client implementation. +//! +//! This module provides types and utilities for interacting with the Band REST API, +//! including configuration, request execution, error handling, and response deserialization. +//! +//! The module provides: +//! +//! - [`builder`] — A builder pattern for creating [`RestApi`] clients with optional parameters like base URL and API key. +//! - [`rest`] — Core API client implementation, including HTTP request logic and integration with Bothan's `AssetInfoProvider` trait. +//! - [`types`] — Data types that represent Band REST API responses such as [`Price`](types::Price) +//! - [`error`] — Custom error types used during API client configuration and request processing. +//! +//! # Integration with Workers +//! +//! This module is intended to be used by worker implementations (such as [`Worker`](`crate::worker::Worker`)) +//! that periodically query Band for asset data. The [`RestApi`] implements the +//! [`AssetInfoProvider`](bothan_lib::worker::rest::AssetInfoProvider) trait, which allows +//! Band responses to be translated into Bothan-compatible asset updates. + +pub use builder::RestApiBuilder; +pub use rest::RestApi; + +pub mod builder; +pub mod error; +pub mod rest; +pub mod types; diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs new file mode 100644 index 00000000..a18817fb --- /dev/null +++ b/bothan-band/src/api/builder.rs @@ -0,0 +1,104 @@ +//! Builder for configuring and constructing `BandAPI`. +//! +//! This module provides a builder for constructing [`RestApi`] clients used +//! to interact with the Band REST API. The builder supports optional configuration +//! of base URL and API key. +//! +//! The module provides: +//! +//! - The [`RestApiBuilder`] for REST API building +//! - Supports setting the API base URL and API key +//! - Automatically uses the default Band URL when parameters are omitted during the [`build`](`RestApiBuilder::build`) call + +use reqwest::ClientBuilder; +use reqwest::header::{HeaderMap}; +use url::Url; + +use crate::api::RestApi; +use crate::api::error::BuildError; +use crate::api::types::DEFAULT_URL; + +/// Builder for creating instances of [`RestApi`]. +/// +/// The `RestApiBuilder` provides a builder pattern for setting up a [`RestApi`] instance +/// by allowing users to specify optional configuration parameters such as the base URL and API key. +/// +/// # Example +/// ``` +/// use bothan_band::api::RestApiBuilder; +/// +/// #[tokio::main] +/// async fn main() { +/// let mut api = RestApiBuilder::default() +/// .with_url("https://bandsource-url.com") +/// .build() +/// .unwrap(); +/// } +/// ``` +pub struct RestApiBuilder { + /// Base URL of the Band REST API. + url: String, +} + +impl RestApiBuilder { + /// Creates a new `RestApiBuilder` with the specified configuration. + /// + /// This method allows manual initialization of the builder using + /// optional parameter for API key, and a required URL string. + /// + /// # Examples + /// + /// ``` + /// use bothan_band::api::RestApiBuilder; + /// + /// let builder = RestApiBuilder::new( + /// "https://bandsource-url.com", + /// ); + /// ``` + pub fn new(url: T) -> Self + where + T: Into, + { + RestApiBuilder { + url: url.into(), + } + } + + /// Sets the URL for the Band API. + /// The default URL is `DEFAULT_URL`. + pub fn with_url(mut self, url: &str) -> Self { + self.url = url.into(); + self + } + + /// Builds the [`RestApi`] instance. + /// + /// This method consumes the builder and attempts to create a fully configured client. + /// + /// # Errors + /// + /// Returns a [`BuildError`] if: + /// - The URL is invalid + /// - The API key or HTTP headers are malformed + /// - The HTTP client fails to build + /// - The API key is missing (required for Band) + pub fn build(self) -> Result { + let headers = HeaderMap::new(); + + let parsed_url = Url::parse(&self.url)?; + + let client = ClientBuilder::new().default_headers(headers).build()?; + + Ok(RestApi::new(parsed_url, client)) + } +} + +impl Default for RestApiBuilder { + /// Creates a new `BandRestAPIBuilder` with the + /// default URL and no API key. + fn default() -> Self { + RestApiBuilder { + url: DEFAULT_URL.into(), + } + } +} diff --git a/bothan-band/src/api/error.rs b/bothan-band/src/api/error.rs new file mode 100644 index 00000000..3e212456 --- /dev/null +++ b/bothan-band/src/api/error.rs @@ -0,0 +1,65 @@ +//! Error types for Band REST API client operations. +//! +//! This module provides custom error types used throughout the Band REST API integration, +//! particularly for REST API client configuration and concurrent background data fetching. + +use thiserror::Error; + +/// Errors from initializing the Band REST API builder. +/// +/// These errors can occur during the initialization and configuration of the HTTP client +/// or while constructing request parameters. +#[derive(Debug, Error)] +pub enum BuildError { + /// Indicates the provided URL was invalid. + #[error("invalid url")] + InvalidURL(#[from] url::ParseError), + + /// Indicates an HTTP header value was invalid or contained prohibited characters. + #[error("invalid header value")] + InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), + + /// Represents general failures during HTTP client construction (e.g., TLS configuration issues). + #[error("reqwest error: {0}")] + FailedToBuild(#[from] reqwest::Error), +} + +/// General errors from Band API operations. +/// +/// These errors typically occur during API calls, response parsing, or data validation. +#[derive(Debug, Error)] +pub enum Error { + /// Indicates the requested limit is too high (must be <= 5000). + #[error("limit must be lower or equal to 5000")] + LimitTooHigh, + + /// Indicates an HTTP request failure due to network issues or HTTP errors. + #[error("failed request: {0}")] + FailedRequest(#[from] reqwest::Error), +} + +/// Errors from fetching and handling data from the Band REST API. +/// +/// These errors typically occur during API calls, response parsing, or data validation. +#[derive(Debug, Error)] +pub enum ProviderError { + /// Indicates that an ID in the request is not a valid integer. + #[error("ids contains non integer value")] + InvalidId, + + /// Indicates HTTP request failure due to network issues or HTTP errors. + #[error("failed to fetch tickers: {0}")] + RequestError(#[from] reqwest::Error), + + /// Indicates a failure to parse the API response. + #[error("parse error: {0}")] + ParseError(#[from] ParseError), +} + +/// Errors that can occur while parsing Band API responses. +#[derive(Debug, Error)] +pub enum ParseError { + /// Indicates that the price value is not a valid number (NaN). + #[error("price is NaN")] + InvalidPrice, +} diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs new file mode 100644 index 00000000..1b58c345 --- /dev/null +++ b/bothan-band/src/api/rest.rs @@ -0,0 +1,135 @@ +//! Band REST API client implementation. +//! +//! This module provides the [`RestApi`], a client for interacting with the Band REST API. +//! It includes methods for retrieving asset quotes and is used internally +//! to implement the [`AssetInfoProvider`] trait for asset workers. +//! +//! This module provides: +//! +//! - Fetches the latest quotes for assets from the `/v2/cryptocurrency/quotes/latest` endpoint +//! - Transforms API responses into [`AssetInfo`] for use in workers +//! - Handles deserialization and error propagation + +use bothan_lib::types::AssetInfo; +use bothan_lib::worker::rest::AssetInfoProvider; +use itertools::Itertools; +use reqwest::{Client, Url}; +use rust_decimal::Decimal; + +use crate::api::error::ParseError; +use crate::api::types::{Price}; +use crate::api::error::ProviderError; + +/// Client for interacting with the Band REST API. +/// +/// The [`RestApi`] includes a base URL and HTTP client used to send +/// requests to the Band REST API. It provides methods for fetching asset quotes. It is also used to implement the [`AssetInfoProvider`] trait +/// for integration into the REST API worker. +/// +/// # Examples +/// +/// ```rust +/// use bothan_band::api::{RestApi, types::Quote}; +/// use reqwest::ClientBuilder; +/// use reqwest::header::{HeaderMap, HeaderValue}; +/// use url::Url; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let mut headers = HeaderMap::new(); +/// let client = ClientBuilder::new().default_headers(headers).build()?; +/// +/// let api = RestApi::new(Url::parse("https://bandsource-url.com")?, client); +/// Ok(()) +/// } +/// ``` +pub struct RestApi { + /// The base URL of the Band REST API. + url: Url, + /// The reqwest HTTP client used to make requests. + client: Client, +} + +impl RestApi { + /// Creates a new instance of `BandRestAPI`. + pub fn new(url: Url, client: Client) -> Self { + Self { url, client } + } + + /// Retrieves market data for the specified cryptocurrency IDs from the Band REST API. + /// + /// This method constructs a request to the Band endpoint + /// and returns a vector of `Price<...>`, where each element corresponds to the ID at the same + /// position in the input slice. + /// + /// # Query Construction + /// + /// The query includes: + /// - `id`: comma-separated list of coin IDs + /// + /// # Errors + /// + /// Returns a [`reqwest::Error`] if: + /// - The request fails due to network issues + /// - The response status is not 2xx + /// - JSON deserialization into `HashMap` fails + pub async fn get_latest_prices( + &self, + ids: &[String], + ) -> Result, reqwest::Error> { + let url = format!("{}prices/", self.url); + let ids_string = ids.iter().map(|id| id.to_string()).join(","); + let params = vec![("signals", ids_string)]; + + let request_builder = self.client.get(&url).query(¶ms); + let response = request_builder.send().await?.error_for_status()?; + let prices = response + .json::>() + .await?; + + Ok(prices) + } +} + +#[async_trait::async_trait] +impl AssetInfoProvider for RestApi { + type Error = ProviderError; + + /// Fetches asset information for a list of coin IDs from the Band REST API. + /// + /// This method retrieves current prices for the given `ids` by calling + /// [`RestApi::get_latest_prices`] and transforms the results into a vector of [`AssetInfo`] structs. + /// + /// Each entry in the response is converted into an [`AssetInfo`] instance using: + /// - The coin signal as the asset identifier + /// - The price and timestamp returned by the API + /// + /// # Errors + /// + /// Returns a [`ProviderError`] if: + /// - The HTTP request fails or returns an invalid response + /// - The price contains a value that cannot be converted into a valid `Decimal` + /// + /// [`RestApi::get_latest_prices`]: crate::api::RestApi::get_latest_prices + /// [`AssetInfo`]: bothan_lib::types::AssetInfo + /// [`Decimal`]: rust_decimal::Decimal + /// [`ProviderError`]: crate::worker::error::ProviderError + async fn get_asset_info(&self, ids: &[String]) -> Result, Self::Error> { + let asset_info = self + .get_latest_prices(ids) + .await? + .into_iter() + .map(|price| parse_price(price)) + .filter_map(|price| price.ok()) + .collect(); + + Ok(asset_info) + } +} + +/// Parses a `Price` into an [`AssetInfo`] struct. +fn parse_price(band_price: Price) -> Result { + let price = Decimal::from_f64_retain(band_price.price).ok_or(ParseError::InvalidPrice)?; + let ts = band_price.timestamp; + Ok(AssetInfo::new(band_price.signal, price, ts)) +} diff --git a/bothan-band/src/api/types.rs b/bothan-band/src/api/types.rs new file mode 100644 index 00000000..91a884c2 --- /dev/null +++ b/bothan-band/src/api/types.rs @@ -0,0 +1,19 @@ +//! Data types for interacting with the Band REST API. +//! +//! This module provides types for deserializing responses from the Band REST API. +//! +use serde::{Deserialize, Serialize}; + +/// The base URL for the Band API. +/// Needs to change to the actual Band source API URL. +pub(crate) const DEFAULT_URL: &str = "https://bandsource-url.com"; + +/// Represents price and market data for a single asset in USD. +/// +/// `Price` contains fields matching those returned by the [Band api endpoint]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Price { + pub signal: String, + pub price: f64, + pub timestamp: i64, +} \ No newline at end of file diff --git a/bothan-band/src/lib.rs b/bothan-band/src/lib.rs new file mode 100644 index 00000000..e31a5354 --- /dev/null +++ b/bothan-band/src/lib.rs @@ -0,0 +1,5 @@ +pub use worker::Worker; +pub use worker::opts::WorkerOpts; + +pub mod api; +pub mod worker; diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs new file mode 100644 index 00000000..0fd666fb --- /dev/null +++ b/bothan-band/src/worker.rs @@ -0,0 +1,107 @@ +//! Band worker implementation. +//! +//! This module provides an implementation of the [`AssetWorker`] trait for interacting with +//! the Band REST API. It defines the [`Worker`], which is responsible for periodically +//! polling [`AssetInfo`](bothan_lib::types::AssetInfo) from Band REST API and storing it to a shared [`WorkerStore`]. +//! +//! The worker is configurable via [`WorkerOpts`] and uses [`RestApiBuilder`] to construct +//! the API client. +//! +//! # The module provides: +//! - Polling of [`AssetInfo`](bothan_lib::types::AssetInfo) periodically in asynchronous task +//! - Ensures graceful cancellation by using a CancellationToken to signal shutdown and a DropGuard +//! to automatically clean up resources when the worker is dropped +//! - Metrics collection for observability +//! - Configurable via API key, polling interval, and endpoint URL +//! +//! # Examples +//! +//! ```rust,no_run +//! use bothan_band::worker::Worker; +//! use bothan_band::WorkerOpts; +//! use bothan_lib::worker::AssetWorker; +//! use bothan_lib::store::Store; +//! +//! #[tokio::test] +//! async fn test(store: T) { +//! let opts = WorkerOpts::default(); +//! let ids = vec!["CS:BTC-USD".to_string(), "CS:ETH-USD".to_string()]; +//! +//! let worker = Worker::build(opts, &store, ids).await?; +//! } +//! ``` + +use bothan_lib::metrics::rest::Metrics; +use bothan_lib::store::{Store, WorkerStore}; +use bothan_lib::worker::AssetWorker; +use bothan_lib::worker::error::AssetWorkerError; +use bothan_lib::worker::rest::start_polling; +use tokio_util::sync::{CancellationToken, DropGuard}; +use tracing::instrument::Instrument; +use tracing::{Level, span, error}; + +use crate::WorkerOpts; +use crate::api::RestApiBuilder; + +pub mod error; +pub mod opts; + +/// Asset worker for fetching data from the Band REST API. +/// +/// The `Worker` manages asynchronous polling for [`AssetInfo`](bothan_lib::types::AssetInfo) +/// and ensures resources are properly cleaned up when dropped. +pub struct Worker { + // Name identifier for the worker. + name: &'static str, + // We keep this DropGuard to ensure that all internal processes + // that the worker holds are dropped when the worker is dropped. + _drop_guard: DropGuard, +} + +#[async_trait::async_trait] +impl AssetWorker for Worker { + type Opts = WorkerOpts; + + /// Returns the name identifier for the worker. + fn name(&self) -> &'static str { + &self.name + } + + /// Builds and starts the `BandWorker`. + /// + /// This method creates a Band REST API client, spawns an asynchronous polling task + /// to periodically fetch asset data, and returns the running [`Worker`] instance. + /// + /// # Errors + /// + /// Returns an [`AssetWorkerError`](bothan_lib::worker::error::AssetWorkerError) if: + /// - The API client fails to build due to invalid configuration + async fn build( + opts: Self::Opts, + store: &S, + ids: Vec, + ) -> Result { + let api = RestApiBuilder::new(opts.url).build()?; + let worker_store = WorkerStore::new(store, opts.name.clone()); + let token = CancellationToken::new(); + let metrics = Metrics::new(Box::leak(opts.name.clone().into_boxed_str())); + + let span = span!(Level::ERROR, "source", name = opts.name.clone()); + tokio::spawn( + start_polling( + token.child_token(), + opts.update_interval, + api, + worker_store, + ids, + metrics, + ) + .instrument(span), + ); + + Ok(Worker { + name: Box::leak(opts.name.clone().into_boxed_str()), + _drop_guard: token.drop_guard(), + }) + } +} diff --git a/bothan-band/src/worker/error.rs b/bothan-band/src/worker/error.rs new file mode 100644 index 00000000..4e1b5aab --- /dev/null +++ b/bothan-band/src/worker/error.rs @@ -0,0 +1,16 @@ +//! Error types for CoinMarketCap worker operations. +//! +//! This module provides custom error types used throughout the CoinMarketCap worker integration, +//! particularly for asset polling and data fetching. + +use thiserror::Error; + +/// Errors from fetching and handling data in the CoinMarketCap worker. +/// +/// These errors typically occur during API calls, response parsing, or data validation in the worker context. +#[derive(Debug, Error)] +pub enum ProviderError { + /// Indicates that the response data contains invalid numeric values (e.g., `NaN`). + #[error("value contains nan")] + InvalidValue, +} diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs new file mode 100644 index 00000000..2d8fbe9a --- /dev/null +++ b/bothan-band/src/worker/opts.rs @@ -0,0 +1,84 @@ +//! Configuration options for initializing a `BandWorker`. +//! +//! This module provides the [`WorkerOpts`] used to configure a `BandWorker`. +//! It allows setting the API endpoint, authentication, and polling interval used by [`Worker`](`crate::worker::Worker`) to fetch data from the Band REST API. +//! +//! The module provides: +//! - The [`WorkerOpts`] for specifying worker parameters +//! - Serialization and deserialization support for configuration files +//! - Defaults for update interval +//! - Internal helpers for handling empty or missing configuration values +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +const DEFAULT_NAME: &str = "band"; + +use crate::api::types::DEFAULT_URL; + +const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); + +/// Options for configuring the `BandWorker`. +/// +/// [`WorkerOpts`] provides a way to specify custom values for creating a +/// `BandWorker`. It specifies parameters such as the API endpoint URL, +/// and the polling interval for fetching data. +/// +/// # Examples +/// +/// ```rust +/// use bothan_band::worker::opts::WorkerOpts; +/// +/// let opts = WorkerOpts { +/// url: "https://bandsource-url.com".to_string(), +/// update_interval: Duration::from_secs(30), +/// }; +/// ``` +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WorkerOpts { + #[serde(default = "default_name")] + pub name: String, + /// The URL for the Band REST API. + /// If none is provided, the Band Pro API base URL will be used. + #[serde(default = "default_url")] + pub url: String, + /// Duration between API polling. + #[serde(default = "default_update_interval")] + #[serde(with = "humantime_serde")] + pub update_interval: Duration, +} + +/// This function returns the default name for the worker. +fn default_name() -> String { + DEFAULT_NAME.to_string() +} + +/// This function returns the default url. +fn default_url() -> String { + DEFAULT_URL.to_string() +} + +/// This function returns the default update interval duration. +fn default_update_interval() -> Duration { + DEFAULT_UPDATE_INTERVAL +} + +impl Default for WorkerOpts { + /// Creates a new `WorkerOpts` with default values. + /// + /// This method initializes the configuration with: + /// - Default name (must change if multiple workers are used) + /// - Default Band API URL + /// - Default update interval + /// + /// # Returns + /// + /// A [`WorkerOpts`] instance with default settings + fn default() -> Self { + Self { + name: default_name(), + url: default_url(), + update_interval: default_update_interval(), + } + } +} diff --git a/bothan-core/Cargo.toml b/bothan-core/Cargo.toml index a4399a92..ad921ea9 100644 --- a/bothan-core/Cargo.toml +++ b/bothan-core/Cargo.toml @@ -19,6 +19,7 @@ bothan-coinmarketcap = { workspace = true } bothan-htx = { workspace = true } bothan-kraken = { workspace = true } bothan-okx = { workspace = true } +bothan-band = { workspace = true } async-trait = { workspace = true } bincode = { workspace = true } diff --git a/bothan-core/src/manager/crypto_asset_info/worker.rs b/bothan-core/src/manager/crypto_asset_info/worker.rs index d0f2dbd7..252b449b 100644 --- a/bothan-core/src/manager/crypto_asset_info/worker.rs +++ b/bothan-core/src/manager/crypto_asset_info/worker.rs @@ -25,6 +25,7 @@ pub enum CryptoAssetWorker { Htx(bothan_htx::Worker), Kraken(bothan_kraken::Worker), Okx(bothan_okx::Worker), + Band(bothan_band::Worker), } #[async_trait::async_trait] @@ -42,6 +43,7 @@ impl AssetWorker for CryptoAssetWorker { CryptoAssetWorker::Htx(w) => w.name(), CryptoAssetWorker::Kraken(w) => w.name(), CryptoAssetWorker::Okx(w) => w.name(), + CryptoAssetWorker::Band(w) => w.name(), } } @@ -78,6 +80,9 @@ impl AssetWorker for CryptoAssetWorker { CryptoAssetWorkerOpts::Okx(opts) => { CryptoAssetWorker::from(bothan_okx::Worker::build(opts, store, ids).await?) } + CryptoAssetWorkerOpts::Band(opts) => { + CryptoAssetWorker::from(bothan_band::Worker::build(opts, store, ids).await?) + } }) } } diff --git a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs index abbb67e6..ebe744f2 100644 --- a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs +++ b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs @@ -11,6 +11,7 @@ pub enum CryptoAssetWorkerOpts { Htx(bothan_htx::WorkerOpts), Kraken(bothan_kraken::WorkerOpts), Okx(bothan_okx::WorkerOpts), + Band(bothan_band::WorkerOpts), } impl CryptoAssetWorkerOpts { @@ -25,6 +26,7 @@ impl CryptoAssetWorkerOpts { CryptoAssetWorkerOpts::Htx(_) => "htx", CryptoAssetWorkerOpts::Kraken(_) => "kraken", CryptoAssetWorkerOpts::Okx(_) => "okx", + CryptoAssetWorkerOpts::Band(opts) => &opts.name, } } } @@ -82,3 +84,9 @@ impl From for CryptoAssetWorkerOpts { CryptoAssetWorkerOpts::Okx(value) } } + +impl From for CryptoAssetWorkerOpts { + fn from(value: bothan_band::WorkerOpts) -> Self { + CryptoAssetWorkerOpts::Band(value) + } +} \ No newline at end of file From 8a884c9d2855a687d17df01150cf24ca50b2e2b5 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 4 Nov 2025 11:49:17 +0700 Subject: [PATCH 02/35] run fmt --- bothan-band/src/api/builder.rs | 6 ++---- bothan-band/src/api/rest.rs | 14 ++++---------- bothan-band/src/api/types.rs | 2 +- bothan-band/src/worker.rs | 2 +- .../src/manager/crypto_asset_info/worker/opts.rs | 2 +- 5 files changed, 9 insertions(+), 17 deletions(-) diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs index a18817fb..b01c3590 100644 --- a/bothan-band/src/api/builder.rs +++ b/bothan-band/src/api/builder.rs @@ -11,7 +11,7 @@ //! - Automatically uses the default Band URL when parameters are omitted during the [`build`](`RestApiBuilder::build`) call use reqwest::ClientBuilder; -use reqwest::header::{HeaderMap}; +use reqwest::header::HeaderMap; use url::Url; use crate::api::RestApi; @@ -59,9 +59,7 @@ impl RestApiBuilder { where T: Into, { - RestApiBuilder { - url: url.into(), - } + RestApiBuilder { url: url.into() } } /// Sets the URL for the Band API. diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 1b58c345..b5743ce7 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -16,9 +16,8 @@ use itertools::Itertools; use reqwest::{Client, Url}; use rust_decimal::Decimal; -use crate::api::error::ParseError; -use crate::api::types::{Price}; -use crate::api::error::ProviderError; +use crate::api::error::{ParseError, ProviderError}; +use crate::api::types::Price; /// Client for interacting with the Band REST API. /// @@ -73,19 +72,14 @@ impl RestApi { /// - The request fails due to network issues /// - The response status is not 2xx /// - JSON deserialization into `HashMap` fails - pub async fn get_latest_prices( - &self, - ids: &[String], - ) -> Result, reqwest::Error> { + pub async fn get_latest_prices(&self, ids: &[String]) -> Result, reqwest::Error> { let url = format!("{}prices/", self.url); let ids_string = ids.iter().map(|id| id.to_string()).join(","); let params = vec![("signals", ids_string)]; let request_builder = self.client.get(&url).query(¶ms); let response = request_builder.send().await?.error_for_status()?; - let prices = response - .json::>() - .await?; + let prices = response.json::>().await?; Ok(prices) } diff --git a/bothan-band/src/api/types.rs b/bothan-band/src/api/types.rs index 91a884c2..9755da9f 100644 --- a/bothan-band/src/api/types.rs +++ b/bothan-band/src/api/types.rs @@ -16,4 +16,4 @@ pub struct Price { pub signal: String, pub price: f64, pub timestamp: i64, -} \ No newline at end of file +} diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index 0fd666fb..47712c51 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -38,7 +38,7 @@ use bothan_lib::worker::error::AssetWorkerError; use bothan_lib::worker::rest::start_polling; use tokio_util::sync::{CancellationToken, DropGuard}; use tracing::instrument::Instrument; -use tracing::{Level, span, error}; +use tracing::{Level, error, span}; use crate::WorkerOpts; use crate::api::RestApiBuilder; diff --git a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs index ebe744f2..da06f82c 100644 --- a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs +++ b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs @@ -89,4 +89,4 @@ impl From for CryptoAssetWorkerOpts { fn from(value: bothan_band::WorkerOpts) -> Self { CryptoAssetWorkerOpts::Band(value) } -} \ No newline at end of file +} From 7a69183f45534a223afe5d3999ff871f788a7d7b Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 4 Nov 2025 11:56:01 +0700 Subject: [PATCH 03/35] run clippy --- bothan-api/server-cli/src/commands/key.rs | 2 +- bothan-api/server-cli/src/commands/request.rs | 6 +++--- bothan-api/server-cli/src/helper.rs | 2 +- bothan-api/server-cli/src/main.rs | 2 +- bothan-api/server/src/config/log.rs | 2 +- bothan-band/src/api/rest.rs | 2 +- bothan-band/src/worker.rs | 4 ++-- bothan-core/src/store/rocksdb/key.rs | 4 ++-- bothan-htx/src/api/websocket.rs | 4 ++-- bothan-lib/src/worker/error.rs | 4 ++-- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/bothan-api/server-cli/src/commands/key.rs b/bothan-api/server-cli/src/commands/key.rs index 2a8ce3af..c71afbf1 100644 --- a/bothan-api/server-cli/src/commands/key.rs +++ b/bothan-api/server-cli/src/commands/key.rs @@ -74,7 +74,7 @@ fn export_key(config: &AppConfig) -> anyhow::Result<()> { read(&config.monitoring.path).with_context(|| "Failed to read monitoring key file")?; let pk = String::from_utf8(pkb).with_context(|| "Failed to parse monitoring key file")?; println!("Private Key"); - println!("{}", pk); + println!("{pk}"); Ok(()) } diff --git a/bothan-api/server-cli/src/commands/request.rs b/bothan-api/server-cli/src/commands/request.rs index bf6ce4a1..a305d922 100644 --- a/bothan-api/server-cli/src/commands/request.rs +++ b/bothan-api/server-cli/src/commands/request.rs @@ -60,7 +60,7 @@ impl RequestCli { let client = match GrpcClient::connect(&uri).await { Ok(client) => client, Err(e) => { - eprintln!("Failed to connect to server: {:#?}", e); + eprintln!("Failed to connect to server: {e:#?}"); std::process::exit(1); } }; @@ -71,7 +71,7 @@ impl RequestCli { .get_info() .await .with_context(|| "Failed to get info")?; - println!("{:#?}", info); + println!("{info:#?}"); } RequestSubCommand::UpdateRegistry { ipfs_hash, version } => { client @@ -98,7 +98,7 @@ impl RequestCli { .get_prices(&ids) .await .with_context(|| "Failed to get prices")?; - println!("{:#?}", prices); + println!("{prices:#?}"); } } Ok(()) diff --git a/bothan-api/server-cli/src/helper.rs b/bothan-api/server-cli/src/helper.rs index 09f9cca7..3af12f40 100644 --- a/bothan-api/server-cli/src/helper.rs +++ b/bothan-api/server-cli/src/helper.rs @@ -32,7 +32,7 @@ impl Exitable for anyhow::Result { match self { Ok(t) => t, Err(e) => { - eprintln!("{:?}", e); + eprintln!("{e:?}"); std::process::exit(code); } } diff --git a/bothan-api/server-cli/src/main.rs b/bothan-api/server-cli/src/main.rs index 885819a5..55fd6eac 100644 --- a/bothan-api/server-cli/src/main.rs +++ b/bothan-api/server-cli/src/main.rs @@ -86,7 +86,7 @@ async fn main() { let cli = match Cli::try_parse() { Ok(cli) => cli, Err(e) => { - eprintln!("{}", e); + eprintln!("{e}"); std::process::exit(1); } }; diff --git a/bothan-api/server/src/config/log.rs b/bothan-api/server/src/config/log.rs index ac247255..a1ee294b 100644 --- a/bothan-api/server/src/config/log.rs +++ b/bothan-api/server/src/config/log.rs @@ -68,7 +68,7 @@ impl Display for LogLevel { LogLevel::Warn => "warn".to_string(), LogLevel::Error => "error".to_string(), }; - write!(f, "{}", str) + write!(f, "{str}") } } diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index b5743ce7..4936e1c0 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -113,7 +113,7 @@ impl AssetInfoProvider for RestApi { .get_latest_prices(ids) .await? .into_iter() - .map(|price| parse_price(price)) + .map(parse_price) .filter_map(|price| price.ok()) .collect(); diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index 47712c51..b1a3b7ce 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -38,7 +38,7 @@ use bothan_lib::worker::error::AssetWorkerError; use bothan_lib::worker::rest::start_polling; use tokio_util::sync::{CancellationToken, DropGuard}; use tracing::instrument::Instrument; -use tracing::{Level, error, span}; +use tracing::{Level, span}; use crate::WorkerOpts; use crate::api::RestApiBuilder; @@ -64,7 +64,7 @@ impl AssetWorker for Worker { /// Returns the name identifier for the worker. fn name(&self) -> &'static str { - &self.name + self.name } /// Builds and starts the `BandWorker`. diff --git a/bothan-core/src/store/rocksdb/key.rs b/bothan-core/src/store/rocksdb/key.rs index a1c6343e..7c784136 100644 --- a/bothan-core/src/store/rocksdb/key.rs +++ b/bothan-core/src/store/rocksdb/key.rs @@ -20,11 +20,11 @@ impl Display for Key<'_> { Key::AssetStore { source_id, asset_id, - } => format!("asset_store::{}::{}", source_id, asset_id), + } => format!("asset_store::{source_id}::{asset_id}"), Key::Registry => "registry".to_string(), Key::RegistryIpfsHash => "registry_ipfs_hash".to_string(), }; - write!(f, "{}", s) + write!(f, "{s}") } } diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index 3d0ef33a..83946499 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -177,7 +177,7 @@ impl WebSocketConnection { /// /// Returns a `tungstenite::Error` if the subscription request fails. pub async fn subscribe_ticker(&mut self, symbol: &str) -> Result<(), tungstenite::Error> { - let formatted_symbol = format!("market.{}.ticker", symbol); + let formatted_symbol = format!("market.{symbol}.ticker"); let payload = json!({ "sub": formatted_symbol, }); @@ -205,7 +205,7 @@ impl WebSocketConnection { /// /// Returns a `tungstenite::Error` if the unsubscription request fails. pub async fn unsubscribe_ticker(&mut self, symbol: &str) -> Result<(), tungstenite::Error> { - let formatted_symbol = format!("market.{}.ticker", symbol); + let formatted_symbol = format!("market.{symbol}.ticker"); let payload = json!({ "unsub": formatted_symbol, }); diff --git a/bothan-lib/src/worker/error.rs b/bothan-lib/src/worker/error.rs index 2c06a5aa..765790e2 100644 --- a/bothan-lib/src/worker/error.rs +++ b/bothan-lib/src/worker/error.rs @@ -121,7 +121,7 @@ impl fmt::Display for AssetWorkerError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.msg)?; if let Some(source) = &self.source { - write!(f, ": {}", source)?; + write!(f, ": {source}")?; } Ok(()) } @@ -139,7 +139,7 @@ where { fn from(err: E) -> Self { Self { - msg: format!("An error occurred: {}", err), + msg: format!("An error occurred: {err}"), source: Some(Box::new(err)), } } From 4b46e81dbcf2fd542b28f56bd778b751bc288294 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 4 Nov 2025 13:18:17 +0700 Subject: [PATCH 04/35] fix band source doc --- bothan-band/src/api/rest.rs | 2 +- bothan-band/src/worker/opts.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 4936e1c0..85decc57 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -28,7 +28,7 @@ use crate::api::types::Price; /// # Examples /// /// ```rust -/// use bothan_band::api::{RestApi, types::Quote}; +/// use bothan_band::api::{RestApi, types::Price}; /// use reqwest::ClientBuilder; /// use reqwest::header::{HeaderMap, HeaderValue}; /// use url::Url; diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs index 2d8fbe9a..b6b07114 100644 --- a/bothan-band/src/worker/opts.rs +++ b/bothan-band/src/worker/opts.rs @@ -28,8 +28,10 @@ const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); /// /// ```rust /// use bothan_band::worker::opts::WorkerOpts; +/// use std::time::Duration; /// /// let opts = WorkerOpts { +/// name: "band".to_string(), /// url: "https://bandsource-url.com".to_string(), /// update_interval: Duration::from_secs(30), /// }; From f206690ee158dd04373be913d30ec98613a4c57d Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 4 Nov 2025 13:50:31 +0700 Subject: [PATCH 05/35] add band source test --- bothan-band/src/api/builder.rs | 12 ++-- bothan-band/src/api/rest.rs | 117 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs index b01c3590..ff16c00d 100644 --- a/bothan-band/src/api/builder.rs +++ b/bothan-band/src/api/builder.rs @@ -2,12 +2,12 @@ //! //! This module provides a builder for constructing [`RestApi`] clients used //! to interact with the Band REST API. The builder supports optional configuration -//! of base URL and API key. +//! of base URL. //! //! The module provides: //! //! - The [`RestApiBuilder`] for REST API building -//! - Supports setting the API base URL and API key +//! - Supports setting the API base URL //! - Automatically uses the default Band URL when parameters are omitted during the [`build`](`RestApiBuilder::build`) call use reqwest::ClientBuilder; @@ -21,7 +21,7 @@ use crate::api::types::DEFAULT_URL; /// Builder for creating instances of [`RestApi`]. /// /// The `RestApiBuilder` provides a builder pattern for setting up a [`RestApi`] instance -/// by allowing users to specify optional configuration parameters such as the base URL and API key. +/// by allowing users to specify optional configuration parameters such as the base URL. /// /// # Example /// ``` @@ -44,7 +44,7 @@ impl RestApiBuilder { /// Creates a new `RestApiBuilder` with the specified configuration. /// /// This method allows manual initialization of the builder using - /// optional parameter for API key, and a required URL string. + /// a required URL string. /// /// # Examples /// @@ -77,9 +77,7 @@ impl RestApiBuilder { /// /// Returns a [`BuildError`] if: /// - The URL is invalid - /// - The API key or HTTP headers are malformed /// - The HTTP client fails to build - /// - The API key is missing (required for Band) pub fn build(self) -> Result { let headers = HeaderMap::new(); @@ -93,7 +91,7 @@ impl RestApiBuilder { impl Default for RestApiBuilder { /// Creates a new `BandRestAPIBuilder` with the - /// default URL and no API key. + /// default URL. fn default() -> Self { RestApiBuilder { url: DEFAULT_URL.into(), diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 85decc57..7546fe38 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -127,3 +127,120 @@ fn parse_price(band_price: Price) -> Result { let ts = band_price.timestamp; Ok(AssetInfo::new(band_price.signal, price, ts)) } + +#[cfg(test)] +mod test { + use mockito::{Matcher, Mock, Server, ServerGuard}; + + use super::*; + use crate::api::RestApiBuilder; + use crate::api::types::Price; + + // Setup a test server and RestApi client instance + async fn setup() -> (ServerGuard, RestApi) { + let server = Server::new_async().await; + let builder = RestApiBuilder::default().with_url(&server.url()); + let api = builder.build().unwrap(); + (server, api) + } + + fn mock_price(signal: &str, price: f64, timestamp: i64) -> Price { + Price { + signal: signal.to_string(), + price, + timestamp, + } + } + + trait MockBandRest { + fn set_successful_prices(&mut self, ids: &[String], prices: &[Price]) -> Mock; + fn set_arbitrary_prices>( + &mut self, + ids: &[String], + data: StrOrBytes, + ) -> Mock; + fn set_failed_prices(&mut self, ids: &[String]) -> Mock; + } + + impl MockBandRest for ServerGuard { + fn set_successful_prices(&mut self, ids: &[String], prices: &[Price]) -> Mock { + let response = serde_json::to_string(prices).unwrap(); + self.mock("GET", "/prices/") + .match_query(Matcher::UrlEncoded("signals".into(), ids.join(","))) + .with_status(200) + .with_body(response) + .create() + } + + fn set_arbitrary_prices>( + &mut self, + ids: &[String], + data: StrOrBytes, + ) -> Mock { + self.mock("GET", "/prices/") + .match_query(Matcher::UrlEncoded("signals".into(), ids.join(","))) + .with_status(200) + .with_body(data) + .create() + } + + fn set_failed_prices(&mut self, ids: &[String]) -> Mock { + self.mock("GET", "/prices/") + .match_query(Matcher::UrlEncoded("signals".into(), ids.join(","))) + .with_status(500) + .create() + } + } + + #[tokio::test] + async fn test_successful_get_latest_prices() { + let (mut server, client) = setup().await; + + let ids = vec!["BTC".to_string()]; + let prices = vec![mock_price("BTC", 80000.0, 100000)]; + let mock = server.set_successful_prices(&ids, &prices); + + let result = client.get_latest_prices(&ids).await; + mock.assert(); + assert_eq!(result.unwrap(), prices); + } + + #[tokio::test] + async fn test_successful_get_latest_prices_with_multiple_assets() { + let (mut server, client) = setup().await; + + let ids = vec!["BTC".to_string(), "ETH".to_string()]; + let prices = vec![ + mock_price("BTC", 80000.0, 100000), + mock_price("ETH", 3500.0, 100002), + ]; + let mock = server.set_successful_prices(&ids, &prices); + + let result = client.get_latest_prices(&ids).await; + mock.assert(); + assert_eq!(result.unwrap(), prices); + } + + #[tokio::test] + async fn test_get_latest_prices_with_unparseable_data() { + let (mut server, client) = setup().await; + + let ids = vec!["BTC".to_string()]; + let mock = server.set_arbitrary_prices(&ids, "not valid json"); + + let result = client.get_latest_prices(&ids).await; + mock.assert(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_failed_get_latest_prices() { + let (mut server, client) = setup().await; + let ids = vec!["BTC".to_string()]; + let mock = server.set_failed_prices(&ids); + + let result = client.get_latest_prices(&ids).await; + mock.assert(); + assert!(result.is_err()); + } +} From abc79f70c87011e48f71add62000e85796b6f99b Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 4 Nov 2025 17:36:45 +0700 Subject: [PATCH 06/35] minor fix --- bothan-api/server/Cargo.toml | 2 +- bothan-band/src/api/builder.rs | 2 +- bothan-band/src/worker.rs | 2 +- bothan-band/src/worker/error.rs | 6 +++--- bothan-coinmarketcap/src/worker.rs | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bothan-api/server/Cargo.toml b/bothan-api/server/Cargo.toml index 166f8920..b25e4b39 100644 --- a/bothan-api/server/Cargo.toml +++ b/bothan-api/server/Cargo.toml @@ -18,7 +18,7 @@ bothan-coinmarketcap = { workspace = true } bothan-htx = { workspace = true } bothan-kraken = { workspace = true } bothan-okx = { workspace = true } -bothan-band = {workspace = true} +bothan-band = { workspace = true } async-trait = { workspace = true } chrono = { workspace = true } diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs index ff16c00d..1096bab8 100644 --- a/bothan-band/src/api/builder.rs +++ b/bothan-band/src/api/builder.rs @@ -29,7 +29,7 @@ use crate::api::types::DEFAULT_URL; /// /// #[tokio::main] /// async fn main() { -/// let mut api = RestApiBuilder::default() +/// let api = RestApiBuilder::default() /// .with_url("https://bandsource-url.com") /// .build() /// .unwrap(); diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index b1a3b7ce..3fa5de18 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -12,7 +12,7 @@ //! - Ensures graceful cancellation by using a CancellationToken to signal shutdown and a DropGuard //! to automatically clean up resources when the worker is dropped //! - Metrics collection for observability -//! - Configurable via API key, polling interval, and endpoint URL +//! - Configurable via polling interval and endpoint URL //! //! # Examples //! diff --git a/bothan-band/src/worker/error.rs b/bothan-band/src/worker/error.rs index 4e1b5aab..a6c0ea8e 100644 --- a/bothan-band/src/worker/error.rs +++ b/bothan-band/src/worker/error.rs @@ -1,11 +1,11 @@ -//! Error types for CoinMarketCap worker operations. +//! Error types for Band worker operations. //! -//! This module provides custom error types used throughout the CoinMarketCap worker integration, +//! This module provides custom error types used throughout the Band worker integration, //! particularly for asset polling and data fetching. use thiserror::Error; -/// Errors from fetching and handling data in the CoinMarketCap worker. +/// Errors from fetching and handling data in the Band worker. /// /// These errors typically occur during API calls, response parsing, or data validation in the worker context. #[derive(Debug, Error)] diff --git a/bothan-coinmarketcap/src/worker.rs b/bothan-coinmarketcap/src/worker.rs index a18461fd..34b15b55 100644 --- a/bothan-coinmarketcap/src/worker.rs +++ b/bothan-coinmarketcap/src/worker.rs @@ -25,7 +25,7 @@ //! #[tokio::test] //! async fn test(store: T) { //! let opts = WorkerOpts::default(); -//! let ids = vec!["1".to_string(), "1027".to_string()]; +//! let ids = vec!["CS:BTC-USD".to_string(), "CS:ETH-USD".to_string()]; //! //! let worker = Worker::build(opts, &store, ids).await?; //! } From 05c07dbc48ed2d796fb386440da7db51d45f6b50 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Wed, 5 Nov 2025 16:46:07 +0700 Subject: [PATCH 07/35] fix band config --- bothan-api/server-cli/src/main.rs | 5 +- bothan-api/server/src/api.rs | 2 + .../src/config/manager/crypto_info/sources.rs | 53 +++++++++++++++++-- bothan-band/src/api/builder.rs | 11 ---- bothan-band/src/api/error.rs | 3 ++ bothan-band/src/api/rest.rs | 11 ++-- bothan-band/src/api/types.rs | 8 +-- bothan-band/src/worker.rs | 12 +++-- bothan-band/src/worker/opts.rs | 31 +++-------- .../manager/crypto_asset_info/worker/opts.rs | 2 +- 10 files changed, 84 insertions(+), 54 deletions(-) diff --git a/bothan-api/server-cli/src/main.rs b/bothan-api/server-cli/src/main.rs index 55fd6eac..3eba1e43 100644 --- a/bothan-api/server-cli/src/main.rs +++ b/bothan-api/server-cli/src/main.rs @@ -26,6 +26,7 @@ use std::path::PathBuf; use std::str::FromStr; use bothan_api::config::AppConfig; +use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; use bothan_api::config::log::LogLevel; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; @@ -98,7 +99,9 @@ async fn main() { "Failed to load config. Try deleting the config file and running 'config init'.", ) } else { - AppConfig::default() + let mut config = AppConfig::default(); + config.manager.crypto.source = CryptoSourceConfigs::with_default_sources(); + config }; let log_lvl = &app_config.log.log_level; diff --git a/bothan-api/server/src/api.rs b/bothan-api/server/src/api.rs index 7d780ec7..eb744481 100644 --- a/bothan-api/server/src/api.rs +++ b/bothan-api/server/src/api.rs @@ -21,9 +21,11 @@ //! ```rust,no_run //! use bothan_api::api::BothanServer; //! use bothan_api::config::AppConfig; +//! use crate::config::manager::crypto_info::sources::CryptoSourceConfigs; //! //! fn main() -> Result<(), Box> { //! let config = AppConfig::default(); +//! config.manager.crypto.source = CryptoSourceConfigs::with_default_sources(); //! // Initialize server with config //! Ok(()) //! } diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 2f83b248..df6c320c 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; /// Configuration for the worker sources for crypto asset info. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize)] pub struct CryptoSourceConfigs { /// Binance worker options. pub binance: Option, @@ -38,6 +38,53 @@ pub struct CryptoSourceConfigs { pub band2: Option, } +impl<'de> Deserialize<'de> for CryptoSourceConfigs { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + binance: Option, + bitfinex: Option, + bybit: Option, + coinbase: Option, + coingecko: Option, + coinmarketcap: Option, + htx: Option, + kraken: Option, + okx: Option, + band1: Option, + band2: Option, + } + + let mut helper = Helper::deserialize(deserializer)?; + + // Custom logic to modify `band1` during deserialization + if let Some(ref mut band1) = helper.band1 { + band1.name = Some("band1".to_string()); + } + // Custom logic to modify `band2` during deserialization + if let Some(ref mut band2) = helper.band2 { + band2.name = Some("band2".to_string()); + } + + Ok(CryptoSourceConfigs { + binance: helper.binance, + bitfinex: helper.bitfinex, + bybit: helper.bybit, + coinbase: helper.coinbase, + coingecko: helper.coingecko, + coinmarketcap: helper.coinmarketcap, + htx: helper.htx, + kraken: helper.kraken, + okx: helper.okx, + band1: helper.band1, + band2: helper.band2, + }) + } +} + impl CryptoSourceConfigs { /// Creates a new `CryptoSourceConfigs` with all sources set to their default options. pub fn with_default_sources() -> Self { @@ -51,8 +98,8 @@ impl CryptoSourceConfigs { htx: Some(bothan_htx::WorkerOpts::default()), kraken: Some(bothan_kraken::WorkerOpts::default()), okx: Some(bothan_okx::WorkerOpts::default()), - band1: Some(bothan_band::WorkerOpts::default()), - band2: Some(bothan_band::WorkerOpts::default()), + band1: Some(bothan_band::WorkerOpts::new("band1", "https://bandsource1.bandchain.org")), + band2: Some(bothan_band::WorkerOpts::new("band2", "https://bandsource1.bandchain.org")), } } } diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs index 1096bab8..b6df2c0b 100644 --- a/bothan-band/src/api/builder.rs +++ b/bothan-band/src/api/builder.rs @@ -16,7 +16,6 @@ use url::Url; use crate::api::RestApi; use crate::api::error::BuildError; -use crate::api::types::DEFAULT_URL; /// Builder for creating instances of [`RestApi`]. /// @@ -88,13 +87,3 @@ impl RestApiBuilder { Ok(RestApi::new(parsed_url, client)) } } - -impl Default for RestApiBuilder { - /// Creates a new `BandRestAPIBuilder` with the - /// default URL. - fn default() -> Self { - RestApiBuilder { - url: DEFAULT_URL.into(), - } - } -} diff --git a/bothan-band/src/api/error.rs b/bothan-band/src/api/error.rs index 3e212456..3638b4ca 100644 --- a/bothan-band/src/api/error.rs +++ b/bothan-band/src/api/error.rs @@ -62,4 +62,7 @@ pub enum ParseError { /// Indicates that the price value is not a valid number (NaN). #[error("price is NaN")] InvalidPrice, + /// Indicates that the timestamp value is missing or invalid. + #[error("invalid timestamp")] + InvalidTimestamp, } diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 7546fe38..f7419845 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -123,8 +123,9 @@ impl AssetInfoProvider for RestApi { /// Parses a `Price` into an [`AssetInfo`] struct. fn parse_price(band_price: Price) -> Result { - let price = Decimal::from_f64_retain(band_price.price).ok_or(ParseError::InvalidPrice)?; - let ts = band_price.timestamp; + let price = band_price.price.ok_or(ParseError::InvalidPrice)?; + let price = Decimal::from_f64_retain(price).ok_or(ParseError::InvalidPrice)?; + let ts = band_price.timestamp.ok_or(ParseError::InvalidTimestamp)?; Ok(AssetInfo::new(band_price.signal, price, ts)) } @@ -139,7 +140,7 @@ mod test { // Setup a test server and RestApi client instance async fn setup() -> (ServerGuard, RestApi) { let server = Server::new_async().await; - let builder = RestApiBuilder::default().with_url(&server.url()); + let builder = RestApiBuilder::new(&server.url()); let api = builder.build().unwrap(); (server, api) } @@ -147,8 +148,8 @@ mod test { fn mock_price(signal: &str, price: f64, timestamp: i64) -> Price { Price { signal: signal.to_string(), - price, - timestamp, + price: Some(price), + timestamp: Some(timestamp), } } diff --git a/bothan-band/src/api/types.rs b/bothan-band/src/api/types.rs index 9755da9f..99bf3b25 100644 --- a/bothan-band/src/api/types.rs +++ b/bothan-band/src/api/types.rs @@ -4,16 +4,12 @@ //! use serde::{Deserialize, Serialize}; -/// The base URL for the Band API. -/// Needs to change to the actual Band source API URL. -pub(crate) const DEFAULT_URL: &str = "https://bandsource-url.com"; - /// Represents price and market data for a single asset in USD. /// /// `Price` contains fields matching those returned by the [Band api endpoint]. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Price { pub signal: String, - pub price: f64, - pub timestamp: i64, + pub price: Option, + pub timestamp: Option, } diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index 3fa5de18..594e9659 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -81,12 +81,16 @@ impl AssetWorker for Worker { store: &S, ids: Vec, ) -> Result { + let name = opts + .name + .clone() + .unwrap(); let api = RestApiBuilder::new(opts.url).build()?; - let worker_store = WorkerStore::new(store, opts.name.clone()); + let worker_store = WorkerStore::new(store, name.clone()); let token = CancellationToken::new(); - let metrics = Metrics::new(Box::leak(opts.name.clone().into_boxed_str())); + let metrics = Metrics::new(Box::leak(name.clone().into_boxed_str())); - let span = span!(Level::ERROR, "source", name = opts.name.clone()); + let span = span!(Level::ERROR, "source", name = name.clone()); tokio::spawn( start_polling( token.child_token(), @@ -100,7 +104,7 @@ impl AssetWorker for Worker { ); Ok(Worker { - name: Box::leak(opts.name.clone().into_boxed_str()), + name: Box::leak(name.clone().into_boxed_str()), _drop_guard: token.drop_guard(), }) } diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs index b6b07114..ac1b5f8b 100644 --- a/bothan-band/src/worker/opts.rs +++ b/bothan-band/src/worker/opts.rs @@ -12,10 +12,6 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -const DEFAULT_NAME: &str = "band"; - -use crate::api::types::DEFAULT_URL; - const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); /// Options for configuring the `BandWorker`. @@ -36,13 +32,12 @@ const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); /// update_interval: Duration::from_secs(30), /// }; /// ``` -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct WorkerOpts { - #[serde(default = "default_name")] - pub name: String, + #[serde(skip)] + pub name: Option, /// The URL for the Band REST API. - /// If none is provided, the Band Pro API base URL will be used. - #[serde(default = "default_url")] pub url: String, /// Duration between API polling. #[serde(default = "default_update_interval")] @@ -50,22 +45,12 @@ pub struct WorkerOpts { pub update_interval: Duration, } -/// This function returns the default name for the worker. -fn default_name() -> String { - DEFAULT_NAME.to_string() -} - -/// This function returns the default url. -fn default_url() -> String { - DEFAULT_URL.to_string() -} - /// This function returns the default update interval duration. fn default_update_interval() -> Duration { DEFAULT_UPDATE_INTERVAL } -impl Default for WorkerOpts { +impl WorkerOpts { /// Creates a new `WorkerOpts` with default values. /// /// This method initializes the configuration with: @@ -76,10 +61,10 @@ impl Default for WorkerOpts { /// # Returns /// /// A [`WorkerOpts`] instance with default settings - fn default() -> Self { + pub fn new(name: &str, url: &str) -> Self { Self { - name: default_name(), - url: default_url(), + name: Some(name.to_string()), + url: url.to_string(), update_interval: default_update_interval(), } } diff --git a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs index da06f82c..2f3b03ba 100644 --- a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs +++ b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs @@ -26,7 +26,7 @@ impl CryptoAssetWorkerOpts { CryptoAssetWorkerOpts::Htx(_) => "htx", CryptoAssetWorkerOpts::Kraken(_) => "kraken", CryptoAssetWorkerOpts::Okx(_) => "okx", - CryptoAssetWorkerOpts::Band(opts) => &opts.name, + CryptoAssetWorkerOpts::Band(opts) => opts.name.as_deref().unwrap(), } } } From 0b941e91973fcbddad02ac298032d9c68e0e5406 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Wed, 5 Nov 2025 16:57:29 +0700 Subject: [PATCH 08/35] fix fmt and test --- bothan-api/server-cli/src/main.rs | 2 +- bothan-api/server/src/api.rs | 4 ++-- .../server/src/config/manager/crypto_info/sources.rs | 10 ++++++++-- bothan-band/src/api/builder.rs | 3 +-- bothan-band/src/api/rest.rs | 2 +- bothan-band/src/worker.rs | 5 +---- bothan-band/src/worker/opts.rs | 2 +- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/bothan-api/server-cli/src/main.rs b/bothan-api/server-cli/src/main.rs index 3eba1e43..92c852fa 100644 --- a/bothan-api/server-cli/src/main.rs +++ b/bothan-api/server-cli/src/main.rs @@ -26,8 +26,8 @@ use std::path::PathBuf; use std::str::FromStr; use bothan_api::config::AppConfig; -use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; use bothan_api::config::log::LogLevel; +use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; use tracing_subscriber::filter::Directive; diff --git a/bothan-api/server/src/api.rs b/bothan-api/server/src/api.rs index eb744481..e666bf27 100644 --- a/bothan-api/server/src/api.rs +++ b/bothan-api/server/src/api.rs @@ -21,10 +21,10 @@ //! ```rust,no_run //! use bothan_api::api::BothanServer; //! use bothan_api::config::AppConfig; -//! use crate::config::manager::crypto_info::sources::CryptoSourceConfigs; +//! use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; //! //! fn main() -> Result<(), Box> { -//! let config = AppConfig::default(); +//! let mut config = AppConfig::default(); //! config.manager.crypto.source = CryptoSourceConfigs::with_default_sources(); //! // Initialize server with config //! Ok(()) diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index df6c320c..ed7e824f 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -98,8 +98,14 @@ impl CryptoSourceConfigs { htx: Some(bothan_htx::WorkerOpts::default()), kraken: Some(bothan_kraken::WorkerOpts::default()), okx: Some(bothan_okx::WorkerOpts::default()), - band1: Some(bothan_band::WorkerOpts::new("band1", "https://bandsource1.bandchain.org")), - band2: Some(bothan_band::WorkerOpts::new("band2", "https://bandsource1.bandchain.org")), + band1: Some(bothan_band::WorkerOpts::new( + "band1", + "https://bandsource1.bandchain.org", + )), + band2: Some(bothan_band::WorkerOpts::new( + "band2", + "https://bandsource1.bandchain.org", + )), } } } diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs index b6df2c0b..67b53ea0 100644 --- a/bothan-band/src/api/builder.rs +++ b/bothan-band/src/api/builder.rs @@ -28,8 +28,7 @@ use crate::api::error::BuildError; /// /// #[tokio::main] /// async fn main() { -/// let api = RestApiBuilder::default() -/// .with_url("https://bandsource-url.com") +/// let api = RestApiBuilder::new("https://bandsource-url.com") /// .build() /// .unwrap(); /// } diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index f7419845..d3f6eea5 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -140,7 +140,7 @@ mod test { // Setup a test server and RestApi client instance async fn setup() -> (ServerGuard, RestApi) { let server = Server::new_async().await; - let builder = RestApiBuilder::new(&server.url()); + let builder = RestApiBuilder::new(server.url()); let api = builder.build().unwrap(); (server, api) } diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index 594e9659..b2ebdbda 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -81,10 +81,7 @@ impl AssetWorker for Worker { store: &S, ids: Vec, ) -> Result { - let name = opts - .name - .clone() - .unwrap(); + let name = opts.name.clone().unwrap(); let api = RestApiBuilder::new(opts.url).build()?; let worker_store = WorkerStore::new(store, name.clone()); let token = CancellationToken::new(); diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs index ac1b5f8b..b4092cbe 100644 --- a/bothan-band/src/worker/opts.rs +++ b/bothan-band/src/worker/opts.rs @@ -27,7 +27,7 @@ const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); /// use std::time::Duration; /// /// let opts = WorkerOpts { -/// name: "band".to_string(), +/// name: Some("band".to_string()), /// url: "https://bandsource-url.com".to_string(), /// update_interval: Duration::from_secs(30), /// }; From 910116c7a812aa8646eea69d0cb98b5df607036e Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Fri, 7 Nov 2025 15:08:38 +0700 Subject: [PATCH 09/35] remove unused and refactor code --- bothan-api/server-cli/src/commands/config.rs | 4 +- bothan-api/server-cli/src/main.rs | 8 +- bothan-api/server/src/api.rs | 1 - .../src/config/manager/crypto_info/sources.rs | 73 ++++++------------- bothan-band/src/api/builder.rs | 5 +- bothan-band/src/api/error.rs | 22 ------ bothan-band/src/api/rest.rs | 3 +- bothan-band/src/worker.rs | 10 +-- bothan-band/src/worker/error.rs | 16 ---- bothan-band/src/worker/opts.rs | 9 +-- bothan-coinmarketcap/src/worker.rs | 2 +- .../manager/crypto_asset_info/worker/opts.rs | 2 +- 12 files changed, 39 insertions(+), 116 deletions(-) delete mode 100644 bothan-band/src/worker/error.rs diff --git a/bothan-api/server-cli/src/commands/config.rs b/bothan-api/server-cli/src/commands/config.rs index 759712bc..d8511d8b 100644 --- a/bothan-api/server-cli/src/commands/config.rs +++ b/bothan-api/server-cli/src/commands/config.rs @@ -7,7 +7,6 @@ use std::path::PathBuf; use anyhow::{Context, anyhow}; use bothan_api::config::AppConfig; -use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; use clap::{Parser, Subcommand}; use crate::bothan_home_dir; @@ -54,8 +53,7 @@ impl ConfigCli { .with_context(|| "Failed to create parent directories")?; } - let mut app_config = AppConfig::default(); - app_config.manager.crypto.source = CryptoSourceConfigs::with_default_sources(); + let app_config = AppConfig::default(); let config_str = toml::to_string(&app_config).with_context(|| "Failed to serialize config")?; diff --git a/bothan-api/server-cli/src/main.rs b/bothan-api/server-cli/src/main.rs index 92c852fa..e3929f05 100644 --- a/bothan-api/server-cli/src/main.rs +++ b/bothan-api/server-cli/src/main.rs @@ -27,7 +27,6 @@ use std::str::FromStr; use bothan_api::config::AppConfig; use bothan_api::config::log::LogLevel; -use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; use tracing_subscriber::filter::Directive; @@ -99,9 +98,7 @@ async fn main() { "Failed to load config. Try deleting the config file and running 'config init'.", ) } else { - let mut config = AppConfig::default(); - config.manager.crypto.source = CryptoSourceConfigs::with_default_sources(); - config + AppConfig::default() }; let log_lvl = &app_config.log.log_level; @@ -120,7 +117,8 @@ async fn main() { .add_directive(create_directive("bothan_cryptocompare", src_log_lvl)) .add_directive(create_directive("bothan_htx", src_log_lvl)) .add_directive(create_directive("bothan_kraken", src_log_lvl)) - .add_directive(create_directive("bothan_okx", src_log_lvl)); + .add_directive(create_directive("bothan_okx", src_log_lvl)) + .add_directive(create_directive("bothan_band", src_log_lvl)); tracing_subscriber::fmt().with_env_filter(filter).init(); diff --git a/bothan-api/server/src/api.rs b/bothan-api/server/src/api.rs index e666bf27..95882977 100644 --- a/bothan-api/server/src/api.rs +++ b/bothan-api/server/src/api.rs @@ -25,7 +25,6 @@ //! //! fn main() -> Result<(), Box> { //! let mut config = AppConfig::default(); -//! config.manager.crypto.source = CryptoSourceConfigs::with_default_sources(); //! // Initialize server with config //! Ok(()) //! } diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index ed7e824f..7629702b 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -6,13 +6,12 @@ //! //! ```rust,no_run //! use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; -//! let sources = CryptoSourceConfigs::with_default_sources(); //! ``` use serde::{Deserialize, Serialize}; /// Configuration for the worker sources for crypto asset info. -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct CryptoSourceConfigs { /// Binance worker options. pub binance: Option, @@ -33,61 +32,35 @@ pub struct CryptoSourceConfigs { /// OKX worker options. pub okx: Option, /// Band1 worker options. + #[serde(deserialize_with = "de_band1")] pub band1: Option, /// Band2 worker options. + #[serde(deserialize_with = "de_band2")] pub band2: Option, } -impl<'de> Deserialize<'de> for CryptoSourceConfigs { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct Helper { - binance: Option, - bitfinex: Option, - bybit: Option, - coinbase: Option, - coingecko: Option, - coinmarketcap: Option, - htx: Option, - kraken: Option, - okx: Option, - band1: Option, - band2: Option, +macro_rules! de_band_named { + ($fn_name:ident, $name:expr) => { + fn $fn_name<'de, D>(d: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let mut v = Option::::deserialize(d)?; + if let Some(ref mut w) = v { + w.name = $name; + } + Ok(v) } - - let mut helper = Helper::deserialize(deserializer)?; - - // Custom logic to modify `band1` during deserialization - if let Some(ref mut band1) = helper.band1 { - band1.name = Some("band1".to_string()); - } - // Custom logic to modify `band2` during deserialization - if let Some(ref mut band2) = helper.band2 { - band2.name = Some("band2".to_string()); - } - - Ok(CryptoSourceConfigs { - binance: helper.binance, - bitfinex: helper.bitfinex, - bybit: helper.bybit, - coinbase: helper.coinbase, - coingecko: helper.coingecko, - coinmarketcap: helper.coinmarketcap, - htx: helper.htx, - kraken: helper.kraken, - okx: helper.okx, - band1: helper.band1, - band2: helper.band2, - }) - } + }; } -impl CryptoSourceConfigs { - /// Creates a new `CryptoSourceConfigs` with all sources set to their default options. - pub fn with_default_sources() -> Self { +const BAND1_WORKER_NAME: &str = "band1"; +de_band_named!(de_band1, BAND1_WORKER_NAME); +const BAND2_WORKER_NAME: &str = "band2"; +de_band_named!(de_band2, BAND2_WORKER_NAME); + +impl Default for CryptoSourceConfigs { + fn default() -> Self { CryptoSourceConfigs { binance: Some(bothan_binance::WorkerOpts::default()), bitfinex: Some(bothan_bitfinex::WorkerOpts::default()), @@ -104,7 +77,7 @@ impl CryptoSourceConfigs { )), band2: Some(bothan_band::WorkerOpts::new( "band2", - "https://bandsource1.bandchain.org", + "https://bandsource2.bandchain.org", )), } } diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs index 67b53ea0..0ab9c9ea 100644 --- a/bothan-band/src/api/builder.rs +++ b/bothan-band/src/api/builder.rs @@ -11,7 +11,6 @@ //! - Automatically uses the default Band URL when parameters are omitted during the [`build`](`RestApiBuilder::build`) call use reqwest::ClientBuilder; -use reqwest::header::HeaderMap; use url::Url; use crate::api::RestApi; @@ -77,11 +76,9 @@ impl RestApiBuilder { /// - The URL is invalid /// - The HTTP client fails to build pub fn build(self) -> Result { - let headers = HeaderMap::new(); - let parsed_url = Url::parse(&self.url)?; - let client = ClientBuilder::new().default_headers(headers).build()?; + let client = ClientBuilder::new().build()?; Ok(RestApi::new(parsed_url, client)) } diff --git a/bothan-band/src/api/error.rs b/bothan-band/src/api/error.rs index 3638b4ca..f78b21f0 100644 --- a/bothan-band/src/api/error.rs +++ b/bothan-band/src/api/error.rs @@ -15,38 +15,16 @@ pub enum BuildError { #[error("invalid url")] InvalidURL(#[from] url::ParseError), - /// Indicates an HTTP header value was invalid or contained prohibited characters. - #[error("invalid header value")] - InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), - /// Represents general failures during HTTP client construction (e.g., TLS configuration issues). #[error("reqwest error: {0}")] FailedToBuild(#[from] reqwest::Error), } -/// General errors from Band API operations. -/// -/// These errors typically occur during API calls, response parsing, or data validation. -#[derive(Debug, Error)] -pub enum Error { - /// Indicates the requested limit is too high (must be <= 5000). - #[error("limit must be lower or equal to 5000")] - LimitTooHigh, - - /// Indicates an HTTP request failure due to network issues or HTTP errors. - #[error("failed request: {0}")] - FailedRequest(#[from] reqwest::Error), -} - /// Errors from fetching and handling data from the Band REST API. /// /// These errors typically occur during API calls, response parsing, or data validation. #[derive(Debug, Error)] pub enum ProviderError { - /// Indicates that an ID in the request is not a valid integer. - #[error("ids contains non integer value")] - InvalidId, - /// Indicates HTTP request failure due to network issues or HTTP errors. #[error("failed to fetch tickers: {0}")] RequestError(#[from] reqwest::Error), diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index d3f6eea5..0d890238 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -113,8 +113,7 @@ impl AssetInfoProvider for RestApi { .get_latest_prices(ids) .await? .into_iter() - .map(parse_price) - .filter_map(|price| price.ok()) + .filter_map(|price| parse_price(price).ok()) .collect(); Ok(asset_info) diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index b2ebdbda..9d6748fe 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -43,7 +43,6 @@ use tracing::{Level, span}; use crate::WorkerOpts; use crate::api::RestApiBuilder; -pub mod error; pub mod opts; /// Asset worker for fetching data from the Band REST API. @@ -81,13 +80,12 @@ impl AssetWorker for Worker { store: &S, ids: Vec, ) -> Result { - let name = opts.name.clone().unwrap(); let api = RestApiBuilder::new(opts.url).build()?; - let worker_store = WorkerStore::new(store, name.clone()); + let worker_store = WorkerStore::new(store, opts.name); let token = CancellationToken::new(); - let metrics = Metrics::new(Box::leak(name.clone().into_boxed_str())); + let metrics = Metrics::new(opts.name); - let span = span!(Level::ERROR, "source", name = name.clone()); + let span = span!(Level::ERROR, "source", name = opts.name); tokio::spawn( start_polling( token.child_token(), @@ -101,7 +99,7 @@ impl AssetWorker for Worker { ); Ok(Worker { - name: Box::leak(name.clone().into_boxed_str()), + name: opts.name, _drop_guard: token.drop_guard(), }) } diff --git a/bothan-band/src/worker/error.rs b/bothan-band/src/worker/error.rs deleted file mode 100644 index a6c0ea8e..00000000 --- a/bothan-band/src/worker/error.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Error types for Band worker operations. -//! -//! This module provides custom error types used throughout the Band worker integration, -//! particularly for asset polling and data fetching. - -use thiserror::Error; - -/// Errors from fetching and handling data in the Band worker. -/// -/// These errors typically occur during API calls, response parsing, or data validation in the worker context. -#[derive(Debug, Error)] -pub enum ProviderError { - /// Indicates that the response data contains invalid numeric values (e.g., `NaN`). - #[error("value contains nan")] - InvalidValue, -} diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs index b4092cbe..ff7f310c 100644 --- a/bothan-band/src/worker/opts.rs +++ b/bothan-band/src/worker/opts.rs @@ -27,16 +27,15 @@ const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); /// use std::time::Duration; /// /// let opts = WorkerOpts { -/// name: Some("band".to_string()), +/// name: "band", /// url: "https://bandsource-url.com".to_string(), /// update_interval: Duration::from_secs(30), /// }; /// ``` #[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] pub struct WorkerOpts { #[serde(skip)] - pub name: Option, + pub name: &'static str, /// The URL for the Band REST API. pub url: String, /// Duration between API polling. @@ -61,9 +60,9 @@ impl WorkerOpts { /// # Returns /// /// A [`WorkerOpts`] instance with default settings - pub fn new(name: &str, url: &str) -> Self { + pub fn new(name: &'static str, url: &str) -> Self { Self { - name: Some(name.to_string()), + name, url: url.to_string(), update_interval: default_update_interval(), } diff --git a/bothan-coinmarketcap/src/worker.rs b/bothan-coinmarketcap/src/worker.rs index 34b15b55..a18461fd 100644 --- a/bothan-coinmarketcap/src/worker.rs +++ b/bothan-coinmarketcap/src/worker.rs @@ -25,7 +25,7 @@ //! #[tokio::test] //! async fn test(store: T) { //! let opts = WorkerOpts::default(); -//! let ids = vec!["CS:BTC-USD".to_string(), "CS:ETH-USD".to_string()]; +//! let ids = vec!["1".to_string(), "1027".to_string()]; //! //! let worker = Worker::build(opts, &store, ids).await?; //! } diff --git a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs index 2f3b03ba..e2b24634 100644 --- a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs +++ b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs @@ -26,7 +26,7 @@ impl CryptoAssetWorkerOpts { CryptoAssetWorkerOpts::Htx(_) => "htx", CryptoAssetWorkerOpts::Kraken(_) => "kraken", CryptoAssetWorkerOpts::Okx(_) => "okx", - CryptoAssetWorkerOpts::Band(opts) => opts.name.as_deref().unwrap(), + CryptoAssetWorkerOpts::Band(opts) => opts.name, } } } From 66fe88edfe522863aa0fb4d6f6f11d8c90803d0a Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Fri, 7 Nov 2025 19:22:22 +0700 Subject: [PATCH 10/35] fix comments --- .../server/src/config/manager/crypto_info/sources.rs | 1 + bothan-band/src/api.rs | 2 +- bothan-band/src/api/builder.rs | 3 +-- bothan-band/src/api/error.rs | 2 +- bothan-band/src/api/rest.rs | 12 +++++------- bothan-band/src/api/types.rs | 2 +- bothan-band/src/worker.rs | 2 +- bothan-band/src/worker/opts.rs | 7 +++---- 8 files changed, 14 insertions(+), 17 deletions(-) diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 7629702b..282e0017 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -6,6 +6,7 @@ //! //! ```rust,no_run //! use bothan_api::config::manager::crypto_info::sources::CryptoSourceConfigs; +//! let sources = CryptoSourceConfigs::default(); //! ``` use serde::{Deserialize, Serialize}; diff --git a/bothan-band/src/api.rs b/bothan-band/src/api.rs index 0f36be5d..f63bce8d 100644 --- a/bothan-band/src/api.rs +++ b/bothan-band/src/api.rs @@ -5,7 +5,7 @@ //! //! The module provides: //! -//! - [`builder`] — A builder pattern for creating [`RestApi`] clients with optional parameters like base URL and API key. +//! - [`builder`] — A builder pattern for creating [`RestApi`] clients with parameters like base URL. //! - [`rest`] — Core API client implementation, including HTTP request logic and integration with Bothan's `AssetInfoProvider` trait. //! - [`types`] — Data types that represent Band REST API responses such as [`Price`](types::Price) //! - [`error`] — Custom error types used during API client configuration and request processing. diff --git a/bothan-band/src/api/builder.rs b/bothan-band/src/api/builder.rs index 0ab9c9ea..745296bd 100644 --- a/bothan-band/src/api/builder.rs +++ b/bothan-band/src/api/builder.rs @@ -8,7 +8,7 @@ //! //! - The [`RestApiBuilder`] for REST API building //! - Supports setting the API base URL -//! - Automatically uses the default Band URL when parameters are omitted during the [`build`](`RestApiBuilder::build`) call +//! - Requires the API base URL to be specified when constructing the builder use reqwest::ClientBuilder; use url::Url; @@ -60,7 +60,6 @@ impl RestApiBuilder { } /// Sets the URL for the Band API. - /// The default URL is `DEFAULT_URL`. pub fn with_url(mut self, url: &str) -> Self { self.url = url.into(); self diff --git a/bothan-band/src/api/error.rs b/bothan-band/src/api/error.rs index f78b21f0..e3db4564 100644 --- a/bothan-band/src/api/error.rs +++ b/bothan-band/src/api/error.rs @@ -26,7 +26,7 @@ pub enum BuildError { #[derive(Debug, Error)] pub enum ProviderError { /// Indicates HTTP request failure due to network issues or HTTP errors. - #[error("failed to fetch tickers: {0}")] + #[error("failed to fetch prices: {0}")] RequestError(#[from] reqwest::Error), /// Indicates a failure to parse the API response. diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 0d890238..2e63f792 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -6,7 +6,7 @@ //! //! This module provides: //! -//! - Fetches the latest quotes for assets from the `/v2/cryptocurrency/quotes/latest` endpoint +//! - Fetches the latest quotes for assets from the Band `/prices/` endpoint //! - Transforms API responses into [`AssetInfo`] for use in workers //! - Handles deserialization and error propagation @@ -30,13 +30,11 @@ use crate::api::types::Price; /// ```rust /// use bothan_band::api::{RestApi, types::Price}; /// use reqwest::ClientBuilder; -/// use reqwest::header::{HeaderMap, HeaderValue}; /// use url::Url; /// /// #[tokio::main] /// async fn main() -> Result<(), Box> { -/// let mut headers = HeaderMap::new(); -/// let client = ClientBuilder::new().default_headers(headers).build()?; +/// let client = ClientBuilder::new().build()?; /// /// let api = RestApi::new(Url::parse("https://bandsource-url.com")?, client); /// Ok(()) @@ -58,20 +56,20 @@ impl RestApi { /// Retrieves market data for the specified cryptocurrency IDs from the Band REST API. /// /// This method constructs a request to the Band endpoint - /// and returns a vector of `Price<...>`, where each element corresponds to the ID at the same + /// and returns a vector of `Price`, where each element corresponds to the ID at the same /// position in the input slice. /// /// # Query Construction /// /// The query includes: - /// - `id`: comma-separated list of coin IDs + /// - `signals`: comma-separated list of coin IDs /// /// # Errors /// /// Returns a [`reqwest::Error`] if: /// - The request fails due to network issues /// - The response status is not 2xx - /// - JSON deserialization into `HashMap` fails + /// - JSON deserialization into `Vec` fails pub async fn get_latest_prices(&self, ids: &[String]) -> Result, reqwest::Error> { let url = format!("{}prices/", self.url); let ids_string = ids.iter().map(|id| id.to_string()).join(","); diff --git a/bothan-band/src/api/types.rs b/bothan-band/src/api/types.rs index 99bf3b25..5f9150e2 100644 --- a/bothan-band/src/api/types.rs +++ b/bothan-band/src/api/types.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; /// Represents price and market data for a single asset in USD. /// -/// `Price` contains fields matching those returned by the [Band api endpoint]. +/// `Price` contains fields matching those returned by the Band API endpoint. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Price { pub signal: String, diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index 9d6748fe..315b4ff9 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -24,7 +24,7 @@ //! //! #[tokio::test] //! async fn test(store: T) { -//! let opts = WorkerOpts::default(); +//! let opts = WorkerOpts::new("band", "https://example.com");; //! let ids = vec!["CS:BTC-USD".to_string(), "CS:ETH-USD".to_string()]; //! //! let worker = Worker::build(opts, &store, ids).await?; diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs index ff7f310c..bb24eaf9 100644 --- a/bothan-band/src/worker/opts.rs +++ b/bothan-band/src/worker/opts.rs @@ -1,13 +1,12 @@ //! Configuration options for initializing a `BandWorker`. //! //! This module provides the [`WorkerOpts`] used to configure a `BandWorker`. -//! It allows setting the API endpoint, authentication, and polling interval used by [`Worker`](`crate::worker::Worker`) to fetch data from the Band REST API. +//! It allows setting the API endpoint and polling interval used by [`Worker`](`crate::worker::Worker`) to fetch data from the Band REST API. //! //! The module provides: //! - The [`WorkerOpts`] for specifying worker parameters //! - Serialization and deserialization support for configuration files //! - Defaults for update interval -//! - Internal helpers for handling empty or missing configuration values use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -53,8 +52,8 @@ impl WorkerOpts { /// Creates a new `WorkerOpts` with default values. /// /// This method initializes the configuration with: - /// - Default name (must change if multiple workers are used) - /// - Default Band API URL + /// - Band worker name (must be provided by the caller) + /// - Band API URL (must be provided by the caller) /// - Default update interval /// /// # Returns From 86870ba09f82404750b77d50c187b67a3dac6a3b Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Mon, 10 Nov 2025 15:34:38 +0700 Subject: [PATCH 11/35] update band source --- bothan-api/server-cli/src/commands/start.rs | 4 +- .../src/config/manager/crypto_info/sources.rs | 44 ++++++++++--------- bothan-band/src/worker.rs | 11 ++--- bothan-band/src/worker/opts.rs | 9 ++-- .../manager/crypto_asset_info/worker/opts.rs | 2 +- 5 files changed, 39 insertions(+), 31 deletions(-) diff --git a/bothan-api/server-cli/src/commands/start.rs b/bothan-api/server-cli/src/commands/start.rs index 96c2c4c6..afe33027 100644 --- a/bothan-api/server-cli/src/commands/start.rs +++ b/bothan-api/server-cli/src/commands/start.rs @@ -251,8 +251,8 @@ async fn init_crypto_opts( add_worker_opts(&mut worker_opts, &source.htx).await?; add_worker_opts(&mut worker_opts, &source.kraken).await?; add_worker_opts(&mut worker_opts, &source.okx).await?; - add_worker_opts(&mut worker_opts, &source.band1).await?; - add_worker_opts(&mut worker_opts, &source.band2).await?; + add_worker_opts(&mut worker_opts, &source.band_kiwi).await?; + add_worker_opts(&mut worker_opts, &source.band_macaw).await?; Ok(worker_opts) } diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 282e0017..73836879 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -32,12 +32,12 @@ pub struct CryptoSourceConfigs { pub kraken: Option, /// OKX worker options. pub okx: Option, - /// Band1 worker options. - #[serde(deserialize_with = "de_band1")] - pub band1: Option, - /// Band2 worker options. - #[serde(deserialize_with = "de_band2")] - pub band2: Option, + /// Band/kiwi worker options. + #[serde(deserialize_with = "de_kiwi")] + pub band_kiwi: Option, + /// Band/macaw worker options. + #[serde(deserialize_with = "de_macaw")] + pub band_macaw: Option, } macro_rules! de_band_named { @@ -46,19 +46,21 @@ macro_rules! de_band_named { where D: serde::Deserializer<'de>, { - let mut v = Option::::deserialize(d)?; - if let Some(ref mut w) = v { - w.name = $name; - } + let v = Option::::deserialize(d)?; + let v = v.map(|w| bothan_band::WorkerOpts::new( + $name, + &w.url, + Some(w.update_interval), + )); Ok(v) } }; } -const BAND1_WORKER_NAME: &str = "band1"; -de_band_named!(de_band1, BAND1_WORKER_NAME); -const BAND2_WORKER_NAME: &str = "band2"; -de_band_named!(de_band2, BAND2_WORKER_NAME); +const BAND1_WORKER_NAME: &str = "band/kiwi"; +de_band_named!(de_kiwi, BAND1_WORKER_NAME); +const BAND2_WORKER_NAME: &str = "band/macaw"; +de_band_named!(de_macaw, BAND2_WORKER_NAME); impl Default for CryptoSourceConfigs { fn default() -> Self { @@ -72,13 +74,15 @@ impl Default for CryptoSourceConfigs { htx: Some(bothan_htx::WorkerOpts::default()), kraken: Some(bothan_kraken::WorkerOpts::default()), okx: Some(bothan_okx::WorkerOpts::default()), - band1: Some(bothan_band::WorkerOpts::new( - "band1", - "https://bandsource1.bandchain.org", + band_kiwi: Some(bothan_band::WorkerOpts::new( + "band/kiwi", + "https://kiwi.bandchain.org", + None, )), - band2: Some(bothan_band::WorkerOpts::new( - "band2", - "https://bandsource2.bandchain.org", + band_macaw: Some(bothan_band::WorkerOpts::new( + "band/macaw", + "https://macaw.banddchain.org", + None, )), } } diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index 315b4ff9..8e0b5051 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -24,7 +24,7 @@ //! //! #[tokio::test] //! async fn test(store: T) { -//! let opts = WorkerOpts::new("band", "https://example.com");; +//! let opts = WorkerOpts::new("band", "https://example.com"); //! let ids = vec!["CS:BTC-USD".to_string(), "CS:ETH-USD".to_string()]; //! //! let worker = Worker::build(opts, &store, ids).await?; @@ -80,12 +80,13 @@ impl AssetWorker for Worker { store: &S, ids: Vec, ) -> Result { + let name: &str = opts.name(); let api = RestApiBuilder::new(opts.url).build()?; - let worker_store = WorkerStore::new(store, opts.name); + let worker_store = WorkerStore::new(store, name); let token = CancellationToken::new(); - let metrics = Metrics::new(opts.name); + let metrics = Metrics::new(name); - let span = span!(Level::ERROR, "source", name = opts.name); + let span = span!(Level::ERROR, "source", name = name); tokio::spawn( start_polling( token.child_token(), @@ -99,7 +100,7 @@ impl AssetWorker for Worker { ); Ok(Worker { - name: opts.name, + name: name, _drop_guard: token.drop_guard(), }) } diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs index bb24eaf9..e537b790 100644 --- a/bothan-band/src/worker/opts.rs +++ b/bothan-band/src/worker/opts.rs @@ -34,7 +34,7 @@ const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); #[derive(Clone, Debug, Deserialize, Serialize)] pub struct WorkerOpts { #[serde(skip)] - pub name: &'static str, + name: &'static str, /// The URL for the Band REST API. pub url: String, /// Duration between API polling. @@ -59,11 +59,14 @@ impl WorkerOpts { /// # Returns /// /// A [`WorkerOpts`] instance with default settings - pub fn new(name: &'static str, url: &str) -> Self { + pub fn new(name: &'static str, url: &str, update_interval: Option) -> Self { Self { name, url: url.to_string(), - update_interval: default_update_interval(), + update_interval: update_interval.unwrap_or(default_update_interval()), } } + + /// Returns the name identifier for the worker. + pub fn name(&self) -> &'static str { self.name } } diff --git a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs index e2b24634..8f36016f 100644 --- a/bothan-core/src/manager/crypto_asset_info/worker/opts.rs +++ b/bothan-core/src/manager/crypto_asset_info/worker/opts.rs @@ -26,7 +26,7 @@ impl CryptoAssetWorkerOpts { CryptoAssetWorkerOpts::Htx(_) => "htx", CryptoAssetWorkerOpts::Kraken(_) => "kraken", CryptoAssetWorkerOpts::Okx(_) => "okx", - CryptoAssetWorkerOpts::Band(opts) => opts.name, + CryptoAssetWorkerOpts::Band(opts) => opts.name(), } } } From da46f5647b2ce2c8425781bcf39a702379ff6cf8 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Mon, 10 Nov 2025 15:34:58 +0700 Subject: [PATCH 12/35] update proto --- README.md | 26 ++++++++++++++++++++++ bothan-api/server/src/proto/descriptor.pb | Bin 92936 -> 99243 bytes 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index d0d29b98..c4f1cb97 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,32 @@ This project comprises primarily of 6 main components: docker-compose up ``` +## Generating Protobuf Files + +Bothan uses [Buf](https://buf.build/) for protobuf file generation and linting. + +1. **Generate all proto files for Go client, Rust client, and server:** + + Run the following command from the root of the project: + + ```bash + buf generate + ``` + + This command will generate protobuf files for all supported languages (Go, Rust, server stubs, etc.) as specified in the `buf.gen.yaml` configuration. + +2. **Generate the descriptor file for the server:** + + To create a protobuf descriptor file needed by the server, run: + + ```bash + buf build -o bothan-api/server/src/proto/descriptor.pb + ``` + + This will output the descriptor file to `bothan-api/server/src/proto/descriptor.pb`. + +For more information on modifying or generating protobuf files, refer to [Buf documentation](https://docs.buf.build/). + ## Support If you encounter any issues or have questions related to Bothan, we encourage you to open a GitHub issue. This ensures a diff --git a/bothan-api/server/src/proto/descriptor.pb b/bothan-api/server/src/proto/descriptor.pb index e70b45b395bc2fba73c56f7166a40fe735683c81..983bfaff59c039803691a82f78b6eff9b921dfd3 100644 GIT binary patch delta 28936 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

f3LBpAUUcw**{h_nuA= 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 delta 22848 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 From d759c3000ca9578ed72f1caead176c9a382122a4 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Mon, 10 Nov 2025 15:40:08 +0700 Subject: [PATCH 13/35] format code --- .../src/config/manager/crypto_info/sources.rs | 6 +----- bothan-band/src/worker.rs | 2 +- bothan-band/src/worker/opts.rs | 14 ++++++++------ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 73836879..82a1cf46 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -47,11 +47,7 @@ macro_rules! de_band_named { D: serde::Deserializer<'de>, { let v = Option::::deserialize(d)?; - let v = v.map(|w| bothan_band::WorkerOpts::new( - $name, - &w.url, - Some(w.update_interval), - )); + let v = v.map(|w| bothan_band::WorkerOpts::new($name, &w.url, Some(w.update_interval))); Ok(v) } }; diff --git a/bothan-band/src/worker.rs b/bothan-band/src/worker.rs index 8e0b5051..0d8aa67c 100644 --- a/bothan-band/src/worker.rs +++ b/bothan-band/src/worker.rs @@ -100,7 +100,7 @@ impl AssetWorker for Worker { ); Ok(Worker { - name: name, + name, _drop_guard: token.drop_guard(), }) } diff --git a/bothan-band/src/worker/opts.rs b/bothan-band/src/worker/opts.rs index e537b790..2def282d 100644 --- a/bothan-band/src/worker/opts.rs +++ b/bothan-band/src/worker/opts.rs @@ -25,11 +25,11 @@ const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); /// use bothan_band::worker::opts::WorkerOpts; /// use std::time::Duration; /// -/// let opts = WorkerOpts { -/// name: "band", -/// url: "https://bandsource-url.com".to_string(), -/// update_interval: Duration::from_secs(30), -/// }; +/// let opts = WorkerOpts::new( +/// "band", +/// "https://bandsource-url.com", +/// Some(Duration::from_secs(30)), +/// ); /// ``` #[derive(Clone, Debug, Deserialize, Serialize)] pub struct WorkerOpts { @@ -68,5 +68,7 @@ impl WorkerOpts { } /// Returns the name identifier for the worker. - pub fn name(&self) -> &'static str { self.name } + pub fn name(&self) -> &'static str { + self.name + } } From e555bb0c4248dbc6ca607f29c3115280f10fc65e Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 11 Nov 2025 13:36:51 +0700 Subject: [PATCH 14/35] add band source config example --- bothan-api/server-cli/config.example.toml | 12 ++++++++++++ .../server/src/config/manager/crypto_info/sources.rs | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/bothan-api/server-cli/config.example.toml b/bothan-api/server-cli/config.example.toml index 26b2daeb..c3372a7e 100644 --- a/bothan-api/server-cli/config.example.toml +++ b/bothan-api/server-cli/config.example.toml @@ -102,6 +102,18 @@ url = "wss://ws.kraken.com/v2" # WebSocket URL for streaming data from OKX. url = "wss://ws.okx.com:8443/ws/v5/public" +[manager.crypto.source.band_kiwi] +# URL for Band Kiwi source +url = "https://kiwi.bandchain.org" +# Update interval for Band Kiwi source +update_interval = "1m" + +[manager.crypto.source.band_macaw] +# URL for Band Macaw source +url = "https://macaw.bandchain.org" +# Update interval for Band Macaw source +update_interval = "1m" + # Telemetry configuration [telemetry] # Enable or disable telemetry. diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 82a1cf46..7b8f2701 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -77,7 +77,7 @@ impl Default for CryptoSourceConfigs { )), band_macaw: Some(bothan_band::WorkerOpts::new( "band/macaw", - "https://macaw.banddchain.org", + "https://macaw.bandchain.org", None, )), } From dfd1e2d718effe48e99f8827e1f32cb7d414c8c1 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 11 Nov 2025 15:05:55 +0700 Subject: [PATCH 15/35] add band query command and fix doc --- Cargo.lock | 1 + README.md | 2 + bothan-api/server-cli/Cargo.toml | 1 + bothan-api/server-cli/src/commands/query.rs | 37 +++++++++++++++++++ .../src/config/manager/crypto_info/sources.rs | 12 +++++- bothan-band/src/api/rest.rs | 6 +-- docs/architecture.md | 1 + 7 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eeef1c75..3a79b0d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -460,6 +460,7 @@ version = "0.0.1" dependencies = [ "anyhow", "bothan-api", + "bothan-band", "bothan-binance", "bothan-bitfinex", "bothan-bybit", diff --git a/README.md b/README.md index c4f1cb97..4238f903 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ This project comprises primarily of 6 main components: - [HTX](bothan-htx) - [Kraken](bothan-kraken) - [OKX](bothan-okx) +- [Band/kiwi](bothan-band) +- [Band/macaw](bothan-band) ## Features diff --git a/bothan-api/server-cli/Cargo.toml b/bothan-api/server-cli/Cargo.toml index c5a7a6f9..24da2467 100644 --- a/bothan-api/server-cli/Cargo.toml +++ b/bothan-api/server-cli/Cargo.toml @@ -24,6 +24,7 @@ bothan-coinmarketcap = { workspace = true } bothan-htx = { workspace = true } bothan-kraken = { workspace = true } bothan-okx = { workspace = true } +bothan-band = { workspace = true } dirs = { workspace = true } futures = { workspace = true } diff --git a/bothan-api/server-cli/src/commands/query.rs b/bothan-api/server-cli/src/commands/query.rs index 131aa709..e81f98cf 100644 --- a/bothan-api/server-cli/src/commands/query.rs +++ b/bothan-api/server-cli/src/commands/query.rs @@ -112,6 +112,18 @@ pub enum QuerySubCommand { #[clap(flatten)] args: QueryArgs, }, + /// Query Band/kiwi prices + #[clap(name = "band/kiwi")] + BandKiwi { + #[clap(flatten)] + args: QueryArgs, + }, + /// Query Band/macaw prices + #[clap(name = "band/macaw")] + BandMacaw { + #[clap(flatten)] + args: QueryArgs, + }, } impl QueryCli { @@ -155,6 +167,14 @@ impl QueryCli { let opts = source_config.okx.ok_or(config_err)?; query_okx(opts, &args.query_ids, args.timeout).await?; } + QuerySubCommand::BandKiwi { args} => { + let opts = source_config.band_kiwi.ok_or(config_err)?; + query_band(opts, &args.query_ids, args.timeout).await?; + } + QuerySubCommand::BandMacaw { args} => { + let opts = source_config.band_macaw.ok_or(config_err)?; + query_band(opts, &args.query_ids, args.timeout).await?; + } } Ok(()) @@ -294,6 +314,23 @@ async fn query_okx>( Ok(()) } +async fn query_band>( + opts: bothan_band::WorkerOpts, + query_ids: &[String], + timeout_interval: T, +) -> anyhow::Result<()> { + let api = bothan_band::api::RestApiBuilder::new(opts.url).build()?; + let asset_infos = timeout( + timeout_interval.into(), + api.get_asset_info(&dedup(query_ids)), + ) + .await??; + + display_asset_infos(asset_infos); + Ok(()) +} + + async fn query_websocket_with_max_sub( connector: Arc, ids: Vec, diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 7b8f2701..0a57dd1c 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -33,9 +33,19 @@ pub struct CryptoSourceConfigs { /// OKX worker options. pub okx: Option, /// Band/kiwi worker options. + /// + /// NOTE: The `name` field in `WorkerOpts` is marked with `#[serde(skip)]`, so deserialized instances + /// will have an empty/default name. The custom deserializer `de_kiwi` reconstructs the options + /// with the correct name. + /// Custom deserializer is required to ensure the correct name is set. #[serde(deserialize_with = "de_kiwi")] pub band_kiwi: Option, - /// Band/macaw worker options. + /// Band/macaw worker options. + /// + /// NOTE: The `name` field in `WorkerOpts` is marked with `#[serde(skip)]`, so deserialized instances + /// will have an empty/default name. The custom deserializer `de_macaw` reconstructs the options + /// with the correct name. + /// Custom deserializer is required to ensure the correct name is set. #[serde(deserialize_with = "de_macaw")] pub band_macaw: Option, } diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 2e63f792..69011f2d 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -71,7 +71,7 @@ impl RestApi { /// - The response status is not 2xx /// - JSON deserialization into `Vec` fails pub async fn get_latest_prices(&self, ids: &[String]) -> Result, reqwest::Error> { - let url = format!("{}prices/", self.url); + let url = format!("{}prices", self.url); let ids_string = ids.iter().map(|id| id.to_string()).join(","); let params = vec![("signals", ids_string)]; @@ -111,8 +111,8 @@ impl AssetInfoProvider for RestApi { .get_latest_prices(ids) .await? .into_iter() - .filter_map(|price| parse_price(price).ok()) - .collect(); + .map(|price| parse_price(price)) + .collect::, _>>()?; Ok(asset_info) } diff --git a/docs/architecture.md b/docs/architecture.md index 7b8b57f2..ae658c3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,6 +82,7 @@ Each provider has its own dedicated module: - `bothan-htx`: Integration with HTX - `bothan-kraken`: Integration with Kraken - `bothan-okx`: Integration with OKX +- `bothan-band`: Integration with Band sources (e.g. band/kiwi, band/macaw) These modules implement provider-specific logic while conforming to common interfaces defined in the core components. From 1b097d679a97b34243a71aeba5c1d6a886476165 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 11 Nov 2025 15:13:48 +0700 Subject: [PATCH 16/35] run lint --- bothan-api/server-cli/src/commands/query.rs | 5 ++--- .../server/src/config/manager/crypto_info/sources.rs | 2 +- bothan-band/src/api/rest.rs | 10 +++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/bothan-api/server-cli/src/commands/query.rs b/bothan-api/server-cli/src/commands/query.rs index e81f98cf..9072f824 100644 --- a/bothan-api/server-cli/src/commands/query.rs +++ b/bothan-api/server-cli/src/commands/query.rs @@ -167,11 +167,11 @@ impl QueryCli { let opts = source_config.okx.ok_or(config_err)?; query_okx(opts, &args.query_ids, args.timeout).await?; } - QuerySubCommand::BandKiwi { args} => { + QuerySubCommand::BandKiwi { args } => { let opts = source_config.band_kiwi.ok_or(config_err)?; query_band(opts, &args.query_ids, args.timeout).await?; } - QuerySubCommand::BandMacaw { args} => { + QuerySubCommand::BandMacaw { args } => { let opts = source_config.band_macaw.ok_or(config_err)?; query_band(opts, &args.query_ids, args.timeout).await?; } @@ -330,7 +330,6 @@ async fn query_band>( Ok(()) } - async fn query_websocket_with_max_sub( connector: Arc, ids: Vec, diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 0a57dd1c..9d1bb3fb 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -40,7 +40,7 @@ pub struct CryptoSourceConfigs { /// Custom deserializer is required to ensure the correct name is set. #[serde(deserialize_with = "de_kiwi")] pub band_kiwi: Option, - /// Band/macaw worker options. + /// Band/macaw worker options. /// /// NOTE: The `name` field in `WorkerOpts` is marked with `#[serde(skip)]`, so deserialized instances /// will have an empty/default name. The custom deserializer `de_macaw` reconstructs the options diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 69011f2d..6087f260 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -6,7 +6,7 @@ //! //! This module provides: //! -//! - Fetches the latest quotes for assets from the Band `/prices/` endpoint +//! - Fetches the latest quotes for assets from the Band `/prices` endpoint //! - Transforms API responses into [`AssetInfo`] for use in workers //! - Handles deserialization and error propagation @@ -111,7 +111,7 @@ impl AssetInfoProvider for RestApi { .get_latest_prices(ids) .await? .into_iter() - .map(|price| parse_price(price)) + .map(parse_price) .collect::, _>>()?; Ok(asset_info) @@ -163,7 +163,7 @@ mod test { impl MockBandRest for ServerGuard { fn set_successful_prices(&mut self, ids: &[String], prices: &[Price]) -> Mock { let response = serde_json::to_string(prices).unwrap(); - self.mock("GET", "/prices/") + self.mock("GET", "/prices") .match_query(Matcher::UrlEncoded("signals".into(), ids.join(","))) .with_status(200) .with_body(response) @@ -175,7 +175,7 @@ mod test { ids: &[String], data: StrOrBytes, ) -> Mock { - self.mock("GET", "/prices/") + self.mock("GET", "/prices") .match_query(Matcher::UrlEncoded("signals".into(), ids.join(","))) .with_status(200) .with_body(data) @@ -183,7 +183,7 @@ mod test { } fn set_failed_prices(&mut self, ids: &[String]) -> Mock { - self.mock("GET", "/prices/") + self.mock("GET", "/prices") .match_query(Matcher::UrlEncoded("signals".into(), ids.join(","))) .with_status(500) .create() From e756ee8f6a9b7bc83be7633e64596f59fa11b6a3 Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 11 Nov 2025 16:19:48 +0700 Subject: [PATCH 17/35] fix price parse error --- bothan-api/server-cli/config.example.toml | 2 +- bothan-band/src/api/rest.rs | 19 +++++++---- bothan-bitfinex/src/api/rest.rs | 25 +++++++++----- bothan-bitfinex/src/worker/opts.rs | 2 +- bothan-coingecko/src/api/rest.rs | 25 +++++++++----- bothan-coinmarketcap/src/api/rest.rs | 41 +++++++++++++++-------- 6 files changed, 75 insertions(+), 39 deletions(-) diff --git a/bothan-api/server-cli/config.example.toml b/bothan-api/server-cli/config.example.toml index c3372a7e..51c3c3be 100644 --- a/bothan-api/server-cli/config.example.toml +++ b/bothan-api/server-cli/config.example.toml @@ -76,7 +76,7 @@ api_key = "" # User agent string for HTTP requests to CoinGecko. user_agent = "Bothan" # Update interval for polling data from CoinGecko. -update_interval = "30s" +update_interval = "1m" # Configuration for CoinMarketCap. [manager.crypto.source.coinmarketcap] diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 6087f260..3616c70c 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -15,6 +15,7 @@ use bothan_lib::worker::rest::AssetInfoProvider; use itertools::Itertools; use reqwest::{Client, Url}; use rust_decimal::Decimal; +use tracing::warn; use crate::api::error::{ParseError, ProviderError}; use crate::api::types::Price; @@ -107,12 +108,18 @@ impl AssetInfoProvider for RestApi { /// [`Decimal`]: rust_decimal::Decimal /// [`ProviderError`]: crate::worker::error::ProviderError async fn get_asset_info(&self, ids: &[String]) -> Result, Self::Error> { - let asset_info = self - .get_latest_prices(ids) - .await? - .into_iter() - .map(parse_price) - .collect::, _>>()?; + let prices = self.get_latest_prices(ids).await?; + let mut asset_info = Vec::with_capacity(prices.len()); + + for band_price in prices { + let signal = band_price.signal.clone(); + match parse_price(band_price) { + Ok(info) => asset_info.push(info), + Err(e) => { + warn!("failed to parse price id '{signal}': {e}"); + } + } + } Ok(asset_info) } diff --git a/bothan-bitfinex/src/api/rest.rs b/bothan-bitfinex/src/api/rest.rs index 95e108d4..f93655ee 100644 --- a/bothan-bitfinex/src/api/rest.rs +++ b/bothan-bitfinex/src/api/rest.rs @@ -16,6 +16,7 @@ use bothan_lib::types::AssetInfo; use bothan_lib::worker::rest::AssetInfoProvider; use reqwest::{Client, Url}; use rust_decimal::Decimal; +use tracing::warn; use crate::api::error::ProviderError; use crate::api::msg::ticker::Ticker; @@ -157,14 +158,20 @@ impl AssetInfoProvider for RestApi { /// - The ticker data contains invalid values such as NaN (`InvalidValue`) async fn get_asset_info(&self, ids: &[String]) -> Result, Self::Error> { let timestamp = chrono::Utc::now().timestamp(); - self.get_tickers(ids) - .await? - .into_iter() - .map(|t| { - let price = - Decimal::from_f64_retain(t.price()).ok_or(ProviderError::InvalidValue)?; - Ok(AssetInfo::new(t.symbol().to_string(), price, timestamp)) - }) - .collect() + let tickers = self.get_tickers(ids).await?; + let mut asset_infos = Vec::with_capacity(tickers.len()); + + for t in tickers { + match Decimal::from_f64_retain(t.price()) { + Some(price) => { + asset_infos.push(AssetInfo::new(t.symbol().to_string(), price, timestamp)); + } + None => { + warn!("failed to parse price for symbol '{}'", t.symbol()); + } + } + } + + Ok(asset_infos) } } diff --git a/bothan-bitfinex/src/worker/opts.rs b/bothan-bitfinex/src/worker/opts.rs index 18786c27..8f2ad89d 100644 --- a/bothan-bitfinex/src/worker/opts.rs +++ b/bothan-bitfinex/src/worker/opts.rs @@ -32,7 +32,7 @@ const DEFAULT_UPDATE_INTERVAL: Duration = Duration::from_secs(60); /// /// let opts = WorkerOpts { /// url: "https://api-pub.bitfinex.com/v2/".to_string(), -/// update_interval: Duration::from_secs(30), +/// update_interval: Duration::from_secs(60), /// }; /// /// // Or use defaults diff --git a/bothan-coingecko/src/api/rest.rs b/bothan-coingecko/src/api/rest.rs index f47ea53d..bb956e9c 100644 --- a/bothan-coingecko/src/api/rest.rs +++ b/bothan-coingecko/src/api/rest.rs @@ -17,6 +17,7 @@ use bothan_lib::worker::rest::AssetInfoProvider; use reqwest::{Client, RequestBuilder, Url}; use rust_decimal::Decimal; use serde::de::DeserializeOwned; +use tracing::warn; use crate::api::error::ProviderError; use crate::api::types::{Coin, Price}; @@ -157,14 +158,22 @@ impl AssetInfoProvider for RestApi { /// [`Decimal`]: rust_decimal::Decimal /// [`ProviderError`]: crate::api::error::ProviderError async fn get_asset_info(&self, ids: &[String]) -> Result, Self::Error> { - self.get_simple_price_usd(ids) - .await? - .into_iter() - .map(|(id, p)| { - let price = Decimal::from_f64_retain(p.usd).ok_or(ProviderError::InvalidValue)?; - Ok(AssetInfo::new(id, price, p.last_updated_at)) - }) - .collect() + let simple_prices = self.get_simple_price_usd(ids).await?; + let mut asset_infos = Vec::with_capacity(ids.len()); + + for id in ids { + match simple_prices.get(id) { + Some(p) => { + let price = + Decimal::from_f64_retain(p.usd).ok_or(ProviderError::InvalidValue)?; + asset_infos.push(AssetInfo::new(id.clone(), price, p.last_updated_at)); + } + None => { + warn!("price data for id '{id}' not found."); + } + } + } + Ok(asset_infos) } } diff --git a/bothan-coinmarketcap/src/api/rest.rs b/bothan-coinmarketcap/src/api/rest.rs index 8a8c8894..7bb2f427 100644 --- a/bothan-coinmarketcap/src/api/rest.rs +++ b/bothan-coinmarketcap/src/api/rest.rs @@ -17,6 +17,7 @@ use bothan_lib::worker::rest::AssetInfoProvider; use itertools::Itertools; use reqwest::{Client, Url}; use rust_decimal::Decimal; +use tracing::warn; use crate::api::error::ParseError; use crate::api::types::{Quote, Response as CmcResponse}; @@ -127,20 +128,32 @@ impl AssetInfoProvider for RestApi { /// [`Decimal`]: rust_decimal::Decimal /// [`ProviderError`]: crate::worker::error::ProviderError async fn get_asset_info(&self, ids: &[String]) -> Result, Self::Error> { - let int_ids = ids - .iter() - .map(|id| { - id.parse::() - .map_err(|_| ProviderError::InvalidId(id.clone())) - }) - .collect::, _>>()?; - - let asset_info = self - .get_latest_quotes(&int_ids) - .await? - .into_iter() - .filter_map(|quote| quote.and_then(|q| parse_quote(q).ok())) - .collect(); + let mut int_ids = Vec::with_capacity(ids.len()); + for id in ids { + match id.parse::() { + Ok(val) => int_ids.push(val), + Err(_) => { + warn!("invalid CoinMarketCap id '{id}': cannot parse to u64",); + } + } + } + + let mut asset_info = Vec::with_capacity(int_ids.len()); + let quotes = self.get_latest_quotes(&int_ids).await?; + + for (idx, quote_opt) in quotes.into_iter().enumerate() { + match quote_opt { + Some(q) => match parse_quote(q) { + Ok(info) => asset_info.push(info), + Err(e) => { + warn!("failed to parse quote for id '{}': {e}", int_ids[idx]); + } + }, + None => { + warn!("no quote found for id '{}'", int_ids[idx]); + } + } + } Ok(asset_info) } From c26e0ac85f4c4abd5ecee1515b6c6ce1588c053e Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 11 Nov 2025 17:29:31 +0700 Subject: [PATCH 18/35] fix bitfinex source --- bothan-bitfinex/src/api/rest.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bothan-bitfinex/src/api/rest.rs b/bothan-bitfinex/src/api/rest.rs index f93655ee..31bcdabc 100644 --- a/bothan-bitfinex/src/api/rest.rs +++ b/bothan-bitfinex/src/api/rest.rs @@ -161,14 +161,21 @@ impl AssetInfoProvider for RestApi { let tickers = self.get_tickers(ids).await?; let mut asset_infos = Vec::with_capacity(tickers.len()); - for t in tickers { - match Decimal::from_f64_retain(t.price()) { - Some(price) => { - asset_infos.push(AssetInfo::new(t.symbol().to_string(), price, timestamp)); - } - None => { - warn!("failed to parse price for symbol '{}'", t.symbol()); + // Build a map from symbol to ticker for quick lookup + let ticker_map: std::collections::HashMap<&str, &Ticker> = tickers.iter().map(|t| (t.symbol(), t)).collect(); + + for id in ids { + if let Some(t) = ticker_map.get(id.as_str()) { + match Decimal::from_f64_retain(t.price()) { + Some(price) => { + asset_infos.push(AssetInfo::new(id.clone(), price, timestamp)); + } + None => { + warn!("failed to parse price for symbol '{}'", t.symbol()); + } } + } else { + warn!("ticker data for id '{}' not found.", id); } } From a31eaa4bf826da151527395420a6846a6a13731b Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Tue, 11 Nov 2025 17:31:06 +0700 Subject: [PATCH 19/35] run lint --- bothan-bitfinex/src/api/rest.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bothan-bitfinex/src/api/rest.rs b/bothan-bitfinex/src/api/rest.rs index 31bcdabc..f92a38d2 100644 --- a/bothan-bitfinex/src/api/rest.rs +++ b/bothan-bitfinex/src/api/rest.rs @@ -162,7 +162,8 @@ impl AssetInfoProvider for RestApi { let mut asset_infos = Vec::with_capacity(tickers.len()); // Build a map from symbol to ticker for quick lookup - let ticker_map: std::collections::HashMap<&str, &Ticker> = tickers.iter().map(|t| (t.symbol(), t)).collect(); + let ticker_map: std::collections::HashMap<&str, &Ticker> = + tickers.iter().map(|t| (t.symbol(), t)).collect(); for id in ids { if let Some(t) = ticker_map.get(id.as_str()) { From 49ceaac51c7f54007ffe1cb90a37cff65d642372 Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Tue, 11 Nov 2025 17:49:06 +0700 Subject: [PATCH 20/35] add more error detail --- bothan-band/src/api/error.rs | 50 ++++++++++++++++++----- bothan-band/src/api/rest.rs | 57 ++++++++++++++++++++------ bothan-binance/src/api/error.rs | 21 +++++++--- bothan-binance/src/api/websocket.rs | 25 ++++++++---- bothan-bitfinex/src/api/error.rs | 32 ++++++++++++--- bothan-bitfinex/src/api/rest.rs | 42 +++++++++++++++---- bothan-bybit/src/api/error.rs | 21 +++++++--- bothan-bybit/src/api/websocket.rs | 20 ++++++--- bothan-coinbase/src/api/error.rs | 23 +++++++---- bothan-coinbase/src/api/websocket.rs | 16 ++++++-- bothan-coingecko/src/api/error.rs | 29 +++++++++++-- bothan-coingecko/src/api/rest.rs | 59 ++++++++++++++++++++------- bothan-coinmarketcap/src/api/error.rs | 39 ++++++++++++++---- bothan-coinmarketcap/src/api/rest.rs | 46 ++++++++++++++++----- bothan-htx/src/api/error.rs | 29 ++++++++----- bothan-htx/src/api/websocket.rs | 27 ++++++++---- bothan-kraken/src/api/error.rs | 27 +++++++----- bothan-kraken/src/api/websocket.rs | 16 ++++++-- bothan-okx/src/api/error.rs | 21 +++++++--- bothan-okx/src/api/websocket.rs | 16 ++++++-- 20 files changed, 462 insertions(+), 154 deletions(-) diff --git a/bothan-band/src/api/error.rs b/bothan-band/src/api/error.rs index e3db4564..31f55b4a 100644 --- a/bothan-band/src/api/error.rs +++ b/bothan-band/src/api/error.rs @@ -3,6 +3,7 @@ //! This module provides custom error types used throughout the Band REST API integration, //! particularly for REST API client configuration and concurrent background data fetching. +use reqwest::StatusCode; use thiserror::Error; /// Errors from initializing the Band REST API builder. @@ -16,7 +17,7 @@ pub enum BuildError { InvalidURL(#[from] url::ParseError), /// Represents general failures during HTTP client construction (e.g., TLS configuration issues). - #[error("reqwest error: {0}")] + #[error("failed to build with error: {0}")] FailedToBuild(#[from] reqwest::Error), } @@ -26,21 +27,48 @@ pub enum BuildError { #[derive(Debug, Error)] pub enum ProviderError { /// Indicates HTTP request failure due to network issues or HTTP errors. - #[error("failed to fetch prices: {0}")] - RequestError(#[from] reqwest::Error), + #[error("failed to fetch prices (signals={signals}): {error}")] + SendingRequestError { + #[source] + error: reqwest::Error, + signals: String, + }, + + /// Indicates the API returned a non-success HTTP status code. + #[error("returned HTTP {status} for signals={signals}: {body}")] + HttpStatusError { + status: StatusCode, + body: String, + signals: String, + }, + + /// Indicates the response body could not be deserialized. + #[error("failed to parse response for signals={signals}: {source}")] + ParseResponseError { + #[source] + source: reqwest::Error, + signals: String, + }, /// Indicates a failure to parse the API response. - #[error("parse error: {0}")] - ParseError(#[from] ParseError), + #[error("invalid price payload for signal {signal}: {source}")] + ParsePriceError { + #[source] + source: ParseError, + signal: String, + }, } /// Errors that can occur while parsing Band API responses. #[derive(Debug, Error)] pub enum ParseError { - /// Indicates that the price value is not a valid number (NaN). - #[error("price is NaN")] - InvalidPrice, - /// Indicates that the timestamp value is missing or invalid. - #[error("invalid timestamp")] - InvalidTimestamp, + /// Indicates that the price field was missing. + #[error("missing price from signal {0}")] + MissingPrice(String), + /// Indicates that the price value is present but not a valid number (NaN/inf). + #[error("invalid price value {price} from signal {signal}")] + InvalidPrice { price: f64, signal: String }, + /// Indicates that the timestamp field was missing. + #[error("missing timestamp from signal {0}")] + MissingTimestamp(String), } diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 3616c70c..cad0134e 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -67,20 +67,45 @@ impl RestApi { /// /// # Errors /// - /// Returns a [`reqwest::Error`] if: + /// Returns a [`ProviderError`] if: /// - The request fails due to network issues /// - The response status is not 2xx /// - JSON deserialization into `Vec` fails - pub async fn get_latest_prices(&self, ids: &[String]) -> Result, reqwest::Error> { + pub async fn get_latest_prices(&self, ids: &[String]) -> Result, ProviderError> { let url = format!("{}prices", self.url); - let ids_string = ids.iter().map(|id| id.to_string()).join(","); - let params = vec![("signals", ids_string)]; + let ids_string = ids.iter().map(String::as_str).join(","); + let params = vec![("signals", &ids_string)]; + + let resp = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .map_err(|error| ProviderError::SendingRequestError { + error, + signals: ids_string.clone(), + })?; - let request_builder = self.client.get(&url).query(¶ms); - let response = request_builder.send().await?.error_for_status()?; - let prices = response.json::>().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp + .text() + .await + .unwrap_or_else(|err| format!("failed to read response body: {err}")); + return Err(ProviderError::HttpStatusError { + status, + body, + signals: ids_string.clone(), + }); + } - Ok(prices) + resp.json::>() + .await + .map_err(|source| ProviderError::ParseResponseError { + source, + signals: ids_string, + }) } } @@ -127,10 +152,18 @@ impl AssetInfoProvider for RestApi { /// Parses a `Price` into an [`AssetInfo`] struct. fn parse_price(band_price: Price) -> Result { - let price = band_price.price.ok_or(ParseError::InvalidPrice)?; - let price = Decimal::from_f64_retain(price).ok_or(ParseError::InvalidPrice)?; - let ts = band_price.timestamp.ok_or(ParseError::InvalidTimestamp)?; - Ok(AssetInfo::new(band_price.signal, price, ts)) + let signal = band_price.signal; + let price = band_price + .price + .ok_or(ParseError::MissingPrice(signal.clone()))?; + let price = Decimal::from_f64_retain(price).ok_or(ParseError::InvalidPrice { + price, + signal: signal.clone(), + })?; + let ts = band_price + .timestamp + .ok_or(ParseError::MissingTimestamp(signal.clone()))?; + Ok(AssetInfo::new(signal, price, ts)) } #[cfg(test)] diff --git a/bothan-binance/src/api/error.rs b/bothan-binance/src/api/error.rs index b6311129..c001c12b 100644 --- a/bothan-binance/src/api/error.rs +++ b/bothan-binance/src/api/error.rs @@ -9,12 +9,16 @@ #[derive(Debug, thiserror::Error)] pub enum Error { /// Indicates a failure to parse a websocket message. - #[error("failed to parse message")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse message: {msg}")] + ParseError { + #[source] + source: serde_json::Error, + msg: String, + }, /// Indicates that the websocket message type is not supported. - #[error("unsupported message")] - UnsupportedWebsocketMessageType, + #[error("unsupported message: {0}")] + UnsupportedWebsocketMessageType(String), } /// Errors encountered while listening for Binance API events. @@ -28,6 +32,11 @@ pub enum ListeningError { Error(#[from] Error), /// Indicates an error while parsing a message from the WebSocket stream. - #[error(transparent)] - InvalidPrice(#[from] rust_decimal::Error), + #[error("invalid price value {price} for symbol {symbol}")] + InvalidPrice { + #[source] + source: rust_decimal::Error, + symbol: String, + price: String, + }, } diff --git a/bothan-binance/src/api/websocket.rs b/bothan-binance/src/api/websocket.rs index d8ae88ca..2a161ddf 100644 --- a/bothan-binance/src/api/websocket.rs +++ b/bothan-binance/src/api/websocket.rs @@ -163,13 +163,12 @@ impl WebSocketConnection { /// Supported message types include text messages (parsed as `Event`), ping messages, and close messages. pub async fn next(&mut self) -> Option> { match self.ws_stream.next().await { - Some(Ok(Message::Text(msg))) => match serde_json::from_str::(&msg) { - Ok(msg) => Some(Ok(msg)), - Err(e) => Some(Err(Error::ParseError(e))), - }, + Some(Ok(Message::Text(msg))) => Some(parse_msg(msg)), Some(Ok(Message::Ping(_))) => Some(Ok(Event::Ping)), Some(Ok(Message::Close(_))) => None, - Some(Ok(_)) => Some(Err(Error::UnsupportedWebsocketMessageType)), + Some(Ok(m)) => Some(Err(Error::UnsupportedWebsocketMessageType(format!( + "{m:?}" + )))), Some(Err(_)) => None, // Consider the connection closed if error detected None => None, } @@ -184,6 +183,10 @@ impl WebSocketConnection { } } +fn parse_msg(msg: String) -> Result { + serde_json::from_str::(&msg).map_err(|source| Error::ParseError { source, msg }) +} + #[async_trait::async_trait] impl AssetInfoProvider for WebSocketConnection { type SubscriptionError = tungstenite::Error; @@ -233,10 +236,16 @@ impl AssetInfoProvider for WebSocketConnection { } } -fn parse_mini_ticker(mini_ticker: MiniTickerInfo) -> Result { +fn parse_mini_ticker(mini_ticker: MiniTickerInfo) -> Result { + let symbol = mini_ticker.symbol.to_ascii_lowercase(); + let price = mini_ticker.close_price; let asset_info = AssetInfo::new( - mini_ticker.symbol.to_ascii_lowercase(), - Decimal::from_str(&mini_ticker.close_price)?, + symbol.clone(), + Decimal::from_str(&price).map_err(|source| ListeningError::InvalidPrice { + source, + symbol, + price, + })?, mini_ticker.event_time / 1000, // convert from millisecond to second ); Ok(Data::AssetInfo(vec![asset_info])) diff --git a/bothan-bitfinex/src/api/error.rs b/bothan-bitfinex/src/api/error.rs index f5b68c6f..8a2611c7 100644 --- a/bothan-bitfinex/src/api/error.rs +++ b/bothan-bitfinex/src/api/error.rs @@ -2,7 +2,7 @@ //! //! This module provides custom error types used throughout the Bitfinex API integration, //! particularly for handling REST API requests and price validation errors. - +use reqwest::StatusCode; use thiserror::Error; /// Errors related to Bitfinex API client configuration and building. @@ -27,10 +27,30 @@ pub enum BuildError { #[derive(Debug, Error)] pub enum ProviderError { /// Indicates a failure to fetch ticker data from the Bitfinex API. - #[error("failed to fetch tickers: {0}")] - RequestError(#[from] reqwest::Error), + #[error("failed to fetch tickers (symbols={symbols}): {error}")] + SendingRequestError { + #[source] + error: reqwest::Error, + symbols: String, + }, + + /// Indicates a non-success HTTP status code. + #[error("returned HTTP {status} for symbols={symbols}: {body}")] + HttpStatusError { + status: StatusCode, + body: String, + symbols: String, + }, + + /// Indicates the response body could not be parsed into the expected shape. + #[error("failed to parse response for symbols={symbols}: {source}")] + ParseResponseError { + #[source] + source: reqwest::Error, + symbols: String, + }, - /// Indicates that the ticker data contains invalid values (e.g., NaN). - #[error("value contains nan")] - InvalidValue, + /// Indicates that the ticker data contains invalid values. + #[error("invalid price value {price} from symbol {symbol}")] + InvalidValue { price: f64, symbol: String }, } diff --git a/bothan-bitfinex/src/api/rest.rs b/bothan-bitfinex/src/api/rest.rs index f92a38d2..f641d1a1 100644 --- a/bothan-bitfinex/src/api/rest.rs +++ b/bothan-bitfinex/src/api/rest.rs @@ -110,25 +110,49 @@ impl RestApi { /// /// # Errors /// - /// Returns a `reqwest::Error` if: - /// - The HTTP request fails due to network issues - /// - The API returns an error response - /// - The response cannot be parsed as JSON + /// Returns a [`ProviderError`] if: + /// - The HTTP request fails due to network issues (`SendingRequestError`) + /// - The API returns an error response (`HttpStatusError`) + /// - The response cannot be parsed as JSON (`ParseResponseError`) pub async fn get_tickers>( &self, tickers: &[T], - ) -> Result, reqwest::Error> { + ) -> Result, ProviderError> { let url = format!("{}/tickers", self.url); let symbols = tickers .iter() .map(|t| t.as_ref()) .collect::>() .join(","); - let params = vec![("symbols", symbols)]; + let params = vec![("symbols", &symbols)]; - let resp = self.client.get(&url).query(¶ms).send().await?; - resp.error_for_status_ref()?; - resp.json().await + let resp = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .map_err(|e| ProviderError::SendingRequestError { + error: e, + symbols: symbols.clone(), + })?; + + let status = resp.status(); + if !status.is_success() { + let body = resp + .text() + .await + .unwrap_or_else(|err| format!("failed to read response body: {err}")); + return Err(ProviderError::HttpStatusError { + status, + body, + symbols: symbols.clone(), + }); + } + + resp.json() + .await + .map_err(|source| ProviderError::ParseResponseError { source, symbols }) } } diff --git a/bothan-bybit/src/api/error.rs b/bothan-bybit/src/api/error.rs index 5bfc2b84..f45856e3 100644 --- a/bothan-bybit/src/api/error.rs +++ b/bothan-bybit/src/api/error.rs @@ -7,12 +7,16 @@ #[derive(Debug, thiserror::Error)] pub enum Error { /// Failed to parse a message from the WebSocket. - #[error("failed to parse message")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse message: {msg}")] + ParseError { + #[source] + source: serde_json::Error, + msg: String, + }, /// Received an unsupported message type from the WebSocket. - #[error("unsupported message")] - UnsupportedWebsocketMessageType, + #[error("unsupported message: {0}")] + UnsupportedWebsocketMessageType(String), } /// Errors that can occur while listening for Bybit WebSocket events. @@ -26,6 +30,11 @@ pub enum ListeningError { Error(#[from] Error), /// An invalid price was encountered while parsing a message. - #[error(transparent)] - InvalidPrice(#[from] rust_decimal::Error), + #[error("invalid price value {price} for symbol {symbol}")] + InvalidPrice { + #[source] + source: rust_decimal::Error, + symbol: String, + price: String, + }, } diff --git a/bothan-bybit/src/api/websocket.rs b/bothan-bybit/src/api/websocket.rs index 71a8a0e0..2a9646cd 100644 --- a/bothan-bybit/src/api/websocket.rs +++ b/bothan-bybit/src/api/websocket.rs @@ -142,7 +142,9 @@ impl WebSocketConnection { Some(Ok(Message::Text(msg))) => Some(parse_msg(msg)), Some(Ok(Message::Ping(_))) => Some(Ok(Response::Ping)), Some(Ok(Message::Close(_))) => None, - Some(Ok(_)) => Some(Err(Error::UnsupportedWebsocketMessageType)), + Some(Ok(m)) => Some(Err(Error::UnsupportedWebsocketMessageType(format!( + "{m:?}" + )))), Some(Err(_)) => None, // Consider the connection closed if error detected None => None, } @@ -158,7 +160,7 @@ impl WebSocketConnection { } fn parse_msg(msg: String) -> Result { - Ok(serde_json::from_str::(&msg)?) + serde_json::from_str::(&msg).map_err(|source| Error::ParseError { source, msg }) } #[async_trait::async_trait] @@ -188,11 +190,17 @@ impl AssetInfoProvider for WebSocketConnection { } } -fn parse_public_ticker(ticker: PublicTickerResponse) -> Result { +fn parse_public_ticker(ticker: PublicTickerResponse) -> Result { + let symbol = ticker.data.symbol; + let price = ticker.data.last_price; let asset_info = AssetInfo::new( - ticker.data.symbol, - Decimal::from_str_exact(&ticker.data.last_price)?, - ticker.ts / 1000, // convert from millisecond to second + symbol.clone(), + Decimal::from_str_exact(&price).map_err(|source| ListeningError::InvalidPrice { + source, + symbol, + price, + })?, + ticker.ts / 1000, ); Ok(Data::AssetInfo(vec![asset_info])) } diff --git a/bothan-coinbase/src/api/error.rs b/bothan-coinbase/src/api/error.rs index d309e458..2823f8b3 100644 --- a/bothan-coinbase/src/api/error.rs +++ b/bothan-coinbase/src/api/error.rs @@ -7,12 +7,16 @@ #[derive(Debug, thiserror::Error)] pub enum Error { /// Indicates a failure to parse a WebSocket message. - #[error("failed to parse message")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse message: {msg}")] + ParseError { + #[source] + source: serde_json::Error, + msg: String, + }, /// Indicates that the WebSocket message type is not supported. - #[error("unsupported message")] - UnsupportedWebsocketMessageType, + #[error("unsupported message: {0}")] + UnsupportedWebsocketMessageType(String), } /// Errors encountered while listening for Coinbase API events. @@ -25,9 +29,14 @@ pub enum ListeningError { #[error(transparent)] Error(#[from] Error), - /// Indicates an error while parsing a message from the WebSocket stream. - #[error(transparent)] - InvalidPrice(#[from] rust_decimal::Error), + /// Indicates an error while parsing price data from the WebSocket stream. + #[error("invalid price value {price} for symbol {symbol}")] + InvalidPrice { + #[source] + source: rust_decimal::Error, + symbol: String, + price: String, + }, /// Indicates an error while parsing a timestamp from the WebSocket message. #[error(transparent)] diff --git a/bothan-coinbase/src/api/websocket.rs b/bothan-coinbase/src/api/websocket.rs index 95cdf549..5c712f1a 100644 --- a/bothan-coinbase/src/api/websocket.rs +++ b/bothan-coinbase/src/api/websocket.rs @@ -169,7 +169,9 @@ impl WebSocketConnection { Some(Ok(Message::Text(msg))) => Some(parse_msg(msg)), Some(Ok(Message::Ping(_))) => Some(Ok(Response::Ping)), Some(Ok(Message::Close(_))) => None, - Some(Ok(_)) => Some(Err(Error::UnsupportedWebsocketMessageType)), + Some(Ok(m)) => Some(Err(Error::UnsupportedWebsocketMessageType(format!( + "{m:?}" + )))), Some(Err(_)) => None, // Consider the connection closed if error detected None => None, } @@ -184,7 +186,7 @@ impl WebSocketConnection { } fn parse_msg(msg: String) -> Result { - Ok(serde_json::from_str::(&msg)?) + serde_json::from_str::(&msg).map_err(|source| Error::ParseError { source, msg }) } #[async_trait::async_trait] @@ -242,9 +244,15 @@ impl AssetInfoProvider for WebSocketConnection { } fn parse_ticker(ticker: Box) -> Result { + let symbol = ticker.product_id; + let price = ticker.price; let asset_info = AssetInfo::new( - ticker.product_id, - Decimal::from_str_exact(&ticker.price)?, + symbol.clone(), + Decimal::from_str_exact(&price).map_err(|source| ListeningError::InvalidPrice { + source, + symbol, + price, + })?, chrono::DateTime::parse_from_rfc3339(&ticker.time)?.timestamp(), ); Ok(Data::AssetInfo(vec![asset_info])) diff --git a/bothan-coingecko/src/api/error.rs b/bothan-coingecko/src/api/error.rs index da725d1b..e7029c3e 100644 --- a/bothan-coingecko/src/api/error.rs +++ b/bothan-coingecko/src/api/error.rs @@ -3,6 +3,7 @@ //! This module provides custom error types used throughout the CoinGecko REST API integration, //! particularly for REST API client configuration and concurrent background data fetching. +use reqwest::StatusCode; use thiserror::Error; /// Errors from initializing the CoinGecko REST API builder. @@ -30,10 +31,30 @@ pub enum BuildError { #[derive(Debug, Error)] pub enum ProviderError { /// Indicates HTTP request failure due to network issues or HTTP errors. - #[error("failed to fetch tickers: {0}")] - RequestError(#[from] reqwest::Error), + #[error("failed to fetch {resource}: {error}")] + SendingRequestError { + #[source] + error: reqwest::Error, + resource: String, + }, + + /// Indicates a non-success HTTP status code. + #[error("returned HTTP {status} for {resource}: {body}")] + HttpStatusError { + status: StatusCode, + body: String, + resource: String, + }, + + /// Indicates the response body could not be parsed into the expected shape. + #[error("failed to parse {resource}: {source}")] + ParseResponseError { + #[source] + source: reqwest::Error, + resource: String, + }, /// Indicates that the response data contains invalid numeric values (e.g., `NaN`). - #[error("value contains nan")] - InvalidValue, + #[error("invalid price value {price} for id {id}")] + InvalidValue { price: f64, id: String }, } diff --git a/bothan-coingecko/src/api/rest.rs b/bothan-coingecko/src/api/rest.rs index bb956e9c..fa4d19bc 100644 --- a/bothan-coingecko/src/api/rest.rs +++ b/bothan-coingecko/src/api/rest.rs @@ -63,11 +63,11 @@ impl RestApi { } /// Retrieves a list of coins from the CoinGecko REST API. - pub async fn get_coins_list(&self) -> Result, reqwest::Error> { + pub async fn get_coins_list(&self) -> Result, ProviderError> { let url = format!("{}coins/list", self.url); let builder = self.client.get(url); - request::>(builder).await + request::>(builder, "coins list".to_string()).await } /// Retrieves market data for the specified coins from the CoinGecko REST API. @@ -86,14 +86,14 @@ impl RestApi { /// /// # Errors /// - /// Returns a [`reqwest::Error`] if: - /// - The request fails due to network issues - /// - The response status is not 2xx - /// - JSON deserialization into [`HashMap`] fails + /// Returns a [`ProviderError`] if: + /// - The request fails due to network issues (`SendingRequestError`) + /// - The response status is not 2xx (`HttpStatusError`) + /// - JSON deserialization into [`HashMap`] fails (`ParseResponseError`) pub async fn get_simple_price_usd>( &self, ids: &[T], - ) -> Result, reqwest::Error> { + ) -> Result, ProviderError> { let url = format!("{}simple/price", self.url); let joined_ids = ids .iter() @@ -110,7 +110,11 @@ impl RestApi { let builder_with_query = self.client.get(&url).query(¶ms); - request::>(builder_with_query).await + request::>( + builder_with_query, + format!("simple price (ids={joined_ids})"), + ) + .await } } @@ -121,16 +125,40 @@ impl RestApi { /// /// # Errors /// -/// Returns a [`reqwest::Error`] if: +/// Returns a [`ProviderError`] if: /// - The request fails to send (e.g., network issues) /// - The response returns a non-success status code (e.g., 400, 500) /// - JSON deserialization into type `T` fails async fn request( request_builder: RequestBuilder, -) -> Result { - let response = request_builder.send().await?.error_for_status()?; + resource: String, +) -> Result { + let response = + request_builder + .send() + .await + .map_err(|error| ProviderError::SendingRequestError { + error, + resource: resource.clone(), + })?; + + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|err| format!("failed to read response body: {err}")); + return Err(ProviderError::HttpStatusError { + status, + body, + resource, + }); + } - response.json::().await + response + .json::() + .await + .map_err(|source| ProviderError::ParseResponseError { source, resource }) } #[async_trait::async_trait] @@ -165,8 +193,11 @@ impl AssetInfoProvider for RestApi { match simple_prices.get(id) { Some(p) => { let price = - Decimal::from_f64_retain(p.usd).ok_or(ProviderError::InvalidValue)?; - asset_infos.push(AssetInfo::new(id.clone(), price, p.last_updated_at)); + Decimal::from_f64_retain(p.usd).ok_or(ProviderError::InvalidValue { + price: p.usd, + id: id.into(), + })?; + asset_infos.push(AssetInfo::new(id.into(), price, p.last_updated_at)); } None => { warn!("price data for id '{id}' not found."); diff --git a/bothan-coinmarketcap/src/api/error.rs b/bothan-coinmarketcap/src/api/error.rs index c74b6b9a..7cce43e8 100644 --- a/bothan-coinmarketcap/src/api/error.rs +++ b/bothan-coinmarketcap/src/api/error.rs @@ -3,6 +3,7 @@ //! This module provides custom error types used throughout the CoinMarketCap REST API integration, //! particularly for REST API client configuration and concurrent background data fetching. +use reqwest::StatusCode; use thiserror::Error; /// Errors from initializing the CoinMarketCap REST API builder. @@ -48,16 +49,40 @@ pub enum Error { #[derive(Debug, Error)] pub enum ProviderError { /// Indicates that an ID in the request is not a valid integer. - #[error("ids contains non integer value")] - InvalidId, + #[error("ids contains non integer value: {0}")] + InvalidId(String), /// Indicates HTTP request failure due to network issues or HTTP errors. - #[error("failed to fetch tickers: {0}")] - RequestError(#[from] reqwest::Error), + #[error("failed to fetch quotes (ids={ids}): {error}")] + SendingRequestError { + #[source] + error: reqwest::Error, + ids: String, + }, - /// Indicates a failure to parse the API response. - #[error("parse error: {0}")] - ParseError(#[from] ParseError), + /// Indicates a non-success HTTP status code. + #[error("returned HTTP {status} for ids={ids}: {body}")] + HttpStatusError { + status: StatusCode, + body: String, + ids: String, + }, + + /// Indicates the response body could not be parsed into the expected shape. + #[error("failed to parse response for ids={ids}: {source}")] + ParseResponseError { + #[source] + source: reqwest::Error, + ids: String, + }, + + /// Indicates the ticker data contains invalid values for a specific asset. + #[error("invalid quote for id {id}: {source}")] + InvalidQuote { + #[source] + source: ParseError, + id: String, + }, } /// Errors that can occur while parsing CoinMarketCap API responses. diff --git a/bothan-coinmarketcap/src/api/rest.rs b/bothan-coinmarketcap/src/api/rest.rs index 7bb2f427..1d8f839e 100644 --- a/bothan-coinmarketcap/src/api/rest.rs +++ b/bothan-coinmarketcap/src/api/rest.rs @@ -19,9 +19,8 @@ use reqwest::{Client, Url}; use rust_decimal::Decimal; use tracing::warn; -use crate::api::error::ParseError; +use crate::api::error::{ParseError, ProviderError}; use crate::api::types::{Quote, Response as CmcResponse}; -use crate::worker::error::ProviderError; /// Client for interacting with the CoinMarketCap REST API. /// @@ -76,23 +75,48 @@ impl RestApi { /// /// # Errors /// - /// Returns a [`reqwest::Error`] if: - /// - The request fails due to network issues - /// - The response status is not 2xx - /// - JSON deserialization into `HashMap` fails + /// Returns a [`ProviderError`] if: + /// - The request fails due to network issues (`SendingRequestError`) + /// - The response status is not 2xx (`HttpStatusError`) + /// - JSON deserialization into `HashMap` fails (`ParseResponseError`) pub async fn get_latest_quotes( &self, ids: &[u64], - ) -> Result>, reqwest::Error> { + ) -> Result>, ProviderError> { let url = format!("{}v2/cryptocurrency/quotes/latest", self.url); let ids_string = ids.iter().map(|id| id.to_string()).join(","); - let params = vec![("id", ids_string)]; + let params = vec![("id", &ids_string)]; let request_builder = self.client.get(&url).query(¶ms); - let response = request_builder.send().await?.error_for_status()?; + let response = + request_builder + .send() + .await + .map_err(|error| ProviderError::SendingRequestError { + error, + ids: ids_string.clone(), + })?; + + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|err| format!("failed to read response body: {err}")); + return Err(ProviderError::HttpStatusError { + status, + body, + ids: ids_string, + }); + } + let cmc_response = response .json::>>() - .await?; + .await + .map_err(|source| ProviderError::ParseResponseError { + source, + ids: ids_string.clone(), + })?; let mut quote_map = cmc_response.data; let quotes = ids @@ -126,7 +150,7 @@ impl AssetInfoProvider for RestApi { /// [`RestApi::get_latest_quotes`]: crate::api::RestApi::get_latest_quotes /// [`AssetInfo`]: bothan_lib::types::AssetInfo /// [`Decimal`]: rust_decimal::Decimal - /// [`ProviderError`]: crate::worker::error::ProviderError + /// [`ProviderError`]: crate::api::error::ProviderError async fn get_asset_info(&self, ids: &[String]) -> Result, Self::Error> { let mut int_ids = Vec::with_capacity(ids.len()); for id in ids { diff --git a/bothan-htx/src/api/error.rs b/bothan-htx/src/api/error.rs index ce4c1dda..00fa6f36 100644 --- a/bothan-htx/src/api/error.rs +++ b/bothan-htx/src/api/error.rs @@ -18,12 +18,16 @@ pub enum Error { Io(#[from] io::Error), /// Indicates a failure to parse a WebSocket message. - #[error("failed to parse message")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse message: {msg}")] + ParseError { + #[source] + source: serde_json::Error, + msg: String, + }, /// Indicates that the WebSocket message type is not supported. - #[error("unsupported message")] - UnsupportedWebsocketMessageType, + #[error("unsupported message: {0}")] + UnsupportedWebsocketMessageType(String), } /// Errors encountered while listening for HTX API events. @@ -37,12 +41,17 @@ pub enum ListeningError { Error(#[from] Error), /// Indicates that the received channel ID is invalid or malformed. - #[error("received invalid channel id")] - InvalidChannelId, - - /// Indicates that the received price data contains NaN values. - #[error("received NaN")] - InvalidPrice, + #[error("received invalid channel id: {0}")] + InvalidChannelId(String), + + /// Indicates that the received price data contains invalid values. + #[error("invalid price value {price} for symbol {symbol}")] + InvalidPrice { + #[source] + source: rust_decimal::Error, + symbol: String, + price: String, + }, /// Indicates a failure to send a pong response to a ping message. #[error("failed to pong")] diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index 83946499..73de9d8b 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -260,7 +260,9 @@ impl WebSocketConnection { Some(Ok(Message::Binary(msg))) => Some(decode_response(&msg)), Some(Ok(Message::Ping(_))) => None, Some(Ok(Message::Close(_))) => None, - Some(Ok(_)) => Some(Err(Error::UnsupportedWebsocketMessageType)), + Some(Ok(m)) => Some(Err(Error::UnsupportedWebsocketMessageType(format!( + "{m:?}" + )))), Some(Err(_)) => None, // Consider the connection closed if error detected None => None, } @@ -300,7 +302,10 @@ fn decode_response(msg: &[u8]) -> Result { let mut decoder = GzDecoder::new(msg); let mut decompressed_msg = String::new(); decoder.read_to_string(&mut decompressed_msg)?; - Ok(serde_json::from_str::(&decompressed_msg)?) + serde_json::from_str::(&decompressed_msg).map_err(|source| Error::ParseError { + source, + msg: decompressed_msg, + }) } #[async_trait::async_trait] @@ -389,16 +394,22 @@ impl AssetInfoProvider for WebSocketConnection { /// - The channel ID cannot be extracted from the channel name /// - The price data contains invalid values (NaN) fn parse_data(data: super::types::Data) -> Result { - let id = data - .ch + let ch = data.ch; + let id = ch + .clone() .split('.') .nth(1) - .ok_or(ListeningError::InvalidChannelId)? + .ok_or(ListeningError::InvalidChannelId(ch))? .to_string(); + let price = data.tick.last_price.to_string(); let asset_info = AssetInfo::new( - id, - Decimal::from_f64_retain(data.tick.last_price).ok_or(ListeningError::InvalidPrice)?, - data.timestamp / 1000, // convert from millisecond to second + id.clone(), + Decimal::from_str_exact(&price).map_err(|source| ListeningError::InvalidPrice { + source, + symbol: id, + price, + })?, + data.timestamp / 1000, ); Ok(Data::AssetInfo(vec![asset_info])) } diff --git a/bothan-kraken/src/api/error.rs b/bothan-kraken/src/api/error.rs index e2a8f5fb..3d4818ad 100644 --- a/bothan-kraken/src/api/error.rs +++ b/bothan-kraken/src/api/error.rs @@ -22,12 +22,16 @@ pub enum Error { IO(#[from] io::Error), /// Indicates a failure to parse a WebSocket message. - #[error("failed to parse message")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse message: {msg}")] + ParseError { + #[source] + source: serde_json::Error, + msg: String, + }, /// Indicates that the WebSocket message type is not supported. - #[error("unsupported message")] - UnsupportedWebsocketMessageType, + #[error("unsupported message: {0}")] + UnsupportedWebsocketMessageType(String), } /// Errors that can occur during the listening and processing phase. @@ -41,11 +45,12 @@ pub enum ListeningError { #[error(transparent)] Error(#[from] Error), - /// Indicates that the received channel ID is invalid or malformed. - #[error("received invalid channel id")] - InvalidChannelId, - - /// Indicates that the received price data contains NaN values. - #[error("received NaN")] - InvalidPrice, + /// Indicates that the received price data contains invalid values. + #[error("invalid price value {price} for symbol {symbol}")] + InvalidPrice { + #[source] + source: rust_decimal::Error, + symbol: String, + price: String, + }, } diff --git a/bothan-kraken/src/api/websocket.rs b/bothan-kraken/src/api/websocket.rs index 977c1ecf..c910c8b7 100644 --- a/bothan-kraken/src/api/websocket.rs +++ b/bothan-kraken/src/api/websocket.rs @@ -315,7 +315,9 @@ impl WebSocketConnection { Some(Ok(Message::Text(msg))) => Some(parse_msg(msg)), Some(Ok(Message::Ping(_))) => Some(Ok(Response::Ping)), Some(Ok(Message::Close(_))) => None, - Some(Ok(_)) => Some(Err(Error::UnsupportedWebsocketMessageType)), + Some(Ok(m)) => Some(Err(Error::UnsupportedWebsocketMessageType(format!( + "{m:?}" + )))), Some(Err(_)) => None, // Consider the connection closed if error detected None => None, } @@ -398,7 +400,7 @@ fn build_ticker_request( /// Returns a `Result` containing a parsed `Response` on success, /// or an `Error` if parsing fails. fn parse_msg(msg: String) -> Result { - Ok(serde_json::from_str::(&msg)?) + serde_json::from_str::(&msg).map_err(|source| Error::ParseError { source, msg }) } #[async_trait::async_trait] @@ -506,9 +508,15 @@ fn parse_tickers(tickers: Vec, timestamp: i64) -> Result Result { + let symbol = ticker.symbol; + let price = ticker.last.to_string(); Ok(AssetInfo::new( - ticker.symbol, - Decimal::from_f64_retain(ticker.last).ok_or(ListeningError::InvalidPrice)?, + symbol.clone(), + Decimal::from_str_exact(&price).map_err(|source| ListeningError::InvalidPrice { + source, + symbol, + price, + })?, timestamp, )) } diff --git a/bothan-okx/src/api/error.rs b/bothan-okx/src/api/error.rs index afcb0d63..b0c85953 100644 --- a/bothan-okx/src/api/error.rs +++ b/bothan-okx/src/api/error.rs @@ -28,15 +28,19 @@ pub enum Error { /// /// This variant wraps serde JSON errors that can occur when parsing /// WebSocket messages from the OKX API. - #[error("failed to parse message")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse message: {msg}")] + ParseError { + #[source] + source: serde_json::Error, + msg: String, + }, /// Received an unsupported WebSocket message type. /// /// This variant indicates that the WebSocket connection received a message /// type that is not supported by the OKX integration. - #[error("unsupported message")] - UnsupportedWebsocketMessageType, + #[error("unsupported message: {0}")] + UnsupportedWebsocketMessageType(String), } /// Errors that can occur during the listening and processing phase. @@ -57,8 +61,13 @@ pub enum ListeningError { /// /// This variant indicates that the price data received from the OKX API /// could not be converted to a valid decimal value. - #[error(transparent)] - InvalidPrice(#[from] rust_decimal::Error), + #[error("invalid price value {price} for symbol {symbol}")] + InvalidPrice { + #[source] + source: rust_decimal::Error, + symbol: String, + price: String, + }, /// Invalid timestamp data encountered during processing. /// diff --git a/bothan-okx/src/api/websocket.rs b/bothan-okx/src/api/websocket.rs index ae11b4f7..ea4e7778 100644 --- a/bothan-okx/src/api/websocket.rs +++ b/bothan-okx/src/api/websocket.rs @@ -284,7 +284,9 @@ impl WebSocketConnection { Some(Ok(Message::Text(msg))) => Some(parse_msg(msg)), Some(Ok(Message::Ping(_))) => Some(Ok(Response::Ping)), Some(Ok(Message::Close(_))) => None, - Some(Ok(_)) => Some(Err(Error::UnsupportedWebsocketMessageType)), + Some(Ok(m)) => Some(Err(Error::UnsupportedWebsocketMessageType(format!( + "{m:?}" + )))), Some(Err(_)) => None, // Consider the connection closed if error detected None => None, } @@ -365,7 +367,7 @@ fn build_ticker_request(inst_ids: &[T]) -> Vec { /// Returns a `Result` containing a parsed `Response` on success, /// or an `Error` if parsing fails. fn parse_msg(msg: String) -> Result { - Ok(serde_json::from_str::(&msg)?) + serde_json::from_str::(&msg).map_err(|source| Error::ParseError { source, msg }) } #[async_trait::async_trait] @@ -472,9 +474,15 @@ fn parse_tickers(tickers: Vec) -> Result { /// - The price data contains invalid values /// - The timestamp cannot be parsed fn parse_ticker(ticker: Ticker) -> Result { + let symbol = ticker.inst_id; + let price = ticker.last; Ok(AssetInfo::new( - ticker.inst_id, - Decimal::from_str_exact(&ticker.last)?, + symbol.clone(), + Decimal::from_str_exact(&price).map_err(|source| ListeningError::InvalidPrice { + source, + symbol, + price, + })?, str::parse::(&ticker.ts)? / 1000, )) } From f9eac84883a4719d63d0234e16d56d3f45c0f76b Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Wed, 12 Nov 2025 13:38:12 +0700 Subject: [PATCH 21/35] fix comments --- bothan-api/server/src/config/manager/crypto_info/sources.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 9d1bb3fb..8ffa5b9d 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -36,16 +36,12 @@ pub struct CryptoSourceConfigs { /// /// NOTE: The `name` field in `WorkerOpts` is marked with `#[serde(skip)]`, so deserialized instances /// will have an empty/default name. The custom deserializer `de_kiwi` reconstructs the options - /// with the correct name. - /// Custom deserializer is required to ensure the correct name is set. #[serde(deserialize_with = "de_kiwi")] pub band_kiwi: Option, /// Band/macaw worker options. /// /// NOTE: The `name` field in `WorkerOpts` is marked with `#[serde(skip)]`, so deserialized instances /// will have an empty/default name. The custom deserializer `de_macaw` reconstructs the options - /// with the correct name. - /// Custom deserializer is required to ensure the correct name is set. #[serde(deserialize_with = "de_macaw")] pub band_macaw: Option, } From 613a934cf559fda185e5a2542117fe6106e6efe8 Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Wed, 12 Nov 2025 15:39:50 +0700 Subject: [PATCH 22/35] fix comment --- bothan-band/src/api/error.rs | 8 -------- bothan-band/src/api/rest.rs | 3 +-- bothan-bitfinex/src/api/error.rs | 4 ---- bothan-bitfinex/src/api/rest.rs | 6 +++++- bothan-coinmarketcap/src/api/error.rs | 8 -------- 5 files changed, 6 insertions(+), 23 deletions(-) diff --git a/bothan-band/src/api/error.rs b/bothan-band/src/api/error.rs index 31f55b4a..b76503be 100644 --- a/bothan-band/src/api/error.rs +++ b/bothan-band/src/api/error.rs @@ -49,14 +49,6 @@ pub enum ProviderError { source: reqwest::Error, signals: String, }, - - /// Indicates a failure to parse the API response. - #[error("invalid price payload for signal {signal}: {source}")] - ParsePriceError { - #[source] - source: ParseError, - signal: String, - }, } /// Errors that can occur while parsing Band API responses. diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index cad0134e..bf676015 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -137,11 +137,10 @@ impl AssetInfoProvider for RestApi { let mut asset_info = Vec::with_capacity(prices.len()); for band_price in prices { - let signal = band_price.signal.clone(); match parse_price(band_price) { Ok(info) => asset_info.push(info), Err(e) => { - warn!("failed to parse price id '{signal}': {e}"); + warn!("failed to parse price: {e}"); } } } diff --git a/bothan-bitfinex/src/api/error.rs b/bothan-bitfinex/src/api/error.rs index 8a2611c7..770071ba 100644 --- a/bothan-bitfinex/src/api/error.rs +++ b/bothan-bitfinex/src/api/error.rs @@ -49,8 +49,4 @@ pub enum ProviderError { source: reqwest::Error, symbols: String, }, - - /// Indicates that the ticker data contains invalid values. - #[error("invalid price value {price} from symbol {symbol}")] - InvalidValue { price: f64, symbol: String }, } diff --git a/bothan-bitfinex/src/api/rest.rs b/bothan-bitfinex/src/api/rest.rs index f641d1a1..f527d6e9 100644 --- a/bothan-bitfinex/src/api/rest.rs +++ b/bothan-bitfinex/src/api/rest.rs @@ -196,7 +196,11 @@ impl AssetInfoProvider for RestApi { asset_infos.push(AssetInfo::new(id.clone(), price, timestamp)); } None => { - warn!("failed to parse price for symbol '{}'", t.symbol()); + warn!( + "failed to parse price {} for symbol '{}'", + t.price(), + t.symbol() + ); } } } else { diff --git a/bothan-coinmarketcap/src/api/error.rs b/bothan-coinmarketcap/src/api/error.rs index 7cce43e8..6e2600fb 100644 --- a/bothan-coinmarketcap/src/api/error.rs +++ b/bothan-coinmarketcap/src/api/error.rs @@ -75,14 +75,6 @@ pub enum ProviderError { source: reqwest::Error, ids: String, }, - - /// Indicates the ticker data contains invalid values for a specific asset. - #[error("invalid quote for id {id}: {source}")] - InvalidQuote { - #[source] - source: ParseError, - id: String, - }, } /// Errors that can occur while parsing CoinMarketCap API responses. From aa575e4e23ba3f6c04b8ccd0a90f292a3c6b9178 Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Wed, 12 Nov 2025 15:43:59 +0700 Subject: [PATCH 23/35] change from 0.0.1 to 0.1.0 --- Cargo.lock | 30 ++++++++++++------------ Cargo.toml | 26 ++++++++++---------- bothan-api/client/rust-client/Cargo.toml | 2 +- bothan-api/server-cli/Cargo.toml | 2 +- bothan-api/server/Cargo.toml | 2 +- bothan-band/Cargo.toml | 2 +- bothan-binance/Cargo.toml | 2 +- bothan-bitfinex/Cargo.toml | 2 +- bothan-bybit/Cargo.toml | 2 +- bothan-coinbase/Cargo.toml | 2 +- bothan-coingecko/Cargo.toml | 2 +- bothan-coinmarketcap/Cargo.toml | 2 +- bothan-core/Cargo.toml | 2 +- bothan-htx/Cargo.toml | 2 +- bothan-kraken/Cargo.toml | 2 +- bothan-lib/Cargo.toml | 2 +- bothan-okx/Cargo.toml | 2 +- 17 files changed, 43 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a79b0d8..47363319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,7 +428,7 @@ dependencies = [ [[package]] name = "bothan-api" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-band", @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "bothan-api-cli" -version = "0.0.1" +version = "0.1.0" dependencies = [ "anyhow", "bothan-api", @@ -493,7 +493,7 @@ dependencies = [ [[package]] name = "bothan-band" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -514,7 +514,7 @@ dependencies = [ [[package]] name = "bothan-binance" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -534,7 +534,7 @@ dependencies = [ [[package]] name = "bothan-bitfinex" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -553,7 +553,7 @@ dependencies = [ [[package]] name = "bothan-bybit" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -571,7 +571,7 @@ dependencies = [ [[package]] name = "bothan-client" -version = "0.0.1" +version = "0.1.0" dependencies = [ "pbjson", "prost", @@ -585,7 +585,7 @@ dependencies = [ [[package]] name = "bothan-coinbase" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -605,7 +605,7 @@ dependencies = [ [[package]] name = "bothan-coingecko" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -624,7 +624,7 @@ dependencies = [ [[package]] name = "bothan-coinmarketcap" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -645,7 +645,7 @@ dependencies = [ [[package]] name = "bothan-core" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "axum 0.8.4", @@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "bothan-htx" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "bothan-kraken" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", @@ -725,7 +725,7 @@ dependencies = [ [[package]] name = "bothan-lib" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bincode", @@ -747,7 +747,7 @@ dependencies = [ [[package]] name = "bothan-okx" -version = "0.0.1" +version = "0.1.0" dependencies = [ "async-trait", "bothan-lib", diff --git a/Cargo.toml b/Cargo.toml index 76f49707..2e9336be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,20 +17,20 @@ resolver = "2" [workspace.dependencies] bothan-api = { path = "bothan-api/server" } -bothan-core = { path = "bothan-core", version = "0.0.1" } -bothan-client = { path = "bothan-api/client/rust-client", version = "0.0.1" } -bothan-lib = { path = "bothan-lib", version = "0.0.1" } +bothan-core = { path = "bothan-core", version = "0.1.0" } +bothan-client = { path = "bothan-api/client/rust-client", version = "0.1.0" } +bothan-lib = { path = "bothan-lib", version = "0.1.0" } -bothan-binance = { path = "bothan-binance", version = "0.0.1" } -bothan-bitfinex = { path = "bothan-bitfinex", version = "0.0.1" } -bothan-bybit = { path = "bothan-bybit", version = "0.0.1" } -bothan-coinbase = { path = "bothan-coinbase", version = "0.0.1" } -bothan-coingecko = { path = "bothan-coingecko", version = "0.0.1" } -bothan-coinmarketcap = { path = "bothan-coinmarketcap", version = "0.0.1" } -bothan-htx = { path = "bothan-htx", version = "0.0.1" } -bothan-kraken = { path = "bothan-kraken", version = "0.0.1" } -bothan-okx = { path = "bothan-okx", version = "0.0.1" } -bothan-band = { path = "bothan-band", version = "0.0.1" } +bothan-binance = { path = "bothan-binance", version = "0.1.0" } +bothan-bitfinex = { path = "bothan-bitfinex", version = "0.1.0" } +bothan-bybit = { path = "bothan-bybit", version = "0.1.0" } +bothan-coinbase = { path = "bothan-coinbase", version = "0.1.0" } +bothan-coingecko = { path = "bothan-coingecko", version = "0.1.0" } +bothan-coinmarketcap = { path = "bothan-coinmarketcap", version = "0.1.0" } +bothan-htx = { path = "bothan-htx", version = "0.1.0" } +bothan-kraken = { path = "bothan-kraken", version = "0.1.0" } +bothan-okx = { path = "bothan-okx", version = "0.1.0" } +bothan-band = { path = "bothan-band", version = "0.1.0" } anyhow = "1.0.86" async-trait = "0.1.77" diff --git a/bothan-api/client/rust-client/Cargo.toml b/bothan-api/client/rust-client/Cargo.toml index cc68da3f..5c629a70 100644 --- a/bothan-api/client/rust-client/Cargo.toml +++ b/bothan-api/client/rust-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-client" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the Bothan API" authors.workspace = true edition.workspace = true diff --git a/bothan-api/server-cli/Cargo.toml b/bothan-api/server-cli/Cargo.toml index 24da2467..e4041850 100644 --- a/bothan-api/server-cli/Cargo.toml +++ b/bothan-api/server-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-api-cli" -version = "0.0.1" +version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/bothan-api/server/Cargo.toml b/bothan-api/server/Cargo.toml index b25e4b39..f820df49 100644 --- a/bothan-api/server/Cargo.toml +++ b/bothan-api/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-api" -version = "0.0.1" +version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/bothan-band/Cargo.toml b/bothan-band/Cargo.toml index 206a3ae2..e2b90c5c 100644 --- a/bothan-band/Cargo.toml +++ b/bothan-band/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-band" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the Band source with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-binance/Cargo.toml b/bothan-binance/Cargo.toml index d4cfed22..6e79322d 100644 --- a/bothan-binance/Cargo.toml +++ b/bothan-binance/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-binance" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the Binance exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-bitfinex/Cargo.toml b/bothan-bitfinex/Cargo.toml index 3337f7e6..41dd2bcf 100644 --- a/bothan-bitfinex/Cargo.toml +++ b/bothan-bitfinex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-bitfinex" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the Bitfinex exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-bybit/Cargo.toml b/bothan-bybit/Cargo.toml index 16cc6333..768be99d 100644 --- a/bothan-bybit/Cargo.toml +++ b/bothan-bybit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-bybit" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the Bybit exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-coinbase/Cargo.toml b/bothan-coinbase/Cargo.toml index 8ee74858..9a188983 100644 --- a/bothan-coinbase/Cargo.toml +++ b/bothan-coinbase/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-coinbase" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the Coinbase exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-coingecko/Cargo.toml b/bothan-coingecko/Cargo.toml index 5bd77682..b033007d 100644 --- a/bothan-coingecko/Cargo.toml +++ b/bothan-coingecko/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-coingecko" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the CoinGecko exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-coinmarketcap/Cargo.toml b/bothan-coinmarketcap/Cargo.toml index 6d08c70b..61888470 100644 --- a/bothan-coinmarketcap/Cargo.toml +++ b/bothan-coinmarketcap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-coinmarketcap" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the CoinMarketCap exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-core/Cargo.toml b/bothan-core/Cargo.toml index ad921ea9..d7e6d514 100644 --- a/bothan-core/Cargo.toml +++ b/bothan-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-core" -version = "0.0.1" +version = "0.1.0" description = "Core library for Bothan" authors.workspace = true edition.workspace = true diff --git a/bothan-htx/Cargo.toml b/bothan-htx/Cargo.toml index 111d24f8..516ad857 100644 --- a/bothan-htx/Cargo.toml +++ b/bothan-htx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-htx" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the HTX exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-kraken/Cargo.toml b/bothan-kraken/Cargo.toml index 5f8e677f..222b4855 100644 --- a/bothan-kraken/Cargo.toml +++ b/bothan-kraken/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-kraken" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the Kraken exchange with Bothan integration" edition.workspace = true license.workspace = true diff --git a/bothan-lib/Cargo.toml b/bothan-lib/Cargo.toml index 77a86712..093b9676 100644 --- a/bothan-lib/Cargo.toml +++ b/bothan-lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-lib" -version = "0.0.1" +version = "0.1.0" description = "Library contain base functionality and types for Bothan" authors.workspace = true edition.workspace = true diff --git a/bothan-okx/Cargo.toml b/bothan-okx/Cargo.toml index 272e7149..6d78662c 100644 --- a/bothan-okx/Cargo.toml +++ b/bothan-okx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bothan-okx" -version = "0.0.1" +version = "0.1.0" description = "Rust client for the OKX exchange with Bothan integration" edition.workspace = true license.workspace = true From 8b1fd79171bdea6a1f12917a3371f47a0b63275d Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Wed, 12 Nov 2025 17:01:47 +0700 Subject: [PATCH 24/35] add test --- .../src/config/manager/crypto_info/sources.rs | 4 + bothan-band/src/api/rest.rs | 74 +++++++++++++++ bothan-coingecko/src/api/rest.rs | 95 +++++++++++++++++-- bothan-coingecko/src/api/types.rs | 2 +- bothan-coinmarketcap/src/api/rest.rs | 78 +++++++++++++++ 5 files changed, 245 insertions(+), 8 deletions(-) diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index 8ffa5b9d..b096ba5b 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -46,6 +46,10 @@ pub struct CryptoSourceConfigs { pub band_macaw: Option, } +// Macro to generate deserialization functions for Band workers with preset names. +// This macro defines a function that: +// - Deserializes an Option, +// - If present, creates a new WorkerOpts with the given name and original URL/update_interval. macro_rules! de_band_named { ($fn_name:ident, $name:expr) => { fn $fn_name<'de, D>(d: D) -> Result, D::Error> diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 3616c70c..aaa9ac6d 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -157,6 +157,14 @@ mod test { } } + fn mock_price_none(signal: &str, timestamp: i64) -> Price { + Price { + signal: signal.to_string(), + price: None, + timestamp: Some(timestamp), + } + } + trait MockBandRest { fn set_successful_prices(&mut self, ids: &[String], prices: &[Price]) -> Mock; fn set_arbitrary_prices>( @@ -248,4 +256,70 @@ mod test { mock.assert(); assert!(result.is_err()); } + + #[tokio::test] + async fn test_get_asset_info_price_is_none() { + let (mut server, client) = setup().await; + + let ids = vec!["BTC".to_string(), "ETH".to_string()]; + let prices = vec![ + mock_price("BTC", 80000.0, 100000), + // ETH will have price = None + mock_price_none("ETH", 100002), + ]; + let mock = server.set_successful_prices(&ids, &prices); + + let asset_infos = client.get_asset_info(&ids).await.unwrap(); + + mock.assert(); + + // Only BTC info should be present; ETH is skipped due to None price + assert_eq!(asset_infos.len(), 1); + assert_eq!(asset_infos[0].id, "BTC"); + assert_eq!( + asset_infos[0].price, + rust_decimal::Decimal::from_f64_retain(80000.0).unwrap() + ); + assert_eq!(asset_infos[0].timestamp, 100000); + } + + #[tokio::test] + async fn test_get_asset_info_success_multiple_assets() { + let (mut server, client) = setup().await; + + let ids = vec!["BTC".to_string(), "ETH".to_string(), "BAND".to_string()]; + let prices = vec![ + mock_price("BTC", 80000.0, 100000), + mock_price("ETH", 3500.0, 100002), + mock_price("BAND", 1.6, 100003), + ]; + let mock = server.set_successful_prices(&ids, &prices); + + let asset_infos = client.get_asset_info(&ids).await.unwrap(); + + mock.assert(); + + assert_eq!(asset_infos.len(), 3); + + assert_eq!(asset_infos[0].id, "BTC"); + assert_eq!( + asset_infos[0].price, + rust_decimal::Decimal::from_f64_retain(80000.0).unwrap() + ); + assert_eq!(asset_infos[0].timestamp, 100000); + + assert_eq!(asset_infos[1].id, "ETH"); + assert_eq!( + asset_infos[1].price, + rust_decimal::Decimal::from_f64_retain(3500.0).unwrap() + ); + assert_eq!(asset_infos[1].timestamp, 100002); + + assert_eq!(asset_infos[2].id, "BAND"); + assert_eq!( + asset_infos[2].price, + rust_decimal::Decimal::from_f64_retain(1.6).unwrap() + ); + assert_eq!(asset_infos[2].timestamp, 100003); + } } diff --git a/bothan-coingecko/src/api/rest.rs b/bothan-coingecko/src/api/rest.rs index bb956e9c..7b4c0c9f 100644 --- a/bothan-coingecko/src/api/rest.rs +++ b/bothan-coingecko/src/api/rest.rs @@ -163,11 +163,19 @@ impl AssetInfoProvider for RestApi { for id in ids { match simple_prices.get(id) { - Some(p) => { - let price = - Decimal::from_f64_retain(p.usd).ok_or(ProviderError::InvalidValue)?; - asset_infos.push(AssetInfo::new(id.clone(), price, p.last_updated_at)); - } + Some(p) => match p.usd { + Some(usd) => match Decimal::from_f64_retain(usd) { + Some(price) => { + asset_infos.push(AssetInfo::new(id.clone(), price, p.last_updated_at)); + } + None => { + warn!("failed to parse price for id '{id}': invalid or NaN value."); + } + }, + None => { + warn!("price data for id '{id}' has a missing USD value."); + } + }, None => { warn!("price data for id '{id}' not found."); } @@ -179,10 +187,13 @@ impl AssetInfoProvider for RestApi { #[cfg(test)] mod test { + use std::collections::HashMap; + use mockito::{Matcher, Mock, Server, ServerGuard}; use super::*; use crate::api::RestApiBuilder; + use crate::api::types::Price; async fn setup() -> (ServerGuard, RestApi) { let server = Server::new_async().await; @@ -303,7 +314,7 @@ mod test { let prices: HashMap = HashMap::from([( "bitcoin".to_string(), Price { - usd: 42000.69, + usd: Some(42000.69), last_updated_at: 42000, }, )]); @@ -323,7 +334,7 @@ mod test { let prices = HashMap::from([( "bitcoin".to_string(), Price { - usd: 42000.69, + usd: Some(42000.69), last_updated_at: 42000, }, )]); @@ -360,4 +371,74 @@ mod test { mock.assert(); assert!(result.is_err()); } + + // New test: AssetInfoProvider returns empty AssetInfo for not found / null prices + #[tokio::test] + async fn test_asset_info_id_not_found_in_result() { + let (mut server, client) = setup().await; + + let prices: HashMap = HashMap::from([( + "bitcoin".to_string(), + Price { + usd: Some(42000.0), + last_updated_at: 1700000000, + }, + )]); + // "missingcoin" does not exist in returned prices + let ids = vec!["bitcoin".to_string(), "missingcoin".to_string()]; + + let mock = server.set_successful_simple_price(&["bitcoin", "missingcoin"], &prices); + + let asset_infos = client.get_asset_info(&ids).await.unwrap(); + + mock.assert(); + + // Only bitcoin is returned, missingcoin should be skipped (not found, only one result) + assert_eq!(asset_infos.len(), 1); + assert_eq!(asset_infos[0].id, "bitcoin"); + assert_eq!( + asset_infos[0].price, + Decimal::from_f64_retain(42000.0).unwrap() + ); + assert_eq!(asset_infos[0].timestamp, 1700000000); + } + + #[tokio::test] + async fn test_asset_info_price_is_nan_or_invalid() { + let (mut server, client) = setup().await; + // Insert a value that is NaN + let prices = HashMap::from([ + ( + "bitcoin".to_string(), + Price { + usd: None, + last_updated_at: 123456, + }, + ), + ( + "ethereum".to_string(), + Price { + usd: Some(2500.0), + last_updated_at: 789012, + }, + ), + ]); + + let ids = vec!["bitcoin".to_string(), "ethereum".to_string()]; + + let mock = server.set_successful_simple_price(&["bitcoin", "ethereum"], &prices); + + let asset_infos = client.get_asset_info(&ids).await.unwrap(); + + mock.assert(); + + // Only ethereum should be in the result; bitcoin is skipped due to NaN + assert_eq!(asset_infos.len(), 1); + assert_eq!(asset_infos[0].id, "ethereum"); + assert_eq!( + asset_infos[0].price, + Decimal::from_f64_retain(2500.0).unwrap() + ); + assert_eq!(asset_infos[0].timestamp, 789012); + } } diff --git a/bothan-coingecko/src/api/types.rs b/bothan-coingecko/src/api/types.rs index 332b72fe..8f9cbb3e 100644 --- a/bothan-coingecko/src/api/types.rs +++ b/bothan-coingecko/src/api/types.rs @@ -57,7 +57,7 @@ pub struct Coin { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Price { /// Latest price in USD. - pub usd: f64, + pub usd: Option, /// Unix timestamp (in seconds) of the latest price update. pub last_updated_at: i64, } diff --git a/bothan-coinmarketcap/src/api/rest.rs b/bothan-coinmarketcap/src/api/rest.rs index 7bb2f427..3dc32f3f 100644 --- a/bothan-coinmarketcap/src/api/rest.rs +++ b/bothan-coinmarketcap/src/api/rest.rs @@ -220,6 +220,30 @@ pub(crate) mod test { } } + pub(crate) fn mock_quote_no_price() -> Quote { + Quote { + id: 2, + symbol: "ETH".to_string(), + slug: "ethereum".to_string(), + name: "Ethereum".to_string(), + price_quotes: PriceQuotes { + usd: PriceQuote { + price: None, + volume_24h: 999.0, + volume_change_24h: 111.0, + market_cap: Some(20000000.0), + market_cap_dominance: 88.0, + fully_diluted_market_cap: 4200000.0, + percent_change_1h: 1.5, + percent_change_24h: 5.0, + percent_change_7d: 9.9, + percent_change_30d: 0.0, + last_updated: "2024-03-16T06:55:15.700Z".to_string(), + }, + }, + } + } + pub(crate) trait MockCoinMarketCap { fn set_successful_quotes(&mut self, ids: &[&str], quotes: &[Quote]) -> Mock; fn set_arbitrary_quotes>( @@ -323,4 +347,58 @@ pub(crate) mod test { mock.assert(); assert!(result.is_err()); } + + #[tokio::test] + async fn test_get_asset_info_with_none_price() { + let (mut server, client) = setup().await; + let mut quotes = vec![mock_quote()]; + let quote_none_price = mock_quote_no_price(); + quotes.push(quote_none_price.clone()); + + // ids as strings; 1 has price, 2 does not + let ids = vec!["1".to_string(), "2".to_string()]; + + // Both quotes in the response map + let mock = server.set_successful_quotes(&["1", "2"], "es); + + let asset_infos = client.get_asset_info(&ids).await.unwrap(); + + mock.assert(); + + // Only the first asset (price Some) should be included. + assert_eq!(asset_infos.len(), 1); + assert_eq!(asset_infos[0].id, "1"); + assert_eq!( + asset_infos[0].price, + rust_decimal::Decimal::from_f64_retain(80000.0).unwrap() + ); + // Verify timestamp parsed for the correct quote + assert_eq!( + asset_infos[0].timestamp, + chrono::DateTime::parse_from_rfc3339("2024-03-16T06:55:15.626Z") + .unwrap() + .timestamp() + ); + } + + #[tokio::test] + async fn test_get_asset_info_with_no_quotes_returned() { + let (mut server, client) = setup().await; + + // No quotes in response + let quotes: Vec = Vec::new(); + + // ids for which no quotes will be returned + let ids = vec!["1".to_string(), "2".to_string(), "3".to_string()]; + + // The server mock returns no quotes for the requested ids + let mock = server.set_successful_quotes(&["1", "2", "3"], "es); + + let asset_infos = client.get_asset_info(&ids).await.unwrap(); + + mock.assert(); + + // No asset info should be included since there are no quotes + assert_eq!(asset_infos.len(), 0); + } } From c48b039a7c7bce8c7e08e3bfbc4b1d1f7473de2a Mon Sep 17 00:00:00 2001 From: Nattapat Iammelap Date: Thu, 13 Nov 2025 13:37:37 +0700 Subject: [PATCH 25/35] add default band worker opts --- bothan-api/server/src/config/manager/crypto_info/sources.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bothan-api/server/src/config/manager/crypto_info/sources.rs b/bothan-api/server/src/config/manager/crypto_info/sources.rs index b096ba5b..85c8ab83 100644 --- a/bothan-api/server/src/config/manager/crypto_info/sources.rs +++ b/bothan-api/server/src/config/manager/crypto_info/sources.rs @@ -36,13 +36,13 @@ pub struct CryptoSourceConfigs { /// /// NOTE: The `name` field in `WorkerOpts` is marked with `#[serde(skip)]`, so deserialized instances /// will have an empty/default name. The custom deserializer `de_kiwi` reconstructs the options - #[serde(deserialize_with = "de_kiwi")] + #[serde(default, deserialize_with = "de_kiwi")] pub band_kiwi: Option, /// Band/macaw worker options. /// /// NOTE: The `name` field in `WorkerOpts` is marked with `#[serde(skip)]`, so deserialized instances /// will have an empty/default name. The custom deserializer `de_macaw` reconstructs the options - #[serde(deserialize_with = "de_macaw")] + #[serde(default, deserialize_with = "de_macaw")] pub band_macaw: Option, } From 8cb507fa2b7910080feac15cfb2f2eb7235f6919 Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 14:10:55 +0700 Subject: [PATCH 26/35] fix log warn debug error --- bothan-api/server/src/api/utils.rs | 4 ++-- bothan-band/src/api/rest.rs | 4 ++-- bothan-bitfinex/src/api/rest.rs | 4 ++-- bothan-coinbase/src/api/websocket.rs | 4 ++-- bothan-coingecko/src/api/rest.rs | 4 ++-- bothan-coinmarketcap/src/api/rest.rs | 6 +++--- .../src/manager/crypto_asset_info/price/tasks.rs | 10 +++++----- bothan-htx/src/api/websocket.rs | 4 ++-- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/bothan-api/server/src/api/utils.rs b/bothan-api/server/src/api/utils.rs index 1c4a0151..f80aca2b 100644 --- a/bothan-api/server/src/api/utils.rs +++ b/bothan-api/server/src/api/utils.rs @@ -9,7 +9,7 @@ use bothan_core::manager::crypto_asset_info::types::PriceState; use rust_decimal::prelude::Zero; -use tracing::warn; +use tracing::error; use crate::api::server::PRECISION; use crate::proto::bothan::v1::{Price, Status}; @@ -32,7 +32,7 @@ pub fn parse_price_state(id: String, price_state: PriceState) -> Price { match u64::try_from(mantissa) { Ok(p) => Price::new(id, p, Status::Available), Err(_) => { - warn!("failed to convert {mantissa} to u64 for id {id}"); + error!("failed to convert {mantissa} to u64 for id {id}"); Price::new(id, 0u64, Status::Unavailable) } } diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index b9a76560..7f07cc8b 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -15,7 +15,7 @@ use bothan_lib::worker::rest::AssetInfoProvider; use itertools::Itertools; use reqwest::{Client, Url}; use rust_decimal::Decimal; -use tracing::warn; +use tracing::error; use crate::api::error::{ParseError, ProviderError}; use crate::api::types::Price; @@ -140,7 +140,7 @@ impl AssetInfoProvider for RestApi { match parse_price(band_price) { Ok(info) => asset_info.push(info), Err(e) => { - warn!("failed to parse price: {e}"); + error!("failed to parse price: {e}"); } } } diff --git a/bothan-bitfinex/src/api/rest.rs b/bothan-bitfinex/src/api/rest.rs index f527d6e9..2cf87c99 100644 --- a/bothan-bitfinex/src/api/rest.rs +++ b/bothan-bitfinex/src/api/rest.rs @@ -16,7 +16,7 @@ use bothan_lib::types::AssetInfo; use bothan_lib::worker::rest::AssetInfoProvider; use reqwest::{Client, Url}; use rust_decimal::Decimal; -use tracing::warn; +use tracing::{error, warn}; use crate::api::error::ProviderError; use crate::api::msg::ticker::Ticker; @@ -196,7 +196,7 @@ impl AssetInfoProvider for RestApi { asset_infos.push(AssetInfo::new(id.clone(), price, timestamp)); } None => { - warn!( + error!( "failed to parse price {} for symbol '{}'", t.price(), t.symbol() diff --git a/bothan-coinbase/src/api/websocket.rs b/bothan-coinbase/src/api/websocket.rs index 5c712f1a..e527a4a0 100644 --- a/bothan-coinbase/src/api/websocket.rs +++ b/bothan-coinbase/src/api/websocket.rs @@ -20,7 +20,7 @@ use serde_json::json; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite}; -use tracing::warn; +use tracing::error; use crate::api::Ticker; use crate::api::error::{Error, ListeningError}; @@ -227,7 +227,7 @@ impl AssetInfoProvider for WebSocketConnection { Response::Ticker(t) => parse_ticker(t)?, Response::Ping => Data::Ping, Response::Error(e) => { - warn!("received error in response: {:?}", e); + error!("received error in response: {:?}", e); Data::Unused } _ => Data::Unused, diff --git a/bothan-coingecko/src/api/rest.rs b/bothan-coingecko/src/api/rest.rs index f596d62a..c7a6d4b6 100644 --- a/bothan-coingecko/src/api/rest.rs +++ b/bothan-coingecko/src/api/rest.rs @@ -17,7 +17,7 @@ use bothan_lib::worker::rest::AssetInfoProvider; use reqwest::{Client, RequestBuilder, Url}; use rust_decimal::Decimal; use serde::de::DeserializeOwned; -use tracing::warn; +use tracing::{error, warn}; use crate::api::error::ProviderError; use crate::api::types::{Coin, Price}; @@ -197,7 +197,7 @@ impl AssetInfoProvider for RestApi { asset_infos.push(AssetInfo::new(id.clone(), price, p.last_updated_at)); } None => { - warn!("failed to parse price for id '{id}': invalid or NaN value."); + error!("failed to parse price '{usd}' for id '{id}'"); } }, None => { diff --git a/bothan-coinmarketcap/src/api/rest.rs b/bothan-coinmarketcap/src/api/rest.rs index a52e740c..15a8d24d 100644 --- a/bothan-coinmarketcap/src/api/rest.rs +++ b/bothan-coinmarketcap/src/api/rest.rs @@ -17,7 +17,7 @@ use bothan_lib::worker::rest::AssetInfoProvider; use itertools::Itertools; use reqwest::{Client, Url}; use rust_decimal::Decimal; -use tracing::warn; +use tracing::{error, warn}; use crate::api::error::{ParseError, ProviderError}; use crate::api::types::{Quote, Response as CmcResponse}; @@ -157,7 +157,7 @@ impl AssetInfoProvider for RestApi { match id.parse::() { Ok(val) => int_ids.push(val), Err(_) => { - warn!("invalid CoinMarketCap id '{id}': cannot parse to u64",); + error!("invalid CoinMarketCap id '{id}': cannot parse to u64",); } } } @@ -170,7 +170,7 @@ impl AssetInfoProvider for RestApi { Some(q) => match parse_quote(q) { Ok(info) => asset_info.push(info), Err(e) => { - warn!("failed to parse quote for id '{}': {e}", int_ids[idx]); + error!("failed to parse quote for id '{}': {e}", int_ids[idx]); } }, None => { diff --git a/bothan-core/src/manager/crypto_asset_info/price/tasks.rs b/bothan-core/src/manager/crypto_asset_info/price/tasks.rs index ff0ced53..e89ec102 100644 --- a/bothan-core/src/manager/crypto_asset_info/price/tasks.rs +++ b/bothan-core/src/manager/crypto_asset_info/price/tasks.rs @@ -16,7 +16,7 @@ use bothan_lib::store::Store; use bothan_lib::types::AssetInfo; use num_traits::Zero; use rust_decimal::Decimal; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use crate::manager::crypto_asset_info::price::cache::PriceCache; use crate::manager::crypto_asset_info::price::error::{Error, MissingPrerequisiteError}; @@ -71,15 +71,15 @@ pub async fn get_signal_price_states( continue; } Err(Error::InvalidSignal) => { - warn!("signal with id {} is not supported", id); + debug!("signal with id {} is not supported", id); cache.set_unsupported(id); } Err(Error::FailedToProcessSignal(e)) => { - warn!("error while processing signal id {}: {}", id, e); + error!("error while processing signal id {}: {}", id, e); cache.set_unavailable(id); } Err(Error::FailedToPostProcessSignal(e)) => { - warn!("error while post processing signal id {}: {}", id, e); + error!("error while post processing signal id {}: {}", id, e); cache.set_unavailable(id); } } @@ -241,7 +241,7 @@ async fn process_source_query( Ok(None) } Err(_) => { - warn!("error while querying source {source_id} for {query_id}"); + error!("error while querying source {source_id} for {query_id}"); metrics.update_store_operation( source_id.clone(), start_time.elapsed().as_micros(), diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index 73de9d8b..fe47ca68 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -23,7 +23,7 @@ use serde_json::json; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite}; -use tracing::warn; +use tracing::error; use crate::api::error::{Error, ListeningError}; use crate::api::types::Response; @@ -357,7 +357,7 @@ impl AssetInfoProvider for WebSocketConnection { Ok(Response::DataUpdate(d)) => parse_data(d), Ok(Response::Ping(p)) => reply_pong(self, p.ping).await, Ok(Response::Error(e)) => { - warn!("received error in response: {:?}", e); + error!("received error in response: {:?}", e); Ok(Data::Unused) } Err(e) => Err(ListeningError::Error(e)), From fe12d99f41234810fa3652540b569ae2a3b3f3de Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 15:08:36 +0700 Subject: [PATCH 27/35] fix comment --- bothan-htx/src/api/websocket.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index fe47ca68..dc7ba051 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -396,11 +396,10 @@ impl AssetInfoProvider for WebSocketConnection { fn parse_data(data: super::types::Data) -> Result { let ch = data.ch; let id = ch - .clone() .split('.') .nth(1) - .ok_or(ListeningError::InvalidChannelId(ch))? - .to_string(); + .map(|s| s.to_string()) + .ok_or_else(|| ListeningError::InvalidChannelId(ch))?; let price = data.tick.last_price.to_string(); let asset_info = AssetInfo::new( id.clone(), From d853a9170ffabf089f0a1e2df27a3fadd75830b0 Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 16:07:10 +0700 Subject: [PATCH 28/35] add log --- bothan-band/src/api/rest.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bothan-band/src/api/rest.rs b/bothan-band/src/api/rest.rs index 7f07cc8b..60d192c6 100644 --- a/bothan-band/src/api/rest.rs +++ b/bothan-band/src/api/rest.rs @@ -15,7 +15,7 @@ use bothan_lib::worker::rest::AssetInfoProvider; use itertools::Itertools; use reqwest::{Client, Url}; use rust_decimal::Decimal; -use tracing::error; +use tracing::{error, warn}; use crate::api::error::{ParseError, ProviderError}; use crate::api::types::Price; @@ -139,8 +139,14 @@ impl AssetInfoProvider for RestApi { for band_price in prices { match parse_price(band_price) { Ok(info) => asset_info.push(info), - Err(e) => { - error!("failed to parse price: {e}"); + Err(ParseError::InvalidPrice { price, signal }) => { + error!("failed to parse price '{price}' for signal '{signal}'"); + }, + Err(ParseError::MissingPrice(signal)) => { + warn!("missing price for '{signal}'"); + } + Err(ParseError::MissingTimestamp(signal)) => { + warn!("missing timestamp for '{signal}'"); } } } From 40192c7c0907d5c08966b07c473312f4619e2acd Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 17:58:04 +0700 Subject: [PATCH 29/35] fix --- bothan-htx/src/api/error.rs | 2 -- bothan-htx/src/api/websocket.rs | 7 ++----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/bothan-htx/src/api/error.rs b/bothan-htx/src/api/error.rs index 52d12a50..a1965207 100644 --- a/bothan-htx/src/api/error.rs +++ b/bothan-htx/src/api/error.rs @@ -47,8 +47,6 @@ pub enum ListeningError { /// Indicates that the received price data contains invalid values. #[error("invalid price value {price} for symbol {symbol}")] InvalidPrice { - #[source] - source: rust_decimal::Error, symbol: String, price: f64, }, diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index cbd864bc..6aaab5ac 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -403,11 +403,8 @@ fn parse_data(data: super::types::Data) -> Result { let price = data.tick.last_price; let asset_info = AssetInfo::new( id.clone(), - Decimal::try_from(price).map_err(|source| ListeningError::InvalidPrice { - source, - symbol: id.clone(), - price, - })?, + Decimal::from_f64_retain(data.tick.last_price) + .ok_or(ListeningError::InvalidPrice { symbol: id, price })?, data.timestamp / 1000, ); Ok(Data::AssetInfo(vec![asset_info])) From 040ab6fd626a2fdd0ea7d3062b86c76e7e5778da Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 17:59:14 +0700 Subject: [PATCH 30/35] refac --- bothan-htx/src/api/websocket.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index 6aaab5ac..9f52c11d 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -394,12 +394,11 @@ impl AssetInfoProvider for WebSocketConnection { /// - The channel ID cannot be extracted from the channel name /// - The price data contains invalid values (NaN) fn parse_data(data: super::types::Data) -> Result { - let ch = data.ch; - let id = ch + let id = data.ch .split('.') .nth(1) .map(|s| s.to_string()) - .ok_or_else(|| ListeningError::InvalidChannelId(ch))?; + .ok_or_else(|| ListeningError::InvalidChannelId(data.ch))?; let price = data.tick.last_price; let asset_info = AssetInfo::new( id.clone(), From ed5863cf2acb2234f1123296380167b6fe3234c3 Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 18:02:02 +0700 Subject: [PATCH 31/35] fix --- bothan-htx/src/api/websocket.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index 9f52c11d..7a182d1c 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -394,11 +394,13 @@ impl AssetInfoProvider for WebSocketConnection { /// - The channel ID cannot be extracted from the channel name /// - The price data contains invalid values (NaN) fn parse_data(data: super::types::Data) -> Result { - let id = data.ch + let ch = data.ch; + let id = ch + .clone() .split('.') .nth(1) - .map(|s| s.to_string()) - .ok_or_else(|| ListeningError::InvalidChannelId(data.ch))?; + .ok_or_else(|| ListeningError::InvalidChannelId(ch))? + .to_string(); let price = data.tick.last_price; let asset_info = AssetInfo::new( id.clone(), From 7402cfce2952db0dbe32b454db6050da24f90fcf Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 18:03:00 +0700 Subject: [PATCH 32/35] fix --- bothan-htx/src/api/websocket.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bothan-htx/src/api/websocket.rs b/bothan-htx/src/api/websocket.rs index 7a182d1c..09be7416 100644 --- a/bothan-htx/src/api/websocket.rs +++ b/bothan-htx/src/api/websocket.rs @@ -399,7 +399,7 @@ fn parse_data(data: super::types::Data) -> Result { .clone() .split('.') .nth(1) - .ok_or_else(|| ListeningError::InvalidChannelId(ch))? + .ok_or(ListeningError::InvalidChannelId(ch))? .to_string(); let price = data.tick.last_price; let asset_info = AssetInfo::new( From 51c62913accd8102cb66d7572c9446fdcbab5c6d Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 18:04:10 +0700 Subject: [PATCH 33/35] lint --- bothan-htx/src/api/error.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bothan-htx/src/api/error.rs b/bothan-htx/src/api/error.rs index a1965207..50fa7039 100644 --- a/bothan-htx/src/api/error.rs +++ b/bothan-htx/src/api/error.rs @@ -46,10 +46,7 @@ pub enum ListeningError { /// Indicates that the received price data contains invalid values. #[error("invalid price value {price} for symbol {symbol}")] - InvalidPrice { - symbol: String, - price: f64, - }, + InvalidPrice { symbol: String, price: f64 }, /// Indicates a failure to send a pong response to a ping message. #[error("failed to pong")] From 3e4045e0cf296f644bc54a1986f43fbd53af7cc4 Mon Sep 17 00:00:00 2001 From: Tanut Lertwarachai Date: Thu, 13 Nov 2025 18:05:30 +0700 Subject: [PATCH 34/35] refac --- bothan-kraken/src/api/error.rs | 7 +------ bothan-kraken/src/api/websocket.rs | 8 ++------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/bothan-kraken/src/api/error.rs b/bothan-kraken/src/api/error.rs index 3d4818ad..0ae6bd83 100644 --- a/bothan-kraken/src/api/error.rs +++ b/bothan-kraken/src/api/error.rs @@ -47,10 +47,5 @@ pub enum ListeningError { /// Indicates that the received price data contains invalid values. #[error("invalid price value {price} for symbol {symbol}")] - InvalidPrice { - #[source] - source: rust_decimal::Error, - symbol: String, - price: String, - }, + InvalidPrice { symbol: String, price: f64 }, } diff --git a/bothan-kraken/src/api/websocket.rs b/bothan-kraken/src/api/websocket.rs index c910c8b7..3ad51351 100644 --- a/bothan-kraken/src/api/websocket.rs +++ b/bothan-kraken/src/api/websocket.rs @@ -509,14 +509,10 @@ fn parse_tickers(tickers: Vec, timestamp: i64) -> Result Result { let symbol = ticker.symbol; - let price = ticker.last.to_string(); + let price = ticker.last; Ok(AssetInfo::new( symbol.clone(), - Decimal::from_str_exact(&price).map_err(|source| ListeningError::InvalidPrice { - source, - symbol, - price, - })?, + Decimal::from_f64_retain(price).ok_or(ListeningError::InvalidPrice { symbol, price })?, timestamp, )) } From e223a84532dee0e4e1116626af89f95cf2f5e308 Mon Sep 17 00:00:00 2001 From: Kitipong Sirirueangsakul Date: Fri, 14 Nov 2025 13:23:14 +0700 Subject: [PATCH 35/35] patch go-client --- bothan-api-proxy/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bothan-api-proxy/go.mod b/bothan-api-proxy/go.mod index 16f06d16..807783b4 100644 --- a/bothan-api-proxy/go.mod +++ b/bothan-api-proxy/go.mod @@ -3,7 +3,7 @@ module go-proxy go 1.24.2 require ( - github.com/bandprotocol/bothan/bothan-api/client/go-client v0.0.1 + github.com/bandprotocol/bothan/bothan-api/client/go-client v0.1.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 google.golang.org/grpc v1.67.1 )