diff --git a/packages/komodo_cex_market_data/index_generator.yaml b/packages/komodo_cex_market_data/index_generator.yaml new file mode 100644 index 00000000..7e9bcb01 --- /dev/null +++ b/packages/komodo_cex_market_data/index_generator.yaml @@ -0,0 +1,125 @@ +# Used to generate Dart index file. Can be ran with `dart run index_generator` +# from this package's root directory. +# See https://pub.dev/packages/index_generator for more information. +index_generator: + page_width: 80 + exclude: + - "**.g.dart" + - "**.freezed.dart" + + libraries: + # Binance market data provider + - directory_path: lib/src/binance + file_name: _binance_index + name: _binance + exclude: + - "{_,**/_}*.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to Binance market data provider functionality. + disclaimer: false + + # CoinGecko market data provider + - directory_path: lib/src/coingecko + file_name: _coingecko_index + name: _coingecko + exclude: + - "{_,**/_}*.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to CoinGecko market data provider functionality. + disclaimer: false + + # CoinPaprika market data provider + - directory_path: lib/src/coinpaprika + file_name: _coinpaprika_index + name: _coinpaprika + exclude: + - "{_,**/_}*.dart" + - "models/models.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to CoinPaprika market data provider functionality. + disclaimer: false + + # Komodo-specific functionality + - directory_path: lib/src/komodo + file_name: _komodo_index + name: _komodo + exclude: + - "{_,**/_}*.dart" + - "komodo.dart" + - "prices/prices.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to Komodo-specific market data functionality. + disclaimer: false + + # Common models and types + - directory_path: lib/src/models + file_name: _models_index + name: _models + exclude: + - "{_,**/_}*.dart" + - "models.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to common models and types for market data. + disclaimer: false + + # Common utilities + - directory_path: lib/src/common + file_name: _common_index + name: _common + exclude: + - "{_,**/_}*.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to common utilities for market data providers. + disclaimer: false + + # Bootstrap functionality + - directory_path: lib/src/bootstrap + file_name: _bootstrap_index + name: _bootstrap + exclude: + - "{_,**/_}*.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to market data bootstrap functionality. + disclaimer: false + + # Main src-level exports (individual files not in subdirectories) + - directory_path: lib/src/ + file_name: _core_index + name: _core + include: + - "*.dart" + exclude: + - "**/*_index.dart" + - "{_,**/_}*.dart" + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private classes related to core market data functionality. + disclaimer: false + + # Combined internal exports + - directory_path: lib/src/ + file_name: _internal_exports + name: _internal_exports + include: + - "**/*_index.dart" + exclude: [] + comments: | + (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + docs: | + Internal/private exports combining all market data provider functionality. + disclaimer: false diff --git a/packages/komodo_cex_market_data/lib/komodo_cex_market_data.dart b/packages/komodo_cex_market_data/lib/komodo_cex_market_data.dart index 814af0db..011a0190 100644 --- a/packages/komodo_cex_market_data/lib/komodo_cex_market_data.dart +++ b/packages/komodo_cex_market_data/lib/komodo_cex_market_data.dart @@ -1,8 +1,35 @@ -/// Komodo CEX market data library for fetching and managing cryptocurrency market data. +/// Komodo CEX market data library for fetching and managing cryptocurrency +/// market data. /// -/// Provides support for multiple market data providers with fallback capabilities, -/// repository selection strategies, and robust error handling. -library; +/// This library provides comprehensive support for multiple cryptocurrency +/// market data providers +/// including Binance, CoinGecko, and CoinPaprika. It features: +/// +/// * Multiple market data providers with fallback capabilities +/// * Repository selection strategies and priority management +/// * Robust error handling and retry mechanisms +/// * OHLC data, price information, and market statistics +/// * Sparkline data for charts and visualizations +/// * Bootstrap functionality for initial data setup +/// * Hive-based caching and persistence +/// +/// ## Usage +/// +/// The library is designed to work with the broader Komodo DeFi SDK ecosystem +/// and provides a unified interface for accessing market data across different +/// centralized exchanges and data providers. +/// +/// ## Providers +/// +/// * **Binance**: High-priority provider for real-time market data +/// * **CoinGecko**: Primary fallback provider with comprehensive coverage +/// * **CoinPaprika**: Secondary fallback provider +/// * **Komodo**: Internal price data and calculations +/// +/// The library automatically handles provider selection, fallbacks, and +/// error recovery to ensure reliable market data access. +library komodo_cex_market_data; -export 'src/komodo_cex_market_data_base.dart'; -export 'src/repository_fallback_mixin.dart'; +// Export all generated indices for comprehensive API coverage +export 'src/_core_index.dart'; +export 'src/_internal_exports.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/_core_index.dart b/packages/komodo_cex_market_data/lib/src/_core_index.dart new file mode 100644 index 00000000..bbfb60d9 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/_core_index.dart @@ -0,0 +1,13 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to core market data functionality. +library _core; + +export 'cex_repository.dart'; +export 'hive_adapters.dart'; +export 'id_resolution_strategy.dart'; +export 'komodo_cex_market_data_base.dart'; +export 'repository_fallback_mixin.dart'; +export 'repository_priority_manager.dart'; +export 'repository_selection_strategy.dart'; +export 'sparkline_repository.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/_internal_exports.dart b/packages/komodo_cex_market_data/lib/src/_internal_exports.dart new file mode 100644 index 00000000..8355537b --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/_internal_exports.dart @@ -0,0 +1,12 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private exports combining all market data provider functionality. +library _internal_exports; + +export 'binance/_binance_index.dart'; +export 'bootstrap/_bootstrap_index.dart'; +export 'coingecko/_coingecko_index.dart'; +export 'coinpaprika/_coinpaprika_index.dart'; +export 'common/_common_index.dart'; +export 'komodo/_komodo_index.dart'; +export 'models/_models_index.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/binance/_binance_index.dart b/packages/komodo_cex_market_data/lib/src/binance/_binance_index.dart new file mode 100644 index 00000000..19b438cd --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/_binance_index.dart @@ -0,0 +1,15 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to Binance market data provider functionality. +library _binance; + +export 'data/binance_provider.dart'; +export 'data/binance_provider_interface.dart'; +export 'data/binance_repository.dart'; +export 'models/binance_24hr_ticker.dart'; +export 'models/binance_exchange_info.dart'; +export 'models/binance_exchange_info_reduced.dart'; +export 'models/filter.dart'; +export 'models/rate_limit.dart'; +export 'models/symbol.dart'; +export 'models/symbol_reduced.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/binance/binance.dart b/packages/komodo_cex_market_data/lib/src/binance/binance.dart deleted file mode 100644 index a7aea8fd..00000000 --- a/packages/komodo_cex_market_data/lib/src/binance/binance.dart +++ /dev/null @@ -1,8 +0,0 @@ -export 'data/binance_provider.dart'; -export 'data/binance_provider_interface.dart'; -export 'data/binance_repository.dart'; -export 'models/binance_exchange_info.dart'; -export 'models/filter.dart'; -export 'models/rate_limit.dart'; -export 'models/symbol.dart'; -export 'models/symbol_reduced.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart index c21e63aa..8024524d 100644 --- a/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart @@ -60,10 +60,9 @@ class BinanceRepository implements CexRepository { baseUrl: baseUrl, ); final coinsList = _convertSymbolsToCoins(exchangeInfo); - _cachedFiatCurrencies = - exchangeInfo.symbols - .map((s) => s.quoteAsset.toUpperCase()) - .toSet(); + _cachedFiatCurrencies = exchangeInfo.symbols + .map((s) => s.quoteAsset.toUpperCase()) + .toSet(); return coinsList; } catch (e) { lastException = e is Exception ? e : Exception(e.toString()); @@ -191,8 +190,9 @@ class BinanceRepository implements CexRepository { for (var i = 0; i <= daysDiff; i += 500) { final batchStartDate = startDate.add(Duration(days: i)); - final batchEndDate = - i + 500 > daysDiff ? endDate : startDate.add(Duration(days: i + 500)); + final batchEndDate = i + 500 > daysDiff + ? endDate + : startDate.add(Duration(days: i + 500)); final ohlcData = await getCoinOhlc( assetId, diff --git a/packages/komodo_cex_market_data/lib/src/bootstrap/_bootstrap_index.dart b/packages/komodo_cex_market_data/lib/src/bootstrap/_bootstrap_index.dart new file mode 100644 index 00000000..76b440da --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/bootstrap/_bootstrap_index.dart @@ -0,0 +1,6 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to market data bootstrap functionality. +library _bootstrap; + +export 'market_data_bootstrap.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart b/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart index 0bb764f9..4a52ad45 100644 --- a/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart +++ b/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart @@ -15,27 +15,27 @@ class MarketDataConfig { /// const config = MarketDataConfig( /// enableBinance: true, /// enableCoinGecko: false, + /// enableCoinPaprika: true, /// enableKomodoPrice: true, /// customRepositories: [myCustomRepo], /// selectionStrategy: MyCustomStrategy(), - /// // Optional: inject test providers for better testability - /// // binanceProvider: MyMockBinanceProvider(), - /// // coinGeckoProvider: MyMockCoinGeckoProvider(), - /// // komodoPriceProvider: MyMockKomodoPriceProvider(), /// ); /// ``` const MarketDataConfig({ this.enableBinance = true, this.enableCoinGecko = true, + this.enableCoinPaprika = true, this.enableKomodoPrice = true, this.customRepositories = const [], this.selectionStrategy, this.binanceProvider, this.coinGeckoProvider, + this.coinPaprikaProvider, this.komodoPriceProvider, this.repositoryPriority = const [ RepositoryType.komodoPrice, RepositoryType.binance, + RepositoryType.coinPaprika, RepositoryType.coinGecko, ], }); @@ -46,6 +46,9 @@ class MarketDataConfig { /// Whether to enable CoinGecko repository final bool enableCoinGecko; + /// Whether to enable CoinPaprika repository + final bool enableCoinPaprika; + /// Whether to enable Komodo price repository final bool enableKomodoPrice; @@ -61,6 +64,9 @@ class MarketDataConfig { /// Optional custom CoinGecko provider (uses default if null) final ICoinGeckoProvider? coinGeckoProvider; + /// Optional custom CoinPaprika provider (uses default if null) + final ICoinPaprikaProvider? coinPaprikaProvider; + /// Optional custom Komodo price provider (uses default if null) final IKomodoPriceProvider? komodoPriceProvider; @@ -69,7 +75,25 @@ class MarketDataConfig { } /// Enum representing available repository types -enum RepositoryType { komodoPrice, binance, coinGecko } +/// Enum representing the types of available repositories for market data. +/// +/// - [komodoPrice]: Komodo's own price repository. +/// - [binance]: Binance exchange repository. +/// - [coinGecko]: CoinGecko data provider repository. +/// - [coinPaprika]: CoinPaprika data provider repository. +enum RepositoryType { + /// Komodo's own price repository. + komodoPrice, + + /// Binance exchange repository. + binance, + + /// CoinGecko data provider repository. + coinGecko, + + /// CoinPaprika data provider repository. + coinPaprika, +} /// Bootstrap factory for market data dependencies class MarketDataBootstrap { @@ -101,6 +125,12 @@ class MarketDataBootstrap { ); } + if (config.enableCoinPaprika) { + container.registerSingletonAsync( + () async => config.coinPaprikaProvider ?? CoinPaprikaProvider(), + ); + } + if (config.enableKomodoPrice) { container.registerSingletonAsync( () async => config.komodoPriceProvider ?? KomodoPriceProvider(), @@ -130,6 +160,15 @@ class MarketDataBootstrap { ); } + if (config.enableCoinPaprika) { + container.registerSingletonAsync( + () async => CoinPaprikaRepository( + coinPaprikaProvider: await container.getAsync(), + ), + dependsOn: [ICoinPaprikaProvider], + ); + } + if (config.enableKomodoPrice) { container.registerSingletonAsync( () async => KomodoPriceRepository( @@ -162,18 +201,23 @@ class MarketDataBootstrap { final availableRepos = {}; if (config.enableKomodoPrice) { - availableRepos[RepositoryType.komodoPrice] = - await container.getAsync(); + availableRepos[RepositoryType.komodoPrice] = await container + .getAsync(); } if (config.enableBinance) { - availableRepos[RepositoryType.binance] = - await container.getAsync(); + availableRepos[RepositoryType.binance] = await container + .getAsync(); } if (config.enableCoinGecko) { - availableRepos[RepositoryType.coinGecko] = - await container.getAsync(); + availableRepos[RepositoryType.coinGecko] = await container + .getAsync(); + } + + if (config.enableCoinPaprika) { + availableRepos[RepositoryType.coinPaprika] = await container + .getAsync(); } // Add repositories in configured priority order @@ -202,6 +246,10 @@ class MarketDataBootstrap { dependencies.add(CoinGeckoRepository); } + if (config.enableCoinPaprika) { + dependencies.add(CoinPaprikaRepository); + } + if (config.enableKomodoPrice) { dependencies.add(KomodoPriceRepository); } diff --git a/packages/komodo_cex_market_data/lib/src/cex_repository.dart b/packages/komodo_cex_market_data/lib/src/cex_repository.dart index 2057e19d..2571295e 100644 --- a/packages/komodo_cex_market_data/lib/src/cex_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/cex_repository.dart @@ -1,5 +1,5 @@ import 'package:decimal/decimal.dart'; -import 'package:komodo_cex_market_data/src/models/models.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; import 'package:komodo_cex_market_data/src/repository_selection_strategy.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/_coingecko_index.dart b/packages/komodo_cex_market_data/lib/src/coingecko/_coingecko_index.dart new file mode 100644 index 00000000..e0882677 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/_coingecko_index.dart @@ -0,0 +1,21 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to CoinGecko market data provider functionality. +library _coingecko; + +export 'data/coingecko_cex_provider.dart'; +export 'data/coingecko_repository.dart'; +export 'models/coin_historical_data/code_additions_deletions4_weeks.dart'; +export 'models/coin_historical_data/coin_historical_data.dart'; +export 'models/coin_historical_data/community_data.dart'; +export 'models/coin_historical_data/current_price.dart'; +export 'models/coin_historical_data/developer_data.dart'; +export 'models/coin_historical_data/image.dart'; +export 'models/coin_historical_data/localization.dart'; +export 'models/coin_historical_data/market_cap.dart'; +export 'models/coin_historical_data/market_data.dart'; +export 'models/coin_historical_data/public_interest_stats.dart'; +export 'models/coin_historical_data/total_volume.dart'; +export 'models/coin_market_chart.dart'; +export 'models/coin_market_data.dart'; +export 'models/coingecko_api_plan.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/coingecko.dart b/packages/komodo_cex_market_data/lib/src/coingecko/coingecko.dart deleted file mode 100644 index 8b6d5c0a..00000000 --- a/packages/komodo_cex_market_data/lib/src/coingecko/coingecko.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'data/coingecko_cex_provider.dart'; -export 'data/coingecko_repository.dart'; -export 'models/coin_market_chart.dart'; -export 'models/coin_market_data.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart index c943388a..db448833 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart @@ -3,15 +3,51 @@ import 'dart:convert'; import 'package:decimal/decimal.dart' show Decimal; import 'package:http/http.dart' as http; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/coin_historical_data.dart'; import 'package:logging/logging.dart'; /// Interface for fetching data from CoinGecko API. +/// +/// This interface provides methods to interact with the CoinGecko cryptocurrency +/// data API, including fetching coin lists, market data, charts, and historical data. +/// All methods are asynchronous and return Future objects. abstract class ICoinGeckoProvider { + /// Fetches a list of available coins from CoinGecko. + /// + /// [includePlatforms] - Whether to include platform information for each coin. + /// When true, returns additional blockchain platform details. + /// + /// Returns a list of [CexCoin] objects representing available cryptocurrencies. + /// + /// Throws an exception if the API request fails or the response cannot be parsed. Future> fetchCoinList({bool includePlatforms = false}); + /// Fetches the list of supported vs (versus) currencies. + /// + /// Returns a list of currency codes (e.g., 'usd', 'eur', 'btc') that can be + /// used as the base currency for price comparisons and market data. + /// + /// Throws an exception if the API request fails or the response cannot be parsed. Future> fetchSupportedVsCurrencies(); + /// Fetches market data for coins with various filtering and pagination options. + /// + /// [vsCurrency] - The target currency for price data (default: 'usd'). + /// [ids] - Optional list of coin IDs to filter results. If null, returns all coins. + /// [category] - Optional category filter (e.g., 'decentralized_finance_defi'). + /// [order] - Sort order for results (default: 'market_cap_asc'). + /// Options: 'market_cap_asc', 'market_cap_desc', 'volume_asc', 'volume_desc', + /// 'id_asc', 'id_desc', 'gecko_asc', 'gecko_desc'. + /// [perPage] - Number of results per page (default: 100, max: 250). + /// [page] - Page number for pagination (default: 1). + /// [sparkline] - Whether to include sparkline data (default: false). + /// [priceChangePercentage] - Optional time period for price change percentage. + /// Options: '1h', '24h', '7d', '14d', '30d', '200d', '1y'. + /// [locale] - Language for localization (default: 'en'). + /// [precision] - Optional precision for price data. + /// + /// Returns a list of [CoinMarketData] objects containing market information. + /// + /// Throws an exception if the API request fails or the response cannot be parsed. Future> fetchCoinMarketData({ String vsCurrency = 'usd', List? ids, @@ -25,6 +61,18 @@ abstract class ICoinGeckoProvider { String? precision, }); + /// Fetches historical market chart data for a specific coin. + /// + /// [id] - The CoinGecko ID of the coin (e.g., 'bitcoin'). + /// [vsCurrency] - The target currency for price data (e.g., 'usd'). + /// [fromUnixTimestamp] - Start time as Unix timestamp (seconds since epoch). + /// [toUnixTimestamp] - End time as Unix timestamp (seconds since epoch). + /// [precision] - Optional precision for price data. + /// + /// Returns a [CoinMarketChart] object containing price, market cap, and volume data + /// for the specified time period. + /// + /// Throws an exception if the API request fails or the response cannot be parsed. Future fetchCoinMarketChart({ required String id, required String vsCurrency, @@ -33,6 +81,16 @@ abstract class ICoinGeckoProvider { String? precision, }); + /// Fetches OHLC (Open, High, Low, Close) price data for a specific coin. + /// + /// [id] - The CoinGecko ID of the coin (e.g., 'bitcoin'). + /// [vsCurrency] - The target currency for price data (e.g., 'usd'). + /// [days] - Number of days of data to retrieve. + /// [precision] - Optional precision for price data. + /// + /// Returns a [CoinOhlc] object containing OHLC price data for the specified period. + /// + /// Throws an exception if the API request fails or the response cannot be parsed. Future fetchCoinOhlc( String id, String vsCurrency, @@ -40,6 +98,17 @@ abstract class ICoinGeckoProvider { int? precision, }); + /// Fetches historical market data for a specific coin on a specific date. + /// + /// [id] - The CoinGecko ID of the coin (e.g., 'bitcoin'). + /// [date] - The specific date for which to retrieve historical data. + /// [vsCurrency] - The target currency for price data (default: 'usd'). + /// [localization] - Whether to include localized data (default: false). + /// + /// Returns a [CoinHistoricalData] object containing historical market information + /// for the specified coin and date. + /// + /// Throws an exception if the API request fails or the response cannot be parsed. Future fetchCoinHistoricalMarketData({ required String id, required DateTime date, @@ -47,6 +116,15 @@ abstract class ICoinGeckoProvider { bool localization = false, }); + /// Fetches current prices for multiple coins in specified currencies. + /// + /// [coinGeckoIds] - List of CoinGecko IDs for the coins to fetch prices for. + /// [vsCurrencies] - List of target currencies for price data (default: ['usd']). + /// + /// Returns a map where keys are coin IDs and values are [AssetMarketInformation] + /// objects containing current price data in the specified currencies. + /// + /// Throws an exception if the API request fails or the response cannot be parsed. Future> fetchCoinPrices( List coinGeckoIds, { List vsCurrencies = const ['usd'], @@ -80,19 +158,17 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { final uri = Uri.https(baseUrl, '$apiVersion/coins/list', queryParameters); final response = await http.get(uri); - if (response.statusCode == 200) { - final coins = jsonDecode(response.body) as List; - return coins - .map( - (dynamic element) => - CexCoin.fromJson(element as Map), - ) - .toList(); - } else { - throw Exception( - 'Failed to load coin list: ${response.statusCode} ${response.body}', - ); + if (response.statusCode != 200) { + _throwApiErrorOrException(response, 'coin list fetch'); } + + final coins = jsonDecode(response.body) as List; + return coins + .map( + (dynamic element) => + CexCoin.fromJson(element as Map), + ) + .toList(); } /// Fetches the list of supported vs currencies. @@ -104,14 +180,12 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { ); final response = await http.get(uri); - if (response.statusCode == 200) { - final currencies = jsonDecode(response.body) as List; - return currencies.map((dynamic currency) => currency as String).toList(); - } else { - throw Exception( - 'Failed to load supported vs currencies: ${response.statusCode} ${response.body}', - ); + if (response.statusCode != 200) { + _throwApiErrorOrException(response, 'supported currencies fetch'); } + + final currencies = jsonDecode(response.body) as List; + return currencies.map((dynamic currency) => currency as String).toList(); } /// Fetches the market data for a specific currency. @@ -159,19 +233,16 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { ); return http.get(uri).then((http.Response response) { - if (response.statusCode == 200) { - final coins = jsonDecode(response.body) as List; - return coins - .map( - (dynamic element) => - CoinMarketData.fromJson(element as Map), - ) - .toList(); - } else { - throw Exception( - 'Failed to load coin market data: ${response.statusCode} ${response.body}', - ); + if (response.statusCode != 200) { + _throwApiErrorOrException(response, 'market data fetch'); } + final coins = jsonDecode(response.body) as List; + return coins + .map( + (dynamic element) => + CoinMarketData.fromJson(element as Map), + ) + .toList(); }); } @@ -215,10 +286,9 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { int currentFrom = fromUnixTimestamp; while (currentFrom < toUnixTimestamp) { - final currentTo = - (currentFrom + maxSecondsPerRequest) > toUnixTimestamp - ? toUnixTimestamp - : currentFrom + maxSecondsPerRequest; + final currentTo = (currentFrom + maxSecondsPerRequest) > toUnixTimestamp + ? toUnixTimestamp + : currentFrom + maxSecondsPerRequest; final chart = await _fetchCoinMarketChartSingle( id: id, @@ -257,14 +327,12 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { ); return http.get(uri).then((http.Response response) { - if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - return CoinMarketChart.fromJson(data); - } else { - throw Exception( - 'Failed to load coin market chart: ${response.statusCode} ${response.body}', - ); + if (response.statusCode != 200) { + _throwApiErrorOrException(response, 'market chart fetch', coinId: id); } + + final data = jsonDecode(response.body) as Map; + return CoinMarketChart.fromJson(data); }); } @@ -316,8 +384,10 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { return sortedKeys.map((key) => uniquePoints[key]!).toList(); } - /// Validates that the requested time range is within CoinGecko's historical data limits. - /// Public API users are limited to querying historical data within the past 365 days. + /// Validates that the requested time range is within CoinGecko's historical + /// data limits. + /// Public API users are limited to querying historical data within the + /// past 365 days. void _validateHistoricalDataAccess( int fromUnixTimestamp, int toUnixTimestamp, @@ -325,7 +395,6 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; const maxDaysBack = 365; const secondsPerDay = 86400; - const maxSecondsBack = maxDaysBack * secondsPerDay; // Check if the from date is more than 365 days in the past final daysFromNow = (now - fromUnixTimestamp) / secondsPerDay; @@ -370,14 +439,16 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { ); return http.get(uri).then((http.Response response) { - if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - return CoinHistoricalData.fromJson(data); - } else { - throw Exception( - 'Failed to load coin market chart: ${response.statusCode} ${response.body}', + if (response.statusCode != 200) { + _throwApiErrorOrException( + response, + 'historical market data fetch', + coinId: id, ); } + + final data = jsonDecode(response.body) as Map; + return CoinHistoricalData.fromJson(data); }); } @@ -386,7 +457,7 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { final month = date.month.toString().padLeft(2, '0'); final year = date.year.toString(); - return '$day-$month-$year'; + return '$year-$month-$day'; } /// Fetches prices from CoinGecko API. @@ -420,9 +491,14 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { final res = await http.get(tickersUrl); final body = res.body; + // Check for HTTP errors first + if (res.statusCode != 200) { + _throwApiErrorOrException(res, 'price data fetch'); + } + final json = jsonDecode(body) as Map?; if (json == null) { - throw Exception('Invalid response from CoinGecko API: empty JSON'); + throw Exception('Invalid response from CoinGecko API: empty response'); } final prices = {}; @@ -453,7 +529,8 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { 'Failed to parse price "$priceString" for $coingeckoId as Decimal', ); throw Exception( - 'Invalid price data for $coingeckoId: could not parse "$priceString" as decimal', + 'Invalid price data for $coingeckoId: could not parse ' + '"$priceString" as decimal', ); } @@ -502,14 +579,43 @@ class CoinGeckoCexProvider implements ICoinGeckoProvider { ); return http.get(uri).then((http.Response response) { - if (response.statusCode == 200) { - final data = jsonDecode(response.body) as List; - return CoinOhlc.fromJson(data, source: OhlcSource.coingecko); - } else { - throw Exception( - 'Failed to load coin ohlc data: ${response.statusCode} ${response.body}', - ); + if (response.statusCode != 200) { + _throwApiErrorOrException(response, 'OHLC data fetch', coinId: id); } + + final data = jsonDecode(response.body) as List; + return CoinOhlc.fromJson(data, source: OhlcSource.coingecko); }); } + + /// Throws an [Exception] with a properly formatted error message. + /// + /// This method consolidates the error handling logic that was repeated + /// throughout the class. It parses the API error, logs a warning, and + /// throws an appropriate exception. + /// + /// [response]: The HTTP response containing the error + /// [operation]: The operation that was performed (e.g., "OHLC data fetch") + /// [coinId]: Optional coin identifier for more specific error context + void _throwApiErrorOrException( + http.Response response, + String operation, { + String? coinId, + }) { + final apiError = ApiErrorParser.parseCoinGeckoError( + response.statusCode, + response.body, + ); + + _logger.warning( + ApiErrorParser.createSafeErrorMessage( + operation: operation, + service: 'CoinGecko', + statusCode: response.statusCode, + coinId: coinId, + ), + ); + + throw Exception(apiError.message); + } } diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart index 96135e18..6858dd4e 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart @@ -1,30 +1,32 @@ import 'package:async/async.dart'; import 'package:decimal/decimal.dart'; import 'package:komodo_cex_market_data/src/cex_repository.dart'; -import 'package:komodo_cex_market_data/src/coingecko/coingecko.dart'; +import 'package:komodo_cex_market_data/src/coingecko/_coingecko_index.dart'; import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/coin_historical_data.dart'; import 'package:komodo_cex_market_data/src/id_resolution_strategy.dart'; -import 'package:komodo_cex_market_data/src/models/models.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; import 'package:komodo_cex_market_data/src/repository_selection_strategy.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// The number of seconds in a day. const int secondsInDay = 86400; -/// The maximum number of days that CoinGecko API supports for historical data. -const int maxCoinGeckoDays = 365; - /// A repository class for interacting with the CoinGecko API. class CoinGeckoRepository implements CexRepository { /// Creates a new instance of [CoinGeckoRepository]. CoinGeckoRepository({ required this.coinGeckoProvider, + this.apiPlan = const CoingeckoApiPlan.demo(), bool enableMemoization = true, }) : _idResolutionStrategy = CoinGeckoIdResolutionStrategy(), _enableMemoization = enableMemoization; /// The CoinGecko provider to use for fetching data. final ICoinGeckoProvider coinGeckoProvider; + + /// The API plan defining rate limits and historical data access. + final CoingeckoApiPlan apiPlan; + final IdResolutionStrategy _idResolutionStrategy; final bool _enableMemoization; @@ -59,19 +61,16 @@ class CoinGeckoRepository implements CexRepository { /// Internal method to fetch coin list data from the API. Future> _fetchCoinListInternal() async { final coins = await coinGeckoProvider.fetchCoinList(); - final supportedCurrencies = - await coinGeckoProvider.fetchSupportedVsCurrencies(); + final supportedCurrencies = await coinGeckoProvider + .fetchSupportedVsCurrencies(); - final result = - coins - .map( - (CexCoin e) => - e.copyWith(currencies: supportedCurrencies.toSet()), - ) - .toSet(); + final result = coins + .map((CexCoin e) => e.copyWith(currencies: supportedCurrencies.toSet())) + .toSet(); - _cachedFiatCurrencies = - supportedCurrencies.map((s) => s.toUpperCase()).toSet(); + _cachedFiatCurrencies = supportedCurrencies + .map((s) => s.toUpperCase()) + .toSet(); return result.toList(); } @@ -89,13 +88,21 @@ class CoinGeckoRepository implements CexRepository { if (startAt != null && endAt != null) { final timeDelta = endAt.difference(startAt); days = (timeDelta.inSeconds.toDouble() / secondsInDay).ceil(); + + // Ensure we don't request 0 days + if (days <= 0) { + days = 1; + } } // Use the same ticker resolution as other methods final tradingSymbol = resolveTradingSymbol(assetId); - // If the request is within the CoinGecko limit, make a single request - if (days <= maxCoinGeckoDays) { + // Get the maximum days allowed by the current API plan for daily historical data + final maxDaysAllowed = _getMaxDaysForDailyData(); + + // If the request is within the API plan limit, make a single request + if (days <= maxDaysAllowed) { return coinGeckoProvider.fetchCoinOhlc( tradingSymbol, quoteCurrency.coinGeckoId, @@ -106,18 +113,16 @@ class CoinGeckoRepository implements CexRepository { // If the request exceeds the limit, we need startAt and endAt to split requests if (startAt == null || endAt == null) { throw ArgumentError( - 'startAt and endAt must be provided for requests exceeding $maxCoinGeckoDays days', + 'startAt and endAt must be provided for requests exceeding $maxDaysAllowed days', ); } - // Split the request into multiple sequential requests + // Split the request into multiple sequential requests to stay within free tier limits final allOhlcData = []; var currentStart = startAt; while (currentStart.isBefore(endAt)) { - final currentEnd = currentStart.add( - const Duration(days: maxCoinGeckoDays), - ); + final currentEnd = currentStart.add(Duration(days: maxDaysAllowed)); final batchEndDate = currentEnd.isAfter(endAt) ? endAt : currentEnd; final batchDays = batchEndDate.difference(currentStart).inDays; @@ -131,6 +136,11 @@ class CoinGeckoRepository implements CexRepository { allOhlcData.addAll(batchOhlc.ohlc); currentStart = batchEndDate; + + // Add a small delay between batch requests to avoid rate limiting + if (currentStart.isBefore(endAt)) { + await Future.delayed(const Duration(milliseconds: 100)); + } } return CoinOhlc(ohlc: allOhlcData); @@ -170,14 +180,14 @@ class CoinGeckoRepository implements CexRepository { final currentPriceMap = coinPrice.marketData?.currentPrice?.toJson(); if (currentPriceMap == null) { throw Exception( - 'Market data or current price not found in response: $coinPrice', + 'Market data or current price not found in historical data response', ); } final price = currentPriceMap[mappedFiatId]; if (price == null) { throw Exception( - 'Price data for $mappedFiatId not found in response: $coinPrice', + 'Price data for $mappedFiatId not found in historical data response', ); } return Decimal.parse(price.toString()); @@ -208,13 +218,13 @@ class CoinGeckoRepository implements CexRepository { final result = {}; - // Process in batches to avoid overwhelming the API - for (var i = 0; i <= daysDiff; i += maxCoinGeckoDays) { + // Process in batches to avoid overwhelming the API and stay within API plan limits + final maxDaysAllowed = _getMaxDaysForDailyData(); + for (var i = 0; i <= daysDiff; i += maxDaysAllowed) { final batchStartDate = startDate.add(Duration(days: i)); - final batchEndDate = - i + maxCoinGeckoDays > daysDiff - ? endDate - : startDate.add(Duration(days: i + maxCoinGeckoDays)); + final batchEndDate = i + maxDaysAllowed > daysDiff + ? endDate + : startDate.add(Duration(days: i + maxDaysAllowed)); final ohlcData = await getCoinOhlc( assetId, @@ -238,6 +248,11 @@ class CoinGeckoRepository implements CexRepository { }); result.addAll(batchResult); + + // Add a small delay between batch requests to avoid rate limiting + if (i + maxDaysAllowed <= daysDiff) { + await Future.delayed(const Duration(milliseconds: 100)); + } } return result; @@ -293,4 +308,22 @@ class CoinGeckoRepository implements CexRepository { return false; } } + + /// Gets the maximum number of days allowed for daily historical data requests + /// based on the current API plan. + int _getMaxDaysForDailyData() { + final cutoffDate = apiPlan.getDailyHistoricalDataCutoff(); + if (cutoffDate == null) { + // No cutoff means unlimited access, but we still need to batch requests + // Use a reasonable batch size for API efficiency + return 365; + } + + final now = DateTime.now().toUtc(); + final daysSinceCutoff = now.difference(cutoffDate).inDays; + + // For demo plan (1 year limit), return 365 + // For paid plans with cutoff from 2013/2018, return a reasonable batch size + return daysSinceCutoff > 365 ? 365 : daysSinceCutoff; + } } diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart index 5473d8b5..66b3fa61 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart @@ -13,9 +13,9 @@ class CodeAdditionsDeletions4Weeks extends Equatable { final dynamic deletions; Map toJson() => { - 'additions': additions, - 'deletions': deletions, - }; + 'additions': additions, + 'deletions': deletions, + }; CodeAdditionsDeletions4Weeks copyWith({ dynamic additions, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/coin_historical_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/coin_historical_data.dart index 5ca8db5f..0855e99f 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/coin_historical_data.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/coin_historical_data.dart @@ -62,16 +62,16 @@ class CoinHistoricalData extends Equatable { final PublicInterestStats? publicInterestStats; Map toJson() => { - 'id': id, - 'symbol': symbol, - 'name': name, - 'localization': localization?.toJson(), - 'image': image?.toJson(), - 'market_data': marketData?.toJson(), - 'community_data': communityData?.toJson(), - 'developer_data': developerData?.toJson(), - 'public_interest_stats': publicInterestStats?.toJson(), - }; + 'id': id, + 'symbol': symbol, + 'name': name, + 'localization': localization?.toJson(), + 'image': image?.toJson(), + 'market_data': marketData?.toJson(), + 'community_data': communityData?.toJson(), + 'developer_data': developerData?.toJson(), + 'public_interest_stats': publicInterestStats?.toJson(), + }; CoinHistoricalData copyWith({ String? id, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/community_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/community_data.dart index 314e6074..516b25b4 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/community_data.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/community_data.dart @@ -11,13 +11,13 @@ class CommunityData extends Equatable { }); factory CommunityData.fromJson(Map json) => CommunityData( - facebookLikes: json['facebook_likes'] as dynamic, - twitterFollowers: json['twitter_followers'] as dynamic, - redditAveragePosts48h: json['reddit_average_posts_48h'] as int?, - redditAverageComments48h: json['reddit_average_comments_48h'] as int?, - redditSubscribers: json['reddit_subscribers'] as dynamic, - redditAccountsActive48h: json['reddit_accounts_active_48h'] as dynamic, - ); + facebookLikes: json['facebook_likes'] as dynamic, + twitterFollowers: json['twitter_followers'] as dynamic, + redditAveragePosts48h: json['reddit_average_posts_48h'] as int?, + redditAverageComments48h: json['reddit_average_comments_48h'] as int?, + redditSubscribers: json['reddit_subscribers'] as dynamic, + redditAccountsActive48h: json['reddit_accounts_active_48h'] as dynamic, + ); final dynamic facebookLikes; final dynamic twitterFollowers; final int? redditAveragePosts48h; @@ -26,13 +26,13 @@ class CommunityData extends Equatable { final dynamic redditAccountsActive48h; Map toJson() => { - 'facebook_likes': facebookLikes, - 'twitter_followers': twitterFollowers, - 'reddit_average_posts_48h': redditAveragePosts48h, - 'reddit_average_comments_48h': redditAverageComments48h, - 'reddit_subscribers': redditSubscribers, - 'reddit_accounts_active_48h': redditAccountsActive48h, - }; + 'facebook_likes': facebookLikes, + 'twitter_followers': twitterFollowers, + 'reddit_average_posts_48h': redditAveragePosts48h, + 'reddit_average_comments_48h': redditAverageComments48h, + 'reddit_subscribers': redditSubscribers, + 'reddit_accounts_active_48h': redditAccountsActive48h, + }; CommunityData copyWith({ dynamic facebookLikes, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/current_price.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/current_price.dart index 5a56ecd0..6e42b089 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/current_price.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/current_price.dart @@ -67,69 +67,69 @@ class CurrentPrice extends Equatable { }); factory CurrentPrice.fromJson(Map json) => CurrentPrice( - aed: (json['aed'] as num?)?.toDouble(), - ars: (json['ars'] as num?)?.toDouble(), - aud: (json['aud'] as num?)?.toDouble(), - bch: (json['bch'] as num?)?.toDouble(), - bdt: (json['bdt'] as num?)?.toDouble(), - bhd: (json['bhd'] as num?)?.toDouble(), - bmd: (json['bmd'] as num?)?.toDouble(), - bnb: (json['bnb'] as num?)?.toDouble(), - brl: (json['brl'] as num?)?.toDouble(), - btc: json['btc'] as num?, - cad: (json['cad'] as num?)?.toDouble(), - chf: (json['chf'] as num?)?.toDouble(), - clp: (json['clp'] as num?)?.toDouble(), - cny: (json['cny'] as num?)?.toDouble(), - czk: (json['czk'] as num?)?.toDouble(), - dkk: (json['dkk'] as num?)?.toDouble(), - dot: (json['dot'] as num?)?.toDouble(), - eos: (json['eos'] as num?)?.toDouble(), - eth: (json['eth'] as num?)?.toDouble(), - eur: (json['eur'] as num?)?.toDouble(), - gbp: (json['gbp'] as num?)?.toDouble(), - gel: (json['gel'] as num?)?.toDouble(), - hkd: (json['hkd'] as num?)?.toDouble(), - huf: (json['huf'] as num?)?.toDouble(), - idr: (json['idr'] as num?)?.toDouble(), - ils: (json['ils'] as num?)?.toDouble(), - inr: (json['inr'] as num?)?.toDouble(), - jpy: (json['jpy'] as num?)?.toDouble(), - krw: (json['krw'] as num?)?.toDouble(), - kwd: (json['kwd'] as num?)?.toDouble(), - lkr: (json['lkr'] as num?)?.toDouble(), - ltc: (json['ltc'] as num?)?.toDouble(), - mmk: (json['mmk'] as num?)?.toDouble(), - mxn: (json['mxn'] as num?)?.toDouble(), - myr: (json['myr'] as num?)?.toDouble(), - ngn: (json['ngn'] as num?)?.toDouble(), - nok: (json['nok'] as num?)?.toDouble(), - nzd: (json['nzd'] as num?)?.toDouble(), - php: (json['php'] as num?)?.toDouble(), - pkr: (json['pkr'] as num?)?.toDouble(), - pln: (json['pln'] as num?)?.toDouble(), - rub: (json['rub'] as num?)?.toDouble(), - sar: (json['sar'] as num?)?.toDouble(), - sek: (json['sek'] as num?)?.toDouble(), - sgd: (json['sgd'] as num?)?.toDouble(), - thb: (json['thb'] as num?)?.toDouble(), - tRY: (json['try'] as num?)?.toDouble(), - twd: (json['twd'] as num?)?.toDouble(), - uah: (json['uah'] as num?)?.toDouble(), - usd: (json['usd'] as num?)?.toDouble(), - vef: (json['vef'] as num?)?.toDouble(), - vnd: (json['vnd'] as num?)?.toDouble(), - xag: (json['xag'] as num?)?.toDouble(), - xau: (json['xau'] as num?)?.toDouble(), - xdr: (json['xdr'] as num?)?.toDouble(), - xlm: (json['xlm'] as num?)?.toDouble(), - xrp: (json['xrp'] as num?)?.toDouble(), - yfi: (json['yfi'] as num?)?.toDouble(), - zar: (json['zar'] as num?)?.toDouble(), - bits: (json['bits'] as num?)?.toDouble(), - link: (json['link'] as num?)?.toDouble(), - sats: (json['sats'] as num?)?.toDouble(), - ); + aed: (json['aed'] as num?)?.toDouble(), + ars: (json['ars'] as num?)?.toDouble(), + aud: (json['aud'] as num?)?.toDouble(), + bch: (json['bch'] as num?)?.toDouble(), + bdt: (json['bdt'] as num?)?.toDouble(), + bhd: (json['bhd'] as num?)?.toDouble(), + bmd: (json['bmd'] as num?)?.toDouble(), + bnb: (json['bnb'] as num?)?.toDouble(), + brl: (json['brl'] as num?)?.toDouble(), + btc: json['btc'] as num?, + cad: (json['cad'] as num?)?.toDouble(), + chf: (json['chf'] as num?)?.toDouble(), + clp: (json['clp'] as num?)?.toDouble(), + cny: (json['cny'] as num?)?.toDouble(), + czk: (json['czk'] as num?)?.toDouble(), + dkk: (json['dkk'] as num?)?.toDouble(), + dot: (json['dot'] as num?)?.toDouble(), + eos: (json['eos'] as num?)?.toDouble(), + eth: (json['eth'] as num?)?.toDouble(), + eur: (json['eur'] as num?)?.toDouble(), + gbp: (json['gbp'] as num?)?.toDouble(), + gel: (json['gel'] as num?)?.toDouble(), + hkd: (json['hkd'] as num?)?.toDouble(), + huf: (json['huf'] as num?)?.toDouble(), + idr: (json['idr'] as num?)?.toDouble(), + ils: (json['ils'] as num?)?.toDouble(), + inr: (json['inr'] as num?)?.toDouble(), + jpy: (json['jpy'] as num?)?.toDouble(), + krw: (json['krw'] as num?)?.toDouble(), + kwd: (json['kwd'] as num?)?.toDouble(), + lkr: (json['lkr'] as num?)?.toDouble(), + ltc: (json['ltc'] as num?)?.toDouble(), + mmk: (json['mmk'] as num?)?.toDouble(), + mxn: (json['mxn'] as num?)?.toDouble(), + myr: (json['myr'] as num?)?.toDouble(), + ngn: (json['ngn'] as num?)?.toDouble(), + nok: (json['nok'] as num?)?.toDouble(), + nzd: (json['nzd'] as num?)?.toDouble(), + php: (json['php'] as num?)?.toDouble(), + pkr: (json['pkr'] as num?)?.toDouble(), + pln: (json['pln'] as num?)?.toDouble(), + rub: (json['rub'] as num?)?.toDouble(), + sar: (json['sar'] as num?)?.toDouble(), + sek: (json['sek'] as num?)?.toDouble(), + sgd: (json['sgd'] as num?)?.toDouble(), + thb: (json['thb'] as num?)?.toDouble(), + tRY: (json['try'] as num?)?.toDouble(), + twd: (json['twd'] as num?)?.toDouble(), + uah: (json['uah'] as num?)?.toDouble(), + usd: (json['usd'] as num?)?.toDouble(), + vef: (json['vef'] as num?)?.toDouble(), + vnd: (json['vnd'] as num?)?.toDouble(), + xag: (json['xag'] as num?)?.toDouble(), + xau: (json['xau'] as num?)?.toDouble(), + xdr: (json['xdr'] as num?)?.toDouble(), + xlm: (json['xlm'] as num?)?.toDouble(), + xrp: (json['xrp'] as num?)?.toDouble(), + yfi: (json['yfi'] as num?)?.toDouble(), + zar: (json['zar'] as num?)?.toDouble(), + bits: (json['bits'] as num?)?.toDouble(), + link: (json['link'] as num?)?.toDouble(), + sats: (json['sats'] as num?)?.toDouble(), + ); final num? aed; final num? ars; final num? aud; @@ -194,69 +194,69 @@ class CurrentPrice extends Equatable { final num? sats; Map toJson() => { - 'aed': aed, - 'ars': ars, - 'aud': aud, - 'bch': bch, - 'bdt': bdt, - 'bhd': bhd, - 'bmd': bmd, - 'bnb': bnb, - 'brl': brl, - 'btc': btc, - 'cad': cad, - 'chf': chf, - 'clp': clp, - 'cny': cny, - 'czk': czk, - 'dkk': dkk, - 'dot': dot, - 'eos': eos, - 'eth': eth, - 'eur': eur, - 'gbp': gbp, - 'gel': gel, - 'hkd': hkd, - 'huf': huf, - 'idr': idr, - 'ils': ils, - 'inr': inr, - 'jpy': jpy, - 'krw': krw, - 'kwd': kwd, - 'lkr': lkr, - 'ltc': ltc, - 'mmk': mmk, - 'mxn': mxn, - 'myr': myr, - 'ngn': ngn, - 'nok': nok, - 'nzd': nzd, - 'php': php, - 'pkr': pkr, - 'pln': pln, - 'rub': rub, - 'sar': sar, - 'sek': sek, - 'sgd': sgd, - 'thb': thb, - 'try': tRY, - 'twd': twd, - 'uah': uah, - 'usd': usd, - 'vef': vef, - 'vnd': vnd, - 'xag': xag, - 'xau': xau, - 'xdr': xdr, - 'xlm': xlm, - 'xrp': xrp, - 'yfi': yfi, - 'zar': zar, - 'bits': bits, - 'link': link, - 'sats': sats, - }; + 'aed': aed, + 'ars': ars, + 'aud': aud, + 'bch': bch, + 'bdt': bdt, + 'bhd': bhd, + 'bmd': bmd, + 'bnb': bnb, + 'brl': brl, + 'btc': btc, + 'cad': cad, + 'chf': chf, + 'clp': clp, + 'cny': cny, + 'czk': czk, + 'dkk': dkk, + 'dot': dot, + 'eos': eos, + 'eth': eth, + 'eur': eur, + 'gbp': gbp, + 'gel': gel, + 'hkd': hkd, + 'huf': huf, + 'idr': idr, + 'ils': ils, + 'inr': inr, + 'jpy': jpy, + 'krw': krw, + 'kwd': kwd, + 'lkr': lkr, + 'ltc': ltc, + 'mmk': mmk, + 'mxn': mxn, + 'myr': myr, + 'ngn': ngn, + 'nok': nok, + 'nzd': nzd, + 'php': php, + 'pkr': pkr, + 'pln': pln, + 'rub': rub, + 'sar': sar, + 'sek': sek, + 'sgd': sgd, + 'thb': thb, + 'try': tRY, + 'twd': twd, + 'uah': uah, + 'usd': usd, + 'vef': vef, + 'vnd': vnd, + 'xag': xag, + 'xau': xau, + 'xdr': xdr, + 'xlm': xlm, + 'xrp': xrp, + 'yfi': yfi, + 'zar': zar, + 'bits': bits, + 'link': link, + 'sats': sats, + }; CurrentPrice copyWith({ num? aed, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/developer_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/developer_data.dart index d1903864..d233342e 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/developer_data.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/developer_data.dart @@ -16,22 +16,21 @@ class DeveloperData extends Equatable { }); factory DeveloperData.fromJson(Map json) => DeveloperData( - forks: json['forks'] as dynamic, - stars: json['stars'] as dynamic, - subscribers: json['subscribers'] as dynamic, - totalIssues: json['total_issues'] as dynamic, - closedIssues: json['closed_issues'] as dynamic, - pullRequestsMerged: json['pull_requests_merged'] as dynamic, - pullRequestContributors: json['pull_request_contributors'] as dynamic, - codeAdditionsDeletions4Weeks: - json['code_additions_deletions_4_weeks'] == null - ? null - : CodeAdditionsDeletions4Weeks.fromJson( - json['code_additions_deletions_4_weeks'] - as Map, - ), - commitCount4Weeks: json['commit_count_4_weeks'] as dynamic, - ); + forks: json['forks'] as dynamic, + stars: json['stars'] as dynamic, + subscribers: json['subscribers'] as dynamic, + totalIssues: json['total_issues'] as dynamic, + closedIssues: json['closed_issues'] as dynamic, + pullRequestsMerged: json['pull_requests_merged'] as dynamic, + pullRequestContributors: json['pull_request_contributors'] as dynamic, + codeAdditionsDeletions4Weeks: + json['code_additions_deletions_4_weeks'] == null + ? null + : CodeAdditionsDeletions4Weeks.fromJson( + json['code_additions_deletions_4_weeks'] as Map, + ), + commitCount4Weeks: json['commit_count_4_weeks'] as dynamic, + ); final dynamic forks; final dynamic stars; final dynamic subscribers; @@ -43,17 +42,16 @@ class DeveloperData extends Equatable { final dynamic commitCount4Weeks; Map toJson() => { - 'forks': forks, - 'stars': stars, - 'subscribers': subscribers, - 'total_issues': totalIssues, - 'closed_issues': closedIssues, - 'pull_requests_merged': pullRequestsMerged, - 'pull_request_contributors': pullRequestContributors, - 'code_additions_deletions_4_weeks': - codeAdditionsDeletions4Weeks?.toJson(), - 'commit_count_4_weeks': commitCount4Weeks, - }; + 'forks': forks, + 'stars': stars, + 'subscribers': subscribers, + 'total_issues': totalIssues, + 'closed_issues': closedIssues, + 'pull_requests_merged': pullRequestsMerged, + 'pull_request_contributors': pullRequestContributors, + 'code_additions_deletions_4_weeks': codeAdditionsDeletions4Weeks?.toJson(), + 'commit_count_4_weeks': commitCount4Weeks, + }; DeveloperData copyWith({ dynamic forks, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/image.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/image.dart index 2de5344f..9c83c5a6 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/image.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/image.dart @@ -3,26 +3,15 @@ import 'package:equatable/equatable.dart'; class Image extends Equatable { const Image({this.thumb, this.small}); - factory Image.fromJson(Map json) => Image( - thumb: json['thumb'] as String?, - small: json['small'] as String?, - ); + factory Image.fromJson(Map json) => + Image(thumb: json['thumb'] as String?, small: json['small'] as String?); final String? thumb; final String? small; - Map toJson() => { - 'thumb': thumb, - 'small': small, - }; + Map toJson() => {'thumb': thumb, 'small': small}; - Image copyWith({ - String? thumb, - String? small, - }) { - return Image( - thumb: thumb ?? this.thumb, - small: small ?? this.small, - ); + Image copyWith({String? thumb, String? small}) { + return Image(thumb: thumb ?? this.thumb, small: small ?? this.small); } @override diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/localization.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/localization.dart index d644502f..78a263a8 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/localization.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/localization.dart @@ -39,41 +39,41 @@ class Localization extends Equatable { }); factory Localization.fromJson(Map json) => Localization( - en: json['en'] as String?, - de: json['de'] as String?, - es: json['es'] as String?, - fr: json['fr'] as String?, - it: json['it'] as String?, - pl: json['pl'] as String?, - ro: json['ro'] as String?, - hu: json['hu'] as String?, - nl: json['nl'] as String?, - pt: json['pt'] as String?, - sv: json['sv'] as String?, - vi: json['vi'] as String?, - tr: json['tr'] as String?, - ru: json['ru'] as String?, - ja: json['ja'] as String?, - zh: json['zh'] as String?, - zhTw: json['zh-tw'] as String?, - ko: json['ko'] as String?, - ar: json['ar'] as String?, - th: json['th'] as String?, - id: json['id'] as String?, - cs: json['cs'] as String?, - da: json['da'] as String?, - el: json['el'] as String?, - hi: json['hi'] as String?, - no: json['no'] as String?, - sk: json['sk'] as String?, - uk: json['uk'] as String?, - he: json['he'] as String?, - fi: json['fi'] as String?, - bg: json['bg'] as String?, - hr: json['hr'] as String?, - lt: json['lt'] as String?, - sl: json['sl'] as String?, - ); + en: json['en'] as String?, + de: json['de'] as String?, + es: json['es'] as String?, + fr: json['fr'] as String?, + it: json['it'] as String?, + pl: json['pl'] as String?, + ro: json['ro'] as String?, + hu: json['hu'] as String?, + nl: json['nl'] as String?, + pt: json['pt'] as String?, + sv: json['sv'] as String?, + vi: json['vi'] as String?, + tr: json['tr'] as String?, + ru: json['ru'] as String?, + ja: json['ja'] as String?, + zh: json['zh'] as String?, + zhTw: json['zh-tw'] as String?, + ko: json['ko'] as String?, + ar: json['ar'] as String?, + th: json['th'] as String?, + id: json['id'] as String?, + cs: json['cs'] as String?, + da: json['da'] as String?, + el: json['el'] as String?, + hi: json['hi'] as String?, + no: json['no'] as String?, + sk: json['sk'] as String?, + uk: json['uk'] as String?, + he: json['he'] as String?, + fi: json['fi'] as String?, + bg: json['bg'] as String?, + hr: json['hr'] as String?, + lt: json['lt'] as String?, + sl: json['sl'] as String?, + ); final String? en; final String? de; final String? es; @@ -110,41 +110,41 @@ class Localization extends Equatable { final String? sl; Map toJson() => { - 'en': en, - 'de': de, - 'es': es, - 'fr': fr, - 'it': it, - 'pl': pl, - 'ro': ro, - 'hu': hu, - 'nl': nl, - 'pt': pt, - 'sv': sv, - 'vi': vi, - 'tr': tr, - 'ru': ru, - 'ja': ja, - 'zh': zh, - 'zh-tw': zhTw, - 'ko': ko, - 'ar': ar, - 'th': th, - 'id': id, - 'cs': cs, - 'da': da, - 'el': el, - 'hi': hi, - 'no': no, - 'sk': sk, - 'uk': uk, - 'he': he, - 'fi': fi, - 'bg': bg, - 'hr': hr, - 'lt': lt, - 'sl': sl, - }; + 'en': en, + 'de': de, + 'es': es, + 'fr': fr, + 'it': it, + 'pl': pl, + 'ro': ro, + 'hu': hu, + 'nl': nl, + 'pt': pt, + 'sv': sv, + 'vi': vi, + 'tr': tr, + 'ru': ru, + 'ja': ja, + 'zh': zh, + 'zh-tw': zhTw, + 'ko': ko, + 'ar': ar, + 'th': th, + 'id': id, + 'cs': cs, + 'da': da, + 'el': el, + 'hi': hi, + 'no': no, + 'sk': sk, + 'uk': uk, + 'he': he, + 'fi': fi, + 'bg': bg, + 'hr': hr, + 'lt': lt, + 'sl': sl, + }; Localization copyWith({ String? en, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_cap.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_cap.dart index 5144e635..0a86f336 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_cap.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_cap.dart @@ -67,69 +67,69 @@ class MarketCap extends Equatable { }); factory MarketCap.fromJson(Map json) => MarketCap( - aed: (json['aed'] as num?)?.toDouble(), - ars: (json['ars'] as num?)?.toDouble(), - aud: (json['aud'] as num?)?.toDouble(), - bch: (json['bch'] as num?)?.toDouble(), - bdt: (json['bdt'] as num?)?.toDouble(), - bhd: (json['bhd'] as num?)?.toDouble(), - bmd: (json['bmd'] as num?)?.toDouble(), - bnb: (json['bnb'] as num?)?.toDouble(), - brl: (json['brl'] as num?)?.toDouble(), - btc: json['btc'] as num?, - cad: (json['cad'] as num?)?.toDouble(), - chf: (json['chf'] as num?)?.toDouble(), - clp: (json['clp'] as num?)?.toDouble(), - cny: (json['cny'] as num?)?.toDouble(), - czk: (json['czk'] as num?)?.toDouble(), - dkk: (json['dkk'] as num?)?.toDouble(), - dot: (json['dot'] as num?)?.toDouble(), - eos: (json['eos'] as num?)?.toDouble(), - eth: (json['eth'] as num?)?.toDouble(), - eur: (json['eur'] as num?)?.toDouble(), - gbp: (json['gbp'] as num?)?.toDouble(), - gel: (json['gel'] as num?)?.toDouble(), - hkd: (json['hkd'] as num?)?.toDouble(), - huf: (json['huf'] as num?)?.toDouble(), - idr: json['idr'] as num?, - ils: (json['ils'] as num?)?.toDouble(), - inr: (json['inr'] as num?)?.toDouble(), - jpy: (json['jpy'] as num?)?.toDouble(), - krw: (json['krw'] as num?)?.toDouble(), - kwd: (json['kwd'] as num?)?.toDouble(), - lkr: (json['lkr'] as num?)?.toDouble(), - ltc: (json['ltc'] as num?)?.toDouble(), - mmk: json['mmk'] as num?, - mxn: (json['mxn'] as num?)?.toDouble(), - myr: (json['myr'] as num?)?.toDouble(), - ngn: (json['ngn'] as num?)?.toDouble(), - nok: (json['nok'] as num?)?.toDouble(), - nzd: (json['nzd'] as num?)?.toDouble(), - php: (json['php'] as num?)?.toDouble(), - pkr: (json['pkr'] as num?)?.toDouble(), - pln: (json['pln'] as num?)?.toDouble(), - rub: (json['rub'] as num?)?.toDouble(), - sar: (json['sar'] as num?)?.toDouble(), - sek: (json['sek'] as num?)?.toDouble(), - sgd: (json['sgd'] as num?)?.toDouble(), - thb: (json['thb'] as num?)?.toDouble(), - tRY: (json['try'] as num?)?.toDouble(), - twd: (json['twd'] as num?)?.toDouble(), - uah: (json['uah'] as num?)?.toDouble(), - usd: (json['usd'] as num?)?.toDouble(), - vef: (json['vef'] as num?)?.toDouble(), - vnd: json['vnd'] as num?, - xag: (json['xag'] as num?)?.toDouble(), - xau: (json['xau'] as num?)?.toDouble(), - xdr: (json['xdr'] as num?)?.toDouble(), - xlm: (json['xlm'] as num?)?.toDouble(), - xrp: (json['xrp'] as num?)?.toDouble(), - yfi: (json['yfi'] as num?)?.toDouble(), - zar: (json['zar'] as num?)?.toDouble(), - bits: (json['bits'] as num?)?.toDouble(), - link: (json['link'] as num?)?.toDouble(), - sats: (json['sats'] as num?)?.toDouble(), - ); + aed: (json['aed'] as num?)?.toDouble(), + ars: (json['ars'] as num?)?.toDouble(), + aud: (json['aud'] as num?)?.toDouble(), + bch: (json['bch'] as num?)?.toDouble(), + bdt: (json['bdt'] as num?)?.toDouble(), + bhd: (json['bhd'] as num?)?.toDouble(), + bmd: (json['bmd'] as num?)?.toDouble(), + bnb: (json['bnb'] as num?)?.toDouble(), + brl: (json['brl'] as num?)?.toDouble(), + btc: json['btc'] as num?, + cad: (json['cad'] as num?)?.toDouble(), + chf: (json['chf'] as num?)?.toDouble(), + clp: (json['clp'] as num?)?.toDouble(), + cny: (json['cny'] as num?)?.toDouble(), + czk: (json['czk'] as num?)?.toDouble(), + dkk: (json['dkk'] as num?)?.toDouble(), + dot: (json['dot'] as num?)?.toDouble(), + eos: (json['eos'] as num?)?.toDouble(), + eth: (json['eth'] as num?)?.toDouble(), + eur: (json['eur'] as num?)?.toDouble(), + gbp: (json['gbp'] as num?)?.toDouble(), + gel: (json['gel'] as num?)?.toDouble(), + hkd: (json['hkd'] as num?)?.toDouble(), + huf: (json['huf'] as num?)?.toDouble(), + idr: json['idr'] as num?, + ils: (json['ils'] as num?)?.toDouble(), + inr: (json['inr'] as num?)?.toDouble(), + jpy: (json['jpy'] as num?)?.toDouble(), + krw: (json['krw'] as num?)?.toDouble(), + kwd: (json['kwd'] as num?)?.toDouble(), + lkr: (json['lkr'] as num?)?.toDouble(), + ltc: (json['ltc'] as num?)?.toDouble(), + mmk: json['mmk'] as num?, + mxn: (json['mxn'] as num?)?.toDouble(), + myr: (json['myr'] as num?)?.toDouble(), + ngn: (json['ngn'] as num?)?.toDouble(), + nok: (json['nok'] as num?)?.toDouble(), + nzd: (json['nzd'] as num?)?.toDouble(), + php: (json['php'] as num?)?.toDouble(), + pkr: (json['pkr'] as num?)?.toDouble(), + pln: (json['pln'] as num?)?.toDouble(), + rub: (json['rub'] as num?)?.toDouble(), + sar: (json['sar'] as num?)?.toDouble(), + sek: (json['sek'] as num?)?.toDouble(), + sgd: (json['sgd'] as num?)?.toDouble(), + thb: (json['thb'] as num?)?.toDouble(), + tRY: (json['try'] as num?)?.toDouble(), + twd: (json['twd'] as num?)?.toDouble(), + uah: (json['uah'] as num?)?.toDouble(), + usd: (json['usd'] as num?)?.toDouble(), + vef: (json['vef'] as num?)?.toDouble(), + vnd: json['vnd'] as num?, + xag: (json['xag'] as num?)?.toDouble(), + xau: (json['xau'] as num?)?.toDouble(), + xdr: (json['xdr'] as num?)?.toDouble(), + xlm: (json['xlm'] as num?)?.toDouble(), + xrp: (json['xrp'] as num?)?.toDouble(), + yfi: (json['yfi'] as num?)?.toDouble(), + zar: (json['zar'] as num?)?.toDouble(), + bits: (json['bits'] as num?)?.toDouble(), + link: (json['link'] as num?)?.toDouble(), + sats: (json['sats'] as num?)?.toDouble(), + ); final num? aed; final num? ars; final num? aud; @@ -194,69 +194,69 @@ class MarketCap extends Equatable { final num? sats; Map toJson() => { - 'aed': aed, - 'ars': ars, - 'aud': aud, - 'bch': bch, - 'bdt': bdt, - 'bhd': bhd, - 'bmd': bmd, - 'bnb': bnb, - 'brl': brl, - 'btc': btc, - 'cad': cad, - 'chf': chf, - 'clp': clp, - 'cny': cny, - 'czk': czk, - 'dkk': dkk, - 'dot': dot, - 'eos': eos, - 'eth': eth, - 'eur': eur, - 'gbp': gbp, - 'gel': gel, - 'hkd': hkd, - 'huf': huf, - 'idr': idr, - 'ils': ils, - 'inr': inr, - 'jpy': jpy, - 'krw': krw, - 'kwd': kwd, - 'lkr': lkr, - 'ltc': ltc, - 'mmk': mmk, - 'mxn': mxn, - 'myr': myr, - 'ngn': ngn, - 'nok': nok, - 'nzd': nzd, - 'php': php, - 'pkr': pkr, - 'pln': pln, - 'rub': rub, - 'sar': sar, - 'sek': sek, - 'sgd': sgd, - 'thb': thb, - 'try': tRY, - 'twd': twd, - 'uah': uah, - 'usd': usd, - 'vef': vef, - 'vnd': vnd, - 'xag': xag, - 'xau': xau, - 'xdr': xdr, - 'xlm': xlm, - 'xrp': xrp, - 'yfi': yfi, - 'zar': zar, - 'bits': bits, - 'link': link, - 'sats': sats, - }; + 'aed': aed, + 'ars': ars, + 'aud': aud, + 'bch': bch, + 'bdt': bdt, + 'bhd': bhd, + 'bmd': bmd, + 'bnb': bnb, + 'brl': brl, + 'btc': btc, + 'cad': cad, + 'chf': chf, + 'clp': clp, + 'cny': cny, + 'czk': czk, + 'dkk': dkk, + 'dot': dot, + 'eos': eos, + 'eth': eth, + 'eur': eur, + 'gbp': gbp, + 'gel': gel, + 'hkd': hkd, + 'huf': huf, + 'idr': idr, + 'ils': ils, + 'inr': inr, + 'jpy': jpy, + 'krw': krw, + 'kwd': kwd, + 'lkr': lkr, + 'ltc': ltc, + 'mmk': mmk, + 'mxn': mxn, + 'myr': myr, + 'ngn': ngn, + 'nok': nok, + 'nzd': nzd, + 'php': php, + 'pkr': pkr, + 'pln': pln, + 'rub': rub, + 'sar': sar, + 'sek': sek, + 'sgd': sgd, + 'thb': thb, + 'try': tRY, + 'twd': twd, + 'uah': uah, + 'usd': usd, + 'vef': vef, + 'vnd': vnd, + 'xag': xag, + 'xau': xau, + 'xdr': xdr, + 'xlm': xlm, + 'xrp': xrp, + 'yfi': yfi, + 'zar': zar, + 'bits': bits, + 'link': link, + 'sats': sats, + }; MarketCap copyWith({ num? aed, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_data.dart index aefbf063..c83ef7c6 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_data.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_data.dart @@ -8,29 +8,25 @@ class MarketData extends Equatable { const MarketData({this.currentPrice, this.marketCap, this.totalVolume}); factory MarketData.fromJson(Map json) => MarketData( - currentPrice: json['current_price'] == null - ? null - : CurrentPrice.fromJson( - json['current_price'] as Map, - ), - marketCap: json['market_cap'] == null - ? null - : MarketCap.fromJson(json['market_cap'] as Map), - totalVolume: json['total_volume'] == null - ? null - : TotalVolume.fromJson( - json['total_volume'] as Map, - ), - ); + currentPrice: json['current_price'] == null + ? null + : CurrentPrice.fromJson(json['current_price'] as Map), + marketCap: json['market_cap'] == null + ? null + : MarketCap.fromJson(json['market_cap'] as Map), + totalVolume: json['total_volume'] == null + ? null + : TotalVolume.fromJson(json['total_volume'] as Map), + ); final CurrentPrice? currentPrice; final MarketCap? marketCap; final TotalVolume? totalVolume; Map toJson() => { - 'current_price': currentPrice?.toJson(), - 'market_cap': marketCap?.toJson(), - 'total_volume': totalVolume?.toJson(), - }; + 'current_price': currentPrice?.toJson(), + 'market_cap': marketCap?.toJson(), + 'total_volume': totalVolume?.toJson(), + }; MarketData copyWith({ CurrentPrice? currentPrice, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/public_interest_stats.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/public_interest_stats.dart index 9cde50ab..0d2c4091 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/public_interest_stats.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/public_interest_stats.dart @@ -13,14 +13,11 @@ class PublicInterestStats extends Equatable { final dynamic bingMatches; Map toJson() => { - 'alexa_rank': alexaRank, - 'bing_matches': bingMatches, - }; + 'alexa_rank': alexaRank, + 'bing_matches': bingMatches, + }; - PublicInterestStats copyWith({ - dynamic alexaRank, - dynamic bingMatches, - }) { + PublicInterestStats copyWith({dynamic alexaRank, dynamic bingMatches}) { return PublicInterestStats( alexaRank: alexaRank ?? this.alexaRank, bingMatches: bingMatches ?? this.bingMatches, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/total_volume.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/total_volume.dart index b4520e4d..afb871f0 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/total_volume.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/total_volume.dart @@ -67,69 +67,69 @@ class TotalVolume extends Equatable { }); factory TotalVolume.fromJson(Map json) => TotalVolume( - aed: (json['aed'] as num?)?.toDouble(), - ars: (json['ars'] as num?)?.toDouble(), - aud: (json['aud'] as num?)?.toDouble(), - bch: (json['bch'] as num?)?.toDouble(), - bdt: (json['bdt'] as num?)?.toDouble(), - bhd: (json['bhd'] as num?)?.toDouble(), - bmd: (json['bmd'] as num?)?.toDouble(), - bnb: (json['bnb'] as num?)?.toDouble(), - brl: (json['brl'] as num?)?.toDouble(), - btc: (json['btc'] as num?)?.toDouble(), - cad: (json['cad'] as num?)?.toDouble(), - chf: (json['chf'] as num?)?.toDouble(), - clp: (json['clp'] as num?)?.toDouble(), - cny: (json['cny'] as num?)?.toDouble(), - czk: (json['czk'] as num?)?.toDouble(), - dkk: (json['dkk'] as num?)?.toDouble(), - dot: (json['dot'] as num?)?.toDouble(), - eos: (json['eos'] as num?)?.toDouble(), - eth: (json['eth'] as num?)?.toDouble(), - eur: (json['eur'] as num?)?.toDouble(), - gbp: (json['gbp'] as num?)?.toDouble(), - gel: (json['gel'] as num?)?.toDouble(), - hkd: (json['hkd'] as num?)?.toDouble(), - huf: (json['huf'] as num?)?.toDouble(), - idr: (json['idr'] as num?)?.toDouble(), - ils: (json['ils'] as num?)?.toDouble(), - inr: (json['inr'] as num?)?.toDouble(), - jpy: (json['jpy'] as num?)?.toDouble(), - krw: (json['krw'] as num?)?.toDouble(), - kwd: (json['kwd'] as num?)?.toDouble(), - lkr: (json['lkr'] as num?)?.toDouble(), - ltc: (json['ltc'] as num?)?.toDouble(), - mmk: (json['mmk'] as num?)?.toDouble(), - mxn: (json['mxn'] as num?)?.toDouble(), - myr: (json['myr'] as num?)?.toDouble(), - ngn: (json['ngn'] as num?)?.toDouble(), - nok: (json['nok'] as num?)?.toDouble(), - nzd: (json['nzd'] as num?)?.toDouble(), - php: (json['php'] as num?)?.toDouble(), - pkr: (json['pkr'] as num?)?.toDouble(), - pln: (json['pln'] as num?)?.toDouble(), - rub: (json['rub'] as num?)?.toDouble(), - sar: (json['sar'] as num?)?.toDouble(), - sek: (json['sek'] as num?)?.toDouble(), - sgd: (json['sgd'] as num?)?.toDouble(), - thb: (json['thb'] as num?)?.toDouble(), - tRY: (json['try'] as num?)?.toDouble(), - twd: (json['twd'] as num?)?.toDouble(), - uah: (json['uah'] as num?)?.toDouble(), - usd: (json['usd'] as num?)?.toDouble(), - vef: (json['vef'] as num?)?.toDouble(), - vnd: json['vnd'] as num?, - xag: (json['xag'] as num?)?.toDouble(), - xau: (json['xau'] as num?)?.toDouble(), - xdr: (json['xdr'] as num?)?.toDouble(), - xlm: (json['xlm'] as num?)?.toDouble(), - xrp: (json['xrp'] as num?)?.toDouble(), - yfi: (json['yfi'] as num?)?.toDouble(), - zar: (json['zar'] as num?)?.toDouble(), - bits: (json['bits'] as num?)?.toDouble(), - link: (json['link'] as num?)?.toDouble(), - sats: (json['sats'] as num?)?.toDouble(), - ); + aed: (json['aed'] as num?)?.toDouble(), + ars: (json['ars'] as num?)?.toDouble(), + aud: (json['aud'] as num?)?.toDouble(), + bch: (json['bch'] as num?)?.toDouble(), + bdt: (json['bdt'] as num?)?.toDouble(), + bhd: (json['bhd'] as num?)?.toDouble(), + bmd: (json['bmd'] as num?)?.toDouble(), + bnb: (json['bnb'] as num?)?.toDouble(), + brl: (json['brl'] as num?)?.toDouble(), + btc: (json['btc'] as num?)?.toDouble(), + cad: (json['cad'] as num?)?.toDouble(), + chf: (json['chf'] as num?)?.toDouble(), + clp: (json['clp'] as num?)?.toDouble(), + cny: (json['cny'] as num?)?.toDouble(), + czk: (json['czk'] as num?)?.toDouble(), + dkk: (json['dkk'] as num?)?.toDouble(), + dot: (json['dot'] as num?)?.toDouble(), + eos: (json['eos'] as num?)?.toDouble(), + eth: (json['eth'] as num?)?.toDouble(), + eur: (json['eur'] as num?)?.toDouble(), + gbp: (json['gbp'] as num?)?.toDouble(), + gel: (json['gel'] as num?)?.toDouble(), + hkd: (json['hkd'] as num?)?.toDouble(), + huf: (json['huf'] as num?)?.toDouble(), + idr: (json['idr'] as num?)?.toDouble(), + ils: (json['ils'] as num?)?.toDouble(), + inr: (json['inr'] as num?)?.toDouble(), + jpy: (json['jpy'] as num?)?.toDouble(), + krw: (json['krw'] as num?)?.toDouble(), + kwd: (json['kwd'] as num?)?.toDouble(), + lkr: (json['lkr'] as num?)?.toDouble(), + ltc: (json['ltc'] as num?)?.toDouble(), + mmk: (json['mmk'] as num?)?.toDouble(), + mxn: (json['mxn'] as num?)?.toDouble(), + myr: (json['myr'] as num?)?.toDouble(), + ngn: (json['ngn'] as num?)?.toDouble(), + nok: (json['nok'] as num?)?.toDouble(), + nzd: (json['nzd'] as num?)?.toDouble(), + php: (json['php'] as num?)?.toDouble(), + pkr: (json['pkr'] as num?)?.toDouble(), + pln: (json['pln'] as num?)?.toDouble(), + rub: (json['rub'] as num?)?.toDouble(), + sar: (json['sar'] as num?)?.toDouble(), + sek: (json['sek'] as num?)?.toDouble(), + sgd: (json['sgd'] as num?)?.toDouble(), + thb: (json['thb'] as num?)?.toDouble(), + tRY: (json['try'] as num?)?.toDouble(), + twd: (json['twd'] as num?)?.toDouble(), + uah: (json['uah'] as num?)?.toDouble(), + usd: (json['usd'] as num?)?.toDouble(), + vef: (json['vef'] as num?)?.toDouble(), + vnd: json['vnd'] as num?, + xag: (json['xag'] as num?)?.toDouble(), + xau: (json['xau'] as num?)?.toDouble(), + xdr: (json['xdr'] as num?)?.toDouble(), + xlm: (json['xlm'] as num?)?.toDouble(), + xrp: (json['xrp'] as num?)?.toDouble(), + yfi: (json['yfi'] as num?)?.toDouble(), + zar: (json['zar'] as num?)?.toDouble(), + bits: (json['bits'] as num?)?.toDouble(), + link: (json['link'] as num?)?.toDouble(), + sats: (json['sats'] as num?)?.toDouble(), + ); final num? aed; final num? ars; final num? aud; @@ -194,69 +194,69 @@ class TotalVolume extends Equatable { final num? sats; Map toJson() => { - 'aed': aed, - 'ars': ars, - 'aud': aud, - 'bch': bch, - 'bdt': bdt, - 'bhd': bhd, - 'bmd': bmd, - 'bnb': bnb, - 'brl': brl, - 'btc': btc, - 'cad': cad, - 'chf': chf, - 'clp': clp, - 'cny': cny, - 'czk': czk, - 'dkk': dkk, - 'dot': dot, - 'eos': eos, - 'eth': eth, - 'eur': eur, - 'gbp': gbp, - 'gel': gel, - 'hkd': hkd, - 'huf': huf, - 'idr': idr, - 'ils': ils, - 'inr': inr, - 'jpy': jpy, - 'krw': krw, - 'kwd': kwd, - 'lkr': lkr, - 'ltc': ltc, - 'mmk': mmk, - 'mxn': mxn, - 'myr': myr, - 'ngn': ngn, - 'nok': nok, - 'nzd': nzd, - 'php': php, - 'pkr': pkr, - 'pln': pln, - 'rub': rub, - 'sar': sar, - 'sek': sek, - 'sgd': sgd, - 'thb': thb, - 'try': tRY, - 'twd': twd, - 'uah': uah, - 'usd': usd, - 'vef': vef, - 'vnd': vnd, - 'xag': xag, - 'xau': xau, - 'xdr': xdr, - 'xlm': xlm, - 'xrp': xrp, - 'yfi': yfi, - 'zar': zar, - 'bits': bits, - 'link': link, - 'sats': sats, - }; + 'aed': aed, + 'ars': ars, + 'aud': aud, + 'bch': bch, + 'bdt': bdt, + 'bhd': bhd, + 'bmd': bmd, + 'bnb': bnb, + 'brl': brl, + 'btc': btc, + 'cad': cad, + 'chf': chf, + 'clp': clp, + 'cny': cny, + 'czk': czk, + 'dkk': dkk, + 'dot': dot, + 'eos': eos, + 'eth': eth, + 'eur': eur, + 'gbp': gbp, + 'gel': gel, + 'hkd': hkd, + 'huf': huf, + 'idr': idr, + 'ils': ils, + 'inr': inr, + 'jpy': jpy, + 'krw': krw, + 'kwd': kwd, + 'lkr': lkr, + 'ltc': ltc, + 'mmk': mmk, + 'mxn': mxn, + 'myr': myr, + 'ngn': ngn, + 'nok': nok, + 'nzd': nzd, + 'php': php, + 'pkr': pkr, + 'pln': pln, + 'rub': rub, + 'sar': sar, + 'sek': sek, + 'sgd': sgd, + 'thb': thb, + 'try': tRY, + 'twd': twd, + 'uah': uah, + 'usd': usd, + 'vef': vef, + 'vnd': vnd, + 'xag': xag, + 'xau': xau, + 'xdr': xdr, + 'xlm': xlm, + 'xrp': xrp, + 'yfi': yfi, + 'zar': zar, + 'bits': bits, + 'link': link, + 'sats': sats, + }; TotalVolume copyWith({ num? aed, diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_chart.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_chart.dart index cd4c913c..82809501 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_chart.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_chart.dart @@ -34,8 +34,9 @@ class CoinMarketChart { Map toJson() { return { - 'prices': - prices.map((List e) => e.map((num e) => e).toList()).toList(), + 'prices': prices + .map((List e) => e.map((num e) => e).toList()) + .toList(), 'market_caps': marketCaps .map((List e) => e.map((num e) => e).toList()) .toList(), diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.dart new file mode 100644 index 00000000..b6d2394b --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.dart @@ -0,0 +1,241 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'coingecko_api_plan.freezed.dart'; +part 'coingecko_api_plan.g.dart'; + +/// Represents the different CoinGecko API plans with their specific limitations. +@freezed +abstract class CoingeckoApiPlan with _$CoingeckoApiPlan { + /// Private constructor required for custom methods in freezed classes. + const CoingeckoApiPlan._(); + + /// Demo plan: Free (Beta) + /// - 10,000 calls/month + /// - 30 calls/minute rate limit + /// - 1 year daily/hourly historical data + /// - 1 day 5-minutely historical data + /// - Attribution required + const factory CoingeckoApiPlan.demo({ + @Default(10000) int monthlyCallLimit, + @Default(30) int rateLimitPerMinute, + @Default(true) bool attributionRequired, + }) = _DemoPlan; + + /// Analyst plan: $129/mo ($103.2/mo yearly) + /// - 500,000 calls/month + /// - 500 calls/minute rate limit + /// - Historical data from 2013 (daily), 2018 (hourly) + /// - 1 day 5-minutely historical data + /// - Commercial license + const factory CoingeckoApiPlan.analyst({ + @Default(500000) int monthlyCallLimit, + @Default(500) int rateLimitPerMinute, + @Default(false) bool attributionRequired, + }) = _AnalystPlan; + + /// Lite plan: $499/mo ($399.2/mo yearly) + /// - 2,000,000 calls/month + /// - 500 calls/minute rate limit + /// - Historical data from 2013 (daily), 2018 (hourly) + /// - 1 day 5-minutely historical data + /// - Commercial license + const factory CoingeckoApiPlan.lite({ + @Default(2000000) int monthlyCallLimit, + @Default(500) int rateLimitPerMinute, + @Default(false) bool attributionRequired, + }) = _LitePlan; + + /// Pro plan: $999/mo ($799.2/mo yearly) + /// - 5M-15M calls/month (configurable) + /// - 1,000 calls/minute rate limit + /// - Historical data from 2013 (daily), 2018 (hourly) + /// - 1 day 5-minutely historical data + /// - Commercial license + const factory CoingeckoApiPlan.pro({ + @Default(5000000) int monthlyCallLimit, + @Default(1000) int rateLimitPerMinute, + @Default(false) bool attributionRequired, + }) = _ProPlan; + + /// Enterprise plan: Custom pricing + /// - Custom call limits + /// - Custom rate limits + /// - Historical data from 2013 (daily), 2018 (hourly), 2018 (5-minutely) + /// - 99.9% uptime SLA + /// - Custom license options + const factory CoingeckoApiPlan.enterprise({ + int? monthlyCallLimit, + int? rateLimitPerMinute, + @Default(false) bool attributionRequired, + @Default(true) bool hasSla, + }) = _EnterprisePlan; + + /// Creates a plan from JSON representation. + factory CoingeckoApiPlan.fromJson(Map json) => + _$CoingeckoApiPlanFromJson(json); + + /// Returns true if the plan has unlimited monthly API calls. + bool get hasUnlimitedCalls => monthlyCallLimit == null; + + /// Returns true if the plan has unlimited rate limit per minute. + bool get hasUnlimitedRateLimit => rateLimitPerMinute == null; + + /// Gets the plan name as a string. + String get planName { + return when( + demo: (_, __, ___) => 'Demo', + analyst: (_, __, ___) => 'Analyst', + lite: (_, __, ___) => 'Lite', + pro: (_, __, ___) => 'Pro', + enterprise: (_, __, ___, ____) => 'Enterprise', + ); + } + + /// Returns true if this is the default free tier plan. + bool get isFreeTier => when( + demo: (_, __, ___) => true, + analyst: (_, __, ___) => false, + lite: (_, __, ___) => false, + pro: (_, __, ___) => false, + enterprise: (_, __, ___, ____) => false, + ); + + /// Returns the monthly price in USD, null for custom pricing. + double? get monthlyPriceUsd => when( + demo: (_, __, ___) => 0.0, + analyst: (_, __, ___) => 129.0, + lite: (_, __, ___) => 499.0, + pro: (_, __, ___) => 999.0, + enterprise: (_, __, ___, ____) => null, // Custom pricing + ); + + /// Returns the yearly price in USD (with discount), null for custom pricing. + double? get yearlyPriceUsd => when( + demo: (_, __, ___) => 0.0, + analyst: (_, __, ___) => 1238.4, // $103.2/mo * 12 + lite: (_, __, ___) => 4790.4, // $399.2/mo * 12 + pro: (_, __, ___) => 9590.4, // $799.2/mo * 12 + enterprise: (_, __, ___, ____) => null, // Custom pricing + ); + + /// Gets a human-readable description of the monthly call limit. + String get monthlyCallLimitDescription { + if (hasUnlimitedCalls) { + return 'Custom call credits'; + } + + final limit = monthlyCallLimit!; + if (limit >= 1000000) { + return '${(limit / 1000000).toStringAsFixed(limit % 1000000 == 0 ? 0 : 1)}M calls/month'; + } else if (limit >= 1000) { + return '${(limit / 1000).toStringAsFixed(limit % 1000 == 0 ? 0 : 1)}K calls/month'; + } else { + return '$limit calls/month'; + } + } + + /// Gets a human-readable description of the rate limit. + String get rateLimitDescription { + if (hasUnlimitedRateLimit) { + return 'Custom rate limit'; + } + + return '$rateLimitPerMinute calls/minute'; + } + + /// Gets the daily historical data availability description. + String get dailyHistoricalDataDescription => when( + demo: (_, __, ___) => '1 year of daily historical data', + analyst: (_, __, ___) => 'Daily historical data from 2013', + lite: (_, __, ___) => 'Daily historical data from 2013', + pro: (_, __, ___) => 'Daily historical data from 2013', + enterprise: (_, __, ___, ____) => 'Daily historical data from 2013', + ); + + /// Gets the hourly historical data availability description. + String get hourlyHistoricalDataDescription => when( + demo: (_, __, ___) => '1 year of hourly historical data', + analyst: (_, __, ___) => 'Hourly historical data from 2018', + lite: (_, __, ___) => 'Hourly historical data from 2018', + pro: (_, __, ___) => 'Hourly historical data from 2018', + enterprise: (_, __, ___, ____) => 'Hourly historical data from 2018', + ); + + /// Gets the 5-minutely historical data availability description. + String get fiveMinutelyHistoricalDataDescription => when( + demo: (_, __, ___) => '1 day of 5-minutely historical data', + analyst: (_, __, ___) => '1 day of 5-minutely historical data', + lite: (_, __, ___) => '1 day of 5-minutely historical data', + pro: (_, __, ___) => '1 day of 5-minutely historical data', + enterprise: (_, __, ___, ____) => '5-minutely historical data from 2018', + ); + + /// Gets the daily historical data cutoff date based on the plan's limitations. + /// Returns null for plans with full historical access. + DateTime? getDailyHistoricalDataCutoff() { + return when( + demo: (_, __, ___) => + DateTime.now().toUtc().subtract(const Duration(days: 365)), + analyst: (_, __, ___) => DateTime.utc(2013), + lite: (_, __, ___) => DateTime.utc(2013), + pro: (_, __, ___) => DateTime.utc(2013), + enterprise: (_, __, ___, ____) => DateTime.utc(2013), + ); + } + + /// Gets the hourly historical data cutoff date based on the plan's limitations. + /// Returns null for plans with full historical access. + DateTime? getHourlyHistoricalDataCutoff() { + return when( + demo: (_, __, ___) => + DateTime.now().toUtc().subtract(const Duration(days: 365)), + analyst: (_, __, ___) => DateTime.utc(2018), + lite: (_, __, ___) => DateTime.utc(2018), + pro: (_, __, ___) => DateTime.utc(2018), + enterprise: (_, __, ___, ____) => DateTime.utc(2018), + ); + } + + /// Gets the 5-minutely historical data cutoff date based on the plan's limitations. + /// Returns null for plans with unlimited access. + DateTime? get5MinutelyHistoricalDataCutoff() { + return when( + demo: (_, __, ___) => + DateTime.now().toUtc().subtract(const Duration(days: 1)), + analyst: (_, __, ___) => + DateTime.now().toUtc().subtract(const Duration(days: 1)), + lite: (_, __, ___) => + DateTime.now().toUtc().subtract(const Duration(days: 1)), + pro: (_, __, ___) => + DateTime.now().toUtc().subtract(const Duration(days: 1)), + enterprise: (_, __, ___, ____) => DateTime.utc(2018), + ); + } + + /// Returns true if the plan includes SLA (Service Level Agreement). + bool get hasSlaSupport => when( + demo: (_, __, ___) => false, + analyst: (_, __, ___) => false, + lite: (_, __, ___) => false, + pro: (_, __, ___) => false, + enterprise: (_, __, ___, hasSla) => hasSla, + ); + + /// Validates if the given timestamp is within the plan's daily historical data limits. + bool isWithinDailyHistoricalLimit(DateTime timestamp) { + final cutoff = getDailyHistoricalDataCutoff(); + return cutoff == null || !timestamp.isBefore(cutoff); + } + + /// Validates if the given timestamp is within the plan's hourly historical data limits. + bool isWithinHourlyHistoricalLimit(DateTime timestamp) { + final cutoff = getHourlyHistoricalDataCutoff(); + return cutoff == null || !timestamp.isBefore(cutoff); + } + + /// Validates if the given timestamp is within the plan's 5-minutely historical data limits. + bool isWithin5MinutelyHistoricalLimit(DateTime timestamp) { + final cutoff = get5MinutelyHistoricalDataCutoff(); + return cutoff == null || !timestamp.isBefore(cutoff); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.freezed.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.freezed.dart new file mode 100644 index 00000000..ca9d4cb9 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.freezed.dart @@ -0,0 +1,656 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'coingecko_api_plan.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +CoingeckoApiPlan _$CoingeckoApiPlanFromJson( + Map json +) { + switch (json['runtimeType']) { + case 'demo': + return _DemoPlan.fromJson( + json + ); + case 'analyst': + return _AnalystPlan.fromJson( + json + ); + case 'lite': + return _LitePlan.fromJson( + json + ); + case 'pro': + return _ProPlan.fromJson( + json + ); + case 'enterprise': + return _EnterprisePlan.fromJson( + json + ); + + default: + throw CheckedFromJsonException( + json, + 'runtimeType', + 'CoingeckoApiPlan', + 'Invalid union type "${json['runtimeType']}"!' +); + } + +} + +/// @nodoc +mixin _$CoingeckoApiPlan { + + int? get monthlyCallLimit; int? get rateLimitPerMinute; bool get attributionRequired; +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoingeckoApiPlanCopyWith get copyWith => _$CoingeckoApiPlanCopyWithImpl(this as CoingeckoApiPlan, _$identity); + + /// Serializes this CoingeckoApiPlan to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoingeckoApiPlan&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)&&(identical(other.rateLimitPerMinute, rateLimitPerMinute) || other.rateLimitPerMinute == rateLimitPerMinute)&&(identical(other.attributionRequired, attributionRequired) || other.attributionRequired == attributionRequired)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,monthlyCallLimit,rateLimitPerMinute,attributionRequired); + +@override +String toString() { + return 'CoingeckoApiPlan(monthlyCallLimit: $monthlyCallLimit, rateLimitPerMinute: $rateLimitPerMinute, attributionRequired: $attributionRequired)'; +} + + +} + +/// @nodoc +abstract mixin class $CoingeckoApiPlanCopyWith<$Res> { + factory $CoingeckoApiPlanCopyWith(CoingeckoApiPlan value, $Res Function(CoingeckoApiPlan) _then) = _$CoingeckoApiPlanCopyWithImpl; +@useResult +$Res call({ + int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired +}); + + + + +} +/// @nodoc +class _$CoingeckoApiPlanCopyWithImpl<$Res> + implements $CoingeckoApiPlanCopyWith<$Res> { + _$CoingeckoApiPlanCopyWithImpl(this._self, this._then); + + final CoingeckoApiPlan _self; + final $Res Function(CoingeckoApiPlan) _then; + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? monthlyCallLimit = null,Object? rateLimitPerMinute = null,Object? attributionRequired = null,}) { + return _then(_self.copyWith( +monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit! : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int,rateLimitPerMinute: null == rateLimitPerMinute ? _self.rateLimitPerMinute! : rateLimitPerMinute // ignore: cast_nullable_to_non_nullable +as int,attributionRequired: null == attributionRequired ? _self.attributionRequired : attributionRequired // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoingeckoApiPlan]. +extension CoingeckoApiPlanPatterns on CoingeckoApiPlan { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( _DemoPlan value)? demo,TResult Function( _AnalystPlan value)? analyst,TResult Function( _LitePlan value)? lite,TResult Function( _ProPlan value)? pro,TResult Function( _EnterprisePlan value)? enterprise,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _DemoPlan() when demo != null: +return demo(_that);case _AnalystPlan() when analyst != null: +return analyst(_that);case _LitePlan() when lite != null: +return lite(_that);case _ProPlan() when pro != null: +return pro(_that);case _EnterprisePlan() when enterprise != null: +return enterprise(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( _DemoPlan value) demo,required TResult Function( _AnalystPlan value) analyst,required TResult Function( _LitePlan value) lite,required TResult Function( _ProPlan value) pro,required TResult Function( _EnterprisePlan value) enterprise,}){ +final _that = this; +switch (_that) { +case _DemoPlan(): +return demo(_that);case _AnalystPlan(): +return analyst(_that);case _LitePlan(): +return lite(_that);case _ProPlan(): +return pro(_that);case _EnterprisePlan(): +return enterprise(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( _DemoPlan value)? demo,TResult? Function( _AnalystPlan value)? analyst,TResult? Function( _LitePlan value)? lite,TResult? Function( _ProPlan value)? pro,TResult? Function( _EnterprisePlan value)? enterprise,}){ +final _that = this; +switch (_that) { +case _DemoPlan() when demo != null: +return demo(_that);case _AnalystPlan() when analyst != null: +return analyst(_that);case _LitePlan() when lite != null: +return lite(_that);case _ProPlan() when pro != null: +return pro(_that);case _EnterprisePlan() when enterprise != null: +return enterprise(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? demo,TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? analyst,TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? lite,TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? pro,TResult Function( int? monthlyCallLimit, int? rateLimitPerMinute, bool attributionRequired, bool hasSla)? enterprise,required TResult orElse(),}) {final _that = this; +switch (_that) { +case _DemoPlan() when demo != null: +return demo(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _AnalystPlan() when analyst != null: +return analyst(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _LitePlan() when lite != null: +return lite(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _ProPlan() when pro != null: +return pro(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _EnterprisePlan() when enterprise != null: +return enterprise(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired,_that.hasSla);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired) demo,required TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired) analyst,required TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired) lite,required TResult Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired) pro,required TResult Function( int? monthlyCallLimit, int? rateLimitPerMinute, bool attributionRequired, bool hasSla) enterprise,}) {final _that = this; +switch (_that) { +case _DemoPlan(): +return demo(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _AnalystPlan(): +return analyst(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _LitePlan(): +return lite(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _ProPlan(): +return pro(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _EnterprisePlan(): +return enterprise(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired,_that.hasSla);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? demo,TResult? Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? analyst,TResult? Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? lite,TResult? Function( int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired)? pro,TResult? Function( int? monthlyCallLimit, int? rateLimitPerMinute, bool attributionRequired, bool hasSla)? enterprise,}) {final _that = this; +switch (_that) { +case _DemoPlan() when demo != null: +return demo(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _AnalystPlan() when analyst != null: +return analyst(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _LitePlan() when lite != null: +return lite(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _ProPlan() when pro != null: +return pro(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired);case _EnterprisePlan() when enterprise != null: +return enterprise(_that.monthlyCallLimit,_that.rateLimitPerMinute,_that.attributionRequired,_that.hasSla);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _DemoPlan extends CoingeckoApiPlan { + const _DemoPlan({this.monthlyCallLimit = 10000, this.rateLimitPerMinute = 30, this.attributionRequired = true, final String? $type}): $type = $type ?? 'demo',super._(); + factory _DemoPlan.fromJson(Map json) => _$DemoPlanFromJson(json); + +@override@JsonKey() final int monthlyCallLimit; +@override@JsonKey() final int rateLimitPerMinute; +@override@JsonKey() final bool attributionRequired; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$DemoPlanCopyWith<_DemoPlan> get copyWith => __$DemoPlanCopyWithImpl<_DemoPlan>(this, _$identity); + +@override +Map toJson() { + return _$DemoPlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DemoPlan&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)&&(identical(other.rateLimitPerMinute, rateLimitPerMinute) || other.rateLimitPerMinute == rateLimitPerMinute)&&(identical(other.attributionRequired, attributionRequired) || other.attributionRequired == attributionRequired)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,monthlyCallLimit,rateLimitPerMinute,attributionRequired); + +@override +String toString() { + return 'CoingeckoApiPlan.demo(monthlyCallLimit: $monthlyCallLimit, rateLimitPerMinute: $rateLimitPerMinute, attributionRequired: $attributionRequired)'; +} + + +} + +/// @nodoc +abstract mixin class _$DemoPlanCopyWith<$Res> implements $CoingeckoApiPlanCopyWith<$Res> { + factory _$DemoPlanCopyWith(_DemoPlan value, $Res Function(_DemoPlan) _then) = __$DemoPlanCopyWithImpl; +@override @useResult +$Res call({ + int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired +}); + + + + +} +/// @nodoc +class __$DemoPlanCopyWithImpl<$Res> + implements _$DemoPlanCopyWith<$Res> { + __$DemoPlanCopyWithImpl(this._self, this._then); + + final _DemoPlan _self; + final $Res Function(_DemoPlan) _then; + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? monthlyCallLimit = null,Object? rateLimitPerMinute = null,Object? attributionRequired = null,}) { + return _then(_DemoPlan( +monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int,rateLimitPerMinute: null == rateLimitPerMinute ? _self.rateLimitPerMinute : rateLimitPerMinute // ignore: cast_nullable_to_non_nullable +as int,attributionRequired: null == attributionRequired ? _self.attributionRequired : attributionRequired // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _AnalystPlan extends CoingeckoApiPlan { + const _AnalystPlan({this.monthlyCallLimit = 500000, this.rateLimitPerMinute = 500, this.attributionRequired = false, final String? $type}): $type = $type ?? 'analyst',super._(); + factory _AnalystPlan.fromJson(Map json) => _$AnalystPlanFromJson(json); + +@override@JsonKey() final int monthlyCallLimit; +@override@JsonKey() final int rateLimitPerMinute; +@override@JsonKey() final bool attributionRequired; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AnalystPlanCopyWith<_AnalystPlan> get copyWith => __$AnalystPlanCopyWithImpl<_AnalystPlan>(this, _$identity); + +@override +Map toJson() { + return _$AnalystPlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AnalystPlan&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)&&(identical(other.rateLimitPerMinute, rateLimitPerMinute) || other.rateLimitPerMinute == rateLimitPerMinute)&&(identical(other.attributionRequired, attributionRequired) || other.attributionRequired == attributionRequired)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,monthlyCallLimit,rateLimitPerMinute,attributionRequired); + +@override +String toString() { + return 'CoingeckoApiPlan.analyst(monthlyCallLimit: $monthlyCallLimit, rateLimitPerMinute: $rateLimitPerMinute, attributionRequired: $attributionRequired)'; +} + + +} + +/// @nodoc +abstract mixin class _$AnalystPlanCopyWith<$Res> implements $CoingeckoApiPlanCopyWith<$Res> { + factory _$AnalystPlanCopyWith(_AnalystPlan value, $Res Function(_AnalystPlan) _then) = __$AnalystPlanCopyWithImpl; +@override @useResult +$Res call({ + int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired +}); + + + + +} +/// @nodoc +class __$AnalystPlanCopyWithImpl<$Res> + implements _$AnalystPlanCopyWith<$Res> { + __$AnalystPlanCopyWithImpl(this._self, this._then); + + final _AnalystPlan _self; + final $Res Function(_AnalystPlan) _then; + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? monthlyCallLimit = null,Object? rateLimitPerMinute = null,Object? attributionRequired = null,}) { + return _then(_AnalystPlan( +monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int,rateLimitPerMinute: null == rateLimitPerMinute ? _self.rateLimitPerMinute : rateLimitPerMinute // ignore: cast_nullable_to_non_nullable +as int,attributionRequired: null == attributionRequired ? _self.attributionRequired : attributionRequired // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _LitePlan extends CoingeckoApiPlan { + const _LitePlan({this.monthlyCallLimit = 2000000, this.rateLimitPerMinute = 500, this.attributionRequired = false, final String? $type}): $type = $type ?? 'lite',super._(); + factory _LitePlan.fromJson(Map json) => _$LitePlanFromJson(json); + +@override@JsonKey() final int monthlyCallLimit; +@override@JsonKey() final int rateLimitPerMinute; +@override@JsonKey() final bool attributionRequired; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LitePlanCopyWith<_LitePlan> get copyWith => __$LitePlanCopyWithImpl<_LitePlan>(this, _$identity); + +@override +Map toJson() { + return _$LitePlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LitePlan&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)&&(identical(other.rateLimitPerMinute, rateLimitPerMinute) || other.rateLimitPerMinute == rateLimitPerMinute)&&(identical(other.attributionRequired, attributionRequired) || other.attributionRequired == attributionRequired)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,monthlyCallLimit,rateLimitPerMinute,attributionRequired); + +@override +String toString() { + return 'CoingeckoApiPlan.lite(monthlyCallLimit: $monthlyCallLimit, rateLimitPerMinute: $rateLimitPerMinute, attributionRequired: $attributionRequired)'; +} + + +} + +/// @nodoc +abstract mixin class _$LitePlanCopyWith<$Res> implements $CoingeckoApiPlanCopyWith<$Res> { + factory _$LitePlanCopyWith(_LitePlan value, $Res Function(_LitePlan) _then) = __$LitePlanCopyWithImpl; +@override @useResult +$Res call({ + int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired +}); + + + + +} +/// @nodoc +class __$LitePlanCopyWithImpl<$Res> + implements _$LitePlanCopyWith<$Res> { + __$LitePlanCopyWithImpl(this._self, this._then); + + final _LitePlan _self; + final $Res Function(_LitePlan) _then; + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? monthlyCallLimit = null,Object? rateLimitPerMinute = null,Object? attributionRequired = null,}) { + return _then(_LitePlan( +monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int,rateLimitPerMinute: null == rateLimitPerMinute ? _self.rateLimitPerMinute : rateLimitPerMinute // ignore: cast_nullable_to_non_nullable +as int,attributionRequired: null == attributionRequired ? _self.attributionRequired : attributionRequired // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _ProPlan extends CoingeckoApiPlan { + const _ProPlan({this.monthlyCallLimit = 5000000, this.rateLimitPerMinute = 1000, this.attributionRequired = false, final String? $type}): $type = $type ?? 'pro',super._(); + factory _ProPlan.fromJson(Map json) => _$ProPlanFromJson(json); + +@override@JsonKey() final int monthlyCallLimit; +@override@JsonKey() final int rateLimitPerMinute; +@override@JsonKey() final bool attributionRequired; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ProPlanCopyWith<_ProPlan> get copyWith => __$ProPlanCopyWithImpl<_ProPlan>(this, _$identity); + +@override +Map toJson() { + return _$ProPlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProPlan&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)&&(identical(other.rateLimitPerMinute, rateLimitPerMinute) || other.rateLimitPerMinute == rateLimitPerMinute)&&(identical(other.attributionRequired, attributionRequired) || other.attributionRequired == attributionRequired)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,monthlyCallLimit,rateLimitPerMinute,attributionRequired); + +@override +String toString() { + return 'CoingeckoApiPlan.pro(monthlyCallLimit: $monthlyCallLimit, rateLimitPerMinute: $rateLimitPerMinute, attributionRequired: $attributionRequired)'; +} + + +} + +/// @nodoc +abstract mixin class _$ProPlanCopyWith<$Res> implements $CoingeckoApiPlanCopyWith<$Res> { + factory _$ProPlanCopyWith(_ProPlan value, $Res Function(_ProPlan) _then) = __$ProPlanCopyWithImpl; +@override @useResult +$Res call({ + int monthlyCallLimit, int rateLimitPerMinute, bool attributionRequired +}); + + + + +} +/// @nodoc +class __$ProPlanCopyWithImpl<$Res> + implements _$ProPlanCopyWith<$Res> { + __$ProPlanCopyWithImpl(this._self, this._then); + + final _ProPlan _self; + final $Res Function(_ProPlan) _then; + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? monthlyCallLimit = null,Object? rateLimitPerMinute = null,Object? attributionRequired = null,}) { + return _then(_ProPlan( +monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int,rateLimitPerMinute: null == rateLimitPerMinute ? _self.rateLimitPerMinute : rateLimitPerMinute // ignore: cast_nullable_to_non_nullable +as int,attributionRequired: null == attributionRequired ? _self.attributionRequired : attributionRequired // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _EnterprisePlan extends CoingeckoApiPlan { + const _EnterprisePlan({this.monthlyCallLimit, this.rateLimitPerMinute, this.attributionRequired = false, this.hasSla = true, final String? $type}): $type = $type ?? 'enterprise',super._(); + factory _EnterprisePlan.fromJson(Map json) => _$EnterprisePlanFromJson(json); + +@override final int? monthlyCallLimit; +@override final int? rateLimitPerMinute; +@override@JsonKey() final bool attributionRequired; +@JsonKey() final bool hasSla; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EnterprisePlanCopyWith<_EnterprisePlan> get copyWith => __$EnterprisePlanCopyWithImpl<_EnterprisePlan>(this, _$identity); + +@override +Map toJson() { + return _$EnterprisePlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EnterprisePlan&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)&&(identical(other.rateLimitPerMinute, rateLimitPerMinute) || other.rateLimitPerMinute == rateLimitPerMinute)&&(identical(other.attributionRequired, attributionRequired) || other.attributionRequired == attributionRequired)&&(identical(other.hasSla, hasSla) || other.hasSla == hasSla)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,monthlyCallLimit,rateLimitPerMinute,attributionRequired,hasSla); + +@override +String toString() { + return 'CoingeckoApiPlan.enterprise(monthlyCallLimit: $monthlyCallLimit, rateLimitPerMinute: $rateLimitPerMinute, attributionRequired: $attributionRequired, hasSla: $hasSla)'; +} + + +} + +/// @nodoc +abstract mixin class _$EnterprisePlanCopyWith<$Res> implements $CoingeckoApiPlanCopyWith<$Res> { + factory _$EnterprisePlanCopyWith(_EnterprisePlan value, $Res Function(_EnterprisePlan) _then) = __$EnterprisePlanCopyWithImpl; +@override @useResult +$Res call({ + int? monthlyCallLimit, int? rateLimitPerMinute, bool attributionRequired, bool hasSla +}); + + + + +} +/// @nodoc +class __$EnterprisePlanCopyWithImpl<$Res> + implements _$EnterprisePlanCopyWith<$Res> { + __$EnterprisePlanCopyWithImpl(this._self, this._then); + + final _EnterprisePlan _self; + final $Res Function(_EnterprisePlan) _then; + +/// Create a copy of CoingeckoApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? monthlyCallLimit = freezed,Object? rateLimitPerMinute = freezed,Object? attributionRequired = null,Object? hasSla = null,}) { + return _then(_EnterprisePlan( +monthlyCallLimit: freezed == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int?,rateLimitPerMinute: freezed == rateLimitPerMinute ? _self.rateLimitPerMinute : rateLimitPerMinute // ignore: cast_nullable_to_non_nullable +as int?,attributionRequired: null == attributionRequired ? _self.attributionRequired : attributionRequired // ignore: cast_nullable_to_non_nullable +as bool,hasSla: null == hasSla ? _self.hasSla : hasSla // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.g.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.g.dart new file mode 100644 index 00000000..633361f5 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coingecko_api_plan.g.dart @@ -0,0 +1,82 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'coingecko_api_plan.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_DemoPlan _$DemoPlanFromJson(Map json) => _DemoPlan( + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 10000, + rateLimitPerMinute: (json['rateLimitPerMinute'] as num?)?.toInt() ?? 30, + attributionRequired: json['attributionRequired'] as bool? ?? true, + $type: json['runtimeType'] as String?, +); + +Map _$DemoPlanToJson(_DemoPlan instance) => { + 'monthlyCallLimit': instance.monthlyCallLimit, + 'rateLimitPerMinute': instance.rateLimitPerMinute, + 'attributionRequired': instance.attributionRequired, + 'runtimeType': instance.$type, +}; + +_AnalystPlan _$AnalystPlanFromJson(Map json) => _AnalystPlan( + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 500000, + rateLimitPerMinute: (json['rateLimitPerMinute'] as num?)?.toInt() ?? 500, + attributionRequired: json['attributionRequired'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); + +Map _$AnalystPlanToJson(_AnalystPlan instance) => + { + 'monthlyCallLimit': instance.monthlyCallLimit, + 'rateLimitPerMinute': instance.rateLimitPerMinute, + 'attributionRequired': instance.attributionRequired, + 'runtimeType': instance.$type, + }; + +_LitePlan _$LitePlanFromJson(Map json) => _LitePlan( + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 2000000, + rateLimitPerMinute: (json['rateLimitPerMinute'] as num?)?.toInt() ?? 500, + attributionRequired: json['attributionRequired'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); + +Map _$LitePlanToJson(_LitePlan instance) => { + 'monthlyCallLimit': instance.monthlyCallLimit, + 'rateLimitPerMinute': instance.rateLimitPerMinute, + 'attributionRequired': instance.attributionRequired, + 'runtimeType': instance.$type, +}; + +_ProPlan _$ProPlanFromJson(Map json) => _ProPlan( + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 5000000, + rateLimitPerMinute: (json['rateLimitPerMinute'] as num?)?.toInt() ?? 1000, + attributionRequired: json['attributionRequired'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); + +Map _$ProPlanToJson(_ProPlan instance) => { + 'monthlyCallLimit': instance.monthlyCallLimit, + 'rateLimitPerMinute': instance.rateLimitPerMinute, + 'attributionRequired': instance.attributionRequired, + 'runtimeType': instance.$type, +}; + +_EnterprisePlan _$EnterprisePlanFromJson(Map json) => + _EnterprisePlan( + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt(), + rateLimitPerMinute: (json['rateLimitPerMinute'] as num?)?.toInt(), + attributionRequired: json['attributionRequired'] as bool? ?? false, + hasSla: json['hasSla'] as bool? ?? true, + $type: json['runtimeType'] as String?, + ); + +Map _$EnterprisePlanToJson(_EnterprisePlan instance) => + { + 'monthlyCallLimit': instance.monthlyCallLimit, + 'rateLimitPerMinute': instance.rateLimitPerMinute, + 'attributionRequired': instance.attributionRequired, + 'hasSla': instance.hasSla, + 'runtimeType': instance.$type, + }; diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/_coinpaprika_index.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/_coinpaprika_index.dart new file mode 100644 index 00000000..c80e2572 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/_coinpaprika_index.dart @@ -0,0 +1,13 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to CoinPaprika market data provider functionality. +library _coinpaprika; + +export 'constants/coinpaprika_intervals.dart'; +export 'data/coinpaprika_cex_provider.dart'; +export 'data/coinpaprika_repository.dart'; +export 'models/coinpaprika_api_plan.dart'; +export 'models/coinpaprika_coin.dart'; +export 'models/coinpaprika_market.dart'; +export 'models/coinpaprika_ticker.dart'; +export 'models/coinpaprika_ticker_quote.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/constants/coinpaprika_intervals.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/constants/coinpaprika_intervals.dart new file mode 100644 index 00000000..ae5dc4f8 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/constants/coinpaprika_intervals.dart @@ -0,0 +1,83 @@ +/// CoinPaprika API interval constants organized by time categories. +/// +/// This file defines interval constants that build on each other, organized into: +/// - 5-minute intervals: 5m, 10m, 15m, 30m, 45m +/// - Hourly intervals: 1h, 2h, 3h, 6h, 12h +/// - Daily intervals: 24h, 1d, 7d, 14d, 30d, 90d, 365d + +/// 5-minute based intervals +class CoinPaprikaFiveMinuteIntervals { + static const String fiveMinutes = '5m'; + static const String tenMinutes = '10m'; + static const String fifteenMinutes = '15m'; + static const String thirtyMinutes = '30m'; + static const String fortyFiveMinutes = '45m'; + + /// All 5-minute based intervals + static const List all = [ + fiveMinutes, + tenMinutes, + fifteenMinutes, + thirtyMinutes, + fortyFiveMinutes, + ]; +} + +/// Hourly based intervals +class CoinPaprikaHourlyIntervals { + static const String oneHour = '1h'; + static const String twoHours = '2h'; + static const String threeHours = '3h'; + static const String sixHours = '6h'; + static const String twelveHours = '12h'; + + /// All hourly intervals + static const List all = [ + oneHour, + twoHours, + threeHours, + sixHours, + twelveHours, + ]; +} + +/// Daily based intervals +class CoinPaprikaDailyIntervals { + static const String twentyFourHours = '24h'; + static const String oneDay = '1d'; + static const String sevenDays = '7d'; + static const String fourteenDays = '14d'; + static const String thirtyDays = '30d'; + static const String ninetyDays = '90d'; + static const String threeHundredSixtyFiveDays = '365d'; + + /// All daily intervals + static const List all = [ + twentyFourHours, + oneDay, + sevenDays, + fourteenDays, + thirtyDays, + ninetyDays, + threeHundredSixtyFiveDays, + ]; +} + +/// Combined interval constants and defaults for different API plans +class CoinPaprikaIntervals { + /// All available intervals across all plans + static const List allIntervals = [ + ...CoinPaprikaDailyIntervals.all, + ...CoinPaprikaHourlyIntervals.all, + ...CoinPaprikaFiveMinuteIntervals.all, + ]; + + /// Free plan available intervals (daily only) + static const List freeDefaults = CoinPaprikaDailyIntervals.all; + + /// Starter, Pro, Business, Ultimate, and Enterprise plans intervals + static const List premiumDefaults = allIntervals; + + /// Default interval for API requests + static const String defaultInterval = CoinPaprikaDailyIntervals.twentyFourHours; +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart new file mode 100644 index 00000000..68d3264e --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart @@ -0,0 +1,587 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:decimal/decimal.dart'; +import 'package:http/http.dart' as http; +import 'package:komodo_cex_market_data/src/coinpaprika/constants/coinpaprika_intervals.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_api_plan.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_coin.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_market.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_ticker.dart'; +import 'package:komodo_cex_market_data/src/common/api_error_parser.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:logging/logging.dart'; + +/// Configuration constants for CoinPaprika API. +class CoinPaprikaConfig { + /// Base URL for CoinPaprika API + static const String baseUrl = 'https://api.coinpaprika.com/v1'; + + /// Request timeout duration + static const Duration timeout = Duration(seconds: 30); + + /// Maximum number of retries for failed requests + static const int maxRetries = 3; +} + +/// Abstract interface for CoinPaprika data provider. +abstract class ICoinPaprikaProvider { + /// Fetches the list of all available coins. + Future> fetchCoinList(); + + /// List of supported quote currencies for CoinPaprika integration. + /// This is a hard-coded superset of currencies supported by the SDK. + List get supportedQuoteCurrencies; + + /// Fetches historical OHLC data for a specific coin. + /// + /// [coinId]: The CoinPaprika coin identifier (e.g., "btc-bitcoin") + /// [startDate]: Start date for historical data + /// [endDate]: End date for historical data (optional) + /// [quote]: Quote currency (default: USD) + /// [interval]: Data interval (default: 24h) + Future> fetchHistoricalOhlc({ + required String coinId, + required DateTime startDate, + DateTime? endDate, + QuoteCurrency quote, + String interval = CoinPaprikaIntervals.defaultInterval, + }); + + /// Fetches current market data for a specific coin. + /// + /// [coinId]: The CoinPaprika coin identifier (e.g., "btc-bitcoin") + /// [quotes]: List of quote currencies + Future> fetchCoinMarkets({ + required String coinId, + List quotes, + }); + + /// Fetches ticker data for a specific coin. + /// + /// [coinId]: The CoinPaprika coin identifier (e.g., "btc-bitcoin") + /// [quotes]: List of quote currencies + Future fetchCoinTicker({ + required String coinId, + List quotes, + }); + + /// The current API plan with its limitations and features. + CoinPaprikaApiPlan get apiPlan; +} + +/// Implementation of CoinPaprika data provider using HTTP requests. +class CoinPaprikaProvider implements ICoinPaprikaProvider { + /// Creates a new CoinPaprika provider instance. + CoinPaprikaProvider({ + String? apiKey, + this.baseUrl = 'api.coinpaprika.com', + this.apiVersion = '/v1', + this.apiPlan = const CoinPaprikaApiPlan.free(), + http.Client? httpClient, + }) : _apiKey = apiKey, + _httpClient = httpClient ?? http.Client(); + + /// The base URL for the CoinPaprika API. + final String baseUrl; + + /// The API version for the CoinPaprika API. + final String apiVersion; + + /// The current API plan with its limitations and features. + @override + final CoinPaprikaApiPlan apiPlan; + + /// The API key for the CoinPaprika API. + final String? _apiKey; + + /// The HTTP client for the CoinPaprika API. + final http.Client _httpClient; + + static final Logger _logger = Logger('CoinPaprikaProvider'); + + @override + List get supportedQuoteCurrencies => + List.unmodifiable(_supported); + + @override + Future> fetchCoinList() async { + _logger.info('Fetching coin list from CoinPaprika'); + + final uri = Uri.https(baseUrl, '$apiVersion/coins'); + try { + final response = await _httpClient + .get(uri, headers: _createRequestHeaderMap()) + .timeout(CoinPaprikaConfig.timeout); + + if (response.statusCode != 200) { + _throwApiErrorOrException(response, 'ALL', 'coin list fetch'); + } + + final coins = jsonDecode(response.body) as List; + final result = coins + .cast>() + .map(CoinPaprikaCoin.fromJson) + .toList(); + + _logger.info( + 'Successfully fetched ${result.length} coins from CoinPaprika', + ); + return result; + } on TimeoutException catch (e) { + _logger.severe('Timeout while fetching coin list from CoinPaprika', e); + throw TimeoutException( + 'Request to fetch coin list timed out after ${CoinPaprikaConfig.timeout.inSeconds} seconds', + CoinPaprikaConfig.timeout, + ); + } catch (e, st) { + _logger.severe('Failed to fetch coin list from CoinPaprika', e, st); + rethrow; + } + } + + /// Fetches historical OHLC data using the correct CoinPaprika API format. + /// + /// [coinId]: The CoinPaprika coin identifier (e.g., "btc-bitcoin") + /// [startDate]: Start date for historical data + /// [endDate]: End date for historical data (optional) + /// [quote]: Quote currency (default: USD) + /// [interval]: Data interval (default: 24h) + @override + Future> fetchHistoricalOhlc({ + required String coinId, + required DateTime startDate, + DateTime? endDate, + QuoteCurrency quote = FiatCurrency.usd, + String interval = '1d', + }) async { + _validateInterval(interval); + _validateHistoricalDataRequest(startDate: startDate, endDate: endDate); + + // Convert interval format: '24h' -> '1d' for CoinPaprika API compatibility + final apiInterval = _convertIntervalForApi(interval); + + // Map quote currency: stablecoins -> underlying fiat (e.g., USDT -> USD) + final mappedQuote = _mapQuoteCurrencyForApi(quote); + + _logger.fine( + 'Fetching OHLC data for $coinId from ${_formatDateForApi(startDate)} ' + '${endDate != null ? 'to ${_formatDateForApi(endDate)}' : ''} ' + '(interval: $apiInterval, quote: ${mappedQuote.coinPaprikaId})', + ); + + // CoinPaprika API only requires start date and interval for historical data + final queryParams = { + 'start': _formatDateForApi(startDate), + 'interval': apiInterval, + 'quote': mappedQuote.coinPaprikaId.toLowerCase(), + 'limit': '5000', + if (endDate != null) 'end': _formatDateForApi(endDate), + }; + + final uri = Uri.https( + baseUrl, + '$apiVersion/tickers/$coinId/historical', + queryParams, + ); + try { + final response = await _httpClient + .get(uri, headers: _createRequestHeaderMap()) + .timeout(CoinPaprikaConfig.timeout); + + if (response.statusCode != 200) { + _throwApiErrorOrException(response, coinId, 'OHLC data fetch'); + } + + final ticksData = jsonDecode(response.body) as List; + final result = ticksData + .cast>() + .map(_parseTicksToOhlc) + .toList(); + + _logger.info( + 'Successfully fetched ${result.length} OHLC data points for $coinId', + ); + return result; + } on TimeoutException catch (e) { + _logger.severe( + 'Timeout while fetching OHLC data for $coinId from CoinPaprika', + e, + ); + throw TimeoutException( + 'Request to fetch OHLC data for $coinId timed out after ${CoinPaprikaConfig.timeout.inSeconds} seconds', + CoinPaprikaConfig.timeout, + ); + } catch (e, st) { + _logger.severe( + 'Failed to fetch OHLC data for $coinId from CoinPaprika', + e, + st, + ); + rethrow; + } + } + + @override + Future> fetchCoinMarkets({ + required String coinId, + List quotes = const [FiatCurrency.usd], + }) async { + // Map quote currencies: stablecoins -> underlying fiat + final mappedQuotes = quotes.map(_mapQuoteCurrencyForApi).toList(); + final quotesParam = mappedQuotes + .map((q) => q.coinPaprikaId.toUpperCase()) + .join(','); + _logger.info('Fetching market data for $coinId with quotes: $quotesParam'); + + final queryParams = {'quotes': quotesParam}; + + final uri = Uri.https( + baseUrl, + '$apiVersion/coins/$coinId/markets', + queryParams, + ); + try { + final response = await _httpClient + .get(uri, headers: _createRequestHeaderMap()) + .timeout(CoinPaprikaConfig.timeout); + + if (response.statusCode != 200) { + _throwApiErrorOrException(response, coinId, 'market data fetch'); + } + + final markets = jsonDecode(response.body) as List; + final result = markets + .cast>() + .map(CoinPaprikaMarket.fromJson) + .toList(); + + _logger.info('Successfully fetched ${result.length} markets for $coinId'); + return result; + } on TimeoutException catch (e) { + _logger.severe( + 'Timeout while fetching market data for $coinId from CoinPaprika', + e, + ); + throw TimeoutException( + 'Request to fetch market data for $coinId timed out after ${CoinPaprikaConfig.timeout.inSeconds} seconds', + CoinPaprikaConfig.timeout, + ); + } catch (e, st) { + _logger.severe( + 'Failed to fetch market data for $coinId from CoinPaprika', + e, + st, + ); + rethrow; + } + } + + /// Fetches ticker data for a specific coin. + /// + /// [coinId]: The CoinPaprika coin identifier (e.g., "btc-bitcoin") + /// [quotes]: List of quote currencies + @override + Future fetchCoinTicker({ + required String coinId, + List quotes = const [FiatCurrency.usd], + }) async { + // Map quote currencies: stablecoins -> underlying fiat + final mappedQuotes = quotes.map(_mapQuoteCurrencyForApi).toList(); + final quotesParam = mappedQuotes + .map((q) => q.coinPaprikaId.toUpperCase()) + .join(','); + _logger.info('Fetching ticker data for $coinId with quotes: $quotesParam'); + + final queryParams = {'quotes': quotesParam}; + + final uri = Uri.https(baseUrl, '$apiVersion/tickers/$coinId', queryParams); + try { + final response = await _httpClient + .get(uri, headers: _createRequestHeaderMap()) + .timeout(CoinPaprikaConfig.timeout); + + if (response.statusCode != 200) { + _throwApiErrorOrException(response, coinId, 'ticker data fetch'); + } + final ticker = jsonDecode(response.body) as Map; + final result = CoinPaprikaTicker.fromJson(ticker); + _logger.info('Successfully fetched ticker data for $coinId'); + return result; + } on TimeoutException catch (e) { + _logger.severe( + 'Timeout while fetching ticker data for $coinId from CoinPaprika', + e, + ); + throw TimeoutException( + 'Request to fetch ticker data for $coinId timed out after ' + '${CoinPaprikaConfig.timeout.inSeconds} seconds', + CoinPaprikaConfig.timeout, + ); + } catch (e, st) { + _logger.severe( + 'Failed to fetch ticker data for $coinId from CoinPaprika', + e, + st, + ); + rethrow; + } + } + + /// Validates if the requested date range is within the current API plan's + /// limitations. + /// + /// Different API plans have different limitations: + /// - Historical data access cutoff dates + /// - Available intervals + /// + /// Throws [ArgumentError] if the request is invalid. + void _validateHistoricalDataRequest({ + DateTime? startDate, + DateTime? endDate, + String interval = '1d', + }) { + // Validate interval support + if (!apiPlan.isIntervalSupported(interval)) { + throw ArgumentError( + 'Interval "$interval" is not supported in the ${apiPlan.planName} plan. ' + 'Supported intervals: ${apiPlan.availableIntervals.join(", ")}', + ); + } + + // If the plan has unlimited OHLC history, no date validation needed + if (apiPlan.hasUnlimitedOhlcHistory) return; + + // If no dates provided, assume recent data request (valid) + if (startDate == null && endDate == null) return; + + final cutoffDate = apiPlan.getHistoricalDataCutoff(); + if (cutoffDate == null) return; // No limitations + + // Check if any requested date is before the cutoff + if (startDate != null && startDate.isBefore(cutoffDate)) { + throw ArgumentError( + 'Historical data before ${_formatDateForApi(cutoffDate)} is not ' + 'available in the ${apiPlan.planName} plan. ' + 'Requested start date: ${_formatDateForApi(startDate)}. ' + '${apiPlan.ohlcLimitDescription}. Please request more recent data or ' + 'upgrade your plan.', + ); + } + + if (endDate != null && endDate.isBefore(cutoffDate)) { + throw ArgumentError( + 'Historical data before ${_formatDateForApi(cutoffDate)} is not ' + 'available in the ${apiPlan.planName} plan. ' + 'Requested end date: ${_formatDateForApi(endDate)}. ' + '${apiPlan.ohlcLimitDescription}. Please request more recent data or ' + 'upgrade your plan.', + ); + } + } + + /// Validates if the requested interval is supported by the current API plan. + /// + /// Throws [ArgumentError] if the interval is not supported. + void _validateInterval(String interval) { + if (!apiPlan.isIntervalSupported(interval)) { + throw ArgumentError( + 'Interval "$interval" is not supported in the ${apiPlan.planName} ' + 'plan. Supported intervals: ${apiPlan.availableIntervals.join(", ")}. ' + 'Please use a supported interval or upgrade to a higher plan.', + ); + } + } + + /// Creates HTTP headers for CoinPaprika API requests. + /// + /// If an API key is provided, it will be included as a Bearer token + /// in the Authorization header. + /// + /// If [contentType] is provided, it will be included as a Content-Type header + Map? _createRequestHeaderMap({String? contentType}) { + Map? headers; + if (contentType != null) { + headers = { + 'Content-Type': contentType, + 'Accept': contentType, + }; + } + + if (_apiKey != null && _apiKey.isNotEmpty) { + headers ??= {}; + headers['Authorization'] = 'Bearer $_apiKey'; + } + + return headers; + } + + /// Formats a DateTime to the format expected by CoinPaprika API. + /// + /// CoinPaprika expects dates in YYYY-MM-DD format, not ISO 8601 with time. + /// This prevents the "Invalid value provided for the date parameter" error. + String _formatDateForApi(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + } + + /// Maps quote currencies for CoinPaprika API compatibility. + /// + /// CoinPaprika treats stablecoins as their underlying fiat currencies. + /// For example, USDT should be mapped to USD before sending API requests. + /// + /// This ensures consistency with the repository layer and proper API behavior. + QuoteCurrency _mapQuoteCurrencyForApi(QuoteCurrency quote) { + return quote.when( + fiat: (_, __) => quote, + stablecoin: (_, __, underlyingFiat) => underlyingFiat, + crypto: (_, __) => quote, // Use as-is for crypto + commodity: (_, __) => quote, // Use as-is for commodity + ); + } + + /// Converts internal interval format to CoinPaprika API format. + /// + /// Internal format -> API format: + /// - 24h -> 1d (daily data) + /// - 1d -> 1d (daily data) + /// - 1h -> 1h (hourly data) + /// - 5m -> 5m (5-minute data) + /// - 15m -> 15m (15-minute data) + /// - 30m -> 30m (30-minute data) + String _convertIntervalForApi(String interval) { + switch (interval) { + case CoinPaprikaDailyIntervals.twentyFourHours: + case CoinPaprikaDailyIntervals.oneDay: + return CoinPaprikaDailyIntervals.oneDay; + case CoinPaprikaHourlyIntervals.oneHour: + case CoinPaprikaFiveMinuteIntervals.fiveMinutes: + case CoinPaprikaFiveMinuteIntervals.fifteenMinutes: + case CoinPaprikaFiveMinuteIntervals.thirtyMinutes: + return interval; + default: + // For any unrecognized interval, pass it through as-is + return interval; + } + } + + /// Hard-coded list of supported quote currencies for CoinPaprika. + /// Includes: BTC, ETH, USD, EUR, PLN, KRW, GBP, CAD, JPY, RUB, TRY, NZD, AUD, + /// CHF, UAH, HKD, SGD, NGN, PHP, MXN, BRL, THB, CLP, CNY, CZK, DKK, HUF, IDR, + /// ILS, INR, MYR, NOK, PKR, SEK, TWD, ZAR, VND, BOB, COP, PEN, ARS, ISK + /// + /// FiatCurrency/other constants are used where available; otherwise ad-hoc + /// instances created. + static final List _supported = [ + Cryptocurrency.btc, + Cryptocurrency.eth, + FiatCurrency.usd, + FiatCurrency.eur, + FiatCurrency.pln, + FiatCurrency.krw, + FiatCurrency.gbp, + FiatCurrency.cad, + FiatCurrency.jpy, + FiatCurrency.rub, + FiatCurrency.tryLira, + FiatCurrency.nzd, + FiatCurrency.aud, + FiatCurrency.chf, + FiatCurrency.uah, + FiatCurrency.hkd, + FiatCurrency.sgd, + FiatCurrency.ngn, + FiatCurrency.php, + FiatCurrency.mxn, + FiatCurrency.brl, + FiatCurrency.thb, + FiatCurrency.clp, + FiatCurrency.cny, + FiatCurrency.czk, + FiatCurrency.dkk, + FiatCurrency.huf, + FiatCurrency.idr, + FiatCurrency.ils, + FiatCurrency.inr, + FiatCurrency.myr, + FiatCurrency.nok, + FiatCurrency.pkr, + FiatCurrency.sek, + FiatCurrency.twd, + FiatCurrency.zar, + FiatCurrency.vnd, + FiatCurrency.bob, + FiatCurrency.cop, + FiatCurrency.pen, + FiatCurrency.ars, + FiatCurrency.isk, + ]; + + /// Throws an [ArgumentError] if the response is an API error, + /// otherwise throws an [Exception]. + /// + /// [coinId]: The CoinPaprika coin identifier (e.g., "btc-bitcoin") + /// [operation]: The operation that was performed (e.g., "OHLC data fetch") + void _throwApiErrorOrException( + http.Response response, + String coinId, + String operation, + ) { + final apiError = ApiErrorParser.parseCoinPaprikaError( + response.statusCode, + response.body, + ); + + // Check if this is a CoinPaprika API limitation error + if (response.statusCode == 400 && + response.body.contains('is not allowed in this plan')) { + final cutoffDate = apiPlan.getHistoricalDataCutoff(); + + _logger.warning( + 'CoinPaprika API historical data limitation encountered for $coinId. ' + '${apiPlan.planName} plan limitation: ${apiPlan.ohlcLimitDescription} ' + '${cutoffDate != null ? '(since ${_formatDateForApi(cutoffDate)})' : ''}', + ); + + throw ArgumentError( + 'Historical data not available: ${apiPlan.ohlcLimitDescription}. ' + 'Please request more recent data or upgrade your plan.', + ); + } + + _logger.warning( + ApiErrorParser.createSafeErrorMessage( + operation: operation, + service: 'CoinPaprika', + statusCode: response.statusCode, + coinId: coinId, + ), + ); + + throw Exception(apiError.message); + } + + /// Helper method to parse CoinPaprika historical ticks JSON into Ohlc format. + /// Since ticks only have a single price point, we use it for open, high, low, and close. + Ohlc _parseTicksToOhlc(Map json) { + final timestampStr = json['timestamp'] as String; + final timestamp = DateTime.parse(timestampStr).millisecondsSinceEpoch; + final price = Decimal.parse(json['price'].toString()); + + return Ohlc.coinpaprika( + timeOpen: timestamp, + timeClose: timestamp, + open: price, + high: price, + low: price, + close: price, + volume: json['volume_24h'] != null + ? Decimal.parse(json['volume_24h'].toString()) + : null, + marketCap: json['market_cap'] != null + ? Decimal.parse(json['market_cap'].toString()) + : null, + ); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_repository.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_repository.dart new file mode 100644 index 00000000..18e9a62c --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_repository.dart @@ -0,0 +1,514 @@ +import 'package:async/async.dart'; +import 'package:decimal/decimal.dart'; +import 'package:komodo_cex_market_data/src/cex_repository.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/data/coinpaprika_cex_provider.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_api_plan.dart'; +import 'package:komodo_cex_market_data/src/id_resolution_strategy.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:komodo_cex_market_data/src/repository_selection_strategy.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; + +/// A repository class for interacting with the CoinPaprika API. +/// +/// ## API Plan Limitations +/// CoinPaprika has different API plans with varying limitations: +/// - Free: 1 day of OHLC historical data +/// - Starter: 1 month of OHLC historical data +/// - Pro: 3 months of OHLC historical data +/// - Business: 1 year of OHLC historical data +/// - Ultimate/Enterprise: No OHLC historical data limitations +/// +/// The provider layer handles validation and will throw appropriate errors +/// for requests that exceed the current plan's limitations. +/// For older historical data or higher limits, upgrade to a higher plan. +class CoinPaprikaRepository implements CexRepository { + /// Creates a new instance of [CoinPaprikaRepository]. + CoinPaprikaRepository({ + required this.coinPaprikaProvider, + bool enableMemoization = true, + }) : _idResolutionStrategy = CoinPaprikaIdResolutionStrategy(), + _enableMemoization = enableMemoization; + + /// The CoinPaprika provider to use for fetching data. + final ICoinPaprikaProvider coinPaprikaProvider; + final IdResolutionStrategy _idResolutionStrategy; + final bool _enableMemoization; + + final AsyncMemoizer> _coinListMemoizer = AsyncMemoizer(); + Set? _cachedQuoteCurrencies; + + static final Logger _logger = Logger('CoinPaprikaRepository'); + + @override + Future> getCoinList() async { + if (_enableMemoization) { + return _coinListMemoizer.runOnce(_fetchCoinListInternal); + } else { + return _fetchCoinListInternal(); + } + } + + /// Internal method to fetch coin list data from the API. + Future> _fetchCoinListInternal() async { + try { + final coins = await coinPaprikaProvider.fetchCoinList(); + + // Build supported quote currencies from provider (hard-coded in provider) + final supportedCurrencies = coinPaprikaProvider.supportedQuoteCurrencies + .map((q) => q.coinPaprikaId) + .toSet(); + + final result = coins + .where((coin) => coin.isActive) // Only include active coins + .map( + (coin) => CexCoin( + id: coin.id, + symbol: coin.symbol, + name: coin.name, + currencies: supportedCurrencies, + ), + ) + .toList(); + + _cachedQuoteCurrencies = supportedCurrencies + .map((s) => s.toUpperCase()) + .toSet(); + + _logger.info( + 'Successfully processed ${result.length} active coins from CoinPaprika', + ); + return result; + } catch (e, stackTrace) { + _logger.severe( + 'Failed to fetch coin list from CoinPaprika', + e, + stackTrace, + ); + rethrow; + } + } + + @override + Future getCoinOhlc( + AssetId assetId, + QuoteCurrency quoteCurrency, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) async { + try { + final tradingSymbol = resolveTradingSymbol(assetId); + final apiPlan = coinPaprikaProvider.apiPlan; + + // Determine the actual fetchable date range (using UTC) + var effectiveStartAt = startAt; + final effectiveEndAt = endAt ?? DateTime.now().toUtc(); + + // If no startAt provided, use default based on plan limit or + // reasonable default + if (effectiveStartAt == null) { + if (apiPlan.hasUnlimitedOhlcHistory) { + effectiveStartAt = effectiveEndAt.subtract( + const Duration(days: 365), + ); // Default 1 year for unlimited + } else { + effectiveStartAt = effectiveEndAt.subtract( + apiPlan.ohlcHistoricalDataLimit!, + ); + } + } + + // Check if the requested range is entirely before the cutoff date + // (only for limited plans) + if (!apiPlan.hasUnlimitedOhlcHistory) { + final cutoffDate = apiPlan.getHistoricalDataCutoff(); + if (cutoffDate != null) { + // If both start and end dates are before cutoff, return empty data + if (effectiveEndAt.isBefore(cutoffDate)) { + _logger.info( + 'Requested date range is entirely before cutoff ' + '(${_formatDateForApi(cutoffDate)}) - no data available for ' + '${apiPlan.planName} plan', + ); + return const CoinOhlc(ohlc: []); + } + + // If start date is before cutoff, adjust it to cutoff date + if (effectiveStartAt.isBefore(cutoffDate)) { + _logger.info( + 'Adjusting start date from ${_formatDateForApi(effectiveStartAt)} ' + 'to cutoff date ${_formatDateForApi(cutoffDate)} for ' + '${apiPlan.planName} plan', + ); + effectiveStartAt = cutoffDate; + } + } + } + + // If effective start is after end, return empty data + if (effectiveStartAt.isAfter(effectiveEndAt)) { + _logger.info( + 'Effective startAt is after endAt - no data available for requested ' + 'period due to ${apiPlan.planName} plan limitations', + ); + return const CoinOhlc(ohlc: []); + } + + // Determine reasonable batch size based on API plan + final batchDuration = _getBatchDuration(apiPlan); + final totalDuration = effectiveEndAt.difference(effectiveStartAt); + + // If the request is within the batch size, make a single request + if (totalDuration <= batchDuration) { + return _fetchSingleOhlcRequest( + tradingSymbol, + quoteCurrency, + effectiveStartAt, + effectiveEndAt, + ); + } + + // Split the request into multiple sequential requests + return _fetchMultipleOhlcRequests( + tradingSymbol, + quoteCurrency, + effectiveStartAt, + effectiveEndAt, + batchDuration, + ); + } catch (e, stackTrace) { + _logger.severe( + 'Failed to fetch OHLC data for ${assetId.id}', + e, + stackTrace, + ); + rethrow; + } + } + + /// Fetches OHLC data in a single request (within plan limits). + Future _fetchSingleOhlcRequest( + String tradingSymbol, + QuoteCurrency quoteCurrency, + DateTime? startAt, + DateTime? endAt, + ) async { + final apiPlan = coinPaprikaProvider.apiPlan; + + final ohlcData = await coinPaprikaProvider.fetchHistoricalOhlc( + coinId: tradingSymbol, + startDate: + startAt ?? + DateTime.now().toUtc().subtract( + apiPlan.hasUnlimitedOhlcHistory + ? const Duration(days: 1) + // "!" is safe because we checked hasUnlimitedOhlcHistory above + : apiPlan.ohlcHistoricalDataLimit!, + ), + endDate: endAt, + quote: quoteCurrency, + ); + + return CoinOhlc(ohlc: ohlcData); + } + + /// Fetches OHLC data in multiple requests to handle API plan limitations. + Future _fetchMultipleOhlcRequests( + String tradingSymbol, + QuoteCurrency quoteCurrency, + DateTime startAt, + DateTime endAt, + Duration batchDuration, + ) async { + final apiPlan = coinPaprikaProvider.apiPlan; + final allOhlcData = []; + var currentStart = startAt; + + _logger.info( + 'Splitting OHLC request for $tradingSymbol into multiple batches ' + '(${apiPlan.planName} plan: ${apiPlan.ohlcLimitDescription}) ' + 'with ${batchDuration.inDays}-day batches ' + 'from ${startAt.toIso8601String()} to ${endAt.toIso8601String()}', + ); + + while (currentStart.isBefore(endAt)) { + final batchEnd = currentStart.add(batchDuration); + final actualEnd = batchEnd.isAfter(endAt) ? endAt : batchEnd; + + final actualBatchDuration = actualEnd.difference(currentStart); + // Smallest interval is 5 minutes, so we can't have a resolution of + // smaller than a minute + if (actualBatchDuration.inMinutes <= 0) break; + + // Ensure batch duration doesn't exceed our chosen batch size + if (actualBatchDuration > batchDuration) { + throw ArgumentError( + 'Batch duration ${actualBatchDuration.inDays} days ' + 'exceeds safe limit of ${batchDuration.inDays} days', + ); + } + + _logger.fine( + 'Fetching batch: ${currentStart.toIso8601String()} to ' + '${actualEnd.toIso8601String()} ' + '(duration: ${actualBatchDuration.inDays} days)', + ); + + try { + final batchOhlc = await _fetchSingleOhlcRequest( + tradingSymbol, + quoteCurrency, + currentStart, + actualEnd, + ); + + allOhlcData.addAll(batchOhlc.ohlc); + _logger.fine('Batch successful: ${batchOhlc.ohlc.length} data points'); + } catch (e) { + _logger.warning( + 'Failed to fetch batch ${currentStart.toIso8601String()} to ' + '${actualEnd.toIso8601String()}: $e', + ); + // Continue with next batch instead of failing completely + } + + currentStart = actualEnd; + + // Add delay between requests to avoid rate limiting + if (currentStart.isBefore(endAt)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + } + + _logger.info( + 'Successfully fetched ${allOhlcData.length} OHLC data points across ' + 'multiple batches for $tradingSymbol', + ); + return CoinOhlc(ohlc: allOhlcData); + } + + /// Determines reasonable batch size based on API plan. + Duration _getBatchDuration(CoinPaprikaApiPlan apiPlan) { + if (apiPlan.hasUnlimitedOhlcHistory) { + return const Duration(days: 90); // Reasonable default for unlimited plans + } else { + final planLimit = apiPlan.ohlcHistoricalDataLimit!; + // Use smaller batches: max 90 days or plan limit minus buffer, + // whichever is smaller + const bufferDuration = Duration(minutes: 1); + final maxPlanBatch = planLimit - bufferDuration; + return maxPlanBatch.inDays > 90 ? const Duration(days: 90) : maxPlanBatch; + } + } + + /// Formats a DateTime to the format expected by logging and error messages. + String _formatDateForApi(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + } + + @override + String resolveTradingSymbol(AssetId assetId) { + return _idResolutionStrategy.resolveTradingSymbol(assetId); + } + + @override + bool canHandleAsset(AssetId assetId) { + return _idResolutionStrategy.canResolve(assetId); + } + + @override + Future getCoinFiatPrice( + AssetId assetId, { + DateTime? priceDate, + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async { + try { + final tradingSymbol = resolveTradingSymbol(assetId); + final quoteCurrencyId = fiatCurrency.coinPaprikaId.toUpperCase(); + + if (priceDate != null) { + // For historical prices, use OHLC data + final endDate = priceDate.add(const Duration(hours: 1)); + final ohlcData = await getCoinOhlc( + assetId, + fiatCurrency, + GraphInterval.oneHour, + startAt: priceDate, + endAt: endDate, + ); + + if (ohlcData.ohlc.isEmpty) { + throw Exception( + 'No price data available for ${assetId.id} at $priceDate', + ); + } + + return ohlcData.ohlc.first.closeDecimal; + } + + // For current prices, use ticker endpoint + final ticker = await coinPaprikaProvider.fetchCoinTicker( + coinId: tradingSymbol, + quotes: [fiatCurrency], + ); + + final quoteData = ticker.quotes[quoteCurrencyId]; + if (quoteData == null) { + throw Exception( + 'No price data found for ${assetId.id} in $quoteCurrencyId', + ); + } + + return Decimal.parse(quoteData.price.toString()); + } catch (e, stackTrace) { + _logger.severe('Failed to get price for ${assetId.id}', e, stackTrace); + rethrow; + } + } + + @override + Future> getCoinFiatPrices( + AssetId assetId, + List dates, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async { + try { + if (dates.isEmpty) { + _logger.warning( + 'No dates provided for price retrieval of ${assetId.id}', + ); + return {}; + } + + final sortedDates = List.from(dates)..sort(); + final startDate = sortedDates.first.subtract(const Duration(hours: 1)); + final endDate = sortedDates.last.add(const Duration(hours: 1)); + + final ohlcData = await getCoinOhlc( + assetId, + fiatCurrency, + GraphInterval.oneDay, + startAt: startDate, + endAt: endDate, + ); + + final result = {}; + + // Match OHLC data to requested dates + for (final date in dates) { + final dayStart = DateTime.utc(date.year, date.month, date.day); + final dayEnd = dayStart.add(const Duration(days: 1)).toUtc(); + + // Find the closest OHLC data point + Ohlc? closestOhlc; + for (final ohlc in ohlcData.ohlc) { + final ohlcDate = DateTime.fromMillisecondsSinceEpoch( + ohlc.closeTimeMs, + isUtc: true, + ); + if (!ohlcDate.isBefore(dayStart) && ohlcDate.isBefore(dayEnd)) { + closestOhlc = ohlc; + break; + } + } + + if (closestOhlc != null) { + result[date] = closestOhlc.closeDecimal; + } + } + + return result; + } catch (e, stackTrace) { + _logger.severe('Failed to get prices for ${assetId.id}', e, stackTrace); + rethrow; + } + } + + @override + Future getCoin24hrPriceChange( + AssetId assetId, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async { + try { + final tradingSymbol = resolveTradingSymbol(assetId); + final quoteCurrencyId = fiatCurrency.coinPaprikaId.toUpperCase(); + + // Use ticker endpoint for 24hr price change + final ticker = await coinPaprikaProvider.fetchCoinTicker( + coinId: tradingSymbol, + quotes: [fiatCurrency], + ); + + final quoteData = ticker.quotes[quoteCurrencyId]; + if (quoteData == null) { + throw Exception( + 'No price change data found for ${assetId.id} in $quoteCurrencyId', + ); + } + + return Decimal.parse(quoteData.percentChange24h.toString()); + } catch (e, stackTrace) { + _logger.severe( + 'Failed to get 24hr price change for ${assetId.id}', + e, + stackTrace, + ); + rethrow; + } + } + + @override + Future supports( + AssetId assetId, + QuoteCurrency fiatCurrency, + PriceRequestType requestType, + ) async { + try { + // Check if we can resolve the trading symbol + if (!canHandleAsset(assetId)) { + return false; + } + + // Check if quote currency is supported + // For stablecoins, we need to check if their underlying fiat currency is + // supported since CoinPaprika treats stablecoins as their underlying + // fiat currencies + final currencyToCheck = fiatCurrency.when( + fiat: (_, __) => fiatCurrency, + stablecoin: (_, __, underlyingFiat) => underlyingFiat, + crypto: (_, __) => fiatCurrency, // Use as-is for crypto + commodity: (_, __) => fiatCurrency, // Use as-is for commodity + ); + + final supportedQuotes = + _cachedQuoteCurrencies ?? + coinPaprikaProvider.supportedQuoteCurrencies + .map((q) => q.coinPaprikaId.toUpperCase()) + .toSet(); + + if (!supportedQuotes.contains( + currencyToCheck.coinPaprikaId.toUpperCase(), + )) { + return false; + } + + // Ensure coin list is loaded to verify coin existence + final coins = await getCoinList(); + final tradingSymbol = resolveTradingSymbol(assetId); + + final coinExists = coins.any( + (coin) => coin.id.toLowerCase() == tradingSymbol.toLowerCase(), + ); + + return coinExists; + } catch (e) { + // If we can't resolve or verify support, assume unsupported + _logger.warning('Failed to check support for ${assetId.id}: $e'); + return false; + } + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.dart new file mode 100644 index 00000000..1e51a561 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.dart @@ -0,0 +1,148 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/constants/coinpaprika_intervals.dart'; + +part 'coinpaprika_api_plan.freezed.dart'; +part 'coinpaprika_api_plan.g.dart'; + +/// Represents the different CoinPaprika API plans with their specific limitations. +@freezed +abstract class CoinPaprikaApiPlan with _$CoinPaprikaApiPlan { + /// Private constructor required for custom methods in freezed classes. + const CoinPaprikaApiPlan._(); + + /// Free plan: $0/mo + /// - 20,000 calls/month + /// - 1 year daily historical data + /// - 1 year historical ticks data + /// - Daily intervals: 24h, 1d, 7d, 14d, 30d, 90d, 365d + const factory CoinPaprikaApiPlan.free({ + @Default(Duration(days: 365)) Duration ohlcHistoricalDataLimit, + @Default(CoinPaprikaIntervals.freeDefaults) List availableIntervals, + @Default(20000) int monthlyCallLimit, + }) = _FreePlan; + + /// Starter plan: $99/mo + /// - 400,000 calls/month + /// - 5 years daily historical data + /// - Daily intervals: 24h, 1d, 7d, 14d, 30d, 90d, 365d + /// - Hourly intervals: 1h, 2h, 3h, 6h, 12h (last 30 days) + /// - 5-minute intervals: 5m, 10m, 15m, 30m, 45m (last 7 days) + const factory CoinPaprikaApiPlan.starter({ + @Default(Duration(days: 1825)) Duration ohlcHistoricalDataLimit, // 5 years + @Default(CoinPaprikaIntervals.premiumDefaults) + List availableIntervals, + @Default(400000) int monthlyCallLimit, + }) = _StarterPlan; + + /// Pro plan: $199/mo + /// - 1,000,000 calls/month + /// - Unlimited daily historical data + /// - Daily intervals: 24h, 1d, 7d, 14d, 30d, 90d, 365d + /// - Hourly intervals: 1h, 2h, 3h, 6h, 12h (last 90 days) + /// - 5-minute intervals: 5m, 10m, 15m, 30m, 45m (last 30 days) + const factory CoinPaprikaApiPlan.pro({ + Duration? ohlcHistoricalDataLimit, // null means unlimited + @Default(CoinPaprikaIntervals.premiumDefaults) + List availableIntervals, + @Default(1000000) int monthlyCallLimit, + }) = _ProPlan; + + /// Business plan: $799/mo + /// - 5,000,000 calls/month + /// - Unlimited daily historical data + /// - Daily intervals: 24h, 1d, 7d, 14d, 30d, 90d, 365d + /// - Hourly intervals: 1h, 2h, 3h, 6h, 12h (last 365 days) + /// - 5-minute intervals: 5m, 10m, 15m, 30m, 45m (last 365 days) + const factory CoinPaprikaApiPlan.business({ + Duration? ohlcHistoricalDataLimit, // null means unlimited + @Default(CoinPaprikaIntervals.premiumDefaults) + List availableIntervals, + @Default(5000000) int monthlyCallLimit, + }) = _BusinessPlan; + + /// Ultimate plan: $1,499/mo + /// - 10,000,000 calls/month + /// - Unlimited daily historical data + /// - No limits on historical data + /// - All intervals: 24h, 1d, 7d, 14d, 30d, 90d, 365d, 1h, 2h, 3h, 6h, 12h, 5m, 10m, 15m, 30m, 45m + const factory CoinPaprikaApiPlan.ultimate({ + Duration? ohlcHistoricalDataLimit, // null means no limit + @Default(CoinPaprikaIntervals.premiumDefaults) + List availableIntervals, + @Default(10000000) int monthlyCallLimit, + }) = _UltimatePlan; + + /// Enterprise plan: Custom pricing + /// - No limits on calls/month + /// - Unlimited daily historical data + /// - No limits on historical data + /// - All intervals: 24h, 1d, 7d, 14d, 30d, 90d, 365d, 1h, 2h, 3h, 6h, 12h, 5m, 10m, 15m, 30m, 45m + const factory CoinPaprikaApiPlan.enterprise({ + Duration? ohlcHistoricalDataLimit, // null means no limit + @Default(CoinPaprikaIntervals.premiumDefaults) + List availableIntervals, + int? monthlyCallLimit, // null means no limit, + }) = _EnterprisePlan; + + /// Creates a plan from JSON representation. + factory CoinPaprikaApiPlan.fromJson(Map json) => + _$CoinPaprikaApiPlanFromJson(json); + + /// Returns true if the plan has unlimited OHLC historical data access. + bool get hasUnlimitedOhlcHistory => ohlcHistoricalDataLimit == null; + + /// Returns true if the plan has unlimited monthly API calls. + bool get hasUnlimitedCalls => monthlyCallLimit == null; + + /// Gets the historical data cutoff date based on the plan's limitations. + /// Returns null if there's no limit. + /// Uses UTC time and applies a 1-minute buffer for safer API requests. + DateTime? getHistoricalDataCutoff() { + if (hasUnlimitedOhlcHistory) return null; + + // Use UTC time and apply 1-minute buffer to be more conservative + const buffer = Duration(minutes: 1); + final limit = ohlcHistoricalDataLimit!; + final safeWindow = limit > buffer ? (limit - buffer) : Duration.zero; + return DateTime.now().toUtc().subtract(safeWindow); + } + + /// Validates if the given interval is supported by this plan. + bool isIntervalSupported(String interval) { + return availableIntervals.contains(interval); + } + + /// Gets the plan name as a string. + String get planName { + return when( + free: (_, __, ___) => 'Free', + starter: (_, __, ___) => 'Starter', + pro: (_, __, ___) => 'Pro', + business: (_, __, ___) => 'Business', + ultimate: (_, __, ___) => 'Ultimate', + enterprise: (_, __, ___) => 'Enterprise', + ); + } + + /// Gets a human-readable description of the OHLC historical data limitation. + String get ohlcLimitDescription { + if (hasUnlimitedOhlcHistory) { + return 'No limit on historical OHLC data'; + } + + final limit = ohlcHistoricalDataLimit!; + if (limit.inDays >= 365) { + final years = (limit.inDays / 365).round(); + return '$years year${years > 1 ? 's' : ''} of OHLC historical data'; + } else if (limit.inDays >= 30) { + final months = (limit.inDays / 30).round(); + return '$months month${months > 1 ? 's' : ''} of OHLC historical data'; + } else if (limit.inDays > 0) { + return '${limit.inDays} day${limit.inDays > 1 ? 's' : ''} of OHLC ' + 'historical data'; + } else { + return '${limit.inHours} hour${limit.inHours > 1 ? 's' : ''} of OHLC ' + 'historical data'; + } + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.freezed.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.freezed.dart new file mode 100644 index 00000000..87b89e49 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.freezed.dart @@ -0,0 +1,788 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'coinpaprika_api_plan.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +CoinPaprikaApiPlan _$CoinPaprikaApiPlanFromJson( + Map json +) { + switch (json['runtimeType']) { + case 'free': + return _FreePlan.fromJson( + json + ); + case 'starter': + return _StarterPlan.fromJson( + json + ); + case 'pro': + return _ProPlan.fromJson( + json + ); + case 'business': + return _BusinessPlan.fromJson( + json + ); + case 'ultimate': + return _UltimatePlan.fromJson( + json + ); + case 'enterprise': + return _EnterprisePlan.fromJson( + json + ); + + default: + throw CheckedFromJsonException( + json, + 'runtimeType', + 'CoinPaprikaApiPlan', + 'Invalid union type "${json['runtimeType']}"!' +); + } + +} + +/// @nodoc +mixin _$CoinPaprikaApiPlan { + + Duration? get ohlcHistoricalDataLimit;// 5 years + List get availableIntervals; int? get monthlyCallLimit; +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoinPaprikaApiPlanCopyWith get copyWith => _$CoinPaprikaApiPlanCopyWithImpl(this as CoinPaprikaApiPlan, _$identity); + + /// Serializes this CoinPaprikaApiPlan to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoinPaprikaApiPlan&&(identical(other.ohlcHistoricalDataLimit, ohlcHistoricalDataLimit) || other.ohlcHistoricalDataLimit == ohlcHistoricalDataLimit)&&const DeepCollectionEquality().equals(other.availableIntervals, availableIntervals)&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,ohlcHistoricalDataLimit,const DeepCollectionEquality().hash(availableIntervals),monthlyCallLimit); + +@override +String toString() { + return 'CoinPaprikaApiPlan(ohlcHistoricalDataLimit: $ohlcHistoricalDataLimit, availableIntervals: $availableIntervals, monthlyCallLimit: $monthlyCallLimit)'; +} + + +} + +/// @nodoc +abstract mixin class $CoinPaprikaApiPlanCopyWith<$Res> { + factory $CoinPaprikaApiPlanCopyWith(CoinPaprikaApiPlan value, $Res Function(CoinPaprikaApiPlan) _then) = _$CoinPaprikaApiPlanCopyWithImpl; +@useResult +$Res call({ + Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit +}); + + + + +} +/// @nodoc +class _$CoinPaprikaApiPlanCopyWithImpl<$Res> + implements $CoinPaprikaApiPlanCopyWith<$Res> { + _$CoinPaprikaApiPlanCopyWithImpl(this._self, this._then); + + final CoinPaprikaApiPlan _self; + final $Res Function(CoinPaprikaApiPlan) _then; + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? ohlcHistoricalDataLimit = null,Object? availableIntervals = null,Object? monthlyCallLimit = null,}) { + return _then(_self.copyWith( +ohlcHistoricalDataLimit: null == ohlcHistoricalDataLimit ? _self.ohlcHistoricalDataLimit! : ohlcHistoricalDataLimit // ignore: cast_nullable_to_non_nullable +as Duration,availableIntervals: null == availableIntervals ? _self.availableIntervals : availableIntervals // ignore: cast_nullable_to_non_nullable +as List,monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit! : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoinPaprikaApiPlan]. +extension CoinPaprikaApiPlanPatterns on CoinPaprikaApiPlan { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( _FreePlan value)? free,TResult Function( _StarterPlan value)? starter,TResult Function( _ProPlan value)? pro,TResult Function( _BusinessPlan value)? business,TResult Function( _UltimatePlan value)? ultimate,TResult Function( _EnterprisePlan value)? enterprise,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FreePlan() when free != null: +return free(_that);case _StarterPlan() when starter != null: +return starter(_that);case _ProPlan() when pro != null: +return pro(_that);case _BusinessPlan() when business != null: +return business(_that);case _UltimatePlan() when ultimate != null: +return ultimate(_that);case _EnterprisePlan() when enterprise != null: +return enterprise(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( _FreePlan value) free,required TResult Function( _StarterPlan value) starter,required TResult Function( _ProPlan value) pro,required TResult Function( _BusinessPlan value) business,required TResult Function( _UltimatePlan value) ultimate,required TResult Function( _EnterprisePlan value) enterprise,}){ +final _that = this; +switch (_that) { +case _FreePlan(): +return free(_that);case _StarterPlan(): +return starter(_that);case _ProPlan(): +return pro(_that);case _BusinessPlan(): +return business(_that);case _UltimatePlan(): +return ultimate(_that);case _EnterprisePlan(): +return enterprise(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( _FreePlan value)? free,TResult? Function( _StarterPlan value)? starter,TResult? Function( _ProPlan value)? pro,TResult? Function( _BusinessPlan value)? business,TResult? Function( _UltimatePlan value)? ultimate,TResult? Function( _EnterprisePlan value)? enterprise,}){ +final _that = this; +switch (_that) { +case _FreePlan() when free != null: +return free(_that);case _StarterPlan() when starter != null: +return starter(_that);case _ProPlan() when pro != null: +return pro(_that);case _BusinessPlan() when business != null: +return business(_that);case _UltimatePlan() when ultimate != null: +return ultimate(_that);case _EnterprisePlan() when enterprise != null: +return enterprise(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? free,TResult Function( Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? starter,TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? pro,TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? business,TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? ultimate,TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int? monthlyCallLimit)? enterprise,required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FreePlan() when free != null: +return free(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _StarterPlan() when starter != null: +return starter(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _ProPlan() when pro != null: +return pro(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _BusinessPlan() when business != null: +return business(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _UltimatePlan() when ultimate != null: +return ultimate(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _EnterprisePlan() when enterprise != null: +return enterprise(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit) free,required TResult Function( Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit) starter,required TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit) pro,required TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit) business,required TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit) ultimate,required TResult Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int? monthlyCallLimit) enterprise,}) {final _that = this; +switch (_that) { +case _FreePlan(): +return free(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _StarterPlan(): +return starter(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _ProPlan(): +return pro(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _BusinessPlan(): +return business(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _UltimatePlan(): +return ultimate(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _EnterprisePlan(): +return enterprise(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? free,TResult? Function( Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? starter,TResult? Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? pro,TResult? Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? business,TResult? Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit)? ultimate,TResult? Function( Duration? ohlcHistoricalDataLimit, List availableIntervals, int? monthlyCallLimit)? enterprise,}) {final _that = this; +switch (_that) { +case _FreePlan() when free != null: +return free(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _StarterPlan() when starter != null: +return starter(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _ProPlan() when pro != null: +return pro(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _BusinessPlan() when business != null: +return business(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _UltimatePlan() when ultimate != null: +return ultimate(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _EnterprisePlan() when enterprise != null: +return enterprise(_that.ohlcHistoricalDataLimit,_that.availableIntervals,_that.monthlyCallLimit);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _FreePlan extends CoinPaprikaApiPlan { + const _FreePlan({this.ohlcHistoricalDataLimit = const Duration(days: 365), final List availableIntervals = const ['24h', '1d', '7d', '14d', '30d', '90d', '365d'], this.monthlyCallLimit = 20000, final String? $type}): _availableIntervals = availableIntervals,$type = $type ?? 'free',super._(); + factory _FreePlan.fromJson(Map json) => _$FreePlanFromJson(json); + +@override@JsonKey() final Duration ohlcHistoricalDataLimit; + final List _availableIntervals; +@override@JsonKey() List get availableIntervals { + if (_availableIntervals is EqualUnmodifiableListView) return _availableIntervals; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_availableIntervals); +} + +@override@JsonKey() final int monthlyCallLimit; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FreePlanCopyWith<_FreePlan> get copyWith => __$FreePlanCopyWithImpl<_FreePlan>(this, _$identity); + +@override +Map toJson() { + return _$FreePlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FreePlan&&(identical(other.ohlcHistoricalDataLimit, ohlcHistoricalDataLimit) || other.ohlcHistoricalDataLimit == ohlcHistoricalDataLimit)&&const DeepCollectionEquality().equals(other._availableIntervals, _availableIntervals)&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,ohlcHistoricalDataLimit,const DeepCollectionEquality().hash(_availableIntervals),monthlyCallLimit); + +@override +String toString() { + return 'CoinPaprikaApiPlan.free(ohlcHistoricalDataLimit: $ohlcHistoricalDataLimit, availableIntervals: $availableIntervals, monthlyCallLimit: $monthlyCallLimit)'; +} + + +} + +/// @nodoc +abstract mixin class _$FreePlanCopyWith<$Res> implements $CoinPaprikaApiPlanCopyWith<$Res> { + factory _$FreePlanCopyWith(_FreePlan value, $Res Function(_FreePlan) _then) = __$FreePlanCopyWithImpl; +@override @useResult +$Res call({ + Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit +}); + + + + +} +/// @nodoc +class __$FreePlanCopyWithImpl<$Res> + implements _$FreePlanCopyWith<$Res> { + __$FreePlanCopyWithImpl(this._self, this._then); + + final _FreePlan _self; + final $Res Function(_FreePlan) _then; + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? ohlcHistoricalDataLimit = null,Object? availableIntervals = null,Object? monthlyCallLimit = null,}) { + return _then(_FreePlan( +ohlcHistoricalDataLimit: null == ohlcHistoricalDataLimit ? _self.ohlcHistoricalDataLimit : ohlcHistoricalDataLimit // ignore: cast_nullable_to_non_nullable +as Duration,availableIntervals: null == availableIntervals ? _self._availableIntervals : availableIntervals // ignore: cast_nullable_to_non_nullable +as List,monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _StarterPlan extends CoinPaprikaApiPlan { + const _StarterPlan({this.ohlcHistoricalDataLimit = const Duration(days: 1825), final List availableIntervals = const ['24h', '1d', '7d', '14d', '30d', '90d', '365d', '1h', '2h', '3h', '6h', '12h', '5m', '10m', '15m', '30m', '45m'], this.monthlyCallLimit = 400000, final String? $type}): _availableIntervals = availableIntervals,$type = $type ?? 'starter',super._(); + factory _StarterPlan.fromJson(Map json) => _$StarterPlanFromJson(json); + +@override@JsonKey() final Duration ohlcHistoricalDataLimit; +// 5 years + final List _availableIntervals; +// 5 years +@override@JsonKey() List get availableIntervals { + if (_availableIntervals is EqualUnmodifiableListView) return _availableIntervals; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_availableIntervals); +} + +@override@JsonKey() final int monthlyCallLimit; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$StarterPlanCopyWith<_StarterPlan> get copyWith => __$StarterPlanCopyWithImpl<_StarterPlan>(this, _$identity); + +@override +Map toJson() { + return _$StarterPlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StarterPlan&&(identical(other.ohlcHistoricalDataLimit, ohlcHistoricalDataLimit) || other.ohlcHistoricalDataLimit == ohlcHistoricalDataLimit)&&const DeepCollectionEquality().equals(other._availableIntervals, _availableIntervals)&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,ohlcHistoricalDataLimit,const DeepCollectionEquality().hash(_availableIntervals),monthlyCallLimit); + +@override +String toString() { + return 'CoinPaprikaApiPlan.starter(ohlcHistoricalDataLimit: $ohlcHistoricalDataLimit, availableIntervals: $availableIntervals, monthlyCallLimit: $monthlyCallLimit)'; +} + + +} + +/// @nodoc +abstract mixin class _$StarterPlanCopyWith<$Res> implements $CoinPaprikaApiPlanCopyWith<$Res> { + factory _$StarterPlanCopyWith(_StarterPlan value, $Res Function(_StarterPlan) _then) = __$StarterPlanCopyWithImpl; +@override @useResult +$Res call({ + Duration ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit +}); + + + + +} +/// @nodoc +class __$StarterPlanCopyWithImpl<$Res> + implements _$StarterPlanCopyWith<$Res> { + __$StarterPlanCopyWithImpl(this._self, this._then); + + final _StarterPlan _self; + final $Res Function(_StarterPlan) _then; + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? ohlcHistoricalDataLimit = null,Object? availableIntervals = null,Object? monthlyCallLimit = null,}) { + return _then(_StarterPlan( +ohlcHistoricalDataLimit: null == ohlcHistoricalDataLimit ? _self.ohlcHistoricalDataLimit : ohlcHistoricalDataLimit // ignore: cast_nullable_to_non_nullable +as Duration,availableIntervals: null == availableIntervals ? _self._availableIntervals : availableIntervals // ignore: cast_nullable_to_non_nullable +as List,monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _ProPlan extends CoinPaprikaApiPlan { + const _ProPlan({this.ohlcHistoricalDataLimit, final List availableIntervals = const ['24h', '1d', '7d', '14d', '30d', '90d', '365d', '1h', '2h', '3h', '6h', '12h', '5m', '10m', '15m', '30m', '45m'], this.monthlyCallLimit = 1000000, final String? $type}): _availableIntervals = availableIntervals,$type = $type ?? 'pro',super._(); + factory _ProPlan.fromJson(Map json) => _$ProPlanFromJson(json); + +@override final Duration? ohlcHistoricalDataLimit; +// null means unlimited + final List _availableIntervals; +// null means unlimited +@override@JsonKey() List get availableIntervals { + if (_availableIntervals is EqualUnmodifiableListView) return _availableIntervals; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_availableIntervals); +} + +@override@JsonKey() final int monthlyCallLimit; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ProPlanCopyWith<_ProPlan> get copyWith => __$ProPlanCopyWithImpl<_ProPlan>(this, _$identity); + +@override +Map toJson() { + return _$ProPlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProPlan&&(identical(other.ohlcHistoricalDataLimit, ohlcHistoricalDataLimit) || other.ohlcHistoricalDataLimit == ohlcHistoricalDataLimit)&&const DeepCollectionEquality().equals(other._availableIntervals, _availableIntervals)&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,ohlcHistoricalDataLimit,const DeepCollectionEquality().hash(_availableIntervals),monthlyCallLimit); + +@override +String toString() { + return 'CoinPaprikaApiPlan.pro(ohlcHistoricalDataLimit: $ohlcHistoricalDataLimit, availableIntervals: $availableIntervals, monthlyCallLimit: $monthlyCallLimit)'; +} + + +} + +/// @nodoc +abstract mixin class _$ProPlanCopyWith<$Res> implements $CoinPaprikaApiPlanCopyWith<$Res> { + factory _$ProPlanCopyWith(_ProPlan value, $Res Function(_ProPlan) _then) = __$ProPlanCopyWithImpl; +@override @useResult +$Res call({ + Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit +}); + + + + +} +/// @nodoc +class __$ProPlanCopyWithImpl<$Res> + implements _$ProPlanCopyWith<$Res> { + __$ProPlanCopyWithImpl(this._self, this._then); + + final _ProPlan _self; + final $Res Function(_ProPlan) _then; + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? ohlcHistoricalDataLimit = freezed,Object? availableIntervals = null,Object? monthlyCallLimit = null,}) { + return _then(_ProPlan( +ohlcHistoricalDataLimit: freezed == ohlcHistoricalDataLimit ? _self.ohlcHistoricalDataLimit : ohlcHistoricalDataLimit // ignore: cast_nullable_to_non_nullable +as Duration?,availableIntervals: null == availableIntervals ? _self._availableIntervals : availableIntervals // ignore: cast_nullable_to_non_nullable +as List,monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _BusinessPlan extends CoinPaprikaApiPlan { + const _BusinessPlan({this.ohlcHistoricalDataLimit, final List availableIntervals = const ['24h', '1d', '7d', '14d', '30d', '90d', '365d', '1h', '2h', '3h', '6h', '12h', '5m', '10m', '15m', '30m', '45m'], this.monthlyCallLimit = 5000000, final String? $type}): _availableIntervals = availableIntervals,$type = $type ?? 'business',super._(); + factory _BusinessPlan.fromJson(Map json) => _$BusinessPlanFromJson(json); + +@override final Duration? ohlcHistoricalDataLimit; +// null means unlimited + final List _availableIntervals; +// null means unlimited +@override@JsonKey() List get availableIntervals { + if (_availableIntervals is EqualUnmodifiableListView) return _availableIntervals; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_availableIntervals); +} + +@override@JsonKey() final int monthlyCallLimit; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BusinessPlanCopyWith<_BusinessPlan> get copyWith => __$BusinessPlanCopyWithImpl<_BusinessPlan>(this, _$identity); + +@override +Map toJson() { + return _$BusinessPlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BusinessPlan&&(identical(other.ohlcHistoricalDataLimit, ohlcHistoricalDataLimit) || other.ohlcHistoricalDataLimit == ohlcHistoricalDataLimit)&&const DeepCollectionEquality().equals(other._availableIntervals, _availableIntervals)&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,ohlcHistoricalDataLimit,const DeepCollectionEquality().hash(_availableIntervals),monthlyCallLimit); + +@override +String toString() { + return 'CoinPaprikaApiPlan.business(ohlcHistoricalDataLimit: $ohlcHistoricalDataLimit, availableIntervals: $availableIntervals, monthlyCallLimit: $monthlyCallLimit)'; +} + + +} + +/// @nodoc +abstract mixin class _$BusinessPlanCopyWith<$Res> implements $CoinPaprikaApiPlanCopyWith<$Res> { + factory _$BusinessPlanCopyWith(_BusinessPlan value, $Res Function(_BusinessPlan) _then) = __$BusinessPlanCopyWithImpl; +@override @useResult +$Res call({ + Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit +}); + + + + +} +/// @nodoc +class __$BusinessPlanCopyWithImpl<$Res> + implements _$BusinessPlanCopyWith<$Res> { + __$BusinessPlanCopyWithImpl(this._self, this._then); + + final _BusinessPlan _self; + final $Res Function(_BusinessPlan) _then; + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? ohlcHistoricalDataLimit = freezed,Object? availableIntervals = null,Object? monthlyCallLimit = null,}) { + return _then(_BusinessPlan( +ohlcHistoricalDataLimit: freezed == ohlcHistoricalDataLimit ? _self.ohlcHistoricalDataLimit : ohlcHistoricalDataLimit // ignore: cast_nullable_to_non_nullable +as Duration?,availableIntervals: null == availableIntervals ? _self._availableIntervals : availableIntervals // ignore: cast_nullable_to_non_nullable +as List,monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _UltimatePlan extends CoinPaprikaApiPlan { + const _UltimatePlan({this.ohlcHistoricalDataLimit, final List availableIntervals = const ['24h', '1d', '7d', '14d', '30d', '90d', '365d', '1h', '2h', '3h', '6h', '12h', '5m', '10m', '15m', '30m', '45m'], this.monthlyCallLimit = 10000000, final String? $type}): _availableIntervals = availableIntervals,$type = $type ?? 'ultimate',super._(); + factory _UltimatePlan.fromJson(Map json) => _$UltimatePlanFromJson(json); + +@override final Duration? ohlcHistoricalDataLimit; +// null means no limit + final List _availableIntervals; +// null means no limit +@override@JsonKey() List get availableIntervals { + if (_availableIntervals is EqualUnmodifiableListView) return _availableIntervals; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_availableIntervals); +} + +@override@JsonKey() final int monthlyCallLimit; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$UltimatePlanCopyWith<_UltimatePlan> get copyWith => __$UltimatePlanCopyWithImpl<_UltimatePlan>(this, _$identity); + +@override +Map toJson() { + return _$UltimatePlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UltimatePlan&&(identical(other.ohlcHistoricalDataLimit, ohlcHistoricalDataLimit) || other.ohlcHistoricalDataLimit == ohlcHistoricalDataLimit)&&const DeepCollectionEquality().equals(other._availableIntervals, _availableIntervals)&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,ohlcHistoricalDataLimit,const DeepCollectionEquality().hash(_availableIntervals),monthlyCallLimit); + +@override +String toString() { + return 'CoinPaprikaApiPlan.ultimate(ohlcHistoricalDataLimit: $ohlcHistoricalDataLimit, availableIntervals: $availableIntervals, monthlyCallLimit: $monthlyCallLimit)'; +} + + +} + +/// @nodoc +abstract mixin class _$UltimatePlanCopyWith<$Res> implements $CoinPaprikaApiPlanCopyWith<$Res> { + factory _$UltimatePlanCopyWith(_UltimatePlan value, $Res Function(_UltimatePlan) _then) = __$UltimatePlanCopyWithImpl; +@override @useResult +$Res call({ + Duration? ohlcHistoricalDataLimit, List availableIntervals, int monthlyCallLimit +}); + + + + +} +/// @nodoc +class __$UltimatePlanCopyWithImpl<$Res> + implements _$UltimatePlanCopyWith<$Res> { + __$UltimatePlanCopyWithImpl(this._self, this._then); + + final _UltimatePlan _self; + final $Res Function(_UltimatePlan) _then; + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? ohlcHistoricalDataLimit = freezed,Object? availableIntervals = null,Object? monthlyCallLimit = null,}) { + return _then(_UltimatePlan( +ohlcHistoricalDataLimit: freezed == ohlcHistoricalDataLimit ? _self.ohlcHistoricalDataLimit : ohlcHistoricalDataLimit // ignore: cast_nullable_to_non_nullable +as Duration?,availableIntervals: null == availableIntervals ? _self._availableIntervals : availableIntervals // ignore: cast_nullable_to_non_nullable +as List,monthlyCallLimit: null == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +@JsonSerializable() + +class _EnterprisePlan extends CoinPaprikaApiPlan { + const _EnterprisePlan({this.ohlcHistoricalDataLimit, final List availableIntervals = const ['24h', '1d', '7d', '14d', '30d', '90d', '365d', '1h', '2h', '3h', '6h', '12h', '5m', '10m', '15m', '30m', '45m'], this.monthlyCallLimit, final String? $type}): _availableIntervals = availableIntervals,$type = $type ?? 'enterprise',super._(); + factory _EnterprisePlan.fromJson(Map json) => _$EnterprisePlanFromJson(json); + +@override final Duration? ohlcHistoricalDataLimit; +// null means no limit + final List _availableIntervals; +// null means no limit +@override@JsonKey() List get availableIntervals { + if (_availableIntervals is EqualUnmodifiableListView) return _availableIntervals; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_availableIntervals); +} + +@override final int? monthlyCallLimit; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EnterprisePlanCopyWith<_EnterprisePlan> get copyWith => __$EnterprisePlanCopyWithImpl<_EnterprisePlan>(this, _$identity); + +@override +Map toJson() { + return _$EnterprisePlanToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EnterprisePlan&&(identical(other.ohlcHistoricalDataLimit, ohlcHistoricalDataLimit) || other.ohlcHistoricalDataLimit == ohlcHistoricalDataLimit)&&const DeepCollectionEquality().equals(other._availableIntervals, _availableIntervals)&&(identical(other.monthlyCallLimit, monthlyCallLimit) || other.monthlyCallLimit == monthlyCallLimit)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,ohlcHistoricalDataLimit,const DeepCollectionEquality().hash(_availableIntervals),monthlyCallLimit); + +@override +String toString() { + return 'CoinPaprikaApiPlan.enterprise(ohlcHistoricalDataLimit: $ohlcHistoricalDataLimit, availableIntervals: $availableIntervals, monthlyCallLimit: $monthlyCallLimit)'; +} + + +} + +/// @nodoc +abstract mixin class _$EnterprisePlanCopyWith<$Res> implements $CoinPaprikaApiPlanCopyWith<$Res> { + factory _$EnterprisePlanCopyWith(_EnterprisePlan value, $Res Function(_EnterprisePlan) _then) = __$EnterprisePlanCopyWithImpl; +@override @useResult +$Res call({ + Duration? ohlcHistoricalDataLimit, List availableIntervals, int? monthlyCallLimit +}); + + + + +} +/// @nodoc +class __$EnterprisePlanCopyWithImpl<$Res> + implements _$EnterprisePlanCopyWith<$Res> { + __$EnterprisePlanCopyWithImpl(this._self, this._then); + + final _EnterprisePlan _self; + final $Res Function(_EnterprisePlan) _then; + +/// Create a copy of CoinPaprikaApiPlan +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? ohlcHistoricalDataLimit = freezed,Object? availableIntervals = null,Object? monthlyCallLimit = freezed,}) { + return _then(_EnterprisePlan( +ohlcHistoricalDataLimit: freezed == ohlcHistoricalDataLimit ? _self.ohlcHistoricalDataLimit : ohlcHistoricalDataLimit // ignore: cast_nullable_to_non_nullable +as Duration?,availableIntervals: null == availableIntervals ? _self._availableIntervals : availableIntervals // ignore: cast_nullable_to_non_nullable +as List,monthlyCallLimit: freezed == monthlyCallLimit ? _self.monthlyCallLimit : monthlyCallLimit // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.g.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.g.dart new file mode 100644 index 00000000..9d2d8e83 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_api_plan.g.dart @@ -0,0 +1,240 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'coinpaprika_api_plan.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_FreePlan _$FreePlanFromJson(Map json) => _FreePlan( + ohlcHistoricalDataLimit: json['ohlcHistoricalDataLimit'] == null + ? const Duration(days: 365) + : Duration( + microseconds: (json['ohlcHistoricalDataLimit'] as num).toInt(), + ), + availableIntervals: + (json['availableIntervals'] as List?) + ?.map((e) => e as String) + .toList() ?? + const ['24h', '1d', '7d', '14d', '30d', '90d', '365d'], + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 20000, + $type: json['runtimeType'] as String?, +); + +Map _$FreePlanToJson(_FreePlan instance) => { + 'ohlcHistoricalDataLimit': instance.ohlcHistoricalDataLimit.inMicroseconds, + 'availableIntervals': instance.availableIntervals, + 'monthlyCallLimit': instance.monthlyCallLimit, + 'runtimeType': instance.$type, +}; + +_StarterPlan _$StarterPlanFromJson(Map json) => _StarterPlan( + ohlcHistoricalDataLimit: json['ohlcHistoricalDataLimit'] == null + ? const Duration(days: 1825) + : Duration( + microseconds: (json['ohlcHistoricalDataLimit'] as num).toInt(), + ), + availableIntervals: + (json['availableIntervals'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ], + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 400000, + $type: json['runtimeType'] as String?, +); + +Map _$StarterPlanToJson( + _StarterPlan instance, +) => { + 'ohlcHistoricalDataLimit': instance.ohlcHistoricalDataLimit.inMicroseconds, + 'availableIntervals': instance.availableIntervals, + 'monthlyCallLimit': instance.monthlyCallLimit, + 'runtimeType': instance.$type, +}; + +_ProPlan _$ProPlanFromJson(Map json) => _ProPlan( + ohlcHistoricalDataLimit: json['ohlcHistoricalDataLimit'] == null + ? null + : Duration( + microseconds: (json['ohlcHistoricalDataLimit'] as num).toInt(), + ), + availableIntervals: + (json['availableIntervals'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ], + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 1000000, + $type: json['runtimeType'] as String?, +); + +Map _$ProPlanToJson(_ProPlan instance) => { + 'ohlcHistoricalDataLimit': instance.ohlcHistoricalDataLimit?.inMicroseconds, + 'availableIntervals': instance.availableIntervals, + 'monthlyCallLimit': instance.monthlyCallLimit, + 'runtimeType': instance.$type, +}; + +_BusinessPlan _$BusinessPlanFromJson(Map json) => + _BusinessPlan( + ohlcHistoricalDataLimit: json['ohlcHistoricalDataLimit'] == null + ? null + : Duration( + microseconds: (json['ohlcHistoricalDataLimit'] as num).toInt(), + ), + availableIntervals: + (json['availableIntervals'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ], + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 5000000, + $type: json['runtimeType'] as String?, + ); + +Map _$BusinessPlanToJson( + _BusinessPlan instance, +) => { + 'ohlcHistoricalDataLimit': instance.ohlcHistoricalDataLimit?.inMicroseconds, + 'availableIntervals': instance.availableIntervals, + 'monthlyCallLimit': instance.monthlyCallLimit, + 'runtimeType': instance.$type, +}; + +_UltimatePlan _$UltimatePlanFromJson(Map json) => + _UltimatePlan( + ohlcHistoricalDataLimit: json['ohlcHistoricalDataLimit'] == null + ? null + : Duration( + microseconds: (json['ohlcHistoricalDataLimit'] as num).toInt(), + ), + availableIntervals: + (json['availableIntervals'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ], + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt() ?? 10000000, + $type: json['runtimeType'] as String?, + ); + +Map _$UltimatePlanToJson( + _UltimatePlan instance, +) => { + 'ohlcHistoricalDataLimit': instance.ohlcHistoricalDataLimit?.inMicroseconds, + 'availableIntervals': instance.availableIntervals, + 'monthlyCallLimit': instance.monthlyCallLimit, + 'runtimeType': instance.$type, +}; + +_EnterprisePlan _$EnterprisePlanFromJson(Map json) => + _EnterprisePlan( + ohlcHistoricalDataLimit: json['ohlcHistoricalDataLimit'] == null + ? null + : Duration( + microseconds: (json['ohlcHistoricalDataLimit'] as num).toInt(), + ), + availableIntervals: + (json['availableIntervals'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ], + monthlyCallLimit: (json['monthlyCallLimit'] as num?)?.toInt(), + $type: json['runtimeType'] as String?, + ); + +Map _$EnterprisePlanToJson( + _EnterprisePlan instance, +) => { + 'ohlcHistoricalDataLimit': instance.ohlcHistoricalDataLimit?.inMicroseconds, + 'availableIntervals': instance.availableIntervals, + 'monthlyCallLimit': instance.monthlyCallLimit, + 'runtimeType': instance.$type, +}; diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.dart new file mode 100644 index 00000000..f954156c --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'coinpaprika_coin.freezed.dart'; +part 'coinpaprika_coin.g.dart'; + +/// Represents a coin from CoinPaprika's coins list endpoint. +@freezed +abstract class CoinPaprikaCoin with _$CoinPaprikaCoin { + /// Creates a CoinPaprika coin instance. + @JsonSerializable(fieldRename: FieldRename.snake) + const factory CoinPaprikaCoin({ + /// Unique identifier for the coin (e.g., "btc-bitcoin") + required String id, + + /// Full name of the coin (e.g., "Bitcoin") + required String name, + + /// Symbol/ticker of the coin (e.g., "BTC") + required String symbol, + + /// Market ranking of the coin + required int rank, + + /// Whether this is a new coin (added within last 5 days) + required bool isNew, + + /// Whether this coin is currently active + required bool isActive, + + /// Type of cryptocurrency ("coin" or "token") + required String type, + }) = _CoinPaprikaCoin; + + /// Creates a CoinPaprika coin instance from JSON. + factory CoinPaprikaCoin.fromJson(Map json) => + _$CoinPaprikaCoinFromJson(json); +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.freezed.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.freezed.dart new file mode 100644 index 00000000..c266b68a --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.freezed.dart @@ -0,0 +1,309 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'coinpaprika_coin.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CoinPaprikaCoin { + +/// Unique identifier for the coin (e.g., "btc-bitcoin") + String get id;/// Full name of the coin (e.g., "Bitcoin") + String get name;/// Symbol/ticker of the coin (e.g., "BTC") + String get symbol;/// Market ranking of the coin + int get rank;/// Whether this is a new coin (added within last 5 days) + bool get isNew;/// Whether this coin is currently active + bool get isActive;/// Type of cryptocurrency ("coin" or "token") + String get type; +/// Create a copy of CoinPaprikaCoin +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoinPaprikaCoinCopyWith get copyWith => _$CoinPaprikaCoinCopyWithImpl(this as CoinPaprikaCoin, _$identity); + + /// Serializes this CoinPaprikaCoin to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoinPaprikaCoin&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.rank, rank) || other.rank == rank)&&(identical(other.isNew, isNew) || other.isNew == isNew)&&(identical(other.isActive, isActive) || other.isActive == isActive)&&(identical(other.type, type) || other.type == type)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,symbol,rank,isNew,isActive,type); + +@override +String toString() { + return 'CoinPaprikaCoin(id: $id, name: $name, symbol: $symbol, rank: $rank, isNew: $isNew, isActive: $isActive, type: $type)'; +} + + +} + +/// @nodoc +abstract mixin class $CoinPaprikaCoinCopyWith<$Res> { + factory $CoinPaprikaCoinCopyWith(CoinPaprikaCoin value, $Res Function(CoinPaprikaCoin) _then) = _$CoinPaprikaCoinCopyWithImpl; +@useResult +$Res call({ + String id, String name, String symbol, int rank, bool isNew, bool isActive, String type +}); + + + + +} +/// @nodoc +class _$CoinPaprikaCoinCopyWithImpl<$Res> + implements $CoinPaprikaCoinCopyWith<$Res> { + _$CoinPaprikaCoinCopyWithImpl(this._self, this._then); + + final CoinPaprikaCoin _self; + final $Res Function(CoinPaprikaCoin) _then; + +/// Create a copy of CoinPaprikaCoin +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? symbol = null,Object? rank = null,Object? isNew = null,Object? isActive = null,Object? type = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable +as String,rank: null == rank ? _self.rank : rank // ignore: cast_nullable_to_non_nullable +as int,isNew: null == isNew ? _self.isNew : isNew // ignore: cast_nullable_to_non_nullable +as bool,isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoinPaprikaCoin]. +extension CoinPaprikaCoinPatterns on CoinPaprikaCoin { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CoinPaprikaCoin value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CoinPaprikaCoin() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CoinPaprikaCoin value) $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaCoin(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CoinPaprikaCoin value)? $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaCoin() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String symbol, int rank, bool isNew, bool isActive, String type)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CoinPaprikaCoin() when $default != null: +return $default(_that.id,_that.name,_that.symbol,_that.rank,_that.isNew,_that.isActive,_that.type);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, String symbol, int rank, bool isNew, bool isActive, String type) $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaCoin(): +return $default(_that.id,_that.name,_that.symbol,_that.rank,_that.isNew,_that.isActive,_that.type);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String symbol, int rank, bool isNew, bool isActive, String type)? $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaCoin() when $default != null: +return $default(_that.id,_that.name,_that.symbol,_that.rank,_that.isNew,_that.isActive,_that.type);case _: + return null; + +} +} + +} + +/// @nodoc + +@JsonSerializable(fieldRename: FieldRename.snake) +class _CoinPaprikaCoin implements CoinPaprikaCoin { + const _CoinPaprikaCoin({required this.id, required this.name, required this.symbol, required this.rank, required this.isNew, required this.isActive, required this.type}); + factory _CoinPaprikaCoin.fromJson(Map json) => _$CoinPaprikaCoinFromJson(json); + +/// Unique identifier for the coin (e.g., "btc-bitcoin") +@override final String id; +/// Full name of the coin (e.g., "Bitcoin") +@override final String name; +/// Symbol/ticker of the coin (e.g., "BTC") +@override final String symbol; +/// Market ranking of the coin +@override final int rank; +/// Whether this is a new coin (added within last 5 days) +@override final bool isNew; +/// Whether this coin is currently active +@override final bool isActive; +/// Type of cryptocurrency ("coin" or "token") +@override final String type; + +/// Create a copy of CoinPaprikaCoin +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CoinPaprikaCoinCopyWith<_CoinPaprikaCoin> get copyWith => __$CoinPaprikaCoinCopyWithImpl<_CoinPaprikaCoin>(this, _$identity); + +@override +Map toJson() { + return _$CoinPaprikaCoinToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CoinPaprikaCoin&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.rank, rank) || other.rank == rank)&&(identical(other.isNew, isNew) || other.isNew == isNew)&&(identical(other.isActive, isActive) || other.isActive == isActive)&&(identical(other.type, type) || other.type == type)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,symbol,rank,isNew,isActive,type); + +@override +String toString() { + return 'CoinPaprikaCoin(id: $id, name: $name, symbol: $symbol, rank: $rank, isNew: $isNew, isActive: $isActive, type: $type)'; +} + + +} + +/// @nodoc +abstract mixin class _$CoinPaprikaCoinCopyWith<$Res> implements $CoinPaprikaCoinCopyWith<$Res> { + factory _$CoinPaprikaCoinCopyWith(_CoinPaprikaCoin value, $Res Function(_CoinPaprikaCoin) _then) = __$CoinPaprikaCoinCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, String symbol, int rank, bool isNew, bool isActive, String type +}); + + + + +} +/// @nodoc +class __$CoinPaprikaCoinCopyWithImpl<$Res> + implements _$CoinPaprikaCoinCopyWith<$Res> { + __$CoinPaprikaCoinCopyWithImpl(this._self, this._then); + + final _CoinPaprikaCoin _self; + final $Res Function(_CoinPaprikaCoin) _then; + +/// Create a copy of CoinPaprikaCoin +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? symbol = null,Object? rank = null,Object? isNew = null,Object? isActive = null,Object? type = null,}) { + return _then(_CoinPaprikaCoin( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable +as String,rank: null == rank ? _self.rank : rank // ignore: cast_nullable_to_non_nullable +as int,isNew: null == isNew ? _self.isNew : isNew // ignore: cast_nullable_to_non_nullable +as bool,isActive: null == isActive ? _self.isActive : isActive // ignore: cast_nullable_to_non_nullable +as bool,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.g.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.g.dart new file mode 100644 index 00000000..2d7a4242 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_coin.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'coinpaprika_coin.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CoinPaprikaCoin _$CoinPaprikaCoinFromJson(Map json) => + _CoinPaprikaCoin( + id: json['id'] as String, + name: json['name'] as String, + symbol: json['symbol'] as String, + rank: (json['rank'] as num).toInt(), + isNew: json['is_new'] as bool, + isActive: json['is_active'] as bool, + type: json['type'] as String, + ); + +Map _$CoinPaprikaCoinToJson(_CoinPaprikaCoin instance) => + { + 'id': instance.id, + 'name': instance.name, + 'symbol': instance.symbol, + 'rank': instance.rank, + 'is_new': instance.isNew, + 'is_active': instance.isActive, + 'type': instance.type, + }; diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.dart new file mode 100644 index 00000000..6982d791 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.dart @@ -0,0 +1,88 @@ +import 'package:decimal/decimal.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:komodo_cex_market_data/src/models/json_converters.dart'; + +part 'coinpaprika_market.freezed.dart'; +part 'coinpaprika_market.g.dart'; + +/// Represents market data for a coin from CoinPaprika's markets endpoint. +@freezed +abstract class CoinPaprikaMarket with _$CoinPaprikaMarket { + /// Creates a CoinPaprika market instance. + @JsonSerializable(fieldRename: FieldRename.snake) + const factory CoinPaprikaMarket({ + /// Exchange identifier (e.g., "binance") + required String exchangeId, + + /// Exchange display name (e.g., "Binance") + required String exchangeName, + + /// Trading pair (e.g., "BTC/USDT") + required String pair, + + /// Base currency identifier (e.g., "btc-bitcoin") + required String baseCurrencyId, + + /// Base currency name (e.g., "Bitcoin") + required String baseCurrencyName, + + /// Quote currency identifier (e.g., "usdt-tether") + required String quoteCurrencyId, + + /// Quote currency name (e.g., "Tether") + required String quoteCurrencyName, + + /// Direct URL to the market on the exchange + required String marketUrl, + + /// Market category (e.g., "Spot") + required String category, + + /// Fee type (e.g., "Percentage") + required String feeType, + + /// Whether this market is considered an outlier + required bool outlier, + + /// Adjusted 24h volume share percentage + required double adjustedVolume24hShare, + + /// Quote data for different currencies + required Map quotes, + + /// Last update timestamp as ISO 8601 string + required String lastUpdated, + }) = _CoinPaprikaMarket; + + /// Creates a CoinPaprika market instance from JSON. + factory CoinPaprikaMarket.fromJson(Map json) => + _$CoinPaprikaMarketFromJson(json); +} + +/// Represents price and volume data for a specific quote currency. +@freezed +abstract class CoinPaprikaQuote with _$CoinPaprikaQuote { + /// Creates a CoinPaprika quote instance. + const factory CoinPaprikaQuote({ + /// Current price as a [Decimal] for precision + @DecimalConverter() required Decimal price, + + /// 24-hour trading volume as a [Decimal] + @JsonKey(name: 'volume_24h') @DecimalConverter() required Decimal volume24h, + }) = _CoinPaprikaQuote; + + /// Creates a CoinPaprika quote instance from JSON. + factory CoinPaprikaQuote.fromJson(Map json) => + _$CoinPaprikaQuoteFromJson(json); +} + +/// Extension providing convenient accessors for CoinPaprika market data. +extension CoinPaprikaMarketGetters on CoinPaprikaMarket { + /// Gets the last updated time as a [DateTime]. + DateTime get lastUpdatedDateTime => DateTime.parse(lastUpdated); + + /// Gets a quote for a specific currency key. + CoinPaprikaQuote? getQuoteFor(String currencyKey) { + return quotes[currencyKey.toUpperCase()]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.freezed.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.freezed.dart new file mode 100644 index 00000000..e40d9466 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.freezed.dart @@ -0,0 +1,621 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'coinpaprika_market.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CoinPaprikaMarket { + +/// Exchange identifier (e.g., "binance") + String get exchangeId;/// Exchange display name (e.g., "Binance") + String get exchangeName;/// Trading pair (e.g., "BTC/USDT") + String get pair;/// Base currency identifier (e.g., "btc-bitcoin") + String get baseCurrencyId;/// Base currency name (e.g., "Bitcoin") + String get baseCurrencyName;/// Quote currency identifier (e.g., "usdt-tether") + String get quoteCurrencyId;/// Quote currency name (e.g., "Tether") + String get quoteCurrencyName;/// Direct URL to the market on the exchange + String get marketUrl;/// Market category (e.g., "Spot") + String get category;/// Fee type (e.g., "Percentage") + String get feeType;/// Whether this market is considered an outlier + bool get outlier;/// Adjusted 24h volume share percentage + double get adjustedVolume24hShare;/// Quote data for different currencies + Map get quotes;/// Last update timestamp as ISO 8601 string + String get lastUpdated; +/// Create a copy of CoinPaprikaMarket +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoinPaprikaMarketCopyWith get copyWith => _$CoinPaprikaMarketCopyWithImpl(this as CoinPaprikaMarket, _$identity); + + /// Serializes this CoinPaprikaMarket to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoinPaprikaMarket&&(identical(other.exchangeId, exchangeId) || other.exchangeId == exchangeId)&&(identical(other.exchangeName, exchangeName) || other.exchangeName == exchangeName)&&(identical(other.pair, pair) || other.pair == pair)&&(identical(other.baseCurrencyId, baseCurrencyId) || other.baseCurrencyId == baseCurrencyId)&&(identical(other.baseCurrencyName, baseCurrencyName) || other.baseCurrencyName == baseCurrencyName)&&(identical(other.quoteCurrencyId, quoteCurrencyId) || other.quoteCurrencyId == quoteCurrencyId)&&(identical(other.quoteCurrencyName, quoteCurrencyName) || other.quoteCurrencyName == quoteCurrencyName)&&(identical(other.marketUrl, marketUrl) || other.marketUrl == marketUrl)&&(identical(other.category, category) || other.category == category)&&(identical(other.feeType, feeType) || other.feeType == feeType)&&(identical(other.outlier, outlier) || other.outlier == outlier)&&(identical(other.adjustedVolume24hShare, adjustedVolume24hShare) || other.adjustedVolume24hShare == adjustedVolume24hShare)&&const DeepCollectionEquality().equals(other.quotes, quotes)&&(identical(other.lastUpdated, lastUpdated) || other.lastUpdated == lastUpdated)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,exchangeId,exchangeName,pair,baseCurrencyId,baseCurrencyName,quoteCurrencyId,quoteCurrencyName,marketUrl,category,feeType,outlier,adjustedVolume24hShare,const DeepCollectionEquality().hash(quotes),lastUpdated); + +@override +String toString() { + return 'CoinPaprikaMarket(exchangeId: $exchangeId, exchangeName: $exchangeName, pair: $pair, baseCurrencyId: $baseCurrencyId, baseCurrencyName: $baseCurrencyName, quoteCurrencyId: $quoteCurrencyId, quoteCurrencyName: $quoteCurrencyName, marketUrl: $marketUrl, category: $category, feeType: $feeType, outlier: $outlier, adjustedVolume24hShare: $adjustedVolume24hShare, quotes: $quotes, lastUpdated: $lastUpdated)'; +} + + +} + +/// @nodoc +abstract mixin class $CoinPaprikaMarketCopyWith<$Res> { + factory $CoinPaprikaMarketCopyWith(CoinPaprikaMarket value, $Res Function(CoinPaprikaMarket) _then) = _$CoinPaprikaMarketCopyWithImpl; +@useResult +$Res call({ + String exchangeId, String exchangeName, String pair, String baseCurrencyId, String baseCurrencyName, String quoteCurrencyId, String quoteCurrencyName, String marketUrl, String category, String feeType, bool outlier, double adjustedVolume24hShare, Map quotes, String lastUpdated +}); + + + + +} +/// @nodoc +class _$CoinPaprikaMarketCopyWithImpl<$Res> + implements $CoinPaprikaMarketCopyWith<$Res> { + _$CoinPaprikaMarketCopyWithImpl(this._self, this._then); + + final CoinPaprikaMarket _self; + final $Res Function(CoinPaprikaMarket) _then; + +/// Create a copy of CoinPaprikaMarket +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? exchangeId = null,Object? exchangeName = null,Object? pair = null,Object? baseCurrencyId = null,Object? baseCurrencyName = null,Object? quoteCurrencyId = null,Object? quoteCurrencyName = null,Object? marketUrl = null,Object? category = null,Object? feeType = null,Object? outlier = null,Object? adjustedVolume24hShare = null,Object? quotes = null,Object? lastUpdated = null,}) { + return _then(_self.copyWith( +exchangeId: null == exchangeId ? _self.exchangeId : exchangeId // ignore: cast_nullable_to_non_nullable +as String,exchangeName: null == exchangeName ? _self.exchangeName : exchangeName // ignore: cast_nullable_to_non_nullable +as String,pair: null == pair ? _self.pair : pair // ignore: cast_nullable_to_non_nullable +as String,baseCurrencyId: null == baseCurrencyId ? _self.baseCurrencyId : baseCurrencyId // ignore: cast_nullable_to_non_nullable +as String,baseCurrencyName: null == baseCurrencyName ? _self.baseCurrencyName : baseCurrencyName // ignore: cast_nullable_to_non_nullable +as String,quoteCurrencyId: null == quoteCurrencyId ? _self.quoteCurrencyId : quoteCurrencyId // ignore: cast_nullable_to_non_nullable +as String,quoteCurrencyName: null == quoteCurrencyName ? _self.quoteCurrencyName : quoteCurrencyName // ignore: cast_nullable_to_non_nullable +as String,marketUrl: null == marketUrl ? _self.marketUrl : marketUrl // ignore: cast_nullable_to_non_nullable +as String,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable +as String,feeType: null == feeType ? _self.feeType : feeType // ignore: cast_nullable_to_non_nullable +as String,outlier: null == outlier ? _self.outlier : outlier // ignore: cast_nullable_to_non_nullable +as bool,adjustedVolume24hShare: null == adjustedVolume24hShare ? _self.adjustedVolume24hShare : adjustedVolume24hShare // ignore: cast_nullable_to_non_nullable +as double,quotes: null == quotes ? _self.quotes : quotes // ignore: cast_nullable_to_non_nullable +as Map,lastUpdated: null == lastUpdated ? _self.lastUpdated : lastUpdated // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoinPaprikaMarket]. +extension CoinPaprikaMarketPatterns on CoinPaprikaMarket { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CoinPaprikaMarket value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CoinPaprikaMarket() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CoinPaprikaMarket value) $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaMarket(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CoinPaprikaMarket value)? $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaMarket() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String exchangeId, String exchangeName, String pair, String baseCurrencyId, String baseCurrencyName, String quoteCurrencyId, String quoteCurrencyName, String marketUrl, String category, String feeType, bool outlier, double adjustedVolume24hShare, Map quotes, String lastUpdated)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CoinPaprikaMarket() when $default != null: +return $default(_that.exchangeId,_that.exchangeName,_that.pair,_that.baseCurrencyId,_that.baseCurrencyName,_that.quoteCurrencyId,_that.quoteCurrencyName,_that.marketUrl,_that.category,_that.feeType,_that.outlier,_that.adjustedVolume24hShare,_that.quotes,_that.lastUpdated);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String exchangeId, String exchangeName, String pair, String baseCurrencyId, String baseCurrencyName, String quoteCurrencyId, String quoteCurrencyName, String marketUrl, String category, String feeType, bool outlier, double adjustedVolume24hShare, Map quotes, String lastUpdated) $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaMarket(): +return $default(_that.exchangeId,_that.exchangeName,_that.pair,_that.baseCurrencyId,_that.baseCurrencyName,_that.quoteCurrencyId,_that.quoteCurrencyName,_that.marketUrl,_that.category,_that.feeType,_that.outlier,_that.adjustedVolume24hShare,_that.quotes,_that.lastUpdated);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String exchangeId, String exchangeName, String pair, String baseCurrencyId, String baseCurrencyName, String quoteCurrencyId, String quoteCurrencyName, String marketUrl, String category, String feeType, bool outlier, double adjustedVolume24hShare, Map quotes, String lastUpdated)? $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaMarket() when $default != null: +return $default(_that.exchangeId,_that.exchangeName,_that.pair,_that.baseCurrencyId,_that.baseCurrencyName,_that.quoteCurrencyId,_that.quoteCurrencyName,_that.marketUrl,_that.category,_that.feeType,_that.outlier,_that.adjustedVolume24hShare,_that.quotes,_that.lastUpdated);case _: + return null; + +} +} + +} + +/// @nodoc + +@JsonSerializable(fieldRename: FieldRename.snake) +class _CoinPaprikaMarket implements CoinPaprikaMarket { + const _CoinPaprikaMarket({required this.exchangeId, required this.exchangeName, required this.pair, required this.baseCurrencyId, required this.baseCurrencyName, required this.quoteCurrencyId, required this.quoteCurrencyName, required this.marketUrl, required this.category, required this.feeType, required this.outlier, required this.adjustedVolume24hShare, required final Map quotes, required this.lastUpdated}): _quotes = quotes; + factory _CoinPaprikaMarket.fromJson(Map json) => _$CoinPaprikaMarketFromJson(json); + +/// Exchange identifier (e.g., "binance") +@override final String exchangeId; +/// Exchange display name (e.g., "Binance") +@override final String exchangeName; +/// Trading pair (e.g., "BTC/USDT") +@override final String pair; +/// Base currency identifier (e.g., "btc-bitcoin") +@override final String baseCurrencyId; +/// Base currency name (e.g., "Bitcoin") +@override final String baseCurrencyName; +/// Quote currency identifier (e.g., "usdt-tether") +@override final String quoteCurrencyId; +/// Quote currency name (e.g., "Tether") +@override final String quoteCurrencyName; +/// Direct URL to the market on the exchange +@override final String marketUrl; +/// Market category (e.g., "Spot") +@override final String category; +/// Fee type (e.g., "Percentage") +@override final String feeType; +/// Whether this market is considered an outlier +@override final bool outlier; +/// Adjusted 24h volume share percentage +@override final double adjustedVolume24hShare; +/// Quote data for different currencies + final Map _quotes; +/// Quote data for different currencies +@override Map get quotes { + if (_quotes is EqualUnmodifiableMapView) return _quotes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_quotes); +} + +/// Last update timestamp as ISO 8601 string +@override final String lastUpdated; + +/// Create a copy of CoinPaprikaMarket +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CoinPaprikaMarketCopyWith<_CoinPaprikaMarket> get copyWith => __$CoinPaprikaMarketCopyWithImpl<_CoinPaprikaMarket>(this, _$identity); + +@override +Map toJson() { + return _$CoinPaprikaMarketToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CoinPaprikaMarket&&(identical(other.exchangeId, exchangeId) || other.exchangeId == exchangeId)&&(identical(other.exchangeName, exchangeName) || other.exchangeName == exchangeName)&&(identical(other.pair, pair) || other.pair == pair)&&(identical(other.baseCurrencyId, baseCurrencyId) || other.baseCurrencyId == baseCurrencyId)&&(identical(other.baseCurrencyName, baseCurrencyName) || other.baseCurrencyName == baseCurrencyName)&&(identical(other.quoteCurrencyId, quoteCurrencyId) || other.quoteCurrencyId == quoteCurrencyId)&&(identical(other.quoteCurrencyName, quoteCurrencyName) || other.quoteCurrencyName == quoteCurrencyName)&&(identical(other.marketUrl, marketUrl) || other.marketUrl == marketUrl)&&(identical(other.category, category) || other.category == category)&&(identical(other.feeType, feeType) || other.feeType == feeType)&&(identical(other.outlier, outlier) || other.outlier == outlier)&&(identical(other.adjustedVolume24hShare, adjustedVolume24hShare) || other.adjustedVolume24hShare == adjustedVolume24hShare)&&const DeepCollectionEquality().equals(other._quotes, _quotes)&&(identical(other.lastUpdated, lastUpdated) || other.lastUpdated == lastUpdated)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,exchangeId,exchangeName,pair,baseCurrencyId,baseCurrencyName,quoteCurrencyId,quoteCurrencyName,marketUrl,category,feeType,outlier,adjustedVolume24hShare,const DeepCollectionEquality().hash(_quotes),lastUpdated); + +@override +String toString() { + return 'CoinPaprikaMarket(exchangeId: $exchangeId, exchangeName: $exchangeName, pair: $pair, baseCurrencyId: $baseCurrencyId, baseCurrencyName: $baseCurrencyName, quoteCurrencyId: $quoteCurrencyId, quoteCurrencyName: $quoteCurrencyName, marketUrl: $marketUrl, category: $category, feeType: $feeType, outlier: $outlier, adjustedVolume24hShare: $adjustedVolume24hShare, quotes: $quotes, lastUpdated: $lastUpdated)'; +} + + +} + +/// @nodoc +abstract mixin class _$CoinPaprikaMarketCopyWith<$Res> implements $CoinPaprikaMarketCopyWith<$Res> { + factory _$CoinPaprikaMarketCopyWith(_CoinPaprikaMarket value, $Res Function(_CoinPaprikaMarket) _then) = __$CoinPaprikaMarketCopyWithImpl; +@override @useResult +$Res call({ + String exchangeId, String exchangeName, String pair, String baseCurrencyId, String baseCurrencyName, String quoteCurrencyId, String quoteCurrencyName, String marketUrl, String category, String feeType, bool outlier, double adjustedVolume24hShare, Map quotes, String lastUpdated +}); + + + + +} +/// @nodoc +class __$CoinPaprikaMarketCopyWithImpl<$Res> + implements _$CoinPaprikaMarketCopyWith<$Res> { + __$CoinPaprikaMarketCopyWithImpl(this._self, this._then); + + final _CoinPaprikaMarket _self; + final $Res Function(_CoinPaprikaMarket) _then; + +/// Create a copy of CoinPaprikaMarket +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? exchangeId = null,Object? exchangeName = null,Object? pair = null,Object? baseCurrencyId = null,Object? baseCurrencyName = null,Object? quoteCurrencyId = null,Object? quoteCurrencyName = null,Object? marketUrl = null,Object? category = null,Object? feeType = null,Object? outlier = null,Object? adjustedVolume24hShare = null,Object? quotes = null,Object? lastUpdated = null,}) { + return _then(_CoinPaprikaMarket( +exchangeId: null == exchangeId ? _self.exchangeId : exchangeId // ignore: cast_nullable_to_non_nullable +as String,exchangeName: null == exchangeName ? _self.exchangeName : exchangeName // ignore: cast_nullable_to_non_nullable +as String,pair: null == pair ? _self.pair : pair // ignore: cast_nullable_to_non_nullable +as String,baseCurrencyId: null == baseCurrencyId ? _self.baseCurrencyId : baseCurrencyId // ignore: cast_nullable_to_non_nullable +as String,baseCurrencyName: null == baseCurrencyName ? _self.baseCurrencyName : baseCurrencyName // ignore: cast_nullable_to_non_nullable +as String,quoteCurrencyId: null == quoteCurrencyId ? _self.quoteCurrencyId : quoteCurrencyId // ignore: cast_nullable_to_non_nullable +as String,quoteCurrencyName: null == quoteCurrencyName ? _self.quoteCurrencyName : quoteCurrencyName // ignore: cast_nullable_to_non_nullable +as String,marketUrl: null == marketUrl ? _self.marketUrl : marketUrl // ignore: cast_nullable_to_non_nullable +as String,category: null == category ? _self.category : category // ignore: cast_nullable_to_non_nullable +as String,feeType: null == feeType ? _self.feeType : feeType // ignore: cast_nullable_to_non_nullable +as String,outlier: null == outlier ? _self.outlier : outlier // ignore: cast_nullable_to_non_nullable +as bool,adjustedVolume24hShare: null == adjustedVolume24hShare ? _self.adjustedVolume24hShare : adjustedVolume24hShare // ignore: cast_nullable_to_non_nullable +as double,quotes: null == quotes ? _self._quotes : quotes // ignore: cast_nullable_to_non_nullable +as Map,lastUpdated: null == lastUpdated ? _self.lastUpdated : lastUpdated // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$CoinPaprikaQuote { + +/// Current price as a [Decimal] for precision +@DecimalConverter() Decimal get price;/// 24-hour trading volume as a [Decimal] +@JsonKey(name: 'volume_24h')@DecimalConverter() Decimal get volume24h; +/// Create a copy of CoinPaprikaQuote +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoinPaprikaQuoteCopyWith get copyWith => _$CoinPaprikaQuoteCopyWithImpl(this as CoinPaprikaQuote, _$identity); + + /// Serializes this CoinPaprikaQuote to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoinPaprikaQuote&&(identical(other.price, price) || other.price == price)&&(identical(other.volume24h, volume24h) || other.volume24h == volume24h)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,price,volume24h); + +@override +String toString() { + return 'CoinPaprikaQuote(price: $price, volume24h: $volume24h)'; +} + + +} + +/// @nodoc +abstract mixin class $CoinPaprikaQuoteCopyWith<$Res> { + factory $CoinPaprikaQuoteCopyWith(CoinPaprikaQuote value, $Res Function(CoinPaprikaQuote) _then) = _$CoinPaprikaQuoteCopyWithImpl; +@useResult +$Res call({ +@DecimalConverter() Decimal price,@JsonKey(name: 'volume_24h')@DecimalConverter() Decimal volume24h +}); + + + + +} +/// @nodoc +class _$CoinPaprikaQuoteCopyWithImpl<$Res> + implements $CoinPaprikaQuoteCopyWith<$Res> { + _$CoinPaprikaQuoteCopyWithImpl(this._self, this._then); + + final CoinPaprikaQuote _self; + final $Res Function(CoinPaprikaQuote) _then; + +/// Create a copy of CoinPaprikaQuote +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? price = null,Object? volume24h = null,}) { + return _then(_self.copyWith( +price: null == price ? _self.price : price // ignore: cast_nullable_to_non_nullable +as Decimal,volume24h: null == volume24h ? _self.volume24h : volume24h // ignore: cast_nullable_to_non_nullable +as Decimal, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoinPaprikaQuote]. +extension CoinPaprikaQuotePatterns on CoinPaprikaQuote { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CoinPaprikaQuote value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CoinPaprikaQuote() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CoinPaprikaQuote value) $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaQuote(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CoinPaprikaQuote value)? $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaQuote() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@DecimalConverter() Decimal price, @JsonKey(name: 'volume_24h')@DecimalConverter() Decimal volume24h)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CoinPaprikaQuote() when $default != null: +return $default(_that.price,_that.volume24h);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@DecimalConverter() Decimal price, @JsonKey(name: 'volume_24h')@DecimalConverter() Decimal volume24h) $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaQuote(): +return $default(_that.price,_that.volume24h);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@DecimalConverter() Decimal price, @JsonKey(name: 'volume_24h')@DecimalConverter() Decimal volume24h)? $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaQuote() when $default != null: +return $default(_that.price,_that.volume24h);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _CoinPaprikaQuote implements CoinPaprikaQuote { + const _CoinPaprikaQuote({@DecimalConverter() required this.price, @JsonKey(name: 'volume_24h')@DecimalConverter() required this.volume24h}); + factory _CoinPaprikaQuote.fromJson(Map json) => _$CoinPaprikaQuoteFromJson(json); + +/// Current price as a [Decimal] for precision +@override@DecimalConverter() final Decimal price; +/// 24-hour trading volume as a [Decimal] +@override@JsonKey(name: 'volume_24h')@DecimalConverter() final Decimal volume24h; + +/// Create a copy of CoinPaprikaQuote +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CoinPaprikaQuoteCopyWith<_CoinPaprikaQuote> get copyWith => __$CoinPaprikaQuoteCopyWithImpl<_CoinPaprikaQuote>(this, _$identity); + +@override +Map toJson() { + return _$CoinPaprikaQuoteToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CoinPaprikaQuote&&(identical(other.price, price) || other.price == price)&&(identical(other.volume24h, volume24h) || other.volume24h == volume24h)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,price,volume24h); + +@override +String toString() { + return 'CoinPaprikaQuote(price: $price, volume24h: $volume24h)'; +} + + +} + +/// @nodoc +abstract mixin class _$CoinPaprikaQuoteCopyWith<$Res> implements $CoinPaprikaQuoteCopyWith<$Res> { + factory _$CoinPaprikaQuoteCopyWith(_CoinPaprikaQuote value, $Res Function(_CoinPaprikaQuote) _then) = __$CoinPaprikaQuoteCopyWithImpl; +@override @useResult +$Res call({ +@DecimalConverter() Decimal price,@JsonKey(name: 'volume_24h')@DecimalConverter() Decimal volume24h +}); + + + + +} +/// @nodoc +class __$CoinPaprikaQuoteCopyWithImpl<$Res> + implements _$CoinPaprikaQuoteCopyWith<$Res> { + __$CoinPaprikaQuoteCopyWithImpl(this._self, this._then); + + final _CoinPaprikaQuote _self; + final $Res Function(_CoinPaprikaQuote) _then; + +/// Create a copy of CoinPaprikaQuote +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? price = null,Object? volume24h = null,}) { + return _then(_CoinPaprikaQuote( +price: null == price ? _self.price : price // ignore: cast_nullable_to_non_nullable +as Decimal,volume24h: null == volume24h ? _self.volume24h : volume24h // ignore: cast_nullable_to_non_nullable +as Decimal, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.g.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.g.dart new file mode 100644 index 00000000..3c4528c2 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_market.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'coinpaprika_market.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CoinPaprikaMarket _$CoinPaprikaMarketFromJson(Map json) => + _CoinPaprikaMarket( + exchangeId: json['exchange_id'] as String, + exchangeName: json['exchange_name'] as String, + pair: json['pair'] as String, + baseCurrencyId: json['base_currency_id'] as String, + baseCurrencyName: json['base_currency_name'] as String, + quoteCurrencyId: json['quote_currency_id'] as String, + quoteCurrencyName: json['quote_currency_name'] as String, + marketUrl: json['market_url'] as String, + category: json['category'] as String, + feeType: json['fee_type'] as String, + outlier: json['outlier'] as bool, + adjustedVolume24hShare: (json['adjusted_volume24h_share'] as num) + .toDouble(), + quotes: (json['quotes'] as Map).map( + (k, e) => + MapEntry(k, CoinPaprikaQuote.fromJson(e as Map)), + ), + lastUpdated: json['last_updated'] as String, + ); + +Map _$CoinPaprikaMarketToJson(_CoinPaprikaMarket instance) => + { + 'exchange_id': instance.exchangeId, + 'exchange_name': instance.exchangeName, + 'pair': instance.pair, + 'base_currency_id': instance.baseCurrencyId, + 'base_currency_name': instance.baseCurrencyName, + 'quote_currency_id': instance.quoteCurrencyId, + 'quote_currency_name': instance.quoteCurrencyName, + 'market_url': instance.marketUrl, + 'category': instance.category, + 'fee_type': instance.feeType, + 'outlier': instance.outlier, + 'adjusted_volume24h_share': instance.adjustedVolume24hShare, + 'quotes': instance.quotes, + 'last_updated': instance.lastUpdated, + }; + +_CoinPaprikaQuote _$CoinPaprikaQuoteFromJson(Map json) => + _CoinPaprikaQuote( + price: Decimal.fromJson(json['price'] as String), + volume24h: Decimal.fromJson(json['volume_24h'] as String), + ); + +Map _$CoinPaprikaQuoteToJson(_CoinPaprikaQuote instance) => + { + 'price': instance.price, + 'volume_24h': instance.volume24h, + }; diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.dart new file mode 100644 index 00000000..2d8fb5e0 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.dart @@ -0,0 +1,50 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_ticker_quote.dart'; + +part 'coinpaprika_ticker.freezed.dart'; +part 'coinpaprika_ticker.g.dart'; + +/// Represents ticker data from CoinPaprika's ticker endpoint. +@freezed +abstract class CoinPaprikaTicker with _$CoinPaprikaTicker { + /// Creates a CoinPaprika ticker instance. + @JsonSerializable(fieldRename: FieldRename.snake) + const factory CoinPaprikaTicker({ + /// Map of quotes for different currencies (BTC, USD, etc.) + required Map quotes, + + /// Unique identifier for the coin (e.g., "btc-bitcoin") + @Default('') String id, + + /// Full name of the coin (e.g., "Bitcoin") + @Default('') String name, + + /// Symbol/ticker of the coin (e.g., "BTC") + @Default('') String symbol, + + /// Market ranking of the coin + @Default(0) int rank, + + /// Circulating supply of the coin + @Default(0) int circulatingSupply, + + /// Total supply of the coin + @Default(0) int totalSupply, + + /// Maximum supply of the coin (nullable) + int? maxSupply, + + /// Beta value (volatility measure) + @Default(0.0) double betaValue, + + /// Date of first data point + DateTime? firstDataAt, + + /// Last updated timestamp + DateTime? lastUpdated, + }) = _CoinPaprikaTicker; + + /// Creates a CoinPaprika ticker instance from JSON. + factory CoinPaprikaTicker.fromJson(Map json) => + _$CoinPaprikaTickerFromJson(json); +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.freezed.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.freezed.dart new file mode 100644 index 00000000..efba236c --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.freezed.dart @@ -0,0 +1,336 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'coinpaprika_ticker.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CoinPaprikaTicker { + +/// Map of quotes for different currencies (BTC, USD, etc.) + Map get quotes;/// Unique identifier for the coin (e.g., "btc-bitcoin") + String get id;/// Full name of the coin (e.g., "Bitcoin") + String get name;/// Symbol/ticker of the coin (e.g., "BTC") + String get symbol;/// Market ranking of the coin + int get rank;/// Circulating supply of the coin + int get circulatingSupply;/// Total supply of the coin + int get totalSupply;/// Maximum supply of the coin (nullable) + int? get maxSupply;/// Beta value (volatility measure) + double get betaValue;/// Date of first data point + DateTime? get firstDataAt;/// Last updated timestamp + DateTime? get lastUpdated; +/// Create a copy of CoinPaprikaTicker +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoinPaprikaTickerCopyWith get copyWith => _$CoinPaprikaTickerCopyWithImpl(this as CoinPaprikaTicker, _$identity); + + /// Serializes this CoinPaprikaTicker to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoinPaprikaTicker&&const DeepCollectionEquality().equals(other.quotes, quotes)&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.rank, rank) || other.rank == rank)&&(identical(other.circulatingSupply, circulatingSupply) || other.circulatingSupply == circulatingSupply)&&(identical(other.totalSupply, totalSupply) || other.totalSupply == totalSupply)&&(identical(other.maxSupply, maxSupply) || other.maxSupply == maxSupply)&&(identical(other.betaValue, betaValue) || other.betaValue == betaValue)&&(identical(other.firstDataAt, firstDataAt) || other.firstDataAt == firstDataAt)&&(identical(other.lastUpdated, lastUpdated) || other.lastUpdated == lastUpdated)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(quotes),id,name,symbol,rank,circulatingSupply,totalSupply,maxSupply,betaValue,firstDataAt,lastUpdated); + +@override +String toString() { + return 'CoinPaprikaTicker(quotes: $quotes, id: $id, name: $name, symbol: $symbol, rank: $rank, circulatingSupply: $circulatingSupply, totalSupply: $totalSupply, maxSupply: $maxSupply, betaValue: $betaValue, firstDataAt: $firstDataAt, lastUpdated: $lastUpdated)'; +} + + +} + +/// @nodoc +abstract mixin class $CoinPaprikaTickerCopyWith<$Res> { + factory $CoinPaprikaTickerCopyWith(CoinPaprikaTicker value, $Res Function(CoinPaprikaTicker) _then) = _$CoinPaprikaTickerCopyWithImpl; +@useResult +$Res call({ + Map quotes, String id, String name, String symbol, int rank, int circulatingSupply, int totalSupply, int? maxSupply, double betaValue, DateTime? firstDataAt, DateTime? lastUpdated +}); + + + + +} +/// @nodoc +class _$CoinPaprikaTickerCopyWithImpl<$Res> + implements $CoinPaprikaTickerCopyWith<$Res> { + _$CoinPaprikaTickerCopyWithImpl(this._self, this._then); + + final CoinPaprikaTicker _self; + final $Res Function(CoinPaprikaTicker) _then; + +/// Create a copy of CoinPaprikaTicker +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? quotes = null,Object? id = null,Object? name = null,Object? symbol = null,Object? rank = null,Object? circulatingSupply = null,Object? totalSupply = null,Object? maxSupply = freezed,Object? betaValue = null,Object? firstDataAt = freezed,Object? lastUpdated = freezed,}) { + return _then(_self.copyWith( +quotes: null == quotes ? _self.quotes : quotes // ignore: cast_nullable_to_non_nullable +as Map,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable +as String,rank: null == rank ? _self.rank : rank // ignore: cast_nullable_to_non_nullable +as int,circulatingSupply: null == circulatingSupply ? _self.circulatingSupply : circulatingSupply // ignore: cast_nullable_to_non_nullable +as int,totalSupply: null == totalSupply ? _self.totalSupply : totalSupply // ignore: cast_nullable_to_non_nullable +as int,maxSupply: freezed == maxSupply ? _self.maxSupply : maxSupply // ignore: cast_nullable_to_non_nullable +as int?,betaValue: null == betaValue ? _self.betaValue : betaValue // ignore: cast_nullable_to_non_nullable +as double,firstDataAt: freezed == firstDataAt ? _self.firstDataAt : firstDataAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastUpdated: freezed == lastUpdated ? _self.lastUpdated : lastUpdated // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoinPaprikaTicker]. +extension CoinPaprikaTickerPatterns on CoinPaprikaTicker { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CoinPaprikaTicker value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CoinPaprikaTicker() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CoinPaprikaTicker value) $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaTicker(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CoinPaprikaTicker value)? $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaTicker() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Map quotes, String id, String name, String symbol, int rank, int circulatingSupply, int totalSupply, int? maxSupply, double betaValue, DateTime? firstDataAt, DateTime? lastUpdated)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CoinPaprikaTicker() when $default != null: +return $default(_that.quotes,_that.id,_that.name,_that.symbol,_that.rank,_that.circulatingSupply,_that.totalSupply,_that.maxSupply,_that.betaValue,_that.firstDataAt,_that.lastUpdated);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Map quotes, String id, String name, String symbol, int rank, int circulatingSupply, int totalSupply, int? maxSupply, double betaValue, DateTime? firstDataAt, DateTime? lastUpdated) $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaTicker(): +return $default(_that.quotes,_that.id,_that.name,_that.symbol,_that.rank,_that.circulatingSupply,_that.totalSupply,_that.maxSupply,_that.betaValue,_that.firstDataAt,_that.lastUpdated);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map quotes, String id, String name, String symbol, int rank, int circulatingSupply, int totalSupply, int? maxSupply, double betaValue, DateTime? firstDataAt, DateTime? lastUpdated)? $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaTicker() when $default != null: +return $default(_that.quotes,_that.id,_that.name,_that.symbol,_that.rank,_that.circulatingSupply,_that.totalSupply,_that.maxSupply,_that.betaValue,_that.firstDataAt,_that.lastUpdated);case _: + return null; + +} +} + +} + +/// @nodoc + +@JsonSerializable(fieldRename: FieldRename.snake) +class _CoinPaprikaTicker implements CoinPaprikaTicker { + const _CoinPaprikaTicker({required final Map quotes, this.id = '', this.name = '', this.symbol = '', this.rank = 0, this.circulatingSupply = 0, this.totalSupply = 0, this.maxSupply, this.betaValue = 0.0, this.firstDataAt, this.lastUpdated}): _quotes = quotes; + factory _CoinPaprikaTicker.fromJson(Map json) => _$CoinPaprikaTickerFromJson(json); + +/// Map of quotes for different currencies (BTC, USD, etc.) + final Map _quotes; +/// Map of quotes for different currencies (BTC, USD, etc.) +@override Map get quotes { + if (_quotes is EqualUnmodifiableMapView) return _quotes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_quotes); +} + +/// Unique identifier for the coin (e.g., "btc-bitcoin") +@override@JsonKey() final String id; +/// Full name of the coin (e.g., "Bitcoin") +@override@JsonKey() final String name; +/// Symbol/ticker of the coin (e.g., "BTC") +@override@JsonKey() final String symbol; +/// Market ranking of the coin +@override@JsonKey() final int rank; +/// Circulating supply of the coin +@override@JsonKey() final int circulatingSupply; +/// Total supply of the coin +@override@JsonKey() final int totalSupply; +/// Maximum supply of the coin (nullable) +@override final int? maxSupply; +/// Beta value (volatility measure) +@override@JsonKey() final double betaValue; +/// Date of first data point +@override final DateTime? firstDataAt; +/// Last updated timestamp +@override final DateTime? lastUpdated; + +/// Create a copy of CoinPaprikaTicker +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CoinPaprikaTickerCopyWith<_CoinPaprikaTicker> get copyWith => __$CoinPaprikaTickerCopyWithImpl<_CoinPaprikaTicker>(this, _$identity); + +@override +Map toJson() { + return _$CoinPaprikaTickerToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CoinPaprikaTicker&&const DeepCollectionEquality().equals(other._quotes, _quotes)&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.rank, rank) || other.rank == rank)&&(identical(other.circulatingSupply, circulatingSupply) || other.circulatingSupply == circulatingSupply)&&(identical(other.totalSupply, totalSupply) || other.totalSupply == totalSupply)&&(identical(other.maxSupply, maxSupply) || other.maxSupply == maxSupply)&&(identical(other.betaValue, betaValue) || other.betaValue == betaValue)&&(identical(other.firstDataAt, firstDataAt) || other.firstDataAt == firstDataAt)&&(identical(other.lastUpdated, lastUpdated) || other.lastUpdated == lastUpdated)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_quotes),id,name,symbol,rank,circulatingSupply,totalSupply,maxSupply,betaValue,firstDataAt,lastUpdated); + +@override +String toString() { + return 'CoinPaprikaTicker(quotes: $quotes, id: $id, name: $name, symbol: $symbol, rank: $rank, circulatingSupply: $circulatingSupply, totalSupply: $totalSupply, maxSupply: $maxSupply, betaValue: $betaValue, firstDataAt: $firstDataAt, lastUpdated: $lastUpdated)'; +} + + +} + +/// @nodoc +abstract mixin class _$CoinPaprikaTickerCopyWith<$Res> implements $CoinPaprikaTickerCopyWith<$Res> { + factory _$CoinPaprikaTickerCopyWith(_CoinPaprikaTicker value, $Res Function(_CoinPaprikaTicker) _then) = __$CoinPaprikaTickerCopyWithImpl; +@override @useResult +$Res call({ + Map quotes, String id, String name, String symbol, int rank, int circulatingSupply, int totalSupply, int? maxSupply, double betaValue, DateTime? firstDataAt, DateTime? lastUpdated +}); + + + + +} +/// @nodoc +class __$CoinPaprikaTickerCopyWithImpl<$Res> + implements _$CoinPaprikaTickerCopyWith<$Res> { + __$CoinPaprikaTickerCopyWithImpl(this._self, this._then); + + final _CoinPaprikaTicker _self; + final $Res Function(_CoinPaprikaTicker) _then; + +/// Create a copy of CoinPaprikaTicker +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? quotes = null,Object? id = null,Object? name = null,Object? symbol = null,Object? rank = null,Object? circulatingSupply = null,Object? totalSupply = null,Object? maxSupply = freezed,Object? betaValue = null,Object? firstDataAt = freezed,Object? lastUpdated = freezed,}) { + return _then(_CoinPaprikaTicker( +quotes: null == quotes ? _self._quotes : quotes // ignore: cast_nullable_to_non_nullable +as Map,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable +as String,rank: null == rank ? _self.rank : rank // ignore: cast_nullable_to_non_nullable +as int,circulatingSupply: null == circulatingSupply ? _self.circulatingSupply : circulatingSupply // ignore: cast_nullable_to_non_nullable +as int,totalSupply: null == totalSupply ? _self.totalSupply : totalSupply // ignore: cast_nullable_to_non_nullable +as int,maxSupply: freezed == maxSupply ? _self.maxSupply : maxSupply // ignore: cast_nullable_to_non_nullable +as int?,betaValue: null == betaValue ? _self.betaValue : betaValue // ignore: cast_nullable_to_non_nullable +as double,firstDataAt: freezed == firstDataAt ? _self.firstDataAt : firstDataAt // ignore: cast_nullable_to_non_nullable +as DateTime?,lastUpdated: freezed == lastUpdated ? _self.lastUpdated : lastUpdated // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.g.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.g.dart new file mode 100644 index 00000000..5c3a7ea9 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'coinpaprika_ticker.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CoinPaprikaTicker _$CoinPaprikaTickerFromJson(Map json) => + _CoinPaprikaTicker( + quotes: (json['quotes'] as Map).map( + (k, e) => MapEntry( + k, + CoinPaprikaTickerQuote.fromJson(e as Map), + ), + ), + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + symbol: json['symbol'] as String? ?? '', + rank: (json['rank'] as num?)?.toInt() ?? 0, + circulatingSupply: (json['circulating_supply'] as num?)?.toInt() ?? 0, + totalSupply: (json['total_supply'] as num?)?.toInt() ?? 0, + maxSupply: (json['max_supply'] as num?)?.toInt(), + betaValue: (json['beta_value'] as num?)?.toDouble() ?? 0.0, + firstDataAt: json['first_data_at'] == null + ? null + : DateTime.parse(json['first_data_at'] as String), + lastUpdated: json['last_updated'] == null + ? null + : DateTime.parse(json['last_updated'] as String), + ); + +Map _$CoinPaprikaTickerToJson(_CoinPaprikaTicker instance) => + { + 'quotes': instance.quotes, + 'id': instance.id, + 'name': instance.name, + 'symbol': instance.symbol, + 'rank': instance.rank, + 'circulating_supply': instance.circulatingSupply, + 'total_supply': instance.totalSupply, + 'max_supply': instance.maxSupply, + 'beta_value': instance.betaValue, + 'first_data_at': instance.firstDataAt?.toIso8601String(), + 'last_updated': instance.lastUpdated?.toIso8601String(), + }; diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.dart new file mode 100644 index 00000000..010cb757 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.dart @@ -0,0 +1,67 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'coinpaprika_ticker_quote.freezed.dart'; +part 'coinpaprika_ticker_quote.g.dart'; + +/// Represents a detailed quote for a specific currency from CoinPaprika's ticker endpoint. +@freezed +abstract class CoinPaprikaTickerQuote with _$CoinPaprikaTickerQuote { + /// Creates a CoinPaprika ticker quote instance. + @JsonSerializable(fieldRename: FieldRename.snake) + const factory CoinPaprikaTickerQuote({ + /// Current price in the quote currency + required double price, + + /// 24-hour trading volume + @Default(0.0) double volume24h, + + /// 24-hour volume change percentage + @Default(0.0) double volume24hChange24h, + + /// Market capitalization + @Default(0.0) double marketCap, + + /// 24-hour market cap change percentage + @Default(0.0) double marketCapChange24h, + + /// Price change percentage in the last 15 minutes + @Default(0.0) double percentChange15m, + + /// Price change percentage in the last 30 minutes + @Default(0.0) double percentChange30m, + + /// Price change percentage in the last 1 hour + @Default(0.0) double percentChange1h, + + /// Price change percentage in the last 6 hours + @Default(0.0) double percentChange6h, + + /// Price change percentage in the last 12 hours + @Default(0.0) double percentChange12h, + + /// Price change percentage in the last 24 hours + @Default(0.0) double percentChange24h, + + /// Price change percentage in the last 7 days + @Default(0.0) double percentChange7d, + + /// Price change percentage in the last 30 days + @Default(0.0) double percentChange30d, + + /// Price change percentage in the last 1 year + @Default(0.0) double percentChange1y, + + /// All-time high price (nullable) + double? athPrice, + + /// Date of all-time high (nullable) + DateTime? athDate, + + /// Percentage from all-time high price (nullable) + double? percentFromPriceAth, + }) = _CoinPaprikaTickerQuote; + + /// Creates a CoinPaprika ticker quote instance from JSON. + factory CoinPaprikaTickerQuote.fromJson(Map json) => + _$CoinPaprikaTickerQuoteFromJson(json); +} diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.freezed.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.freezed.dart new file mode 100644 index 00000000..aa0b8adb --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.freezed.dart @@ -0,0 +1,359 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'coinpaprika_ticker_quote.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CoinPaprikaTickerQuote { + +/// Current price in the quote currency + double get price;/// 24-hour trading volume + double get volume24h;/// 24-hour volume change percentage + double get volume24hChange24h;/// Market capitalization + double get marketCap;/// 24-hour market cap change percentage + double get marketCapChange24h;/// Price change percentage in the last 15 minutes + double get percentChange15m;/// Price change percentage in the last 30 minutes + double get percentChange30m;/// Price change percentage in the last 1 hour + double get percentChange1h;/// Price change percentage in the last 6 hours + double get percentChange6h;/// Price change percentage in the last 12 hours + double get percentChange12h;/// Price change percentage in the last 24 hours + double get percentChange24h;/// Price change percentage in the last 7 days + double get percentChange7d;/// Price change percentage in the last 30 days + double get percentChange30d;/// Price change percentage in the last 1 year + double get percentChange1y;/// All-time high price (nullable) + double? get athPrice;/// Date of all-time high (nullable) + DateTime? get athDate;/// Percentage from all-time high price (nullable) + double? get percentFromPriceAth; +/// Create a copy of CoinPaprikaTickerQuote +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoinPaprikaTickerQuoteCopyWith get copyWith => _$CoinPaprikaTickerQuoteCopyWithImpl(this as CoinPaprikaTickerQuote, _$identity); + + /// Serializes this CoinPaprikaTickerQuote to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoinPaprikaTickerQuote&&(identical(other.price, price) || other.price == price)&&(identical(other.volume24h, volume24h) || other.volume24h == volume24h)&&(identical(other.volume24hChange24h, volume24hChange24h) || other.volume24hChange24h == volume24hChange24h)&&(identical(other.marketCap, marketCap) || other.marketCap == marketCap)&&(identical(other.marketCapChange24h, marketCapChange24h) || other.marketCapChange24h == marketCapChange24h)&&(identical(other.percentChange15m, percentChange15m) || other.percentChange15m == percentChange15m)&&(identical(other.percentChange30m, percentChange30m) || other.percentChange30m == percentChange30m)&&(identical(other.percentChange1h, percentChange1h) || other.percentChange1h == percentChange1h)&&(identical(other.percentChange6h, percentChange6h) || other.percentChange6h == percentChange6h)&&(identical(other.percentChange12h, percentChange12h) || other.percentChange12h == percentChange12h)&&(identical(other.percentChange24h, percentChange24h) || other.percentChange24h == percentChange24h)&&(identical(other.percentChange7d, percentChange7d) || other.percentChange7d == percentChange7d)&&(identical(other.percentChange30d, percentChange30d) || other.percentChange30d == percentChange30d)&&(identical(other.percentChange1y, percentChange1y) || other.percentChange1y == percentChange1y)&&(identical(other.athPrice, athPrice) || other.athPrice == athPrice)&&(identical(other.athDate, athDate) || other.athDate == athDate)&&(identical(other.percentFromPriceAth, percentFromPriceAth) || other.percentFromPriceAth == percentFromPriceAth)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,price,volume24h,volume24hChange24h,marketCap,marketCapChange24h,percentChange15m,percentChange30m,percentChange1h,percentChange6h,percentChange12h,percentChange24h,percentChange7d,percentChange30d,percentChange1y,athPrice,athDate,percentFromPriceAth); + +@override +String toString() { + return 'CoinPaprikaTickerQuote(price: $price, volume24h: $volume24h, volume24hChange24h: $volume24hChange24h, marketCap: $marketCap, marketCapChange24h: $marketCapChange24h, percentChange15m: $percentChange15m, percentChange30m: $percentChange30m, percentChange1h: $percentChange1h, percentChange6h: $percentChange6h, percentChange12h: $percentChange12h, percentChange24h: $percentChange24h, percentChange7d: $percentChange7d, percentChange30d: $percentChange30d, percentChange1y: $percentChange1y, athPrice: $athPrice, athDate: $athDate, percentFromPriceAth: $percentFromPriceAth)'; +} + + +} + +/// @nodoc +abstract mixin class $CoinPaprikaTickerQuoteCopyWith<$Res> { + factory $CoinPaprikaTickerQuoteCopyWith(CoinPaprikaTickerQuote value, $Res Function(CoinPaprikaTickerQuote) _then) = _$CoinPaprikaTickerQuoteCopyWithImpl; +@useResult +$Res call({ + double price, double volume24h, double volume24hChange24h, double marketCap, double marketCapChange24h, double percentChange15m, double percentChange30m, double percentChange1h, double percentChange6h, double percentChange12h, double percentChange24h, double percentChange7d, double percentChange30d, double percentChange1y, double? athPrice, DateTime? athDate, double? percentFromPriceAth +}); + + + + +} +/// @nodoc +class _$CoinPaprikaTickerQuoteCopyWithImpl<$Res> + implements $CoinPaprikaTickerQuoteCopyWith<$Res> { + _$CoinPaprikaTickerQuoteCopyWithImpl(this._self, this._then); + + final CoinPaprikaTickerQuote _self; + final $Res Function(CoinPaprikaTickerQuote) _then; + +/// Create a copy of CoinPaprikaTickerQuote +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? price = null,Object? volume24h = null,Object? volume24hChange24h = null,Object? marketCap = null,Object? marketCapChange24h = null,Object? percentChange15m = null,Object? percentChange30m = null,Object? percentChange1h = null,Object? percentChange6h = null,Object? percentChange12h = null,Object? percentChange24h = null,Object? percentChange7d = null,Object? percentChange30d = null,Object? percentChange1y = null,Object? athPrice = freezed,Object? athDate = freezed,Object? percentFromPriceAth = freezed,}) { + return _then(_self.copyWith( +price: null == price ? _self.price : price // ignore: cast_nullable_to_non_nullable +as double,volume24h: null == volume24h ? _self.volume24h : volume24h // ignore: cast_nullable_to_non_nullable +as double,volume24hChange24h: null == volume24hChange24h ? _self.volume24hChange24h : volume24hChange24h // ignore: cast_nullable_to_non_nullable +as double,marketCap: null == marketCap ? _self.marketCap : marketCap // ignore: cast_nullable_to_non_nullable +as double,marketCapChange24h: null == marketCapChange24h ? _self.marketCapChange24h : marketCapChange24h // ignore: cast_nullable_to_non_nullable +as double,percentChange15m: null == percentChange15m ? _self.percentChange15m : percentChange15m // ignore: cast_nullable_to_non_nullable +as double,percentChange30m: null == percentChange30m ? _self.percentChange30m : percentChange30m // ignore: cast_nullable_to_non_nullable +as double,percentChange1h: null == percentChange1h ? _self.percentChange1h : percentChange1h // ignore: cast_nullable_to_non_nullable +as double,percentChange6h: null == percentChange6h ? _self.percentChange6h : percentChange6h // ignore: cast_nullable_to_non_nullable +as double,percentChange12h: null == percentChange12h ? _self.percentChange12h : percentChange12h // ignore: cast_nullable_to_non_nullable +as double,percentChange24h: null == percentChange24h ? _self.percentChange24h : percentChange24h // ignore: cast_nullable_to_non_nullable +as double,percentChange7d: null == percentChange7d ? _self.percentChange7d : percentChange7d // ignore: cast_nullable_to_non_nullable +as double,percentChange30d: null == percentChange30d ? _self.percentChange30d : percentChange30d // ignore: cast_nullable_to_non_nullable +as double,percentChange1y: null == percentChange1y ? _self.percentChange1y : percentChange1y // ignore: cast_nullable_to_non_nullable +as double,athPrice: freezed == athPrice ? _self.athPrice : athPrice // ignore: cast_nullable_to_non_nullable +as double?,athDate: freezed == athDate ? _self.athDate : athDate // ignore: cast_nullable_to_non_nullable +as DateTime?,percentFromPriceAth: freezed == percentFromPriceAth ? _self.percentFromPriceAth : percentFromPriceAth // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoinPaprikaTickerQuote]. +extension CoinPaprikaTickerQuotePatterns on CoinPaprikaTickerQuote { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CoinPaprikaTickerQuote value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CoinPaprikaTickerQuote() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CoinPaprikaTickerQuote value) $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaTickerQuote(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CoinPaprikaTickerQuote value)? $default,){ +final _that = this; +switch (_that) { +case _CoinPaprikaTickerQuote() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( double price, double volume24h, double volume24hChange24h, double marketCap, double marketCapChange24h, double percentChange15m, double percentChange30m, double percentChange1h, double percentChange6h, double percentChange12h, double percentChange24h, double percentChange7d, double percentChange30d, double percentChange1y, double? athPrice, DateTime? athDate, double? percentFromPriceAth)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CoinPaprikaTickerQuote() when $default != null: +return $default(_that.price,_that.volume24h,_that.volume24hChange24h,_that.marketCap,_that.marketCapChange24h,_that.percentChange15m,_that.percentChange30m,_that.percentChange1h,_that.percentChange6h,_that.percentChange12h,_that.percentChange24h,_that.percentChange7d,_that.percentChange30d,_that.percentChange1y,_that.athPrice,_that.athDate,_that.percentFromPriceAth);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( double price, double volume24h, double volume24hChange24h, double marketCap, double marketCapChange24h, double percentChange15m, double percentChange30m, double percentChange1h, double percentChange6h, double percentChange12h, double percentChange24h, double percentChange7d, double percentChange30d, double percentChange1y, double? athPrice, DateTime? athDate, double? percentFromPriceAth) $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaTickerQuote(): +return $default(_that.price,_that.volume24h,_that.volume24hChange24h,_that.marketCap,_that.marketCapChange24h,_that.percentChange15m,_that.percentChange30m,_that.percentChange1h,_that.percentChange6h,_that.percentChange12h,_that.percentChange24h,_that.percentChange7d,_that.percentChange30d,_that.percentChange1y,_that.athPrice,_that.athDate,_that.percentFromPriceAth);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double price, double volume24h, double volume24hChange24h, double marketCap, double marketCapChange24h, double percentChange15m, double percentChange30m, double percentChange1h, double percentChange6h, double percentChange12h, double percentChange24h, double percentChange7d, double percentChange30d, double percentChange1y, double? athPrice, DateTime? athDate, double? percentFromPriceAth)? $default,) {final _that = this; +switch (_that) { +case _CoinPaprikaTickerQuote() when $default != null: +return $default(_that.price,_that.volume24h,_that.volume24hChange24h,_that.marketCap,_that.marketCapChange24h,_that.percentChange15m,_that.percentChange30m,_that.percentChange1h,_that.percentChange6h,_that.percentChange12h,_that.percentChange24h,_that.percentChange7d,_that.percentChange30d,_that.percentChange1y,_that.athPrice,_that.athDate,_that.percentFromPriceAth);case _: + return null; + +} +} + +} + +/// @nodoc + +@JsonSerializable(fieldRename: FieldRename.snake) +class _CoinPaprikaTickerQuote implements CoinPaprikaTickerQuote { + const _CoinPaprikaTickerQuote({required this.price, this.volume24h = 0.0, this.volume24hChange24h = 0.0, this.marketCap = 0.0, this.marketCapChange24h = 0.0, this.percentChange15m = 0.0, this.percentChange30m = 0.0, this.percentChange1h = 0.0, this.percentChange6h = 0.0, this.percentChange12h = 0.0, this.percentChange24h = 0.0, this.percentChange7d = 0.0, this.percentChange30d = 0.0, this.percentChange1y = 0.0, this.athPrice, this.athDate, this.percentFromPriceAth}); + factory _CoinPaprikaTickerQuote.fromJson(Map json) => _$CoinPaprikaTickerQuoteFromJson(json); + +/// Current price in the quote currency +@override final double price; +/// 24-hour trading volume +@override@JsonKey() final double volume24h; +/// 24-hour volume change percentage +@override@JsonKey() final double volume24hChange24h; +/// Market capitalization +@override@JsonKey() final double marketCap; +/// 24-hour market cap change percentage +@override@JsonKey() final double marketCapChange24h; +/// Price change percentage in the last 15 minutes +@override@JsonKey() final double percentChange15m; +/// Price change percentage in the last 30 minutes +@override@JsonKey() final double percentChange30m; +/// Price change percentage in the last 1 hour +@override@JsonKey() final double percentChange1h; +/// Price change percentage in the last 6 hours +@override@JsonKey() final double percentChange6h; +/// Price change percentage in the last 12 hours +@override@JsonKey() final double percentChange12h; +/// Price change percentage in the last 24 hours +@override@JsonKey() final double percentChange24h; +/// Price change percentage in the last 7 days +@override@JsonKey() final double percentChange7d; +/// Price change percentage in the last 30 days +@override@JsonKey() final double percentChange30d; +/// Price change percentage in the last 1 year +@override@JsonKey() final double percentChange1y; +/// All-time high price (nullable) +@override final double? athPrice; +/// Date of all-time high (nullable) +@override final DateTime? athDate; +/// Percentage from all-time high price (nullable) +@override final double? percentFromPriceAth; + +/// Create a copy of CoinPaprikaTickerQuote +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CoinPaprikaTickerQuoteCopyWith<_CoinPaprikaTickerQuote> get copyWith => __$CoinPaprikaTickerQuoteCopyWithImpl<_CoinPaprikaTickerQuote>(this, _$identity); + +@override +Map toJson() { + return _$CoinPaprikaTickerQuoteToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CoinPaprikaTickerQuote&&(identical(other.price, price) || other.price == price)&&(identical(other.volume24h, volume24h) || other.volume24h == volume24h)&&(identical(other.volume24hChange24h, volume24hChange24h) || other.volume24hChange24h == volume24hChange24h)&&(identical(other.marketCap, marketCap) || other.marketCap == marketCap)&&(identical(other.marketCapChange24h, marketCapChange24h) || other.marketCapChange24h == marketCapChange24h)&&(identical(other.percentChange15m, percentChange15m) || other.percentChange15m == percentChange15m)&&(identical(other.percentChange30m, percentChange30m) || other.percentChange30m == percentChange30m)&&(identical(other.percentChange1h, percentChange1h) || other.percentChange1h == percentChange1h)&&(identical(other.percentChange6h, percentChange6h) || other.percentChange6h == percentChange6h)&&(identical(other.percentChange12h, percentChange12h) || other.percentChange12h == percentChange12h)&&(identical(other.percentChange24h, percentChange24h) || other.percentChange24h == percentChange24h)&&(identical(other.percentChange7d, percentChange7d) || other.percentChange7d == percentChange7d)&&(identical(other.percentChange30d, percentChange30d) || other.percentChange30d == percentChange30d)&&(identical(other.percentChange1y, percentChange1y) || other.percentChange1y == percentChange1y)&&(identical(other.athPrice, athPrice) || other.athPrice == athPrice)&&(identical(other.athDate, athDate) || other.athDate == athDate)&&(identical(other.percentFromPriceAth, percentFromPriceAth) || other.percentFromPriceAth == percentFromPriceAth)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,price,volume24h,volume24hChange24h,marketCap,marketCapChange24h,percentChange15m,percentChange30m,percentChange1h,percentChange6h,percentChange12h,percentChange24h,percentChange7d,percentChange30d,percentChange1y,athPrice,athDate,percentFromPriceAth); + +@override +String toString() { + return 'CoinPaprikaTickerQuote(price: $price, volume24h: $volume24h, volume24hChange24h: $volume24hChange24h, marketCap: $marketCap, marketCapChange24h: $marketCapChange24h, percentChange15m: $percentChange15m, percentChange30m: $percentChange30m, percentChange1h: $percentChange1h, percentChange6h: $percentChange6h, percentChange12h: $percentChange12h, percentChange24h: $percentChange24h, percentChange7d: $percentChange7d, percentChange30d: $percentChange30d, percentChange1y: $percentChange1y, athPrice: $athPrice, athDate: $athDate, percentFromPriceAth: $percentFromPriceAth)'; +} + + +} + +/// @nodoc +abstract mixin class _$CoinPaprikaTickerQuoteCopyWith<$Res> implements $CoinPaprikaTickerQuoteCopyWith<$Res> { + factory _$CoinPaprikaTickerQuoteCopyWith(_CoinPaprikaTickerQuote value, $Res Function(_CoinPaprikaTickerQuote) _then) = __$CoinPaprikaTickerQuoteCopyWithImpl; +@override @useResult +$Res call({ + double price, double volume24h, double volume24hChange24h, double marketCap, double marketCapChange24h, double percentChange15m, double percentChange30m, double percentChange1h, double percentChange6h, double percentChange12h, double percentChange24h, double percentChange7d, double percentChange30d, double percentChange1y, double? athPrice, DateTime? athDate, double? percentFromPriceAth +}); + + + + +} +/// @nodoc +class __$CoinPaprikaTickerQuoteCopyWithImpl<$Res> + implements _$CoinPaprikaTickerQuoteCopyWith<$Res> { + __$CoinPaprikaTickerQuoteCopyWithImpl(this._self, this._then); + + final _CoinPaprikaTickerQuote _self; + final $Res Function(_CoinPaprikaTickerQuote) _then; + +/// Create a copy of CoinPaprikaTickerQuote +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? price = null,Object? volume24h = null,Object? volume24hChange24h = null,Object? marketCap = null,Object? marketCapChange24h = null,Object? percentChange15m = null,Object? percentChange30m = null,Object? percentChange1h = null,Object? percentChange6h = null,Object? percentChange12h = null,Object? percentChange24h = null,Object? percentChange7d = null,Object? percentChange30d = null,Object? percentChange1y = null,Object? athPrice = freezed,Object? athDate = freezed,Object? percentFromPriceAth = freezed,}) { + return _then(_CoinPaprikaTickerQuote( +price: null == price ? _self.price : price // ignore: cast_nullable_to_non_nullable +as double,volume24h: null == volume24h ? _self.volume24h : volume24h // ignore: cast_nullable_to_non_nullable +as double,volume24hChange24h: null == volume24hChange24h ? _self.volume24hChange24h : volume24hChange24h // ignore: cast_nullable_to_non_nullable +as double,marketCap: null == marketCap ? _self.marketCap : marketCap // ignore: cast_nullable_to_non_nullable +as double,marketCapChange24h: null == marketCapChange24h ? _self.marketCapChange24h : marketCapChange24h // ignore: cast_nullable_to_non_nullable +as double,percentChange15m: null == percentChange15m ? _self.percentChange15m : percentChange15m // ignore: cast_nullable_to_non_nullable +as double,percentChange30m: null == percentChange30m ? _self.percentChange30m : percentChange30m // ignore: cast_nullable_to_non_nullable +as double,percentChange1h: null == percentChange1h ? _self.percentChange1h : percentChange1h // ignore: cast_nullable_to_non_nullable +as double,percentChange6h: null == percentChange6h ? _self.percentChange6h : percentChange6h // ignore: cast_nullable_to_non_nullable +as double,percentChange12h: null == percentChange12h ? _self.percentChange12h : percentChange12h // ignore: cast_nullable_to_non_nullable +as double,percentChange24h: null == percentChange24h ? _self.percentChange24h : percentChange24h // ignore: cast_nullable_to_non_nullable +as double,percentChange7d: null == percentChange7d ? _self.percentChange7d : percentChange7d // ignore: cast_nullable_to_non_nullable +as double,percentChange30d: null == percentChange30d ? _self.percentChange30d : percentChange30d // ignore: cast_nullable_to_non_nullable +as double,percentChange1y: null == percentChange1y ? _self.percentChange1y : percentChange1y // ignore: cast_nullable_to_non_nullable +as double,athPrice: freezed == athPrice ? _self.athPrice : athPrice // ignore: cast_nullable_to_non_nullable +as double?,athDate: freezed == athDate ? _self.athDate : athDate // ignore: cast_nullable_to_non_nullable +as DateTime?,percentFromPriceAth: freezed == percentFromPriceAth ? _self.percentFromPriceAth : percentFromPriceAth // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + + +} + +// dart format on diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.g.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.g.dart new file mode 100644 index 00000000..71cfebee --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/models/coinpaprika_ticker_quote.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'coinpaprika_ticker_quote.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CoinPaprikaTickerQuote _$CoinPaprikaTickerQuoteFromJson( + Map json, +) => _CoinPaprikaTickerQuote( + price: (json['price'] as num).toDouble(), + volume24h: (json['volume24h'] as num?)?.toDouble() ?? 0.0, + volume24hChange24h: (json['volume24h_change24h'] as num?)?.toDouble() ?? 0.0, + marketCap: (json['market_cap'] as num?)?.toDouble() ?? 0.0, + marketCapChange24h: (json['market_cap_change24h'] as num?)?.toDouble() ?? 0.0, + percentChange15m: (json['percent_change15m'] as num?)?.toDouble() ?? 0.0, + percentChange30m: (json['percent_change30m'] as num?)?.toDouble() ?? 0.0, + percentChange1h: (json['percent_change1h'] as num?)?.toDouble() ?? 0.0, + percentChange6h: (json['percent_change6h'] as num?)?.toDouble() ?? 0.0, + percentChange12h: (json['percent_change12h'] as num?)?.toDouble() ?? 0.0, + percentChange24h: (json['percent_change24h'] as num?)?.toDouble() ?? 0.0, + percentChange7d: (json['percent_change7d'] as num?)?.toDouble() ?? 0.0, + percentChange30d: (json['percent_change30d'] as num?)?.toDouble() ?? 0.0, + percentChange1y: (json['percent_change1y'] as num?)?.toDouble() ?? 0.0, + athPrice: (json['ath_price'] as num?)?.toDouble(), + athDate: json['ath_date'] == null + ? null + : DateTime.parse(json['ath_date'] as String), + percentFromPriceAth: (json['percent_from_price_ath'] as num?)?.toDouble(), +); + +Map _$CoinPaprikaTickerQuoteToJson( + _CoinPaprikaTickerQuote instance, +) => { + 'price': instance.price, + 'volume24h': instance.volume24h, + 'volume24h_change24h': instance.volume24hChange24h, + 'market_cap': instance.marketCap, + 'market_cap_change24h': instance.marketCapChange24h, + 'percent_change15m': instance.percentChange15m, + 'percent_change30m': instance.percentChange30m, + 'percent_change1h': instance.percentChange1h, + 'percent_change6h': instance.percentChange6h, + 'percent_change12h': instance.percentChange12h, + 'percent_change24h': instance.percentChange24h, + 'percent_change7d': instance.percentChange7d, + 'percent_change30d': instance.percentChange30d, + 'percent_change1y': instance.percentChange1y, + 'ath_price': instance.athPrice, + 'ath_date': instance.athDate?.toIso8601String(), + 'percent_from_price_ath': instance.percentFromPriceAth, +}; diff --git a/packages/komodo_cex_market_data/lib/src/common/_common_index.dart b/packages/komodo_cex_market_data/lib/src/common/_common_index.dart new file mode 100644 index 00000000..94716d10 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/common/_common_index.dart @@ -0,0 +1,6 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to common utilities for market data providers. +library _common; + +export 'api_error_parser.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/common/api_error_parser.dart b/packages/komodo_cex_market_data/lib/src/common/api_error_parser.dart new file mode 100644 index 00000000..5c676219 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/common/api_error_parser.dart @@ -0,0 +1,323 @@ +import 'dart:convert'; + +/// API Error Parser for Safe Error Handling +/// +/// This module provides secure error parsing utilities that prevent sensitive +/// information from being exposed in logs or error messages. It is specifically +/// designed to handle API responses from cryptocurrency data providers without +/// leaking: +/// +/// - Raw API response bodies +/// - API keys or authentication tokens +/// - User-specific data or identifiers +/// - Internal server details or stack traces +/// +/// ## Security Features +/// +/// 1. **No Raw Response Logging**: Never includes raw HTTP response bodies +/// in error messages or logs. +/// +/// 2. **Sanitized Error Messages**: Provides clean, user-friendly error +/// messages that don't expose sensitive API details. +/// +/// 3. **Rate Limit Handling**: Specifically handles 429 and 402 status codes +/// which are common in cryptocurrency API services with plan limitations. +/// +/// 4. **Pattern Recognition**: Identifies specific error patterns (like +/// CoinPaprika's plan limitation messages) without exposing the full text. +/// +/// ## Usage +/// +/// ```dart +/// // Instead of: +/// throw Exception('API Error: ${response.statusCode} ${response.body}'); +/// +/// // Use: +/// final apiError = ApiErrorParser.parseCoinPaprikaError( +/// response.statusCode, +/// response.body, +/// ); +/// logger.warning(ApiErrorParser.createSafeErrorMessage( +/// operation: 'price fetch', +/// service: 'CoinPaprika', +/// statusCode: response.statusCode, +/// )); +/// throw Exception(apiError.message); +/// ``` + +/// Represents a parsed API error with safe, loggable information. +class ApiError { + const ApiError({ + required this.statusCode, + required this.message, + this.errorType, + this.retryAfter, + this.isRateLimitError = false, + this.isPaymentRequiredError = false, + this.isQuotaExceededError = false, + }); + + /// HTTP status code + final int statusCode; + + /// Safe, parsed error message + final String message; + + /// Type/category of the error (e.g., 'rate_limit', 'quota_exceeded') + final String? errorType; + + /// Retry-After header value in seconds (for rate limit errors) + final int? retryAfter; + + /// Whether this is a rate limiting error (429) + final bool isRateLimitError; + + /// Whether this is a payment required error (402) + final bool isPaymentRequiredError; + + /// Whether this is a quota exceeded error + final bool isQuotaExceededError; + + @override + String toString() { + final buffer = StringBuffer('API Error $statusCode: $message'); + if (errorType != null) { + buffer.write(' (type: $errorType)'); + } + if (retryAfter != null) { + buffer.write(' (retry after: ${retryAfter}s)'); + } + return buffer.toString(); + } +} + +/// Utility class for parsing API error responses without exposing raw response bodies. +class ApiErrorParser { + /// Parses CoinPaprika API error responses. + static ApiError parseCoinPaprikaError(int statusCode, String? responseBody) { + switch (statusCode) { + case 429: + return ApiError( + statusCode: statusCode, + message: 'Rate limit exceeded. Please reduce request frequency.', + errorType: 'rate_limit', + isRateLimitError: true, + retryAfter: _parseRetryAfter(responseBody) ?? 60, + ); + + case 402: + return ApiError( + statusCode: statusCode, + message: 'Payment required. Please upgrade your CoinPaprika plan.', + errorType: 'payment_required', + isPaymentRequiredError: true, + ); + + case 400: + // Check for specific CoinPaprika error messages + if (responseBody != null && + responseBody.contains('Getting historical OHLCV data before') && + responseBody.contains('is not allowed in this plan')) { + return ApiError( + statusCode: statusCode, + message: + 'Historical data access denied for current plan. ' + 'Please request more recent data or upgrade your plan.', + errorType: 'plan_limitation', + isQuotaExceededError: true, + ); + } + + if (responseBody != null && responseBody.contains('Invalid')) { + return ApiError( + statusCode: statusCode, + message: 'Invalid request parameters.', + errorType: 'invalid_request', + ); + } + + return ApiError( + statusCode: statusCode, + message: 'Bad request. Please check your request parameters.', + errorType: 'bad_request', + ); + + case 401: + return ApiError( + statusCode: statusCode, + message: 'Unauthorized. Please check your API key.', + errorType: 'unauthorized', + ); + + case 403: + return ApiError( + statusCode: statusCode, + message: 'Forbidden. Access denied for this resource.', + errorType: 'forbidden', + ); + + case 404: + return ApiError( + statusCode: statusCode, + message: 'Resource not found. Please verify the coin ID.', + errorType: 'not_found', + ); + + case 500: + case 502: + case 503: + case 504: + return ApiError( + statusCode: statusCode, + message: 'CoinPaprika server error. Please try again later.', + errorType: 'server_error', + ); + + default: + return ApiError( + statusCode: statusCode, + message: 'Unexpected error occurred.', + errorType: 'unknown', + ); + } + } + + /// Parses CoinGecko API error responses. + static ApiError parseCoinGeckoError(int statusCode, String? responseBody) { + switch (statusCode) { + case 429: + return ApiError( + statusCode: statusCode, + message: 'Rate limit exceeded. Please reduce request frequency.', + errorType: 'rate_limit', + isRateLimitError: true, + retryAfter: _parseRetryAfter(responseBody) ?? 60, + ); + + case 402: + return ApiError( + statusCode: statusCode, + message: 'Payment required. Please upgrade your CoinGecko plan.', + errorType: 'payment_required', + isPaymentRequiredError: true, + ); + + case 400: + // Check for specific CoinGecko error patterns + if (responseBody != null && + (responseBody.contains('days') || responseBody.contains('365'))) { + return ApiError( + statusCode: statusCode, + message: + 'Historical data request exceeds free tier limits (365 days). ' + 'Please request more recent data or upgrade your plan.', + errorType: 'plan_limitation', + isQuotaExceededError: true, + ); + } + + return ApiError( + statusCode: statusCode, + message: 'Bad request. Please check your request parameters.', + errorType: 'bad_request', + ); + + case 401: + return ApiError( + statusCode: statusCode, + message: 'Unauthorized. Please check your API key.', + errorType: 'unauthorized', + ); + + case 403: + return ApiError( + statusCode: statusCode, + message: 'Forbidden. Access denied for this resource.', + errorType: 'forbidden', + ); + + case 404: + return ApiError( + statusCode: statusCode, + message: 'Resource not found. Please verify the coin ID.', + errorType: 'not_found', + ); + + case 500: + case 502: + case 503: + case 504: + return ApiError( + statusCode: statusCode, + message: 'CoinGecko server error. Please try again later.', + errorType: 'server_error', + ); + + default: + return ApiError( + statusCode: statusCode, + message: 'Unexpected error occurred.', + errorType: 'unknown', + ); + } + } + + /// Attempts to parse Retry-After header from response body or headers. + static int? _parseRetryAfter(String? responseBody) { + if (responseBody == null) return null; + + // Try to parse JSON response for retry information + try { + final json = jsonDecode(responseBody) as Map?; + if (json != null) { + // Common retry fields in API responses + final retryAfter = + json['retry_after'] ?? json['retryAfter'] ?? json['retry-after']; + if (retryAfter is int) return retryAfter; + if (retryAfter is String) return int.tryParse(retryAfter); + } + } catch (_) { + // Ignore JSON parsing errors + } + + // Default retry suggestion for rate limits + return null; + } + + /// Creates a safe error message for logging purposes. + static String createSafeErrorMessage({ + required String operation, + required String service, + required int statusCode, + String? coinId, + }) { + final buffer = StringBuffer('$service API error during $operation'); + + if (coinId != null) { + buffer.write(' for $coinId'); + } + + buffer.write(' (HTTP $statusCode)'); + + // Add contextual information based on status code + switch (statusCode) { + case 429: + buffer.write(' - Rate limit exceeded'); + case 402: + buffer.write(' - Payment/upgrade required'); + case 401: + buffer.write(' - Authentication failed'); + case 403: + buffer.write(' - Access forbidden'); + case 404: + buffer.write(' - Resource not found'); + case 500: + case 502: + case 503: + case 504: + buffer.write(' - Server error'); + } + + return buffer.toString(); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart b/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart index cb41b1cd..c6694d70 100644 --- a/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart +++ b/packages/komodo_cex_market_data/lib/src/id_resolution_strategy.dart @@ -128,6 +128,59 @@ class CoinGeckoIdResolutionStrategy implements IdResolutionStrategy { } } +/// CoinPaprika-specific ID resolution strategy +class CoinPaprikaIdResolutionStrategy implements IdResolutionStrategy { + static final Logger _logger = Logger('CoinPaprikaIdResolutionStrategy'); + + @override + String get platformName => 'CoinPaprika'; + + /// Only uses the coinPaprikaId, as CoinPaprika API requires specific coin IDs. + /// If coinPaprikaId is null, then the CoinPaprika API cannot be used and an + /// error is thrown in [resolveTradingSymbol]. + @override + List getIdPriority(AssetId assetId) { + final coinPaprikaId = assetId.symbol.coinPaprikaId; + + if (coinPaprikaId == null || coinPaprikaId.isEmpty) { + _logger.warning( + 'Missing coinPaprikaId for asset ${assetId.symbol.configSymbol}. ' + 'CoinPaprika API cannot be used for this asset.', + ); + } + + return [ + coinPaprikaId, + ].where((id) => id != null && id.isNotEmpty).cast().toList(); + } + + @override + bool canResolve(AssetId assetId) { + return getIdPriority(assetId).isNotEmpty; + } + + @override + String resolveTradingSymbol(AssetId assetId) { + final ids = getIdPriority(assetId); + if (ids.isEmpty) { + // Thrown when the asset lacks a CoinPaprika identifier and no suitable + // fallback exists in [getIdPriority]. Callers should catch this in + // feature-detection paths (e.g., supports()). + throw ArgumentError( + 'Cannot resolve trading symbol for asset ${assetId.id} on $platformName', + ); + } + + final resolvedSymbol = ids.first; + _logger.finest( + 'Resolved trading symbol for ${assetId.symbol.configSymbol}: $resolvedSymbol ' + '(priority: ${ids.join(', ')})', + ); + + return resolvedSymbol; + } +} + /// Komodo-specific ID resolution strategy class KomodoIdResolutionStrategy implements IdResolutionStrategy { @override diff --git a/packages/komodo_cex_market_data/lib/src/komodo/_komodo_index.dart b/packages/komodo_cex_market_data/lib/src/komodo/_komodo_index.dart new file mode 100644 index 00000000..eb5f3038 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/komodo/_komodo_index.dart @@ -0,0 +1,7 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to Komodo-specific market data functionality. +library _komodo; + +export 'prices/komodo_price_provider.dart'; +export 'prices/komodo_price_repository.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/komodo/komodo.dart b/packages/komodo_cex_market_data/lib/src/komodo/komodo.dart deleted file mode 100644 index 0e8a7ea1..00000000 --- a/packages/komodo_cex_market_data/lib/src/komodo/komodo.dart +++ /dev/null @@ -1 +0,0 @@ -export 'prices/prices.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart index 4051f9e0..acde7623 100644 --- a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart +++ b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:komodo_cex_market_data/src/models/models.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; /// Interface for fetching prices from Komodo API. abstract class IKomodoPriceProvider { diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart index 2a6bdd1e..18f3fd2c 100644 --- a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart @@ -1,7 +1,7 @@ import 'package:decimal/decimal.dart'; import 'package:komodo_cex_market_data/src/cex_repository.dart'; import 'package:komodo_cex_market_data/src/komodo/prices/komodo_price_provider.dart'; -import 'package:komodo_cex_market_data/src/models/models.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; import 'package:komodo_cex_market_data/src/repository_selection_strategy.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -95,18 +95,17 @@ class KomodoPriceRepository extends CexRepository { } void _updateCoinListCache(Map prices) { - _cachedCoinsList = - prices.values - .map( - (e) => CexCoin( - id: e.ticker, - symbol: e.ticker, - name: e.ticker, - currencies: const {'USD', 'USDT'}, - source: 'komodo', - ), - ) - .toList(); + _cachedCoinsList = prices.values + .map( + (e) => CexCoin( + id: e.ticker, + symbol: e.ticker, + name: e.ticker, + currencies: const {'USD', 'USDT'}, + source: 'komodo', + ), + ) + .toList(); _cachedFiatCurrencies = {'USD', 'USDT'}; } diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/prices.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/prices.dart deleted file mode 100644 index ec6de51b..00000000 --- a/packages/komodo_cex_market_data/lib/src/komodo/prices/prices.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'komodo_price_provider.dart'; -export 'komodo_price_repository.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/komodo_cex_market_data_base.dart b/packages/komodo_cex_market_data/lib/src/komodo_cex_market_data_base.dart index ab8aaa10..af2ebbf1 100644 --- a/packages/komodo_cex_market_data/lib/src/komodo_cex_market_data_base.dart +++ b/packages/komodo_cex_market_data/lib/src/komodo_cex_market_data_base.dart @@ -1,10 +1,15 @@ -export 'binance/binance.dart'; -export 'bootstrap/market_data_bootstrap.dart'; +// Core exports for the Komodo CEX market data library +// This file exports the main functionality using generated indices + +export 'binance/_binance_index.dart'; +export 'bootstrap/_bootstrap_index.dart'; export 'cex_repository.dart'; -export 'coingecko/coingecko.dart'; +export 'coingecko/_coingecko_index.dart'; +export 'coinpaprika/_coinpaprika_index.dart'; +export 'common/_common_index.dart'; export 'id_resolution_strategy.dart'; -export 'komodo/komodo.dart'; -export 'models/models.dart'; +export 'komodo/_komodo_index.dart'; +export 'models/_models_index.dart'; export 'repository_priority_manager.dart'; export 'repository_selection_strategy.dart'; export 'sparkline_repository.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/models/_models_index.dart b/packages/komodo_cex_market_data/lib/src/models/_models_index.dart new file mode 100644 index 00000000..2d9b0908 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/models/_models_index.dart @@ -0,0 +1,12 @@ +// (Internal/private) Generated by the `index_generator` package with the `index_generator.yaml` configuration file. + +/// Internal/private classes related to common models and types for market data. +library _models; + +export 'asset_market_information.dart'; +export 'cex_coin.dart'; +export 'coin_ohlc.dart'; +export 'graph_interval.dart'; +export 'json_converters.dart'; +export 'quote_currency.dart'; +export 'sparkline_data.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.dart b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.dart index ce6ff9ce..35f73eec 100644 --- a/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.dart +++ b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.dart @@ -14,13 +14,12 @@ class CoinOhlc extends Equatable { /// Creates a new instance of [CoinOhlc] from an array of klines. factory CoinOhlc.fromJson(List json, {OhlcSource? source}) { return CoinOhlc( - ohlc: - json - .map( - (dynamic kline) => - Ohlc.fromKlineArray(kline as List, source: source), - ) - .toList(), + ohlc: json + .map( + (dynamic kline) => + Ohlc.fromKlineArray(kline as List, source: source), + ) + .toList(), ); } @@ -89,6 +88,7 @@ extension OhlcListToCoinOhlc on List { /// This is a union type that can represent data from different sources: /// - [CoinGeckoOhlc]: OHLC data from CoinGecko API /// - [BinanceOhlc]: Kline data from Binance API with additional trading information +/// - [CoinPaprikaOhlc]: OHLC data from CoinPaprika API @freezed abstract class Ohlc with _$Ohlc { /// Creates an OHLC data point from CoinGecko API format. @@ -98,12 +98,16 @@ abstract class Ohlc with _$Ohlc { const factory Ohlc.coingecko({ /// Unix timestamp in milliseconds for this data point required int timestamp, + /// Opening price as a [Decimal] for precision @DecimalConverter() required Decimal open, + /// Highest price reached during this period as a [Decimal] @DecimalConverter() required Decimal high, + /// Lowest price reached during this period as a [Decimal] @DecimalConverter() required Decimal low, + /// Closing price as a [Decimal] for precision @DecimalConverter() required Decimal close, }) = CoinGeckoOhlc; @@ -115,28 +119,68 @@ abstract class Ohlc with _$Ohlc { const factory Ohlc.binance({ /// Unix timestamp in milliseconds when this kline opened required int openTime, + /// Opening price as a [Decimal] for precision @DecimalConverter() required Decimal open, + /// Highest price reached during this kline as a [Decimal] @DecimalConverter() required Decimal high, + /// Lowest price reached during this kline as a [Decimal] @DecimalConverter() required Decimal low, + /// Closing price as a [Decimal] for precision @DecimalConverter() required Decimal close, + /// Unix timestamp in milliseconds when this kline closed required int closeTime, + /// Trading volume during this kline as a [Decimal] @DecimalConverter() Decimal? volume, + /// Quote asset volume during this kline as a [Decimal] @DecimalConverter() Decimal? quoteAssetVolume, + /// Number of trades executed during this kline int? numberOfTrades, + /// Volume of the asset bought by takers during this kline as a [Decimal] @DecimalConverter() Decimal? takerBuyBaseAssetVolume, + /// Quote asset volume of the asset bought by takers during this kline as a [Decimal] @DecimalConverter() Decimal? takerBuyQuoteAssetVolume, }) = BinanceOhlc; + /// Creates an OHLC data point from CoinPaprika API format. + /// + /// CoinPaprika provides OHLC data with separate open and close timestamps. + @JsonSerializable(fieldRename: FieldRename.snake) + const factory Ohlc.coinpaprika({ + /// Unix timestamp in milliseconds when this period opened + required int timeOpen, + + /// Unix timestamp in milliseconds when this period closed + required int timeClose, + + /// Opening price as a [Decimal] for precision + @DecimalConverter() required Decimal open, + + /// Highest price reached during this period as a [Decimal] + @DecimalConverter() required Decimal high, + + /// Lowest price reached during this period as a [Decimal] + @DecimalConverter() required Decimal low, + + /// Closing price as a [Decimal] for precision + @DecimalConverter() required Decimal close, + + /// Trading volume during this period as a [Decimal] + @DecimalConverter() Decimal? volume, + + /// Market capitalization as a [Decimal] + @DecimalConverter() Decimal? marketCap, + }) = CoinPaprikaOhlc; + /// Creates an [Ohlc] instance from a JSON map. factory Ohlc.fromJson(Map json) => _$OhlcFromJson(json); @@ -185,18 +229,27 @@ abstract class Ohlc with _$Ohlc { high: asDecimal(json[2]), low: asDecimal(json[3]), close: asDecimal(json[4]), - volume: - json.length > 5 ? const DecimalConverter().fromJson(json[5]) : null, + volume: json.length > 5 + ? const DecimalConverter().fromJson(json[5]) + : null, closeTime: json.length > 6 ? asInt(json[6]) : asInt(json[0]), - quoteAssetVolume: - json.length > 7 ? const DecimalConverter().fromJson(json[7]) : null, + quoteAssetVolume: json.length > 7 + ? const DecimalConverter().fromJson(json[7]) + : null, numberOfTrades: json.length > 8 ? asInt(json[8]) : null, - takerBuyBaseAssetVolume: - json.length > 9 ? const DecimalConverter().fromJson(json[9]) : null, - takerBuyQuoteAssetVolume: - json.length > 10 - ? const DecimalConverter().fromJson(json[10]) - : null, + takerBuyBaseAssetVolume: json.length > 9 + ? const DecimalConverter().fromJson(json[9]) + : null, + takerBuyQuoteAssetVolume: json.length > 10 + ? const DecimalConverter().fromJson(json[10]) + : null, + ); + } + + // CoinPaprika format (not typically used with arrays, but included for completeness) + if (source == OhlcSource.coinpaprika) { + throw ArgumentError( + 'CoinPaprika OHLC data should be parsed from JSON objects, not arrays.', ); } @@ -213,8 +266,12 @@ abstract class Ohlc with _$Ohlc { enum OhlcSource { /// CoinGecko API format: 5-element arrays coingecko, + /// Binance API format: 11+ element arrays - binance + binance, + + /// CoinPaprika API format: JSON objects + coinpaprika, } /// Extension providing unified accessors for [Ohlc] data regardless of source. @@ -226,28 +283,49 @@ extension OhlcGetters on Ohlc { /// /// For CoinGecko data, this returns the timestamp. /// For Binance data, this returns the openTime. - int get openTimeMs => - map(coingecko: (c) => c.timestamp, binance: (b) => b.openTime); + /// For CoinPaprika data, this returns the timeOpen. + int get openTimeMs => map( + coingecko: (c) => c.timestamp, + binance: (b) => b.openTime, + coinpaprika: (cp) => cp.timeOpen, + ); /// Gets the closing time in milliseconds since epoch. /// /// For CoinGecko data, this returns the timestamp (same as open time). /// For Binance data, this returns the closeTime. - int get closeTimeMs => - map(coingecko: (c) => c.timestamp, binance: (b) => b.closeTime); + /// For CoinPaprika data, this returns the timeClose. + int get closeTimeMs => map( + coingecko: (c) => c.timestamp, + binance: (b) => b.closeTime, + coinpaprika: (cp) => cp.timeClose, + ); /// Gets the opening price as a [Decimal] for precision. - Decimal get openDecimal => - map(coingecko: (c) => c.open, binance: (b) => b.open); + Decimal get openDecimal => map( + coingecko: (c) => c.open, + binance: (b) => b.open, + coinpaprika: (cp) => cp.open, + ); /// Gets the highest price as a [Decimal] for precision. - Decimal get highDecimal => - map(coingecko: (c) => c.high, binance: (b) => b.high); + Decimal get highDecimal => map( + coingecko: (c) => c.high, + binance: (b) => b.high, + coinpaprika: (cp) => cp.high, + ); /// Gets the lowest price as a [Decimal] for precision. - Decimal get lowDecimal => map(coingecko: (c) => c.low, binance: (b) => b.low); + Decimal get lowDecimal => map( + coingecko: (c) => c.low, + binance: (b) => b.low, + coinpaprika: (cp) => cp.low, + ); /// Gets the closing price as a [Decimal] for precision. - Decimal get closeDecimal => - map(coingecko: (c) => c.close, binance: (b) => b.close); + Decimal get closeDecimal => map( + coingecko: (c) => c.close, + binance: (b) => b.close, + coinpaprika: (cp) => cp.close, + ); } diff --git a/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.freezed.dart b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.freezed.dart index c132621b..1b487412 100644 --- a/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.freezed.dart +++ b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.freezed.dart @@ -23,6 +23,10 @@ Ohlc _$OhlcFromJson( return BinanceOhlc.fromJson( json ); + case 'coinpaprika': + return CoinPaprikaOhlc.fromJson( + json + ); default: throw CheckedFromJsonException( @@ -119,12 +123,13 @@ extension OhlcPatterns on Ohlc { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( CoinGeckoOhlc value)? coingecko,TResult Function( BinanceOhlc value)? binance,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( CoinGeckoOhlc value)? coingecko,TResult Function( BinanceOhlc value)? binance,TResult Function( CoinPaprikaOhlc value)? coinpaprika,required TResult orElse(),}){ final _that = this; switch (_that) { case CoinGeckoOhlc() when coingecko != null: return coingecko(_that);case BinanceOhlc() when binance != null: -return binance(_that);case _: +return binance(_that);case CoinPaprikaOhlc() when coinpaprika != null: +return coinpaprika(_that);case _: return orElse(); } @@ -142,12 +147,13 @@ return binance(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( CoinGeckoOhlc value) coingecko,required TResult Function( BinanceOhlc value) binance,}){ +@optionalTypeArgs TResult map({required TResult Function( CoinGeckoOhlc value) coingecko,required TResult Function( BinanceOhlc value) binance,required TResult Function( CoinPaprikaOhlc value) coinpaprika,}){ final _that = this; switch (_that) { case CoinGeckoOhlc(): return coingecko(_that);case BinanceOhlc(): -return binance(_that);case _: +return binance(_that);case CoinPaprikaOhlc(): +return coinpaprika(_that);case _: throw StateError('Unexpected subclass'); } @@ -164,12 +170,13 @@ return binance(_that);case _: /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( CoinGeckoOhlc value)? coingecko,TResult? Function( BinanceOhlc value)? binance,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( CoinGeckoOhlc value)? coingecko,TResult? Function( BinanceOhlc value)? binance,TResult? Function( CoinPaprikaOhlc value)? coinpaprika,}){ final _that = this; switch (_that) { case CoinGeckoOhlc() when coingecko != null: return coingecko(_that);case BinanceOhlc() when binance != null: -return binance(_that);case _: +return binance(_that);case CoinPaprikaOhlc() when coinpaprika != null: +return coinpaprika(_that);case _: return null; } @@ -186,11 +193,12 @@ return binance(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( int timestamp, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close)? coingecko,TResult Function( int openTime, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, int closeTime, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? quoteAssetVolume, int? numberOfTrades, @DecimalConverter() Decimal? takerBuyBaseAssetVolume, @DecimalConverter() Decimal? takerBuyQuoteAssetVolume)? binance,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( int timestamp, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close)? coingecko,TResult Function( int openTime, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, int closeTime, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? quoteAssetVolume, int? numberOfTrades, @DecimalConverter() Decimal? takerBuyBaseAssetVolume, @DecimalConverter() Decimal? takerBuyQuoteAssetVolume)? binance,TResult Function( int timeOpen, int timeClose, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? marketCap)? coinpaprika,required TResult orElse(),}) {final _that = this; switch (_that) { case CoinGeckoOhlc() when coingecko != null: return coingecko(_that.timestamp,_that.open,_that.high,_that.low,_that.close);case BinanceOhlc() when binance != null: -return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that.closeTime,_that.volume,_that.quoteAssetVolume,_that.numberOfTrades,_that.takerBuyBaseAssetVolume,_that.takerBuyQuoteAssetVolume);case _: +return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that.closeTime,_that.volume,_that.quoteAssetVolume,_that.numberOfTrades,_that.takerBuyBaseAssetVolume,_that.takerBuyQuoteAssetVolume);case CoinPaprikaOhlc() when coinpaprika != null: +return coinpaprika(_that.timeOpen,_that.timeClose,_that.open,_that.high,_that.low,_that.close,_that.volume,_that.marketCap);case _: return orElse(); } @@ -208,11 +216,12 @@ return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that. /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( int timestamp, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close) coingecko,required TResult Function( int openTime, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, int closeTime, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? quoteAssetVolume, int? numberOfTrades, @DecimalConverter() Decimal? takerBuyBaseAssetVolume, @DecimalConverter() Decimal? takerBuyQuoteAssetVolume) binance,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( int timestamp, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close) coingecko,required TResult Function( int openTime, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, int closeTime, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? quoteAssetVolume, int? numberOfTrades, @DecimalConverter() Decimal? takerBuyBaseAssetVolume, @DecimalConverter() Decimal? takerBuyQuoteAssetVolume) binance,required TResult Function( int timeOpen, int timeClose, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? marketCap) coinpaprika,}) {final _that = this; switch (_that) { case CoinGeckoOhlc(): return coingecko(_that.timestamp,_that.open,_that.high,_that.low,_that.close);case BinanceOhlc(): -return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that.closeTime,_that.volume,_that.quoteAssetVolume,_that.numberOfTrades,_that.takerBuyBaseAssetVolume,_that.takerBuyQuoteAssetVolume);case _: +return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that.closeTime,_that.volume,_that.quoteAssetVolume,_that.numberOfTrades,_that.takerBuyBaseAssetVolume,_that.takerBuyQuoteAssetVolume);case CoinPaprikaOhlc(): +return coinpaprika(_that.timeOpen,_that.timeClose,_that.open,_that.high,_that.low,_that.close,_that.volume,_that.marketCap);case _: throw StateError('Unexpected subclass'); } @@ -229,11 +238,12 @@ return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that. /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( int timestamp, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close)? coingecko,TResult? Function( int openTime, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, int closeTime, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? quoteAssetVolume, int? numberOfTrades, @DecimalConverter() Decimal? takerBuyBaseAssetVolume, @DecimalConverter() Decimal? takerBuyQuoteAssetVolume)? binance,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( int timestamp, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close)? coingecko,TResult? Function( int openTime, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, int closeTime, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? quoteAssetVolume, int? numberOfTrades, @DecimalConverter() Decimal? takerBuyBaseAssetVolume, @DecimalConverter() Decimal? takerBuyQuoteAssetVolume)? binance,TResult? Function( int timeOpen, int timeClose, @DecimalConverter() Decimal open, @DecimalConverter() Decimal high, @DecimalConverter() Decimal low, @DecimalConverter() Decimal close, @DecimalConverter() Decimal? volume, @DecimalConverter() Decimal? marketCap)? coinpaprika,}) {final _that = this; switch (_that) { case CoinGeckoOhlc() when coingecko != null: return coingecko(_that.timestamp,_that.open,_that.high,_that.low,_that.close);case BinanceOhlc() when binance != null: -return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that.closeTime,_that.volume,_that.quoteAssetVolume,_that.numberOfTrades,_that.takerBuyBaseAssetVolume,_that.takerBuyQuoteAssetVolume);case _: +return binance(_that.openTime,_that.open,_that.high,_that.low,_that.close,_that.closeTime,_that.volume,_that.quoteAssetVolume,_that.numberOfTrades,_that.takerBuyBaseAssetVolume,_that.takerBuyQuoteAssetVolume);case CoinPaprikaOhlc() when coinpaprika != null: +return coinpaprika(_that.timeOpen,_that.timeClose,_that.open,_that.high,_that.low,_that.close,_that.volume,_that.marketCap);case _: return null; } @@ -429,6 +439,101 @@ as Decimal?, } +} + +/// @nodoc + +@JsonSerializable(fieldRename: FieldRename.snake) +class CoinPaprikaOhlc implements Ohlc { + const CoinPaprikaOhlc({required this.timeOpen, required this.timeClose, @DecimalConverter() required this.open, @DecimalConverter() required this.high, @DecimalConverter() required this.low, @DecimalConverter() required this.close, @DecimalConverter() this.volume, @DecimalConverter() this.marketCap, final String? $type}): $type = $type ?? 'coinpaprika'; + factory CoinPaprikaOhlc.fromJson(Map json) => _$CoinPaprikaOhlcFromJson(json); + +/// Unix timestamp in milliseconds when this period opened + final int timeOpen; +/// Unix timestamp in milliseconds when this period closed + final int timeClose; +/// Opening price as a [Decimal] for precision +@override@DecimalConverter() final Decimal open; +/// Highest price reached during this period as a [Decimal] +@override@DecimalConverter() final Decimal high; +/// Lowest price reached during this period as a [Decimal] +@override@DecimalConverter() final Decimal low; +/// Closing price as a [Decimal] for precision +@override@DecimalConverter() final Decimal close; +/// Trading volume during this period as a [Decimal] +@DecimalConverter() final Decimal? volume; +/// Market capitalization as a [Decimal] +@DecimalConverter() final Decimal? marketCap; + +@JsonKey(name: 'runtimeType') +final String $type; + + +/// Create a copy of Ohlc +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoinPaprikaOhlcCopyWith get copyWith => _$CoinPaprikaOhlcCopyWithImpl(this, _$identity); + +@override +Map toJson() { + return _$CoinPaprikaOhlcToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoinPaprikaOhlc&&(identical(other.timeOpen, timeOpen) || other.timeOpen == timeOpen)&&(identical(other.timeClose, timeClose) || other.timeClose == timeClose)&&(identical(other.open, open) || other.open == open)&&(identical(other.high, high) || other.high == high)&&(identical(other.low, low) || other.low == low)&&(identical(other.close, close) || other.close == close)&&(identical(other.volume, volume) || other.volume == volume)&&(identical(other.marketCap, marketCap) || other.marketCap == marketCap)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,timeOpen,timeClose,open,high,low,close,volume,marketCap); + +@override +String toString() { + return 'Ohlc.coinpaprika(timeOpen: $timeOpen, timeClose: $timeClose, open: $open, high: $high, low: $low, close: $close, volume: $volume, marketCap: $marketCap)'; +} + + +} + +/// @nodoc +abstract mixin class $CoinPaprikaOhlcCopyWith<$Res> implements $OhlcCopyWith<$Res> { + factory $CoinPaprikaOhlcCopyWith(CoinPaprikaOhlc value, $Res Function(CoinPaprikaOhlc) _then) = _$CoinPaprikaOhlcCopyWithImpl; +@override @useResult +$Res call({ + int timeOpen, int timeClose,@DecimalConverter() Decimal open,@DecimalConverter() Decimal high,@DecimalConverter() Decimal low,@DecimalConverter() Decimal close,@DecimalConverter() Decimal? volume,@DecimalConverter() Decimal? marketCap +}); + + + + +} +/// @nodoc +class _$CoinPaprikaOhlcCopyWithImpl<$Res> + implements $CoinPaprikaOhlcCopyWith<$Res> { + _$CoinPaprikaOhlcCopyWithImpl(this._self, this._then); + + final CoinPaprikaOhlc _self; + final $Res Function(CoinPaprikaOhlc) _then; + +/// Create a copy of Ohlc +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? timeOpen = null,Object? timeClose = null,Object? open = null,Object? high = null,Object? low = null,Object? close = null,Object? volume = freezed,Object? marketCap = freezed,}) { + return _then(CoinPaprikaOhlc( +timeOpen: null == timeOpen ? _self.timeOpen : timeOpen // ignore: cast_nullable_to_non_nullable +as int,timeClose: null == timeClose ? _self.timeClose : timeClose // ignore: cast_nullable_to_non_nullable +as int,open: null == open ? _self.open : open // ignore: cast_nullable_to_non_nullable +as Decimal,high: null == high ? _self.high : high // ignore: cast_nullable_to_non_nullable +as Decimal,low: null == low ? _self.low : low // ignore: cast_nullable_to_non_nullable +as Decimal,close: null == close ? _self.close : close // ignore: cast_nullable_to_non_nullable +as Decimal,volume: freezed == volume ? _self.volume : volume // ignore: cast_nullable_to_non_nullable +as Decimal?,marketCap: freezed == marketCap ? _self.marketCap : marketCap // ignore: cast_nullable_to_non_nullable +as Decimal?, + )); +} + + } // dart format on diff --git a/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.g.dart b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.g.dart index f95bd7f1..617c290a 100644 --- a/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.g.dart +++ b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.g.dart @@ -68,3 +68,29 @@ Map _$BinanceOhlcToJson(BinanceOhlc instance) => ), 'runtimeType': instance.$type, }; + +CoinPaprikaOhlc _$CoinPaprikaOhlcFromJson(Map json) => + CoinPaprikaOhlc( + timeOpen: (json['time_open'] as num).toInt(), + timeClose: (json['time_close'] as num).toInt(), + open: Decimal.fromJson(json['open'] as String), + high: Decimal.fromJson(json['high'] as String), + low: Decimal.fromJson(json['low'] as String), + close: Decimal.fromJson(json['close'] as String), + volume: const DecimalConverter().fromJson(json['volume']), + marketCap: const DecimalConverter().fromJson(json['market_cap']), + $type: json['runtimeType'] as String?, + ); + +Map _$CoinPaprikaOhlcToJson(CoinPaprikaOhlc instance) => + { + 'time_open': instance.timeOpen, + 'time_close': instance.timeClose, + 'open': instance.open, + 'high': instance.high, + 'low': instance.low, + 'close': instance.close, + 'volume': const DecimalConverter().toJson(instance.volume), + 'market_cap': const DecimalConverter().toJson(instance.marketCap), + 'runtimeType': instance.$type, + }; diff --git a/packages/komodo_cex_market_data/lib/src/models/models.dart b/packages/komodo_cex_market_data/lib/src/models/models.dart deleted file mode 100644 index a647187f..00000000 --- a/packages/komodo_cex_market_data/lib/src/models/models.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'asset_market_information.dart'; -export 'cex_coin.dart'; -export 'coin_ohlc.dart'; -export 'quote_currency.dart'; -export 'graph_interval.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/models/quote_currency.dart b/packages/komodo_cex_market_data/lib/src/models/quote_currency.dart index 511e014f..13d24bd4 100644 --- a/packages/komodo_cex_market_data/lib/src/models/quote_currency.dart +++ b/packages/komodo_cex_market_data/lib/src/models/quote_currency.dart @@ -46,8 +46,8 @@ sealed class QuoteCurrency with _$QuoteCurrency { if (symbol == 'TRY') return 'try'; return symbol.toLowerCase(); }, - stablecoin: - (symbol, displayName, underlyingFiat) => underlyingFiat.coinGeckoId, + stablecoin: (symbol, displayName, underlyingFiat) => + underlyingFiat.coinGeckoId, crypto: (symbol, displayName) => symbol.toLowerCase(), commodity: (symbol, displayName) => symbol.toLowerCase(), ); @@ -374,6 +374,22 @@ class FiatCurrency { symbol: 'ZAR', displayName: 'South African Rand', ); + static const bob = QuoteCurrency.fiat( + symbol: 'BOB', + displayName: 'Bolivian Boliviano', + ); + static const cop = QuoteCurrency.fiat( + symbol: 'COP', + displayName: 'Colombian Peso', + ); + static const pen = QuoteCurrency.fiat( + symbol: 'PEN', + displayName: 'Peruvian Sol', + ); + static const isk = QuoteCurrency.fiat( + symbol: 'ISK', + displayName: 'Icelandic Krona', + ); /// List of all available fiat currencies. /// @@ -436,6 +452,10 @@ class FiatCurrency { vef, vnd, zar, + bob, + cop, + pen, + isk, ]; /// Optimized lookup map for fast symbol-to-currency resolution. @@ -873,3 +893,19 @@ extension QuoteCurrencyMapping on QuoteCurrency { } } } + +/// CoinPaprika-specific quote currency extensions +extension CoinPaprikaQuoteCurrency on QuoteCurrency { + /// Gets the CoinPaprika-compatible currency identifier. + /// + /// CoinPaprika uses lowercase currency symbols for quote currencies. + /// This extension maps QuoteCurrency instances to their CoinPaprika equivalents. + String get coinPaprikaId { + return map( + fiat: (fiat) => fiat.symbol.toLowerCase(), + stablecoin: (stable) => stable.symbol.toLowerCase(), + crypto: (crypto) => crypto.symbol.toLowerCase(), + commodity: (commodity) => commodity.symbol.toLowerCase(), + ); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/repository_priority_manager.dart b/packages/komodo_cex_market_data/lib/src/repository_priority_manager.dart index 0db494ed..bfddca31 100644 --- a/packages/komodo_cex_market_data/lib/src/repository_priority_manager.dart +++ b/packages/komodo_cex_market_data/lib/src/repository_priority_manager.dart @@ -1,7 +1,8 @@ -import 'package:komodo_cex_market_data/src/binance/binance.dart'; +import 'package:komodo_cex_market_data/src/binance/_binance_index.dart'; import 'package:komodo_cex_market_data/src/cex_repository.dart'; -import 'package:komodo_cex_market_data/src/coingecko/coingecko.dart'; -import 'package:komodo_cex_market_data/src/komodo/komodo.dart'; +import 'package:komodo_cex_market_data/src/coingecko/_coingecko_index.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/_coinpaprika_index.dart'; +import 'package:komodo_cex_market_data/src/komodo/_komodo_index.dart'; /// Utility class for managing repository priorities using a map-based approach. /// @@ -13,14 +14,16 @@ class RepositoryPriorityManager { static const Map defaultPriorities = { KomodoPriceRepository: 1, BinanceRepository: 2, - CoinGeckoRepository: 3, + CoinPaprikaRepository: 3, + CoinGeckoRepository: 4, }; /// Priority map optimized for sparkline data fetching. /// Binance is prioritized for sparkline data due to better data quality. static const Map sparklinePriorities = { BinanceRepository: 1, - CoinGeckoRepository: 2, + CoinPaprikaRepository: 2, + CoinGeckoRepository: 3, }; /// Gets the priority of a repository using the default priority scheme. @@ -73,13 +76,13 @@ class RepositoryPriorityManager { List repositories, Map customPriorities, ) { - final sorted = repositories.toList(); - sorted.sort( - (a, b) => getPriorityWithCustomMap( - a, - customPriorities, - ).compareTo(getPriorityWithCustomMap(b, customPriorities)), - ); + final sorted = repositories.toList() + ..sort( + (a, b) => getPriorityWithCustomMap( + a, + customPriorities, + ).compareTo(getPriorityWithCustomMap(b, customPriorities)), + ); return sorted; } @@ -90,10 +93,10 @@ class RepositoryPriorityManager { static List sortBySparklinePriority( List repositories, ) { - final sorted = repositories.toList(); - sorted.sort( - (a, b) => getSparklinePriority(a).compareTo(getSparklinePriority(b)), - ); + final sorted = repositories.toList() + ..sort( + (a, b) => getSparklinePriority(a).compareTo(getSparklinePriority(b)), + ); return sorted; } } diff --git a/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart b/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart index e779eebd..57be4ab0 100644 --- a/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart +++ b/packages/komodo_cex_market_data/lib/src/repository_selection_strategy.dart @@ -1,13 +1,34 @@ import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' show RepositoryPriorityManager; import 'package:komodo_cex_market_data/src/cex_repository.dart'; -import 'package:komodo_cex_market_data/src/models/cex_coin.dart'; import 'package:komodo_cex_market_data/src/models/quote_currency.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart' show Logger; /// Enum for the type of price request -enum PriceRequestType { currentPrice, priceChange, priceHistory } +/// +/// This enum defines the different types of price-related requests that can be made +/// to cryptocurrency exchange repositories. Each type represents a specific kind +/// of market data that may be supported differently across various data providers. +enum PriceRequestType { + /// Request for the current/latest price of an asset + /// + /// This represents the most recent price available for a given asset + /// in a specific fiat currency. + currentPrice, + + /// Request for price change information over a time period + /// + /// This includes percentage changes, absolute changes, and other + /// price movement metrics for a given time frame. + priceChange, + + /// Request for historical price data + /// + /// This includes price data points over time, such as daily, hourly, + /// or minute-level price history for charting and analysis purposes. + priceHistory, +} /// Strategy interface for selecting repositories abstract class RepositorySelectionStrategy { @@ -26,31 +47,11 @@ abstract class RepositorySelectionStrategy { /// Default strategy for selecting the best repository for a given asset class DefaultRepositorySelectionStrategy implements RepositorySelectionStrategy { - final Map _supportCache = {}; - static final Logger _logger = Logger('DefaultRepositorySelectionStrategy'); @override Future ensureCacheInitialized(List repositories) async { - for (final repo in repositories) { - if (!_supportCache.containsKey(repo)) { - try { - final coins = await repo.getCoinList(); - final fiatCurrencies = - coins - .expand((c) => c.currencies.map((s) => s.toUpperCase())) - .toSet(); - _supportCache[repo] = _RepositorySupportCache( - coins: coins, - fiatCurrencies: fiatCurrencies, - ); - } catch (e, st) { - // Ignore repository initialization failures and continue. - // Repositories that fail to initialize won't be selected. - _logger.severe('Failed to initialize repository', e, st); - } - } - } + // No longer needed since we delegate to repository-specific supports() method } /// Selects the best repository for a given asset, fiat, and request type @@ -61,42 +62,37 @@ class DefaultRepositorySelectionStrategy required PriceRequestType requestType, required List availableRepositories, }) async { - await ensureCacheInitialized(availableRepositories); - final candidates = - availableRepositories - .where((repo) => _supportsAssetAndFiat(repo, assetId, fiatCurrency)) - .toList() - ..sort( - (a, b) => RepositoryPriorityManager.getPriority( - a, - ).compareTo(RepositoryPriorityManager.getPriority(b)), - ); - return candidates.isNotEmpty ? candidates.first : null; - } + final candidates = []; + const timeout = Duration(seconds: 2); - /// Checks if a repository supports the given asset and fiat currency - bool _supportsAssetAndFiat( - CexRepository repo, - AssetId assetId, - QuoteCurrency fiatCurrency, - ) { - final cache = _supportCache[repo]; - if (cache == null) return false; - - final supportsAsset = cache.coins.any( - (c) => c.id.toUpperCase() == assetId.symbol.configSymbol.toUpperCase(), + await Future.wait( + availableRepositories.map((repo) async { + try { + final isSupported = await repo + .supports(assetId, fiatCurrency, requestType) + .timeout(timeout, onTimeout: () => false); + if (isSupported) { + candidates.add(repo); + } + } catch (e, st) { + // Log errors but continue with other repositories + _logger.warning( + 'Failed to check support for ${repo.runtimeType} with asset ' + '${assetId.id} and fiat ${fiatCurrency.symbol} (requestType: $requestType)', + e, + st, + ); + } + }), ); - final supportsFiat = cache.fiatCurrencies.contains( - fiatCurrency.symbol.toUpperCase(), + + // Sort by priority + candidates.sort( + (a, b) => RepositoryPriorityManager.getPriority( + a, + ).compareTo(RepositoryPriorityManager.getPriority(b)), ); - return supportsAsset && supportsFiat; + return candidates.isNotEmpty ? candidates.first : null; } } - -class _RepositorySupportCache { - _RepositorySupportCache({required this.coins, required this.fiatCurrencies}); - - final List coins; - final Set fiatCurrencies; -} diff --git a/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart b/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart index 890aa5e7..da5d13c8 100644 --- a/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart @@ -2,37 +2,33 @@ import 'dart:async'; import 'package:hive_ce/hive.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:komodo_cex_market_data/src/hive_adapters.dart'; -import 'package:komodo_cex_market_data/src/models/sparkline_data.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; -// TODO: create higher-level abstraction and move to SDK to avoid duplicating -// repositories and creating global variables like these -// Global CoinGecko repository instance for backward compatibility -final CoinGeckoRepository _coinGeckoRepository = CoinGeckoRepository( - coinGeckoProvider: CoinGeckoCexProvider(), -); -final BinanceRepository _binanceRepository = BinanceRepository( - binanceProvider: const BinanceProvider(), -); - -SparklineRepository sparklineRepository = SparklineRepository(); - /// Repository for fetching sparkline data +// TODO: create higher-level abstraction and move to SDK class SparklineRepository with RepositoryFallbackMixin { /// Creates a new SparklineRepository with the given repositories. /// /// If repositories are not provided, defaults to Binance and CoinGecko. - SparklineRepository({ - List? repositories, + SparklineRepository( + this._repositories, { RepositorySelectionStrategy? selectionStrategy, - }) : _repositories = - repositories ?? [_binanceRepository, _coinGeckoRepository], - _selectionStrategy = + }) : _selectionStrategy = selectionStrategy ?? DefaultRepositorySelectionStrategy(); - static final Logger _logger = Logger('SparklineRepository'); + /// Creates a new SparklineRepository with the default repositories. + /// + /// Default repositories are Binance, CoinGecko, and CoinPaprika. + factory SparklineRepository.defaultInstance() { + return SparklineRepository([ + BinanceRepository(binanceProvider: const BinanceProvider()), + CoinPaprikaRepository(coinPaprikaProvider: CoinPaprikaProvider()), + CoinGeckoRepository(coinGeckoProvider: CoinGeckoCexProvider()), + ], selectionStrategy: DefaultRepositorySelectionStrategy()); + } + + static final Logger _logger = Logger('SparklineRepository'); final List _repositories; final RepositorySelectionStrategy _selectionStrategy; @@ -43,7 +39,8 @@ class SparklineRepository with RepositoryFallbackMixin { final Duration cacheExpiry = const Duration(hours: 1); Box? _box; - /// Map to track ongoing requests and prevent duplicate requests for the same symbol + /// Map to track ongoing requests and prevent duplicate requests for the + /// same symbol final Map?>> _inFlightRequests = {}; @override @@ -179,7 +176,8 @@ class SparklineRepository with RepositoryFallbackMixin { /// Internal method to perform the actual sparkline fetch /// - /// This is separated from fetchSparkline to enable proper request deduplication + /// This is separated from fetchSparkline to enable proper request + /// deduplication Future?> _performSparklineFetch(AssetId assetId) async { final symbol = assetId.symbol.configSymbol; @@ -232,7 +230,8 @@ class SparklineRepository with RepositoryFallbackMixin { ); } _logger.fine( - 'Fetched ${data.length} close prices for $symbol from ${repo.runtimeType}', + 'Fetched ${data.length} close prices for $symbol from ' + '${repo.runtimeType}', ); return data; }, diff --git a/packages/komodo_cex_market_data/pubspec.yaml b/packages/komodo_cex_market_data/pubspec.yaml index f2d23055..6da7efc5 100644 --- a/packages/komodo_cex_market_data/pubspec.yaml +++ b/packages/komodo_cex_market_data/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: dev_dependencies: flutter_lints: ^6.0.0 # flutter.dev + index_generator: ^4.0.1 mocktail: ^1.0.4 test: ^1.25.7 freezed: ^3.0.4 diff --git a/packages/komodo_cex_market_data/test/binance/binance_repository_test.dart b/packages/komodo_cex_market_data/test/binance/binance_repository_test.dart index 19f5efb8..01883957 100644 --- a/packages/komodo_cex_market_data/test/binance/binance_repository_test.dart +++ b/packages/komodo_cex_market_data/test/binance/binance_repository_test.dart @@ -260,10 +260,9 @@ void main() { high: Decimal.fromInt(51000), low: Decimal.fromInt(49000), close: Decimal.fromInt(50500), - openTime: - DateTime.now() - .subtract(const Duration(days: 1)) - .millisecondsSinceEpoch, + openTime: DateTime.now() + .subtract(const Duration(days: 1)) + .millisecondsSinceEpoch, closeTime: DateTime.now().millisecondsSinceEpoch, ), ], @@ -336,10 +335,9 @@ void main() { lowPrice: Decimal.parse('49000'), volume: Decimal.parse('1000'), quoteVolume: Decimal.parse('50500000'), - openTime: - DateTime.now() - .subtract(const Duration(hours: 24)) - .millisecondsSinceEpoch, + openTime: DateTime.now() + .subtract(const Duration(hours: 24)) + .millisecondsSinceEpoch, closeTime: DateTime.now().millisecondsSinceEpoch, firstId: 1, lastId: 10000, diff --git a/packages/komodo_cex_market_data/test/coingecko/coingecko_cex_provider_test.dart b/packages/komodo_cex_market_data/test/coingecko/coingecko_cex_provider_test.dart index 073c581c..67c79284 100644 --- a/packages/komodo_cex_market_data/test/coingecko/coingecko_cex_provider_test.dart +++ b/packages/komodo_cex_market_data/test/coingecko/coingecko_cex_provider_test.dart @@ -137,11 +137,10 @@ void main() { // underlying issues are resolved. }, tags: ['live', 'integration'], - skip: - _runLiveApiTests - ? false - : 'Live API tests are skipped by default. Enable with -DRUN_LIVE_API_TESTS=true (dart test) ' - 'or --dart-define=RUN_LIVE_API_TESTS=true (flutter test).', + skip: _runLiveApiTests + ? false + : 'Live API tests are skipped by default. Enable with -DRUN_LIVE_API_TESTS=true (dart test) ' + 'or --dart-define=RUN_LIVE_API_TESTS=true (flutter test).', ); // test('fetchCoinHistoricalData test', () async { diff --git a/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart b/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart index 8b401061..55f41540 100644 --- a/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart +++ b/packages/komodo_cex_market_data/test/coingecko/coingecko_repository_test.dart @@ -1,7 +1,5 @@ import 'package:decimal/decimal.dart'; -import 'package:komodo_cex_market_data/src/coingecko/coingecko.dart'; -import 'package:komodo_cex_market_data/src/models/models.dart'; -import 'package:komodo_cex_market_data/src/repository_selection_strategy.dart'; +import 'package:komodo_cex_market_data/src/_core_index.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; diff --git a/packages/komodo_cex_market_data/test/coingecko/models/coingecko_api_plan_test.dart b/packages/komodo_cex_market_data/test/coingecko/models/coingecko_api_plan_test.dart new file mode 100644 index 00000000..9b330c67 --- /dev/null +++ b/packages/komodo_cex_market_data/test/coingecko/models/coingecko_api_plan_test.dart @@ -0,0 +1,483 @@ +import 'package:komodo_cex_market_data/src/coingecko/models/coingecko_api_plan.dart'; +import 'package:test/test.dart'; + +void main() { + group('CoingeckoApiPlan', () { + group('Demo Plan (Free Tier)', () { + late CoingeckoApiPlan demoPlan; + + setUp(() { + demoPlan = const CoingeckoApiPlan.demo(); + }); + + test('should have correct default values', () { + expect(demoPlan.monthlyCallLimit, equals(10000)); + expect(demoPlan.rateLimitPerMinute, equals(30)); + expect(demoPlan.attributionRequired, isTrue); + }); + + test('should be identified as free tier', () { + expect(demoPlan.isFreeTier, isTrue); + }); + + test('should have correct plan name', () { + expect(demoPlan.planName, equals('Demo')); + }); + + test('should have correct pricing', () { + expect(demoPlan.monthlyPriceUsd, equals(0.0)); + expect(demoPlan.yearlyPriceUsd, equals(0.0)); + }); + + test('should have correct call limit description', () { + expect(demoPlan.monthlyCallLimitDescription, equals('10K calls/month')); + }); + + test('should have correct rate limit description', () { + expect(demoPlan.rateLimitDescription, equals('30 calls/minute')); + }); + + test('should have limited historical data access', () { + expect( + demoPlan.dailyHistoricalDataDescription, + equals('1 year of daily historical data'), + ); + expect( + demoPlan.hourlyHistoricalDataDescription, + equals('1 year of hourly historical data'), + ); + expect( + demoPlan.fiveMinutelyHistoricalDataDescription, + equals('1 day of 5-minutely historical data'), + ); + }); + + test('should not have SLA support', () { + expect(demoPlan.hasSlaSupport, isFalse); + }); + + test('should have correct historical data cutoffs', () { + final now = DateTime.now().toUtc(); + final dailyCutoff = demoPlan.getDailyHistoricalDataCutoff(); + final hourlyCutoff = demoPlan.getHourlyHistoricalDataCutoff(); + final fiveMinutelyCutoff = demoPlan.get5MinutelyHistoricalDataCutoff(); + + expect(dailyCutoff, isNotNull); + expect(hourlyCutoff, isNotNull); + expect(fiveMinutelyCutoff, isNotNull); + + // Daily and hourly cutoffs should be approximately 1 year ago + final oneYearAgo = now.subtract(const Duration(days: 365)); + expect(dailyCutoff!.difference(oneYearAgo).inDays.abs(), lessThan(2)); + expect(hourlyCutoff!.difference(oneYearAgo).inDays.abs(), lessThan(2)); + + // 5-minutely cutoff should be approximately 1 day ago + final oneDayAgo = now.subtract(const Duration(days: 1)); + expect( + fiveMinutelyCutoff!.difference(oneDayAgo).inDays.abs(), + lessThan(2), + ); + }); + + test('should validate historical data limits correctly', () { + final now = DateTime.now().toUtc(); + final twoYearsAgo = now.subtract(const Duration(days: 730)); + final sixMonthsAgo = now.subtract(const Duration(days: 180)); + final twoDaysAgo = now.subtract(const Duration(days: 2)); + + expect(demoPlan.isWithinDailyHistoricalLimit(sixMonthsAgo), isTrue); + expect(demoPlan.isWithinDailyHistoricalLimit(twoYearsAgo), isFalse); + + expect(demoPlan.isWithinHourlyHistoricalLimit(sixMonthsAgo), isTrue); + expect(demoPlan.isWithinHourlyHistoricalLimit(twoYearsAgo), isFalse); + + expect(demoPlan.isWithin5MinutelyHistoricalLimit(now), isTrue); + expect(demoPlan.isWithin5MinutelyHistoricalLimit(twoDaysAgo), isFalse); + }); + }); + + group('Analyst Plan', () { + late CoingeckoApiPlan analystPlan; + + setUp(() { + analystPlan = const CoingeckoApiPlan.analyst(); + }); + + test('should have correct default values', () { + expect(analystPlan.monthlyCallLimit, equals(500000)); + expect(analystPlan.rateLimitPerMinute, equals(500)); + expect(analystPlan.attributionRequired, isFalse); + }); + + test('should not be free tier', () { + expect(analystPlan.isFreeTier, isFalse); + }); + + test('should have correct plan name', () { + expect(analystPlan.planName, equals('Analyst')); + }); + + test('should have correct pricing', () { + expect(analystPlan.monthlyPriceUsd, equals(129.0)); + expect(analystPlan.yearlyPriceUsd, equals(1238.4)); + }); + + test('should have correct call limit description', () { + expect( + analystPlan.monthlyCallLimitDescription, + equals('500K calls/month'), + ); + }); + + test('should have extended historical data access', () { + expect( + analystPlan.dailyHistoricalDataDescription, + equals('Daily historical data from 2013'), + ); + expect( + analystPlan.hourlyHistoricalDataDescription, + equals('Hourly historical data from 2018'), + ); + expect( + analystPlan.fiveMinutelyHistoricalDataDescription, + equals('1 day of 5-minutely historical data'), + ); + }); + + test('should have correct historical data cutoffs', () { + final dailyCutoff = analystPlan.getDailyHistoricalDataCutoff(); + final hourlyCutoff = analystPlan.getHourlyHistoricalDataCutoff(); + + expect(dailyCutoff, equals(DateTime.utc(2013))); + expect(hourlyCutoff, equals(DateTime.utc(2018))); + }); + }); + + group('Lite Plan', () { + late CoingeckoApiPlan litePlan; + + setUp(() { + litePlan = const CoingeckoApiPlan.lite(); + }); + + test('should have correct default values', () { + expect(litePlan.monthlyCallLimit, equals(2000000)); + expect(litePlan.rateLimitPerMinute, equals(500)); + expect(litePlan.attributionRequired, isFalse); + }); + + test('should have correct pricing', () { + expect(litePlan.monthlyPriceUsd, equals(499.0)); + expect(litePlan.yearlyPriceUsd, equals(4790.4)); + }); + + test('should have correct call limit description', () { + expect(litePlan.monthlyCallLimitDescription, equals('2M calls/month')); + }); + }); + + group('Pro Plan', () { + late CoingeckoApiPlan proPlan; + + setUp(() { + proPlan = const CoingeckoApiPlan.pro(); + }); + + test('should have correct default values', () { + expect(proPlan.monthlyCallLimit, equals(5000000)); + expect(proPlan.rateLimitPerMinute, equals(1000)); + expect(proPlan.attributionRequired, isFalse); + }); + + test('should allow custom call limits', () { + const pro8M = CoingeckoApiPlan.pro(monthlyCallLimit: 8000000); + const pro10M = CoingeckoApiPlan.pro(monthlyCallLimit: 10000000); + const pro15M = CoingeckoApiPlan.pro(monthlyCallLimit: 15000000); + + expect(pro8M.monthlyCallLimit, equals(8000000)); + expect(pro10M.monthlyCallLimit, equals(10000000)); + expect(pro15M.monthlyCallLimit, equals(15000000)); + + expect(pro8M.monthlyCallLimitDescription, equals('8M calls/month')); + expect(pro10M.monthlyCallLimitDescription, equals('10M calls/month')); + expect(pro15M.monthlyCallLimitDescription, equals('15M calls/month')); + }); + + test('should have correct pricing', () { + expect(proPlan.monthlyPriceUsd, equals(999.0)); + expect(proPlan.yearlyPriceUsd, equals(9590.4)); + }); + + test('should have higher rate limit', () { + expect(proPlan.rateLimitDescription, equals('1000 calls/minute')); + }); + }); + + group('Enterprise Plan', () { + late CoingeckoApiPlan enterprisePlan; + + setUp(() { + enterprisePlan = const CoingeckoApiPlan.enterprise(); + }); + + test('should have unlimited calls and rate limits by default', () { + expect(enterprisePlan.monthlyCallLimit, isNull); + expect(enterprisePlan.rateLimitPerMinute, isNull); + expect(enterprisePlan.hasUnlimitedCalls, isTrue); + expect(enterprisePlan.hasUnlimitedRateLimit, isTrue); + }); + + test('should support custom limits', () { + const customEnterprise = CoingeckoApiPlan.enterprise( + monthlyCallLimit: 50000000, + rateLimitPerMinute: 5000, + ); + + expect(customEnterprise.monthlyCallLimit, equals(50000000)); + expect(customEnterprise.rateLimitPerMinute, equals(5000)); + expect(customEnterprise.hasUnlimitedCalls, isFalse); + expect(customEnterprise.hasUnlimitedRateLimit, isFalse); + }); + + test('should have SLA support by default', () { + expect(enterprisePlan.hasSlaSupport, isTrue); + }); + + test('should allow disabling SLA', () { + const noSlaEnterprise = CoingeckoApiPlan.enterprise(hasSla: false); + expect(noSlaEnterprise.hasSlaSupport, isFalse); + }); + + test('should have custom pricing', () { + expect(enterprisePlan.monthlyPriceUsd, isNull); + expect(enterprisePlan.yearlyPriceUsd, isNull); + }); + + test('should have custom descriptions for unlimited plans', () { + expect( + enterprisePlan.monthlyCallLimitDescription, + equals('Custom call credits'), + ); + expect( + enterprisePlan.rateLimitDescription, + equals('Custom rate limit'), + ); + }); + + test('should have extended 5-minutely historical data access', () { + expect( + enterprisePlan.fiveMinutelyHistoricalDataDescription, + equals('5-minutely historical data from 2018'), + ); + + final fiveMinutelyCutoff = enterprisePlan + .get5MinutelyHistoricalDataCutoff(); + expect(fiveMinutelyCutoff, equals(DateTime.utc(2018))); + }); + }); + + group('Historical Limit Checks (Inclusive Behavior)', () { + test( + 'timestamps exactly at cutoff dates should be considered within limit', + () { + const analystPlan = CoingeckoApiPlan.analyst(); + + // Test daily cutoff (2013-01-01 UTC) + final dailyCutoff = DateTime.utc(2013); + expect( + analystPlan.isWithinDailyHistoricalLimit(dailyCutoff), + isTrue, + reason: 'Timestamp exactly at daily cutoff should be within limit', + ); + + // Test hourly cutoff (2018-01-01 UTC) + final hourlyCutoff = DateTime.utc(2018); + expect( + analystPlan.isWithinHourlyHistoricalLimit(hourlyCutoff), + isTrue, + reason: 'Timestamp exactly at hourly cutoff should be within limit', + ); + + // Test timestamps before cutoffs + final beforeDaily = DateTime.utc(2012, 12, 31); + final beforeHourly = DateTime.utc(2017, 12, 31); + expect( + analystPlan.isWithinDailyHistoricalLimit(beforeDaily), + isFalse, + reason: 'Timestamp before daily cutoff should not be within limit', + ); + expect( + analystPlan.isWithinHourlyHistoricalLimit(beforeHourly), + isFalse, + reason: 'Timestamp before hourly cutoff should not be within limit', + ); + + // Test timestamps after cutoffs + final afterDaily = DateTime.utc(2013, 1, 2); + final afterHourly = DateTime.utc(2018, 1, 2); + expect( + analystPlan.isWithinDailyHistoricalLimit(afterDaily), + isTrue, + reason: 'Timestamp after daily cutoff should be within limit', + ); + expect( + analystPlan.isWithinHourlyHistoricalLimit(afterHourly), + isTrue, + reason: 'Timestamp after hourly cutoff should be within limit', + ); + }, + ); + + test('enterprise plan 5-minutely cutoff should be inclusive', () { + const enterprisePlan = CoingeckoApiPlan.enterprise(); + + // Test 5-minutely cutoff (2018-01-01 UTC) + final fiveMinutelyCutoff = DateTime.utc(2018); + expect( + enterprisePlan.isWithin5MinutelyHistoricalLimit(fiveMinutelyCutoff), + isTrue, + reason: + 'Timestamp exactly at 5-minutely cutoff should be within limit', + ); + + // Test timestamp before cutoff + final beforeCutoff = DateTime.utc(2017, 12, 31); + expect( + enterprisePlan.isWithin5MinutelyHistoricalLimit(beforeCutoff), + isFalse, + reason: + 'Timestamp before 5-minutely cutoff should not be within limit', + ); + + // Test timestamp after cutoff + final afterCutoff = DateTime.utc(2018, 1, 2); + expect( + enterprisePlan.isWithin5MinutelyHistoricalLimit(afterCutoff), + isTrue, + reason: 'Timestamp after 5-minutely cutoff should be within limit', + ); + }); + }); + + group('JSON Serialization', () { + test('should serialize and deserialize demo plan correctly', () { + const original = CoingeckoApiPlan.demo(); + final json = original.toJson(); + final restored = CoingeckoApiPlan.fromJson(json); + + expect(restored.monthlyCallLimit, equals(original.monthlyCallLimit)); + expect( + restored.rateLimitPerMinute, + equals(original.rateLimitPerMinute), + ); + expect( + restored.attributionRequired, + equals(original.attributionRequired), + ); + expect(restored.planName, equals(original.planName)); + }); + + test('should serialize and deserialize enterprise plan correctly', () { + const original = CoingeckoApiPlan.enterprise( + monthlyCallLimit: 100000000, + rateLimitPerMinute: 10000, + hasSla: false, + ); + final json = original.toJson(); + final restored = CoingeckoApiPlan.fromJson(json); + + expect(restored.monthlyCallLimit, equals(original.monthlyCallLimit)); + expect( + restored.rateLimitPerMinute, + equals(original.rateLimitPerMinute), + ); + expect( + restored.attributionRequired, + equals(original.attributionRequired), + ); + expect(restored.hasSlaSupport, equals(original.hasSlaSupport)); + }); + }); + + group('Call Limit Descriptions', () { + test('should format small numbers correctly', () { + const plan = CoingeckoApiPlan.demo(monthlyCallLimit: 500); + expect(plan.monthlyCallLimitDescription, equals('500 calls/month')); + }); + + test('should format thousands correctly', () { + const plan = CoingeckoApiPlan.demo(monthlyCallLimit: 1500); + expect(plan.monthlyCallLimitDescription, equals('1.5K calls/month')); + }); + + test('should format millions correctly', () { + const plan = CoingeckoApiPlan.demo(monthlyCallLimit: 2500000); + expect(plan.monthlyCallLimitDescription, equals('2.5M calls/month')); + }); + + test('should format whole thousands without decimals', () { + const plan = CoingeckoApiPlan.demo(monthlyCallLimit: 1000); + expect(plan.monthlyCallLimitDescription, equals('1K calls/month')); + }); + + test('should format whole millions without decimals', () { + const plan = CoingeckoApiPlan.demo(monthlyCallLimit: 5000000); + expect(plan.monthlyCallLimitDescription, equals('5M calls/month')); + }); + }); + + group('Historical Data Validation', () { + test('should validate timestamps correctly for different plans', () { + const demo = CoingeckoApiPlan.demo(); + const analyst = CoingeckoApiPlan.analyst(); + const enterprise = CoingeckoApiPlan.enterprise(); + + final now = DateTime.now().toUtc(); + final oldDate = DateTime(2010); + final sixMonthsAgo = now.subtract(const Duration(days: 180)); + final twoYearsAgo = now.subtract(const Duration(days: 730)); + + // Demo plan should reject old dates but accept recent ones + expect(demo.isWithinDailyHistoricalLimit(oldDate), isFalse); + expect(demo.isWithinDailyHistoricalLimit(twoYearsAgo), isFalse); + expect(demo.isWithinDailyHistoricalLimit(sixMonthsAgo), isTrue); + + // Analyst plan should accept dates from 2013 + expect(analyst.isWithinDailyHistoricalLimit(oldDate), isFalse); + expect(analyst.isWithinDailyHistoricalLimit(DateTime(2014)), isTrue); + + // Enterprise plan should accept dates from 2013 + expect(enterprise.isWithinDailyHistoricalLimit(DateTime(2014)), isTrue); + }); + }); + + group('Edge Cases', () { + test('should handle null values in enterprise plan correctly', () { + const enterprise = CoingeckoApiPlan.enterprise(); + + expect(enterprise.hasUnlimitedCalls, isTrue); + expect(enterprise.hasUnlimitedRateLimit, isTrue); + expect( + enterprise.monthlyCallLimitDescription, + equals('Custom call credits'), + ); + expect(enterprise.rateLimitDescription, equals('Custom rate limit')); + }); + + test('should handle custom enterprise plan with specific limits', () { + const enterprise = CoingeckoApiPlan.enterprise( + monthlyCallLimit: 25000000, + rateLimitPerMinute: 2500, + ); + + expect(enterprise.hasUnlimitedCalls, isFalse); + expect(enterprise.hasUnlimitedRateLimit, isFalse); + expect( + enterprise.monthlyCallLimitDescription, + equals('25M calls/month'), + ); + expect(enterprise.rateLimitDescription, equals('2500 calls/minute')); + }); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_cex_provider_test.dart b/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_cex_provider_test.dart new file mode 100644 index 00000000..e438e531 --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_cex_provider_test.dart @@ -0,0 +1,743 @@ +import 'package:komodo_cex_market_data/src/coinpaprika/data/coinpaprika_cex_provider.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_api_plan.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import 'fixtures/mock_helpers.dart'; +import 'fixtures/test_constants.dart'; +import 'fixtures/test_fixtures.dart'; +import 'fixtures/verification_helpers.dart'; + +void main() { + group('CoinPaprikaProvider', () { + late MockHttpClient mockHttpClient; + late CoinPaprikaProvider provider; + + setUp(() { + MockHelpers.registerFallbackValues(); + mockHttpClient = MockHttpClient(); + provider = CoinPaprikaProvider( + httpClient: mockHttpClient, + baseUrl: 'api.coinpaprika.com', + apiVersion: '/v1', + apiPlan: const CoinPaprikaApiPlan.pro(), + ); + }); + + group('supportedQuoteCurrencies', () { + test('returns the correct hard-coded list of supported currencies', () { + // Act + final supportedCurrencies = provider.supportedQuoteCurrencies; + + // Assert + expect(supportedCurrencies, isNotEmpty); + for (final currency in TestConstants.defaultSupportedCurrencies) { + expect(supportedCurrencies, contains(currency)); + } + + // Verify the list is unmodifiable + expect( + () => supportedCurrencies.add(FiatCurrency.cad), + throwsUnsupportedError, + ); + }); + + test('includes expected number of currencies', () { + // Act + final supportedCurrencies = provider.supportedQuoteCurrencies; + + // Assert - Based on the hard-coded list in the provider + expect(supportedCurrencies.length, equals(42)); + }); + + test('does not include unsupported currencies', () { + // Act + final supportedCurrencies = provider.supportedQuoteCurrencies; + + // Assert + final supportedSymbols = supportedCurrencies + .map((c) => c.symbol) + .toSet(); + expect(supportedSymbols, isNot(contains('GOLD'))); + expect(supportedSymbols, isNot(contains('SILVER'))); + expect(supportedSymbols, isNot(contains('UNSUPPORTED'))); + }); + }); + + group('fetchHistoricalOhlc URL format validation', () { + test('generates correct URL format without quote parameter', () async { + // Arrange + final mockResponse = TestFixtures.createHistoricalOhlcResponse(); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + final startDate = DateTime.now().subtract(const Duration(days: 30)); + + // Act + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: startDate, + ); + + // Assert - Verify URL structure (real provider includes quote and limit params) + final capturedUri = verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + expect(capturedUri.host, equals('api.coinpaprika.com')); + expect(capturedUri.path, equals('/v1/tickers/btc-bitcoin/historical')); + expect(capturedUri.queryParameters.containsKey('start'), isTrue); + expect(capturedUri.queryParameters.containsKey('interval'), isTrue); + expect(capturedUri.queryParameters['interval'], equals('1d')); + }); + + test('converts 24h interval to 1d for API compatibility', () async { + // Arrange + final mockResponse = TestFixtures.createHistoricalOhlcResponse(); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + // Act + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 30)), + interval: TestConstants.interval24h, + ); + + // Assert + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval24h, + TestConstants.interval1d, + ); + }); + + test('preserves 1h interval as-is', () async { + // Arrange + final mockResponse = TestFixtures.createHistoricalOhlcResponse( + price: 44000, + volume24h: TestConstants.mediumVolume, + marketCap: 800000000000, + ); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + // Act + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 7)), + interval: TestConstants.interval1h, + ); + + // Assert + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval1h, + TestConstants.interval1h, + ); + }); + + test('formats date correctly as YYYY-MM-DD', () async { + // Arrange + final mockResponse = TestFixtures.createHistoricalOhlcResponse( + price: 1.02, + volume24h: TestConstants.lowVolume, + marketCap: TestConstants.smallMarketCap, + ); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + final startDate = DateTime(2024, 8, 25, 14, 30, 45); // Date with time + + // Act + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: startDate, + ); + + // Assert + VerificationHelpers.verifyDateFormatting( + mockHttpClient, + startDate, + '2024-08-25', + ); + }); + + test('generates URL matching correct format example', () async { + // Arrange + final mockResponse = TestFixtures.createHistoricalOhlcResponse( + timestamp: '2025-01-01T00:00:00Z', + ); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + final startDate = DateTime.now().subtract(const Duration(days: 30)); + + // Act + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: startDate, + ); + + // Assert + final capturedUri = verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + expect(capturedUri.host, equals('api.coinpaprika.com')); + expect(capturedUri.path, equals('/v1/tickers/btc-bitcoin/historical')); + expect(capturedUri.queryParameters.containsKey('start'), isTrue); + expect(capturedUri.queryParameters.containsKey('interval'), isTrue); + }); + }); + + group('interval conversion tests', () { + final emptyResponse = TestFixtures.createHistoricalOhlcResponse( + ticks: [], + ); + final testDate = DateTime.now().subtract(const Duration(days: 30)); + + test('converts 24h to 1d', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: testDate, + interval: TestConstants.interval24h, + ); + + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval24h, + TestConstants.interval1d, + ); + }); + + test('preserves 1d as-is', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: testDate, + ); + + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval1d, + TestConstants.interval1d, + ); + }); + + test('preserves 1h as-is', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 7)), + interval: TestConstants.interval1h, + ); + + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval1h, + TestConstants.interval1h, + ); + }); + + test('preserves 5m as-is', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 7)), + interval: TestConstants.interval5m, + ); + + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval5m, + TestConstants.interval5m, + ); + }); + + test('preserves 15m as-is', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 7)), + interval: TestConstants.interval15m, + ); + + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval15m, + TestConstants.interval15m, + ); + }); + + test('preserves 30m as-is', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 7)), + interval: TestConstants.interval30m, + ); + + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + TestConstants.interval30m, + TestConstants.interval30m, + ); + }); + + test('passes through unknown intervals as-is', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 30)), + interval: '7d', + ); + + VerificationHelpers.verifyIntervalConversion( + mockHttpClient, + '7d', + '7d', + ); + }); + }); + + group('date formatting tests', () { + final emptyResponse = TestFixtures.createHistoricalOhlcResponse( + ticks: [], + ); + + test('formats single digit month correctly', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime(2024, 3, 5), + ); + + VerificationHelpers.verifyDateFormatting( + mockHttpClient, + DateTime(2024, 3, 5), + TestConstants.dateFormatWithSingleDigits, + ); + }); + + test('formats single digit day correctly', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime(2024, 12, 7), + ); + + VerificationHelpers.verifyDateFormatting( + mockHttpClient, + DateTime(2024, 12, 7), + '2024-12-07', + ); + }); + + test('ignores time portion of datetime', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime(2024, 6, 15, 14, 30, 45, 123, 456), + ); + + VerificationHelpers.verifyDateFormatting( + mockHttpClient, + DateTime(2024, 6, 15, 14, 30, 45, 123, 456), + '2024-06-15', + ); + }); + }); + + group('URL format regression tests', () { + test('URL format does not cause 400 Bad Request - regression test', () async { + // This test validates the fix for the issue where the old URL format: + // https://api.coinpaprika.com/v1/tickers/aur-auroracoin/historical?start=2025-08-25"e=usdt&interval=24h&limit=5000&end=2025-09-01 + // was causing 400 Bad Request responses. + // + // The correct format is: + // https://api.coinpaprika.com/v1/tickers/btc-bitcoin/historical?start=2025-01-01&interval=1d + + final mockResponse = TestFixtures.createHistoricalOhlcResponse( + timestamp: '2025-01-01T00:00:00Z', + ); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + final startDate = DateTime.now().subtract(const Duration(days: 30)); + + // Act - this should not throw an exception or cause 400 Bad Request + final result = await provider.fetchHistoricalOhlc( + coinId: 'aur-auroracoin', + startDate: startDate, + interval: TestConstants.interval24h, // This gets converted to '1d' + ); + + // Assert + expect(result, isNotEmpty); + + final capturedUri = verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + expect(capturedUri.host, equals(TestConstants.baseUrl)); + expect(capturedUri.path, equals('${TestConstants.apiVersion}/tickers/aur-auroracoin/historical')); + expect(capturedUri.queryParameters.containsKey('start'), isTrue); + expect(capturedUri.queryParameters.containsKey('interval'), isTrue); + }); + + test( + 'validates that quote parameter is properly mapped in URLs', + () async { + // The real provider includes quote parameter with proper mapping + final mockResponse = TestFixtures.createHistoricalOhlcResponse( + ticks: [], + ); + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + // This call should map USDT to USD in the URL + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: DateTime.now().subtract(const Duration(days: 30)), + quote: Stablecoin.usdt, // This should be mapped to USD + ); + + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single + as Uri; + expect(capturedUri.queryParameters.containsKey('quote'), isTrue, + reason: 'Quote parameter should be included in historical OHLC requests'); + expect(capturedUri.queryParameters['quote'], equals('usd'), + reason: 'USDT should be mapped to USD'); + }, + ); + }); + + group('fetchCoinTicker quote currency mapping', () { + test( + 'uses correct coinPaprikaId mapping for multiple quote currencies', + () async { + // Arrange + final mockResponse = TestFixtures.createTickerResponse( + quotes: TestFixtures.createMultipleQuotes( + currencies: [ + TestConstants.usdQuote, + TestConstants.usdtQuote, + TestConstants.eurQuote, + ], + prices: [ + TestConstants.bitcoinPrice, + TestConstants.bitcoinPrice + 10, + 42000.0, + ], + ), + ); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + // Act + await provider.fetchCoinTicker( + coinId: TestConstants.bitcoinCoinId, + quotes: [FiatCurrency.usd, Stablecoin.usdt, FiatCurrency.eur], + ); + + // Assert + VerificationHelpers.verifyTickerUrl( + mockHttpClient, + TestConstants.bitcoinCoinId, + expectedQuotes: 'USD,USD,EUR', + ); + }, + ); + + test('converts coinPaprikaId to uppercase for API request', () async { + // Arrange + final mockResponse = TestFixtures.createTickerResponse( + quotes: TestFixtures.createMultipleQuotes( + currencies: [TestConstants.btcQuote, TestConstants.ethQuote], + prices: [1.0, 15.2], + ), + ); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + // Act + await provider.fetchCoinTicker( + coinId: TestConstants.bitcoinCoinId, + quotes: [Cryptocurrency.btc, Cryptocurrency.eth], + ); + + // Assert + VerificationHelpers.verifyTickerUrl( + mockHttpClient, + TestConstants.bitcoinCoinId, + expectedQuotes: 'BTC,ETH', + ); + }); + + test('handles single quote currency correctly', () async { + // Arrange + final mockResponse = TestFixtures.createTickerResponse( + quotes: { + TestConstants.gbpQuote: { + 'price': 38000.0, + 'volume_24h': TestConstants.highVolume, + 'volume_24h_change_24h': 0.0, + 'market_cap': TestConstants.bitcoinMarketCap, + 'market_cap_change_24h': 0.0, + 'percent_change_15m': 0.0, + 'percent_change_30m': 0.0, + 'percent_change_1h': 0.0, + 'percent_change_6h': 0.0, + 'percent_change_12h': 0.0, + 'percent_change_24h': TestConstants.positiveChange, + 'percent_change_7d': 0.0, + 'percent_change_30d': 0.0, + 'percent_change_1y': 0.0, + }, + }, + ); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + // Act + await provider.fetchCoinTicker( + coinId: TestConstants.bitcoinCoinId, + quotes: [FiatCurrency.gbp], + ); + + // Assert + VerificationHelpers.verifyTickerUrl( + mockHttpClient, + TestConstants.bitcoinCoinId, + expectedQuotes: TestConstants.gbpQuote, + ); + }); + }); + + group('fetchCoinMarkets quote currency mapping', () { + test('uses correct coinPaprikaId mapping for market data', () async { + // Arrange + final mockResponse = TestFixtures.createMarketsResponse(); + + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => mockResponse); + + // Act + await provider.fetchCoinMarkets( + coinId: TestConstants.bitcoinCoinId, + quotes: [FiatCurrency.usd, Stablecoin.usdt], + ); + + // Assert + VerificationHelpers.verifyMarketsUrl( + mockHttpClient, + TestConstants.bitcoinCoinId, + expectedQuotes: 'USD,USD', + ); + }); + }); + + group('coinPaprikaId extension usage', () { + test('verifies QuoteCurrency.coinPaprikaId returns lowercase values', () { + // Test various currency types to ensure the extension works correctly + final expectedMappings = + { + FiatCurrency.usd: 'usd', + FiatCurrency.eur: 'eur', + FiatCurrency.gbp: 'gbp', + Stablecoin.usdt: 'usdt', + Stablecoin.usdc: 'usdc', + Stablecoin.eurs: 'eurs', + Cryptocurrency.btc: 'btc', + Cryptocurrency.eth: 'eth', + }..forEach((currency, expectedId) { + expect(currency.coinPaprikaId, equals(expectedId)); + }); + }); + + test('verifies provider uses coinPaprikaId extension consistently', () { + // Arrange + const testCurrency = FiatCurrency.jpy; + + // Act & Assert + expect(testCurrency.coinPaprikaId, equals('jpy')); + + // Verify this matches what would be used in the provider + final supportedCurrencies = provider.supportedQuoteCurrencies; + final jpyCurrency = supportedCurrencies.firstWhere( + (currency) => currency.symbol == 'JPY', + ); + expect(jpyCurrency.coinPaprikaId, equals('jpy')); + }); + }); + + group('error handling', () { + test('throws exception when HTTP request fails for OHLC', () async { + // Arrange + MockHelpers.setupErrorResponses(mockHttpClient); + + // Act & Assert + expect( + () => provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: TestData.pastDate, + ), + throwsA(isA()), + ); + }); + + test('throws exception when HTTP request fails for ticker', () async { + // Arrange + MockHelpers.setupErrorResponses(mockHttpClient); + + // Act & Assert + expect( + () => provider.fetchCoinTicker( + coinId: TestConstants.bitcoinCoinId, + quotes: [FiatCurrency.usd], + ), + throwsA(isA()), + ); + }); + }); + + group('Quote Currency Mapping', () { + final testDate = DateTime.now().subtract(const Duration(days: 30)); + final emptyResponse = TestFixtures.createHistoricalOhlcResponse( + ticks: [], + ); + + test( + 'OHLC requests include quote parameter with proper mapping', + () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => emptyResponse); + + await provider.fetchHistoricalOhlc( + coinId: TestConstants.bitcoinCoinId, + startDate: testDate, + quote: Stablecoin.usdt, // Should be mapped to USD + ); + + // Verify that quote parameter is included and properly mapped + final captured = verify( + () => mockHttpClient.get(captureAny()), + ).captured; + final uri = captured.first as Uri; + expect( + uri.queryParameters.containsKey('quote'), + isTrue, + reason: 'OHLC requests should include quote parameter in real provider', + ); + expect( + uri.queryParameters['quote'], + equals('usd'), + reason: 'USDT should be mapped to USD in quote parameter', + ); + }, + ); + + test('maps stablecoins to underlying fiat for ticker requests', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => TestFixtures.createTickerResponse()); + + await provider.fetchCoinTicker( + coinId: TestConstants.bitcoinCoinId, + quotes: [Stablecoin.usdt, Stablecoin.usdc], // Should map to USD + ); + + final captured = verify( + () => mockHttpClient.get(captureAny()), + ).captured; + final uri = captured.first as Uri; + expect(uri.queryParameters['quotes'], equals('USD,USD')); + }); + + test('maps stablecoins to underlying fiat for market requests', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => TestFixtures.createMarketsResponse()); + + await provider.fetchCoinMarkets( + coinId: TestConstants.bitcoinCoinId, + quotes: [Stablecoin.usdt, Stablecoin.eurs], // USDT->USD, EURS->EUR + ); + + final captured = verify( + () => mockHttpClient.get(captureAny()), + ).captured; + final uri = captured.first as Uri; + expect(uri.queryParameters['quotes'], equals('USD,EUR')); + }); + + test('handles mixed quote types correctly', () async { + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => TestFixtures.createMarketsResponse()); + + await provider.fetchCoinMarkets( + coinId: TestConstants.bitcoinCoinId, + quotes: [ + FiatCurrency.usd, // Should remain USD + Stablecoin.usdt, // Should map to USD + Cryptocurrency.btc, // Should remain BTC + Stablecoin.eurs, // Should map to EUR + ], + ); + + final captured = verify( + () => mockHttpClient.get(captureAny()), + ).captured; + final uri = captured.first as Uri; + expect(uri.queryParameters['quotes'], equals('USD,USD,BTC,EUR')); + }); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_repository_test.dart b/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_repository_test.dart new file mode 100644 index 00000000..7adfe5e5 --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika/coinpaprika_repository_test.dart @@ -0,0 +1,969 @@ +import 'package:decimal/decimal.dart'; +import 'package:komodo_cex_market_data/src/_core_index.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import 'fixtures/mock_helpers.dart'; +import 'fixtures/test_constants.dart'; +import 'fixtures/test_fixtures.dart'; +import 'fixtures/verification_helpers.dart'; + +void main() { + setUpAll(MockHelpers.registerFallbackValues); + + group('CoinPaprikaRepository', () { + late MockCoinPaprikaProvider mockProvider; + late CoinPaprikaRepository repository; + + setUp(() { + mockProvider = MockCoinPaprikaProvider(); + repository = CoinPaprikaRepository( + coinPaprikaProvider: mockProvider, + enableMemoization: false, // Disable for testing + ); + + MockHelpers.setupMockProvider(mockProvider); + }); + + group('getCoinList', () { + test('returns list of active coins with supported currencies', () async { + // Arrange + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: TestData.allCoins, + ); + + // Act + final result = await repository.getCoinList(); + + // Assert + expect(result, hasLength(2)); // Only active coins + expect(result[0].id, equals(TestConstants.bitcoinCoinId)); + expect(result[0].symbol, equals(TestConstants.bitcoinSymbol)); + expect(result[0].name, equals(TestConstants.bitcoinName)); + expect(result[0].currencies, contains('usd')); + expect(result[0].currencies, contains('btc')); + expect(result[0].currencies, contains('eur')); + + expect(result[1].id, equals(TestConstants.ethereumCoinId)); + expect(result[1].symbol, equals(TestConstants.ethereumSymbol)); + expect(result[1].name, equals(TestConstants.ethereumName)); + + VerificationHelpers.verifyFetchCoinList(mockProvider); + }); + + test('handles provider errors gracefully', () async { + // Arrange + MockHelpers.setupProviderErrors( + mockProvider, + coinListError: Exception('API error'), + ); + + // Act & Assert + await expectLater( + () => repository.getCoinList(), + throwsA(isA()), + ); + VerificationHelpers.verifyFetchCoinList(mockProvider); + }); + }); + + group('resolveTradingSymbol', () { + test('returns coinPaprikaId when available', () { + // Act + final result = repository.resolveTradingSymbol(TestData.bitcoinAsset); + + // Assert + expect(result, equals(TestConstants.bitcoinCoinId)); + }); + + test('throws ArgumentError when coinPaprikaId is missing', () async { + // Act & Assert + await expectLater( + () => repository.resolveTradingSymbol(TestData.unsupportedAsset), + throwsA(isA()), + ); + }); + }); + + group('canHandleAsset', () { + test('returns true when coinPaprikaId is available', () { + // Act + final result = repository.canHandleAsset(TestData.bitcoinAsset); + + // Assert + expect(result, isTrue); + }); + + test('returns false when coinPaprikaId is missing', () { + // Act + final result = repository.canHandleAsset(TestData.unsupportedAsset); + + // Assert + expect(result, isFalse); + }); + }); + + group('getCoinFiatPrice', () { + test('returns current price from markets endpoint', () async { + // Arrange + MockHelpers.setupProviderTickerResponse(mockProvider); + + // Act + final result = await repository.getCoinFiatPrice(TestData.bitcoinAsset); + + // Assert + expect(result, equals(TestData.bitcoinPriceDecimal)); + VerificationHelpers.verifyFetchCoinTicker( + mockProvider, + expectedCoinId: TestConstants.bitcoinCoinId, + expectedQuotes: [Stablecoin.usdt], + ); + }); + + test('throws exception when no market data available', () async { + // Arrange + MockHelpers.setupEmptyQuotesScenario(mockProvider); + + // Act & Assert + expect( + () => repository.getCoinFiatPrice(TestData.bitcoinAsset), + throwsA(isA()), + ); + }); + }); + + group('getCoinOhlc', () { + test('returns OHLC data within API plan limits', () async { + // Arrange + final mockOhlcData = [TestFixtures.createMockOhlc()]; + MockHelpers.setupProviderOhlcResponse( + mockProvider, + ohlcData: mockOhlcData, + ); + + final now = DateTime.now(); + final startAt = now.subtract(const Duration(hours: 12)); + final endAt = now.subtract( + const Duration(hours: 1), + ); // Within 24h limit + + // Act + final result = await repository.getCoinOhlc( + TestData.bitcoinAsset, + Stablecoin.usdt, + GraphInterval.oneHour, + startAt: startAt, + endAt: endAt, + ); + + // Assert + expect(result.ohlc, hasLength(1)); + expect( + result.ohlc.first.openDecimal, + equals(TestData.bitcoinPriceDecimal), + ); + expect(result.ohlc.first.highDecimal, equals(Decimal.fromInt(52000))); + expect(result.ohlc.first.lowDecimal, equals(Decimal.fromInt(44000))); + expect( + result.ohlc.first.closeDecimal, + equals(TestData.bitcoinPriceDecimal), + ); + + VerificationHelpers.verifyFetchHistoricalOhlc(mockProvider); + }); + + test( + 'throws ArgumentError for requests exceeding 24h without start/end dates', + () async { + // Act - should not throw since default period is 24h (within limit) + final result = await repository.getCoinOhlc( + TestData.bitcoinAsset, + Stablecoin.usdt, + GraphInterval.oneHour, + // No startAt/endAt - defaults to 24h which is within limit + ); + + // Assert - should get empty result, not throw error + expect(result.ohlc, isEmpty); + }, + ); + + test( + 'splits requests to fetch all available data when exceeding plan limits', + () async { + // Arrange + MockHelpers.setupBatchingScenario( + mockProvider, + apiPlan: const CoinPaprikaApiPlan.business(), + ); + + final now = DateTime.now(); + final requestedStart = now.subtract( + const Duration(days: 200), + ); // Within 365-day limit + final endAt = now; + + final result = await repository.getCoinOhlc( + TestData.bitcoinAsset, + Stablecoin.usdt, + GraphInterval.oneDay, + startAt: requestedStart, + endAt: endAt, + ); + + // Assert - should contain data from all batches + expect(result.ohlc, isNotEmpty); + expect( + result.ohlc.length, + greaterThanOrEqualTo(2), + ); // Multiple batches should return combined data + + // Verify that multiple provider calls were made for batching (200 days should trigger multiple batches) + VerificationHelpers.verifyMultipleProviderCalls(mockProvider, 0); + }, + ); + + test( + 'returns empty OHLC when entire requested range is before cutoff', + () async { + // Arrange + MockHelpers.setupApiPlan( + mockProvider, + const CoinPaprikaApiPlan.free(), + ); + + // Request data from 400 days ago to 390 days ago (both before cutoff) + final requestedStart = DateTime.now().subtract( + const Duration(days: 400), + ); + final requestedEnd = DateTime.now().subtract( + const Duration(days: 390), + ); + + // Act + final result = await repository.getCoinOhlc( + TestData.bitcoinAsset, + Stablecoin.usdt, + GraphInterval.oneDay, + startAt: requestedStart, + endAt: requestedEnd, + ); + + // Assert - should return empty OHLC without making provider calls + expect(result.ohlc, isEmpty); + + // Verify no provider calls were made since effective range is invalid + VerificationHelpers.verifyNoFetchHistoricalOhlcCalls(mockProvider); + }, + ); + + test( + 'fetches all available data by splitting requests when part of range is before cutoff', + () async { + // Arrange + MockHelpers.setupApiPlan( + mockProvider, + const CoinPaprikaApiPlan.free(), + ); + + final mockOhlcData = [ + TestFixtures.createMockOhlc( + timeOpen: DateTime.now().subtract(const Duration(days: 100)), + timeClose: DateTime.now().subtract(const Duration(days: 99)), + ), + ]; + MockHelpers.setupProviderOhlcResponse( + mockProvider, + ohlcData: mockOhlcData, + ); + + // Request data from 400 days ago to now (starts before cutoff but ends within available range) + final requestedStart = DateTime.now().subtract( + const Duration(days: 400), + ); + final endAt = DateTime.now(); + + // Act + final result = await repository.getCoinOhlc( + TestData.bitcoinAsset, + Stablecoin.usdt, + GraphInterval.oneDay, + startAt: requestedStart, + endAt: endAt, + ); + + // Assert - should get available data from cutoff onwards + expect(result.ohlc, isNotEmpty); + + VerificationHelpers.verifyFetchHistoricalOhlc( + mockProvider, + expectedCoinId: TestConstants.bitcoinCoinId, + expectedQuote: Stablecoin.usdt, + expectedCallCount: 5, // 400 days batched into ~90-day chunks + ); + }, + ); + }); + + group('supports', () { + test('returns true for supported asset and quote currency', () async { + // Arrange + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: [TestData.bitcoinCoin], + ); + + // Act + final result = await repository.supports( + TestData.bitcoinAsset, + FiatCurrency.usd, + PriceRequestType.currentPrice, + ); + + // Assert + expect(result, isTrue); + }); + + test('returns true for supported stablecoin quote currency', () async { + // Arrange + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: [TestData.bitcoinCoin], + ); + + // Act - Using USDT stablecoin which should be supported via its underlying fiat (USD) + // even though the provider only lists USD, not USDT, in supportedQuoteCurrencies + final result = await repository.supports( + TestData.bitcoinAsset, + Stablecoin.usdt, + PriceRequestType.currentPrice, + ); + + // Assert + expect(result, isTrue); + }); + + test( + 'returns true for EUR-based stablecoin when EUR is supported', + () async { + // Arrange + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: [TestData.bitcoinCoin], + ); + + // Act - Using EURS stablecoin which should be supported via its underlying fiat (EUR) + final result = await repository.supports( + TestData.bitcoinAsset, + Stablecoin.eurs, + PriceRequestType.currentPrice, + ); + + // Assert + expect(result, isTrue); + }, + ); + + test( + 'returns false for stablecoin with unsupported underlying fiat', + () async { + // Arrange - Mock provider that doesn't support JPY + when(() => mockProvider.supportedQuoteCurrencies).thenReturn([ + FiatCurrency.usd, + FiatCurrency.eur, + // No JPY here + ]); + + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: [TestData.bitcoinCoin], + ); + + // Act - Using JPYT stablecoin which maps to JPY (not supported by provider) + final result = await repository.supports( + TestData.bitcoinAsset, + Stablecoin.jpyt, + PriceRequestType.currentPrice, + ); + + // Assert + expect(result, isFalse); + }, + ); + + test('returns false for unsupported quote currency', () async { + // Arrange + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: [TestData.bitcoinCoin], + ); + + // Act - Using an unsupported quote currency + final result = await repository.supports( + TestData.bitcoinAsset, + const QuoteCurrency.commodity(symbol: 'GOLD', displayName: 'Gold'), + PriceRequestType.currentPrice, + ); + + // Assert + expect(result, isFalse); + }); + + test('returns false for unsupported fiat currency', () async { + // Arrange + MockHelpers.setupProviderCoinListResponse( + mockProvider, + coins: [TestData.bitcoinCoin], + ); + + // Create an unsupported fiat currency + const unsupportedFiat = QuoteCurrency.fiat( + symbol: 'UNSUPPORTED', + displayName: 'Unsupported Currency', + ); + + // Act - Using an unsupported fiat currency + final result = await repository.supports( + TestData.bitcoinAsset, + unsupportedFiat, + PriceRequestType.currentPrice, + ); + + // Assert + expect(result, isFalse); + }); + + test('returns false when asset cannot be resolved', () async { + // Act + final result = await repository.supports( + TestData.unsupportedAsset, + FiatCurrency.usd, + PriceRequestType.currentPrice, + ); + + // Assert + expect(result, isFalse); + }); + }); + + group('stablecoin to fiat mapping', () { + test('correctly maps USDT to USD for price requests', () async { + // Arrange + MockHelpers.setupProviderTickerResponse(mockProvider); + + // Act - Using USDT stablecoin + final result = await repository.getCoinFiatPrice(TestData.bitcoinAsset); + + // Assert + expect(result, equals(TestData.bitcoinPriceDecimal)); + + // Verify that the provider was called with USDT stablecoin + VerificationHelpers.verifyFetchCoinTicker( + mockProvider, + expectedCoinId: TestConstants.bitcoinCoinId, + expectedQuotes: [Stablecoin.usdt], + ); + }); + + test('correctly maps EUR-pegged stablecoin for price requests', () async { + // Arrange + final mockTicker = TestFixtures.createMockTicker( + quoteCurrency: TestConstants.eursQuote, + price: 42000, + ); + MockHelpers.setupProviderTickerResponse( + mockProvider, + ticker: mockTicker, + ); + + // Act - Using EURS stablecoin + final result = await repository.getCoinFiatPrice( + TestData.bitcoinAsset, + fiatCurrency: Stablecoin.eurs, + ); + + // Assert + expect(result, equals(Decimal.fromInt(42000))); + + // Verify that the provider was called with EURS stablecoin + VerificationHelpers.verifyFetchCoinTicker( + mockProvider, + expectedCoinId: TestConstants.bitcoinCoinId, + expectedQuotes: [Stablecoin.eurs], + ); + }); + + test( + 'uses correct coinPaprikaId for stablecoin in OHLC requests', + () async { + // Arrange + final mockOhlcData = [TestFixtures.createMockOhlc()]; + MockHelpers.setupProviderOhlcResponse( + mockProvider, + ohlcData: mockOhlcData, + ); + + final now = DateTime.now(); + final startAt = now.subtract(const Duration(hours: 12)); + final endAt = now.subtract(const Duration(hours: 1)); + + // Act - Using USDT stablecoin + final result = await repository.getCoinOhlc( + TestData.bitcoinAsset, + Stablecoin.usdt, + GraphInterval.oneHour, + startAt: startAt, + endAt: endAt, + ); + + // Assert + expect(result.ohlc, hasLength(1)); + expect( + result.ohlc.first.closeDecimal, + equals(TestData.bitcoinPriceDecimal), + ); + + // Verify that the provider was called with USDT stablecoin directly + VerificationHelpers.verifyFetchHistoricalOhlc( + mockProvider, + expectedCoinId: TestConstants.bitcoinCoinId, + expectedQuote: Stablecoin.usdt, + ); + }, + ); + + test( + 'correctly handles 24hr price change with stablecoin currency', + () async { + // Arrange + final mockTicker = TestFixtures.createMockTicker( + quoteCurrency: TestConstants.usdcQuote, + percentChange24h: 3.2, + ); + MockHelpers.setupProviderTickerResponse( + mockProvider, + ticker: mockTicker, + ); + + // Act - Using USDC stablecoin + final result = await repository.getCoin24hrPriceChange( + TestData.bitcoinAsset, + fiatCurrency: Stablecoin.usdc, + ); + + // Assert + expect(result, equals(Decimal.parse('3.2'))); + + // Verify that the provider was called with USDC stablecoin + VerificationHelpers.verifyFetchCoinTicker( + mockProvider, + expectedCoinId: TestConstants.bitcoinCoinId, + expectedQuotes: [Stablecoin.usdc], + ); + }, + ); + }); + + group('Batch Duration Validation Tests', () { + test('ensures no batch exceeds 90 days for free plan', () async { + // Arrange + final assetId = AssetId( + id: 'BTC', + name: 'Bitcoin', + symbol: AssetSymbol( + assetConfigId: 'BTC', + coinPaprikaId: 'btc-bitcoin', + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + // Set up Free plan (90-day batch size for historical ticks) + when( + () => mockProvider.apiPlan, + ).thenReturn(const CoinPaprikaApiPlan.free()); + + final mockOhlcData = [ + Ohlc.coinpaprika( + timeOpen: DateTime.now() + .toUtc() + .subtract(const Duration(days: 30)) + .millisecondsSinceEpoch, + timeClose: DateTime.now().toUtc().millisecondsSinceEpoch, + open: Decimal.fromInt(50000), + high: Decimal.fromInt(52000), + low: Decimal.fromInt(48000), + close: Decimal.fromInt(51000), + volume: Decimal.fromInt(1000000), + marketCap: Decimal.fromInt(900000000000), + ), + ]; + + // Mock provider to track batch requests + final capturedBatchRequests = >[]; + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer((invocation) async { + final startDate = invocation.namedArguments[#startDate] as DateTime; + final endDate = invocation.namedArguments[#endDate] as DateTime; + + capturedBatchRequests.add({ + 'startDate': startDate, + 'endDate': endDate, + 'duration': endDate.difference(startDate), + }); + + return mockOhlcData; + }); + + // Request data for exactly 200 days to force multiple batches + // Each batch should be 90 days + final now = DateTime.now().toUtc(); + final requestedStart = now.subtract(const Duration(days: 200)); + final requestedEnd = now; + + // Act + await repository.getCoinOhlc( + assetId, + FiatCurrency.usd, + GraphInterval.oneDay, + startAt: requestedStart, + endAt: requestedEnd, + ); + + // Assert + expect(capturedBatchRequests, isNotEmpty); + + // Verify each batch is within the safe limit (90 days) + const maxSafeDuration = Duration(days: 90); + for (final request in capturedBatchRequests) { + final duration = request['duration'] as Duration; + expect( + duration, + lessThanOrEqualTo(maxSafeDuration), + reason: + 'Batch duration ${duration.inDays} days ' + 'exceeds safe limit of 90 days', + ); + } + }); + + test('uses UTC time for all date calculations', () async { + // Arrange + final assetId = AssetId( + id: 'BTC', + name: 'Bitcoin', + symbol: AssetSymbol( + assetConfigId: 'BTC', + coinPaprikaId: 'btc-bitcoin', + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + when( + () => mockProvider.apiPlan, + ).thenReturn(const CoinPaprikaApiPlan.free()); + + final mockOhlcData = [ + Ohlc.coinpaprika( + timeOpen: DateTime.now().toUtc().millisecondsSinceEpoch, + timeClose: DateTime.now().toUtc().millisecondsSinceEpoch, + open: Decimal.fromInt(50000), + high: Decimal.fromInt(52000), + low: Decimal.fromInt(48000), + close: Decimal.fromInt(51000), + volume: Decimal.fromInt(1000000), + marketCap: Decimal.fromInt(900000000000), + ), + ]; + + DateTime? capturedStartDate; + DateTime? capturedEndDate; + + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer((invocation) async { + capturedStartDate = + invocation.namedArguments[#startDate] as DateTime?; + capturedEndDate = invocation.namedArguments[#endDate] as DateTime?; + return mockOhlcData; + }); + + // Act - don't provide endAt to test default behavior + await repository.getCoinOhlc( + assetId, + FiatCurrency.usd, + GraphInterval.oneDay, + ); + + // Assert + expect(capturedEndDate, isNotNull); + + // Verify the captured endDate is in UTC (should have zero offset from UTC) + if (capturedEndDate != null) { + final utcNow = DateTime.now().toUtc(); + final timeDifference = capturedEndDate!.difference(utcNow).abs(); + + // Should be very close to current UTC time (within 1 minute) + expect( + timeDifference, + lessThan(const Duration(minutes: 1)), + reason: + 'End date should be close to current UTC time. ' + 'Captured: ${capturedEndDate!.toIso8601String()}, ' + 'UTC Now: ${utcNow.toIso8601String()}', + ); + } + }); + + test( + 'validates batch duration and throws error if exceeding safe limit', + () async { + // Arrange + final assetId = AssetId( + id: 'BTC', + name: 'Bitcoin', + symbol: AssetSymbol( + assetConfigId: 'BTC', + coinPaprikaId: 'btc-bitcoin', + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + // Set up a custom plan that would create an invalid batch + when( + () => mockProvider.apiPlan, + ).thenReturn(const CoinPaprikaApiPlan.free()); + + // Mock the provider to return data + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer( + (_) async => [ + Ohlc.coinpaprika( + timeOpen: DateTime.now().toUtc().millisecondsSinceEpoch, + timeClose: DateTime.now().toUtc().millisecondsSinceEpoch, + open: Decimal.fromInt(50000), + high: Decimal.fromInt(52000), + low: Decimal.fromInt(48000), + close: Decimal.fromInt(51000), + volume: Decimal.fromInt(1000000), + marketCap: Decimal.fromInt(900000000000), + ), + ], + ); + + // Act & Assert + // Create a scenario where batch calculation might exceed safe limit + final now = DateTime.now().toUtc(); + final requestedStart = DateTime( + now.year, + now.month, + now.day - 2, + ); // Exactly 2 days ago + final requestedEnd = DateTime( + now.year, + now.month, + now.day, + ); // Start of today + + // This should not throw an error as the repository should handle batching correctly + expect( + () => repository.getCoinOhlc( + assetId, + FiatCurrency.usd, + GraphInterval.oneDay, + startAt: requestedStart, + endAt: requestedEnd, + ), + returnsNormally, + ); + }, + ); + + test( + 'handles starter plan with 5-year limit and 90-day batch size', + () async { + // Arrange + final assetId = AssetId( + id: 'BTC', + name: 'Bitcoin', + symbol: AssetSymbol( + assetConfigId: 'BTC', + coinPaprikaId: 'btc-bitcoin', + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + // Set up Starter plan (5 years limit with 90-day batch size) + when( + () => mockProvider.apiPlan, + ).thenReturn(const CoinPaprikaApiPlan.starter()); + + final mockOhlcData = [ + Ohlc.coinpaprika( + timeOpen: DateTime.now().toUtc().millisecondsSinceEpoch, + timeClose: DateTime.now().toUtc().millisecondsSinceEpoch, + open: Decimal.fromInt(50000), + high: Decimal.fromInt(52000), + low: Decimal.fromInt(48000), + close: Decimal.fromInt(51000), + volume: Decimal.fromInt(1000000), + marketCap: Decimal.fromInt(900000000000), + ), + ]; + + final capturedBatchRequests = []; + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer((invocation) async { + final startDate = invocation.namedArguments[#startDate] as DateTime; + final endDate = invocation.namedArguments[#endDate] as DateTime; + capturedBatchRequests.add(endDate.difference(startDate)); + return mockOhlcData; + }); + + // Request data for exactly 200 days (should create multiple 90-day batches) + final now = DateTime.now().toUtc(); + final requestedStart = now.subtract(const Duration(days: 200)); + final requestedEnd = now; + + // Act + await repository.getCoinOhlc( + assetId, + FiatCurrency.usd, + GraphInterval.oneDay, + startAt: requestedStart, + endAt: requestedEnd, + ); + + // Assert + expect(capturedBatchRequests, isNotEmpty); + + // For starter plan with 90-day batch size, max batch should be 90 days + const maxSafeDuration = Duration(days: 90); + + for (final duration in capturedBatchRequests) { + expect( + duration, + lessThanOrEqualTo(maxSafeDuration), + reason: + 'Batch duration ${duration.inDays} days ' + 'exceeds safe limit of 90 days for starter plan', + ); + } + }, + ); + + test('batch size prevents oversized requests', () async { + // Arrange + final assetId = AssetId( + id: 'BTC', + name: 'Bitcoin', + symbol: AssetSymbol( + assetConfigId: 'BTC', + coinPaprikaId: 'btc-bitcoin', + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + // Test with 90-day batch size for historical ticks + when( + () => mockProvider.apiPlan, + ).thenReturn(const CoinPaprikaApiPlan.free()); + + final mockOhlcData = [ + Ohlc.coinpaprika( + timeOpen: DateTime.now().toUtc().millisecondsSinceEpoch, + timeClose: DateTime.now().toUtc().millisecondsSinceEpoch, + open: Decimal.fromInt(50000), + high: Decimal.fromInt(52000), + low: Decimal.fromInt(48000), + close: Decimal.fromInt(51000), + volume: Decimal.fromInt(1000000), + marketCap: Decimal.fromInt(900000000000), + ), + ]; + + Duration? capturedBatchDuration; + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer((invocation) async { + final startDate = invocation.namedArguments[#startDate] as DateTime; + final endDate = invocation.namedArguments[#endDate] as DateTime; + capturedBatchDuration = endDate.difference(startDate); + return mockOhlcData; + }); + + // Request data for exactly 50 days - should fit in single batch + final now = DateTime.now().toUtc(); + final requestedStart = now.subtract(const Duration(days: 50)); + final requestedEnd = now; + + // Act + await repository.getCoinOhlc( + assetId, + FiatCurrency.usd, + GraphInterval.oneDay, + startAt: requestedStart, + endAt: requestedEnd, + ); + + // Assert + expect(capturedBatchDuration, isNotNull); + + // Batch should not exceed 90-day limit + const expectedMaxDuration = Duration(days: 90); + expect( + capturedBatchDuration, + lessThanOrEqualTo(expectedMaxDuration), + reason: + 'Batch duration should not exceed 90-day limit. ' + 'Expected max: 90 days, ' + 'Actual: ${capturedBatchDuration!.inDays} days', + ); + }); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika/fixtures/mock_helpers.dart b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/mock_helpers.dart new file mode 100644 index 00000000..7b8f6506 --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/mock_helpers.dart @@ -0,0 +1,337 @@ +/// Mock helpers for setting up common mock objects and behaviors +library; + +import 'package:http/http.dart' as http; +import 'package:komodo_cex_market_data/src/coinpaprika/_coinpaprika_index.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import 'test_constants.dart'; +import 'test_fixtures.dart'; + +/// Mock HTTP client for testing +class MockHttpClient extends Mock implements http.Client {} + +/// Mock CoinPaprika provider for testing +class MockCoinPaprikaProvider extends Mock implements ICoinPaprikaProvider {} + +/// Helper class for setting up common mock behaviors +class MockHelpers { + MockHelpers._(); + + /// Registers common fallback values for mocktail + static void registerFallbackValues() { + registerFallbackValue(Uri()); + registerFallbackValue(FiatCurrency.usd); + registerFallbackValue(DateTime.now()); + } + + /// Sets up a MockHttpClient with common successful responses + static void setupMockHttpClient(MockHttpClient mockHttpClient) { + // Default successful responses for common endpoints + when( + () => mockHttpClient.get(any()), + ).thenAnswer((_) async => TestFixtures.createCoinListResponse()); + } + + /// Sets up a MockCoinPaprikaProvider with common default behaviors + static void setupMockProvider(MockCoinPaprikaProvider mockProvider) { + // Default supported quote currencies + when( + () => mockProvider.supportedQuoteCurrencies, + ).thenReturn(TestConstants.defaultSupportedCurrencies); + + // Default API plan + when( + () => mockProvider.apiPlan, + ).thenReturn(const CoinPaprikaApiPlan.free()); + + // Default empty OHLC response + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer((_) async => []); + + // Default coin list response + when( + () => mockProvider.fetchCoinList(), + ).thenAnswer((_) async => TestData.activeCoins); + } + + /// Configures MockHttpClient to return specific historical OHLC responses + static void setupHistoricalOhlcResponse( + MockHttpClient mockHttpClient, { + List>? ticks, + int statusCode = 200, + }) { + when( + () => mockHttpClient.get( + any( + that: isA().having( + (uri) => uri.path, + 'path', + contains('/historical'), + ), + ), + ), + ).thenAnswer( + (_) async => TestFixtures.createHistoricalOhlcResponse( + ticks: ticks, + statusCode: statusCode, + ), + ); + } + + /// Configures MockHttpClient to return specific ticker responses + static void setupTickerResponse( + MockHttpClient mockHttpClient, { + String? coinId, + Map>? quotes, + int statusCode = 200, + }) { + when( + () => mockHttpClient.get( + any( + that: isA().having( + (uri) => uri.path, + 'path', + allOf(contains('/tickers/'), isNot(contains('/historical'))), + ), + ), + ), + ).thenAnswer( + (_) async => TestFixtures.createTickerResponse( + coinId: coinId, + quotes: quotes, + statusCode: statusCode, + ), + ); + } + + /// Configures MockHttpClient to return specific markets responses + static void setupMarketsResponse( + MockHttpClient mockHttpClient, { + List>? markets, + int statusCode = 200, + }) { + when( + () => mockHttpClient.get( + any( + that: isA().having( + (uri) => uri.path, + 'path', + contains('/tickers'), + ), + ), + ), + ).thenAnswer( + (_) async => TestFixtures.createMarketsResponse( + markets: markets, + statusCode: statusCode, + ), + ); + } + + /// Configures MockHttpClient to return error responses for all endpoints + static void setupErrorResponses( + MockHttpClient mockHttpClient, { + int statusCode = 500, + String? errorMessage, + }) { + when(() => mockHttpClient.get(any())).thenAnswer( + (_) async => TestFixtures.createErrorResponse( + statusCode: statusCode, + errorMessage: errorMessage, + ), + ); + } + + /// Configures MockCoinPaprikaProvider to return specific OHLC data + static void setupProviderOhlcResponse( + MockCoinPaprikaProvider mockProvider, { + List? ohlcData, + }) { + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer((_) async => ohlcData ?? TestFixtures.createMockOhlcList()); + } + + /// Configures MockCoinPaprikaProvider to return specific ticker data + static void setupProviderTickerResponse( + MockCoinPaprikaProvider mockProvider, { + CoinPaprikaTicker? ticker, + }) { + when( + () => mockProvider.fetchCoinTicker( + coinId: any(named: 'coinId'), + quotes: any(named: 'quotes'), + ), + ).thenAnswer((_) async => ticker ?? TestFixtures.createMockTicker()); + } + + /// Configures MockCoinPaprikaProvider to return specific markets data + static void setupProviderMarketsResponse( + MockCoinPaprikaProvider mockProvider, { + List? markets, + }) { + when( + () => mockProvider.fetchCoinMarkets( + coinId: any(named: 'coinId'), + quotes: any(named: 'quotes'), + ), + ).thenAnswer((_) async => markets ?? [TestFixtures.createMockMarket()]); + } + + /// Configures MockCoinPaprikaProvider to return specific coin list data + static void setupProviderCoinListResponse( + MockCoinPaprikaProvider mockProvider, { + List? coins, + }) { + when( + () => mockProvider.fetchCoinList(), + ).thenAnswer((_) async => coins ?? TestData.activeCoins); + } + + /// Configures MockCoinPaprikaProvider with extended supported currencies + static void setupExtendedSupportedCurrencies( + MockCoinPaprikaProvider mockProvider, + ) { + when( + () => mockProvider.supportedQuoteCurrencies, + ).thenReturn(TestConstants.extendedSupportedCurrencies); + } + + /// Configures MockCoinPaprikaProvider with specific API plan + static void setupApiPlan( + MockCoinPaprikaProvider mockProvider, + CoinPaprikaApiPlan apiPlan, + ) { + when(() => mockProvider.apiPlan).thenReturn(apiPlan); + } + + /// Configures MockCoinPaprikaProvider to throw exceptions + static void setupProviderErrors( + MockCoinPaprikaProvider mockProvider, { + Exception? coinListError, + Exception? ohlcError, + Exception? tickerError, + Exception? marketsError, + }) { + if (coinListError != null) { + when(() => mockProvider.fetchCoinList()).thenThrow(coinListError); + } + + if (ohlcError != null) { + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenThrow(ohlcError); + } + + if (tickerError != null) { + when( + () => mockProvider.fetchCoinTicker( + coinId: any(named: 'coinId'), + quotes: any(named: 'quotes'), + ), + ).thenThrow(tickerError); + } + + if (marketsError != null) { + when( + () => mockProvider.fetchCoinMarkets( + coinId: any(named: 'coinId'), + quotes: any(named: 'quotes'), + ), + ).thenThrow(marketsError); + } + } + + /// Creates a complete mock setup for successful scenarios + static void setupSuccessfulScenario( + MockCoinPaprikaProvider mockProvider, { + List? coins, + List? ohlcData, + CoinPaprikaTicker? ticker, + List? markets, + CoinPaprikaApiPlan? apiPlan, + }) { + setupMockProvider(mockProvider); + + if (coins != null) { + setupProviderCoinListResponse(mockProvider, coins: coins); + } + + if (ohlcData != null) { + setupProviderOhlcResponse(mockProvider, ohlcData: ohlcData); + } + + if (ticker != null) { + setupProviderTickerResponse(mockProvider, ticker: ticker); + } + + if (markets != null) { + setupProviderMarketsResponse(mockProvider, markets: markets); + } + + if (apiPlan != null) { + setupApiPlan(mockProvider, apiPlan); + } + } + + /// Creates mock setup for testing batching scenarios + static void setupBatchingScenario( + MockCoinPaprikaProvider mockProvider, { + int batchCount = 2, + int itemsPerBatch = 10, + CoinPaprikaApiPlan? apiPlan, + }) { + setupMockProvider(mockProvider); + + if (apiPlan != null) { + setupApiPlan(mockProvider, apiPlan); + } + + // Mock different responses for each batch request + final batchData = TestFixtures.createBatchOhlcData( + batchCount: batchCount, + itemsPerBatch: itemsPerBatch, + ); + + when( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).thenAnswer((_) async => batchData); + } + + /// Sets up a scenario where provider returns empty ticker quotes + static void setupEmptyQuotesScenario(MockCoinPaprikaProvider mockProvider) { + setupMockProvider(mockProvider); + setupProviderTickerResponse( + mockProvider, + ticker: TestFixtures.createEmptyQuotesTicker(), + ); + } +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika/fixtures/test_constants.dart b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/test_constants.dart new file mode 100644 index 00000000..3de45893 --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/test_constants.dart @@ -0,0 +1,262 @@ +/// Common test constants and data used across CoinPaprika tests +library; + +import 'package:decimal/decimal.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_coin.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Common test constants +class TestConstants { + TestConstants._(); + + // Common coin IDs + static const String bitcoinCoinId = 'btc-bitcoin'; + static const String ethereumCoinId = 'eth-ethereum'; + static const String inactiveCoinId = 'inactive-coin'; + static const String testCoinId = 'test-coin'; + + // Common symbols + static const String bitcoinSymbol = 'BTC'; + static const String ethereumSymbol = 'ETH'; + static const String inactiveSymbol = 'INACTIVE'; + static const String testSymbol = 'TEST'; + + // Common names + static const String bitcoinName = 'Bitcoin'; + static const String ethereumName = 'Ethereum'; + static const String inactiveName = 'Inactive Coin'; + static const String testName = 'Test Coin'; + + // Common prices + static const double bitcoinPrice = 50000.0; + static const double ethereumPrice = 3000.0; + static const double altcoinPrice = 1.50; + + // Common volumes + static const double highVolume = 1000000.0; + static const double mediumVolume = 500000.0; + static const double lowVolume = 100000.0; + + // Common market caps + static const double bitcoinMarketCap = 900000000000.0; + static const double ethereumMarketCap = 350000000000.0; + static const double smallMarketCap = 20000000.0; + + // Common percentage changes + static const double positiveChange = 2.5; + static const double negativeChange = -1.2; + static const double highPositiveChange = 15.8; + static const double highNegativeChange = -8.4; + + // Common supply values + static const int bitcoinCirculatingSupply = 19000000; + static const int bitcoinTotalSupply = 21000000; + static const int bitcoinMaxSupply = 21000000; + static const int ethereumCirculatingSupply = 120000000; + + // Common timestamps (as ISO strings for easy parsing) + static const String currentTimestamp = '2024-01-01T12:00:00Z'; + static const String pastTimestamp = '2024-01-01T00:00:00Z'; + static const String futureTimestamp = '2024-01-02T00:00:00Z'; + + // Common API URLs + static const String baseUrl = 'api.coinpaprika.com'; + static const String apiVersion = '/v1'; + + // Common intervals + static const String interval1d = '1d'; + static const String interval1h = '1h'; + static const String interval24h = '24h'; + static const String interval5m = '5m'; + static const String interval15m = '15m'; + static const String interval30m = '30m'; + + // Date formatting + static const String dateFormat = '2024-01-01'; + static const String dateFormatWithSingleDigits = '2024-03-05'; + + // Common quote currencies (as strings for API responses) + static const String usdQuote = 'USD'; + static const String eurQuote = 'EUR'; + static const String gbpQuote = 'GBP'; + static const String usdtQuote = 'USDT'; + static const String usdcQuote = 'USDC'; + static const String eursQuote = 'EURS'; + static const String btcQuote = 'BTC'; + static const String ethQuote = 'ETH'; + + // Common supported currencies list + static const List defaultSupportedCurrencies = [ + FiatCurrency.usd, + FiatCurrency.eur, + FiatCurrency.gbp, + FiatCurrency.jpy, + Cryptocurrency.btc, + Cryptocurrency.eth, + ]; + + // Extended supported currencies list (42 currencies as mentioned in provider test) + static const List extendedSupportedCurrencies = [ + // Fiat currencies + FiatCurrency.usd, + FiatCurrency.eur, + FiatCurrency.gbp, + FiatCurrency.jpy, + FiatCurrency.cad, + FiatCurrency.aud, + FiatCurrency.chf, + FiatCurrency.cny, + FiatCurrency.sek, + FiatCurrency.nok, + FiatCurrency.mxn, + FiatCurrency.sgd, + FiatCurrency.hkd, + FiatCurrency.inr, + FiatCurrency.krw, + FiatCurrency.rub, + FiatCurrency.brl, + FiatCurrency.zar, + FiatCurrency.tryLira, + FiatCurrency.nzd, + FiatCurrency.pln, + FiatCurrency.dkk, + FiatCurrency.twd, + FiatCurrency.thb, + FiatCurrency.huf, + FiatCurrency.czk, + FiatCurrency.ils, + FiatCurrency.clp, + FiatCurrency.php, + FiatCurrency.aed, + FiatCurrency.cop, + FiatCurrency.sar, + FiatCurrency.myr, + FiatCurrency.uah, + FiatCurrency.lkr, + FiatCurrency.mmk, + FiatCurrency.idr, + FiatCurrency.vnd, + FiatCurrency.bdt, + FiatCurrency.uah, + // Cryptocurrencies + Cryptocurrency.btc, + Cryptocurrency.eth, + ]; +} + +/// Predefined test data for common scenarios +class TestData { + TestData._(); + + /// Standard Bitcoin coin data + static const CoinPaprikaCoin bitcoinCoin = CoinPaprikaCoin( + id: TestConstants.bitcoinCoinId, + name: TestConstants.bitcoinName, + symbol: TestConstants.bitcoinSymbol, + rank: 1, + isNew: false, + isActive: true, + type: 'coin', + ); + + /// Standard Ethereum coin data + static const CoinPaprikaCoin ethereumCoin = CoinPaprikaCoin( + id: TestConstants.ethereumCoinId, + name: TestConstants.ethereumName, + symbol: TestConstants.ethereumSymbol, + rank: 2, + isNew: false, + isActive: true, + type: 'coin', + ); + + /// Inactive coin data for testing filtering + static const CoinPaprikaCoin inactiveCoin = CoinPaprikaCoin( + id: TestConstants.inactiveCoinId, + name: TestConstants.inactiveName, + symbol: TestConstants.inactiveSymbol, + rank: 999, + isNew: false, + isActive: false, + type: 'coin', + ); + + /// Standard active coins list (excluding inactive coins) + static const List activeCoins = [ + bitcoinCoin, + ethereumCoin, + ]; + + /// Full coins list including inactive coins + static const List allCoins = [ + bitcoinCoin, + ethereumCoin, + inactiveCoin, + ]; + + /// Standard AssetId for Bitcoin with coinPaprikaId + static final AssetId bitcoinAsset = AssetId( + id: TestConstants.bitcoinSymbol, + name: TestConstants.bitcoinName, + symbol: AssetSymbol( + assetConfigId: TestConstants.bitcoinSymbol, + coinPaprikaId: TestConstants.bitcoinCoinId, + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + /// Standard AssetId for Ethereum with coinPaprikaId + static final AssetId ethereumAsset = AssetId( + id: TestConstants.ethereumSymbol, + name: TestConstants.ethereumName, + symbol: AssetSymbol( + assetConfigId: TestConstants.ethereumSymbol, + coinPaprikaId: TestConstants.ethereumCoinId, + ), + chainId: AssetChainId(chainId: 1), + derivationPath: null, + subClass: CoinSubClass.erc20, + ); + + /// AssetId without coinPaprikaId for testing unsupported assets + static final AssetId unsupportedAsset = AssetId( + id: TestConstants.bitcoinSymbol, + name: TestConstants.bitcoinName, + symbol: AssetSymbol( + assetConfigId: TestConstants.bitcoinSymbol, + // No coinPaprikaId + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + /// Common test dates + static final DateTime testDate = DateTime.parse(TestConstants.currentTimestamp); + static final DateTime pastDate = DateTime.parse(TestConstants.pastTimestamp); + static final DateTime futureDate = DateTime.parse(TestConstants.futureTimestamp); + + /// UTC test dates + static final DateTime testDateUtc = DateTime.parse(TestConstants.currentTimestamp).toUtc(); + static final DateTime pastDateUtc = DateTime.parse(TestConstants.pastTimestamp).toUtc(); + + /// Common Decimal values + static final Decimal bitcoinPriceDecimal = Decimal.fromInt(TestConstants.bitcoinPrice.toInt()); + static final Decimal ethereumPriceDecimal = Decimal.fromInt(TestConstants.ethereumPrice.toInt()); + static final Decimal altcoinPriceDecimal = Decimal.parse(TestConstants.altcoinPrice.toString()); + + /// Common volume Decimal values + static final Decimal highVolumeDecimal = Decimal.fromInt(TestConstants.highVolume.toInt()); + static final Decimal mediumVolumeDecimal = Decimal.fromInt(TestConstants.mediumVolume.toInt()); + + /// Common market cap Decimal values + static final Decimal bitcoinMarketCapDecimal = Decimal.fromInt(TestConstants.bitcoinMarketCap.toInt()); + static final Decimal ethereumMarketCapDecimal = Decimal.fromInt(TestConstants.ethereumMarketCap.toInt()); + + /// Standard percentage change Decimal values + static final Decimal positiveChangeDecimal = Decimal.parse(TestConstants.positiveChange.toString()); + static final Decimal negativeChangeDecimal = Decimal.parse(TestConstants.negativeChange.toString()); +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika/fixtures/test_fixtures.dart b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/test_fixtures.dart new file mode 100644 index 00000000..fbe0e43c --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/test_fixtures.dart @@ -0,0 +1,367 @@ +/// Test fixtures for creating mock data used across CoinPaprika tests +library; + +import 'dart:convert'; + +import 'package:decimal/decimal.dart'; +import 'package:http/http.dart' as http; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' + show CoinPaprikaQuote; +import 'package:komodo_cex_market_data/src/_core_index.dart' + show CoinPaprikaMarket; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_ticker.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_ticker_quote.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; + +import 'test_constants.dart'; + +/// Factory for creating test fixtures +class TestFixtures { + TestFixtures._(); + + /// Creates a mock HTTP response for coin list endpoint + static http.Response createCoinListResponse({ + int statusCode = 200, + List>? coins, + }) { + final coinsData = + coins ?? + [ + { + 'id': TestConstants.bitcoinCoinId, + 'name': TestConstants.bitcoinName, + 'symbol': TestConstants.bitcoinSymbol, + 'rank': 1, + 'is_new': false, + 'is_active': true, + 'type': 'coin', + }, + { + 'id': TestConstants.ethereumCoinId, + 'name': TestConstants.ethereumName, + 'symbol': TestConstants.ethereumSymbol, + 'rank': 2, + 'is_new': false, + 'is_active': true, + 'type': 'coin', + }, + ]; + + return http.Response(jsonEncode(coinsData), statusCode); + } + + /// Creates a mock HTTP response for historical OHLC endpoint + static http.Response createHistoricalOhlcResponse({ + int statusCode = 200, + List>? ticks, + String? timestamp, + double? price, + double? volume24h, + double? marketCap, + }) { + final ticksData = + ticks ?? + [ + { + 'timestamp': timestamp ?? TestConstants.currentTimestamp, + 'price': price ?? TestConstants.bitcoinPrice, + 'volume_24h': volume24h ?? TestConstants.highVolume, + 'market_cap': marketCap ?? TestConstants.bitcoinMarketCap, + }, + ]; + + return http.Response(jsonEncode(ticksData), statusCode); + } + + /// Creates a mock HTTP response for ticker endpoint + static http.Response createTickerResponse({ + int statusCode = 200, + String? coinId, + String? name, + String? symbol, + Map>? quotes, + }) { + final tickerData = { + 'id': coinId ?? TestConstants.bitcoinCoinId, + 'name': name ?? TestConstants.bitcoinName, + 'symbol': symbol ?? TestConstants.bitcoinSymbol, + 'rank': 1, + 'circulating_supply': TestConstants.bitcoinCirculatingSupply, + 'total_supply': TestConstants.bitcoinTotalSupply, + 'max_supply': TestConstants.bitcoinMaxSupply, + 'beta_value': 0.0, + 'first_data_at': TestConstants.pastTimestamp, + 'last_updated': TestConstants.currentTimestamp, + 'quotes': + quotes ?? + { + TestConstants.usdtQuote: { + 'price': TestConstants.bitcoinPrice, + 'volume_24h': TestConstants.highVolume, + 'volume_24h_change_24h': 0.0, + 'market_cap': TestConstants.bitcoinMarketCap, + 'market_cap_change_24h': 0.0, + 'percent_change_15m': 0.0, + 'percent_change_30m': 0.0, + 'percent_change_1h': 0.0, + 'percent_change_6h': 0.0, + 'percent_change_12h': 0.0, + 'percent_change_24h': TestConstants.positiveChange, + 'percent_change_7d': 0.0, + 'percent_change_30d': 0.0, + 'percent_change_1y': 0.0, + }, + }, + }; + + return http.Response(jsonEncode(tickerData), statusCode); + } + + /// Creates a mock HTTP response for markets endpoint + static http.Response createMarketsResponse({ + int statusCode = 200, + List>? markets, + }) { + final marketsData = + markets ?? + [ + { + 'exchange_id': 'binance', + 'exchange_name': 'Binance', + 'pair': 'BTC/USDT', + 'base_currency_id': TestConstants.bitcoinCoinId, + 'base_currency_name': TestConstants.bitcoinName, + 'quote_currency_id': 'usdt-tether', + 'quote_currency_name': 'Tether', + 'market_url': 'https://binance.com/trade/BTC_USDT', + 'category': 'Spot', + 'fee_type': 'Percentage', + 'outlier': false, + 'adjusted_volume24h_share': 12.5, + 'last_updated': TestConstants.currentTimestamp, + 'quotes': { + TestConstants.usdQuote: { + 'price': TestConstants.bitcoinPrice.toString(), + 'volume_24h': TestConstants.highVolume.toString(), + }, + }, + }, + ]; + + return http.Response(jsonEncode(marketsData), statusCode); + } + + /// Creates a mock error HTTP response + static http.Response createErrorResponse({ + int statusCode = 500, + String? errorMessage, + }) { + return http.Response(errorMessage ?? 'Server Error', statusCode); + } + + /// Creates a mock CoinPaprikaTicker with customizable parameters + static CoinPaprikaTicker createMockTicker({ + String? id, + String? name, + String? symbol, + int? rank, + String quoteCurrency = TestConstants.usdtQuote, + double price = TestConstants.bitcoinPrice, + double percentChange24h = TestConstants.positiveChange, + double volume24h = TestConstants.highVolume, + double marketCap = TestConstants.bitcoinMarketCap, + }) { + return CoinPaprikaTicker( + id: id ?? TestConstants.bitcoinCoinId, + name: name ?? TestConstants.bitcoinName, + symbol: symbol ?? TestConstants.bitcoinSymbol, + rank: rank ?? 1, + circulatingSupply: TestConstants.bitcoinCirculatingSupply, + totalSupply: TestConstants.bitcoinTotalSupply, + maxSupply: TestConstants.bitcoinMaxSupply, + firstDataAt: TestData.pastDate, + lastUpdated: TestData.testDate, + quotes: { + quoteCurrency: CoinPaprikaTickerQuote( + price: price, + volume24h: volume24h, + marketCap: marketCap, + percentChange24h: percentChange24h, + ), + }, + ); + } + + /// Creates a mock OHLC data point + static Ohlc createMockOhlc({ + DateTime? timeOpen, + DateTime? timeClose, + Decimal? open, + Decimal? high, + Decimal? low, + Decimal? close, + Decimal? volume, + Decimal? marketCap, + }) { + final now = DateTime.now(); + return Ohlc.coinpaprika( + timeOpen: + timeOpen?.millisecondsSinceEpoch ?? + now.subtract(const Duration(hours: 12)).millisecondsSinceEpoch, + timeClose: + timeClose?.millisecondsSinceEpoch ?? + now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch, + open: open ?? TestData.bitcoinPriceDecimal, + high: high ?? Decimal.fromInt(52000), + low: low ?? Decimal.fromInt(44000), + close: close ?? TestData.bitcoinPriceDecimal, + volume: volume ?? TestData.highVolumeDecimal, + marketCap: marketCap ?? TestData.bitcoinMarketCapDecimal, + ); + } + + /// Creates a list of mock OHLC data points + static List createMockOhlcList({ + int count = 1, + DateTime? baseTime, + Duration? interval, + }) { + final base = baseTime ?? DateTime.now().subtract(const Duration(days: 1)); + final step = interval ?? const Duration(hours: 1); + + return List.generate(count, (index) { + final timeOpen = base.add(step * index); + final timeClose = timeOpen.add(step); + + return createMockOhlc( + timeOpen: timeOpen, + timeClose: timeClose, + open: Decimal.fromInt(50000 + index * 100), + high: Decimal.fromInt(52000 + index * 100), + low: Decimal.fromInt(48000 + index * 100), + close: Decimal.fromInt(51000 + index * 100), + ); + }); + } + + /// Creates a mock CoinPaprikaMarket + static CoinPaprikaMarket createMockMarket({ + String? exchangeId, + String? exchangeName, + String? pair, + String? baseId, + String? baseName, + String? quoteId, + String? quoteName, + Map? quotes, + }) { + return CoinPaprikaMarket( + exchangeId: exchangeId ?? 'binance', + exchangeName: exchangeName ?? 'Binance', + pair: pair ?? 'BTC/USDT', + baseCurrencyId: baseId ?? TestConstants.bitcoinCoinId, + baseCurrencyName: baseName ?? TestConstants.bitcoinName, + quoteCurrencyId: quoteId ?? 'usdt-tether', + quoteCurrencyName: quoteName ?? 'Tether', + marketUrl: 'https://binance.com/trade/BTC_USDT', + category: 'Spot', + feeType: 'Percentage', + outlier: false, + adjustedVolume24hShare: 12.5, + lastUpdated: TestData.testDate.toIso8601String(), + quotes: quotes ?? {}, + ); + } + + /// Creates a ticker with empty quotes for testing error scenarios + static CoinPaprikaTicker createEmptyQuotesTicker({ + String? id, + String? name, + String? symbol, + }) { + return CoinPaprikaTicker( + id: id ?? TestConstants.bitcoinCoinId, + name: name ?? TestConstants.bitcoinName, + symbol: symbol ?? TestConstants.bitcoinSymbol, + rank: 1, + circulatingSupply: TestConstants.bitcoinCirculatingSupply, + totalSupply: TestConstants.bitcoinTotalSupply, + maxSupply: TestConstants.bitcoinMaxSupply, + firstDataAt: TestData.pastDate, + lastUpdated: TestData.testDate, + quotes: {}, // Empty quotes to trigger exception + ); + } + + /// Creates multiple quote currencies data for testing + static Map> createMultipleQuotes({ + List? currencies, + List? prices, + }) { + final defaultCurrencies = + currencies ?? + [ + TestConstants.usdQuote, + TestConstants.usdtQuote, + TestConstants.eurQuote, + ]; + final defaultPrices = + prices ?? + [TestConstants.bitcoinPrice, TestConstants.bitcoinPrice + 10, 42000.0]; + + final quotes = >{}; + + for (int i = 0; i < defaultCurrencies.length; i++) { + quotes[defaultCurrencies[i]] = { + 'price': defaultPrices[i], + 'volume_24h': TestConstants.highVolume, + 'volume_24h_change_24h': 0.0, + 'market_cap': TestConstants.bitcoinMarketCap, + 'market_cap_change_24h': 0.0, + 'percent_change_15m': 0.0, + 'percent_change_30m': 0.0, + 'percent_change_1h': 0.0, + 'percent_change_6h': 0.0, + 'percent_change_12h': 0.0, + 'percent_change_24h': TestConstants.positiveChange, + 'percent_change_7d': 0.0, + 'percent_change_30d': 0.0, + 'percent_change_1y': 0.0, + }; + } + + return quotes; + } + + /// Creates batch OHLC data for testing pagination/batching scenarios + static List createBatchOhlcData({ + int batchCount = 2, + int itemsPerBatch = 10, + DateTime? startDate, + }) { + final start = + startDate ?? DateTime.now().subtract(const Duration(days: 30)); + final allData = []; + + for (int batch = 0; batch < batchCount; batch++) { + for (int item = 0; item < itemsPerBatch; item++) { + final index = batch * itemsPerBatch + item; + final timeOpen = start.add(Duration(hours: index)); + final timeClose = timeOpen.add(const Duration(hours: 1)); + + allData.add( + createMockOhlc( + timeOpen: timeOpen, + timeClose: timeClose, + open: Decimal.fromInt(45000 + index * 10), + high: Decimal.fromInt(52000 + index * 10), + low: Decimal.fromInt(44000 + index * 10), + close: Decimal.fromInt(50000 + index * 10), + ), + ); + } + } + + return allData; + } +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika/fixtures/verification_helpers.dart b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/verification_helpers.dart new file mode 100644 index 00000000..591df409 --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika/fixtures/verification_helpers.dart @@ -0,0 +1,509 @@ +/// Verification helpers for common test assertions and patterns +library; + +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import 'mock_helpers.dart'; +import 'test_constants.dart'; + +/// Helper class for common verification patterns in tests +class VerificationHelpers { + VerificationHelpers._(); + + /// Verifies that an HTTP GET request was made to the expected URI + static void verifyHttpGetCall( + MockHttpClient mockHttpClient, + String expectedUrl, + ) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + expect(capturedUri.toString(), equals(expectedUrl)); + } + + /// Verifies that an HTTP GET request was made with specific path and query parameters + static void verifyHttpGetCallWithParams( + MockHttpClient mockHttpClient, { + String? expectedHost, + String? expectedPath, + Map? expectedQueryParams, + }) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + if (expectedHost != null) { + expect(capturedUri.host, equals(expectedHost)); + } + + if (expectedPath != null) { + expect(capturedUri.path, equals(expectedPath)); + } + + if (expectedQueryParams != null) { + expect(capturedUri.queryParameters, equals(expectedQueryParams)); + } + } + + /// Verifies that HTTP GET was called with URI containing specific elements + static void verifyHttpGetCallContains( + MockHttpClient mockHttpClient, { + String? hostContains, + String? pathContains, + String? queryContains, + }) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + if (hostContains != null) { + expect(capturedUri.host, contains(hostContains)); + } + + if (pathContains != null) { + expect(capturedUri.path, contains(pathContains)); + } + + if (queryContains != null) { + expect(capturedUri.query, contains(queryContains)); + } + } + + /// Performs multiple verifications on the same captured URI to avoid double-verification issues + static void verifyHttpGetCallMultiple( + MockHttpClient mockHttpClient, { + String? expectedUrl, + String? expectedHost, + String? expectedPath, + Map? expectedQueryParams, + List? expectedQueryParamKeys, + List? excludedParams, + String? hostContains, + String? pathContains, + String? queryContains, + }) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + if (expectedUrl != null) { + expect(capturedUri.toString(), equals(expectedUrl)); + } + + if (expectedHost != null) { + expect(capturedUri.host, equals(expectedHost)); + } + + if (expectedPath != null) { + expect(capturedUri.path, equals(expectedPath)); + } + + if (expectedQueryParams != null) { + expect(capturedUri.queryParameters, equals(expectedQueryParams)); + } + + if (expectedQueryParamKeys != null) { + expect( + capturedUri.queryParameters.keys.toSet(), + equals(expectedQueryParamKeys.toSet()), + reason: 'Only expected query parameters should be present', + ); + } + + if (excludedParams != null) { + for (final param in excludedParams) { + expect( + capturedUri.queryParameters.containsKey(param), + isFalse, + reason: '$param parameter should not be included', + ); + } + } + + if (hostContains != null) { + expect(capturedUri.host, contains(hostContains)); + } + + if (pathContains != null) { + expect(capturedUri.path, contains(pathContains)); + } + + if (queryContains != null) { + expect(capturedUri.query, contains(queryContains)); + } + } + + /// Verifies that fetchHistoricalOhlc was called with expected parameters + static void verifyFetchHistoricalOhlc( + MockCoinPaprikaProvider mockProvider, { + String? expectedCoinId, + DateTime? expectedStartDate, + DateTime? expectedEndDate, + QuoteCurrency? expectedQuote, + String? expectedInterval, + int? expectedCallCount, + }) { + final verification = verify( + () => mockProvider.fetchHistoricalOhlc( + coinId: expectedCoinId ?? any(named: 'coinId'), + startDate: expectedStartDate ?? any(named: 'startDate'), + endDate: expectedEndDate ?? any(named: 'endDate'), + quote: expectedQuote ?? any(named: 'quote'), + interval: expectedInterval ?? any(named: 'interval'), + ), + ); + + if (expectedCallCount != null) { + verification.called(expectedCallCount); + } else { + verification.called(1); + } + } + + /// Verifies that fetchCoinTicker was called with expected parameters + static void verifyFetchCoinTicker( + MockCoinPaprikaProvider mockProvider, { + String? expectedCoinId, + List? expectedQuotes, + int? expectedCallCount, + }) { + final verification = verify( + () => mockProvider.fetchCoinTicker( + coinId: expectedCoinId ?? any(named: 'coinId'), + quotes: expectedQuotes ?? any(named: 'quotes'), + ), + ); + + if (expectedCallCount != null) { + verification.called(expectedCallCount); + } else { + verification.called(1); + } + } + + /// Verifies that fetchCoinMarkets was called with expected parameters + static void verifyFetchCoinMarkets( + MockCoinPaprikaProvider mockProvider, { + String? expectedCoinId, + List? expectedQuotes, + int? expectedCallCount, + }) { + final verification = verify( + () => mockProvider.fetchCoinMarkets( + coinId: expectedCoinId ?? any(named: 'coinId'), + quotes: expectedQuotes ?? any(named: 'quotes'), + ), + ); + + if (expectedCallCount != null) { + verification.called(expectedCallCount); + } else { + verification.called(1); + } + } + + /// Verifies that fetchCoinList was called the expected number of times + static void verifyFetchCoinList( + MockCoinPaprikaProvider mockProvider, { + int? expectedCallCount, + }) { + final verification = verify(() => mockProvider.fetchCoinList()); + + if (expectedCallCount != null) { + verification.called(expectedCallCount); + } else { + verification.called(1); + } + } + + /// Verifies that no calls were made to fetchHistoricalOhlc + static void verifyNoFetchHistoricalOhlcCalls( + MockCoinPaprikaProvider mockProvider, + ) { + verifyNever( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ); + } + + /// Verifies URL format for historical OHLC endpoint + static void verifyHistoricalOhlcUrl( + MockHttpClient mockHttpClient, + String expectedCoinId, { + String? expectedStartDate, + String? expectedInterval, + List? excludedParams, + }) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + // Verify URL structure + expect(capturedUri.host, equals(TestConstants.baseUrl)); + expect( + capturedUri.path, + equals('${TestConstants.apiVersion}/tickers/$expectedCoinId/historical'), + ); + + // Verify required query parameters + if (expectedStartDate != null) { + expect(capturedUri.queryParameters['start'], equals(expectedStartDate)); + } + + if (expectedInterval != null) { + expect(capturedUri.queryParameters['interval'], equals(expectedInterval)); + } + + // Verify excluded parameters are not present + if (excludedParams != null) { + for (final param in excludedParams) { + expect( + capturedUri.queryParameters.containsKey(param), + isFalse, + reason: '$param parameter should not be included', + ); + } + } + } + + /// Verifies URL format for ticker endpoint + static void verifyTickerUrl( + MockHttpClient mockHttpClient, + String expectedCoinId, { + String? expectedQuotes, + }) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + // Verify URL structure + expect(capturedUri.host, equals(TestConstants.baseUrl)); + expect( + capturedUri.path, + equals('${TestConstants.apiVersion}/tickers/$expectedCoinId'), + ); + + // Verify quotes parameter + if (expectedQuotes != null) { + expect(capturedUri.queryParameters['quotes'], equals(expectedQuotes)); + } + } + + /// Verifies URL format for markets endpoint + static void verifyMarketsUrl( + MockHttpClient mockHttpClient, + String expectedCoinId, { + String? expectedQuotes, + }) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + // Verify URL structure + expect(capturedUri.host, equals(TestConstants.baseUrl)); + expect( + capturedUri.path, + equals('${TestConstants.apiVersion}/coins/$expectedCoinId/markets'), + ); + + // Verify quotes parameter + if (expectedQuotes != null) { + expect(capturedUri.queryParameters['quotes'], equals(expectedQuotes)); + } + } + + /// Verifies that a date range is within expected bounds + static void verifyDateRange( + DateTime actualStart, + DateTime actualEnd, { + DateTime? expectedStart, + DateTime? expectedEnd, + Duration? maxDuration, + Duration? tolerance, + }) { + final defaultTolerance = tolerance ?? const Duration(minutes: 5); + + if (expectedStart != null) { + final startDiff = actualStart.difference(expectedStart).abs(); + expect( + startDiff, + lessThan(defaultTolerance), + reason: 'Start date should be close to expected date', + ); + } + + if (expectedEnd != null) { + final endDiff = actualEnd.difference(expectedEnd).abs(); + expect( + endDiff, + lessThan(defaultTolerance), + reason: 'End date should be close to expected date', + ); + } + + if (maxDuration != null) { + final actualDuration = actualEnd.difference(actualStart); + expect( + actualDuration, + lessThanOrEqualTo(maxDuration), + reason: 'Duration should not exceed maximum allowed', + ); + } + } + + /// Verifies that batch requests don't exceed safe duration limits + static void verifyBatchDurations( + MockCoinPaprikaProvider mockProvider, { + Duration? maxBatchDuration, + int? minBatchCount, + }) { + final capturedCalls = verify( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: captureAny(named: 'startDate'), + endDate: captureAny(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).captured; + + // Extract start and end dates from captured calls + final batches = []; + for (int i = 0; i < capturedCalls.length; i += 2) { + final startDate = capturedCalls[i] as DateTime; + final endDate = capturedCalls[i + 1] as DateTime; + batches.add(endDate.difference(startDate)); + } + + if (minBatchCount != null) { + expect( + batches.length, + greaterThanOrEqualTo(minBatchCount), + reason: 'Should have at least $minBatchCount batches', + ); + } + + if (maxBatchDuration != null) { + for (final duration in batches) { + expect( + duration, + lessThanOrEqualTo(maxBatchDuration), + reason: 'Batch duration should not exceed safe limit', + ); + } + } + } + + /// Verifies that interval conversion was applied correctly + static void verifyIntervalConversion( + MockHttpClient mockHttpClient, + String inputInterval, + String expectedApiInterval, + ) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + expect( + capturedUri.queryParameters['interval'], + equals(expectedApiInterval), + reason: + 'Interval $inputInterval should be converted to $expectedApiInterval', + ); + } + + /// Verifies that quote currency mapping was applied correctly + static void verifyQuoteCurrencyMapping( + MockCoinPaprikaProvider mockProvider, + List inputQuotes, + List expectedQuotes, + ) { + verify( + () => mockProvider.fetchCoinTicker( + coinId: any(named: 'coinId'), + quotes: expectedQuotes, + ), + ).called(1); + } + + /// Verifies that date formatting follows the expected pattern + static void verifyDateFormatting( + MockHttpClient mockHttpClient, + DateTime inputDate, + String expectedFormattedDate, + ) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + expect( + capturedUri.queryParameters['start'], + equals(expectedFormattedDate), + reason: 'Date should be formatted as YYYY-MM-DD', + ); + } + + /// Verifies that no quote parameter is included in URL (for historical OHLC) + static void verifyNoQuoteParameter(MockHttpClient mockHttpClient) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + expect( + capturedUri.queryParameters.containsKey('quote'), + isFalse, + reason: + 'Quote parameter should not be included in historical OHLC requests', + ); + } + + /// Verifies that only expected query parameters are present + static void verifyOnlyExpectedQueryParams( + MockHttpClient mockHttpClient, + List expectedParams, + ) { + final capturedUri = + verify(() => mockHttpClient.get(captureAny())).captured.single as Uri; + + expect( + capturedUri.queryParameters.keys.toSet(), + equals(expectedParams.toSet()), + reason: 'Only expected query parameters should be present', + ); + } + + /// Verifies that multiple provider calls were made for batching + static void verifyMultipleProviderCalls( + MockCoinPaprikaProvider mockProvider, + int expectedMinCalls, + ) { + verify( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).called(greaterThan(expectedMinCalls)); + } + + /// Verifies that UTC time was used in date calculations + static void verifyUtcTimeUsage(MockCoinPaprikaProvider mockProvider) { + final capturedCalls = verify( + () => mockProvider.fetchHistoricalOhlc( + coinId: any(named: 'coinId'), + startDate: captureAny(named: 'startDate'), + endDate: captureAny(named: 'endDate'), + quote: any(named: 'quote'), + interval: any(named: 'interval'), + ), + ).captured; + + // Verify that captured dates are in UTC + for (int i = 0; i < capturedCalls.length; i += 2) { + final startDate = capturedCalls[i] as DateTime; + final endDate = capturedCalls[i + 1] as DateTime; + + expect(startDate.isUtc, isTrue, reason: 'Start date should be in UTC'); + expect(endDate.isUtc, isTrue, reason: 'End date should be in UTC'); + } + } +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika_provider_validation_test.dart b/packages/komodo_cex_market_data/test/coinpaprika_provider_validation_test.dart new file mode 100644 index 00000000..c49cd3da --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika_provider_validation_test.dart @@ -0,0 +1,669 @@ +import 'package:http/http.dart' as http; +import 'package:komodo_cex_market_data/src/coinpaprika/data/coinpaprika_cex_provider.dart'; +import 'package:komodo_cex_market_data/src/coinpaprika/models/coinpaprika_api_plan.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +/// Mock HTTP client for testing +class MockHttpClient extends Mock implements http.Client {} + +/// Test class that extends the provider to test validation logic +class TestCoinPaprikaProvider extends CoinPaprikaProvider { + TestCoinPaprikaProvider({ + super.apiKey, + super.apiPlan = const CoinPaprikaApiPlan.free(), + super.httpClient, + }); + + /// Expose the private date formatting method for testing + String formatDateForApi(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + } + + /// Test validation by simulating the internal validation logic + void testValidation({ + DateTime? startDate, + DateTime? endDate, + String interval = '24h', + }) { + // Validate interval support + if (!apiPlan.isIntervalSupported(interval)) { + throw ArgumentError( + 'Interval "$interval" is not supported in the ${apiPlan.planName} plan. ' + 'Supported intervals: ${apiPlan.availableIntervals.join(", ")}', + ); + } + + // If the plan has unlimited OHLC history, no date validation needed + if (apiPlan.hasUnlimitedOhlcHistory) return; + + // If no dates provided, assume recent data request (valid) + if (startDate == null && endDate == null) return; + + final cutoffDate = apiPlan.getHistoricalDataCutoff(); + if (cutoffDate == null) return; // No limitations + + // Check if any requested date is before the cutoff + if (startDate != null && startDate.isBefore(cutoffDate)) { + throw ArgumentError( + 'Historical data before ${formatDateForApi(cutoffDate)} is not available in the ${apiPlan.planName} plan. ' + 'Requested start date: ${formatDateForApi(startDate)}. ' + '${apiPlan.ohlcLimitDescription}. Please request more recent data or upgrade your plan.', + ); + } + + if (endDate != null && endDate.isBefore(cutoffDate)) { + throw ArgumentError( + 'Historical data before ${formatDateForApi(cutoffDate)} is not available in the ${apiPlan.planName} plan. ' + 'Requested end date: ${formatDateForApi(endDate)}. ' + '${apiPlan.ohlcLimitDescription}. Please request more recent data or upgrade your plan.', + ); + } + } +} + +void main() { + group('CoinPaprika Provider API Key Tests', () { + late MockHttpClient mockHttpClient; + + setUp(() { + mockHttpClient = MockHttpClient(); + registerFallbackValue(Uri()); + }); + + test( + 'should not include Authorization header when no API key provided', + () async { + // Arrange + final provider = CoinPaprikaProvider(httpClient: mockHttpClient); + + when( + () => mockHttpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('[]', 200)); + + // Act + await provider.fetchCoinList(); + + // Assert - verify Authorization header is not present + final capturedHeaders = + verify( + () => mockHttpClient.get( + any(), + headers: captureAny(named: 'headers'), + ), + ).captured.single + as Map?; + + expect(capturedHeaders, isNot(contains('Authorization'))); + }, + ); + + test( + 'should not include Authorization header when API key is empty', + () async { + // Arrange + final provider = CoinPaprikaProvider( + apiKey: '', + httpClient: mockHttpClient, + ); + + when( + () => mockHttpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('[]', 200)); + + // Act + await provider.fetchCoinList(); + + // Assert - verify Authorization header is not present + final capturedHeaders = + verify( + () => mockHttpClient.get( + any(), + headers: captureAny(named: 'headers'), + ), + ).captured.single + as Map?; + + expect(capturedHeaders, isNot(contains('Authorization'))); + }, + ); + + test('should include Bearer token when API key is provided', () async { + // Arrange + const testApiKey = 'test-api-key-123'; + final provider = CoinPaprikaProvider( + apiKey: testApiKey, + httpClient: mockHttpClient, + ); + + when( + () => mockHttpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('[]', 200)); + + // Act + await provider.fetchCoinList(); + + // Assert - verify Authorization header contains Bearer token + final capturedHeaders = + verify( + () => mockHttpClient.get( + any(), + headers: captureAny(named: 'headers'), + ), + ).captured.single + as Map?; + + expect(capturedHeaders!['Authorization'], equals('Bearer $testApiKey')); + }); + + test('should include Bearer token in all API methods', () async { + // Arrange + const testApiKey = 'test-api-key-456'; + final provider = CoinPaprikaProvider( + apiKey: testApiKey, + httpClient: mockHttpClient, + ); + + when( + () => mockHttpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('[]', 200)); + + // Act - test multiple API methods + await provider.fetchCoinList(); + + when( + () => mockHttpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('[]', 200)); + await provider.fetchCoinMarkets(coinId: 'btc-bitcoin'); + + when( + () => mockHttpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('{"quotes":{}}', 200)); + await provider.fetchCoinTicker(coinId: 'btc-bitcoin'); + + // Assert - verify all requests include Bearer token + final capturedHeaders = verify( + () => mockHttpClient.get(any(), headers: captureAny(named: 'headers')), + ).captured; + + // Check that all 3 requests had the correct Authorization header + for (final headers in capturedHeaders) { + final headerMap = headers as Map; + expect(headerMap['Authorization'], equals('Bearer $testApiKey')); + } + }); + + test('should include Bearer token in OHLC requests', () async { + // Arrange + const testApiKey = 'ohlc-test-key'; + final provider = CoinPaprikaProvider( + apiKey: testApiKey, + httpClient: mockHttpClient, + // Use unlimited plan to avoid date validation + apiPlan: const CoinPaprikaApiPlan.ultimate(), + ); + + when( + () => mockHttpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer((_) async => http.Response('[]', 200)); + + // Act + await provider.fetchHistoricalOhlc( + coinId: 'btc-bitcoin', + startDate: DateTime.now().subtract(const Duration(days: 1)), + ); + + // Assert - verify OHLC request includes Bearer token + final capturedHeaders = + verify( + () => mockHttpClient.get( + any(), + headers: captureAny(named: 'headers'), + ), + ).captured.single + as Map; + + expect(capturedHeaders['Authorization'], equals('Bearer $testApiKey')); + }); + }); + + group('CoinPaprikaProvider Validation Tests', () { + group('Free Plan Validation', () { + late TestCoinPaprikaProvider testProvider; + + setUp(() { + testProvider = TestCoinPaprikaProvider(); + }); + + test('should allow recent dates within cutoff', () { + final now = DateTime.now(); + final recentDate = now.subtract( + const Duration(days: 200), + ); // Within 365 days limit for free plan + + expect( + () => testProvider.testValidation(startDate: recentDate), + returnsNormally, + ); + }); + + test('should reject dates before the cutoff period', () { + final now = DateTime.now(); + final oldDate = now.subtract( + const Duration(days: 400), + ); // Beyond 365 days limit for free plan + + expect( + () => testProvider.testValidation(startDate: oldDate), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf([ + contains('Historical data before'), + contains('Free plan'), + contains('1 year of OHLC historical data'), + ]), + ), + ), + ); + }); + + test('should allow null dates (current data request)', () { + expect(() => testProvider.testValidation(), returnsNormally); + }); + + test('should include helpful error message with plan information', () { + const freePlan = CoinPaprikaApiPlan.free(); + final cutoffDate = freePlan.getHistoricalDataCutoff()!; + final oldDate = cutoffDate.subtract(const Duration(hours: 1)); + + try { + testProvider.testValidation(startDate: oldDate); + fail('Should have thrown ArgumentError'); + } catch (e) { + expect(e, isA()); + final error = e as ArgumentError; + + // Check that the error message contains the key information + expect(error.message, contains('Historical data before')); + expect(error.message, contains('Free plan')); + expect(error.message, contains('1 year of OHLC historical data')); + expect(error.message, contains('upgrade your plan')); + } + }); + }); + + group('Starter Plan Validation', () { + late TestCoinPaprikaProvider testProvider; + + setUp(() { + testProvider = TestCoinPaprikaProvider( + apiPlan: const CoinPaprikaApiPlan.starter(), + ); + }); + + test('should allow dates within starter plan limit', () { + final now = DateTime.now(); + final recentDate = now.subtract( + const Duration(days: 15), + ); // Within 30 day limit for starter plan + + expect( + () => testProvider.testValidation(startDate: recentDate), + returnsNormally, + ); + }); + + test('should reject dates before starter plan cutoff', () { + final now = DateTime.now(); + final oldDate = now.subtract( + const Duration(days: 2000), + ); // Beyond 5 year limit for starter plan + + expect( + () => testProvider.testValidation(startDate: oldDate), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf([ + contains('Historical data before'), + contains('Starter plan'), + contains('5 years of OHLC historical data'), + ]), + ), + ), + ); + }); + }); + + group('Ultimate Plan Validation', () { + late TestCoinPaprikaProvider testProvider; + + setUp(() { + testProvider = TestCoinPaprikaProvider( + apiPlan: const CoinPaprikaApiPlan.ultimate(), + ); + }); + + test('should allow any dates for unlimited plans', () { + final now = DateTime.now(); + final veryOldDate = now.subtract( + const Duration(days: 365 * 5), + ); // 5 years ago + + expect( + () => testProvider.testValidation(startDate: veryOldDate), + returnsNormally, + ); + }); + }); + + group('Interval Validation', () { + test('should reject unsupported intervals for free plan', () { + final freePlanProvider = TestCoinPaprikaProvider(); + + expect( + () => freePlanProvider.testValidation(interval: '1h'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Interval "1h" is not supported in the Free plan'), + ), + ), + ); + }); + + test('should allow supported daily intervals for free plan', () { + final freePlanProvider = TestCoinPaprikaProvider(); + + expect(freePlanProvider.testValidation, returnsNormally); + expect( + () => freePlanProvider.testValidation(interval: '1d'), + returnsNormally, + ); + expect( + () => freePlanProvider.testValidation(interval: '7d'), + returnsNormally, + ); + expect( + () => freePlanProvider.testValidation(interval: '30d'), + returnsNormally, + ); + expect( + () => freePlanProvider.testValidation(interval: '90d'), + returnsNormally, + ); + expect( + () => freePlanProvider.testValidation(interval: '365d'), + returnsNormally, + ); + }); + + test('should reject unsupported hourly intervals for free plan', () { + final freePlanProvider = TestCoinPaprikaProvider(); + + expect( + () => freePlanProvider.testValidation(interval: '1h'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Interval "1h" is not supported in the Free plan'), + ), + ), + ); + expect( + () => freePlanProvider.testValidation(interval: '5m'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Interval "5m" is not supported in the Free plan'), + ), + ), + ); + }); + + test('should allow supported intervals for business plan', () { + final businessPlanProvider = TestCoinPaprikaProvider( + apiPlan: const CoinPaprikaApiPlan.business(), + ); + + expect( + () => businessPlanProvider.testValidation(interval: '1h'), + returnsNormally, + ); + expect( + () => businessPlanProvider.testValidation(interval: '6h'), + returnsNormally, + ); + expect(businessPlanProvider.testValidation, returnsNormally); + }); + + test('should allow all intervals for enterprise plan', () { + final enterprisePlanProvider = TestCoinPaprikaProvider( + apiPlan: const CoinPaprikaApiPlan.enterprise(), + ); + + expect( + () => enterprisePlanProvider.testValidation(interval: '5m'), + returnsNormally, + ); + expect( + () => enterprisePlanProvider.testValidation(interval: '15m'), + returnsNormally, + ); + expect( + () => enterprisePlanProvider.testValidation(interval: '1h'), + returnsNormally, + ); + }); + }); + }); + + group('API Plan Configuration', () { + test('free plan should have correct limitations', () { + const plan = CoinPaprikaApiPlan.free(); + + expect(plan.ohlcHistoricalDataLimit?.inDays, equals(365)); + expect( + plan.availableIntervals, + equals(['24h', '1d', '7d', '14d', '30d', '90d', '365d']), + ); + expect(plan.monthlyCallLimit, equals(20000)); + expect(plan.hasUnlimitedOhlcHistory, isFalse); + expect(plan.planName, equals('Free')); + }); + + test('starter plan should have correct limitations', () { + const plan = CoinPaprikaApiPlan.starter(); + + expect(plan.ohlcHistoricalDataLimit?.inDays, equals(1825)); // 5 years + expect( + plan.availableIntervals, + equals([ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ]), + ); + expect(plan.monthlyCallLimit, equals(400000)); + expect(plan.hasUnlimitedOhlcHistory, isFalse); + expect(plan.planName, equals('Starter')); + }); + + test('business plan should have correct limitations', () { + const plan = CoinPaprikaApiPlan.business(); + + expect(plan.ohlcHistoricalDataLimit, isNull); // unlimited + expect( + plan.availableIntervals, + equals([ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ]), + ); + expect(plan.monthlyCallLimit, equals(5000000)); + expect(plan.hasUnlimitedOhlcHistory, isTrue); + expect(plan.planName, equals('Business')); + }); + + test('pro plan should have correct limitations', () { + const plan = CoinPaprikaApiPlan.pro(); + + expect(plan.ohlcHistoricalDataLimit, isNull); // unlimited + expect( + plan.availableIntervals, + equals([ + '24h', + '1d', + '7d', + '14d', + '30d', + '90d', + '365d', + '1h', + '2h', + '3h', + '6h', + '12h', + '5m', + '10m', + '15m', + '30m', + '45m', + ]), + ); + expect(plan.monthlyCallLimit, equals(1000000)); + expect(plan.hasUnlimitedOhlcHistory, isTrue); + expect(plan.planName, equals('Pro')); + }); + + test('ultimate plan should have unlimited OHLC history', () { + const plan = CoinPaprikaApiPlan.ultimate(); + + expect(plan.ohlcHistoricalDataLimit, isNull); + expect(plan.hasUnlimitedOhlcHistory, isTrue); + expect(plan.planName, equals('Ultimate')); + }); + + test('enterprise plan should have unlimited features', () { + const plan = CoinPaprikaApiPlan.enterprise(); + + expect(plan.ohlcHistoricalDataLimit, isNull); + expect(plan.monthlyCallLimit, isNull); + expect(plan.hasUnlimitedOhlcHistory, isTrue); + expect(plan.hasUnlimitedCalls, isTrue); + expect(plan.planName, equals('Enterprise')); + }); + }); + + group('DateTime Utility', () { + late TestCoinPaprikaProvider testProvider; + + setUp(() { + testProvider = TestCoinPaprikaProvider(); + }); + + test('should format dates correctly for API', () { + final testDate = DateTime(2025, 8, 31, 15, 30, 45); + expect(testProvider.formatDateForApi(testDate), equals('2025-08-31')); + + final newYear = DateTime(2025); + expect(testProvider.formatDateForApi(newYear), equals('2025-01-01')); + + final endOfYear = DateTime(2025, 12, 31); + expect(testProvider.formatDateForApi(endOfYear), equals('2025-12-31')); + + final singleDigits = DateTime(2025, 5, 3); + expect(testProvider.formatDateForApi(singleDigits), equals('2025-05-03')); + }); + + test('should handle leap year dates correctly', () { + final testProvider = TestCoinPaprikaProvider(); + final leapYearDate = DateTime(2024, 2, 29, 12); + expect(testProvider.formatDateForApi(leapYearDate), equals('2024-02-29')); + }); + }); + + group('Quote Currency Support', () { + late TestCoinPaprikaProvider testProvider; + + setUp(() { + testProvider = TestCoinPaprikaProvider(); + }); + + test('should support standard quote currencies', () { + final supportedCurrencies = testProvider.supportedQuoteCurrencies; + + expect(supportedCurrencies, contains(FiatCurrency.usd)); + expect(supportedCurrencies, contains(FiatCurrency.eur)); + expect(supportedCurrencies, contains(Cryptocurrency.btc)); + expect(supportedCurrencies, contains(Cryptocurrency.eth)); + }); + + test('should have non-empty supported currencies list', () { + expect(testProvider.supportedQuoteCurrencies, isNotEmpty); + expect(testProvider.supportedQuoteCurrencies.length, greaterThan(10)); + }); + + test('should support common fiat currencies', () { + final supportedCurrencies = testProvider.supportedQuoteCurrencies; + + expect(supportedCurrencies, contains(FiatCurrency.gbp)); + expect(supportedCurrencies, contains(FiatCurrency.jpy)); + expect(supportedCurrencies, contains(FiatCurrency.cad)); + }); + }); + + group('Plan Description', () { + test('should provide human-readable descriptions', () { + const freePlan = CoinPaprikaApiPlan.free(); + expect(freePlan.ohlcLimitDescription, contains('1 year')); + + const starterPlan = CoinPaprikaApiPlan.starter(); + expect(starterPlan.ohlcLimitDescription, contains('5 years')); + + const businessPlan = CoinPaprikaApiPlan.business(); + expect(businessPlan.ohlcLimitDescription, contains('No limit')); + + const ultimatePlan = CoinPaprikaApiPlan.ultimate(); + expect(ultimatePlan.ohlcLimitDescription, contains('No limit')); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/coinpaprika_quote_currency_mapping_test.dart b/packages/komodo_cex_market_data/test/coinpaprika_quote_currency_mapping_test.dart new file mode 100644 index 00000000..521a37ab --- /dev/null +++ b/packages/komodo_cex_market_data/test/coinpaprika_quote_currency_mapping_test.dart @@ -0,0 +1,137 @@ +import 'package:komodo_cex_market_data/src/coinpaprika/data/coinpaprika_cex_provider.dart'; +import 'package:komodo_cex_market_data/src/models/_models_index.dart'; +import 'package:test/test.dart'; + +/// Simple test to validate that quote currency mapping works correctly +/// for CoinPaprika API calls. This ensures USDT maps to USD, EURS maps to EUR, etc. +void main() { + group('CoinPaprika Quote Currency Mapping Validation', () { + test('USDT should map to USD in coinPaprikaId', () { + // Verify that USDT stablecoin returns 'usdt' as coinPaprikaId + expect(Stablecoin.usdt.coinPaprikaId, equals('usdt')); + + // Verify that the underlying fiat is USD + expect(Stablecoin.usdt.underlyingFiat.coinPaprikaId, equals('usd')); + }); + + test('USDC should map to USD in coinPaprikaId', () { + expect(Stablecoin.usdc.coinPaprikaId, equals('usdc')); + expect(Stablecoin.usdc.underlyingFiat.coinPaprikaId, equals('usd')); + }); + + test('EURS should map to EUR in coinPaprikaId', () { + expect(Stablecoin.eurs.coinPaprikaId, equals('eurs')); + expect(Stablecoin.eurs.underlyingFiat.coinPaprikaId, equals('eur')); + }); + + test('GBPT should map to GBP in coinPaprikaId', () { + expect(Stablecoin.gbpt.coinPaprikaId, equals('gbpt')); + expect(Stablecoin.gbpt.underlyingFiat.coinPaprikaId, equals('gbp')); + }); + + test('fiat currencies should return themselves', () { + expect(FiatCurrency.usd.coinPaprikaId, equals('usd')); + expect(FiatCurrency.eur.coinPaprikaId, equals('eur')); + expect(FiatCurrency.gbp.coinPaprikaId, equals('gbp')); + }); + + test('cryptocurrencies should return themselves', () { + expect(Cryptocurrency.btc.coinPaprikaId, equals('btc')); + expect(Cryptocurrency.eth.coinPaprikaId, equals('eth')); + }); + + test('stablecoin mapping behavior using when method', () { + // Test that the when method correctly maps stablecoins to underlying fiat + final mappedUsdt = Stablecoin.usdt.when( + fiat: (_, __) => Stablecoin.usdt, + stablecoin: (_, __, underlyingFiat) => underlyingFiat, + crypto: (_, __) => Stablecoin.usdt, + commodity: (_, __) => Stablecoin.usdt, + ); + + expect(mappedUsdt, equals(FiatCurrency.usd)); + expect(mappedUsdt.coinPaprikaId, equals('usd')); + }); + + test('fiat currency preservation using when method', () { + // Test that fiat currencies are preserved as-is + final preservedUsd = FiatCurrency.usd.when( + fiat: (_, __) => FiatCurrency.usd, + stablecoin: (_, __, underlyingFiat) => underlyingFiat, + crypto: (_, __) => FiatCurrency.usd, + commodity: (_, __) => FiatCurrency.usd, + ); + + expect(preservedUsd, equals(FiatCurrency.usd)); + expect(preservedUsd.coinPaprikaId, equals('usd')); + }); + + test('cryptocurrency preservation using when method', () { + // Test that cryptocurrencies are preserved as-is + final preservedBtc = Cryptocurrency.btc.when( + fiat: (_, __) => Cryptocurrency.btc, + stablecoin: (_, __, underlyingFiat) => underlyingFiat, + crypto: (_, __) => Cryptocurrency.btc, + commodity: (_, __) => Cryptocurrency.btc, + ); + + expect(preservedBtc, equals(Cryptocurrency.btc)); + expect(preservedBtc.coinPaprikaId, equals('btc')); + }); + + test('multiple USD stablecoins should all map to USD', () { + final usdStablecoins = [ + Stablecoin.usdt, + Stablecoin.usdc, + Stablecoin.dai, + Stablecoin.busd, + Stablecoin.tusd, + ]; + + for (final stablecoin in usdStablecoins) { + expect( + stablecoin.underlyingFiat.coinPaprikaId, + equals('usd'), + reason: '${stablecoin.symbol} should have USD as underlying fiat', + ); + } + }); + + test('provider mapping logic simulation', () { + // Simulate what the provider's _mapQuoteCurrencyForApi method should do + QuoteCurrency mapQuoteCurrencyForApi(QuoteCurrency quote) { + return quote.when( + fiat: (_, __) => quote, + stablecoin: (_, __, underlyingFiat) => underlyingFiat, + crypto: (_, __) => quote, + commodity: (_, __) => quote, + ); + } + + // Test the mapping logic + expect( + mapQuoteCurrencyForApi(Stablecoin.usdt).coinPaprikaId, + equals('usd'), + reason: 'USDT should map to USD', + ); + + expect( + mapQuoteCurrencyForApi(Stablecoin.eurs).coinPaprikaId, + equals('eur'), + reason: 'EURS should map to EUR', + ); + + expect( + mapQuoteCurrencyForApi(FiatCurrency.usd).coinPaprikaId, + equals('usd'), + reason: 'USD should remain USD', + ); + + expect( + mapQuoteCurrencyForApi(Cryptocurrency.btc).coinPaprikaId, + equals('btc'), + reason: 'BTC should remain BTC', + ); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/common/api_error_parser_test.dart b/packages/komodo_cex_market_data/test/common/api_error_parser_test.dart new file mode 100644 index 00000000..3184bb73 --- /dev/null +++ b/packages/komodo_cex_market_data/test/common/api_error_parser_test.dart @@ -0,0 +1,356 @@ +import 'package:komodo_cex_market_data/src/common/api_error_parser.dart'; +import 'package:test/test.dart'; + +void main() { + group('ApiError', () { + test('toString includes status code and message', () { + const error = ApiError(statusCode: 429, message: 'Rate limit exceeded'); + + expect(error.toString(), 'API Error 429: Rate limit exceeded'); + }); + + test('toString includes error type when provided', () { + const error = ApiError( + statusCode: 429, + message: 'Rate limit exceeded', + errorType: 'rate_limit', + ); + + expect( + error.toString(), + 'API Error 429: Rate limit exceeded (type: rate_limit)', + ); + }); + + test('toString includes retry after when provided', () { + const error = ApiError( + statusCode: 429, + message: 'Rate limit exceeded', + errorType: 'rate_limit', + retryAfter: 60, + ); + + expect( + error.toString(), + 'API Error 429: Rate limit exceeded (type: rate_limit) (retry after: 60s)', + ); + }); + }); + + group('ApiErrorParser.parseCoinPaprikaError', () { + test('parses 429 rate limit error correctly', () { + const responseBody = '{"error": "Rate limit exceeded"}'; + final error = ApiErrorParser.parseCoinPaprikaError(429, responseBody); + + expect(error.statusCode, 429); + expect( + error.message, + 'Rate limit exceeded. Please reduce request frequency.', + ); + expect(error.errorType, 'rate_limit'); + expect(error.isRateLimitError, true); + expect(error.isPaymentRequiredError, false); + expect(error.retryAfter, 60); // Default retry + }); + + test('parses 402 payment required error correctly', () { + const responseBody = '{"error": "Payment required"}'; + final error = ApiErrorParser.parseCoinPaprikaError(402, responseBody); + + expect(error.statusCode, 402); + expect( + error.message, + 'Payment required. Please upgrade your CoinPaprika plan.', + ); + expect(error.errorType, 'payment_required'); + expect(error.isPaymentRequiredError, true); + expect(error.isRateLimitError, false); + }); + + test('parses 400 plan limitation error correctly', () { + const responseBody = + '{"error": "Getting historical OHLCV data before 2024-01-01 is not allowed in this plan"}'; + final error = ApiErrorParser.parseCoinPaprikaError(400, responseBody); + + expect(error.statusCode, 400); + expect(error.message, contains('Historical data access denied')); + expect(error.message, contains('upgrade your plan')); + expect(error.errorType, 'plan_limitation'); + expect(error.isQuotaExceededError, true); + }); + + test('parses generic 400 error correctly', () { + const responseBody = '{"error": "Bad request"}'; + final error = ApiErrorParser.parseCoinPaprikaError(400, responseBody); + + expect(error.statusCode, 400); + expect( + error.message, + 'Bad request. Please check your request parameters.', + ); + expect(error.errorType, 'bad_request'); + }); + + test('parses 401 unauthorized error correctly', () { + const responseBody = '{"error": "Unauthorized"}'; + final error = ApiErrorParser.parseCoinPaprikaError(401, responseBody); + + expect(error.statusCode, 401); + expect(error.message, 'Unauthorized. Please check your API key.'); + expect(error.errorType, 'unauthorized'); + }); + + test('parses 404 not found error correctly', () { + const responseBody = '{"error": "Not found"}'; + final error = ApiErrorParser.parseCoinPaprikaError(404, responseBody); + + expect(error.statusCode, 404); + expect(error.message, 'Resource not found. Please verify the coin ID.'); + expect(error.errorType, 'not_found'); + }); + + test('parses 500 server error correctly', () { + const responseBody = '{"error": "Internal server error"}'; + final error = ApiErrorParser.parseCoinPaprikaError(500, responseBody); + + expect(error.statusCode, 500); + expect( + error.message, + 'CoinPaprika server error. Please try again later.', + ); + expect(error.errorType, 'server_error'); + }); + + test('parses unknown error code correctly', () { + const responseBody = '{"error": "Unknown error"}'; + final error = ApiErrorParser.parseCoinPaprikaError(999, responseBody); + + expect(error.statusCode, 999); + expect(error.message, 'Unexpected error occurred.'); + expect(error.errorType, 'unknown'); + }); + + test('handles null response body safely', () { + final error = ApiErrorParser.parseCoinPaprikaError(429, null); + + expect(error.statusCode, 429); + expect(error.message, isNotNull); + expect(error.isRateLimitError, true); + }); + + test('does not expose raw response body in error message', () { + const sensitiveData = 'SENSITIVE_API_KEY_12345'; + final responseBody = + '{"error": "Rate limit", "api_key": "$sensitiveData"}'; + final error = ApiErrorParser.parseCoinPaprikaError(429, responseBody); + + expect(error.message, isNot(contains(sensitiveData))); + expect(error.toString(), isNot(contains(sensitiveData))); + }); + }); + + group('ApiErrorParser.parseCoinGeckoError', () { + test('parses 429 rate limit error correctly', () { + const responseBody = '{"error": "Rate limit exceeded"}'; + final error = ApiErrorParser.parseCoinGeckoError(429, responseBody); + + expect(error.statusCode, 429); + expect( + error.message, + 'Rate limit exceeded. Please reduce request frequency.', + ); + expect(error.errorType, 'rate_limit'); + expect(error.isRateLimitError, true); + }); + + test('parses 402 payment required error correctly', () { + const responseBody = '{"error": "Payment required"}'; + final error = ApiErrorParser.parseCoinGeckoError(402, responseBody); + + expect(error.statusCode, 402); + expect( + error.message, + 'Payment required. Please upgrade your CoinGecko plan.', + ); + expect(error.errorType, 'payment_required'); + expect(error.isPaymentRequiredError, true); + }); + + test('parses 400 plan limitation error with days limit', () { + const responseBody = '{"error": "Cannot query more than 365 days"}'; + final error = ApiErrorParser.parseCoinGeckoError(400, responseBody); + + expect(error.statusCode, 400); + expect(error.message, contains('365 days')); + expect(error.message, contains('upgrade your plan')); + expect(error.errorType, 'plan_limitation'); + expect(error.isQuotaExceededError, true); + }); + + test('parses generic 400 error correctly', () { + const responseBody = '{"error": "Bad request"}'; + final error = ApiErrorParser.parseCoinGeckoError(400, responseBody); + + expect(error.statusCode, 400); + expect( + error.message, + 'Bad request. Please check your request parameters.', + ); + expect(error.errorType, 'bad_request'); + }); + + test('does not expose raw response body in error message', () { + const sensitiveData = 'PRIVATE_TOKEN_XYZ789'; + final responseBody = '{"error": "Forbidden", "token": "$sensitiveData"}'; + final error = ApiErrorParser.parseCoinGeckoError(403, responseBody); + + expect(error.message, isNot(contains(sensitiveData))); + expect(error.toString(), isNot(contains(sensitiveData))); + }); + }); + + group('ApiErrorParser.createSafeErrorMessage', () { + test('creates basic error message', () { + final message = ApiErrorParser.createSafeErrorMessage( + operation: 'price fetch', + service: 'CoinGecko', + statusCode: 404, + ); + + expect( + message, + 'CoinGecko API error during price fetch (HTTP 404) - Resource not found', + ); + }); + + test('includes coin ID when provided', () { + final message = ApiErrorParser.createSafeErrorMessage( + operation: 'OHLC fetch', + service: 'CoinPaprika', + statusCode: 429, + coinId: 'btc-bitcoin', + ); + + expect( + message, + 'CoinPaprika API error during OHLC fetch for btc-bitcoin (HTTP 429) - Rate limit exceeded', + ); + }); + + test('handles different status codes with appropriate context', () { + final testCases = [ + (429, 'Rate limit exceeded'), + (402, 'Payment/upgrade required'), + (401, 'Authentication failed'), + (403, 'Access forbidden'), + (404, 'Resource not found'), + (500, 'Server error'), + (502, 'Server error'), + (503, 'Server error'), + (504, 'Server error'), + ]; + + for (final (statusCode, expectedContext) in testCases) { + final message = ApiErrorParser.createSafeErrorMessage( + operation: 'test operation', + service: 'TestService', + statusCode: statusCode, + ); + + expect(message, contains(expectedContext)); + expect(message, contains('HTTP $statusCode')); + } + }); + + test('does not include context for unrecognized status codes', () { + final message = ApiErrorParser.createSafeErrorMessage( + operation: 'test operation', + service: 'TestService', + statusCode: 999, + ); + + expect(message, 'TestService API error during test operation (HTTP 999)'); + expect(message, isNot(contains(' - '))); + }); + }); + + group('Security Tests', () { + test('ensures no sensitive data leaks in error messages', () { + const sensitivePatterns = [ + 'api_key', + 'token', + 'password', + 'secret', + 'private', + 'bearer', + 'authorization', + 'x-api-key', + ]; + + // Test with response body containing sensitive data + final responseBody = ''' + { + "error": "Unauthorized", + "api_key": "sk-1234567890abcdef", + "token": "bearer_token_xyz", + "private_data": "sensitive_info", + "debug_info": { + "authorization": "Bearer secret_key", + "x-api-key": "private_key_123" + } + } + '''; + + final coinPaprikaError = ApiErrorParser.parseCoinPaprikaError( + 401, + responseBody, + ); + final coinGeckoError = ApiErrorParser.parseCoinGeckoError( + 401, + responseBody, + ); + + for (final pattern in sensitivePatterns) { + expect( + coinPaprikaError.message.toLowerCase(), + isNot(contains(pattern)), + ); + expect( + coinPaprikaError.toString().toLowerCase(), + isNot(contains(pattern)), + ); + expect(coinGeckoError.message.toLowerCase(), isNot(contains(pattern))); + expect( + coinGeckoError.toString().toLowerCase(), + isNot(contains(pattern)), + ); + } + }); + + test('ensures no raw JSON is included in error messages', () { + const responseBody = ''' + { + "error": "Rate limit exceeded", + "details": { + "limit": 1000, + "remaining": 0, + "reset_time": "2024-01-01T00:00:00Z" + }, + "user_info": { + "plan": "free", + "user_id": "12345" + } + } + '''; + + final error = ApiErrorParser.parseCoinPaprikaError(429, responseBody); + + // Should not contain JSON structure characters in the final message + expect(error.message, isNot(contains('{'))); + expect(error.message, isNot(contains('}'))); + expect(error.message, isNot(contains('"'))); + expect(error.message, isNot(contains('user_id'))); + expect(error.message, isNot(contains('12345'))); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/integration_test.dart b/packages/komodo_cex_market_data/test/integration_test.dart index 66f687e5..da5cefd2 100644 --- a/packages/komodo_cex_market_data/test/integration_test.dart +++ b/packages/komodo_cex_market_data/test/integration_test.dart @@ -49,10 +49,10 @@ void main() { fallbackRepo = MockCexRepository(); mockStrategy = MockRepositorySelectionStrategy(); - sparklineRepo = SparklineRepository( - repositories: [primaryRepo, fallbackRepo], - selectionStrategy: mockStrategy, - ); + sparklineRepo = SparklineRepository([ + primaryRepo, + fallbackRepo, + ], selectionStrategy: mockStrategy); // Setup default supports behavior when( diff --git a/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart b/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart index ebf396ae..6152d8b2 100644 --- a/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart +++ b/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart @@ -1,14 +1,5 @@ import 'package:decimal/decimal.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' - show PriceRequestType; -import 'package:komodo_cex_market_data/src/binance/binance.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_24hr_ticker.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info_reduced.dart'; -import 'package:komodo_cex_market_data/src/cex_repository.dart'; -import 'package:komodo_cex_market_data/src/coingecko/coingecko.dart'; -import 'package:komodo_cex_market_data/src/komodo/komodo.dart'; -import 'package:komodo_cex_market_data/src/models/models.dart'; -import 'package:komodo_cex_market_data/src/repository_priority_manager.dart'; +import 'package:komodo_cex_market_data/src/_core_index.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:test/test.dart'; @@ -93,7 +84,7 @@ class TestBinanceProvider implements IBinanceProvider { int? limit, String? baseUrl, }) async { - return CoinOhlc(ohlc: []); + return const CoinOhlc(ohlc: []); } } @@ -189,7 +180,7 @@ void main() { }); test('returns correct priority for CoinGeckoRepository', () { - expect(RepositoryPriorityManager.getPriority(coinGeckoRepo), equals(3)); + expect(RepositoryPriorityManager.getPriority(coinGeckoRepo), equals(4)); }); test('returns 999 for unknown repository types', () { @@ -208,7 +199,7 @@ void main() { test('returns correct priority for CoinGeckoRepository', () { expect( RepositoryPriorityManager.getSparklinePriority(coinGeckoRepo), - equals(2), + equals(3), ); }); @@ -354,7 +345,7 @@ void main() { ); expect( RepositoryPriorityManager.defaultPriorities[CoinGeckoRepository], - equals(3), + equals(4), ); }); @@ -365,7 +356,7 @@ void main() { ); expect( RepositoryPriorityManager.sparklinePriorities[CoinGeckoRepository], - equals(2), + equals(3), ); expect( RepositoryPriorityManager.sparklinePriorities[KomodoPriceRepository], diff --git a/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart index d4be25d6..b027ad78 100644 --- a/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart +++ b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart @@ -1,11 +1,5 @@ -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' - show PriceRequestType; -import 'package:komodo_cex_market_data/src/binance/binance.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_24hr_ticker.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info_reduced.dart'; -import 'package:komodo_cex_market_data/src/coingecko/coingecko.dart'; -import 'package:komodo_cex_market_data/src/models/models.dart'; -import 'package:komodo_cex_market_data/src/repository_selection_strategy.dart'; +import 'package:decimal/decimal.dart'; +import 'package:komodo_cex_market_data/src/_core_index.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:test/test.dart'; @@ -66,11 +60,124 @@ class TestBinanceProvider implements IBinanceProvider { } } +// Mock repository that always supports requests +class MockSupportingRepository implements CexRepository { + MockSupportingRepository(this.name, {this.shouldSupport = true}); + final String name; + final bool shouldSupport; + + @override + Future supports( + AssetId assetId, + QuoteCurrency fiatCurrency, + PriceRequestType requestType, + ) async { + return shouldSupport; + } + + // Other methods not needed for this test + @override + Future> getCoinList() async => []; + + @override + Future getCoinOhlc( + AssetId assetId, + QuoteCurrency quoteCurrency, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) async => throw UnimplementedError(); + + @override + Future getCoinFiatPrice( + AssetId assetId, { + DateTime? priceDate, + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future> getCoinFiatPrices( + AssetId assetId, + List dates, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future getCoin24hrPriceChange( + AssetId assetId, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + String resolveTradingSymbol(AssetId assetId) => ''; + + @override + bool canHandleAsset(AssetId assetId) => true; + + @override + String toString() => 'MockRepository($name)'; +} + +// Mock repository that throws errors during support checks +class MockFailingRepository implements CexRepository { + @override + Future supports( + AssetId assetId, + QuoteCurrency fiatCurrency, + PriceRequestType requestType, + ) async { + throw Exception('Mock error during support check'); + } + + // Other methods not needed for this test + @override + Future> getCoinList() async => []; + + @override + Future getCoinOhlc( + AssetId assetId, + QuoteCurrency quoteCurrency, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) async => throw UnimplementedError(); + + @override + Future getCoinFiatPrice( + AssetId assetId, { + DateTime? priceDate, + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future> getCoinFiatPrices( + AssetId assetId, + List dates, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future getCoin24hrPriceChange( + AssetId assetId, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + String resolveTradingSymbol(AssetId assetId) => ''; + + @override + bool canHandleAsset(AssetId assetId) => true; + + @override + String toString() => 'MockFailingRepository'; +} + void main() { group('RepositorySelectionStrategy', () { late RepositorySelectionStrategy strategy; late BinanceRepository binance; - late CoinGeckoRepository gecko; setUp(() { strategy = DefaultRepositorySelectionStrategy(); @@ -78,31 +185,309 @@ void main() { binanceProvider: TestBinanceProvider(), enableMemoization: false, ); - gecko = CoinGeckoRepository( - coinGeckoProvider: CoinGeckoCexProvider(), - enableMemoization: false, - ); }); - test('selects repository based on priority', () async { - final asset = AssetId( - id: 'BTC', - name: 'BTC', - symbol: AssetSymbol(assetConfigId: 'BTC'), - chainId: AssetChainId(chainId: 0), - derivationPath: null, - subClass: CoinSubClass.utxo, - ); - final fiat = FiatCurrency.usd; + group('selectRepository', () { + test('selects repository based on priority', () async { + final supportingRepo = MockSupportingRepository('supporting'); + final nonSupportingRepo = MockSupportingRepository( + 'non-supporting', + shouldSupport: false, + ); + + final asset = AssetId( + id: 'BTC', + name: 'BTC', + symbol: AssetSymbol(assetConfigId: 'BTC'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + const fiat = FiatCurrency.usd; + + final repo = await strategy.selectRepository( + assetId: asset, + fiatCurrency: fiat, + requestType: PriceRequestType.currentPrice, + availableRepositories: [nonSupportingRepo, supportingRepo], + ); + + expect(repo, equals(supportingRepo)); + }); + + test( + 'returns null if no repositories support the asset/fiat combination', + () async { + final nonSupportingRepo1 = MockSupportingRepository( + 'repo1', + shouldSupport: false, + ); + final nonSupportingRepo2 = MockSupportingRepository( + 'repo2', + shouldSupport: false, + ); + + final asset = AssetId( + id: 'UNSUPPORTED', + name: 'Unsupported', + symbol: AssetSymbol(assetConfigId: 'UNSUPPORTED'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + final repo = await strategy.selectRepository( + assetId: asset, + fiatCurrency: FiatCurrency.usd, + requestType: PriceRequestType.currentPrice, + availableRepositories: [nonSupportingRepo1, nonSupportingRepo2], + ); - final repo = await strategy.selectRepository( - assetId: asset, - fiatCurrency: fiat, - requestType: PriceRequestType.currentPrice, - availableRepositories: [gecko, binance], + expect(repo, isNull); + }, ); - expect(repo, equals(binance)); + test('handles repository support check failures gracefully', () async { + final errorRepo = MockFailingRepository(); + final supportingRepo = MockSupportingRepository('supporting'); + + final asset = AssetId( + id: 'BTC', + name: 'BTC', + symbol: AssetSymbol(assetConfigId: 'BTC'), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + final repo = await strategy.selectRepository( + assetId: asset, + fiatCurrency: FiatCurrency.usd, + requestType: PriceRequestType.currentPrice, + availableRepositories: [errorRepo, supportingRepo], + ); + + expect(repo, equals(supportingRepo)); + }); + }); + + group('mapped quote currency support', () { + test('should demonstrate quote currency mapping behavior', () async { + // Test USDT stablecoin mapping behavior + expect( + Stablecoin.usdt.coinGeckoId, + equals('usd'), + reason: 'USDT should map to USD for CoinGecko', + ); + + expect( + Stablecoin.usdt.coinPaprikaId, + equals('usdt'), + reason: 'USDT should use usdt identifier for CoinPaprika', + ); + + // Test EUR-pegged stablecoin + expect( + Stablecoin.eurs.coinGeckoId, + equals('eur'), + reason: 'EURS should map to EUR for CoinGecko', + ); + + expect( + Stablecoin.eurs.coinPaprikaId, + equals('eurs'), + reason: 'EURS should use eurs identifier for CoinPaprika', + ); + }); + + test('should work with mock repositories that handle mapping', () async { + // Create mock repositories that demonstrate the mapping behavior + final geckoLikeRepo = MockGeckoStyleRepository(); + final paprikaLikeRepo = MockPaprikaStyleRepository(); + + final btcAsset = AssetId( + id: 'BTC', + name: 'Bitcoin', + symbol: AssetSymbol( + assetConfigId: 'BTC', + coinGeckoId: 'bitcoin', + coinPaprikaId: 'btc-bitcoin', + ), + chainId: AssetChainId(chainId: 0), + derivationPath: null, + subClass: CoinSubClass.utxo, + ); + + // Both should support USDT but via different mapping strategies + final geckoSupportsUSDT = await geckoLikeRepo.supports( + btcAsset, + Stablecoin.usdt, + PriceRequestType.currentPrice, + ); + expect(geckoSupportsUSDT, isTrue); + + final paprikaSupportsUSDT = await paprikaLikeRepo.supports( + btcAsset, + Stablecoin.usdt, + PriceRequestType.currentPrice, + ); + expect(paprikaSupportsUSDT, isTrue); + + // Repository selection should work for mapped currencies + final selectedRepo = await strategy.selectRepository( + assetId: btcAsset, + fiatCurrency: Stablecoin.usdt, + requestType: PriceRequestType.currentPrice, + availableRepositories: [geckoLikeRepo, paprikaLikeRepo], + ); + + expect(selectedRepo, isNotNull); + }); + }); + + group('ensureCacheInitialized', () { + test('should complete without error (no-op implementation)', () async { + await expectLater( + strategy.ensureCacheInitialized([binance]), + completes, + ); + }); }); }); } + +// Mock repository that simulates CoinGecko-style mapping (USDT -> USD) +class MockGeckoStyleRepository implements CexRepository { + @override + Future supports( + AssetId assetId, + QuoteCurrency fiatCurrency, + PriceRequestType requestType, + ) async { + // Simulate CoinGecko behavior: uses coinGeckoId for quote mapping + final mappedQuote = fiatCurrency.coinGeckoId; + + // Support common assets and mapped quote currencies + final supportedAssets = {'BTC', 'ETH'}; + final supportedQuotes = {'usd', 'eur', 'gbp'}; + + final assetSupported = supportedAssets.contains( + assetId.symbol.configSymbol.toUpperCase(), + ); + final quoteSupported = supportedQuotes.contains(mappedQuote); + + return assetSupported && quoteSupported; + } + + // Implement required methods + @override + Future> getCoinList() async => []; + + @override + Future getCoinOhlc( + AssetId assetId, + QuoteCurrency quoteCurrency, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) async => throw UnimplementedError(); + + @override + Future getCoinFiatPrice( + AssetId assetId, { + DateTime? priceDate, + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future> getCoinFiatPrices( + AssetId assetId, + List dates, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future getCoin24hrPriceChange( + AssetId assetId, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + String resolveTradingSymbol(AssetId assetId) => + assetId.symbol.configSymbol.toLowerCase(); + + @override + bool canHandleAsset(AssetId assetId) => true; + + @override + String toString() => 'MockGeckoStyleRepository'; +} + +// Mock repository that simulates CoinPaprika-style mapping (USDT -> usdt) +class MockPaprikaStyleRepository implements CexRepository { + @override + Future supports( + AssetId assetId, + QuoteCurrency fiatCurrency, + PriceRequestType requestType, + ) async { + // Simulate CoinPaprika behavior: uses coinPaprikaId for quote mapping + final mappedQuote = fiatCurrency.coinPaprikaId; + + // Support common assets and direct quote currencies + final supportedAssets = {'BTC', 'ETH'}; + final supportedQuotes = {'usd', 'eur', 'usdt', 'usdc'}; + + final assetSupported = supportedAssets.contains( + assetId.symbol.configSymbol.toUpperCase(), + ); + final quoteSupported = supportedQuotes.contains(mappedQuote); + + return assetSupported && quoteSupported; + } + + // Implement required methods + @override + Future> getCoinList() async => []; + + @override + Future getCoinOhlc( + AssetId assetId, + QuoteCurrency quoteCurrency, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) async => throw UnimplementedError(); + + @override + Future getCoinFiatPrice( + AssetId assetId, { + DateTime? priceDate, + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future> getCoinFiatPrices( + AssetId assetId, + List dates, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + Future getCoin24hrPriceChange( + AssetId assetId, { + QuoteCurrency fiatCurrency = Stablecoin.usdt, + }) async => throw UnimplementedError(); + + @override + String resolveTradingSymbol(AssetId assetId) => + assetId.symbol.configSymbol.toLowerCase(); + + @override + bool canHandleAsset(AssetId assetId) => true; + + @override + String toString() => 'MockPaprikaStyleRepository'; +} diff --git a/packages/komodo_cex_market_data/test/sparkline_repository_test.dart b/packages/komodo_cex_market_data/test/sparkline_repository_test.dart index 3680e0c8..95c1eb69 100644 --- a/packages/komodo_cex_market_data/test/sparkline_repository_test.dart +++ b/packages/komodo_cex_market_data/test/sparkline_repository_test.dart @@ -54,10 +54,10 @@ void main() { fallbackRepo = MockCexRepository(); mockStrategy = MockRepositorySelectionStrategy(); - sparklineRepo = SparklineRepository( - repositories: [primaryRepo, fallbackRepo], - selectionStrategy: mockStrategy, - ); + sparklineRepo = SparklineRepository([ + primaryRepo, + fallbackRepo, + ], selectionStrategy: mockStrategy); // Setup default supports behavior when( @@ -573,7 +573,7 @@ void main() { }); test('throws exception when not initialized', () async { - final uninitializedRepo = SparklineRepository(); + final uninitializedRepo = SparklineRepository.defaultInstance(); expect( () => uninitializedRepo.fetchSparkline(testAsset), diff --git a/packages/komodo_defi_sdk/analysis_options.yaml b/packages/komodo_defi_sdk/analysis_options.yaml index 6330ffa8..dc1d1c01 100644 --- a/packages/komodo_defi_sdk/analysis_options.yaml +++ b/packages/komodo_defi_sdk/analysis_options.yaml @@ -1,4 +1,4 @@ -include: package:very_good_analysis/analysis_options.6.0.0.yaml +include: package:very_good_analysis/analysis_options.7.0.0.yaml analyzer: errors: use_if_null_to_convert_nulls_to_bools: ignore diff --git a/packages/komodo_defi_sdk/example/lib/main.dart b/packages/komodo_defi_sdk/example/lib/main.dart index f0621824..99f4472e 100644 --- a/packages/komodo_defi_sdk/example/lib/main.dart +++ b/packages/komodo_defi_sdk/example/lib/main.dart @@ -12,7 +12,7 @@ import 'package:kdf_sdk_example/widgets/instance_manager/instance_view.dart'; import 'package:kdf_sdk_example/widgets/instance_manager/kdf_instance_drawer.dart'; import 'package:kdf_sdk_example/widgets/instance_manager/kdf_instance_state.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' - show sparklineRepository; + show SparklineRepository; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; @@ -47,6 +47,7 @@ void main() async { await defaultSdk.initialize(); dragon.log('Default SDK instance initialized'); + final sparklineRepository = SparklineRepository.defaultInstance(); unawaited( sparklineRepository.init().catchError(( Object? error, @@ -63,7 +64,12 @@ void main() async { runApp( MultiRepositoryProvider( - providers: [RepositoryProvider.value(value: defaultSdk)], + providers: [ + RepositoryProvider.value(value: defaultSdk), + RepositoryProvider.value( + value: sparklineRepository, + ), + ], child: KdfInstanceManagerProvider( notifier: instanceManager, child: MaterialApp( @@ -154,10 +160,9 @@ class _KomodoAppState extends State { void _updateInstanceUser(String instanceName, KdfUser? user) { setState(() { _currentUsers[instanceName] = user; - _statusMessages[instanceName] = - user != null - ? 'Current wallet: ${user.walletId.name}' - : 'Not signed in'; + _statusMessages[instanceName] = user != null + ? 'Current wallet: ${user.walletId.name}' + : 'Not signed in'; }); dragon.DragonLogs.setSessionMetadata({ 'instance': instanceName, @@ -189,13 +194,12 @@ class _KomodoAppState extends State { if (assets == null) return; setState(() { - _filteredAssets = - assets.values.where((v) { - final asset = v.id.name; - final id = v.id.id; - return asset.toLowerCase().contains(query) || - id.toLowerCase().contains(query); - }).toList(); + _filteredAssets = assets.values.where((v) { + final asset = v.id.name; + final id = v.id.id; + return asset.toLowerCase().contains(query) || + id.toLowerCase().contains(query); + }).toList(); }); } @@ -219,10 +223,9 @@ class _KomodoAppState extends State { actions: [ if (instances.isNotEmpty) ...[ Badge( - backgroundColor: - instances[_selectedInstanceIndex].isConnected - ? Colors.green - : Colors.red, + backgroundColor: instances[_selectedInstanceIndex].isConnected + ? Colors.green + : Colors.red, child: const Icon(Icons.cloud), ), IconButton( @@ -239,45 +242,43 @@ class _KomodoAppState extends State { ], ], ), - body: - instances.isEmpty - ? const Center(child: Text('No KDF instances configured')) - : IndexedStack( - index: _selectedInstanceIndex, - children: [ - for (final instance in instances) - Padding( - padding: const EdgeInsets.all(16), - child: BlocProvider( - create: (context) => AuthBloc(sdk: instance.sdk), - child: BlocListener( - listener: (context, state) { - final user = - state.isAuthenticated ? state.user : null; - _updateInstanceUser(instance.name, user); - }, - child: Form( - key: _formKey, - autovalidateMode: - AutovalidateMode.onUserInteraction, - child: InstanceView( - instance: instance, - state: 'active', - statusMessage: - _statusMessages[instance.name] ?? - 'Not initialized', - searchController: _searchController, - filteredAssets: _filteredAssets, - onNavigateToAsset: - (asset) => - _onNavigateToAsset(instance, asset), - ), + body: instances.isEmpty + ? const Center(child: Text('No KDF instances configured')) + : IndexedStack( + index: _selectedInstanceIndex, + children: [ + for (final instance in instances) + Padding( + padding: const EdgeInsets.all(16), + child: BlocProvider( + create: (context) => AuthBloc(sdk: instance.sdk), + child: BlocListener( + listener: (context, state) { + final user = state.isAuthenticated + ? state.user + : null; + _updateInstanceUser(instance.name, user); + }, + child: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: InstanceView( + instance: instance, + state: 'active', + statusMessage: + _statusMessages[instance.name] ?? + 'Not initialized', + searchController: _searchController, + filteredAssets: _filteredAssets, + onNavigateToAsset: (asset) => + _onNavigateToAsset(instance, asset), ), ), ), ), - ], - ), + ), + ], + ), bottomNavigationBar: _buildInstanceNavigator(instances), ); } diff --git a/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart b/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart index 5488e882..b02e6a2f 100644 --- a/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart +++ b/packages/komodo_defi_sdk/example/lib/widgets/assets/asset_item.dart @@ -1,5 +1,6 @@ import 'package:dragon_charts_flutter/dragon_charts_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart' show RepositoryProvider; import 'package:kdf_sdk_example/widgets/assets/asset_market_info.dart'; import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -62,8 +63,9 @@ class _AssetItemTrailing extends StatelessWidget { // Use the parent coin ticker for child assets so that token logos display // the network they belong to (e.g. ETH for ERC20 tokens). - final protocolTicker = - isChildAsset ? asset.id.parentId?.id : asset.id.subClass.iconTicker; + final protocolTicker = isChildAsset + ? asset.id.parentId?.id + : asset.id.subClass.iconTicker; return Row( mainAxisSize: MainAxisSize.min, @@ -72,7 +74,7 @@ class _AssetItemTrailing extends StatelessWidget { const Icon(Icons.lock, color: Colors.grey), const SizedBox(width: 8), ], - CoinSparkline(assetId: asset.id), + if (!asset.protocol.isTestnet) CoinSparkline(assetId: asset.id), const SizedBox(width: 8), AssetMarketInfo(asset), const SizedBox(width: 8), @@ -124,7 +126,9 @@ class _CoinSparklineState extends State { @override void initState() { super.initState(); - _sparklineFuture = sparklineRepository.fetchSparkline(widget.assetId); + _sparklineFuture = RepositoryProvider.of( + context, + ).fetchSparkline(widget.assetId); } @override @@ -132,7 +136,9 @@ class _CoinSparklineState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.assetId != widget.assetId) { setState(() { - _sparklineFuture = sparklineRepository.fetchSparkline(widget.assetId); + _sparklineFuture = RepositoryProvider.of( + context, + ).fetchSparkline(widget.assetId); }); } } diff --git a/packages/komodo_defi_sdk/test/market_data_manager_test.dart b/packages/komodo_defi_sdk/test/market_data_manager_test.dart index 8d195153..3c704a07 100644 --- a/packages/komodo_defi_sdk/test/market_data_manager_test.dart +++ b/packages/komodo_defi_sdk/test/market_data_manager_test.dart @@ -49,6 +49,9 @@ void main() { ), ], ); + when( + () => fallback.supports(any(), any(), any()), + ).thenAnswer((_) async => true); when( () => fallback.getCoinFiatPrice(asset('BTC')), ).thenAnswer((_) async => Decimal.parse('3.0')); diff --git a/packages/komodo_defi_sdk/test/src/market_data/edge_cases/repository_selection_test.dart b/packages/komodo_defi_sdk/test/src/market_data/edge_cases/repository_selection_test.dart deleted file mode 100644 index ff2e426a..00000000 --- a/packages/komodo_defi_sdk/test/src/market_data/edge_cases/repository_selection_test.dart +++ /dev/null @@ -1,644 +0,0 @@ -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_24hr_ticker.dart'; -import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info_reduced.dart'; -import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/coin_historical_data.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; - -class MockCexRepository extends Mock implements CexRepository {} - -// Lightweight stub providers to create real repository instances for -// priority-based selection tests without hitting the network. -class _TestBinanceProvider implements IBinanceProvider { - @override - Future fetchKlines( - String symbol, - String interval, { - int? startUnixTimestampMilliseconds, - int? endUnixTimestampMilliseconds, - int? limit, - String? baseUrl, - }) async { - return const CoinOhlc(ohlc: []); - } - - @override - Future fetch24hrTicker(String symbol, {String? baseUrl}) { - throw UnimplementedError(); - } - - @override - Future fetchExchangeInfo({String? baseUrl}) { - throw UnimplementedError(); - } - - @override - Future fetchExchangeInfoReduced({ - String? baseUrl, - }) async { - return BinanceExchangeInfoResponseReduced( - timezone: '', - serverTime: 0, - symbols: [ - SymbolReduced( - symbol: 'BTCUSDT', - status: 'TRADING', - baseAsset: 'BTC', - baseAssetPrecision: 8, - quoteAsset: 'USDT', - quotePrecision: 8, - quoteAssetPrecision: 8, - isSpotTradingAllowed: true, - ), - ], - ); - } -} - -class _TestCoinGeckoProvider implements ICoinGeckoProvider { - @override - Future> fetchCoinList({bool includePlatforms = false}) async { - return const [ - CexCoin( - id: 'BTC', - symbol: 'BTC', - name: 'Bitcoin', - currencies: {}, - ), - ]; - } - - @override - Future> fetchSupportedVsCurrencies() async { - return ['usdt']; - } - - @override - Future> fetchCoinMarketData({ - String vsCurrency = 'usd', - List? ids, - String? category, - String order = 'market_cap_asc', - int perPage = 100, - int page = 1, - bool sparkline = false, - String? priceChangePercentage, - String locale = 'en', - String? precision, - }) { - throw UnimplementedError(); - } - - @override - Future fetchCoinMarketChart({ - required String id, - required String vsCurrency, - required int fromUnixTimestamp, - required int toUnixTimestamp, - String? precision, - }) { - throw UnimplementedError(); - } - - @override - Future fetchCoinOhlc( - String id, - String vsCurrency, - int days, { - int? precision, - }) async { - return const CoinOhlc(ohlc: []); - } - - @override - Future fetchCoinHistoricalMarketData({ - required String id, - required DateTime date, - String vsCurrency = 'usd', - bool localization = false, - }) { - throw UnimplementedError(); - } - - @override - Future> fetchCoinPrices( - List coinGeckoIds, { - List vsCurrencies = const ['usd'], - }) { - throw UnimplementedError(); - } -} - -void main() { - group('Repository Selection Strategy Edge Cases', () { - late DefaultRepositorySelectionStrategy strategy; - late MockCexRepository mockBinanceRepo; - late MockCexRepository mockCoinGeckoRepo; - late MockCexRepository mockKomodoRepo; - - setUp(() { - strategy = DefaultRepositorySelectionStrategy(); - mockBinanceRepo = MockCexRepository(); - mockCoinGeckoRepo = MockCexRepository(); - mockKomodoRepo = MockCexRepository(); - }); - - group('Unsupported Asset Handling', () { - test( - 'selectRepository returns null when no repository supports the asset', - () async { - // Setup repositories with limited coin lists that don't include MARTY or DOC - final binanceCoins = [ - const CexCoin( - id: 'BTC', - symbol: 'BTC', - name: 'Bitcoin', - currencies: {'USDT', 'BUSD'}, - source: 'binance', - ), - const CexCoin( - id: 'ETH', - symbol: 'ETH', - name: 'Ethereum', - currencies: {'USDT', 'BUSD'}, - source: 'binance', - ), - ]; - - final coinGeckoCoins = [ - const CexCoin( - id: 'bitcoin', - symbol: 'btc', - name: 'Bitcoin', - currencies: {'usd', 'usdt'}, - source: 'coingecko', - ), - const CexCoin( - id: 'ethereum', - symbol: 'eth', - name: 'Ethereum', - currencies: {'usd', 'usdt'}, - source: 'coingecko', - ), - ]; - - final komodoCoins = [ - const CexCoin( - id: 'KMD', - symbol: 'KMD', - name: 'Komodo', - currencies: {'USD'}, - source: 'komodo', - ), - ]; - - when( - () => mockBinanceRepo.getCoinList(), - ).thenAnswer((_) async => binanceCoins); - when( - () => mockCoinGeckoRepo.getCoinList(), - ).thenAnswer((_) async => coinGeckoCoins); - when( - () => mockKomodoRepo.getCoinList(), - ).thenAnswer((_) async => komodoCoins); - - final repositories = [ - mockBinanceRepo, - mockCoinGeckoRepo, - mockKomodoRepo, - ]; - - // Test with clearly unsupported assets - final martyAsset = AssetId( - id: 'test-marty', - symbol: AssetSymbol(assetConfigId: 'MARTY'), - name: 'MARTY', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - final docAsset = AssetId( - id: 'test-doc', - symbol: AssetSymbol(assetConfigId: 'DOC'), - name: 'DOC', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - final randomAsset = AssetId( - id: 'test-random', - symbol: AssetSymbol(assetConfigId: 'RANDOMCOIN'), - name: 'RANDOMCOIN', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // All of these should return null since no repository supports them - final martyResult = await strategy.selectRepository( - assetId: martyAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: repositories, - ); - expect(martyResult, isNull); - - final docResult = await strategy.selectRepository( - assetId: docAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: repositories, - ); - expect(docResult, isNull); - - final randomResult = await strategy.selectRepository( - assetId: randomAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: repositories, - ); - expect(randomResult, isNull); - }, - ); - - test( - 'selectRepository returns null when asset is supported but fiat currency is not', - () async { - // Setup repository that supports BTC but only with limited fiat currencies - final limitedCoins = [ - const CexCoin( - id: 'BTC', - symbol: 'BTC', - name: 'Bitcoin', - currencies: {'EUR'}, // Only EUR, not USD or USDT - source: 'limited', - ), - ]; - - when( - () => mockBinanceRepo.getCoinList(), - ).thenAnswer((_) async => limitedCoins); - - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // Should return null because BTC/USDT is not supported (only BTC/EUR) - final result = await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ); - expect(result, isNull); - }, - ); - - test( - 'selectRepository returns null when fiat currency is supported but asset is not', - () async { - // Setup repository that supports USDT but only with limited assets - final limitedCoins = [ - const CexCoin( - id: 'ETH', - symbol: 'ETH', - name: 'Ethereum', - currencies: {'USDT'}, // Supports USDT but not BTC - source: 'limited', - ), - ]; - - when( - () => mockBinanceRepo.getCoinList(), - ).thenAnswer((_) async => limitedCoins); - - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // Should return null because BTC is not supported (only ETH) - final result = await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ); - expect(result, isNull); - }, - ); - }); - - group('Case Sensitivity Tests', () { - test('asset matching is case-insensitive', () async { - final mixedCaseCoins = [ - const CexCoin( - id: 'btc', // lowercase id - symbol: 'BTC', // uppercase symbol - name: 'Bitcoin', - currencies: {'USDT'}, - source: 'test', - ), - ]; - - when( - () => mockBinanceRepo.getCoinList(), - ).thenAnswer((_) async => mixedCaseCoins); - - // Test with different cases - final btcUpperAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - final btcLowerAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'btc'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - final upperResult = await strategy.selectRepository( - assetId: btcUpperAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ); - expect(upperResult, equals(mockBinanceRepo)); - - final lowerResult = await strategy.selectRepository( - assetId: btcLowerAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ); - expect(lowerResult, equals(mockBinanceRepo)); - }); - - test('fiat currency matching is case-insensitive', () async { - final mixedCaseCoins = [ - const CexCoin( - id: 'BTC', - symbol: 'BTC', - name: 'Bitcoin', - currencies: {'usdt'}, // lowercase currency - source: 'test', - ), - ]; - - when( - () => mockBinanceRepo.getCoinList(), - ).thenAnswer((_) async => mixedCaseCoins); - - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - final result = await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, // This has uppercase symbol - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ); - expect(result, equals(mockBinanceRepo)); - }); - }); - - group('Empty Repository List Handling', () { - test( - 'selectRepository returns null when no repositories are available', - () async { - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - final result = await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [], // Empty list - ); - expect(result, isNull); - }, - ); - - test( - 'selectRepository handles repositories with empty coin lists', - () async { - when(() => mockBinanceRepo.getCoinList()).thenAnswer((_) async => []); - when( - () => mockCoinGeckoRepo.getCoinList(), - ).thenAnswer((_) async => []); - - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - final result = await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo, mockCoinGeckoRepo], - ); - expect(result, isNull); - }, - ); - }); - - group('Repository Priority Tests', () { - test( - 'selectRepository returns highest priority repository when multiple support the asset', - () async { - // Use real repository types with stub providers so priority mapping applies - final binanceRepo = BinanceRepository( - binanceProvider: _TestBinanceProvider(), - enableMemoization: false, - ); - final coinGeckoRepo = CoinGeckoRepository( - coinGeckoProvider: _TestCoinGeckoProvider(), - enableMemoization: false, - ); - - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - final result = await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [ - coinGeckoRepo, - binanceRepo, - ], // Order shouldn't matter - ); - - // According to RepositoryPriorityManager: Binance(2) > CoinGecko(3) - expect(result, equals(binanceRepo)); - }, - ); - }); - - group('Caching Behavior Tests', () { - test( - 'ensureCacheInitialized handles repository failures gracefully', - () async { - // Setup one repository to fail - when( - () => mockBinanceRepo.getCoinList(), - ).thenThrow(Exception('API Error')); - when(() => mockCoinGeckoRepo.getCoinList()).thenAnswer( - (_) async => [ - const CexCoin( - id: 'BTC', - symbol: 'BTC', - name: 'Bitcoin', - currencies: {'USDT'}, - source: 'coingecko', - ), - ], - ); - - // Should not throw, just handle the failure - expect( - () => strategy.ensureCacheInitialized([ - mockBinanceRepo, - mockCoinGeckoRepo, - ]), - returnsNormally, - ); - - // The working repository should still be usable - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - final result = await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo, mockCoinGeckoRepo], - ); - - // Should return the working repository - expect(result, equals(mockCoinGeckoRepo)); - }, - ); - - test('cache is built correctly from coin list data', () async { - final testCoins = [ - const CexCoin( - id: 'BTC', - symbol: 'BTC', - name: 'Bitcoin', - currencies: {'USDT', 'BUSD', 'EUR'}, - source: 'test', - ), - const CexCoin( - id: 'ETH', - symbol: 'ETH', - name: 'Ethereum', - currencies: {'USDT', 'BUSD'}, - source: 'test', - ), - ]; - - when( - () => mockBinanceRepo.getCoinList(), - ).thenAnswer((_) async => testCoins); - - await strategy.ensureCacheInitialized([mockBinanceRepo]); - - // Test that all supported combinations work - final btcAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - final ethAsset = AssetId( - id: 'ethereum', - symbol: AssetSymbol(assetConfigId: 'ETH'), - name: 'Ethereum', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // BTC with various fiat currencies - expect( - await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ), - equals(mockBinanceRepo), - ); - - expect( - await strategy.selectRepository( - assetId: btcAsset, - fiatCurrency: Stablecoin.busd, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ), - equals(mockBinanceRepo), - ); - - // ETH should work with USDT - expect( - await strategy.selectRepository( - assetId: ethAsset, - fiatCurrency: Stablecoin.usdt, - requestType: PriceRequestType.currentPrice, - availableRepositories: [mockBinanceRepo], - ), - equals(mockBinanceRepo), - ); - }); - }); - }); -} diff --git a/packages/komodo_defi_sdk/test/src/market_data/edge_cases/retry_limits_test.dart b/packages/komodo_defi_sdk/test/src/market_data/edge_cases/retry_limits_test.dart deleted file mode 100644 index ef289ea0..00000000 --- a/packages/komodo_defi_sdk/test/src/market_data/edge_cases/retry_limits_test.dart +++ /dev/null @@ -1,453 +0,0 @@ -import 'package:decimal/decimal.dart'; -import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; - -class MockCexRepository extends Mock implements CexRepository {} - -class MockRepositorySelectionStrategy extends Mock - implements RepositorySelectionStrategy {} - -class FakeAssetId extends Fake implements AssetId {} - -class TestRetryManager with RepositoryFallbackMixin { - TestRetryManager({ - required this.repositories, - required this.selectionStrategy, - }); - - final List repositories; - @override - final RepositorySelectionStrategy selectionStrategy; - - @override - List get priceRepositories => repositories; - - // Expose the mixin method for testing - Future testTryRepositoriesInOrder( - AssetId assetId, - QuoteCurrency quoteCurrency, - PriceRequestType requestType, - Future Function(CexRepository repo) operation, - String operationName, { - int? maxTotalAttempts, - }) { - return tryRepositoriesInOrder( - assetId, - quoteCurrency, - requestType, - operation, - operationName, - maxTotalAttempts: maxTotalAttempts ?? 3, - ); - } -} - -void main() { - setUpAll(() { - registerFallbackValue(FakeAssetId()); - registerFallbackValue(Stablecoin.usdt); - registerFallbackValue(PriceRequestType.currentPrice); - }); - - group('Retry Limits and Anti-Spam Tests', () { - late MockCexRepository mockBinanceRepo; - late MockCexRepository mockCoinGeckoRepo; - late MockRepositorySelectionStrategy mockSelectionStrategy; - late TestRetryManager testManager; - - setUp(() { - mockBinanceRepo = MockCexRepository(); - mockCoinGeckoRepo = MockCexRepository(); - mockSelectionStrategy = MockRepositorySelectionStrategy(); - - testManager = TestRetryManager( - repositories: [mockBinanceRepo, mockCoinGeckoRepo], - selectionStrategy: mockSelectionStrategy, - ); - - // Setup basic repository behavior - when(() => mockBinanceRepo.getCoinList()).thenAnswer((_) async => []); - when(() => mockCoinGeckoRepo.getCoinList()).thenAnswer((_) async => []); - }); - - group('Repository-Level Retry Limits', () { - test( - 'BinanceRepository getCoinList does not exceed 3 attempts on failure', - () async { - final mockProvider = MockBinanceProvider(); - var callCount = 0; - when( - () => mockProvider.fetchExchangeInfoReduced( - baseUrl: any(named: 'baseUrl'), - ), - ).thenAnswer((_) async { - callCount++; - throw Exception('Simulated Binance API failure'); - }); - - final binanceRepo = BinanceRepository( - binanceProvider: mockProvider, - enableMemoization: false, - ); - - // Should handle internal failures gracefully and not spam beyond limit - try { - await binanceRepo.getCoinList(); - } catch (_) { - // Some implementations may propagate the last error; ignore for call count assertion - } - - // Binance tries primary and secondary endpoints; ensure attempts <= 3 - expect(callCount, lessThanOrEqualTo(3)); - }, - ); - - test( - 'CoinGeckoRepository getCoinList does not exceed 3 attempts on failure', - () async { - final mockProvider = MockCoinGeckoProvider(); - var callCount = 0; - when(mockProvider.fetchCoinList).thenAnswer((_) async { - callCount++; - throw Exception('Simulated CoinGecko API failure'); - }); - - final coinGeckoRepo = CoinGeckoRepository( - coinGeckoProvider: mockProvider, - enableMemoization: false, - ); - - try { - await coinGeckoRepo.getCoinList(); - } catch (_) { - // Expected to fail due to simulated provider failure - } - - // Ensure the repository does not retry more than a conservative cap - expect(callCount, lessThanOrEqualTo(3)); - }, - ); - }); - - group('Fallback Mixin Retry Behavior', () { - test('respects maxTotalAttempts limit and prevents spam', () async { - final testAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // Ensure repositories report support so fallback ordering includes them - when( - () => mockBinanceRepo.supports(any(), any(), any()), - ).thenAnswer((_) async => true); - when( - () => mockCoinGeckoRepo.supports(any(), any(), any()), - ).thenAnswer((_) async => true); - - // Mock selection strategy to return primary repo - when( - () => mockSelectionStrategy.selectRepository( - assetId: any(named: 'assetId'), - fiatCurrency: any(named: 'fiatCurrency'), - requestType: any(named: 'requestType'), - availableRepositories: any(named: 'availableRepositories'), - ), - ).thenAnswer((_) async => mockBinanceRepo); - - // Mock primary repo to always fail - var callCount = 0; - when( - () => mockBinanceRepo.getCoinFiatPrice( - any(), - fiatCurrency: any(named: 'fiatCurrency'), - ), - ).thenAnswer((_) async { - callCount++; - throw Exception('Simulated API failure'); - }); - - // Mock fallback repo to succeed - when( - () => mockCoinGeckoRepo.getCoinFiatPrice( - any(), - fiatCurrency: any(named: 'fiatCurrency'), - ), - ).thenAnswer((_) async => Decimal.parse('50000.0')); - - // Test with custom maxTotalAttempts = 2 to allow fallback - final result = await testManager.testTryRepositoriesInOrder( - testAsset, - Stablecoin.usdt, - PriceRequestType.currentPrice, - (repo) => repo.getCoinFiatPrice(testAsset), - 'fiatPrice', - maxTotalAttempts: 2, - ); - - // Should succeed using fallback repo after primary fails - expect(result, equals(Decimal.parse('50000.0'))); - - // Primary repo should only be called once (respecting maxTotalAttempts: 2) - expect(callCount, equals(1)); - }); - - test( - 'limits total API calls across repositories to prevent spam', - () async { - final testAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // Ensure repositories report support so primary is used and fallback is available - when( - () => mockBinanceRepo.supports(any(), any(), any()), - ).thenAnswer((_) async => true); - when( - () => mockCoinGeckoRepo.supports(any(), any(), any()), - ).thenAnswer((_) async => true); - - // Mock selection strategy to return primary repo - when( - () => mockSelectionStrategy.selectRepository( - assetId: any(named: 'assetId'), - fiatCurrency: any(named: 'fiatCurrency'), - requestType: any(named: 'requestType'), - availableRepositories: any(named: 'availableRepositories'), - ), - ).thenAnswer((_) async => mockBinanceRepo); - - var binanceCallCount = 0; - var coinGeckoCallCount = 0; - - // Mock both repos to fail to test total retry limit - when( - () => mockBinanceRepo.getCoinFiatPrice( - any(), - fiatCurrency: any(named: 'fiatCurrency'), - ), - ).thenAnswer((_) async { - binanceCallCount++; - throw Exception('Binance API failure'); - }); - - when( - () => mockCoinGeckoRepo.getCoinFiatPrice( - any(), - fiatCurrency: any(named: 'fiatCurrency'), - ), - ).thenAnswer((_) async { - coinGeckoCallCount++; - throw Exception('CoinGecko API failure'); - }); - - // Should fail after trying both repositories with limited retries - await expectLater( - testManager.testTryRepositoriesInOrder( - testAsset, - Stablecoin.usdt, - PriceRequestType.currentPrice, - (repo) => repo.getCoinFiatPrice(testAsset), - 'fiatPrice', - maxTotalAttempts: 1, // Limit to 1 total attempt - ), - throwsA(isA()), - ); - - // With maxTotalAttempts: 1, should only call primary repository once - expect(binanceCallCount, equals(1)); - expect(coinGeckoCallCount, equals(0)); // Should not reach fallback - - // Total API calls should be exactly 1 - final totalCalls = binanceCallCount + coinGeckoCallCount; - expect(totalCalls, equals(1)); - }, - ); - }); - - group('Backoff Strategy Verification', () { - test('conservative retry behavior under load', () async { - // Use single repository manager to test retry behavior - final singleRepoManager = TestRetryManager( - repositories: [mockBinanceRepo], // Only one repository - selectionStrategy: mockSelectionStrategy, - ); - - final testAsset = AssetId( - id: 'ethereum', - symbol: AssetSymbol(assetConfigId: 'ETH'), - name: 'Ethereum', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // Mock selection strategy to return the only repository - when( - () => mockSelectionStrategy.selectRepository( - assetId: any(named: 'assetId'), - fiatCurrency: any(named: 'fiatCurrency'), - requestType: any(named: 'requestType'), - availableRepositories: any(named: 'availableRepositories'), - ), - ).thenAnswer((_) async => mockBinanceRepo); - - var totalRetryAttempts = 0; - - // Mock repo to track retry attempts - when( - () => mockBinanceRepo.getCoinFiatPrice( - any(), - fiatCurrency: any(named: 'fiatCurrency'), - ), - ).thenAnswer((_) async { - totalRetryAttempts++; - if (totalRetryAttempts < 3) { - throw Exception('Temporary failure'); - } - return Decimal.parse('3000.0'); - }); - - // Should succeed after a few retries with single repository - final result = await singleRepoManager.testTryRepositoriesInOrder( - testAsset, - Stablecoin.usdt, - PriceRequestType.currentPrice, - (repo) => repo.getCoinFiatPrice(testAsset), - 'fiatPrice', - maxTotalAttempts: 3, - ); - - expect(result, equals(Decimal.parse('3000.0'))); - expect(totalRetryAttempts, equals(3)); - }); - }); - - group('Anti-Spam Edge Cases', () { - test('handles multiple concurrent requests without spam', () async { - final testAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // Mock selection strategy - when( - () => mockSelectionStrategy.selectRepository( - assetId: any(named: 'assetId'), - fiatCurrency: any(named: 'fiatCurrency'), - requestType: any(named: 'requestType'), - availableRepositories: any(named: 'availableRepositories'), - ), - ).thenAnswer((_) async => mockBinanceRepo); - - var totalCalls = 0; - - when( - () => mockBinanceRepo.getCoinFiatPrice( - any(), - fiatCurrency: any(named: 'fiatCurrency'), - ), - ).thenAnswer((_) async { - totalCalls++; - return Decimal.parse('50000.0'); - }); - - // Simulate multiple concurrent requests - final futures = List.generate( - 5, - (index) => testManager.testTryRepositoriesInOrder( - testAsset, - Stablecoin.usdt, - PriceRequestType.currentPrice, - (repo) => repo.getCoinFiatPrice(testAsset), - 'fiatPrice', - maxTotalAttempts: 1, - ), - ); - - final results = await Future.wait(futures); - - // All requests should succeed - expect(results.length, equals(5)); - for (final result in results) { - expect(result, equals(Decimal.parse('50000.0'))); - } - - // Total calls should equal number of requests (5) since maxTotalAttempts = 1 - expect(totalCalls, equals(5)); - }); - - test('circuit breaker behavior prevents excessive retries', () async { - final testAsset = AssetId( - id: 'bitcoin', - symbol: AssetSymbol(assetConfigId: 'BTC'), - name: 'Bitcoin', - chainId: AssetChainId(chainId: 1), - derivationPath: '1234', - subClass: CoinSubClass.utxo, - ); - - // Mock selection strategy - when( - () => mockSelectionStrategy.selectRepository( - assetId: any(named: 'assetId'), - fiatCurrency: any(named: 'fiatCurrency'), - requestType: any(named: 'requestType'), - availableRepositories: any(named: 'availableRepositories'), - ), - ).thenAnswer((_) async => mockBinanceRepo); - - var callCount = 0; - - // Mock repo to always fail - when( - () => mockBinanceRepo.getCoinFiatPrice( - any(), - fiatCurrency: any(named: 'fiatCurrency'), - ), - ).thenAnswer((_) async { - callCount++; - throw Exception('Persistent failure'); - }); - - // Multiple attempts should be limited by maxTotalAttempts - for (int i = 0; i < 3; i++) { - try { - await testManager.testTryRepositoriesInOrder( - testAsset, - Stablecoin.usdt, - PriceRequestType.currentPrice, - (repo) => repo.getCoinFiatPrice(testAsset), - 'fiatPrice', - maxTotalAttempts: 1, - ); - } catch (e) { - // Expected to fail - } - } - - // Should have limited total calls despite multiple requests - // 3 requests × 1 total attempt = 3 calls to primary repo - expect(callCount, equals(3)); // Exactly 3 requests × 1 attempt each - }); - }); - }); -} - -class MockBinanceProvider extends Mock implements IBinanceProvider {} - -class MockCoinGeckoProvider extends Mock implements ICoinGeckoProvider {}