diff --git a/.env.example b/.env.example index 0bef205..4cfc34a 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -########################################################################### +############################################################################### # Configurazioni per i modelli di linguaggio ############################################################################### @@ -10,15 +10,32 @@ GOOGLE_API_KEY= # Configurazioni per gli agenti di mercato ############################################################################### -# Coinbase CDP API per Market Agent -# Ottenibili da: https://portal.cdp.coinbase.com/access/api -CDP_API_KEY_NAME= -CDP_API_PRIVATE_KEY= +# https://portal.cdp.coinbase.com/access/api +COINBASE_API_KEY= +COINBASE_API_SECRET= -# CryptoCompare API per Market Agent (alternativa) -# Ottenibile da: https://www.cryptocompare.com/cryptopian/api-keys +# https://www.cryptocompare.com/cryptopian/api-keys CRYPTOCOMPARE_API_KEY= -# Binance API per Market Agent (alternativa) +# https://www.binance.com/en/my/settings/api-management +# Non necessario per operazioni in sola lettura BINANCE_API_KEY= BINANCE_API_SECRET= + +############################################################################### +# Configurazioni per gli agenti di notizie +############################################################################### + +# https://newsapi.org/docs +NEWS_API_KEY= + +# https://cryptopanic.com/developers/api/ +CRYPTOPANIC_API_KEY= + +############################################################################### +# Configurazioni per API di social media +############################################################################### + +# https://www.reddit.com/prefs/apps +REDDIT_API_CLIENT_ID= +REDDIT_API_CLIENT_SECRET= diff --git a/demos/cdp_market_demo.py b/demos/cdp_market_demo.py deleted file mode 100644 index 307d02f..0000000 --- a/demos/cdp_market_demo.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -""" -Demo script per testare il MarketAgent aggiornato con Coinbase CDP -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -from src.app.agents.market_agent import MarketAgent -import logging - -# Setup logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) - -def main(): - print("🚀 Test MarketAgent con Coinbase CDP") - print("=" * 50) - - # Inizializza l'agent - agent = MarketAgent() - - # Verifica provider disponibili - providers = agent.get_available_providers() - print(f"📡 Provider disponibili: {providers}") - - if not providers: - print("⚠️ Nessun provider configurato. Verifica il file .env") - print("\nPer Coinbase CDP, serve:") - print("CDP_API_KEY_NAME=your_key_name") - print("CDP_API_PRIVATE_KEY=your_private_key") - print("\nPer CryptoCompare, serve:") - print("CRYPTOCOMPARE_API_KEY=your_api_key") - return - - # Mostra capabilities di ogni provider - for provider in providers: - capabilities = agent.get_provider_capabilities(provider) - print(f"🔧 {provider.upper()}: {capabilities}") - - print("\n" + "=" * 50) - - # Test ottenimento prezzo singolo - test_symbols = ["BTC", "ETH", "ADA"] - - for symbol in test_symbols: - print(f"\n💰 Prezzo {symbol}:") - - # Prova ogni provider - for provider in providers: - try: - price = agent.get_asset_price(symbol, provider) - if price: - print(f" {provider}: ${price:,.2f}") - else: - print(f" {provider}: N/A") - except Exception as e: - print(f" {provider}: Errore - {e}") - - print("\n" + "=" * 50) - - # Test market overview - print("\n📊 Market Overview:") - try: - overview = agent.get_market_overview(["BTC", "ETH", "ADA", "DOT"]) - - if overview["data"]: - print(f"📡 Fonte: {overview['source']}") - - for crypto, prices in overview["data"].items(): - if isinstance(prices, dict): - usd_price = prices.get("USD", "N/A") - eur_price = prices.get("EUR", "N/A") - - if eur_price != "N/A": - print(f" {crypto}: ${usd_price} (€{eur_price})") - else: - print(f" {crypto}: ${usd_price}") - else: - print("⚠️ Nessun dato disponibile") - - except Exception as e: - print(f"❌ Errore nel market overview: {e}") - - print("\n" + "=" * 50) - - # Test funzione analyze - print("\n🔍 Analisi mercato:") - try: - analysis = agent.analyze("Market overview") - print(analysis) - except Exception as e: - print(f"❌ Errore nell'analisi: {e}") - - # Test specifico Coinbase CDP se disponibile - if 'coinbase' in providers: - print("\n" + "=" * 50) - print("\n🏦 Test specifico Coinbase CDP:") - - try: - # Test asset singolo - btc_info = agent.get_coinbase_asset_info("BTC") - print(f"📈 BTC Info: {btc_info}") - - # Test asset multipli - multi_assets = agent.get_coinbase_multiple_assets(["BTC", "ETH"]) - print(f"📊 Multi Assets: {multi_assets}") - - except Exception as e: - print(f"❌ Errore nel test Coinbase CDP: {e}") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/demos/market_agent_demo.py b/demos/market_agent_demo.py deleted file mode 100644 index 1ef8f21..0000000 --- a/demos/market_agent_demo.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -""" -Esempio di utilizzo del MarketAgent unificato. -Questo script mostra come utilizzare il nuovo MarketAgent che supporta -multiple fonti di dati (Coinbase e CryptoCompare). -""" - -import sys -from pathlib import Path - -# Aggiungi il path src al PYTHONPATH -src_path = Path(__file__).parent / "src" -sys.path.insert(0, str(src_path)) - -from dotenv import load_dotenv -from app.agents.market_agent import MarketAgent - -# Carica variabili d'ambiente -load_dotenv() - -def main(): - print("🚀 Market Agent Demo\n") - - try: - # Inizializza il market agent (auto-configura i provider disponibili) - agent = MarketAgent() - - # Mostra provider disponibili - providers = agent.get_available_providers() - print(f"📡 Available providers: {providers}") - - if not providers: - print("❌ No providers configured. Please check your .env file.") - print("Required variables:") - print(" For Coinbase: COINBASE_API_KEY, COINBASE_SECRET, COINBASE_PASSPHRASE") - print(" For CryptoCompare: CRYPTOCOMPARE_API_KEY") - return - - # Mostra le capacità di ogni provider - print("\n🔧 Provider capabilities:") - for provider in providers: - capabilities = agent.get_provider_capabilities(provider) - print(f" {provider}: {capabilities}") - - # Ottieni panoramica del mercato - print("\n📊 Market Overview:") - overview = agent.get_market_overview(["BTC", "ETH", "ADA"]) - print(f"Data source: {overview.get('source', 'Unknown')}") - - for crypto, prices in overview.get('data', {}).items(): - if isinstance(prices, dict): - usd = prices.get('USD', 'N/A') - eur = prices.get('EUR', 'N/A') - if eur != 'N/A': - print(f" {crypto}: ${usd} (€{eur})") - else: - print(f" {crypto}: ${usd}") - - # Analisi completa del mercato - print("\n📈 Market Analysis:") - analysis = agent.analyze("comprehensive market analysis") - print(analysis) - - # Test specifici per provider (se disponibili) - if 'cryptocompare' in providers: - print("\n🔸 CryptoCompare specific test:") - try: - btc_price = agent.get_single_crypto_price("BTC", "USD") - print(f" BTC price: ${btc_price}") - - top_coins = agent.get_top_cryptocurrencies(5) - if top_coins.get('Data'): - print(" Top 5 cryptocurrencies by market cap:") - for coin in top_coins['Data'][:3]: # Show top 3 - coin_info = coin.get('CoinInfo', {}) - display = coin.get('DISPLAY', {}).get('USD', {}) - name = coin_info.get('FullName', 'Unknown') - price = display.get('PRICE', 'N/A') - print(f" {name}: {price}") - except Exception as e: - print(f" CryptoCompare test failed: {e}") - - if 'coinbase' in providers: - print("\n🔸 Coinbase specific test:") - try: - ticker = agent.get_coinbase_ticker("BTC-USD") - price = ticker.get('price', 'N/A') - volume = ticker.get('volume_24h', 'N/A') - print(f" BTC-USD: ${price} (24h volume: {volume})") - except Exception as e: - print(f" Coinbase test failed: {e}") - - print("\n✅ Demo completed successfully!") - - except Exception as e: - print(f"❌ Demo failed: {e}") - print("Make sure you have configured at least one provider in your .env file.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py new file mode 100644 index 0000000..fea2245 --- /dev/null +++ b/demos/market_providers_api_demo.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +Demo Completo per Market Data Providers +======================================== + +Questo script dimostra l'utilizzo di tutti i wrapper che implementano BaseWrapper: +- CoinBaseWrapper (richiede credenziali) +- CryptoCompareWrapper (richiede API key) +- BinanceWrapper (richiede credenziali) +- PublicBinanceAgent (accesso pubblico) + +Lo script effettua chiamate GET a diversi provider e visualizza i dati +in modo strutturato con informazioni dettagliate su timestamp, stato +delle richieste e formattazione tabellare. +""" + +import sys +import os +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Optional, Any +import traceback + +# Aggiungi il path src al PYTHONPATH +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root / "src")) + +from dotenv import load_dotenv +from app.markets import ( + CoinBaseWrapper, + CryptoCompareWrapper, + BinanceWrapper, + BaseWrapper +) + +# Carica variabili d'ambiente +load_dotenv() + +class DemoFormatter: + """Classe per formattare l'output del demo in modo strutturato.""" + + @staticmethod + def print_header(title: str, char: str = "=", width: int = 80): + """Stampa un'intestazione formattata.""" + print(f"\n{char * width}") + print(f"{title:^{width}}") + print(f"{char * width}") + + @staticmethod + def print_subheader(title: str, char: str = "-", width: int = 60): + """Stampa una sotto-intestazione formattata.""" + print(f"\n{char * width}") + print(f" {title}") + print(f"{char * width}") + + @staticmethod + def print_request_info(provider_name: str, method: str, timestamp: datetime, + status: str, error: Optional[str] = None): + """Stampa informazioni sulla richiesta.""" + print(f"🕒 Timestamp: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"🏷️ Provider: {provider_name}") + print(f"🔧 Method: {method}") + print(f"📊 Status: {status}") + if error: + print(f"❌ Error: {error}") + print() + + @staticmethod + def print_product_table(products: List[Any], title: str = "Products"): + """Stampa una tabella di prodotti.""" + if not products: + print(f"📋 {title}: Nessun prodotto trovato") + return + + print(f"📋 {title} ({len(products)} items):") + print(f"{'Symbol':<15} {'ID':<20} {'Price':<12} {'Quote':<10} {'Status':<10}") + print("-" * 67) + + for product in products[:10]: # Mostra solo i primi 10 + symbol = getattr(product, 'symbol', 'N/A') + product_id = getattr(product, 'id', 'N/A') + price = getattr(product, 'price', 0.0) + quote = getattr(product, 'quote_currency', 'N/A') + status = getattr(product, 'status', 'N/A') + + # Tronca l'ID se troppo lungo + if len(product_id) > 18: + product_id = product_id[:15] + "..." + + price_str = f"${price:.2f}" if price > 0 else "N/A" + + print(f"{symbol:<15} {product_id:<20} {price_str:<12} {quote:<10} {status:<10}") + + if len(products) > 10: + print(f"... e altri {len(products) - 10} prodotti") + print() + + @staticmethod + def print_prices_table(prices: List[Any], title: str = "Historical Prices"): + """Stampa una tabella di prezzi storici.""" + if not prices: + print(f"💰 {title}: Nessun prezzo trovato") + return + + print(f"💰 {title} ({len(prices)} entries):") + print(f"{'Time':<12} {'Open':<12} {'High':<12} {'Low':<12} {'Close':<12} {'Volume':<15}") + print("-" * 75) + + for price in prices[:5]: # Mostra solo i primi 5 + time_str = getattr(price, 'time', 'N/A') + # Il time è già una stringa, non serve strftime + if len(time_str) > 10: + time_str = time_str[:10] # Tronca se troppo lungo + + open_price = f"${getattr(price, 'open', 0):.2f}" + high_price = f"${getattr(price, 'high', 0):.2f}" + low_price = f"${getattr(price, 'low', 0):.2f}" + close_price = f"${getattr(price, 'close', 0):.2f}" + volume = f"{getattr(price, 'volume', 0):,.0f}" + + print(f"{time_str:<12} {open_price:<12} {high_price:<12} {low_price:<12} {close_price:<12} {volume:<15}") + + if len(prices) > 5: + print(f"... e altri {len(prices) - 5} prezzi") + print() + +class ProviderTester: + """Classe per testare i provider di market data.""" + + def __init__(self): + self.formatter = DemoFormatter() + self.test_symbols = ["BTC", "ETH", "ADA"] + + def test_provider(self, wrapper: BaseWrapper, provider_name: str) -> Dict[str, Any]: + """Testa un provider specifico con tutti i metodi disponibili.""" + results = { + "provider_name": provider_name, + "tests": {}, + "overall_status": "SUCCESS" + } + + self.formatter.print_subheader(f"🔍 Testing {provider_name}") + + # Test get_product + for symbol in self.test_symbols: + timestamp = datetime.now() + try: + product = wrapper.get_product(symbol) + self.formatter.print_request_info( + provider_name, f"get_product({symbol})", timestamp, "✅ SUCCESS" + ) + if product: + print(f"📦 Product: {product.symbol} (ID: {product.id})") + print(f" Price: ${product.price:.2f}, Quote: {product.quote_currency}") + print(f" Status: {product.status}, Volume 24h: {product.volume_24h:,.2f}") + else: + print(f"📦 Product: Nessun prodotto trovato per {symbol}") + + results["tests"][f"get_product_{symbol}"] = "SUCCESS" + + except Exception as e: + error_msg = str(e) + self.formatter.print_request_info( + provider_name, f"get_product({symbol})", timestamp, "❌ ERROR", error_msg + ) + results["tests"][f"get_product_{symbol}"] = f"ERROR: {error_msg}" + results["overall_status"] = "PARTIAL" + + # Test get_products + timestamp = datetime.now() + try: + products = wrapper.get_products(self.test_symbols) + self.formatter.print_request_info( + provider_name, f"get_products({self.test_symbols})", timestamp, "✅ SUCCESS" + ) + self.formatter.print_product_table(products, f"{provider_name} Products") + results["tests"]["get_products"] = "SUCCESS" + + except Exception as e: + error_msg = str(e) + self.formatter.print_request_info( + provider_name, f"get_products({self.test_symbols})", timestamp, "❌ ERROR", error_msg + ) + results["tests"]["get_products"] = f"ERROR: {error_msg}" + results["overall_status"] = "PARTIAL" + + # Test get_all_products + timestamp = datetime.now() + try: + all_products = wrapper.get_all_products() + self.formatter.print_request_info( + provider_name, "get_all_products()", timestamp, "✅ SUCCESS" + ) + self.formatter.print_product_table(all_products, f"{provider_name} All Products") + results["tests"]["get_all_products"] = "SUCCESS" + + except Exception as e: + error_msg = str(e) + self.formatter.print_request_info( + provider_name, "get_all_products()", timestamp, "❌ ERROR", error_msg + ) + results["tests"]["get_all_products"] = f"ERROR: {error_msg}" + results["overall_status"] = "PARTIAL" + + # Test get_historical_prices + timestamp = datetime.now() + try: + prices = wrapper.get_historical_prices("BTC") + self.formatter.print_request_info( + provider_name, "get_historical_prices(BTC)", timestamp, "✅ SUCCESS" + ) + self.formatter.print_prices_table(prices, f"{provider_name} BTC Historical Prices") + results["tests"]["get_historical_prices"] = "SUCCESS" + + except Exception as e: + error_msg = str(e) + self.formatter.print_request_info( + provider_name, "get_historical_prices(BTC)", timestamp, "❌ ERROR", error_msg + ) + results["tests"]["get_historical_prices"] = f"ERROR: {error_msg}" + results["overall_status"] = "PARTIAL" + + return results + +def check_environment_variables() -> Dict[str, bool]: + """Verifica la presenza delle variabili d'ambiente necessarie.""" + env_vars = { + "COINBASE_API_KEY": bool(os.getenv("COINBASE_API_KEY")), + "COINBASE_API_SECRET": bool(os.getenv("COINBASE_API_SECRET")), + "CRYPTOCOMPARE_API_KEY": bool(os.getenv("CRYPTOCOMPARE_API_KEY")), + "BINANCE_API_KEY": bool(os.getenv("BINANCE_API_KEY")), + "BINANCE_API_SECRET": bool(os.getenv("BINANCE_API_SECRET")), + } + return env_vars + +def initialize_providers() -> Dict[str, BaseWrapper]: + """Inizializza tutti i provider disponibili.""" + providers = {} + env_vars = check_environment_variables() + + # CryptoCompareWrapper + if env_vars["CRYPTOCOMPARE_API_KEY"]: + try: + providers["CryptoCompare"] = CryptoCompareWrapper() + print("✅ CryptoCompareWrapper inizializzato con successo") + except Exception as e: + print(f"❌ Errore nell'inizializzazione di CryptoCompareWrapper: {e}") + else: + print("⚠️ CryptoCompareWrapper saltato: CRYPTOCOMPARE_API_KEY non trovata") + + # CoinBaseWrapper + if env_vars["COINBASE_API_KEY"] and env_vars["COINBASE_API_SECRET"]: + try: + providers["CoinBase"] = CoinBaseWrapper() + print("✅ CoinBaseWrapper inizializzato con successo") + except Exception as e: + print(f"❌ Errore nell'inizializzazione di CoinBaseWrapper: {e}") + else: + print("⚠️ CoinBaseWrapper saltato: credenziali Coinbase non complete") + + # BinanceWrapper + + try: + providers["Binance"] = BinanceWrapper() + print("✅ BinanceWrapper inizializzato con successo") + except Exception as e: + print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}") + + return providers + +def print_summary(results: List[Dict[str, Any]]): + """Stampa un riassunto finale dei risultati.""" + formatter = DemoFormatter() + formatter.print_header("📊 RIASSUNTO FINALE", "=", 80) + + total_providers = len(results) + successful_providers = sum(1 for r in results if r["overall_status"] == "SUCCESS") + partial_providers = sum(1 for r in results if r["overall_status"] == "PARTIAL") + + print(f"🔢 Provider testati: {total_providers}") + print(f"✅ Provider completamente funzionanti: {successful_providers}") + print(f"⚠️ Provider parzialmente funzionanti: {partial_providers}") + print(f"❌ Provider non funzionanti: {total_providers - successful_providers - partial_providers}") + + print("\n📋 Dettaglio per provider:") + for result in results: + provider_name = result["provider_name"] + status = result["overall_status"] + status_icon = "✅" if status == "SUCCESS" else "⚠️" if status == "PARTIAL" else "❌" + + print(f"\n{status_icon} {provider_name}:") + for test_name, test_result in result["tests"].items(): + test_icon = "✅" if test_result == "SUCCESS" else "❌" + print(f" {test_icon} {test_name}: {test_result}") + +def main(): + """Funzione principale del demo.""" + formatter = DemoFormatter() + + # Intestazione principale + formatter.print_header("🚀 DEMO COMPLETO MARKET DATA PROVIDERS", "=", 80) + + print(f"🕒 Avvio demo: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("📝 Questo demo testa tutti i wrapper BaseWrapper disponibili") + print("🔍 Ogni test include timestamp, stato della richiesta e dati formattati") + + # Verifica variabili d'ambiente + formatter.print_subheader("🔐 Verifica Configurazione") + env_vars = check_environment_variables() + + print("Variabili d'ambiente:") + for var_name, is_present in env_vars.items(): + status = "✅ Presente" if is_present else "❌ Mancante" + print(f" {var_name}: {status}") + + # Inizializza provider + formatter.print_subheader("🏗️ Inizializzazione Provider") + providers = initialize_providers() + + if not providers: + print("❌ Nessun provider disponibile. Verifica la configurazione.") + return + + print(f"\n🎯 Provider disponibili per il test: {list(providers.keys())}") + + # Testa ogni provider + formatter.print_header("🧪 ESECUZIONE TEST PROVIDER", "=", 80) + + tester = ProviderTester() + all_results = [] + + for provider_name, wrapper in providers.items(): + try: + result = tester.test_provider(wrapper, provider_name) + all_results.append(result) + except Exception as e: + print(f"❌ Errore critico nel test di {provider_name}: {e}") + traceback.print_exc() + all_results.append({ + "provider_name": provider_name, + "tests": {}, + "overall_status": "CRITICAL_ERROR", + "error": str(e) + }) + + # Stampa riassunto finale + print_summary(all_results) + + # Informazioni aggiuntive + formatter.print_header("ℹ️ INFORMAZIONI AGGIUNTIVE", "=", 80) + print("📚 Documentazione:") + print(" - BaseWrapper: src/app/markets/base.py") + print(" - Test completi: tests/agents/test_market.py") + print(" - Configurazione: .env") + + print("\n🔧 Per abilitare tutti i provider:") + print(" 1. Configura le credenziali nel file .env") + print(" 2. Segui la documentazione di ogni provider") + print(" 3. Riavvia il demo") + + print(f"\n🏁 Demo completato: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/demos/news_api.py b/demos/news_api.py new file mode 100644 index 0000000..26dab24 --- /dev/null +++ b/demos/news_api.py @@ -0,0 +1,16 @@ +#### FOR ALL FILES OUTSIDE src/ FOLDER #### +import sys +import os +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) +########################################### + +from dotenv import load_dotenv +from app.news import NewsApiWrapper + +def main(): + api = NewsApiWrapper() + print("ok") + +if __name__ == "__main__": + load_dotenv() + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 35a3b6e..e091aba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,25 +10,31 @@ requires-python = "==3.12.*" # Per ogni roba ho fatto un commento per evitare di dimenticarmi cosa fa chi. # Inoltre ho messo una emoji per indicare se è raccomandato o meno. dependencies = [ - # ✅ per i test - "pytest", - # ✅ per gestire variabili d'ambiente (generalmente API keys od opzioni) - "dotenv", - # 🟡 per fare scraping di pagine web - #"bs4", - # ✅ per fare una UI web semplice con input e output - "gradio", + "pytest", # Test + "dotenv", # Gestire variabili d'ambiente (generalmente API keys od opzioni) + "gradio", # UI web semplice con user_input e output - # ✅ per costruire agenti (ovvero modelli che possono fare più cose tramite tool) https://github.com/agno-agi/agno + # Per costruire agenti (ovvero modelli che possono fare più cose tramite tool) https://github.com/agno-agi/agno # altamente consigliata dato che ha anche tools integrati per fare scraping, calcoli e molto altro # oltre a questa è necessario installare anche le librerie specifiche per i modelli che si vogliono usare "agno", - # ✅ Modelli supportati e installati (aggiungere qui sotto quelli che si vogliono usare) + # Modelli supportati e installati (aggiungere qui sotto quelli che si vogliono usare) "google-genai", "ollama", - # ✅ per interagire con API di exchange di criptovalute + # API di exchange di criptovalute "coinbase-advanced-py", "python-binance", + + # API di notizie + "newsapi-python", + "gnews", + "ddgs", + + # API di social media + "praw", # Reddit ] +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app.py b/src/app.py index 983779e..8477149 100644 --- a/src/app.py +++ b/src/app.py @@ -1,7 +1,7 @@ import gradio as gr from dotenv import load_dotenv -from app.tool import ToolAgent +from app.pipeline import Pipeline from agno.utils.log import log_info ######################################## @@ -16,31 +16,31 @@ if __name__ == "__main__": load_dotenv() ###################################### - tool_agent = ToolAgent() + pipeline = Pipeline() with gr.Blocks() as demo: gr.Markdown("# 🤖 Agente di Analisi e Consulenza Crypto") with gr.Row(): provider = gr.Dropdown( - choices=tool_agent.list_providers(), + choices=pipeline.list_providers(), type="index", label="Modello da usare" ) - provider.change(fn=tool_agent.choose_provider, inputs=provider, outputs=None) + provider.change(fn=pipeline.choose_provider, inputs=provider, outputs=None) style = gr.Dropdown( - choices=tool_agent.list_styles(), + choices=pipeline.list_styles(), type="index", label="Stile di investimento" ) - style.change(fn=tool_agent.choose_style, inputs=style, outputs=None) + style.change(fn=pipeline.choose_style, inputs=style, outputs=None) user_input = gr.Textbox(label="Richiesta utente") output = gr.Textbox(label="Risultato analisi", lines=12) analyze_btn = gr.Button("🔎 Analizza") - analyze_btn.click(fn=tool_agent.interact, inputs=[user_input], outputs=output) + analyze_btn.click(fn=pipeline.interact, inputs=[user_input], outputs=output) server, port = ("0.0.0.0", 8000) log_info(f"Starting UPO AppAI on http://{server}:{port}") diff --git a/src/app/agents/market_agent.py b/src/app/agents/market_agent.py new file mode 100644 index 0000000..12f9eab --- /dev/null +++ b/src/app/agents/market_agent.py @@ -0,0 +1,90 @@ +from typing import Union, List, Dict, Optional, Any, Iterator, Sequence +from agno.agent import Agent +from agno.models.message import Message +from agno.run.agent import RunOutput, RunOutputEvent +from pydantic import BaseModel + +from app.toolkits.market_toolkit import MarketToolkit +from app.markets.base import ProductInfo # modello dati già definito nel tuo progetto + + +class MarketAgent(Agent): + """ + Wrapper che trasforma MarketToolkit in un Agent compatibile con Team. + Produce sia output leggibile (content) che dati strutturati (metadata). + """ + + def __init__(self, currency: str = "USD"): + super().__init__() + self.toolkit = MarketToolkit() + self.currency = currency + self.name = "MarketAgent" + + def run( + self, + input: Union[str, List, Dict, Message, BaseModel, List[Message]], + *, + stream: Optional[bool] = None, + stream_intermediate_steps: Optional[bool] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + session_state: Optional[Dict[str, Any]] = None, + audio: Optional[Sequence[Any]] = None, + images: Optional[Sequence[Any]] = None, + videos: Optional[Sequence[Any]] = None, + files: Optional[Sequence[Any]] = None, + retries: Optional[int] = None, + knowledge_filters: Optional[Dict[str, Any]] = None, + add_history_to_context: Optional[bool] = None, + add_dependencies_to_context: Optional[bool] = None, + add_session_state_to_context: Optional[bool] = None, + dependencies: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + yield_run_response: bool = False, + debug_mode: Optional[bool] = None, + **kwargs: Any, + ) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]: + # 1. Estraggo la query dal parametro "input" + if isinstance(input, str): + query = input + elif isinstance(input, dict) and "query" in input: + query = input["query"] + elif isinstance(input, Message): + query = input.content + elif isinstance(input, BaseModel): + query = str(input) + elif isinstance(input, list) and input and isinstance(input[0], Message): + query = input[0].content + else: + query = str(input) + + # 2. Individuo i simboli da analizzare + symbols = [] + for token in query.upper().split(): + if token in ("BTC", "ETH", "XRP", "LTC", "BCH"): # TODO: estendere dinamicamente + symbols.append(token) + + if not symbols: + symbols = ["BTC", "ETH"] # default + + # 3. Recupero i dati dal toolkit + results = [] + products: List[ProductInfo] = [] + + try: + products.extend(self.toolkit.get_current_prices(symbols)) # supponiamo ritorni un ProductInfo o simile + # Usa list comprehension per iterare symbols e products insieme + results.extend([ + f"{symbol}: ${product.price:.2f}" if hasattr(product, 'price') and product.price else f"{symbol}: N/A" + for symbol, product in zip(symbols, products) + ]) + except Exception as e: + results.extend(f"Errore: Impossibile recuperare i dati di mercato\n{str(e)}") + + # 4. Preparo output leggibile + metadati strutturati + output_text = "📊 Dati di mercato:\n" + "\n".join(results) + + return RunOutput( + content=output_text, + metadata={"products": products} + ) diff --git a/src/app/agents/news_agent.py b/src/app/agents/news_agent.py index 831d5b3..d6de5e5 100644 --- a/src/app/agents/news_agent.py +++ b/src/app/agents/news_agent.py @@ -1,4 +1,34 @@ -class NewsAgent: +from agno.agent import Agent + +class NewsAgent(Agent): + """ + Gli agenti devono esporre un metodo run con questa firma. + + def run( + self, + input: Union[str, List, Dict, Message, BaseModel, List[Message]], + *, + stream: Optional[bool] = None, + stream_intermediate_steps: Optional[bool] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + session_state: Optional[Dict[str, Any]] = None, + audio: Optional[Sequence[Any]] = None, + images: Optional[Sequence[Any]] = None, + videos: Optional[Sequence[Any]] = None, + files: Optional[Sequence[Any]] = None, + retries: Optional[int] = None, + knowledge_filters: Optional[Dict[str, Any]] = None, + add_history_to_context: Optional[bool] = None, + add_dependencies_to_context: Optional[bool] = None, + add_session_state_to_context: Optional[bool] = None, + dependencies: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + yield_run_response: bool = False, + debug_mode: Optional[bool] = None, + **kwargs: Any, + ) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]: + """ @staticmethod def analyze(query: str) -> str: # Mock analisi news diff --git a/src/app/agents/social_agent.py b/src/app/agents/social_agent.py index 1ec2fb5..cefa7ef 100644 --- a/src/app/agents/social_agent.py +++ b/src/app/agents/social_agent.py @@ -1,4 +1,35 @@ -class SocialAgent: +from agno.agent import Agent + + +class SocialAgent(Agent): + """ + Gli agenti devono esporre un metodo run con questa firma. + + def run( + self, + input: Union[str, List, Dict, Message, BaseModel, List[Message]], + *, + stream: Optional[bool] = None, + stream_intermediate_steps: Optional[bool] = None, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + session_state: Optional[Dict[str, Any]] = None, + audio: Optional[Sequence[Any]] = None, + images: Optional[Sequence[Any]] = None, + videos: Optional[Sequence[Any]] = None, + files: Optional[Sequence[Any]] = None, + retries: Optional[int] = None, + knowledge_filters: Optional[Dict[str, Any]] = None, + add_history_to_context: Optional[bool] = None, + add_dependencies_to_context: Optional[bool] = None, + add_session_state_to_context: Optional[bool] = None, + dependencies: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + yield_run_response: bool = False, + debug_mode: Optional[bool] = None, + **kwargs: Any, + ) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]: + """ @staticmethod def analyze(query: str) -> str: # Mock analisi social diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index 4bb3e9e..e5853d5 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,57 +1,96 @@ -from app.markets.base import BaseWrapper -from app.markets.coinbase import CoinBaseWrapper -from app.markets.cryptocompare import CryptoCompareWrapper +from .base import BaseWrapper, ProductInfo, Price +from .coinbase import CoinBaseWrapper +from .binance import BinanceWrapper +from .cryptocompare import CryptoCompareWrapper +from .binance_public import PublicBinanceAgent +from app.utils.wrapper_handler import WrapperHandler +from typing import List, Optional +from agno.tools import Toolkit -from agno.utils.log import log_warning -class MarketAPIs(BaseWrapper): +__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "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 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. """ - @staticmethod - def get_list_available_market_apis(currency: str = "USD") -> list[BaseWrapper]: - """ - Restituisce una lista di istanze delle API di mercato disponibili. - La priorità è data dall'ordine delle API nella lista wrappers. - 1. CoinBase - 2. CryptoCompare - - :param currency: Valuta di riferimento (default "USD") - :return: Lista di istanze delle API di mercato disponibili - """ - wrapper_builders = [ - CoinBaseWrapper, - CryptoCompareWrapper, - ] - - result = [] - for wrapper in wrapper_builders: - try: - result.append(wrapper(currency=currency)) - except Exception as _: - log_warning(f"{wrapper} cannot be initialized, maybe missing API key?") - - assert result, "No market API keys set in environment variables." - return result - - def __init__(self, currency: str = "USD"): - """ - Inizializza la classe con la valuta di riferimento e la priorità dei provider. - :param currency: Valuta di riferimento (default "USD") - """ + def __init__(self, currency: str = "USD", enable_aggregation: bool = False): self.currency = currency - self.wrappers = MarketAPIs.get_list_available_market_apis(currency=currency) + wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ] + 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 - # Metodi che semplicemente chiamano il metodo corrispondente del primo wrapper disponibile - # TODO magari fare in modo che se il primo fallisce, prova con il secondo, ecc. - # oppure fare un round-robin tra i vari wrapper oppure usarli tutti e fare una media dei risultati - def get_product(self, asset_id): - return self.wrappers[0].get_product(asset_id) - def get_products(self, asset_ids: list): - return self.wrappers[0].get_products(asset_ids) - def get_all_products(self): - return self.wrappers[0].get_all_products() - def get_historical_prices(self, asset_id = "BTC"): - return self.wrappers[0].get_historical_prices(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[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) -> 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: 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 032f8aa..117c174 100644 --- a/src/app/markets/base.py +++ b/src/app/markets/base.py @@ -1,18 +1,49 @@ -from coinbase.rest.types.product_types import Candle, GetProductResponse + 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") -> list['Price']: + + 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): @@ -27,25 +58,6 @@ class ProductInfo(BaseModel): status: str = "" quote_currency: str = "" - def from_coinbase(product_data: GetProductResponse) -> 'ProductInfo': - product = ProductInfo() - product.id = product_data.product_id - product.symbol = product_data.base_currency_id - product.price = float(product_data.price) - product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0 - # TODO Check what status means in Coinbase - product.status = product_data.status - return product - - def from_cryptocompare(asset_data: dict) -> 'ProductInfo': - product = ProductInfo() - product.id = asset_data['FROMSYMBOL'] + '-' + asset_data['TOSYMBOL'] - product.symbol = asset_data['FROMSYMBOL'] - product.price = float(asset_data['PRICE']) - product.volume_24h = float(asset_data['VOLUME24HOUR']) - product.status = "" # Cryptocompare does not provide status - return product - class Price(BaseModel): """ Rappresenta i dati di prezzo per un asset, come ottenuti dalle API di mercato. @@ -57,23 +69,3 @@ class Price(BaseModel): close: float = 0.0 volume: float = 0.0 time: str = "" - - def from_coinbase(candle_data: Candle) -> 'Price': - price = Price() - price.high = float(candle_data.high) - price.low = float(candle_data.low) - price.open = float(candle_data.open) - price.close = float(candle_data.close) - price.volume = float(candle_data.volume) - price.time = str(candle_data.start) - return price - - def from_cryptocompare(price_data: dict) -> 'Price': - price = Price() - price.high = float(price_data['high']) - price.low = float(price_data['low']) - price.open = float(price_data['open']) - price.close = float(price_data['close']) - price.volume = float(price_data['volumeto']) - price.time = str(price_data['time']) - return price diff --git a/src/app/markets/binance.py b/src/app/markets/binance.py index 80f64c2..d5dfe10 100644 --- a/src/app/markets/binance.py +++ b/src/app/markets/binance.py @@ -1,30 +1,88 @@ -# Versione pubblica senza autenticazione +import os +from datetime import datetime from binance.client import Client +from .base import ProductInfo, BaseWrapper, Price -# TODO fare l'aggancio con API in modo da poterlo usare come wrapper di mercato -# TODO implementare i metodi di BaseWrapper +def get_product(currency: str, ticker_data: dict[str, str]) -> 'ProductInfo': + product = ProductInfo() + product.id = ticker_data.get('symbol') + product.symbol = ticker_data.get('symbol', '').replace(currency, '') + product.price = float(ticker_data.get('price', 0)) + product.volume_24h = float(ticker_data.get('volume', 0)) + product.status = "TRADING" # Binance non fornisce status esplicito + product.quote_currency = currency + return product -class PublicBinanceAgent: - def __init__(self): - # Client pubblico (senza credenziali) - self.client = Client() +class BinanceWrapper(BaseWrapper): + """ + Wrapper per le API autenticate di Binance.\n + Implementa l'interfaccia BaseWrapper per fornire accesso unificato + ai dati di mercato di Binance tramite le API REST con autenticazione.\n + https://binance-docs.github.io/apidocs/spot/en/ + """ - def get_public_prices(self): - """Ottiene prezzi pubblici""" - try: - btc_price = self.client.get_symbol_ticker(symbol="BTCUSDT") - eth_price = self.client.get_symbol_ticker(symbol="ETHUSDT") + def __init__(self, currency: str = "USDT"): + api_key = os.getenv("BINANCE_API_KEY") + api_secret = os.getenv("BINANCE_API_SECRET") - return { - 'BTC_USD': float(btc_price['price']), - 'ETH_USD': float(eth_price['price']), - 'source': 'binance_public' - } - except Exception as e: - print(f"Errore: {e}") - return None + self.currency = currency + self.client = Client(api_key=api_key, api_secret=api_secret) -# Uso senza credenziali -public_agent = PublicBinanceAgent() -public_prices = public_agent.get_public_prices() -print(public_prices) + def __format_symbol(self, asset_id: str) -> str: + """ + Formatta l'asset_id nel formato richiesto da Binance. + """ + return asset_id.replace('-', '') if '-' in asset_id else f"{asset_id}{self.currency}" + + def get_product(self, asset_id: str) -> ProductInfo: + symbol = self.__format_symbol(asset_id) + + ticker = self.client.get_symbol_ticker(symbol=symbol) + ticker_24h = self.client.get_ticker(symbol=symbol) + ticker['volume'] = ticker_24h.get('volume', 0) # Aggiunge volume 24h ai dati del ticker + + return get_product(self.currency, ticker) + + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + symbols = [self.__format_symbol(asset_id) for asset_id in asset_ids] + symbols_str = f"[\"{'","'.join(symbols)}\"]" + + tickers = self.client.get_symbol_ticker(symbols=symbols_str) + tickers_24h = self.client.get_ticker(symbols=symbols_str) # un po brutale, ma va bene così + for t, t24 in zip(tickers, tickers_24h): + t['volume'] = t24.get('volume', 0) + + return [get_product(self.currency, ticker) for ticker in tickers] + + def get_all_products(self) -> list[ProductInfo]: + all_tickers = self.client.get_ticker() + products = [] + + for ticker in all_tickers: + # Filtra solo i simboli che terminano con la valuta di default + if ticker['symbol'].endswith(self.currency): + product = get_product(self.currency, ticker) + products.append(product) + return products + + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + symbol = self.__format_symbol(asset_id) + + # Ottiene candele orarie degli ultimi 30 giorni + klines = self.client.get_historical_klines( + symbol=symbol, + interval=Client.KLINE_INTERVAL_1HOUR, + limit=limit, + ) + + prices = [] + for kline in klines: + price = Price() + price.open = float(kline[1]) + price.high = float(kline[2]) + price.low = float(kline[3]) + price.close = float(kline[4]) + price.volume = float(kline[5]) + price.time = str(datetime.fromtimestamp(kline[0] / 1000)) + prices.append(price) + return prices diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index aac556d..286ec6f 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -1,19 +1,57 @@ import os +from enum import Enum +from datetime import datetime, timedelta from coinbase.rest import RESTClient -from app.markets.base import ProductInfo, BaseWrapper, Price +from coinbase.rest.types.product_types import Candle, GetProductResponse, Product +from .base import ProductInfo, BaseWrapper, Price + + +def get_product(product_data: GetProductResponse | Product) -> 'ProductInfo': + product = ProductInfo() + product.id = product_data.product_id or "" + 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 + # TODO Check what status means in Coinbase + product.status = product_data.status or "" + return product + +def get_price(candle_data: Candle) -> 'Price': + price = Price() + price.high = float(candle_data.high) if candle_data.high else 0.0 + price.low = float(candle_data.low) if candle_data.low else 0.0 + price.open = float(candle_data.open) if candle_data.open else 0.0 + price.close = float(candle_data.close) if candle_data.close else 0.0 + price.volume = float(candle_data.volume) if candle_data.volume else 0.0 + price.time = str(candle_data.start) if candle_data.start else "" + return price + + +class Granularity(Enum): + UNKNOWN_GRANULARITY = 0 + ONE_MINUTE = 60 + FIVE_MINUTE = 300 + FIFTEEN_MINUTE = 900 + THIRTY_MINUTE = 1800 + ONE_HOUR = 3600 + TWO_HOUR = 7200 + FOUR_HOUR = 14400 + SIX_HOUR = 21600 + ONE_DAY = 86400 class CoinBaseWrapper(BaseWrapper): """ - Wrapper per le API di Coinbase. - La documentazione delle API è disponibile qui: https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/introduction + Wrapper per le API di Coinbase Advanced Trade.\n + Implementa l'interfaccia BaseWrapper per fornire accesso unificato + ai dati di mercato di Coinbase tramite le API REST.\n + https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/introduction """ - def __init__(self, api_key:str = None, api_private_key:str = None, currency: str = "USD"): - if api_key is None: - api_key = os.getenv("COINBASE_API_KEY") + + def __init__(self, currency: str = "USD"): + api_key = os.getenv("COINBASE_API_KEY") assert api_key is not None, "API key is required" - if api_private_key is None: - api_private_key = os.getenv("COINBASE_API_SECRET") + api_private_key = os.getenv("COINBASE_API_SECRET") assert api_private_key is not None, "API private key is required" self.currency = currency @@ -28,18 +66,27 @@ class CoinBaseWrapper(BaseWrapper): def get_product(self, asset_id: str) -> ProductInfo: asset_id = self.__format(asset_id) asset = self.client.get_product(asset_id) - return ProductInfo.from_coinbase(asset) + return get_product(asset) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: all_asset_ids = [self.__format(asset_id) for asset_id in asset_ids] - assets = self.client.get_products(all_asset_ids) - return [ProductInfo.from_coinbase(asset) for asset in assets.products] + assets = self.client.get_products(product_ids=all_asset_ids) + return [get_product(asset) for asset in assets.products] def get_all_products(self) -> list[ProductInfo]: assets = self.client.get_products() - return [ProductInfo.from_coinbase(asset) for asset in assets.products] + return [get_product(asset) for asset in assets.products] - def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]: + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: asset_id = self.__format(asset_id) - data = self.client.get_candles(product_id=asset_id) - return [Price.from_coinbase(candle) for candle in data.candles] + end_time = datetime.now() + start_time = end_time - timedelta(days=14) + + data = self.client.get_candles( + product_id=asset_id, + granularity=Granularity.ONE_HOUR.name, + start=str(int(start_time.timestamp())), + end=str(int(end_time.timestamp())), + limit=limit + ) + return [get_price(candle) for candle in data.candles] diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index 188a2c2..c81a3bb 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -1,6 +1,28 @@ import os import requests -from app.markets.base import ProductInfo, BaseWrapper, Price +from typing import Optional, Dict, Any +from .base import ProductInfo, BaseWrapper, Price + + +def get_product(asset_data: dict) -> 'ProductInfo': + product = ProductInfo() + product.id = asset_data['FROMSYMBOL'] + '-' + asset_data['TOSYMBOL'] + product.symbol = asset_data['FROMSYMBOL'] + product.price = float(asset_data['PRICE']) + product.volume_24h = float(asset_data['VOLUME24HOUR']) + product.status = "" # Cryptocompare does not provide status + return product + +def get_price(price_data: dict) -> 'Price': + price = Price() + price.high = float(price_data['high']) + price.low = float(price_data['low']) + price.open = float(price_data['open']) + price.close = float(price_data['close']) + price.volume = float(price_data['volumeto']) + price.time = str(price_data['time']) + return price + BASE_URL = "https://min-api.cryptocompare.com" @@ -10,15 +32,14 @@ class CryptoCompareWrapper(BaseWrapper): La documentazione delle API è disponibile qui: https://developers.coindesk.com/documentation/legacy/Price/SingleSymbolPriceEndpoint !!ATTENZIONE!! sembra essere una API legacy e potrebbe essere deprecata in futuro. """ - def __init__(self, api_key:str = None, currency:str='USD'): - if api_key is None: - api_key = os.getenv("CRYPTOCOMPARE_API_KEY") + def __init__(self, currency:str='USD'): + api_key = os.getenv("CRYPTOCOMPARE_API_KEY") assert api_key is not None, "API key is required" self.api_key = api_key self.currency = currency - def __request(self, endpoint: str, params: dict = None) -> dict: + def __request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: if params is None: params = {} params['api_key'] = self.api_key @@ -32,7 +53,7 @@ class CryptoCompareWrapper(BaseWrapper): "tsyms": self.currency }) data = response.get('RAW', {}).get(asset_id, {}).get(self.currency, {}) - return ProductInfo.from_cryptocompare(data) + return get_product(data) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: response = self.__request("/data/pricemultifull", params = { @@ -43,20 +64,20 @@ class CryptoCompareWrapper(BaseWrapper): data = response.get('RAW', {}) for asset_id in asset_ids: asset_data = data.get(asset_id, {}).get(self.currency, {}) - assets.append(ProductInfo.from_cryptocompare(asset_data)) + assets.append(get_product(asset_data)) return assets def get_all_products(self) -> list[ProductInfo]: - raise NotImplementedError("CryptoCompare does not support fetching all assets") + # TODO serve davvero il workaroud qui? Possiamo prendere i dati da un altro endpoint intanto + raise NotImplementedError("get_all_products is not supported by CryptoCompare API") - def get_historical_prices(self, asset_id: str, day_back: int = 10) -> list[dict]: - assert day_back <= 30, "day_back should be less than or equal to 30" + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[dict]: response = self.__request("/data/v2/histohour", params = { "fsym": asset_id, "tsym": self.currency, - "limit": day_back * 24 + "limit": limit-1 # because the API returns limit+1 items (limit + current) }) data = response.get('Data', {}).get('Data', []) - prices = [Price.from_cryptocompare(price_data) for price_data in data] + prices = [get_price(price_data) for price_data in data] return prices diff --git a/src/app/models.py b/src/app/models.py index 12aae9c..c1bff9b 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1,13 +1,13 @@ import os import requests from enum import Enum -from pydantic import BaseModel from agno.agent import Agent from agno.models.base import Model from agno.models.google import Gemini from agno.models.ollama import Ollama - from agno.utils.log import log_warning +from pydantic import BaseModel + class AppModels(Enum): """ @@ -41,6 +41,7 @@ class AppModels(Enum): availables.append(AppModels.OLLAMA_QWEN) return availables + @staticmethod def availables_online() -> list['AppModels']: """ Controlla quali provider di modelli LLM online hanno le loro API keys disponibili @@ -49,9 +50,7 @@ class AppModels(Enum): if not os.getenv("GOOGLE_API_KEY"): log_warning("No GOOGLE_API_KEY set in environment variables.") return [] - availables = [] - availables.append(AppModels.GEMINI) - availables.append(AppModels.GEMINI_PRO) + availables = [AppModels.GEMINI, AppModels.GEMINI_PRO] return availables @staticmethod @@ -75,9 +74,13 @@ class AppModels(Enum): def extract_json_str_from_response(response: str) -> str: """ Estrae il JSON dalla risposta del modello. - response: risposta del modello (stringa). - Ritorna la parte JSON della risposta come stringa. - Se non viene trovato nessun JSON, ritorna una stringa vuota. + Args: + response: risposta del modello (stringa). + + Returns: + La parte JSON della risposta come stringa. + Se non viene trovato nessun JSON, ritorna una stringa vuota. + ATTENZIONE: questa funzione è molto semplice e potrebbe non funzionare in tutti i casi. Si assume che il JSON sia ben formato e che inizi con '{' e finisca con '}'. Quindi anche solo un json array farà fallire questa funzione. @@ -98,9 +101,15 @@ class AppModels(Enum): def get_model(self, instructions:str) -> Model: """ Restituisce un'istanza del modello specificato. - instructions: istruzioni da passare al modello (system prompt). - Ritorna un'istanza di BaseModel o una sua sottoclasse. - Raise ValueError se il modello non è supportato. + + Args: + instructions: istruzioni da passare al modello (system prompt). + + Returns: + Un'istanza di BaseModel o una sua sottoclasse. + + Raise: + ValueError se il modello non è supportato. """ name = self.value if self in {AppModels.GEMINI, AppModels.GEMINI_PRO}: @@ -113,8 +122,13 @@ class AppModels(Enum): def get_agent(self, instructions: str, name: str = "", output: BaseModel | None = None) -> Agent: """ Costruisce un agente con il modello e le istruzioni specificate. - instructions: istruzioni da passare al modello (system prompt). - Ritorna un'istanza di Agent. + Args: + instructions: istruzioni da passare al modello (system prompt) + name: nome dell'agente (opzionale) + output: schema di output opzionale (Pydantic BaseModel) + + Returns: + Un'istanza di Agent. """ return Agent( model=self.get_model(instructions), diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py new file mode 100644 index 0000000..d38cd43 --- /dev/null +++ b/src/app/news/__init__.py @@ -0,0 +1,32 @@ +from app.utils.wrapper_handler import WrapperHandler +from .base import NewsWrapper, Article +from .news_api import NewsApiWrapper +from .gnews_api import GoogleNewsWrapper +from .cryptopanic_api import CryptoPanicWrapper +from .duckduckgo import DuckDuckGoWrapper + +__all__ = ["NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"] + + +class NewsAPIs(NewsWrapper): + """ + A wrapper class that aggregates multiple news API wrappers and tries them in order until one succeeds. + This class uses the WrapperHandler to manage multiple NewsWrapper instances. + It includes, and tries, the following news API wrappers in this order: + - GoogleNewsWrapper + - DuckDuckGoWrapper + - NewsApiWrapper + - CryptoPanicWrapper + + It provides methods to get top headlines and latest news by delegating the calls to the first successful wrapper. + If all wrappers fail, it raises an exception. + """ + + def __init__(self): + wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] + self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers) + + def get_top_headlines(self, total: int = 100) -> list[Article]: + return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(total)) + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + return self.wrapper_handler.try_call(lambda w: w.get_latest_news(query, total)) diff --git a/src/app/news/base.py b/src/app/news/base.py new file mode 100644 index 0000000..0a8f6be --- /dev/null +++ b/src/app/news/base.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + +class Article(BaseModel): + source: str = "" + time: str = "" + title: str = "" + description: str = "" + +class NewsWrapper: + """ + Base class for news API wrappers. + All news API wrappers should inherit from this class and implement the methods. + """ + + def get_top_headlines(self, total: int = 100) -> list[Article]: + """ + Get top headlines, optionally limited by total. + Args: + total (int): The maximum number of articles to return. + Returns: + list[Article]: A list of Article objects. + """ + raise NotImplementedError("This method should be overridden by subclasses") + + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + """ + Get latest news based on a query. + Args: + query (str): The search query. + total (int): The maximum number of articles to return. + Returns: + list[Article]: A list of Article objects. + """ + raise NotImplementedError("This method should be overridden by subclasses") + diff --git a/src/app/news/cryptopanic_api.py b/src/app/news/cryptopanic_api.py new file mode 100644 index 0000000..a949c69 --- /dev/null +++ b/src/app/news/cryptopanic_api.py @@ -0,0 +1,77 @@ +import os +import requests +from enum import Enum +from .base import NewsWrapper, Article + +class CryptoPanicFilter(Enum): + RISING = "rising" + HOT = "hot" + BULLISH = "bullish" + BEARISH = "bearish" + IMPORTANT = "important" + SAVED = "saved" + LOL = "lol" + ANY = "" + +class CryptoPanicKind(Enum): + NEWS = "news" + MEDIA = "media" + ALL = "all" + +def get_articles(response: dict) -> list[Article]: + articles = [] + if 'results' in response: + for item in response['results']: + article = Article() + article.source = item.get('source', {}).get('title', '') + article.time = item.get('published_at', '') + article.title = item.get('title', '') + article.description = item.get('description', '') + articles.append(article) + return articles + +class CryptoPanicWrapper(NewsWrapper): + """ + A wrapper for the CryptoPanic API (Documentation: https://cryptopanic.com/developers/api/) + Requires an API key set in the environment variable CRYPTOPANIC_API_KEY. + It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/month). + Supports different plan types via the CRYPTOPANIC_API_PLAN environment variable (developer, growth, enterprise). + """ + + def __init__(self): + self.api_key = os.getenv("CRYPTOPANIC_API_KEY", "") + assert self.api_key, "CRYPTOPANIC_API_KEY environment variable not set" + + # Set here for the future, but currently not needed + plan_type = os.getenv("CRYPTOPANIC_API_PLAN", "developer").lower() + assert plan_type in ["developer", "growth", "enterprise"], "Invalid CRYPTOPANIC_API_PLAN value" + + self.base_url = f"https://cryptopanic.com/api/{plan_type}/v2" + self.filter = CryptoPanicFilter.ANY + self.kind = CryptoPanicKind.NEWS + + def get_base_params(self) -> dict[str, str]: + params = {} + params['public'] = 'true' # recommended for app and bots + params['auth_token'] = self.api_key + params['kind'] = self.kind.value + if self.filter != CryptoPanicFilter.ANY: + params['filter'] = self.filter.value + return params + + def set_filter(self, filter: CryptoPanicFilter): + self.filter = filter + + def get_top_headlines(self, total: int = 100) -> list[Article]: + return self.get_latest_news("", total) # same endpoint so just call the other method + + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + params = self.get_base_params() + params['currencies'] = query + + response = requests.get(f"{self.base_url}/posts/", params=params) + assert response.status_code == 200, f"Error fetching data: {response}" + + json_response = response.json() + articles = get_articles(json_response) + return articles[:total] diff --git a/src/app/news/duckduckgo.py b/src/app/news/duckduckgo.py new file mode 100644 index 0000000..3a7c0bf --- /dev/null +++ b/src/app/news/duckduckgo.py @@ -0,0 +1,32 @@ +import json +from .base import Article, NewsWrapper +from agno.tools.duckduckgo import DuckDuckGoTools + +def create_article(result: dict) -> Article: + article = Article() + article.source = result.get("source", "") + article.time = result.get("date", "") + article.title = result.get("title", "") + article.description = result.get("body", "") + return article + +class DuckDuckGoWrapper(NewsWrapper): + """ + A wrapper for DuckDuckGo News search using the Tool from agno.tools.duckduckgo. + It can be rewritten to use direct API calls if needed in the future, but currently is easy to write and use. + """ + + def __init__(self): + self.tool = DuckDuckGoTools() + self.query = "crypto" + + def get_top_headlines(self, total: int = 100) -> list[Article]: + results = self.tool.duckduckgo_news(self.query, max_results=total) + json_results = json.loads(results) + return [create_article(result) for result in json_results] + + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + results = self.tool.duckduckgo_news(query or self.query, max_results=total) + json_results = json.loads(results) + return [create_article(result) for result in json_results] + diff --git a/src/app/news/gnews_api.py b/src/app/news/gnews_api.py new file mode 100644 index 0000000..2e35f46 --- /dev/null +++ b/src/app/news/gnews_api.py @@ -0,0 +1,36 @@ +from gnews import GNews +from .base import Article, NewsWrapper + +def result_to_article(result: dict) -> Article: + article = Article() + article.source = result.get("source", "") + article.time = result.get("publishedAt", "") + article.title = result.get("title", "") + article.description = result.get("description", "") + return article + +class GoogleNewsWrapper(NewsWrapper): + """ + A wrapper for the Google News RSS Feed (Documentation: https://github.com/ranahaani/GNews/?tab=readme-ov-file#about-gnews) + It does not require an API key and is free to use. + """ + + def get_top_headlines(self, total: int = 100) -> list[Article]: + gnews = GNews(language='en', max_results=total, period='7d') + results = gnews.get_top_news() + + articles = [] + for result in results: + article = result_to_article(result) + articles.append(article) + return articles + + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + gnews = GNews(language='en', max_results=total, period='7d') + results = gnews.get_news(query) + + articles = [] + for result in results: + article = result_to_article(result) + articles.append(article) + return articles diff --git a/src/app/news/news_api.py b/src/app/news/news_api.py new file mode 100644 index 0000000..0e6d684 --- /dev/null +++ b/src/app/news/news_api.py @@ -0,0 +1,50 @@ +import os +import newsapi +from .base import Article, NewsWrapper + +def result_to_article(result: dict) -> Article: + article = Article() + article.source = result.get("source", {}).get("name", "") + article.time = result.get("publishedAt", "") + article.title = result.get("title", "") + article.description = result.get("description", "") + return article + +class NewsApiWrapper(NewsWrapper): + """ + A wrapper for the NewsAPI (Documentation: https://newsapi.org/docs/get-started) + Requires an API key set in the environment variable NEWS_API_KEY. + It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/day). + """ + + def __init__(self): + api_key = os.getenv("NEWS_API_KEY") + assert api_key is not None, "NEWS_API_KEY environment variable not set" + + self.client = newsapi.NewsApiClient(api_key=api_key) + self.category = "business" # Cryptocurrency is under business + self.language = "en" # TODO Only English articles for now? + self.max_page_size = 100 + + def get_top_headlines(self, total: int = 100) -> list[Article]: + page_size = min(self.max_page_size, total) + pages = (total // page_size) + (1 if total % page_size > 0 else 0) + + articles = [] + for page in range(1, pages + 1): + headlines = self.client.get_top_headlines(q="", category=self.category, language=self.language, page_size=page_size, page=page) + results = [result_to_article(article) for article in headlines.get("articles", [])] + articles.extend(results) + return articles + + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + page_size = min(self.max_page_size, total) + pages = (total // page_size) + (1 if total % page_size > 0 else 0) + + articles = [] + for page in range(1, pages + 1): + everything = self.client.get_everything(q=query, language=self.language, sort_by="publishedAt", page_size=page_size, page=page) + results = [result_to_article(article) for article in everything.get("articles", [])] + articles.extend(results) + return articles + diff --git a/src/app/pipeline.py b/src/app/pipeline.py new file mode 100644 index 0000000..7a440de --- /dev/null +++ b/src/app/pipeline.py @@ -0,0 +1,83 @@ +from typing import List + +from agno.team import Team +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.models import AppModels +from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS + + +class Pipeline: + """ + Pipeline coordinata: esegue tutti gli agenti del Team, aggrega i risultati e invoca il Predictor. + """ + + def __init__(self): + # Inizializza gli agenti + self.market_agent = MarketAgent() + self.news_agent = NewsAgent() + self.social_agent = SocialAgent() + + # Crea il Team + self.team = Team(name="CryptoAnalysisTeam", members=[self.market_agent, self.news_agent, self.social_agent]) + + # Modelli disponibili e Predictor + self.available_models = AppModels.availables() + self.predictor_model = self.available_models[0] + self.predictor = self.predictor_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type] + + # Stili + self.styles = list(PredictorStyle) + self.style = self.styles[0] + + def choose_provider(self, index: int): + self.predictor_model = self.available_models[index] + self.predictor = self.predictor_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type] + + def choose_style(self, index: int): + self.style = self.styles[index] + + def interact(self, query: str) -> str: + """ + Esegue il Team (Market + News + Social), aggrega i risultati e invoca il Predictor. + """ + # Step 1: raccogli output del Team + team_results = self.team.run(query) + if isinstance(team_results, dict): # alcuni Team possono restituire dict + pieces = [str(v) for v in team_results.values()] + elif isinstance(team_results, list): + pieces = [str(r) for r in team_results] + else: + pieces = [str(team_results)] + aggregated_text = "\n\n".join(pieces) + + # Step 2: prepara input per Predictor + predictor_input = PredictorInput( + data=[], # TODO: mappare meglio i dati di mercato in ProductInfo + style=self.style, + sentiment=aggregated_text + ) + + # Step 3: chiama Predictor + result = self.predictor.run(predictor_input) + prediction: PredictorOutput = result.content + + # Step 4: formatta output finale + portfolio_lines = "\n".join( + [f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio] + ) + output = ( + f"📊 Strategia ({self.style.value}): {prediction.strategy}\n\n" + f"💼 Portafoglio consigliato:\n{portfolio_lines}" + ) + + return output + + def list_providers(self) -> List[str]: + return [m.name for m in self.available_models] + + def list_styles(self) -> List[str]: + return [s.value for s in self.styles] diff --git a/src/app/agents/predictor.py b/src/app/predictor.py similarity index 91% rename from src/app/agents/predictor.py rename to src/app/predictor.py index e811846..38780de 100644 --- a/src/app/agents/predictor.py +++ b/src/app/predictor.py @@ -1,6 +1,7 @@ from enum import Enum -from app.markets.base import ProductInfo from pydantic import BaseModel, Field +from app.markets.base import ProductInfo + class PredictorStyle(Enum): CONSERVATIVE = "Conservativo" @@ -23,7 +24,7 @@ class PredictorOutput(BaseModel): PREDICTOR_INSTRUCTIONS = """ You are an **Allocation Algorithm (Crypto-Algo)** specialized in analyzing market data and sentiment to generate an investment strategy and a target portfolio. -Your sole objective is to process the input data and generate the strictly structured output as required by the response format. **You MUST NOT provide introductions, preambles, explanations, conclusions, or any additional comments that are not strictly required.** +Your sole objective is to process the user_input data and generate the strictly structured output as required by the response format. **You MUST NOT provide introductions, preambles, explanations, conclusions, or any additional comments that are not strictly required.** ## Processing Instructions (Absolute Rule) diff --git a/src/app/social/__init.py b/src/app/social/__init.py new file mode 100644 index 0000000..0d46bc8 --- /dev/null +++ b/src/app/social/__init.py @@ -0,0 +1 @@ +from .base import SocialWrapper \ No newline at end of file diff --git a/src/app/social/base.py b/src/app/social/base.py new file mode 100644 index 0000000..945cdd5 --- /dev/null +++ b/src/app/social/base.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + + +class SocialPost(BaseModel): + time: str = "" + title: str = "" + description: str = "" + comments: list["SocialComment"] = [] + + def __str__(self): + return f"Title: {self.title}\nDescription: {self.description}\nComments: {len(self.comments)}\n[{" | ".join(str(c) for c in self.comments)}]" + +class SocialComment(BaseModel): + time: str = "" + description: str = "" + + def __str__(self): + return f"Time: {self.time}\nDescription: {self.description}" + +# TODO IMPLEMENTARLO SE SI USANO PIU' WRAPPER (E QUINDI PIU' SOCIAL) +class SocialWrapper: + pass diff --git a/src/app/social/reddit.py b/src/app/social/reddit.py new file mode 100644 index 0000000..7a3c824 --- /dev/null +++ b/src/app/social/reddit.py @@ -0,0 +1,53 @@ +import os +from praw import Reddit +from praw.models import Submission, MoreComments +from .base import SocialWrapper, SocialPost, SocialComment + +MAX_COMMENTS = 5 + + +def create_social_post(post: Submission) -> SocialPost: + social = SocialPost() + social.time = str(post.created) + social.title = post.title + social.description = post.selftext + + for i, top_comment in enumerate(post.comments): + if i >= MAX_COMMENTS: + break + if isinstance(top_comment, MoreComments): #skip MoreComments objects + continue + + comment = SocialComment() + comment.time = str(top_comment.created) + comment.description = top_comment.body + social.comments.append(comment) + return social + +class RedditWrapper(SocialWrapper): + """ + A wrapper for the Reddit API using PRAW (Python Reddit API Wrapper). + Requires the following environment variables to be set: + - REDDIT_API_CLIENT_ID + - REDDIT_API_CLIENT_SECRET + You can get them by creating an app at https://www.reddit.com/prefs/apps + """ + + def __init__(self): + self.client_id = os.getenv("REDDIT_API_CLIENT_ID") + assert self.client_id is not None, "REDDIT_API_CLIENT_ID environment variable is not set" + + self.client_secret = os.getenv("REDDIT_API_CLIENT_SECRET") + assert self.client_secret is not None, "REDDIT_API_CLIENT_SECRET environment variable is not set" + + self.tool = Reddit( + client_id=self.client_id, + client_secret=self.client_secret, + user_agent="upo-appAI", + ) + + def get_top_crypto_posts(self, limit=5) -> list[SocialPost]: + subreddit = self.tool.subreddit("CryptoCurrency") + top_posts = subreddit.top(limit=limit, time_filter="week") + return [create_social_post(post) for post in top_posts] + diff --git a/src/app/tool.py b/src/app/tool.py deleted file mode 100644 index d0b3ca0..0000000 --- a/src/app/tool.py +++ /dev/null @@ -1,88 +0,0 @@ -from app.agents.news_agent import NewsAgent -from app.agents.social_agent import SocialAgent -from app.agents.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS -from app.markets import MarketAPIs -from app.models import AppModels -from agno.utils.log import log_info - -class ToolAgent: - """ - Classe principale che coordina gli agenti per rispondere alle richieste dell'utente. - """ - - def __init__(self): - """ - Inizializza l'agente con i modelli disponibili, gli stili e l'API di mercato. - """ - self.available_models = AppModels.availables() - self.all_styles = list(PredictorStyle) - self.style = self.all_styles[0] # Default to the first style - - self.market = MarketAPIs(currency="USD") - self.choose_provider(0) # Default to the first model - - def choose_provider(self, index: int): - """ - Sceglie il modello LLM da utilizzare in base all'indice fornito. - index: indice del modello nella lista available_models. - """ - # TODO Utilizzare AGNO per gestire i modelli... è molto più semplice e permette di cambiare modello facilmente - # TODO https://docs.agno.com/introduction - # Inoltre permette di creare dei team e workflow di agenti più facilmente - self.chosen_model = self.available_models[index] - self.predictor = self.chosen_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) - self.news_agent = NewsAgent() - self.social_agent = SocialAgent() - - def choose_style(self, index: int): - """ - Sceglie lo stile di previsione da utilizzare in base all'indice fornito. - index: indice dello stile nella lista all_styles. - """ - self.style = self.all_styles[index] - - def interact(self, query: str) -> str: - """ - Funzione principale che coordina gli agenti per rispondere alla richiesta dell'utente. - query: richiesta dell'utente (es. "Qual è la previsione per Bitcoin?") - style_index: indice dello stile di previsione nella lista all_styles. - """ - - log_info(f"[model={self.chosen_model.name}] [style={self.style.name}] [query=\"{query.replace('"', "'")}\"]") - # TODO Step 0: ricerca e analisi della richiesta (es. estrazione di criptovalute specifiche) - # Prendere la query dell'utente e fare un'analisi preliminare con una agente o con un team di agenti (social e news) - - # Step 1: raccolta analisi - cryptos = ["BTC", "ETH", "XRP", "LTC", "BCH"] # TODO rendere dinamico in futuro - market_data = self.market.get_products(cryptos) - news_sentiment = self.news_agent.analyze(query) - social_sentiment = self.social_agent.analyze(query) - log_info(f"End of data collection") - - # Step 2: aggrega sentiment - sentiment = f"{news_sentiment}\n{social_sentiment}" - - # Step 3: previsione - inputs = PredictorInput(data=market_data, style=self.style, sentiment=sentiment) - result = self.predictor.run(inputs) - prediction: PredictorOutput = result.content - log_info(f"End of prediction") - - market_data = "\n".join([f"{product.symbol}: {product.price}" for product in market_data]) - output = f"[{prediction.strategy}]\nPortafoglio:\n" + "\n".join( - [f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio] - ) - - return f"INPUT:\n{market_data}\n{sentiment}\n\n\nOUTPUT:\n{output}" - - def list_providers(self) -> list[str]: - """ - Restituisce la lista dei nomi dei modelli disponibili. - """ - return [model.name for model in self.available_models] - - def list_styles(self) -> list[str]: - """ - Restituisce la lista degli stili di previsione disponibili. - """ - return [style.value for style in self.all_styles] diff --git a/src/app/toolkits/__init__.py b/src/app/toolkits/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/agents/market.py b/src/app/toolkits/market_toolkit.py similarity index 83% rename from src/app/agents/market.py rename to src/app/toolkits/market_toolkit.py index affa466..61a4d9f 100644 --- a/src/app/agents/market.py +++ b/src/app/toolkits/market_toolkit.py @@ -1,5 +1,6 @@ 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 # Non so se può essere utile, per ora lo lascio qui @@ -8,20 +9,20 @@ from app.markets import MarketAPIs # in base alle sue proprie chiamate API class MarketToolkit(Toolkit): def __init__(self): - self.market_api = MarketAPIs("USD") # change currency if needed + self.market_api = MarketAPIsTool("USD") # change currency if needed super().__init__( name="Market Toolkit", tools=[ self.get_historical_data, - self.get_current_price, + self.get_current_prices, ], ) def get_historical_data(self, symbol: str): return self.market_api.get_historical_prices(symbol) - def get_current_price(self, symbol: str): + def get_current_prices(self, symbol: list): return self.market_api.get_products(symbol) def prepare_inputs(): diff --git a/src/app/utils/aggregated_models.py b/src/app/utils/aggregated_models.py new file mode 100644 index 0000000..8eba8a5 --- /dev/null +++ b/src/app/utils/aggregated_models.py @@ -0,0 +1,184 @@ +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" + 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 index 2e89e7f..639bb9b 100644 --- a/src/app/utils/market_aggregator.py +++ b/src/app/utils/market_aggregator.py @@ -1,5 +1,5 @@ import statistics -from typing import Dict, List, Any +from typing import Dict, Any class MarketAggregator: """ @@ -65,6 +65,7 @@ class MarketAggregator: return float(v[:-1]) * 1_000 try: return float(v) - except Exception: + 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..f72e91c --- /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 MarketAPIs + self._market_apis = MarketAPIs(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/src/app/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py new file mode 100644 index 0000000..df86c36 --- /dev/null +++ b/src/app/utils/wrapper_handler.py @@ -0,0 +1,110 @@ +import time +from typing import TypeVar, Callable, Generic, Iterable, Type +from agno.utils.log import log_warning + +W = TypeVar("W") +T = TypeVar("T") + +class WrapperHandler(Generic[W]): + """ + A handler for managing multiple wrappers with retry logic. + It attempts to call a function on the current wrapper, and if it fails, + it retries a specified number of times before switching to the next wrapper. + If all wrappers fail, it raises an exception. + + Note: use `build_wrappers` to create an instance of this class for better error handling. + """ + + def __init__(self, wrappers: list[W], try_per_wrapper: int = 3, retry_delay: int = 2): + """ + Initializes the WrapperHandler with a list of wrappers and retry settings.\n + Use `build_wrappers` to create an instance of this class for better error handling. + Args: + wrappers (list[W]): A list of wrapper instances to manage. + try_per_wrapper (int): Number of retries per wrapper before switching to the next. + retry_delay (int): Delay in seconds between retries. + """ + self.wrappers = wrappers + self.retry_per_wrapper = try_per_wrapper + self.retry_delay = retry_delay + self.index = 0 + self.retry_count = 0 + + def try_call(self, func: Callable[[W], T]) -> T: + """ + Attempts to call the provided function on the current wrapper. + If it fails, it retries a specified number of times before switching to the next wrapper. + If all wrappers fail, it raises an exception. + Args: + func (Callable[[W], T]): A function that takes a wrapper and returns a result. + Returns: + T: The result of the function call. + Raises: + Exception: If all wrappers fail after retries. + """ + iterations = 0 + while iterations < len(self.wrappers): + try: + wrapper = self.wrappers[self.index] + result = func(wrapper) + self.retry_count = 0 + return result + except Exception as e: + self.retry_count += 1 + if self.retry_count >= self.retry_per_wrapper: + self.index = (self.index + 1) % len(self.wrappers) + self.retry_count = 0 + iterations += 1 + else: + log_warning(f"{wrapper} failed {self.retry_count}/{self.retry_per_wrapper}: {e}") + time.sleep(self.retry_delay) + + raise Exception(f"All wrappers failed after retries") + + def try_call_all(self, func: Callable[[W], T]) -> dict[str, T]: + """ + Calls the provided function on all wrappers, collecting results. + If a wrapper fails, it logs a warning and continues with the next. + If all wrappers fail, it raises an exception. + Args: + func (Callable[[W], T]): A function that takes a wrapper and returns a result. + Returns: + list[T]: A list of results from the function calls. + Raises: + Exception: If all wrappers fail. + """ + results = {} + for wrapper in self.wrappers: + try: + result = func(wrapper) + results[wrapper.__class__] = result + except Exception as e: + log_warning(f"{wrapper} failed: {e}") + if not results: + raise Exception("All wrappers failed") + return results + + @staticmethod + def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2) -> 'WrapperHandler[W]': + """ + Builds a WrapperHandler instance with the given wrapper constructors. + It attempts to initialize each wrapper and logs a warning if any cannot be initialized. + Only successfully initialized wrappers are included in the handler. + Args: + constructors (Iterable[Type[W]]): An iterable of wrapper classes to instantiate. e.g. [WrapperA, WrapperB] + try_per_wrapper (int): Number of retries per wrapper before switching to the next. + retry_delay (int): Delay in seconds between retries. + Returns: + WrapperHandler[W]: An instance of WrapperHandler with the initialized wrappers. + Raises: + Exception: If no wrappers could be initialized. + """ + result = [] + for wrapper_class in constructors: + try: + wrapper = wrapper_class() + result.append(wrapper) + except Exception as e: + log_warning(f"{wrapper_class} cannot be initialized: {e}") + + return WrapperHandler(result, try_per_wrapper, retry_delay) \ No newline at end of file diff --git a/tests/agents/test_market.py b/tests/agents/test_market.py deleted file mode 100644 index 56931b3..0000000 --- a/tests/agents/test_market.py +++ /dev/null @@ -1,146 +0,0 @@ -import os -import pytest -from app.agents.market import MarketToolkit -from app.markets.base import BaseWrapper -from app.markets.coinbase import CoinBaseWrapper -from app.markets.cryptocompare import CryptoCompareWrapper -from app.markets import MarketAPIs - -class TestMarketSystem: - """Test suite per il sistema di mercato (wrappers + toolkit)""" - - @pytest.fixture(scope="class") - def market_wrapper(self) -> BaseWrapper: - return MarketAPIs("USD") - - def test_wrapper_initialization(self, market_wrapper): - assert market_wrapper is not None - assert hasattr(market_wrapper, 'get_product') - assert hasattr(market_wrapper, 'get_products') - assert hasattr(market_wrapper, 'get_all_products') - assert hasattr(market_wrapper, 'get_historical_prices') - - def test_providers_configuration(self): - available_providers = [] - if os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY'): - available_providers.append('coinbase') - if os.getenv('CRYPTOCOMPARE_API_KEY'): - available_providers.append('cryptocompare') - assert len(available_providers) > 0 - - def test_wrapper_capabilities(self, market_wrapper): - capabilities = [] - if hasattr(market_wrapper, 'get_product'): - capabilities.append('single_product') - if hasattr(market_wrapper, 'get_products'): - capabilities.append('multiple_products') - if hasattr(market_wrapper, 'get_historical_prices'): - capabilities.append('historical_data') - assert len(capabilities) > 0 - - def test_market_data_retrieval(self, market_wrapper): - btc_product = market_wrapper.get_product("BTC") - assert btc_product is not None - assert hasattr(btc_product, 'symbol') - assert hasattr(btc_product, 'price') - assert btc_product.price > 0 - - def test_market_toolkit_integration(self, market_wrapper): - try: - toolkit = MarketToolkit() - assert toolkit is not None - assert hasattr(toolkit, 'market_agent') - assert toolkit.market_api is not None - - tools = toolkit.tools - assert len(tools) > 0 - - except Exception as e: - print(f"MarketToolkit test failed: {e}") - # Non fail completamente - il toolkit potrebbe avere dipendenze specifiche - - @pytest.mark.skipif( - not os.getenv('CRYPTOCOMPARE_API_KEY'), - reason="CRYPTOCOMPARE_API_KEY not configured" - ) - def test_cryptocompare_wrapper(self): - try: - api_key = os.getenv('CRYPTOCOMPARE_API_KEY') - wrapper = CryptoCompareWrapper(api_key=api_key, currency="USD") - - btc_product = wrapper.get_product("BTC") - assert btc_product is not None - assert btc_product.symbol == "BTC" - assert btc_product.price > 0 - - products = wrapper.get_products(["BTC", "ETH"]) - assert isinstance(products, list) - assert len(products) > 0 - - for product in products: - if product.symbol in ["BTC", "ETH"]: - assert product.price > 0 - - except Exception as e: - print(f"CryptoCompare test failed: {e}") - # Non fail il test se c'è un errore di rete o API - - @pytest.mark.skipif( - not (os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY')), - reason="Coinbase credentials not configured" - ) - def test_coinbase_wrapper(self): - try: - api_key = os.getenv('CDP_API_KEY_NAME') - api_secret = os.getenv('CDP_API_PRIVATE_KEY') - wrapper = CoinBaseWrapper( - api_key=api_key, - api_private_key=api_secret, - currency="USD" - ) - - btc_product = wrapper.get_product("BTC") - assert btc_product is not None - assert btc_product.symbol == "BTC" - assert btc_product.price > 0 - - products = wrapper.get_products(["BTC", "ETH"]) - assert isinstance(products, list) - assert len(products) > 0 - - except Exception as e: - print(f"Coinbase test failed: {e}") - # Non fail il test se c'è un errore di credenziali o rete - - def test_provider_selection_mechanism(self): - potential_providers = 0 - if os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY'): - potential_providers += 1 - if os.getenv('CRYPTOCOMPARE_API_KEY'): - potential_providers += 1 - - if potential_providers == 0: - with pytest.raises(AssertionError, match="No valid API keys"): - MarketAPIs.get_list_available_market_apis() - else: - wrapper = MarketAPIs("USD") - assert wrapper is not None - assert hasattr(wrapper, 'get_product') - - def test_error_handling(self, market_wrapper): - try: - 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 - - try: - empty_products = market_wrapper.get_products([]) - assert isinstance(empty_products, list) - except Exception as e: - pass - - def test_wrapper_currency_support(self, market_wrapper): - assert hasattr(market_wrapper, 'currency') - assert isinstance(market_wrapper.currency, str) - assert len(market_wrapper.currency) >= 3 # USD, EUR, etc. diff --git a/tests/agents/test_predictor.py b/tests/agents/test_predictor.py index c99104b..9f28717 100644 --- a/tests/agents/test_predictor.py +++ b/tests/agents/test_predictor.py @@ -1,5 +1,5 @@ import pytest -from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle +from app.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle from app.markets.base import ProductInfo from app.models import AppModels @@ -16,8 +16,8 @@ def unified_checks(model: AppModels, input): for item in content.portfolio: assert item.asset not in (None, "", "null") assert isinstance(item.asset, str) - assert item.percentage > 0 - assert item.percentage <= 100 + assert item.percentage >= 0.0 + assert item.percentage <= 100.0 assert isinstance(item.percentage, (int, float)) assert item.motivation not in (None, "", "null") assert isinstance(item.motivation, str) @@ -41,6 +41,7 @@ class TestPredictor: def test_gemini_model_output(self, inputs): unified_checks(AppModels.GEMINI, inputs) + @pytest.mark.slow def test_ollama_qwen_model_output(self, inputs): unified_checks(AppModels.OLLAMA_QWEN, inputs) diff --git a/tests/api/test_binance.py b/tests/api/test_binance.py new file mode 100644 index 0000000..e4e0c20 --- /dev/null +++ b/tests/api/test_binance.py @@ -0,0 +1,52 @@ +import pytest +from app.markets.binance import BinanceWrapper + +@pytest.mark.market +@pytest.mark.api +class TestBinance: + + def test_binance_init(self): + market = BinanceWrapper() + assert market is not None + assert hasattr(market, 'currency') + assert market.currency == "USDT" + + def test_binance_get_product(self): + market = BinanceWrapper() + product = market.get_product("BTC") + assert product is not None + assert hasattr(product, 'symbol') + assert product.symbol == "BTC" + assert hasattr(product, 'price') + assert product.price > 0 + + def test_binance_get_products(self): + market = BinanceWrapper() + products = market.get_products(["BTC", "ETH"]) + assert products is not None + assert isinstance(products, list) + assert len(products) == 2 + symbols = [p.symbol for p in products] + assert "BTC" in symbols + assert "ETH" in symbols + for product in products: + assert hasattr(product, 'price') + assert product.price > 0 + + def test_binance_invalid_product(self): + market = BinanceWrapper() + with pytest.raises(Exception): + _ = market.get_product("INVALID") + + def test_binance_history(self): + market = BinanceWrapper() + history = market.get_historical_prices("BTC", 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 diff --git a/tests/api/test_coinbase.py b/tests/api/test_coinbase.py new file mode 100644 index 0000000..b5f92e8 --- /dev/null +++ b/tests/api/test_coinbase.py @@ -0,0 +1,54 @@ +import os +import pytest +from app.markets import CoinBaseWrapper + +@pytest.mark.market +@pytest.mark.api +@pytest.mark.skipif(not(os.getenv('COINBASE_API_KEY')) or not(os.getenv('COINBASE_API_SECRET')), reason="COINBASE_API_KEY or COINBASE_API_SECRET not set in environment variables") +class TestCoinBase: + + def test_coinbase_init(self): + market = CoinBaseWrapper() + assert market is not None + assert hasattr(market, 'currency') + assert market.currency == "USD" + + def test_coinbase_get_product(self): + market = CoinBaseWrapper() + product = market.get_product("BTC") + assert product is not None + assert hasattr(product, 'symbol') + assert product.symbol == "BTC" + assert hasattr(product, 'price') + assert product.price > 0 + + def test_coinbase_get_products(self): + market = CoinBaseWrapper() + products = market.get_products(["BTC", "ETH"]) + assert products is not None + assert isinstance(products, list) + assert len(products) == 2 + symbols = [p.symbol for p in products] + assert "BTC" in symbols + assert "ETH" in symbols + for product in products: + assert hasattr(product, 'price') + assert product.price > 0 + + def test_coinbase_invalid_product(self): + market = CoinBaseWrapper() + with pytest.raises(Exception): + _ = market.get_product("INVALID") + + def test_coinbase_history(self): + market = CoinBaseWrapper() + history = market.get_historical_prices("BTC", 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 diff --git a/tests/api/test_cryptocompare.py b/tests/api/test_cryptocompare.py new file mode 100644 index 0000000..52aef9a --- /dev/null +++ b/tests/api/test_cryptocompare.py @@ -0,0 +1,56 @@ +import os +import pytest +from app.markets import CryptoCompareWrapper + +@pytest.mark.market +@pytest.mark.api +@pytest.mark.skipif(not os.getenv('CRYPTOCOMPARE_API_KEY'), reason="CRYPTOCOMPARE_API_KEY not set in environment variables") +class TestCryptoCompare: + + def test_cryptocompare_init(self): + market = CryptoCompareWrapper() + assert market is not None + assert hasattr(market, 'api_key') + assert market.api_key == os.getenv('CRYPTOCOMPARE_API_KEY') + assert hasattr(market, 'currency') + assert market.currency == "USD" + + def test_cryptocompare_get_product(self): + market = CryptoCompareWrapper() + product = market.get_product("BTC") + assert product is not None + assert hasattr(product, 'symbol') + assert product.symbol == "BTC" + assert hasattr(product, 'price') + assert product.price > 0 + + def test_cryptocompare_get_products(self): + market = CryptoCompareWrapper() + products = market.get_products(["BTC", "ETH"]) + assert products is not None + assert isinstance(products, list) + assert len(products) == 2 + symbols = [p.symbol for p in products] + assert "BTC" in symbols + assert "ETH" in symbols + for product in products: + assert hasattr(product, 'price') + assert product.price > 0 + + def test_cryptocompare_invalid_product(self): + market = CryptoCompareWrapper() + with pytest.raises(Exception): + _ = market.get_product("INVALID") + + def test_cryptocompare_history(self): + market = CryptoCompareWrapper() + history = market.get_historical_prices("BTC", 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 diff --git a/tests/api/test_cryptopanic_api.py b/tests/api/test_cryptopanic_api.py new file mode 100644 index 0000000..c8020d3 --- /dev/null +++ b/tests/api/test_cryptopanic_api.py @@ -0,0 +1,38 @@ +import os +import pytest +from app.news import CryptoPanicWrapper + + +@pytest.mark.limited +@pytest.mark.news +@pytest.mark.api +@pytest.mark.skipif(not os.getenv("CRYPTOPANIC_API_KEY"), reason="CRYPTOPANIC_API_KEY not set") +class TestCryptoPanicAPI: + + def test_crypto_panic_api_initialization(self): + crypto = CryptoPanicWrapper() + assert crypto is not None + + def test_crypto_panic_api_get_latest_news(self): + crypto = CryptoPanicWrapper() + articles = crypto.get_latest_news(query="", total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert article.source is not None or article.source != "" + assert article.time is not None or article.time != "" + assert article.title is not None or article.title != "" + assert article.description is not None or article.description != "" + + # Useless since both methods use the same endpoint + # def test_crypto_panic_api_get_top_headlines(self): + # crypto = CryptoPanicWrapper() + # articles = crypto.get_top_headlines(total=2) + # assert isinstance(articles, list) + # assert len(articles) == 2 + # for article in articles: + # assert article.source is not None or article.source != "" + # assert article.time is not None or article.time != "" + # assert article.title is not None or article.title != "" + # assert article.description is not None or article.description != "" + diff --git a/tests/api/test_duckduckgo_news.py b/tests/api/test_duckduckgo_news.py new file mode 100644 index 0000000..e0bb599 --- /dev/null +++ b/tests/api/test_duckduckgo_news.py @@ -0,0 +1,34 @@ +import pytest +from app.news import DuckDuckGoWrapper + + +@pytest.mark.news +@pytest.mark.api +class TestDuckDuckGoNews: + + def test_duckduckgo_initialization(self): + news = DuckDuckGoWrapper() + assert news.tool is not None + + def test_duckduckgo_get_latest_news(self): + news = DuckDuckGoWrapper() + articles = news.get_latest_news(query="crypto", total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert article.source is not None or article.source != "" + assert article.time is not None or article.time != "" + assert article.title is not None or article.title != "" + assert article.description is not None or article.description != "" + + def test_duckduckgo_get_top_headlines(self): + news = DuckDuckGoWrapper() + articles = news.get_top_headlines(total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert article.source is not None or article.source != "" + assert article.time is not None or article.time != "" + assert article.title is not None or article.title != "" + assert article.description is not None or article.description != "" + diff --git a/tests/api/test_google_news.py b/tests/api/test_google_news.py new file mode 100644 index 0000000..c7750f3 --- /dev/null +++ b/tests/api/test_google_news.py @@ -0,0 +1,34 @@ +import pytest +from app.news import GoogleNewsWrapper + + +@pytest.mark.news +@pytest.mark.api +class TestGoogleNews: + + def test_gnews_api_initialization(self): + gnews_api = GoogleNewsWrapper() + assert gnews_api is not None + + def test_gnews_api_get_latest_news(self): + gnews_api = GoogleNewsWrapper() + articles = gnews_api.get_latest_news(query="crypto", total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert article.source is not None or article.source != "" + assert article.time is not None or article.time != "" + assert article.title is not None or article.title != "" + assert article.description is not None or article.description != "" + + def test_gnews_api_get_top_headlines(self): + news_api = GoogleNewsWrapper() + articles = news_api.get_top_headlines(total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert article.source is not None or article.source != "" + assert article.time is not None or article.time != "" + assert article.title is not None or article.title != "" + assert article.description is not None or article.description != "" + diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py new file mode 100644 index 0000000..927419b --- /dev/null +++ b/tests/api/test_news_api.py @@ -0,0 +1,37 @@ +import os +import pytest +from app.news import NewsApiWrapper + + +@pytest.mark.news +@pytest.mark.api +@pytest.mark.skipif(not os.getenv("NEWS_API_KEY"), reason="NEWS_API_KEY not set") +class TestNewsAPI: + + def test_news_api_initialization(self): + news_api = NewsApiWrapper() + assert news_api.client is not None + + def test_news_api_get_latest_news(self): + news_api = NewsApiWrapper() + articles = news_api.get_latest_news(query="crypto", total=2) + assert isinstance(articles, list) + assert len(articles) > 0 # Ensure we got some articles (apparently it doesn't always return the requested number) + for article in articles: + assert article.source is not None or article.source != "" + assert article.time is not None or article.time != "" + assert article.title is not None or article.title != "" + assert article.description is not None or article.description != "" + + + def test_news_api_get_top_headlines(self): + news_api = NewsApiWrapper() + articles = news_api.get_top_headlines(total=2) + assert isinstance(articles, list) + # assert len(articles) > 0 # apparently it doesn't always return SOME articles + for article in articles: + assert article.source is not None or article.source != "" + assert article.time is not None or article.time != "" + assert article.title is not None or article.title != "" + assert article.description is not None or article.description != "" + diff --git a/tests/api/test_reddit.py b/tests/api/test_reddit.py new file mode 100644 index 0000000..84c66da --- /dev/null +++ b/tests/api/test_reddit.py @@ -0,0 +1,24 @@ +import pytest +from praw import Reddit +from app.social.reddit import MAX_COMMENTS, RedditWrapper + +@pytest.mark.social +@pytest.mark.api +class TestRedditWrapper: + def test_initialization(self): + wrapper = RedditWrapper() + assert wrapper.client_id is not None + assert wrapper.client_secret is not None + assert isinstance(wrapper.tool, Reddit) + + def test_get_top_crypto_posts(self): + wrapper = RedditWrapper() + posts = wrapper.get_top_crypto_posts(limit=2) + assert isinstance(posts, list) + assert len(posts) == 2 + for post in posts: + assert post.title != "" + assert isinstance(post.comments, list) + assert len(post.comments) <= MAX_COMMENTS + for comment in post.comments: + assert comment.description != "" diff --git a/tests/conftest.py b/tests/conftest.py index cfe3606..c792e04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,16 +2,10 @@ Configurazione pytest per i test del progetto upo-appAI. """ -import sys import pytest -from pathlib import Path - -# Aggiungi il path src al PYTHONPATH per tutti i test -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) +from dotenv import load_dotenv # Carica le variabili d'ambiente per tutti i test -from dotenv import load_dotenv load_dotenv() @@ -21,32 +15,28 @@ def pytest_configure(config:pytest.Config): markers = [ ("slow", "marks tests as slow (deselect with '-m \"not slow\"')"), ("api", "marks tests that require API access"), - ("coinbase", "marks tests that require Coinbase credentials"), - ("cryptocompare", "marks tests that require CryptoCompare credentials"), + ("market", "marks tests that use market data"), ("gemini", "marks tests that use Gemini model"), ("ollama_gpt", "marks tests that use Ollama GPT model"), ("ollama_qwen", "marks tests that use Ollama Qwen model"), + ("news", "marks tests that use news"), + ("social", "marks tests that use social media"), + ("limited", "marks tests that have limited execution due to API constraints"), + ("wrapper", "marks tests for wrapper handler"), + ("tools", "marks tests for tools"), ] for marker in markers: line = f"{marker[0]}: {marker[1]}" config.addinivalue_line("markers", line) def pytest_collection_modifyitems(config, items): - """Modifica automaticamente gli item di test aggiungendogli marker basati sul nome""" + """Modifica automaticamente degli item di test rimovendoli""" + # Rimuovo i test "limited" e "slow" se non richiesti esplicitamente + mark_to_remove = ['limited', 'slow'] + for mark in mark_to_remove: + markexpr = getattr(config.option, "markexpr", None) + if markexpr and mark in markexpr.lower(): + continue - markers_to_add = { - "api": pytest.mark.api, - "coinbase": pytest.mark.api, - "cryptocompare": pytest.mark.api, - "overview": pytest.mark.slow, - "analysis": pytest.mark.slow, - "gemini": pytest.mark.gemini, - "ollama_gpt": pytest.mark.ollama_gpt, - "ollama_qwen": pytest.mark.ollama_qwen, - } - - for item in items: - name = item.name.lower() - for key, marker in markers_to_add.items(): - if key in name: - item.add_marker(marker) + new_mark = (f"({markexpr}) and " if markexpr else "") + f"not {mark}" + setattr(config.option, "markexpr", new_mark) diff --git a/tests/tools/test_market_tool.py b/tests/tools/test_market_tool.py new file mode 100644 index 0000000..0d6d1a1 --- /dev/null +++ b/tests/tools/test_market_tool.py @@ -0,0 +1,70 @@ +import os +import pytest +from app.agents.market_agent import MarketToolkit +from app.markets import MarketAPIsTool + +@pytest.mark.limited # usa molte api calls e non voglio esaurire le chiavi api +@pytest.mark.tools +@pytest.mark.api +class TestMarketAPIsTool: + def test_wrapper_initialization(self): + market_wrapper = MarketAPIsTool("USD") + assert market_wrapper is not None + assert hasattr(market_wrapper, 'get_product') + assert hasattr(market_wrapper, 'get_products') + assert hasattr(market_wrapper, 'get_all_products') + assert hasattr(market_wrapper, 'get_historical_prices') + + def test_wrapper_capabilities(self): + market_wrapper = MarketAPIsTool("USD") + capabilities = [] + if hasattr(market_wrapper, 'get_product'): + capabilities.append('single_product') + if hasattr(market_wrapper, 'get_products'): + capabilities.append('multiple_products') + if hasattr(market_wrapper, 'get_historical_prices'): + capabilities.append('historical_data') + assert len(capabilities) > 0 + + def test_market_data_retrieval(self): + market_wrapper = MarketAPIsTool("USD") + btc_product = market_wrapper.get_product("BTC") + assert btc_product is not None + assert hasattr(btc_product, 'symbol') + assert hasattr(btc_product, 'price') + assert btc_product.price > 0 + + def test_market_toolkit_integration(self): + try: + toolkit = MarketToolkit() + assert toolkit is not None + assert hasattr(toolkit, 'market_agent') + assert toolkit.market_api is not None + + tools = toolkit.tools + assert len(tools) > 0 + + except Exception as e: + print(f"MarketToolkit test failed: {e}") + # Non fail completamente - il toolkit potrebbe avere dipendenze specifiche + + def test_provider_selection_mechanism(self): + potential_providers = 0 + if os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY'): + potential_providers += 1 + if os.getenv('CRYPTOCOMPARE_API_KEY'): + potential_providers += 1 + + def test_error_handling(self): + try: + 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 = 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..236d2a4 --- /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.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/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py new file mode 100644 index 0000000..4770977 --- /dev/null +++ b/tests/utils/test_wrapper_handler.py @@ -0,0 +1,90 @@ +import pytest +from app.utils.wrapper_handler import WrapperHandler + +class MockWrapper: + def do_something(self) -> str: + return "Success" + +class MockWrapper2(MockWrapper): + def do_something(self) -> str: + return "Success 2" + +class FailingWrapper(MockWrapper): + def do_something(self): + raise Exception("Intentional Failure") + + +@pytest.mark.wrapper +class TestWrapperHandler: + def test_all_wrappers_fail(self): + wrappers = [FailingWrapper, FailingWrapper] + handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + + with pytest.raises(Exception) as exc_info: + handler.try_call(lambda w: w.do_something()) + assert "All wrappers failed after retries" in str(exc_info.value) + + def test_success_on_first_try(self): + wrappers = [MockWrapper, FailingWrapper] + handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + + result = handler.try_call(lambda w: w.do_something()) + assert result == "Success" + assert handler.index == 0 # Should still be on the first wrapper + assert handler.retry_count == 0 + + def test_eventual_success(self): + wrappers = [FailingWrapper, MockWrapper] + handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + + result = handler.try_call(lambda w: w.do_something()) + assert result == "Success" + assert handler.index == 1 # Should have switched to the second wrapper + assert handler.retry_count == 0 + + def test_partial_failures(self): + wrappers = [FailingWrapper, MockWrapper, FailingWrapper] + handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + + result = handler.try_call(lambda w: w.do_something()) + assert result == "Success" + assert handler.index == 1 # Should have switched to the second wrapper + assert handler.retry_count == 0 + + # Next call should still succeed on the second wrapper + result = handler.try_call(lambda w: w.do_something()) + assert result == "Success" + assert handler.index == 1 # Should still be on the second wrapper + assert handler.retry_count == 0 + + handler.index = 2 # Manually switch to the third wrapper + result = handler.try_call(lambda w: w.do_something()) + assert result == "Success" + assert handler.index == 1 # Should return to the second wrapper after failure + assert handler.retry_count == 0 + + def test_try_call_all_success(self): + wrappers = [MockWrapper, MockWrapper2] + handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + results = handler.try_call_all(lambda w: w.do_something()) + assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"} + + def test_try_call_all_partial_failures(self): + # Only the second wrapper should succeed + wrappers = [FailingWrapper, MockWrapper, FailingWrapper] + handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + results = handler.try_call_all(lambda w: w.do_something()) + assert results == {MockWrapper: "Success"} + + # Only the second and fourth wrappers should succeed + wrappers = [FailingWrapper, MockWrapper, FailingWrapper, MockWrapper2] + handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + results = handler.try_call_all(lambda w: w.do_something()) + assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"} + + def test_try_call_all_all_fail(self): + # Test when all wrappers fail + handler_all_fail: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers([FailingWrapper, FailingWrapper], try_per_wrapper=1, retry_delay=0) + with pytest.raises(Exception) as exc_info: + handler_all_fail.try_call_all(lambda w: w.do_something()) + assert "All wrappers failed" in str(exc_info.value) diff --git a/uv.lock b/uv.lock index 646299f..2d7d6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -130,6 +130,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + [[package]] name = "brotli" version = "1.1.0" @@ -156,6 +169,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255, upload-time = "2023-09-07T14:04:17.83Z" }, ] +[[package]] +name = "brotlicffi" +version = "1.1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/9d/70caa61192f570fcf0352766331b735afa931b4c6bc9a348a0925cc13288/brotlicffi-1.1.0.0.tar.gz", hash = "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13", size = 465192, upload-time = "2023-09-14T14:22:40.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/11/7b96009d3dcc2c931e828ce1e157f03824a69fb728d06bfd7b2fc6f93718/brotlicffi-1.1.0.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851", size = 453786, upload-time = "2023-09-14T14:21:57.72Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/a8f46f4a4ee7856fbd6ac0c6fb0dc65ed181ba46cd77875b8d9bbe494d9e/brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b", size = 2911165, upload-time = "2023-09-14T14:21:59.613Z" }, + { url = "https://files.pythonhosted.org/packages/be/20/201559dff14e83ba345a5ec03335607e47467b6633c210607e693aefac40/brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9feb210d932ffe7798ee62e6145d3a757eb6233aa9a4e7db78dd3690d7755814", size = 2927895, upload-time = "2023-09-14T14:22:01.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/15/695b1409264143be3c933f708a3f81d53c4a1e1ebbc06f46331decbf6563/brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84763dbdef5dd5c24b75597a77e1b30c66604725707565188ba54bab4f114820", size = 2851834, upload-time = "2023-09-14T14:22:03.571Z" }, + { url = "https://files.pythonhosted.org/packages/b4/40/b961a702463b6005baf952794c2e9e0099bde657d0d7e007f923883b907f/brotlicffi-1.1.0.0-cp37-abi3-win32.whl", hash = "sha256:1b12b50e07c3911e1efa3a8971543e7648100713d4e0971b13631cce22c587eb", size = 341731, upload-time = "2023-09-14T14:22:05.74Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/5408a03c041114ceab628ce21766a4ea882aa6f6f0a800e04ee3a30ec6b9/brotlicffi-1.1.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:994a4f0681bb6c6c3b0925530a1926b7a189d878e6e5e38fae8efa47c5d9c613", size = 366783, upload-time = "2023-09-14T14:22:07.096Z" }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -310,6 +340,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453, upload-time = "2025-06-26T09:29:21.412Z" }, ] +[[package]] +name = "ddgs" +version = "9.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx", extra = ["brotli", "http2", "socks"] }, + { name = "lxml" }, + { name = "primp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/45/7a408de2cd89855403ea18ed776f12c291eabe7dd54bc5b00f7cdb43f8ba/ddgs-9.6.0.tar.gz", hash = "sha256:8caf555d4282c1cf5c15969994ad55f4239bd15e97cf004a5da8f1cad37529bf", size = 35865, upload-time = "2025-09-17T13:27:10.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/cd/ef820662e0d87f46b829bba7e2324c7978e0153692bbd2f08f7746049708/ddgs-9.6.0-py3-none-any.whl", hash = "sha256:24120f1b672fd3a28309db029e7038eb3054381730aea7a08d51bb909dd55520", size = 41558, upload-time = "2025-09-17T13:27:08.99Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -344,6 +398,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670, upload-time = "2025-09-16T18:29:21.329Z" }, ] +[[package]] +name = "feedparser" +version = "6.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sgmllib3k" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" }, +] + [[package]] name = "ffmpy" version = "0.6.1" @@ -421,6 +487,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, ] +[[package]] +name = "gnews" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "dnspython" }, + { name = "feedparser" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/65/d4b19ebde3edd4d0cb63660fe61e9777de1dd35ea819cb72a5b53002bb97/gnews-0.4.2.tar.gz", hash = "sha256:5016cf5299f42ea072adb295abe5e9f093c5c422da2c12e6661d1dcdbc56d011", size = 24847, upload-time = "2025-07-27T13:46:54.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/77/00b21cce68b6041e78edf23efbc95eea6a4555cd474594b7360d1b9e4444/gnews-0.4.2-py3-none-any.whl", hash = "sha256:ed1fa603a7edeb3886925e756b114afb1e0c5b7b9f56fe5ebeedeeb730d2a9c4", size = 18142, upload-time = "2025-07-27T13:46:53.848Z" }, +] + [[package]] name = "google-auth" version = "2.40.3" @@ -528,6 +609,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "hf-xet" version = "1.1.10" @@ -543,6 +637,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/0e/471f0a21db36e71a2f1752767ad77e92d8cde24e974e03d662931b1305ec/hf_xet-1.1.10-cp37-abi3-win_amd64.whl", hash = "sha256:5f54b19cc347c13235ae7ee98b330c26dd65ef1df47e5316ffb1e87713ca7045", size = 2804691, upload-time = "2025-09-12T20:10:28.433Z" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -571,6 +674,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +brotli = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, +] +http2 = [ + { name = "h2" }, +] +socks = [ + { name = "socksio" }, +] + [[package]] name = "huggingface-hub" version = "0.35.0" @@ -590,6 +705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/85/a18508becfa01f1e4351b5e18651b06d210dbd96debccd48a452acccb901/huggingface_hub-0.35.0-py3-none-any.whl", hash = "sha256:f2e2f693bca9a26530b1c0b9bcd4c1495644dad698e6a0060f90e22e772c31e9", size = 563436, upload-time = "2025-09-16T13:49:30.627Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -620,6 +744,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -686,6 +836,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, ] +[[package]] +name = "newsapi-python" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/4b/12fb9495211fc5a6d3a96968759c1a48444124a1654aaf65d0de80b46794/newsapi-python-0.2.7.tar.gz", hash = "sha256:a4b66d5dd9892198cdaa476f7542f2625cdd218e5e3121c8f880b2ace717a3c2", size = 7485, upload-time = "2023-03-02T13:15:35.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/47/e3b099102f0c826d37841d2266e19f1568dcf58ba86e4c6948e2a124f91d/newsapi_python-0.2.7-py2.py3-none-any.whl", hash = "sha256:11d34013a24d92ca7b7cbdac84ed2d504862b1e22467bc2a9a6913a70962318e", size = 7942, upload-time = "2023-03-02T13:15:34.475Z" }, +] + [[package]] name = "numpy" version = "2.3.3" @@ -799,6 +961,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "praw" +version = "7.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prawcore" }, + { name = "update-checker" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/52/7dd0b3d9ccb78e90236420ef6c51b6d9b2400a7229442f0cfcf2258cce21/praw-7.8.1.tar.gz", hash = "sha256:3c5767909f71e48853eb6335fef7b50a43cbe3da728cdfb16d3be92904b0a4d8", size = 154106, upload-time = "2024-10-25T21:49:33.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/ca/60ec131c3b43bff58261167045778b2509b83922ce8f935ac89d871bd3ea/praw-7.8.1-py3-none-any.whl", hash = "sha256:15917a81a06e20ff0aaaf1358481f4588449fa2421233040cb25e5c8202a3e2f", size = 189338, upload-time = "2024-10-25T21:49:31.109Z" }, +] + +[[package]] +name = "prawcore" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/62/d4c99cf472205f1e5da846b058435a6a7c988abf8eb6f7d632a7f32f4a77/prawcore-2.4.0.tar.gz", hash = "sha256:b7b2b5a1d04406e086ab4e79988dc794df16059862f329f4c6a43ed09986c335", size = 15862, upload-time = "2023-10-01T23:30:49.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/5c/8af904314e42d5401afcfaff69940dc448e974f80f7aa39b241a4fbf0cf1/prawcore-2.4.0-py3-none-any.whl", hash = "sha256:29af5da58d85704b439ad3c820873ad541f4535e00bb98c66f0fbcc8c603065a", size = 17203, upload-time = "2023-10-01T23:30:47.651Z" }, +] + +[[package]] +name = "primp" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/0b/a87556189da4de1fc6360ca1aa05e8335509633f836cdd06dd17f0743300/primp-0.15.0.tar.gz", hash = "sha256:1af8ea4b15f57571ff7fc5e282a82c5eb69bc695e19b8ddeeda324397965b30a", size = 113022, upload-time = "2025-04-17T11:41:05.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/5a/146ac964b99ea7657ad67eb66f770be6577dfe9200cb28f9a95baffd6c3f/primp-0.15.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1b281f4ca41a0c6612d4c6e68b96e28acfe786d226a427cd944baa8d7acd644f", size = 3178914, upload-time = "2025-04-17T11:40:59.558Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/cc2321e32db3ce64d6e32950d5bcbea01861db97bfb20b5394affc45b387/primp-0.15.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:489cbab55cd793ceb8f90bb7423c6ea64ebb53208ffcf7a044138e3c66d77299", size = 2955079, upload-time = "2025-04-17T11:40:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7b/cbd5d999a07ff2a21465975d4eb477ae6f69765e8fe8c9087dab250180d8/primp-0.15.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c18b45c23f94016215f62d2334552224236217aaeb716871ce0e4dcfa08eb161", size = 3281018, upload-time = "2025-04-17T11:40:55.308Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6e/a6221c612e61303aec2bcac3f0a02e8b67aee8c0db7bdc174aeb8010f975/primp-0.15.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e985a9cba2e3f96a323722e5440aa9eccaac3178e74b884778e926b5249df080", size = 3255229, upload-time = "2025-04-17T11:40:47.811Z" }, + { url = "https://files.pythonhosted.org/packages/3b/54/bfeef5aca613dc660a69d0760a26c6b8747d8fdb5a7f20cb2cee53c9862f/primp-0.15.0-cp38-abi3-manylinux_2_34_armv7l.whl", hash = "sha256:6b84a6ffa083e34668ff0037221d399c24d939b5629cd38223af860de9e17a83", size = 3014522, upload-time = "2025-04-17T11:40:50.191Z" }, + { url = "https://files.pythonhosted.org/packages/ac/96/84078e09f16a1dad208f2fe0f8a81be2cf36e024675b0f9eec0c2f6e2182/primp-0.15.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:592f6079646bdf5abbbfc3b0a28dac8de943f8907a250ce09398cda5eaebd260", size = 3418567, upload-time = "2025-04-17T11:41:01.595Z" }, + { url = "https://files.pythonhosted.org/packages/6c/80/8a7a9587d3eb85be3d0b64319f2f690c90eb7953e3f73a9ddd9e46c8dc42/primp-0.15.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a728e5a05f37db6189eb413d22c78bd143fa59dd6a8a26dacd43332b3971fe8", size = 3606279, upload-time = "2025-04-17T11:41:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/0c/dd/f0183ed0145e58cf9d286c1b2c14f63ccee987a4ff79ac85acc31b5d86bd/primp-0.15.0-cp38-abi3-win_amd64.whl", hash = "sha256:aeb6bd20b06dfc92cfe4436939c18de88a58c640752cf7f30d9e4ae893cdec32", size = 3149967, upload-time = "2025-04-17T11:41:07.067Z" }, +] + [[package]] name = "propcache" version = "0.3.2" @@ -1152,6 +1356,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, ] +[[package]] +name = "sgmllib3k" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } + [[package]] name = "shellingham" version = "1.5.4" @@ -1188,6 +1398,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + [[package]] name = "starlette" version = "0.48.0" @@ -1288,6 +1516,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "update-checker" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/0b/1bec4a6cc60d33ce93d11a7bcf1aeffc7ad0aa114986073411be31395c6f/update_checker-0.18.0.tar.gz", hash = "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13", size = 6699, upload-time = "2020-08-04T07:08:50.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ba/8dd7fa5f0b1c6a8ac62f8f57f7e794160c1f86f31c6d0fb00f582372a3e4/update_checker-0.18.0-py3-none-any.whl", hash = "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd", size = 7008, upload-time = "2020-08-04T07:08:49.51Z" }, +] + [[package]] name = "upo-app-ai" version = "0.1.0" @@ -1295,10 +1535,14 @@ source = { virtual = "." } dependencies = [ { name = "agno" }, { name = "coinbase-advanced-py" }, + { name = "ddgs" }, { name = "dotenv" }, + { name = "gnews" }, { name = "google-genai" }, { name = "gradio" }, + { name = "newsapi-python" }, { name = "ollama" }, + { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, ] @@ -1307,10 +1551,14 @@ dependencies = [ requires-dist = [ { name = "agno" }, { name = "coinbase-advanced-py" }, + { name = "ddgs" }, { name = "dotenv" }, + { name = "gnews" }, { name = "google-genai" }, { name = "gradio" }, + { name = "newsapi-python" }, { name = "ollama" }, + { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, ] @@ -1337,6 +1585,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" }, +] + [[package]] name = "websockets" version = "13.1"