diff --git a/README.md b/README.md index aae9a60..ea778b6 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ uv pip install -e . A questo punto si può già modificare il codice e, quando necessario, far partire il progetto tramite il comando: ```sh -uv run python src/app +uv run src/app ``` # **Applicazione** @@ -106,10 +106,11 @@ src └── app ├── __main__.py ├── agents <-- Agenti, modelli, prompts e simili - ├── base <-- Classi base per le API - ├── markets <-- Market data provider (Es. Binance) - ├── news <-- News data provider (Es. NewsAPI) - ├── social <-- Social data provider (Es. Reddit) + ├── api <-- Tutte le API esterne + │ ├── base <-- Classi base per le API + │ ├── markets <-- Market data provider (Es. Binance) + │ ├── news <-- News data provider (Es. NewsAPI) + │ └── social <-- Social data provider (Es. Reddit) └── utils <-- Codice di utilità generale ``` diff --git a/src/app/agents/pipeline.py b/src/app/agents/pipeline.py index a7d1001..ce32c06 100644 --- a/src/app/agents/pipeline.py +++ b/src/app/agents/pipeline.py @@ -2,7 +2,7 @@ from agno.run.agent import RunOutput from app.agents.models import AppModels from app.agents.team import create_team_with from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.base.markets import ProductInfo +from app.api.base.markets import ProductInfo class Pipeline: diff --git a/src/app/agents/predictor.py b/src/app/agents/predictor.py index 69a92af..4c5bb1a 100644 --- a/src/app/agents/predictor.py +++ b/src/app/agents/predictor.py @@ -1,6 +1,6 @@ from enum import Enum from pydantic import BaseModel, Field -from app.base.markets import ProductInfo +from app.api.base.markets import ProductInfo class PredictorStyle(Enum): diff --git a/src/app/agents/team.py b/src/app/agents/team.py index 27b9cae..04bcab6 100644 --- a/src/app/agents/team.py +++ b/src/app/agents/team.py @@ -1,8 +1,8 @@ from agno.team import Team from app.agents import AppModels -from app.markets import MarketAPIsTool -from app.news import NewsAPIsTool -from app.social import SocialAPIsTool +from app.api.markets import MarketAPIsTool +from app.api.news import NewsAPIsTool +from app.api.social import SocialAPIsTool def create_team_with(models: AppModels, coordinator: AppModels | None = None) -> Team: diff --git a/src/app/base/__init__.py b/src/app/api/__init__.py similarity index 100% rename from src/app/base/__init__.py rename to src/app/api/__init__.py diff --git a/src/app/api/base/__init__.py b/src/app/api/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/base/markets.py b/src/app/api/base/markets.py new file mode 100644 index 0000000..8b6c754 --- /dev/null +++ b/src/app/api/base/markets.py @@ -0,0 +1,152 @@ +import statistics +from datetime import datetime +from pydantic import BaseModel + + +class ProductInfo(BaseModel): + """ + Product information as obtained from market APIs. + Implements conversion methods from raw API data. + """ + id: str = "" + symbol: str = "" + price: float = 0.0 + volume_24h: float = 0.0 + currency: str = "" + + @staticmethod + def aggregate(products: dict[str, list['ProductInfo']]) -> list['ProductInfo']: + """ + Aggregates a list of ProductInfo by symbol. + Args: + products (dict[str, list[ProductInfo]]): Map provider -> list of ProductInfo + Returns: + list[ProductInfo]: List of ProductInfo aggregated by 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 + aggregated_products: list[ProductInfo] = [] + for symbol, product_list in symbols_infos.items(): + product = ProductInfo() + + product.id = f"{symbol}_AGGREGATED" + product.symbol = symbol + product.currency = next(p.currency for p in product_list if p.currency) + + 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 + + + +class Price(BaseModel): + """ + Represents price data for an asset as obtained from market APIs. + Implements conversion methods from raw API data. + """ + high: float = 0.0 + low: float = 0.0 + open: float = 0.0 + close: float = 0.0 + volume: float = 0.0 + timestamp: str = "" + """Timestamp in format YYYY-MM-DD HH:MM""" + + def set_timestamp(self, timestamp_ms: int | None = None, timestamp_s: int | None = None) -> None: + """ + Sets the timestamp from milliseconds or seconds. + The timestamp is saved as a formatted string 'YYYY-MM-DD HH:MM'. + Args: + timestamp_ms: Timestamp in milliseconds. + timestamp_s: Timestamp in seconds. + Raises: + ValueError: If neither timestamp_ms nor timestamp_s is provided. + """ + if timestamp_ms is not None: + timestamp = timestamp_ms // 1000 + elif timestamp_s is not None: + timestamp = timestamp_s + else: + raise ValueError("Either timestamp_ms or timestamp_s must be provided") + assert timestamp > 0, "Invalid timestamp data received" + + self.timestamp = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M') + + @staticmethod + def aggregate(prices: dict[str, list['Price']]) -> list['Price']: + """ + Aggregates historical prices for the same symbol by calculating the mean. + Args: + prices (dict[str, list[Price]]): Map provider -> list of Price. + The map must contain only Price objects for the same symbol. + Returns: + list[Price]: List of Price objects aggregated by timestamp. + """ + + # Costruiamo una mappa timestamp -> lista di Price + timestamped_prices: dict[str, list[Price]] = {} + for _, price_list in prices.items(): + for price in price_list: + timestamped_prices.setdefault(price.timestamp, []).append(price) + + # Ora aggregiamo i prezzi per ogni timestamp + aggregated_prices: list[Price] = [] + for time, price_list in timestamped_prices.items(): + price = Price() + price.timestamp = 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 + +class MarketWrapper: + """ + Base class for market API wrappers. + All market API wrappers should inherit from this class and implement the methods. + Provides interface for retrieving product and price information from market APIs. + """ + + 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("This method should be overridden by subclasses") + + 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("This method should be overridden by subclasses") + + def get_historical_prices(self, asset_id: str, 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("This method should be overridden by subclasses") diff --git a/src/app/base/news.py b/src/app/api/base/news.py similarity index 77% rename from src/app/base/news.py rename to src/app/api/base/news.py index 8a0d51e..1f67999 100644 --- a/src/app/base/news.py +++ b/src/app/api/base/news.py @@ -2,6 +2,9 @@ from pydantic import BaseModel class Article(BaseModel): + """ + Represents a news article with source, time, title, and description. + """ source: str = "" time: str = "" title: str = "" @@ -11,11 +14,12 @@ class NewsWrapper: """ Base class for news API wrappers. All news API wrappers should inherit from this class and implement the methods. + Provides interface for retrieving news articles from news APIs. """ def get_top_headlines(self, limit: int = 100) -> list[Article]: """ - Get top headlines, optionally limited by limit. + Retrieve top headlines, optionally limited by the specified number. Args: limit (int): The maximum number of articles to return. Returns: @@ -25,7 +29,7 @@ class NewsWrapper: def get_latest_news(self, query: str, limit: int = 100) -> list[Article]: """ - Get latest news based on a query. + Retrieve the latest news based on a search query. Args: query (str): The search query. limit (int): The maximum number of articles to return. diff --git a/src/app/base/social.py b/src/app/api/base/social.py similarity index 69% rename from src/app/base/social.py rename to src/app/api/base/social.py index dd894f5..721ac0c 100644 --- a/src/app/base/social.py +++ b/src/app/api/base/social.py @@ -2,12 +2,18 @@ from pydantic import BaseModel class SocialPost(BaseModel): + """ + Represents a social media post with time, title, description, and comments. + """ time: str = "" title: str = "" description: str = "" comments: list["SocialComment"] = [] class SocialComment(BaseModel): + """ + Represents a comment on a social media post. + """ time: str = "" description: str = "" @@ -16,11 +22,12 @@ class SocialWrapper: """ Base class for social media API wrappers. All social media API wrappers should inherit from this class and implement the methods. + Provides interface for retrieving social media posts and comments from APIs. """ def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]: """ - Get top cryptocurrency-related posts, optionally limited by total. + Retrieve top cryptocurrency-related posts, optionally limited by the specified number. Args: limit (int): The maximum number of posts to return. Returns: diff --git a/src/app/markets/__init__.py b/src/app/api/markets/__init__.py similarity index 75% rename from src/app/markets/__init__.py rename to src/app/api/markets/__init__.py index bf2d344..9a48853 100644 --- a/src/app/markets/__init__.py +++ b/src/app/api/markets/__init__.py @@ -1,10 +1,10 @@ from agno.tools import Toolkit -from app.base.markets import MarketWrapper, Price, ProductInfo -from app.markets.binance import BinanceWrapper -from app.markets.coinbase import CoinBaseWrapper -from app.markets.cryptocompare import CryptoCompareWrapper -from app.markets.yfinance import YFinanceWrapper -from app.utils import aggregate_history_prices, aggregate_product_info, WrapperHandler +from app.api.wrapper_handler import WrapperHandler +from app.api.base.markets import MarketWrapper, Price, ProductInfo +from app.api.markets.binance import BinanceWrapper +from app.api.markets.coinbase import CoinBaseWrapper +from app.api.markets.cryptocompare import CryptoCompareWrapper +from app.api.markets.yfinance import YFinanceWrapper __all__ = [ "MarketAPIsTool", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "ProductInfo", "Price" ] @@ -34,7 +34,7 @@ class MarketAPIsTool(MarketWrapper, Toolkit): """ kwargs = {"currency": currency or "USD"} wrappers: list[type[MarketWrapper]] = [BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper] - self.wrappers = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) + self.handler = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs) Toolkit.__init__( # type: ignore self, @@ -49,11 +49,11 @@ class MarketAPIsTool(MarketWrapper, Toolkit): ) def get_product(self, asset_id: str) -> ProductInfo: - return self.wrappers.try_call(lambda w: w.get_product(asset_id)) + return self.handler.try_call(lambda w: w.get_product(asset_id)) def get_products(self, asset_ids: list[str]) -> list[ProductInfo]: - return self.wrappers.try_call(lambda w: w.get_products(asset_ids)) + return self.handler.try_call(lambda w: w.get_products(asset_ids)) def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[Price]: - return self.wrappers.try_call(lambda w: w.get_historical_prices(asset_id, limit)) + return self.handler.try_call(lambda w: w.get_historical_prices(asset_id, limit)) def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]: @@ -67,8 +67,8 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - all_products = self.wrappers.try_call_all(lambda w: w.get_products(asset_ids)) - return aggregate_product_info(all_products) + all_products = self.handler.try_call_all(lambda w: w.get_products(asset_ids)) + return ProductInfo.aggregate(all_products) def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]: """ @@ -82,5 +82,5 @@ class MarketAPIsTool(MarketWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - all_prices = self.wrappers.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) - return aggregate_history_prices(all_prices) + all_prices = self.handler.try_call_all(lambda w: w.get_historical_prices(asset_id, limit)) + return Price.aggregate(all_prices) diff --git a/src/app/markets/binance.py b/src/app/api/markets/binance.py similarity index 83% rename from src/app/markets/binance.py rename to src/app/api/markets/binance.py index ffd31bb..18b5f35 100644 --- a/src/app/markets/binance.py +++ b/src/app/api/markets/binance.py @@ -1,7 +1,7 @@ import os from typing import Any from binance.client import Client # type: ignore -from app.base.markets import ProductInfo, MarketWrapper, Price +from app.api.base.markets import ProductInfo, MarketWrapper, Price def extract_product(currency: str, ticker_data: dict[str, Any]) -> ProductInfo: @@ -25,6 +25,12 @@ def extract_price(kline_data: list[Any]) -> Price: price.set_timestamp(timestamp_ms=timestamp) return price + +# Add here eventual other fiat not supported by Binance +FIAT_TO_STABLECOIN = { + "USD": "USDT", +} + class BinanceWrapper(MarketWrapper): """ Wrapper per le API autenticate di Binance.\n @@ -36,16 +42,15 @@ class BinanceWrapper(MarketWrapper): def __init__(self, currency: str = "USD"): """ Inizializza il wrapper di Binance con le credenziali API e la valuta di riferimento. - Se viene fornita una valuta fiat come "USD", questa viene automaticamente convertita in una stablecoin Tether ("USDT") per compatibilità con Binance, - poiché Binance non supporta direttamente le valute fiat per il trading di criptovalute. - Tutti i prezzi e volumi restituiti saranno quindi denominati nella stablecoin (ad esempio, "USDT") e non nella valuta fiat originale. - Args: - currency (str): Valuta in cui restituire i prezzi. Se "USD" viene fornito, verrà utilizzato "USDT". Default è "USD". + Alcune valute fiat non sono supportate direttamente da Binance (es. "USD"). + Infatti, se viene fornita una valuta fiat come "USD", questa viene automaticamente convertita in una stablecoin Tether ("USDT") per compatibilità con Binance. + Args: + currency (str): Valuta in cui restituire i prezzi. Se "USD" viene fornito, verrà utilizzato "USDT". Default è "USD". """ api_key = os.getenv("BINANCE_API_KEY") api_secret = os.getenv("BINANCE_API_SECRET") - self.currency = f"{currency}T" + self.currency = currency if currency not in FIAT_TO_STABLECOIN else FIAT_TO_STABLECOIN[currency] self.client = Client(api_key=api_key, api_secret=api_secret) def __format_symbol(self, asset_id: str) -> str: diff --git a/src/app/markets/coinbase.py b/src/app/api/markets/coinbase.py similarity index 98% rename from src/app/markets/coinbase.py rename to src/app/api/markets/coinbase.py index c59382b..13016f6 100644 --- a/src/app/markets/coinbase.py +++ b/src/app/api/markets/coinbase.py @@ -3,7 +3,7 @@ from enum import Enum from datetime import datetime, timedelta from coinbase.rest import RESTClient # type: ignore from coinbase.rest.types.product_types import Candle, GetProductResponse, Product # type: ignore -from app.base.markets import ProductInfo, MarketWrapper, Price +from app.api.base.markets import ProductInfo, MarketWrapper, Price def extract_product(product_data: GetProductResponse | Product) -> ProductInfo: diff --git a/src/app/markets/cryptocompare.py b/src/app/api/markets/cryptocompare.py similarity index 97% rename from src/app/markets/cryptocompare.py rename to src/app/api/markets/cryptocompare.py index 5431267..a6c5d70 100644 --- a/src/app/markets/cryptocompare.py +++ b/src/app/api/markets/cryptocompare.py @@ -1,7 +1,7 @@ import os from typing import Any import requests -from app.base.markets import ProductInfo, MarketWrapper, Price +from app.api.base.markets import ProductInfo, MarketWrapper, Price def extract_product(asset_data: dict[str, Any]) -> ProductInfo: diff --git a/src/app/markets/yfinance.py b/src/app/api/markets/yfinance.py similarity index 97% rename from src/app/markets/yfinance.py rename to src/app/api/markets/yfinance.py index 2670eda..f63192e 100644 --- a/src/app/markets/yfinance.py +++ b/src/app/api/markets/yfinance.py @@ -1,6 +1,6 @@ import json from agno.tools.yfinance import YFinanceTools -from app.base.markets import MarketWrapper, ProductInfo, Price +from app.api.base.markets import MarketWrapper, ProductInfo, Price def extract_product(stock_data: dict[str, str]) -> ProductInfo: diff --git a/src/app/news/__init__.py b/src/app/api/news/__init__.py similarity index 79% rename from src/app/news/__init__.py rename to src/app/api/news/__init__.py index b0cb553..a66cf05 100644 --- a/src/app/news/__init__.py +++ b/src/app/api/news/__init__.py @@ -1,10 +1,10 @@ from agno.tools import Toolkit -from app.utils import WrapperHandler -from app.base.news import NewsWrapper, Article -from app.news.news_api import NewsApiWrapper -from app.news.googlenews import GoogleNewsWrapper -from app.news.cryptopanic_api import CryptoPanicWrapper -from app.news.duckduckgo import DuckDuckGoWrapper +from app.api.wrapper_handler import WrapperHandler +from app.api.base.news import NewsWrapper, Article +from app.api.news.news_api import NewsApiWrapper +from app.api.news.googlenews import GoogleNewsWrapper +from app.api.news.cryptopanic_api import CryptoPanicWrapper +from app.api.news.duckduckgo import DuckDuckGoWrapper __all__ = ["NewsAPIsTool", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper", "Article"] @@ -34,7 +34,7 @@ class NewsAPIsTool(NewsWrapper, Toolkit): - CryptoPanicWrapper. """ wrappers: list[type[NewsWrapper]] = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper] - self.wrapper_handler = WrapperHandler.build_wrappers(wrappers) + self.handler = WrapperHandler.build_wrappers(wrappers) Toolkit.__init__( # type: ignore self, @@ -48,9 +48,9 @@ class NewsAPIsTool(NewsWrapper, Toolkit): ) def get_top_headlines(self, limit: int = 100) -> list[Article]: - return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit)) + return self.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)) + return self.handler.try_call(lambda w: w.get_latest_news(query, limit)) def get_top_headlines_aggregated(self, limit: int = 100) -> dict[str, list[Article]]: """ @@ -62,7 +62,7 @@ class NewsAPIsTool(NewsWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - return self.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit)) + return self.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]]: """ @@ -75,4 +75,4 @@ class NewsAPIsTool(NewsWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - return self.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query, limit)) + return self.handler.try_call_all(lambda w: w.get_latest_news(query, limit)) diff --git a/src/app/news/cryptopanic_api.py b/src/app/api/news/cryptopanic_api.py similarity index 98% rename from src/app/news/cryptopanic_api.py rename to src/app/api/news/cryptopanic_api.py index 1e16078..b1810b7 100644 --- a/src/app/news/cryptopanic_api.py +++ b/src/app/api/news/cryptopanic_api.py @@ -2,7 +2,7 @@ import os from typing import Any import requests from enum import Enum -from app.base.news import NewsWrapper, Article +from app.api.base.news import NewsWrapper, Article class CryptoPanicFilter(Enum): diff --git a/src/app/news/duckduckgo.py b/src/app/api/news/duckduckgo.py similarity index 96% rename from src/app/news/duckduckgo.py rename to src/app/api/news/duckduckgo.py index 8108239..d854a2d 100644 --- a/src/app/news/duckduckgo.py +++ b/src/app/api/news/duckduckgo.py @@ -1,7 +1,7 @@ import json from typing import Any from agno.tools.duckduckgo import DuckDuckGoTools -from app.base.news import Article, NewsWrapper +from app.api.base.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/news/googlenews.py b/src/app/api/news/googlenews.py similarity index 96% rename from src/app/news/googlenews.py rename to src/app/api/news/googlenews.py index 0041c7f..613484f 100644 --- a/src/app/news/googlenews.py +++ b/src/app/api/news/googlenews.py @@ -1,6 +1,6 @@ from typing import Any from gnews import GNews # type: ignore -from app.base.news import Article, NewsWrapper +from app.api.base.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/news/news_api.py b/src/app/api/news/news_api.py similarity index 97% rename from src/app/news/news_api.py rename to src/app/api/news/news_api.py index b5bf375..3c229f3 100644 --- a/src/app/news/news_api.py +++ b/src/app/api/news/news_api.py @@ -1,7 +1,7 @@ import os from typing import Any import newsapi # type: ignore -from app.base.news import Article, NewsWrapper +from app.api.base.news import Article, NewsWrapper def extract_article(result: dict[str, Any]) -> Article: diff --git a/src/app/social/__init__.py b/src/app/api/social/__init__.py similarity index 82% rename from src/app/social/__init__.py rename to src/app/api/social/__init__.py index 261bcba..69d4331 100644 --- a/src/app/social/__init__.py +++ b/src/app/api/social/__init__.py @@ -1,7 +1,7 @@ from agno.tools import Toolkit -from app.utils import WrapperHandler -from app.base.social import SocialPost, SocialWrapper -from app.social.reddit import RedditWrapper +from app.api.wrapper_handler import WrapperHandler +from app.api.base.social import SocialPost, SocialWrapper +from app.api.social.reddit import RedditWrapper __all__ = ["SocialAPIsTool", "RedditWrapper", "SocialPost"] @@ -26,7 +26,7 @@ class SocialAPIsTool(SocialWrapper, Toolkit): """ wrappers: list[type[SocialWrapper]] = [RedditWrapper] - self.wrapper_handler = WrapperHandler.build_wrappers(wrappers) + self.handler = WrapperHandler.build_wrappers(wrappers) Toolkit.__init__( # type: ignore self, @@ -38,7 +38,7 @@ class SocialAPIsTool(SocialWrapper, Toolkit): ) 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)) + return self.handler.try_call(lambda w: w.get_top_crypto_posts(limit)) def get_top_crypto_posts_aggregated(self, limit_per_wrapper: int = 5) -> dict[str, list[SocialPost]]: """ @@ -50,4 +50,4 @@ class SocialAPIsTool(SocialWrapper, Toolkit): Raises: Exception: If all wrappers fail to provide results. """ - return self.wrapper_handler.try_call_all(lambda w: w.get_top_crypto_posts(limit_per_wrapper)) + return self.handler.try_call_all(lambda w: w.get_top_crypto_posts(limit_per_wrapper)) diff --git a/src/app/social/reddit.py b/src/app/api/social/reddit.py similarity index 96% rename from src/app/social/reddit.py rename to src/app/api/social/reddit.py index eeca968..e098ee3 100644 --- a/src/app/social/reddit.py +++ b/src/app/api/social/reddit.py @@ -1,7 +1,7 @@ import os from praw import Reddit # type: ignore from praw.models import Submission # type: ignore -from app.base.social import SocialWrapper, SocialPost, SocialComment +from app.api.base.social import SocialWrapper, SocialPost, SocialComment MAX_COMMENTS = 5 diff --git a/src/app/utils/wrapper_handler.py b/src/app/api/wrapper_handler.py similarity index 100% rename from src/app/utils/wrapper_handler.py rename to src/app/api/wrapper_handler.py diff --git a/src/app/base/markets.py b/src/app/base/markets.py deleted file mode 100644 index cd00879..0000000 --- a/src/app/base/markets.py +++ /dev/null @@ -1,83 +0,0 @@ -from datetime import datetime -from pydantic import BaseModel - - -class ProductInfo(BaseModel): - """ - Informazioni sul prodotto, come ottenute dalle API di mercato. - Implementa i metodi di conversione dai dati grezzi delle API. - """ - id: str = "" - symbol: str = "" - price: float = 0.0 - volume_24h: float = 0.0 - currency: str = "" - -class Price(BaseModel): - """ - Rappresenta i dati di prezzo per un asset, come ottenuti dalle API di mercato. - Implementa i metodi di conversione dai dati grezzi delle API. - """ - high: float = 0.0 - low: float = 0.0 - open: float = 0.0 - close: float = 0.0 - volume: float = 0.0 - timestamp: str = "" - """Timestamp con formato YYYY-MM-DD HH:MM""" - - def set_timestamp(self, timestamp_ms: int | None = None, timestamp_s: int | None = None) -> None: - """ - Imposta il timestamp a partire da millisecondi o secondi. - IL timestamp viene salvato come stringa formattata 'YYYY-MM-DD HH:MM'. - Args: - timestamp_ms: Timestamp in millisecondi. - timestamp_s: Timestamp in secondi. - Raises: - """ - if timestamp_ms is not None: - timestamp = timestamp_ms // 1000 - elif timestamp_s is not None: - timestamp = timestamp_s - else: - raise ValueError("Either timestamp_ms or timestamp_s must be provided") - assert timestamp > 0, "Invalid timestamp data received" - - self.timestamp = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M') - -class MarketWrapper: - """ - 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("This method should be overridden by subclasses") - - 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("This method should be overridden by subclasses") - - def get_historical_prices(self, asset_id: str, 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("This method should be overridden by subclasses") diff --git a/src/app/utils/__init__.py b/src/app/utils/__init__.py index 1a511c1..579b141 100644 --- a/src/app/utils/__init__.py +++ b/src/app/utils/__init__.py @@ -1,5 +1,3 @@ -from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info -from app.utils.wrapper_handler import WrapperHandler from app.utils.chat_manager import ChatManager -__all__ = ["aggregate_history_prices", "aggregate_product_info", "WrapperHandler", "ChatManager"] +__all__ = ["ChatManager"] diff --git a/src/app/utils/market_aggregation.py b/src/app/utils/market_aggregation.py deleted file mode 100644 index 7f9f32c..0000000 --- a/src/app/utils/market_aggregation.py +++ /dev/null @@ -1,65 +0,0 @@ -import statistics -from app.base.markets import ProductInfo, Price - - -def aggregate_history_prices(prices: dict[str, list[Price]]) -> list[Price]: - """ - Aggrega i prezzi storici per symbol calcolando la media. - Args: - prices (dict[str, list[Price]]): Mappa provider -> lista di Price - Returns: - list[Price]: Lista di Price aggregati per timestamp - """ - - # Costruiamo una mappa timestamp -> lista di Price - timestamped_prices: dict[str, list[Price]] = {} - for _, price_list in prices.items(): - for price in price_list: - timestamped_prices.setdefault(price.timestamp, []).append(price) - - # Ora aggregiamo i prezzi per ogni timestamp - aggregated_prices: list[Price] = [] - for time, price_list in timestamped_prices.items(): - price = Price() - price.timestamp = 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 - aggregated_products: list[ProductInfo] = [] - for symbol, product_list in symbols_infos.items(): - product = ProductInfo() - - product.id = f"{symbol}_AGGREGATED" - product.symbol = symbol - product.currency = next(p.currency for p in product_list if p.currency) - - 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 - diff --git a/tests/agents/test_predictor.py b/tests/agents/test_predictor.py index 9a2ac11..2dda67e 100644 --- a/tests/agents/test_predictor.py +++ b/tests/agents/test_predictor.py @@ -1,7 +1,7 @@ import pytest from app.agents import AppModels from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle -from app.base.markets import ProductInfo +from app.api.base.markets import ProductInfo def unified_checks(model: AppModels, input: PredictorInput) -> None: llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output_schema=PredictorOutput) # type: ignore[arg-type] diff --git a/tests/api/test_binance.py b/tests/api/test_binance.py index b4ea0bb..4fee373 100644 --- a/tests/api/test_binance.py +++ b/tests/api/test_binance.py @@ -1,5 +1,18 @@ import pytest -from app.markets.binance import BinanceWrapper +import asyncio +from app.api.markets.binance import BinanceWrapper + +# fix warning about no event loop +@pytest.fixture(scope="session", autouse=True) +def event_loop(): + """ + Ensure there is an event loop for the duration of the tests. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + loop.close() + @pytest.mark.market @pytest.mark.api @@ -51,3 +64,18 @@ class TestBinance: assert entry.close > 0 assert entry.high > 0 assert entry.timestamp != '' + + def test_binance_fiat_conversion(self): + market = BinanceWrapper(currency="USD") + assert market.currency == "USDT" + product = market.get_product("BTC") + assert product is not None + assert product.symbol == "BTC" + assert product.price > 0 + + market = BinanceWrapper(currency="EUR") + assert market.currency == "EUR" + product = market.get_product("BTC") + assert product is not None + assert product.symbol == "BTC" + assert product.price > 0 diff --git a/tests/api/test_coinbase.py b/tests/api/test_coinbase.py index e114f4c..f022375 100644 --- a/tests/api/test_coinbase.py +++ b/tests/api/test_coinbase.py @@ -1,6 +1,6 @@ import os import pytest -from app.markets import CoinBaseWrapper +from app.api.markets import CoinBaseWrapper @pytest.mark.market @pytest.mark.api diff --git a/tests/api/test_cryptocompare.py b/tests/api/test_cryptocompare.py index 23deaf3..689a732 100644 --- a/tests/api/test_cryptocompare.py +++ b/tests/api/test_cryptocompare.py @@ -1,6 +1,6 @@ import os import pytest -from app.markets import CryptoCompareWrapper +from app.api.markets import CryptoCompareWrapper @pytest.mark.market @pytest.mark.api diff --git a/tests/api/test_cryptopanic_api.py b/tests/api/test_cryptopanic_api.py index 3c29bdb..51015f8 100644 --- a/tests/api/test_cryptopanic_api.py +++ b/tests/api/test_cryptopanic_api.py @@ -1,6 +1,6 @@ import os import pytest -from app.news import CryptoPanicWrapper +from app.api.news import CryptoPanicWrapper @pytest.mark.limited diff --git a/tests/api/test_duckduckgo_news.py b/tests/api/test_duckduckgo_news.py index f1de9c6..34eb362 100644 --- a/tests/api/test_duckduckgo_news.py +++ b/tests/api/test_duckduckgo_news.py @@ -1,5 +1,5 @@ import pytest -from app.news import DuckDuckGoWrapper +from app.api.news import DuckDuckGoWrapper @pytest.mark.news diff --git a/tests/api/test_google_news.py b/tests/api/test_google_news.py index 0b7241c..7b02ed8 100644 --- a/tests/api/test_google_news.py +++ b/tests/api/test_google_news.py @@ -1,5 +1,5 @@ import pytest -from app.news import GoogleNewsWrapper +from app.api.news import GoogleNewsWrapper @pytest.mark.news diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 839941c..30508d6 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -1,6 +1,6 @@ import os import pytest -from app.news import NewsApiWrapper +from app.api.news import NewsApiWrapper @pytest.mark.news diff --git a/tests/api/test_reddit.py b/tests/api/test_reddit.py index 3e42eb6..d4533a5 100644 --- a/tests/api/test_reddit.py +++ b/tests/api/test_reddit.py @@ -1,6 +1,6 @@ import os import pytest -from app.social.reddit import MAX_COMMENTS, RedditWrapper +from app.api.social.reddit import MAX_COMMENTS, RedditWrapper @pytest.mark.social @pytest.mark.api diff --git a/tests/api/test_yfinance.py b/tests/api/test_yfinance.py index fa4174a..1f443d4 100644 --- a/tests/api/test_yfinance.py +++ b/tests/api/test_yfinance.py @@ -1,5 +1,5 @@ import pytest -from app.markets import YFinanceWrapper +from app.api.markets import YFinanceWrapper @pytest.mark.market @pytest.mark.api diff --git a/tests/tools/test_market_tool.py b/tests/tools/test_market_tool.py index 674707f..5e28edd 100644 --- a/tests/tools/test_market_tool.py +++ b/tests/tools/test_market_tool.py @@ -1,5 +1,5 @@ import pytest -from app.markets import MarketAPIsTool +from app.api.markets import MarketAPIsTool @pytest.mark.tools diff --git a/tests/tools/test_news_tool.py b/tests/tools/test_news_tool.py index 3b8254f..5f685a8 100644 --- a/tests/tools/test_news_tool.py +++ b/tests/tools/test_news_tool.py @@ -1,5 +1,5 @@ import pytest -from app.news import NewsAPIsTool +from app.api.news import NewsAPIsTool @pytest.mark.tools @@ -12,7 +12,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(limit=2)) + result = tool.handler.try_call(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, list) assert len(result) > 0 for article in result: @@ -21,7 +21,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", limit=2)) + result = tool.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: @@ -30,7 +30,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(limit=2)) + result = tool.handler.try_call_all(lambda w: w.get_top_headlines(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 for _provider, articles in result.items(): @@ -40,7 +40,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", limit=2)) + result = tool.handler.try_call_all(lambda w: w.get_latest_news(query="crypto", limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 for _provider, articles in result.items(): diff --git a/tests/tools/test_socials_tool.py b/tests/tools/test_socials_tool.py index d08ed0f..29a81ae 100644 --- a/tests/tools/test_socials_tool.py +++ b/tests/tools/test_socials_tool.py @@ -1,5 +1,5 @@ import pytest -from app.social import SocialAPIsTool +from app.api.social import SocialAPIsTool @pytest.mark.tools @@ -12,7 +12,7 @@ class TestSocialAPIsTool: 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)) + result = tool.handler.try_call(lambda w: w.get_top_crypto_posts(limit=2)) assert isinstance(result, list) assert len(result) > 0 for post in result: @@ -21,10 +21,10 @@ class TestSocialAPIsTool: 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)) + result = tool.handler.try_call_all(lambda w: w.get_top_crypto_posts(limit=2)) assert isinstance(result, dict) assert len(result.keys()) > 0 - for provider, posts in result.items(): + for _provider, posts in result.items(): for post in posts: 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 index 35e3084..8c6ea18 100644 --- a/tests/utils/test_market_aggregator.py +++ b/tests/utils/test_market_aggregator.py @@ -1,7 +1,6 @@ import pytest from datetime import datetime -from app.base.markets import ProductInfo, Price -from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info +from app.api.base.markets import ProductInfo, Price @pytest.mark.aggregator @@ -34,7 +33,7 @@ class TestMarketDataAggregator: "Provider3": [self.__product("BTC", 49900.0, 900.0, "USD")], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 info = aggregated[0] @@ -58,7 +57,7 @@ class TestMarketDataAggregator: ], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 2 btc_info = next((p for p in aggregated if p.symbol == "BTC"), None) @@ -81,7 +80,7 @@ class TestMarketDataAggregator: "Provider1": [], "Provider2": [], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 0 def test_aggregate_product_info_with_partial_data(self): @@ -89,7 +88,7 @@ class TestMarketDataAggregator: "Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")], "Provider2": [], } - aggregated = aggregate_product_info(products) + aggregated = ProductInfo.aggregate(products) assert len(aggregated) == 1 info = aggregated[0] assert info.symbol == "BTC" @@ -120,7 +119,7 @@ class TestMarketDataAggregator: price.set_timestamp(timestamp_s=timestamp_2h_ago) timestamp_2h_ago = price.timestamp - aggregated = aggregate_history_prices(prices) + aggregated = Price.aggregate(prices) assert len(aggregated) == 2 assert aggregated[0].timestamp == timestamp_1h_ago assert aggregated[0].high == pytest.approx(50050.0, rel=1e-3) # type: ignore diff --git a/tests/utils/test_wrapper_handler.py b/tests/utils/test_wrapper_handler.py index c6094a1..86922ab 100644 --- a/tests/utils/test_wrapper_handler.py +++ b/tests/utils/test_wrapper_handler.py @@ -1,5 +1,5 @@ import pytest -from app.utils.wrapper_handler import WrapperHandler +from app.api.wrapper_handler import WrapperHandler class MockWrapper: def do_something(self) -> str: