From 55858a7458cf2b7ddc4bdc43de41c48ab1a10168 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Wed, 29 Oct 2025 21:17:32 +0100 Subject: [PATCH 01/12] Aggiunto test per aggregazione prodotto --- tests/utils/test_market_aggregator.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index 0d62985..bce0b90 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -127,3 +127,28 @@ class TestMarketDataAggregator: assert aggregated[1].timestamp == timestamp_2h_ago assert aggregated[1].high == pytest.approx(50250.0, rel=1e-3) # type: ignore assert aggregated[1].low == pytest.approx(49850.0, rel=1e-3) # type: ignore + + def test_aggregate_product_info_different_currencies(self): + products: dict[str, list[ProductInfo]] = { + "Provider1": [self.__product("BTC", 100000.0, 1000.0, "USD")], + "Provider2": [self.__product("BTC", 70000.0, 800.0, "EUR")], + } + + aggregated = ProductInfo.aggregate(products) + assert len(aggregated) == 1 + + info = aggregated[0] + assert info is not None + assert info.id == "BTC-USD_AGGREGATED" + assert info.symbol == "BTC" + assert info.currency == "USD" + assert info.price == pytest.approx(100000.0, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore + + info = aggregated[1] + assert info is not None + assert info.id == "BTC-EUR_AGGREGATED" + assert info.symbol == "BTC" + assert info.currency == "EUR" + assert info.price == pytest.approx(70000.0, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(800.0, rel=1e-3) # type: ignore -- 2.49.1 From 512bc4568ee3126452073222d392d119a32e7d93 Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Thu, 30 Oct 2025 11:15:26 +0100 Subject: [PATCH 02/12] lavori in corso: agginto campo provider a productinfo. Inseguire le istanze e mettere il campo provider come init quano si istanzia --- src/app/api/core/markets.py | 34 ++++++++++++++++++++------- src/app/api/tools/market_tool.py | 4 +++- tests/utils/test_market_aggregator.py | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/app/api/core/markets.py b/src/app/api/core/markets.py index 6b53f61..8f32795 100644 --- a/src/app/api/core/markets.py +++ b/src/app/api/core/markets.py @@ -13,21 +13,28 @@ class ProductInfo(BaseModel): price: float = 0.0 volume_24h: float = 0.0 currency: str = "" + provider: str = "" + + def init(self, provider:str): + self.provider = provider @staticmethod def aggregate(products: dict[str, list['ProductInfo']]) -> list['ProductInfo']: """ - Aggregates a list of ProductInfo by symbol. + Aggregates a list of ProductInfo by symbol across different providers. Args: products (dict[str, list[ProductInfo]]): Map provider -> list of ProductInfo Returns: - list[ProductInfo]: List of ProductInfo aggregated by symbol + list[ProductInfo]: List of ProductInfo aggregated by symbol, combining data from all providers """ - # Costruzione mappa symbol -> lista di ProductInfo + # Costruzione mappa symbol -> lista di ProductInfo (da tutti i provider) symbols_infos: dict[str, list[ProductInfo]] = {} - for _, product_list in products.items(): + for provider_name, product_list in products.items(): for product in product_list: + # Assicuriamo che il provider sia impostato + if not product.provider: + product.provider = provider_name symbols_infos.setdefault(product.symbol, []).append(product) # Aggregazione per ogni symbol @@ -37,13 +44,24 @@ class ProductInfo(BaseModel): product.id = f"{symbol}_AGGREGATED" product.symbol = symbol - product.currency = next(p.currency for p in product_list if p.currency) + product.currency = next((p.currency for p in product_list if p.currency), "") + + # Raccogliamo i provider che hanno fornito dati + providers = [p.provider for p in product_list if p.provider] + product.provider = ", ".join(set(providers)) if providers else "AGGREGATED" - volume_sum = sum(p.volume_24h for p in product_list) + # Calcolo del volume medio + volume_sum = sum(p.volume_24h for p in product_list if p.volume_24h > 0) product.volume_24h = volume_sum / len(product_list) if product_list else 0.0 - prices = sum(p.price * p.volume_24h for p in product_list) - product.price = (prices / volume_sum) if volume_sum > 0 else 0.0 + # Calcolo del prezzo pesato per volume (VWAP - Volume Weighted Average Price) + if volume_sum > 0: + prices_weighted = sum(p.price * p.volume_24h for p in product_list if p.volume_24h > 0) + product.price = prices_weighted / volume_sum + else: + # Se non c'è volume, facciamo una media semplice dei prezzi + valid_prices = [p.price for p in product_list if p.price > 0] + product.price = sum(valid_prices) / len(valid_prices) if valid_prices else 0.0 aggregated_products.append(product) return aggregated_products diff --git a/src/app/api/tools/market_tool.py b/src/app/api/tools/market_tool.py index 649f9d4..409d380 100644 --- a/src/app/api/tools/market_tool.py +++ b/src/app/api/tools/market_tool.py @@ -104,7 +104,9 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Raises: Exception: If all providers fail to return results. """ - all_products = self.handler.try_call_all(lambda w: w.get_products(asset_ids)) + all_products: dict[str, list[ProductInfo]] = {} + for asset in asset_ids: + all_products[asset] = self.handler.try_call_all(lambda w: w.get_product(asset)) return ProductInfo.aggregate(all_products) def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index bce0b90..644d107 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -139,7 +139,7 @@ class TestMarketDataAggregator: info = aggregated[0] assert info is not None - assert info.id == "BTC-USD_AGGREGATED" + assert info.id == "BTC_AGGREGATED" assert info.symbol == "BTC" assert info.currency == "USD" assert info.price == pytest.approx(100000.0, rel=1e-3) # type: ignore -- 2.49.1 From c07938618a1a83d0282f1dd6a7aec20b0a3be37a Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Thu, 30 Oct 2025 12:52:58 +0100 Subject: [PATCH 03/12] commit prima di fare cherry-pick della commit 72dc551 --- src/app/agents/prompts/team_market.md | 51 ++++++- src/app/api/core/markets.py | 67 ++++++++- src/app/api/markets/binance.py | 1 + src/app/api/markets/coinbase.py | 1 + src/app/api/markets/cryptocompare.py | 1 + src/app/api/markets/yfinance.py | 1 + src/app/api/tools/market_tool.py | 33 ++++- tests/utils/test_market_aggregator.py | 204 ++++++++++++++++++++++---- 8 files changed, 317 insertions(+), 42 deletions(-) diff --git a/src/app/agents/prompts/team_market.md b/src/app/agents/prompts/team_market.md index 93e6c24..1d4465b 100644 --- a/src/app/agents/prompts/team_market.md +++ b/src/app/agents/prompts/team_market.md @@ -17,11 +17,52 @@ - **Interval**: Determine granularity (hourly, daily, weekly) from context - **Defaults**: If not specified, use current price or last 24h data -**TOOL DESCRIPTIONS:** -- get_product: Fetches current price for a specific cryptocurrency from a single source. -- get_historical_price: Retrieves historical price data for a cryptocurrency over a specified time range from a single source. -- get_products_aggregated: Fetches current prices by aggregating data from multiple sources. Use this if user requests more specific or reliable data. -- get_historical_prices_aggregated: Retrieves historical price data by aggregating multiple sources. Use this if user requests more specific or reliable data. +**AVAILABLE TOOLS (6 total):** + +**Single-Source Tools (FAST - use first available provider):** +1. `get_product(asset_id: str)` → ProductInfo + - Fetches current price for ONE asset from the first available provider + - Example: `get_product("BTC")` → returns BTC price from Binance/YFinance/Coinbase/CryptoCompare + - Use for: Quick single asset lookup + +2. `get_products(asset_ids: list[str])` → list[ProductInfo] + - Fetches current prices for MULTIPLE assets from the first available provider + - Example: `get_products(["BTC", "ETH", "SOL"])` → returns 3 prices from same provider + - Use for: Quick multi-asset lookup + +3. `get_historical_prices(asset_id: str, limit: int = 100)` → list[Price] + - Fetches historical price data for ONE asset from the first available provider + - Example: `get_historical_prices("BTC", limit=30)` → last 30 price points + - Use for: Quick historical data lookup + +**Multi-Source Aggregated Tools (COMPREHENSIVE - queries ALL providers and merges results):** +4. `get_product_aggregated(asset_id: str)` → ProductInfo + - Queries ALL providers (Binance, YFinance, Coinbase, CryptoCompare) for ONE asset and aggregates + - Returns most reliable price using volume-weighted average (VWAP) + - Example: `get_product_aggregated("BTC")` → BTC price from all 4 providers, merged + - Use for: When user requests "reliable", "accurate", or "comprehensive" data for ONE asset + - Warning: Uses more API calls (4x) + +5. `get_products_aggregated(asset_ids: list[str])` → list[ProductInfo] + - Queries ALL providers for MULTIPLE assets and aggregates results + - Returns more reliable data with multiple sources and confidence scores + - Example: `get_products_aggregated(["BTC", "ETH"])` → prices from all 4 providers, merged + - Use for: When user requests "comprehensive" or "detailed" data for MULTIPLE assets + - Warning: Uses more API calls (4x per asset) + +6. `get_historical_prices_aggregated(asset_id: str = "BTC", limit: int = 100)` → list[Price] + - Queries ALL providers for historical data and aggregates results + - Returns more complete historical dataset with multiple sources + - Example: `get_historical_prices_aggregated("BTC", limit=50)` → 50 points from each provider + - Use for: When user requests "comprehensive" or "detailed" historical analysis + - Warning: Uses more API calls (4x) + +**TOOL SELECTION STRATEGY:** +- **Simple queries** ("What's BTC price?") → Use `get_product()` (tool #1) +- **Reliable single asset** ("Get me the most accurate BTC price") → Use `get_product_aggregated()` (tool #4) +- **Multiple assets quick** ("Compare BTC, ETH prices") → Use `get_products()` (tool #2) +- **Multiple assets comprehensive** ("Detailed analysis of BTC and ETH") → Use `get_products_aggregated()` (tool #5) +- **Historical data** → Specify appropriate `limit` parameter (7 for week, 30 for month, etc.) **OUTPUT FORMAT JSON:** diff --git a/src/app/api/core/markets.py b/src/app/api/core/markets.py index 8f32795..34c788e 100644 --- a/src/app/api/core/markets.py +++ b/src/app/api/core/markets.py @@ -14,12 +14,9 @@ class ProductInfo(BaseModel): volume_24h: float = 0.0 currency: str = "" provider: str = "" - - def init(self, provider:str): - self.provider = provider @staticmethod - def aggregate(products: dict[str, list['ProductInfo']]) -> list['ProductInfo']: + def aggregate_multi_assets(products: dict[str, list['ProductInfo']]) -> list['ProductInfo']: """ Aggregates a list of ProductInfo by symbol across different providers. Args: @@ -65,6 +62,68 @@ class ProductInfo(BaseModel): aggregated_products.append(product) return aggregated_products + + @staticmethod + def aggregate_single_asset(assets: list['ProductInfo'] | dict[str, 'ProductInfo'] | dict[str, list['ProductInfo']]) -> 'ProductInfo': + """ + Aggregates an asset across different exchanges. + Args: + assets: Can be: + - list[ProductInfo]: Direct list of products + - dict[str, ProductInfo]: Map provider -> ProductInfo (from WrapperHandler.try_call_all) + - dict[str, list[ProductInfo]]: Map provider -> list of ProductInfo + Returns: + ProductInfo: Aggregated ProductInfo combining data from all exchanges + """ + + # Defensive handling: normalize to a flat list of ProductInfo + if not assets: + raise ValueError("aggregate_single_asset requires at least one ProductInfo") + + # Normalize to a flat list of ProductInfo + if isinstance(assets, dict): + # Check if dict values are ProductInfo or list[ProductInfo] + first_value = next(iter(assets.values())) if assets else None + if first_value and isinstance(first_value, list): + # dict[str, list[ProductInfo]] -> flatten + assets_list = [product for product_list in assets.values() for product in product_list] + else: + # dict[str, ProductInfo] -> extract values + assets_list = list(assets.values()) + elif isinstance(assets, list) and assets and isinstance(assets[0], list): + # Flatten list[list[ProductInfo]] -> list[ProductInfo] + assets_list = [product for sublist in assets for product in sublist] + else: + # Already a flat list of ProductInfo + assets_list = list(assets) + + if not assets_list: + raise ValueError("aggregate_single_asset requires at least one ProductInfo") + + # Aggregazione per ogni Exchange + aggregated: ProductInfo = ProductInfo() + first = assets_list[0] + aggregated.id = f"{first.symbol}_AGGREGATED" + aggregated.symbol = first.symbol + aggregated.currency = next((p.currency for p in assets_list if p.currency), "") + + # Raccogliamo i provider che hanno fornito dati + providers = [p.provider for p in assets_list if p.provider] + aggregated.provider = ", ".join(set(providers)) if providers else "AGGREGATED" + + # Calcolo del volume medio + volume_sum = sum(p.volume_24h for p in assets_list if p.volume_24h > 0) + aggregated.volume_24h = volume_sum / len(assets_list) if assets_list else 0.0 + # Calcolo del prezzo pesato per volume (VWAP - Volume Weighted Average Price) + if volume_sum > 0: + prices_weighted = sum(p.price * p.volume_24h for p in assets_list if p.volume_24h > 0) + aggregated.price = prices_weighted / volume_sum + else: + # Se non c'è volume, facciamo una media semplice dei prezzi + valid_prices = [p.price for p in assets_list if p.price > 0] + aggregated.price = sum(valid_prices) / len(valid_prices) if valid_prices else 0.0 + + return aggregated diff --git a/src/app/api/markets/binance.py b/src/app/api/markets/binance.py index 4d892e5..9d0ed58 100644 --- a/src/app/api/markets/binance.py +++ b/src/app/api/markets/binance.py @@ -11,6 +11,7 @@ def extract_product(currency: str, ticker_data: dict[str, Any]) -> ProductInfo: product.price = float(ticker_data.get('price', 0)) product.volume_24h = float(ticker_data.get('volume', 0)) product.currency = currency + product.provider = "Binance" return product def extract_price(kline_data: list[Any]) -> Price: diff --git a/src/app/api/markets/coinbase.py b/src/app/api/markets/coinbase.py index 0115238..fbe0a55 100644 --- a/src/app/api/markets/coinbase.py +++ b/src/app/api/markets/coinbase.py @@ -12,6 +12,7 @@ def extract_product(product_data: GetProductResponse | Product) -> ProductInfo: product.symbol = product_data.base_currency_id or "" product.price = float(product_data.price) if product_data.price else 0.0 product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0.0 + product.provider = "Coinbase" return product def extract_price(candle_data: Candle) -> Price: diff --git a/src/app/api/markets/cryptocompare.py b/src/app/api/markets/cryptocompare.py index 64706a0..e07379b 100644 --- a/src/app/api/markets/cryptocompare.py +++ b/src/app/api/markets/cryptocompare.py @@ -11,6 +11,7 @@ def extract_product(asset_data: dict[str, Any]) -> ProductInfo: product.price = float(asset_data.get('PRICE', 0)) product.volume_24h = float(asset_data.get('VOLUME24HOUR', 0)) assert product.price > 0, "Invalid price data received from CryptoCompare" + product.provider = "CryptoCompare" return product def extract_price(price_data: dict[str, Any]) -> Price: diff --git a/src/app/api/markets/yfinance.py b/src/app/api/markets/yfinance.py index 23964d0..39d2e1d 100644 --- a/src/app/api/markets/yfinance.py +++ b/src/app/api/markets/yfinance.py @@ -13,6 +13,7 @@ def extract_product(stock_data: dict[str, str]) -> ProductInfo: product.price = float(stock_data.get('Current Stock Price', f"0.0 USD").split(" ")[0]) # prende solo il numero product.volume_24h = 0.0 # YFinance non fornisce il volume 24h direttamente product.currency = product.id.split('-')[1] # La valuta è la parte dopo il '-' + product.provider = "YFinance" return product def extract_price(hist_data: dict[str, str]) -> Price: diff --git a/src/app/api/tools/market_tool.py b/src/app/api/tools/market_tool.py index 409d380..5443fb9 100644 --- a/src/app/api/tools/market_tool.py +++ b/src/app/api/tools/market_tool.py @@ -33,6 +33,7 @@ class MarketAPIsTool(MarketWrapper, Toolkit): self.get_product, self.get_products, self.get_historical_prices, + self.get_product_aggregated, self.get_products_aggregated, self.get_historical_prices_aggregated, ], @@ -87,6 +88,36 @@ class MarketAPIsTool(MarketWrapper, Toolkit): """ return self.handler.try_call(lambda w: w.get_historical_prices(asset_id, limit)) + def get_product_aggregated(self, asset_id: str) -> ProductInfo: + """ + Gets product information for a *single* asset from *all available providers* and *aggregates* the results. + + This method queries all configured sources (Binance, YFinance, Coinbase, CryptoCompare) + and combines the data using volume-weighted average price (VWAP) to provide + the most accurate and comprehensive price data. + + Use this when you need highly reliable price data from multiple sources. + Warning: This uses more API calls (4x) than get_product(). + + Args: + asset_id (str): The asset ID to retrieve information for (e.g., "BTC", "ETH"). + + Returns: + ProductInfo: A single ProductInfo object with aggregated data from all providers. + The 'provider' field will list all sources used (e.g., "Binance, YFinance, Coinbase"). + + Raises: + Exception: If all providers fail to return results. + + Example: + >>> tool.get_product_aggregated("BTC") + ProductInfo(symbol="BTC", price=45123.50, provider="Binance, YFinance, Coinbase", ...) + """ + # try_call_all returns dict[str, ProductInfo] where key is provider name + # We need list[ProductInfo] for aggregation, so we extract values + all_products = self.handler.try_call_all(lambda w: w.get_product(asset_id)) + return ProductInfo.aggregate_single_asset(all_products) + def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]: """ Gets product information for multiple assets from *all available providers* and *aggregates* the results. @@ -107,7 +138,7 @@ class MarketAPIsTool(MarketWrapper, Toolkit): all_products: dict[str, list[ProductInfo]] = {} for asset in asset_ids: all_products[asset] = self.handler.try_call_all(lambda w: w.get_product(asset)) - return ProductInfo.aggregate(all_products) + return ProductInfo.aggregate_multi_assets(all_products) def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: """ diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index 644d107..720f668 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -7,13 +7,14 @@ from app.api.core.markets import ProductInfo, Price @pytest.mark.market class TestMarketDataAggregator: - def __product(self, symbol: str, price: float, volume: float, currency: str) -> ProductInfo: + def __product(self, symbol: str, price: float, volume: float, currency: str, provider: str = "") -> ProductInfo: prod = ProductInfo() - prod.id=f"{symbol}-{currency}" - prod.symbol=symbol - prod.price=price - prod.volume_24h=volume - prod.currency=currency + prod.id = f"{symbol}-{currency}" + prod.symbol = symbol + prod.price = price + prod.volume_24h = volume + prod.currency = currency + prod.provider = provider return prod def __price(self, timestamp_s: int, high: float, low: float, open: float, close: float, volume: float) -> Price: @@ -28,15 +29,13 @@ class TestMarketDataAggregator: def test_aggregate_product_info(self): products: dict[str, list[ProductInfo]] = { - "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], - "Provider2": [self.__product("BTC", 50100.0, 1100.0, "USD")], - "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD")], + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1")], + "Provider2": [self.__product("BTC", 50100.0, 1100.0, "USD", "Provider2")], + "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD", "Provider3")], } - aggregated = ProductInfo.aggregate(products) - assert len(aggregated) == 1 - - info = aggregated[0] + # aggregate_single_asset returns a single ProductInfo, not a list + info = ProductInfo.aggregate_single_asset(products) assert info is not None assert info.symbol == "BTC" @@ -48,16 +47,17 @@ class TestMarketDataAggregator: def test_aggregate_product_info_multiple_symbols(self): products = { "Provider1": [ - self.__product("BTC", 50000.0, 1000.0, "USD"), - self.__product("ETH", 4000.0, 2000.0, "USD"), + self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1"), + self.__product("ETH", 4000.0, 2000.0, "USD", "Provider1"), ], "Provider2": [ - self.__product("BTC", 50100.0, 1100.0, "USD"), - self.__product("ETH", 4050.0, 2100.0, "USD"), + self.__product("BTC", 50100.0, 1100.0, "USD", "Provider2"), + self.__product("ETH", 4050.0, 2100.0, "USD", "Provider2"), ], } - aggregated = ProductInfo.aggregate(products) + # aggregate_multi_assets aggregates by symbol across providers + aggregated = ProductInfo.aggregate_multi_assets(products) assert len(aggregated) == 2 btc_info = next((p for p in aggregated if p.symbol == "BTC"), None) @@ -80,15 +80,15 @@ class TestMarketDataAggregator: "Provider1": [], "Provider2": [], } - aggregated = ProductInfo.aggregate(products) + aggregated = ProductInfo.aggregate_multi_assets(products) assert len(aggregated) == 0 def test_aggregate_product_info_with_partial_data(self): products: dict[str, list[ProductInfo]] = { - "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1")], "Provider2": [], } - aggregated = ProductInfo.aggregate(products) + aggregated = ProductInfo.aggregate_multi_assets(products) assert len(aggregated) == 1 info = aggregated[0] assert info.symbol == "BTC" @@ -130,25 +130,165 @@ class TestMarketDataAggregator: def test_aggregate_product_info_different_currencies(self): products: dict[str, list[ProductInfo]] = { - "Provider1": [self.__product("BTC", 100000.0, 1000.0, "USD")], - "Provider2": [self.__product("BTC", 70000.0, 800.0, "EUR")], + "Provider1": [self.__product("BTC", 100000.0, 1000.0, "USD", "Provider1")], + "Provider2": [self.__product("BTC", 70000.0, 800.0, "EUR", "Provider2")], } - aggregated = ProductInfo.aggregate(products) + aggregated = ProductInfo.aggregate_multi_assets(products) assert len(aggregated) == 1 info = aggregated[0] assert info is not None assert info.id == "BTC_AGGREGATED" assert info.symbol == "BTC" - assert info.currency == "USD" - assert info.price == pytest.approx(100000.0, rel=1e-3) # type: ignore - assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore + assert info.currency in ["USD", "EUR"] # Can be either, depending on which is found first + # When aggregating different currencies, VWAP is calculated + # (100000.0 * 1000.0 + 70000.0 * 800.0) / (1000.0 + 800.0) + expected_price = (100000.0 * 1000.0 + 70000.0 * 800.0) / (1000.0 + 800.0) + assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(900.0, rel=1e-3) # type: ignore # Average of volumes - info = aggregated[1] + # ===== Tests for aggregate_single_asset ===== + + def test_aggregate_single_asset_from_dict(self): + """Test aggregate_single_asset with dict input (simulating WrapperHandler.try_call_all)""" + products_dict: dict[str, ProductInfo] = { + "BinanceWrapper": self.__product("BTC", 50000.0, 1000.0, "USD", "Binance"), + "YFinanceWrapper": self.__product("BTC", 50100.0, 1100.0, "USD", "YFinance"), + "CoinBaseWrapper": self.__product("BTC", 49900.0, 900.0, "USD", "Coinbase"), + } + + info = ProductInfo.aggregate_single_asset(products_dict) assert info is not None - assert info.id == "BTC-EUR_AGGREGATED" assert info.symbol == "BTC" - assert info.currency == "EUR" - assert info.price == pytest.approx(70000.0, rel=1e-3) # type: ignore - assert info.volume_24h == pytest.approx(800.0, rel=1e-3) # type: ignore + assert info.id == "BTC_AGGREGATED" + assert "Binance" in info.provider + assert "YFinance" in info.provider + assert "Coinbase" in info.provider + + # VWAP calculation + expected_price = (50000.0 * 1000.0 + 50100.0 * 1100.0 + 49900.0 * 900.0) / (1000.0 + 1100.0 + 900.0) + assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore + assert info.currency == "USD" + + def test_aggregate_single_asset_from_list(self): + """Test aggregate_single_asset with list input""" + products_list = [ + self.__product("ETH", 4000.0, 2000.0, "USD", "Binance"), + self.__product("ETH", 4050.0, 2100.0, "USD", "Coinbase"), + ] + + info = ProductInfo.aggregate_single_asset(products_list) + assert info is not None + assert info.symbol == "ETH" + assert info.id == "ETH_AGGREGATED" + assert "Binance" in info.provider + assert "Coinbase" in info.provider + + expected_price = (4000.0 * 2000.0 + 4050.0 * 2100.0) / (2000.0 + 2100.0) + assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore + + def test_aggregate_single_asset_no_volume_fallback(self): + """Test fallback to simple average when no volume data""" + products_list = [ + self.__product("SOL", 100.0, 0.0, "USD", "Provider1"), + self.__product("SOL", 110.0, 0.0, "USD", "Provider2"), + self.__product("SOL", 90.0, 0.0, "USD", "Provider3"), + ] + + info = ProductInfo.aggregate_single_asset(products_list) + assert info is not None + assert info.symbol == "SOL" + # Simple average: (100 + 110 + 90) / 3 = 100 + assert info.price == pytest.approx(100.0, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(0.0, rel=1e-3) # type: ignore + + def test_aggregate_single_asset_empty_raises(self): + """Test that empty input raises ValueError""" + with pytest.raises(ValueError, match="requires at least one ProductInfo"): + ProductInfo.aggregate_single_asset([]) + + with pytest.raises(ValueError, match="requires at least one ProductInfo"): + ProductInfo.aggregate_single_asset({}) + + def test_aggregate_single_asset_dict_with_lists(self): + """Test aggregate_single_asset with dict[str, list[ProductInfo]] (flattens correctly)""" + products_dict_lists: dict[str, list[ProductInfo]] = { + "Provider1": [self.__product("ADA", 0.50, 1000.0, "USD", "Provider1")], + "Provider2": [self.__product("ADA", 0.52, 1200.0, "USD", "Provider2")], + } + + info = ProductInfo.aggregate_single_asset(products_dict_lists) + assert info is not None + assert info.symbol == "ADA" + expected_price = (0.50 * 1000.0 + 0.52 * 1200.0) / (1000.0 + 1200.0) + assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore + + def test_aggregate_single_asset_missing_currency(self): + """Test that aggregate_single_asset handles missing currency gracefully""" + products_list = [ + self.__product("DOT", 10.0, 500.0, "", "Provider1"), + self.__product("DOT", 10.5, 600.0, "USD", "Provider2"), + ] + + info = ProductInfo.aggregate_single_asset(products_list) + assert info is not None + assert info.symbol == "DOT" + assert info.currency == "USD" # Should pick the first non-empty currency + + def test_aggregate_single_asset_single_provider(self): + """Test aggregate_single_asset with only one provider (edge case)""" + products = { + "BinanceWrapper": self.__product("MATIC", 0.80, 5000.0, "USD", "Binance"), + } + + info = ProductInfo.aggregate_single_asset(products) + assert info is not None + assert info.symbol == "MATIC" + assert info.price == pytest.approx(0.80, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(5000.0, rel=1e-3) # type: ignore + assert info.provider == "Binance" + + # ===== Tests for aggregate_multi_assets with edge cases ===== + + def test_aggregate_multi_assets_empty_providers(self): + """Test aggregate_multi_assets with some providers returning empty lists""" + products = { + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1")], + "Provider2": [], + "Provider3": [self.__product("BTC", 50100.0, 1100.0, "USD", "Provider3")], + } + + aggregated = ProductInfo.aggregate_multi_assets(products) + assert len(aggregated) == 1 + info = aggregated[0] + assert info.symbol == "BTC" + assert "Provider1" in info.provider + assert "Provider3" in info.provider + + def test_aggregate_multi_assets_mixed_symbols(self): + """Test that aggregate_multi_assets correctly separates different symbols""" + products = { + "Provider1": [ + self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1"), + self.__product("ETH", 4000.0, 2000.0, "USD", "Provider1"), + self.__product("SOL", 100.0, 500.0, "USD", "Provider1"), + ], + "Provider2": [ + self.__product("BTC", 50100.0, 1100.0, "USD", "Provider2"), + self.__product("ETH", 4050.0, 2100.0, "USD", "Provider2"), + ], + } + + aggregated = ProductInfo.aggregate_multi_assets(products) + assert len(aggregated) == 3 + + symbols = {p.symbol for p in aggregated} + assert symbols == {"BTC", "ETH", "SOL"} + + btc = next(p for p in aggregated if p.symbol == "BTC") + assert "Provider1" in btc.provider and "Provider2" in btc.provider + + sol = next(p for p in aggregated if p.symbol == "SOL") + assert sol.provider == "Provider1" # Only one provider -- 2.49.1 From 14b20ed07d165ae6fe857703b4e49b60e7c3b35c Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Wed, 29 Oct 2025 17:58:01 +0100 Subject: [PATCH 04/12] refactor: improve documentation and error handling in crypto symbols tools --- configs.yaml.example | 2 - src/app/agents/plan_memory_tool.py | 13 +++++ src/app/agents/prompts/team_leader.md | 14 ++++-- src/app/api/tools/symbols_tool.py | 71 +++++++++++++++++++-------- 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/configs.yaml.example b/configs.yaml.example index 9591519..49ed160 100644 --- a/configs.yaml.example +++ b/configs.yaml.example @@ -27,8 +27,6 @@ models: - name: mistral-large-latest label: Mistral ollama: - - name: gpt-oss:latest - label: Ollama GPT - name: qwen3:8b label: Qwen 3 (8B) - name: qwen3:4b diff --git a/src/app/agents/plan_memory_tool.py b/src/app/agents/plan_memory_tool.py index 93bbda5..d30573d 100644 --- a/src/app/agents/plan_memory_tool.py +++ b/src/app/agents/plan_memory_tool.py @@ -4,6 +4,18 @@ from typing import TypedDict, Literal class Task(TypedDict): + """ + Represents a single task in the execution plan. + + Attributes: + name (str): The unique name of the task. + status (Literal["pending", "completed", "failed"]): The current status of the task. + - "pending": The task is yet to be executed. + - "completed": The task has been successfully executed. + - "failed": The task execution was unsuccessful. + result (str | None): An optional field to store the result or outcome of the task. + This could be a summary, an error message, or any relevant information. + """ name: str status: Literal["pending", "completed", "failed"] result: str | None @@ -13,6 +25,7 @@ class PlanMemoryTool(Toolkit): def __init__(self): self.tasks: list[Task] = [] Toolkit.__init__(self, # type: ignore[call-arg] + name="Plan Memory Tool", instructions="Provides stateful, persistent memory for the Team Leader. " \ "This is your primary to-do list and state tracker. " \ "Use it to create, execute step-by-step, and record the results of your execution plan.", diff --git a/src/app/agents/prompts/team_leader.md b/src/app/agents/prompts/team_leader.md index dc609d2..8b17e3c 100644 --- a/src/app/agents/prompts/team_leader.md +++ b/src/app/agents/prompts/team_leader.md @@ -18,9 +18,17 @@ You orchestrate data retrieval and synthesis using a tool-driven execution plan. - **NewsAgent**: Live news articles with sentiment analysis (NewsAPI, GoogleNews, CryptoPanic) - **SocialAgent**: Current social media discussions (Reddit, X, 4chan) -**YOUR PERSONAL TOOLS (FOR PLANNING & SYNTHESIS):** - - **PlanMemoryTool**: MUST be used to manage your execution plan. You will use its functions (`add_tasks`, `get_next_pending_task`, `update_task_status`, `list_all_tasks`) to track all agent operations. This is your stateful memory. - - **ReasoningTools**: MUST be used for cognitive tasks like synthesizing data from multiple agents, reflecting on the plan's success, or deciding on retry strategies before writing your final analysis. +**YOUR PERSONAL TOOLS (FOR PLANNING, SYNTHESIS & UTILITIES):** +*The framework will provide you with the exact functions for these tools. Your job is to use them according to these strategies.* + +- **Planning & State (`PlanMemoryTool`)**: + This is your stateful memory. You MUST use it to build your plan (`add_tasks`) *before* delegating, execute the plan step-by-step (`get_next_pending_task`), and record all outcomes (`update_task_status`). + +- **Cognition & Synthesis (`ReasoningTools`)**: + You MUST use this tool to reflect on the data gathered from your team and to synthesize the `Analysis` sections of your final report. + +- **Data Utilities (`CryptoSymbolsTools`)**: + You MUST use this tool to find the correct ticker (e.g., "BTC-USD") when the user asks for a name (e.g., "Bitcoin"). Do this *before* you create a task for the `MarketAgent`. **AGENT OUTPUT SCHEMAS (MANDATORY REFERENCE):** You MUST parse the exact structures your agents provide: diff --git a/src/app/api/tools/symbols_tool.py b/src/app/api/tools/symbols_tool.py index 90cee7e..9bf03e4 100644 --- a/src/app/api/tools/symbols_tool.py +++ b/src/app/api/tools/symbols_tool.py @@ -13,48 +13,79 @@ logging = logging.getLogger("crypto_symbols") BASE_URL = "https://finance.yahoo.com/markets/crypto/all/" + class CryptoSymbolsTools(Toolkit): """ - Classe per ottenere i simboli delle criptovalute tramite Yahoo Finance. + Class for obtaining cryptocurrency symbols via Yahoo Finance. + (This class-level docstring is for developers). """ def __init__(self, cache_file: str = 'resources/cryptos.csv'): self.cache_file = cache_file - self.final_table = pd.read_csv(self.cache_file) if os.path.exists(self.cache_file) else pd.DataFrame() # type: ignore - Toolkit.__init__(self, # type: ignore - name="Crypto Symbols Tool", - instructions="Tool to get cryptocurrency symbols and search them by name.", - tools=[ - self.get_all_symbols, - self.get_symbols_by_name, - ], - ) + try: + self.final_table = pd.read_csv(self.cache_file) if os.path.exists(self.cache_file) else pd.DataFrame( + columns=['Symbol', 'Name']) + except Exception: + self.final_table = pd.DataFrame(columns=['Symbol', 'Name']) + + Toolkit.__init__(self, # type: ignore + name="Crypto Symbols Tool", + instructions="A utility tool to find and verify the correct cryptocurrency symbols (tickers). " \ + "Use this to translate a cryptocurrency name (e.g., 'Bitcoin') into its official symbol " \ + "(e.g., 'BTC-USD') *before* delegating tasks to the MarketAgent.", + tools=[ + self.get_all_symbols, + self.get_symbols_by_name, + ], + ) def get_all_symbols(self) -> list[str]: """ - Restituisce tutti i simboli delle criptovalute. + Returns a complete list of all available cryptocurrency symbols (tickers). + + Warning: This list can be very long. Prefer 'get_symbols_by_name' + if you are searching for a specific asset. + Returns: - list[str]: Lista di tutti i simboli delle criptovalute. + list[str]: A comprehensive list of all supported crypto symbols (e.g., "BTC-USD", "ETH-USD"). """ return self.final_table['Symbol'].tolist() if not self.final_table.empty else [] def get_symbols_by_name(self, query: str) -> list[tuple[str, str]]: """ - Cerca i simboli che contengono la query. + Searches the cryptocurrency database for assets matching a name or symbol. + + Use this to find the exact, correct symbol for a cryptocurrency name. + (e.g., query="Bitcoin" might return [("BTC-USD", "Bitcoin USD")]). + Args: - query (str): Query di ricerca. + query (str): The name, partial name, or symbol to search for (e.g., "Bitcoin", "ETH"). + Returns: - list[tuple[str, str]]: Lista di tuple (simbolo, nome) che contengono la query. + list[tuple[str, str]]: A list of tuples, where each tuple contains + the (symbol, full_name) of a matching asset. + Returns an empty list if no matches are found. """ - query_lower = query.lower() - positions = self.final_table['Name'].str.lower().str.contains(query_lower) - return self.final_table[positions][['Symbol', 'Name']].apply(tuple, axis=1).tolist() + if self.final_table.empty or 'Name' not in self.final_table.columns or 'Symbol' not in self.final_table.columns: + return [] + + try: + # Cerca sia nel nome che nel simbolo, ignorando maiuscole/minuscole + mask = self.final_table['Name'].str.contains(query, case=False, na=False) | \ + self.final_table['Symbol'].str.contains(query, case=False, na=False) + + filtered_df = self.final_table[mask] + + # Converte il risultato in una lista di tuple + return list(zip(filtered_df['Symbol'], filtered_df['Name'])) + except Exception: + return [] async def fetch_crypto_symbols(self, force_refresh: bool = False) -> None: """ - Recupera tutti i simboli delle criptovalute da Yahoo Finance e li memorizza in cache. + It retrieves all cryptocurrency symbols from Yahoo Finance and caches them. Args: - force_refresh (bool): Se True, forza il recupero anche se i dati sono già in cache. + force_refresh (bool): If True, it forces the retrieval even if the data are already in the cache. """ if not force_refresh and not self.final_table.empty: return -- 2.49.1 From 83363f1b7572981a71ea8e79fbfc1879f0f85d63 Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Thu, 30 Oct 2025 14:56:42 +0100 Subject: [PATCH 05/12] modifica aggregazione --- src/app/api/core/markets.py | 53 ++++++++++++++------------- tests/utils/test_market_aggregator.py | 14 +++---- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/app/api/core/markets.py b/src/app/api/core/markets.py index 34c788e..e5fe647 100644 --- a/src/app/api/core/markets.py +++ b/src/app/api/core/markets.py @@ -34,33 +34,28 @@ class ProductInfo(BaseModel): product.provider = provider_name symbols_infos.setdefault(product.symbol, []).append(product) - # Aggregazione per ogni symbol + # Aggregazione per ogni symbol usando aggregate_single_asset aggregated_products: list[ProductInfo] = [] for symbol, product_list in symbols_infos.items(): - product = ProductInfo() - - product.id = f"{symbol}_AGGREGATED" - product.symbol = symbol - product.currency = next((p.currency for p in product_list if p.currency), "") - - # Raccogliamo i provider che hanno fornito dati - providers = [p.provider for p in product_list if p.provider] - product.provider = ", ".join(set(providers)) if providers else "AGGREGATED" - - # Calcolo del volume medio - volume_sum = sum(p.volume_24h for p in product_list if p.volume_24h > 0) - product.volume_24h = volume_sum / len(product_list) if product_list else 0.0 - - # Calcolo del prezzo pesato per volume (VWAP - Volume Weighted Average Price) - if volume_sum > 0: - prices_weighted = sum(p.price * p.volume_24h for p in product_list if p.volume_24h > 0) - product.price = prices_weighted / volume_sum - else: - # Se non c'è volume, facciamo una media semplice dei prezzi - valid_prices = [p.price for p in product_list if p.price > 0] - product.price = sum(valid_prices) / len(valid_prices) if valid_prices else 0.0 - - aggregated_products.append(product) + try: + # Usa aggregate_single_asset per aggregare ogni simbolo + aggregated = ProductInfo.aggregate_single_asset(product_list) + + # aggregate_single_asset calcola il volume medio, ma per multi_assets + # vogliamo il volume totale. Ricalcoliamo il volume come somma dopo il filtro USD + # Dobbiamo rifare il filtro USD per contare correttamente + currencies = set(p.currency for p in product_list if p.currency) + if len(currencies) > 1: + product_list = [p for p in product_list if p.currency.upper() == "USD"] + + # Volume totale + aggregated.volume_24h = sum(p.volume_24h for p in product_list if p.volume_24h > 0) + + aggregated_products.append(aggregated) + except ValueError: + # Se aggregate_single_asset fallisce (es. no USD when currencies differ), salta + continue + return aggregated_products @staticmethod @@ -100,6 +95,14 @@ class ProductInfo(BaseModel): if not assets_list: raise ValueError("aggregate_single_asset requires at least one ProductInfo") + # Controllo valuta: se non sono tutte uguali, filtra solo USD + currencies = set(p.currency for p in assets_list if p.currency) + if len(currencies) > 1: + # Valute diverse: filtra solo USD + assets_list = [p for p in assets_list if p.currency.upper() == "USD"] + if not assets_list: + raise ValueError("aggregate_single_asset: no USD products available when currencies differ") + # Aggregazione per ogni Exchange aggregated: ProductInfo = ProductInfo() first = assets_list[0] diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index 720f668..93f96fa 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -66,13 +66,13 @@ class TestMarketDataAggregator: assert btc_info is not None avg_weighted_price_btc = (50000.0 * 1000.0 + 50100.0 * 1100.0) / (1000.0 + 1100.0) assert btc_info.price == pytest.approx(avg_weighted_price_btc, rel=1e-3) # type: ignore - assert btc_info.volume_24h == pytest.approx(1050.0, rel=1e-3) # type: ignore + assert btc_info.volume_24h == pytest.approx(2100.0, rel=1e-3) # type: ignore # Total volume (1000 + 1100) assert btc_info.currency == "USD" assert eth_info is not None avg_weighted_price_eth = (4000.0 * 2000.0 + 4050.0 * 2100.0) / (2000.0 + 2100.0) assert eth_info.price == pytest.approx(avg_weighted_price_eth, rel=1e-3) # type: ignore - assert eth_info.volume_24h == pytest.approx(2050.0, rel=1e-3) # type: ignore + assert eth_info.volume_24h == pytest.approx(4100.0, rel=1e-3) # type: ignore # Total volume (2000 + 2100) assert eth_info.currency == "USD" def test_aggregate_product_info_with_no_data(self): @@ -141,12 +141,10 @@ class TestMarketDataAggregator: assert info is not None assert info.id == "BTC_AGGREGATED" assert info.symbol == "BTC" - assert info.currency in ["USD", "EUR"] # Can be either, depending on which is found first - # When aggregating different currencies, VWAP is calculated - # (100000.0 * 1000.0 + 70000.0 * 800.0) / (1000.0 + 800.0) - expected_price = (100000.0 * 1000.0 + 70000.0 * 800.0) / (1000.0 + 800.0) - assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore - assert info.volume_24h == pytest.approx(900.0, rel=1e-3) # type: ignore # Average of volumes + assert info.currency == "USD" # Only USD products are kept + # When currencies differ, only USD is aggregated (only Provider1 in this case) + assert info.price == pytest.approx(100000.0, rel=1e-3) # type: ignore + assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore # Only USD volume # ===== Tests for aggregate_single_asset ===== -- 2.49.1 From 968c137a5aef5656c8825750671e47fe78f0420e Mon Sep 17 00:00:00 2001 From: Berack96 Date: Sat, 1 Nov 2025 22:55:47 +0100 Subject: [PATCH 06/12] revert unnecessary modifiction on files --- configs.yaml.example | 2 ++ src/app/agents/prompts/team_leader.md | 14 ++------ src/app/agents/prompts/team_market.md | 51 +++------------------------ 3 files changed, 10 insertions(+), 57 deletions(-) diff --git a/configs.yaml.example b/configs.yaml.example index 49ed160..9591519 100644 --- a/configs.yaml.example +++ b/configs.yaml.example @@ -27,6 +27,8 @@ models: - name: mistral-large-latest label: Mistral ollama: + - name: gpt-oss:latest + label: Ollama GPT - name: qwen3:8b label: Qwen 3 (8B) - name: qwen3:4b diff --git a/src/app/agents/prompts/team_leader.md b/src/app/agents/prompts/team_leader.md index 8b17e3c..dc609d2 100644 --- a/src/app/agents/prompts/team_leader.md +++ b/src/app/agents/prompts/team_leader.md @@ -18,17 +18,9 @@ You orchestrate data retrieval and synthesis using a tool-driven execution plan. - **NewsAgent**: Live news articles with sentiment analysis (NewsAPI, GoogleNews, CryptoPanic) - **SocialAgent**: Current social media discussions (Reddit, X, 4chan) -**YOUR PERSONAL TOOLS (FOR PLANNING, SYNTHESIS & UTILITIES):** -*The framework will provide you with the exact functions for these tools. Your job is to use them according to these strategies.* - -- **Planning & State (`PlanMemoryTool`)**: - This is your stateful memory. You MUST use it to build your plan (`add_tasks`) *before* delegating, execute the plan step-by-step (`get_next_pending_task`), and record all outcomes (`update_task_status`). - -- **Cognition & Synthesis (`ReasoningTools`)**: - You MUST use this tool to reflect on the data gathered from your team and to synthesize the `Analysis` sections of your final report. - -- **Data Utilities (`CryptoSymbolsTools`)**: - You MUST use this tool to find the correct ticker (e.g., "BTC-USD") when the user asks for a name (e.g., "Bitcoin"). Do this *before* you create a task for the `MarketAgent`. +**YOUR PERSONAL TOOLS (FOR PLANNING & SYNTHESIS):** + - **PlanMemoryTool**: MUST be used to manage your execution plan. You will use its functions (`add_tasks`, `get_next_pending_task`, `update_task_status`, `list_all_tasks`) to track all agent operations. This is your stateful memory. + - **ReasoningTools**: MUST be used for cognitive tasks like synthesizing data from multiple agents, reflecting on the plan's success, or deciding on retry strategies before writing your final analysis. **AGENT OUTPUT SCHEMAS (MANDATORY REFERENCE):** You MUST parse the exact structures your agents provide: diff --git a/src/app/agents/prompts/team_market.md b/src/app/agents/prompts/team_market.md index 1d4465b..93e6c24 100644 --- a/src/app/agents/prompts/team_market.md +++ b/src/app/agents/prompts/team_market.md @@ -17,52 +17,11 @@ - **Interval**: Determine granularity (hourly, daily, weekly) from context - **Defaults**: If not specified, use current price or last 24h data -**AVAILABLE TOOLS (6 total):** - -**Single-Source Tools (FAST - use first available provider):** -1. `get_product(asset_id: str)` → ProductInfo - - Fetches current price for ONE asset from the first available provider - - Example: `get_product("BTC")` → returns BTC price from Binance/YFinance/Coinbase/CryptoCompare - - Use for: Quick single asset lookup - -2. `get_products(asset_ids: list[str])` → list[ProductInfo] - - Fetches current prices for MULTIPLE assets from the first available provider - - Example: `get_products(["BTC", "ETH", "SOL"])` → returns 3 prices from same provider - - Use for: Quick multi-asset lookup - -3. `get_historical_prices(asset_id: str, limit: int = 100)` → list[Price] - - Fetches historical price data for ONE asset from the first available provider - - Example: `get_historical_prices("BTC", limit=30)` → last 30 price points - - Use for: Quick historical data lookup - -**Multi-Source Aggregated Tools (COMPREHENSIVE - queries ALL providers and merges results):** -4. `get_product_aggregated(asset_id: str)` → ProductInfo - - Queries ALL providers (Binance, YFinance, Coinbase, CryptoCompare) for ONE asset and aggregates - - Returns most reliable price using volume-weighted average (VWAP) - - Example: `get_product_aggregated("BTC")` → BTC price from all 4 providers, merged - - Use for: When user requests "reliable", "accurate", or "comprehensive" data for ONE asset - - Warning: Uses more API calls (4x) - -5. `get_products_aggregated(asset_ids: list[str])` → list[ProductInfo] - - Queries ALL providers for MULTIPLE assets and aggregates results - - Returns more reliable data with multiple sources and confidence scores - - Example: `get_products_aggregated(["BTC", "ETH"])` → prices from all 4 providers, merged - - Use for: When user requests "comprehensive" or "detailed" data for MULTIPLE assets - - Warning: Uses more API calls (4x per asset) - -6. `get_historical_prices_aggregated(asset_id: str = "BTC", limit: int = 100)` → list[Price] - - Queries ALL providers for historical data and aggregates results - - Returns more complete historical dataset with multiple sources - - Example: `get_historical_prices_aggregated("BTC", limit=50)` → 50 points from each provider - - Use for: When user requests "comprehensive" or "detailed" historical analysis - - Warning: Uses more API calls (4x) - -**TOOL SELECTION STRATEGY:** -- **Simple queries** ("What's BTC price?") → Use `get_product()` (tool #1) -- **Reliable single asset** ("Get me the most accurate BTC price") → Use `get_product_aggregated()` (tool #4) -- **Multiple assets quick** ("Compare BTC, ETH prices") → Use `get_products()` (tool #2) -- **Multiple assets comprehensive** ("Detailed analysis of BTC and ETH") → Use `get_products_aggregated()` (tool #5) -- **Historical data** → Specify appropriate `limit` parameter (7 for week, 30 for month, etc.) +**TOOL DESCRIPTIONS:** +- get_product: Fetches current price for a specific cryptocurrency from a single source. +- get_historical_price: Retrieves historical price data for a cryptocurrency over a specified time range from a single source. +- get_products_aggregated: Fetches current prices by aggregating data from multiple sources. Use this if user requests more specific or reliable data. +- get_historical_prices_aggregated: Retrieves historical price data by aggregating multiple sources. Use this if user requests more specific or reliable data. **OUTPUT FORMAT JSON:** -- 2.49.1 From c66332f2402d5e727cd104ce4976f5eb773da107 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Sat, 1 Nov 2025 22:58:11 +0100 Subject: [PATCH 07/12] revert modification on plan_mem_tool --- src/app/agents/plan_memory_tool.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/app/agents/plan_memory_tool.py b/src/app/agents/plan_memory_tool.py index d30573d..93bbda5 100644 --- a/src/app/agents/plan_memory_tool.py +++ b/src/app/agents/plan_memory_tool.py @@ -4,18 +4,6 @@ from typing import TypedDict, Literal class Task(TypedDict): - """ - Represents a single task in the execution plan. - - Attributes: - name (str): The unique name of the task. - status (Literal["pending", "completed", "failed"]): The current status of the task. - - "pending": The task is yet to be executed. - - "completed": The task has been successfully executed. - - "failed": The task execution was unsuccessful. - result (str | None): An optional field to store the result or outcome of the task. - This could be a summary, an error message, or any relevant information. - """ name: str status: Literal["pending", "completed", "failed"] result: str | None @@ -25,7 +13,6 @@ class PlanMemoryTool(Toolkit): def __init__(self): self.tasks: list[Task] = [] Toolkit.__init__(self, # type: ignore[call-arg] - name="Plan Memory Tool", instructions="Provides stateful, persistent memory for the Team Leader. " \ "This is your primary to-do list and state tracker. " \ "Use it to create, execute step-by-step, and record the results of your execution plan.", -- 2.49.1 From 6e2203f984639f14875df214eb5d0f28730b4012 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Sat, 1 Nov 2025 23:03:16 +0100 Subject: [PATCH 08/12] fix symbol tool unnecessary mods --- src/app/api/tools/symbols_tool.py | 52 +++++++++---------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/src/app/api/tools/symbols_tool.py b/src/app/api/tools/symbols_tool.py index 9bf03e4..f183d74 100644 --- a/src/app/api/tools/symbols_tool.py +++ b/src/app/api/tools/symbols_tool.py @@ -13,38 +13,27 @@ logging = logging.getLogger("crypto_symbols") BASE_URL = "https://finance.yahoo.com/markets/crypto/all/" - class CryptoSymbolsTools(Toolkit): """ Class for obtaining cryptocurrency symbols via Yahoo Finance. - (This class-level docstring is for developers). """ def __init__(self, cache_file: str = 'resources/cryptos.csv'): self.cache_file = cache_file - try: - self.final_table = pd.read_csv(self.cache_file) if os.path.exists(self.cache_file) else pd.DataFrame( - columns=['Symbol', 'Name']) - except Exception: - self.final_table = pd.DataFrame(columns=['Symbol', 'Name']) - + self.final_table = pd.read_csv(self.cache_file) if os.path.exists(self.cache_file) else pd.DataFrame() # type: ignore Toolkit.__init__(self, # type: ignore - name="Crypto Symbols Tool", - instructions="A utility tool to find and verify the correct cryptocurrency symbols (tickers). " \ - "Use this to translate a cryptocurrency name (e.g., 'Bitcoin') into its official symbol " \ - "(e.g., 'BTC-USD') *before* delegating tasks to the MarketAgent.", - tools=[ - self.get_all_symbols, - self.get_symbols_by_name, - ], - ) + name="Crypto Symbols Tool", + instructions="Tool to get cryptocurrency symbols and search them by name.", + tools=[ + self.get_all_symbols, + self.get_symbols_by_name, + ], + ) def get_all_symbols(self) -> list[str]: """ Returns a complete list of all available cryptocurrency symbols (tickers). - - Warning: This list can be very long. Prefer 'get_symbols_by_name' - if you are searching for a specific asset. + The list could be very long, prefer using 'get_symbols_by_name' for specific searches. Returns: list[str]: A comprehensive list of all supported crypto symbols (e.g., "BTC-USD", "ETH-USD"). @@ -54,32 +43,19 @@ class CryptoSymbolsTools(Toolkit): def get_symbols_by_name(self, query: str) -> list[tuple[str, str]]: """ Searches the cryptocurrency database for assets matching a name or symbol. - Use this to find the exact, correct symbol for a cryptocurrency name. - (e.g., query="Bitcoin" might return [("BTC-USD", "Bitcoin USD")]). - Args: query (str): The name, partial name, or symbol to search for (e.g., "Bitcoin", "ETH"). - Returns: list[tuple[str, str]]: A list of tuples, where each tuple contains the (symbol, full_name) of a matching asset. Returns an empty list if no matches are found. """ - if self.final_table.empty or 'Name' not in self.final_table.columns or 'Symbol' not in self.final_table.columns: - return [] - - try: - # Cerca sia nel nome che nel simbolo, ignorando maiuscole/minuscole - mask = self.final_table['Name'].str.contains(query, case=False, na=False) | \ - self.final_table['Symbol'].str.contains(query, case=False, na=False) - - filtered_df = self.final_table[mask] - - # Converte il risultato in una lista di tuple - return list(zip(filtered_df['Symbol'], filtered_df['Name'])) - except Exception: - return [] + query_lower = query.lower() + positions = self.final_table['Name'].str.lower().str.contains(query_lower) | \ + self.final_table['Symbol'].str.lower().str.contains(query_lower) + filtered_df = self.final_table[positions] + return list(zip(filtered_df['Symbol'], filtered_df['Name'])) async def fetch_crypto_symbols(self, force_refresh: bool = False) -> None: """ -- 2.49.1 From 192adec7d016e9782a14bf9031a5695c3954755e Mon Sep 17 00:00:00 2001 From: Berack96 Date: Sat, 1 Nov 2025 23:06:31 +0100 Subject: [PATCH 09/12] fix market tool --- src/app/api/tools/market_tool.py | 16 ++-------------- src/app/api/tools/symbols_tool.py | 2 +- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/app/api/tools/market_tool.py b/src/app/api/tools/market_tool.py index 5443fb9..f8dbb68 100644 --- a/src/app/api/tools/market_tool.py +++ b/src/app/api/tools/market_tool.py @@ -95,9 +95,6 @@ class MarketAPIsTool(MarketWrapper, Toolkit): This method queries all configured sources (Binance, YFinance, Coinbase, CryptoCompare) and combines the data using volume-weighted average price (VWAP) to provide the most accurate and comprehensive price data. - - Use this when you need highly reliable price data from multiple sources. - Warning: This uses more API calls (4x) than get_product(). Args: asset_id (str): The asset ID to retrieve information for (e.g., "BTC", "ETH"). @@ -108,15 +105,8 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Raises: Exception: If all providers fail to return results. - - Example: - >>> tool.get_product_aggregated("BTC") - ProductInfo(symbol="BTC", price=45123.50, provider="Binance, YFinance, Coinbase", ...) """ - # try_call_all returns dict[str, ProductInfo] where key is provider name - # We need list[ProductInfo] for aggregation, so we extract values - all_products = self.handler.try_call_all(lambda w: w.get_product(asset_id)) - return ProductInfo.aggregate_single_asset(all_products) + return self.get_products_aggregated([asset_id])[0] def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]: """ @@ -135,9 +125,7 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Raises: Exception: If all providers fail to return results. """ - all_products: dict[str, list[ProductInfo]] = {} - for asset in asset_ids: - all_products[asset] = self.handler.try_call_all(lambda w: w.get_product(asset)) + all_products = self.handler.try_call_all(lambda w: w.get_products(asset_ids)) return ProductInfo.aggregate_multi_assets(all_products) def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: diff --git a/src/app/api/tools/symbols_tool.py b/src/app/api/tools/symbols_tool.py index f183d74..6aef344 100644 --- a/src/app/api/tools/symbols_tool.py +++ b/src/app/api/tools/symbols_tool.py @@ -21,7 +21,7 @@ class CryptoSymbolsTools(Toolkit): def __init__(self, cache_file: str = 'resources/cryptos.csv'): self.cache_file = cache_file self.final_table = pd.read_csv(self.cache_file) if os.path.exists(self.cache_file) else pd.DataFrame() # type: ignore - Toolkit.__init__(self, # type: ignore + Toolkit.__init__(self, # type: ignore name="Crypto Symbols Tool", instructions="Tool to get cryptocurrency symbols and search them by name.", tools=[ -- 2.49.1 From 3327bf8127b25b68cac76adec9545553c42ec7a3 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Sat, 1 Nov 2025 23:07:38 +0100 Subject: [PATCH 10/12] rm provider from providers --- src/app/api/markets/binance.py | 1 - src/app/api/markets/coinbase.py | 1 - src/app/api/markets/cryptocompare.py | 1 - src/app/api/markets/yfinance.py | 1 - 4 files changed, 4 deletions(-) diff --git a/src/app/api/markets/binance.py b/src/app/api/markets/binance.py index 9d0ed58..4d892e5 100644 --- a/src/app/api/markets/binance.py +++ b/src/app/api/markets/binance.py @@ -11,7 +11,6 @@ def extract_product(currency: str, ticker_data: dict[str, Any]) -> ProductInfo: product.price = float(ticker_data.get('price', 0)) product.volume_24h = float(ticker_data.get('volume', 0)) product.currency = currency - product.provider = "Binance" return product def extract_price(kline_data: list[Any]) -> Price: diff --git a/src/app/api/markets/coinbase.py b/src/app/api/markets/coinbase.py index fbe0a55..0115238 100644 --- a/src/app/api/markets/coinbase.py +++ b/src/app/api/markets/coinbase.py @@ -12,7 +12,6 @@ def extract_product(product_data: GetProductResponse | Product) -> ProductInfo: product.symbol = product_data.base_currency_id or "" product.price = float(product_data.price) if product_data.price else 0.0 product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0.0 - product.provider = "Coinbase" return product def extract_price(candle_data: Candle) -> Price: diff --git a/src/app/api/markets/cryptocompare.py b/src/app/api/markets/cryptocompare.py index e07379b..64706a0 100644 --- a/src/app/api/markets/cryptocompare.py +++ b/src/app/api/markets/cryptocompare.py @@ -11,7 +11,6 @@ def extract_product(asset_data: dict[str, Any]) -> ProductInfo: product.price = float(asset_data.get('PRICE', 0)) product.volume_24h = float(asset_data.get('VOLUME24HOUR', 0)) assert product.price > 0, "Invalid price data received from CryptoCompare" - product.provider = "CryptoCompare" return product def extract_price(price_data: dict[str, Any]) -> Price: diff --git a/src/app/api/markets/yfinance.py b/src/app/api/markets/yfinance.py index 39d2e1d..23964d0 100644 --- a/src/app/api/markets/yfinance.py +++ b/src/app/api/markets/yfinance.py @@ -13,7 +13,6 @@ def extract_product(stock_data: dict[str, str]) -> ProductInfo: product.price = float(stock_data.get('Current Stock Price', f"0.0 USD").split(" ")[0]) # prende solo il numero product.volume_24h = 0.0 # YFinance non fornisce il volume 24h direttamente product.currency = product.id.split('-')[1] # La valuta è la parte dopo il '-' - product.provider = "YFinance" return product def extract_price(hist_data: dict[str, str]) -> Price: -- 2.49.1 From 30ddb76df72f58d1db042731f350db094ea9cf21 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Sun, 2 Nov 2025 00:01:48 +0100 Subject: [PATCH 11/12] simplified aggregation logic --- src/app/api/core/markets.py | 131 ++++------------- src/app/api/tools/market_tool.py | 2 +- tests/utils/test_market_aggregator.py | 201 ++++++++------------------ 3 files changed, 93 insertions(+), 241 deletions(-) diff --git a/src/app/api/core/markets.py b/src/app/api/core/markets.py index e5fe647..a1b1b79 100644 --- a/src/app/api/core/markets.py +++ b/src/app/api/core/markets.py @@ -16,118 +16,51 @@ class ProductInfo(BaseModel): provider: str = "" @staticmethod - def aggregate_multi_assets(products: dict[str, list['ProductInfo']]) -> list['ProductInfo']: + def aggregate(products: dict[str, list['ProductInfo']], filter_currency: str="USD") -> list['ProductInfo']: """ - Aggregates a list of ProductInfo by symbol across different providers. + Aggregates a list of ProductInfo by symbol. Args: products (dict[str, list[ProductInfo]]): Map provider -> list of ProductInfo + filter_currency (str): If set, only products with this currency are considered. Defaults to "USD". Returns: - list[ProductInfo]: List of ProductInfo aggregated by symbol, combining data from all providers + dict[ProductInfo, str]: Map of aggregated ProductInfo by symbol """ - # Costruzione mappa symbol -> lista di ProductInfo (da tutti i provider) - symbols_infos: dict[str, list[ProductInfo]] = {} - for provider_name, product_list in products.items(): + # Costruzione mappa id -> lista di ProductInfo + lista di provider + id_infos: dict[str, tuple[list[ProductInfo], list[str]]] = {} + for provider, product_list in products.items(): for product in product_list: - # Assicuriamo che il provider sia impostato - if not product.provider: - product.provider = provider_name - symbols_infos.setdefault(product.symbol, []).append(product) + if filter_currency and product.currency != filter_currency: + continue + id_value = product.id.upper().replace("-", "") # Normalizzazione id per compatibilità (es. BTC-USD -> btcusd) + product_list, provider_list = id_infos.setdefault(id_value, ([], []) ) + product_list.append(product) + provider_list.append(provider) - # Aggregazione per ogni symbol usando aggregate_single_asset + # Aggregazione per ogni id aggregated_products: list[ProductInfo] = [] - for symbol, product_list in symbols_infos.items(): - try: - # Usa aggregate_single_asset per aggregare ogni simbolo - aggregated = ProductInfo.aggregate_single_asset(product_list) - - # aggregate_single_asset calcola il volume medio, ma per multi_assets - # vogliamo il volume totale. Ricalcoliamo il volume come somma dopo il filtro USD - # Dobbiamo rifare il filtro USD per contare correttamente - currencies = set(p.currency for p in product_list if p.currency) - if len(currencies) > 1: - product_list = [p for p in product_list if p.currency.upper() == "USD"] - - # Volume totale - aggregated.volume_24h = sum(p.volume_24h for p in product_list if p.volume_24h > 0) - - aggregated_products.append(aggregated) - except ValueError: - # Se aggregate_single_asset fallisce (es. no USD when currencies differ), salta - continue - - return aggregated_products - - @staticmethod - def aggregate_single_asset(assets: list['ProductInfo'] | dict[str, 'ProductInfo'] | dict[str, list['ProductInfo']]) -> 'ProductInfo': - """ - Aggregates an asset across different exchanges. - Args: - assets: Can be: - - list[ProductInfo]: Direct list of products - - dict[str, ProductInfo]: Map provider -> ProductInfo (from WrapperHandler.try_call_all) - - dict[str, list[ProductInfo]]: Map provider -> list of ProductInfo - Returns: - ProductInfo: Aggregated ProductInfo combining data from all exchanges - """ + for id_value, (product_list, provider_list) in id_infos.items(): + product = ProductInfo() - # Defensive handling: normalize to a flat list of ProductInfo - if not assets: - raise ValueError("aggregate_single_asset requires at least one ProductInfo") + product.id = f"{id_value}_AGGREGATED" + product.symbol = next(p.symbol for p in product_list if p.symbol) + product.currency = next(p.currency for p in product_list if p.currency) - # Normalize to a flat list of ProductInfo - if isinstance(assets, dict): - # Check if dict values are ProductInfo or list[ProductInfo] - first_value = next(iter(assets.values())) if assets else None - if first_value and isinstance(first_value, list): - # dict[str, list[ProductInfo]] -> flatten - assets_list = [product for product_list in assets.values() for product in product_list] + volume_sum = sum(p.volume_24h for p in product_list) + product.volume_24h = volume_sum / len(product_list) if product_list else 0.0 + + if volume_sum > 0: + # Calcolo del prezzo pesato per volume (VWAP - Volume Weighted Average Price) + prices_weighted = sum(p.price * p.volume_24h for p in product_list if p.volume_24h > 0) + product.price = prices_weighted / volume_sum else: - # dict[str, ProductInfo] -> extract values - assets_list = list(assets.values()) - elif isinstance(assets, list) and assets and isinstance(assets[0], list): - # Flatten list[list[ProductInfo]] -> list[ProductInfo] - assets_list = [product for sublist in assets for product in sublist] - else: - # Already a flat list of ProductInfo - assets_list = list(assets) - - if not assets_list: - raise ValueError("aggregate_single_asset requires at least one ProductInfo") - - # Controllo valuta: se non sono tutte uguali, filtra solo USD - currencies = set(p.currency for p in assets_list if p.currency) - if len(currencies) > 1: - # Valute diverse: filtra solo USD - assets_list = [p for p in assets_list if p.currency.upper() == "USD"] - if not assets_list: - raise ValueError("aggregate_single_asset: no USD products available when currencies differ") - - # Aggregazione per ogni Exchange - aggregated: ProductInfo = ProductInfo() - first = assets_list[0] - aggregated.id = f"{first.symbol}_AGGREGATED" - aggregated.symbol = first.symbol - aggregated.currency = next((p.currency for p in assets_list if p.currency), "") - - # Raccogliamo i provider che hanno fornito dati - providers = [p.provider for p in assets_list if p.provider] - aggregated.provider = ", ".join(set(providers)) if providers else "AGGREGATED" - - # Calcolo del volume medio - volume_sum = sum(p.volume_24h for p in assets_list if p.volume_24h > 0) - aggregated.volume_24h = volume_sum / len(assets_list) if assets_list else 0.0 - # Calcolo del prezzo pesato per volume (VWAP - Volume Weighted Average Price) - if volume_sum > 0: - prices_weighted = sum(p.price * p.volume_24h for p in assets_list if p.volume_24h > 0) - aggregated.price = prices_weighted / volume_sum - else: - # Se non c'è volume, facciamo una media semplice dei prezzi - valid_prices = [p.price for p in assets_list if p.price > 0] - aggregated.price = sum(valid_prices) / len(valid_prices) if valid_prices else 0.0 - - return aggregated + # Se non c'è volume, facciamo una media semplice dei prezzi + valid_prices = [p.price for p in product_list if p.price > 0] + product.price = sum(valid_prices) / len(valid_prices) if valid_prices else 0.0 + product.provider = ",".join(provider_list) + aggregated_products.append(product) + return aggregated_products class Price(BaseModel): diff --git a/src/app/api/tools/market_tool.py b/src/app/api/tools/market_tool.py index f8dbb68..05bec46 100644 --- a/src/app/api/tools/market_tool.py +++ b/src/app/api/tools/market_tool.py @@ -126,7 +126,7 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Exception: If all providers fail to return results. """ all_products = self.handler.try_call_all(lambda w: w.get_products(asset_ids)) - return ProductInfo.aggregate_multi_assets(all_products) + return ProductInfo.aggregate(all_products) def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: """ diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index 93f96fa..087711a 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -7,14 +7,13 @@ from app.api.core.markets import ProductInfo, Price @pytest.mark.market class TestMarketDataAggregator: - def __product(self, symbol: str, price: float, volume: float, currency: str, provider: str = "") -> ProductInfo: + def __product(self, symbol: str, price: float, volume: float, currency: str) -> ProductInfo: prod = ProductInfo() prod.id = f"{symbol}-{currency}" prod.symbol = symbol prod.price = price prod.volume_24h = volume prod.currency = currency - prod.provider = provider return prod def __price(self, timestamp_s: int, high: float, low: float, open: float, close: float, volume: float) -> Price: @@ -29,35 +28,41 @@ class TestMarketDataAggregator: def test_aggregate_product_info(self): products: dict[str, list[ProductInfo]] = { - "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1")], - "Provider2": [self.__product("BTC", 50100.0, 1100.0, "USD", "Provider2")], - "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD", "Provider3")], + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], + "Provider2": [self.__product("BTC", 50100.0, 1100.0, "USD")], + "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD")], } # aggregate_single_asset returns a single ProductInfo, not a list - info = ProductInfo.aggregate_single_asset(products) + aggregated = ProductInfo.aggregate(products) + assert len(aggregated) == 1 + + info = aggregated[0] assert info is not None + assert info.id == "BTCUSD_AGGREGATED" assert info.symbol == "BTC" + assert info.currency == "USD" + assert "Provider1" in info.provider + assert "Provider2" in info.provider + assert "Provider3" in info.provider avg_weighted_price = (50000.0 * 1000.0 + 50100.0 * 1100.0 + 49900.0 * 900.0) / (1000.0 + 1100.0 + 900.0) assert info.price == pytest.approx(avg_weighted_price, rel=1e-3) # type: ignore assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore - assert info.currency == "USD" def test_aggregate_product_info_multiple_symbols(self): products = { "Provider1": [ - self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1"), - self.__product("ETH", 4000.0, 2000.0, "USD", "Provider1"), + self.__product("BTC", 50000.0, 1000.0, "USD"), + self.__product("ETH", 4000.0, 2000.0, "USD"), ], "Provider2": [ - self.__product("BTC", 50100.0, 1100.0, "USD", "Provider2"), - self.__product("ETH", 4050.0, 2100.0, "USD", "Provider2"), + self.__product("BTC", 50100.0, 1100.0, "USD"), + self.__product("ETH", 4050.0, 2100.0, "USD"), ], } - # aggregate_multi_assets aggregates by symbol across providers - aggregated = ProductInfo.aggregate_multi_assets(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 2 btc_info = next((p for p in aggregated if p.symbol == "BTC"), None) @@ -66,13 +71,13 @@ class TestMarketDataAggregator: assert btc_info is not None avg_weighted_price_btc = (50000.0 * 1000.0 + 50100.0 * 1100.0) / (1000.0 + 1100.0) assert btc_info.price == pytest.approx(avg_weighted_price_btc, rel=1e-3) # type: ignore - assert btc_info.volume_24h == pytest.approx(2100.0, rel=1e-3) # type: ignore # Total volume (1000 + 1100) + assert btc_info.volume_24h == pytest.approx(1050.0, rel=1e-3) # type: ignore assert btc_info.currency == "USD" assert eth_info is not None avg_weighted_price_eth = (4000.0 * 2000.0 + 4050.0 * 2100.0) / (2000.0 + 2100.0) assert eth_info.price == pytest.approx(avg_weighted_price_eth, rel=1e-3) # type: ignore - assert eth_info.volume_24h == pytest.approx(4100.0, rel=1e-3) # type: ignore # Total volume (2000 + 2100) + assert eth_info.volume_24h == pytest.approx(2050.0, rel=1e-3) # type: ignore assert eth_info.currency == "USD" def test_aggregate_product_info_with_no_data(self): @@ -80,15 +85,15 @@ class TestMarketDataAggregator: "Provider1": [], "Provider2": [], } - aggregated = ProductInfo.aggregate_multi_assets(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 0 def test_aggregate_product_info_with_partial_data(self): products: dict[str, list[ProductInfo]] = { - "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1")], + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], "Provider2": [], } - aggregated = ProductInfo.aggregate_multi_assets(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 info = aggregated[0] assert info.symbol == "BTC" @@ -129,157 +134,54 @@ class TestMarketDataAggregator: assert aggregated[1].low == pytest.approx(49850.0, rel=1e-3) # type: ignore def test_aggregate_product_info_different_currencies(self): - products: dict[str, list[ProductInfo]] = { - "Provider1": [self.__product("BTC", 100000.0, 1000.0, "USD", "Provider1")], - "Provider2": [self.__product("BTC", 70000.0, 800.0, "EUR", "Provider2")], + products = { + "Provider1": [self.__product("BTC", 100000.0, 1000.0, "USD")], + "Provider2": [self.__product("BTC", 70000.0, 800.0, "EUR")], } - aggregated = ProductInfo.aggregate_multi_assets(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 info = aggregated[0] assert info is not None - assert info.id == "BTC_AGGREGATED" + assert info.id == "BTCUSD_AGGREGATED" assert info.symbol == "BTC" assert info.currency == "USD" # Only USD products are kept # When currencies differ, only USD is aggregated (only Provider1 in this case) assert info.price == pytest.approx(100000.0, rel=1e-3) # type: ignore assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore # Only USD volume - # ===== Tests for aggregate_single_asset ===== - - def test_aggregate_single_asset_from_dict(self): - """Test aggregate_single_asset with dict input (simulating WrapperHandler.try_call_all)""" - products_dict: dict[str, ProductInfo] = { - "BinanceWrapper": self.__product("BTC", 50000.0, 1000.0, "USD", "Binance"), - "YFinanceWrapper": self.__product("BTC", 50100.0, 1100.0, "USD", "YFinance"), - "CoinBaseWrapper": self.__product("BTC", 49900.0, 900.0, "USD", "Coinbase"), - } - - info = ProductInfo.aggregate_single_asset(products_dict) - assert info is not None - assert info.symbol == "BTC" - assert info.id == "BTC_AGGREGATED" - assert "Binance" in info.provider - assert "YFinance" in info.provider - assert "Coinbase" in info.provider - - # VWAP calculation - expected_price = (50000.0 * 1000.0 + 50100.0 * 1100.0 + 49900.0 * 900.0) / (1000.0 + 1100.0 + 900.0) - assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore - assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) # type: ignore - assert info.currency == "USD" - - def test_aggregate_single_asset_from_list(self): - """Test aggregate_single_asset with list input""" - products_list = [ - self.__product("ETH", 4000.0, 2000.0, "USD", "Binance"), - self.__product("ETH", 4050.0, 2100.0, "USD", "Coinbase"), - ] - - info = ProductInfo.aggregate_single_asset(products_list) - assert info is not None - assert info.symbol == "ETH" - assert info.id == "ETH_AGGREGATED" - assert "Binance" in info.provider - assert "Coinbase" in info.provider - - expected_price = (4000.0 * 2000.0 + 4050.0 * 2100.0) / (2000.0 + 2100.0) - assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore - - def test_aggregate_single_asset_no_volume_fallback(self): - """Test fallback to simple average when no volume data""" - products_list = [ - self.__product("SOL", 100.0, 0.0, "USD", "Provider1"), - self.__product("SOL", 110.0, 0.0, "USD", "Provider2"), - self.__product("SOL", 90.0, 0.0, "USD", "Provider3"), - ] - - info = ProductInfo.aggregate_single_asset(products_list) - assert info is not None - assert info.symbol == "SOL" - # Simple average: (100 + 110 + 90) / 3 = 100 - assert info.price == pytest.approx(100.0, rel=1e-3) # type: ignore - assert info.volume_24h == pytest.approx(0.0, rel=1e-3) # type: ignore - - def test_aggregate_single_asset_empty_raises(self): - """Test that empty input raises ValueError""" - with pytest.raises(ValueError, match="requires at least one ProductInfo"): - ProductInfo.aggregate_single_asset([]) - - with pytest.raises(ValueError, match="requires at least one ProductInfo"): - ProductInfo.aggregate_single_asset({}) - - def test_aggregate_single_asset_dict_with_lists(self): - """Test aggregate_single_asset with dict[str, list[ProductInfo]] (flattens correctly)""" - products_dict_lists: dict[str, list[ProductInfo]] = { - "Provider1": [self.__product("ADA", 0.50, 1000.0, "USD", "Provider1")], - "Provider2": [self.__product("ADA", 0.52, 1200.0, "USD", "Provider2")], - } - - info = ProductInfo.aggregate_single_asset(products_dict_lists) - assert info is not None - assert info.symbol == "ADA" - expected_price = (0.50 * 1000.0 + 0.52 * 1200.0) / (1000.0 + 1200.0) - assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore - - def test_aggregate_single_asset_missing_currency(self): - """Test that aggregate_single_asset handles missing currency gracefully""" - products_list = [ - self.__product("DOT", 10.0, 500.0, "", "Provider1"), - self.__product("DOT", 10.5, 600.0, "USD", "Provider2"), - ] - - info = ProductInfo.aggregate_single_asset(products_list) - assert info is not None - assert info.symbol == "DOT" - assert info.currency == "USD" # Should pick the first non-empty currency - - def test_aggregate_single_asset_single_provider(self): - """Test aggregate_single_asset with only one provider (edge case)""" - products = { - "BinanceWrapper": self.__product("MATIC", 0.80, 5000.0, "USD", "Binance"), - } - - info = ProductInfo.aggregate_single_asset(products) - assert info is not None - assert info.symbol == "MATIC" - assert info.price == pytest.approx(0.80, rel=1e-3) # type: ignore - assert info.volume_24h == pytest.approx(5000.0, rel=1e-3) # type: ignore - assert info.provider == "Binance" - - # ===== Tests for aggregate_multi_assets with edge cases ===== - - def test_aggregate_multi_assets_empty_providers(self): - """Test aggregate_multi_assets with some providers returning empty lists""" - products = { - "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1")], + def test_aggregate_product_info_empty_providers(self): + """Test aggregate_product_info with some providers returning empty lists""" + products: dict[str, list[ProductInfo]] = { + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], "Provider2": [], - "Provider3": [self.__product("BTC", 50100.0, 1100.0, "USD", "Provider3")], + "Provider3": [self.__product("BTC", 50100.0, 1100.0, "USD")], } - aggregated = ProductInfo.aggregate_multi_assets(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 info = aggregated[0] assert info.symbol == "BTC" assert "Provider1" in info.provider + assert "Provider2" not in info.provider assert "Provider3" in info.provider - def test_aggregate_multi_assets_mixed_symbols(self): - """Test that aggregate_multi_assets correctly separates different symbols""" + def test_aggregate_product_info_mixed_symbols(self): + """Test that aggregate_product_info correctly separates different symbols""" products = { "Provider1": [ - self.__product("BTC", 50000.0, 1000.0, "USD", "Provider1"), - self.__product("ETH", 4000.0, 2000.0, "USD", "Provider1"), - self.__product("SOL", 100.0, 500.0, "USD", "Provider1"), + self.__product("BTC", 50000.0, 1000.0, "USD"), + self.__product("ETH", 4000.0, 2000.0, "USD"), + self.__product("SOL", 100.0, 500.0, "USD"), ], "Provider2": [ - self.__product("BTC", 50100.0, 1100.0, "USD", "Provider2"), - self.__product("ETH", 4050.0, 2100.0, "USD", "Provider2"), + self.__product("BTC", 50100.0, 1100.0, "USD"), + self.__product("ETH", 4050.0, 2100.0, "USD"), ], } - aggregated = ProductInfo.aggregate_multi_assets(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 3 symbols = {p.symbol for p in aggregated} @@ -290,3 +192,20 @@ class TestMarketDataAggregator: sol = next(p for p in aggregated if p.symbol == "SOL") assert sol.provider == "Provider1" # Only one provider + + def test_aggregate_product_info_zero_volume(self): + """Test aggregazione quando tutti i prodotti hanno volume zero""" + products = { + "Provider1": [self.__product("BTC", 50000.0, 0.0, "USD")], + "Provider2": [self.__product("BTC", 50100.0, 0.0, "USD")], + "Provider3": [self.__product("BTC", 49900.0, 0.0, "USD")], + } + + aggregated = ProductInfo.aggregate(products) + assert len(aggregated) == 1 + + info = aggregated[0] + # Con volume zero, dovrebbe usare la media semplice dei prezzi + expected_price = (50000.0 + 50100.0 + 49900.0) / 3 + assert info.price == pytest.approx(expected_price, rel=1e-3) # type: ignore + assert info.volume_24h == 0.0 -- 2.49.1 From 15b279faa14b6c1ab9437003e65f76157e26ee8e Mon Sep 17 00:00:00 2001 From: Berack96 Date: Sun, 2 Nov 2025 00:03:53 +0100 Subject: [PATCH 12/12] fix typos --- src/app/api/core/markets.py | 2 +- tests/utils/test_market_aggregator.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/api/core/markets.py b/src/app/api/core/markets.py index a1b1b79..e330c58 100644 --- a/src/app/api/core/markets.py +++ b/src/app/api/core/markets.py @@ -23,7 +23,7 @@ class ProductInfo(BaseModel): products (dict[str, list[ProductInfo]]): Map provider -> list of ProductInfo filter_currency (str): If set, only products with this currency are considered. Defaults to "USD". Returns: - dict[ProductInfo, str]: Map of aggregated ProductInfo by symbol + list[ProductInfo]: List of ProductInfo aggregated by symbol """ # Costruzione mappa id -> lista di ProductInfo + lista di provider diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py index 087711a..3befe78 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -33,7 +33,6 @@ class TestMarketDataAggregator: "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD")], } - # aggregate_single_asset returns a single ProductInfo, not a list aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 -- 2.49.1