From c82f10b32c712681f23c69ec1f0798d8d819cc42 Mon Sep 17 00:00:00 2001 From: trojanhorse47 Date: Mon, 29 Sep 2025 12:22:02 +0200 Subject: [PATCH 01/33] Creazione branch tool, refactor degli import e soppressione dei warning --- pyproject.toml | 2 +- src/app/agents/market.py | 2 +- src/app/agents/predictor.py | 4 ++-- src/app/markets/__init__.py | 14 +++++++----- src/app/markets/coinbase.py | 2 +- src/app/markets/cryptocompare.py | 4 ++-- src/app/models.py | 36 +++++++++++++++++++++--------- src/app/tool.py | 27 ++++++++++++++-------- src/app/utils/market_aggregator.py | 5 +++-- tests/agents/test_market.py | 10 ++++----- tests/agents/test_predictor.py | 12 +++++----- 11 files changed, 72 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35a3b6e..9822cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "dotenv", # ๐ŸŸก per fare scraping di pagine web #"bs4", - # โœ… per fare una UI web semplice con input e output + # โœ… per fare una UI web semplice con user_input e output "gradio", # โœ… per costruire agenti (ovvero modelli che possono fare piรน cose tramite tool) https://github.com/agno-agi/agno diff --git a/src/app/agents/market.py b/src/app/agents/market.py index affa466..a561cb6 100644 --- a/src/app/agents/market.py +++ b/src/app/agents/market.py @@ -1,5 +1,5 @@ from agno.tools import Toolkit -from app.markets import MarketAPIs +from src.app.markets import MarketAPIs # 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 diff --git a/src/app/agents/predictor.py b/src/app/agents/predictor.py index e811846..09d7a26 100644 --- a/src/app/agents/predictor.py +++ b/src/app/agents/predictor.py @@ -1,5 +1,5 @@ from enum import Enum -from app.markets.base import ProductInfo +from src.app.markets.base import ProductInfo from pydantic import BaseModel, Field class PredictorStyle(Enum): @@ -23,7 +23,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/markets/__init__.py b/src/app/markets/__init__.py index 4bb3e9e..cb27b23 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,6 +1,6 @@ -from app.markets.base import BaseWrapper -from app.markets.coinbase import CoinBaseWrapper -from app.markets.cryptocompare import CryptoCompareWrapper +from src.app.markets.base import BaseWrapper +from src.app.markets.coinbase import CoinBaseWrapper +from src.app.markets.cryptocompare import CryptoCompareWrapper from agno.utils.log import log_warning @@ -30,8 +30,8 @@ class MarketAPIs(BaseWrapper): 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?") + except Exception as e: + log_warning(f"{wrapper} cannot be initialized: {e}") assert result, "No market API keys set in environment variables." return result @@ -39,7 +39,9 @@ class MarketAPIs(BaseWrapper): 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") + + Args: + currency: Valuta di riferimento (default "USD") """ self.currency = currency self.wrappers = MarketAPIs.get_list_available_market_apis(currency=currency) diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index aac556d..2e0e779 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -1,6 +1,6 @@ import os from coinbase.rest import RESTClient -from app.markets.base import ProductInfo, BaseWrapper, Price +from src.app.markets.base import ProductInfo, BaseWrapper, Price class CoinBaseWrapper(BaseWrapper): """ diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index 188a2c2..a6e50a0 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -1,6 +1,6 @@ import os import requests -from app.markets.base import ProductInfo, BaseWrapper, Price +from src.app.markets.base import ProductInfo, BaseWrapper, Price BASE_URL = "https://min-api.cryptocompare.com" @@ -8,7 +8,7 @@ class CryptoCompareWrapper(BaseWrapper): """ Wrapper per le API pubbliche di CryptoCompare. 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. + !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: diff --git a/src/app/models.py b/src/app/models.py index 12aae9c..94f7da2 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -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/tool.py b/src/app/tool.py index d0b3ca0..f1ca560 100644 --- a/src/app/tool.py +++ b/src/app/tool.py @@ -1,8 +1,8 @@ -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 src.app.agents.news_agent import NewsAgent +from src.app.agents.social_agent import SocialAgent +from src.app.agents.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS +from src.app.markets import MarketAPIs +from src.app.models import AppModels from agno.utils.log import log_info class ToolAgent: @@ -14,6 +14,10 @@ class ToolAgent: """ Inizializza l'agente con i modelli disponibili, gli stili e l'API di mercato. """ + self.social_agent = None + self.news_agent = None + self.predictor = None + self.chosen_model = None self.available_models = AppModels.availables() self.all_styles = list(PredictorStyle) self.style = self.all_styles[0] # Default to the first style @@ -24,7 +28,9 @@ class ToolAgent: 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. + + Args: + 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 @@ -37,15 +43,18 @@ class ToolAgent: 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. + + Args: + 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. + + Args: + query: richiesta dell'utente (es. "Qual รจ la previsione per Bitcoin?") """ log_info(f"[model={self.chosen_model.name}] [style={self.style.name}] [query=\"{query.replace('"', "'")}\"]") 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/tests/agents/test_market.py b/tests/agents/test_market.py index 56931b3..cb6c390 100644 --- a/tests/agents/test_market.py +++ b/tests/agents/test_market.py @@ -1,10 +1,10 @@ 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 +from src.app.agents.market import MarketToolkit +from src.app.markets.base import BaseWrapper +from src.app.markets.coinbase import CoinBaseWrapper +from src.app.markets.cryptocompare import CryptoCompareWrapper +from src.app.markets import MarketAPIs class TestMarketSystem: """Test suite per il sistema di mercato (wrappers + toolkit)""" diff --git a/tests/agents/test_predictor.py b/tests/agents/test_predictor.py index c99104b..bf10837 100644 --- a/tests/agents/test_predictor.py +++ b/tests/agents/test_predictor.py @@ -1,11 +1,11 @@ import pytest -from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.markets.base import ProductInfo -from app.models import AppModels +from src.app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle +from src.app.markets.base import ProductInfo +from src.app.models import AppModels -def unified_checks(model: AppModels, input): - llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) - result = llm.run(input) +def unified_checks(model: AppModels, user_input): + llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type] + result = llm.run(user_input) content = result.content assert isinstance(content, PredictorOutput) From 2be9e0f319b2bb7e34e4ec5dea647b750bc1dd07 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Mon, 29 Sep 2025 14:39:31 +0200 Subject: [PATCH 02/33] Update pytest configuration and dependencies in pyproject.toml --- pyproject.toml | 4 ++++ tests/conftest.py | 8 +------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35a3b6e..d7caff6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,7 @@ dependencies = [ "coinbase-advanced-py", "python-binance", ] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py index cfe3606..2b6c16f 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() From badd3e2a6cf5c41e3216693340dae38007404cdc Mon Sep 17 00:00:00 2001 From: Berack96 Date: Mon, 29 Sep 2025 15:15:18 +0200 Subject: [PATCH 03/33] Add news API integration and related configurations - Update .env.example to include NEWS_API_KEY configuration - Add newsapi-python dependency in pyproject.toml - Implement NewsAPI class for fetching news articles - Create Article model for structured news data - Add tests for NewsAPI functionality in test_news_api.py - Update pytest configuration to include news marker --- .env.example | 9 ++++++++- pyproject.toml | 3 +++ src/app/news/__init__.py | 3 +++ src/app/news/base.py | 8 ++++++++ src/app/news/news_api.py | 34 ++++++++++++++++++++++++++++++++++ tests/api/test_news_api.py | 19 +++++++++++++++++++ tests/conftest.py | 2 ++ uv.lock | 14 ++++++++++++++ 8 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/app/news/__init__.py create mode 100644 src/app/news/base.py create mode 100644 src/app/news/news_api.py create mode 100644 tests/api/test_news_api.py diff --git a/.env.example b/.env.example index 0bef205..d5d5a38 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -########################################################################### +############################################################################### # Configurazioni per i modelli di linguaggio ############################################################################### @@ -22,3 +22,10 @@ CRYPTOCOMPARE_API_KEY= # Binance API per Market Agent (alternativa) BINANCE_API_KEY= BINANCE_API_SECRET= + +############################################################################### +# Configurazioni per gli agenti di notizie +############################################################################### +# Ottenibile da: https://newsapi.org/docs +NEWS_API_KEY= + diff --git a/pyproject.toml b/pyproject.toml index d7caff6..3ef7154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ dependencies = [ # โœ… per interagire con API di exchange di criptovalute "coinbase-advanced-py", "python-binance", + + # โœ… per interagire con API di notizie + "newsapi-python", ] [tool.pytest.ini_options] diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py new file mode 100644 index 0000000..9b88ff6 --- /dev/null +++ b/src/app/news/__init__.py @@ -0,0 +1,3 @@ +from .news_api import NewsAPI + +__all__ = ["NewsAPI"] \ No newline at end of file diff --git a/src/app/news/base.py b/src/app/news/base.py new file mode 100644 index 0000000..0391424 --- /dev/null +++ b/src/app/news/base.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + +class Article(BaseModel): + source: str = "" + time: str = "" + title: str = "" + description: str = "" + diff --git a/src/app/news/news_api.py b/src/app/news/news_api.py new file mode 100644 index 0000000..54b210a --- /dev/null +++ b/src/app/news/news_api.py @@ -0,0 +1,34 @@ +import os +import newsapi +from .base import Article + + +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 NewsAPI: + 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" + self.language = "en" + self.page_size = 100 + + def get_top_headlines(self, query:str, total:int=100) -> list[Article]: + page_size = min(self.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(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 + + diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py new file mode 100644 index 0000000..99a3179 --- /dev/null +++ b/tests/api/test_news_api.py @@ -0,0 +1,19 @@ +from app.news import NewsAPI + +class TestNewsAPI: + + def test_news_api_initialization(self): + news_api = NewsAPI() + assert news_api.client is not None + + def test_news_api_get_top_headlines(self): + news_api = NewsAPI() + articles = news_api.get_top_headlines(query="crypto", total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert hasattr(article, 'source') + assert hasattr(article, 'time') + assert hasattr(article, 'title') + assert hasattr(article, 'description') + diff --git a/tests/conftest.py b/tests/conftest.py index 2b6c16f..f2601b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ def pytest_configure(config:pytest.Config): ("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"), ] for marker in markers: line = f"{marker[0]}: {marker[1]}" @@ -37,6 +38,7 @@ def pytest_collection_modifyitems(config, items): "gemini": pytest.mark.gemini, "ollama_gpt": pytest.mark.ollama_gpt, "ollama_qwen": pytest.mark.ollama_qwen, + "news": pytest.mark.news, } for item in items: diff --git a/uv.lock b/uv.lock index 646299f..26356c0 100644 --- a/uv.lock +++ b/uv.lock @@ -686,6 +686,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" @@ -1298,6 +1310,7 @@ dependencies = [ { name = "dotenv" }, { name = "google-genai" }, { name = "gradio" }, + { name = "newsapi-python" }, { name = "ollama" }, { name = "pytest" }, { name = "python-binance" }, @@ -1310,6 +1323,7 @@ requires-dist = [ { name = "dotenv" }, { name = "google-genai" }, { name = "gradio" }, + { name = "newsapi-python" }, { name = "ollama" }, { name = "pytest" }, { name = "python-binance" }, From fc4753a24562c4765bde052835cd57606ce9aefd Mon Sep 17 00:00:00 2001 From: Berack96 Date: Mon, 29 Sep 2025 19:21:08 +0200 Subject: [PATCH 04/33] Add news API functionality and update tests for article retrieval --- demos/news_api.py | 16 ++++++++++++++++ src/app/news/news_api.py | 11 ++++++----- tests/api/test_news_api.py | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 demos/news_api.py diff --git a/demos/news_api.py b/demos/news_api.py new file mode 100644 index 0000000..0fc4c37 --- /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 NewsAPI + +def main(): + api = NewsAPI() + print("ok") + +if __name__ == "__main__": + load_dotenv() + main() \ No newline at end of file diff --git a/src/app/news/news_api.py b/src/app/news/news_api.py index 54b210a..ce213cf 100644 --- a/src/app/news/news_api.py +++ b/src/app/news/news_api.py @@ -17,16 +17,17 @@ class NewsAPI: assert api_key is not None, "NEWS_API_KEY environment variable not set" self.client = newsapi.NewsApiClient(api_key=api_key) - self.category = "business" - self.language = "en" - self.page_size = 100 + 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, query:str, total:int=100) -> list[Article]: - page_size = min(self.page_size, total) + 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(category=self.category, language=self.language, page_size=page_size, page=page) + headlines = self.client.get_top_headlines(q=query, 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 diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 99a3179..9558882 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -10,7 +10,7 @@ class TestNewsAPI: news_api = NewsAPI() articles = news_api.get_top_headlines(query="crypto", total=2) assert isinstance(articles, list) - assert len(articles) == 2 + assert len(articles) > 0 # Ensure we got some articles (apparently it doesn't always return the requested number) for article in articles: assert hasattr(article, 'source') assert hasattr(article, 'time') From dfca44c9d5d8c3d114a59c6765e7c7e7dcb26e9a Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Mon, 29 Sep 2025 21:28:41 +0200 Subject: [PATCH 05/33] ToDo: 1. Aggiungere un aggregator per i dati recuperati dai provider. 2. Lavorare effettivamente all'issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Done: 1. creati test per i provider 2. creato market_providers_api_demo.py per mostrare i dati recuperati dalle api dei providers 3. aggiornato i provider 4. creato il provider binance sia pubblico che con chiave 5. creato error_handler.py per gestire decoratori e utilitร : retry automatico, gestione timeout... --- .env.example | 19 +- demos/cdp_market_demo.py | 116 ----- demos/market_agent_demo.py | 100 ----- demos/market_providers_api_demo.py | 374 ++++++++++++++++ src/app/markets/__init__.py | 52 ++- src/app/markets/base.py | 59 ++- src/app/markets/binance.py | 185 +++++++- src/app/markets/binance_public.py | 227 ++++++++++ src/app/markets/coinbase.py | 54 ++- src/app/markets/cryptocompare.py | 43 +- src/app/markets/error_handler.py | 236 ++++++++++ tests/agents/test_market.py | 688 ++++++++++++++++++++++++----- 12 files changed, 1753 insertions(+), 400 deletions(-) delete mode 100644 demos/cdp_market_demo.py delete mode 100644 demos/market_agent_demo.py create mode 100644 demos/market_providers_api_demo.py create mode 100644 src/app/markets/binance_public.py create mode 100644 src/app/markets/error_handler.py diff --git a/.env.example b/.env.example index 0bef205..1b969ef 100644 --- a/.env.example +++ b/.env.example @@ -6,19 +6,30 @@ # Vedi https://docs.agno.com/examples/models per vedere tutti i modelli supportati GOOGLE_API_KEY= +# Inserire il percorso di installazione di ollama (es. /usr/share/ollama/.ollama) +# attenzione che fra Linux nativo e WSL il percorso รจ diverso +OLLAMA_MODELS_PATH= ############################################################################### # 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= +# IMPORTANTE: Usare le credenziali CDP (NON Exchange legacy) +# - COINBASE_API_KEY: organizations/{org_id}/apiKeys/{key_id} +# - COINBASE_API_SECRET: La private key completa (inclusi BEGIN/END) +# - NON serve COINBASE_PASSPHRASE (solo per Exchange legacy) +COINBASE_API_KEY= +COINBASE_API_SECRET= -# CryptoCompare API per Market Agent (alternativa) +# CryptoCompare API per Market Agent # Ottenibile da: https://www.cryptocompare.com/cryptopian/api-keys +# NOTA: API legacy, potrebbe essere deprecata in futuro +# Funzionalitร  limitata: get_all_products() non supportato CRYPTOCOMPARE_API_KEY= -# Binance API per Market Agent (alternativa) +# Binance API per Market Agent +# Ottenibili da: https://www.binance.com/en/my/settings/api-management +# Supporta sia API autenticate che pubbliche (PublicBinance) BINANCE_API_KEY= BINANCE_API_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..39055bb --- /dev/null +++ b/demos/market_providers_api_demo.py @@ -0,0 +1,374 @@ +#!/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, + PublicBinanceAgent, + 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() + + # PublicBinanceAgent (sempre disponibile) + try: + providers["PublicBinance"] = PublicBinanceAgent() + print("โœ… PublicBinanceAgent inizializzato con successo") + except Exception as e: + print(f"โŒ Errore nell'inizializzazione di PublicBinanceAgent: {e}") + + # CryptoCompareWrapper + if env_vars["CRYPTOCOMPARE_API_KEY"]: + try: + 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 + if env_vars["BINANCE_API_KEY"] and env_vars["BINANCE_API_SECRET"]: + try: + providers["Binance"] = BinanceWrapper() + print("โœ… BinanceWrapper inizializzato con successo") + except Exception as e: + print(f"โŒ Errore nell'inizializzazione di BinanceWrapper: {e}") + else: + print("โš ๏ธ BinanceWrapper saltato: credenziali Binance non complete") + + 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/src/app/markets/__init__.py b/src/app/markets/__init__.py index 4bb3e9e..7880fd4 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,8 +1,14 @@ -from app.markets.base import BaseWrapper -from app.markets.coinbase import CoinBaseWrapper -from app.markets.cryptocompare import CryptoCompareWrapper +from .base import BaseWrapper +from .coinbase import CoinBaseWrapper +from .cryptocompare import CryptoCompareWrapper +from .binance import BinanceWrapper +from .binance_public import PublicBinanceAgent +from .error_handler import ProviderFallback, MarketAPIError, safe_execute from agno.utils.log import log_warning +import logging + +logger = logging.getLogger(__name__) class MarketAPIs(BaseWrapper): """ @@ -43,15 +49,37 @@ class MarketAPIs(BaseWrapper): """ self.currency = currency self.wrappers = MarketAPIs.get_list_available_market_apis(currency=currency) + self.fallback_manager = ProviderFallback(self.wrappers) - # 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) + # Metodi con fallback robusto tra provider multipli + def get_product(self, asset_id: str): + """Ottiene informazioni su un prodotto con fallback automatico tra provider.""" + try: + return self.fallback_manager.execute_with_fallback("get_product", asset_id) + except MarketAPIError as e: + logger.error(f"Failed to get product {asset_id}: {str(e)}") + raise + def get_products(self, asset_ids: list): - return self.wrappers[0].get_products(asset_ids) + """Ottiene informazioni su piรน prodotti con fallback automatico tra provider.""" + try: + return self.fallback_manager.execute_with_fallback("get_products", asset_ids) + except MarketAPIError as e: + logger.error(f"Failed to get products {asset_ids}: {str(e)}") + raise + 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) + """Ottiene tutti i prodotti con fallback automatico tra provider.""" + try: + return self.fallback_manager.execute_with_fallback("get_all_products") + except MarketAPIError as e: + logger.error(f"Failed to get all products: {str(e)}") + raise + + def get_historical_prices(self, asset_id: str = "BTC"): + """Ottiene prezzi storici con fallback automatico tra provider.""" + try: + return self.fallback_manager.execute_with_fallback("get_historical_prices", asset_id) + except MarketAPIError as e: + logger.error(f"Failed to get historical prices for {asset_id}: {str(e)}") + raise diff --git a/src/app/markets/base.py b/src/app/markets/base.py index 032f8aa..74bc7e2 100644 --- a/src/app/markets/base.py +++ b/src/app/markets/base.py @@ -1,4 +1,4 @@ -from coinbase.rest.types.product_types import Candle, GetProductResponse +from coinbase.rest.types.product_types import Candle, GetProductResponse, Product from pydantic import BaseModel class BaseWrapper: @@ -27,16 +27,28 @@ class ProductInfo(BaseModel): status: str = "" quote_currency: str = "" + @staticmethod 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 + 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 + product.status = product_data.status or "" return product + @staticmethod + def from_coinbase_product(product_data: 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 + product.status = product_data.status or "" + return product + + @staticmethod def from_cryptocompare(asset_data: dict) -> 'ProductInfo': product = ProductInfo() product.id = asset_data['FROMSYMBOL'] + '-' + asset_data['TOSYMBOL'] @@ -46,6 +58,27 @@ class ProductInfo(BaseModel): product.status = "" # Cryptocompare does not provide status return product + @staticmethod + def from_binance(ticker_data: dict, ticker_24h_data: dict) -> 'ProductInfo': + """ + Crea un oggetto ProductInfo da dati Binance. + + Args: + ticker_data: Dati del ticker di prezzo + ticker_24h_data: Dati del ticker 24h + + Returns: + Oggetto ProductInfo + """ + product = ProductInfo() + product.id = ticker_data['symbol'] + product.symbol = ticker_data['symbol'].replace('USDT', '').replace('BUSD', '') + product.price = float(ticker_data['price']) + product.volume_24h = float(ticker_24h_data['volume']) + product.status = "TRADING" # Binance non fornisce status esplicito + product.quote_currency = "USDT" # Assumiamo USDT come default + return product + class Price(BaseModel): """ Rappresenta i dati di prezzo per un asset, come ottenuti dalle API di mercato. @@ -58,16 +91,18 @@ class Price(BaseModel): volume: float = 0.0 time: str = "" + @staticmethod 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) + 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 + @staticmethod def from_cryptocompare(price_data: dict) -> 'Price': price = Price() price.high = float(price_data['high']) diff --git a/src/app/markets/binance.py b/src/app/markets/binance.py index 80f64c2..8bc1101 100644 --- a/src/app/markets/binance.py +++ b/src/app/markets/binance.py @@ -1,30 +1,169 @@ -# Versione pubblica senza autenticazione +import os +from typing import Optional +from datetime import datetime, timedelta from binance.client import Client +from .base import ProductInfo, BaseWrapper, Price +from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError -# TODO fare l'aggancio con API in modo da poterlo usare come wrapper di mercato -# TODO implementare i metodi di BaseWrapper -class PublicBinanceAgent: - def __init__(self): - # Client pubblico (senza credenziali) - self.client = Client() +class BinanceWrapper(BaseWrapper): + """ + Wrapper per le API autenticate di Binance. + + Implementa l'interfaccia BaseWrapper per fornire accesso unificato + ai dati di mercato di Binance tramite le API REST con autenticazione. + + La documentazione delle API รจ disponibile qui: + https://binance-docs.github.io/apidocs/spot/en/ + """ + + def __init__(self, api_key: Optional[str] = None, api_secret: Optional[str] = None, currency: str = "USDT"): + """ + Inizializza il wrapper con le credenziali API. + + Args: + api_key: Chiave API di Binance (se None, usa variabile d'ambiente) + api_secret: Secret API di Binance (se None, usa variabile d'ambiente) + currency: Valuta di quotazione di default (default: USDT) + """ + if api_key is None: + api_key = os.getenv("BINANCE_API_KEY") + assert api_key is not None, "API key is required" - def get_public_prices(self): - """Ottiene prezzi pubblici""" + if api_secret is None: + api_secret = os.getenv("BINANCE_API_SECRET") + assert api_secret is not None, "API secret is required" + + self.currency = currency + self.client = Client(api_key=api_key, api_secret=api_secret) + + def __format_symbol(self, asset_id: str) -> str: + """ + Formatta l'asset_id nel formato richiesto da Binance. + + Args: + asset_id: ID dell'asset (es. "BTC" o "BTC-USDT") + + Returns: + Simbolo formattato per Binance (es. "BTCUSDT") + """ + if '-' in asset_id: + # Se giร  nel formato "BTC-USDT", converte in "BTCUSDT" + return asset_id.replace('-', '') + else: + # Se solo "BTC", aggiunge la valuta di default + return f"{asset_id}{self.currency}" + + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_product(self, asset_id: str) -> ProductInfo: + """ + Ottiene informazioni su un singolo prodotto. + + Args: + asset_id: ID dell'asset da recuperare + + Returns: + Oggetto ProductInfo con le informazioni del prodotto + """ + symbol = self.__format_symbol(asset_id) + ticker = self.client.get_symbol_ticker(symbol=symbol) + ticker_24h = self.client.get_ticker(symbol=symbol) + + return ProductInfo.from_binance(ticker, ticker_24h) + + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + """ + Ottiene informazioni su piรน prodotti. + + Args: + asset_ids: Lista di ID degli asset da recuperare + + Returns: + Lista di oggetti ProductInfo + """ + products = [] + for asset_id in asset_ids: + try: + product = self.get_product(asset_id) + products.append(product) + except Exception as e: + print(f"Errore nel recupero di {asset_id}: {e}") + continue + return products + + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_all_products(self) -> list[ProductInfo]: + """ + Ottiene informazioni su tutti i prodotti disponibili. + + Returns: + Lista di oggetti ProductInfo per tutti i prodotti + """ try: - btc_price = self.client.get_symbol_ticker(symbol="BTCUSDT") - eth_price = self.client.get_symbol_ticker(symbol="ETHUSDT") - - return { - 'BTC_USD': float(btc_price['price']), - 'ETH_USD': float(eth_price['price']), - 'source': 'binance_public' - } + # Ottiene tutti i ticker 24h che contengono le informazioni necessarie + 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): + try: + # Crea ProductInfo direttamente dal ticker 24h + product = ProductInfo() + product.id = ticker['symbol'] + product.symbol = ticker['symbol'].replace(self.currency, '') + product.price = float(ticker['lastPrice']) + product.volume_24h = float(ticker['volume']) + product.status = "TRADING" # Binance non fornisce status esplicito + product.quote_currency = self.currency + products.append(product) + except (ValueError, KeyError) as e: + print(f"Errore nel parsing di {ticker['symbol']}: {e}") + continue + + return products except Exception as e: - print(f"Errore: {e}") - return None + print(f"Errore nel recupero di tutti i prodotti: {e}") + return [] -# Uso senza credenziali -public_agent = PublicBinanceAgent() -public_prices = public_agent.get_public_prices() -print(public_prices) + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]: + """ + Ottiene i prezzi storici per un asset. + + Args: + asset_id: ID dell'asset (default: "BTC") + + Returns: + Lista di oggetti Price con i dati storici + """ + symbol = self.__format_symbol(asset_id) + + try: + # Ottiene candele orarie degli ultimi 30 giorni + klines = self.client.get_historical_klines( + symbol=symbol, + interval=Client.KLINE_INTERVAL_1HOUR, + start_str="30 days ago UTC" + ) + + 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 + except Exception as e: + print(f"Errore nel recupero dei prezzi storici per {symbol}: {e}") + return [] diff --git a/src/app/markets/binance_public.py b/src/app/markets/binance_public.py new file mode 100644 index 0000000..598840b --- /dev/null +++ b/src/app/markets/binance_public.py @@ -0,0 +1,227 @@ +""" +Versione pubblica di Binance per accesso ai dati pubblici senza autenticazione. + +Questa implementazione estende BaseWrapper per mantenere coerenza +con l'architettura del modulo markets. +""" + +from typing import Optional, Dict, Any +from datetime import datetime, timedelta +from binance.client import Client +from .base import BaseWrapper, ProductInfo, Price +from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError + + +class PublicBinanceAgent(BaseWrapper): + """ + Agent per l'accesso ai dati pubblici di Binance. + + Utilizza l'API pubblica di Binance per ottenere informazioni + sui prezzi e sui mercati senza richiedere autenticazione. + """ + + def __init__(self): + """Inizializza il client pubblico senza credenziali.""" + self.client = Client() + + def __format_symbol(self, asset_id: str) -> str: + """ + Formatta l'asset_id per Binance (es. BTC -> BTCUSDT). + + Args: + asset_id: ID dell'asset (es. "BTC", "ETH") + + Returns: + Simbolo formattato per Binance + """ + if asset_id.endswith("USDT") or asset_id.endswith("BUSD"): + return asset_id + return f"{asset_id}USDT" + + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_product(self, asset_id: str) -> ProductInfo: + """ + Ottiene informazioni su un singolo prodotto. + + Args: + asset_id: ID dell'asset (es. "BTC") + + Returns: + Oggetto ProductInfo con le informazioni del prodotto + """ + symbol = self.__format_symbol(asset_id) + try: + ticker = self.client.get_symbol_ticker(symbol=symbol) + ticker_24h = self.client.get_ticker(symbol=symbol) + return ProductInfo.from_binance(ticker, ticker_24h) + except Exception as e: + print(f"Errore nel recupero del prodotto {asset_id}: {e}") + return ProductInfo(id=asset_id, symbol=asset_id) + + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + """ + Ottiene informazioni su piรน prodotti. + + Args: + asset_ids: Lista di ID degli asset + + Returns: + Lista di oggetti ProductInfo + """ + products = [] + for asset_id in asset_ids: + product = self.get_product(asset_id) + products.append(product) + return products + + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_all_products(self) -> list[ProductInfo]: + """ + Ottiene informazioni su tutti i prodotti disponibili. + + Returns: + Lista di oggetti ProductInfo per i principali asset + """ + # Per la versione pubblica, restituiamo solo i principali asset + major_assets = ["BTC", "ETH", "BNB", "ADA", "DOT", "LINK", "LTC", "XRP"] + return self.get_products(major_assets) + + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]: + """ + Ottiene i prezzi storici per un asset. + + Args: + asset_id: ID dell'asset (default: "BTC") + + Returns: + Lista di oggetti Price con i dati storici + """ + symbol = self.__format_symbol(asset_id) + try: + # Ottieni candele degli ultimi 30 giorni + end_time = datetime.now() + start_time = end_time - timedelta(days=30) + + klines = self.client.get_historical_klines( + symbol, + Client.KLINE_INTERVAL_1DAY, + start_time.strftime("%d %b %Y %H:%M:%S"), + end_time.strftime("%d %b %Y %H:%M:%S") + ) + + prices = [] + for kline in klines: + price = Price( + open=float(kline[1]), + high=float(kline[2]), + low=float(kline[3]), + close=float(kline[4]), + volume=float(kline[5]), + time=str(datetime.fromtimestamp(kline[0] / 1000)) + ) + prices.append(price) + + return prices + except Exception as e: + print(f"Errore nel recupero dei prezzi storici per {asset_id}: {e}") + return [] + + def get_public_prices(self, symbols: Optional[list[str]] = None) -> Optional[Dict[str, Any]]: + """ + Ottiene i prezzi pubblici per i simboli specificati. + + Args: + symbols: Lista di simboli da recuperare (es. ["BTCUSDT", "ETHUSDT"]). + Se None, recupera BTC e ETH di default. + + Returns: + Dizionario con i prezzi e informazioni sulla fonte, o None in caso di errore. + """ + if symbols is None: + symbols = ["BTCUSDT", "ETHUSDT"] + + try: + prices = {} + for symbol in symbols: + ticker = self.client.get_symbol_ticker(symbol=symbol) + # Converte BTCUSDT -> BTC_USD per consistenza + clean_symbol = symbol.replace("USDT", "_USD").replace("BUSD", "_USD") + prices[clean_symbol] = float(ticker['price']) + + return { + **prices, + 'source': 'binance_public', + 'timestamp': self.client.get_server_time()['serverTime'] + } + except Exception as e: + print(f"Errore nel recupero dei prezzi pubblici: {e}") + return None + + def get_24hr_ticker(self, symbol: str) -> Optional[Dict[str, Any]]: + """ + Ottiene le statistiche 24h per un simbolo specifico. + + Args: + symbol: Simbolo del trading pair (es. "BTCUSDT") + + Returns: + Dizionario con le statistiche 24h o None in caso di errore. + """ + try: + ticker = self.client.get_ticker(symbol=symbol) + return { + 'symbol': ticker['symbol'], + 'price': float(ticker['lastPrice']), + 'price_change': float(ticker['priceChange']), + 'price_change_percent': float(ticker['priceChangePercent']), + 'high_24h': float(ticker['highPrice']), + 'low_24h': float(ticker['lowPrice']), + 'volume_24h': float(ticker['volume']), + 'source': 'binance_public' + } + except Exception as e: + print(f"Errore nel recupero del ticker 24h per {symbol}: {e}") + return None + + def get_exchange_info(self) -> Optional[Dict[str, Any]]: + """ + Ottiene informazioni generali sull'exchange. + + Returns: + Dizionario con informazioni sull'exchange o None in caso di errore. + """ + try: + info = self.client.get_exchange_info() + return { + 'timezone': info['timezone'], + 'server_time': info['serverTime'], + 'symbols_count': len(info['symbols']), + 'source': 'binance_public' + } + except Exception as e: + print(f"Errore nel recupero delle informazioni exchange: {e}") + return None + + +# Esempio di utilizzo +if __name__ == "__main__": + # Uso senza credenziali + public_agent = PublicBinanceAgent() + + # Ottieni prezzi di default (BTC e ETH) + public_prices = public_agent.get_public_prices() + print("Prezzi pubblici:", public_prices) + + # Ottieni statistiche 24h per BTC + btc_stats = public_agent.get_24hr_ticker("BTCUSDT") + print("Statistiche BTC 24h:", btc_stats) + + # Ottieni informazioni exchange + exchange_info = public_agent.get_exchange_info() + print("Info exchange:", exchange_info) \ No newline at end of file diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index aac556d..7d2b2d2 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -1,13 +1,21 @@ import os +from typing import Optional +from datetime import datetime, timedelta from coinbase.rest import RESTClient -from app.markets.base import ProductInfo, BaseWrapper, Price +from .base import ProductInfo, BaseWrapper, Price +from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError, RateLimitError 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. + + Implementa l'interfaccia BaseWrapper per fornire accesso unificato + ai dati di mercato di Coinbase tramite le API REST. + + La documentazione delle API รจ disponibile qui: + 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"): + def __init__(self, api_key: Optional[str] = None, api_private_key: Optional[str] = None, currency: str = "USD"): if api_key is None: api_key = os.getenv("COINBASE_API_KEY") assert api_key is not None, "API key is required" @@ -25,21 +33,49 @@ class CoinBaseWrapper(BaseWrapper): def __format(self, asset_id: str) -> str: return asset_id if '-' in asset_id else f"{asset_id}-{self.currency}" + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors 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) + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors 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) + if assets.products: + return [ProductInfo.from_coinbase_product(asset) for asset in assets.products] + return [] + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors def get_all_products(self) -> list[ProductInfo]: assets = self.client.get_products() - return [ProductInfo.from_coinbase(asset) for asset in assets.products] + if assets.products: + return [ProductInfo.from_coinbase_product(asset) for asset in assets.products] + return [] + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors def get_historical_prices(self, asset_id: str = "BTC") -> 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] + # Get last 14 days of hourly data (14*24 = 336 candles, within 350 limit) + end_time = datetime.now() + start_time = end_time - timedelta(days=14) + + # Convert to UNIX timestamps as strings (required by Coinbase API) + start_timestamp = str(int(start_time.timestamp())) + end_timestamp = str(int(end_time.timestamp())) + + data = self.client.get_candles( + product_id=asset_id, + start=start_timestamp, + end=end_timestamp, + granularity="ONE_HOUR", + limit=350 # Explicitly set the limit + ) + if data.candles: + return [Price.from_coinbase(candle) for candle in data.candles] + return [] diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index 188a2c2..5b84843 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -1,6 +1,8 @@ import os import requests -from app.markets.base import ProductInfo, BaseWrapper, Price +from typing import Optional, Dict, Any +from .base import ProductInfo, BaseWrapper, Price +from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError BASE_URL = "https://min-api.cryptocompare.com" @@ -10,7 +12,7 @@ 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'): + def __init__(self, api_key: Optional[str] = None, currency: str = 'USD'): if api_key is None: api_key = os.getenv("CRYPTOCOMPARE_API_KEY") assert api_key is not None, "API key is required" @@ -18,7 +20,7 @@ class CryptoCompareWrapper(BaseWrapper): 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 @@ -26,6 +28,8 @@ class CryptoCompareWrapper(BaseWrapper): response = requests.get(f"{BASE_URL}{endpoint}", params=params) return response.json() + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors def get_product(self, asset_id: str) -> ProductInfo: response = self.__request("/data/pricemultifull", params = { "fsyms": asset_id, @@ -34,6 +38,8 @@ class CryptoCompareWrapper(BaseWrapper): data = response.get('RAW', {}).get(asset_id, {}).get(self.currency, {}) return ProductInfo.from_cryptocompare(data) + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: response = self.__request("/data/pricemultifull", params = { "fsyms": ",".join(asset_ids), @@ -46,10 +52,37 @@ class CryptoCompareWrapper(BaseWrapper): assets.append(ProductInfo.from_cryptocompare(asset_data)) return assets + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors def get_all_products(self) -> list[ProductInfo]: - raise NotImplementedError("CryptoCompare does not support fetching all assets") + """ + Workaround per CryptoCompare: utilizza una lista predefinita di asset popolari + poichรฉ l'API non fornisce un endpoint per recuperare tutti i prodotti. + """ + # Lista di asset popolari supportati da CryptoCompare + popular_assets = [ + "BTC", "ETH", "ADA", "DOT", "LINK", "LTC", "XRP", "BCH", "BNB", "SOL", + "MATIC", "AVAX", "ATOM", "UNI", "DOGE", "SHIB", "TRX", "ETC", "FIL", "XLM" + ] + + try: + # Utilizza get_products per recuperare i dati di tutti gli asset popolari + return self.get_products(popular_assets) + except Exception as e: + # Fallback: prova con un set ridotto di asset principali + main_assets = ["BTC", "ETH", "ADA", "DOT", "LINK"] + try: + return self.get_products(main_assets) + except Exception as fallback_error: + # Se anche il fallback fallisce, solleva l'errore originale con informazioni aggiuntive + raise NotImplementedError( + f"CryptoCompare get_all_products() workaround failed. " + f"Original error: {str(e)}, Fallback error: {str(fallback_error)}" + ) - def get_historical_prices(self, asset_id: str, day_back: int = 10) -> list[dict]: + @retry_on_failure(max_retries=3, delay=1.0) + @handle_api_errors + def get_historical_prices(self, asset_id: str = "BTC", day_back: int = 10) -> list[Price]: assert day_back <= 30, "day_back should be less than or equal to 30" response = self.__request("/data/v2/histohour", params = { "fsym": asset_id, diff --git a/src/app/markets/error_handler.py b/src/app/markets/error_handler.py new file mode 100644 index 0000000..38aa47f --- /dev/null +++ b/src/app/markets/error_handler.py @@ -0,0 +1,236 @@ +""" +Modulo per la gestione robusta degli errori nei market providers. + +Fornisce decoratori e utilitร  per: +- Retry automatico con backoff esponenziale +- Logging standardizzato degli errori +- Gestione di timeout e rate limiting +- Fallback tra provider multipli +""" + +import time +import logging +from functools import wraps +from typing import Any, Callable, Optional, Type, Union, List +from requests.exceptions import RequestException, Timeout, ConnectionError +from binance.exceptions import BinanceAPIException, BinanceRequestException + +# Configurazione logging +logger = logging.getLogger(__name__) + +class MarketAPIError(Exception): + """Eccezione base per errori delle API di mercato.""" + pass + +class RateLimitError(MarketAPIError): + """Eccezione per errori di rate limiting.""" + pass + +class AuthenticationError(MarketAPIError): + """Eccezione per errori di autenticazione.""" + pass + +class DataNotFoundError(MarketAPIError): + """Eccezione quando i dati richiesti non sono disponibili.""" + pass + +def retry_on_failure( + max_retries: int = 3, + delay: float = 1.0, + backoff_factor: float = 2.0, + exceptions: tuple = (RequestException, BinanceAPIException, BinanceRequestException) +) -> Callable: + """ + Decoratore per retry automatico con backoff esponenziale. + + Args: + max_retries: Numero massimo di tentativi + delay: Delay iniziale in secondi + backoff_factor: Fattore di moltiplicazione per il delay + exceptions: Tuple di eccezioni da catturare per il retry + + Returns: + Decoratore per la funzione + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + last_exception = None + current_delay = delay + + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except exceptions as e: + last_exception = e + + if attempt == max_retries: + logger.error( + f"Function {func.__name__} failed after {max_retries + 1} attempts. " + f"Last error: {str(e)}" + ) + raise MarketAPIError(f"Max retries exceeded: {str(e)}") from e + + logger.warning( + f"Attempt {attempt + 1}/{max_retries + 1} failed for {func.__name__}: {str(e)}. " + f"Retrying in {current_delay:.1f}s..." + ) + + time.sleep(current_delay) + current_delay *= backoff_factor + except Exception as e: + # Per eccezioni non previste, non fare retry + logger.error(f"Unexpected error in {func.__name__}: {str(e)}") + raise + + # Questo non dovrebbe mai essere raggiunto + if last_exception: + raise last_exception + else: + raise MarketAPIError("Unknown error occurred") + + return wrapper + return decorator + +def handle_api_errors(func: Callable) -> Callable: + """ + Decoratore per gestione standardizzata degli errori API. + + Converte errori specifici dei provider in eccezioni standardizzate. + """ + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + try: + return func(*args, **kwargs) + except BinanceAPIException as e: + if e.code == -1021: # Timestamp error + raise MarketAPIError(f"Binance timestamp error: {e.message}") + elif e.code == -1003: # Rate limit + raise RateLimitError(f"Binance rate limit exceeded: {e.message}") + elif e.code in [-2014, -2015]: # API key errors + raise AuthenticationError(f"Binance authentication error: {e.message}") + else: + raise MarketAPIError(f"Binance API error [{e.code}]: {e.message}") + except ConnectionError as e: + raise MarketAPIError(f"Connection error: {str(e)}") + except Timeout as e: + raise MarketAPIError(f"Request timeout: {str(e)}") + except RequestException as e: + raise MarketAPIError(f"Request error: {str(e)}") + except Exception as e: + logger.error(f"Unexpected error in {func.__name__}: {str(e)}") + raise MarketAPIError(f"Unexpected error: {str(e)}") from e + + return wrapper + +def safe_execute( + func: Callable, + default_value: Any = None, + log_errors: bool = True, + error_message: Optional[str] = None +) -> Any: + """ + Esegue una funzione in modo sicuro, restituendo un valore di default in caso di errore. + + Args: + func: Funzione da eseguire + default_value: Valore da restituire in caso di errore + log_errors: Se loggare gli errori + error_message: Messaggio di errore personalizzato + + Returns: + Risultato della funzione o valore di default + """ + try: + return func() + except Exception as e: + if log_errors: + message = error_message or f"Error executing {func.__name__}" + logger.warning(f"{message}: {str(e)}") + return default_value + +class ProviderFallback: + """ + Classe per gestire il fallback tra provider multipli. + """ + + def __init__(self, providers: List[Any]): + """ + Inizializza con una lista di provider ordinati per prioritร . + + Args: + providers: Lista di provider ordinati per prioritร  + """ + self.providers = providers + + def execute_with_fallback( + self, + method_name: str, + *args, + **kwargs + ) -> Any: + """ + Esegue un metodo su tutti i provider fino a trovarne uno che funziona. + + Args: + method_name: Nome del metodo da chiamare + *args: Argomenti posizionali + **kwargs: Argomenti nominali + + Returns: + Risultato del primo provider che funziona + + Raises: + MarketAPIError: Se tutti i provider falliscono + """ + last_error = None + + for i, provider in enumerate(self.providers): + try: + if hasattr(provider, method_name): + method = getattr(provider, method_name) + result = method(*args, **kwargs) + + if i > 0: # Se non รจ il primo provider + logger.info(f"Fallback successful: used provider {type(provider).__name__}") + + return result + else: + logger.warning(f"Provider {type(provider).__name__} doesn't have method {method_name}") + continue + + except Exception as e: + last_error = e + logger.warning( + f"Provider {type(provider).__name__} failed for {method_name}: {str(e)}" + ) + continue + + # Se arriviamo qui, tutti i provider hanno fallito + raise MarketAPIError( + f"All providers failed for method {method_name}. Last error: {str(last_error)}" + ) + +def validate_response_data(data: Any, required_fields: Optional[List[str]] = None) -> bool: + """ + Valida che i dati di risposta contengano i campi richiesti. + + Args: + data: Dati da validare + required_fields: Lista di campi richiesti + + Returns: + True se i dati sono validi, False altrimenti + """ + if data is None: + return False + + if required_fields is None: + return True + + if isinstance(data, dict): + return all(field in data for field in required_fields) + elif hasattr(data, '__dict__'): + return all(hasattr(data, field) for field in required_fields) + + return False \ No newline at end of file diff --git a/tests/agents/test_market.py b/tests/agents/test_market.py index 56931b3..9fd5732 100644 --- a/tests/agents/test_market.py +++ b/tests/agents/test_market.py @@ -1,146 +1,596 @@ +""" +Test suite completo per il sistema di mercato. + +Questo modulo testa approfonditamente tutte le implementazioni di BaseWrapper +e verifica la conformitร  all'interfaccia definita in base.py. +""" + import os import pytest -from app.agents.market import MarketToolkit -from app.markets.base import BaseWrapper +from unittest.mock import Mock, patch, MagicMock +from typing import Type, List + +# Import delle classi da testare +from app.markets.base import BaseWrapper, ProductInfo, Price from app.markets.coinbase import CoinBaseWrapper from app.markets.cryptocompare import CryptoCompareWrapper +from app.markets.binance import BinanceWrapper +from app.markets.binance_public import PublicBinanceAgent 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") +class TestBaseWrapperInterface: + """Test per verificare che tutte le implementazioni rispettino l'interfaccia BaseWrapper.""" + + def test_all_wrappers_extend_basewrapper(self): + """Verifica che tutte le classi wrapper estendano BaseWrapper.""" + wrapper_classes = [ + CoinBaseWrapper, + CryptoCompareWrapper, + BinanceWrapper, + PublicBinanceAgent, + MarketAPIs + ] + + for wrapper_class in wrapper_classes: + assert issubclass(wrapper_class, BaseWrapper), f"{wrapper_class.__name__} deve estendere BaseWrapper" + + def test_all_wrappers_implement_required_methods(self): + """Verifica che tutte le classi implementino i metodi richiesti dall'interfaccia.""" + wrapper_classes = [ + CoinBaseWrapper, + CryptoCompareWrapper, + BinanceWrapper, + PublicBinanceAgent, + MarketAPIs + ] + + required_methods = ['get_product', 'get_products', 'get_all_products', 'get_historical_prices'] + + for wrapper_class in wrapper_classes: + for method in required_methods: + assert hasattr(wrapper_class, method), f"{wrapper_class.__name__} deve implementare {method}" + assert callable(getattr(wrapper_class, method)), f"{method} deve essere callable in {wrapper_class.__name__}" - 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 +class TestProductInfoModel: + """Test per la classe ProductInfo e i suoi metodi di conversione.""" + + def test_productinfo_initialization(self): + """Test inizializzazione di ProductInfo.""" + product = ProductInfo() + assert product.id == "" + assert product.symbol == "" + assert product.price == 0.0 + assert product.volume_24h == 0.0 + assert product.status == "" + assert product.quote_currency == "" + + def test_productinfo_with_data(self): + """Test ProductInfo con dati specifici.""" + product = ProductInfo( + id="BTC-USD", + symbol="BTC", + price=50000.0, + volume_24h=1000000.0, + status="TRADING", + quote_currency="USD" + ) + assert product.id == "BTC-USD" + assert product.symbol == "BTC" + assert product.price == 50000.0 + assert product.volume_24h == 1000000.0 + assert product.status == "TRADING" + assert product.quote_currency == "USD" + + def test_productinfo_from_cryptocompare(self): + """Test conversione da dati CryptoCompare.""" + mock_data = { + 'FROMSYMBOL': 'BTC', + 'TOSYMBOL': 'USD', + 'PRICE': 50000.0, + 'VOLUME24HOUR': 1000000.0 + } + + product = ProductInfo.from_cryptocompare(mock_data) + assert product.id == "BTC-USD" + assert product.symbol == "BTC" + assert product.price == 50000.0 + assert product.volume_24h == 1000000.0 + assert product.status == "" + + def test_productinfo_from_binance(self): + """Test conversione da dati Binance.""" + ticker_data = {'symbol': 'BTCUSDT', 'price': '50000.0'} + ticker_24h_data = {'volume': '1000000.0'} + + product = ProductInfo.from_binance(ticker_data, ticker_24h_data) + assert product.id == "BTCUSDT" + assert product.symbol == "BTC" + assert product.price == 50000.0 + assert product.volume_24h == 1000000.0 + assert product.status == "TRADING" + assert product.quote_currency == "USDT" - 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 +class TestPriceModel: + """Test per la classe Price e i suoi metodi di conversione.""" + + def test_price_initialization(self): + """Test inizializzazione di Price.""" + price = Price() + assert price.high == 0.0 + assert price.low == 0.0 + assert price.open == 0.0 + assert price.close == 0.0 + assert price.volume == 0.0 + assert price.time == "" + + def test_price_with_data(self): + """Test Price con dati specifici.""" + price = Price( + high=51000.0, + low=49000.0, + open=50000.0, + close=50500.0, + volume=1000.0, + time="2024-01-01T00:00:00Z" + ) + assert price.high == 51000.0 + assert price.low == 49000.0 + assert price.open == 50000.0 + assert price.close == 50500.0 + assert price.volume == 1000.0 + assert price.time == "2024-01-01T00:00:00Z" + + def test_price_from_cryptocompare(self): + """Test conversione da dati CryptoCompare.""" + mock_data = { + 'high': 51000.0, + 'low': 49000.0, + 'open': 50000.0, + 'close': 50500.0, + 'volumeto': 1000.0, + 'time': 1704067200 + } + + price = Price.from_cryptocompare(mock_data) + assert price.high == 51000.0 + assert price.low == 49000.0 + assert price.open == 50000.0 + assert price.close == 50500.0 + assert price.volume == 1000.0 + assert price.time == "1704067200" - 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 +class TestCoinBaseWrapper: + """Test specifici per CoinBaseWrapper.""" + @pytest.mark.skipif( - not os.getenv('CRYPTOCOMPARE_API_KEY'), - reason="CRYPTOCOMPARE_API_KEY not configured" + not (os.getenv('COINBASE_API_KEY') and os.getenv('COINBASE_API_SECRET')), + reason="Credenziali Coinbase non configurate" ) - def test_cryptocompare_wrapper(self): - try: - api_key = os.getenv('CRYPTOCOMPARE_API_KEY') - wrapper = CryptoCompareWrapper(api_key=api_key, currency="USD") + def test_coinbase_initialization_with_env_vars(self): + """Test inizializzazione con variabili d'ambiente.""" + wrapper = CoinBaseWrapper(currency="USD") + assert wrapper.currency == "USD" + assert wrapper.client is not None + + @patch.dict(os.environ, {}, clear=True) + def test_coinbase_initialization_with_params(self): + """Test inizializzazione con parametri espliciti quando non ci sono variabili d'ambiente.""" + with pytest.raises(AssertionError, match="API key is required"): + CoinBaseWrapper(api_key=None, api_private_key=None) + + @patch('app.markets.coinbase.RESTClient') + def test_coinbase_asset_formatting_behavior(self, mock_client): + """Test comportamento di formattazione asset ID attraverso get_product.""" + mock_response = Mock() + mock_response.product_id = "BTC-USD" + mock_response.base_currency_id = "BTC" + mock_response.price = "50000.0" + mock_response.volume_24h = "1000000.0" + mock_response.status = "TRADING" + + mock_client_instance = Mock() + mock_client_instance.get_product.return_value = mock_response + mock_client.return_value = mock_client_instance + + wrapper = CoinBaseWrapper(api_key="test", api_private_key="test") + + # Test che entrambi i formati funzionino + wrapper.get_product("BTC") + wrapper.get_product("BTC-USD") + + # Verifica che get_product sia stato chiamato con il formato corretto + assert mock_client_instance.get_product.call_count == 2 + + @patch('app.markets.coinbase.RESTClient') + def test_coinbase_get_product(self, mock_client): + """Test get_product con mock.""" + mock_response = Mock() + mock_response.product_id = "BTC-USD" + mock_response.base_currency_id = "BTC" + mock_response.price = "50000.0" + mock_response.volume_24h = "1000000.0" + mock_response.status = "TRADING" + + mock_client_instance = Mock() + mock_client_instance.get_product.return_value = mock_response + mock_client.return_value = mock_client_instance + + wrapper = CoinBaseWrapper(api_key="test", api_private_key="test") + product = wrapper.get_product("BTC") + + assert isinstance(product, ProductInfo) + assert product.symbol == "BTC" + mock_client_instance.get_product.assert_called_once_with("BTC-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 +class TestCryptoCompareWrapper: + """Test specifici per CryptoCompareWrapper.""" + @pytest.mark.skipif( - not (os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY')), - reason="Coinbase credentials not configured" + not os.getenv('CRYPTOCOMPARE_API_KEY'), + reason="CRYPTOCOMPARE_API_KEY non configurata" ) - 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" - ) + def test_cryptocompare_initialization_with_env_var(self): + """Test inizializzazione con variabile d'ambiente.""" + wrapper = CryptoCompareWrapper(currency="USD") + assert wrapper.currency == "USD" + assert wrapper.api_key is not None + + def test_cryptocompare_initialization_with_param(self): + """Test inizializzazione con parametro esplicito.""" + wrapper = CryptoCompareWrapper(api_key="test_key", currency="EUR") + assert wrapper.api_key == "test_key" + assert wrapper.currency == "EUR" + + @patch('app.markets.cryptocompare.requests.get') + def test_cryptocompare_get_product(self, mock_get): + """Test get_product con mock.""" + mock_response = Mock() + mock_response.json.return_value = { + 'RAW': { + 'BTC': { + 'USD': { + 'FROMSYMBOL': 'BTC', + 'TOSYMBOL': 'USD', + 'PRICE': 50000.0, + 'VOLUME24HOUR': 1000000.0 + } + } + } + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + wrapper = CryptoCompareWrapper(api_key="test_key") + product = wrapper.get_product("BTC") + + assert isinstance(product, ProductInfo) + assert product.symbol == "BTC" + assert product.price == 50000.0 + + def test_cryptocompare_get_all_products_workaround(self): + """Test che get_all_products funzioni con il workaround implementato.""" + wrapper = CryptoCompareWrapper(api_key="test_key") + # Il metodo ora dovrebbe restituire una lista di ProductInfo invece di sollevare NotImplementedError + products = wrapper.get_all_products() + assert isinstance(products, list) + # Verifica che la lista non sia vuota (dovrebbe contenere almeno alcuni asset popolari) + assert len(products) > 0 + # Verifica che ogni elemento sia un ProductInfo + for product in products: + assert isinstance(product, ProductInfo) - 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 +class TestBinanceWrapper: + """Test specifici per BinanceWrapper.""" + + def test_binance_initialization_without_credentials(self): + """Test che l'inizializzazione fallisca senza credenziali.""" + # Assicuriamoci che le variabili d'ambiente siano vuote per questo test + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(AssertionError, match="API key is required"): + BinanceWrapper(api_key=None, api_secret="test") + + with pytest.raises(AssertionError, match="API secret is required"): + BinanceWrapper(api_key="test", api_secret=None) + + @patch('app.markets.binance.Client') + def test_binance_symbol_formatting_behavior(self, mock_client): + """Test comportamento di formattazione simbolo attraverso get_product.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.return_value = { + 'symbol': 'BTCUSDT', + 'price': '50000.0' + } + mock_client_instance.get_ticker.return_value = { + 'volume': '1000000.0' + } + mock_client.return_value = mock_client_instance + + wrapper = BinanceWrapper(api_key="test", api_secret="test") + + # Test che entrambi i formati funzionino + wrapper.get_product("BTC") + wrapper.get_product("BTCUSDT") + + # Verifica che i metodi siano stati chiamati + assert mock_client_instance.get_symbol_ticker.call_count == 2 + + @patch('app.markets.binance.Client') + def test_binance_get_product(self, mock_client): + """Test get_product con mock.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.return_value = { + 'symbol': 'BTCUSDT', + 'price': '50000.0' + } + mock_client_instance.get_ticker.return_value = { + 'volume': '1000000.0' + } + mock_client.return_value = mock_client_instance + + wrapper = BinanceWrapper(api_key="test", api_secret="test") + product = wrapper.get_product("BTC") + + assert isinstance(product, ProductInfo) + assert product.symbol == "BTC" + assert product.price == 50000.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 +class TestPublicBinanceAgent: + """Test specifici per PublicBinanceAgent.""" + + @patch('app.markets.binance_public.Client') + def test_public_binance_initialization(self, mock_client): + """Test inizializzazione senza credenziali.""" + agent = PublicBinanceAgent() + assert agent.client is not None + mock_client.assert_called_once_with() + + @patch('app.markets.binance_public.Client') + def test_public_binance_symbol_formatting_behavior(self, mock_client): + """Test comportamento di formattazione simbolo attraverso get_product.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.return_value = { + 'symbol': 'BTCUSDT', + 'price': '50000.0' + } + mock_client_instance.get_ticker.return_value = { + 'volume': '1000000.0' + } + mock_client.return_value = mock_client_instance + + agent = PublicBinanceAgent() + + # Test che entrambi i formati funzionino + agent.get_product("BTC") + agent.get_product("BTCUSDT") + + # Verifica che i metodi siano stati chiamati + assert mock_client_instance.get_symbol_ticker.call_count == 2 + + @patch('app.markets.binance_public.Client') + def test_public_binance_get_product(self, mock_client): + """Test get_product con mock.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.return_value = { + 'symbol': 'BTCUSDT', + 'price': '50000.0' + } + mock_client_instance.get_ticker.return_value = { + 'volume': '1000000.0' + } + mock_client.return_value = mock_client_instance + + agent = PublicBinanceAgent() + product = agent.get_product("BTC") + + assert isinstance(product, ProductInfo) + assert product.symbol == "BTC" + assert product.price == 50000.0 + + @patch('app.markets.binance_public.Client') + def test_public_binance_get_all_products(self, mock_client): + """Test get_all_products restituisce asset principali.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.return_value = { + 'symbol': 'BTCUSDT', + 'price': '50000.0' + } + mock_client_instance.get_ticker.return_value = { + 'volume': '1000000.0' + } + mock_client.return_value = mock_client_instance + + agent = PublicBinanceAgent() + products = agent.get_all_products() + + assert isinstance(products, list) + assert len(products) == 8 # Numero di asset principali definiti + for product in products: + assert isinstance(product, ProductInfo) + + @patch('app.markets.binance_public.Client') + def test_public_binance_get_public_prices(self, mock_client): + """Test metodo specifico get_public_prices.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.return_value = {'price': '50000.0'} + mock_client_instance.get_server_time.return_value = {'serverTime': 1704067200000} + mock_client.return_value = mock_client_instance + + agent = PublicBinanceAgent() + prices = agent.get_public_prices(["BTCUSDT"]) + + assert isinstance(prices, dict) + assert 'BTC_USD' in prices + assert prices['BTC_USD'] == 50000.0 + assert 'source' in prices + assert prices['source'] == 'binance_public' - 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 +class TestMarketAPIs: + """Test per la classe MarketAPIs che aggrega i wrapper.""" + + def test_market_apis_initialization_no_providers(self): + """Test che l'inizializzazione fallisca senza provider disponibili.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(AssertionError, match="No market API keys"): + MarketAPIs("USD") + + @patch('app.markets.CoinBaseWrapper') + def test_market_apis_with_coinbase_only(self, mock_coinbase): + """Test con solo Coinbase disponibile.""" + mock_instance = Mock() + mock_coinbase.return_value = mock_instance + + with patch('app.markets.CryptoCompareWrapper', side_effect=Exception("No API key")): + apis = MarketAPIs("USD") + assert len(apis.wrappers) == 1 + assert apis.wrappers[0] == mock_instance + + @patch('app.markets.CoinBaseWrapper') + @patch('app.markets.CryptoCompareWrapper') + def test_market_apis_delegation(self, mock_crypto, mock_coinbase): + """Test che i metodi vengano delegati al primo wrapper disponibile.""" + mock_coinbase_instance = Mock() + mock_crypto_instance = Mock() + mock_coinbase.return_value = mock_coinbase_instance + mock_crypto.return_value = mock_crypto_instance + + apis = MarketAPIs("USD") + + # Test delegazione get_product + apis.get_product("BTC") + mock_coinbase_instance.get_product.assert_called_once_with("BTC") + + # Test delegazione get_products + apis.get_products(["BTC", "ETH"]) + mock_coinbase_instance.get_products.assert_called_once_with(["BTC", "ETH"]) + + # Test delegazione get_all_products + apis.get_all_products() + mock_coinbase_instance.get_all_products.assert_called_once() + + # Test delegazione get_historical_prices + apis.get_historical_prices("BTC") + mock_coinbase_instance.get_historical_prices.assert_called_once_with("BTC") - 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. +class TestErrorHandling: + """Test per la gestione degli errori in tutti i wrapper.""" + + @patch('app.markets.binance_public.Client') + def test_public_binance_error_handling(self, mock_client): + """Test gestione errori in PublicBinanceAgent.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.side_effect = Exception("API Error") + mock_client.return_value = mock_client_instance + + agent = PublicBinanceAgent() + product = agent.get_product("INVALID") + + # Dovrebbe restituire un ProductInfo vuoto invece di sollevare eccezione + assert isinstance(product, ProductInfo) + assert product.id == "INVALID" + assert product.symbol == "INVALID" + + @patch('app.markets.cryptocompare.requests.get') + def test_cryptocompare_network_error(self, mock_get): + """Test gestione errori di rete in CryptoCompareWrapper.""" + mock_get.side_effect = Exception("Network Error") + + wrapper = CryptoCompareWrapper(api_key="test") + + with pytest.raises(Exception): + wrapper.get_product("BTC") + + @patch('app.markets.binance.Client') + def test_binance_api_error_in_get_products(self, mock_client): + """Test gestione errori in BinanceWrapper.get_products.""" + mock_client_instance = Mock() + mock_client_instance.get_symbol_ticker.side_effect = Exception("API Error") + mock_client.return_value = mock_client_instance + + wrapper = BinanceWrapper(api_key="test", api_secret="test") + products = wrapper.get_products(["BTC", "ETH"]) + + # Dovrebbe restituire lista vuota invece di sollevare eccezione + assert isinstance(products, list) + assert len(products) == 0 + + +class TestIntegrationScenarios: + """Test di integrazione per scenari reali.""" + + def test_wrapper_method_signatures(self): + """Verifica che tutti i wrapper abbiano le stesse signature dei metodi.""" + wrapper_classes = [CoinBaseWrapper, CryptoCompareWrapper, BinanceWrapper, PublicBinanceAgent] + + for wrapper_class in wrapper_classes: + # Verifica get_product + assert hasattr(wrapper_class, 'get_product') + + # Verifica get_products + assert hasattr(wrapper_class, 'get_products') + + # Verifica get_all_products + assert hasattr(wrapper_class, 'get_all_products') + + # Verifica get_historical_prices + assert hasattr(wrapper_class, 'get_historical_prices') + + def test_productinfo_consistency(self): + """Test che tutti i metodi from_* di ProductInfo restituiscano oggetti consistenti.""" + # Test from_cryptocompare + crypto_data = { + 'FROMSYMBOL': 'BTC', + 'TOSYMBOL': 'USD', + 'PRICE': 50000.0, + 'VOLUME24HOUR': 1000000.0 + } + crypto_product = ProductInfo.from_cryptocompare(crypto_data) + + # Test from_binance + binance_ticker = {'symbol': 'BTCUSDT', 'price': '50000.0'} + binance_24h = {'volume': '1000000.0'} + binance_product = ProductInfo.from_binance(binance_ticker, binance_24h) + + # Verifica che entrambi abbiano gli stessi campi + assert hasattr(crypto_product, 'id') + assert hasattr(crypto_product, 'symbol') + assert hasattr(crypto_product, 'price') + assert hasattr(crypto_product, 'volume_24h') + + assert hasattr(binance_product, 'id') + assert hasattr(binance_product, 'symbol') + assert hasattr(binance_product, 'price') + assert hasattr(binance_product, 'volume_24h') + + def test_price_consistency(self): + """Test che tutti i metodi from_* di Price restituiscano oggetti consistenti.""" + # Test from_cryptocompare + crypto_data = { + 'high': 51000.0, + 'low': 49000.0, + 'open': 50000.0, + 'close': 50500.0, + 'volumeto': 1000.0, + 'time': 1704067200 + } + crypto_price = Price.from_cryptocompare(crypto_data) + + # Verifica che abbia tutti i campi richiesti + assert hasattr(crypto_price, 'high') + assert hasattr(crypto_price, 'low') + assert hasattr(crypto_price, 'open') + assert hasattr(crypto_price, 'close') + assert hasattr(crypto_price, 'volume') + assert hasattr(crypto_price, 'time') + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From c17a948ae091fb41a8bd8c8aacdd8123e4fdbb9a Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 00:34:07 +0200 Subject: [PATCH 06/33] Refactor news API integration to use NewsApiWrapper and GnewsWrapper; add tests for Gnews API functionality --- demos/news_api.py | 4 +-- pyproject.toml | 3 +- src/app/news/__init__.py | 5 +-- src/app/news/base.py | 6 ++++ src/app/news/gnews_api.py | 31 +++++++++++++++++ src/app/news/news_api.py | 17 +++++++--- tests/api/test_gnews_api.py | 34 +++++++++++++++++++ tests/api/test_news_api.py | 22 +++++++++++-- tests/conftest.py | 2 -- uv.lock | 66 +++++++++++++++++++++++++++++++++++++ 10 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 src/app/news/gnews_api.py create mode 100644 tests/api/test_gnews_api.py diff --git a/demos/news_api.py b/demos/news_api.py index 0fc4c37..26dab24 100644 --- a/demos/news_api.py +++ b/demos/news_api.py @@ -5,10 +5,10 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src' ########################################### from dotenv import load_dotenv -from app.news import NewsAPI +from app.news import NewsApiWrapper def main(): - api = NewsAPI() + api = NewsApiWrapper() print("ok") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 3ef7154..b83de19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,6 @@ dependencies = [ "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", @@ -34,6 +32,7 @@ dependencies = [ # โœ… per interagire con API di notizie "newsapi-python", + "gnews", ] [tool.pytest.ini_options] diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 9b88ff6..957453d 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -1,3 +1,4 @@ -from .news_api import NewsAPI +from .news_api import NewsApiWrapper +from .gnews_api import GnewsWrapper -__all__ = ["NewsAPI"] \ No newline at end of file +__all__ = ["NewsApiWrapper", "GnewsWrapper"] \ No newline at end of file diff --git a/src/app/news/base.py b/src/app/news/base.py index 0391424..8b3f55d 100644 --- a/src/app/news/base.py +++ b/src/app/news/base.py @@ -6,3 +6,9 @@ class Article(BaseModel): title: str = "" description: str = "" +class NewsWrapper: + def get_top_headlines(self, query: str, total: int = 100) -> list[Article]: + raise NotImplementedError("This method should be overridden by subclasses") + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + raise NotImplementedError("This method should be overridden by subclasses") + diff --git a/src/app/news/gnews_api.py b/src/app/news/gnews_api.py new file mode 100644 index 0000000..53451c9 --- /dev/null +++ b/src/app/news/gnews_api.py @@ -0,0 +1,31 @@ +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 GnewsWrapper(NewsWrapper): + def get_top_headlines(self, query: str, 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 index ce213cf..9629ecd 100644 --- a/src/app/news/news_api.py +++ b/src/app/news/news_api.py @@ -1,7 +1,6 @@ import os import newsapi -from .base import Article - +from .base import Article, NewsWrapper def result_to_article(result: dict) -> Article: article = Article() @@ -11,7 +10,7 @@ def result_to_article(result: dict) -> Article: article.description = result.get("description", "") return article -class NewsAPI: +class NewsApiWrapper(NewsWrapper): def __init__(self): api_key = os.getenv("NEWS_API_KEY") assert api_key is not None, "NEWS_API_KEY environment variable not set" @@ -21,7 +20,7 @@ class NewsAPI: self.language = "en" # TODO Only English articles for now? self.max_page_size = 100 - def get_top_headlines(self, query:str, total:int=100) -> list[Article]: + def get_top_headlines(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) @@ -32,4 +31,14 @@ class NewsAPI: 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/tests/api/test_gnews_api.py b/tests/api/test_gnews_api.py new file mode 100644 index 0000000..49c8418 --- /dev/null +++ b/tests/api/test_gnews_api.py @@ -0,0 +1,34 @@ +import pytest +from app.news import GnewsWrapper + + +@pytest.mark.news +@pytest.mark.api +class TestGnewsAPI: + + def test_gnews_api_initialization(self): + gnews_api = GnewsWrapper() + assert gnews_api is not None + + def test_gnews_api_get_latest_news(self): + gnews_api = GnewsWrapper() + articles = gnews_api.get_latest_news(query="crypto", total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert hasattr(article, 'source') + assert hasattr(article, 'time') + assert hasattr(article, 'title') + assert hasattr(article, 'description') + + def test_gnews_api_get_top_headlines(self): + news_api = GnewsWrapper() + articles = news_api.get_top_headlines(query="crypto", total=2) + assert isinstance(articles, list) + assert len(articles) == 2 + for article in articles: + assert hasattr(article, 'source') + assert hasattr(article, 'time') + assert hasattr(article, 'title') + assert hasattr(article, 'description') + diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 9558882..4778d5b 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -1,13 +1,29 @@ -from app.news import NewsAPI +import pytest +from app.news import NewsApiWrapper + +@pytest.mark.news +@pytest.mark.api class TestNewsAPI: def test_news_api_initialization(self): - news_api = NewsAPI() + 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 hasattr(article, 'source') + assert hasattr(article, 'time') + assert hasattr(article, 'title') + assert hasattr(article, 'description') + + def test_news_api_get_top_headlines(self): - news_api = NewsAPI() + news_api = NewsApiWrapper() articles = news_api.get_top_headlines(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) diff --git a/tests/conftest.py b/tests/conftest.py index f2601b1..21502d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,6 @@ def pytest_collection_modifyitems(config, items): """Modifica automaticamente gli item di test aggiungendogli marker basati sul nome""" markers_to_add = { - "api": pytest.mark.api, "coinbase": pytest.mark.api, "cryptocompare": pytest.mark.api, "overview": pytest.mark.slow, @@ -38,7 +37,6 @@ def pytest_collection_modifyitems(config, items): "gemini": pytest.mark.gemini, "ollama_gpt": pytest.mark.ollama_gpt, "ollama_qwen": pytest.mark.ollama_qwen, - "news": pytest.mark.news, } for item in items: diff --git a/uv.lock b/uv.lock index 26356c0..7eb69ba 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" @@ -310,6 +323,15 @@ 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 = "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 +366,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 +455,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" @@ -1164,6 +1213,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" @@ -1200,6 +1255,15 @@ 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 = "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" @@ -1308,6 +1372,7 @@ dependencies = [ { name = "agno" }, { name = "coinbase-advanced-py" }, { name = "dotenv" }, + { name = "gnews" }, { name = "google-genai" }, { name = "gradio" }, { name = "newsapi-python" }, @@ -1321,6 +1386,7 @@ requires-dist = [ { name = "agno" }, { name = "coinbase-advanced-py" }, { name = "dotenv" }, + { name = "gnews" }, { name = "google-genai" }, { name = "gradio" }, { name = "newsapi-python" }, From 6aa9d4969f54764038a3cb439870bff3f1b85146 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 01:54:26 +0200 Subject: [PATCH 07/33] Add CryptoPanic API integration and related tests; update .env.example and test configurations --- .env.example | 5 ++- src/app/news/__init__.py | 3 +- src/app/news/cryptopanic_api.py | 70 +++++++++++++++++++++++++++++++ tests/api/test_cryptopanic_api.py | 38 +++++++++++++++++ tests/api/test_gnews_api.py | 16 +++---- tests/api/test_news_api.py | 20 +++++---- tests/conftest.py | 11 +++++ 7 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 src/app/news/cryptopanic_api.py create mode 100644 tests/api/test_cryptopanic_api.py diff --git a/.env.example b/.env.example index d5d5a38..f59ad82 100644 --- a/.env.example +++ b/.env.example @@ -10,12 +10,10 @@ 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= -# CryptoCompare API per Market Agent (alternativa) # Ottenibile da: https://www.cryptocompare.com/cryptopian/api-keys CRYPTOCOMPARE_API_KEY= @@ -26,6 +24,9 @@ BINANCE_API_SECRET= ############################################################################### # Configurazioni per gli agenti di notizie ############################################################################### + # Ottenibile da: https://newsapi.org/docs NEWS_API_KEY= +# Ottenibile da: https://cryptopanic.com/developers/api/ +CRYPTOPANIC_API_KEY= diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 957453d..f130e6b 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -1,4 +1,5 @@ from .news_api import NewsApiWrapper from .gnews_api import GnewsWrapper +from .cryptopanic_api import CryptoPanicWrapper -__all__ = ["NewsApiWrapper", "GnewsWrapper"] \ No newline at end of file +__all__ = ["NewsApiWrapper", "GnewsWrapper", "CryptoPanicWrapper"] \ No newline at end of file diff --git a/src/app/news/cryptopanic_api.py b/src/app/news/cryptopanic_api.py new file mode 100644 index 0000000..2e9270c --- /dev/null +++ b/src/app/news/cryptopanic_api.py @@ -0,0 +1,70 @@ +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): + 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, 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] + + def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + return self.get_top_headlines(query, total) # same endpoint for both, so just call it diff --git a/tests/api/test_cryptopanic_api.py b/tests/api/test_cryptopanic_api.py new file mode 100644 index 0000000..dda2f77 --- /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(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 != "" + diff --git a/tests/api/test_gnews_api.py b/tests/api/test_gnews_api.py index 49c8418..1013fa7 100644 --- a/tests/api/test_gnews_api.py +++ b/tests/api/test_gnews_api.py @@ -16,10 +16,10 @@ class TestGnewsAPI: assert isinstance(articles, list) assert len(articles) == 2 for article in articles: - assert hasattr(article, 'source') - assert hasattr(article, 'time') - assert hasattr(article, 'title') - assert hasattr(article, 'description') + 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 = GnewsWrapper() @@ -27,8 +27,8 @@ class TestGnewsAPI: assert isinstance(articles, list) assert len(articles) == 2 for article in articles: - assert hasattr(article, 'source') - assert hasattr(article, 'time') - assert hasattr(article, 'title') - assert hasattr(article, 'description') + 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 index 4778d5b..da8f607 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -1,9 +1,11 @@ +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): @@ -16,20 +18,20 @@ class TestNewsAPI: 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 hasattr(article, 'source') - assert hasattr(article, 'time') - assert hasattr(article, 'title') - assert hasattr(article, 'description') + 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(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) + # assert len(articles) > 0 # apparently it doesn't always return SOME articles for article in articles: - assert hasattr(article, 'source') - assert hasattr(article, 'time') - assert hasattr(article, 'title') - assert hasattr(article, 'description') + 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/conftest.py b/tests/conftest.py index 21502d1..5584992 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ def pytest_configure(config:pytest.Config): ("ollama_gpt", "marks tests that use Ollama GPT model"), ("ollama_qwen", "marks tests that use Ollama Qwen model"), ("news", "marks tests that use news"), + ("limited", "marks tests that have limited execution due to API constraints"), ] for marker in markers: line = f"{marker[0]}: {marker[1]}" @@ -44,3 +45,13 @@ def pytest_collection_modifyitems(config, items): for key, marker in markers_to_add.items(): if key in name: item.add_marker(marker) + + # 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 + + new_mark = (f"({markexpr}) and " if markexpr else "") + f"not {mark}" + setattr(config.option, "markexpr", new_mark) From 912a9b9c8d6ea3d73045a36fcca0f91ff6268400 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 02:55:09 +0200 Subject: [PATCH 08/33] Implement WrapperHandler for managing multiple news API wrappers; add tests for wrapper functionality --- src/app/news/__init__.py | 15 +++++++- src/app/utils/wrapper_handler.py | 48 +++++++++++++++++++++++ tests/agents/test_predictor.py | 5 ++- tests/conftest.py | 1 + tests/utils/test_wrapper_handler.py | 60 +++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/app/utils/wrapper_handler.py create mode 100644 tests/utils/test_wrapper_handler.py diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index f130e6b..8b0fd04 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -1,5 +1,18 @@ +from app.utils.wrapper_handler import WrapperHandler +from .base import NewsWrapper, Article from .news_api import NewsApiWrapper from .gnews_api import GnewsWrapper from .cryptopanic_api import CryptoPanicWrapper -__all__ = ["NewsApiWrapper", "GnewsWrapper", "CryptoPanicWrapper"] \ No newline at end of file +__all__ = ["NewsApiWrapper", "GnewsWrapper", "CryptoPanicWrapper"] + + +class NewsAPIs(NewsWrapper): + def __init__(self): + wrappers = [GnewsWrapper, NewsApiWrapper, CryptoPanicWrapper] + self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers) + + def get_top_headlines(self, query: str, total: int = 100) -> list[Article]: + return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(query, 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/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py new file mode 100644 index 0000000..05cd8f4 --- /dev/null +++ b/src/app/utils/wrapper_handler.py @@ -0,0 +1,48 @@ +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]): + def __init__(self, wrappers: list[W], try_per_wrapper: int = 3, retry_delay: int = 2): + 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: + iterations = 0 + while iterations < len(self.wrappers): + print(f"Trying wrapper {self.index}") + try: + wrapper = self.wrappers[self.index] + result = func(wrapper) + self.retry_count = 0 + return result + except Exception as e: + self.retry_count += 1 + print(f"Error occurred {self.retry_count}/{self.retry_per_wrapper}: {e}") + 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") + + @staticmethod + def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2) -> 'WrapperHandler[W]': + 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_predictor.py b/tests/agents/test_predictor.py index c99104b..1255a48 100644 --- a/tests/agents/test_predictor.py +++ b/tests/agents/test_predictor.py @@ -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/conftest.py b/tests/conftest.py index 5584992..9bd9589 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ def pytest_configure(config:pytest.Config): ("ollama_qwen", "marks tests that use Ollama Qwen model"), ("news", "marks tests that use news"), ("limited", "marks tests that have limited execution due to API constraints"), + ("wrapper", "marks tests for wrapper handler"), ] for marker in markers: line = f"{marker[0]}: {marker[1]}" diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py new file mode 100644 index 0000000..2b9583a --- /dev/null +++ b/tests/utils/test_wrapper_handler.py @@ -0,0 +1,60 @@ +import pytest +from app.utils.wrapper_handler import WrapperHandler + +class MockWrapper: + def do_something(self) -> str: + return "Success" + +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 From 40fb400a9ca100c8a2ce3111b39cc05543e271e8 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 11:46:52 +0200 Subject: [PATCH 09/33] Enhance WrapperHandler - docstrings - add try_call_all method - update tests --- src/app/utils/wrapper_handler.py | 66 ++++++++++++++++++++++++++++- tests/utils/test_wrapper_handler.py | 19 +++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/app/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py index 05cd8f4..0ea72d6 100644 --- a/src/app/utils/wrapper_handler.py +++ b/src/app/utils/wrapper_handler.py @@ -6,7 +6,24 @@ 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 @@ -14,9 +31,19 @@ class WrapperHandler(Generic[W]): 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): - print(f"Trying wrapper {self.index}") try: wrapper = self.wrappers[self.index] result = func(wrapper) @@ -24,7 +51,6 @@ class WrapperHandler(Generic[W]): return result except Exception as e: self.retry_count += 1 - print(f"Error occurred {self.retry_count}/{self.retry_per_wrapper}: {e}") if self.retry_count >= self.retry_per_wrapper: self.index = (self.index + 1) % len(self.wrappers) self.retry_count = 0 @@ -35,8 +61,44 @@ class WrapperHandler(Generic[W]): raise Exception(f"All wrappers failed after retries") + def try_call_all(self, func: Callable[[W], T]) -> list[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.append(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: diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index 2b9583a..fd5ffff 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -58,3 +58,22 @@ class TestWrapperHandler: 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(self): + 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 == ["Success"] # Only the second wrapper should succeed + + wrappers = [FailingWrapper, MockWrapper, FailingWrapper, MockWrapper] + 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 == ["Success", "Success"] # Only the second and fourth wrappers should succeed + + # 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) From fb38aef79039e753c3ca9c70908786fc8c78b323 Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Tue, 30 Sep 2025 12:11:10 +0200 Subject: [PATCH 10/33] pre merge con phil --- src/app.py | 14 ++++---- src/app/agents/market_agent.py | 35 +++++++++++++++++++ src/app/{tool.py => pipeline.py} | 4 +-- src/app/{agents => }/predictor.py | 0 src/app/toolkits/__init__.py | 0 .../market.py => toolkits/market_toolkit.py} | 0 tests/agents/test_predictor.py | 2 +- 7 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 src/app/agents/market_agent.py rename src/app/{tool.py => pipeline.py} (96%) rename src/app/{agents => }/predictor.py (100%) create mode 100644 src/app/toolkits/__init__.py rename src/app/{agents/market.py => toolkits/market_toolkit.py} (100%) 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..3325f97 --- /dev/null +++ b/src/app/agents/market_agent.py @@ -0,0 +1,35 @@ + +from agno.agent import Agent +from src.app.toolkits.market_toolkit import MarketToolkit + + +class MarketAgent(Agent): + """ + Wrapper che trasforma MarketToolkit in un Agent compatibile con Team. + Espone un metodo run(query) che restituisce dati di mercato. + """ + + def __init__(self, currency: str = "USD"): + self.toolkit = MarketToolkit() + self.currency = currency + self.name = "MarketAgent" + + def run(self, query: str) -> str: + # Heuristica semplice: se la query cita simboli specifici, recupera quelli + 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 + + results = [] + for sym in symbols: + try: + price = self.toolkit.get_current_price(sym) + results.append(f"{sym}: {price}") + except Exception as e: + results.append(f"{sym}: errore ({e})") + + return "๐Ÿ“Š Dati di mercato:\n" + "\n".join(results) \ No newline at end of file diff --git a/src/app/tool.py b/src/app/pipeline.py similarity index 96% rename from src/app/tool.py rename to src/app/pipeline.py index d0b3ca0..d5508c2 100644 --- a/src/app/tool.py +++ b/src/app/pipeline.py @@ -1,11 +1,11 @@ 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.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: +class Pipeline: """ Classe principale che coordina gli agenti per rispondere alle richieste dell'utente. """ diff --git a/src/app/agents/predictor.py b/src/app/predictor.py similarity index 100% rename from src/app/agents/predictor.py rename to src/app/predictor.py 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 100% rename from src/app/agents/market.py rename to src/app/toolkits/market_toolkit.py diff --git a/tests/agents/test_predictor.py b/tests/agents/test_predictor.py index c99104b..baf488d 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 From dfe3b4ad902f345ceafe57b1f61b080581315895 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 12:24:43 +0200 Subject: [PATCH 11/33] Add DuckDuckGo and Google News wrappers; refactor CryptoPanic and NewsAPI - Implemented DuckDuckGoWrapper for news retrieval using DuckDuckGo tools. - Added GoogleNewsWrapper for accessing Google News RSS feed. - Refactored CryptoPanicWrapper to unify get_top_headlines and get_latest_news methods. - Updated NewsApiWrapper to simplify top headlines retrieval. - Added tests for DuckDuckGo and Google News wrappers. - Enhanced documentation for CryptoPanicWrapper and NewsApiWrapper. - Created base module for social media integrations. --- pyproject.toml | 1 + src/app/news/__init__.py | 24 +++- src/app/news/base.py | 27 +++- src/app/news/cryptopanic_api.py | 15 +- src/app/news/duckduckgo.py | 32 +++++ src/app/news/gnews_api.py | 9 +- src/app/news/news_api.py | 10 +- src/app/social/__init.py | 1 + src/app/social/base.py | 0 tests/api/test_cryptopanic_api.py | 2 +- tests/api/test_duckduckgo_news.py | 35 +++++ ...{test_gnews_api.py => test_google_news.py} | 12 +- tests/api/test_news_api.py | 2 +- uv.lock | 128 ++++++++++++++++++ 14 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 src/app/news/duckduckgo.py create mode 100644 src/app/social/__init.py create mode 100644 src/app/social/base.py create mode 100644 tests/api/test_duckduckgo_news.py rename tests/api/{test_gnews_api.py => test_google_news.py} (82%) diff --git a/pyproject.toml b/pyproject.toml index b83de19..8f4cc87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ # โœ… per interagire con API di notizie "newsapi-python", "gnews", + "ddgs", ] [tool.pytest.ini_options] diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 8b0fd04..3218296 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -1,18 +1,32 @@ from app.utils.wrapper_handler import WrapperHandler from .base import NewsWrapper, Article from .news_api import NewsApiWrapper -from .gnews_api import GnewsWrapper +from .gnews_api import GoogleNewsWrapper from .cryptopanic_api import CryptoPanicWrapper +from .duckduckgo import DuckDuckGoWrapper -__all__ = ["NewsApiWrapper", "GnewsWrapper", "CryptoPanicWrapper"] +__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: + - GnewsWrapper + - 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 = [GnewsWrapper, NewsApiWrapper, CryptoPanicWrapper] + wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers) - def get_top_headlines(self, query: str, total: int = 100) -> list[Article]: - return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(query, total)) + 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 index 8b3f55d..0a8f6be 100644 --- a/src/app/news/base.py +++ b/src/app/news/base.py @@ -7,8 +7,29 @@ class Article(BaseModel): description: str = "" class NewsWrapper: - def get_top_headlines(self, query: str, total: int = 100) -> list[Article]: - raise NotImplementedError("This method should be overridden by subclasses") - def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + """ + 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 index 2e9270c..a949c69 100644 --- a/src/app/news/cryptopanic_api.py +++ b/src/app/news/cryptopanic_api.py @@ -31,6 +31,13 @@ def get_articles(response: dict) -> list[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" @@ -55,7 +62,10 @@ class CryptoPanicWrapper(NewsWrapper): def set_filter(self, filter: CryptoPanicFilter): self.filter = filter - def get_top_headlines(self, query: str, total: int = 100) -> list[Article]: + 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 @@ -65,6 +75,3 @@ class CryptoPanicWrapper(NewsWrapper): json_response = response.json() articles = get_articles(json_response) return articles[:total] - - def get_latest_news(self, query: str, total: int = 100) -> list[Article]: - return self.get_top_headlines(query, total) # same endpoint for both, so just call it 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 index 53451c9..2e35f46 100644 --- a/src/app/news/gnews_api.py +++ b/src/app/news/gnews_api.py @@ -9,8 +9,13 @@ def result_to_article(result: dict) -> Article: article.description = result.get("description", "") return article -class GnewsWrapper(NewsWrapper): - def get_top_headlines(self, query: str, total: int = 100) -> list[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() diff --git a/src/app/news/news_api.py b/src/app/news/news_api.py index 9629ecd..0e6d684 100644 --- a/src/app/news/news_api.py +++ b/src/app/news/news_api.py @@ -11,6 +11,12 @@ def result_to_article(result: dict) -> Article: 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" @@ -20,13 +26,13 @@ class NewsApiWrapper(NewsWrapper): self.language = "en" # TODO Only English articles for now? self.max_page_size = 100 - def get_top_headlines(self, query: str, total: int = 100) -> list[Article]: + 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=query, category=self.category, language=self.language, page_size=page_size, page=page) + 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 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..e69de29 diff --git a/tests/api/test_cryptopanic_api.py b/tests/api/test_cryptopanic_api.py index dda2f77..c8020d3 100644 --- a/tests/api/test_cryptopanic_api.py +++ b/tests/api/test_cryptopanic_api.py @@ -27,7 +27,7 @@ class TestCryptoPanicAPI: # Useless since both methods use the same endpoint # def test_crypto_panic_api_get_top_headlines(self): # crypto = CryptoPanicWrapper() - # articles = crypto.get_top_headlines(query="crypto", total=2) + # articles = crypto.get_top_headlines(total=2) # assert isinstance(articles, list) # assert len(articles) == 2 # for article in articles: diff --git a/tests/api/test_duckduckgo_news.py b/tests/api/test_duckduckgo_news.py new file mode 100644 index 0000000..ea38272 --- /dev/null +++ b/tests/api/test_duckduckgo_news.py @@ -0,0 +1,35 @@ +import pytest +from app.news import DuckDuckGoWrapper + + +@pytest.mark.news +@pytest.mark.api +class TestDuckDuckGoNews: + + def test_news_api_initialization(self): + news = DuckDuckGoWrapper() + assert news.tool is not None + + def test_news_api_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_news_api_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_gnews_api.py b/tests/api/test_google_news.py similarity index 82% rename from tests/api/test_gnews_api.py rename to tests/api/test_google_news.py index 1013fa7..c7750f3 100644 --- a/tests/api/test_gnews_api.py +++ b/tests/api/test_google_news.py @@ -1,17 +1,17 @@ import pytest -from app.news import GnewsWrapper +from app.news import GoogleNewsWrapper @pytest.mark.news @pytest.mark.api -class TestGnewsAPI: +class TestGoogleNews: def test_gnews_api_initialization(self): - gnews_api = GnewsWrapper() + gnews_api = GoogleNewsWrapper() assert gnews_api is not None def test_gnews_api_get_latest_news(self): - gnews_api = GnewsWrapper() + gnews_api = GoogleNewsWrapper() articles = gnews_api.get_latest_news(query="crypto", total=2) assert isinstance(articles, list) assert len(articles) == 2 @@ -22,8 +22,8 @@ class TestGnewsAPI: assert article.description is not None or article.description != "" def test_gnews_api_get_top_headlines(self): - news_api = GnewsWrapper() - articles = news_api.get_top_headlines(query="crypto", total=2) + news_api = GoogleNewsWrapper() + articles = news_api.get_top_headlines(total=2) assert isinstance(articles, list) assert len(articles) == 2 for article in articles: diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index da8f607..927419b 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -26,7 +26,7 @@ class TestNewsAPI: def test_news_api_get_top_headlines(self): news_api = NewsApiWrapper() - articles = news_api.get_top_headlines(query="crypto", total=2) + 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: diff --git a/uv.lock b/uv.lock index 7eb69ba..ce4499b 100644 --- a/uv.lock +++ b/uv.lock @@ -169,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" @@ -323,6 +340,21 @@ 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" @@ -577,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" @@ -592,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" @@ -620,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" @@ -639,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" @@ -669,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" @@ -860,6 +961,22 @@ 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 = "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" @@ -1255,6 +1372,15 @@ 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" @@ -1371,6 +1497,7 @@ source = { virtual = "." } dependencies = [ { name = "agno" }, { name = "coinbase-advanced-py" }, + { name = "ddgs" }, { name = "dotenv" }, { name = "gnews" }, { name = "google-genai" }, @@ -1385,6 +1512,7 @@ dependencies = [ requires-dist = [ { name = "agno" }, { name = "coinbase-advanced-py" }, + { name = "ddgs" }, { name = "dotenv" }, { name = "gnews" }, { name = "google-genai" }, From fcbb312d081e68cd2b4b23fa03c837a4053561eb Mon Sep 17 00:00:00 2001 From: trojanhorse47 Date: Tue, 30 Sep 2025 12:28:44 +0200 Subject: [PATCH 12/33] - Refactor struttura progetto: divisione tra agent e toolkit --- src/__init__.py | 0 src/app.py | 18 ++-- src/app/agents/market_agent.py | 92 ++++++++++++++++++ src/app/agents/news_agent.py | 32 +++++- src/app/agents/social_agent.py | 33 ++++++- src/app/markets/__init__.py | 3 +- src/app/markets/coinbase.py | 3 + src/app/markets/cryptocompare.py | 2 + src/app/models.py | 7 +- src/app/pipeline.py | 84 ++++++++++++++++ src/app/{agents => }/predictor.py | 5 +- src/app/tool.py | 97 ------------------- src/app/toolkits/__init__.py | 0 .../market.py => toolkits/market_toolkit.py} | 2 + tests/agents/test_market.py | 2 +- 15 files changed, 266 insertions(+), 114 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/app/agents/market_agent.py create mode 100644 src/app/pipeline.py rename src/app/{agents => }/predictor.py (99%) delete mode 100644 src/app/tool.py create mode 100644 src/app/toolkits/__init__.py rename src/app/{agents/market.py => toolkits/market_toolkit.py} (99%) 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..8e9e787 100644 --- a/src/app.py +++ b/src/app.py @@ -1,8 +1,8 @@ import gradio as gr - -from dotenv import load_dotenv -from app.tool import ToolAgent from agno.utils.log import log_info +from dotenv import load_dotenv + +from app.pipeline import Pipeline ######################################## # MAIN APP & GRADIO INTERFACE @@ -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..94d01d5 --- /dev/null +++ b/src/app/agents/market_agent.py @@ -0,0 +1,92 @@ +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 src.app.toolkits.market_toolkit import MarketToolkit +from src.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] = [] + + for sym in symbols: + try: + product = self.toolkit.get_current_price(sym) # supponiamo ritorni un ProductInfo o simile + if isinstance(product, list): + products.extend(product) + else: + products.append(product) + + results.append(f"{sym}: {product.price if hasattr(product, 'price') else product}") + except Exception as e: + results.append(f"{sym}: errore ({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 cb27b23..b965637 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,8 +1,9 @@ +from agno.utils.log import log_warning + from src.app.markets.base import BaseWrapper from src.app.markets.coinbase import CoinBaseWrapper from src.app.markets.cryptocompare import CryptoCompareWrapper -from agno.utils.log import log_warning class MarketAPIs(BaseWrapper): """ diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index 2e0e779..9d2e4be 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -1,7 +1,10 @@ import os + from coinbase.rest import RESTClient + from src.app.markets.base import ProductInfo, BaseWrapper, Price + class CoinBaseWrapper(BaseWrapper): """ Wrapper per le API di Coinbase. diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index a6e50a0..5ba44b4 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -1,5 +1,7 @@ import os + import requests + from src.app.markets.base import ProductInfo, BaseWrapper, Price BASE_URL = "https://min-api.cryptocompare.com" diff --git a/src/app/models.py b/src/app/models.py index 94f7da2..f7ec9b8 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1,13 +1,14 @@ import os -import requests from enum import Enum -from pydantic import BaseModel + +import requests 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): """ diff --git a/src/app/pipeline.py b/src/app/pipeline.py new file mode 100644 index 0000000..9e0e2bd --- /dev/null +++ b/src/app/pipeline.py @@ -0,0 +1,84 @@ +from typing import List + +from agno.team import Team +from agno.utils.log import log_info + +from app.agents.market_agent import MarketAgent +from src.app.agents.news_agent import NewsAgent +from src.app.agents.social_agent import SocialAgent +from src.app.markets import MarketAPIs +from src.app.models import AppModels +from src.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 99% rename from src/app/agents/predictor.py rename to src/app/predictor.py index 09d7a26..101b4ac 100644 --- a/src/app/agents/predictor.py +++ b/src/app/predictor.py @@ -1,7 +1,10 @@ from enum import Enum -from src.app.markets.base import ProductInfo + from pydantic import BaseModel, Field +from src.app.markets.base import ProductInfo + + class PredictorStyle(Enum): CONSERVATIVE = "Conservativo" AGGRESSIVE = "Aggressivo" diff --git a/src/app/tool.py b/src/app/tool.py deleted file mode 100644 index f1ca560..0000000 --- a/src/app/tool.py +++ /dev/null @@ -1,97 +0,0 @@ -from src.app.agents.news_agent import NewsAgent -from src.app.agents.social_agent import SocialAgent -from src.app.agents.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS -from src.app.markets import MarketAPIs -from src.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.social_agent = None - self.news_agent = None - self.predictor = None - self.chosen_model = None - 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. - - Args: - 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. - - Args: - 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. - - Args: - query: richiesta dell'utente (es. "Qual รจ la previsione per Bitcoin?") - """ - - 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 99% rename from src/app/agents/market.py rename to src/app/toolkits/market_toolkit.py index a561cb6..f0aef93 100644 --- a/src/app/agents/market.py +++ b/src/app/toolkits/market_toolkit.py @@ -1,6 +1,8 @@ from agno.tools import Toolkit + from src.app.markets import MarketAPIs + # 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 # per ora mettiamo tutto statico e poi, se abbiamo API-Key senza limiti diff --git a/tests/agents/test_market.py b/tests/agents/test_market.py index cb6c390..ef0b8f7 100644 --- a/tests/agents/test_market.py +++ b/tests/agents/test_market.py @@ -1,6 +1,6 @@ import os import pytest -from src.app.agents.market import MarketToolkit +from src.app.agents.market_toolkit import MarketToolkit from src.app.markets.base import BaseWrapper from src.app.markets.coinbase import CoinBaseWrapper from src.app.markets.cryptocompare import CryptoCompareWrapper From 15182e23c251ed5cf83d983ca2d007e659aa66e0 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 12:41:45 +0200 Subject: [PATCH 13/33] Refactor try_call_all method to return a dictionary of results; update tests for success and partial failures --- src/app/utils/wrapper_handler.py | 6 +++--- tests/utils/test_wrapper_handler.py | 23 +++++++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/app/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py index 0ea72d6..df86c36 100644 --- a/src/app/utils/wrapper_handler.py +++ b/src/app/utils/wrapper_handler.py @@ -61,7 +61,7 @@ class WrapperHandler(Generic[W]): raise Exception(f"All wrappers failed after retries") - def try_call_all(self, func: Callable[[W], T]) -> list[T]: + 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. @@ -73,11 +73,11 @@ class WrapperHandler(Generic[W]): Raises: Exception: If all wrappers fail. """ - results = [] + results = {} for wrapper in self.wrappers: try: result = func(wrapper) - results.append(result) + results[wrapper.__class__] = result except Exception as e: log_warning(f"{wrapper} failed: {e}") if not results: diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index fd5ffff..4770977 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -5,6 +5,10 @@ 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") @@ -59,19 +63,26 @@ class TestWrapperHandler: assert handler.index == 1 # Should return to the second wrapper after failure assert handler.retry_count == 0 - def test_try_call_all(self): + 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 == ["Success"] # Only the second wrapper should succeed + assert results == {MockWrapper: "Success"} - wrappers = [FailingWrapper, MockWrapper, FailingWrapper, MockWrapper] + # 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 == ["Success", "Success"] # Only the second and fourth wrappers should succeed + 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: From c1952526add6434751961d830281c602b3835ad1 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 12:54:19 +0200 Subject: [PATCH 14/33] Fix class and test method names for DuckDuckGoWrapper --- src/app/news/__init__.py | 2 +- tests/api/test_duckduckgo_news.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 3218296..d38cd43 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -13,7 +13,7 @@ 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: - - GnewsWrapper + - GoogleNewsWrapper - DuckDuckGoWrapper - NewsApiWrapper - CryptoPanicWrapper diff --git a/tests/api/test_duckduckgo_news.py b/tests/api/test_duckduckgo_news.py index ea38272..e0bb599 100644 --- a/tests/api/test_duckduckgo_news.py +++ b/tests/api/test_duckduckgo_news.py @@ -6,11 +6,11 @@ from app.news import DuckDuckGoWrapper @pytest.mark.api class TestDuckDuckGoNews: - def test_news_api_initialization(self): + def test_duckduckgo_initialization(self): news = DuckDuckGoWrapper() assert news.tool is not None - def test_news_api_get_latest_news(self): + def test_duckduckgo_get_latest_news(self): news = DuckDuckGoWrapper() articles = news.get_latest_news(query="crypto", total=2) assert isinstance(articles, list) @@ -21,8 +21,7 @@ class TestDuckDuckGoNews: 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): + def test_duckduckgo_get_top_headlines(self): news = DuckDuckGoWrapper() articles = news.get_top_headlines(total=2) assert isinstance(articles, list) From 43b2bddba5fc89fcc53f49d598122b975cdee7aa Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 15:36:37 +0200 Subject: [PATCH 15/33] Add Reddit API wrapper and related tests; update environment configuration --- .env.example | 16 +++++++++--- pyproject.toml | 20 +++++++-------- src/app/social/base.py | 22 +++++++++++++++++ src/app/social/reddit.py | 53 ++++++++++++++++++++++++++++++++++++++++ tests/api/test_reddit.py | 24 ++++++++++++++++++ tests/conftest.py | 1 + uv.lock | 49 +++++++++++++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 src/app/social/reddit.py create mode 100644 tests/api/test_reddit.py diff --git a/.env.example b/.env.example index f59ad82..0948465 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,11 @@ GOOGLE_API_KEY= # Configurazioni per gli agenti di mercato ############################################################################### -# Ottenibili da: https://portal.cdp.coinbase.com/access/api +# https://portal.cdp.coinbase.com/access/api CDP_API_KEY_NAME= CDP_API_PRIVATE_KEY= -# 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) @@ -25,8 +25,16 @@ BINANCE_API_SECRET= # Configurazioni per gli agenti di notizie ############################################################################### -# Ottenibile da: https://newsapi.org/docs +# https://newsapi.org/docs NEWS_API_KEY= -# Ottenibile da: https://cryptopanic.com/developers/api/ +# 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/pyproject.toml b/pyproject.toml index 8f4cc87..74c0029 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,30 +10,30 @@ 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 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", - # โœ… per interagire con API di notizie + # API di notizie "newsapi-python", "gnews", "ddgs", + + # API di social media + "praw", # Reddit ] [tool.pytest.ini_options] diff --git a/src/app/social/base.py b/src/app/social/base.py index e69de29..945cdd5 100644 --- a/src/app/social/base.py +++ 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/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 9bd9589..40d5aab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ def pytest_configure(config:pytest.Config): ("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"), ] diff --git a/uv.lock b/uv.lock index ce4499b..2d7d6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -961,6 +961,32 @@ 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" @@ -1490,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" @@ -1504,6 +1542,7 @@ dependencies = [ { name = "gradio" }, { name = "newsapi-python" }, { name = "ollama" }, + { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, ] @@ -1519,6 +1558,7 @@ requires-dist = [ { name = "gradio" }, { name = "newsapi-python" }, { name = "ollama" }, + { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, ] @@ -1545,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" From 8304cf9ea898aff6de2fcadf5b38c2a5f8dcff77 Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Tue, 30 Sep 2025 15:36:50 +0200 Subject: [PATCH 16/33] pre merge con giacomo --- demos/market_providers_api_demo.py | 2 +- src/app/agents/market_agent.py | 22 +++--- src/app/markets/error_handler.py | 3 +- src/app/toolkits/market_toolkit.py | 4 +- src/app/utils/wrapper_handler.py | 110 +++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 src/app/utils/wrapper_handler.py diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py index 39055bb..16817d5 100644 --- a/demos/market_providers_api_demo.py +++ b/demos/market_providers_api_demo.py @@ -26,7 +26,7 @@ project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root / "src")) from dotenv import load_dotenv -from app.markets import ( +from src.app.markets import ( CoinBaseWrapper, CryptoCompareWrapper, BinanceWrapper, diff --git a/src/app/agents/market_agent.py b/src/app/agents/market_agent.py index 94d01d5..b2c4cfd 100644 --- a/src/app/agents/market_agent.py +++ b/src/app/agents/market_agent.py @@ -71,18 +71,16 @@ class MarketAgent(Agent): results = [] products: List[ProductInfo] = [] - for sym in symbols: - try: - product = self.toolkit.get_current_price(sym) # supponiamo ritorni un ProductInfo o simile - if isinstance(product, list): - products.extend(product) - else: - products.append(product) - - results.append(f"{sym}: {product.price if hasattr(product, 'price') else product}") - except Exception as e: - results.append(f"{sym}: errore ({e})") - + 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) diff --git a/src/app/markets/error_handler.py b/src/app/markets/error_handler.py index 38aa47f..22b6aeb 100644 --- a/src/app/markets/error_handler.py +++ b/src/app/markets/error_handler.py @@ -14,6 +14,7 @@ from functools import wraps from typing import Any, Callable, Optional, Type, Union, List from requests.exceptions import RequestException, Timeout, ConnectionError from binance.exceptions import BinanceAPIException, BinanceRequestException +from base import ProductInfo # Configurazione logging logger = logging.getLogger(__name__) @@ -168,7 +169,7 @@ class ProviderFallback: method_name: str, *args, **kwargs - ) -> Any: + ) -> list[ProductInfo]: """ Esegue un metodo su tutti i provider fino a trovarne uno che funziona. diff --git a/src/app/toolkits/market_toolkit.py b/src/app/toolkits/market_toolkit.py index f0aef93..f366986 100644 --- a/src/app/toolkits/market_toolkit.py +++ b/src/app/toolkits/market_toolkit.py @@ -16,14 +16,14 @@ class MarketToolkit(Toolkit): 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/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 From 3074b58ea5061de3558233d92d03e76af07355ab Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 15:58:52 +0200 Subject: [PATCH 17/33] Fix import statements --- src/app/agents/market_agent.py | 4 ++-- src/app/markets/__init__.py | 2 +- src/app/markets/error_handler.py | 2 +- src/app/models.py | 3 +-- src/app/pipeline.py | 10 +++++----- src/app/predictor.py | 4 +--- src/app/toolkits/market_toolkit.py | 3 +-- 7 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/app/agents/market_agent.py b/src/app/agents/market_agent.py index b2c4cfd..12f9eab 100644 --- a/src/app/agents/market_agent.py +++ b/src/app/agents/market_agent.py @@ -4,8 +4,8 @@ from agno.models.message import Message from agno.run.agent import RunOutput, RunOutputEvent from pydantic import BaseModel -from src.app.toolkits.market_toolkit import MarketToolkit -from src.app.markets.base import ProductInfo # modello dati giร  definito nel tuo progetto +from app.toolkits.market_toolkit import MarketToolkit +from app.markets.base import ProductInfo # modello dati giร  definito nel tuo progetto class MarketAgent(Agent): diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index 20e29ee..0469327 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,4 +1,4 @@ -from base import BaseWrapper +from .base import BaseWrapper from app.markets.coinbase import CoinBaseWrapper from app.markets.cryptocompare import CryptoCompareWrapper from app.markets.binance import BinanceWrapper diff --git a/src/app/markets/error_handler.py b/src/app/markets/error_handler.py index 22b6aeb..c98301a 100644 --- a/src/app/markets/error_handler.py +++ b/src/app/markets/error_handler.py @@ -14,7 +14,7 @@ from functools import wraps from typing import Any, Callable, Optional, Type, Union, List from requests.exceptions import RequestException, Timeout, ConnectionError from binance.exceptions import BinanceAPIException, BinanceRequestException -from base import ProductInfo +from .base import ProductInfo # Configurazione logging logger = logging.getLogger(__name__) diff --git a/src/app/models.py b/src/app/models.py index f7ec9b8..c1bff9b 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1,7 +1,6 @@ import os -from enum import Enum - import requests +from enum import Enum from agno.agent import Agent from agno.models.base import Model from agno.models.google import Gemini diff --git a/src/app/pipeline.py b/src/app/pipeline.py index 9e0e2bd..f515053 100644 --- a/src/app/pipeline.py +++ b/src/app/pipeline.py @@ -4,11 +4,11 @@ from agno.team import Team from agno.utils.log import log_info from app.agents.market_agent import MarketAgent -from src.app.agents.news_agent import NewsAgent -from src.app.agents.social_agent import SocialAgent -from src.app.markets import MarketAPIs -from src.app.models import AppModels -from src.app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS +from app.agents.news_agent import NewsAgent +from app.agents.social_agent import SocialAgent +from app.markets import MarketAPIs +from app.models import AppModels +from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS class Pipeline: diff --git a/src/app/predictor.py b/src/app/predictor.py index 101b4ac..38780de 100644 --- a/src/app/predictor.py +++ b/src/app/predictor.py @@ -1,8 +1,6 @@ from enum import Enum - from pydantic import BaseModel, Field - -from src.app.markets.base import ProductInfo +from app.markets.base import ProductInfo class PredictorStyle(Enum): diff --git a/src/app/toolkits/market_toolkit.py b/src/app/toolkits/market_toolkit.py index f366986..ff6e48d 100644 --- a/src/app/toolkits/market_toolkit.py +++ b/src/app/toolkits/market_toolkit.py @@ -1,6 +1,5 @@ from agno.tools import Toolkit - -from src.app.markets import MarketAPIs +from app.markets import MarketAPIs # TODO (?) in futuro fare in modo che la LLM faccia da sรฉ per il mercato From a5ef982e124a568bd63a8e8bef2e1797929021d6 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 16:38:25 +0200 Subject: [PATCH 18/33] Fixes - separated tests - fix tests - fix bugs reintroduced my previous merge --- src/app/markets/base.py | 2 +- src/app/markets/coinbase.py | 38 +++++++++++--- src/app/markets/cryptocompare.py | 10 ++-- tests/agents/test_market.py | 85 +++++--------------------------- tests/api/test_binance.py | 7 +++ tests/api/test_coinbase.py | 54 ++++++++++++++++++++ tests/api/test_cryptocompare.py | 56 +++++++++++++++++++++ tests/conftest.py | 22 +-------- 8 files changed, 165 insertions(+), 109 deletions(-) create mode 100644 tests/api/test_binance.py create mode 100644 tests/api/test_coinbase.py create mode 100644 tests/api/test_cryptocompare.py diff --git a/src/app/markets/base.py b/src/app/markets/base.py index 032f8aa..b15b125 100644 --- a/src/app/markets/base.py +++ b/src/app/markets/base.py @@ -12,7 +12,7 @@ class BaseWrapper: raise NotImplementedError def get_all_products(self) -> list['ProductInfo']: 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']: raise NotImplementedError class ProductInfo(BaseModel): diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index aac556d..38614aa 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -1,19 +1,32 @@ import os +from enum import Enum +from datetime import datetime, timedelta from coinbase.rest import RESTClient from app.markets.base import ProductInfo, BaseWrapper, 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 """ - 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 @@ -32,14 +45,23 @@ class CoinBaseWrapper(BaseWrapper): 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) + assets = self.client.get_products(product_ids=all_asset_ids) return [ProductInfo.from_coinbase(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] - 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) + 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 [Price.from_coinbase(candle) for candle in data.candles] diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index 188a2c2..b4a092d 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -10,9 +10,8 @@ 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 @@ -49,12 +48,11 @@ class CryptoCompareWrapper(BaseWrapper): def get_all_products(self) -> list[ProductInfo]: raise NotImplementedError("CryptoCompare does not support fetching all assets") - 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', []) diff --git a/tests/agents/test_market.py b/tests/agents/test_market.py index 56931b3..b3e7f46 100644 --- a/tests/agents/test_market.py +++ b/tests/agents/test_market.py @@ -1,34 +1,21 @@ 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): + def test_wrapper_initialization(self): + market_wrapper = MarketAPIs("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_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): + def test_wrapper_capabilities(self): + market_wrapper = MarketAPIs("USD") capabilities = [] if hasattr(market_wrapper, 'get_product'): capabilities.append('single_product') @@ -38,14 +25,15 @@ class TestMarketSystem: capabilities.append('historical_data') assert len(capabilities) > 0 - def test_market_data_retrieval(self, market_wrapper): + def test_market_data_retrieval(self): + market_wrapper = MarketAPIs("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, market_wrapper): + def test_market_toolkit_integration(self): try: toolkit = MarketToolkit() assert toolkit is not None @@ -59,59 +47,6 @@ class TestMarketSystem: 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'): @@ -127,8 +62,9 @@ class TestMarketSystem: assert wrapper is not None assert hasattr(wrapper, 'get_product') - def test_error_handling(self, market_wrapper): + def test_error_handling(self): try: + market_wrapper = MarketAPIs("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: @@ -140,7 +76,8 @@ class TestMarketSystem: except Exception as e: pass - def test_wrapper_currency_support(self, market_wrapper): + def test_wrapper_currency_support(self): + market_wrapper = MarketAPIs("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/api/test_binance.py b/tests/api/test_binance.py new file mode 100644 index 0000000..0a73c15 --- /dev/null +++ b/tests/api/test_binance.py @@ -0,0 +1,7 @@ +import pytest + +@pytest.mark.market +@pytest.mark.api +class TestBinance: + # TODO fare dei test veri e propri + pass \ No newline at end of file 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/conftest.py b/tests/conftest.py index 40d5aab..e65e86f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,8 +15,7 @@ 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"), @@ -30,24 +29,7 @@ def pytest_configure(config:pytest.Config): config.addinivalue_line("markers", line) def pytest_collection_modifyitems(config, items): - """Modifica automaticamente gli item di test aggiungendogli marker basati sul nome""" - - markers_to_add = { - "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) - + """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: From f7dec6fdb61025f327c3fc2d5753c15db8e886b1 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 17:30:14 +0200 Subject: [PATCH 19/33] Refactor market API wrappers to streamline product and price retrieval methods --- src/app/markets/base.py | 76 +----------- src/app/markets/binance.py | 194 +++++++++---------------------- src/app/markets/coinbase.py | 31 ++++- src/app/markets/cryptocompare.py | 29 ++++- tests/api/test_binance.py | 49 +++++++- 5 files changed, 157 insertions(+), 222 deletions(-) diff --git a/src/app/markets/base.py b/src/app/markets/base.py index 7a52a03..2690c4f 100644 --- a/src/app/markets/base.py +++ b/src/app/markets/base.py @@ -1,4 +1,4 @@ -from coinbase.rest.types.product_types import Candle, GetProductResponse, Product + from pydantic import BaseModel class BaseWrapper: @@ -27,58 +27,6 @@ class ProductInfo(BaseModel): status: str = "" quote_currency: str = "" - @staticmethod - def from_coinbase(product_data: GetProductResponse) -> '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 - - @staticmethod - def from_coinbase_product(product_data: 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 - product.status = product_data.status or "" - return product - - @staticmethod - 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 - - @staticmethod - def from_binance(ticker_data: dict, ticker_24h_data: dict) -> 'ProductInfo': - """ - Crea un oggetto ProductInfo da dati Binance. - - Args: - ticker_data: Dati del ticker di prezzo - ticker_24h_data: Dati del ticker 24h - - Returns: - Oggetto ProductInfo - """ - product = ProductInfo() - product.id = ticker_data['symbol'] - product.symbol = ticker_data['symbol'].replace('USDT', '').replace('BUSD', '') - product.price = float(ticker_data['price']) - product.volume_24h = float(ticker_24h_data['volume']) - product.status = "TRADING" # Binance non fornisce status esplicito - product.quote_currency = "USDT" # Assumiamo USDT come default - return product - class Price(BaseModel): """ Rappresenta i dati di prezzo per un asset, come ottenuti dalle API di mercato. @@ -90,25 +38,3 @@ class Price(BaseModel): close: float = 0.0 volume: float = 0.0 time: str = "" - - @staticmethod - def from_coinbase(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 - - @staticmethod - 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 8bc1101..6b6b6d3 100644 --- a/src/app/markets/binance.py +++ b/src/app/markets/binance.py @@ -1,37 +1,31 @@ import os -from typing import Optional -from datetime import datetime, timedelta +from datetime import datetime from binance.client import Client from .base import ProductInfo, BaseWrapper, Price -from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError +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 BinanceWrapper(BaseWrapper): """ - Wrapper per le API autenticate di Binance. - + 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. - - La documentazione delle API รจ disponibile qui: + ai dati di mercato di Binance tramite le API REST con autenticazione.\n https://binance-docs.github.io/apidocs/spot/en/ """ - - def __init__(self, api_key: Optional[str] = None, api_secret: Optional[str] = None, currency: str = "USDT"): - """ - Inizializza il wrapper con le credenziali API. - - Args: - api_key: Chiave API di Binance (se None, usa variabile d'ambiente) - api_secret: Secret API di Binance (se None, usa variabile d'ambiente) - currency: Valuta di quotazione di default (default: USDT) - """ - if api_key is None: - api_key = os.getenv("BINANCE_API_KEY") + + def __init__(self, currency: str = "USDT"): + api_key = os.getenv("BINANCE_API_KEY") assert api_key is not None, "API key is required" - if api_secret is None: - api_secret = os.getenv("BINANCE_API_SECRET") + api_secret = os.getenv("BINANCE_API_SECRET") assert api_secret is not None, "API secret is required" self.currency = currency @@ -40,130 +34,58 @@ class BinanceWrapper(BaseWrapper): def __format_symbol(self, asset_id: str) -> str: """ Formatta l'asset_id nel formato richiesto da Binance. - - Args: - asset_id: ID dell'asset (es. "BTC" o "BTC-USDT") - - Returns: - Simbolo formattato per Binance (es. "BTCUSDT") """ - if '-' in asset_id: - # Se giร  nel formato "BTC-USDT", converte in "BTCUSDT" - return asset_id.replace('-', '') - else: - # Se solo "BTC", aggiunge la valuta di default - return f"{asset_id}{self.currency}" + return asset_id.replace('-', '') if '-' in asset_id else f"{asset_id}{self.currency}" - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_product(self, asset_id: str) -> ProductInfo: - """ - Ottiene informazioni su un singolo prodotto. - - Args: - asset_id: ID dell'asset da recuperare - - Returns: - Oggetto ProductInfo con le informazioni del prodotto - """ symbol = self.__format_symbol(asset_id) + ticker = self.client.get_symbol_ticker(symbol=symbol) ticker_24h = self.client.get_ticker(symbol=symbol) - - return ProductInfo.from_binance(ticker, ticker_24h) + ticker['volume'] = ticker_24h.get('volume', 0) # Aggiunge volume 24h ai dati del ticker + + return get_product(self.currency, ticker) - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - """ - Ottiene informazioni su piรน prodotti. - - Args: - asset_ids: Lista di ID degli asset da recuperare - - Returns: - Lista di oggetti 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 asset_id in asset_ids: - try: - product = self.get_product(asset_id) + + 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) - except Exception as e: - print(f"Errore nel recupero di {asset_id}: {e}") - continue return products - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors - def get_all_products(self) -> list[ProductInfo]: - """ - Ottiene informazioni su tutti i prodotti disponibili. - - Returns: - Lista di oggetti ProductInfo per tutti i prodotti - """ - try: - # Ottiene tutti i ticker 24h che contengono le informazioni necessarie - 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): - try: - # Crea ProductInfo direttamente dal ticker 24h - product = ProductInfo() - product.id = ticker['symbol'] - product.symbol = ticker['symbol'].replace(self.currency, '') - product.price = float(ticker['lastPrice']) - product.volume_24h = float(ticker['volume']) - product.status = "TRADING" # Binance non fornisce status esplicito - product.quote_currency = self.currency - products.append(product) - except (ValueError, KeyError) as e: - print(f"Errore nel parsing di {ticker['symbol']}: {e}") - continue - - return products - except Exception as e: - print(f"Errore nel recupero di tutti i prodotti: {e}") - return [] - - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors - def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]: - """ - Ottiene i prezzi storici per un asset. - - Args: - asset_id: ID dell'asset (default: "BTC") - - Returns: - Lista di oggetti Price con i dati storici - """ + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: symbol = self.__format_symbol(asset_id) - - try: - # Ottiene candele orarie degli ultimi 30 giorni - klines = self.client.get_historical_klines( - symbol=symbol, - interval=Client.KLINE_INTERVAL_1HOUR, - start_str="30 days ago UTC" - ) - - 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 - except Exception as e: - print(f"Errore nel recupero dei prezzi storici per {symbol}: {e}") - return [] + + # 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 95c6261..286ec6f 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -2,8 +2,29 @@ import os from enum import Enum from datetime import datetime, timedelta from coinbase.rest import RESTClient +from coinbase.rest.types.product_types import Candle, GetProductResponse, Product from .base import ProductInfo, BaseWrapper, Price -from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError, RateLimitError + + +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): @@ -45,16 +66,16 @@ 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(product_ids=all_asset_ids) - return [ProductInfo.from_coinbase(asset) for asset in assets.products] + 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_product(asset) for asset in assets.products] + return [get_product(asset) for asset in assets.products] def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: asset_id = self.__format(asset_id) @@ -68,4 +89,4 @@ class CoinBaseWrapper(BaseWrapper): end=str(int(end_time.timestamp())), limit=limit ) - return [Price.from_coinbase(candle) for candle in data.candles] + return [get_price(candle) for candle in data.candles] diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index 8b3cd08..c81a3bb 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -2,7 +2,27 @@ import os import requests from typing import Optional, Dict, Any from .base import ProductInfo, BaseWrapper, Price -from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError + + +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" @@ -33,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 = { @@ -44,10 +64,11 @@ 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]: + # 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, limit: int = 100) -> list[dict]: @@ -58,5 +79,5 @@ class CryptoCompareWrapper(BaseWrapper): }) 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/tests/api/test_binance.py b/tests/api/test_binance.py index 0a73c15..e4e0c20 100644 --- a/tests/api/test_binance.py +++ b/tests/api/test_binance.py @@ -1,7 +1,52 @@ import pytest +from app.markets.binance import BinanceWrapper @pytest.mark.market @pytest.mark.api class TestBinance: - # TODO fare dei test veri e propri - pass \ No newline at end of file + + 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 From e8bf835fb3680342834c891292fab209c0aa9941 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 17:37:36 +0200 Subject: [PATCH 20/33] Add BinanceWrapper to market API exports --- src/app/markets/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index 86a40e8..57f04b7 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,9 +1,10 @@ from .base import BaseWrapper from .coinbase import CoinBaseWrapper +from .binance import BinanceWrapper from .cryptocompare import CryptoCompareWrapper from app.utils.wrapper_handler import WrapperHandler -__all__ = [ "MarketAPIs", "BaseWrapper", "CoinBaseWrapper", "CryptoCompareWrapper" ] +__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper" ] # TODO se si vuole usare un aggregatore di dati di mercato, si puรฒ aggiungere qui facendo una classe extra (simile a questa) che per ogni chiamata chiama tutti i wrapper e aggrega i risultati From f6d7ff649981324740d5066b391619d36ef41de5 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 20:43:05 +0200 Subject: [PATCH 21/33] Refactor market toolkit to remove unused methods and streamline API calls --- src/app/models.py | 48 ++++++------------------------ src/app/toolkits/market_toolkit.py | 21 ++++--------- 2 files changed, 14 insertions(+), 55 deletions(-) diff --git a/src/app/models.py b/src/app/models.py index c1bff9b..77b79b3 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -6,6 +6,7 @@ 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 agno.tools import Toolkit from pydantic import BaseModel @@ -20,6 +21,7 @@ class AppModels(Enum): GEMINI_PRO = "gemini-2.0-pro" # API online, piรน costoso ma migliore OLLAMA_GPT = "gpt-oss:latest" # + good - slow (13b) OLLAMA_QWEN = "qwen3:latest" # + good + fast (8b) + OLLAMA_QWEN_4B = "qwen3:4b" # + fast - decent (4b) @staticmethod def availables_local() -> list['AppModels']: @@ -35,10 +37,9 @@ class AppModels(Enum): availables = [] result = result.text - if AppModels.OLLAMA_GPT.value in result: - availables.append(AppModels.OLLAMA_GPT) - if AppModels.OLLAMA_QWEN.value in result: - availables.append(AppModels.OLLAMA_QWEN) + for model in [model for model in AppModels if model.name.startswith("OLLAMA")]: + if model.value in result: + availables.append(model) return availables @staticmethod @@ -70,63 +71,31 @@ class AppModels(Enum): assert availables, "No valid model API keys set in environment variables." return availables - @staticmethod - def extract_json_str_from_response(response: str) -> str: - """ - Estrae il JSON dalla risposta del modello. - 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. - """ - think = response.rfind("") - if think != -1: - response = response[think:] - - start = response.find("{") - assert start != -1, "No JSON found in the response." - - end = response.rfind("}") - assert end != -1, "No JSON found in the response." - - return response[start:end + 1].strip() - - def get_model(self, instructions:str) -> Model: """ Restituisce un'istanza del modello specificato. - 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}: + if self in {model for model in AppModels if model.name.startswith("GEMINI")}: return Gemini(name, instructions=[instructions]) - elif self in {AppModels.OLLAMA_GPT, AppModels.OLLAMA_QWEN}: + elif self in {model for model in AppModels if model.name.startswith("OLLAMA")}: return Ollama(name, instructions=[instructions]) raise ValueError(f"Modello non supportato: {self}") - def get_agent(self, instructions: str, name: str = "", output: BaseModel | None = None) -> Agent: + def get_agent(self, instructions: str, name: str = "", output: BaseModel | None = None, tools: list[Toolkit] = []) -> Agent: """ Costruisce un agente con il modello e le istruzioni specificate. 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. """ @@ -134,6 +103,7 @@ class AppModels(Enum): model=self.get_model(instructions), name=name, retries=2, + tools=tools, delay_between_retries=5, # seconds output_schema=output # se si usa uno schema di output, lo si passa qui # TODO Eventuali altri parametri da mettere all'agente anche se si possono comunque assegnare dopo la creazione diff --git a/src/app/toolkits/market_toolkit.py b/src/app/toolkits/market_toolkit.py index ff6e48d..cd76cf2 100644 --- a/src/app/toolkits/market_toolkit.py +++ b/src/app/toolkits/market_toolkit.py @@ -9,32 +9,21 @@ 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 = MarketAPIs() super().__init__( name="Market Toolkit", tools=[ - self.get_historical_data, - self.get_current_prices, + self.market_api.get_historical_prices, + self.market_api.get_product, ], ) - def get_historical_data(self, symbol: str): - return self.market_api.get_historical_prices(symbol) - - def get_current_prices(self, symbol: list): - return self.market_api.get_products(symbol) - -def prepare_inputs(): - pass - def instructions(): return """ Utilizza questo strumento per ottenere dati di mercato storici e attuali per criptovalute specifiche. Puoi richiedere i prezzi storici o il prezzo attuale di una criptovaluta specifica. Esempio di utilizzo: - - get_historical_data("BTC") - - get_current_price("ETH") - + - get_historical_prices("BTC", limit=10) # ottieni gli ultimi 10 prezzi storici di Bitcoin + - get_product("ETH") """ - From 3bc24afceac7f06eebb1634d7311fe074752aef3 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 21:09:24 +0200 Subject: [PATCH 22/33] Refactor NewsAPIsTool to aggregate multiple news API wrappers and enhance functionality --- src/app/news/__init__.py | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index d38cd43..3bbfe27 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -1,3 +1,4 @@ +from agno.tools import Toolkit from app.utils.wrapper_handler import WrapperHandler from .base import NewsWrapper, Article from .news_api import NewsApiWrapper @@ -5,28 +6,50 @@ from .gnews_api import GoogleNewsWrapper from .cryptopanic_api import CryptoPanicWrapper from .duckduckgo import DuckDuckGoWrapper -__all__ = ["NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"] +__all__ = ["NewsAPIsTool", "NEWS_INSTRUCTIONS", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"] -class NewsAPIs(NewsWrapper): +class NewsAPIsTool(NewsWrapper, Toolkit): """ - 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: + Aggregates multiple news API wrappers and manages them using WrapperHandler. + This class supports retrieving top headlines and latest news articles by querying multiple sources: - 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. + By default, it returns results from the first successful wrapper. + Optionally, it can be configured to collect articles from all wrappers. + If no wrapper succeeds, an exception is raised. """ def __init__(self): wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers) + Toolkit.__init__( + self, + name="News APIs Toolkit", + tools=[ + self.get_top_headlines, + self.get_latest_news, + ], + ) + + # TODO Pensare se ha senso restituire gli articoli da TUTTI i wrapper o solo dal primo che funziona + # la modifica รจ banale, basta usare try_call_all invece di try_call 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)) + + +# TODO migliorare il prompt +NEWS_INSTRUCTIONS = """ +Utilizza questo strumento per ottenere le ultime notizie e i titoli principali relativi a criptovalute specifiche. Puoi richiedere le notizie piรน recenti o i titoli principali. + +Esempio di utilizzo: +- get_latest_news("crypto", limit=5) # ottieni le ultime 5 notizie su "crypto", la query puรฒ essere qualsiasi argomento di interesse +- get_top_headlines(limit=3) # ottieni i 3 titoli principali delle notizie globali + +""" From 01e7bf58f1ca7afa0fc943e8e15c6e072f5b77e5 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 22:13:12 +0200 Subject: [PATCH 23/33] Refactor news & social modules - update NewsAPIsTool - update SocialAPIsTool - add tests for NewsAPIsTool - added some missing docs --- src/__init__.py | 0 src/app/news/__init__.py | 11 +++- src/app/news/{gnews_api.py => googlenews.py} | 0 src/app/social/__init.py | 1 - src/app/social/__init__.py | 52 +++++++++++++++++++ src/app/social/base.py | 21 +++++--- tests/conftest.py | 1 + tests/tools/test_news_tool.py | 54 ++++++++++++++++++++ 8 files changed, 131 insertions(+), 9 deletions(-) delete mode 100644 src/__init__.py rename src/app/news/{gnews_api.py => googlenews.py} (100%) delete mode 100644 src/app/social/__init.py create mode 100644 src/app/social/__init__.py create mode 100644 tests/tools/test_news_tool.py diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 3bbfe27..05e4309 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -2,7 +2,7 @@ from agno.tools import Toolkit from app.utils.wrapper_handler import WrapperHandler from .base import NewsWrapper, Article from .news_api import NewsApiWrapper -from .gnews_api import GoogleNewsWrapper +from .googlenews import GoogleNewsWrapper from .cryptopanic_api import CryptoPanicWrapper from .duckduckgo import DuckDuckGoWrapper @@ -24,6 +24,15 @@ class NewsAPIsTool(NewsWrapper, Toolkit): """ def __init__(self): + """ + Initialize the NewsAPIsTool with multiple news API wrappers. + The tool uses WrapperHandler to manage and invoke the different news API wrappers. + The following wrappers are included in this order: + - GoogleNewsWrapper. + - DuckDuckGoWrapper. + - NewsApiWrapper. + - CryptoPanicWrapper. + """ wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers) diff --git a/src/app/news/gnews_api.py b/src/app/news/googlenews.py similarity index 100% rename from src/app/news/gnews_api.py rename to src/app/news/googlenews.py diff --git a/src/app/social/__init.py b/src/app/social/__init.py deleted file mode 100644 index 0d46bc8..0000000 --- a/src/app/social/__init.py +++ /dev/null @@ -1 +0,0 @@ -from .base import SocialWrapper \ No newline at end of file diff --git a/src/app/social/__init__.py b/src/app/social/__init__.py new file mode 100644 index 0000000..29d17be --- /dev/null +++ b/src/app/social/__init__.py @@ -0,0 +1,52 @@ +from .base import SocialPost, SocialWrapper +from .reddit import RedditWrapper +from app.utils.wrapper_handler import WrapperHandler +from agno.tools import Toolkit + +__all__ = ["SocialAPIsTool", "SOCIAL_INSTRUCTIONS", "RedditWrapper"] + +class SocialAPIsTool(SocialWrapper, Toolkit): + """ + Aggregates multiple social media API wrappers and manages them using WrapperHandler. + This class supports retrieving top crypto-related posts by querying multiple sources: + - RedditWrapper + + By default, it returns results from the first successful wrapper. + Optionally, it can be configured to collect posts from all wrappers. + If no wrapper succeeds, an exception is raised. + """ + + def __init__(self): + """ + Initialize the SocialAPIsTool with multiple social media API wrappers. + The tool uses WrapperHandler to manage and invoke the different social media API wrappers. + The following wrappers are included in this order: + - RedditWrapper. + """ + + wrappers = [RedditWrapper] + self.wrapper_handler: WrapperHandler[SocialWrapper] = WrapperHandler(wrappers) + + Toolkit.__init__( + self, + name="Socials Toolkit", + tools=[ + self.get_top_crypto_posts, + ], + ) + + # TODO Pensare se ha senso restituire i post da TUTTI i wrapper o solo dal primo che funziona + # la modifica รจ banale, basta usare try_call_all invece di try_call + def get_top_crypto_posts(self, limit:int = 5) -> list[SocialPost]: + return self.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit)) + + +# TODO migliorare il prompt +SOCIAL_INSTRUCTIONS = """ +Utilizza questo strumento per ottenere i post piรน recenti e gli argomenti di tendenza sui social media. Puoi richiedere i post piรน recenti o gli argomenti di tendenza. + +Esempio di utilizzo: +- get_latest_news("crypto", limit=5) # ottieni le ultime 5 notizie su "crypto", la query puรฒ essere qualsiasi argomento di interesse +- get_top_headlines(limit=3) # ottieni i 3 titoli principali delle notizie globali + +""" \ No newline at end of file diff --git a/src/app/social/base.py b/src/app/social/base.py index 945cdd5..1b66c1d 100644 --- a/src/app/social/base.py +++ b/src/app/social/base.py @@ -7,16 +7,23 @@ class SocialPost(BaseModel): 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 + """ + Base class for social media API wrappers. + All social media API wrappers should inherit from this class and implement the methods. + """ + def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: + """ + Get top cryptocurrency-related posts, optionally limited by total. + Args: + limit (int): The maximum number of posts to return. + Returns: + list[SocialPost]: A list of SocialPost objects. + """ + raise NotImplementedError("This method should be overridden by subclasses") + diff --git a/tests/conftest.py b/tests/conftest.py index e65e86f..c792e04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ def pytest_configure(config:pytest.Config): ("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]}" diff --git a/tests/tools/test_news_tool.py b/tests/tools/test_news_tool.py new file mode 100644 index 0000000..71dd51f --- /dev/null +++ b/tests/tools/test_news_tool.py @@ -0,0 +1,54 @@ +import pytest +from app.news import NewsAPIsTool + + +@pytest.mark.limited +@pytest.mark.tools +@pytest.mark.news +@pytest.mark.api +class TestNewsAPITool: + def test_news_api_tool(self): + tool = NewsAPIsTool() + assert tool is not None + + def test_news_api_tool_get_top(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call(lambda w: w.get_top_headlines(total=2)) + assert isinstance(result, list) + assert len(result) > 0 + for article in result: + assert article.title is not None + assert article.source is not None + + def test_news_api_tool_get_latest(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call(lambda w: w.get_latest_news(query="crypto", total=2)) + assert isinstance(result, list) + assert len(result) > 0 + for article in result: + assert article.title is not None + assert article.source is not None + + def test_news_api_tool_get_top__all_results(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(total=2)) + assert isinstance(result, dict) + assert len(result.keys()) > 0 + print("Results from providers:", result.keys()) + for provider, articles in result.items(): + for article in articles: + print(provider, article.title) + assert article.title is not None + assert article.source is not None + + def test_news_api_tool_get_latest__all_results(self): + tool = NewsAPIsTool() + result = tool.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query="crypto", total=2)) + assert isinstance(result, dict) + assert len(result.keys()) > 0 + print("Results from providers:", result.keys()) + for provider, articles in result.items(): + for article in articles: + print(provider, article.title) + assert article.title is not None + assert article.source is not None From 99ebb420facc5ee91b2a2c32cddfb11b2a4b2f84 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Tue, 30 Sep 2025 23:10:53 +0200 Subject: [PATCH 24/33] Refactor news and social instructions - enhance logging in WrapperHandler - add parameterized mock wrappers for testing --- src/app/news/__init__.py | 19 ++++++++++++++----- src/app/social/__init__.py | 23 ++++++++++++++++------- src/app/social/reddit.py | 3 ++- src/app/utils/wrapper_handler.py | 7 +++++-- tests/utils/test_wrapper_handler.py | 19 +++++++++++++++++++ 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 05e4309..115bf52 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -53,12 +53,21 @@ class NewsAPIsTool(NewsWrapper, Toolkit): return self.wrapper_handler.try_call(lambda w: w.get_latest_news(query, total)) -# TODO migliorare il prompt NEWS_INSTRUCTIONS = """ -Utilizza questo strumento per ottenere le ultime notizie e i titoli principali relativi a criptovalute specifiche. Puoi richiedere le notizie piรน recenti o i titoli principali. +**TASK:** You are a specialized **Crypto News Analyst**. Your goal is to fetch the latest news or top headlines related to cryptocurrencies, and then **analyze the sentiment** of the content to provide a concise report to the team leader. Prioritize 'crypto' or specific cryptocurrency names (e.g., 'Bitcoin', 'Ethereum') in your searches. -Esempio di utilizzo: -- get_latest_news("crypto", limit=5) # ottieni le ultime 5 notizie su "crypto", la query puรฒ essere qualsiasi argomento di interesse -- get_top_headlines(limit=3) # ottieni i 3 titoli principali delle notizie globali +**AVAILABLE TOOLS:** +1. `get_latest_news(query: str, limit: int)`: Get the 'limit' most recent news articles for a specific 'query'. +2. `get_top_headlines(limit: int)`: Get the 'limit' top global news headlines. +**USAGE GUIDELINE:** +* Always use `get_latest_news` with a relevant crypto-related query first. +* The default limit for news items should be 5 unless specified otherwise. +* If the tool doesn't return any articles, respond with "No relevant news articles found." + +**REPORTING REQUIREMENT:** +1. **Analyze** the tone and key themes of the retrieved articles. +2. **Summarize** the overall **market sentiment** (e.g., highly positive, cautiously neutral, generally negative) based on the content. +3. **Identify** the top 2-3 **main topics** discussed (e.g., new regulation, price surge, institutional adoption). +4. **Output** a single, brief report summarizing these findings. Do not output the raw articles. """ diff --git a/src/app/social/__init__.py b/src/app/social/__init__.py index 29d17be..de83e63 100644 --- a/src/app/social/__init__.py +++ b/src/app/social/__init__.py @@ -1,10 +1,11 @@ +from agno.tools import Toolkit +from app.utils.wrapper_handler import WrapperHandler from .base import SocialPost, SocialWrapper from .reddit import RedditWrapper -from app.utils.wrapper_handler import WrapperHandler -from agno.tools import Toolkit __all__ = ["SocialAPIsTool", "SOCIAL_INSTRUCTIONS", "RedditWrapper"] + class SocialAPIsTool(SocialWrapper, Toolkit): """ Aggregates multiple social media API wrappers and manages them using WrapperHandler. @@ -41,12 +42,20 @@ class SocialAPIsTool(SocialWrapper, Toolkit): return self.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit)) -# TODO migliorare il prompt SOCIAL_INSTRUCTIONS = """ -Utilizza questo strumento per ottenere i post piรน recenti e gli argomenti di tendenza sui social media. Puoi richiedere i post piรน recenti o gli argomenti di tendenza. +**TASK:** You are a specialized **Social Media Sentiment Analyst**. Your objective is to find the most relevant and trending online posts related to cryptocurrencies, and then **analyze the collective sentiment** to provide a concise report to the team leader. -Esempio di utilizzo: -- get_latest_news("crypto", limit=5) # ottieni le ultime 5 notizie su "crypto", la query puรฒ essere qualsiasi argomento di interesse -- get_top_headlines(limit=3) # ottieni i 3 titoli principali delle notizie globali +**AVAILABLE TOOLS:** +1. `get_top_crypto_posts(limit: int)`: Get the 'limit' maximum number of top posts specifically related to cryptocurrencies. +**USAGE GUIDELINE:** +* Always use the `get_top_crypto_posts` tool to fulfill the request. +* The default limit for posts should be 5 unless specified otherwise. +* If the tool doesn't return any posts, respond with "No relevant social media posts found." + +**REPORTING REQUIREMENT:** +1. **Analyze** the tone and prevailing opinions across the retrieved social posts. +2. **Summarize** the overall **community sentiment** (e.g., high enthusiasm/FOMO, uncertainty, FUD/fear) based on the content. +3. **Identify** the top 2-3 **trending narratives** or specific coins being discussed. +4. **Output** a single, brief report summarizing these findings. Do not output the raw posts. """ \ No newline at end of file diff --git a/src/app/social/reddit.py b/src/app/social/reddit.py index 7a3c824..730f862 100644 --- a/src/app/social/reddit.py +++ b/src/app/social/reddit.py @@ -30,6 +30,7 @@ class RedditWrapper(SocialWrapper): 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 """ @@ -46,7 +47,7 @@ class RedditWrapper(SocialWrapper): user_agent="upo-appAI", ) - def get_top_crypto_posts(self, limit=5) -> list[SocialPost]: + def get_top_crypto_posts(self, limit:int = 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/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py index df86c36..ecc0e11 100644 --- a/src/app/utils/wrapper_handler.py +++ b/src/app/utils/wrapper_handler.py @@ -1,6 +1,6 @@ import time from typing import TypeVar, Callable, Generic, Iterable, Type -from agno.utils.log import log_warning +from agno.utils.log import log_warning, log_info W = TypeVar("W") T = TypeVar("T") @@ -46,17 +46,19 @@ class WrapperHandler(Generic[W]): while iterations < len(self.wrappers): try: wrapper = self.wrappers[self.index] + log_info(f"Trying wrapper: {wrapper} - function {func}") result = func(wrapper) self.retry_count = 0 return result except Exception as e: self.retry_count += 1 + log_warning(f"{wrapper} failed {self.retry_count}/{self.retry_per_wrapper}: {e}") + 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") @@ -74,6 +76,7 @@ class WrapperHandler(Generic[W]): Exception: If all wrappers fail. """ results = {} + log_info(f"All wrappers: {[wrapper.__class__ for wrapper in self.wrappers]} - function {func}") for wrapper in self.wrappers: try: result = func(wrapper) diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index 4770977..d95d928 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -14,6 +14,15 @@ class FailingWrapper(MockWrapper): raise Exception("Intentional Failure") +class MockWrapperWithParameters: + def do_something(self, param1: str, param2: int) -> str: + return f"Success {param1} and {param2}" + +class FailingWrapperWithParameters(MockWrapperWithParameters): + def do_something(self, param1: str, param2: int): + raise Exception("Intentional Failure") + + @pytest.mark.wrapper class TestWrapperHandler: def test_all_wrappers_fail(self): @@ -88,3 +97,13 @@ class TestWrapperHandler: 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) + + + def test_wrappers_with_parameters(self): + wrappers = [FailingWrapperWithParameters, MockWrapperWithParameters] + handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) + + result = handler.try_call(lambda w: w.do_something("test", 42)) + assert result == "Success test and 42" + assert handler.index == 1 # Should have switched to the second wrapper + assert handler.retry_count == 0 From e4e7023c1746bc2cf6de2ccfbaf37f564cc7c674 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Wed, 1 Oct 2025 10:26:21 +0200 Subject: [PATCH 25/33] Refactor news API methods to use 'limit' parameter instead of 'total' for consistency across wrappers --- src/app/news/__init__.py | 8 ++++---- src/app/news/base.py | 10 +++++----- src/app/news/cryptopanic_api.py | 8 ++++---- src/app/news/duckduckgo.py | 8 ++++---- src/app/news/googlenews.py | 8 ++++---- src/app/news/news_api.py | 17 +++++++++------- tests/api/test_cryptopanic_api.py | 2 +- tests/api/test_duckduckgo_news.py | 4 ++-- tests/api/test_google_news.py | 4 ++-- tests/api/test_news_api.py | 4 ++-- tests/tools/test_news_tool.py | 8 ++++---- tests/tools/test_socials_tool.py | 32 +++++++++++++++++++++++++++++++ 12 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 tests/tools/test_socials_tool.py diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 115bf52..080c3ef 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -47,10 +47,10 @@ class NewsAPIsTool(NewsWrapper, Toolkit): # TODO Pensare se ha senso restituire gli articoli da TUTTI i wrapper o solo dal primo che funziona # la modifica รจ banale, basta usare try_call_all invece di try_call - 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)) + def get_top_headlines(self, limit: int = 100) -> list[Article]: + return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit)) + def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: + return self.wrapper_handler.try_call(lambda w: w.get_latest_news(query, limit)) NEWS_INSTRUCTIONS = """ diff --git a/src/app/news/base.py b/src/app/news/base.py index 0a8f6be..55a35ee 100644 --- a/src/app/news/base.py +++ b/src/app/news/base.py @@ -12,22 +12,22 @@ class NewsWrapper: All news API wrappers should inherit from this class and implement the methods. """ - def get_top_headlines(self, total: int = 100) -> list[Article]: + def get_top_headlines(self, limit: int = 100) -> list[Article]: """ - Get top headlines, optionally limited by total. + Get top headlines, optionally limited by limit. Args: - total (int): The maximum number of articles to return. + limit (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]: + def get_latest_news(self, query: str, limit: 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. + limit (int): The maximum number of articles to return. Returns: list[Article]: A list of Article objects. """ diff --git a/src/app/news/cryptopanic_api.py b/src/app/news/cryptopanic_api.py index a949c69..629c7aa 100644 --- a/src/app/news/cryptopanic_api.py +++ b/src/app/news/cryptopanic_api.py @@ -62,10 +62,10 @@ class CryptoPanicWrapper(NewsWrapper): 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_top_headlines(self, limit: int = 100) -> list[Article]: + return self.get_latest_news("", limit) # same endpoint so just call the other method - def get_latest_news(self, query: str, total: int = 100) -> list[Article]: + def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: params = self.get_base_params() params['currencies'] = query @@ -74,4 +74,4 @@ class CryptoPanicWrapper(NewsWrapper): json_response = response.json() articles = get_articles(json_response) - return articles[:total] + return articles[:limit] diff --git a/src/app/news/duckduckgo.py b/src/app/news/duckduckgo.py index 3a7c0bf..c3e1a6d 100644 --- a/src/app/news/duckduckgo.py +++ b/src/app/news/duckduckgo.py @@ -20,13 +20,13 @@ class DuckDuckGoWrapper(NewsWrapper): 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) + def get_top_headlines(self, limit: int = 100) -> list[Article]: + results = self.tool.duckduckgo_news(self.query, max_results=limit) 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) + def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: + results = self.tool.duckduckgo_news(query or self.query, max_results=limit) json_results = json.loads(results) return [create_article(result) for result in json_results] diff --git a/src/app/news/googlenews.py b/src/app/news/googlenews.py index 2e35f46..d8f6421 100644 --- a/src/app/news/googlenews.py +++ b/src/app/news/googlenews.py @@ -15,8 +15,8 @@ class GoogleNewsWrapper(NewsWrapper): 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') + def get_top_headlines(self, limit: int = 100) -> list[Article]: + gnews = GNews(language='en', max_results=limit, period='7d') results = gnews.get_top_news() articles = [] @@ -25,8 +25,8 @@ class GoogleNewsWrapper(NewsWrapper): 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') + def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: + gnews = GNews(language='en', max_results=limit, period='7d') results = gnews.get_news(query) articles = [] diff --git a/src/app/news/news_api.py b/src/app/news/news_api.py index 0e6d684..415fdac 100644 --- a/src/app/news/news_api.py +++ b/src/app/news/news_api.py @@ -26,22 +26,25 @@ class NewsApiWrapper(NewsWrapper): 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) + def __calc_pages(self, limit: int, page_size: int) -> tuple[int, int]: + page_size = min(self.max_page_size, limit) + pages = (limit // page_size) + (1 if limit % page_size > 0 else 0) + return pages, page_size + def get_top_headlines(self, limit: int = 100) -> list[Article]: + pages, page_size = self.__calc_pages(limit, self.max_page_size) 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) - + def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: + pages, page_size = self.__calc_pages(limit, self.max_page_size) 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", [])] diff --git a/tests/api/test_cryptopanic_api.py b/tests/api/test_cryptopanic_api.py index c8020d3..3c29bdb 100644 --- a/tests/api/test_cryptopanic_api.py +++ b/tests/api/test_cryptopanic_api.py @@ -15,7 +15,7 @@ class TestCryptoPanicAPI: def test_crypto_panic_api_get_latest_news(self): crypto = CryptoPanicWrapper() - articles = crypto.get_latest_news(query="", total=2) + articles = crypto.get_latest_news(query="", limit=2) assert isinstance(articles, list) assert len(articles) == 2 for article in articles: diff --git a/tests/api/test_duckduckgo_news.py b/tests/api/test_duckduckgo_news.py index e0bb599..f1de9c6 100644 --- a/tests/api/test_duckduckgo_news.py +++ b/tests/api/test_duckduckgo_news.py @@ -12,7 +12,7 @@ class TestDuckDuckGoNews: def test_duckduckgo_get_latest_news(self): news = DuckDuckGoWrapper() - articles = news.get_latest_news(query="crypto", total=2) + articles = news.get_latest_news(query="crypto", limit=2) assert isinstance(articles, list) assert len(articles) == 2 for article in articles: @@ -23,7 +23,7 @@ class TestDuckDuckGoNews: def test_duckduckgo_get_top_headlines(self): news = DuckDuckGoWrapper() - articles = news.get_top_headlines(total=2) + articles = news.get_top_headlines(limit=2) assert isinstance(articles, list) assert len(articles) == 2 for article in articles: diff --git a/tests/api/test_google_news.py b/tests/api/test_google_news.py index c7750f3..0b7241c 100644 --- a/tests/api/test_google_news.py +++ b/tests/api/test_google_news.py @@ -12,7 +12,7 @@ class TestGoogleNews: def test_gnews_api_get_latest_news(self): gnews_api = GoogleNewsWrapper() - articles = gnews_api.get_latest_news(query="crypto", total=2) + articles = gnews_api.get_latest_news(query="crypto", limit=2) assert isinstance(articles, list) assert len(articles) == 2 for article in articles: @@ -23,7 +23,7 @@ class TestGoogleNews: def test_gnews_api_get_top_headlines(self): news_api = GoogleNewsWrapper() - articles = news_api.get_top_headlines(total=2) + articles = news_api.get_top_headlines(limit=2) assert isinstance(articles, list) assert len(articles) == 2 for article in articles: diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 927419b..4b6b192 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -14,7 +14,7 @@ class TestNewsAPI: def test_news_api_get_latest_news(self): news_api = NewsApiWrapper() - articles = news_api.get_latest_news(query="crypto", total=2) + articles = news_api.get_latest_news(query="crypto", limit=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: @@ -26,7 +26,7 @@ class TestNewsAPI: def test_news_api_get_top_headlines(self): news_api = NewsApiWrapper() - articles = news_api.get_top_headlines(total=2) + articles = news_api.get_top_headlines(limit=2) assert isinstance(articles, list) # assert len(articles) > 0 # apparently it doesn't always return SOME articles for article in articles: diff --git a/tests/tools/test_news_tool.py b/tests/tools/test_news_tool.py index 71dd51f..14d142f 100644 --- a/tests/tools/test_news_tool.py +++ b/tests/tools/test_news_tool.py @@ -13,7 +13,7 @@ class TestNewsAPITool: def test_news_api_tool_get_top(self): tool = NewsAPIsTool() - result = tool.wrapper_handler.try_call(lambda w: w.get_top_headlines(total=2)) + result = tool.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, list) assert len(result) > 0 for article in result: @@ -22,7 +22,7 @@ class TestNewsAPITool: def test_news_api_tool_get_latest(self): tool = NewsAPIsTool() - result = tool.wrapper_handler.try_call(lambda w: w.get_latest_news(query="crypto", total=2)) + result = tool.wrapper_handler.try_call(lambda w: w.get_latest_news(query="crypto", limit=2)) assert isinstance(result, list) assert len(result) > 0 for article in result: @@ -31,7 +31,7 @@ class TestNewsAPITool: def test_news_api_tool_get_top__all_results(self): tool = NewsAPIsTool() - result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(total=2)) + result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 print("Results from providers:", result.keys()) @@ -43,7 +43,7 @@ class TestNewsAPITool: def test_news_api_tool_get_latest__all_results(self): tool = NewsAPIsTool() - result = tool.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query="crypto", total=2)) + result = tool.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query="crypto", limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 print("Results from providers:", result.keys()) diff --git a/tests/tools/test_socials_tool.py b/tests/tools/test_socials_tool.py new file mode 100644 index 0000000..9c66afa --- /dev/null +++ b/tests/tools/test_socials_tool.py @@ -0,0 +1,32 @@ +import pytest +from app.social import SocialAPIsTool + + +@pytest.mark.tools +@pytest.mark.social +@pytest.mark.api +class TestSocialAPIsTool: + def test_social_api_tool(self): + tool = SocialAPIsTool() + assert tool is not None + + def test_social_api_tool_get_top(self): + tool = SocialAPIsTool() + result = tool.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit=2)) + assert isinstance(result, list) + assert len(result) > 0 + for post in result: + assert post.title is not None + assert post.time is not None + + def test_social_api_tool_get_top__all_results(self): + tool = SocialAPIsTool() + result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_crypto_posts(limit=2)) + assert isinstance(result, dict) + assert len(result.keys()) > 0 + print("Results from providers:", result.keys()) + for provider, posts in result.items(): + for post in posts: + print(provider, post.title) + assert post.title is not None + assert post.time is not None From 73dcbbe12b7f72ddfa4bc21d43b47b8d12eafedc Mon Sep 17 00:00:00 2001 From: Berack96 Date: Wed, 1 Oct 2025 11:05:44 +0200 Subject: [PATCH 26/33] Refactor WrapperHandler - validation checks for initialization logic - fix SocialAPIsTool - fix RedditWrapper --- src/app/social/__init__.py | 4 ++-- src/app/social/base.py | 1 + src/app/social/reddit.py | 14 +++++++------- src/app/utils/wrapper_handler.py | 8 ++++++++ tests/utils/test_wrapper_handler.py | 23 +++++++++++++++++++++++ 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/app/social/__init__.py b/src/app/social/__init__.py index de83e63..9ce3708 100644 --- a/src/app/social/__init__.py +++ b/src/app/social/__init__.py @@ -26,7 +26,7 @@ class SocialAPIsTool(SocialWrapper, Toolkit): """ wrappers = [RedditWrapper] - self.wrapper_handler: WrapperHandler[SocialWrapper] = WrapperHandler(wrappers) + self.wrapper_handler: WrapperHandler[SocialWrapper] = WrapperHandler.build_wrappers(wrappers) Toolkit.__init__( self, @@ -38,7 +38,7 @@ class SocialAPIsTool(SocialWrapper, Toolkit): # TODO Pensare se ha senso restituire i post da TUTTI i wrapper o solo dal primo che funziona # la modifica รจ banale, basta usare try_call_all invece di try_call - def get_top_crypto_posts(self, limit:int = 5) -> list[SocialPost]: + def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: return self.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit)) diff --git a/src/app/social/base.py b/src/app/social/base.py index 1b66c1d..dd894f5 100644 --- a/src/app/social/base.py +++ b/src/app/social/base.py @@ -17,6 +17,7 @@ class SocialWrapper: Base class for social media API wrappers. All social media API wrappers should inherit from this class and implement the methods. """ + def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: """ Get top cryptocurrency-related posts, optionally limited by total. diff --git a/src/app/social/reddit.py b/src/app/social/reddit.py index 730f862..8f3867f 100644 --- a/src/app/social/reddit.py +++ b/src/app/social/reddit.py @@ -35,19 +35,19 @@ class RedditWrapper(SocialWrapper): """ 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" + client_id = os.getenv("REDDIT_API_CLIENT_ID") + assert 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" + client_secret = os.getenv("REDDIT_API_CLIENT_SECRET") + assert 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, + client_id=client_id, + client_secret=client_secret, user_agent="upo-appAI", ) - def get_top_crypto_posts(self, limit:int = 5) -> list[SocialPost]: + def get_top_crypto_posts(self, limit: int = 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/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py index ecc0e11..7d16c6c 100644 --- a/src/app/utils/wrapper_handler.py +++ b/src/app/utils/wrapper_handler.py @@ -24,6 +24,8 @@ class WrapperHandler(Generic[W]): try_per_wrapper (int): Number of retries per wrapper before switching to the next. retry_delay (int): Delay in seconds between retries. """ + assert not WrapperHandler.__check(wrappers), "All wrappers must be instances of their respective classes. Use `build_wrappers` to create the WrapperHandler." + self.wrappers = wrappers self.retry_per_wrapper = try_per_wrapper self.retry_delay = retry_delay @@ -87,6 +89,10 @@ class WrapperHandler(Generic[W]): raise Exception("All wrappers failed") return results + @staticmethod + def __check(wrappers: list[W]) -> bool: + return all(w.__class__ is type for w in wrappers) + @staticmethod def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2) -> 'WrapperHandler[W]': """ @@ -102,6 +108,8 @@ class WrapperHandler(Generic[W]): Raises: Exception: If no wrappers could be initialized. """ + assert WrapperHandler.__check(constructors), f"All constructors must be classes. Received: {constructors}" + result = [] for wrapper_class in constructors: try: diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index d95d928..154d3dc 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -25,6 +25,29 @@ class FailingWrapperWithParameters(MockWrapperWithParameters): @pytest.mark.wrapper class TestWrapperHandler: + def test_init_failing(self): + with pytest.raises(AssertionError) as exc_info: + WrapperHandler([MockWrapper, MockWrapper2]) + assert exc_info.type == AssertionError + + def test_init_failing_empty(self): + with pytest.raises(AssertionError) as exc_info: + WrapperHandler.build_wrappers([]) + assert exc_info.type == AssertionError + + def test_init_failing_with_instances(self): + with pytest.raises(AssertionError) as exc_info: + WrapperHandler.build_wrappers([MockWrapper(), MockWrapper2()]) + assert exc_info.type == AssertionError + + def test_init_not_failing(self): + handler = WrapperHandler.build_wrappers([MockWrapper, MockWrapper2]) + assert handler is not None + assert len(handler.wrappers) == 2 + handler = WrapperHandler([MockWrapper(), MockWrapper2()]) + assert handler is not None + assert len(handler.wrappers) == 2 + def test_all_wrappers_fail(self): wrappers = [FailingWrapper, FailingWrapper] handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) From 2aa97e264aeeb7b07dec8f444ed3c76a7fceff18 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Wed, 1 Oct 2025 12:49:43 +0200 Subject: [PATCH 27/33] Enhance error logging in WrapperHandler to provide concise traceback information --- src/app/utils/wrapper_handler.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py index 7d16c6c..4f22a8e 100644 --- a/src/app/utils/wrapper_handler.py +++ b/src/app/utils/wrapper_handler.py @@ -1,4 +1,5 @@ import time +import traceback from typing import TypeVar, Callable, Generic, Iterable, Type from agno.utils.log import log_warning, log_info @@ -54,7 +55,7 @@ class WrapperHandler(Generic[W]): return result except Exception as e: self.retry_count += 1 - log_warning(f"{wrapper} failed {self.retry_count}/{self.retry_per_wrapper}: {e}") + log_warning(f"{wrapper} failed {self.retry_count}/{self.retry_per_wrapper}: {WrapperHandler.__concise_error(e)}") if self.retry_count >= self.retry_per_wrapper: self.index = (self.index + 1) % len(self.wrappers) @@ -84,7 +85,7 @@ class WrapperHandler(Generic[W]): result = func(wrapper) results[wrapper.__class__] = result except Exception as e: - log_warning(f"{wrapper} failed: {e}") + log_warning(f"{wrapper} failed: {WrapperHandler.__concise_error(e)}") if not results: raise Exception("All wrappers failed") return results @@ -93,6 +94,10 @@ class WrapperHandler(Generic[W]): def __check(wrappers: list[W]) -> bool: return all(w.__class__ is type for w in wrappers) + def __concise_error(e: Exception) -> str: + last_frame = traceback.extract_tb(e.__traceback__)[-1] + return f"{e} [\"{last_frame.filename}\", line {last_frame.lineno}]" + @staticmethod def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2) -> 'WrapperHandler[W]': """ From 31057007fb6b6651ee951b92d474019f92059bbb Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Wed, 1 Oct 2025 13:01:51 +0200 Subject: [PATCH 28/33] Finito ISSUE 3 --- demos/market_providers_api_demo.py | 2 +- pyproject.toml | 1 - src/app/markets/__init__.py | 93 ++++++++-- src/app/markets/binance_public.py | 9 - src/app/markets/error_handler.py | 237 ------------------------ src/app/pipeline.py | 1 - src/app/toolkits/market_toolkit.py | 4 +- src/app/utils/aggregated_models.py | 184 ++++++++++++++++++ src/app/utils/market_data_aggregator.py | 184 ++++++++++++++++++ tests/agents/test_market.py | 14 +- tests/test_market_data_aggregator.py | 90 +++++++++ 11 files changed, 547 insertions(+), 272 deletions(-) delete mode 100644 src/app/markets/error_handler.py create mode 100644 src/app/utils/aggregated_models.py create mode 100644 src/app/utils/market_data_aggregator.py create mode 100644 tests/test_market_data_aggregator.py diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py index 16817d5..2c3a8f3 100644 --- a/demos/market_providers_api_demo.py +++ b/demos/market_providers_api_demo.py @@ -26,7 +26,7 @@ project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root / "src")) from dotenv import load_dotenv -from src.app.markets import ( +from app.markets import ( CoinBaseWrapper, CryptoCompareWrapper, BinanceWrapper, diff --git a/pyproject.toml b/pyproject.toml index 74c0029..e091aba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ dependencies = [ # API di social media "praw", # Reddit ] - [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index 57f04b7..e5853d5 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,31 +1,96 @@ -from .base import BaseWrapper +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 - -__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper" ] +from typing import List, Optional +from agno.tools import Toolkit -# TODO se si vuole usare un aggregatore di dati di mercato, si puรฒ aggiungere qui facendo una classe extra (simile a questa) che per ogni chiamata chiama tutti i wrapper e aggrega i risultati -class MarketAPIs(BaseWrapper): +__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "PublicBinanceAgent" ] + + +class MarketAPIsTool(BaseWrapper, Toolkit): """ Classe per gestire le API di mercato disponibili. - Permette di ottenere un'istanza della prima API disponibile in base alla prioritร  specificata. - Supporta operazioni come ottenere informazioni su singoli prodotti, liste di prodotti e dati storici. - Usa un WrapperHandler per gestire piรน wrapper e tentare chiamate in modo resiliente. + + Supporta due modalitร : + 1. **Modalitร  standard** (default): usa il primo wrapper disponibile + 2. **Modalitร  aggregazione**: aggrega dati da tutte le fonti disponibili + + L'aggregazione puรฒ essere abilitata/disabilitata dinamicamente. """ - def __init__(self, currency: str = "USD"): + def __init__(self, currency: str = "USD", enable_aggregation: bool = False): self.currency = currency - wrappers = [ CoinBaseWrapper, CryptoCompareWrapper ] + 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 - def get_product(self, asset_id): + def get_product(self, asset_id: str) -> Optional[ProductInfo]: + """Ottieni informazioni su un prodotto specifico""" + if self._aggregation_enabled: + return self._get_aggregator().get_product(asset_id) return self.wrappers.try_call(lambda w: w.get_product(asset_id)) - def get_products(self, asset_ids: list): + + def get_products(self, asset_ids: List[str]) -> List[ProductInfo]: + """Ottieni informazioni su multiple prodotti""" + if self._aggregation_enabled: + return self._get_aggregator().get_products(asset_ids) return self.wrappers.try_call(lambda w: w.get_products(asset_ids)) - def get_all_products(self): + + def get_all_products(self) -> List[ProductInfo]: + """Ottieni tutti i prodotti disponibili""" + if self._aggregation_enabled: + return self._get_aggregator().get_all_products() return self.wrappers.try_call(lambda w: w.get_all_products()) - def get_historical_prices(self, asset_id = "BTC", limit: int = 100): + + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> List[Price]: + """Ottieni dati storici dei prezzi""" + if self._aggregation_enabled: + return self._get_aggregator().get_historical_prices(asset_id, limit) return self.wrappers.try_call(lambda w: w.get_historical_prices(asset_id, limit)) + + # Metodi per controllare l'aggregazione + def enable_aggregation(self, enabled: bool = True): + """Abilita/disabilita la modalitร  aggregazione""" + self._aggregation_enabled = enabled + if self._aggregator: + self._aggregator.enable_aggregation(enabled) + + def is_aggregation_enabled(self) -> bool: + """Verifica se l'aggregazione รจ abilitata""" + return self._aggregation_enabled + + # Metodo speciale per debugging (opzionale) + def get_aggregated_product_with_debug(self, asset_id: str) -> dict: + """ + Metodo speciale per ottenere dati aggregati con informazioni di debug. + Disponibile solo quando l'aggregazione รจ abilitata. + """ + if not self._aggregation_enabled: + raise RuntimeError("L'aggregazione deve essere abilitata per usare questo metodo") + return self._get_aggregator().get_aggregated_product_with_debug(asset_id) diff --git a/src/app/markets/binance_public.py b/src/app/markets/binance_public.py index 598840b..c1d9896 100644 --- a/src/app/markets/binance_public.py +++ b/src/app/markets/binance_public.py @@ -9,7 +9,6 @@ from typing import Optional, Dict, Any from datetime import datetime, timedelta from binance.client import Client from .base import BaseWrapper, ProductInfo, Price -from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError class PublicBinanceAgent(BaseWrapper): @@ -38,8 +37,6 @@ class PublicBinanceAgent(BaseWrapper): return asset_id return f"{asset_id}USDT" - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_product(self, asset_id: str) -> ProductInfo: """ Ottiene informazioni su un singolo prodotto. @@ -59,8 +56,6 @@ class PublicBinanceAgent(BaseWrapper): print(f"Errore nel recupero del prodotto {asset_id}: {e}") return ProductInfo(id=asset_id, symbol=asset_id) - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: """ Ottiene informazioni su piรน prodotti. @@ -77,8 +72,6 @@ class PublicBinanceAgent(BaseWrapper): products.append(product) return products - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_all_products(self) -> list[ProductInfo]: """ Ottiene informazioni su tutti i prodotti disponibili. @@ -90,8 +83,6 @@ class PublicBinanceAgent(BaseWrapper): major_assets = ["BTC", "ETH", "BNB", "ADA", "DOT", "LINK", "LTC", "XRP"] return self.get_products(major_assets) - @retry_on_failure(max_retries=3, delay=1.0) - @handle_api_errors def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]: """ Ottiene i prezzi storici per un asset. diff --git a/src/app/markets/error_handler.py b/src/app/markets/error_handler.py deleted file mode 100644 index c98301a..0000000 --- a/src/app/markets/error_handler.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -Modulo per la gestione robusta degli errori nei market providers. - -Fornisce decoratori e utilitร  per: -- Retry automatico con backoff esponenziale -- Logging standardizzato degli errori -- Gestione di timeout e rate limiting -- Fallback tra provider multipli -""" - -import time -import logging -from functools import wraps -from typing import Any, Callable, Optional, Type, Union, List -from requests.exceptions import RequestException, Timeout, ConnectionError -from binance.exceptions import BinanceAPIException, BinanceRequestException -from .base import ProductInfo - -# Configurazione logging -logger = logging.getLogger(__name__) - -class MarketAPIError(Exception): - """Eccezione base per errori delle API di mercato.""" - pass - -class RateLimitError(MarketAPIError): - """Eccezione per errori di rate limiting.""" - pass - -class AuthenticationError(MarketAPIError): - """Eccezione per errori di autenticazione.""" - pass - -class DataNotFoundError(MarketAPIError): - """Eccezione quando i dati richiesti non sono disponibili.""" - pass - -def retry_on_failure( - max_retries: int = 3, - delay: float = 1.0, - backoff_factor: float = 2.0, - exceptions: tuple = (RequestException, BinanceAPIException, BinanceRequestException) -) -> Callable: - """ - Decoratore per retry automatico con backoff esponenziale. - - Args: - max_retries: Numero massimo di tentativi - delay: Delay iniziale in secondi - backoff_factor: Fattore di moltiplicazione per il delay - exceptions: Tuple di eccezioni da catturare per il retry - - Returns: - Decoratore per la funzione - """ - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs) -> Any: - last_exception = None - current_delay = delay - - for attempt in range(max_retries + 1): - try: - return func(*args, **kwargs) - except exceptions as e: - last_exception = e - - if attempt == max_retries: - logger.error( - f"Function {func.__name__} failed after {max_retries + 1} attempts. " - f"Last error: {str(e)}" - ) - raise MarketAPIError(f"Max retries exceeded: {str(e)}") from e - - logger.warning( - f"Attempt {attempt + 1}/{max_retries + 1} failed for {func.__name__}: {str(e)}. " - f"Retrying in {current_delay:.1f}s..." - ) - - time.sleep(current_delay) - current_delay *= backoff_factor - except Exception as e: - # Per eccezioni non previste, non fare retry - logger.error(f"Unexpected error in {func.__name__}: {str(e)}") - raise - - # Questo non dovrebbe mai essere raggiunto - if last_exception: - raise last_exception - else: - raise MarketAPIError("Unknown error occurred") - - return wrapper - return decorator - -def handle_api_errors(func: Callable) -> Callable: - """ - Decoratore per gestione standardizzata degli errori API. - - Converte errori specifici dei provider in eccezioni standardizzate. - """ - @wraps(func) - def wrapper(*args, **kwargs) -> Any: - try: - return func(*args, **kwargs) - except BinanceAPIException as e: - if e.code == -1021: # Timestamp error - raise MarketAPIError(f"Binance timestamp error: {e.message}") - elif e.code == -1003: # Rate limit - raise RateLimitError(f"Binance rate limit exceeded: {e.message}") - elif e.code in [-2014, -2015]: # API key errors - raise AuthenticationError(f"Binance authentication error: {e.message}") - else: - raise MarketAPIError(f"Binance API error [{e.code}]: {e.message}") - except ConnectionError as e: - raise MarketAPIError(f"Connection error: {str(e)}") - except Timeout as e: - raise MarketAPIError(f"Request timeout: {str(e)}") - except RequestException as e: - raise MarketAPIError(f"Request error: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error in {func.__name__}: {str(e)}") - raise MarketAPIError(f"Unexpected error: {str(e)}") from e - - return wrapper - -def safe_execute( - func: Callable, - default_value: Any = None, - log_errors: bool = True, - error_message: Optional[str] = None -) -> Any: - """ - Esegue una funzione in modo sicuro, restituendo un valore di default in caso di errore. - - Args: - func: Funzione da eseguire - default_value: Valore da restituire in caso di errore - log_errors: Se loggare gli errori - error_message: Messaggio di errore personalizzato - - Returns: - Risultato della funzione o valore di default - """ - try: - return func() - except Exception as e: - if log_errors: - message = error_message or f"Error executing {func.__name__}" - logger.warning(f"{message}: {str(e)}") - return default_value - -class ProviderFallback: - """ - Classe per gestire il fallback tra provider multipli. - """ - - def __init__(self, providers: List[Any]): - """ - Inizializza con una lista di provider ordinati per prioritร . - - Args: - providers: Lista di provider ordinati per prioritร  - """ - self.providers = providers - - def execute_with_fallback( - self, - method_name: str, - *args, - **kwargs - ) -> list[ProductInfo]: - """ - Esegue un metodo su tutti i provider fino a trovarne uno che funziona. - - Args: - method_name: Nome del metodo da chiamare - *args: Argomenti posizionali - **kwargs: Argomenti nominali - - Returns: - Risultato del primo provider che funziona - - Raises: - MarketAPIError: Se tutti i provider falliscono - """ - last_error = None - - for i, provider in enumerate(self.providers): - try: - if hasattr(provider, method_name): - method = getattr(provider, method_name) - result = method(*args, **kwargs) - - if i > 0: # Se non รจ il primo provider - logger.info(f"Fallback successful: used provider {type(provider).__name__}") - - return result - else: - logger.warning(f"Provider {type(provider).__name__} doesn't have method {method_name}") - continue - - except Exception as e: - last_error = e - logger.warning( - f"Provider {type(provider).__name__} failed for {method_name}: {str(e)}" - ) - continue - - # Se arriviamo qui, tutti i provider hanno fallito - raise MarketAPIError( - f"All providers failed for method {method_name}. Last error: {str(last_error)}" - ) - -def validate_response_data(data: Any, required_fields: Optional[List[str]] = None) -> bool: - """ - Valida che i dati di risposta contengano i campi richiesti. - - Args: - data: Dati da validare - required_fields: Lista di campi richiesti - - Returns: - True se i dati sono validi, False altrimenti - """ - if data is None: - return False - - if required_fields is None: - return True - - if isinstance(data, dict): - return all(field in data for field in required_fields) - elif hasattr(data, '__dict__'): - return all(hasattr(data, field) for field in required_fields) - - return False \ No newline at end of file diff --git a/src/app/pipeline.py b/src/app/pipeline.py index f515053..7a440de 100644 --- a/src/app/pipeline.py +++ b/src/app/pipeline.py @@ -6,7 +6,6 @@ from agno.utils.log import log_info from app.agents.market_agent import MarketAgent from app.agents.news_agent import NewsAgent from app.agents.social_agent import SocialAgent -from app.markets import MarketAPIs from app.models import AppModels from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS diff --git a/src/app/toolkits/market_toolkit.py b/src/app/toolkits/market_toolkit.py index ff6e48d..61a4d9f 100644 --- a/src/app/toolkits/market_toolkit.py +++ b/src/app/toolkits/market_toolkit.py @@ -1,5 +1,5 @@ from agno.tools import Toolkit -from app.markets import MarketAPIs +from app.markets import MarketAPIsTool # TODO (?) in futuro fare in modo che la LLM faccia da sรฉ per il mercato @@ -9,7 +9,7 @@ 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", 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_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/tests/agents/test_market.py b/tests/agents/test_market.py index 5fef76e..10e5cb4 100644 --- a/tests/agents/test_market.py +++ b/tests/agents/test_market.py @@ -1,12 +1,12 @@ import os import pytest from app.agents.market_agent import MarketToolkit -from app.markets import MarketAPIs +from app.markets import MarketAPIsTool @pytest.mark.limited # usa molte api calls e non voglio esaurire le chiavi api -class TestMarketAPIs: +class TestMarketAPIsTool: def test_wrapper_initialization(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") assert market_wrapper is not None assert hasattr(market_wrapper, 'get_product') assert hasattr(market_wrapper, 'get_products') @@ -14,7 +14,7 @@ class TestMarketAPIs: assert hasattr(market_wrapper, 'get_historical_prices') def test_wrapper_capabilities(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") capabilities = [] if hasattr(market_wrapper, 'get_product'): capabilities.append('single_product') @@ -25,7 +25,7 @@ class TestMarketAPIs: assert len(capabilities) > 0 def test_market_data_retrieval(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") btc_product = market_wrapper.get_product("BTC") assert btc_product is not None assert hasattr(btc_product, 'symbol') @@ -55,14 +55,14 @@ class TestMarketAPIs: def test_error_handling(self): try: - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") fake_product = market_wrapper.get_product("NONEXISTENT_CRYPTO_SYMBOL_12345") assert fake_product is None or fake_product.price == 0 except Exception as e: pass def test_wrapper_currency_support(self): - market_wrapper = MarketAPIs("USD") + market_wrapper = MarketAPIsTool("USD") assert hasattr(market_wrapper, 'currency') assert isinstance(market_wrapper.currency, str) assert len(market_wrapper.currency) >= 3 # USD, EUR, etc. diff --git a/tests/test_market_data_aggregator.py b/tests/test_market_data_aggregator.py new file mode 100644 index 0000000..604362c --- /dev/null +++ b/tests/test_market_data_aggregator.py @@ -0,0 +1,90 @@ +import pytest + +from app.utils.market_data_aggregator import MarketDataAggregator +from app.utils.aggregated_models import AggregatedProductInfo +from app.markets.base import ProductInfo, Price + + +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" + + +if __name__ == "__main__": + pytest.main([__file__]) \ No newline at end of file From 6ebd58bdb71c8729639171a146aaec4a6d0d8897 Mon Sep 17 00:00:00 2001 From: Berack96 Date: Wed, 1 Oct 2025 14:53:00 +0200 Subject: [PATCH 29/33] Fixes - MarketAPIs to include BinanceWrapper - update RedditWrapper & tests --- src/app/markets/__init__.py | 2 +- src/app/models.py | 3 ++- src/app/social/reddit.py | 20 +++++++++++++++++--- tests/api/test_reddit.py | 3 +-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index 57f04b7..bec8149 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -18,7 +18,7 @@ class MarketAPIs(BaseWrapper): def __init__(self, currency: str = "USD"): self.currency = currency - wrappers = [ CoinBaseWrapper, CryptoCompareWrapper ] + wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ] self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers) def get_product(self, asset_id): diff --git a/src/app/models.py b/src/app/models.py index 77b79b3..4cc591d 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -21,7 +21,8 @@ class AppModels(Enum): GEMINI_PRO = "gemini-2.0-pro" # API online, piรน costoso ma migliore OLLAMA_GPT = "gpt-oss:latest" # + good - slow (13b) OLLAMA_QWEN = "qwen3:latest" # + good + fast (8b) - OLLAMA_QWEN_4B = "qwen3:4b" # + fast - decent (4b) + OLLAMA_QWEN_4B = "qwen3:4b" # + fast + decent (4b) + OLLAMA_QWEN_1B = "qwen3:1.7b" # + very fast + decent (1.7b) @staticmethod def availables_local() -> list['AppModels']: diff --git a/src/app/social/reddit.py b/src/app/social/reddit.py index 8f3867f..6028010 100644 --- a/src/app/social/reddit.py +++ b/src/app/social/reddit.py @@ -4,6 +4,21 @@ from praw.models import Submission, MoreComments from .base import SocialWrapper, SocialPost, SocialComment MAX_COMMENTS = 5 +# TODO mettere piu' subreddit? +# scelti da https://lkiconsulting.io/marketing/best-crypto-subreddits/ +SUBREDDITS = [ + "CryptoCurrency", + "Bitcoin", + "Ethereum", + "CryptoMarkets", + "Dogecoin", + "Altcoin", + "DeFi", + "NFT", + "BitcoinBeginners", + "CryptoTechnology", + "btc" # alt subs of Bitcoin +] def create_social_post(post: Submission) -> SocialPost: @@ -46,9 +61,8 @@ class RedditWrapper(SocialWrapper): client_secret=client_secret, user_agent="upo-appAI", ) + self.subreddits = self.tool.subreddit("+".join(SUBREDDITS)) def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: - subreddit = self.tool.subreddit("CryptoCurrency") - top_posts = subreddit.top(limit=limit, time_filter="week") + top_posts = self.subreddits.top(limit=limit, time_filter="week") return [create_social_post(post) for post in top_posts] - diff --git a/tests/api/test_reddit.py b/tests/api/test_reddit.py index 84c66da..81ab8ca 100644 --- a/tests/api/test_reddit.py +++ b/tests/api/test_reddit.py @@ -7,8 +7,7 @@ from app.social.reddit import MAX_COMMENTS, RedditWrapper class TestRedditWrapper: def test_initialization(self): wrapper = RedditWrapper() - assert wrapper.client_id is not None - assert wrapper.client_secret is not None + assert wrapper is not None assert isinstance(wrapper.tool, Reddit) def test_get_top_crypto_posts(self): From 42690acfbb886e34b81e39d0fb74f8fe178a75c8 Mon Sep 17 00:00:00 2001 From: Simone Garau <20005068@studenti.uniupo.it> Date: Wed, 1 Oct 2025 15:46:46 +0200 Subject: [PATCH 30/33] feat(markets): add yfinance integration for stock and crypto data - Add yfinance wrapper with support for stocks and cryptocurrencies - Update aggregated models to recognize yfinance products - Include yfinance in market APIs tool and demo script - Add comprehensive tests for yfinance functionality - Update dependencies to include yfinance and required packages --- demos/market_providers_api_demo.py | 9 + pyproject.toml | 1 + src/app/markets/__init__.py | 5 +- src/app/markets/binance.py | 4 +- src/app/markets/yfinance.py | 214 ++++++++++++++++++ src/app/utils/aggregated_models.py | 2 + src/app/utils/market_aggregator.py | 71 ------ src/app/utils/market_data_aggregator.py | 4 +- tests/api/test_yfinance.py | 93 ++++++++ .../test_market_data_aggregator.py | 2 +- uv.lock | 91 ++++++++ 11 files changed, 418 insertions(+), 78 deletions(-) create mode 100644 src/app/markets/yfinance.py delete mode 100644 src/app/utils/market_aggregator.py create mode 100644 tests/api/test_yfinance.py rename tests/{ => utils}/test_market_data_aggregator.py (99%) diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py index 2c3a8f3..c3718fa 100644 --- a/demos/market_providers_api_demo.py +++ b/demos/market_providers_api_demo.py @@ -8,6 +8,7 @@ Questo script dimostra l'utilizzo di tutti i wrapper che implementano BaseWrappe - CryptoCompareWrapper (richiede API key) - BinanceWrapper (richiede credenziali) - PublicBinanceAgent (accesso pubblico) +- YFinanceWrapper (accesso gratuito a dati azionari e crypto) Lo script effettua chiamate GET a diversi provider e visualizza i dati in modo strutturato con informazioni dettagliate su timestamp, stato @@ -31,6 +32,7 @@ from app.markets import ( CryptoCompareWrapper, BinanceWrapper, PublicBinanceAgent, + YFinanceWrapper, BaseWrapper ) @@ -276,6 +278,13 @@ def initialize_providers() -> Dict[str, BaseWrapper]: else: print("โš ๏ธ BinanceWrapper saltato: credenziali Binance non complete") + # YFinanceWrapper (sempre disponibile - dati azionari e crypto gratuiti) + try: + providers["YFinance"] = YFinanceWrapper() + print("โœ… YFinanceWrapper inizializzato con successo") + except Exception as e: + print(f"โŒ Errore nell'inizializzazione di YFinanceWrapper: {e}") + return providers def print_summary(results: List[Dict[str, Any]]): diff --git a/pyproject.toml b/pyproject.toml index e091aba..2e90e39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ # API di exchange di criptovalute "coinbase-advanced-py", "python-binance", + "yfinance", # API di notizie "newsapi-python", diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index e5853d5..eefc442 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -2,13 +2,14 @@ from .base import BaseWrapper, ProductInfo, Price from .coinbase import CoinBaseWrapper from .binance import BinanceWrapper from .cryptocompare import CryptoCompareWrapper +from .yfinance import YFinanceWrapper from .binance_public import PublicBinanceAgent from app.utils.wrapper_handler import WrapperHandler from typing import List, Optional from agno.tools import Toolkit -__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "PublicBinanceAgent" ] +__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "PublicBinanceAgent" ] class MarketAPIsTool(BaseWrapper, Toolkit): @@ -24,7 +25,7 @@ class MarketAPIsTool(BaseWrapper, Toolkit): def __init__(self, currency: str = "USD", enable_aggregation: bool = False): self.currency = currency - wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ] + wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper, YFinanceWrapper ] self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers) # Inizializza l'aggregatore solo se richiesto (lazy initialization) diff --git a/src/app/markets/binance.py b/src/app/markets/binance.py index 6b6b6d3..3d3619e 100644 --- a/src/app/markets/binance.py +++ b/src/app/markets/binance.py @@ -23,10 +23,10 @@ class BinanceWrapper(BaseWrapper): def __init__(self, currency: str = "USDT"): api_key = os.getenv("BINANCE_API_KEY") - assert api_key is not None, "API key is required" + assert api_key is None, "API key is required" api_secret = os.getenv("BINANCE_API_SECRET") - assert api_secret is not None, "API secret is required" + assert api_secret is None, "API secret is required" self.currency = currency self.client = Client(api_key=api_key, api_secret=api_secret) diff --git a/src/app/markets/yfinance.py b/src/app/markets/yfinance.py new file mode 100644 index 0000000..f0e5d6d --- /dev/null +++ b/src/app/markets/yfinance.py @@ -0,0 +1,214 @@ +import json +from agno.tools.yfinance import YFinanceTools +from .base import BaseWrapper, ProductInfo, Price + + +def create_product_info(symbol: str, stock_data: dict) -> ProductInfo: + """ + Converte i dati di YFinanceTools in ProductInfo. + """ + product = ProductInfo() + + # ID univoco per yfinance + product.id = f"yfinance_{symbol}" + product.symbol = symbol + + # Estrai il prezzo corrente - gestisci diversi formati + if 'currentPrice' in stock_data: + product.price = float(stock_data['currentPrice']) + elif 'regularMarketPrice' in stock_data: + product.price = float(stock_data['regularMarketPrice']) + elif 'Current Stock Price' in stock_data: + # Formato: "254.63 USD" - estrai solo il numero + price_str = stock_data['Current Stock Price'].split()[0] + try: + product.price = float(price_str) + except ValueError: + product.price = 0.0 + else: + product.price = 0.0 + + # Volume 24h + if 'volume' in stock_data: + product.volume_24h = float(stock_data['volume']) + elif 'regularMarketVolume' in stock_data: + product.volume_24h = float(stock_data['regularMarketVolume']) + else: + product.volume_24h = 0.0 + + # Status basato sulla disponibilitร  dei dati + product.status = "trading" if product.price > 0 else "offline" + + # Valuta (default USD) + product.quote_currency = stock_data.get('currency', 'USD') or 'USD' + + return product + + +def create_price_from_history(hist_data: dict, timestamp: str) -> Price: + """ + Converte i dati storici di YFinanceTools in Price. + """ + price = Price() + + if timestamp in hist_data: + day_data = hist_data[timestamp] + price.high = float(day_data.get('High', 0.0)) + price.low = float(day_data.get('Low', 0.0)) + price.open = float(day_data.get('Open', 0.0)) + price.close = float(day_data.get('Close', 0.0)) + price.volume = float(day_data.get('Volume', 0.0)) + price.time = timestamp + + return price + + +class YFinanceWrapper(BaseWrapper): + """ + Wrapper per YFinanceTools che fornisce dati di mercato per azioni, ETF e criptovalute. + Implementa l'interfaccia BaseWrapper per compatibilitร  con il sistema esistente. + Usa YFinanceTools dalla libreria agno per coerenza con altri wrapper. + """ + + def __init__(self, currency: str = "USD"): + self.currency = currency + # Inizializza YFinanceTools - non richiede parametri specifici + self.tool = YFinanceTools() + + def _format_symbol(self, asset_id: str) -> str: + """ + Formatta il simbolo per yfinance. + Per crypto, aggiunge '-USD' se non presente. + """ + asset_id = asset_id.upper() + + # Se รจ giร  nel formato corretto (es: BTC-USD), usa cosรฌ + if '-' in asset_id: + return asset_id + + # Per crypto singole (BTC, ETH), aggiungi -USD + if asset_id in ['BTC', 'ETH', 'ADA', 'SOL', 'DOT', 'LINK', 'UNI', 'AAVE']: + return f"{asset_id}-USD" + + # Per azioni, usa il simbolo cosรฌ com'รจ + return asset_id + + def get_product(self, asset_id: str) -> ProductInfo: + """ + Recupera le informazioni di un singolo prodotto. + """ + symbol = self._format_symbol(asset_id) + + # Usa YFinanceTools per ottenere i dati + try: + # Ottieni le informazioni base dello stock + stock_info = self.tool.get_company_info(symbol) + + # Se il risultato รจ una stringa JSON, parsala + if isinstance(stock_info, str): + try: + stock_data = json.loads(stock_info) + except json.JSONDecodeError: + # Se non รจ JSON valido, prova a ottenere solo il prezzo + price_data_str = self.tool.get_current_stock_price(symbol) + if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit(): + price = float(price_data_str) + stock_data = {'currentPrice': price, 'currency': 'USD'} + else: + raise Exception("Dati non validi") + else: + stock_data = stock_info + + return create_product_info(symbol, stock_data) + + except Exception as e: + # Fallback: prova a ottenere solo il prezzo + try: + price_data_str = self.tool.get_current_stock_price(symbol) + if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit(): + price = float(price_data_str) + minimal_data = { + 'currentPrice': price, + 'currency': 'USD' + } + return create_product_info(symbol, minimal_data) + else: + raise Exception("Prezzo non disponibile") + except Exception: + # Se tutto fallisce, restituisci un prodotto vuoto + product = ProductInfo() + product.symbol = symbol + product.status = "offline" + return product + + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: + """ + Recupera le informazioni di multiple assets. + """ + products = [] + + for asset_id in asset_ids: + try: + product = self.get_product(asset_id) + products.append(product) + except Exception as e: + # Se un asset non รจ disponibile, continua con gli altri + continue + + return products + + def get_all_products(self) -> list[ProductInfo]: + """ + Recupera tutti i prodotti disponibili. + Restituisce una lista predefinita di asset popolari. + """ + # Lista di asset popolari (azioni, ETF, crypto) + popular_assets = [ + 'BTC', 'ETH', 'ADA', 'SOL', 'DOT', + 'AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN', + 'SPY', 'QQQ', 'VTI', 'GLD', 'VIX' + ] + + return self.get_products(popular_assets) + + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + """ + Recupera i dati storici di prezzo per un asset. + """ + symbol = self._format_symbol(asset_id) + + try: + # Determina il periodo appropriato in base al limite + if limit <= 7: + period = "1d" + interval = "15m" + elif limit <= 30: + period = "5d" + interval = "1h" + elif limit <= 90: + period = "1mo" + interval = "1d" + else: + period = "3mo" + interval = "1d" + + # Ottieni i dati storici + hist_data = self.tool.get_historical_stock_prices(symbol, period=period, interval=interval) + + if isinstance(hist_data, str): + hist_data = json.loads(hist_data) + + # Il formato dei dati รจ {timestamp: {Open: x, High: y, Low: z, Close: w, Volume: v}} + prices = [] + timestamps = sorted(hist_data.keys())[-limit:] # Prendi gli ultimi 'limit' timestamp + + for timestamp in timestamps: + price = create_price_from_history(hist_data, timestamp) + if price.close > 0: # Solo se ci sono dati validi + prices.append(price) + + return prices + + except Exception as e: + # Se fallisce, restituisci lista vuota + return [] \ No newline at end of file diff --git a/src/app/utils/aggregated_models.py b/src/app/utils/aggregated_models.py index 8eba8a5..ee9f3ef 100644 --- a/src/app/utils/aggregated_models.py +++ b/src/app/utils/aggregated_models.py @@ -77,6 +77,8 @@ class AggregatedProductInfo(ProductInfo): return "binance" elif "crypto" in product.id.lower() or "cc" in product.id.lower(): return "cryptocompare" + elif "yfinance" in product.id.lower() or "yf" in product.id.lower(): + return "yfinance" else: return "unknown" diff --git a/src/app/utils/market_aggregator.py b/src/app/utils/market_aggregator.py deleted file mode 100644 index 639bb9b..0000000 --- a/src/app/utils/market_aggregator.py +++ /dev/null @@ -1,71 +0,0 @@ -import statistics -from typing import Dict, Any - -class MarketAggregator: - """ - Aggrega dati di mercato da piรน provider e genera segnali e analisi avanzate. - """ - @staticmethod - def aggregate(symbol: str, sources: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: - prices = [] - volumes = [] - price_map = {} - for provider, data in sources.items(): - price = data.get('price') - if price is not None: - prices.append(price) - price_map[provider] = price - volume = data.get('volume') - if volume is not None: - volumes.append(MarketAggregator._parse_volume(volume)) - - # Aggregated price (mean) - agg_price = statistics.mean(prices) if prices else None - # Spread analysis - spread = (max(prices) - min(prices)) / agg_price if prices and agg_price else 0 - # Confidence - stddev = statistics.stdev(prices) if len(prices) > 1 else 0 - confidence = max(0.5, 1 - (stddev / agg_price)) if agg_price else 0 - if spread < 0.005: - confidence += 0.1 - if len(prices) >= 3: - confidence += 0.05 - confidence = min(confidence, 1.0) - # Volume trend - total_volume = sum(volumes) if volumes else None - # Price divergence - max_deviation = (max(prices) - min(prices)) / agg_price if prices and agg_price else 0 - # Signals - signals = { - "spread_analysis": f"Low spread ({spread:.2%}) indicates healthy liquidity" if spread < 0.01 else f"Spread {spread:.2%} - check liquidity", - "volume_trend": f"Combined volume: {total_volume:.2f}" if total_volume else "Volume data not available", - "price_divergence": f"Max deviation: {max_deviation:.2%} - {'Normal range' if max_deviation < 0.01 else 'High divergence'}" - } - return { - "aggregated_data": { - f"{symbol}_USD": { - "price": round(agg_price, 2) if agg_price else None, - "confidence": round(confidence, 2), - "sources_count": len(prices) - } - }, - "individual_sources": price_map, - "market_signals": signals - } - @staticmethod - def _parse_volume(volume: Any) -> float: - # Supporta stringhe tipo "1.2M" o numeri - if isinstance(volume, (int, float)): - return float(volume) - if isinstance(volume, str): - v = volume.upper().replace(' ', '') - if v.endswith('M'): - return float(v[:-1]) * 1_000_000 - if v.endswith('K'): - return float(v[:-1]) * 1_000 - try: - return float(v) - except Exception as e: - print(f"Errore nel parsing del volume: {e}") - return 0.0 - return 0.0 diff --git a/src/app/utils/market_data_aggregator.py b/src/app/utils/market_data_aggregator.py index f72e91c..ea2d7c0 100644 --- a/src/app/utils/market_data_aggregator.py +++ b/src/app/utils/market_data_aggregator.py @@ -12,8 +12,8 @@ class MarketDataAggregator: def __init__(self, currency: str = "USD"): # Import lazy per evitare circular import - from app.markets import MarketAPIs - self._market_apis = MarketAPIs(currency) + from app.markets import MarketAPIsTool + self._market_apis = MarketAPIsTool(currency) self._aggregation_enabled = True def get_product(self, asset_id: str) -> ProductInfo: diff --git a/tests/api/test_yfinance.py b/tests/api/test_yfinance.py new file mode 100644 index 0000000..c0e9ba2 --- /dev/null +++ b/tests/api/test_yfinance.py @@ -0,0 +1,93 @@ +import os +import pytest +from app.markets import YFinanceWrapper + +@pytest.mark.market +@pytest.mark.api +class TestYFinance: + + def test_yfinance_init(self): + market = YFinanceWrapper() + assert market is not None + assert hasattr(market, 'currency') + assert market.currency == "USD" + assert hasattr(market, 'tool') + assert market.tool is not None + + def test_yfinance_get_product(self): + market = YFinanceWrapper() + product = market.get_product("AAPL") + assert product is not None + assert hasattr(product, 'symbol') + assert product.symbol == "AAPL" + assert hasattr(product, 'price') + assert product.price > 0 + assert hasattr(product, 'status') + assert product.status == "trading" + + def test_yfinance_get_crypto_product(self): + market = YFinanceWrapper() + product = market.get_product("BTC") + assert product is not None + assert hasattr(product, 'symbol') + # BTC verrร  convertito in BTC-USD dal formattatore + assert product.symbol in ["BTC", "BTC-USD"] + assert hasattr(product, 'price') + assert product.price > 0 + + def test_yfinance_get_products(self): + market = YFinanceWrapper() + products = market.get_products(["AAPL", "GOOGL"]) + assert products is not None + assert isinstance(products, list) + assert len(products) == 2 + symbols = [p.symbol for p in products] + assert "AAPL" in symbols + assert "GOOGL" in symbols + for product in products: + assert hasattr(product, 'price') + assert product.price > 0 + + def test_yfinance_get_all_products(self): + market = YFinanceWrapper() + products = market.get_all_products() + assert products is not None + assert isinstance(products, list) + assert len(products) > 0 + # Dovrebbe contenere asset popolari + symbols = [p.symbol for p in products] + assert "AAPL" in symbols # Apple dovrebbe essere nella lista + for product in products: + assert hasattr(product, 'symbol') + assert hasattr(product, 'price') + + def test_yfinance_invalid_product(self): + market = YFinanceWrapper() + # Per YFinance, un prodotto invalido dovrebbe restituire un prodotto offline + product = market.get_product("INVALIDSYMBOL123") + assert product is not None + assert product.status == "offline" + + def test_yfinance_history(self): + market = YFinanceWrapper() + history = market.get_historical_prices("AAPL", limit=5) + assert history is not None + assert isinstance(history, list) + assert len(history) == 5 + for entry in history: + assert hasattr(entry, 'time') + assert hasattr(entry, 'close') + assert hasattr(entry, 'high') + assert entry.close > 0 + assert entry.high > 0 + + def test_yfinance_crypto_history(self): + market = YFinanceWrapper() + history = market.get_historical_prices("BTC", limit=3) + assert history is not None + assert isinstance(history, list) + assert len(history) == 3 + for entry in history: + assert hasattr(entry, 'time') + assert hasattr(entry, 'close') + assert entry.close > 0 \ No newline at end of file diff --git a/tests/test_market_data_aggregator.py b/tests/utils/test_market_data_aggregator.py similarity index 99% rename from tests/test_market_data_aggregator.py rename to tests/utils/test_market_data_aggregator.py index 604362c..af00e61 100644 --- a/tests/test_market_data_aggregator.py +++ b/tests/utils/test_market_data_aggregator.py @@ -4,7 +4,7 @@ from app.utils.market_data_aggregator import MarketDataAggregator from app.utils.aggregated_models import AggregatedProductInfo from app.markets.base import ProductInfo, Price - +@pytest.mark.aggregator class TestMarketDataAggregator: def test_initialization(self): diff --git a/uv.lock b/uv.lock index 2d7d6a1..389bf03 100644 --- a/uv.lock +++ b/uv.lock @@ -325,6 +325,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" }, ] +[[package]] +name = "curl-cffi" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, +] + [[package]] name = "dateparser" version = "1.2.2" @@ -428,6 +449,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416, upload-time = "2024-10-13T12:15:32.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148, upload-time = "2024-10-13T12:15:26.839Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146, upload-time = "2024-10-13T12:15:28.16Z" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -836,6 +867,12 @@ 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 = "multitasking" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } + [[package]] name = "newsapi-python" version = "0.2.7" @@ -933,6 +970,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, ] +[[package]] +name = "peewee" +version = "3.18.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" } + [[package]] name = "pillow" version = "11.3.0" @@ -952,6 +995,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, ] +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1028,6 +1080,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "6.32.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635, upload-time = "2025-09-11T21:38:42.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/98/645183ea03ab3995d29086b8bf4f7562ebd3d10c9a4b14ee3f20d47cfe50/protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085", size = 424411, upload-time = "2025-09-11T21:38:27.427Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f3/6f58f841f6ebafe076cebeae33fc336e900619d34b1c93e4b5c97a81fdfa/protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1", size = 435738, upload-time = "2025-09-11T21:38:30.959Z" }, + { url = "https://files.pythonhosted.org/packages/10/56/a8a3f4e7190837139e68c7002ec749190a163af3e330f65d90309145a210/protobuf-6.32.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8c7e6eb619ffdf105ee4ab76af5a68b60a9d0f66da3ea12d1640e6d8dab7281", size = 426454, upload-time = "2025-09-11T21:38:34.076Z" }, + { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874, upload-time = "2025-09-11T21:38:35.509Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013, upload-time = "2025-09-11T21:38:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289, upload-time = "2025-09-11T21:38:41.234Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -1545,6 +1611,7 @@ dependencies = [ { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, + { name = "yfinance" }, ] [package.metadata] @@ -1561,6 +1628,7 @@ requires-dist = [ { name = "praw" }, { name = "pytest" }, { name = "python-binance" }, + { name = "yfinance" }, ] [[package]] @@ -1644,3 +1712,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] + +[[package]] +name = "yfinance" +version = "0.2.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/73/50450b9906c5137d2d02fde6f7360865366c72baea1f8d0550cc990829ce/yfinance-0.2.66.tar.gz", hash = "sha256:fae354cc1649109444b2c84194724afcc52c2a7799551ce44c739424ded6af9c", size = 132820, upload-time = "2025-09-17T11:22:35.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/bf/7c0c89ff8ba53592b9cb5157f70e90d8bbb04d60094fc4f10035e158b981/yfinance-0.2.66-py2.py3-none-any.whl", hash = "sha256:511a1a40a687f277aae3a02543009a8aeaa292fce5509671f58915078aebb5c7", size = 123427, upload-time = "2025-09-17T11:22:33.972Z" }, +] From dc9dc98298604c5a74df2575e886fcdce47b46ce Mon Sep 17 00:00:00 2001 From: Simo <60067394+Simo93-rgb@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:51:25 +0200 Subject: [PATCH 31/33] 3 market api (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Creazione branch tool, refactor degli import e soppressione dei warning * Update pytest configuration and dependencies in pyproject.toml * Add news API integration and related configurations - Update .env.example to include NEWS_API_KEY configuration - Add newsapi-python dependency in pyproject.toml - Implement NewsAPI class for fetching news articles - Create Article model for structured news data - Add tests for NewsAPI functionality in test_news_api.py - Update pytest configuration to include news marker * Add news API functionality and update tests for article retrieval * ToDo: 1. Aggiungere un aggregator per i dati recuperati dai provider. 2. Lavorare effettivamente all'issue Done: 1. creati test per i provider 2. creato market_providers_api_demo.py per mostrare i dati recuperati dalle api dei providers 3. aggiornato i provider 4. creato il provider binance sia pubblico che con chiave 5. creato error_handler.py per gestire decoratori e utilitร : retry automatico, gestione timeout... * Refactor news API integration to use NewsApiWrapper and GnewsWrapper; add tests for Gnews API functionality * Add CryptoPanic API integration and related tests; update .env.example and test configurations * Implement WrapperHandler for managing multiple news API wrappers; add tests for wrapper functionality * Enhance WrapperHandler - docstrings - add try_call_all method - update tests * pre merge con phil * Add DuckDuckGo and Google News wrappers; refactor CryptoPanic and NewsAPI - Implemented DuckDuckGoWrapper for news retrieval using DuckDuckGo tools. - Added GoogleNewsWrapper for accessing Google News RSS feed. - Refactored CryptoPanicWrapper to unify get_top_headlines and get_latest_news methods. - Updated NewsApiWrapper to simplify top headlines retrieval. - Added tests for DuckDuckGo and Google News wrappers. - Enhanced documentation for CryptoPanicWrapper and NewsApiWrapper. - Created base module for social media integrations. * - Refactor struttura progetto: divisione tra agent e toolkit * Refactor try_call_all method to return a dictionary of results; update tests for success and partial failures * Fix class and test method names for DuckDuckGoWrapper * Add Reddit API wrapper and related tests; update environment configuration * pre merge con giacomo * Fix import statements * Fixes - separated tests - fix tests - fix bugs reintroduced my previous merge * Refactor market API wrappers to streamline product and price retrieval methods * Add BinanceWrapper to market API exports * Finito ISSUE 3 * Final review - rm PublicBinanceAgent & updated demo - moved in the correct folder some tests - fix binance bug --------- Co-authored-by: trojanhorse47 Co-authored-by: Berack96 Co-authored-by: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> --- .env.example | 33 +- demos/cdp_market_demo.py | 116 ------ demos/market_agent_demo.py | 100 ----- demos/market_providers_api_demo.py | 364 ++++++++++++++++++ demos/news_api.py | 16 + pyproject.toml | 28 +- src/__init__.py | 0 src/app.py | 14 +- src/app/agents/market_agent.py | 90 +++++ src/app/agents/news_agent.py | 32 +- src/app/agents/social_agent.py | 33 +- src/app/markets/__init__.py | 137 ++++--- src/app/markets/base.py | 78 ++-- src/app/markets/binance.py | 106 +++-- src/app/markets/coinbase.py | 77 +++- src/app/markets/cryptocompare.py | 45 ++- src/app/models.py | 40 +- src/app/news/__init__.py | 32 ++ src/app/news/base.py | 35 ++ src/app/news/cryptopanic_api.py | 77 ++++ src/app/news/duckduckgo.py | 32 ++ src/app/news/gnews_api.py | 36 ++ src/app/news/news_api.py | 50 +++ src/app/pipeline.py | 83 ++++ src/app/{agents => }/predictor.py | 5 +- src/app/social/__init.py | 1 + src/app/social/base.py | 22 ++ src/app/social/reddit.py | 53 +++ src/app/tool.py | 88 ----- src/app/toolkits/__init__.py | 0 .../market.py => toolkits/market_toolkit.py} | 9 +- src/app/utils/aggregated_models.py | 184 +++++++++ src/app/utils/market_aggregator.py | 5 +- src/app/utils/market_data_aggregator.py | 184 +++++++++ src/app/utils/wrapper_handler.py | 110 ++++++ tests/agents/test_market.py | 146 ------- tests/agents/test_predictor.py | 7 +- tests/api/test_binance.py | 52 +++ tests/api/test_coinbase.py | 54 +++ tests/api/test_cryptocompare.py | 56 +++ tests/api/test_cryptopanic_api.py | 38 ++ tests/api/test_duckduckgo_news.py | 34 ++ tests/api/test_google_news.py | 34 ++ tests/api/test_news_api.py | 37 ++ tests/api/test_reddit.py | 24 ++ tests/conftest.py | 42 +- tests/tools/test_market_tool.py | 70 ++++ tests/utils/test_market_data_aggregator.py | 88 +++++ tests/utils/test_wrapper_handler.py | 90 +++++ uv.lock | 257 +++++++++++++ 50 files changed, 2673 insertions(+), 671 deletions(-) delete mode 100644 demos/cdp_market_demo.py delete mode 100644 demos/market_agent_demo.py create mode 100644 demos/market_providers_api_demo.py create mode 100644 demos/news_api.py create mode 100644 src/__init__.py create mode 100644 src/app/agents/market_agent.py create mode 100644 src/app/news/__init__.py create mode 100644 src/app/news/base.py create mode 100644 src/app/news/cryptopanic_api.py create mode 100644 src/app/news/duckduckgo.py create mode 100644 src/app/news/gnews_api.py create mode 100644 src/app/news/news_api.py create mode 100644 src/app/pipeline.py rename src/app/{agents => }/predictor.py (91%) create mode 100644 src/app/social/__init.py create mode 100644 src/app/social/base.py create mode 100644 src/app/social/reddit.py delete mode 100644 src/app/tool.py create mode 100644 src/app/toolkits/__init__.py rename src/app/{agents/market.py => toolkits/market_toolkit.py} (83%) create mode 100644 src/app/utils/aggregated_models.py create mode 100644 src/app/utils/market_data_aggregator.py create mode 100644 src/app/utils/wrapper_handler.py delete mode 100644 tests/agents/test_market.py create mode 100644 tests/api/test_binance.py create mode 100644 tests/api/test_coinbase.py create mode 100644 tests/api/test_cryptocompare.py create mode 100644 tests/api/test_cryptopanic_api.py create mode 100644 tests/api/test_duckduckgo_news.py create mode 100644 tests/api/test_google_news.py create mode 100644 tests/api/test_news_api.py create mode 100644 tests/api/test_reddit.py create mode 100644 tests/tools/test_market_tool.py create mode 100644 tests/utils/test_market_data_aggregator.py create mode 100644 tests/utils/test_wrapper_handler.py 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" From d2fbc0ceea77475aa3edd4c1e64923151f08ea99 Mon Sep 17 00:00:00 2001 From: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com> Date: Thu, 2 Oct 2025 01:40:59 +0200 Subject: [PATCH 32/33] 12 fix docs (#13) * fix dependencies uv.lock * refactor test markers for clarity * refactor: clean up imports and remove unused files * refactor: remove unused agent files and clean up market API instructions * refactor: enhance wrapper initialization with keyword arguments and clean up tests * refactor: remove PublicBinanceAgent * refactor: aggregator - simplified MarketDataAggregator and related models to functions * refactor: update README and .env.example to reflect the latest changes to the project * refactor: simplify product info and price creation in YFinanceWrapper * refactor: remove get_all_products method from market API wrappers and update documentation * fix: environment variable assertions * refactor: remove status attribute from ProductInfo and update related methods to use timestamp_ms * feat: implement aggregate_history_prices function to calculate hourly price averages * refactor: update docker-compose and app.py for improved environment variable handling and compatibility * feat: add detailed market instructions and improve error handling in price aggregation methods * feat: add aggregated news retrieval methods for top headlines and latest news * refactor: improve error messages in WrapperHandler for better clarity * fix: correct quote currency extraction in create_product_info and remove debug prints from tests --- .env.example | 6 +- README.md | 122 ++++---- demos/market_providers_api_demo.py | 20 +- docker-compose.yaml | 8 - pyproject.toml | 1 + src/app.py | 5 +- src/app/agents/__init__.py | 0 src/app/agents/market_agent.py | 90 ------ src/app/agents/news_agent.py | 35 --- src/app/agents/social_agent.py | 36 --- src/app/markets/__init__.py | 149 +++++----- src/app/markets/base.py | 18 +- src/app/markets/binance.py | 36 +-- src/app/markets/binance_public.py | 218 --------------- src/app/markets/coinbase.py | 16 +- src/app/markets/cryptocompare.py | 38 ++- src/app/markets/yfinance.py | 214 +++----------- src/app/news/__init__.py | 25 +- src/app/news/news_api.py | 2 +- src/app/pipeline.py | 46 ++- src/app/social/reddit.py | 8 +- src/app/toolkits/__init__.py | 0 src/app/toolkits/market_toolkit.py | 29 -- src/app/utils/aggregated_models.py | 186 ------------- src/app/utils/market_aggregation.py | 91 ++++++ src/app/utils/market_data_aggregator.py | 184 ------------ src/app/utils/wrapper_handler.py | 32 ++- tests/api/test_binance.py | 3 +- tests/api/test_coinbase.py | 3 +- tests/api/test_cryptocompare.py | 3 +- tests/api/test_news_api.py | 2 +- tests/api/test_reddit.py | 2 + tests/api/test_yfinance.py | 55 +--- tests/conftest.py | 15 +- tests/tools/test_market_tool.py | 33 +-- tests/tools/test_news_tool.py | 5 - tests/tools/test_socials_tool.py | 2 - tests/utils/test_market_aggregator.py | 120 ++++++++ tests/utils/test_market_data_aggregator.py | 88 ------ tests/utils/test_wrapper_handler.py | 24 +- uv.lock | 309 +++++++++++---------- 41 files changed, 726 insertions(+), 1553 deletions(-) delete mode 100644 src/app/agents/__init__.py delete mode 100644 src/app/agents/market_agent.py delete mode 100644 src/app/agents/news_agent.py delete mode 100644 src/app/agents/social_agent.py delete mode 100644 src/app/markets/binance_public.py delete mode 100644 src/app/toolkits/__init__.py delete mode 100644 src/app/toolkits/market_toolkit.py delete mode 100644 src/app/utils/aggregated_models.py create mode 100644 src/app/utils/market_aggregation.py delete mode 100644 src/app/utils/market_data_aggregator.py create mode 100644 tests/utils/test_market_aggregator.py delete mode 100644 tests/utils/test_market_data_aggregator.py diff --git a/.env.example b/.env.example index 06a53cb..fd9a427 100644 --- a/.env.example +++ b/.env.example @@ -2,13 +2,9 @@ # Configurazioni per i modelli di linguaggio ############################################################################### -# Alcune API sono a pagamento, altre hanno un piano gratuito con limiti di utilizzo -# Vedi https://docs.agno.com/examples/models per vedere tutti i modelli supportati +# https://makersuite.google.com/app/apikey GOOGLE_API_KEY= -# Inserire il percorso di installazione di ollama (es. /usr/share/ollama/.ollama) -# attenzione che fra Linux nativo e WSL il percorso รจ diverso -OLLAMA_MODELS_PATH= ############################################################################### # Configurazioni per gli agenti di mercato ############################################################################### diff --git a/README.md b/README.md index 4e02240..a545c92 100644 --- a/README.md +++ b/README.md @@ -9,53 +9,69 @@ L'obiettivo รจ quello di creare un sistema di consulenza finanziaria basato su L # **Indice** - [Installazione](#installazione) - - [Ollama (Modelli Locali)](#ollama-modelli-locali) - - [Variabili d'Ambiente](#variabili-dambiente) - - [Installazione in locale con UV](#installazione-in-locale-con-uv) - - [Installazione con Docker](#installazione-con-docker) + - [1. Variabili d'Ambiente](#1-variabili-dambiente) + - [2. Ollama](#2-ollama) + - [3. Docker](#3-docker) + - [4. UV (solo per sviluppo locale)](#4-uv-solo-per-sviluppo-locale) - [Applicazione](#applicazione) - [Ultimo Aggiornamento](#ultimo-aggiornamento) - [Tests](#tests) # **Installazione** -Per l'installazione di questo progetto si consiglia di utilizzare **Docker**. Con questo approccio si evita di dover installare manualmente tutte le dipendenze e si puรฒ eseguire il progetto in un ambiente isolato. -Per lo sviluppo locale si puรฒ utilizzare **uv** che si occupa di creare un ambiente virtuale e installare tutte le dipendenze. +L'installazione di questo progetto richiede 3 passaggi totali (+1 se si vuole sviluppare in locale) che devono essere eseguiti in sequenza. Se questi passaggi sono eseguiti correttamente, l'applicazione dovrebbe partire senza problemi. Altrimenti รจ molto probabile che si verifichino errori di vario tipo (moduli mancanti, chiavi API non trovate, ecc.). -In ogni caso, ***prima*** di avviare l'applicazione รจ perรฒ necessario configurare correttamente le **API keys** e installare Ollama per l'utilizzo dei modelli locali, altrimenti il progetto, anche se installato correttamente, non riuscirร  a partire. +1. Configurare le variabili d'ambiente +2. Installare Ollama e i modelli locali +3. Far partire il progetto con Docker (consigliato) +4. (Solo per sviluppo locale) Installare uv e creare l'ambiente virtuale -### Ollama (Modelli Locali) -Per utilizzare modelli AI localmente, รจ necessario installare Ollama: +> [!IMPORTANT]\ +> Prima di iniziare, assicurarsi di avere clonato il repository e di essere nella cartella principale del progetto. -**1. Installazione Ollama**: -- **Linux**: `curl -fsSL https://ollama.com/install.sh | sh` -- **macOS/Windows**: Scarica l'installer da [https://ollama.com/download/windows](https://ollama.com/download/windows) +### **1. Variabili d'Ambiente** -**2. GPU Support (Raccomandato)**: -Per utilizzare la GPU con Ollama, assicurati di avere NVIDIA CUDA Toolkit installato: -- **Download**: [NVIDIA CUDA Downloads](https://developer.nvidia.com/cuda-downloads?target_os=Windows&target_arch=x86_64&target_version=11&target_type=exe_local) -- **Documentazione WSL**: [CUDA WSL User Guide](https://docs.nvidia.com/cuda/wsl-user-guide/index.html) - -**3. Installazione Modelli**: -Si possono avere piรน modelli installati contemporaneamente. Per questo progetto si consiglia di utilizzare il modello open source `gpt-oss` poichรฉ prestante e compatibile con tante funzionalitร . Per il download: `ollama pull gpt-oss:latest` - -### Variabili d'Ambiente - -**1. Copia il file di esempio**: +Copia il file `.env.example` in `.env` e modificalo con le tue API keys: ```sh cp .env.example .env ``` -**2. Modifica il file .env** creato con le tue API keys e il path dei modelli Ollama, inserendoli nelle variabili opportune dopo l'uguale e ***senza*** spazi. +Le API Keys devono essere inserite nelle variabili opportune dopo l'uguale e ***senza*** spazi. Esse si possono ottenere tramite i loro providers (alcune sono gratuite, altre a pagamento).\ +Nel file [.env.example](.env.example) sono presenti tutte le variabili da compilare con anche il link per recuperare le chiavi, quindi, dopo aver copiato il file, basta seguire le istruzioni al suo interno. -Le API Keys puoi ottenerle tramite i seguenti servizi (alcune sono gratuite, altre a pagamento): -- **Google AI**: [Google AI Studio](https://makersuite.google.com/app/apikey) (gratuito con limiti) -- **Anthropic**: [Anthropic Console](https://console.anthropic.com/) -- **DeepSeek**: [DeepSeek Platform](https://platform.deepseek.com/) -- **OpenAI**: [OpenAI Platform](https://platform.openai.com/api-keys) +Le chiavi non sono necessarie per far partire l'applicazione, ma senza di esse alcune funzionalitร  non saranno disponibili o saranno limitate. Per esempio senza la chiave di NewsAPI non si potranno recuperare le ultime notizie sul mercato delle criptovalute. Ciononostante, l'applicazione usa anche degli strumenti che non richiedono chiavi API, come Yahoo Finance e GNews, che permettono di avere comunque un'analisi di base del mercato. + +### **2. Ollama** +Per utilizzare modelli AI localmente, รจ necessario installare Ollama, un gestore di modelli LLM che consente di eseguire modelli direttamente sul proprio hardware. Si consiglia di utilizzare Ollama con il supporto GPU per prestazioni ottimali, ma รจ possibile eseguirlo anche solo con la CPU. + +Per l'installazione scaricare Ollama dal loro [sito ufficiale](https://ollama.com/download/linux). + +Dopo l'installazione, si possono iniziare a scaricare i modelli desiderati tramite il comando `ollama pull :`. + +I modelli usati dall'applicazione sono visibili in [src/app/models.py](src/app/models.py). Di seguito metto lo stesso una lista di modelli, ma potrebbe non essere aggiornata: +- `gpt-oss:latest` +- `qwen3:latest` +- `qwen3:4b` +- `qwen3:1.7b` + +### **3. Docker** +Se si vuole solamente avviare il progetto, si consiglia di utilizzare [Docker](https://www.docker.com), dato che sono stati creati i files [Dockerfile](Dockerfile) e [docker-compose.yaml](docker-compose.yaml) per creare il container con tutti i file necessari e giร  in esecuzione. + +```sh +# Configura le variabili d'ambiente +cp .env.example .env +nano .env # Modifica il file + +# Avvia il container +docker compose up --build -d +``` + +Se si sono seguiti i passaggi precedenti per la configurazione delle variabili d'ambiente, l'applicazione dovrebbe partire correttamente, dato che il file `.env` verrร  automaticamente caricato nel container grazie alla configurazione in `docker-compose.yaml`. + +### **4. UV (solo per sviluppo locale)** + +Per prima cosa installa uv se non รจ giร  presente sul sistema -## **Installazione in locale con UV** -**1. Installazione uv**: Per prima cosa installa uv se non รจ giร  presente sul sistema: ```sh # Windows (PowerShell) powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" @@ -64,54 +80,28 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie curl -LsSf https://astral.sh/uv/install.sh | sh ``` -**2. Ambiente e dipendenze**: uv installerร  python e creerร  automaticamente l'ambiente virtuale con le dipendenze corrette: +UV installerร  python e creerร  automaticamente l'ambiente virtuale con le dipendenze corrette (nota che questo passaggio รจ opzionale, dato che uv, ogni volta che si esegue un comando, controlla se l'ambiente รจ attivo e se le dipendenze sono installate): + ```sh uv sync --frozen --no-cache ``` -**3. Run**: Successivamente si puรฒ far partire il progetto tramite il comando: +A questo punto si puรฒ far partire il progetto tramite il comando: + ```sh uv run python src/app.py ``` -## **Installazione con Docker** -Alternativamente, se si ha installato [Docker](https://www.docker.com), si puรฒ utilizzare il [Dockerfile](Dockerfile) e il [docker-compose.yaml](docker-compose.yaml) per creare il container con tutti i file necessari e giร  in esecuzione: - -**IMPORTANTE**: Assicurati di aver configurato il file `.env` come descritto sopra prima di avviare Docker. - -```sh -docker compose up --build -d -``` - -Il file `.env` verrร  automaticamente caricato nel container grazie alla configurazione in `docker-compose.yaml`. - # **Applicazione** ***L'applicazione รจ attualmente in fase di sviluppo.*** Usando la libreria ``gradio`` รจ stata creata un'interfaccia web semplice per interagire con l'agente principale. Gli agenti secondari si trovano nella cartella `src/app/agents` e sono: -- **Market Agent**: Agente unificato che supporta multiple fonti di dati (Coinbase + CryptoCompare) con auto-configurazione -- **News Agent**: Recupera le notizie finanziarie piรน recenti utilizzando. -- **Social Agent**: Analizza i sentimenti sui social media utilizzando. +- **Market Agent**: Agente unificato che supporta multiple fonti di dati con auto-retry e gestione degli errori. +- **News Agent**: Recupera le notizie finanziarie piรน recenti sul mercato delle criptovalute. +- **Social Agent**: Analizza i sentimenti sui social media riguardo alle criptovalute. - **Predictor Agent**: Utilizza i dati raccolti dagli altri agenti per fare previsioni. -## Ultimo Aggiornamento - -### Cose non funzionanti -- **Market Agent**: Non รจ un vero agente dato che non usa LLM per ragionare ma prende solo i dati -- **market_aggregator.py**: Non รจ usato per ora -- **News Agent**: Non funziona lo scraping online, per ora usa dati mock -- **Social Agent**: Non funziona lo scraping online, per ora usa dati mock -- **Demos**: Le demos nella cartella [demos](demos) non sono aggiornate e non funzionano per ora - -### ToDo -- [X] Per lo scraping online bisogna iscriversi e recuperare le chiavi API -- [X] **Market Agent**: [CryptoCompare](https://www.cryptocompare.com/cryptopian/api-keys) -- [X] **Market Agent**: [Coinbase](https://www.coinbase.com/cloud/discover/api-keys) -- [ ] **News Agent**: [CryptoPanic](https://cryptopanic.com/) -- [ ] **Social Agent**: [post piรน hot da r/CryptoCurrency (Reddit)](https://www.reddit.com/) -- [ ] Capire come `gpt-oss` parsifica la risposta e per questioni "estetiche" si puรฒ pensare di visualizzare lo stream dei token. Vedere il sorgente `src/ollama_demo.py` per risolvere il problema. - ## Tests Per eseguire i test, assicurati di aver configurato correttamente le variabili d'ambiente nel file `.env` come descritto sopra. Poi esegui il comando: @@ -119,8 +109,8 @@ Per eseguire i test, assicurati di aver configurato correttamente le variabili d uv run pytest -v # Oppure per test specifici -uv run pytest -v tests/agents/test_market.py -uv run pytest -v tests/agents/test_predictor.py +uv run pytest -v tests/api/test_binance.py +uv run pytest -v -k "test_news" # Oppure usando i markers uv run pytest -v -m api diff --git a/demos/market_providers_api_demo.py b/demos/market_providers_api_demo.py index 8c368e8..fc05c26 100644 --- a/demos/market_providers_api_demo.py +++ b/demos/market_providers_api_demo.py @@ -154,7 +154,7 @@ class ProviderTester: 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}") + print(f" Volume 24h: {product.volume_24h:,.2f}") else: print(f"๐Ÿ“ฆ Product: Nessun prodotto trovato per {symbol}") @@ -186,24 +186,6 @@ class ProviderTester: 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: diff --git a/docker-compose.yaml b/docker-compose.yaml index 884d9fd..20f5616 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,12 +9,4 @@ services: env_file: - .env environment: - # Modelli supportati - OLLAMA_HOST=http://host.docker.internal:11434 - - GOOGLE_API_KEY=${GOOGLE_API_KEY} - # Chiavi per le crypto API - - CDP_API_KEY_NAME=${CDP_API_KEY_NAME} - - CDP_API_PRIVATE_KEY=${CDP_API_PRIVATE_KEY} - - CRYPTOCOMPARE_API_KEY=${CRYPTOCOMPARE_API_KEY} - - BINANCE_API_KEY=${BINANCE_API_KEY} - - BINANCE_API_SECRET=${BINANCE_API_SECRET} diff --git a/pyproject.toml b/pyproject.toml index 2e90e39..d039c6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ # API di social media "praw", # Reddit ] + [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] diff --git a/src/app.py b/src/app.py index 8477149..cf09fd5 100644 --- a/src/app.py +++ b/src/app.py @@ -42,6 +42,7 @@ if __name__ == "__main__": analyze_btn = gr.Button("๐Ÿ”Ž Analizza") 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}") + server, port = ("0.0.0.0", 8000) # 0.0.0.0 per docker compatibility + server_log = "localhost" if server == "0.0.0.0" else server + log_info(f"Starting UPO AppAI on http://{server_log}:{port}") demo.launch(server_name=server, server_port=port, quiet=True) diff --git a/src/app/agents/__init__.py b/src/app/agents/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/agents/market_agent.py b/src/app/agents/market_agent.py deleted file mode 100644 index 12f9eab..0000000 --- a/src/app/agents/market_agent.py +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index d6de5e5..0000000 --- a/src/app/agents/news_agent.py +++ /dev/null @@ -1,35 +0,0 @@ -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 - return "๐Ÿ“ฐ Sentiment news: ottimismo sul mercato crypto grazie all'adozione istituzionale." diff --git a/src/app/agents/social_agent.py b/src/app/agents/social_agent.py deleted file mode 100644 index cefa7ef..0000000 --- a/src/app/agents/social_agent.py +++ /dev/null @@ -1,36 +0,0 @@ -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 - return "๐Ÿ’ฌ Sentiment social: forte interesse retail su nuove altcoin emergenti." diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index eefc442..b782b8f 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -1,97 +1,106 @@ +from agno.tools import Toolkit +from app.utils.wrapper_handler import WrapperHandler +from app.utils.market_aggregation import aggregate_product_info, aggregate_history_prices from .base import BaseWrapper, ProductInfo, Price from .coinbase import CoinBaseWrapper from .binance import BinanceWrapper from .cryptocompare import CryptoCompareWrapper from .yfinance import YFinanceWrapper -from .binance_public import PublicBinanceAgent -from app.utils.wrapper_handler import WrapperHandler -from typing import List, Optional -from agno.tools import Toolkit - -__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "PublicBinanceAgent" ] +__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper" ] class MarketAPIsTool(BaseWrapper, Toolkit): """ - Classe per gestire le API di mercato disponibili. - - 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. + Class that aggregates multiple market API wrappers and manages them using WrapperHandler. + This class supports retrieving product information and historical prices. + This class can also aggregate data from multiple sources to provide a more comprehensive view of the market. + The following wrappers are included in this order: + - BinanceWrapper + - YFinanceWrapper + - CoinBaseWrapper + - CryptoCompareWrapper """ - def __init__(self, currency: str = "USD", enable_aggregation: bool = False): - self.currency = currency - wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper, YFinanceWrapper ] - self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers) - - # Inizializza l'aggregatore solo se richiesto (lazy initialization) - self._aggregator = None - self._aggregation_enabled = enable_aggregation - + def __init__(self, currency: str = "USD"): + """ + Initialize the MarketAPIsTool with multiple market API wrappers. + The following wrappers are included in this order: + - BinanceWrapper + - YFinanceWrapper + - CoinBaseWrapper + - CryptoCompareWrapper + Args: + currency (str): Valuta in cui restituire i prezzi. Default รจ "USD". + """ + kwargs = {"currency": currency or "USD"} + wrappers = [ BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ] + self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) + Toolkit.__init__( self, name="Market APIs Toolkit", tools=[ self.get_product, self.get_products, - self.get_all_products, self.get_historical_prices, + self.get_products_aggregated, + self.get_historical_prices_aggregated, ], ) - - def _get_aggregator(self): - """Lazy initialization dell'aggregatore""" - if self._aggregator is None: - from app.utils.market_data_aggregator import MarketDataAggregator - self._aggregator = MarketDataAggregator(self.currency) - self._aggregator.enable_aggregation(self._aggregation_enabled) - return self._aggregator - def get_product(self, asset_id: str) -> Optional[ProductInfo]: - """Ottieni informazioni su un prodotto specifico""" - if self._aggregation_enabled: - return self._get_aggregator().get_product(asset_id) + def get_product(self, asset_id: str) -> ProductInfo: 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) + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: 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) + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: 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: + + + def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]: """ - Metodo speciale per ottenere dati aggregati con informazioni di debug. - Disponibile solo quando l'aggregazione รจ abilitata. + Restituisce i dati aggregati per una lista di asset_id.\n + Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento). + Args: + asset_ids (list[str]): Lista di asset_id da cercare. + Returns: + list[ProductInfo]: Lista di ProductInfo aggregati. """ - 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) + all_products = self.wrappers.try_call_all(lambda w: w.get_products(asset_ids)) + return aggregate_product_info(all_products) + + def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: + """ + Restituisce i dati storici aggregati per un asset_id. Usa i dati di tutte le fonti disponibili e li aggrega.\n + Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento). + Args: + asset_id (str): Asset ID da cercare. + limit (int): Numero massimo di dati storici da restituire. + Returns: + list[Price]: Lista di Price aggregati. + """ + all_prices = self.wrappers.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) + return aggregate_history_prices(all_prices) + +MARKET_INSTRUCTIONS = """ +**TASK:** You are a specialized **Crypto Price Data Retrieval Agent**. Your primary goal is to fetch the most recent and/or historical price data for requested cryptocurrency assets (e.g., 'BTC', 'ETH', 'SOL'). You must provide the data in a clear and structured format. + +**AVAILABLE TOOLS:** +1. `get_products(asset_ids: list[str])`: Get **current** product/price info for a list of assets. **(PREFERITA: usa questa per i prezzi live)** +2. `get_historical_prices(asset_id: str, limit: int)`: Get historical price data for one asset. Default limit is 100. **(PREFERITA: usa questa per i dati storici)** +3. `get_products_aggregated(asset_ids: list[str])`: Get **aggregated current** product/price info for a list of assets. **(USA SOLO SE richiesto 'aggregato' o se `get_products` fallisce)** +4. `get_historical_prices_aggregated(asset_id: str, limit: int)`: Get **aggregated historical** price data for one asset. **(USA SOLO SE richiesto 'aggregato' o se `get_historical_prices` fallisce)** + +**USAGE GUIDELINE:** +* **Asset ID:** Always convert common names (e.g., 'Bitcoin', 'Ethereum') into their official ticker/ID (e.g., 'BTC', 'ETH'). +* **Cost Management (Cruciale per LLM locale):** + * **Prioritร  Bassa per Aggregazione:** **Non** usare i metodi `*aggregated` a meno che l'utente non lo richieda esplicitamente o se i metodi non-aggregati falliscono. + * **Limitazione Storica:** Il limite predefinito per i dati storici deve essere **20** punti dati, a meno che l'utente non specifichi un limite diverso. +* **Fallimento Tool:** Se lo strumento non restituisce dati per un asset specifico, rispondi per quell'asset con: "Dati di prezzo non trovati per [Asset ID]." + +**REPORTING REQUIREMENT:** +1. **Format:** Output the results in a clear, easy-to-read list or table. +2. **Live Price Request:** If an asset's *current price* is requested, report the **Asset ID**, **Latest Price**, and **Time/Date of the price**. +3. **Historical Price Request:** If *historical data* is requested, report the **Asset ID**, the **Limit** of points returned, and the **First** and **Last** entries from the list of historical prices (Date, Price). Non stampare l'intera lista di dati storici. +4. **Output:** For all requests, fornire un **unico e conciso riepilogo** dei dati reperiti. +""" \ No newline at end of file diff --git a/src/app/markets/base.py b/src/app/markets/base.py index 117c174..1ef247b 100644 --- a/src/app/markets/base.py +++ b/src/app/markets/base.py @@ -1,4 +1,3 @@ - from pydantic import BaseModel class BaseWrapper: @@ -15,7 +14,7 @@ class BaseWrapper: Returns: ProductInfo: An object containing product information. """ - raise NotImplementedError + raise NotImplementedError("This method should be overridden by subclasses") def get_products(self, asset_ids: list[str]) -> list['ProductInfo']: """ @@ -25,15 +24,7 @@ class BaseWrapper: 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 + raise NotImplementedError("This method should be overridden by subclasses") def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list['Price']: """ @@ -44,7 +35,7 @@ class BaseWrapper: Returns: list[Price]: A list of Price objects. """ - raise NotImplementedError + raise NotImplementedError("This method should be overridden by subclasses") class ProductInfo(BaseModel): """ @@ -55,7 +46,6 @@ class ProductInfo(BaseModel): symbol: str = "" price: float = 0.0 volume_24h: float = 0.0 - status: str = "" quote_currency: str = "" class Price(BaseModel): @@ -68,4 +58,4 @@ class Price(BaseModel): open: float = 0.0 close: float = 0.0 volume: float = 0.0 - time: str = "" + timestamp_ms: int = 0 # Timestamp in milliseconds diff --git a/src/app/markets/binance.py b/src/app/markets/binance.py index d5dfe10..8e941c8 100644 --- a/src/app/markets/binance.py +++ b/src/app/markets/binance.py @@ -3,16 +3,25 @@ from datetime import datetime from binance.client import Client from .base import ProductInfo, BaseWrapper, Price -def get_product(currency: str, ticker_data: dict[str, str]) -> 'ProductInfo': +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 +def get_price(kline_data: list) -> Price: + price = Price() + price.open = float(kline_data[1]) + price.high = float(kline_data[2]) + price.low = float(kline_data[3]) + price.close = float(kline_data[4]) + price.volume = float(kline_data[5]) + price.timestamp_ms = kline_data[0] + return price + class BinanceWrapper(BaseWrapper): """ Wrapper per le API autenticate di Binance.\n @@ -54,17 +63,6 @@ class BinanceWrapper(BaseWrapper): 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) @@ -74,15 +72,5 @@ class BinanceWrapper(BaseWrapper): interval=Client.KLINE_INTERVAL_1HOUR, limit=limit, ) + return [get_price(kline) for kline in klines] - 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/binance_public.py b/src/app/markets/binance_public.py deleted file mode 100644 index c1d9896..0000000 --- a/src/app/markets/binance_public.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Versione pubblica di Binance per accesso ai dati pubblici senza autenticazione. - -Questa implementazione estende BaseWrapper per mantenere coerenza -con l'architettura del modulo markets. -""" - -from typing import Optional, Dict, Any -from datetime import datetime, timedelta -from binance.client import Client -from .base import BaseWrapper, ProductInfo, Price - - -class PublicBinanceAgent(BaseWrapper): - """ - Agent per l'accesso ai dati pubblici di Binance. - - Utilizza l'API pubblica di Binance per ottenere informazioni - sui prezzi e sui mercati senza richiedere autenticazione. - """ - - def __init__(self): - """Inizializza il client pubblico senza credenziali.""" - self.client = Client() - - def __format_symbol(self, asset_id: str) -> str: - """ - Formatta l'asset_id per Binance (es. BTC -> BTCUSDT). - - Args: - asset_id: ID dell'asset (es. "BTC", "ETH") - - Returns: - Simbolo formattato per Binance - """ - if asset_id.endswith("USDT") or asset_id.endswith("BUSD"): - return asset_id - return f"{asset_id}USDT" - - def get_product(self, asset_id: str) -> ProductInfo: - """ - Ottiene informazioni su un singolo prodotto. - - Args: - asset_id: ID dell'asset (es. "BTC") - - Returns: - Oggetto ProductInfo con le informazioni del prodotto - """ - symbol = self.__format_symbol(asset_id) - try: - ticker = self.client.get_symbol_ticker(symbol=symbol) - ticker_24h = self.client.get_ticker(symbol=symbol) - return ProductInfo.from_binance(ticker, ticker_24h) - except Exception as e: - print(f"Errore nel recupero del prodotto {asset_id}: {e}") - return ProductInfo(id=asset_id, symbol=asset_id) - - def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - """ - Ottiene informazioni su piรน prodotti. - - Args: - asset_ids: Lista di ID degli asset - - Returns: - Lista di oggetti ProductInfo - """ - products = [] - for asset_id in asset_ids: - product = self.get_product(asset_id) - products.append(product) - return products - - def get_all_products(self) -> list[ProductInfo]: - """ - Ottiene informazioni su tutti i prodotti disponibili. - - Returns: - Lista di oggetti ProductInfo per i principali asset - """ - # Per la versione pubblica, restituiamo solo i principali asset - major_assets = ["BTC", "ETH", "BNB", "ADA", "DOT", "LINK", "LTC", "XRP"] - return self.get_products(major_assets) - - def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]: - """ - Ottiene i prezzi storici per un asset. - - Args: - asset_id: ID dell'asset (default: "BTC") - - Returns: - Lista di oggetti Price con i dati storici - """ - symbol = self.__format_symbol(asset_id) - try: - # Ottieni candele degli ultimi 30 giorni - end_time = datetime.now() - start_time = end_time - timedelta(days=30) - - klines = self.client.get_historical_klines( - symbol, - Client.KLINE_INTERVAL_1DAY, - start_time.strftime("%d %b %Y %H:%M:%S"), - end_time.strftime("%d %b %Y %H:%M:%S") - ) - - prices = [] - for kline in klines: - price = Price( - open=float(kline[1]), - high=float(kline[2]), - low=float(kline[3]), - close=float(kline[4]), - volume=float(kline[5]), - time=str(datetime.fromtimestamp(kline[0] / 1000)) - ) - prices.append(price) - - return prices - except Exception as e: - print(f"Errore nel recupero dei prezzi storici per {asset_id}: {e}") - return [] - - def get_public_prices(self, symbols: Optional[list[str]] = None) -> Optional[Dict[str, Any]]: - """ - Ottiene i prezzi pubblici per i simboli specificati. - - Args: - symbols: Lista di simboli da recuperare (es. ["BTCUSDT", "ETHUSDT"]). - Se None, recupera BTC e ETH di default. - - Returns: - Dizionario con i prezzi e informazioni sulla fonte, o None in caso di errore. - """ - if symbols is None: - symbols = ["BTCUSDT", "ETHUSDT"] - - try: - prices = {} - for symbol in symbols: - ticker = self.client.get_symbol_ticker(symbol=symbol) - # Converte BTCUSDT -> BTC_USD per consistenza - clean_symbol = symbol.replace("USDT", "_USD").replace("BUSD", "_USD") - prices[clean_symbol] = float(ticker['price']) - - return { - **prices, - 'source': 'binance_public', - 'timestamp': self.client.get_server_time()['serverTime'] - } - except Exception as e: - print(f"Errore nel recupero dei prezzi pubblici: {e}") - return None - - def get_24hr_ticker(self, symbol: str) -> Optional[Dict[str, Any]]: - """ - Ottiene le statistiche 24h per un simbolo specifico. - - Args: - symbol: Simbolo del trading pair (es. "BTCUSDT") - - Returns: - Dizionario con le statistiche 24h o None in caso di errore. - """ - try: - ticker = self.client.get_ticker(symbol=symbol) - return { - 'symbol': ticker['symbol'], - 'price': float(ticker['lastPrice']), - 'price_change': float(ticker['priceChange']), - 'price_change_percent': float(ticker['priceChangePercent']), - 'high_24h': float(ticker['highPrice']), - 'low_24h': float(ticker['lowPrice']), - 'volume_24h': float(ticker['volume']), - 'source': 'binance_public' - } - except Exception as e: - print(f"Errore nel recupero del ticker 24h per {symbol}: {e}") - return None - - def get_exchange_info(self) -> Optional[Dict[str, Any]]: - """ - Ottiene informazioni generali sull'exchange. - - Returns: - Dizionario con informazioni sull'exchange o None in caso di errore. - """ - try: - info = self.client.get_exchange_info() - return { - 'timezone': info['timezone'], - 'server_time': info['serverTime'], - 'symbols_count': len(info['symbols']), - 'source': 'binance_public' - } - except Exception as e: - print(f"Errore nel recupero delle informazioni exchange: {e}") - return None - - -# Esempio di utilizzo -if __name__ == "__main__": - # Uso senza credenziali - public_agent = PublicBinanceAgent() - - # Ottieni prezzi di default (BTC e ETH) - public_prices = public_agent.get_public_prices() - print("Prezzi pubblici:", public_prices) - - # Ottieni statistiche 24h per BTC - btc_stats = public_agent.get_24hr_ticker("BTCUSDT") - print("Statistiche BTC 24h:", btc_stats) - - # Ottieni informazioni exchange - exchange_info = public_agent.get_exchange_info() - print("Info exchange:", exchange_info) \ No newline at end of file diff --git a/src/app/markets/coinbase.py b/src/app/markets/coinbase.py index 286ec6f..54409c1 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/markets/coinbase.py @@ -6,24 +6,22 @@ from coinbase.rest.types.product_types import Candle, GetProductResponse, Produc from .base import ProductInfo, BaseWrapper, Price -def get_product(product_data: GetProductResponse | Product) -> 'ProductInfo': +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': +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 "" + price.timestamp_ms = int(candle_data.start) * 1000 if candle_data.start else 0 return price @@ -49,10 +47,10 @@ class CoinBaseWrapper(BaseWrapper): def __init__(self, currency: str = "USD"): api_key = os.getenv("COINBASE_API_KEY") - assert api_key is not None, "API key is required" + assert api_key, "COINBASE_API_KEY environment variable not set" api_private_key = os.getenv("COINBASE_API_SECRET") - assert api_private_key is not None, "API private key is required" + assert api_private_key, "COINBASE_API_SECRET environment variable not set" self.currency = currency self.client: RESTClient = RESTClient( @@ -73,10 +71,6 @@ class CoinBaseWrapper(BaseWrapper): 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 [get_product(asset) for asset in assets.products] - def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: asset_id = self.__format(asset_id) end_time = datetime.now() diff --git a/src/app/markets/cryptocompare.py b/src/app/markets/cryptocompare.py index c81a3bb..f4b96e9 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/markets/cryptocompare.py @@ -1,26 +1,26 @@ import os import requests -from typing import Optional, Dict, Any from .base import ProductInfo, BaseWrapper, Price -def get_product(asset_data: dict) -> 'ProductInfo': +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 + product.id = asset_data.get('FROMSYMBOL', '') + '-' + asset_data.get('TOSYMBOL', '') + product.symbol = asset_data.get('FROMSYMBOL', '') + product.price = float(asset_data.get('PRICE', 0)) + product.volume_24h = float(asset_data.get('VOLUME24HOUR', 0)) + assert product.price > 0, "Invalid price data received from CryptoCompare" return product -def get_price(price_data: dict) -> 'Price': +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']) + price.high = float(price_data.get('high', 0)) + price.low = float(price_data.get('low', 0)) + price.open = float(price_data.get('open', 0)) + price.close = float(price_data.get('close', 0)) + price.volume = float(price_data.get('volumeto', 0)) + price.timestamp_ms = price_data.get('time', 0) * 1000 + assert price.timestamp_ms > 0, "Invalid timestamp data received from CryptoCompare" return price @@ -34,12 +34,12 @@ class CryptoCompareWrapper(BaseWrapper): """ def __init__(self, currency:str='USD'): api_key = os.getenv("CRYPTOCOMPARE_API_KEY") - assert api_key is not None, "API key is required" + assert api_key, "CRYPTOCOMPARE_API_KEY environment variable not set" self.api_key = api_key self.currency = currency - def __request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def __request(self, endpoint: str, params: dict[str, str] | None = None) -> dict[str, str]: if params is None: params = {} params['api_key'] = self.api_key @@ -67,11 +67,7 @@ class CryptoCompareWrapper(BaseWrapper): assets.append(get_product(asset_data)) return assets - def get_all_products(self) -> list[ProductInfo]: - # 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, limit: int = 100) -> list[dict]: + def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: response = self.__request("/data/v2/histohour", params = { "fsym": asset_id, "tsym": self.currency, diff --git a/src/app/markets/yfinance.py b/src/app/markets/yfinance.py index f0e5d6d..acfacb8 100644 --- a/src/app/markets/yfinance.py +++ b/src/app/markets/yfinance.py @@ -3,63 +3,29 @@ from agno.tools.yfinance import YFinanceTools from .base import BaseWrapper, ProductInfo, Price -def create_product_info(symbol: str, stock_data: dict) -> ProductInfo: +def create_product_info(stock_data: dict[str, str]) -> ProductInfo: """ Converte i dati di YFinanceTools in ProductInfo. """ product = ProductInfo() - - # ID univoco per yfinance - product.id = f"yfinance_{symbol}" - product.symbol = symbol - - # Estrai il prezzo corrente - gestisci diversi formati - if 'currentPrice' in stock_data: - product.price = float(stock_data['currentPrice']) - elif 'regularMarketPrice' in stock_data: - product.price = float(stock_data['regularMarketPrice']) - elif 'Current Stock Price' in stock_data: - # Formato: "254.63 USD" - estrai solo il numero - price_str = stock_data['Current Stock Price'].split()[0] - try: - product.price = float(price_str) - except ValueError: - product.price = 0.0 - else: - product.price = 0.0 - - # Volume 24h - if 'volume' in stock_data: - product.volume_24h = float(stock_data['volume']) - elif 'regularMarketVolume' in stock_data: - product.volume_24h = float(stock_data['regularMarketVolume']) - else: - product.volume_24h = 0.0 - - # Status basato sulla disponibilitร  dei dati - product.status = "trading" if product.price > 0 else "offline" - - # Valuta (default USD) - product.quote_currency = stock_data.get('currency', 'USD') or 'USD' - + product.id = stock_data.get('Symbol', '') + product.symbol = product.id.split('-')[0] # Rimuovi il suffisso della valuta per le crypto + product.price = float(stock_data.get('Current Stock Price', f"0.0 USD").split(" ")[0]) # prende solo il numero + product.volume_24h = 0.0 # YFinance non fornisce il volume 24h direttamente + product.quote_currency = product.id.split('-')[1] # La valuta รจ la parte dopo il '-' return product - -def create_price_from_history(hist_data: dict, timestamp: str) -> Price: +def create_price_from_history(hist_data: dict[str, str]) -> Price: """ Converte i dati storici di YFinanceTools in Price. """ price = Price() - - if timestamp in hist_data: - day_data = hist_data[timestamp] - price.high = float(day_data.get('High', 0.0)) - price.low = float(day_data.get('Low', 0.0)) - price.open = float(day_data.get('Open', 0.0)) - price.close = float(day_data.get('Close', 0.0)) - price.volume = float(day_data.get('Volume', 0.0)) - price.time = timestamp - + price.high = float(hist_data.get('High', 0.0)) + price.low = float(hist_data.get('Low', 0.0)) + price.open = float(hist_data.get('Open', 0.0)) + price.close = float(hist_data.get('Close', 0.0)) + price.volume = float(hist_data.get('Volume', 0.0)) + price.timestamp_ms = int(hist_data.get('Timestamp', '0')) return price @@ -69,146 +35,46 @@ class YFinanceWrapper(BaseWrapper): Implementa l'interfaccia BaseWrapper per compatibilitร  con il sistema esistente. Usa YFinanceTools dalla libreria agno per coerenza con altri wrapper. """ - + def __init__(self, currency: str = "USD"): self.currency = currency - # Inizializza YFinanceTools - non richiede parametri specifici self.tool = YFinanceTools() - + def _format_symbol(self, asset_id: str) -> str: """ Formatta il simbolo per yfinance. - Per crypto, aggiunge '-USD' se non presente. + Per crypto, aggiunge '-' e la valuta (es. BTC -> BTC-USD). """ asset_id = asset_id.upper() - - # Se รจ giร  nel formato corretto (es: BTC-USD), usa cosรฌ - if '-' in asset_id: - return asset_id - - # Per crypto singole (BTC, ETH), aggiungi -USD - if asset_id in ['BTC', 'ETH', 'ADA', 'SOL', 'DOT', 'LINK', 'UNI', 'AAVE']: - return f"{asset_id}-USD" - - # Per azioni, usa il simbolo cosรฌ com'รจ - return asset_id - + return f"{asset_id}-{self.currency}" if '-' not in asset_id else asset_id + def get_product(self, asset_id: str) -> ProductInfo: - """ - Recupera le informazioni di un singolo prodotto. - """ symbol = self._format_symbol(asset_id) - - # Usa YFinanceTools per ottenere i dati - try: - # Ottieni le informazioni base dello stock - stock_info = self.tool.get_company_info(symbol) - - # Se il risultato รจ una stringa JSON, parsala - if isinstance(stock_info, str): - try: - stock_data = json.loads(stock_info) - except json.JSONDecodeError: - # Se non รจ JSON valido, prova a ottenere solo il prezzo - price_data_str = self.tool.get_current_stock_price(symbol) - if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit(): - price = float(price_data_str) - stock_data = {'currentPrice': price, 'currency': 'USD'} - else: - raise Exception("Dati non validi") - else: - stock_data = stock_info - - return create_product_info(symbol, stock_data) - - except Exception as e: - # Fallback: prova a ottenere solo il prezzo - try: - price_data_str = self.tool.get_current_stock_price(symbol) - if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit(): - price = float(price_data_str) - minimal_data = { - 'currentPrice': price, - 'currency': 'USD' - } - return create_product_info(symbol, minimal_data) - else: - raise Exception("Prezzo non disponibile") - except Exception: - # Se tutto fallisce, restituisci un prodotto vuoto - product = ProductInfo() - product.symbol = symbol - product.status = "offline" - return product - + stock_info = self.tool.get_company_info(symbol) + stock_info = json.loads(stock_info) + return create_product_info(stock_info) + def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - """ - Recupera le informazioni di multiple assets. - """ products = [] - for asset_id in asset_ids: - try: - product = self.get_product(asset_id) - products.append(product) - except Exception as e: - # Se un asset non รจ disponibile, continua con gli altri - continue - + product = self.get_product(asset_id) + products.append(product) return products - - def get_all_products(self) -> list[ProductInfo]: - """ - Recupera tutti i prodotti disponibili. - Restituisce una lista predefinita di asset popolari. - """ - # Lista di asset popolari (azioni, ETF, crypto) - popular_assets = [ - 'BTC', 'ETH', 'ADA', 'SOL', 'DOT', - 'AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN', - 'SPY', 'QQQ', 'VTI', 'GLD', 'VIX' - ] - - return self.get_products(popular_assets) - + def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: - """ - Recupera i dati storici di prezzo per un asset. - """ symbol = self._format_symbol(asset_id) - - try: - # Determina il periodo appropriato in base al limite - if limit <= 7: - period = "1d" - interval = "15m" - elif limit <= 30: - period = "5d" - interval = "1h" - elif limit <= 90: - period = "1mo" - interval = "1d" - else: - period = "3mo" - interval = "1d" - - # Ottieni i dati storici - hist_data = self.tool.get_historical_stock_prices(symbol, period=period, interval=interval) - - if isinstance(hist_data, str): - hist_data = json.loads(hist_data) - - # Il formato dei dati รจ {timestamp: {Open: x, High: y, Low: z, Close: w, Volume: v}} - prices = [] - timestamps = sorted(hist_data.keys())[-limit:] # Prendi gli ultimi 'limit' timestamp - - for timestamp in timestamps: - price = create_price_from_history(hist_data, timestamp) - if price.close > 0: # Solo se ci sono dati validi - prices.append(price) - - return prices - - except Exception as e: - # Se fallisce, restituisci lista vuota - return [] \ No newline at end of file + + days = limit // 24 + 1 # Arrotonda per eccesso + hist_data = self.tool.get_historical_stock_prices(symbol, period=f"{days}d", interval="1h") + hist_data = json.loads(hist_data) + + # Il formato dei dati รจ {timestamp: {Open: x, High: y, Low: z, Close: w, Volume: v}} + timestamps = sorted(hist_data.keys())[-limit:] + + prices = [] + for timestamp in timestamps: + temp = hist_data[timestamp] + temp['Timestamp'] = timestamp + price = create_price_from_history(temp) + prices.append(price) + return prices diff --git a/src/app/news/__init__.py b/src/app/news/__init__.py index 080c3ef..94873fd 100644 --- a/src/app/news/__init__.py +++ b/src/app/news/__init__.py @@ -45,13 +45,32 @@ class NewsAPIsTool(NewsWrapper, Toolkit): ], ) - # TODO Pensare se ha senso restituire gli articoli da TUTTI i wrapper o solo dal primo che funziona - # la modifica รจ banale, basta usare try_call_all invece di try_call def get_top_headlines(self, limit: int = 100) -> list[Article]: return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit)) def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: return self.wrapper_handler.try_call(lambda w: w.get_latest_news(query, limit)) + def get_top_headlines_aggregated(self, limit: int = 100) -> dict[str, list[Article]]: + """ + Calls get_top_headlines on all wrappers/providers and returns a dictionary mapping their names to their articles. + Args: + limit (int): Maximum number of articles to retrieve from each provider. + Returns: + dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles + """ + return self.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit)) + + def get_latest_news_aggregated(self, query: str, limit: int = 100) -> dict[str, list[Article]]: + """ + Calls get_latest_news on all wrappers/providers and returns a dictionary mapping their names to their articles. + Args: + query (str): The search query to find relevant news articles. + limit (int): Maximum number of articles to retrieve from each provider. + Returns: + dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles + """ + return self.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query, limit)) + NEWS_INSTRUCTIONS = """ **TASK:** You are a specialized **Crypto News Analyst**. Your goal is to fetch the latest news or top headlines related to cryptocurrencies, and then **analyze the sentiment** of the content to provide a concise report to the team leader. Prioritize 'crypto' or specific cryptocurrency names (e.g., 'Bitcoin', 'Ethereum') in your searches. @@ -59,6 +78,8 @@ NEWS_INSTRUCTIONS = """ **AVAILABLE TOOLS:** 1. `get_latest_news(query: str, limit: int)`: Get the 'limit' most recent news articles for a specific 'query'. 2. `get_top_headlines(limit: int)`: Get the 'limit' top global news headlines. +3. `get_latest_news_aggregated(query: str, limit: int)`: Get aggregated latest news articles for a specific 'query'. +4. `get_top_headlines_aggregated(limit: int)`: Get aggregated top global news headlines. **USAGE GUIDELINE:** * Always use `get_latest_news` with a relevant crypto-related query first. diff --git a/src/app/news/news_api.py b/src/app/news/news_api.py index 415fdac..6f62ef6 100644 --- a/src/app/news/news_api.py +++ b/src/app/news/news_api.py @@ -19,7 +19,7 @@ class NewsApiWrapper(NewsWrapper): def __init__(self): api_key = os.getenv("NEWS_API_KEY") - assert api_key is not None, "NEWS_API_KEY environment variable not set" + assert api_key, "NEWS_API_KEY environment variable not set" self.client = newsapi.NewsApiClient(api_key=api_key) self.category = "business" # Cryptocurrency is under business diff --git a/src/app/pipeline.py b/src/app/pipeline.py index 7a440de..10dddab 100644 --- a/src/app/pipeline.py +++ b/src/app/pipeline.py @@ -1,11 +1,9 @@ -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.news import NewsAPIsTool, NEWS_INSTRUCTIONS +from app.social import SocialAPIsTool, SOCIAL_INSTRUCTIONS +from app.markets import MarketAPIsTool, MARKET_INSTRUCTIONS from app.models import AppModels from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS @@ -17,12 +15,38 @@ class Pipeline: def __init__(self): # Inizializza gli agenti - self.market_agent = MarketAgent() - self.news_agent = NewsAgent() - self.social_agent = SocialAgent() + market_agent = AppModels.OLLAMA_QWEN_1B.get_agent( + instructions=MARKET_INSTRUCTIONS, + name="MarketAgent", + tools=[MarketAPIsTool()] + ) + news_agent = AppModels.OLLAMA_QWEN_1B.get_agent( + instructions=NEWS_INSTRUCTIONS, + name="NewsAgent", + tools=[NewsAPIsTool()] + ) + social_agent = AppModels.OLLAMA_QWEN_1B.get_agent( + instructions=SOCIAL_INSTRUCTIONS, + name="SocialAgent", + tools=[SocialAPIsTool()] + ) # Crea il Team - self.team = Team(name="CryptoAnalysisTeam", members=[self.market_agent, self.news_agent, self.social_agent]) + prompt = """ + You are the coordinator of a team of analysts specialized in cryptocurrency market analysis. + Your role is to gather insights from various sources, including market data, news articles, and social media trends. + Based on the information provided by your team members, you will synthesize a comprehensive sentiment analysis for each cryptocurrency discussed. + Your analysis should consider the following aspects: + 1. Market Trends: Evaluate the current market trends and price movements. + 2. News Impact: Assess the impact of recent news articles on market sentiment. + 3. Social Media Buzz: Analyze social media discussions and trends related to the cryptocurrencies. + Your final output should be a well-rounded sentiment analysis that can guide investment decisions. + """ # TODO migliorare il prompt + self.team = Team( + model = AppModels.OLLAMA_QWEN_1B.get_model(prompt), + name="CryptoAnalysisTeam", + members=[market_agent, news_agent, social_agent] + ) # Modelli disponibili e Predictor self.available_models = AppModels.availables() @@ -76,8 +100,8 @@ class Pipeline: return output - def list_providers(self) -> List[str]: + def list_providers(self) -> list[str]: return [m.name for m in self.available_models] - def list_styles(self) -> List[str]: + def list_styles(self) -> list[str]: return [s.value for s in self.styles] diff --git a/src/app/social/reddit.py b/src/app/social/reddit.py index 6028010..904448d 100644 --- a/src/app/social/reddit.py +++ b/src/app/social/reddit.py @@ -4,8 +4,8 @@ from praw.models import Submission, MoreComments from .base import SocialWrapper, SocialPost, SocialComment MAX_COMMENTS = 5 -# TODO mettere piu' subreddit? -# scelti da https://lkiconsulting.io/marketing/best-crypto-subreddits/ +# metterne altri se necessario. +# fonti: https://lkiconsulting.io/marketing/best-crypto-subreddits/ SUBREDDITS = [ "CryptoCurrency", "Bitcoin", @@ -51,10 +51,10 @@ class RedditWrapper(SocialWrapper): def __init__(self): client_id = os.getenv("REDDIT_API_CLIENT_ID") - assert client_id is not None, "REDDIT_API_CLIENT_ID environment variable is not set" + assert client_id, "REDDIT_API_CLIENT_ID environment variable is not set" client_secret = os.getenv("REDDIT_API_CLIENT_SECRET") - assert client_secret is not None, "REDDIT_API_CLIENT_SECRET environment variable is not set" + assert client_secret, "REDDIT_API_CLIENT_SECRET environment variable is not set" self.tool = Reddit( client_id=client_id, diff --git a/src/app/toolkits/__init__.py b/src/app/toolkits/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/toolkits/market_toolkit.py b/src/app/toolkits/market_toolkit.py deleted file mode 100644 index 7267b96..0000000 --- a/src/app/toolkits/market_toolkit.py +++ /dev/null @@ -1,29 +0,0 @@ -from agno.tools import Toolkit -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 -# per ora mettiamo tutto statico e poi, se abbiamo API-Key senza limiti -# possiamo fare in modo di far scegliere alla LLM quale crypto proporre -# in base alle sue proprie chiamate API -class MarketToolkit(Toolkit): - def __init__(self): - self.market_api = MarketAPIs() - - super().__init__( - name="Market Toolkit", - tools=[ - self.market_api.get_historical_prices, - self.market_api.get_product, - ], - ) - -def instructions(): - return """ - Utilizza questo strumento per ottenere dati di mercato storici e attuali per criptovalute specifiche. - Puoi richiedere i prezzi storici o il prezzo attuale di una criptovaluta specifica. - Esempio di utilizzo: - - get_historical_prices("BTC", limit=10) # ottieni gli ultimi 10 prezzi storici di Bitcoin - - get_product("ETH") - """ diff --git a/src/app/utils/aggregated_models.py b/src/app/utils/aggregated_models.py deleted file mode 100644 index ee9f3ef..0000000 --- a/src/app/utils/aggregated_models.py +++ /dev/null @@ -1,186 +0,0 @@ -import statistics -from typing import Dict, List, Optional, Set -from pydantic import BaseModel, Field, PrivateAttr -from app.markets.base import ProductInfo - -class AggregationMetadata(BaseModel): - """Metadati nascosti per debugging e audit trail""" - sources_used: Set[str] = Field(default_factory=set, description="Exchange usati nell'aggregazione") - sources_ignored: Set[str] = Field(default_factory=set, description="Exchange ignorati (errori)") - aggregation_timestamp: str = Field(default="", description="Timestamp dell'aggregazione") - confidence_score: float = Field(default=0.0, description="Score 0-1 sulla qualitร  dei dati") - - class Config: - # Nasconde questi campi dalla serializzazione di default - extra = "forbid" - -class AggregatedProductInfo(ProductInfo): - """ - Versione aggregata di ProductInfo che mantiene la trasparenza per l'utente finale - mentre fornisce metadati di debugging opzionali. - """ - - # Override dei campi con logica di aggregazione - id: str = Field(description="ID aggregato basato sul simbolo standardizzato") - status: str = Field(description="Status aggregato (majority vote o conservative)") - - # Campi privati per debugging (non visibili di default) - _metadata: Optional[AggregationMetadata] = PrivateAttr(default=None) - _source_data: Optional[Dict[str, ProductInfo]] = PrivateAttr(default=None) - - @classmethod - def from_multiple_sources(cls, products: List[ProductInfo]) -> 'AggregatedProductInfo': - """ - Crea un AggregatedProductInfo da una lista di ProductInfo. - Usa strategie intelligenti per gestire ID e status. - """ - if not products: - raise ValueError("Nessun prodotto da aggregare") - - # Raggruppa per symbol (la chiave vera per l'aggregazione) - symbol_groups = {} - for product in products: - if product.symbol not in symbol_groups: - symbol_groups[product.symbol] = [] - symbol_groups[product.symbol].append(product) - - # Per ora gestiamo un symbol alla volta - if len(symbol_groups) > 1: - raise ValueError(f"Simboli multipli non supportati: {list(symbol_groups.keys())}") - - symbol_products = list(symbol_groups.values())[0] - - # Estrai tutte le fonti - sources = [] - for product in symbol_products: - # Determina la fonte dall'ID o da altri metadati se disponibili - source = cls._detect_source(product) - sources.append(source) - - # Aggrega i dati - aggregated_data = cls._aggregate_products(symbol_products, sources) - - # Crea l'istanza e assegna gli attributi privati - instance = cls(**aggregated_data) - instance._metadata = aggregated_data.get("_metadata") - instance._source_data = aggregated_data.get("_source_data") - - return instance - - @staticmethod - def _detect_source(product: ProductInfo) -> str: - """Rileva la fonte da un ProductInfo""" - # Strategia semplice: usa pattern negli ID - if "coinbase" in product.id.lower() or "cb" in product.id.lower(): - return "coinbase" - elif "binance" in product.id.lower() or "bn" in product.id.lower(): - return "binance" - elif "crypto" in product.id.lower() or "cc" in product.id.lower(): - return "cryptocompare" - elif "yfinance" in product.id.lower() or "yf" in product.id.lower(): - return "yfinance" - else: - return "unknown" - - @classmethod - def _aggregate_products(cls, products: List[ProductInfo], sources: List[str]) -> dict: - """ - Logica di aggregazione principale. - Gestisce ID, status e altri campi numerici. - """ - import statistics - from datetime import datetime - - # ID: usa il symbol come chiave standardizzata - symbol = products[0].symbol - aggregated_id = f"{symbol}_AGG" - - # Status: strategia "conservativa" - il piรน restrittivo vince - # Ordine: trading_only < limit_only < auction < maintenance < offline - status_priority = { - "trading": 1, - "limit_only": 2, - "auction": 3, - "maintenance": 4, - "offline": 5, - "": 0 # Default se non specificato - } - - statuses = [p.status for p in products if p.status] - if statuses: - # Prendi lo status con prioritร  piรน alta (piรน restrittivo) - aggregated_status = max(statuses, key=lambda s: status_priority.get(s, 0)) - else: - aggregated_status = "trading" # Default ottimistico - - # Prezzo: media semplice (uso diretto del campo price come float) - prices = [p.price for p in products if p.price > 0] - aggregated_price = statistics.mean(prices) if prices else 0.0 - - # Volume: somma (assumendo che i volumi siano esclusivi per exchange) - volumes = [p.volume_24h for p in products if p.volume_24h > 0] - total_volume = sum(volumes) - aggregated_volume = sum(price_i * volume_i for price_i, volume_i in zip((p.price for p in products), (volume for volume in volumes))) / total_volume - aggregated_volume = round(aggregated_volume, 5) - # aggregated_volume = sum(volumes) if volumes else 0.0 # NOTE old implementation - - # Valuta: prendi la prima (dovrebbero essere tutte uguali) - quote_currency = next((p.quote_currency for p in products if p.quote_currency), "USD") - - # Calcola confidence score - confidence = cls._calculate_confidence(products, sources) - - # Crea metadati per debugging - metadata = AggregationMetadata( - sources_used=set(sources), - aggregation_timestamp=datetime.now().isoformat(), - confidence_score=confidence - ) - - # Salva dati sorgente per debugging - source_data = dict(zip(sources, products)) - - return { - "symbol": symbol, - "price": aggregated_price, - "volume_24h": aggregated_volume, - "quote_currency": quote_currency, - "id": aggregated_id, - "status": aggregated_status, - "_metadata": metadata, - "_source_data": source_data - } - - @staticmethod - def _calculate_confidence(products: List[ProductInfo], sources: List[str]) -> float: - """Calcola un punteggio di confidenza 0-1""" - if not products: - return 0.0 - - score = 1.0 - - # Riduci score se pochi dati - if len(products) < 2: - score *= 0.7 - - # Riduci score se prezzi troppo diversi - prices = [p.price for p in products if p.price > 0] - if len(prices) > 1: - price_std = (max(prices) - min(prices)) / statistics.mean(prices) - if price_std > 0.05: # >5% variazione - score *= 0.8 - - # Riduci score se fonti sconosciute - unknown_sources = sum(1 for s in sources if s == "unknown") - if unknown_sources > 0: - score *= (1 - unknown_sources / len(sources)) - - return max(0.0, min(1.0, score)) - - def get_debug_info(self) -> dict: - """Metodo opzionale per ottenere informazioni di debug""" - return { - "aggregated_product": self.dict(), - "metadata": self._metadata.dict() if self._metadata else None, - "sources": list(self._source_data.keys()) if self._source_data else [] - } \ No newline at end of file diff --git a/src/app/utils/market_aggregation.py b/src/app/utils/market_aggregation.py new file mode 100644 index 0000000..f20e4fb --- /dev/null +++ b/src/app/utils/market_aggregation.py @@ -0,0 +1,91 @@ +import statistics +from app.markets.base import ProductInfo, Price + + +def aggregate_history_prices(prices: dict[str, list[Price]]) -> list[Price]: + """ + Aggrega i prezzi storici per symbol calcolando la media oraria. + Args: + prices (dict[str, list[Price]]): Mappa provider -> lista di Price + Returns: + list[Price]: Lista di Price aggregati per ora + """ + + # Costruiamo una mappa timestamp_h -> lista di Price + timestamped_prices: dict[int, list[Price]] = {} + for _, price_list in prices.items(): + for price in price_list: + time = price.timestamp_ms - (price.timestamp_ms % 3600000) # arrotonda all'ora (non dovrebbe essere necessario) + timestamped_prices.setdefault(time, []).append(price) + + # Ora aggregiamo i prezzi per ogni ora + aggregated_prices = [] + for time, price_list in timestamped_prices.items(): + price = Price() + price.timestamp_ms = time + price.high = statistics.mean([p.high for p in price_list]) + price.low = statistics.mean([p.low for p in price_list]) + price.open = statistics.mean([p.open for p in price_list]) + price.close = statistics.mean([p.close for p in price_list]) + price.volume = statistics.mean([p.volume for p in price_list]) + aggregated_prices.append(price) + return aggregated_prices + +def aggregate_product_info(products: dict[str, list[ProductInfo]]) -> list[ProductInfo]: + """ + Aggrega una lista di ProductInfo per symbol. + Args: + products (dict[str, list[ProductInfo]]): Mappa provider -> lista di ProductInfo + Returns: + list[ProductInfo]: Lista di ProductInfo aggregati per symbol + """ + + # Costruzione mappa symbol -> lista di ProductInfo + symbols_infos: dict[str, list[ProductInfo]] = {} + for _, product_list in products.items(): + for product in product_list: + symbols_infos.setdefault(product.symbol, []).append(product) + + # Aggregazione per ogni symbol + sources = list(products.keys()) + aggregated_products = [] + for symbol, product_list in symbols_infos.items(): + product = ProductInfo() + + product.id = f"{symbol}_AGGREGATED" + product.symbol = symbol + product.quote_currency = next(p.quote_currency for p in product_list if p.quote_currency) + + volume_sum = sum(p.volume_24h for p in product_list) + product.volume_24h = volume_sum / len(product_list) if product_list else 0.0 + + prices = sum(p.price * p.volume_24h for p in product_list) + product.price = (prices / volume_sum) if volume_sum > 0 else 0.0 + + aggregated_products.append(product) + return aggregated_products + +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)) diff --git a/src/app/utils/market_data_aggregator.py b/src/app/utils/market_data_aggregator.py deleted file mode 100644 index ea2d7c0..0000000 --- a/src/app/utils/market_data_aggregator.py +++ /dev/null @@ -1,184 +0,0 @@ -from typing import List, Optional, Dict, Any -from app.markets.base import ProductInfo, Price -from app.utils.aggregated_models import AggregatedProductInfo - -class MarketDataAggregator: - """ - Aggregatore di dati di mercato che mantiene la trasparenza per l'utente. - - Compone MarketAPIs per fornire gli stessi metodi, ma restituisce dati aggregati - da tutte le fonti disponibili. L'utente finale non vede la complessitร . - """ - - def __init__(self, currency: str = "USD"): - # Import lazy per evitare circular import - from app.markets import MarketAPIsTool - self._market_apis = MarketAPIsTool(currency) - self._aggregation_enabled = True - - def get_product(self, asset_id: str) -> ProductInfo: - """ - Override che aggrega dati da tutte le fonti disponibili. - Per l'utente sembra un normale ProductInfo. - """ - if not self._aggregation_enabled: - return self._market_apis.get_product(asset_id) - - # Raccogli dati da tutte le fonti - try: - raw_results = self.wrappers.try_call_all( - lambda wrapper: wrapper.get_product(asset_id) - ) - - # Converti in ProductInfo se necessario - products = [] - for wrapper_class, result in raw_results.items(): - if isinstance(result, ProductInfo): - products.append(result) - elif isinstance(result, dict): - # Converti dizionario in ProductInfo - products.append(ProductInfo(**result)) - - if not products: - raise Exception("Nessun dato disponibile") - - # Aggrega i risultati - aggregated = AggregatedProductInfo.from_multiple_sources(products) - - # Restituisci come ProductInfo normale (nascondi la complessitร ) - return ProductInfo(**aggregated.dict(exclude={"_metadata", "_source_data"})) - - except Exception as e: - # Fallback: usa il comportamento normale se l'aggregazione fallisce - return self._market_apis.get_product(asset_id) - - def get_products(self, asset_ids: List[str]) -> List[ProductInfo]: - """ - Aggrega dati per multiple asset. - """ - if not self._aggregation_enabled: - return self._market_apis.get_products(asset_ids) - - aggregated_products = [] - - for asset_id in asset_ids: - try: - product = self.get_product(asset_id) - aggregated_products.append(product) - except Exception as e: - # Salta asset che non riescono ad aggregare - continue - - return aggregated_products - - def get_all_products(self) -> List[ProductInfo]: - """ - Aggrega tutti i prodotti disponibili. - """ - if not self._aggregation_enabled: - return self._market_apis.get_all_products() - - # Raccogli tutti i prodotti da tutte le fonti - try: - all_products_by_source = self.wrappers.try_call_all( - lambda wrapper: wrapper.get_all_products() - ) - - # Raggruppa per symbol per aggregare - symbol_groups = {} - for wrapper_class, products in all_products_by_source.items(): - if not isinstance(products, list): - continue - - for product in products: - if isinstance(product, dict): - product = ProductInfo(**product) - - if product.symbol not in symbol_groups: - symbol_groups[product.symbol] = [] - symbol_groups[product.symbol].append(product) - - # Aggrega ogni gruppo - aggregated_products = [] - for symbol, products in symbol_groups.items(): - try: - aggregated = AggregatedProductInfo.from_multiple_sources(products) - # Restituisci come ProductInfo normale - aggregated_products.append( - ProductInfo(**aggregated.dict(exclude={"_metadata", "_source_data"})) - ) - except Exception: - # Se l'aggregazione fallisce, usa il primo disponibile - if products: - aggregated_products.append(products[0]) - - return aggregated_products - - except Exception as e: - # Fallback: usa il comportamento normale - return self._market_apis.get_all_products() - - def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> List[Price]: - """ - Per i dati storici, usa una strategia diversa: - prendi i dati dalla fonte piรน affidabile o aggrega se possibile. - """ - if not self._aggregation_enabled: - return self._market_apis.get_historical_prices(asset_id, limit) - - # Per dati storici, usa il primo wrapper che funziona - # (l'aggregazione di dati storici รจ piรน complessa) - try: - return self.wrappers.try_call( - lambda wrapper: wrapper.get_historical_prices(asset_id, limit) - ) - except Exception as e: - # Fallback: usa il comportamento normale - return self._market_apis.get_historical_prices(asset_id, limit) - - def enable_aggregation(self, enabled: bool = True): - """Abilita o disabilita l'aggregazione""" - self._aggregation_enabled = enabled - - def is_aggregation_enabled(self) -> bool: - """Controlla se l'aggregazione รจ abilitata""" - return self._aggregation_enabled - - # Metodi proxy per completare l'interfaccia BaseWrapper - @property - def wrappers(self): - """Accesso al wrapper handler per compatibilitร """ - return self._market_apis.wrappers - - def get_aggregated_product_with_debug(self, asset_id: str) -> Dict[str, Any]: - """ - Metodo speciale per debugging: restituisce dati aggregati con metadati. - Usato solo per testing e monitoraggio. - """ - try: - raw_results = self.wrappers.try_call_all( - lambda wrapper: wrapper.get_product(asset_id) - ) - - products = [] - for wrapper_class, result in raw_results.items(): - if isinstance(result, ProductInfo): - products.append(result) - elif isinstance(result, dict): - products.append(ProductInfo(**result)) - - if not products: - raise Exception("Nessun dato disponibile") - - aggregated = AggregatedProductInfo.from_multiple_sources(products) - - return { - "product": aggregated.dict(exclude={"_metadata", "_source_data"}), - "debug": aggregated.get_debug_info() - } - - except Exception as e: - return { - "error": str(e), - "debug": {"error": str(e)} - } \ No newline at end of file diff --git a/src/app/utils/wrapper_handler.py b/src/app/utils/wrapper_handler.py index 4f22a8e..40fe371 100644 --- a/src/app/utils/wrapper_handler.py +++ b/src/app/utils/wrapper_handler.py @@ -1,3 +1,4 @@ +import inspect import time import traceback from typing import TypeVar, Callable, Generic, Iterable, Type @@ -45,17 +46,24 @@ class WrapperHandler(Generic[W]): Raises: Exception: If all wrappers fail after retries. """ + log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}") + iterations = 0 while iterations < len(self.wrappers): + wrapper = self.wrappers[self.index] + wrapper_name = wrapper.__class__.__name__ + try: - wrapper = self.wrappers[self.index] - log_info(f"Trying wrapper: {wrapper} - function {func}") + log_info(f"try_call {wrapper_name}") result = func(wrapper) + log_info(f"{wrapper_name} succeeded") self.retry_count = 0 return result + except Exception as e: self.retry_count += 1 - log_warning(f"{wrapper} failed {self.retry_count}/{self.retry_per_wrapper}: {WrapperHandler.__concise_error(e)}") + error = WrapperHandler.__concise_error(e) + log_warning(f"{wrapper_name} failed {self.retry_count}/{self.retry_per_wrapper}: {error}") if self.retry_count >= self.retry_per_wrapper: self.index = (self.index + 1) % len(self.wrappers) @@ -64,7 +72,7 @@ class WrapperHandler(Generic[W]): else: time.sleep(self.retry_delay) - raise Exception(f"All wrappers failed after retries") + raise Exception(f"All wrappers failed, latest error: {error}") def try_call_all(self, func: Callable[[W], T]) -> dict[str, T]: """ @@ -78,28 +86,33 @@ class WrapperHandler(Generic[W]): Raises: Exception: If all wrappers fail. """ + log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}") + results = {} - log_info(f"All wrappers: {[wrapper.__class__ for wrapper in self.wrappers]} - function {func}") for wrapper in self.wrappers: + wrapper_name = wrapper.__class__.__name__ try: result = func(wrapper) + log_info(f"{wrapper_name} succeeded") results[wrapper.__class__] = result except Exception as e: - log_warning(f"{wrapper} failed: {WrapperHandler.__concise_error(e)}") + error = WrapperHandler.__concise_error(e) + log_warning(f"{wrapper_name} failed: {error}") if not results: - raise Exception("All wrappers failed") + raise Exception(f"All wrappers failed, latest error: {error}") return results @staticmethod def __check(wrappers: list[W]) -> bool: return all(w.__class__ is type for w in wrappers) + @staticmethod def __concise_error(e: Exception) -> str: last_frame = traceback.extract_tb(e.__traceback__)[-1] return f"{e} [\"{last_frame.filename}\", line {last_frame.lineno}]" @staticmethod - def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2) -> 'WrapperHandler[W]': + def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2, kwargs: dict | None = None) -> '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. @@ -108,6 +121,7 @@ class WrapperHandler(Generic[W]): 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. + kwargs (dict | None): Optional dictionary with keyword arguments common to all wrappers. Returns: WrapperHandler[W]: An instance of WrapperHandler with the initialized wrappers. Raises: @@ -118,7 +132,7 @@ class WrapperHandler(Generic[W]): result = [] for wrapper_class in constructors: try: - wrapper = wrapper_class() + wrapper = wrapper_class(**(kwargs or {})) result.append(wrapper) except Exception as e: log_warning(f"{wrapper_class} cannot be initialized: {e}") diff --git a/tests/api/test_binance.py b/tests/api/test_binance.py index e4e0c20..dc4bfcb 100644 --- a/tests/api/test_binance.py +++ b/tests/api/test_binance.py @@ -45,8 +45,9 @@ class TestBinance: assert isinstance(history, list) assert len(history) == 5 for entry in history: - assert hasattr(entry, 'time') + assert hasattr(entry, 'timestamp_ms') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 + assert entry.timestamp_ms > 0 diff --git a/tests/api/test_coinbase.py b/tests/api/test_coinbase.py index b5f92e8..3ab8d43 100644 --- a/tests/api/test_coinbase.py +++ b/tests/api/test_coinbase.py @@ -47,8 +47,9 @@ class TestCoinBase: assert isinstance(history, list) assert len(history) == 5 for entry in history: - assert hasattr(entry, 'time') + assert hasattr(entry, 'timestamp_ms') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 + assert entry.timestamp_ms > 0 diff --git a/tests/api/test_cryptocompare.py b/tests/api/test_cryptocompare.py index 52aef9a..3c9133a 100644 --- a/tests/api/test_cryptocompare.py +++ b/tests/api/test_cryptocompare.py @@ -49,8 +49,9 @@ class TestCryptoCompare: assert isinstance(history, list) assert len(history) == 5 for entry in history: - assert hasattr(entry, 'time') + assert hasattr(entry, 'timestamp_ms') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 + assert entry.timestamp_ms > 0 diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 4b6b192..839941c 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -5,7 +5,7 @@ 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") +@pytest.mark.skipif(not os.getenv("NEWS_API_KEY"), reason="NEWS_API_KEY not set in environment variables") class TestNewsAPI: def test_news_api_initialization(self): diff --git a/tests/api/test_reddit.py b/tests/api/test_reddit.py index 81ab8ca..59cd61f 100644 --- a/tests/api/test_reddit.py +++ b/tests/api/test_reddit.py @@ -1,9 +1,11 @@ +import os import pytest from praw import Reddit from app.social.reddit import MAX_COMMENTS, RedditWrapper @pytest.mark.social @pytest.mark.api +@pytest.mark.skipif(not(os.getenv("REDDIT_API_CLIENT_ID")) or not os.getenv("REDDIT_API_CLIENT_SECRET"), reason="REDDIT_CLIENT_ID and REDDIT_API_CLIENT_SECRET not set in environment variables") class TestRedditWrapper: def test_initialization(self): wrapper = RedditWrapper() diff --git a/tests/api/test_yfinance.py b/tests/api/test_yfinance.py index c0e9ba2..4971ccd 100644 --- a/tests/api/test_yfinance.py +++ b/tests/api/test_yfinance.py @@ -1,4 +1,3 @@ -import os import pytest from app.markets import YFinanceWrapper @@ -14,17 +13,6 @@ class TestYFinance: assert hasattr(market, 'tool') assert market.tool is not None - def test_yfinance_get_product(self): - market = YFinanceWrapper() - product = market.get_product("AAPL") - assert product is not None - assert hasattr(product, 'symbol') - assert product.symbol == "AAPL" - assert hasattr(product, 'price') - assert product.price > 0 - assert hasattr(product, 'status') - assert product.status == "trading" - def test_yfinance_get_crypto_product(self): market = YFinanceWrapper() product = market.get_product("BTC") @@ -37,57 +25,32 @@ class TestYFinance: def test_yfinance_get_products(self): market = YFinanceWrapper() - products = market.get_products(["AAPL", "GOOGL"]) + 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 "AAPL" in symbols - assert "GOOGL" in symbols + assert "BTC" in symbols + assert "ETH" in symbols for product in products: assert hasattr(product, 'price') assert product.price > 0 - def test_yfinance_get_all_products(self): - market = YFinanceWrapper() - products = market.get_all_products() - assert products is not None - assert isinstance(products, list) - assert len(products) > 0 - # Dovrebbe contenere asset popolari - symbols = [p.symbol for p in products] - assert "AAPL" in symbols # Apple dovrebbe essere nella lista - for product in products: - assert hasattr(product, 'symbol') - assert hasattr(product, 'price') - def test_yfinance_invalid_product(self): market = YFinanceWrapper() - # Per YFinance, un prodotto invalido dovrebbe restituire un prodotto offline - product = market.get_product("INVALIDSYMBOL123") - assert product is not None - assert product.status == "offline" + with pytest.raises(Exception): + _ = market.get_product("INVALIDSYMBOL123") - def test_yfinance_history(self): + def test_yfinance_crypto_history(self): market = YFinanceWrapper() - history = market.get_historical_prices("AAPL", limit=5) + 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, 'timestamp_ms') assert hasattr(entry, 'close') assert hasattr(entry, 'high') assert entry.close > 0 assert entry.high > 0 - - def test_yfinance_crypto_history(self): - market = YFinanceWrapper() - history = market.get_historical_prices("BTC", limit=3) - assert history is not None - assert isinstance(history, list) - assert len(history) == 3 - for entry in history: - assert hasattr(entry, 'time') - assert hasattr(entry, 'close') - assert entry.close > 0 \ No newline at end of file + assert entry.timestamp_ms > 0 diff --git a/tests/conftest.py b/tests/conftest.py index 2b7cf90..290fbf2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,17 +14,20 @@ def pytest_configure(config:pytest.Config): markers = [ ("slow", "marks tests as slow (deselect with '-m \"not slow\"')"), + ("limited", "marks tests that have limited execution due to API constraints"), + ("api", "marks tests that require API access"), ("market", "marks tests that use market data"), + ("news", "marks tests that use news"), + ("social", "marks tests that use social media"), + ("wrapper", "marks tests for wrapper handler"), + + ("tools", "marks tests for tools"), + ("aggregator", "marks tests for market data aggregator"), + ("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"), - ("aggregator", "marks tests for market data aggregator"), ] for marker in markers: line = f"{marker[0]}: {marker[1]}" diff --git a/tests/tools/test_market_tool.py b/tests/tools/test_market_tool.py index 0d6d1a1..c6da5a8 100644 --- a/tests/tools/test_market_tool.py +++ b/tests/tools/test_market_tool.py @@ -1,10 +1,9 @@ -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.market @pytest.mark.api class TestMarketAPIsTool: def test_wrapper_initialization(self): @@ -12,7 +11,6 @@ class TestMarketAPIsTool: 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): @@ -34,27 +32,6 @@ class TestMarketAPIsTool: 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") @@ -62,9 +39,3 @@ class TestMarketAPIsTool: 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/tools/test_news_tool.py b/tests/tools/test_news_tool.py index 14d142f..5a57f82 100644 --- a/tests/tools/test_news_tool.py +++ b/tests/tools/test_news_tool.py @@ -2,7 +2,6 @@ import pytest from app.news import NewsAPIsTool -@pytest.mark.limited @pytest.mark.tools @pytest.mark.news @pytest.mark.api @@ -34,10 +33,8 @@ class TestNewsAPITool: result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 - print("Results from providers:", result.keys()) for provider, articles in result.items(): for article in articles: - print(provider, article.title) assert article.title is not None assert article.source is not None @@ -46,9 +43,7 @@ class TestNewsAPITool: result = tool.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query="crypto", limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 - print("Results from providers:", result.keys()) for provider, articles in result.items(): for article in articles: - print(provider, article.title) assert article.title is not None assert article.source is not None diff --git a/tests/tools/test_socials_tool.py b/tests/tools/test_socials_tool.py index 9c66afa..d08ed0f 100644 --- a/tests/tools/test_socials_tool.py +++ b/tests/tools/test_socials_tool.py @@ -24,9 +24,7 @@ class TestSocialAPIsTool: result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_crypto_posts(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 - print("Results from providers:", result.keys()) for provider, posts in result.items(): for post in posts: - print(provider, post.title) assert post.title is not None assert post.time is not None diff --git a/tests/utils/test_market_aggregator.py b/tests/utils/test_market_aggregator.py new file mode 100644 index 0000000..d7881ef --- /dev/null +++ b/tests/utils/test_market_aggregator.py @@ -0,0 +1,120 @@ +import pytest +from app.markets.base import ProductInfo, Price +from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info + + +@pytest.mark.aggregator +@pytest.mark.market +class TestMarketDataAggregator: + + def __product(self, symbol: str, price: float, volume: float, currency: str) -> ProductInfo: + prod = ProductInfo() + prod.id=f"{symbol}-{currency}" + prod.symbol=symbol + prod.price=price + prod.volume_24h=volume + prod.quote_currency=currency + return prod + + def __price(self, timestamp_ms: int, high: float, low: float, open: float, close: float, volume: float) -> Price: + price = Price() + price.timestamp_ms = timestamp_ms + price.high = high + price.low = low + price.open = open + price.close = close + price.volume = volume + return price + + def test_aggregate_product_info(self): + products: dict[str, list[ProductInfo]] = { + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], + "Provider2": [self.__product("BTC", 50100.0, 1100.0, "USD")], + "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD")], + } + + aggregated = aggregate_product_info(products) + assert len(aggregated) == 1 + + info = aggregated[0] + assert info is not None + assert info.symbol == "BTC" + + avg_weighted_price = (50000.0 * 1000.0 + 50100.0 * 1100.0 + 49900.0 * 900.0) / (1000.0 + 1100.0 + 900.0) + assert info.price == pytest.approx(avg_weighted_price, rel=1e-3) + assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) + assert info.quote_currency == "USD" + + def test_aggregate_product_info_multiple_symbols(self): + products = { + "Provider1": [ + self.__product("BTC", 50000.0, 1000.0, "USD"), + self.__product("ETH", 4000.0, 2000.0, "USD"), + ], + "Provider2": [ + self.__product("BTC", 50100.0, 1100.0, "USD"), + self.__product("ETH", 4050.0, 2100.0, "USD"), + ], + } + + aggregated = aggregate_product_info(products) + assert len(aggregated) == 2 + + btc_info = next((p for p in aggregated if p.symbol == "BTC"), None) + eth_info = next((p for p in aggregated if p.symbol == "ETH"), None) + + assert btc_info is not None + avg_weighted_price_btc = (50000.0 * 1000.0 + 50100.0 * 1100.0) / (1000.0 + 1100.0) + assert btc_info.price == pytest.approx(avg_weighted_price_btc, rel=1e-3) + assert btc_info.volume_24h == pytest.approx(1050.0, rel=1e-3) + assert btc_info.quote_currency == "USD" + + assert eth_info is not None + avg_weighted_price_eth = (4000.0 * 2000.0 + 4050.0 * 2100.0) / (2000.0 + 2100.0) + assert eth_info.price == pytest.approx(avg_weighted_price_eth, rel=1e-3) + assert eth_info.volume_24h == pytest.approx(2050.0, rel=1e-3) + assert eth_info.quote_currency == "USD" + + def test_aggregate_product_info_with_no_data(self): + products = { + "Provider1": [], + "Provider2": [], + } + aggregated = aggregate_product_info(products) + assert len(aggregated) == 0 + + def test_aggregate_product_info_with_partial_data(self): + products = { + "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], + "Provider2": [], + } + aggregated = aggregate_product_info(products) + assert len(aggregated) == 1 + info = aggregated[0] + assert info.symbol == "BTC" + assert info.price == pytest.approx(50000.0, rel=1e-3) + assert info.volume_24h == pytest.approx(1000.0, rel=1e-3) + assert info.quote_currency == "USD" + + def test_aggregate_history_prices(self): + """Test aggregazione di prezzi storici usando aggregate_history_prices""" + + prices = { + "Provider1": [ + self.__price(1685577600000, 50000.0, 49500.0, 49600.0, 49900.0, 150.0), + self.__price(1685581200000, 50200.0, 49800.0, 50000.0, 50100.0, 200.0), + ], + "Provider2": [ + self.__price(1685577600000, 50100.0, 49600.0, 49700.0, 50000.0, 180.0), + self.__price(1685581200000, 50300.0, 49900.0, 50100.0, 50200.0, 220.0), + ], + } + + aggregated = aggregate_history_prices(prices) + assert len(aggregated) == 2 + assert aggregated[0].timestamp_ms == 1685577600000 + assert aggregated[0].high == pytest.approx(50050.0, rel=1e-3) + assert aggregated[0].low == pytest.approx(49550.0, rel=1e-3) + assert aggregated[1].timestamp_ms == 1685581200000 + assert aggregated[1].high == pytest.approx(50250.0, rel=1e-3) + assert aggregated[1].low == pytest.approx(49850.0, rel=1e-3) diff --git a/tests/utils/test_market_data_aggregator.py b/tests/utils/test_market_data_aggregator.py deleted file mode 100644 index e8d1a6f..0000000 --- a/tests/utils/test_market_data_aggregator.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest -from app.utils.market_data_aggregator import MarketDataAggregator -from app.utils.aggregated_models import AggregatedProductInfo -from app.markets.base import ProductInfo, Price - -@pytest.mark.aggregator -@pytest.mark.limited -@pytest.mark.market -@pytest.mark.api -class TestMarketDataAggregator: - - def test_initialization(self): - """Test che il MarketDataAggregator si inizializzi correttamente""" - aggregator = MarketDataAggregator() - assert aggregator is not None - assert aggregator.is_aggregation_enabled() == True - - def test_aggregation_toggle(self): - """Test del toggle dell'aggregazione""" - aggregator = MarketDataAggregator() - - # Disabilita aggregazione - aggregator.enable_aggregation(False) - assert aggregator.is_aggregation_enabled() == False - - # Riabilita aggregazione - aggregator.enable_aggregation(True) - assert aggregator.is_aggregation_enabled() == True - - def test_aggregated_product_info_creation(self): - """Test creazione AggregatedProductInfo da fonti multiple""" - - # Crea dati di esempio - product1 = ProductInfo( - id="BTC-USD", - symbol="BTC-USD", - price=50000.0, - volume_24h=1000.0, - status="active", - quote_currency="USD" - ) - - product2 = ProductInfo( - id="BTC-USD", - symbol="BTC-USD", - price=50100.0, - volume_24h=1100.0, - status="active", - quote_currency="USD" - ) - - # Aggrega i prodotti - aggregated = AggregatedProductInfo.from_multiple_sources([product1, product2]) - - assert aggregated.symbol == "BTC-USD" - assert aggregated.price == pytest.approx(50050.0, rel=1e-3) # media tra 50000 e 50100 - assert aggregated.volume_24h == 50052.38095 # somma dei volumi - assert aggregated.status == "active" # majority vote - assert aggregated.id == "BTC-USD_AGG" # mapping_id con suffisso aggregazione - - def test_confidence_calculation(self): - """Test del calcolo della confidence""" - - product1 = ProductInfo( - id="BTC-USD", - symbol="BTC-USD", - price=50000.0, - volume_24h=1000.0, - status="active", - quote_currency="USD" - ) - - product2 = ProductInfo( - id="BTC-USD", - symbol="BTC-USD", - price=50100.0, - volume_24h=1100.0, - status="active", - quote_currency="USD" - ) - - aggregated = AggregatedProductInfo.from_multiple_sources([product1, product2]) - - # Verifica che ci siano metadati - assert aggregated._metadata is not None - assert len(aggregated._metadata.sources_used) > 0 - assert aggregated._metadata.aggregation_timestamp != "" - # La confidence puรฒ essere 0.0 se ci sono fonti "unknown" diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index 154d3dc..996f632 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -54,7 +54,7 @@ class TestWrapperHandler: 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) + assert "All wrappers failed" in str(exc_info.value) def test_success_on_first_try(self): wrappers = [MockWrapper, FailingWrapper] @@ -121,7 +121,6 @@ class TestWrapperHandler: handler_all_fail.try_call_all(lambda w: w.do_something()) assert "All wrappers failed" in str(exc_info.value) - def test_wrappers_with_parameters(self): wrappers = [FailingWrapperWithParameters, MockWrapperWithParameters] handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0) @@ -130,3 +129,24 @@ class TestWrapperHandler: assert result == "Success test and 42" assert handler.index == 1 # Should have switched to the second wrapper assert handler.retry_count == 0 + + def test_wrappers_with_parameters_all_fail(self): + wrappers = [FailingWrapperWithParameters, FailingWrapperWithParameters] + handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + + with pytest.raises(Exception) as exc_info: + handler.try_call(lambda w: w.do_something("test", 42)) + assert "All wrappers failed" in str(exc_info.value) + + def test_try_call_all_with_parameters(self): + wrappers = [FailingWrapperWithParameters, MockWrapperWithParameters] + handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + results = handler.try_call_all(lambda w: w.do_something("param", 99)) + assert results == {MockWrapperWithParameters: "Success param and 99"} + + def test_try_call_all_with_parameters_all_fail(self): + wrappers = [FailingWrapperWithParameters, FailingWrapperWithParameters] + handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0) + with pytest.raises(Exception) as exc_info: + handler.try_call_all(lambda w: w.do_something("param", 99)) + assert "All wrappers failed" in str(exc_info.value) diff --git a/uv.lock b/uv.lock index 9c977c3..d8114d6 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.12.*" [[package]] name = "agno" -version = "2.0.5" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -20,9 +20,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/30/53264fe9543fcb1d8b860ca3a3b705da5032e73f93818273d3169c3edc1b/agno-2.0.5.tar.gz", hash = "sha256:37d72fb98eb97c3abdb15be186f795b39f8ff668d97d9f9382e5971329e075d1", size = 855430, upload-time = "2025-09-17T02:45:10.348Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/c4/7a8915453846a4dea643b303d8e7448955c2221034309b10f7b2af1c9c3b/agno-2.1.0.tar.gz", hash = "sha256:0a840528fbc69bead7ff0dc6f28e59864af86f138db22a7b65bc9de71a141391", size = 906137, upload-time = "2025-10-01T11:32:21.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/f7/c216f0a218ef99958b77194613250131834f08d3a48b7a33bde6c638c5be/agno-2.0.5-py3-none-any.whl", hash = "sha256:9050bdb63feca2f421c0e3e73172ac269e82ed462e54b7318868a65107a89bd3", size = 1077050, upload-time = "2025-09-17T02:45:08.153Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/3ab4eabb9c135177956a353ead7b025c2b8e563a6be7213eb001a5bab446/agno-2.1.0-py3-none-any.whl", hash = "sha256:c0e0554ffbfddbc222b08d58d9e3225325e81880910ef1221a27227b7a522e27", size = 1134550, upload-time = "2025-10-01T11:32:19.102Z" }, ] [[package]] @@ -45,7 +45,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.11.16" +version = "3.12.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -56,24 +56,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/1c4721d143e14af753f2bf5e3b681883e1f24b592c0482df6fa6e33597fa/aiohttp-3.11.16.tar.gz", hash = "sha256:16f8a2c9538c14a557b4d309ed4d0a7c60f0253e8ed7b6c9a2859a7582f8b1b8", size = 7676826, upload-time = "2025-04-02T02:17:44.74Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/38/100d01cbc60553743baf0fba658cb125f8ad674a8a771f765cdc155a890d/aiohttp-3.11.16-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:911a6e91d08bb2c72938bc17f0a2d97864c531536b7832abee6429d5296e5b27", size = 704881, upload-time = "2025-04-02T02:16:09.26Z" }, - { url = "https://files.pythonhosted.org/packages/21/ed/b4102bb6245e36591209e29f03fe87e7956e54cb604ee12e20f7eb47f994/aiohttp-3.11.16-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac13b71761e49d5f9e4d05d33683bbafef753e876e8e5a7ef26e937dd766713", size = 464564, upload-time = "2025-04-02T02:16:10.781Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e1/a9ab6c47b62ecee080eeb33acd5352b40ecad08fb2d0779bcc6739271745/aiohttp-3.11.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd36c119c5d6551bce374fcb5c19269638f8d09862445f85a5a48596fd59f4bb", size = 456548, upload-time = "2025-04-02T02:16:12.764Z" }, - { url = "https://files.pythonhosted.org/packages/80/ad/216c6f71bdff2becce6c8776f0aa32cb0fa5d83008d13b49c3208d2e4016/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d489d9778522fbd0f8d6a5c6e48e3514f11be81cb0a5954bdda06f7e1594b321", size = 1691749, upload-time = "2025-04-02T02:16:14.304Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ea/7df7bcd3f4e734301605f686ffc87993f2d51b7acb6bcc9b980af223f297/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69a2cbd61788d26f8f1e626e188044834f37f6ae3f937bd9f08b65fc9d7e514e", size = 1736874, upload-time = "2025-04-02T02:16:16.538Z" }, - { url = "https://files.pythonhosted.org/packages/51/41/c7724b9c87a29b7cfd1202ec6446bae8524a751473d25e2ff438bc9a02bf/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd464ba806e27ee24a91362ba3621bfc39dbbb8b79f2e1340201615197370f7c", size = 1786885, upload-time = "2025-04-02T02:16:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/86/b3/f61f8492fa6569fa87927ad35a40c159408862f7e8e70deaaead349e2fba/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce63ae04719513dd2651202352a2beb9f67f55cb8490c40f056cea3c5c355ce", size = 1698059, upload-time = "2025-04-02T02:16:20.234Z" }, - { url = "https://files.pythonhosted.org/packages/ce/be/7097cf860a9ce8bbb0e8960704e12869e111abcd3fbd245153373079ccec/aiohttp-3.11.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b00dd520d88eac9d1768439a59ab3d145065c91a8fab97f900d1b5f802895e", size = 1626527, upload-time = "2025-04-02T02:16:22.092Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1d/aaa841c340e8c143a8d53a1f644c2a2961c58cfa26e7b398d6bf75cf5d23/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f6428fee52d2bcf96a8aa7b62095b190ee341ab0e6b1bcf50c615d7966fd45b", size = 1644036, upload-time = "2025-04-02T02:16:23.707Z" }, - { url = "https://files.pythonhosted.org/packages/2c/88/59d870f76e9345e2b149f158074e78db457985c2b4da713038d9da3020a8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:13ceac2c5cdcc3f64b9015710221ddf81c900c5febc505dbd8f810e770011540", size = 1685270, upload-time = "2025-04-02T02:16:25.874Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b1/c6686948d4c79c3745595efc469a9f8a43cab3c7efc0b5991be65d9e8cb8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fadbb8f1d4140825069db3fedbbb843290fd5f5bc0a5dbd7eaf81d91bf1b003b", size = 1650852, upload-time = "2025-04-02T02:16:27.556Z" }, - { url = "https://files.pythonhosted.org/packages/fe/94/3e42a6916fd3441721941e0f1b8438e1ce2a4c49af0e28e0d3c950c9b3c9/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6a792ce34b999fbe04a7a71a90c74f10c57ae4c51f65461a411faa70e154154e", size = 1704481, upload-time = "2025-04-02T02:16:29.573Z" }, - { url = "https://files.pythonhosted.org/packages/b1/6d/6ab5854ff59b27075c7a8c610597d2b6c38945f9a1284ee8758bc3720ff6/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f4065145bf69de124accdd17ea5f4dc770da0a6a6e440c53f6e0a8c27b3e635c", size = 1735370, upload-time = "2025-04-02T02:16:31.191Z" }, - { url = "https://files.pythonhosted.org/packages/73/2a/08a68eec3c99a6659067d271d7553e4d490a0828d588e1daa3970dc2b771/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa73e8c2656a3653ae6c307b3f4e878a21f87859a9afab228280ddccd7369d71", size = 1697619, upload-time = "2025-04-02T02:16:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/61/d5/fea8dbbfb0cd68fbb56f0ae913270a79422d9a41da442a624febf72d2aaf/aiohttp-3.11.16-cp312-cp312-win32.whl", hash = "sha256:f244b8e541f414664889e2c87cac11a07b918cb4b540c36f7ada7bfa76571ea2", size = 411710, upload-time = "2025-04-02T02:16:34.525Z" }, - { url = "https://files.pythonhosted.org/packages/33/fb/41cde15fbe51365024550bf77b95a4fc84ef41365705c946da0421f0e1e0/aiohttp-3.11.16-cp312-cp312-win_amd64.whl", hash = "sha256:23a15727fbfccab973343b6d1b7181bfb0b4aa7ae280f36fd2f90f5476805682", size = 438012, upload-time = "2025-04-02T02:16:36.103Z" }, + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, ] [[package]] @@ -100,16 +101,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] [[package]] @@ -188,11 +189,11 @@ wheels = [ [[package]] name = "cachetools" -version = "5.5.2" +version = "6.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, + { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, ] [[package]] @@ -249,14 +250,14 @@ wheels = [ [[package]] name = "click" -version = "8.2.1" +version = "8.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] [[package]] @@ -286,43 +287,43 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.1" +version = "46.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/62/e3664e6ffd7743e1694b244dde70b43a394f6f7fbcacf7014a8ff5197c73/cryptography-46.0.1.tar.gz", hash = "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7", size = 749198, upload-time = "2025-09-17T00:10:35.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/9b/e301418629f7bfdf72db9e80ad6ed9d1b83c487c471803eaa6464c511a01/cryptography-46.0.2.tar.gz", hash = "sha256:21b6fc8c71a3f9a604f028a329e5560009cc4a3a828bfea5fcba8eb7647d88fe", size = 749293, upload-time = "2025-10-01T00:29:11.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/8c/44ee01267ec01e26e43ebfdae3f120ec2312aa72fa4c0507ebe41a26739f/cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475", size = 7285044, upload-time = "2025-09-17T00:08:36.807Z" }, - { url = "https://files.pythonhosted.org/packages/22/59/9ae689a25047e0601adfcb159ec4f83c0b4149fdb5c3030cc94cd218141d/cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080", size = 4308182, upload-time = "2025-09-17T00:08:39.388Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/ca6cc9df7118f2fcd142c76b1da0f14340d77518c05b1ebfbbabca6b9e7d/cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e", size = 4572393, upload-time = "2025-09-17T00:08:41.663Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a3/0f5296f63815d8e985922b05c31f77ce44787b3127a67c0b7f70f115c45f/cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6", size = 4308400, upload-time = "2025-09-17T00:08:43.559Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/74fcda3e4e01be1d32775d5b4dd841acaac3c1b8fa4d0774c7ac8d52463d/cryptography-46.0.1-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8", size = 4015786, upload-time = "2025-09-17T00:08:45.758Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b8/85d23287baeef273b0834481a3dd55bbed3a53587e3b8d9f0898235b8f91/cryptography-46.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28", size = 4982606, upload-time = "2025-09-17T00:08:47.602Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d3/de61ad5b52433b389afca0bc70f02a7a1f074651221f599ce368da0fe437/cryptography-46.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9", size = 4604234, upload-time = "2025-09-17T00:08:49.879Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1f/dbd4d6570d84748439237a7478d124ee0134bf166ad129267b7ed8ea6d22/cryptography-46.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736", size = 4307669, upload-time = "2025-09-17T00:08:52.321Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fd/ca0a14ce7f0bfe92fa727aacaf2217eb25eb7e4ed513b14d8e03b26e63ed/cryptography-46.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b", size = 4947579, upload-time = "2025-09-17T00:08:54.697Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/09c30543bb93401f6f88fce556b3bdbb21e55ae14912c04b7bf355f5f96c/cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab", size = 4603669, upload-time = "2025-09-17T00:08:57.16Z" }, - { url = "https://files.pythonhosted.org/packages/23/9a/38cb01cb09ce0adceda9fc627c9cf98eb890fc8d50cacbe79b011df20f8a/cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75", size = 4435828, upload-time = "2025-09-17T00:08:59.606Z" }, - { url = "https://files.pythonhosted.org/packages/0f/53/435b5c36a78d06ae0bef96d666209b0ecd8f8181bfe4dda46536705df59e/cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5", size = 4709553, upload-time = "2025-09-17T00:09:01.832Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c4/0da6e55595d9b9cd3b6eb5dc22f3a07ded7f116a3ea72629cab595abb804/cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0", size = 3058327, upload-time = "2025-09-17T00:09:03.726Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/cd29a35e0d6e78a0ee61793564c8cff0929c38391cb0de27627bdc7525aa/cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7", size = 3523893, upload-time = "2025-09-17T00:09:06.272Z" }, - { url = "https://files.pythonhosted.org/packages/f2/dd/eea390f3e78432bc3d2f53952375f8b37cb4d37783e626faa6a51e751719/cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0", size = 2932145, upload-time = "2025-09-17T00:09:08.568Z" }, - { url = "https://files.pythonhosted.org/packages/98/e5/fbd632385542a3311915976f88e0dfcf09e62a3fc0aff86fb6762162a24d/cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b", size = 7255677, upload-time = "2025-09-17T00:09:42.407Z" }, - { url = "https://files.pythonhosted.org/packages/56/3e/13ce6eab9ad6eba1b15a7bd476f005a4c1b3f299f4c2f32b22408b0edccf/cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8", size = 4301110, upload-time = "2025-09-17T00:09:45.614Z" }, - { url = "https://files.pythonhosted.org/packages/a2/67/65dc233c1ddd688073cf7b136b06ff4b84bf517ba5529607c9d79720fc67/cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", size = 4562369, upload-time = "2025-09-17T00:09:47.601Z" }, - { url = "https://files.pythonhosted.org/packages/17/db/d64ae4c6f4e98c3dac5bf35dd4d103f4c7c345703e43560113e5e8e31b2b/cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2", size = 4302126, upload-time = "2025-09-17T00:09:49.335Z" }, - { url = "https://files.pythonhosted.org/packages/3d/19/5f1eea17d4805ebdc2e685b7b02800c4f63f3dd46cfa8d4c18373fea46c8/cryptography-46.0.1-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32", size = 4009431, upload-time = "2025-09-17T00:09:51.239Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/229ba6088fe7abccbfe4c5edb96c7a5ad547fac5fdd0d40aa6ea540b2985/cryptography-46.0.1-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef", size = 4980739, upload-time = "2025-09-17T00:09:54.181Z" }, - { url = "https://files.pythonhosted.org/packages/3a/9c/50aa38907b201e74bc43c572f9603fa82b58e831bd13c245613a23cff736/cryptography-46.0.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0", size = 4592289, upload-time = "2025-09-17T00:09:56.731Z" }, - { url = "https://files.pythonhosted.org/packages/5a/33/229858f8a5bb22f82468bb285e9f4c44a31978d5f5830bb4ea1cf8a4e454/cryptography-46.0.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128", size = 4301815, upload-time = "2025-09-17T00:09:58.548Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/b76b2c87fbd6ed4a231884bea3ce073406ba8e2dae9defad910d33cbf408/cryptography-46.0.1-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca", size = 4943251, upload-time = "2025-09-17T00:10:00.475Z" }, - { url = "https://files.pythonhosted.org/packages/94/0f/f66125ecf88e4cb5b8017ff43f3a87ede2d064cb54a1c5893f9da9d65093/cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc", size = 4591247, upload-time = "2025-09-17T00:10:02.874Z" }, - { url = "https://files.pythonhosted.org/packages/f6/22/9f3134ae436b63b463cfdf0ff506a0570da6873adb4bf8c19b8a5b4bac64/cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7", size = 4428534, upload-time = "2025-09-17T00:10:04.994Z" }, - { url = "https://files.pythonhosted.org/packages/89/39/e6042bcb2638650b0005c752c38ea830cbfbcbb1830e4d64d530000aa8dc/cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a", size = 4699541, upload-time = "2025-09-17T00:10:06.925Z" }, - { url = "https://files.pythonhosted.org/packages/68/46/753d457492d15458c7b5a653fc9a84a1c9c7a83af6ebdc94c3fc373ca6e8/cryptography-46.0.1-cp38-abi3-win32.whl", hash = "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1", size = 3043779, upload-time = "2025-09-17T00:10:08.951Z" }, - { url = "https://files.pythonhosted.org/packages/2f/50/b6f3b540c2f6ee712feeb5fa780bb11fad76634e71334718568e7695cb55/cryptography-46.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3", size = 3517226, upload-time = "2025-09-17T00:10:10.769Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" }, + { url = "https://files.pythonhosted.org/packages/e0/98/7a8df8c19a335c8028414738490fc3955c0cecbfdd37fcc1b9c3d04bd561/cryptography-46.0.2-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3e32ab7dd1b1ef67b9232c4cf5e2ee4cd517d4316ea910acaaa9c5712a1c663", size = 7261255, upload-time = "2025-10-01T00:27:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/c6/38/b2adb2aa1baa6706adc3eb746691edd6f90a656a9a65c3509e274d15a2b8/cryptography-46.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fd1a69086926b623ef8126b4c33d5399ce9e2f3fac07c9c734c2a4ec38b6d02", size = 4297596, upload-time = "2025-10-01T00:27:25.258Z" }, + { url = "https://files.pythonhosted.org/packages/e4/27/0f190ada240003119488ae66c897b5e97149292988f556aef4a6a2a57595/cryptography-46.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb7fb9cd44c2582aa5990cf61a4183e6f54eea3172e54963787ba47287edd135", size = 4450899, upload-time = "2025-10-01T00:27:27.458Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/e4744105ab02fdf6bb58ba9a816e23b7a633255987310b4187d6745533db/cryptography-46.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9066cfd7f146f291869a9898b01df1c9b0e314bfa182cef432043f13fc462c92", size = 4300382, upload-time = "2025-10-01T00:27:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/33/fb/bf9571065c18c04818cb07de90c43fc042c7977c68e5de6876049559c72f/cryptography-46.0.2-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:97e83bf4f2f2c084d8dd792d13841d0a9b241643151686010866bbd076b19659", size = 4017347, upload-time = "2025-10-01T00:27:30.767Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/fc51856b9b16155ca071080e1a3ad0c3a8e86616daf7eb018d9565b99baa/cryptography-46.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:4a766d2a5d8127364fd936572c6e6757682fc5dfcbdba1632d4554943199f2fa", size = 4983500, upload-time = "2025-10-01T00:27:32.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/0f51e926799025e31746d454ab2e36f8c3f0d41592bc65cb9840368d3275/cryptography-46.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fab8f805e9675e61ed8538f192aad70500fa6afb33a8803932999b1049363a08", size = 4482591, upload-time = "2025-10-01T00:27:34.869Z" }, + { url = "https://files.pythonhosted.org/packages/86/96/4302af40b23ab8aa360862251fb8fc450b2a06ff24bc5e261c2007f27014/cryptography-46.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1e3b6428a3d56043bff0bb85b41c535734204e599c1c0977e1d0f261b02f3ad5", size = 4300019, upload-time = "2025-10-01T00:27:37.029Z" }, + { url = "https://files.pythonhosted.org/packages/9b/59/0be12c7fcc4c5e34fe2b665a75bc20958473047a30d095a7657c218fa9e8/cryptography-46.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:1a88634851d9b8de8bb53726f4300ab191d3b2f42595e2581a54b26aba71b7cc", size = 4950006, upload-time = "2025-10-01T00:27:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/55/1d/42fda47b0111834b49e31590ae14fd020594d5e4dadd639bce89ad790fba/cryptography-46.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:be939b99d4e091eec9a2bcf41aaf8f351f312cd19ff74b5c83480f08a8a43e0b", size = 4482088, upload-time = "2025-10-01T00:27:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/60f583f69aa1602c2bdc7022dae86a0d2b837276182f8c1ec825feb9b874/cryptography-46.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f13b040649bc18e7eb37936009b24fd31ca095a5c647be8bb6aaf1761142bd1", size = 4425599, upload-time = "2025-10-01T00:27:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/d1/57/d8d4134cd27e6e94cf44adb3f3489f935bde85f3a5508e1b5b43095b917d/cryptography-46.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bdc25e4e01b261a8fda4e98618f1c9515febcecebc9566ddf4a70c63967043b", size = 4697458, upload-time = "2025-10-01T00:27:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2b/531e37408573e1da33adfb4c58875013ee8ac7d548d1548967d94a0ae5c4/cryptography-46.0.2-cp311-abi3-win32.whl", hash = "sha256:8b9bf67b11ef9e28f4d78ff88b04ed0929fcd0e4f70bb0f704cfc32a5c6311ee", size = 3056077, upload-time = "2025-10-01T00:27:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cd/2f83cafd47ed2dc5a3a9c783ff5d764e9e70d3a160e0df9a9dcd639414ce/cryptography-46.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:758cfc7f4c38c5c5274b55a57ef1910107436f4ae842478c4989abbd24bd5acb", size = 3512585, upload-time = "2025-10-01T00:27:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/00/36/676f94e10bfaa5c5b86c469ff46d3e0663c5dc89542f7afbadac241a3ee4/cryptography-46.0.2-cp311-abi3-win_arm64.whl", hash = "sha256:218abd64a2e72f8472c2102febb596793347a3e65fafbb4ad50519969da44470", size = 2927474, upload-time = "2025-10-01T00:27:52.91Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bb/fa95abcf147a1b0bb94d95f53fbb09da77b24c776c5d87d36f3d94521d2c/cryptography-46.0.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a08e7401a94c002e79dc3bc5231b6558cd4b2280ee525c4673f650a37e2c7685", size = 7248090, upload-time = "2025-10-01T00:28:22.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/f42071ce0e3ffbfa80a88feadb209c779fda92a23fbc1e14f74ebf72ef6b/cryptography-46.0.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d30bc11d35743bf4ddf76674a0a369ec8a21f87aaa09b0661b04c5f6c46e8d7b", size = 4293123, upload-time = "2025-10-01T00:28:25.072Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/1fdbd2e5c1ba822828d250e5a966622ef00185e476d1cd2726b6dd135e53/cryptography-46.0.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bca3f0ce67e5a2a2cf524e86f44697c4323a86e0fd7ba857de1c30d52c11ede1", size = 4439524, upload-time = "2025-10-01T00:28:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/5e4989a7d102d4306053770d60f978c7b6b1ea2ff8c06e0265e305b23516/cryptography-46.0.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ff798ad7a957a5021dcbab78dfff681f0cf15744d0e6af62bd6746984d9c9e9c", size = 4297264, upload-time = "2025-10-01T00:28:29.327Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/b56f847d220cb1d6d6aef5a390e116ad603ce13a0945a3386a33abc80385/cryptography-46.0.2-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cb5e8daac840e8879407acbe689a174f5ebaf344a062f8918e526824eb5d97af", size = 4011872, upload-time = "2025-10-01T00:28:31.479Z" }, + { url = "https://files.pythonhosted.org/packages/e1/80/2971f214b066b888944f7b57761bf709ee3f2cf805619a18b18cab9b263c/cryptography-46.0.2-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:3f37aa12b2d91e157827d90ce78f6180f0c02319468a0aea86ab5a9566da644b", size = 4978458, upload-time = "2025-10-01T00:28:33.267Z" }, + { url = "https://files.pythonhosted.org/packages/a5/84/0cb0a2beaa4f1cbe63ebec4e97cd7e0e9f835d0ba5ee143ed2523a1e0016/cryptography-46.0.2-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e38f203160a48b93010b07493c15f2babb4e0f2319bbd001885adb3f3696d21", size = 4472195, upload-time = "2025-10-01T00:28:36.039Z" }, + { url = "https://files.pythonhosted.org/packages/30/8b/2b542ddbf78835c7cd67b6fa79e95560023481213a060b92352a61a10efe/cryptography-46.0.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d19f5f48883752b5ab34cff9e2f7e4a7f216296f33714e77d1beb03d108632b6", size = 4296791, upload-time = "2025-10-01T00:28:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/78/12/9065b40201b4f4876e93b9b94d91feb18de9150d60bd842a16a21565007f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:04911b149eae142ccd8c9a68892a70c21613864afb47aba92d8c7ed9cc001023", size = 4939629, upload-time = "2025-10-01T00:28:39.654Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9e/6507dc048c1b1530d372c483dfd34e7709fc542765015425f0442b08547f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8b16c1ede6a937c291d41176934268e4ccac2c6521c69d3f5961c5a1e11e039e", size = 4471988, upload-time = "2025-10-01T00:28:41.822Z" }, + { url = "https://files.pythonhosted.org/packages/b1/86/d025584a5f7d5c5ec8d3633dbcdce83a0cd579f1141ceada7817a4c26934/cryptography-46.0.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:747b6f4a4a23d5a215aadd1d0b12233b4119c4313df83ab4137631d43672cc90", size = 4422989, upload-time = "2025-10-01T00:28:43.608Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/536370418b38a15a61bbe413006b79dfc3d2b4b0eafceb5581983f973c15/cryptography-46.0.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b275e398ab3a7905e168c036aad54b5969d63d3d9099a0a66cc147a3cc983be", size = 4685578, upload-time = "2025-10-01T00:28:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/15/52/ea7e2b1910f547baed566c866fbb86de2402e501a89ecb4871ea7f169a81/cryptography-46.0.2-cp38-abi3-win32.whl", hash = "sha256:0b507c8e033307e37af61cb9f7159b416173bdf5b41d11c4df2e499a1d8e007c", size = 3036711, upload-time = "2025-10-01T00:28:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/9e/171f40f9c70a873e73c2efcdbe91e1d4b1777a03398fa1c4af3c56a2477a/cryptography-46.0.2-cp38-abi3-win_amd64.whl", hash = "sha256:f9b2dc7668418fb6f221e4bf701f716e05e8eadb4f1988a2487b11aedf8abe62", size = 3500007, upload-time = "2025-10-01T00:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" }, ] [[package]] @@ -407,16 +408,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.116.2" +version = "0.118.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/64/1296f46d6b9e3b23fb22e5d01af3f104ef411425531376212f1eefa2794d/fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529", size = 298595, upload-time = "2025-09-16T18:29:23.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536, upload-time = "2025-09-29T03:37:23.126Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694, upload-time = "2025-09-29T03:37:21.338Z" }, ] [[package]] @@ -535,21 +536,21 @@ wheels = [ [[package]] name = "google-auth" -version = "2.40.3" +version = "2.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/af/5129ce5b2f9688d2fa49b463e544972a7c82b0fdb50980dafee92e121d9f/google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2", size = 292284, upload-time = "2025-09-30T22:51:26.363Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, + { url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" }, ] [[package]] name = "google-genai" -version = "1.38.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -561,14 +562,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/11/108ddd3aca8af6a9e2369e59b9646a3a4c64aefb39d154f6467ab8d79f34/google_genai-1.38.0.tar.gz", hash = "sha256:363272fc4f677d0be6a1aed7ebabe8adf45e1626a7011a7886a587e9464ca9ec", size = 244903, upload-time = "2025-09-16T23:25:42.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/3e/25b88bda07ca237043f1be45d13c49ffbc73f9edf45d3232345802f67197/google_genai-1.39.1.tar.gz", hash = "sha256:4721704b43d170fc3f1b1cb5494bee1a7f7aae20de3a5383cdf6a129139df80b", size = 244631, upload-time = "2025-09-26T20:56:19.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/6c/1de711bab3c118284904c3bedf870519e8c63a7a8e0905ac3833f1db9cbc/google_genai-1.38.0-py3-none-any.whl", hash = "sha256:95407425132d42b3fa11bc92b3f5cf61a0fbd8d9add1f0e89aac52c46fbba090", size = 245558, upload-time = "2025-09-16T23:25:41.141Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/12c1f386184d2fcd694b73adeabc3714a5ed65c01cc183b4e3727a26b9d1/google_genai-1.39.1-py3-none-any.whl", hash = "sha256:6ca36c7e40db6fcba7049dfdd102c86da326804f34403bd7d90fa613a45e5a78", size = 244681, upload-time = "2025-09-26T20:56:17.527Z" }, ] [[package]] name = "gradio" -version = "5.46.0" +version = "5.47.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -600,14 +601,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/4b/733c3d9eb57936a004434647ab245e2c55e0262164dcd8aa891e718adf36/gradio-5.46.0.tar.gz", hash = "sha256:04ffe0bf79e81d63c16560fb483db06d39d96f2f36b7a672fa595dd6ddc69784", size = 72189142, upload-time = "2025-09-16T20:45:06.044Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/df/b792699b386c91aac38f5f844f92703a9fdd37aa4d2193c37de2cd4fa007/gradio-5.47.2.tar.gz", hash = "sha256:2e1cc00421da159ed9e9e2c8760e792ca2d8fa9bc610f3da0ec5cfa3fa6ca0be", size = 72289342, upload-time = "2025-09-26T19:51:10.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/a4/fc769047a7e17d6d9b63bf96385e799114c63c5f883b4accbd6914eb2649/gradio-5.46.0-py3-none-any.whl", hash = "sha256:e088fd68a3a04365caf3c7d6846c6e1fff1aca3b4e5b49ec5003f18bdbe343d5", size = 60273957, upload-time = "2025-09-16T20:44:53.732Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/7fed1186a9c289dad190011c1d86be761aeef968e856d653efa2f1d48dc9/gradio-5.47.2-py3-none-any.whl", hash = "sha256:e5cdf106b27bdb321284f327537682f3060ef0c62d9c70236eeaa8b1917a6803", size = 60369896, upload-time = "2025-09-26T19:51:05.636Z" }, ] [[package]] name = "gradio-client" -version = "1.13.0" +version = "1.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec" }, @@ -617,9 +618,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/70/c2989a14bfb3975ca4923463b2e01eb917a79f8842aac48cb472a133cf26/gradio_client-1.13.0.tar.gz", hash = "sha256:07de7e8e58553335d56e0c7db6af60f1205fd1f167bf07f3e0f587888695a8f3", size = 322963, upload-time = "2025-09-10T17:06:32.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/a9/a3beb0ece8c05c33e6376b790fa42e0dd157abca8220cf639b249a597467/gradio_client-1.13.3.tar.gz", hash = "sha256:869b3e67e0f7a0f40df8c48c94de99183265cf4b7b1d9bd4623e336d219ffbe7", size = 323253, upload-time = "2025-09-26T19:51:21.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/aa/8ad1cc8be082867aaa941aae30a38d68db9fbaf4306a51143307acd79b7a/gradio_client-1.13.0-py3-none-any.whl", hash = "sha256:4489ebd07ae40c6cc7a6a02cf60a53e9e3345aa5342a3814c356775bbad64bbc", size = 325012, upload-time = "2025-09-10T17:06:30.721Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0b/337b74504681b5dde39f20d803bb09757f9973ecdc65fd4e819d4b11faf7/gradio_client-1.13.3-py3-none-any.whl", hash = "sha256:3f63e4d33a2899c1a12b10fe3cf77b82a6919ff1a1fb6391f6aa225811aa390c", size = 325350, upload-time = "2025-09-26T19:51:20.288Z" }, ] [[package]] @@ -719,7 +720,7 @@ socks = [ [[package]] name = "huggingface-hub" -version = "0.35.0" +version = "0.35.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -731,9 +732,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/79/d71d40efa058e8c4a075158f8855bc2998037b5ff1c84f249f34435c1df7/huggingface_hub-0.35.0.tar.gz", hash = "sha256:ccadd2a78eef75effff184ad89401413629fabc52cefd76f6bbacb9b1c0676ac", size = 461486, upload-time = "2025-09-16T13:49:33.282Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/7e/a0a97de7c73671863ca6b3f61fa12518caf35db37825e43d63a70956738c/huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a", size = 461798, upload-time = "2025-09-29T14:29:58.625Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl", hash = "sha256:0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba", size = 564262, upload-time = "2025-09-29T14:29:55.813Z" }, ] [[package]] @@ -815,20 +816,21 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] [[package]] @@ -867,6 +869,12 @@ 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 = "multitasking" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } + [[package]] name = "newsapi-python" version = "0.2.7" @@ -900,15 +908,15 @@ wheels = [ [[package]] name = "ollama" -version = "0.5.4" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/62/a36be4555e4218d6c8b35e72e0dfe0823845400097275cd81c9aec4ddf39/ollama-0.5.4.tar.gz", hash = "sha256:75857505a5d42e5e58114a1b78cc8c24596d8866863359d8a2329946a9b6d6f3", size = 45233, upload-time = "2025-09-16T00:25:25.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/47/f9ee32467fe92744474a8c72e138113f3b529fc266eea76abfdec9a33f3b/ollama-0.6.0.tar.gz", hash = "sha256:da2b2d846b5944cfbcee1ca1e6ee0585f6c9d45a2fe9467cbcd096a37383da2f", size = 50811, upload-time = "2025-09-24T22:46:02.417Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/af/d0a23c8fdec4c8ddb771191d9b36a57fbce6741835a78f1b18ab6d15ae7d/ollama-0.5.4-py3-none-any.whl", hash = "sha256:6374c9bb4f2a371b3583c09786112ba85b006516745689c172a7e28af4d4d1a2", size = 13548, upload-time = "2025-09-16T00:25:24.186Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c1/edc9f41b425ca40b26b7c104c5f6841a4537bb2552bfa6ca66e81405bb95/ollama-0.6.0-py3-none-any.whl", hash = "sha256:534511b3ccea2dff419ae06c3b58d7f217c55be7897c8ce5868dfb6b219cf7a0", size = 14130, upload-time = "2025-09-24T22:46:01.19Z" }, ] [[package]] @@ -945,7 +953,7 @@ wheels = [ [[package]] name = "pandas" -version = "2.3.2" +version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -953,15 +961,15 @@ dependencies = [ { name = "pytz" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, - { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, - { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, - { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, - { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, - { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, ] [[package]] @@ -1179,16 +1187,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, ] [[package]] @@ -1292,19 +1300,20 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] [[package]] @@ -1371,28 +1380,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.0" +version = "0.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, - { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, - { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, - { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, - { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, - { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, - { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, - { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, - { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, - { url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" }, - { url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, + { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, + { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, + { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, + { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, + { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" }, + { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, ] [[package]] @@ -1521,7 +1530,7 @@ wheels = [ [[package]] name = "typer" -version = "0.17.4" +version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1529,9 +1538,9 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, ] [[package]] @@ -1545,14 +1554,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -1627,24 +1636,24 @@ requires-dist = [ [[package]] name = "urllib3" -version = "2.3.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] name = "uvicorn" -version = "0.35.0" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, ] [[package]] From 85153c405b1331618a881c854e111ec628af19d1 Mon Sep 17 00:00:00 2001 From: trojanhorse47 <79195021+trojanhorse47@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:42:11 +0200 Subject: [PATCH 33/33] Tool (#15) * Refactor market agent and toolkit to support batch price retrieval * 1. Correzione del modello base del Team: inizializzato con qwen3:latest 2. Modifica dell'interfaccia e inserimento di un ChatManager per gestire interazione, salvataggio e caricamento della chat. * * Fix degli import + Aggiunta cancellazione casella di input all'invio della richiesta dell'utente * Riorganizzazione degli import per utilizzare il percorso corretto in tutti i moduli * Remove unused imports from __init__.py * Update __all__ in __init__.py to include MARKET_INSTRUCTIONS --------- Co-authored-by: Berack96 --- src/app.py | 79 ++++++++++++----- src/app/chat_manager.py | 78 ++++++++++++++++ src/app/markets/__init__.py | 2 +- src/app/pipeline.py | 157 +++++++++++++++++++++------------ tests/agents/test_predictor.py | 2 +- 5 files changed, 236 insertions(+), 82 deletions(-) create mode 100644 src/app/chat_manager.py diff --git a/src/app.py b/src/app.py index cf09fd5..65c22cc 100644 --- a/src/app.py +++ b/src/app.py @@ -1,48 +1,83 @@ import gradio as gr - -from dotenv import load_dotenv -from app.pipeline import Pipeline from agno.utils.log import log_info +from dotenv import load_dotenv + +from app.chat_manager import ChatManager ######################################## -# MAIN APP & GRADIO INTERFACE +# MAIN APP & GRADIO CHAT INTERFACE ######################################## if __name__ == "__main__": - ###################################### - # DA FARE PRIMA DI ESEGUIRE L'APP - # qui carichiamo le variabili d'ambiente dal file .env - # una volta fatto, possiamo usare le API keys senza problemi - # quindi non รจ necessario richiamare load_dotenv() altrove + # Carica variabili dโ€™ambiente (.env) load_dotenv() - ###################################### - pipeline = Pipeline() + # Inizializza ChatManager + chat = ChatManager() + ######################################## + # Funzioni Gradio + ######################################## + def respond(message, history): + response = chat.send_message(message) + history.append({"role": "user", "content": message}) + history.append({"role": "assistant", "content": response}) + return history, history, "" + + def save_current_chat(): + chat.save_chat("chat.json") + return "๐Ÿ’พ Chat salvata in chat.json" + + def load_previous_chat(): + chat.load_chat("chat.json") + history = [] + for m in chat.get_history(): + history.append({"role": m["role"], "content": m["content"]}) + return history, history + + def reset_chat(): + chat.reset_chat() + return [], [] + + ######################################## + # Interfaccia Gradio + ######################################## with gr.Blocks() as demo: - gr.Markdown("# ๐Ÿค– Agente di Analisi e Consulenza Crypto") + gr.Markdown("# ๐Ÿค– Agente di Analisi e Consulenza Crypto (Chat)") + # Dropdown provider e stile with gr.Row(): provider = gr.Dropdown( - choices=pipeline.list_providers(), + choices=chat.list_providers(), type="index", label="Modello da usare" ) - provider.change(fn=pipeline.choose_provider, inputs=provider, outputs=None) + provider.change(fn=chat.choose_provider, inputs=provider, outputs=None) style = gr.Dropdown( - choices=pipeline.list_styles(), + choices=chat.list_styles(), type="index", label="Stile di investimento" ) - style.change(fn=pipeline.choose_style, inputs=style, outputs=None) + style.change(fn=chat.choose_style, inputs=style, outputs=None) - user_input = gr.Textbox(label="Richiesta utente") - output = gr.Textbox(label="Risultato analisi", lines=12) + chatbot = gr.Chatbot(label="Conversazione", height=500, type="messages") + msg = gr.Textbox(label="Scrivi la tua richiesta", placeholder="Es: Quali sono le crypto interessanti oggi?") - analyze_btn = gr.Button("๐Ÿ”Ž Analizza") - analyze_btn.click(fn=pipeline.interact, inputs=[user_input], outputs=output) + with gr.Row(): + clear_btn = gr.Button("๐Ÿ—‘๏ธ Reset Chat") + save_btn = gr.Button("๐Ÿ’พ Salva Chat") + load_btn = gr.Button("๐Ÿ“‚ Carica Chat") - server, port = ("0.0.0.0", 8000) # 0.0.0.0 per docker compatibility + # Invio messaggio + msg.submit(respond, inputs=[msg, chatbot], outputs=[chatbot, chatbot, msg]) + # Reset + clear_btn.click(reset_chat, inputs=None, outputs=[chatbot, chatbot]) + # Salvataggio + save_btn.click(save_current_chat, inputs=None, outputs=None) + # Caricamento + load_btn.click(load_previous_chat, inputs=None, outputs=[chatbot, chatbot]) + + server, port = ("0.0.0.0", 8000) server_log = "localhost" if server == "0.0.0.0" else server - log_info(f"Starting UPO AppAI on http://{server_log}:{port}") + log_info(f"Starting UPO AppAI Chat on http://{server_log}:{port}") # noqa demo.launch(server_name=server, server_port=port, quiet=True) diff --git a/src/app/chat_manager.py b/src/app/chat_manager.py new file mode 100644 index 0000000..7928c95 --- /dev/null +++ b/src/app/chat_manager.py @@ -0,0 +1,78 @@ +import os +import json +from typing import List, Dict +from app.pipeline import Pipeline + +SAVE_DIR = os.path.join(os.path.dirname(__file__), "..", "saves") +os.makedirs(SAVE_DIR, exist_ok=True) + +class ChatManager: + """ + Gestisce la conversazione con la Pipeline: + - mantiene lo storico dei messaggi + - invoca la Pipeline per generare risposte + - salva e ricarica le chat + """ + + def __init__(self): + self.pipeline = Pipeline() + self.history: List[Dict[str, str]] = [] # [{"role": "user"/"assistant", "content": "..."}] + + def send_message(self, message: str) -> str: + """ + Aggiunge un messaggio utente, chiama la Pipeline e salva la risposta nello storico. + """ + # Aggiungi messaggio utente allo storico + self.history.append({"role": "user", "content": message}) + + # Pipeline elabora la query + response = self.pipeline.interact(message) + + # Aggiungi risposta assistente allo storico + self.history.append({"role": "assistant", "content": response}) + + return response + + def save_chat(self, filename: str = "chat.json") -> None: + """ + Salva la chat corrente in src/saves/. + """ + path = os.path.join(SAVE_DIR, filename) + with open(path, "w", encoding="utf-8") as f: + json.dump(self.history, f, ensure_ascii=False, indent=2) + + def load_chat(self, filename: str = "chat.json") -> None: + """ + Carica una chat salvata da src/saves/. + """ + path = os.path.join(SAVE_DIR, filename) + if not os.path.exists(path): + self.history = [] + return + with open(path, "r", encoding="utf-8") as f: + self.history = json.load(f) + + def reset_chat(self) -> None: + """ + Resetta lo storico della chat. + """ + self.history = [] + + def get_history(self) -> List[Dict[str, str]]: + """ + Restituisce lo storico completo della chat. + """ + return self.history + + # Facciamo pass-through di provider e style, cosรฌ Gradio puรฒ usarli + def choose_provider(self, index: int): + self.pipeline.choose_provider(index) + + def choose_style(self, index: int): + self.pipeline.choose_style(index) + + def list_providers(self) -> List[str]: + return self.pipeline.list_providers() + + def list_styles(self) -> List[str]: + return self.pipeline.list_styles() diff --git a/src/app/markets/__init__.py b/src/app/markets/__init__.py index b782b8f..ef73f68 100644 --- a/src/app/markets/__init__.py +++ b/src/app/markets/__init__.py @@ -7,7 +7,7 @@ from .binance import BinanceWrapper from .cryptocompare import CryptoCompareWrapper from .yfinance import YFinanceWrapper -__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper" ] +__all__ = [ "MarketAPIsTool", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "MARKET_INSTRUCTIONS" ] class MarketAPIsTool(BaseWrapper, Toolkit): diff --git a/src/app/pipeline.py b/src/app/pipeline.py index 10dddab..a7ae9d4 100644 --- a/src/app/pipeline.py +++ b/src/app/pipeline.py @@ -1,5 +1,5 @@ +from agno.run.agent import RunOutput from agno.team import Team -from agno.utils.log import log_info from app.news import NewsAPIsTool, NEWS_INSTRUCTIONS from app.social import SocialAPIsTool, SOCIAL_INSTRUCTIONS @@ -10,98 +10,139 @@ from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDI class Pipeline: """ - Pipeline coordinata: esegue tutti gli agenti del Team, aggrega i risultati e invoca il Predictor. + Coordina gli agenti di servizio (Market, News, Social) e il Predictor finale. + Il Team รจ orchestrato da qwen3:latest (Ollama), mentre il Predictor รจ dinamico + e scelto dall'utente tramite i dropdown dell'interfaccia grafica. """ - def __init__(self): # Inizializza gli agenti - market_agent = AppModels.OLLAMA_QWEN_1B.get_agent( + self.market_agent = AppModels.OLLAMA_QWEN.get_agent( instructions=MARKET_INSTRUCTIONS, name="MarketAgent", tools=[MarketAPIsTool()] ) - news_agent = AppModels.OLLAMA_QWEN_1B.get_agent( + self.news_agent = AppModels.OLLAMA_QWEN.get_agent( instructions=NEWS_INSTRUCTIONS, name="NewsAgent", tools=[NewsAPIsTool()] ) - social_agent = AppModels.OLLAMA_QWEN_1B.get_agent( + self.social_agent = AppModels.OLLAMA_QWEN.get_agent( instructions=SOCIAL_INSTRUCTIONS, name="SocialAgent", tools=[SocialAPIsTool()] ) - # Crea il Team - prompt = """ - You are the coordinator of a team of analysts specialized in cryptocurrency market analysis. - Your role is to gather insights from various sources, including market data, news articles, and social media trends. - Based on the information provided by your team members, you will synthesize a comprehensive sentiment analysis for each cryptocurrency discussed. - Your analysis should consider the following aspects: - 1. Market Trends: Evaluate the current market trends and price movements. - 2. News Impact: Assess the impact of recent news articles on market sentiment. - 3. Social Media Buzz: Analyze social media discussions and trends related to the cryptocurrencies. - Your final output should be a well-rounded sentiment analysis that can guide investment decisions. - """ # TODO migliorare il prompt - self.team = Team( - model = AppModels.OLLAMA_QWEN_1B.get_model(prompt), - name="CryptoAnalysisTeam", - members=[market_agent, news_agent, social_agent] + # === Modello di orchestrazione del Team === + team_model = AppModels.OLLAMA_QWEN.get_model( + # TODO: migliorare le istruzioni del team + "Agisci come coordinatore: smista le richieste tra MarketAgent, NewsAgent e SocialAgent." ) - # Modelli disponibili e Predictor + # === Team === + self.team = Team( + name="CryptoAnalysisTeam", + members=[self.market_agent, self.news_agent, self.social_agent], + model=team_model + ) + + # === 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] + self.all_styles = list(PredictorStyle) - # Stili - self.styles = list(PredictorStyle) - self.style = self.styles[0] + # Scelte di default + self.chosen_model = self.available_models[0] if self.available_models else None + self.style = self.all_styles[0] if self.all_styles else None + self._init_predictor() # Inizializza il predictor con il modello di default + + # ====================== + # Dropdown handlers + # ====================== 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] + """ + Sceglie il modello LLM da usare per il Predictor. + """ + self.chosen_model = self.available_models[index] + self._init_predictor() 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. + Sceglie lo stile (conservativo/aggressivo) da usare per 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) + self.style = self.all_styles[index] - # 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 + # ====================== + # Helpers + # ====================== + def _init_predictor(self): + """ + Inizializza (o reinizializza) il Predictor in base al modello scelto. + """ + if not self.chosen_model: + return + self.predictor = self.chosen_model.get_agent( + PREDICTOR_INSTRUCTIONS, + output=PredictorOutput, # type: ignore + ) + + 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] + + # ====================== + # Core interaction + # ====================== + def interact(self, query: str) -> str: + """ + 1. Raccoglie output dai membri del Team + 2. Aggrega output strutturati + 3. Invoca Predictor + 4. Restituisce la strategia finale + """ + if not self.predictor or not self.style: + return "โš ๏ธ Devi prima selezionare un modello e una strategia validi dagli appositi menu." + + # Step 1: raccolta output dai membri del Team + team_outputs = self.team.run(query) + + # Step 2: aggregazione output strutturati + all_products = [] + sentiments = [] + + for agent_output in team_outputs.member_responses: + if isinstance(agent_output, RunOutput): + if "products" in agent_output.metadata: + all_products.extend(agent_output.metadata["products"]) + if "sentiment_news" in agent_output.metadata: + sentiments.append(agent_output.metadata["sentiment_news"]) + if "sentiment_social" in agent_output.metadata: + sentiments.append(agent_output.metadata["sentiment_social"]) + + aggregated_sentiment = "\n".join(sentiments) + + # Step 3: invocazione Predictor + predictor_input = PredictorInput( + data=all_products, + style=self.style, + sentiment=aggregated_sentiment ) - # Step 3: chiama Predictor result = self.predictor.run(predictor_input) prediction: PredictorOutput = result.content - # Step 4: formatta output finale + # Step 4: restituzione strategia finale portfolio_lines = "\n".join( [f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio] ) - output = ( + return ( 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/tests/agents/test_predictor.py b/tests/agents/test_predictor.py index 9f28717..5867938 100644 --- a/tests/agents/test_predictor.py +++ b/tests/agents/test_predictor.py @@ -4,7 +4,7 @@ from app.markets.base import ProductInfo from app.models import AppModels def unified_checks(model: AppModels, input): - llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) + llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type] result = llm.run(input) content = result.content