Tool #15

Merged
trojanhorse47 merged 7 commits from tool into main 2025-10-03 11:42:11 +02:00
55 changed files with 2522 additions and 2099 deletions
Showing only changes of commit 6fda746ed5 - Show all commits

View File

@@ -1,35 +1,40 @@
###########################################################################
###############################################################################
# Configurazioni per i modelli di linguaggio
###############################################################################
# Alcune API sono a pagamento, altre hanno un piano gratuito con limiti di utilizzo
# Vedi https://docs.agno.com/examples/models per vedere tutti i modelli supportati
# https://makersuite.google.com/app/apikey
GOOGLE_API_KEY=
# Inserire il percorso di installazione di ollama (es. /usr/share/ollama/.ollama)
# attenzione che fra Linux nativo e WSL il percorso è diverso
OLLAMA_MODELS_PATH=
###############################################################################
# Configurazioni per gli agenti di mercato
###############################################################################
# Coinbase CDP API per Market Agent
# Ottenibili da: https://portal.cdp.coinbase.com/access/api
# IMPORTANTE: Usare le credenziali CDP (NON Exchange legacy)
# - COINBASE_API_KEY: organizations/{org_id}/apiKeys/{key_id}
# - COINBASE_API_SECRET: La private key completa (inclusi BEGIN/END)
# - NON serve COINBASE_PASSPHRASE (solo per Exchange legacy)
# https://portal.cdp.coinbase.com/access/api
COINBASE_API_KEY=
COINBASE_API_SECRET=
# CryptoCompare API per Market Agent
# Ottenibile da: https://www.cryptocompare.com/cryptopian/api-keys
# NOTA: API legacy, potrebbe essere deprecata in futuro
# Funzionalità limitata: get_all_products() non supportato
# https://www.cryptocompare.com/cryptopian/api-keys
CRYPTOCOMPARE_API_KEY=
# Binance API per Market Agent
# Ottenibili da: https://www.binance.com/en/my/settings/api-management
# Supporta sia API autenticate che pubbliche (PublicBinance)
# https://www.binance.com/en/my/settings/api-management
# Non necessario per operazioni in sola lettura
BINANCE_API_KEY=
BINANCE_API_SECRET=
###############################################################################
# Configurazioni per gli agenti di notizie
###############################################################################
# https://newsapi.org/docs
NEWS_API_KEY=
# https://cryptopanic.com/developers/api/
CRYPTOPANIC_API_KEY=
###############################################################################
# Configurazioni per API di social media
###############################################################################
# https://www.reddit.com/prefs/apps
REDDIT_API_CLIENT_ID=
REDDIT_API_CLIENT_SECRET=

122
README.md
View File

@@ -9,53 +9,69 @@ L'obiettivo è quello di creare un sistema di consulenza finanziaria basato su L
# **Indice**
- [Installazione](#installazione)
- [Ollama (Modelli Locali)](#ollama-modelli-locali)
- [Variabili d'Ambiente](#variabili-dambiente)
- [Installazione in locale con UV](#installazione-in-locale-con-uv)
- [Installazione con Docker](#installazione-con-docker)
- [1. Variabili d'Ambiente](#1-variabili-dambiente)
- [2. Ollama](#2-ollama)
- [3. Docker](#3-docker)
- [4. UV (solo per sviluppo locale)](#4-uv-solo-per-sviluppo-locale)
- [Applicazione](#applicazione)
- [Ultimo Aggiornamento](#ultimo-aggiornamento)
- [Tests](#tests)
# **Installazione**
Per l'installazione di questo progetto si consiglia di utilizzare **Docker**. Con questo approccio si evita di dover installare manualmente tutte le dipendenze e si può eseguire il progetto in un ambiente isolato.
Per lo sviluppo locale si può utilizzare **uv** che si occupa di creare un ambiente virtuale e installare tutte le dipendenze.
L'installazione di questo progetto richiede 3 passaggi totali (+1 se si vuole sviluppare in locale) che devono essere eseguiti in sequenza. Se questi passaggi sono eseguiti correttamente, l'applicazione dovrebbe partire senza problemi. Altrimenti è molto probabile che si verifichino errori di vario tipo (moduli mancanti, chiavi API non trovate, ecc.).
In ogni caso, ***prima*** di avviare l'applicazione è però necessario configurare correttamente le **API keys** e installare Ollama per l'utilizzo dei modelli locali, altrimenti il progetto, anche se installato correttamente, non riuscirà a partire.
1. Configurare le variabili d'ambiente
2. Installare Ollama e i modelli locali
3. Far partire il progetto con Docker (consigliato)
4. (Solo per sviluppo locale) Installare uv e creare l'ambiente virtuale
### Ollama (Modelli Locali)
Per utilizzare modelli AI localmente, è necessario installare Ollama:
> [!IMPORTANT]\
> Prima di iniziare, assicurarsi di avere clonato il repository e di essere nella cartella principale del progetto.
**1. Installazione Ollama**:
- **Linux**: `curl -fsSL https://ollama.com/install.sh | sh`
- **macOS/Windows**: Scarica l'installer da [https://ollama.com/download/windows](https://ollama.com/download/windows)
### **1. Variabili d'Ambiente**
**2. GPU Support (Raccomandato)**:
Per utilizzare la GPU con Ollama, assicurati di avere NVIDIA CUDA Toolkit installato:
- **Download**: [NVIDIA CUDA Downloads](https://developer.nvidia.com/cuda-downloads?target_os=Windows&target_arch=x86_64&target_version=11&target_type=exe_local)
- **Documentazione WSL**: [CUDA WSL User Guide](https://docs.nvidia.com/cuda/wsl-user-guide/index.html)
**3. Installazione Modelli**:
Si possono avere più modelli installati contemporaneamente. Per questo progetto si consiglia di utilizzare il modello open source `gpt-oss` poiché prestante e compatibile con tante funzionalità. Per il download: `ollama pull gpt-oss:latest`
### Variabili d'Ambiente
**1. Copia il file di esempio**:
Copia il file `.env.example` in `.env` e modificalo con le tue API keys:
```sh
cp .env.example .env
```
**2. Modifica il file .env** creato con le tue API keys e il path dei modelli Ollama, inserendoli nelle variabili opportune dopo l'uguale e ***senza*** spazi.
Le API Keys devono essere inserite nelle variabili opportune dopo l'uguale e ***senza*** spazi. Esse si possono ottenere tramite i loro providers (alcune sono gratuite, altre a pagamento).\
Nel file [.env.example](.env.example) sono presenti tutte le variabili da compilare con anche il link per recuperare le chiavi, quindi, dopo aver copiato il file, basta seguire le istruzioni al suo interno.
Le API Keys puoi ottenerle tramite i seguenti servizi (alcune sono gratuite, altre a pagamento):
- **Google AI**: [Google AI Studio](https://makersuite.google.com/app/apikey) (gratuito con limiti)
- **Anthropic**: [Anthropic Console](https://console.anthropic.com/)
- **DeepSeek**: [DeepSeek Platform](https://platform.deepseek.com/)
- **OpenAI**: [OpenAI Platform](https://platform.openai.com/api-keys)
Le chiavi non sono necessarie per far partire l'applicazione, ma senza di esse alcune funzionalità non saranno disponibili o saranno limitate. Per esempio senza la chiave di NewsAPI non si potranno recuperare le ultime notizie sul mercato delle criptovalute. Ciononostante, l'applicazione usa anche degli strumenti che non richiedono chiavi API, come Yahoo Finance e GNews, che permettono di avere comunque un'analisi di base del mercato.
### **2. Ollama**
Per utilizzare modelli AI localmente, è necessario installare Ollama, un gestore di modelli LLM che consente di eseguire modelli direttamente sul proprio hardware. Si consiglia di utilizzare Ollama con il supporto GPU per prestazioni ottimali, ma è possibile eseguirlo anche solo con la CPU.
Per l'installazione scaricare Ollama dal loro [sito ufficiale](https://ollama.com/download/linux).
Dopo l'installazione, si possono iniziare a scaricare i modelli desiderati tramite il comando `ollama pull <model>:<tag>`.
I modelli usati dall'applicazione sono visibili in [src/app/models.py](src/app/models.py). Di seguito metto lo stesso una lista di modelli, ma potrebbe non essere aggiornata:
- `gpt-oss:latest`
- `qwen3:latest`
- `qwen3:4b`
- `qwen3:1.7b`
### **3. Docker**
Se si vuole solamente avviare il progetto, si consiglia di utilizzare [Docker](https://www.docker.com), dato che sono stati creati i files [Dockerfile](Dockerfile) e [docker-compose.yaml](docker-compose.yaml) per creare il container con tutti i file necessari e già in esecuzione.
```sh
# Configura le variabili d'ambiente
cp .env.example .env
nano .env # Modifica il file
# Avvia il container
docker compose up --build -d
```
Se si sono seguiti i passaggi precedenti per la configurazione delle variabili d'ambiente, l'applicazione dovrebbe partire correttamente, dato che il file `.env` verrà automaticamente caricato nel container grazie alla configurazione in `docker-compose.yaml`.
### **4. UV (solo per sviluppo locale)**
Per prima cosa installa uv se non è già presente sul sistema
## **Installazione in locale con UV**
**1. Installazione uv**: Per prima cosa installa uv se non è già presente sul sistema:
```sh
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
@@ -64,54 +80,28 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**2. Ambiente e dipendenze**: uv installerà python e creerà automaticamente l'ambiente virtuale con le dipendenze corrette:
UV installerà python e creerà automaticamente l'ambiente virtuale con le dipendenze corrette (nota che questo passaggio è opzionale, dato che uv, ogni volta che si esegue un comando, controlla se l'ambiente è attivo e se le dipendenze sono installate):
```sh
uv sync --frozen --no-cache
```
**3. Run**: Successivamente si può far partire il progetto tramite il comando:
A questo punto si può far partire il progetto tramite il comando:
```sh
uv run python src/app.py
```
## **Installazione con Docker**
Alternativamente, se si ha installato [Docker](https://www.docker.com), si può utilizzare il [Dockerfile](Dockerfile) e il [docker-compose.yaml](docker-compose.yaml) per creare il container con tutti i file necessari e già in esecuzione:
**IMPORTANTE**: Assicurati di aver configurato il file `.env` come descritto sopra prima di avviare Docker.
```sh
docker compose up --build -d
```
Il file `.env` verrà automaticamente caricato nel container grazie alla configurazione in `docker-compose.yaml`.
# **Applicazione**
***L'applicazione è attualmente in fase di sviluppo.***
Usando la libreria ``gradio`` è stata creata un'interfaccia web semplice per interagire con l'agente principale. Gli agenti secondari si trovano nella cartella `src/app/agents` e sono:
- **Market Agent**: Agente unificato che supporta multiple fonti di dati (Coinbase + CryptoCompare) con auto-configurazione
- **News Agent**: Recupera le notizie finanziarie più recenti utilizzando.
- **Social Agent**: Analizza i sentimenti sui social media utilizzando.
- **Market Agent**: Agente unificato che supporta multiple fonti di dati con auto-retry e gestione degli errori.
- **News Agent**: Recupera le notizie finanziarie più recenti sul mercato delle criptovalute.
- **Social Agent**: Analizza i sentimenti sui social media riguardo alle criptovalute.
- **Predictor Agent**: Utilizza i dati raccolti dagli altri agenti per fare previsioni.
## Ultimo Aggiornamento
### Cose non funzionanti
- **Market Agent**: Non è un vero agente dato che non usa LLM per ragionare ma prende solo i dati
- **market_aggregator.py**: Non è usato per ora
- **News Agent**: Non funziona lo scraping online, per ora usa dati mock
- **Social Agent**: Non funziona lo scraping online, per ora usa dati mock
- **Demos**: Le demos nella cartella [demos](demos) non sono aggiornate e non funzionano per ora
### ToDo
- [X] Per lo scraping online bisogna iscriversi e recuperare le chiavi API
- [X] **Market Agent**: [CryptoCompare](https://www.cryptocompare.com/cryptopian/api-keys)
- [X] **Market Agent**: [Coinbase](https://www.coinbase.com/cloud/discover/api-keys)
- [ ] **News Agent**: [CryptoPanic](https://cryptopanic.com/)
- [ ] **Social Agent**: [post più hot da r/CryptoCurrency (Reddit)](https://www.reddit.com/)
- [ ] Capire come `gpt-oss` parsifica la risposta e per questioni "estetiche" si può pensare di visualizzare lo stream dei token. Vedere il sorgente `src/ollama_demo.py` per risolvere il problema.
## Tests
Per eseguire i test, assicurati di aver configurato correttamente le variabili d'ambiente nel file `.env` come descritto sopra. Poi esegui il comando:
@@ -119,8 +109,8 @@ Per eseguire i test, assicurati di aver configurato correttamente le variabili d
uv run pytest -v
# Oppure per test specifici
uv run pytest -v tests/agents/test_market.py
uv run pytest -v tests/agents/test_predictor.py
uv run pytest -v tests/api/test_binance.py
uv run pytest -v -k "test_news"
# Oppure usando i markers
uv run pytest -v -m api

View File

@@ -8,6 +8,7 @@ Questo script dimostra l'utilizzo di tutti i wrapper che implementano BaseWrappe
- CryptoCompareWrapper (richiede API key)
- BinanceWrapper (richiede credenziali)
- PublicBinanceAgent (accesso pubblico)
- YFinanceWrapper (accesso gratuito a dati azionari e crypto)
Lo script effettua chiamate GET a diversi provider e visualizza i dati
in modo strutturato con informazioni dettagliate su timestamp, stato
@@ -26,11 +27,11 @@ project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
from dotenv import load_dotenv
from app.markets import (
from app.markets import (
CoinBaseWrapper,
CryptoCompareWrapper,
BinanceWrapper,
PublicBinanceAgent,
BinanceWrapper,
YFinanceWrapper,
BaseWrapper
)
@@ -153,7 +154,7 @@ class ProviderTester:
if product:
print(f"📦 Product: {product.symbol} (ID: {product.id})")
print(f" Price: ${product.price:.2f}, Quote: {product.quote_currency}")
print(f" Status: {product.status}, Volume 24h: {product.volume_24h:,.2f}")
print(f" Volume 24h: {product.volume_24h:,.2f}")
else:
print(f"📦 Product: Nessun prodotto trovato per {symbol}")
@@ -185,24 +186,6 @@ class ProviderTester:
results["tests"]["get_products"] = f"ERROR: {error_msg}"
results["overall_status"] = "PARTIAL"
# Test get_all_products
timestamp = datetime.now()
try:
all_products = wrapper.get_all_products()
self.formatter.print_request_info(
provider_name, "get_all_products()", timestamp, "✅ SUCCESS"
)
self.formatter.print_product_table(all_products, f"{provider_name} All Products")
results["tests"]["get_all_products"] = "SUCCESS"
except Exception as e:
error_msg = str(e)
self.formatter.print_request_info(
provider_name, "get_all_products()", timestamp, "❌ ERROR", error_msg
)
results["tests"]["get_all_products"] = f"ERROR: {error_msg}"
results["overall_status"] = "PARTIAL"
# Test get_historical_prices
timestamp = datetime.now()
try:
@@ -239,13 +222,6 @@ def initialize_providers() -> Dict[str, BaseWrapper]:
providers = {}
env_vars = check_environment_variables()
# PublicBinanceAgent (sempre disponibile)
try:
providers["PublicBinance"] = PublicBinanceAgent()
print("✅ PublicBinanceAgent inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di PublicBinanceAgent: {e}")
# CryptoCompareWrapper
if env_vars["CRYPTOCOMPARE_API_KEY"]:
try:
@@ -267,15 +243,18 @@ def initialize_providers() -> Dict[str, BaseWrapper]:
print("⚠️ CoinBaseWrapper saltato: credenziali Coinbase non complete")
# BinanceWrapper
if env_vars["BINANCE_API_KEY"] and env_vars["BINANCE_API_SECRET"]:
try:
providers["Binance"] = BinanceWrapper()
print("✅ BinanceWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}")
else:
print("⚠️ BinanceWrapper saltato: credenziali Binance non complete")
try:
providers["Binance"] = BinanceWrapper()
print("✅ BinanceWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}")
# YFinanceWrapper (sempre disponibile - dati azionari e crypto gratuiti)
try:
providers["YFinance"] = YFinanceWrapper()
print("✅ YFinanceWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di YFinanceWrapper: {e}")
return providers
def print_summary(results: List[Dict[str, Any]]):

16
demos/news_api.py Normal file
View File

@@ -0,0 +1,16 @@
#### FOR ALL FILES OUTSIDE src/ FOLDER ####
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
###########################################
from dotenv import load_dotenv
from app.news import NewsApiWrapper
def main():
api = NewsApiWrapper()
print("ok")
if __name__ == "__main__":
load_dotenv()
main()

View File

@@ -9,12 +9,4 @@ services:
env_file:
- .env
environment:
# Modelli supportati
- OLLAMA_HOST=http://host.docker.internal:11434
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
# Chiavi per le crypto API
- CDP_API_KEY_NAME=${CDP_API_KEY_NAME}
- CDP_API_PRIVATE_KEY=${CDP_API_PRIVATE_KEY}
- CRYPTOCOMPARE_API_KEY=${CRYPTOCOMPARE_API_KEY}
- BINANCE_API_KEY=${BINANCE_API_KEY}
- BINANCE_API_SECRET=${BINANCE_API_SECRET}

View File

@@ -10,25 +10,33 @@ requires-python = "==3.12.*"
# Per ogni roba ho fatto un commento per evitare di dimenticarmi cosa fa chi.
# Inoltre ho messo una emoji per indicare se è raccomandato o meno.
dependencies = [
# ✅ per i test
"pytest",
# ✅ per gestire variabili d'ambiente (generalmente API keys od opzioni)
"dotenv",
# 🟡 per fare scraping di pagine web
#"bs4",
# ✅ per fare una UI web semplice con user_input e output
"gradio",
"pytest", # Test
"dotenv", # Gestire variabili d'ambiente (generalmente API keys od opzioni)
"gradio", # UI web semplice con user_input e output
# ✅ per costruire agenti (ovvero modelli che possono fare più cose tramite tool) https://github.com/agno-agi/agno
# Per costruire agenti (ovvero modelli che possono fare più cose tramite tool) https://github.com/agno-agi/agno
# altamente consigliata dato che ha anche tools integrati per fare scraping, calcoli e molto altro
# oltre a questa è necessario installare anche le librerie specifiche per i modelli che si vogliono usare
"agno",
# Modelli supportati e installati (aggiungere qui sotto quelli che si vogliono usare)
# Modelli supportati e installati (aggiungere qui sotto quelli che si vogliono usare)
"google-genai",
"ollama",
# ✅ per interagire con API di exchange di criptovalute
# API di exchange di criptovalute
"coinbase-advanced-py",
"python-binance",
"yfinance",
# API di notizie
"newsapi-python",
"gnews",
"ddgs",
# API di social media
"praw", # Reddit
]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

View File

View File

@@ -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)

View File

@@ -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}
)

View File

@@ -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."

View File

@@ -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."

View File

@@ -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.
"""

View File

@@ -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

View File

@@ -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]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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
View 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
View 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")

View 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]

View 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]

View 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
View 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

View File

@@ -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(

View File

@@ -1,7 +1,5 @@
from enum import Enum
from pydantic import BaseModel, Field
from app.markets.base import ProductInfo

View 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
View 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
View 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]

View File

@@ -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")
"""

View 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))

View File

@@ -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

View 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)

View File

@@ -1,596 +0,0 @@
"""
Test suite completo per il sistema di mercato.
Questo modulo testa approfonditamente tutte le implementazioni di BaseWrapper
e verifica la conformità all'interfaccia definita in base.py.
"""
import os
from unittest.mock import Mock, patch
import pytest
from app.markets import MarketAPIs
# Import delle classi da testare
from app.markets.base import BaseWrapper, ProductInfo, Price
from app.markets.binance import BinanceWrapper
from app.markets.binance_public import PublicBinanceAgent
from app.markets.coinbase import CoinBaseWrapper
from app.markets.cryptocompare import CryptoCompareWrapper
class TestBaseWrapperInterface:
"""Test per verificare che tutte le implementazioni rispettino l'interfaccia BaseWrapper."""
def test_all_wrappers_extend_basewrapper(self):
"""Verifica che tutte le classi wrapper estendano BaseWrapper."""
wrapper_classes = [
CoinBaseWrapper,
CryptoCompareWrapper,
BinanceWrapper,
PublicBinanceAgent,
MarketAPIs
]
for wrapper_class in wrapper_classes:
assert issubclass(wrapper_class, BaseWrapper), f"{wrapper_class.__name__} deve estendere BaseWrapper"
def test_all_wrappers_implement_required_methods(self):
"""Verifica che tutte le classi implementino i metodi richiesti dall'interfaccia."""
wrapper_classes = [
CoinBaseWrapper,
CryptoCompareWrapper,
BinanceWrapper,
PublicBinanceAgent,
MarketAPIs
]
required_methods = ['get_product', 'get_products', 'get_all_products', 'get_historical_prices']
for wrapper_class in wrapper_classes:
for method in required_methods:
assert hasattr(wrapper_class, method), f"{wrapper_class.__name__} deve implementare {method}"
assert callable(getattr(wrapper_class, method)), f"{method} deve essere callable in {wrapper_class.__name__}"
class TestProductInfoModel:
"""Test per la classe ProductInfo e i suoi metodi di conversione."""
def test_productinfo_initialization(self):
"""Test inizializzazione di ProductInfo."""
product = ProductInfo()
assert product.id == ""
assert product.symbol == ""
assert product.price == 0.0
assert product.volume_24h == 0.0
assert product.status == ""
assert product.quote_currency == ""
def test_productinfo_with_data(self):
"""Test ProductInfo con dati specifici."""
product = ProductInfo(
id="BTC-USD",
symbol="BTC",
price=50000.0,
volume_24h=1000000.0,
status="TRADING",
quote_currency="USD"
)
assert product.id == "BTC-USD"
assert product.symbol == "BTC"
assert product.price == 50000.0
assert product.volume_24h == 1000000.0
assert product.status == "TRADING"
assert product.quote_currency == "USD"
def test_productinfo_from_cryptocompare(self):
"""Test conversione da dati CryptoCompare."""
mock_data = {
'FROMSYMBOL': 'BTC',
'TOSYMBOL': 'USD',
'PRICE': 50000.0,
'VOLUME24HOUR': 1000000.0
}
product = ProductInfo.from_cryptocompare(mock_data)
assert product.id == "BTC-USD"
assert product.symbol == "BTC"
assert product.price == 50000.0
assert product.volume_24h == 1000000.0
assert product.status == ""
def test_productinfo_from_binance(self):
"""Test conversione da dati Binance."""
ticker_data = {'symbol': 'BTCUSDT', 'price': '50000.0'}
ticker_24h_data = {'volume': '1000000.0'}
product = ProductInfo.from_binance(ticker_data, ticker_24h_data)
assert product.id == "BTCUSDT"
assert product.symbol == "BTC"
assert product.price == 50000.0
assert product.volume_24h == 1000000.0
assert product.status == "TRADING"
assert product.quote_currency == "USDT"
class TestPriceModel:
"""Test per la classe Price e i suoi metodi di conversione."""
def test_price_initialization(self):
"""Test inizializzazione di Price."""
price = Price()
assert price.high == 0.0
assert price.low == 0.0
assert price.open == 0.0
assert price.close == 0.0
assert price.volume == 0.0
assert price.time == ""
def test_price_with_data(self):
"""Test Price con dati specifici."""
price = Price(
high=51000.0,
low=49000.0,
open=50000.0,
close=50500.0,
volume=1000.0,
time="2024-01-01T00:00:00Z"
)
assert price.high == 51000.0
assert price.low == 49000.0
assert price.open == 50000.0
assert price.close == 50500.0
assert price.volume == 1000.0
assert price.time == "2024-01-01T00:00:00Z"
def test_price_from_cryptocompare(self):
"""Test conversione da dati CryptoCompare."""
mock_data = {
'high': 51000.0,
'low': 49000.0,
'open': 50000.0,
'close': 50500.0,
'volumeto': 1000.0,
'time': 1704067200
}
price = Price.from_cryptocompare(mock_data)
assert price.high == 51000.0
assert price.low == 49000.0
assert price.open == 50000.0
assert price.close == 50500.0
assert price.volume == 1000.0
assert price.time == "1704067200"
class TestCoinBaseWrapper:
"""Test specifici per CoinBaseWrapper."""
@pytest.mark.skipif(
not (os.getenv('COINBASE_API_KEY') and os.getenv('COINBASE_API_SECRET')),
reason="Credenziali Coinbase non configurate"
)
def test_coinbase_initialization_with_env_vars(self):
"""Test inizializzazione con variabili d'ambiente."""
wrapper = CoinBaseWrapper(currency="USD")
assert wrapper.currency == "USD"
assert wrapper.client is not None
@patch.dict(os.environ, {}, clear=True)
def test_coinbase_initialization_with_params(self):
"""Test inizializzazione con parametri espliciti quando non ci sono variabili d'ambiente."""
with pytest.raises(AssertionError, match="API key is required"):
CoinBaseWrapper(api_key=None, api_private_key=None)
@patch('app.markets.coinbase.RESTClient')
def test_coinbase_asset_formatting_behavior(self, mock_client):
"""Test comportamento di formattazione asset ID attraverso get_product."""
mock_response = Mock()
mock_response.product_id = "BTC-USD"
mock_response.base_currency_id = "BTC"
mock_response.price = "50000.0"
mock_response.volume_24h = "1000000.0"
mock_response.status = "TRADING"
mock_client_instance = Mock()
mock_client_instance.get_product.return_value = mock_response
mock_client.return_value = mock_client_instance
wrapper = CoinBaseWrapper(api_key="test", api_private_key="test")
# Test che entrambi i formati funzionino
wrapper.get_product("BTC")
wrapper.get_product("BTC-USD")
# Verifica che get_product sia stato chiamato con il formato corretto
assert mock_client_instance.get_product.call_count == 2
@patch('app.markets.coinbase.RESTClient')
def test_coinbase_get_product(self, mock_client):
"""Test get_product con mock."""
mock_response = Mock()
mock_response.product_id = "BTC-USD"
mock_response.base_currency_id = "BTC"
mock_response.price = "50000.0"
mock_response.volume_24h = "1000000.0"
mock_response.status = "TRADING"
mock_client_instance = Mock()
mock_client_instance.get_product.return_value = mock_response
mock_client.return_value = mock_client_instance
wrapper = CoinBaseWrapper(api_key="test", api_private_key="test")
product = wrapper.get_product("BTC")
assert isinstance(product, ProductInfo)
assert product.symbol == "BTC"
mock_client_instance.get_product.assert_called_once_with("BTC-USD")
class TestCryptoCompareWrapper:
"""Test specifici per CryptoCompareWrapper."""
@pytest.mark.skipif(
not os.getenv('CRYPTOCOMPARE_API_KEY'),
reason="CRYPTOCOMPARE_API_KEY non configurata"
)
def test_cryptocompare_initialization_with_env_var(self):
"""Test inizializzazione con variabile d'ambiente."""
wrapper = CryptoCompareWrapper(currency="USD")
assert wrapper.currency == "USD"
assert wrapper.api_key is not None
def test_cryptocompare_initialization_with_param(self):
"""Test inizializzazione con parametro esplicito."""
wrapper = CryptoCompareWrapper(api_key="test_key", currency="EUR")
assert wrapper.api_key == "test_key"
assert wrapper.currency == "EUR"
@patch('app.markets.cryptocompare.requests.get')
def test_cryptocompare_get_product(self, mock_get):
"""Test get_product con mock."""
mock_response = Mock()
mock_response.json.return_value = {
'RAW': {
'BTC': {
'USD': {
'FROMSYMBOL': 'BTC',
'TOSYMBOL': 'USD',
'PRICE': 50000.0,
'VOLUME24HOUR': 1000000.0
}
}
}
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
wrapper = CryptoCompareWrapper(api_key="test_key")
product = wrapper.get_product("BTC")
assert isinstance(product, ProductInfo)
assert product.symbol == "BTC"
assert product.price == 50000.0
def test_cryptocompare_get_all_products_workaround(self):
"""Test che get_all_products funzioni con il workaround implementato."""
wrapper = CryptoCompareWrapper(api_key="test_key")
# Il metodo ora dovrebbe restituire una lista di ProductInfo invece di sollevare NotImplementedError
products = wrapper.get_all_products()
assert isinstance(products, list)
# Verifica che la lista non sia vuota (dovrebbe contenere almeno alcuni asset popolari)
assert len(products) > 0
# Verifica che ogni elemento sia un ProductInfo
for product in products:
assert isinstance(product, ProductInfo)
class TestBinanceWrapper:
"""Test specifici per BinanceWrapper."""
def test_binance_initialization_without_credentials(self):
"""Test che l'inizializzazione fallisca senza credenziali."""
# Assicuriamoci che le variabili d'ambiente siano vuote per questo test
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(AssertionError, match="API key is required"):
BinanceWrapper(api_key=None, api_secret="test")
with pytest.raises(AssertionError, match="API secret is required"):
BinanceWrapper(api_key="test", api_secret=None)
@patch('app.markets.binance.Client')
def test_binance_symbol_formatting_behavior(self, mock_client):
"""Test comportamento di formattazione simbolo attraverso get_product."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.return_value = {
'symbol': 'BTCUSDT',
'price': '50000.0'
}
mock_client_instance.get_ticker.return_value = {
'volume': '1000000.0'
}
mock_client.return_value = mock_client_instance
wrapper = BinanceWrapper(api_key="test", api_secret="test")
# Test che entrambi i formati funzionino
wrapper.get_product("BTC")
wrapper.get_product("BTCUSDT")
# Verifica che i metodi siano stati chiamati
assert mock_client_instance.get_symbol_ticker.call_count == 2
@patch('app.markets.binance.Client')
def test_binance_get_product(self, mock_client):
"""Test get_product con mock."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.return_value = {
'symbol': 'BTCUSDT',
'price': '50000.0'
}
mock_client_instance.get_ticker.return_value = {
'volume': '1000000.0'
}
mock_client.return_value = mock_client_instance
wrapper = BinanceWrapper(api_key="test", api_secret="test")
product = wrapper.get_product("BTC")
assert isinstance(product, ProductInfo)
assert product.symbol == "BTC"
assert product.price == 50000.0
class TestPublicBinanceAgent:
"""Test specifici per PublicBinanceAgent."""
@patch('app.markets.binance_public.Client')
def test_public_binance_initialization(self, mock_client):
"""Test inizializzazione senza credenziali."""
agent = PublicBinanceAgent()
assert agent.client is not None
mock_client.assert_called_once_with()
@patch('app.markets.binance_public.Client')
def test_public_binance_symbol_formatting_behavior(self, mock_client):
"""Test comportamento di formattazione simbolo attraverso get_product."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.return_value = {
'symbol': 'BTCUSDT',
'price': '50000.0'
}
mock_client_instance.get_ticker.return_value = {
'volume': '1000000.0'
}
mock_client.return_value = mock_client_instance
agent = PublicBinanceAgent()
# Test che entrambi i formati funzionino
agent.get_product("BTC")
agent.get_product("BTCUSDT")
# Verifica che i metodi siano stati chiamati
assert mock_client_instance.get_symbol_ticker.call_count == 2
@patch('app.markets.binance_public.Client')
def test_public_binance_get_product(self, mock_client):
"""Test get_product con mock."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.return_value = {
'symbol': 'BTCUSDT',
'price': '50000.0'
}
mock_client_instance.get_ticker.return_value = {
'volume': '1000000.0'
}
mock_client.return_value = mock_client_instance
agent = PublicBinanceAgent()
product = agent.get_product("BTC")
assert isinstance(product, ProductInfo)
assert product.symbol == "BTC"
assert product.price == 50000.0
@patch('app.markets.binance_public.Client')
def test_public_binance_get_all_products(self, mock_client):
"""Test get_all_products restituisce asset principali."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.return_value = {
'symbol': 'BTCUSDT',
'price': '50000.0'
}
mock_client_instance.get_ticker.return_value = {
'volume': '1000000.0'
}
mock_client.return_value = mock_client_instance
agent = PublicBinanceAgent()
products = agent.get_all_products()
assert isinstance(products, list)
assert len(products) == 8 # Numero di asset principali definiti
for product in products:
assert isinstance(product, ProductInfo)
@patch('app.markets.binance_public.Client')
def test_public_binance_get_public_prices(self, mock_client):
"""Test metodo specifico get_public_prices."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.return_value = {'price': '50000.0'}
mock_client_instance.get_server_time.return_value = {'serverTime': 1704067200000}
mock_client.return_value = mock_client_instance
agent = PublicBinanceAgent()
prices = agent.get_public_prices(["BTCUSDT"])
assert isinstance(prices, dict)
assert 'BTC_USD' in prices
assert prices['BTC_USD'] == 50000.0
assert 'source' in prices
assert prices['source'] == 'binance_public'
class TestMarketAPIs:
"""Test per la classe MarketAPIs che aggrega i wrapper."""
def test_market_apis_initialization_no_providers(self):
"""Test che l'inizializzazione fallisca senza provider disponibili."""
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(AssertionError, match="No market API keys"):
MarketAPIs("USD")
@patch('app.markets.CoinBaseWrapper')
def test_market_apis_with_coinbase_only(self, mock_coinbase):
"""Test con solo Coinbase disponibile."""
mock_instance = Mock()
mock_coinbase.return_value = mock_instance
with patch('app.markets.CryptoCompareWrapper', side_effect=Exception("No API key")):
apis = MarketAPIs("USD")
assert len(apis.wrappers) == 1
assert apis.wrappers[0] == mock_instance
@patch('app.markets.CoinBaseWrapper')
@patch('app.markets.CryptoCompareWrapper')
def test_market_apis_delegation(self, mock_crypto, mock_coinbase):
"""Test che i metodi vengano delegati al primo wrapper disponibile."""
mock_coinbase_instance = Mock()
mock_crypto_instance = Mock()
mock_coinbase.return_value = mock_coinbase_instance
mock_crypto.return_value = mock_crypto_instance
apis = MarketAPIs("USD")
# Test delegazione get_product
apis.get_product("BTC")
mock_coinbase_instance.get_product.assert_called_once_with("BTC")
# Test delegazione get_products
apis.get_products(["BTC", "ETH"])
mock_coinbase_instance.get_products.assert_called_once_with(["BTC", "ETH"])
# Test delegazione get_all_products
apis.get_all_products()
mock_coinbase_instance.get_all_products.assert_called_once()
# Test delegazione get_historical_prices
apis.get_historical_prices("BTC")
mock_coinbase_instance.get_historical_prices.assert_called_once_with("BTC")
class TestErrorHandling:
"""Test per la gestione degli errori in tutti i wrapper."""
@patch('app.markets.binance_public.Client')
def test_public_binance_error_handling(self, mock_client):
"""Test gestione errori in PublicBinanceAgent."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.side_effect = Exception("API Error")
mock_client.return_value = mock_client_instance
agent = PublicBinanceAgent()
product = agent.get_product("INVALID")
# Dovrebbe restituire un ProductInfo vuoto invece di sollevare eccezione
assert isinstance(product, ProductInfo)
assert product.id == "INVALID"
assert product.symbol == "INVALID"
@patch('app.markets.cryptocompare.requests.get')
def test_cryptocompare_network_error(self, mock_get):
"""Test gestione errori di rete in CryptoCompareWrapper."""
mock_get.side_effect = Exception("Network Error")
wrapper = CryptoCompareWrapper(api_key="test")
with pytest.raises(Exception):
wrapper.get_product("BTC")
@patch('app.markets.binance.Client')
def test_binance_api_error_in_get_products(self, mock_client):
"""Test gestione errori in BinanceWrapper.get_products."""
mock_client_instance = Mock()
mock_client_instance.get_symbol_ticker.side_effect = Exception("API Error")
mock_client.return_value = mock_client_instance
wrapper = BinanceWrapper(api_key="test", api_secret="test")
products = wrapper.get_products(["BTC", "ETH"])
# Dovrebbe restituire lista vuota invece di sollevare eccezione
assert isinstance(products, list)
assert len(products) == 0
class TestIntegrationScenarios:
"""Test di integrazione per scenari reali."""
def test_wrapper_method_signatures(self):
"""Verifica che tutti i wrapper abbiano le stesse signature dei metodi."""
wrapper_classes = [CoinBaseWrapper, CryptoCompareWrapper, BinanceWrapper, PublicBinanceAgent]
for wrapper_class in wrapper_classes:
# Verifica get_product
assert hasattr(wrapper_class, 'get_product')
# Verifica get_products
assert hasattr(wrapper_class, 'get_products')
# Verifica get_all_products
assert hasattr(wrapper_class, 'get_all_products')
# Verifica get_historical_prices
assert hasattr(wrapper_class, 'get_historical_prices')
def test_productinfo_consistency(self):
"""Test che tutti i metodi from_* di ProductInfo restituiscano oggetti consistenti."""
# Test from_cryptocompare
crypto_data = {
'FROMSYMBOL': 'BTC',
'TOSYMBOL': 'USD',
'PRICE': 50000.0,
'VOLUME24HOUR': 1000000.0
}
crypto_product = ProductInfo.from_cryptocompare(crypto_data)
# Test from_binance
binance_ticker = {'symbol': 'BTCUSDT', 'price': '50000.0'}
binance_24h = {'volume': '1000000.0'}
binance_product = ProductInfo.from_binance(binance_ticker, binance_24h)
# Verifica che entrambi abbiano gli stessi campi
assert hasattr(crypto_product, 'id')
assert hasattr(crypto_product, 'symbol')
assert hasattr(crypto_product, 'price')
assert hasattr(crypto_product, 'volume_24h')
assert hasattr(binance_product, 'id')
assert hasattr(binance_product, 'symbol')
assert hasattr(binance_product, 'price')
assert hasattr(binance_product, 'volume_24h')
def test_price_consistency(self):
"""Test che tutti i metodi from_* di Price restituiscano oggetti consistenti."""
# Test from_cryptocompare
crypto_data = {
'high': 51000.0,
'low': 49000.0,
'open': 50000.0,
'close': 50500.0,
'volumeto': 1000.0,
'time': 1704067200
}
crypto_price = Price.from_cryptocompare(crypto_data)
# Verifica che abbia tutti i campi richiesti
assert hasattr(crypto_price, 'high')
assert hasattr(crypto_price, 'low')
assert hasattr(crypto_price, 'open')
assert hasattr(crypto_price, 'close')
assert hasattr(crypto_price, 'volume')
assert hasattr(crypto_price, 'time')
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -16,8 +16,8 @@ def unified_checks(model: AppModels, input):
for item in content.portfolio:
assert item.asset not in (None, "", "null")
assert isinstance(item.asset, str)
assert item.percentage > 0
assert item.percentage <= 100
assert item.percentage >= 0.0
assert item.percentage <= 100.0
assert isinstance(item.percentage, (int, float))
assert item.motivation not in (None, "", "null")
assert isinstance(item.motivation, str)
@@ -41,6 +41,7 @@ class TestPredictor:
def test_gemini_model_output(self, inputs):
unified_checks(AppModels.GEMINI, inputs)
@pytest.mark.slow
def test_ollama_qwen_model_output(self, inputs):
unified_checks(AppModels.OLLAMA_QWEN, inputs)

53
tests/api/test_binance.py Normal file
View File

@@ -0,0 +1,53 @@
import pytest
from app.markets.binance import BinanceWrapper
@pytest.mark.market
@pytest.mark.api
class TestBinance:
def test_binance_init(self):
market = BinanceWrapper()
assert market is not None
assert hasattr(market, 'currency')
assert market.currency == "USDT"
def test_binance_get_product(self):
market = BinanceWrapper()
product = market.get_product("BTC")
assert product is not None
assert hasattr(product, 'symbol')
assert product.symbol == "BTC"
assert hasattr(product, 'price')
assert product.price > 0
def test_binance_get_products(self):
market = BinanceWrapper()
products = market.get_products(["BTC", "ETH"])
assert products is not None
assert isinstance(products, list)
assert len(products) == 2
symbols = [p.symbol for p in products]
assert "BTC" in symbols
assert "ETH" in symbols
for product in products:
assert hasattr(product, 'price')
assert product.price > 0
def test_binance_invalid_product(self):
market = BinanceWrapper()
with pytest.raises(Exception):
_ = market.get_product("INVALID")
def test_binance_history(self):
market = BinanceWrapper()
history = market.get_historical_prices("BTC", limit=5)
assert history is not None
assert isinstance(history, list)
assert len(history) == 5
for entry in history:
assert hasattr(entry, 'timestamp_ms')
assert hasattr(entry, 'close')
assert hasattr(entry, 'high')
assert entry.close > 0
assert entry.high > 0
assert entry.timestamp_ms > 0

View File

@@ -0,0 +1,55 @@
import os
import pytest
from app.markets import CoinBaseWrapper
@pytest.mark.market
@pytest.mark.api
@pytest.mark.skipif(not(os.getenv('COINBASE_API_KEY')) or not(os.getenv('COINBASE_API_SECRET')), reason="COINBASE_API_KEY or COINBASE_API_SECRET not set in environment variables")
class TestCoinBase:
def test_coinbase_init(self):
market = CoinBaseWrapper()
assert market is not None
assert hasattr(market, 'currency')
assert market.currency == "USD"
def test_coinbase_get_product(self):
market = CoinBaseWrapper()
product = market.get_product("BTC")
assert product is not None
assert hasattr(product, 'symbol')
assert product.symbol == "BTC"
assert hasattr(product, 'price')
assert product.price > 0
def test_coinbase_get_products(self):
market = CoinBaseWrapper()
products = market.get_products(["BTC", "ETH"])
assert products is not None
assert isinstance(products, list)
assert len(products) == 2
symbols = [p.symbol for p in products]
assert "BTC" in symbols
assert "ETH" in symbols
for product in products:
assert hasattr(product, 'price')
assert product.price > 0
def test_coinbase_invalid_product(self):
market = CoinBaseWrapper()
with pytest.raises(Exception):
_ = market.get_product("INVALID")
def test_coinbase_history(self):
market = CoinBaseWrapper()
history = market.get_historical_prices("BTC", limit=5)
assert history is not None
assert isinstance(history, list)
assert len(history) == 5
for entry in history:
assert hasattr(entry, 'timestamp_ms')
assert hasattr(entry, 'close')
assert hasattr(entry, 'high')
assert entry.close > 0
assert entry.high > 0
assert entry.timestamp_ms > 0

View File

@@ -0,0 +1,57 @@
import os
import pytest
from app.markets import CryptoCompareWrapper
@pytest.mark.market
@pytest.mark.api
@pytest.mark.skipif(not os.getenv('CRYPTOCOMPARE_API_KEY'), reason="CRYPTOCOMPARE_API_KEY not set in environment variables")
class TestCryptoCompare:
def test_cryptocompare_init(self):
market = CryptoCompareWrapper()
assert market is not None
assert hasattr(market, 'api_key')
assert market.api_key == os.getenv('CRYPTOCOMPARE_API_KEY')
assert hasattr(market, 'currency')
assert market.currency == "USD"
def test_cryptocompare_get_product(self):
market = CryptoCompareWrapper()
product = market.get_product("BTC")
assert product is not None
assert hasattr(product, 'symbol')
assert product.symbol == "BTC"
assert hasattr(product, 'price')
assert product.price > 0
def test_cryptocompare_get_products(self):
market = CryptoCompareWrapper()
products = market.get_products(["BTC", "ETH"])
assert products is not None
assert isinstance(products, list)
assert len(products) == 2
symbols = [p.symbol for p in products]
assert "BTC" in symbols
assert "ETH" in symbols
for product in products:
assert hasattr(product, 'price')
assert product.price > 0
def test_cryptocompare_invalid_product(self):
market = CryptoCompareWrapper()
with pytest.raises(Exception):
_ = market.get_product("INVALID")
def test_cryptocompare_history(self):
market = CryptoCompareWrapper()
history = market.get_historical_prices("BTC", limit=5)
assert history is not None
assert isinstance(history, list)
assert len(history) == 5
for entry in history:
assert hasattr(entry, 'timestamp_ms')
assert hasattr(entry, 'close')
assert hasattr(entry, 'high')
assert entry.close > 0
assert entry.high > 0
assert entry.timestamp_ms > 0

View File

@@ -0,0 +1,38 @@
import os
import pytest
from app.news import CryptoPanicWrapper
@pytest.mark.limited
@pytest.mark.news
@pytest.mark.api
@pytest.mark.skipif(not os.getenv("CRYPTOPANIC_API_KEY"), reason="CRYPTOPANIC_API_KEY not set")
class TestCryptoPanicAPI:
def test_crypto_panic_api_initialization(self):
crypto = CryptoPanicWrapper()
assert crypto is not None
def test_crypto_panic_api_get_latest_news(self):
crypto = CryptoPanicWrapper()
articles = crypto.get_latest_news(query="", limit=2)
assert isinstance(articles, list)
assert len(articles) == 2
for article in articles:
assert article.source is not None or article.source != ""
assert article.time is not None or article.time != ""
assert article.title is not None or article.title != ""
assert article.description is not None or article.description != ""
# Useless since both methods use the same endpoint
# def test_crypto_panic_api_get_top_headlines(self):
# crypto = CryptoPanicWrapper()
# articles = crypto.get_top_headlines(total=2)
# assert isinstance(articles, list)
# assert len(articles) == 2
# for article in articles:
# assert article.source is not None or article.source != ""
# assert article.time is not None or article.time != ""
# assert article.title is not None or article.title != ""
# assert article.description is not None or article.description != ""

View File

@@ -0,0 +1,34 @@
import pytest
from app.news import DuckDuckGoWrapper
@pytest.mark.news
@pytest.mark.api
class TestDuckDuckGoNews:
def test_duckduckgo_initialization(self):
news = DuckDuckGoWrapper()
assert news.tool is not None
def test_duckduckgo_get_latest_news(self):
news = DuckDuckGoWrapper()
articles = news.get_latest_news(query="crypto", limit=2)
assert isinstance(articles, list)
assert len(articles) == 2
for article in articles:
assert article.source is not None or article.source != ""
assert article.time is not None or article.time != ""
assert article.title is not None or article.title != ""
assert article.description is not None or article.description != ""
def test_duckduckgo_get_top_headlines(self):
news = DuckDuckGoWrapper()
articles = news.get_top_headlines(limit=2)
assert isinstance(articles, list)
assert len(articles) == 2
for article in articles:
assert article.source is not None or article.source != ""
assert article.time is not None or article.time != ""
assert article.title is not None or article.title != ""
assert article.description is not None or article.description != ""

View File

@@ -0,0 +1,34 @@
import pytest
from app.news import GoogleNewsWrapper
@pytest.mark.news
@pytest.mark.api
class TestGoogleNews:
def test_gnews_api_initialization(self):
gnews_api = GoogleNewsWrapper()
assert gnews_api is not None
def test_gnews_api_get_latest_news(self):
gnews_api = GoogleNewsWrapper()
articles = gnews_api.get_latest_news(query="crypto", limit=2)
assert isinstance(articles, list)
assert len(articles) == 2
for article in articles:
assert article.source is not None or article.source != ""
assert article.time is not None or article.time != ""
assert article.title is not None or article.title != ""
assert article.description is not None or article.description != ""
def test_gnews_api_get_top_headlines(self):
news_api = GoogleNewsWrapper()
articles = news_api.get_top_headlines(limit=2)
assert isinstance(articles, list)
assert len(articles) == 2
for article in articles:
assert article.source is not None or article.source != ""
assert article.time is not None or article.time != ""
assert article.title is not None or article.title != ""
assert article.description is not None or article.description != ""

View File

@@ -0,0 +1,37 @@
import os
import pytest
from app.news import NewsApiWrapper
@pytest.mark.news
@pytest.mark.api
@pytest.mark.skipif(not os.getenv("NEWS_API_KEY"), reason="NEWS_API_KEY not set in environment variables")
class TestNewsAPI:
def test_news_api_initialization(self):
news_api = NewsApiWrapper()
assert news_api.client is not None
def test_news_api_get_latest_news(self):
news_api = NewsApiWrapper()
articles = news_api.get_latest_news(query="crypto", limit=2)
assert isinstance(articles, list)
assert len(articles) > 0 # Ensure we got some articles (apparently it doesn't always return the requested number)
for article in articles:
assert article.source is not None or article.source != ""
assert article.time is not None or article.time != ""
assert article.title is not None or article.title != ""
assert article.description is not None or article.description != ""
def test_news_api_get_top_headlines(self):
news_api = NewsApiWrapper()
articles = news_api.get_top_headlines(limit=2)
assert isinstance(articles, list)
# assert len(articles) > 0 # apparently it doesn't always return SOME articles
for article in articles:
assert article.source is not None or article.source != ""
assert article.time is not None or article.time != ""
assert article.title is not None or article.title != ""
assert article.description is not None or article.description != ""

25
tests/api/test_reddit.py Normal file
View File

@@ -0,0 +1,25 @@
import os
import pytest
from praw import Reddit
from app.social.reddit import MAX_COMMENTS, RedditWrapper
@pytest.mark.social
@pytest.mark.api
@pytest.mark.skipif(not(os.getenv("REDDIT_API_CLIENT_ID")) or not os.getenv("REDDIT_API_CLIENT_SECRET"), reason="REDDIT_CLIENT_ID and REDDIT_API_CLIENT_SECRET not set in environment variables")
class TestRedditWrapper:
def test_initialization(self):
wrapper = RedditWrapper()
assert wrapper is not None
assert isinstance(wrapper.tool, Reddit)
def test_get_top_crypto_posts(self):
wrapper = RedditWrapper()
posts = wrapper.get_top_crypto_posts(limit=2)
assert isinstance(posts, list)
assert len(posts) == 2
for post in posts:
assert post.title != ""
assert isinstance(post.comments, list)
assert len(post.comments) <= MAX_COMMENTS
for comment in post.comments:
assert comment.description != ""

View File

@@ -0,0 +1,56 @@
import pytest
from app.markets import YFinanceWrapper
@pytest.mark.market
@pytest.mark.api
class TestYFinance:
def test_yfinance_init(self):
market = YFinanceWrapper()
assert market is not None
assert hasattr(market, 'currency')
assert market.currency == "USD"
assert hasattr(market, 'tool')
assert market.tool is not None
def test_yfinance_get_crypto_product(self):
market = YFinanceWrapper()
product = market.get_product("BTC")
assert product is not None
assert hasattr(product, 'symbol')
# BTC verrà convertito in BTC-USD dal formattatore
assert product.symbol in ["BTC", "BTC-USD"]
assert hasattr(product, 'price')
assert product.price > 0
def test_yfinance_get_products(self):
market = YFinanceWrapper()
products = market.get_products(["BTC", "ETH"])
assert products is not None
assert isinstance(products, list)
assert len(products) == 2
symbols = [p.symbol for p in products]
assert "BTC" in symbols
assert "ETH" in symbols
for product in products:
assert hasattr(product, 'price')
assert product.price > 0
def test_yfinance_invalid_product(self):
market = YFinanceWrapper()
with pytest.raises(Exception):
_ = market.get_product("INVALIDSYMBOL123")
def test_yfinance_crypto_history(self):
market = YFinanceWrapper()
history = market.get_historical_prices("BTC", limit=5)
assert history is not None
assert isinstance(history, list)
assert len(history) == 5
for entry in history:
assert hasattr(entry, 'timestamp_ms')
assert hasattr(entry, 'close')
assert hasattr(entry, 'high')
assert entry.close > 0
assert entry.high > 0
assert entry.timestamp_ms > 0

View File

@@ -2,16 +2,10 @@
Configurazione pytest per i test del progetto upo-appAI.
"""
import sys
import pytest
from pathlib import Path
# Aggiungi il path src al PYTHONPATH per tutti i test
src_path = Path(__file__).parent.parent / "src"
sys.path.insert(0, str(src_path))
from dotenv import load_dotenv
# Carica le variabili d'ambiente per tutti i test
from dotenv import load_dotenv
load_dotenv()
@@ -20,9 +14,17 @@ def pytest_configure(config:pytest.Config):
markers = [
("slow", "marks tests as slow (deselect with '-m \"not slow\"')"),
("limited", "marks tests that have limited execution due to API constraints"),
("api", "marks tests that require API access"),
("coinbase", "marks tests that require Coinbase credentials"),
("cryptocompare", "marks tests that require CryptoCompare credentials"),
("market", "marks tests that use market data"),
("news", "marks tests that use news"),
("social", "marks tests that use social media"),
("wrapper", "marks tests for wrapper handler"),
("tools", "marks tests for tools"),
("aggregator", "marks tests for market data aggregator"),
("gemini", "marks tests that use Gemini model"),
("ollama_gpt", "marks tests that use Ollama GPT model"),
("ollama_qwen", "marks tests that use Ollama Qwen model"),
@@ -32,21 +34,13 @@ def pytest_configure(config:pytest.Config):
config.addinivalue_line("markers", line)
def pytest_collection_modifyitems(config, items):
"""Modifica automaticamente gli item di test aggiungendogli marker basati sul nome"""
"""Modifica automaticamente degli item di test rimovendoli"""
# Rimuovo i test "limited" e "slow" se non richiesti esplicitamente
mark_to_remove = ['limited', 'slow']
for mark in mark_to_remove:
markexpr = getattr(config.option, "markexpr", None)
if markexpr and mark in markexpr.lower():
continue
markers_to_add = {
"api": pytest.mark.api,
"coinbase": pytest.mark.api,
"cryptocompare": pytest.mark.api,
"overview": pytest.mark.slow,
"analysis": pytest.mark.slow,
"gemini": pytest.mark.gemini,
"ollama_gpt": pytest.mark.ollama_gpt,
"ollama_qwen": pytest.mark.ollama_qwen,
}
for item in items:
name = item.name.lower()
for key, marker in markers_to_add.items():
if key in name:
item.add_marker(marker)
new_mark = (f"({markexpr}) and " if markexpr else "") + f"not {mark}"
setattr(config.option, "markexpr", new_mark)

View File

@@ -0,0 +1,41 @@
import pytest
from app.markets import MarketAPIsTool
@pytest.mark.tools
@pytest.mark.market
@pytest.mark.api
class TestMarketAPIsTool:
def test_wrapper_initialization(self):
market_wrapper = MarketAPIsTool("USD")
assert market_wrapper is not None
assert hasattr(market_wrapper, 'get_product')
assert hasattr(market_wrapper, 'get_products')
assert hasattr(market_wrapper, 'get_historical_prices')
def test_wrapper_capabilities(self):
market_wrapper = MarketAPIsTool("USD")
capabilities = []
if hasattr(market_wrapper, 'get_product'):
capabilities.append('single_product')
if hasattr(market_wrapper, 'get_products'):
capabilities.append('multiple_products')
if hasattr(market_wrapper, 'get_historical_prices'):
capabilities.append('historical_data')
assert len(capabilities) > 0
def test_market_data_retrieval(self):
market_wrapper = MarketAPIsTool("USD")
btc_product = market_wrapper.get_product("BTC")
assert btc_product is not None
assert hasattr(btc_product, 'symbol')
assert hasattr(btc_product, 'price')
assert btc_product.price > 0
def test_error_handling(self):
try:
market_wrapper = MarketAPIsTool("USD")
fake_product = market_wrapper.get_product("NONEXISTENT_CRYPTO_SYMBOL_12345")
assert fake_product is None or fake_product.price == 0
except Exception as e:
pass

View File

@@ -0,0 +1,49 @@
import pytest
from app.news import NewsAPIsTool
@pytest.mark.tools
@pytest.mark.news
@pytest.mark.api
class TestNewsAPITool:
def test_news_api_tool(self):
tool = NewsAPIsTool()
assert tool is not None
def test_news_api_tool_get_top(self):
tool = NewsAPIsTool()
result = tool.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit=2))
assert isinstance(result, list)
assert len(result) > 0
for article in result:
assert article.title is not None
assert article.source is not None
def test_news_api_tool_get_latest(self):
tool = NewsAPIsTool()
result = tool.wrapper_handler.try_call(lambda w: w.get_latest_news(query="crypto", limit=2))
assert isinstance(result, list)
assert len(result) > 0
for article in result:
assert article.title is not None
assert article.source is not None
def test_news_api_tool_get_top__all_results(self):
tool = NewsAPIsTool()
result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit=2))
assert isinstance(result, dict)
assert len(result.keys()) > 0
for provider, articles in result.items():
for article in articles:
assert article.title is not None
assert article.source is not None
def test_news_api_tool_get_latest__all_results(self):
tool = NewsAPIsTool()
result = tool.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query="crypto", limit=2))
assert isinstance(result, dict)
assert len(result.keys()) > 0
for provider, articles in result.items():
for article in articles:
assert article.title is not None
assert article.source is not None

View File

@@ -0,0 +1,30 @@
import pytest
from app.social import SocialAPIsTool
@pytest.mark.tools
@pytest.mark.social
@pytest.mark.api
class TestSocialAPIsTool:
def test_social_api_tool(self):
tool = SocialAPIsTool()
assert tool is not None
def test_social_api_tool_get_top(self):
tool = SocialAPIsTool()
result = tool.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit=2))
assert isinstance(result, list)
assert len(result) > 0
for post in result:
assert post.title is not None
assert post.time is not None
def test_social_api_tool_get_top__all_results(self):
tool = SocialAPIsTool()
result = tool.wrapper_handler.try_call_all(lambda w: w.get_top_crypto_posts(limit=2))
assert isinstance(result, dict)
assert len(result.keys()) > 0
for provider, posts in result.items():
for post in posts:
assert post.title is not None
assert post.time is not None

View File

@@ -0,0 +1,120 @@
import pytest
from app.markets.base import ProductInfo, Price
from app.utils.market_aggregation import aggregate_history_prices, aggregate_product_info
@pytest.mark.aggregator
@pytest.mark.market
class TestMarketDataAggregator:
def __product(self, symbol: str, price: float, volume: float, currency: str) -> ProductInfo:
prod = ProductInfo()
prod.id=f"{symbol}-{currency}"
prod.symbol=symbol
prod.price=price
prod.volume_24h=volume
prod.quote_currency=currency
return prod
def __price(self, timestamp_ms: int, high: float, low: float, open: float, close: float, volume: float) -> Price:
price = Price()
price.timestamp_ms = timestamp_ms
price.high = high
price.low = low
price.open = open
price.close = close
price.volume = volume
return price
def test_aggregate_product_info(self):
products: dict[str, list[ProductInfo]] = {
"Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")],
"Provider2": [self.__product("BTC", 50100.0, 1100.0, "USD")],
"Provider3": [self.__product("BTC", 49900.0, 900.0, "USD")],
}
aggregated = aggregate_product_info(products)
assert len(aggregated) == 1
info = aggregated[0]
assert info is not None
assert info.symbol == "BTC"
avg_weighted_price = (50000.0 * 1000.0 + 50100.0 * 1100.0 + 49900.0 * 900.0) / (1000.0 + 1100.0 + 900.0)
assert info.price == pytest.approx(avg_weighted_price, rel=1e-3)
assert info.volume_24h == pytest.approx(1000.0, rel=1e-3)
assert info.quote_currency == "USD"
def test_aggregate_product_info_multiple_symbols(self):
products = {
"Provider1": [
self.__product("BTC", 50000.0, 1000.0, "USD"),
self.__product("ETH", 4000.0, 2000.0, "USD"),
],
"Provider2": [
self.__product("BTC", 50100.0, 1100.0, "USD"),
self.__product("ETH", 4050.0, 2100.0, "USD"),
],
}
aggregated = aggregate_product_info(products)
assert len(aggregated) == 2
btc_info = next((p for p in aggregated if p.symbol == "BTC"), None)
eth_info = next((p for p in aggregated if p.symbol == "ETH"), None)
assert btc_info is not None
avg_weighted_price_btc = (50000.0 * 1000.0 + 50100.0 * 1100.0) / (1000.0 + 1100.0)
assert btc_info.price == pytest.approx(avg_weighted_price_btc, rel=1e-3)
assert btc_info.volume_24h == pytest.approx(1050.0, rel=1e-3)
assert btc_info.quote_currency == "USD"
assert eth_info is not None
avg_weighted_price_eth = (4000.0 * 2000.0 + 4050.0 * 2100.0) / (2000.0 + 2100.0)
assert eth_info.price == pytest.approx(avg_weighted_price_eth, rel=1e-3)
assert eth_info.volume_24h == pytest.approx(2050.0, rel=1e-3)
assert eth_info.quote_currency == "USD"
def test_aggregate_product_info_with_no_data(self):
products = {
"Provider1": [],
"Provider2": [],
}
aggregated = aggregate_product_info(products)
assert len(aggregated) == 0
def test_aggregate_product_info_with_partial_data(self):
products = {
"Provider1": [self.__product("BTC", 50000.0, 1000.0, "USD")],
"Provider2": [],
}
aggregated = aggregate_product_info(products)
assert len(aggregated) == 1
info = aggregated[0]
assert info.symbol == "BTC"
assert info.price == pytest.approx(50000.0, rel=1e-3)
assert info.volume_24h == pytest.approx(1000.0, rel=1e-3)
assert info.quote_currency == "USD"
def test_aggregate_history_prices(self):
"""Test aggregazione di prezzi storici usando aggregate_history_prices"""
prices = {
"Provider1": [
self.__price(1685577600000, 50000.0, 49500.0, 49600.0, 49900.0, 150.0),
self.__price(1685581200000, 50200.0, 49800.0, 50000.0, 50100.0, 200.0),
],
"Provider2": [
self.__price(1685577600000, 50100.0, 49600.0, 49700.0, 50000.0, 180.0),
self.__price(1685581200000, 50300.0, 49900.0, 50100.0, 50200.0, 220.0),
],
}
aggregated = aggregate_history_prices(prices)
assert len(aggregated) == 2
assert aggregated[0].timestamp_ms == 1685577600000
assert aggregated[0].high == pytest.approx(50050.0, rel=1e-3)
assert aggregated[0].low == pytest.approx(49550.0, rel=1e-3)
assert aggregated[1].timestamp_ms == 1685581200000
assert aggregated[1].high == pytest.approx(50250.0, rel=1e-3)
assert aggregated[1].low == pytest.approx(49850.0, rel=1e-3)

View File

@@ -0,0 +1,152 @@
import pytest
from app.utils.wrapper_handler import WrapperHandler
class MockWrapper:
def do_something(self) -> str:
return "Success"
class MockWrapper2(MockWrapper):
def do_something(self) -> str:
return "Success 2"
class FailingWrapper(MockWrapper):
def do_something(self):
raise Exception("Intentional Failure")
class MockWrapperWithParameters:
def do_something(self, param1: str, param2: int) -> str:
return f"Success {param1} and {param2}"
class FailingWrapperWithParameters(MockWrapperWithParameters):
def do_something(self, param1: str, param2: int):
raise Exception("Intentional Failure")
@pytest.mark.wrapper
class TestWrapperHandler:
def test_init_failing(self):
with pytest.raises(AssertionError) as exc_info:
WrapperHandler([MockWrapper, MockWrapper2])
assert exc_info.type == AssertionError
def test_init_failing_empty(self):
with pytest.raises(AssertionError) as exc_info:
WrapperHandler.build_wrappers([])
assert exc_info.type == AssertionError
def test_init_failing_with_instances(self):
with pytest.raises(AssertionError) as exc_info:
WrapperHandler.build_wrappers([MockWrapper(), MockWrapper2()])
assert exc_info.type == AssertionError
def test_init_not_failing(self):
handler = WrapperHandler.build_wrappers([MockWrapper, MockWrapper2])
assert handler is not None
assert len(handler.wrappers) == 2
handler = WrapperHandler([MockWrapper(), MockWrapper2()])
assert handler is not None
assert len(handler.wrappers) == 2
def test_all_wrappers_fail(self):
wrappers = [FailingWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0)
with pytest.raises(Exception) as exc_info:
handler.try_call(lambda w: w.do_something())
assert "All wrappers failed" in str(exc_info.value)
def test_success_on_first_try(self):
wrappers = [MockWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0)
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 0 # Should still be on the first wrapper
assert handler.retry_count == 0
def test_eventual_success(self):
wrappers = [FailingWrapper, MockWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0)
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should have switched to the second wrapper
assert handler.retry_count == 0
def test_partial_failures(self):
wrappers = [FailingWrapper, MockWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should have switched to the second wrapper
assert handler.retry_count == 0
# Next call should still succeed on the second wrapper
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should still be on the second wrapper
assert handler.retry_count == 0
handler.index = 2 # Manually switch to the third wrapper
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should return to the second wrapper after failure
assert handler.retry_count == 0
def test_try_call_all_success(self):
wrappers = [MockWrapper, MockWrapper2]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
results = handler.try_call_all(lambda w: w.do_something())
assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"}
def test_try_call_all_partial_failures(self):
# Only the second wrapper should succeed
wrappers = [FailingWrapper, MockWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
results = handler.try_call_all(lambda w: w.do_something())
assert results == {MockWrapper: "Success"}
# Only the second and fourth wrappers should succeed
wrappers = [FailingWrapper, MockWrapper, FailingWrapper, MockWrapper2]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
results = handler.try_call_all(lambda w: w.do_something())
assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"}
def test_try_call_all_all_fail(self):
# Test when all wrappers fail
handler_all_fail: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers([FailingWrapper, FailingWrapper], try_per_wrapper=1, retry_delay=0)
with pytest.raises(Exception) as exc_info:
handler_all_fail.try_call_all(lambda w: w.do_something())
assert "All wrappers failed" in str(exc_info.value)
def test_wrappers_with_parameters(self):
wrappers = [FailingWrapperWithParameters, MockWrapperWithParameters]
handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0)
result = handler.try_call(lambda w: w.do_something("test", 42))
assert result == "Success test and 42"
assert handler.index == 1 # Should have switched to the second wrapper
assert handler.retry_count == 0
def test_wrappers_with_parameters_all_fail(self):
wrappers = [FailingWrapperWithParameters, FailingWrapperWithParameters]
handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
with pytest.raises(Exception) as exc_info:
handler.try_call(lambda w: w.do_something("test", 42))
assert "All wrappers failed" in str(exc_info.value)
def test_try_call_all_with_parameters(self):
wrappers = [FailingWrapperWithParameters, MockWrapperWithParameters]
handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
results = handler.try_call_all(lambda w: w.do_something("param", 99))
assert results == {MockWrapperWithParameters: "Success param and 99"}
def test_try_call_all_with_parameters_all_fail(self):
wrappers = [FailingWrapperWithParameters, FailingWrapperWithParameters]
handler: WrapperHandler[MockWrapperWithParameters] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
with pytest.raises(Exception) as exc_info:
handler.try_call_all(lambda w: w.do_something("param", 99))
assert "All wrappers failed" in str(exc_info.value)

653
uv.lock generated

File diff suppressed because it is too large Load Diff