diff --git a/.env.example b/.env.example index 85a072d..06a53cb 100644 --- a/.env.example +++ b/.env.example @@ -20,9 +20,8 @@ COINBASE_API_SECRET= # https://www.cryptocompare.com/cryptopian/api-keys CRYPTOCOMPARE_API_KEY= -# Binance API per Market Agent -# Ottenibili da: https://www.binance.com/en/my/settings/api-management -# Supporta sia API autenticate che pubbliche (PublicBinance) +# https://www.binance.com/en/my/settings/api-management +# Non necessario per operazioni in sola lettura BINANCE_API_KEY= BINANCE_API_SECRET= diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py index 16817d5..8c368e8 100644 --- a/demos/market_providers_api_demo.py +++ b/demos/market_providers_api_demo.py @@ -8,6 +8,7 @@ Questo script dimostra l'utilizzo di tutti i wrapper che implementano BaseWrappe - CryptoCompareWrapper (richiede API key) - BinanceWrapper (richiede credenziali) - PublicBinanceAgent (accesso pubblico) +- YFinanceWrapper (accesso gratuito a dati azionari e crypto) Lo script effettua chiamate GET a diversi provider e visualizza i dati in modo strutturato con informazioni dettagliate su timestamp, stato @@ -26,11 +27,11 @@ project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root / "src")) from dotenv import load_dotenv -from src.app.markets import ( +from app.markets import ( CoinBaseWrapper, CryptoCompareWrapper, - BinanceWrapper, - PublicBinanceAgent, + BinanceWrapper, + YFinanceWrapper, BaseWrapper ) @@ -239,13 +240,6 @@ def initialize_providers() -> Dict[str, BaseWrapper]: providers = {} env_vars = check_environment_variables() - # PublicBinanceAgent (sempre disponibile) - try: - providers["PublicBinance"] = PublicBinanceAgent() - print("✅ PublicBinanceAgent inizializzato con successo") - except Exception as e: - print(f"❌ Errore nell'inizializzazione di PublicBinanceAgent: {e}") - # CryptoCompareWrapper if env_vars["CRYPTOCOMPARE_API_KEY"]: try: @@ -267,15 +261,18 @@ def initialize_providers() -> Dict[str, BaseWrapper]: print("⚠️ CoinBaseWrapper saltato: credenziali Coinbase non complete") # BinanceWrapper - if env_vars["BINANCE_API_KEY"] and env_vars["BINANCE_API_SECRET"]: - try: - providers["Binance"] = BinanceWrapper() - print("✅ BinanceWrapper inizializzato con successo") - except Exception as e: - print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}") - else: - print("⚠️ BinanceWrapper saltato: credenziali Binance non complete") + try: + providers["Binance"] = BinanceWrapper() + print("✅ BinanceWrapper inizializzato con successo") + except Exception as e: + print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}") + # YFinanceWrapper (sempre disponibile - dati azionari e crypto gratuiti) + try: + providers["YFinance"] = YFinanceWrapper() + print("✅ YFinanceWrapper inizializzato con successo") + except Exception as e: + print(f"❌ Errore nell'inizializzazione di YFinanceWrapper: {e}") return providers def print_summary(results: List[Dict[str, Any]]): diff --git a/pyproject.toml b/pyproject.toml index 74c0029..2e90e39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ # API di exchange di criptovalute "coinbase-advanced-py", "python-binance", + "yfinance", # API di notizie "newsapi-python", @@ -35,7 +36,6 @@ dependencies = [ # API di social media "praw", # Reddit ] - [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index bec8149..eefc442 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,31 +1,97 @@ -from .base import BaseWrapper +from .base import BaseWrapper, ProductInfo, Price from .coinbase import CoinBaseWrapper from .binance import BinanceWrapper from .cryptocompare import CryptoCompareWrapper +from .yfinance import YFinanceWrapper +from .binance_public import PublicBinanceAgent from app.utils.wrapper_handler import WrapperHandler - -__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper" ] +from typing import List, Optional +from agno.tools import Toolkit -# TODO se si vuole usare un aggregatore di dati di mercato, si può aggiungere qui facendo una classe extra (simile a questa) che per ogni chiamata chiama tutti i wrapper e aggrega i risultati -class MarketAPIs(BaseWrapper): +__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "PublicBinanceAgent" ] + + +class MarketAPIsTool(BaseWrapper, Toolkit): """ Classe per gestire le API di mercato disponibili. - Permette di ottenere un'istanza della prima API disponibile in base alla priorità specificata. - Supporta operazioni come ottenere informazioni su singoli prodotti, liste di prodotti e dati storici. - Usa un WrapperHandler per gestire più wrapper e tentare chiamate in modo resiliente. + + Supporta due modalità: + 1. **Modalità standard** (default): usa il primo wrapper disponibile + 2. **Modalità aggregazione**: aggrega dati da tutte le fonti disponibili + + L'aggregazione può essere abilitata/disabilitata dinamicamente. """ - def __init__(self, currency: str = "USD"): + def __init__(self, currency: str = "USD", enable_aggregation: bool = False): self.currency = currency - wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ] + wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper, YFinanceWrapper ] self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers) + + # Inizializza l'aggregatore solo se richiesto (lazy initialization) + self._aggregator = None + self._aggregation_enabled = enable_aggregation + + Toolkit.__init__( + self, + name="Market APIs Toolkit", + tools=[ + self.get_product, + self.get_products, + self.get_all_products, + self.get_historical_prices, + ], + ) + + def _get_aggregator(self): + """Lazy initialization dell'aggregatore""" + if self._aggregator is None: + from app.utils.market_data_aggregator import MarketDataAggregator + self._aggregator = MarketDataAggregator(self.currency) + self._aggregator.enable_aggregation(self._aggregation_enabled) + return self._aggregator - def get_product(self, asset_id): + def get_product(self, asset_id: str) -> Optional[ProductInfo]: + """Ottieni informazioni su un prodotto specifico""" + if self._aggregation_enabled: + return self._get_aggregator().get_product(asset_id) return self.wrappers.try_call(lambda w: w.get_product(asset_id)) - def get_products(self, asset_ids: list): + + def get_products(self, asset_ids: List[str]) -> List[ProductInfo]: + """Ottieni informazioni su multiple prodotti""" + if self._aggregation_enabled: + return self._get_aggregator().get_products(asset_ids) return self.wrappers.try_call(lambda w: w.get_products(asset_ids)) - def get_all_products(self): + + def get_all_products(self) -> List[ProductInfo]: + """Ottieni tutti i prodotti disponibili""" + if self._aggregation_enabled: + return self._get_aggregator().get_all_products() return self.wrappers.try_call(lambda w: w.get_all_products()) - def get_historical_prices(self, asset_id = "BTC", limit: int = 100): + + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> List[Price]: + """Ottieni dati storici dei prezzi""" + if self._aggregation_enabled: + return self._get_aggregator().get_historical_prices(asset_id, limit) return self.wrappers.try_call(lambda w: w.get_historical_prices(asset_id, limit)) + + # Metodi per controllare l'aggregazione + def enable_aggregation(self, enabled: bool = True): + """Abilita/disabilita la modalità aggregazione""" + self._aggregation_enabled = enabled + if self._aggregator: + self._aggregator.enable_aggregation(enabled) + + def is_aggregation_enabled(self) -> bool: + """Verifica se l'aggregazione è abilitata""" + return self._aggregation_enabled + + # Metodo speciale per debugging (opzionale) + def get_aggregated_product_with_debug(self, asset_id: str) -> dict: + """ + Metodo speciale per ottenere dati aggregati con informazioni di debug. + Disponibile solo quando l'aggregazione è abilitata. + """ + if not self._aggregation_enabled: + raise RuntimeError("L'aggregazione deve essere abilitata per usare questo metodo") + return self._get_aggregator().get_aggregated_product_with_debug(asset_id) diff --git a/src/app/markets/base.py b/src/app/markets/base.py index 2690c4f..117c174 100644 --- a/src/app/markets/base.py +++ b/src/app/markets/base.py @@ -3,16 +3,47 @@ from pydantic import BaseModel class BaseWrapper: """ - Interfaccia per i wrapper delle API di mercato. - Implementa i metodi di base che ogni wrapper deve avere. + Base class for market API wrappers. + All market API wrappers should inherit from this class and implement the methods. """ + def get_product(self, asset_id: str) -> 'ProductInfo': + """ + Get product information for a specific asset ID. + Args: + asset_id (str): The asset ID to retrieve information for. + Returns: + ProductInfo: An object containing product information. + """ raise NotImplementedError + def get_products(self, asset_ids: list[str]) -> list['ProductInfo']: + """ + Get product information for multiple asset IDs. + Args: + asset_ids (list[str]): The list of asset IDs to retrieve information for. + Returns: + list[ProductInfo]: A list of objects containing product information. + """ raise NotImplementedError + def get_all_products(self) -> list['ProductInfo']: + """ + Get product information for all available assets. + Returns: + list[ProductInfo]: A list of objects containing product information. + """ raise NotImplementedError + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list['Price']: + """ + Get historical price data for a specific asset ID. + Args: + asset_id (str): The asset ID to retrieve price data for. + limit (int): The maximum number of price data points to return. + Returns: + list[Price]: A list of Price objects. + """ raise NotImplementedError class ProductInfo(BaseModel): diff --git a/src/app/markets/binance.py b/src/app/markets/binance.py index 6b6b6d3..d5dfe10 100644 --- a/src/app/markets/binance.py +++ b/src/app/markets/binance.py @@ -23,10 +23,7 @@ class BinanceWrapper(BaseWrapper): def __init__(self, currency: str = "USDT"): api_key = os.getenv("BINANCE_API_KEY") - assert api_key is not None, "API key is required" - api_secret = os.getenv("BINANCE_API_SECRET") - assert api_secret is not None, "API secret is required" self.currency = currency self.client = Client(api_key=api_key, api_secret=api_secret) diff --git a/src/app/markets/binance_public.py b/src/app/markets/binance_public.py index 598840b..c1d9896 100644 --- a/src/app/markets/binance_public.py +++ b/src/app/markets/binance_public.py @@ -9,7 +9,6 @@ from typing import Optional, Dict, Any from datetime import datetime, timedelta from binance.client import Client from .base import BaseWrapper, ProductInfo, Price -from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError class PublicBinanceAgent(BaseWrapper): @@ -38,8 +37,6 @@ class PublicBinanceAgent(BaseWrapper): return asset_id return f"{asset_id}USDT" - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_product(self, asset_id: str) -> ProductInfo: """ Ottiene informazioni su un singolo prodotto. @@ -59,8 +56,6 @@ class PublicBinanceAgent(BaseWrapper): print(f"Errore nel recupero del prodotto {asset_id}: {e}") return ProductInfo(id=asset_id, symbol=asset_id) - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: """ Ottiene informazioni su più prodotti. @@ -77,8 +72,6 @@ class PublicBinanceAgent(BaseWrapper): products.append(product) return products - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_all_products(self) -> list[ProductInfo]: """ Ottiene informazioni su tutti i prodotti disponibili. @@ -90,8 +83,6 @@ class PublicBinanceAgent(BaseWrapper): major_assets = ["BTC", "ETH", "BNB", "ADA", "DOT", "LINK", "LTC", "XRP"] return self.get_products(major_assets) - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]: """ Ottiene i prezzi storici per un asset. diff --git a/src/app/markets/error_handler.py b/src/app/markets/error_handler.py deleted file mode 100644 index c98301a..0000000 --- a/src/app/markets/error_handler.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -Modulo per la gestione robusta degli errori nei market providers. - -Fornisce decoratori e utilità per: -- Retry automatico con backoff esponenziale -- Logging standardizzato degli errori -- Gestione di timeout e rate limiting -- Fallback tra provider multipli -""" - -import time -import logging -from functools import wraps -from typing import Any, Callable, Optional, Type, Union, List -from requests.exceptions import RequestException, Timeout, ConnectionError -from binance.exceptions import BinanceAPIException, BinanceRequestException -from .base import ProductInfo - -# Configurazione logging -logger = logging.getLogger(__name__) - -class MarketAPIError(Exception): - """Eccezione base per errori delle API di mercato.""" - pass - -class RateLimitError(MarketAPIError): - """Eccezione per errori di rate limiting.""" - pass - -class AuthenticationError(MarketAPIError): - """Eccezione per errori di autenticazione.""" - pass - -class DataNotFoundError(MarketAPIError): - """Eccezione quando i dati richiesti non sono disponibili.""" - pass - -def retry_on_failure( - max_retries: int = 3, - delay: float = 1.0, - backoff_factor: float = 2.0, - exceptions: tuple = (RequestException, BinanceAPIException, BinanceRequestException) -) -> Callable: - """ - Decoratore per retry automatico con backoff esponenziale. - - Args: - max_retries: Numero massimo di tentativi - delay: Delay iniziale in secondi - backoff_factor: Fattore di moltiplicazione per il delay - exceptions: Tuple di eccezioni da catturare per il retry - - Returns: - Decoratore per la funzione - """ - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs) -> Any: - last_exception = None - current_delay = delay - - for attempt in range(max_retries + 1): - try: - return func(*args, **kwargs) - except exceptions as e: - last_exception = e - - if attempt == max_retries: - logger.error( - f"Function {func.__name__} failed after {max_retries + 1} attempts. " - f"Last error: {str(e)}" - ) - raise MarketAPIError(f"Max retries exceeded: {str(e)}") from e - - logger.warning( - f"Attempt {attempt + 1}/{max_retries + 1} failed for {func.__name__}: {str(e)}. " - f"Retrying in {current_delay:.1f}s..." - ) - - time.sleep(current_delay) - current_delay *= backoff_factor - except Exception as e: - # Per eccezioni non previste, non fare retry - logger.error(f"Unexpected error in {func.__name__}: {str(e)}") - raise - - # Questo non dovrebbe mai essere raggiunto - if last_exception: - raise last_exception - else: - raise MarketAPIError("Unknown error occurred") - - return wrapper - return decorator - -def handle_api_errors(func: Callable) -> Callable: - """ - Decoratore per gestione standardizzata degli errori API. - - Converte errori specifici dei provider in eccezioni standardizzate. - """ - @wraps(func) - def wrapper(*args, **kwargs) -> Any: - try: - return func(*args, **kwargs) - except BinanceAPIException as e: - if e.code == -1021: # Timestamp error - raise MarketAPIError(f"Binance timestamp error: {e.message}") - elif e.code == -1003: # Rate limit - raise RateLimitError(f"Binance rate limit exceeded: {e.message}") - elif e.code in [-2014, -2015]: # API key errors - raise AuthenticationError(f"Binance authentication error: {e.message}") - else: - raise MarketAPIError(f"Binance API error [{e.code}]: {e.message}") - except ConnectionError as e: - raise MarketAPIError(f"Connection error: {str(e)}") - except Timeout as e: - raise MarketAPIError(f"Request timeout: {str(e)}") - except RequestException as e: - raise MarketAPIError(f"Request error: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error in {func.__name__}: {str(e)}") - raise MarketAPIError(f"Unexpected error: {str(e)}") from e - - return wrapper - -def safe_execute( - func: Callable, - default_value: Any = None, - log_errors: bool = True, - error_message: Optional[str] = None -) -> Any: - """ - Esegue una funzione in modo sicuro, restituendo un valore di default in caso di errore. - - Args: - func: Funzione da eseguire - default_value: Valore da restituire in caso di errore - log_errors: Se loggare gli errori - error_message: Messaggio di errore personalizzato - - Returns: - Risultato della funzione o valore di default - """ - try: - return func() - except Exception as e: - if log_errors: - message = error_message or f"Error executing {func.__name__}" - logger.warning(f"{message}: {str(e)}") - return default_value - -class ProviderFallback: - """ - Classe per gestire il fallback tra provider multipli. - """ - - def __init__(self, providers: List[Any]): - """ - Inizializza con una lista di provider ordinati per priorità. - - Args: - providers: Lista di provider ordinati per priorità - """ - self.providers = providers - - def execute_with_fallback( - self, - method_name: str, - *args, - **kwargs - ) -> list[ProductInfo]: - """ - Esegue un metodo su tutti i provider fino a trovarne uno che funziona. - - Args: - method_name: Nome del metodo da chiamare - *args: Argomenti posizionali - **kwargs: Argomenti nominali - - Returns: - Risultato del primo provider che funziona - - Raises: - MarketAPIError: Se tutti i provider falliscono - """ - last_error = None - - for i, provider in enumerate(self.providers): - try: - if hasattr(provider, method_name): - method = getattr(provider, method_name) - result = method(*args, **kwargs) - - if i > 0: # Se non è il primo provider - logger.info(f"Fallback successful: used provider {type(provider).__name__}") - - return result - else: - logger.warning(f"Provider {type(provider).__name__} doesn't have method {method_name}") - continue - - except Exception as e: - last_error = e - logger.warning( - f"Provider {type(provider).__name__} failed for {method_name}: {str(e)}" - ) - continue - - # Se arriviamo qui, tutti i provider hanno fallito - raise MarketAPIError( - f"All providers failed for method {method_name}. Last error: {str(last_error)}" - ) - -def validate_response_data(data: Any, required_fields: Optional[List[str]] = None) -> bool: - """ - Valida che i dati di risposta contengano i campi richiesti. - - Args: - data: Dati da validare - required_fields: Lista di campi richiesti - - Returns: - True se i dati sono validi, False altrimenti - """ - if data is None: - return False - - if required_fields is None: - return True - - if isinstance(data, dict): - return all(field in data for field in required_fields) - elif hasattr(data, '__dict__'): - return all(hasattr(data, field) for field in required_fields) - - return False \ No newline at end of file diff --git a/src/app/markets/yfinance.py b/src/app/markets/yfinance.py new file mode 100644 index 0000000..f0e5d6d --- /dev/null +++ b/src/app/markets/yfinance.py @@ -0,0 +1,214 @@ +import json +from agno.tools.yfinance import YFinanceTools +from .base import BaseWrapper, ProductInfo, Price + + +def create_product_info(symbol: str, stock_data: dict) -> ProductInfo: + """ + Converte i dati di YFinanceTools in ProductInfo. + """ + product = ProductInfo() + + # ID univoco per yfinance + product.id = f"yfinance_{symbol}" + product.symbol = symbol + + # Estrai il prezzo corrente - gestisci diversi formati + if 'currentPrice' in stock_data: + product.price = float(stock_data['currentPrice']) + elif 'regularMarketPrice' in stock_data: + product.price = float(stock_data['regularMarketPrice']) + elif 'Current Stock Price' in stock_data: + # Formato: "254.63 USD" - estrai solo il numero + price_str = stock_data['Current Stock Price'].split()[0] + try: + product.price = float(price_str) + except ValueError: + product.price = 0.0 + else: + product.price = 0.0 + + # Volume 24h + if 'volume' in stock_data: + product.volume_24h = float(stock_data['volume']) + elif 'regularMarketVolume' in stock_data: + product.volume_24h = float(stock_data['regularMarketVolume']) + else: + product.volume_24h = 0.0 + + # Status basato sulla disponibilità dei dati + product.status = "trading" if product.price > 0 else "offline" + + # Valuta (default USD) + product.quote_currency = stock_data.get('currency', 'USD') or 'USD' + + return product + + +def create_price_from_history(hist_data: dict, timestamp: str) -> Price: + """ + Converte i dati storici di YFinanceTools in Price. + """ + price = Price() + + if timestamp in hist_data: + day_data = hist_data[timestamp] + price.high = float(day_data.get('High', 0.0)) + price.low = float(day_data.get('Low', 0.0)) + price.open = float(day_data.get('Open', 0.0)) + price.close = float(day_data.get('Close', 0.0)) + price.volume = float(day_data.get('Volume', 0.0)) + price.time = timestamp + + return price + + +class YFinanceWrapper(BaseWrapper): + """ + Wrapper per YFinanceTools che fornisce dati di mercato per azioni, ETF e criptovalute. + Implementa l'interfaccia BaseWrapper per compatibilità con il sistema esistente. + Usa YFinanceTools dalla libreria agno per coerenza con altri wrapper. + """ + + def __init__(self, currency: str = "USD"): + self.currency = currency + # Inizializza YFinanceTools - non richiede parametri specifici + self.tool = YFinanceTools() + + def _format_symbol(self, asset_id: str) -> str: + """ + Formatta il simbolo per yfinance. + Per crypto, aggiunge '-USD' se non presente. + """ + asset_id = asset_id.upper() + + # Se è già nel formato corretto (es: BTC-USD), usa così + if '-' in asset_id: + return asset_id + + # Per crypto singole (BTC, ETH), aggiungi -USD + if asset_id in ['BTC', 'ETH', 'ADA', 'SOL', 'DOT', 'LINK', 'UNI', 'AAVE']: + return f"{asset_id}-USD" + + # Per azioni, usa il simbolo così com'è + return asset_id + + def get_product(self, asset_id: str) -> ProductInfo: + """ + Recupera le informazioni di un singolo prodotto. + """ + symbol = self._format_symbol(asset_id) + + # Usa YFinanceTools per ottenere i dati + try: + # Ottieni le informazioni base dello stock + stock_info = self.tool.get_company_info(symbol) + + # Se il risultato è una stringa JSON, parsala + if isinstance(stock_info, str): + try: + stock_data = json.loads(stock_info) + except json.JSONDecodeError: + # Se non è JSON valido, prova a ottenere solo il prezzo + price_data_str = self.tool.get_current_stock_price(symbol) + if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit(): + price = float(price_data_str) + stock_data = {'currentPrice': price, 'currency': 'USD'} + else: + raise Exception("Dati non validi") + else: + stock_data = stock_info + + return create_product_info(symbol, stock_data) + + except Exception as e: + # Fallback: prova a ottenere solo il prezzo + try: + price_data_str = self.tool.get_current_stock_price(symbol) + if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit(): + price = float(price_data_str) + minimal_data = { + 'currentPrice': price, + 'currency': 'USD' + } + return create_product_info(symbol, minimal_data) + else: + raise Exception("Prezzo non disponibile") + except Exception: + # Se tutto fallisce, restituisci un prodotto vuoto + product = ProductInfo() + product.symbol = symbol + product.status = "offline" + return product + + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + """ + Recupera le informazioni di multiple assets. + """ + products = [] + + for asset_id in asset_ids: + try: + product = self.get_product(asset_id) + products.append(product) + except Exception as e: + # Se un asset non è disponibile, continua con gli altri + continue + + return products + + def get_all_products(self) -> list[ProductInfo]: + """ + Recupera tutti i prodotti disponibili. + Restituisce una lista predefinita di asset popolari. + """ + # Lista di asset popolari (azioni, ETF, crypto) + popular_assets = [ + 'BTC', 'ETH', 'ADA', 'SOL', 'DOT', + 'AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN', + 'SPY', 'QQQ', 'VTI', 'GLD', 'VIX' + ] + + return self.get_products(popular_assets) + + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + """ + Recupera i dati storici di prezzo per un asset. + """ + symbol = self._format_symbol(asset_id) + + try: + # Determina il periodo appropriato in base al limite + if limit <= 7: + period = "1d" + interval = "15m" + elif limit <= 30: + period = "5d" + interval = "1h" + elif limit <= 90: + period = "1mo" + interval = "1d" + else: + period = "3mo" + interval = "1d" + + # Ottieni i dati storici + hist_data = self.tool.get_historical_stock_prices(symbol, period=period, interval=interval) + + if isinstance(hist_data, str): + hist_data = json.loads(hist_data) + + # Il formato dei dati è {timestamp: {Open: x, High: y, Low: z, Close: w, Volume: v}} + prices = [] + timestamps = sorted(hist_data.keys())[-limit:] # Prendi gli ultimi 'limit' timestamp + + for timestamp in timestamps: + price = create_price_from_history(hist_data, timestamp) + if price.close > 0: # Solo se ci sono dati validi + prices.append(price) + + return prices + + except Exception as e: + # Se fallisce, restituisci lista vuota + return [] \ No newline at end of file diff --git a/src/app/pipeline.py b/src/app/pipeline.py index f515053..7a440de 100644 --- a/src/app/pipeline.py +++ b/src/app/pipeline.py @@ -6,7 +6,6 @@ from agno.utils.log import log_info from app.agents.market_agent import MarketAgent from app.agents.news_agent import NewsAgent from app.agents.social_agent import SocialAgent -from app.markets import MarketAPIs from app.models import AppModels from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS diff --git a/src/app/toolkits/market_toolkit.py b/src/app/toolkits/market_toolkit.py index cd76cf2..7267b96 100644 --- a/src/app/toolkits/market_toolkit.py +++ b/src/app/toolkits/market_toolkit.py @@ -1,5 +1,5 @@ from agno.tools import Toolkit -from app.markets import MarketAPIs +from app.markets import MarketAPIsTool # TODO (?) in futuro fare in modo che la LLM faccia da sé per il mercato diff --git a/src/app/utils/aggregated_models.py b/src/app/utils/aggregated_models.py new file mode 100644 index 0000000..ee9f3ef --- /dev/null +++ b/src/app/utils/aggregated_models.py @@ -0,0 +1,186 @@ +import statistics +from typing import Dict, List, Optional, Set +from pydantic import BaseModel, Field, PrivateAttr +from app.markets.base import ProductInfo + +class AggregationMetadata(BaseModel): + """Metadati nascosti per debugging e audit trail""" + sources_used: Set[str] = Field(default_factory=set, description="Exchange usati nell'aggregazione") + sources_ignored: Set[str] = Field(default_factory=set, description="Exchange ignorati (errori)") + aggregation_timestamp: str = Field(default="", description="Timestamp dell'aggregazione") + confidence_score: float = Field(default=0.0, description="Score 0-1 sulla qualità dei dati") + + class Config: + # Nasconde questi campi dalla serializzazione di default + extra = "forbid" + +class AggregatedProductInfo(ProductInfo): + """ + Versione aggregata di ProductInfo che mantiene la trasparenza per l'utente finale + mentre fornisce metadati di debugging opzionali. + """ + + # Override dei campi con logica di aggregazione + id: str = Field(description="ID aggregato basato sul simbolo standardizzato") + status: str = Field(description="Status aggregato (majority vote o conservative)") + + # Campi privati per debugging (non visibili di default) + _metadata: Optional[AggregationMetadata] = PrivateAttr(default=None) + _source_data: Optional[Dict[str, ProductInfo]] = PrivateAttr(default=None) + + @classmethod + def from_multiple_sources(cls, products: List[ProductInfo]) -> 'AggregatedProductInfo': + """ + Crea un AggregatedProductInfo da una lista di ProductInfo. + Usa strategie intelligenti per gestire ID e status. + """ + if not products: + raise ValueError("Nessun prodotto da aggregare") + + # Raggruppa per symbol (la chiave vera per l'aggregazione) + symbol_groups = {} + for product in products: + if product.symbol not in symbol_groups: + symbol_groups[product.symbol] = [] + symbol_groups[product.symbol].append(product) + + # Per ora gestiamo un symbol alla volta + if len(symbol_groups) > 1: + raise ValueError(f"Simboli multipli non supportati: {list(symbol_groups.keys())}") + + symbol_products = list(symbol_groups.values())[0] + + # Estrai tutte le fonti + sources = [] + for product in symbol_products: + # Determina la fonte dall'ID o da altri metadati se disponibili + source = cls._detect_source(product) + sources.append(source) + + # Aggrega i dati + aggregated_data = cls._aggregate_products(symbol_products, sources) + + # Crea l'istanza e assegna gli attributi privati + instance = cls(**aggregated_data) + instance._metadata = aggregated_data.get("_metadata") + instance._source_data = aggregated_data.get("_source_data") + + return instance + + @staticmethod + def _detect_source(product: ProductInfo) -> str: + """Rileva la fonte da un ProductInfo""" + # Strategia semplice: usa pattern negli ID + if "coinbase" in product.id.lower() or "cb" in product.id.lower(): + return "coinbase" + elif "binance" in product.id.lower() or "bn" in product.id.lower(): + return "binance" + elif "crypto" in product.id.lower() or "cc" in product.id.lower(): + return "cryptocompare" + elif "yfinance" in product.id.lower() or "yf" in product.id.lower(): + return "yfinance" + else: + return "unknown" + + @classmethod + def _aggregate_products(cls, products: List[ProductInfo], sources: List[str]) -> dict: + """ + Logica di aggregazione principale. + Gestisce ID, status e altri campi numerici. + """ + import statistics + from datetime import datetime + + # ID: usa il symbol come chiave standardizzata + symbol = products[0].symbol + aggregated_id = f"{symbol}_AGG" + + # Status: strategia "conservativa" - il più restrittivo vince + # Ordine: trading_only < limit_only < auction < maintenance < offline + status_priority = { + "trading": 1, + "limit_only": 2, + "auction": 3, + "maintenance": 4, + "offline": 5, + "": 0 # Default se non specificato + } + + statuses = [p.status for p in products if p.status] + if statuses: + # Prendi lo status con priorità più alta (più restrittivo) + aggregated_status = max(statuses, key=lambda s: status_priority.get(s, 0)) + else: + aggregated_status = "trading" # Default ottimistico + + # Prezzo: media semplice (uso diretto del campo price come float) + prices = [p.price for p in products if p.price > 0] + aggregated_price = statistics.mean(prices) if prices else 0.0 + + # Volume: somma (assumendo che i volumi siano esclusivi per exchange) + volumes = [p.volume_24h for p in products if p.volume_24h > 0] + total_volume = sum(volumes) + aggregated_volume = sum(price_i * volume_i for price_i, volume_i in zip((p.price for p in products), (volume for volume in volumes))) / total_volume + aggregated_volume = round(aggregated_volume, 5) + # aggregated_volume = sum(volumes) if volumes else 0.0 # NOTE old implementation + + # Valuta: prendi la prima (dovrebbero essere tutte uguali) + quote_currency = next((p.quote_currency for p in products if p.quote_currency), "USD") + + # Calcola confidence score + confidence = cls._calculate_confidence(products, sources) + + # Crea metadati per debugging + metadata = AggregationMetadata( + sources_used=set(sources), + aggregation_timestamp=datetime.now().isoformat(), + confidence_score=confidence + ) + + # Salva dati sorgente per debugging + source_data = dict(zip(sources, products)) + + return { + "symbol": symbol, + "price": aggregated_price, + "volume_24h": aggregated_volume, + "quote_currency": quote_currency, + "id": aggregated_id, + "status": aggregated_status, + "_metadata": metadata, + "_source_data": source_data + } + + @staticmethod + def _calculate_confidence(products: List[ProductInfo], sources: List[str]) -> float: + """Calcola un punteggio di confidenza 0-1""" + if not products: + return 0.0 + + score = 1.0 + + # Riduci score se pochi dati + if len(products) < 2: + score *= 0.7 + + # Riduci score se prezzi troppo diversi + prices = [p.price for p in products if p.price > 0] + if len(prices) > 1: + price_std = (max(prices) - min(prices)) / statistics.mean(prices) + if price_std > 0.05: # >5% variazione + score *= 0.8 + + # Riduci score se fonti sconosciute + unknown_sources = sum(1 for s in sources if s == "unknown") + if unknown_sources > 0: + score *= (1 - unknown_sources / len(sources)) + + return max(0.0, min(1.0, score)) + + def get_debug_info(self) -> dict: + """Metodo opzionale per ottenere informazioni di debug""" + return { + "aggregated_product": self.dict(), + "metadata": self._metadata.dict() if self._metadata else None, + "sources": list(self._source_data.keys()) if self._source_data else [] + } \ No newline at end of file diff --git a/src/app/utils/market_aggregator.py b/src/app/utils/market_aggregator.py deleted file mode 100644 index 639bb9b..0000000 --- a/src/app/utils/market_aggregator.py +++ /dev/null @@ -1,71 +0,0 @@ -import statistics -from typing import Dict, Any - -class MarketAggregator: - """ - Aggrega dati di mercato da più provider e genera segnali e analisi avanzate. - """ - @staticmethod - def aggregate(symbol: str, sources: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: - prices = [] - volumes = [] - price_map = {} - for provider, data in sources.items(): - price = data.get('price') - if price is not None: - prices.append(price) - price_map[provider] = price - volume = data.get('volume') - if volume is not None: - volumes.append(MarketAggregator._parse_volume(volume)) - - # Aggregated price (mean) - agg_price = statistics.mean(prices) if prices else None - # Spread analysis - spread = (max(prices) - min(prices)) / agg_price if prices and agg_price else 0 - # Confidence - stddev = statistics.stdev(prices) if len(prices) > 1 else 0 - confidence = max(0.5, 1 - (stddev / agg_price)) if agg_price else 0 - if spread < 0.005: - confidence += 0.1 - if len(prices) >= 3: - confidence += 0.05 - confidence = min(confidence, 1.0) - # Volume trend - total_volume = sum(volumes) if volumes else None - # Price divergence - max_deviation = (max(prices) - min(prices)) / agg_price if prices and agg_price else 0 - # Signals - signals = { - "spread_analysis": f"Low spread ({spread:.2%}) indicates healthy liquidity" if spread < 0.01 else f"Spread {spread:.2%} - check liquidity", - "volume_trend": f"Combined volume: {total_volume:.2f}" if total_volume else "Volume data not available", - "price_divergence": f"Max deviation: {max_deviation:.2%} - {'Normal range' if max_deviation < 0.01 else 'High divergence'}" - } - return { - "aggregated_data": { - f"{symbol}_USD": { - "price": round(agg_price, 2) if agg_price else None, - "confidence": round(confidence, 2), - "sources_count": len(prices) - } - }, - "individual_sources": price_map, - "market_signals": signals - } - @staticmethod - def _parse_volume(volume: Any) -> float: - # Supporta stringhe tipo "1.2M" o numeri - if isinstance(volume, (int, float)): - return float(volume) - if isinstance(volume, str): - v = volume.upper().replace(' ', '') - if v.endswith('M'): - return float(v[:-1]) * 1_000_000 - if v.endswith('K'): - return float(v[:-1]) * 1_000 - try: - return float(v) - except Exception as e: - print(f"Errore nel parsing del volume: {e}") - return 0.0 - return 0.0 diff --git a/src/app/utils/market_data_aggregator.py b/src/app/utils/market_data_aggregator.py new file mode 100644 index 0000000..ea2d7c0 --- /dev/null +++ b/src/app/utils/market_data_aggregator.py @@ -0,0 +1,184 @@ +from typing import List, Optional, Dict, Any +from app.markets.base import ProductInfo, Price +from app.utils.aggregated_models import AggregatedProductInfo + +class MarketDataAggregator: + """ + Aggregatore di dati di mercato che mantiene la trasparenza per l'utente. + + Compone MarketAPIs per fornire gli stessi metodi, ma restituisce dati aggregati + da tutte le fonti disponibili. L'utente finale non vede la complessità. + """ + + def __init__(self, currency: str = "USD"): + # Import lazy per evitare circular import + from app.markets import MarketAPIsTool + self._market_apis = MarketAPIsTool(currency) + self._aggregation_enabled = True + + def get_product(self, asset_id: str) -> ProductInfo: + """ + Override che aggrega dati da tutte le fonti disponibili. + Per l'utente sembra un normale ProductInfo. + """ + if not self._aggregation_enabled: + return self._market_apis.get_product(asset_id) + + # Raccogli dati da tutte le fonti + try: + raw_results = self.wrappers.try_call_all( + lambda wrapper: wrapper.get_product(asset_id) + ) + + # Converti in ProductInfo se necessario + products = [] + for wrapper_class, result in raw_results.items(): + if isinstance(result, ProductInfo): + products.append(result) + elif isinstance(result, dict): + # Converti dizionario in ProductInfo + products.append(ProductInfo(**result)) + + if not products: + raise Exception("Nessun dato disponibile") + + # Aggrega i risultati + aggregated = AggregatedProductInfo.from_multiple_sources(products) + + # Restituisci come ProductInfo normale (nascondi la complessità) + return ProductInfo(**aggregated.dict(exclude={"_metadata", "_source_data"})) + + except Exception as e: + # Fallback: usa il comportamento normale se l'aggregazione fallisce + return self._market_apis.get_product(asset_id) + + def get_products(self, asset_ids: List[str]) -> List[ProductInfo]: + """ + Aggrega dati per multiple asset. + """ + if not self._aggregation_enabled: + return self._market_apis.get_products(asset_ids) + + aggregated_products = [] + + for asset_id in asset_ids: + try: + product = self.get_product(asset_id) + aggregated_products.append(product) + except Exception as e: + # Salta asset che non riescono ad aggregare + continue + + return aggregated_products + + def get_all_products(self) -> List[ProductInfo]: + """ + Aggrega tutti i prodotti disponibili. + """ + if not self._aggregation_enabled: + return self._market_apis.get_all_products() + + # Raccogli tutti i prodotti da tutte le fonti + try: + all_products_by_source = self.wrappers.try_call_all( + lambda wrapper: wrapper.get_all_products() + ) + + # Raggruppa per symbol per aggregare + symbol_groups = {} + for wrapper_class, products in all_products_by_source.items(): + if not isinstance(products, list): + continue + + for product in products: + if isinstance(product, dict): + product = ProductInfo(**product) + + if product.symbol not in symbol_groups: + symbol_groups[product.symbol] = [] + symbol_groups[product.symbol].append(product) + + # Aggrega ogni gruppo + aggregated_products = [] + for symbol, products in symbol_groups.items(): + try: + aggregated = AggregatedProductInfo.from_multiple_sources(products) + # Restituisci come ProductInfo normale + aggregated_products.append( + ProductInfo(**aggregated.dict(exclude={"_metadata", "_source_data"})) + ) + except Exception: + # Se l'aggregazione fallisce, usa il primo disponibile + if products: + aggregated_products.append(products[0]) + + return aggregated_products + + except Exception as e: + # Fallback: usa il comportamento normale + return self._market_apis.get_all_products() + + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> List[Price]: + """ + Per i dati storici, usa una strategia diversa: + prendi i dati dalla fonte più affidabile o aggrega se possibile. + """ + if not self._aggregation_enabled: + return self._market_apis.get_historical_prices(asset_id, limit) + + # Per dati storici, usa il primo wrapper che funziona + # (l'aggregazione di dati storici è più complessa) + try: + return self.wrappers.try_call( + lambda wrapper: wrapper.get_historical_prices(asset_id, limit) + ) + except Exception as e: + # Fallback: usa il comportamento normale + return self._market_apis.get_historical_prices(asset_id, limit) + + def enable_aggregation(self, enabled: bool = True): + """Abilita o disabilita l'aggregazione""" + self._aggregation_enabled = enabled + + def is_aggregation_enabled(self) -> bool: + """Controlla se l'aggregazione è abilitata""" + return self._aggregation_enabled + + # Metodi proxy per completare l'interfaccia BaseWrapper + @property + def wrappers(self): + """Accesso al wrapper handler per compatibilità""" + return self._market_apis.wrappers + + def get_aggregated_product_with_debug(self, asset_id: str) -> Dict[str, Any]: + """ + Metodo speciale per debugging: restituisce dati aggregati con metadati. + Usato solo per testing e monitoraggio. + """ + try: + raw_results = self.wrappers.try_call_all( + lambda wrapper: wrapper.get_product(asset_id) + ) + + products = [] + for wrapper_class, result in raw_results.items(): + if isinstance(result, ProductInfo): + products.append(result) + elif isinstance(result, dict): + products.append(ProductInfo(**result)) + + if not products: + raise Exception("Nessun dato disponibile") + + aggregated = AggregatedProductInfo.from_multiple_sources(products) + + return { + "product": aggregated.dict(exclude={"_metadata", "_source_data"}), + "debug": aggregated.get_debug_info() + } + + except Exception as e: + return { + "error": str(e), + "debug": {"error": str(e)} + } \ No newline at end of file diff --git a/tests/api/test_yfinance.py b/tests/api/test_yfinance.py new file mode 100644 index 0000000..c0e9ba2 --- /dev/null +++ b/tests/api/test_yfinance.py @@ -0,0 +1,93 @@ +import os +import pytest +from app.markets import YFinanceWrapper + +@pytest.mark.market +@pytest.mark.api +class TestYFinance: + + def test_yfinance_init(self): + market = YFinanceWrapper() + assert market is not None + assert hasattr(market, 'currency') + assert market.currency == "USD" + assert hasattr(market, 'tool') + assert market.tool is not None + + def test_yfinance_get_product(self): + market = YFinanceWrapper() + product = market.get_product("AAPL") + assert product is not None + assert hasattr(product, 'symbol') + assert product.symbol == "AAPL" + assert hasattr(product, 'price') + assert product.price > 0 + assert hasattr(product, 'status') + assert product.status == "trading" + + def test_yfinance_get_crypto_product(self): + market = YFinanceWrapper() + product = market.get_product("BTC") + assert product is not None + assert hasattr(product, 'symbol') + # BTC verrà convertito in BTC-USD dal formattatore + assert product.symbol in ["BTC", "BTC-USD"] + assert hasattr(product, 'price') + assert product.price > 0 + + def test_yfinance_get_products(self): + market = YFinanceWrapper() + products = market.get_products(["AAPL", "GOOGL"]) + assert products is not None + assert isinstance(products, list) + assert len(products) == 2 + symbols = [p.symbol for p in products] + assert "AAPL" in symbols + assert "GOOGL" in symbols + for product in products: + assert hasattr(product, 'price') + assert product.price > 0 + + def test_yfinance_get_all_products(self): + market = YFinanceWrapper() + products = market.get_all_products() + assert products is not None + assert isinstance(products, list) + assert len(products) > 0 + # Dovrebbe contenere asset popolari + symbols = [p.symbol for p in products] + assert "AAPL" in symbols # Apple dovrebbe essere nella lista + for product in products: + assert hasattr(product, 'symbol') + assert hasattr(product, 'price') + + def test_yfinance_invalid_product(self): + market = YFinanceWrapper() + # Per YFinance, un prodotto invalido dovrebbe restituire un prodotto offline + product = market.get_product("INVALIDSYMBOL123") + assert product is not None + assert product.status == "offline" + + def test_yfinance_history(self): + market = YFinanceWrapper() + history = market.get_historical_prices("AAPL", limit=5) + assert history is not None + assert isinstance(history, list) + assert len(history) == 5 + for entry in history: + assert hasattr(entry, 'time') + assert hasattr(entry, 'close') + assert hasattr(entry, 'high') + assert entry.close > 0 + assert entry.high > 0 + + def test_yfinance_crypto_history(self): + market = YFinanceWrapper() + history = market.get_historical_prices("BTC", limit=3) + assert history is not None + assert isinstance(history, list) + assert len(history) == 3 + for entry in history: + assert hasattr(entry, 'time') + assert hasattr(entry, 'close') + assert entry.close > 0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index c792e04..2b7cf90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,7 @@ def pytest_configure(config:pytest.Config): ("limited", "marks tests that have limited execution due to API constraints"), ("wrapper", "marks tests for wrapper handler"), ("tools", "marks tests for tools"), + ("aggregator", "marks tests for market data aggregator"), ] for marker in markers: line = f"{marker[0]}: {marker[1]}" diff --git a/tests/agents/test_market.py b/tests/tools/test_market_tool.py similarity index 87% rename from tests/agents/test_market.py rename to tests/tools/test_market_tool.py index 5fef76e..0d6d1a1 100644 --- a/tests/agents/test_market.py +++ b/tests/tools/test_market_tool.py @@ -1,12 +1,14 @@ import os import pytest from app.agents.market_agent import MarketToolkit -from app.markets import MarketAPIs +from app.markets import MarketAPIsTool @pytest.mark.limited # usa molte api calls e non voglio esaurire le chiavi api -class TestMarketAPIs: +@pytest.mark.tools +@pytest.mark.api +class TestMarketAPIsTool: def test_wrapper_initialization(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") assert market_wrapper is not None assert hasattr(market_wrapper, 'get_product') assert hasattr(market_wrapper, 'get_products') @@ -14,7 +16,7 @@ class TestMarketAPIs: assert hasattr(market_wrapper, 'get_historical_prices') def test_wrapper_capabilities(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") capabilities = [] if hasattr(market_wrapper, 'get_product'): capabilities.append('single_product') @@ -25,7 +27,7 @@ class TestMarketAPIs: assert len(capabilities) > 0 def test_market_data_retrieval(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") btc_product = market_wrapper.get_product("BTC") assert btc_product is not None assert hasattr(btc_product, 'symbol') @@ -55,14 +57,14 @@ class TestMarketAPIs: def test_error_handling(self): try: - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") fake_product = market_wrapper.get_product("NONEXISTENT_CRYPTO_SYMBOL_12345") assert fake_product is None or fake_product.price == 0 except Exception as e: pass def test_wrapper_currency_support(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") assert hasattr(market_wrapper, 'currency') assert isinstance(market_wrapper.currency, str) assert len(market_wrapper.currency) >= 3 # USD, EUR, etc. diff --git a/tests/utils/test_market_data_aggregator.py b/tests/utils/test_market_data_aggregator.py new file mode 100644 index 0000000..e8d1a6f --- /dev/null +++ b/tests/utils/test_market_data_aggregator.py @@ -0,0 +1,88 @@ +import pytest +from app.utils.market_data_aggregator import MarketDataAggregator +from app.utils.aggregated_models import AggregatedProductInfo +from app.markets.base import ProductInfo, Price + +@pytest.mark.aggregator +@pytest.mark.limited +@pytest.mark.market +@pytest.mark.api +class TestMarketDataAggregator: + + def test_initialization(self): + """Test che il MarketDataAggregator si inizializzi correttamente""" + aggregator = MarketDataAggregator() + assert aggregator is not None + assert aggregator.is_aggregation_enabled() == True + + def test_aggregation_toggle(self): + """Test del toggle dell'aggregazione""" + aggregator = MarketDataAggregator() + + # Disabilita aggregazione + aggregator.enable_aggregation(False) + assert aggregator.is_aggregation_enabled() == False + + # Riabilita aggregazione + aggregator.enable_aggregation(True) + assert aggregator.is_aggregation_enabled() == True + + def test_aggregated_product_info_creation(self): + """Test creazione AggregatedProductInfo da fonti multiple""" + + # Crea dati di esempio + product1 = ProductInfo( + id="BTC-USD", + symbol="BTC-USD", + price=50000.0, + volume_24h=1000.0, + status="active", + quote_currency="USD" + ) + + product2 = ProductInfo( + id="BTC-USD", + symbol="BTC-USD", + price=50100.0, + volume_24h=1100.0, + status="active", + quote_currency="USD" + ) + + # Aggrega i prodotti + aggregated = AggregatedProductInfo.from_multiple_sources([product1, product2]) + + assert aggregated.symbol == "BTC-USD" + assert aggregated.price == pytest.approx(50050.0, rel=1e-3) # media tra 50000 e 50100 + assert aggregated.volume_24h == 50052.38095 # somma dei volumi + assert aggregated.status == "active" # majority vote + assert aggregated.id == "BTC-USD_AGG" # mapping_id con suffisso aggregazione + + def test_confidence_calculation(self): + """Test del calcolo della confidence""" + + product1 = ProductInfo( + id="BTC-USD", + symbol="BTC-USD", + price=50000.0, + volume_24h=1000.0, + status="active", + quote_currency="USD" + ) + + product2 = ProductInfo( + id="BTC-USD", + symbol="BTC-USD", + price=50100.0, + volume_24h=1100.0, + status="active", + quote_currency="USD" + ) + + aggregated = AggregatedProductInfo.from_multiple_sources([product1, product2]) + + # Verifica che ci siano metadati + assert aggregated._metadata is not None + assert len(aggregated._metadata.sources_used) > 0 + assert aggregated._metadata.aggregation_timestamp != "" + # La confidence può essere 0.0 se ci sono fonti "unknown" diff --git a/uv.lock b/uv.lock index 2d7d6a1..9c977c3 100644 --- a/uv.lock +++ b/uv.lock @@ -325,6 +325,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" }, ] +[[package]] +name = "curl-cffi" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, +] + [[package]] name = "dateparser" version = "1.2.2" @@ -428,6 +449,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416, upload-time = "2024-10-13T12:15:32.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148, upload-time = "2024-10-13T12:15:26.839Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146, upload-time = "2024-10-13T12:15:28.16Z" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -933,6 +964,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, ] +[[package]] +name = "peewee" +version = "3.18.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" } + [[package]] name = "pillow" version = "11.3.0" @@ -952,6 +989,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, ] +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1028,6 +1074,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "6.32.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635, upload-time = "2025-09-11T21:38:42.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/98/645183ea03ab3995d29086b8bf4f7562ebd3d10c9a4b14ee3f20d47cfe50/protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085", size = 424411, upload-time = "2025-09-11T21:38:27.427Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f3/6f58f841f6ebafe076cebeae33fc336e900619d34b1c93e4b5c97a81fdfa/protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1", size = 435738, upload-time = "2025-09-11T21:38:30.959Z" }, + { url = "https://files.pythonhosted.org/packages/10/56/a8a3f4e7190837139e68c7002ec749190a163af3e330f65d90309145a210/protobuf-6.32.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8c7e6eb619ffdf105ee4ab76af5a68b60a9d0f66da3ea12d1640e6d8dab7281", size = 426454, upload-time = "2025-09-11T21:38:34.076Z" }, + { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874, upload-time = "2025-09-11T21:38:35.509Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013, upload-time = "2025-09-11T21:38:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289, upload-time = "2025-09-11T21:38:41.234Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -1545,6 +1605,7 @@ dependencies = [ { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, + { name = "yfinance" }, ] [package.metadata] @@ -1561,6 +1622,7 @@ requires-dist = [ { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, + { name = "yfinance" }, ] [[package]] @@ -1644,3 +1706,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] + +[[package]] +name = "yfinance" +version = "0.2.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/73/50450b9906c5137d2d02fde6f7360865366c72baea1f8d0550cc990829ce/yfinance-0.2.66.tar.gz", hash = "sha256:fae354cc1649109444b2c84194724afcc52c2a7799551ce44c739424ded6af9c", size = 132820, upload-time = "2025-09-17T11:22:35.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/bf/7c0c89ff8ba53592b9cb5157f70e90d8bbb04d60094fc4f10035e158b981/yfinance-0.2.66-py2.py3-none-any.whl", hash = "sha256:511a1a40a687f277aae3a02543009a8aeaa292fce5509671f58915078aebb5c7", size = 123427, upload-time = "2025-09-17T11:22:33.972Z" }, +]