Merge remote-tracking branch 'origin/main' into tool
# Conflicts: # src/app.py # src/app/agents/market_agent.py # src/app/markets/__init__.py # src/app/markets/binance.py # src/app/markets/binance_public.py # src/app/markets/coinbase.py # src/app/markets/cryptocompare.py # src/app/pipeline.py # src/app/predictor.py # src/app/toolkits/market_toolkit.py # tests/agents/test_market.py
This commit is contained in:
@@ -77,6 +77,7 @@ if __name__ == "__main__":
|
||||
# Caricamento
|
||||
load_btn.click(load_previous_chat, inputs=None, outputs=[chatbot, chatbot])
|
||||
|
||||
server, port = ("127.0.0.1", 8000)
|
||||
log_info(f"Starting UPO AppAI Chat on http://{server}:{port}") # noqa
|
||||
server, port = ("0.0.0.0", 8000)
|
||||
server_log = "localhost" if server == "0.0.0.0" else server
|
||||
log_info(f"Starting UPO AppAI Chat on http://{server_log}:{port}") # noqa
|
||||
demo.launch(server_name=server, server_port=port, quiet=True)
|
||||
|
||||
@@ -1,91 +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 {e}")
|
||||
|
||||
# 4. Preparo output leggibile + metadati strutturati
|
||||
output_text = "📊 Dati di mercato:\n" + "\n".join(results)
|
||||
|
||||
return RunOutput(
|
||||
content=output_text,
|
||||
metadata={"products": products}
|
||||
)
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -1,88 +1,106 @@
|
||||
from .base import BaseWrapper
|
||||
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 .cryptocompare import CryptoCompareWrapper
|
||||
from .binance import BinanceWrapper
|
||||
from .binance_public import PublicBinanceAgent
|
||||
from .error_handler import ProviderFallback, MarketAPIError, safe_execute
|
||||
from .cryptocompare import CryptoCompareWrapper
|
||||
from .yfinance import YFinanceWrapper
|
||||
|
||||
from agno.utils.log import log_warning
|
||||
import logging
|
||||
__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper" ]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MarketAPIs(BaseWrapper):
|
||||
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.
|
||||
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
|
||||
"""
|
||||
|
||||
@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 = [
|
||||
BinanceWrapper,
|
||||
CoinBaseWrapper,
|
||||
CryptoCompareWrapper,
|
||||
]
|
||||
|
||||
result = []
|
||||
for wrapper in wrapper_builders:
|
||||
try:
|
||||
result.append(wrapper(currency=currency))
|
||||
except Exception as e:
|
||||
log_warning(f"{wrapper} cannot be initialized: {e}")
|
||||
|
||||
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.
|
||||
|
||||
Initialize the MarketAPIsTool with multiple market API wrappers.
|
||||
The following wrappers are included in this order:
|
||||
- BinanceWrapper
|
||||
- YFinanceWrapper
|
||||
- CoinBaseWrapper
|
||||
- CryptoCompareWrapper
|
||||
Args:
|
||||
currency: Valuta di riferimento (default "USD")
|
||||
currency (str): Valuta in cui restituire i prezzi. Default è "USD".
|
||||
"""
|
||||
self.currency = currency
|
||||
self.wrappers = MarketAPIs.get_list_available_market_apis(currency=currency)
|
||||
self.fallback_manager = ProviderFallback(self.wrappers)
|
||||
kwargs = {"currency": currency or "USD"}
|
||||
wrappers = [ BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ]
|
||||
self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs)
|
||||
|
||||
# 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
|
||||
Toolkit.__init__(
|
||||
self,
|
||||
name="Market APIs Toolkit",
|
||||
tools=[
|
||||
self.get_product,
|
||||
self.get_products,
|
||||
self.get_historical_prices,
|
||||
self.get_products_aggregated,
|
||||
self.get_historical_prices_aggregated,
|
||||
],
|
||||
)
|
||||
|
||||
def get_products(self, asset_ids: list):
|
||||
"""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_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]:
|
||||
return self.wrappers.try_call(lambda w: w.get_products(asset_ids))
|
||||
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))
|
||||
|
||||
def get_all_products(self):
|
||||
"""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
|
||||
def get_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
@@ -1,19 +1,41 @@
|
||||
from coinbase.rest.types.product_types import Candle, GetProductResponse, Product
|
||||
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':
|
||||
raise NotImplementedError
|
||||
"""
|
||||
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']:
|
||||
raise NotImplementedError
|
||||
def get_all_products(self) -> list['ProductInfo']:
|
||||
raise NotImplementedError
|
||||
def get_historical_prices(self, asset_id: str = "BTC") -> list['Price']:
|
||||
raise NotImplementedError
|
||||
"""
|
||||
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 = "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("This method should be overridden by subclasses")
|
||||
|
||||
class ProductInfo(BaseModel):
|
||||
"""
|
||||
@@ -24,61 +46,8 @@ class ProductInfo(BaseModel):
|
||||
symbol: str = ""
|
||||
price: float = 0.0
|
||||
volume_24h: float = 0.0
|
||||
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.
|
||||
@@ -89,26 +58,4 @@ class Price(BaseModel):
|
||||
open: float = 0.0
|
||||
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
|
||||
timestamp_ms: int = 0 # Timestamp in milliseconds
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from binance.client import Client
|
||||
from app.markets.base import ProductInfo, BaseWrapper, Price
|
||||
from app.markets.error_handler import retry_on_failure, handle_api_errors, MarketAPIError
|
||||
from .base import ProductInfo, BaseWrapper, Price
|
||||
|
||||
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.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.
|
||||
|
||||
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")
|
||||
assert api_key is not None, "API key is required"
|
||||
|
||||
if api_secret is None:
|
||||
api_secret = os.getenv("BINANCE_API_SECRET")
|
||||
assert api_secret is not None, "API secret is required"
|
||||
def __init__(self, currency: str = "USDT"):
|
||||
api_key = os.getenv("BINANCE_API_KEY")
|
||||
api_secret = os.getenv("BINANCE_API_SECRET")
|
||||
|
||||
self.currency = currency
|
||||
self.client = Client(api_key=api_key, api_secret=api_secret)
|
||||
@@ -40,130 +40,37 @@ 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
|
||||
"""
|
||||
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
|
||||
symbols = [self.__format_symbol(asset_id) for asset_id in asset_ids]
|
||||
symbols_str = f"[\"{'","'.join(symbols)}\"]"
|
||||
|
||||
@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 []
|
||||
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)
|
||||
|
||||
@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
|
||||
"""
|
||||
return [get_product(self.currency, ticker) for ticker in tickers]
|
||||
|
||||
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,
|
||||
)
|
||||
return [get_price(kline) for kline in klines]
|
||||
|
||||
|
||||
@@ -1,227 +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 app.markets.base import BaseWrapper, ProductInfo, Price
|
||||
from app.markets.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)
|
||||
@@ -1,28 +1,56 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
from coinbase.rest import RESTClient
|
||||
from app.markets.base import ProductInfo, BaseWrapper, Price
|
||||
from app.markets.error_handler import retry_on_failure, handle_api_errors, MarketAPIError, RateLimitError
|
||||
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
|
||||
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.timestamp_ms = int(candle_data.start) * 1000 if candle_data.start else 0
|
||||
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 Advanced Trade.
|
||||
|
||||
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.
|
||||
|
||||
La documentazione delle API è disponibile qui:
|
||||
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: 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"
|
||||
|
||||
if api_private_key is None:
|
||||
api_private_key = os.getenv("COINBASE_API_SECRET")
|
||||
assert api_private_key is not None, "API private key is required"
|
||||
def __init__(self, currency: str = "USD"):
|
||||
api_key = os.getenv("COINBASE_API_KEY")
|
||||
assert api_key, "COINBASE_API_KEY environment variable not set"
|
||||
|
||||
api_private_key = os.getenv("COINBASE_API_SECRET")
|
||||
assert api_private_key, "COINBASE_API_SECRET environment variable not set"
|
||||
|
||||
self.currency = currency
|
||||
self.client: RESTClient = RESTClient(
|
||||
@@ -33,49 +61,26 @@ 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)
|
||||
return get_product(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(product_ids=all_asset_ids)
|
||||
if assets.products:
|
||||
return [ProductInfo.from_coinbase_product(asset) for asset in assets.products]
|
||||
return []
|
||||
return [get_product(asset) for asset in assets.products]
|
||||
|
||||
@retry_on_failure(max_retries=3, delay=1.0)
|
||||
@handle_api_errors
|
||||
def get_all_products(self) -> list[ProductInfo]:
|
||||
assets = self.client.get_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]:
|
||||
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
|
||||
asset_id = self.__format(asset_id)
|
||||
# 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
|
||||
granularity=Granularity.ONE_HOUR.name,
|
||||
start=str(int(start_time.timestamp())),
|
||||
end=str(int(end_time.timestamp())),
|
||||
limit=limit
|
||||
)
|
||||
if data.candles:
|
||||
return [Price.from_coinbase(candle) for candle in data.candles]
|
||||
return []
|
||||
return [get_price(candle) for candle in data.candles]
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import os
|
||||
import requests
|
||||
from typing import Optional, Dict, Any
|
||||
from app.markets.base import ProductInfo, BaseWrapper, Price
|
||||
from app.markets.error_handler import retry_on_failure, handle_api_errors, MarketAPIError
|
||||
from .base import ProductInfo, BaseWrapper, Price
|
||||
|
||||
|
||||
def get_product(asset_data: dict) -> ProductInfo:
|
||||
product = ProductInfo()
|
||||
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:
|
||||
price = Price()
|
||||
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
|
||||
|
||||
|
||||
BASE_URL = "https://min-api.cryptocompare.com"
|
||||
|
||||
@@ -12,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: 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"
|
||||
def __init__(self, currency:str='USD'):
|
||||
api_key = os.getenv("CRYPTOCOMPARE_API_KEY")
|
||||
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
|
||||
@@ -28,18 +47,14 @@ 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,
|
||||
"tsyms": self.currency
|
||||
})
|
||||
data = response.get('RAW', {}).get(asset_id, {}).get(self.currency, {})
|
||||
return ProductInfo.from_cryptocompare(data)
|
||||
return get_product(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),
|
||||
@@ -49,47 +64,16 @@ 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
|
||||
|
||||
@retry_on_failure(max_retries=3, delay=1.0)
|
||||
@handle_api_errors
|
||||
def get_all_products(self) -> list[ProductInfo]:
|
||||
"""
|
||||
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)}"
|
||||
)
|
||||
|
||||
@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"
|
||||
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,
|
||||
"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
|
||||
|
||||
@@ -1,236 +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
|
||||
|
||||
# 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
|
||||
80
src/app/markets/yfinance.py
Normal file
80
src/app/markets/yfinance.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
from agno.tools.yfinance import YFinanceTools
|
||||
from .base import BaseWrapper, ProductInfo, Price
|
||||
|
||||
|
||||
def create_product_info(stock_data: dict[str, str]) -> ProductInfo:
|
||||
"""
|
||||
Converte i dati di YFinanceTools in ProductInfo.
|
||||
"""
|
||||
product = ProductInfo()
|
||||
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[str, str]) -> Price:
|
||||
"""
|
||||
Converte i dati storici di YFinanceTools in Price.
|
||||
"""
|
||||
price = Price()
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
self.tool = YFinanceTools()
|
||||
|
||||
def _format_symbol(self, asset_id: str) -> str:
|
||||
"""
|
||||
Formatta il simbolo per yfinance.
|
||||
Per crypto, aggiunge '-' e la valuta (es. BTC -> BTC-USD).
|
||||
"""
|
||||
asset_id = asset_id.upper()
|
||||
return f"{asset_id}-{self.currency}" if '-' not in asset_id else asset_id
|
||||
|
||||
def get_product(self, asset_id: str) -> ProductInfo:
|
||||
symbol = self._format_symbol(asset_id)
|
||||
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]:
|
||||
products = []
|
||||
for asset_id in asset_ids:
|
||||
product = self.get_product(asset_id)
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -1,12 +1,12 @@
|
||||
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
|
||||
from agno.models.ollama import Ollama
|
||||
from agno.utils.log import log_warning
|
||||
from agno.tools import Toolkit
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -21,6 +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_1B = "qwen3:1.7b" # + very fast + decent (1.7b)
|
||||
|
||||
@staticmethod
|
||||
def availables_local() -> list['AppModels']:
|
||||
@@ -36,10 +38,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
|
||||
@@ -71,63 +72,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("</think>")
|
||||
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.
|
||||
"""
|
||||
@@ -135,6 +104,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
|
||||
|
||||
94
src/app/news/__init__.py
Normal file
94
src/app/news/__init__.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from agno.tools import Toolkit
|
||||
from app.utils.wrapper_handler import WrapperHandler
|
||||
from .base import NewsWrapper, Article
|
||||
from .news_api import NewsApiWrapper
|
||||
from .googlenews import GoogleNewsWrapper
|
||||
from .cryptopanic_api import CryptoPanicWrapper
|
||||
from .duckduckgo import DuckDuckGoWrapper
|
||||
|
||||
__all__ = ["NewsAPIsTool", "NEWS_INSTRUCTIONS", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"]
|
||||
|
||||
|
||||
class NewsAPIsTool(NewsWrapper, Toolkit):
|
||||
"""
|
||||
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
|
||||
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
Toolkit.__init__(
|
||||
self,
|
||||
name="News APIs Toolkit",
|
||||
tools=[
|
||||
self.get_top_headlines,
|
||||
self.get_latest_news,
|
||||
],
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
* 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.
|
||||
"""
|
||||
35
src/app/news/base.py
Normal file
35
src/app/news/base.py
Normal file
@@ -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, limit: int = 100) -> list[Article]:
|
||||
"""
|
||||
Get top headlines, optionally limited by limit.
|
||||
Args:
|
||||
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, limit: int = 100) -> list[Article]:
|
||||
"""
|
||||
Get latest news based on a query.
|
||||
Args:
|
||||
query (str): The search query.
|
||||
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")
|
||||
|
||||
77
src/app/news/cryptopanic_api.py
Normal file
77
src/app/news/cryptopanic_api.py
Normal file
@@ -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, 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, limit: 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[:limit]
|
||||
32
src/app/news/duckduckgo.py
Normal file
32
src/app/news/duckduckgo.py
Normal file
@@ -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, 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, 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]
|
||||
|
||||
36
src/app/news/googlenews.py
Normal file
36
src/app/news/googlenews.py
Normal file
@@ -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, limit: int = 100) -> list[Article]:
|
||||
gnews = GNews(language='en', max_results=limit, 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, limit: int = 100) -> list[Article]:
|
||||
gnews = GNews(language='en', max_results=limit, period='7d')
|
||||
results = gnews.get_news(query)
|
||||
|
||||
articles = []
|
||||
for result in results:
|
||||
article = result_to_article(result)
|
||||
articles.append(article)
|
||||
return articles
|
||||
53
src/app/news/news_api.py
Normal file
53
src/app/news/news_api.py
Normal file
@@ -0,0 +1,53 @@
|
||||
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, "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 __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, 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", [])]
|
||||
articles.extend(results)
|
||||
return articles
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from agno.run.agent import RunOutput
|
||||
from agno.team import Team
|
||||
|
||||
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 PredictorInput, PredictorOutput, PredictorStyle, PREDICTOR_INSTRUCTIONS
|
||||
from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS
|
||||
|
||||
|
||||
class Pipeline:
|
||||
@@ -15,10 +15,22 @@ class Pipeline:
|
||||
e scelto dall'utente tramite i dropdown dell'interfaccia grafica.
|
||||
"""
|
||||
def __init__(self):
|
||||
# === Membri del team ===
|
||||
self.market_agent = MarketAgent()
|
||||
self.news_agent = NewsAgent()
|
||||
self.social_agent = SocialAgent()
|
||||
# Inizializza gli agenti
|
||||
self.market_agent = AppModels.OLLAMA_QWEN.get_agent(
|
||||
instructions=MARKET_INSTRUCTIONS,
|
||||
name="MarketAgent",
|
||||
tools=[MarketAPIsTool()]
|
||||
)
|
||||
self.news_agent = AppModels.OLLAMA_QWEN.get_agent(
|
||||
instructions=NEWS_INSTRUCTIONS,
|
||||
name="NewsAgent",
|
||||
tools=[NewsAPIsTool()]
|
||||
)
|
||||
self.social_agent = AppModels.OLLAMA_QWEN.get_agent(
|
||||
instructions=SOCIAL_INSTRUCTIONS,
|
||||
name="SocialAgent",
|
||||
tools=[SocialAPIsTool()]
|
||||
)
|
||||
|
||||
# === Modello di orchestrazione del Team ===
|
||||
team_model = AppModels.OLLAMA_QWEN.get_model(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.markets.base import ProductInfo
|
||||
|
||||
|
||||
|
||||
61
src/app/social/__init__.py
Normal file
61
src/app/social/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from agno.tools import Toolkit
|
||||
from app.utils.wrapper_handler import WrapperHandler
|
||||
from .base import SocialPost, SocialWrapper
|
||||
from .reddit import RedditWrapper
|
||||
|
||||
__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.build_wrappers(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))
|
||||
|
||||
|
||||
SOCIAL_INSTRUCTIONS = """
|
||||
**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.
|
||||
|
||||
**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.
|
||||
"""
|
||||
30
src/app/social/base.py
Normal file
30
src/app/social/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SocialPost(BaseModel):
|
||||
time: str = ""
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
comments: list["SocialComment"] = []
|
||||
|
||||
class SocialComment(BaseModel):
|
||||
time: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
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.
|
||||
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")
|
||||
|
||||
68
src/app/social/reddit.py
Normal file
68
src/app/social/reddit.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import os
|
||||
from praw import Reddit
|
||||
from praw.models import Submission, MoreComments
|
||||
from .base import SocialWrapper, SocialPost, SocialComment
|
||||
|
||||
MAX_COMMENTS = 5
|
||||
# metterne altri se necessario.
|
||||
# fonti: 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:
|
||||
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):
|
||||
client_id = os.getenv("REDDIT_API_CLIENT_ID")
|
||||
assert client_id, "REDDIT_API_CLIENT_ID environment variable is not set"
|
||||
|
||||
client_secret = os.getenv("REDDIT_API_CLIENT_SECRET")
|
||||
assert client_secret, "REDDIT_API_CLIENT_SECRET environment variable is not set"
|
||||
|
||||
self.tool = Reddit(
|
||||
client_id=client_id,
|
||||
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]:
|
||||
top_posts = self.subreddits.top(limit=limit, time_filter="week")
|
||||
return [create_social_post(post) for post in top_posts]
|
||||
@@ -1,41 +0,0 @@
|
||||
from agno.tools import Toolkit
|
||||
|
||||
from 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
|
||||
# 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("USD") # change currency if needed
|
||||
|
||||
super().__init__(
|
||||
name="Market Toolkit",
|
||||
tools=[
|
||||
self.get_historical_data,
|
||||
self.get_current_prices,
|
||||
],
|
||||
)
|
||||
|
||||
def get_historical_data(self, symbol: str):
|
||||
return self.market_api.get_historical_prices(symbol)
|
||||
|
||||
def get_current_prices(self, symbols: list):
|
||||
return self.market_api.get_products(symbols)
|
||||
|
||||
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")
|
||||
|
||||
"""
|
||||
|
||||
91
src/app/utils/market_aggregation.py
Normal file
91
src/app/utils/market_aggregation.py
Normal file
@@ -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))
|
||||
@@ -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
|
||||
140
src/app/utils/wrapper_handler.py
Normal file
140
src/app/utils/wrapper_handler.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import inspect
|
||||
import time
|
||||
import traceback
|
||||
from typing import TypeVar, Callable, Generic, Iterable, Type
|
||||
from agno.utils.log import log_warning, log_info
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
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
|
||||
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)
|
||||
self.retry_count = 0
|
||||
iterations += 1
|
||||
else:
|
||||
time.sleep(self.retry_delay)
|
||||
|
||||
raise Exception(f"All wrappers failed, latest error: {error}")
|
||||
|
||||
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.
|
||||
"""
|
||||
log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}")
|
||||
|
||||
results = {}
|
||||
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:
|
||||
error = WrapperHandler.__concise_error(e)
|
||||
log_warning(f"{wrapper_name} failed: {error}")
|
||||
if not results:
|
||||
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, 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.
|
||||
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.
|
||||
kwargs (dict | None): Optional dictionary with keyword arguments common to all wrappers.
|
||||
Returns:
|
||||
WrapperHandler[W]: An instance of WrapperHandler with the initialized wrappers.
|
||||
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:
|
||||
wrapper = wrapper_class(**(kwargs or {}))
|
||||
result.append(wrapper)
|
||||
except Exception as e:
|
||||
log_warning(f"{wrapper_class} cannot be initialized: {e}")
|
||||
|
||||
return WrapperHandler(result, try_per_wrapper, retry_delay)
|
||||
Reference in New Issue
Block a user