Merge branch 'main' into demos
This commit is contained in:
36
.env.example
36
.env.example
@@ -1,24 +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=
|
||||
|
||||
###############################################################################
|
||||
# Configurazioni per gli agenti di mercato
|
||||
###############################################################################
|
||||
|
||||
# Coinbase CDP API per Market Agent
|
||||
# Ottenibili da: https://portal.cdp.coinbase.com/access/api
|
||||
CDP_API_KEY_NAME=
|
||||
CDP_API_PRIVATE_KEY=
|
||||
# https://portal.cdp.coinbase.com/access/api
|
||||
COINBASE_API_KEY=
|
||||
COINBASE_API_SECRET=
|
||||
|
||||
# CryptoCompare API per Market Agent (alternativa)
|
||||
# Ottenibile da: https://www.cryptocompare.com/cryptopian/api-keys
|
||||
# https://www.cryptocompare.com/cryptopian/api-keys
|
||||
CRYPTOCOMPARE_API_KEY=
|
||||
|
||||
# Binance API per Market Agent (alternativa)
|
||||
# https://www.binance.com/en/my/settings/api-management
|
||||
# Non necessario per operazioni in sola lettura
|
||||
BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
|
||||
###############################################################################
|
||||
# Configurazioni per gli agenti di notizie
|
||||
###############################################################################
|
||||
|
||||
# https://newsapi.org/docs
|
||||
NEWS_API_KEY=
|
||||
|
||||
# https://cryptopanic.com/developers/api/
|
||||
CRYPTOPANIC_API_KEY=
|
||||
|
||||
###############################################################################
|
||||
# Configurazioni per API di social media
|
||||
###############################################################################
|
||||
|
||||
# https://www.reddit.com/prefs/apps
|
||||
REDDIT_API_CLIENT_ID=
|
||||
REDDIT_API_CLIENT_SECRET=
|
||||
|
||||
122
README.md
122
README.md
@@ -9,53 +9,69 @@ L'obiettivo è quello di creare un sistema di consulenza finanziaria basato su L
|
||||
|
||||
# **Indice**
|
||||
- [Installazione](#installazione)
|
||||
- [Ollama (Modelli Locali)](#ollama-modelli-locali)
|
||||
- [Variabili d'Ambiente](#variabili-dambiente)
|
||||
- [Installazione in locale con UV](#installazione-in-locale-con-uv)
|
||||
- [Installazione con Docker](#installazione-con-docker)
|
||||
- [1. Variabili d'Ambiente](#1-variabili-dambiente)
|
||||
- [2. Ollama](#2-ollama)
|
||||
- [3. Docker](#3-docker)
|
||||
- [4. UV (solo per sviluppo locale)](#4-uv-solo-per-sviluppo-locale)
|
||||
- [Applicazione](#applicazione)
|
||||
- [Ultimo Aggiornamento](#ultimo-aggiornamento)
|
||||
- [Tests](#tests)
|
||||
|
||||
# **Installazione**
|
||||
Per l'installazione di questo progetto si consiglia di utilizzare **Docker**. Con questo approccio si evita di dover installare manualmente tutte le dipendenze e si può eseguire il progetto in un ambiente isolato.
|
||||
|
||||
Per lo sviluppo locale si può utilizzare **uv** che si occupa di creare un ambiente virtuale e installare tutte le dipendenze.
|
||||
L'installazione di questo progetto richiede 3 passaggi totali (+1 se si vuole sviluppare in locale) che devono essere eseguiti in sequenza. Se questi passaggi sono eseguiti correttamente, l'applicazione dovrebbe partire senza problemi. Altrimenti è molto probabile che si verifichino errori di vario tipo (moduli mancanti, chiavi API non trovate, ecc.).
|
||||
|
||||
In ogni caso, ***prima*** di avviare l'applicazione è però necessario configurare correttamente le **API keys** e installare Ollama per l'utilizzo dei modelli locali, altrimenti il progetto, anche se installato correttamente, non riuscirà a partire.
|
||||
1. Configurare le variabili d'ambiente
|
||||
2. Installare Ollama e i modelli locali
|
||||
3. Far partire il progetto con Docker (consigliato)
|
||||
4. (Solo per sviluppo locale) Installare uv e creare l'ambiente virtuale
|
||||
|
||||
### Ollama (Modelli Locali)
|
||||
Per utilizzare modelli AI localmente, è necessario installare Ollama:
|
||||
> [!IMPORTANT]\
|
||||
> Prima di iniziare, assicurarsi di avere clonato il repository e di essere nella cartella principale del progetto.
|
||||
|
||||
**1. Installazione Ollama**:
|
||||
- **Linux**: `curl -fsSL https://ollama.com/install.sh | sh`
|
||||
- **macOS/Windows**: Scarica l'installer da [https://ollama.com/download/windows](https://ollama.com/download/windows)
|
||||
### **1. Variabili d'Ambiente**
|
||||
|
||||
**2. GPU Support (Raccomandato)**:
|
||||
Per utilizzare la GPU con Ollama, assicurati di avere NVIDIA CUDA Toolkit installato:
|
||||
- **Download**: [NVIDIA CUDA Downloads](https://developer.nvidia.com/cuda-downloads?target_os=Windows&target_arch=x86_64&target_version=11&target_type=exe_local)
|
||||
- **Documentazione WSL**: [CUDA WSL User Guide](https://docs.nvidia.com/cuda/wsl-user-guide/index.html)
|
||||
|
||||
**3. Installazione Modelli**:
|
||||
Si possono avere più modelli installati contemporaneamente. Per questo progetto si consiglia di utilizzare il modello open source `gpt-oss` poiché prestante e compatibile con tante funzionalità. Per il download: `ollama pull gpt-oss:latest`
|
||||
|
||||
### Variabili d'Ambiente
|
||||
|
||||
**1. Copia il file di esempio**:
|
||||
Copia il file `.env.example` in `.env` e modificalo con le tue API keys:
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**2. Modifica il file .env** creato con le tue API keys e il path dei modelli Ollama, inserendoli nelle variabili opportune dopo l'uguale e ***senza*** spazi.
|
||||
Le API Keys devono essere inserite nelle variabili opportune dopo l'uguale e ***senza*** spazi. Esse si possono ottenere tramite i loro providers (alcune sono gratuite, altre a pagamento).\
|
||||
Nel file [.env.example](.env.example) sono presenti tutte le variabili da compilare con anche il link per recuperare le chiavi, quindi, dopo aver copiato il file, basta seguire le istruzioni al suo interno.
|
||||
|
||||
Le API Keys puoi ottenerle tramite i seguenti servizi (alcune sono gratuite, altre a pagamento):
|
||||
- **Google AI**: [Google AI Studio](https://makersuite.google.com/app/apikey) (gratuito con limiti)
|
||||
- **Anthropic**: [Anthropic Console](https://console.anthropic.com/)
|
||||
- **DeepSeek**: [DeepSeek Platform](https://platform.deepseek.com/)
|
||||
- **OpenAI**: [OpenAI Platform](https://platform.openai.com/api-keys)
|
||||
Le chiavi non sono necessarie per far partire l'applicazione, ma senza di esse alcune funzionalità non saranno disponibili o saranno limitate. Per esempio senza la chiave di NewsAPI non si potranno recuperare le ultime notizie sul mercato delle criptovalute. Ciononostante, l'applicazione usa anche degli strumenti che non richiedono chiavi API, come Yahoo Finance e GNews, che permettono di avere comunque un'analisi di base del mercato.
|
||||
|
||||
### **2. Ollama**
|
||||
Per utilizzare modelli AI localmente, è necessario installare Ollama, un gestore di modelli LLM che consente di eseguire modelli direttamente sul proprio hardware. Si consiglia di utilizzare Ollama con il supporto GPU per prestazioni ottimali, ma è possibile eseguirlo anche solo con la CPU.
|
||||
|
||||
Per l'installazione scaricare Ollama dal loro [sito ufficiale](https://ollama.com/download/linux).
|
||||
|
||||
Dopo l'installazione, si possono iniziare a scaricare i modelli desiderati tramite il comando `ollama pull <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
|
||||
|
||||
353
demos/market_providers_api_demo.py
Normal file
353
demos/market_providers_api_demo.py
Normal file
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demo Completo per Market Data Providers
|
||||
========================================
|
||||
|
||||
Questo script dimostra l'utilizzo di tutti i wrapper che implementano BaseWrapper:
|
||||
- CoinBaseWrapper (richiede credenziali)
|
||||
- CryptoCompareWrapper (richiede API key)
|
||||
- BinanceWrapper (richiede credenziali)
|
||||
- PublicBinanceAgent (accesso pubblico)
|
||||
- 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
|
||||
delle richieste e formattazione tabellare.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
import traceback
|
||||
|
||||
# Aggiungi il path src al PYTHONPATH
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root / "src"))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from app.markets import (
|
||||
CoinBaseWrapper,
|
||||
CryptoCompareWrapper,
|
||||
BinanceWrapper,
|
||||
YFinanceWrapper,
|
||||
BaseWrapper
|
||||
)
|
||||
|
||||
# Carica variabili d'ambiente
|
||||
load_dotenv()
|
||||
|
||||
class DemoFormatter:
|
||||
"""Classe per formattare l'output del demo in modo strutturato."""
|
||||
|
||||
@staticmethod
|
||||
def print_header(title: str, char: str = "=", width: int = 80):
|
||||
"""Stampa un'intestazione formattata."""
|
||||
print(f"\n{char * width}")
|
||||
print(f"{title:^{width}}")
|
||||
print(f"{char * width}")
|
||||
|
||||
@staticmethod
|
||||
def print_subheader(title: str, char: str = "-", width: int = 60):
|
||||
"""Stampa una sotto-intestazione formattata."""
|
||||
print(f"\n{char * width}")
|
||||
print(f" {title}")
|
||||
print(f"{char * width}")
|
||||
|
||||
@staticmethod
|
||||
def print_request_info(provider_name: str, method: str, timestamp: datetime,
|
||||
status: str, error: Optional[str] = None):
|
||||
"""Stampa informazioni sulla richiesta."""
|
||||
print(f"🕒 Timestamp: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"🏷️ Provider: {provider_name}")
|
||||
print(f"🔧 Method: {method}")
|
||||
print(f"📊 Status: {status}")
|
||||
if error:
|
||||
print(f"❌ Error: {error}")
|
||||
print()
|
||||
|
||||
@staticmethod
|
||||
def print_product_table(products: List[Any], title: str = "Products"):
|
||||
"""Stampa una tabella di prodotti."""
|
||||
if not products:
|
||||
print(f"📋 {title}: Nessun prodotto trovato")
|
||||
return
|
||||
|
||||
print(f"📋 {title} ({len(products)} items):")
|
||||
print(f"{'Symbol':<15} {'ID':<20} {'Price':<12} {'Quote':<10} {'Status':<10}")
|
||||
print("-" * 67)
|
||||
|
||||
for product in products[:10]: # Mostra solo i primi 10
|
||||
symbol = getattr(product, 'symbol', 'N/A')
|
||||
product_id = getattr(product, 'id', 'N/A')
|
||||
price = getattr(product, 'price', 0.0)
|
||||
quote = getattr(product, 'quote_currency', 'N/A')
|
||||
status = getattr(product, 'status', 'N/A')
|
||||
|
||||
# Tronca l'ID se troppo lungo
|
||||
if len(product_id) > 18:
|
||||
product_id = product_id[:15] + "..."
|
||||
|
||||
price_str = f"${price:.2f}" if price > 0 else "N/A"
|
||||
|
||||
print(f"{symbol:<15} {product_id:<20} {price_str:<12} {quote:<10} {status:<10}")
|
||||
|
||||
if len(products) > 10:
|
||||
print(f"... e altri {len(products) - 10} prodotti")
|
||||
print()
|
||||
|
||||
@staticmethod
|
||||
def print_prices_table(prices: List[Any], title: str = "Historical Prices"):
|
||||
"""Stampa una tabella di prezzi storici."""
|
||||
if not prices:
|
||||
print(f"💰 {title}: Nessun prezzo trovato")
|
||||
return
|
||||
|
||||
print(f"💰 {title} ({len(prices)} entries):")
|
||||
print(f"{'Time':<12} {'Open':<12} {'High':<12} {'Low':<12} {'Close':<12} {'Volume':<15}")
|
||||
print("-" * 75)
|
||||
|
||||
for price in prices[:5]: # Mostra solo i primi 5
|
||||
time_str = getattr(price, 'time', 'N/A')
|
||||
# Il time è già una stringa, non serve strftime
|
||||
if len(time_str) > 10:
|
||||
time_str = time_str[:10] # Tronca se troppo lungo
|
||||
|
||||
open_price = f"${getattr(price, 'open', 0):.2f}"
|
||||
high_price = f"${getattr(price, 'high', 0):.2f}"
|
||||
low_price = f"${getattr(price, 'low', 0):.2f}"
|
||||
close_price = f"${getattr(price, 'close', 0):.2f}"
|
||||
volume = f"{getattr(price, 'volume', 0):,.0f}"
|
||||
|
||||
print(f"{time_str:<12} {open_price:<12} {high_price:<12} {low_price:<12} {close_price:<12} {volume:<15}")
|
||||
|
||||
if len(prices) > 5:
|
||||
print(f"... e altri {len(prices) - 5} prezzi")
|
||||
print()
|
||||
|
||||
class ProviderTester:
|
||||
"""Classe per testare i provider di market data."""
|
||||
|
||||
def __init__(self):
|
||||
self.formatter = DemoFormatter()
|
||||
self.test_symbols = ["BTC", "ETH", "ADA"]
|
||||
|
||||
def test_provider(self, wrapper: BaseWrapper, provider_name: str) -> Dict[str, Any]:
|
||||
"""Testa un provider specifico con tutti i metodi disponibili."""
|
||||
results = {
|
||||
"provider_name": provider_name,
|
||||
"tests": {},
|
||||
"overall_status": "SUCCESS"
|
||||
}
|
||||
|
||||
self.formatter.print_subheader(f"🔍 Testing {provider_name}")
|
||||
|
||||
# Test get_product
|
||||
for symbol in self.test_symbols:
|
||||
timestamp = datetime.now()
|
||||
try:
|
||||
product = wrapper.get_product(symbol)
|
||||
self.formatter.print_request_info(
|
||||
provider_name, f"get_product({symbol})", timestamp, "✅ SUCCESS"
|
||||
)
|
||||
if product:
|
||||
print(f"📦 Product: {product.symbol} (ID: {product.id})")
|
||||
print(f" Price: ${product.price:.2f}, Quote: {product.quote_currency}")
|
||||
print(f" Volume 24h: {product.volume_24h:,.2f}")
|
||||
else:
|
||||
print(f"📦 Product: Nessun prodotto trovato per {symbol}")
|
||||
|
||||
results["tests"][f"get_product_{symbol}"] = "SUCCESS"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
self.formatter.print_request_info(
|
||||
provider_name, f"get_product({symbol})", timestamp, "❌ ERROR", error_msg
|
||||
)
|
||||
results["tests"][f"get_product_{symbol}"] = f"ERROR: {error_msg}"
|
||||
results["overall_status"] = "PARTIAL"
|
||||
|
||||
# Test get_products
|
||||
timestamp = datetime.now()
|
||||
try:
|
||||
products = wrapper.get_products(self.test_symbols)
|
||||
self.formatter.print_request_info(
|
||||
provider_name, f"get_products({self.test_symbols})", timestamp, "✅ SUCCESS"
|
||||
)
|
||||
self.formatter.print_product_table(products, f"{provider_name} Products")
|
||||
results["tests"]["get_products"] = "SUCCESS"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
self.formatter.print_request_info(
|
||||
provider_name, f"get_products({self.test_symbols})", timestamp, "❌ ERROR", error_msg
|
||||
)
|
||||
results["tests"]["get_products"] = f"ERROR: {error_msg}"
|
||||
results["overall_status"] = "PARTIAL"
|
||||
|
||||
# Test get_historical_prices
|
||||
timestamp = datetime.now()
|
||||
try:
|
||||
prices = wrapper.get_historical_prices("BTC")
|
||||
self.formatter.print_request_info(
|
||||
provider_name, "get_historical_prices(BTC)", timestamp, "✅ SUCCESS"
|
||||
)
|
||||
self.formatter.print_prices_table(prices, f"{provider_name} BTC Historical Prices")
|
||||
results["tests"]["get_historical_prices"] = "SUCCESS"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
self.formatter.print_request_info(
|
||||
provider_name, "get_historical_prices(BTC)", timestamp, "❌ ERROR", error_msg
|
||||
)
|
||||
results["tests"]["get_historical_prices"] = f"ERROR: {error_msg}"
|
||||
results["overall_status"] = "PARTIAL"
|
||||
|
||||
return results
|
||||
|
||||
def check_environment_variables() -> Dict[str, bool]:
|
||||
"""Verifica la presenza delle variabili d'ambiente necessarie."""
|
||||
env_vars = {
|
||||
"COINBASE_API_KEY": bool(os.getenv("COINBASE_API_KEY")),
|
||||
"COINBASE_API_SECRET": bool(os.getenv("COINBASE_API_SECRET")),
|
||||
"CRYPTOCOMPARE_API_KEY": bool(os.getenv("CRYPTOCOMPARE_API_KEY")),
|
||||
"BINANCE_API_KEY": bool(os.getenv("BINANCE_API_KEY")),
|
||||
"BINANCE_API_SECRET": bool(os.getenv("BINANCE_API_SECRET")),
|
||||
}
|
||||
return env_vars
|
||||
|
||||
def initialize_providers() -> Dict[str, BaseWrapper]:
|
||||
"""Inizializza tutti i provider disponibili."""
|
||||
providers = {}
|
||||
env_vars = check_environment_variables()
|
||||
|
||||
# CryptoCompareWrapper
|
||||
if env_vars["CRYPTOCOMPARE_API_KEY"]:
|
||||
try:
|
||||
providers["CryptoCompare"] = CryptoCompareWrapper()
|
||||
print("✅ CryptoCompareWrapper inizializzato con successo")
|
||||
except Exception as e:
|
||||
print(f"❌ Errore nell'inizializzazione di CryptoCompareWrapper: {e}")
|
||||
else:
|
||||
print("⚠️ CryptoCompareWrapper saltato: CRYPTOCOMPARE_API_KEY non trovata")
|
||||
|
||||
# CoinBaseWrapper
|
||||
if env_vars["COINBASE_API_KEY"] and env_vars["COINBASE_API_SECRET"]:
|
||||
try:
|
||||
providers["CoinBase"] = CoinBaseWrapper()
|
||||
print("✅ CoinBaseWrapper inizializzato con successo")
|
||||
except Exception as e:
|
||||
print(f"❌ Errore nell'inizializzazione di CoinBaseWrapper: {e}")
|
||||
else:
|
||||
print("⚠️ CoinBaseWrapper saltato: credenziali Coinbase non complete")
|
||||
|
||||
# BinanceWrapper
|
||||
try:
|
||||
providers["Binance"] = BinanceWrapper()
|
||||
print("✅ BinanceWrapper inizializzato con successo")
|
||||
except Exception as e:
|
||||
print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}")
|
||||
|
||||
# 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]]):
|
||||
"""Stampa un riassunto finale dei risultati."""
|
||||
formatter = DemoFormatter()
|
||||
formatter.print_header("📊 RIASSUNTO FINALE", "=", 80)
|
||||
|
||||
total_providers = len(results)
|
||||
successful_providers = sum(1 for r in results if r["overall_status"] == "SUCCESS")
|
||||
partial_providers = sum(1 for r in results if r["overall_status"] == "PARTIAL")
|
||||
|
||||
print(f"🔢 Provider testati: {total_providers}")
|
||||
print(f"✅ Provider completamente funzionanti: {successful_providers}")
|
||||
print(f"⚠️ Provider parzialmente funzionanti: {partial_providers}")
|
||||
print(f"❌ Provider non funzionanti: {total_providers - successful_providers - partial_providers}")
|
||||
|
||||
print("\n📋 Dettaglio per provider:")
|
||||
for result in results:
|
||||
provider_name = result["provider_name"]
|
||||
status = result["overall_status"]
|
||||
status_icon = "✅" if status == "SUCCESS" else "⚠️" if status == "PARTIAL" else "❌"
|
||||
|
||||
print(f"\n{status_icon} {provider_name}:")
|
||||
for test_name, test_result in result["tests"].items():
|
||||
test_icon = "✅" if test_result == "SUCCESS" else "❌"
|
||||
print(f" {test_icon} {test_name}: {test_result}")
|
||||
|
||||
def main():
|
||||
"""Funzione principale del demo."""
|
||||
formatter = DemoFormatter()
|
||||
|
||||
# Intestazione principale
|
||||
formatter.print_header("🚀 DEMO COMPLETO MARKET DATA PROVIDERS", "=", 80)
|
||||
|
||||
print(f"🕒 Avvio demo: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print("📝 Questo demo testa tutti i wrapper BaseWrapper disponibili")
|
||||
print("🔍 Ogni test include timestamp, stato della richiesta e dati formattati")
|
||||
|
||||
# Verifica variabili d'ambiente
|
||||
formatter.print_subheader("🔐 Verifica Configurazione")
|
||||
env_vars = check_environment_variables()
|
||||
|
||||
print("Variabili d'ambiente:")
|
||||
for var_name, is_present in env_vars.items():
|
||||
status = "✅ Presente" if is_present else "❌ Mancante"
|
||||
print(f" {var_name}: {status}")
|
||||
|
||||
# Inizializza provider
|
||||
formatter.print_subheader("🏗️ Inizializzazione Provider")
|
||||
providers = initialize_providers()
|
||||
|
||||
if not providers:
|
||||
print("❌ Nessun provider disponibile. Verifica la configurazione.")
|
||||
return
|
||||
|
||||
print(f"\n🎯 Provider disponibili per il test: {list(providers.keys())}")
|
||||
|
||||
# Testa ogni provider
|
||||
formatter.print_header("🧪 ESECUZIONE TEST PROVIDER", "=", 80)
|
||||
|
||||
tester = ProviderTester()
|
||||
all_results = []
|
||||
|
||||
for provider_name, wrapper in providers.items():
|
||||
try:
|
||||
result = tester.test_provider(wrapper, provider_name)
|
||||
all_results.append(result)
|
||||
except Exception as e:
|
||||
print(f"❌ Errore critico nel test di {provider_name}: {e}")
|
||||
traceback.print_exc()
|
||||
all_results.append({
|
||||
"provider_name": provider_name,
|
||||
"tests": {},
|
||||
"overall_status": "CRITICAL_ERROR",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
# Stampa riassunto finale
|
||||
print_summary(all_results)
|
||||
|
||||
# Informazioni aggiuntive
|
||||
formatter.print_header("ℹ️ INFORMAZIONI AGGIUNTIVE", "=", 80)
|
||||
print("📚 Documentazione:")
|
||||
print(" - BaseWrapper: src/app/markets/base.py")
|
||||
print(" - Test completi: tests/agents/test_market.py")
|
||||
print(" - Configurazione: .env")
|
||||
|
||||
print("\n🔧 Per abilitare tutti i provider:")
|
||||
print(" 1. Configura le credenziali nel file .env")
|
||||
print(" 2. Segui la documentazione di ogni provider")
|
||||
print(" 3. Riavvia il demo")
|
||||
|
||||
print(f"\n🏁 Demo completato: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
demos/news_api.py
Normal file
16
demos/news_api.py
Normal 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()
|
||||
@@ -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}
|
||||
|
||||
@@ -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 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"]
|
||||
|
||||
78
src/app.py
78
src/app.py
@@ -1,47 +1,83 @@
|
||||
import gradio as gr
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from app.tool import ToolAgent
|
||||
from agno.utils.log import log_info
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.chat_manager import ChatManager
|
||||
|
||||
########################################
|
||||
# MAIN APP & GRADIO INTERFACE
|
||||
# MAIN APP & GRADIO CHAT INTERFACE
|
||||
########################################
|
||||
if __name__ == "__main__":
|
||||
######################################
|
||||
# DA FARE PRIMA DI ESEGUIRE L'APP
|
||||
# qui carichiamo le variabili d'ambiente dal file .env
|
||||
# una volta fatto, possiamo usare le API keys senza problemi
|
||||
# quindi non è necessario richiamare load_dotenv() altrove
|
||||
# Carica variabili d’ambiente (.env)
|
||||
load_dotenv()
|
||||
######################################
|
||||
|
||||
tool_agent = ToolAgent()
|
||||
# Inizializza ChatManager
|
||||
chat = ChatManager()
|
||||
|
||||
########################################
|
||||
# Funzioni Gradio
|
||||
########################################
|
||||
def respond(message, history):
|
||||
response = chat.send_message(message)
|
||||
history.append({"role": "user", "content": message})
|
||||
history.append({"role": "assistant", "content": response})
|
||||
return history, history, ""
|
||||
|
||||
def save_current_chat():
|
||||
chat.save_chat("chat.json")
|
||||
return "💾 Chat salvata in chat.json"
|
||||
|
||||
def load_previous_chat():
|
||||
chat.load_chat("chat.json")
|
||||
history = []
|
||||
for m in chat.get_history():
|
||||
history.append({"role": m["role"], "content": m["content"]})
|
||||
return history, history
|
||||
|
||||
def reset_chat():
|
||||
chat.reset_chat()
|
||||
return [], []
|
||||
|
||||
########################################
|
||||
# Interfaccia Gradio
|
||||
########################################
|
||||
with gr.Blocks() as demo:
|
||||
gr.Markdown("# 🤖 Agente di Analisi e Consulenza Crypto")
|
||||
gr.Markdown("# 🤖 Agente di Analisi e Consulenza Crypto (Chat)")
|
||||
|
||||
# Dropdown provider e stile
|
||||
with gr.Row():
|
||||
provider = gr.Dropdown(
|
||||
choices=tool_agent.list_providers(),
|
||||
choices=chat.list_providers(),
|
||||
type="index",
|
||||
label="Modello da usare"
|
||||
)
|
||||
provider.change(fn=tool_agent.choose_provider, inputs=provider, outputs=None)
|
||||
provider.change(fn=chat.choose_provider, inputs=provider, outputs=None)
|
||||
|
||||
style = gr.Dropdown(
|
||||
choices=tool_agent.list_styles(),
|
||||
choices=chat.list_styles(),
|
||||
type="index",
|
||||
label="Stile di investimento"
|
||||
)
|
||||
style.change(fn=tool_agent.choose_style, inputs=style, outputs=None)
|
||||
style.change(fn=chat.choose_style, inputs=style, outputs=None)
|
||||
|
||||
user_input = gr.Textbox(label="Richiesta utente")
|
||||
output = gr.Textbox(label="Risultato analisi", lines=12)
|
||||
chatbot = gr.Chatbot(label="Conversazione", height=500, type="messages")
|
||||
msg = gr.Textbox(label="Scrivi la tua richiesta", placeholder="Es: Quali sono le crypto interessanti oggi?")
|
||||
|
||||
analyze_btn = gr.Button("🔎 Analizza")
|
||||
analyze_btn.click(fn=tool_agent.interact, inputs=[user_input], outputs=output)
|
||||
with gr.Row():
|
||||
clear_btn = gr.Button("🗑️ Reset Chat")
|
||||
save_btn = gr.Button("💾 Salva Chat")
|
||||
load_btn = gr.Button("📂 Carica Chat")
|
||||
|
||||
# Invio messaggio
|
||||
msg.submit(respond, inputs=[msg, chatbot], outputs=[chatbot, chatbot, msg])
|
||||
# Reset
|
||||
clear_btn.click(reset_chat, inputs=None, outputs=[chatbot, chatbot])
|
||||
# Salvataggio
|
||||
save_btn.click(save_current_chat, inputs=None, outputs=None)
|
||||
# Caricamento
|
||||
load_btn.click(load_previous_chat, inputs=None, outputs=[chatbot, chatbot])
|
||||
|
||||
server, port = ("0.0.0.0", 8000)
|
||||
log_info(f"Starting UPO AppAI on http://{server}:{port}")
|
||||
server_log = "localhost" if server == "0.0.0.0" else server
|
||||
log_info(f"Starting UPO AppAI Chat on http://{server_log}:{port}") # noqa
|
||||
demo.launch(server_name=server, server_port=port, quiet=True)
|
||||
|
||||
@@ -1,39 +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_price,
|
||||
],
|
||||
)
|
||||
|
||||
def get_historical_data(self, symbol: str):
|
||||
return self.market_api.get_historical_prices(symbol)
|
||||
|
||||
def get_current_price(self, symbol: str):
|
||||
return self.market_api.get_products(symbol)
|
||||
|
||||
def prepare_inputs():
|
||||
pass
|
||||
|
||||
def instructions():
|
||||
return """
|
||||
Utilizza questo strumento per ottenere dati di mercato storici e attuali per criptovalute specifiche.
|
||||
Puoi richiedere i prezzi storici o il prezzo attuale di una criptovaluta specifica.
|
||||
Esempio di utilizzo:
|
||||
- get_historical_data("BTC")
|
||||
- get_current_price("ETH")
|
||||
|
||||
"""
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class NewsAgent:
|
||||
@staticmethod
|
||||
def analyze(query: str) -> str:
|
||||
# Mock analisi news
|
||||
return "📰 Sentiment news: ottimismo sul mercato crypto grazie all'adozione istituzionale."
|
||||
@@ -1,5 +0,0 @@
|
||||
class SocialAgent:
|
||||
@staticmethod
|
||||
def analyze(query: str) -> str:
|
||||
# Mock analisi social
|
||||
return "💬 Sentiment social: forte interesse retail su nuove altcoin emergenti."
|
||||
78
src/app/chat_manager.py
Normal file
78
src/app/chat_manager.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
import json
|
||||
from typing import List, Dict
|
||||
from app.pipeline import Pipeline
|
||||
|
||||
SAVE_DIR = os.path.join(os.path.dirname(__file__), "..", "saves")
|
||||
os.makedirs(SAVE_DIR, exist_ok=True)
|
||||
|
||||
class ChatManager:
|
||||
"""
|
||||
Gestisce la conversazione con la Pipeline:
|
||||
- mantiene lo storico dei messaggi
|
||||
- invoca la Pipeline per generare risposte
|
||||
- salva e ricarica le chat
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.pipeline = Pipeline()
|
||||
self.history: List[Dict[str, str]] = [] # [{"role": "user"/"assistant", "content": "..."}]
|
||||
|
||||
def send_message(self, message: str) -> str:
|
||||
"""
|
||||
Aggiunge un messaggio utente, chiama la Pipeline e salva la risposta nello storico.
|
||||
"""
|
||||
# Aggiungi messaggio utente allo storico
|
||||
self.history.append({"role": "user", "content": message})
|
||||
|
||||
# Pipeline elabora la query
|
||||
response = self.pipeline.interact(message)
|
||||
|
||||
# Aggiungi risposta assistente allo storico
|
||||
self.history.append({"role": "assistant", "content": response})
|
||||
|
||||
return response
|
||||
|
||||
def save_chat(self, filename: str = "chat.json") -> None:
|
||||
"""
|
||||
Salva la chat corrente in src/saves/<filename>.
|
||||
"""
|
||||
path = os.path.join(SAVE_DIR, filename)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.history, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def load_chat(self, filename: str = "chat.json") -> None:
|
||||
"""
|
||||
Carica una chat salvata da src/saves/<filename>.
|
||||
"""
|
||||
path = os.path.join(SAVE_DIR, filename)
|
||||
if not os.path.exists(path):
|
||||
self.history = []
|
||||
return
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
self.history = json.load(f)
|
||||
|
||||
def reset_chat(self) -> None:
|
||||
"""
|
||||
Resetta lo storico della chat.
|
||||
"""
|
||||
self.history = []
|
||||
|
||||
def get_history(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Restituisce lo storico completo della chat.
|
||||
"""
|
||||
return self.history
|
||||
|
||||
# Facciamo pass-through di provider e style, così Gradio può usarli
|
||||
def choose_provider(self, index: int):
|
||||
self.pipeline.choose_provider(index)
|
||||
|
||||
def choose_style(self, index: int):
|
||||
self.pipeline.choose_style(index)
|
||||
|
||||
def list_providers(self) -> List[str]:
|
||||
return self.pipeline.list_providers()
|
||||
|
||||
def list_styles(self) -> List[str]:
|
||||
return self.pipeline.list_styles()
|
||||
@@ -1,57 +1,106 @@
|
||||
from app.markets.base import BaseWrapper
|
||||
from app.markets.coinbase import CoinBaseWrapper
|
||||
from app.markets.cryptocompare import CryptoCompareWrapper
|
||||
from agno.tools import Toolkit
|
||||
from app.utils.wrapper_handler import WrapperHandler
|
||||
from app.utils.market_aggregation import aggregate_product_info, aggregate_history_prices
|
||||
from .base import BaseWrapper, ProductInfo, Price
|
||||
from .coinbase import CoinBaseWrapper
|
||||
from .binance import BinanceWrapper
|
||||
from .cryptocompare import CryptoCompareWrapper
|
||||
from .yfinance import YFinanceWrapper
|
||||
|
||||
from agno.utils.log import log_warning
|
||||
__all__ = [ "MarketAPIsTool", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "MARKET_INSTRUCTIONS" ]
|
||||
|
||||
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 = [
|
||||
CoinBaseWrapper,
|
||||
CryptoCompareWrapper,
|
||||
]
|
||||
|
||||
result = []
|
||||
for wrapper in wrapper_builders:
|
||||
try:
|
||||
result.append(wrapper(currency=currency))
|
||||
except Exception as _:
|
||||
log_warning(f"{wrapper} cannot be initialized, maybe missing API key?")
|
||||
|
||||
assert result, "No market API keys set in environment variables."
|
||||
return result
|
||||
|
||||
def __init__(self, currency: str = "USD"):
|
||||
"""
|
||||
Inizializza la classe con la valuta di riferimento e la priorità dei provider.
|
||||
:param currency: Valuta di riferimento (default "USD")
|
||||
Initialize the MarketAPIsTool with multiple market API wrappers.
|
||||
The following wrappers are included in this order:
|
||||
- BinanceWrapper
|
||||
- YFinanceWrapper
|
||||
- CoinBaseWrapper
|
||||
- CryptoCompareWrapper
|
||||
Args:
|
||||
currency (str): Valuta in cui restituire i prezzi. Default è "USD".
|
||||
"""
|
||||
self.currency = currency
|
||||
self.wrappers = MarketAPIs.get_list_available_market_apis(currency=currency)
|
||||
kwargs = {"currency": currency or "USD"}
|
||||
wrappers = [ BinanceWrapper, YFinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ]
|
||||
self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers, kwargs=kwargs)
|
||||
|
||||
# Metodi che semplicemente chiamano il metodo corrispondente del primo wrapper disponibile
|
||||
# TODO magari fare in modo che se il primo fallisce, prova con il secondo, ecc.
|
||||
# oppure fare un round-robin tra i vari wrapper oppure usarli tutti e fare una media dei risultati
|
||||
def get_product(self, asset_id):
|
||||
return self.wrappers[0].get_product(asset_id)
|
||||
def get_products(self, asset_ids: list):
|
||||
return self.wrappers[0].get_products(asset_ids)
|
||||
def get_all_products(self):
|
||||
return self.wrappers[0].get_all_products()
|
||||
def get_historical_prices(self, asset_id = "BTC"):
|
||||
return self.wrappers[0].get_historical_prices(asset_id)
|
||||
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_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_products_aggregated(self, asset_ids: list[str]) -> list[ProductInfo]:
|
||||
"""
|
||||
Restituisce i dati aggregati per una lista di asset_id.\n
|
||||
Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento).
|
||||
Args:
|
||||
asset_ids (list[str]): Lista di asset_id da cercare.
|
||||
Returns:
|
||||
list[ProductInfo]: Lista di ProductInfo aggregati.
|
||||
"""
|
||||
all_products = self.wrappers.try_call_all(lambda w: w.get_products(asset_ids))
|
||||
return aggregate_product_info(all_products)
|
||||
|
||||
def get_historical_prices_aggregated(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
|
||||
"""
|
||||
Restituisce i dati storici aggregati per un asset_id. Usa i dati di tutte le fonti disponibili e li aggrega.\n
|
||||
Attenzione che si usano tutte le fonti, quindi potrebbe usare molte chiamate API (che potrebbero essere a pagamento).
|
||||
Args:
|
||||
asset_id (str): Asset ID da cercare.
|
||||
limit (int): Numero massimo di dati storici da restituire.
|
||||
Returns:
|
||||
list[Price]: Lista di Price aggregati.
|
||||
"""
|
||||
all_prices = self.wrappers.try_call_all(lambda w: w.get_historical_prices(asset_id, limit))
|
||||
return aggregate_history_prices(all_prices)
|
||||
|
||||
MARKET_INSTRUCTIONS = """
|
||||
**TASK:** You are a specialized **Crypto Price Data Retrieval Agent**. Your primary goal is to fetch the most recent and/or historical price data for requested cryptocurrency assets (e.g., 'BTC', 'ETH', 'SOL'). You must provide the data in a clear and structured format.
|
||||
|
||||
**AVAILABLE TOOLS:**
|
||||
1. `get_products(asset_ids: list[str])`: Get **current** product/price info for a list of assets. **(PREFERITA: usa questa per i prezzi live)**
|
||||
2. `get_historical_prices(asset_id: str, limit: int)`: Get historical price data for one asset. Default limit is 100. **(PREFERITA: usa questa per i dati storici)**
|
||||
3. `get_products_aggregated(asset_ids: list[str])`: Get **aggregated current** product/price info for a list of assets. **(USA SOLO SE richiesto 'aggregato' o se `get_products` fallisce)**
|
||||
4. `get_historical_prices_aggregated(asset_id: str, limit: int)`: Get **aggregated historical** price data for one asset. **(USA SOLO SE richiesto 'aggregato' o se `get_historical_prices` fallisce)**
|
||||
|
||||
**USAGE GUIDELINE:**
|
||||
* **Asset ID:** Always convert common names (e.g., 'Bitcoin', 'Ethereum') into their official ticker/ID (e.g., 'BTC', 'ETH').
|
||||
* **Cost Management (Cruciale per LLM locale):**
|
||||
* **Priorità Bassa per Aggregazione:** **Non** usare i metodi `*aggregated` a meno che l'utente non lo richieda esplicitamente o se i metodi non-aggregati falliscono.
|
||||
* **Limitazione Storica:** Il limite predefinito per i dati storici deve essere **20** punti dati, a meno che l'utente non specifichi un limite diverso.
|
||||
* **Fallimento Tool:** Se lo strumento non restituisce dati per un asset specifico, rispondi per quell'asset con: "Dati di prezzo non trovati per [Asset ID]."
|
||||
|
||||
**REPORTING REQUIREMENT:**
|
||||
1. **Format:** Output the results in a clear, easy-to-read list or table.
|
||||
2. **Live Price Request:** If an asset's *current price* is requested, report the **Asset ID**, **Latest Price**, and **Time/Date of the price**.
|
||||
3. **Historical Price Request:** If *historical data* is requested, report the **Asset ID**, the **Limit** of points returned, and the **First** and **Last** entries from the list of historical prices (Date, Price). Non stampare l'intera lista di dati storici.
|
||||
4. **Output:** For all requests, fornire un **unico e conciso riepilogo** dei dati reperiti.
|
||||
"""
|
||||
@@ -1,19 +1,41 @@
|
||||
from coinbase.rest.types.product_types import Candle, GetProductResponse
|
||||
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,28 +46,8 @@ class ProductInfo(BaseModel):
|
||||
symbol: str = ""
|
||||
price: float = 0.0
|
||||
volume_24h: float = 0.0
|
||||
status: str = ""
|
||||
quote_currency: str = ""
|
||||
|
||||
def from_coinbase(product_data: GetProductResponse) -> 'ProductInfo':
|
||||
product = ProductInfo()
|
||||
product.id = product_data.product_id
|
||||
product.symbol = product_data.base_currency_id
|
||||
product.price = float(product_data.price)
|
||||
product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0
|
||||
# TODO Check what status means in Coinbase
|
||||
product.status = product_data.status
|
||||
return product
|
||||
|
||||
def from_cryptocompare(asset_data: dict) -> 'ProductInfo':
|
||||
product = ProductInfo()
|
||||
product.id = asset_data['FROMSYMBOL'] + '-' + asset_data['TOSYMBOL']
|
||||
product.symbol = asset_data['FROMSYMBOL']
|
||||
product.price = float(asset_data['PRICE'])
|
||||
product.volume_24h = float(asset_data['VOLUME24HOUR'])
|
||||
product.status = "" # Cryptocompare does not provide status
|
||||
return product
|
||||
|
||||
class Price(BaseModel):
|
||||
"""
|
||||
Rappresenta i dati di prezzo per un asset, come ottenuti dalle API di mercato.
|
||||
@@ -56,24 +58,4 @@ class Price(BaseModel):
|
||||
open: float = 0.0
|
||||
close: float = 0.0
|
||||
volume: float = 0.0
|
||||
time: str = ""
|
||||
|
||||
def from_coinbase(candle_data: Candle) -> 'Price':
|
||||
price = Price()
|
||||
price.high = float(candle_data.high)
|
||||
price.low = float(candle_data.low)
|
||||
price.open = float(candle_data.open)
|
||||
price.close = float(candle_data.close)
|
||||
price.volume = float(candle_data.volume)
|
||||
price.time = str(candle_data.start)
|
||||
return price
|
||||
|
||||
def from_cryptocompare(price_data: dict) -> 'Price':
|
||||
price = Price()
|
||||
price.high = float(price_data['high'])
|
||||
price.low = float(price_data['low'])
|
||||
price.open = float(price_data['open'])
|
||||
price.close = float(price_data['close'])
|
||||
price.volume = float(price_data['volumeto'])
|
||||
price.time = str(price_data['time'])
|
||||
return price
|
||||
timestamp_ms: int = 0 # Timestamp in milliseconds
|
||||
|
||||
@@ -1,30 +1,76 @@
|
||||
# Versione pubblica senza autenticazione
|
||||
import os
|
||||
from datetime import datetime
|
||||
from binance.client import Client
|
||||
from .base import ProductInfo, BaseWrapper, Price
|
||||
|
||||
# TODO fare l'aggancio con API in modo da poterlo usare come wrapper di mercato
|
||||
# TODO implementare i metodi di BaseWrapper
|
||||
def get_product(currency: str, ticker_data: dict[str, str]) -> ProductInfo:
|
||||
product = ProductInfo()
|
||||
product.id = ticker_data.get('symbol')
|
||||
product.symbol = ticker_data.get('symbol', '').replace(currency, '')
|
||||
product.price = float(ticker_data.get('price', 0))
|
||||
product.volume_24h = float(ticker_data.get('volume', 0))
|
||||
product.quote_currency = currency
|
||||
return product
|
||||
|
||||
class PublicBinanceAgent:
|
||||
def __init__(self):
|
||||
# Client pubblico (senza credenziali)
|
||||
self.client = Client()
|
||||
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
|
||||
|
||||
def get_public_prices(self):
|
||||
"""Ottiene prezzi pubblici"""
|
||||
try:
|
||||
btc_price = self.client.get_symbol_ticker(symbol="BTCUSDT")
|
||||
eth_price = self.client.get_symbol_ticker(symbol="ETHUSDT")
|
||||
class BinanceWrapper(BaseWrapper):
|
||||
"""
|
||||
Wrapper per le API autenticate di Binance.\n
|
||||
Implementa l'interfaccia BaseWrapper per fornire accesso unificato
|
||||
ai dati di mercato di Binance tramite le API REST con autenticazione.\n
|
||||
https://binance-docs.github.io/apidocs/spot/en/
|
||||
"""
|
||||
|
||||
return {
|
||||
'BTC_USD': float(btc_price['price']),
|
||||
'ETH_USD': float(eth_price['price']),
|
||||
'source': 'binance_public'
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Errore: {e}")
|
||||
return None
|
||||
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)
|
||||
|
||||
def __format_symbol(self, asset_id: str) -> str:
|
||||
"""
|
||||
Formatta l'asset_id nel formato richiesto da Binance.
|
||||
"""
|
||||
return asset_id.replace('-', '') if '-' in asset_id else f"{asset_id}{self.currency}"
|
||||
|
||||
def get_product(self, asset_id: str) -> ProductInfo:
|
||||
symbol = self.__format_symbol(asset_id)
|
||||
|
||||
ticker = self.client.get_symbol_ticker(symbol=symbol)
|
||||
ticker_24h = self.client.get_ticker(symbol=symbol)
|
||||
ticker['volume'] = ticker_24h.get('volume', 0) # Aggiunge volume 24h ai dati del ticker
|
||||
|
||||
return get_product(self.currency, ticker)
|
||||
|
||||
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
|
||||
symbols = [self.__format_symbol(asset_id) for asset_id in asset_ids]
|
||||
symbols_str = f"[\"{'","'.join(symbols)}\"]"
|
||||
|
||||
tickers = self.client.get_symbol_ticker(symbols=symbols_str)
|
||||
tickers_24h = self.client.get_ticker(symbols=symbols_str) # un po brutale, ma va bene così
|
||||
for t, t24 in zip(tickers, tickers_24h):
|
||||
t['volume'] = t24.get('volume', 0)
|
||||
|
||||
return [get_product(self.currency, ticker) for ticker in tickers]
|
||||
|
||||
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
|
||||
symbol = self.__format_symbol(asset_id)
|
||||
|
||||
# Ottiene candele orarie degli ultimi 30 giorni
|
||||
klines = self.client.get_historical_klines(
|
||||
symbol=symbol,
|
||||
interval=Client.KLINE_INTERVAL_1HOUR,
|
||||
limit=limit,
|
||||
)
|
||||
return [get_price(kline) for kline in klines]
|
||||
|
||||
# Uso senza credenziali
|
||||
public_agent = PublicBinanceAgent()
|
||||
public_prices = public_agent.get_public_prices()
|
||||
print(public_prices)
|
||||
|
||||
@@ -1,20 +1,56 @@
|
||||
import os
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
from coinbase.rest import RESTClient
|
||||
from app.markets.base import ProductInfo, BaseWrapper, Price
|
||||
from coinbase.rest.types.product_types import Candle, GetProductResponse, Product
|
||||
from .base import ProductInfo, BaseWrapper, Price
|
||||
|
||||
|
||||
def get_product(product_data: GetProductResponse | Product) -> ProductInfo:
|
||||
product = ProductInfo()
|
||||
product.id = product_data.product_id or ""
|
||||
product.symbol = product_data.base_currency_id or ""
|
||||
product.price = float(product_data.price) if product_data.price else 0.0
|
||||
product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0.0
|
||||
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.
|
||||
La documentazione delle API è disponibile qui: https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/introduction
|
||||
Wrapper per le API di Coinbase Advanced Trade.\n
|
||||
Implementa l'interfaccia BaseWrapper per fornire accesso unificato
|
||||
ai dati di mercato di Coinbase tramite le API REST.\n
|
||||
https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/introduction
|
||||
"""
|
||||
def __init__(self, api_key:str = None, api_private_key:str = None, currency: str = "USD"):
|
||||
if api_key is None:
|
||||
api_key = os.getenv("COINBASE_API_KEY")
|
||||
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(
|
||||
@@ -28,18 +64,23 @@ class CoinBaseWrapper(BaseWrapper):
|
||||
def get_product(self, asset_id: str) -> ProductInfo:
|
||||
asset_id = self.__format(asset_id)
|
||||
asset = self.client.get_product(asset_id)
|
||||
return ProductInfo.from_coinbase(asset)
|
||||
return get_product(asset)
|
||||
|
||||
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
|
||||
all_asset_ids = [self.__format(asset_id) for asset_id in asset_ids]
|
||||
assets = self.client.get_products(all_asset_ids)
|
||||
return [ProductInfo.from_coinbase(asset) for asset in assets.products]
|
||||
assets = self.client.get_products(product_ids=all_asset_ids)
|
||||
return [get_product(asset) for asset in assets.products]
|
||||
|
||||
def get_all_products(self) -> list[ProductInfo]:
|
||||
assets = self.client.get_products()
|
||||
return [ProductInfo.from_coinbase(asset) for asset in assets.products]
|
||||
|
||||
def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]:
|
||||
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
|
||||
asset_id = self.__format(asset_id)
|
||||
data = self.client.get_candles(product_id=asset_id)
|
||||
return [Price.from_coinbase(candle) for candle in data.candles]
|
||||
end_time = datetime.now()
|
||||
start_time = end_time - timedelta(days=14)
|
||||
|
||||
data = self.client.get_candles(
|
||||
product_id=asset_id,
|
||||
granularity=Granularity.ONE_HOUR.name,
|
||||
start=str(int(start_time.timestamp())),
|
||||
end=str(int(end_time.timestamp())),
|
||||
limit=limit
|
||||
)
|
||||
return [get_price(candle) for candle in data.candles]
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import os
|
||||
import requests
|
||||
from app.markets.base import ProductInfo, BaseWrapper, Price
|
||||
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"
|
||||
|
||||
@@ -10,15 +32,14 @@ class CryptoCompareWrapper(BaseWrapper):
|
||||
La documentazione delle API è disponibile qui: https://developers.coindesk.com/documentation/legacy/Price/SingleSymbolPriceEndpoint
|
||||
!!ATTENZIONE!! sembra essere una API legacy e potrebbe essere deprecata in futuro.
|
||||
"""
|
||||
def __init__(self, api_key:str = None, currency:str='USD'):
|
||||
if api_key is None:
|
||||
api_key = os.getenv("CRYPTOCOMPARE_API_KEY")
|
||||
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: dict = None) -> dict:
|
||||
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
|
||||
@@ -32,7 +53,7 @@ class CryptoCompareWrapper(BaseWrapper):
|
||||
"tsyms": self.currency
|
||||
})
|
||||
data = response.get('RAW', {}).get(asset_id, {}).get(self.currency, {})
|
||||
return ProductInfo.from_cryptocompare(data)
|
||||
return get_product(data)
|
||||
|
||||
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
|
||||
response = self.__request("/data/pricemultifull", params = {
|
||||
@@ -43,20 +64,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
|
||||
|
||||
def get_all_products(self) -> list[ProductInfo]:
|
||||
raise NotImplementedError("CryptoCompare does not support fetching all assets")
|
||||
|
||||
def get_historical_prices(self, asset_id: str, day_back: int = 10) -> list[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
|
||||
|
||||
80
src/app/markets/yfinance.py
Normal file
80
src/app/markets/yfinance.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
from agno.tools.yfinance import YFinanceTools
|
||||
from .base import BaseWrapper, ProductInfo, Price
|
||||
|
||||
|
||||
def create_product_info(stock_data: dict[str, str]) -> ProductInfo:
|
||||
"""
|
||||
Converte i dati di YFinanceTools in ProductInfo.
|
||||
"""
|
||||
product = ProductInfo()
|
||||
product.id = stock_data.get('Symbol', '')
|
||||
product.symbol = product.id.split('-')[0] # Rimuovi il suffisso della valuta per le crypto
|
||||
product.price = float(stock_data.get('Current Stock Price', f"0.0 USD").split(" ")[0]) # prende solo il numero
|
||||
product.volume_24h = 0.0 # YFinance non fornisce il volume 24h direttamente
|
||||
product.quote_currency = product.id.split('-')[1] # La valuta è la parte dopo il '-'
|
||||
return product
|
||||
|
||||
def create_price_from_history(hist_data: dict[str, str]) -> Price:
|
||||
"""
|
||||
Converte i dati storici di YFinanceTools in Price.
|
||||
"""
|
||||
price = Price()
|
||||
price.high = float(hist_data.get('High', 0.0))
|
||||
price.low = float(hist_data.get('Low', 0.0))
|
||||
price.open = float(hist_data.get('Open', 0.0))
|
||||
price.close = float(hist_data.get('Close', 0.0))
|
||||
price.volume = float(hist_data.get('Volume', 0.0))
|
||||
price.timestamp_ms = int(hist_data.get('Timestamp', '0'))
|
||||
return price
|
||||
|
||||
|
||||
class YFinanceWrapper(BaseWrapper):
|
||||
"""
|
||||
Wrapper per YFinanceTools che fornisce dati di mercato per azioni, ETF e criptovalute.
|
||||
Implementa l'interfaccia BaseWrapper per compatibilità con il sistema esistente.
|
||||
Usa YFinanceTools dalla libreria agno per coerenza con altri wrapper.
|
||||
"""
|
||||
|
||||
def __init__(self, currency: str = "USD"):
|
||||
self.currency = currency
|
||||
self.tool = YFinanceTools()
|
||||
|
||||
def _format_symbol(self, asset_id: str) -> str:
|
||||
"""
|
||||
Formatta il simbolo per yfinance.
|
||||
Per crypto, aggiunge '-' e la valuta (es. BTC -> BTC-USD).
|
||||
"""
|
||||
asset_id = asset_id.upper()
|
||||
return f"{asset_id}-{self.currency}" if '-' not in asset_id else asset_id
|
||||
|
||||
def get_product(self, asset_id: str) -> ProductInfo:
|
||||
symbol = self._format_symbol(asset_id)
|
||||
stock_info = self.tool.get_company_info(symbol)
|
||||
stock_info = json.loads(stock_info)
|
||||
return create_product_info(stock_info)
|
||||
|
||||
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
|
||||
products = []
|
||||
for asset_id in asset_ids:
|
||||
product = self.get_product(asset_id)
|
||||
products.append(product)
|
||||
return products
|
||||
|
||||
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
|
||||
symbol = self._format_symbol(asset_id)
|
||||
|
||||
days = limit // 24 + 1 # Arrotonda per eccesso
|
||||
hist_data = self.tool.get_historical_stock_prices(symbol, period=f"{days}d", interval="1h")
|
||||
hist_data = json.loads(hist_data)
|
||||
|
||||
# Il formato dei dati è {timestamp: {Open: x, High: y, Low: z, Close: w, Volume: v}}
|
||||
timestamps = sorted(hist_data.keys())[-limit:]
|
||||
|
||||
prices = []
|
||||
for timestamp in timestamps:
|
||||
temp = hist_data[timestamp]
|
||||
temp['Timestamp'] = timestamp
|
||||
price = create_price_from_history(temp)
|
||||
prices.append(price)
|
||||
return prices
|
||||
@@ -1,13 +1,14 @@
|
||||
import os
|
||||
import requests
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
from agno.agent import Agent
|
||||
from agno.models.base import Model
|
||||
from agno.models.google import Gemini
|
||||
from agno.models.ollama import Ollama
|
||||
|
||||
from agno.utils.log import log_warning
|
||||
from agno.tools import Toolkit
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AppModels(Enum):
|
||||
"""
|
||||
@@ -20,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']:
|
||||
@@ -35,12 +38,12 @@ 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
|
||||
def availables_online() -> list['AppModels']:
|
||||
"""
|
||||
Controlla quali provider di modelli LLM online hanno le loro API keys disponibili
|
||||
@@ -49,9 +52,7 @@ class AppModels(Enum):
|
||||
if not os.getenv("GOOGLE_API_KEY"):
|
||||
log_warning("No GOOGLE_API_KEY set in environment variables.")
|
||||
return []
|
||||
availables = []
|
||||
availables.append(AppModels.GEMINI)
|
||||
availables.append(AppModels.GEMINI_PRO)
|
||||
availables = [AppModels.GEMINI, AppModels.GEMINI_PRO]
|
||||
return availables
|
||||
|
||||
@staticmethod
|
||||
@@ -71,55 +72,39 @@ 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.
|
||||
response: risposta del modello (stringa).
|
||||
Ritorna 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.
|
||||
instructions: istruzioni da passare al modello (system prompt).
|
||||
Ritorna un'istanza di BaseModel o una sua sottoclasse.
|
||||
Raise ValueError se il modello non è supportato.
|
||||
Args:
|
||||
instructions: istruzioni da passare al modello (system prompt).
|
||||
Returns:
|
||||
Un'istanza di BaseModel o una sua sottoclasse.
|
||||
Raise:
|
||||
ValueError se il modello non è supportato.
|
||||
"""
|
||||
name = self.value
|
||||
if self in {AppModels.GEMINI, AppModels.GEMINI_PRO}:
|
||||
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.
|
||||
instructions: istruzioni da passare al modello (system prompt).
|
||||
Ritorna un'istanza di Agent.
|
||||
Args:
|
||||
instructions: istruzioni da passare al modello (system prompt)
|
||||
name: nome dell'agente (opzionale)
|
||||
output: schema di output opzionale (Pydantic BaseModel)
|
||||
Returns:
|
||||
Un'istanza di Agent.
|
||||
"""
|
||||
return Agent(
|
||||
model=self.get_model(instructions),
|
||||
name=name,
|
||||
retries=2,
|
||||
tools=tools,
|
||||
delay_between_retries=5, # seconds
|
||||
output_schema=output # se si usa uno schema di output, lo si passa qui
|
||||
# TODO Eventuali altri parametri da mettere all'agente anche se si possono comunque assegnare dopo la creazione
|
||||
|
||||
94
src/app/news/__init__.py
Normal file
94
src/app/news/__init__.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from agno.tools import Toolkit
|
||||
from app.utils.wrapper_handler import WrapperHandler
|
||||
from .base import NewsWrapper, Article
|
||||
from .news_api import NewsApiWrapper
|
||||
from .googlenews import GoogleNewsWrapper
|
||||
from .cryptopanic_api import CryptoPanicWrapper
|
||||
from .duckduckgo import DuckDuckGoWrapper
|
||||
|
||||
__all__ = ["NewsAPIsTool", "NEWS_INSTRUCTIONS", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"]
|
||||
|
||||
|
||||
class NewsAPIsTool(NewsWrapper, Toolkit):
|
||||
"""
|
||||
Aggregates multiple news API wrappers and manages them using WrapperHandler.
|
||||
This class supports retrieving top headlines and latest news articles by querying multiple sources:
|
||||
- GoogleNewsWrapper
|
||||
- DuckDuckGoWrapper
|
||||
- NewsApiWrapper
|
||||
- CryptoPanicWrapper
|
||||
|
||||
By default, it returns results from the first successful wrapper.
|
||||
Optionally, it can be configured to collect articles from all wrappers.
|
||||
If no wrapper succeeds, an exception is raised.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the NewsAPIsTool with multiple news API wrappers.
|
||||
The tool uses WrapperHandler to manage and invoke the different news API wrappers.
|
||||
The following wrappers are included in this order:
|
||||
- GoogleNewsWrapper.
|
||||
- DuckDuckGoWrapper.
|
||||
- NewsApiWrapper.
|
||||
- CryptoPanicWrapper.
|
||||
"""
|
||||
wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper]
|
||||
self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers)
|
||||
|
||||
Toolkit.__init__(
|
||||
self,
|
||||
name="News APIs Toolkit",
|
||||
tools=[
|
||||
self.get_top_headlines,
|
||||
self.get_latest_news,
|
||||
],
|
||||
)
|
||||
|
||||
def get_top_headlines(self, limit: int = 100) -> list[Article]:
|
||||
return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(limit))
|
||||
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
|
||||
return self.wrapper_handler.try_call(lambda w: w.get_latest_news(query, limit))
|
||||
|
||||
def get_top_headlines_aggregated(self, limit: int = 100) -> dict[str, list[Article]]:
|
||||
"""
|
||||
Calls get_top_headlines on all wrappers/providers and returns a dictionary mapping their names to their articles.
|
||||
Args:
|
||||
limit (int): Maximum number of articles to retrieve from each provider.
|
||||
Returns:
|
||||
dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles
|
||||
"""
|
||||
return self.wrapper_handler.try_call_all(lambda w: w.get_top_headlines(limit))
|
||||
|
||||
def get_latest_news_aggregated(self, query: str, limit: int = 100) -> dict[str, list[Article]]:
|
||||
"""
|
||||
Calls get_latest_news on all wrappers/providers and returns a dictionary mapping their names to their articles.
|
||||
Args:
|
||||
query (str): The search query to find relevant news articles.
|
||||
limit (int): Maximum number of articles to retrieve from each provider.
|
||||
Returns:
|
||||
dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles
|
||||
"""
|
||||
return self.wrapper_handler.try_call_all(lambda w: w.get_latest_news(query, limit))
|
||||
|
||||
|
||||
NEWS_INSTRUCTIONS = """
|
||||
**TASK:** You are a specialized **Crypto News Analyst**. Your goal is to fetch the latest news or top headlines related to cryptocurrencies, and then **analyze the sentiment** of the content to provide a concise report to the team leader. Prioritize 'crypto' or specific cryptocurrency names (e.g., 'Bitcoin', 'Ethereum') in your searches.
|
||||
|
||||
**AVAILABLE TOOLS:**
|
||||
1. `get_latest_news(query: str, limit: int)`: Get the 'limit' most recent news articles for a specific 'query'.
|
||||
2. `get_top_headlines(limit: int)`: Get the 'limit' top global news headlines.
|
||||
3. `get_latest_news_aggregated(query: str, limit: int)`: Get aggregated latest news articles for a specific 'query'.
|
||||
4. `get_top_headlines_aggregated(limit: int)`: Get aggregated top global news headlines.
|
||||
|
||||
**USAGE GUIDELINE:**
|
||||
* Always use `get_latest_news` with a relevant crypto-related query first.
|
||||
* The default limit for news items should be 5 unless specified otherwise.
|
||||
* If the tool doesn't return any articles, respond with "No relevant news articles found."
|
||||
|
||||
**REPORTING REQUIREMENT:**
|
||||
1. **Analyze** the tone and key themes of the retrieved articles.
|
||||
2. **Summarize** the overall **market sentiment** (e.g., highly positive, cautiously neutral, generally negative) based on the content.
|
||||
3. **Identify** the top 2-3 **main topics** discussed (e.g., new regulation, price surge, institutional adoption).
|
||||
4. **Output** a single, brief report summarizing these findings. Do not output the raw articles.
|
||||
"""
|
||||
35
src/app/news/base.py
Normal file
35
src/app/news/base.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Article(BaseModel):
|
||||
source: str = ""
|
||||
time: str = ""
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
|
||||
class NewsWrapper:
|
||||
"""
|
||||
Base class for news API wrappers.
|
||||
All news API wrappers should inherit from this class and implement the methods.
|
||||
"""
|
||||
|
||||
def get_top_headlines(self, limit: int = 100) -> list[Article]:
|
||||
"""
|
||||
Get top headlines, optionally limited by limit.
|
||||
Args:
|
||||
limit (int): The maximum number of articles to return.
|
||||
Returns:
|
||||
list[Article]: A list of Article objects.
|
||||
"""
|
||||
raise NotImplementedError("This method should be overridden by subclasses")
|
||||
|
||||
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
|
||||
"""
|
||||
Get latest news based on a query.
|
||||
Args:
|
||||
query (str): The search query.
|
||||
limit (int): The maximum number of articles to return.
|
||||
Returns:
|
||||
list[Article]: A list of Article objects.
|
||||
"""
|
||||
raise NotImplementedError("This method should be overridden by subclasses")
|
||||
|
||||
77
src/app/news/cryptopanic_api.py
Normal file
77
src/app/news/cryptopanic_api.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import requests
|
||||
from enum import Enum
|
||||
from .base import NewsWrapper, Article
|
||||
|
||||
class CryptoPanicFilter(Enum):
|
||||
RISING = "rising"
|
||||
HOT = "hot"
|
||||
BULLISH = "bullish"
|
||||
BEARISH = "bearish"
|
||||
IMPORTANT = "important"
|
||||
SAVED = "saved"
|
||||
LOL = "lol"
|
||||
ANY = ""
|
||||
|
||||
class CryptoPanicKind(Enum):
|
||||
NEWS = "news"
|
||||
MEDIA = "media"
|
||||
ALL = "all"
|
||||
|
||||
def get_articles(response: dict) -> list[Article]:
|
||||
articles = []
|
||||
if 'results' in response:
|
||||
for item in response['results']:
|
||||
article = Article()
|
||||
article.source = item.get('source', {}).get('title', '')
|
||||
article.time = item.get('published_at', '')
|
||||
article.title = item.get('title', '')
|
||||
article.description = item.get('description', '')
|
||||
articles.append(article)
|
||||
return articles
|
||||
|
||||
class CryptoPanicWrapper(NewsWrapper):
|
||||
"""
|
||||
A wrapper for the CryptoPanic API (Documentation: https://cryptopanic.com/developers/api/)
|
||||
Requires an API key set in the environment variable CRYPTOPANIC_API_KEY.
|
||||
It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/month).
|
||||
Supports different plan types via the CRYPTOPANIC_API_PLAN environment variable (developer, growth, enterprise).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.getenv("CRYPTOPANIC_API_KEY", "")
|
||||
assert self.api_key, "CRYPTOPANIC_API_KEY environment variable not set"
|
||||
|
||||
# Set here for the future, but currently not needed
|
||||
plan_type = os.getenv("CRYPTOPANIC_API_PLAN", "developer").lower()
|
||||
assert plan_type in ["developer", "growth", "enterprise"], "Invalid CRYPTOPANIC_API_PLAN value"
|
||||
|
||||
self.base_url = f"https://cryptopanic.com/api/{plan_type}/v2"
|
||||
self.filter = CryptoPanicFilter.ANY
|
||||
self.kind = CryptoPanicKind.NEWS
|
||||
|
||||
def get_base_params(self) -> dict[str, str]:
|
||||
params = {}
|
||||
params['public'] = 'true' # recommended for app and bots
|
||||
params['auth_token'] = self.api_key
|
||||
params['kind'] = self.kind.value
|
||||
if self.filter != CryptoPanicFilter.ANY:
|
||||
params['filter'] = self.filter.value
|
||||
return params
|
||||
|
||||
def set_filter(self, filter: CryptoPanicFilter):
|
||||
self.filter = filter
|
||||
|
||||
def get_top_headlines(self, limit: int = 100) -> list[Article]:
|
||||
return self.get_latest_news("", limit) # same endpoint so just call the other method
|
||||
|
||||
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
|
||||
params = self.get_base_params()
|
||||
params['currencies'] = query
|
||||
|
||||
response = requests.get(f"{self.base_url}/posts/", params=params)
|
||||
assert response.status_code == 200, f"Error fetching data: {response}"
|
||||
|
||||
json_response = response.json()
|
||||
articles = get_articles(json_response)
|
||||
return articles[:limit]
|
||||
32
src/app/news/duckduckgo.py
Normal file
32
src/app/news/duckduckgo.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
from .base import Article, NewsWrapper
|
||||
from agno.tools.duckduckgo import DuckDuckGoTools
|
||||
|
||||
def create_article(result: dict) -> Article:
|
||||
article = Article()
|
||||
article.source = result.get("source", "")
|
||||
article.time = result.get("date", "")
|
||||
article.title = result.get("title", "")
|
||||
article.description = result.get("body", "")
|
||||
return article
|
||||
|
||||
class DuckDuckGoWrapper(NewsWrapper):
|
||||
"""
|
||||
A wrapper for DuckDuckGo News search using the Tool from agno.tools.duckduckgo.
|
||||
It can be rewritten to use direct API calls if needed in the future, but currently is easy to write and use.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tool = DuckDuckGoTools()
|
||||
self.query = "crypto"
|
||||
|
||||
def get_top_headlines(self, limit: int = 100) -> list[Article]:
|
||||
results = self.tool.duckduckgo_news(self.query, max_results=limit)
|
||||
json_results = json.loads(results)
|
||||
return [create_article(result) for result in json_results]
|
||||
|
||||
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
|
||||
results = self.tool.duckduckgo_news(query or self.query, max_results=limit)
|
||||
json_results = json.loads(results)
|
||||
return [create_article(result) for result in json_results]
|
||||
|
||||
36
src/app/news/googlenews.py
Normal file
36
src/app/news/googlenews.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from gnews import GNews
|
||||
from .base import Article, NewsWrapper
|
||||
|
||||
def result_to_article(result: dict) -> Article:
|
||||
article = Article()
|
||||
article.source = result.get("source", "")
|
||||
article.time = result.get("publishedAt", "")
|
||||
article.title = result.get("title", "")
|
||||
article.description = result.get("description", "")
|
||||
return article
|
||||
|
||||
class GoogleNewsWrapper(NewsWrapper):
|
||||
"""
|
||||
A wrapper for the Google News RSS Feed (Documentation: https://github.com/ranahaani/GNews/?tab=readme-ov-file#about-gnews)
|
||||
It does not require an API key and is free to use.
|
||||
"""
|
||||
|
||||
def get_top_headlines(self, limit: int = 100) -> list[Article]:
|
||||
gnews = GNews(language='en', max_results=limit, period='7d')
|
||||
results = gnews.get_top_news()
|
||||
|
||||
articles = []
|
||||
for result in results:
|
||||
article = result_to_article(result)
|
||||
articles.append(article)
|
||||
return articles
|
||||
|
||||
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
|
||||
gnews = GNews(language='en', max_results=limit, period='7d')
|
||||
results = gnews.get_news(query)
|
||||
|
||||
articles = []
|
||||
for result in results:
|
||||
article = result_to_article(result)
|
||||
articles.append(article)
|
||||
return articles
|
||||
53
src/app/news/news_api.py
Normal file
53
src/app/news/news_api.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
import newsapi
|
||||
from .base import Article, NewsWrapper
|
||||
|
||||
def result_to_article(result: dict) -> Article:
|
||||
article = Article()
|
||||
article.source = result.get("source", {}).get("name", "")
|
||||
article.time = result.get("publishedAt", "")
|
||||
article.title = result.get("title", "")
|
||||
article.description = result.get("description", "")
|
||||
return article
|
||||
|
||||
class NewsApiWrapper(NewsWrapper):
|
||||
"""
|
||||
A wrapper for the NewsAPI (Documentation: https://newsapi.org/docs/get-started)
|
||||
Requires an API key set in the environment variable NEWS_API_KEY.
|
||||
It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/day).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
api_key = os.getenv("NEWS_API_KEY")
|
||||
assert api_key, "NEWS_API_KEY environment variable not set"
|
||||
|
||||
self.client = newsapi.NewsApiClient(api_key=api_key)
|
||||
self.category = "business" # Cryptocurrency is under business
|
||||
self.language = "en" # TODO Only English articles for now?
|
||||
self.max_page_size = 100
|
||||
|
||||
def __calc_pages(self, limit: int, page_size: int) -> tuple[int, int]:
|
||||
page_size = min(self.max_page_size, limit)
|
||||
pages = (limit // page_size) + (1 if limit % page_size > 0 else 0)
|
||||
return pages, page_size
|
||||
|
||||
def get_top_headlines(self, limit: int = 100) -> list[Article]:
|
||||
pages, page_size = self.__calc_pages(limit, self.max_page_size)
|
||||
articles = []
|
||||
|
||||
for page in range(1, pages + 1):
|
||||
headlines = self.client.get_top_headlines(q="", category=self.category, language=self.language, page_size=page_size, page=page)
|
||||
results = [result_to_article(article) for article in headlines.get("articles", [])]
|
||||
articles.extend(results)
|
||||
return articles
|
||||
|
||||
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
|
||||
pages, page_size = self.__calc_pages(limit, self.max_page_size)
|
||||
articles = []
|
||||
|
||||
for page in range(1, pages + 1):
|
||||
everything = self.client.get_everything(q=query, language=self.language, sort_by="publishedAt", page_size=page_size, page=page)
|
||||
results = [result_to_article(article) for article in everything.get("articles", [])]
|
||||
articles.extend(results)
|
||||
return articles
|
||||
|
||||
148
src/app/pipeline.py
Normal file
148
src/app/pipeline.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from agno.run.agent import RunOutput
|
||||
from agno.team import Team
|
||||
|
||||
from app.news import NewsAPIsTool, NEWS_INSTRUCTIONS
|
||||
from app.social import SocialAPIsTool, SOCIAL_INSTRUCTIONS
|
||||
from app.markets import MarketAPIsTool, MARKET_INSTRUCTIONS
|
||||
from app.models import AppModels
|
||||
from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""
|
||||
Coordina gli agenti di servizio (Market, News, Social) e il Predictor finale.
|
||||
Il Team è orchestrato da qwen3:latest (Ollama), mentre il Predictor è dinamico
|
||||
e scelto dall'utente tramite i dropdown dell'interfaccia grafica.
|
||||
"""
|
||||
def __init__(self):
|
||||
# Inizializza gli agenti
|
||||
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(
|
||||
# TODO: migliorare le istruzioni del team
|
||||
"Agisci come coordinatore: smista le richieste tra MarketAgent, NewsAgent e SocialAgent."
|
||||
)
|
||||
|
||||
# === Team ===
|
||||
self.team = Team(
|
||||
name="CryptoAnalysisTeam",
|
||||
members=[self.market_agent, self.news_agent, self.social_agent],
|
||||
model=team_model
|
||||
)
|
||||
|
||||
# === Predictor ===
|
||||
self.available_models = AppModels.availables()
|
||||
self.all_styles = list(PredictorStyle)
|
||||
|
||||
# Scelte di default
|
||||
self.chosen_model = self.available_models[0] if self.available_models else None
|
||||
self.style = self.all_styles[0] if self.all_styles else None
|
||||
|
||||
self._init_predictor() # Inizializza il predictor con il modello di default
|
||||
|
||||
# ======================
|
||||
# Dropdown handlers
|
||||
# ======================
|
||||
def choose_provider(self, index: int):
|
||||
"""
|
||||
Sceglie il modello LLM da usare per il Predictor.
|
||||
"""
|
||||
self.chosen_model = self.available_models[index]
|
||||
self._init_predictor()
|
||||
|
||||
def choose_style(self, index: int):
|
||||
"""
|
||||
Sceglie lo stile (conservativo/aggressivo) da usare per il Predictor.
|
||||
"""
|
||||
self.style = self.all_styles[index]
|
||||
|
||||
# ======================
|
||||
# Helpers
|
||||
# ======================
|
||||
def _init_predictor(self):
|
||||
"""
|
||||
Inizializza (o reinizializza) il Predictor in base al modello scelto.
|
||||
"""
|
||||
if not self.chosen_model:
|
||||
return
|
||||
self.predictor = self.chosen_model.get_agent(
|
||||
PREDICTOR_INSTRUCTIONS,
|
||||
output=PredictorOutput, # type: ignore
|
||||
)
|
||||
|
||||
def list_providers(self) -> list[str]:
|
||||
"""
|
||||
Restituisce la lista dei nomi dei modelli disponibili.
|
||||
"""
|
||||
return [model.name for model in self.available_models]
|
||||
|
||||
def list_styles(self) -> list[str]:
|
||||
"""
|
||||
Restituisce la lista degli stili di previsione disponibili.
|
||||
"""
|
||||
return [style.value for style in self.all_styles]
|
||||
|
||||
# ======================
|
||||
# Core interaction
|
||||
# ======================
|
||||
def interact(self, query: str) -> str:
|
||||
"""
|
||||
1. Raccoglie output dai membri del Team
|
||||
2. Aggrega output strutturati
|
||||
3. Invoca Predictor
|
||||
4. Restituisce la strategia finale
|
||||
"""
|
||||
if not self.predictor or not self.style:
|
||||
return "⚠️ Devi prima selezionare un modello e una strategia validi dagli appositi menu."
|
||||
|
||||
# Step 1: raccolta output dai membri del Team
|
||||
team_outputs = self.team.run(query)
|
||||
|
||||
# Step 2: aggregazione output strutturati
|
||||
all_products = []
|
||||
sentiments = []
|
||||
|
||||
for agent_output in team_outputs.member_responses:
|
||||
if isinstance(agent_output, RunOutput):
|
||||
if "products" in agent_output.metadata:
|
||||
all_products.extend(agent_output.metadata["products"])
|
||||
if "sentiment_news" in agent_output.metadata:
|
||||
sentiments.append(agent_output.metadata["sentiment_news"])
|
||||
if "sentiment_social" in agent_output.metadata:
|
||||
sentiments.append(agent_output.metadata["sentiment_social"])
|
||||
|
||||
aggregated_sentiment = "\n".join(sentiments)
|
||||
|
||||
# Step 3: invocazione Predictor
|
||||
predictor_input = PredictorInput(
|
||||
data=all_products,
|
||||
style=self.style,
|
||||
sentiment=aggregated_sentiment
|
||||
)
|
||||
|
||||
result = self.predictor.run(predictor_input)
|
||||
prediction: PredictorOutput = result.content
|
||||
|
||||
# Step 4: restituzione strategia finale
|
||||
portfolio_lines = "\n".join(
|
||||
[f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio]
|
||||
)
|
||||
return (
|
||||
f"📊 Strategia ({self.style.value}): {prediction.strategy}\n\n"
|
||||
f"💼 Portafoglio consigliato:\n{portfolio_lines}"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from enum import Enum
|
||||
from app.markets.base import ProductInfo
|
||||
from pydantic import BaseModel, Field
|
||||
from app.markets.base import ProductInfo
|
||||
|
||||
|
||||
class PredictorStyle(Enum):
|
||||
CONSERVATIVE = "Conservativo"
|
||||
@@ -23,7 +24,7 @@ class PredictorOutput(BaseModel):
|
||||
PREDICTOR_INSTRUCTIONS = """
|
||||
You are an **Allocation Algorithm (Crypto-Algo)** specialized in analyzing market data and sentiment to generate an investment strategy and a target portfolio.
|
||||
|
||||
Your sole objective is to process the input data and generate the strictly structured output as required by the response format. **You MUST NOT provide introductions, preambles, explanations, conclusions, or any additional comments that are not strictly required.**
|
||||
Your sole objective is to process the user_input data and generate the strictly structured output as required by the response format. **You MUST NOT provide introductions, preambles, explanations, conclusions, or any additional comments that are not strictly required.**
|
||||
|
||||
## Processing Instructions (Absolute Rule)
|
||||
|
||||
61
src/app/social/__init__.py
Normal file
61
src/app/social/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from agno.tools import Toolkit
|
||||
from app.utils.wrapper_handler import WrapperHandler
|
||||
from .base import SocialPost, SocialWrapper
|
||||
from .reddit import RedditWrapper
|
||||
|
||||
__all__ = ["SocialAPIsTool", "SOCIAL_INSTRUCTIONS", "RedditWrapper"]
|
||||
|
||||
|
||||
class SocialAPIsTool(SocialWrapper, Toolkit):
|
||||
"""
|
||||
Aggregates multiple social media API wrappers and manages them using WrapperHandler.
|
||||
This class supports retrieving top crypto-related posts by querying multiple sources:
|
||||
- RedditWrapper
|
||||
|
||||
By default, it returns results from the first successful wrapper.
|
||||
Optionally, it can be configured to collect posts from all wrappers.
|
||||
If no wrapper succeeds, an exception is raised.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the SocialAPIsTool with multiple social media API wrappers.
|
||||
The tool uses WrapperHandler to manage and invoke the different social media API wrappers.
|
||||
The following wrappers are included in this order:
|
||||
- RedditWrapper.
|
||||
"""
|
||||
|
||||
wrappers = [RedditWrapper]
|
||||
self.wrapper_handler: WrapperHandler[SocialWrapper] = WrapperHandler.build_wrappers(wrappers)
|
||||
|
||||
Toolkit.__init__(
|
||||
self,
|
||||
name="Socials Toolkit",
|
||||
tools=[
|
||||
self.get_top_crypto_posts,
|
||||
],
|
||||
)
|
||||
|
||||
# TODO Pensare se ha senso restituire i post da TUTTI i wrapper o solo dal primo che funziona
|
||||
# la modifica è banale, basta usare try_call_all invece di try_call
|
||||
def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]:
|
||||
return self.wrapper_handler.try_call(lambda w: w.get_top_crypto_posts(limit))
|
||||
|
||||
|
||||
SOCIAL_INSTRUCTIONS = """
|
||||
**TASK:** You are a specialized **Social Media Sentiment Analyst**. Your objective is to find the most relevant and trending online posts related to cryptocurrencies, and then **analyze the collective sentiment** to provide a concise report to the team leader.
|
||||
|
||||
**AVAILABLE TOOLS:**
|
||||
1. `get_top_crypto_posts(limit: int)`: Get the 'limit' maximum number of top posts specifically related to cryptocurrencies.
|
||||
|
||||
**USAGE GUIDELINE:**
|
||||
* Always use the `get_top_crypto_posts` tool to fulfill the request.
|
||||
* The default limit for posts should be 5 unless specified otherwise.
|
||||
* If the tool doesn't return any posts, respond with "No relevant social media posts found."
|
||||
|
||||
**REPORTING REQUIREMENT:**
|
||||
1. **Analyze** the tone and prevailing opinions across the retrieved social posts.
|
||||
2. **Summarize** the overall **community sentiment** (e.g., high enthusiasm/FOMO, uncertainty, FUD/fear) based on the content.
|
||||
3. **Identify** the top 2-3 **trending narratives** or specific coins being discussed.
|
||||
4. **Output** a single, brief report summarizing these findings. Do not output the raw posts.
|
||||
"""
|
||||
30
src/app/social/base.py
Normal file
30
src/app/social/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SocialPost(BaseModel):
|
||||
time: str = ""
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
comments: list["SocialComment"] = []
|
||||
|
||||
class SocialComment(BaseModel):
|
||||
time: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class SocialWrapper:
|
||||
"""
|
||||
Base class for social media API wrappers.
|
||||
All social media API wrappers should inherit from this class and implement the methods.
|
||||
"""
|
||||
|
||||
def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]:
|
||||
"""
|
||||
Get top cryptocurrency-related posts, optionally limited by total.
|
||||
Args:
|
||||
limit (int): The maximum number of posts to return.
|
||||
Returns:
|
||||
list[SocialPost]: A list of SocialPost objects.
|
||||
"""
|
||||
raise NotImplementedError("This method should be overridden by subclasses")
|
||||
|
||||
68
src/app/social/reddit.py
Normal file
68
src/app/social/reddit.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import os
|
||||
from praw import Reddit
|
||||
from praw.models import Submission, MoreComments
|
||||
from .base import SocialWrapper, SocialPost, SocialComment
|
||||
|
||||
MAX_COMMENTS = 5
|
||||
# metterne altri se necessario.
|
||||
# fonti: https://lkiconsulting.io/marketing/best-crypto-subreddits/
|
||||
SUBREDDITS = [
|
||||
"CryptoCurrency",
|
||||
"Bitcoin",
|
||||
"Ethereum",
|
||||
"CryptoMarkets",
|
||||
"Dogecoin",
|
||||
"Altcoin",
|
||||
"DeFi",
|
||||
"NFT",
|
||||
"BitcoinBeginners",
|
||||
"CryptoTechnology",
|
||||
"btc" # alt subs of Bitcoin
|
||||
]
|
||||
|
||||
|
||||
def create_social_post(post: Submission) -> SocialPost:
|
||||
social = SocialPost()
|
||||
social.time = str(post.created)
|
||||
social.title = post.title
|
||||
social.description = post.selftext
|
||||
|
||||
for i, top_comment in enumerate(post.comments):
|
||||
if i >= MAX_COMMENTS:
|
||||
break
|
||||
if isinstance(top_comment, MoreComments): #skip MoreComments objects
|
||||
continue
|
||||
|
||||
comment = SocialComment()
|
||||
comment.time = str(top_comment.created)
|
||||
comment.description = top_comment.body
|
||||
social.comments.append(comment)
|
||||
return social
|
||||
|
||||
class RedditWrapper(SocialWrapper):
|
||||
"""
|
||||
A wrapper for the Reddit API using PRAW (Python Reddit API Wrapper).
|
||||
Requires the following environment variables to be set:
|
||||
- REDDIT_API_CLIENT_ID
|
||||
- REDDIT_API_CLIENT_SECRET
|
||||
|
||||
You can get them by creating an app at https://www.reddit.com/prefs/apps
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
client_id = os.getenv("REDDIT_API_CLIENT_ID")
|
||||
assert client_id, "REDDIT_API_CLIENT_ID environment variable is not set"
|
||||
|
||||
client_secret = os.getenv("REDDIT_API_CLIENT_SECRET")
|
||||
assert client_secret, "REDDIT_API_CLIENT_SECRET environment variable is not set"
|
||||
|
||||
self.tool = Reddit(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
user_agent="upo-appAI",
|
||||
)
|
||||
self.subreddits = self.tool.subreddit("+".join(SUBREDDITS))
|
||||
|
||||
def get_top_crypto_posts(self, limit: int = 5) -> list[SocialPost]:
|
||||
top_posts = self.subreddits.top(limit=limit, time_filter="week")
|
||||
return [create_social_post(post) for post in top_posts]
|
||||
@@ -1,88 +0,0 @@
|
||||
from app.agents.news_agent import NewsAgent
|
||||
from app.agents.social_agent import SocialAgent
|
||||
from app.agents.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS
|
||||
from app.markets import MarketAPIs
|
||||
from app.models import AppModels
|
||||
from agno.utils.log import log_info
|
||||
|
||||
class ToolAgent:
|
||||
"""
|
||||
Classe principale che coordina gli agenti per rispondere alle richieste dell'utente.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Inizializza l'agente con i modelli disponibili, gli stili e l'API di mercato.
|
||||
"""
|
||||
self.available_models = AppModels.availables()
|
||||
self.all_styles = list(PredictorStyle)
|
||||
self.style = self.all_styles[0] # Default to the first style
|
||||
|
||||
self.market = MarketAPIs(currency="USD")
|
||||
self.choose_provider(0) # Default to the first model
|
||||
|
||||
def choose_provider(self, index: int):
|
||||
"""
|
||||
Sceglie il modello LLM da utilizzare in base all'indice fornito.
|
||||
index: indice del modello nella lista available_models.
|
||||
"""
|
||||
# TODO Utilizzare AGNO per gestire i modelli... è molto più semplice e permette di cambiare modello facilmente
|
||||
# TODO https://docs.agno.com/introduction
|
||||
# Inoltre permette di creare dei team e workflow di agenti più facilmente
|
||||
self.chosen_model = self.available_models[index]
|
||||
self.predictor = self.chosen_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput)
|
||||
self.news_agent = NewsAgent()
|
||||
self.social_agent = SocialAgent()
|
||||
|
||||
def choose_style(self, index: int):
|
||||
"""
|
||||
Sceglie lo stile di previsione da utilizzare in base all'indice fornito.
|
||||
index: indice dello stile nella lista all_styles.
|
||||
"""
|
||||
self.style = self.all_styles[index]
|
||||
|
||||
def interact(self, query: str) -> str:
|
||||
"""
|
||||
Funzione principale che coordina gli agenti per rispondere alla richiesta dell'utente.
|
||||
query: richiesta dell'utente (es. "Qual è la previsione per Bitcoin?")
|
||||
style_index: indice dello stile di previsione nella lista all_styles.
|
||||
"""
|
||||
|
||||
log_info(f"[model={self.chosen_model.name}] [style={self.style.name}] [query=\"{query.replace('"', "'")}\"]")
|
||||
# TODO Step 0: ricerca e analisi della richiesta (es. estrazione di criptovalute specifiche)
|
||||
# Prendere la query dell'utente e fare un'analisi preliminare con una agente o con un team di agenti (social e news)
|
||||
|
||||
# Step 1: raccolta analisi
|
||||
cryptos = ["BTC", "ETH", "XRP", "LTC", "BCH"] # TODO rendere dinamico in futuro
|
||||
market_data = self.market.get_products(cryptos)
|
||||
news_sentiment = self.news_agent.analyze(query)
|
||||
social_sentiment = self.social_agent.analyze(query)
|
||||
log_info(f"End of data collection")
|
||||
|
||||
# Step 2: aggrega sentiment
|
||||
sentiment = f"{news_sentiment}\n{social_sentiment}"
|
||||
|
||||
# Step 3: previsione
|
||||
inputs = PredictorInput(data=market_data, style=self.style, sentiment=sentiment)
|
||||
result = self.predictor.run(inputs)
|
||||
prediction: PredictorOutput = result.content
|
||||
log_info(f"End of prediction")
|
||||
|
||||
market_data = "\n".join([f"{product.symbol}: {product.price}" for product in market_data])
|
||||
output = f"[{prediction.strategy}]\nPortafoglio:\n" + "\n".join(
|
||||
[f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio]
|
||||
)
|
||||
|
||||
return f"INPUT:\n{market_data}\n{sentiment}\n\n\nOUTPUT:\n{output}"
|
||||
|
||||
def list_providers(self) -> list[str]:
|
||||
"""
|
||||
Restituisce la lista dei nomi dei modelli disponibili.
|
||||
"""
|
||||
return [model.name for model in self.available_models]
|
||||
|
||||
def list_styles(self) -> list[str]:
|
||||
"""
|
||||
Restituisce la lista degli stili di previsione disponibili.
|
||||
"""
|
||||
return [style.value for style in self.all_styles]
|
||||
91
src/app/utils/market_aggregation.py
Normal file
91
src/app/utils/market_aggregation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import statistics
|
||||
from app.markets.base import ProductInfo, Price
|
||||
|
||||
|
||||
def aggregate_history_prices(prices: dict[str, list[Price]]) -> list[Price]:
|
||||
"""
|
||||
Aggrega i prezzi storici per symbol calcolando la media oraria.
|
||||
Args:
|
||||
prices (dict[str, list[Price]]): Mappa provider -> lista di Price
|
||||
Returns:
|
||||
list[Price]: Lista di Price aggregati per ora
|
||||
"""
|
||||
|
||||
# Costruiamo una mappa timestamp_h -> lista di Price
|
||||
timestamped_prices: dict[int, list[Price]] = {}
|
||||
for _, price_list in prices.items():
|
||||
for price in price_list:
|
||||
time = price.timestamp_ms - (price.timestamp_ms % 3600000) # arrotonda all'ora (non dovrebbe essere necessario)
|
||||
timestamped_prices.setdefault(time, []).append(price)
|
||||
|
||||
# Ora aggregiamo i prezzi per ogni ora
|
||||
aggregated_prices = []
|
||||
for time, price_list in timestamped_prices.items():
|
||||
price = Price()
|
||||
price.timestamp_ms = time
|
||||
price.high = statistics.mean([p.high for p in price_list])
|
||||
price.low = statistics.mean([p.low for p in price_list])
|
||||
price.open = statistics.mean([p.open for p in price_list])
|
||||
price.close = statistics.mean([p.close for p in price_list])
|
||||
price.volume = statistics.mean([p.volume for p in price_list])
|
||||
aggregated_prices.append(price)
|
||||
return aggregated_prices
|
||||
|
||||
def aggregate_product_info(products: dict[str, list[ProductInfo]]) -> list[ProductInfo]:
|
||||
"""
|
||||
Aggrega una lista di ProductInfo per symbol.
|
||||
Args:
|
||||
products (dict[str, list[ProductInfo]]): Mappa provider -> lista di ProductInfo
|
||||
Returns:
|
||||
list[ProductInfo]: Lista di ProductInfo aggregati per symbol
|
||||
"""
|
||||
|
||||
# Costruzione mappa symbol -> lista di ProductInfo
|
||||
symbols_infos: dict[str, list[ProductInfo]] = {}
|
||||
for _, product_list in products.items():
|
||||
for product in product_list:
|
||||
symbols_infos.setdefault(product.symbol, []).append(product)
|
||||
|
||||
# Aggregazione per ogni symbol
|
||||
sources = list(products.keys())
|
||||
aggregated_products = []
|
||||
for symbol, product_list in symbols_infos.items():
|
||||
product = ProductInfo()
|
||||
|
||||
product.id = f"{symbol}_AGGREGATED"
|
||||
product.symbol = symbol
|
||||
product.quote_currency = next(p.quote_currency for p in product_list if p.quote_currency)
|
||||
|
||||
volume_sum = sum(p.volume_24h for p in product_list)
|
||||
product.volume_24h = volume_sum / len(product_list) if product_list else 0.0
|
||||
|
||||
prices = sum(p.price * p.volume_24h for p in product_list)
|
||||
product.price = (prices / volume_sum) if volume_sum > 0 else 0.0
|
||||
|
||||
aggregated_products.append(product)
|
||||
return aggregated_products
|
||||
|
||||
def _calculate_confidence(products: list[ProductInfo], sources: list[str]) -> float:
|
||||
"""Calcola un punteggio di confidenza 0-1"""
|
||||
if not products:
|
||||
return 0.0
|
||||
|
||||
score = 1.0
|
||||
|
||||
# Riduci score se pochi dati
|
||||
if len(products) < 2:
|
||||
score *= 0.7
|
||||
|
||||
# Riduci score se prezzi troppo diversi
|
||||
prices = [p.price for p in products if p.price > 0]
|
||||
if len(prices) > 1:
|
||||
price_std = (max(prices) - min(prices)) / statistics.mean(prices)
|
||||
if price_std > 0.05: # >5% variazione
|
||||
score *= 0.8
|
||||
|
||||
# Riduci score se fonti sconosciute
|
||||
unknown_sources = sum(1 for s in sources if s == "unknown")
|
||||
if unknown_sources > 0:
|
||||
score *= (1 - unknown_sources / len(sources))
|
||||
|
||||
return max(0.0, min(1.0, score))
|
||||
@@ -1,70 +0,0 @@
|
||||
import statistics
|
||||
from typing import Dict, List, 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:
|
||||
return 0.0
|
||||
return 0.0
|
||||
140
src/app/utils/wrapper_handler.py
Normal file
140
src/app/utils/wrapper_handler.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import inspect
|
||||
import time
|
||||
import traceback
|
||||
from typing import TypeVar, Callable, Generic, Iterable, Type
|
||||
from agno.utils.log import log_warning, log_info
|
||||
|
||||
W = TypeVar("W")
|
||||
T = TypeVar("T")
|
||||
|
||||
class WrapperHandler(Generic[W]):
|
||||
"""
|
||||
A handler for managing multiple wrappers with retry logic.
|
||||
It attempts to call a function on the current wrapper, and if it fails,
|
||||
it retries a specified number of times before switching to the next wrapper.
|
||||
If all wrappers fail, it raises an exception.
|
||||
|
||||
Note: use `build_wrappers` to create an instance of this class for better error handling.
|
||||
"""
|
||||
|
||||
def __init__(self, wrappers: list[W], try_per_wrapper: int = 3, retry_delay: int = 2):
|
||||
"""
|
||||
Initializes the WrapperHandler with a list of wrappers and retry settings.\n
|
||||
Use `build_wrappers` to create an instance of this class for better error handling.
|
||||
Args:
|
||||
wrappers (list[W]): A list of wrapper instances to manage.
|
||||
try_per_wrapper (int): Number of retries per wrapper before switching to the next.
|
||||
retry_delay (int): Delay in seconds between retries.
|
||||
"""
|
||||
assert not WrapperHandler.__check(wrappers), "All wrappers must be instances of their respective classes. Use `build_wrappers` to create the WrapperHandler."
|
||||
|
||||
self.wrappers = wrappers
|
||||
self.retry_per_wrapper = try_per_wrapper
|
||||
self.retry_delay = retry_delay
|
||||
self.index = 0
|
||||
self.retry_count = 0
|
||||
|
||||
def try_call(self, func: Callable[[W], T]) -> T:
|
||||
"""
|
||||
Attempts to call the provided function on the current wrapper.
|
||||
If it fails, it retries a specified number of times before switching to the next wrapper.
|
||||
If all wrappers fail, it raises an exception.
|
||||
Args:
|
||||
func (Callable[[W], T]): A function that takes a wrapper and returns a result.
|
||||
Returns:
|
||||
T: The result of the function call.
|
||||
Raises:
|
||||
Exception: If all wrappers fail after retries.
|
||||
"""
|
||||
log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}")
|
||||
|
||||
iterations = 0
|
||||
while iterations < len(self.wrappers):
|
||||
wrapper = self.wrappers[self.index]
|
||||
wrapper_name = wrapper.__class__.__name__
|
||||
|
||||
try:
|
||||
log_info(f"try_call {wrapper_name}")
|
||||
result = func(wrapper)
|
||||
log_info(f"{wrapper_name} succeeded")
|
||||
self.retry_count = 0
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.retry_count += 1
|
||||
error = WrapperHandler.__concise_error(e)
|
||||
log_warning(f"{wrapper_name} failed {self.retry_count}/{self.retry_per_wrapper}: {error}")
|
||||
|
||||
if self.retry_count >= self.retry_per_wrapper:
|
||||
self.index = (self.index + 1) % len(self.wrappers)
|
||||
self.retry_count = 0
|
||||
iterations += 1
|
||||
else:
|
||||
time.sleep(self.retry_delay)
|
||||
|
||||
raise Exception(f"All wrappers failed, latest error: {error}")
|
||||
|
||||
def try_call_all(self, func: Callable[[W], T]) -> dict[str, T]:
|
||||
"""
|
||||
Calls the provided function on all wrappers, collecting results.
|
||||
If a wrapper fails, it logs a warning and continues with the next.
|
||||
If all wrappers fail, it raises an exception.
|
||||
Args:
|
||||
func (Callable[[W], T]): A function that takes a wrapper and returns a result.
|
||||
Returns:
|
||||
list[T]: A list of results from the function calls.
|
||||
Raises:
|
||||
Exception: If all wrappers fail.
|
||||
"""
|
||||
log_info(f"{inspect.getsource(func).strip()} {inspect.getclosurevars(func).nonlocals}")
|
||||
|
||||
results = {}
|
||||
for wrapper in self.wrappers:
|
||||
wrapper_name = wrapper.__class__.__name__
|
||||
try:
|
||||
result = func(wrapper)
|
||||
log_info(f"{wrapper_name} succeeded")
|
||||
results[wrapper.__class__] = result
|
||||
except Exception as e:
|
||||
error = WrapperHandler.__concise_error(e)
|
||||
log_warning(f"{wrapper_name} failed: {error}")
|
||||
if not results:
|
||||
raise Exception(f"All wrappers failed, latest error: {error}")
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def __check(wrappers: list[W]) -> bool:
|
||||
return all(w.__class__ is type for w in wrappers)
|
||||
|
||||
@staticmethod
|
||||
def __concise_error(e: Exception) -> str:
|
||||
last_frame = traceback.extract_tb(e.__traceback__)[-1]
|
||||
return f"{e} [\"{last_frame.filename}\", line {last_frame.lineno}]"
|
||||
|
||||
@staticmethod
|
||||
def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2, kwargs: dict | None = None) -> 'WrapperHandler[W]':
|
||||
"""
|
||||
Builds a WrapperHandler instance with the given wrapper constructors.
|
||||
It attempts to initialize each wrapper and logs a warning if any cannot be initialized.
|
||||
Only successfully initialized wrappers are included in the handler.
|
||||
Args:
|
||||
constructors (Iterable[Type[W]]): An iterable of wrapper classes to instantiate. e.g. [WrapperA, WrapperB]
|
||||
try_per_wrapper (int): Number of retries per wrapper before switching to the next.
|
||||
retry_delay (int): Delay in seconds between retries.
|
||||
kwargs (dict | None): Optional dictionary with keyword arguments common to all wrappers.
|
||||
Returns:
|
||||
WrapperHandler[W]: An instance of WrapperHandler with the initialized wrappers.
|
||||
Raises:
|
||||
Exception: If no wrappers could be initialized.
|
||||
"""
|
||||
assert WrapperHandler.__check(constructors), f"All constructors must be classes. Received: {constructors}"
|
||||
|
||||
result = []
|
||||
for wrapper_class in constructors:
|
||||
try:
|
||||
wrapper = wrapper_class(**(kwargs or {}))
|
||||
result.append(wrapper)
|
||||
except Exception as e:
|
||||
log_warning(f"{wrapper_class} cannot be initialized: {e}")
|
||||
|
||||
return WrapperHandler(result, try_per_wrapper, retry_delay)
|
||||
@@ -1,146 +0,0 @@
|
||||
import os
|
||||
import pytest
|
||||
from app.agents.market import MarketToolkit
|
||||
from app.markets.base import BaseWrapper
|
||||
from app.markets.coinbase import CoinBaseWrapper
|
||||
from app.markets.cryptocompare import CryptoCompareWrapper
|
||||
from app.markets import MarketAPIs
|
||||
|
||||
class TestMarketSystem:
|
||||
"""Test suite per il sistema di mercato (wrappers + toolkit)"""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def market_wrapper(self) -> BaseWrapper:
|
||||
return MarketAPIs("USD")
|
||||
|
||||
def test_wrapper_initialization(self, market_wrapper):
|
||||
assert market_wrapper is not None
|
||||
assert hasattr(market_wrapper, 'get_product')
|
||||
assert hasattr(market_wrapper, 'get_products')
|
||||
assert hasattr(market_wrapper, 'get_all_products')
|
||||
assert hasattr(market_wrapper, 'get_historical_prices')
|
||||
|
||||
def test_providers_configuration(self):
|
||||
available_providers = []
|
||||
if os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY'):
|
||||
available_providers.append('coinbase')
|
||||
if os.getenv('CRYPTOCOMPARE_API_KEY'):
|
||||
available_providers.append('cryptocompare')
|
||||
assert len(available_providers) > 0
|
||||
|
||||
def test_wrapper_capabilities(self, market_wrapper):
|
||||
capabilities = []
|
||||
if hasattr(market_wrapper, 'get_product'):
|
||||
capabilities.append('single_product')
|
||||
if hasattr(market_wrapper, 'get_products'):
|
||||
capabilities.append('multiple_products')
|
||||
if hasattr(market_wrapper, 'get_historical_prices'):
|
||||
capabilities.append('historical_data')
|
||||
assert len(capabilities) > 0
|
||||
|
||||
def test_market_data_retrieval(self, market_wrapper):
|
||||
btc_product = market_wrapper.get_product("BTC")
|
||||
assert btc_product is not None
|
||||
assert hasattr(btc_product, 'symbol')
|
||||
assert hasattr(btc_product, 'price')
|
||||
assert btc_product.price > 0
|
||||
|
||||
def test_market_toolkit_integration(self, market_wrapper):
|
||||
try:
|
||||
toolkit = MarketToolkit()
|
||||
assert toolkit is not None
|
||||
assert hasattr(toolkit, 'market_agent')
|
||||
assert toolkit.market_api is not None
|
||||
|
||||
tools = toolkit.tools
|
||||
assert len(tools) > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"MarketToolkit test failed: {e}")
|
||||
# Non fail completamente - il toolkit potrebbe avere dipendenze specifiche
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv('CRYPTOCOMPARE_API_KEY'),
|
||||
reason="CRYPTOCOMPARE_API_KEY not configured"
|
||||
)
|
||||
def test_cryptocompare_wrapper(self):
|
||||
try:
|
||||
api_key = os.getenv('CRYPTOCOMPARE_API_KEY')
|
||||
wrapper = CryptoCompareWrapper(api_key=api_key, currency="USD")
|
||||
|
||||
btc_product = wrapper.get_product("BTC")
|
||||
assert btc_product is not None
|
||||
assert btc_product.symbol == "BTC"
|
||||
assert btc_product.price > 0
|
||||
|
||||
products = wrapper.get_products(["BTC", "ETH"])
|
||||
assert isinstance(products, list)
|
||||
assert len(products) > 0
|
||||
|
||||
for product in products:
|
||||
if product.symbol in ["BTC", "ETH"]:
|
||||
assert product.price > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"CryptoCompare test failed: {e}")
|
||||
# Non fail il test se c'è un errore di rete o API
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY')),
|
||||
reason="Coinbase credentials not configured"
|
||||
)
|
||||
def test_coinbase_wrapper(self):
|
||||
try:
|
||||
api_key = os.getenv('CDP_API_KEY_NAME')
|
||||
api_secret = os.getenv('CDP_API_PRIVATE_KEY')
|
||||
wrapper = CoinBaseWrapper(
|
||||
api_key=api_key,
|
||||
api_private_key=api_secret,
|
||||
currency="USD"
|
||||
)
|
||||
|
||||
btc_product = wrapper.get_product("BTC")
|
||||
assert btc_product is not None
|
||||
assert btc_product.symbol == "BTC"
|
||||
assert btc_product.price > 0
|
||||
|
||||
products = wrapper.get_products(["BTC", "ETH"])
|
||||
assert isinstance(products, list)
|
||||
assert len(products) > 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Coinbase test failed: {e}")
|
||||
# Non fail il test se c'è un errore di credenziali o rete
|
||||
|
||||
def test_provider_selection_mechanism(self):
|
||||
potential_providers = 0
|
||||
if os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY'):
|
||||
potential_providers += 1
|
||||
if os.getenv('CRYPTOCOMPARE_API_KEY'):
|
||||
potential_providers += 1
|
||||
|
||||
if potential_providers == 0:
|
||||
with pytest.raises(AssertionError, match="No valid API keys"):
|
||||
MarketAPIs.get_list_available_market_apis()
|
||||
else:
|
||||
wrapper = MarketAPIs("USD")
|
||||
assert wrapper is not None
|
||||
assert hasattr(wrapper, 'get_product')
|
||||
|
||||
def test_error_handling(self, market_wrapper):
|
||||
try:
|
||||
fake_product = market_wrapper.get_product("NONEXISTENT_CRYPTO_SYMBOL_12345")
|
||||
assert fake_product is None or fake_product.price == 0
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
try:
|
||||
empty_products = market_wrapper.get_products([])
|
||||
assert isinstance(empty_products, list)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def test_wrapper_currency_support(self, market_wrapper):
|
||||
assert hasattr(market_wrapper, 'currency')
|
||||
assert isinstance(market_wrapper.currency, str)
|
||||
assert len(market_wrapper.currency) >= 3 # USD, EUR, etc.
|
||||
@@ -1,10 +1,10 @@
|
||||
import pytest
|
||||
from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle
|
||||
from app.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle
|
||||
from app.markets.base import ProductInfo
|
||||
from app.models import AppModels
|
||||
|
||||
def unified_checks(model: AppModels, input):
|
||||
llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput)
|
||||
llm = model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type]
|
||||
result = llm.run(input)
|
||||
content = result.content
|
||||
|
||||
@@ -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
53
tests/api/test_binance.py
Normal 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
|
||||
55
tests/api/test_coinbase.py
Normal file
55
tests/api/test_coinbase.py
Normal 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
|
||||
57
tests/api/test_cryptocompare.py
Normal file
57
tests/api/test_cryptocompare.py
Normal 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
|
||||
38
tests/api/test_cryptopanic_api.py
Normal file
38
tests/api/test_cryptopanic_api.py
Normal 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 != ""
|
||||
|
||||
34
tests/api/test_duckduckgo_news.py
Normal file
34
tests/api/test_duckduckgo_news.py
Normal 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 != ""
|
||||
|
||||
34
tests/api/test_google_news.py
Normal file
34
tests/api/test_google_news.py
Normal 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 != ""
|
||||
|
||||
37
tests/api/test_news_api.py
Normal file
37
tests/api/test_news_api.py
Normal 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
25
tests/api/test_reddit.py
Normal 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 != ""
|
||||
56
tests/api/test_yfinance.py
Normal file
56
tests/api/test_yfinance.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
41
tests/tools/test_market_tool.py
Normal file
41
tests/tools/test_market_tool.py
Normal 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
|
||||
49
tests/tools/test_news_tool.py
Normal file
49
tests/tools/test_news_tool.py
Normal 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
|
||||
30
tests/tools/test_socials_tool.py
Normal file
30
tests/tools/test_socials_tool.py
Normal 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
|
||||
120
tests/utils/test_market_aggregator.py
Normal file
120
tests/utils/test_market_aggregator.py
Normal 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)
|
||||
152
tests/utils/test_wrapper_handler.py
Normal file
152
tests/utils/test_wrapper_handler.py
Normal 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)
|
||||
Reference in New Issue
Block a user