3 market api (#8)

* Creazione branch tool, refactor degli import e soppressione dei warning

* Update pytest configuration and dependencies in pyproject.toml

* Add news API integration and related configurations

- Update .env.example to include NEWS_API_KEY configuration
- Add newsapi-python dependency in pyproject.toml
- Implement NewsAPI class for fetching news articles
- Create Article model for structured news data
- Add tests for NewsAPI functionality in test_news_api.py
- Update pytest configuration to include news marker

* Add news API functionality and update tests for article retrieval

* ToDo:
1. Aggiungere un aggregator per i dati recuperati dai provider.
2. Lavorare effettivamente all'issue

Done:
1. creati test per i provider
2. creato market_providers_api_demo.py per mostrare i dati recuperati dalle api dei providers
3. aggiornato i provider
4. creato il provider binance sia pubblico che con chiave
5. creato error_handler.py per gestire decoratori e utilità: retry automatico, gestione timeout...

* Refactor news API integration to use NewsApiWrapper and GnewsWrapper; add tests for Gnews API functionality

* Add CryptoPanic API integration and related tests; update .env.example and test configurations

* Implement WrapperHandler for managing multiple news API wrappers; add tests for wrapper functionality

* Enhance WrapperHandler
- docstrings
- add try_call_all method
- update tests

* pre merge con phil

* Add DuckDuckGo and Google News wrappers; refactor CryptoPanic and NewsAPI

- Implemented DuckDuckGoWrapper for news retrieval using DuckDuckGo tools.
- Added GoogleNewsWrapper for accessing Google News RSS feed.
- Refactored CryptoPanicWrapper to unify get_top_headlines and get_latest_news methods.
- Updated NewsApiWrapper to simplify top headlines retrieval.
- Added tests for DuckDuckGo and Google News wrappers.
- Enhanced documentation for CryptoPanicWrapper and NewsApiWrapper.
- Created base module for social media integrations.

* - Refactor struttura progetto: divisione tra agent e toolkit

* Refactor try_call_all method to return a dictionary of results; update tests for success and partial failures

* Fix class and test method names for DuckDuckGoWrapper

* Add Reddit API wrapper and related tests; update environment configuration

* pre merge con giacomo

* Fix import statements

* Fixes
- separated tests
- fix tests
- fix bugs reintroduced my previous merge

* Refactor market API wrappers to streamline product and price retrieval methods

* Add BinanceWrapper to market API exports

* Finito ISSUE 3

* Final review
- rm PublicBinanceAgent & updated demo
- moved in the correct folder some tests
- fix binance bug

---------

Co-authored-by: trojanhorse47 <cosmomemory@hotmail.it>
Co-authored-by: Berack96 <giacomobertolazzi7@gmail.com>
Co-authored-by: Giacomo Bertolazzi <31776951+Berack96@users.noreply.github.com>
This commit was merged in pull request #8.
This commit is contained in:
Simo
2025-10-01 15:51:25 +02:00
committed by GitHub
parent 4615ebe63e
commit dc9dc98298
50 changed files with 2673 additions and 671 deletions

View File

@@ -1,4 +1,4 @@
###########################################################################
###############################################################################
# Configurazioni per i modelli di linguaggio
###############################################################################
@@ -10,15 +10,32 @@ GOOGLE_API_KEY=
# Configurazioni per gli agenti di mercato
###############################################################################
# Coinbase CDP API per Market Agent
# Ottenibili da: https://portal.cdp.coinbase.com/access/api
CDP_API_KEY_NAME=
CDP_API_PRIVATE_KEY=
# https://portal.cdp.coinbase.com/access/api
COINBASE_API_KEY=
COINBASE_API_SECRET=
# CryptoCompare API per Market Agent (alternativa)
# Ottenibile da: https://www.cryptocompare.com/cryptopian/api-keys
# https://www.cryptocompare.com/cryptopian/api-keys
CRYPTOCOMPARE_API_KEY=
# Binance API per Market Agent (alternativa)
# https://www.binance.com/en/my/settings/api-management
# Non necessario per operazioni in sola lettura
BINANCE_API_KEY=
BINANCE_API_SECRET=
###############################################################################
# Configurazioni per gli agenti di notizie
###############################################################################
# https://newsapi.org/docs
NEWS_API_KEY=
# https://cryptopanic.com/developers/api/
CRYPTOPANIC_API_KEY=
###############################################################################
# Configurazioni per API di social media
###############################################################################
# https://www.reddit.com/prefs/apps
REDDIT_API_CLIENT_ID=
REDDIT_API_CLIENT_SECRET=

View File

@@ -1,116 +0,0 @@
#!/usr/bin/env python3
"""
Demo script per testare il MarketAgent aggiornato con Coinbase CDP
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from src.app.agents.market_agent import MarketAgent
import logging
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def main():
print("🚀 Test MarketAgent con Coinbase CDP")
print("=" * 50)
# Inizializza l'agent
agent = MarketAgent()
# Verifica provider disponibili
providers = agent.get_available_providers()
print(f"📡 Provider disponibili: {providers}")
if not providers:
print("⚠️ Nessun provider configurato. Verifica il file .env")
print("\nPer Coinbase CDP, serve:")
print("CDP_API_KEY_NAME=your_key_name")
print("CDP_API_PRIVATE_KEY=your_private_key")
print("\nPer CryptoCompare, serve:")
print("CRYPTOCOMPARE_API_KEY=your_api_key")
return
# Mostra capabilities di ogni provider
for provider in providers:
capabilities = agent.get_provider_capabilities(provider)
print(f"🔧 {provider.upper()}: {capabilities}")
print("\n" + "=" * 50)
# Test ottenimento prezzo singolo
test_symbols = ["BTC", "ETH", "ADA"]
for symbol in test_symbols:
print(f"\n💰 Prezzo {symbol}:")
# Prova ogni provider
for provider in providers:
try:
price = agent.get_asset_price(symbol, provider)
if price:
print(f" {provider}: ${price:,.2f}")
else:
print(f" {provider}: N/A")
except Exception as e:
print(f" {provider}: Errore - {e}")
print("\n" + "=" * 50)
# Test market overview
print("\n📊 Market Overview:")
try:
overview = agent.get_market_overview(["BTC", "ETH", "ADA", "DOT"])
if overview["data"]:
print(f"📡 Fonte: {overview['source']}")
for crypto, prices in overview["data"].items():
if isinstance(prices, dict):
usd_price = prices.get("USD", "N/A")
eur_price = prices.get("EUR", "N/A")
if eur_price != "N/A":
print(f" {crypto}: ${usd_price} (€{eur_price})")
else:
print(f" {crypto}: ${usd_price}")
else:
print("⚠️ Nessun dato disponibile")
except Exception as e:
print(f"❌ Errore nel market overview: {e}")
print("\n" + "=" * 50)
# Test funzione analyze
print("\n🔍 Analisi mercato:")
try:
analysis = agent.analyze("Market overview")
print(analysis)
except Exception as e:
print(f"❌ Errore nell'analisi: {e}")
# Test specifico Coinbase CDP se disponibile
if 'coinbase' in providers:
print("\n" + "=" * 50)
print("\n🏦 Test specifico Coinbase CDP:")
try:
# Test asset singolo
btc_info = agent.get_coinbase_asset_info("BTC")
print(f"📈 BTC Info: {btc_info}")
# Test asset multipli
multi_assets = agent.get_coinbase_multiple_assets(["BTC", "ETH"])
print(f"📊 Multi Assets: {multi_assets}")
except Exception as e:
print(f"❌ Errore nel test Coinbase CDP: {e}")
if __name__ == "__main__":
main()

View File

@@ -1,100 +0,0 @@
#!/usr/bin/env python3
"""
Esempio di utilizzo del MarketAgent unificato.
Questo script mostra come utilizzare il nuovo MarketAgent che supporta
multiple fonti di dati (Coinbase e CryptoCompare).
"""
import sys
from pathlib import Path
# Aggiungi il path src al PYTHONPATH
src_path = Path(__file__).parent / "src"
sys.path.insert(0, str(src_path))
from dotenv import load_dotenv
from app.agents.market_agent import MarketAgent
# Carica variabili d'ambiente
load_dotenv()
def main():
print("🚀 Market Agent Demo\n")
try:
# Inizializza il market agent (auto-configura i provider disponibili)
agent = MarketAgent()
# Mostra provider disponibili
providers = agent.get_available_providers()
print(f"📡 Available providers: {providers}")
if not providers:
print("❌ No providers configured. Please check your .env file.")
print("Required variables:")
print(" For Coinbase: COINBASE_API_KEY, COINBASE_SECRET, COINBASE_PASSPHRASE")
print(" For CryptoCompare: CRYPTOCOMPARE_API_KEY")
return
# Mostra le capacità di ogni provider
print("\n🔧 Provider capabilities:")
for provider in providers:
capabilities = agent.get_provider_capabilities(provider)
print(f" {provider}: {capabilities}")
# Ottieni panoramica del mercato
print("\n📊 Market Overview:")
overview = agent.get_market_overview(["BTC", "ETH", "ADA"])
print(f"Data source: {overview.get('source', 'Unknown')}")
for crypto, prices in overview.get('data', {}).items():
if isinstance(prices, dict):
usd = prices.get('USD', 'N/A')
eur = prices.get('EUR', 'N/A')
if eur != 'N/A':
print(f" {crypto}: ${usd} (€{eur})")
else:
print(f" {crypto}: ${usd}")
# Analisi completa del mercato
print("\n📈 Market Analysis:")
analysis = agent.analyze("comprehensive market analysis")
print(analysis)
# Test specifici per provider (se disponibili)
if 'cryptocompare' in providers:
print("\n🔸 CryptoCompare specific test:")
try:
btc_price = agent.get_single_crypto_price("BTC", "USD")
print(f" BTC price: ${btc_price}")
top_coins = agent.get_top_cryptocurrencies(5)
if top_coins.get('Data'):
print(" Top 5 cryptocurrencies by market cap:")
for coin in top_coins['Data'][:3]: # Show top 3
coin_info = coin.get('CoinInfo', {})
display = coin.get('DISPLAY', {}).get('USD', {})
name = coin_info.get('FullName', 'Unknown')
price = display.get('PRICE', 'N/A')
print(f" {name}: {price}")
except Exception as e:
print(f" CryptoCompare test failed: {e}")
if 'coinbase' in providers:
print("\n🔸 Coinbase specific test:")
try:
ticker = agent.get_coinbase_ticker("BTC-USD")
price = ticker.get('price', 'N/A')
volume = ticker.get('volume_24h', 'N/A')
print(f" BTC-USD: ${price} (24h volume: {volume})")
except Exception as e:
print(f" Coinbase test failed: {e}")
print("\n✅ Demo completed successfully!")
except Exception as e:
print(f"❌ Demo failed: {e}")
print("Make sure you have configured at least one provider in your .env file.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""
Demo Completo per Market Data Providers
========================================
Questo script dimostra l'utilizzo di tutti i wrapper che implementano BaseWrapper:
- CoinBaseWrapper (richiede credenziali)
- CryptoCompareWrapper (richiede API key)
- BinanceWrapper (richiede credenziali)
- PublicBinanceAgent (accesso pubblico)
Lo script effettua chiamate GET a diversi provider e visualizza i dati
in modo strutturato con informazioni dettagliate su timestamp, stato
delle richieste e formattazione tabellare.
"""
import sys
import os
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Any
import traceback
# Aggiungi il path src al PYTHONPATH
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
from dotenv import load_dotenv
from app.markets import (
CoinBaseWrapper,
CryptoCompareWrapper,
BinanceWrapper,
BaseWrapper
)
# Carica variabili d'ambiente
load_dotenv()
class DemoFormatter:
"""Classe per formattare l'output del demo in modo strutturato."""
@staticmethod
def print_header(title: str, char: str = "=", width: int = 80):
"""Stampa un'intestazione formattata."""
print(f"\n{char * width}")
print(f"{title:^{width}}")
print(f"{char * width}")
@staticmethod
def print_subheader(title: str, char: str = "-", width: int = 60):
"""Stampa una sotto-intestazione formattata."""
print(f"\n{char * width}")
print(f" {title}")
print(f"{char * width}")
@staticmethod
def print_request_info(provider_name: str, method: str, timestamp: datetime,
status: str, error: Optional[str] = None):
"""Stampa informazioni sulla richiesta."""
print(f"🕒 Timestamp: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"🏷️ Provider: {provider_name}")
print(f"🔧 Method: {method}")
print(f"📊 Status: {status}")
if error:
print(f"❌ Error: {error}")
print()
@staticmethod
def print_product_table(products: List[Any], title: str = "Products"):
"""Stampa una tabella di prodotti."""
if not products:
print(f"📋 {title}: Nessun prodotto trovato")
return
print(f"📋 {title} ({len(products)} items):")
print(f"{'Symbol':<15} {'ID':<20} {'Price':<12} {'Quote':<10} {'Status':<10}")
print("-" * 67)
for product in products[:10]: # Mostra solo i primi 10
symbol = getattr(product, 'symbol', 'N/A')
product_id = getattr(product, 'id', 'N/A')
price = getattr(product, 'price', 0.0)
quote = getattr(product, 'quote_currency', 'N/A')
status = getattr(product, 'status', 'N/A')
# Tronca l'ID se troppo lungo
if len(product_id) > 18:
product_id = product_id[:15] + "..."
price_str = f"${price:.2f}" if price > 0 else "N/A"
print(f"{symbol:<15} {product_id:<20} {price_str:<12} {quote:<10} {status:<10}")
if len(products) > 10:
print(f"... e altri {len(products) - 10} prodotti")
print()
@staticmethod
def print_prices_table(prices: List[Any], title: str = "Historical Prices"):
"""Stampa una tabella di prezzi storici."""
if not prices:
print(f"💰 {title}: Nessun prezzo trovato")
return
print(f"💰 {title} ({len(prices)} entries):")
print(f"{'Time':<12} {'Open':<12} {'High':<12} {'Low':<12} {'Close':<12} {'Volume':<15}")
print("-" * 75)
for price in prices[:5]: # Mostra solo i primi 5
time_str = getattr(price, 'time', 'N/A')
# Il time è già una stringa, non serve strftime
if len(time_str) > 10:
time_str = time_str[:10] # Tronca se troppo lungo
open_price = f"${getattr(price, 'open', 0):.2f}"
high_price = f"${getattr(price, 'high', 0):.2f}"
low_price = f"${getattr(price, 'low', 0):.2f}"
close_price = f"${getattr(price, 'close', 0):.2f}"
volume = f"{getattr(price, 'volume', 0):,.0f}"
print(f"{time_str:<12} {open_price:<12} {high_price:<12} {low_price:<12} {close_price:<12} {volume:<15}")
if len(prices) > 5:
print(f"... e altri {len(prices) - 5} prezzi")
print()
class ProviderTester:
"""Classe per testare i provider di market data."""
def __init__(self):
self.formatter = DemoFormatter()
self.test_symbols = ["BTC", "ETH", "ADA"]
def test_provider(self, wrapper: BaseWrapper, provider_name: str) -> Dict[str, Any]:
"""Testa un provider specifico con tutti i metodi disponibili."""
results = {
"provider_name": provider_name,
"tests": {},
"overall_status": "SUCCESS"
}
self.formatter.print_subheader(f"🔍 Testing {provider_name}")
# Test get_product
for symbol in self.test_symbols:
timestamp = datetime.now()
try:
product = wrapper.get_product(symbol)
self.formatter.print_request_info(
provider_name, f"get_product({symbol})", timestamp, "✅ SUCCESS"
)
if product:
print(f"📦 Product: {product.symbol} (ID: {product.id})")
print(f" Price: ${product.price:.2f}, Quote: {product.quote_currency}")
print(f" Status: {product.status}, Volume 24h: {product.volume_24h:,.2f}")
else:
print(f"📦 Product: Nessun prodotto trovato per {symbol}")
results["tests"][f"get_product_{symbol}"] = "SUCCESS"
except Exception as e:
error_msg = str(e)
self.formatter.print_request_info(
provider_name, f"get_product({symbol})", timestamp, "❌ ERROR", error_msg
)
results["tests"][f"get_product_{symbol}"] = f"ERROR: {error_msg}"
results["overall_status"] = "PARTIAL"
# Test get_products
timestamp = datetime.now()
try:
products = wrapper.get_products(self.test_symbols)
self.formatter.print_request_info(
provider_name, f"get_products({self.test_symbols})", timestamp, "✅ SUCCESS"
)
self.formatter.print_product_table(products, f"{provider_name} Products")
results["tests"]["get_products"] = "SUCCESS"
except Exception as e:
error_msg = str(e)
self.formatter.print_request_info(
provider_name, f"get_products({self.test_symbols})", timestamp, "❌ ERROR", error_msg
)
results["tests"]["get_products"] = f"ERROR: {error_msg}"
results["overall_status"] = "PARTIAL"
# Test get_all_products
timestamp = datetime.now()
try:
all_products = wrapper.get_all_products()
self.formatter.print_request_info(
provider_name, "get_all_products()", timestamp, "✅ SUCCESS"
)
self.formatter.print_product_table(all_products, f"{provider_name} All Products")
results["tests"]["get_all_products"] = "SUCCESS"
except Exception as e:
error_msg = str(e)
self.formatter.print_request_info(
provider_name, "get_all_products()", timestamp, "❌ ERROR", error_msg
)
results["tests"]["get_all_products"] = f"ERROR: {error_msg}"
results["overall_status"] = "PARTIAL"
# Test get_historical_prices
timestamp = datetime.now()
try:
prices = wrapper.get_historical_prices("BTC")
self.formatter.print_request_info(
provider_name, "get_historical_prices(BTC)", timestamp, "✅ SUCCESS"
)
self.formatter.print_prices_table(prices, f"{provider_name} BTC Historical Prices")
results["tests"]["get_historical_prices"] = "SUCCESS"
except Exception as e:
error_msg = str(e)
self.formatter.print_request_info(
provider_name, "get_historical_prices(BTC)", timestamp, "❌ ERROR", error_msg
)
results["tests"]["get_historical_prices"] = f"ERROR: {error_msg}"
results["overall_status"] = "PARTIAL"
return results
def check_environment_variables() -> Dict[str, bool]:
"""Verifica la presenza delle variabili d'ambiente necessarie."""
env_vars = {
"COINBASE_API_KEY": bool(os.getenv("COINBASE_API_KEY")),
"COINBASE_API_SECRET": bool(os.getenv("COINBASE_API_SECRET")),
"CRYPTOCOMPARE_API_KEY": bool(os.getenv("CRYPTOCOMPARE_API_KEY")),
"BINANCE_API_KEY": bool(os.getenv("BINANCE_API_KEY")),
"BINANCE_API_SECRET": bool(os.getenv("BINANCE_API_SECRET")),
}
return env_vars
def initialize_providers() -> Dict[str, BaseWrapper]:
"""Inizializza tutti i provider disponibili."""
providers = {}
env_vars = check_environment_variables()
# CryptoCompareWrapper
if env_vars["CRYPTOCOMPARE_API_KEY"]:
try:
providers["CryptoCompare"] = CryptoCompareWrapper()
print("✅ CryptoCompareWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di CryptoCompareWrapper: {e}")
else:
print("⚠️ CryptoCompareWrapper saltato: CRYPTOCOMPARE_API_KEY non trovata")
# CoinBaseWrapper
if env_vars["COINBASE_API_KEY"] and env_vars["COINBASE_API_SECRET"]:
try:
providers["CoinBase"] = CoinBaseWrapper()
print("✅ CoinBaseWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di CoinBaseWrapper: {e}")
else:
print("⚠️ CoinBaseWrapper saltato: credenziali Coinbase non complete")
# BinanceWrapper
try:
providers["Binance"] = BinanceWrapper()
print("✅ BinanceWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}")
return providers
def print_summary(results: List[Dict[str, Any]]):
"""Stampa un riassunto finale dei risultati."""
formatter = DemoFormatter()
formatter.print_header("📊 RIASSUNTO FINALE", "=", 80)
total_providers = len(results)
successful_providers = sum(1 for r in results if r["overall_status"] == "SUCCESS")
partial_providers = sum(1 for r in results if r["overall_status"] == "PARTIAL")
print(f"🔢 Provider testati: {total_providers}")
print(f"✅ Provider completamente funzionanti: {successful_providers}")
print(f"⚠️ Provider parzialmente funzionanti: {partial_providers}")
print(f"❌ Provider non funzionanti: {total_providers - successful_providers - partial_providers}")
print("\n📋 Dettaglio per provider:")
for result in results:
provider_name = result["provider_name"]
status = result["overall_status"]
status_icon = "" if status == "SUCCESS" else "⚠️" if status == "PARTIAL" else ""
print(f"\n{status_icon} {provider_name}:")
for test_name, test_result in result["tests"].items():
test_icon = "" if test_result == "SUCCESS" else ""
print(f" {test_icon} {test_name}: {test_result}")
def main():
"""Funzione principale del demo."""
formatter = DemoFormatter()
# Intestazione principale
formatter.print_header("🚀 DEMO COMPLETO MARKET DATA PROVIDERS", "=", 80)
print(f"🕒 Avvio demo: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("📝 Questo demo testa tutti i wrapper BaseWrapper disponibili")
print("🔍 Ogni test include timestamp, stato della richiesta e dati formattati")
# Verifica variabili d'ambiente
formatter.print_subheader("🔐 Verifica Configurazione")
env_vars = check_environment_variables()
print("Variabili d'ambiente:")
for var_name, is_present in env_vars.items():
status = "✅ Presente" if is_present else "❌ Mancante"
print(f" {var_name}: {status}")
# Inizializza provider
formatter.print_subheader("🏗️ Inizializzazione Provider")
providers = initialize_providers()
if not providers:
print("❌ Nessun provider disponibile. Verifica la configurazione.")
return
print(f"\n🎯 Provider disponibili per il test: {list(providers.keys())}")
# Testa ogni provider
formatter.print_header("🧪 ESECUZIONE TEST PROVIDER", "=", 80)
tester = ProviderTester()
all_results = []
for provider_name, wrapper in providers.items():
try:
result = tester.test_provider(wrapper, provider_name)
all_results.append(result)
except Exception as e:
print(f"❌ Errore critico nel test di {provider_name}: {e}")
traceback.print_exc()
all_results.append({
"provider_name": provider_name,
"tests": {},
"overall_status": "CRITICAL_ERROR",
"error": str(e)
})
# Stampa riassunto finale
print_summary(all_results)
# Informazioni aggiuntive
formatter.print_header(" INFORMAZIONI AGGIUNTIVE", "=", 80)
print("📚 Documentazione:")
print(" - BaseWrapper: src/app/markets/base.py")
print(" - Test completi: tests/agents/test_market.py")
print(" - Configurazione: .env")
print("\n🔧 Per abilitare tutti i provider:")
print(" 1. Configura le credenziali nel file .env")
print(" 2. Segui la documentazione di ogni provider")
print(" 3. Riavvia il demo")
print(f"\n🏁 Demo completato: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if __name__ == "__main__":
main()

16
demos/news_api.py Normal file
View File

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

View File

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

0
src/__init__.py Normal file
View File

View File

@@ -1,7 +1,7 @@
import gradio as gr
from dotenv import load_dotenv
from app.tool import ToolAgent
from app.pipeline import Pipeline
from agno.utils.log import log_info
########################################
@@ -16,31 +16,31 @@ if __name__ == "__main__":
load_dotenv()
######################################
tool_agent = ToolAgent()
pipeline = Pipeline()
with gr.Blocks() as demo:
gr.Markdown("# 🤖 Agente di Analisi e Consulenza Crypto")
with gr.Row():
provider = gr.Dropdown(
choices=tool_agent.list_providers(),
choices=pipeline.list_providers(),
type="index",
label="Modello da usare"
)
provider.change(fn=tool_agent.choose_provider, inputs=provider, outputs=None)
provider.change(fn=pipeline.choose_provider, inputs=provider, outputs=None)
style = gr.Dropdown(
choices=tool_agent.list_styles(),
choices=pipeline.list_styles(),
type="index",
label="Stile di investimento"
)
style.change(fn=tool_agent.choose_style, inputs=style, outputs=None)
style.change(fn=pipeline.choose_style, inputs=style, outputs=None)
user_input = gr.Textbox(label="Richiesta utente")
output = gr.Textbox(label="Risultato analisi", lines=12)
analyze_btn = gr.Button("🔎 Analizza")
analyze_btn.click(fn=tool_agent.interact, inputs=[user_input], outputs=output)
analyze_btn.click(fn=pipeline.interact, inputs=[user_input], outputs=output)
server, port = ("0.0.0.0", 8000)
log_info(f"Starting UPO AppAI on http://{server}:{port}")

View File

@@ -0,0 +1,90 @@
from typing import Union, List, Dict, Optional, Any, Iterator, Sequence
from agno.agent import Agent
from agno.models.message import Message
from agno.run.agent import RunOutput, RunOutputEvent
from pydantic import BaseModel
from app.toolkits.market_toolkit import MarketToolkit
from app.markets.base import ProductInfo # modello dati già definito nel tuo progetto
class MarketAgent(Agent):
"""
Wrapper che trasforma MarketToolkit in un Agent compatibile con Team.
Produce sia output leggibile (content) che dati strutturati (metadata).
"""
def __init__(self, currency: str = "USD"):
super().__init__()
self.toolkit = MarketToolkit()
self.currency = currency
self.name = "MarketAgent"
def run(
self,
input: Union[str, List, Dict, Message, BaseModel, List[Message]],
*,
stream: Optional[bool] = None,
stream_intermediate_steps: Optional[bool] = None,
user_id: Optional[str] = None,
session_id: Optional[str] = None,
session_state: Optional[Dict[str, Any]] = None,
audio: Optional[Sequence[Any]] = None,
images: Optional[Sequence[Any]] = None,
videos: Optional[Sequence[Any]] = None,
files: Optional[Sequence[Any]] = None,
retries: Optional[int] = None,
knowledge_filters: Optional[Dict[str, Any]] = None,
add_history_to_context: Optional[bool] = None,
add_dependencies_to_context: Optional[bool] = None,
add_session_state_to_context: Optional[bool] = None,
dependencies: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
yield_run_response: bool = False,
debug_mode: Optional[bool] = None,
**kwargs: Any,
) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]:
# 1. Estraggo la query dal parametro "input"
if isinstance(input, str):
query = input
elif isinstance(input, dict) and "query" in input:
query = input["query"]
elif isinstance(input, Message):
query = input.content
elif isinstance(input, BaseModel):
query = str(input)
elif isinstance(input, list) and input and isinstance(input[0], Message):
query = input[0].content
else:
query = str(input)
# 2. Individuo i simboli da analizzare
symbols = []
for token in query.upper().split():
if token in ("BTC", "ETH", "XRP", "LTC", "BCH"): # TODO: estendere dinamicamente
symbols.append(token)
if not symbols:
symbols = ["BTC", "ETH"] # default
# 3. Recupero i dati dal toolkit
results = []
products: List[ProductInfo] = []
try:
products.extend(self.toolkit.get_current_prices(symbols)) # supponiamo ritorni un ProductInfo o simile
# Usa list comprehension per iterare symbols e products insieme
results.extend([
f"{symbol}: ${product.price:.2f}" if hasattr(product, 'price') and product.price else f"{symbol}: N/A"
for symbol, product in zip(symbols, products)
])
except Exception as e:
results.extend(f"Errore: Impossibile recuperare i dati di mercato\n{str(e)}")
# 4. Preparo output leggibile + metadati strutturati
output_text = "📊 Dati di mercato:\n" + "\n".join(results)
return RunOutput(
content=output_text,
metadata={"products": products}
)

View File

@@ -1,4 +1,34 @@
class NewsAgent:
from agno.agent import Agent
class NewsAgent(Agent):
"""
Gli agenti devono esporre un metodo run con questa firma.
def run(
self,
input: Union[str, List, Dict, Message, BaseModel, List[Message]],
*,
stream: Optional[bool] = None,
stream_intermediate_steps: Optional[bool] = None,
user_id: Optional[str] = None,
session_id: Optional[str] = None,
session_state: Optional[Dict[str, Any]] = None,
audio: Optional[Sequence[Any]] = None,
images: Optional[Sequence[Any]] = None,
videos: Optional[Sequence[Any]] = None,
files: Optional[Sequence[Any]] = None,
retries: Optional[int] = None,
knowledge_filters: Optional[Dict[str, Any]] = None,
add_history_to_context: Optional[bool] = None,
add_dependencies_to_context: Optional[bool] = None,
add_session_state_to_context: Optional[bool] = None,
dependencies: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
yield_run_response: bool = False,
debug_mode: Optional[bool] = None,
**kwargs: Any,
) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]:
"""
@staticmethod
def analyze(query: str) -> str:
# Mock analisi news

View File

@@ -1,4 +1,35 @@
class SocialAgent:
from agno.agent import Agent
class SocialAgent(Agent):
"""
Gli agenti devono esporre un metodo run con questa firma.
def run(
self,
input: Union[str, List, Dict, Message, BaseModel, List[Message]],
*,
stream: Optional[bool] = None,
stream_intermediate_steps: Optional[bool] = None,
user_id: Optional[str] = None,
session_id: Optional[str] = None,
session_state: Optional[Dict[str, Any]] = None,
audio: Optional[Sequence[Any]] = None,
images: Optional[Sequence[Any]] = None,
videos: Optional[Sequence[Any]] = None,
files: Optional[Sequence[Any]] = None,
retries: Optional[int] = None,
knowledge_filters: Optional[Dict[str, Any]] = None,
add_history_to_context: Optional[bool] = None,
add_dependencies_to_context: Optional[bool] = None,
add_session_state_to_context: Optional[bool] = None,
dependencies: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
yield_run_response: bool = False,
debug_mode: Optional[bool] = None,
**kwargs: Any,
) -> Union[RunOutput, Iterator[Union[RunOutputEvent, RunOutput]]]:
"""
@staticmethod
def analyze(query: str) -> str:
# Mock analisi social

View File

@@ -1,57 +1,96 @@
from app.markets.base import BaseWrapper
from app.markets.coinbase import CoinBaseWrapper
from app.markets.cryptocompare import CryptoCompareWrapper
from .base import BaseWrapper, ProductInfo, Price
from .coinbase import CoinBaseWrapper
from .binance import BinanceWrapper
from .cryptocompare import CryptoCompareWrapper
from .binance_public import PublicBinanceAgent
from app.utils.wrapper_handler import WrapperHandler
from typing import List, Optional
from agno.tools import Toolkit
from agno.utils.log import log_warning
class MarketAPIs(BaseWrapper):
__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "PublicBinanceAgent" ]
class MarketAPIsTool(BaseWrapper, Toolkit):
"""
Classe per gestire le API di mercato disponibili.
Permette di ottenere un'istanza della prima API disponibile in base alla priorità specificata.
Supporta due modalità:
1. **Modalità standard** (default): usa il primo wrapper disponibile
2. **Modalità aggregazione**: aggrega dati da tutte le fonti disponibili
L'aggregazione può essere abilitata/disabilitata dinamicamente.
"""
@staticmethod
def get_list_available_market_apis(currency: str = "USD") -> list[BaseWrapper]:
"""
Restituisce una lista di istanze delle API di mercato disponibili.
La priorità è data dall'ordine delle API nella lista wrappers.
1. CoinBase
2. CryptoCompare
:param currency: Valuta di riferimento (default "USD")
:return: Lista di istanze delle API di mercato disponibili
"""
wrapper_builders = [
CoinBaseWrapper,
CryptoCompareWrapper,
]
result = []
for wrapper in wrapper_builders:
try:
result.append(wrapper(currency=currency))
except Exception as _:
log_warning(f"{wrapper} cannot be initialized, maybe missing API key?")
assert result, "No market API keys set in environment variables."
return result
def __init__(self, currency: str = "USD"):
"""
Inizializza la classe con la valuta di riferimento e la priorità dei provider.
:param currency: Valuta di riferimento (default "USD")
"""
def __init__(self, currency: str = "USD", enable_aggregation: bool = False):
self.currency = currency
self.wrappers = MarketAPIs.get_list_available_market_apis(currency=currency)
wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ]
self.wrappers: WrapperHandler[BaseWrapper] = WrapperHandler.build_wrappers(wrappers)
# Inizializza l'aggregatore solo se richiesto (lazy initialization)
self._aggregator = None
self._aggregation_enabled = enable_aggregation
Toolkit.__init__(
self,
name="Market APIs Toolkit",
tools=[
self.get_product,
self.get_products,
self.get_all_products,
self.get_historical_prices,
],
)
def _get_aggregator(self):
"""Lazy initialization dell'aggregatore"""
if self._aggregator is None:
from app.utils.market_data_aggregator import MarketDataAggregator
self._aggregator = MarketDataAggregator(self.currency)
self._aggregator.enable_aggregation(self._aggregation_enabled)
return self._aggregator
# Metodi che semplicemente chiamano il metodo corrispondente del primo wrapper disponibile
# TODO magari fare in modo che se il primo fallisce, prova con il secondo, ecc.
# oppure fare un round-robin tra i vari wrapper oppure usarli tutti e fare una media dei risultati
def get_product(self, asset_id):
return self.wrappers[0].get_product(asset_id)
def get_products(self, asset_ids: list):
return self.wrappers[0].get_products(asset_ids)
def get_all_products(self):
return self.wrappers[0].get_all_products()
def get_historical_prices(self, asset_id = "BTC"):
return self.wrappers[0].get_historical_prices(asset_id)
def get_product(self, asset_id: str) -> Optional[ProductInfo]:
"""Ottieni informazioni su un prodotto specifico"""
if self._aggregation_enabled:
return self._get_aggregator().get_product(asset_id)
return self.wrappers.try_call(lambda w: w.get_product(asset_id))
def get_products(self, asset_ids: List[str]) -> List[ProductInfo]:
"""Ottieni informazioni su multiple prodotti"""
if self._aggregation_enabled:
return self._get_aggregator().get_products(asset_ids)
return self.wrappers.try_call(lambda w: w.get_products(asset_ids))
def get_all_products(self) -> List[ProductInfo]:
"""Ottieni tutti i prodotti disponibili"""
if self._aggregation_enabled:
return self._get_aggregator().get_all_products()
return self.wrappers.try_call(lambda w: w.get_all_products())
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> List[Price]:
"""Ottieni dati storici dei prezzi"""
if self._aggregation_enabled:
return self._get_aggregator().get_historical_prices(asset_id, limit)
return self.wrappers.try_call(lambda w: w.get_historical_prices(asset_id, limit))
# Metodi per controllare l'aggregazione
def enable_aggregation(self, enabled: bool = True):
"""Abilita/disabilita la modalità aggregazione"""
self._aggregation_enabled = enabled
if self._aggregator:
self._aggregator.enable_aggregation(enabled)
def is_aggregation_enabled(self) -> bool:
"""Verifica se l'aggregazione è abilitata"""
return self._aggregation_enabled
# Metodo speciale per debugging (opzionale)
def get_aggregated_product_with_debug(self, asset_id: str) -> dict:
"""
Metodo speciale per ottenere dati aggregati con informazioni di debug.
Disponibile solo quando l'aggregazione è abilitata.
"""
if not self._aggregation_enabled:
raise RuntimeError("L'aggregazione deve essere abilitata per usare questo metodo")
return self._get_aggregator().get_aggregated_product_with_debug(asset_id)

View File

@@ -1,18 +1,49 @@
from coinbase.rest.types.product_types import Candle, GetProductResponse
from pydantic import BaseModel
class BaseWrapper:
"""
Interfaccia per i wrapper delle API di mercato.
Implementa i metodi di base che ogni wrapper deve avere.
Base class for market API wrappers.
All market API wrappers should inherit from this class and implement the methods.
"""
def get_product(self, asset_id: str) -> 'ProductInfo':
"""
Get product information for a specific asset ID.
Args:
asset_id (str): The asset ID to retrieve information for.
Returns:
ProductInfo: An object containing product information.
"""
raise NotImplementedError
def get_products(self, asset_ids: list[str]) -> list['ProductInfo']:
"""
Get product information for multiple asset IDs.
Args:
asset_ids (list[str]): The list of asset IDs to retrieve information for.
Returns:
list[ProductInfo]: A list of objects containing product information.
"""
raise NotImplementedError
def get_all_products(self) -> list['ProductInfo']:
"""
Get product information for all available assets.
Returns:
list[ProductInfo]: A list of objects containing product information.
"""
raise NotImplementedError
def get_historical_prices(self, asset_id: str = "BTC") -> list['Price']:
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list['Price']:
"""
Get historical price data for a specific asset ID.
Args:
asset_id (str): The asset ID to retrieve price data for.
limit (int): The maximum number of price data points to return.
Returns:
list[Price]: A list of Price objects.
"""
raise NotImplementedError
class ProductInfo(BaseModel):
@@ -27,25 +58,6 @@ class ProductInfo(BaseModel):
status: str = ""
quote_currency: str = ""
def from_coinbase(product_data: GetProductResponse) -> 'ProductInfo':
product = ProductInfo()
product.id = product_data.product_id
product.symbol = product_data.base_currency_id
product.price = float(product_data.price)
product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0
# TODO Check what status means in Coinbase
product.status = product_data.status
return product
def from_cryptocompare(asset_data: dict) -> 'ProductInfo':
product = ProductInfo()
product.id = asset_data['FROMSYMBOL'] + '-' + asset_data['TOSYMBOL']
product.symbol = asset_data['FROMSYMBOL']
product.price = float(asset_data['PRICE'])
product.volume_24h = float(asset_data['VOLUME24HOUR'])
product.status = "" # Cryptocompare does not provide status
return product
class Price(BaseModel):
"""
Rappresenta i dati di prezzo per un asset, come ottenuti dalle API di mercato.
@@ -57,23 +69,3 @@ class Price(BaseModel):
close: float = 0.0
volume: float = 0.0
time: str = ""
def from_coinbase(candle_data: Candle) -> 'Price':
price = Price()
price.high = float(candle_data.high)
price.low = float(candle_data.low)
price.open = float(candle_data.open)
price.close = float(candle_data.close)
price.volume = float(candle_data.volume)
price.time = str(candle_data.start)
return price
def from_cryptocompare(price_data: dict) -> 'Price':
price = Price()
price.high = float(price_data['high'])
price.low = float(price_data['low'])
price.open = float(price_data['open'])
price.close = float(price_data['close'])
price.volume = float(price_data['volumeto'])
price.time = str(price_data['time'])
return price

View File

@@ -1,30 +1,88 @@
# Versione pubblica senza autenticazione
import os
from datetime import datetime
from binance.client import Client
from .base import ProductInfo, BaseWrapper, Price
# TODO fare l'aggancio con API in modo da poterlo usare come wrapper di mercato
# TODO implementare i metodi di BaseWrapper
def get_product(currency: str, ticker_data: dict[str, str]) -> 'ProductInfo':
product = ProductInfo()
product.id = ticker_data.get('symbol')
product.symbol = ticker_data.get('symbol', '').replace(currency, '')
product.price = float(ticker_data.get('price', 0))
product.volume_24h = float(ticker_data.get('volume', 0))
product.status = "TRADING" # Binance non fornisce status esplicito
product.quote_currency = currency
return product
class PublicBinanceAgent:
def __init__(self):
# Client pubblico (senza credenziali)
self.client = Client()
class BinanceWrapper(BaseWrapper):
"""
Wrapper per le API autenticate di Binance.\n
Implementa l'interfaccia BaseWrapper per fornire accesso unificato
ai dati di mercato di Binance tramite le API REST con autenticazione.\n
https://binance-docs.github.io/apidocs/spot/en/
"""
def get_public_prices(self):
"""Ottiene prezzi pubblici"""
try:
btc_price = self.client.get_symbol_ticker(symbol="BTCUSDT")
eth_price = self.client.get_symbol_ticker(symbol="ETHUSDT")
def __init__(self, currency: str = "USDT"):
api_key = os.getenv("BINANCE_API_KEY")
api_secret = os.getenv("BINANCE_API_SECRET")
return {
'BTC_USD': float(btc_price['price']),
'ETH_USD': float(eth_price['price']),
'source': 'binance_public'
}
except Exception as e:
print(f"Errore: {e}")
return None
self.currency = currency
self.client = Client(api_key=api_key, api_secret=api_secret)
# Uso senza credenziali
public_agent = PublicBinanceAgent()
public_prices = public_agent.get_public_prices()
print(public_prices)
def __format_symbol(self, asset_id: str) -> str:
"""
Formatta l'asset_id nel formato richiesto da Binance.
"""
return asset_id.replace('-', '') if '-' in asset_id else f"{asset_id}{self.currency}"
def get_product(self, asset_id: str) -> ProductInfo:
symbol = self.__format_symbol(asset_id)
ticker = self.client.get_symbol_ticker(symbol=symbol)
ticker_24h = self.client.get_ticker(symbol=symbol)
ticker['volume'] = ticker_24h.get('volume', 0) # Aggiunge volume 24h ai dati del ticker
return get_product(self.currency, ticker)
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
symbols = [self.__format_symbol(asset_id) for asset_id in asset_ids]
symbols_str = f"[\"{'","'.join(symbols)}\"]"
tickers = self.client.get_symbol_ticker(symbols=symbols_str)
tickers_24h = self.client.get_ticker(symbols=symbols_str) # un po brutale, ma va bene così
for t, t24 in zip(tickers, tickers_24h):
t['volume'] = t24.get('volume', 0)
return [get_product(self.currency, ticker) for ticker in tickers]
def get_all_products(self) -> list[ProductInfo]:
all_tickers = self.client.get_ticker()
products = []
for ticker in all_tickers:
# Filtra solo i simboli che terminano con la valuta di default
if ticker['symbol'].endswith(self.currency):
product = get_product(self.currency, ticker)
products.append(product)
return products
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
symbol = self.__format_symbol(asset_id)
# Ottiene candele orarie degli ultimi 30 giorni
klines = self.client.get_historical_klines(
symbol=symbol,
interval=Client.KLINE_INTERVAL_1HOUR,
limit=limit,
)
prices = []
for kline in klines:
price = Price()
price.open = float(kline[1])
price.high = float(kline[2])
price.low = float(kline[3])
price.close = float(kline[4])
price.volume = float(kline[5])
price.time = str(datetime.fromtimestamp(kline[0] / 1000))
prices.append(price)
return prices

View File

@@ -1,19 +1,57 @@
import os
from enum import Enum
from datetime import datetime, timedelta
from coinbase.rest import RESTClient
from app.markets.base import ProductInfo, BaseWrapper, Price
from coinbase.rest.types.product_types import Candle, GetProductResponse, Product
from .base import ProductInfo, BaseWrapper, Price
def get_product(product_data: GetProductResponse | Product) -> 'ProductInfo':
product = ProductInfo()
product.id = product_data.product_id or ""
product.symbol = product_data.base_currency_id or ""
product.price = float(product_data.price) if product_data.price else 0.0
product.volume_24h = float(product_data.volume_24h) if product_data.volume_24h else 0.0
# TODO Check what status means in Coinbase
product.status = product_data.status or ""
return product
def get_price(candle_data: Candle) -> 'Price':
price = Price()
price.high = float(candle_data.high) if candle_data.high else 0.0
price.low = float(candle_data.low) if candle_data.low else 0.0
price.open = float(candle_data.open) if candle_data.open else 0.0
price.close = float(candle_data.close) if candle_data.close else 0.0
price.volume = float(candle_data.volume) if candle_data.volume else 0.0
price.time = str(candle_data.start) if candle_data.start else ""
return price
class Granularity(Enum):
UNKNOWN_GRANULARITY = 0
ONE_MINUTE = 60
FIVE_MINUTE = 300
FIFTEEN_MINUTE = 900
THIRTY_MINUTE = 1800
ONE_HOUR = 3600
TWO_HOUR = 7200
FOUR_HOUR = 14400
SIX_HOUR = 21600
ONE_DAY = 86400
class CoinBaseWrapper(BaseWrapper):
"""
Wrapper per le API di Coinbase.
La documentazione delle API è disponibile qui: https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/introduction
Wrapper per le API di Coinbase Advanced Trade.\n
Implementa l'interfaccia BaseWrapper per fornire accesso unificato
ai dati di mercato di Coinbase tramite le API REST.\n
https://docs.cdp.coinbase.com/api-reference/advanced-trade-api/rest-api/introduction
"""
def __init__(self, api_key:str = None, api_private_key:str = None, currency: str = "USD"):
if api_key is None:
api_key = os.getenv("COINBASE_API_KEY")
def __init__(self, currency: str = "USD"):
api_key = os.getenv("COINBASE_API_KEY")
assert api_key is not None, "API key is required"
if api_private_key is None:
api_private_key = os.getenv("COINBASE_API_SECRET")
api_private_key = os.getenv("COINBASE_API_SECRET")
assert api_private_key is not None, "API private key is required"
self.currency = currency
@@ -28,18 +66,27 @@ class CoinBaseWrapper(BaseWrapper):
def get_product(self, asset_id: str) -> ProductInfo:
asset_id = self.__format(asset_id)
asset = self.client.get_product(asset_id)
return ProductInfo.from_coinbase(asset)
return get_product(asset)
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
all_asset_ids = [self.__format(asset_id) for asset_id in asset_ids]
assets = self.client.get_products(all_asset_ids)
return [ProductInfo.from_coinbase(asset) for asset in assets.products]
assets = self.client.get_products(product_ids=all_asset_ids)
return [get_product(asset) for asset in assets.products]
def get_all_products(self) -> list[ProductInfo]:
assets = self.client.get_products()
return [ProductInfo.from_coinbase(asset) for asset in assets.products]
return [get_product(asset) for asset in assets.products]
def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]:
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
asset_id = self.__format(asset_id)
data = self.client.get_candles(product_id=asset_id)
return [Price.from_coinbase(candle) for candle in data.candles]
end_time = datetime.now()
start_time = end_time - timedelta(days=14)
data = self.client.get_candles(
product_id=asset_id,
granularity=Granularity.ONE_HOUR.name,
start=str(int(start_time.timestamp())),
end=str(int(end_time.timestamp())),
limit=limit
)
return [get_price(candle) for candle in data.candles]

View File

@@ -1,6 +1,28 @@
import os
import requests
from app.markets.base import ProductInfo, BaseWrapper, Price
from typing import Optional, Dict, Any
from .base import ProductInfo, BaseWrapper, Price
def get_product(asset_data: dict) -> 'ProductInfo':
product = ProductInfo()
product.id = asset_data['FROMSYMBOL'] + '-' + asset_data['TOSYMBOL']
product.symbol = asset_data['FROMSYMBOL']
product.price = float(asset_data['PRICE'])
product.volume_24h = float(asset_data['VOLUME24HOUR'])
product.status = "" # Cryptocompare does not provide status
return product
def get_price(price_data: dict) -> 'Price':
price = Price()
price.high = float(price_data['high'])
price.low = float(price_data['low'])
price.open = float(price_data['open'])
price.close = float(price_data['close'])
price.volume = float(price_data['volumeto'])
price.time = str(price_data['time'])
return price
BASE_URL = "https://min-api.cryptocompare.com"
@@ -10,15 +32,14 @@ class CryptoCompareWrapper(BaseWrapper):
La documentazione delle API è disponibile qui: https://developers.coindesk.com/documentation/legacy/Price/SingleSymbolPriceEndpoint
!!ATTENZIONE!! sembra essere una API legacy e potrebbe essere deprecata in futuro.
"""
def __init__(self, api_key:str = None, currency:str='USD'):
if api_key is None:
api_key = os.getenv("CRYPTOCOMPARE_API_KEY")
def __init__(self, currency:str='USD'):
api_key = os.getenv("CRYPTOCOMPARE_API_KEY")
assert api_key is not None, "API key is required"
self.api_key = api_key
self.currency = currency
def __request(self, endpoint: str, params: dict = None) -> dict:
def __request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if params is None:
params = {}
params['api_key'] = self.api_key
@@ -32,7 +53,7 @@ class CryptoCompareWrapper(BaseWrapper):
"tsyms": self.currency
})
data = response.get('RAW', {}).get(asset_id, {}).get(self.currency, {})
return ProductInfo.from_cryptocompare(data)
return get_product(data)
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
response = self.__request("/data/pricemultifull", params = {
@@ -43,20 +64,20 @@ class CryptoCompareWrapper(BaseWrapper):
data = response.get('RAW', {})
for asset_id in asset_ids:
asset_data = data.get(asset_id, {}).get(self.currency, {})
assets.append(ProductInfo.from_cryptocompare(asset_data))
assets.append(get_product(asset_data))
return assets
def get_all_products(self) -> list[ProductInfo]:
raise NotImplementedError("CryptoCompare does not support fetching all assets")
# TODO serve davvero il workaroud qui? Possiamo prendere i dati da un altro endpoint intanto
raise NotImplementedError("get_all_products is not supported by CryptoCompare API")
def get_historical_prices(self, asset_id: str, day_back: int = 10) -> list[dict]:
assert day_back <= 30, "day_back should be less than or equal to 30"
def get_historical_prices(self, asset_id: str, limit: int = 100) -> list[dict]:
response = self.__request("/data/v2/histohour", params = {
"fsym": asset_id,
"tsym": self.currency,
"limit": day_back * 24
"limit": limit-1 # because the API returns limit+1 items (limit + current)
})
data = response.get('Data', {}).get('Data', [])
prices = [Price.from_cryptocompare(price_data) for price_data in data]
prices = [get_price(price_data) for price_data in data]
return prices

View File

@@ -1,13 +1,13 @@
import os
import requests
from enum import Enum
from pydantic import BaseModel
from agno.agent import Agent
from agno.models.base import Model
from agno.models.google import Gemini
from agno.models.ollama import Ollama
from agno.utils.log import log_warning
from pydantic import BaseModel
class AppModels(Enum):
"""
@@ -41,6 +41,7 @@ class AppModels(Enum):
availables.append(AppModels.OLLAMA_QWEN)
return availables
@staticmethod
def availables_online() -> list['AppModels']:
"""
Controlla quali provider di modelli LLM online hanno le loro API keys disponibili
@@ -49,9 +50,7 @@ class AppModels(Enum):
if not os.getenv("GOOGLE_API_KEY"):
log_warning("No GOOGLE_API_KEY set in environment variables.")
return []
availables = []
availables.append(AppModels.GEMINI)
availables.append(AppModels.GEMINI_PRO)
availables = [AppModels.GEMINI, AppModels.GEMINI_PRO]
return availables
@staticmethod
@@ -75,9 +74,13 @@ class AppModels(Enum):
def extract_json_str_from_response(response: str) -> str:
"""
Estrae il JSON dalla risposta del modello.
response: risposta del modello (stringa).
Ritorna la parte JSON della risposta come stringa.
Se non viene trovato nessun JSON, ritorna una stringa vuota.
Args:
response: risposta del modello (stringa).
Returns:
La parte JSON della risposta come stringa.
Se non viene trovato nessun JSON, ritorna una stringa vuota.
ATTENZIONE: questa funzione è molto semplice e potrebbe non funzionare
in tutti i casi. Si assume che il JSON sia ben formato e che inizi con
'{' e finisca con '}'. Quindi anche solo un json array farà fallire questa funzione.
@@ -98,9 +101,15 @@ class AppModels(Enum):
def get_model(self, instructions:str) -> Model:
"""
Restituisce un'istanza del modello specificato.
instructions: istruzioni da passare al modello (system prompt).
Ritorna un'istanza di BaseModel o una sua sottoclasse.
Raise ValueError se il modello non è supportato.
Args:
instructions: istruzioni da passare al modello (system prompt).
Returns:
Un'istanza di BaseModel o una sua sottoclasse.
Raise:
ValueError se il modello non è supportato.
"""
name = self.value
if self in {AppModels.GEMINI, AppModels.GEMINI_PRO}:
@@ -113,8 +122,13 @@ class AppModels(Enum):
def get_agent(self, instructions: str, name: str = "", output: BaseModel | None = None) -> Agent:
"""
Costruisce un agente con il modello e le istruzioni specificate.
instructions: istruzioni da passare al modello (system prompt).
Ritorna un'istanza di Agent.
Args:
instructions: istruzioni da passare al modello (system prompt)
name: nome dell'agente (opzionale)
output: schema di output opzionale (Pydantic BaseModel)
Returns:
Un'istanza di Agent.
"""
return Agent(
model=self.get_model(instructions),

32
src/app/news/__init__.py Normal file
View File

@@ -0,0 +1,32 @@
from app.utils.wrapper_handler import WrapperHandler
from .base import NewsWrapper, Article
from .news_api import NewsApiWrapper
from .gnews_api import GoogleNewsWrapper
from .cryptopanic_api import CryptoPanicWrapper
from .duckduckgo import DuckDuckGoWrapper
__all__ = ["NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper"]
class NewsAPIs(NewsWrapper):
"""
A wrapper class that aggregates multiple news API wrappers and tries them in order until one succeeds.
This class uses the WrapperHandler to manage multiple NewsWrapper instances.
It includes, and tries, the following news API wrappers in this order:
- GoogleNewsWrapper
- DuckDuckGoWrapper
- NewsApiWrapper
- CryptoPanicWrapper
It provides methods to get top headlines and latest news by delegating the calls to the first successful wrapper.
If all wrappers fail, it raises an exception.
"""
def __init__(self):
wrappers = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper]
self.wrapper_handler: WrapperHandler[NewsWrapper] = WrapperHandler.build_wrappers(wrappers)
def get_top_headlines(self, total: int = 100) -> list[Article]:
return self.wrapper_handler.try_call(lambda w: w.get_top_headlines(total))
def get_latest_news(self, query: str, total: int = 100) -> list[Article]:
return self.wrapper_handler.try_call(lambda w: w.get_latest_news(query, total))

35
src/app/news/base.py Normal file
View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel
class Article(BaseModel):
source: str = ""
time: str = ""
title: str = ""
description: str = ""
class NewsWrapper:
"""
Base class for news API wrappers.
All news API wrappers should inherit from this class and implement the methods.
"""
def get_top_headlines(self, total: int = 100) -> list[Article]:
"""
Get top headlines, optionally limited by total.
Args:
total (int): The maximum number of articles to return.
Returns:
list[Article]: A list of Article objects.
"""
raise NotImplementedError("This method should be overridden by subclasses")
def get_latest_news(self, query: str, total: int = 100) -> list[Article]:
"""
Get latest news based on a query.
Args:
query (str): The search query.
total (int): The maximum number of articles to return.
Returns:
list[Article]: A list of Article objects.
"""
raise NotImplementedError("This method should be overridden by subclasses")

View File

@@ -0,0 +1,77 @@
import os
import requests
from enum import Enum
from .base import NewsWrapper, Article
class CryptoPanicFilter(Enum):
RISING = "rising"
HOT = "hot"
BULLISH = "bullish"
BEARISH = "bearish"
IMPORTANT = "important"
SAVED = "saved"
LOL = "lol"
ANY = ""
class CryptoPanicKind(Enum):
NEWS = "news"
MEDIA = "media"
ALL = "all"
def get_articles(response: dict) -> list[Article]:
articles = []
if 'results' in response:
for item in response['results']:
article = Article()
article.source = item.get('source', {}).get('title', '')
article.time = item.get('published_at', '')
article.title = item.get('title', '')
article.description = item.get('description', '')
articles.append(article)
return articles
class CryptoPanicWrapper(NewsWrapper):
"""
A wrapper for the CryptoPanic API (Documentation: https://cryptopanic.com/developers/api/)
Requires an API key set in the environment variable CRYPTOPANIC_API_KEY.
It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/month).
Supports different plan types via the CRYPTOPANIC_API_PLAN environment variable (developer, growth, enterprise).
"""
def __init__(self):
self.api_key = os.getenv("CRYPTOPANIC_API_KEY", "")
assert self.api_key, "CRYPTOPANIC_API_KEY environment variable not set"
# Set here for the future, but currently not needed
plan_type = os.getenv("CRYPTOPANIC_API_PLAN", "developer").lower()
assert plan_type in ["developer", "growth", "enterprise"], "Invalid CRYPTOPANIC_API_PLAN value"
self.base_url = f"https://cryptopanic.com/api/{plan_type}/v2"
self.filter = CryptoPanicFilter.ANY
self.kind = CryptoPanicKind.NEWS
def get_base_params(self) -> dict[str, str]:
params = {}
params['public'] = 'true' # recommended for app and bots
params['auth_token'] = self.api_key
params['kind'] = self.kind.value
if self.filter != CryptoPanicFilter.ANY:
params['filter'] = self.filter.value
return params
def set_filter(self, filter: CryptoPanicFilter):
self.filter = filter
def get_top_headlines(self, total: int = 100) -> list[Article]:
return self.get_latest_news("", total) # same endpoint so just call the other method
def get_latest_news(self, query: str, total: int = 100) -> list[Article]:
params = self.get_base_params()
params['currencies'] = query
response = requests.get(f"{self.base_url}/posts/", params=params)
assert response.status_code == 200, f"Error fetching data: {response}"
json_response = response.json()
articles = get_articles(json_response)
return articles[:total]

View File

@@ -0,0 +1,32 @@
import json
from .base import Article, NewsWrapper
from agno.tools.duckduckgo import DuckDuckGoTools
def create_article(result: dict) -> Article:
article = Article()
article.source = result.get("source", "")
article.time = result.get("date", "")
article.title = result.get("title", "")
article.description = result.get("body", "")
return article
class DuckDuckGoWrapper(NewsWrapper):
"""
A wrapper for DuckDuckGo News search using the Tool from agno.tools.duckduckgo.
It can be rewritten to use direct API calls if needed in the future, but currently is easy to write and use.
"""
def __init__(self):
self.tool = DuckDuckGoTools()
self.query = "crypto"
def get_top_headlines(self, total: int = 100) -> list[Article]:
results = self.tool.duckduckgo_news(self.query, max_results=total)
json_results = json.loads(results)
return [create_article(result) for result in json_results]
def get_latest_news(self, query: str, total: int = 100) -> list[Article]:
results = self.tool.duckduckgo_news(query or self.query, max_results=total)
json_results = json.loads(results)
return [create_article(result) for result in json_results]

36
src/app/news/gnews_api.py Normal file
View File

@@ -0,0 +1,36 @@
from gnews import GNews
from .base import Article, NewsWrapper
def result_to_article(result: dict) -> Article:
article = Article()
article.source = result.get("source", "")
article.time = result.get("publishedAt", "")
article.title = result.get("title", "")
article.description = result.get("description", "")
return article
class GoogleNewsWrapper(NewsWrapper):
"""
A wrapper for the Google News RSS Feed (Documentation: https://github.com/ranahaani/GNews/?tab=readme-ov-file#about-gnews)
It does not require an API key and is free to use.
"""
def get_top_headlines(self, total: int = 100) -> list[Article]:
gnews = GNews(language='en', max_results=total, period='7d')
results = gnews.get_top_news()
articles = []
for result in results:
article = result_to_article(result)
articles.append(article)
return articles
def get_latest_news(self, query: str, total: int = 100) -> list[Article]:
gnews = GNews(language='en', max_results=total, period='7d')
results = gnews.get_news(query)
articles = []
for result in results:
article = result_to_article(result)
articles.append(article)
return articles

50
src/app/news/news_api.py Normal file
View File

@@ -0,0 +1,50 @@
import os
import newsapi
from .base import Article, NewsWrapper
def result_to_article(result: dict) -> Article:
article = Article()
article.source = result.get("source", {}).get("name", "")
article.time = result.get("publishedAt", "")
article.title = result.get("title", "")
article.description = result.get("description", "")
return article
class NewsApiWrapper(NewsWrapper):
"""
A wrapper for the NewsAPI (Documentation: https://newsapi.org/docs/get-started)
Requires an API key set in the environment variable NEWS_API_KEY.
It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/day).
"""
def __init__(self):
api_key = os.getenv("NEWS_API_KEY")
assert api_key is not None, "NEWS_API_KEY environment variable not set"
self.client = newsapi.NewsApiClient(api_key=api_key)
self.category = "business" # Cryptocurrency is under business
self.language = "en" # TODO Only English articles for now?
self.max_page_size = 100
def get_top_headlines(self, total: int = 100) -> list[Article]:
page_size = min(self.max_page_size, total)
pages = (total // page_size) + (1 if total % page_size > 0 else 0)
articles = []
for page in range(1, pages + 1):
headlines = self.client.get_top_headlines(q="", category=self.category, language=self.language, page_size=page_size, page=page)
results = [result_to_article(article) for article in headlines.get("articles", [])]
articles.extend(results)
return articles
def get_latest_news(self, query: str, total: int = 100) -> list[Article]:
page_size = min(self.max_page_size, total)
pages = (total // page_size) + (1 if total % page_size > 0 else 0)
articles = []
for page in range(1, pages + 1):
everything = self.client.get_everything(q=query, language=self.language, sort_by="publishedAt", page_size=page_size, page=page)
results = [result_to_article(article) for article in everything.get("articles", [])]
articles.extend(results)
return articles

83
src/app/pipeline.py Normal file
View File

@@ -0,0 +1,83 @@
from typing import List
from agno.team import Team
from agno.utils.log import log_info
from app.agents.market_agent import MarketAgent
from app.agents.news_agent import NewsAgent
from app.agents.social_agent import SocialAgent
from app.models import AppModels
from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS
class Pipeline:
"""
Pipeline coordinata: esegue tutti gli agenti del Team, aggrega i risultati e invoca il Predictor.
"""
def __init__(self):
# Inizializza gli agenti
self.market_agent = MarketAgent()
self.news_agent = NewsAgent()
self.social_agent = SocialAgent()
# Crea il Team
self.team = Team(name="CryptoAnalysisTeam", members=[self.market_agent, self.news_agent, self.social_agent])
# Modelli disponibili e Predictor
self.available_models = AppModels.availables()
self.predictor_model = self.available_models[0]
self.predictor = self.predictor_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type]
# Stili
self.styles = list(PredictorStyle)
self.style = self.styles[0]
def choose_provider(self, index: int):
self.predictor_model = self.available_models[index]
self.predictor = self.predictor_model.get_agent(PREDICTOR_INSTRUCTIONS, output=PredictorOutput) # type: ignore[arg-type]
def choose_style(self, index: int):
self.style = self.styles[index]
def interact(self, query: str) -> str:
"""
Esegue il Team (Market + News + Social), aggrega i risultati e invoca il Predictor.
"""
# Step 1: raccogli output del Team
team_results = self.team.run(query)
if isinstance(team_results, dict): # alcuni Team possono restituire dict
pieces = [str(v) for v in team_results.values()]
elif isinstance(team_results, list):
pieces = [str(r) for r in team_results]
else:
pieces = [str(team_results)]
aggregated_text = "\n\n".join(pieces)
# Step 2: prepara input per Predictor
predictor_input = PredictorInput(
data=[], # TODO: mappare meglio i dati di mercato in ProductInfo
style=self.style,
sentiment=aggregated_text
)
# Step 3: chiama Predictor
result = self.predictor.run(predictor_input)
prediction: PredictorOutput = result.content
# Step 4: formatta output finale
portfolio_lines = "\n".join(
[f"{item.asset} ({item.percentage}%): {item.motivation}" for item in prediction.portfolio]
)
output = (
f"📊 Strategia ({self.style.value}): {prediction.strategy}\n\n"
f"💼 Portafoglio consigliato:\n{portfolio_lines}"
)
return output
def list_providers(self) -> List[str]:
return [m.name for m in self.available_models]
def list_styles(self) -> List[str]:
return [s.value for s in self.styles]

View File

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

1
src/app/social/__init.py Normal file
View File

@@ -0,0 +1 @@
from .base import SocialWrapper

22
src/app/social/base.py Normal file
View File

@@ -0,0 +1,22 @@
from pydantic import BaseModel
class SocialPost(BaseModel):
time: str = ""
title: str = ""
description: str = ""
comments: list["SocialComment"] = []
def __str__(self):
return f"Title: {self.title}\nDescription: {self.description}\nComments: {len(self.comments)}\n[{" | ".join(str(c) for c in self.comments)}]"
class SocialComment(BaseModel):
time: str = ""
description: str = ""
def __str__(self):
return f"Time: {self.time}\nDescription: {self.description}"
# TODO IMPLEMENTARLO SE SI USANO PIU' WRAPPER (E QUINDI PIU' SOCIAL)
class SocialWrapper:
pass

53
src/app/social/reddit.py Normal file
View File

@@ -0,0 +1,53 @@
import os
from praw import Reddit
from praw.models import Submission, MoreComments
from .base import SocialWrapper, SocialPost, SocialComment
MAX_COMMENTS = 5
def create_social_post(post: Submission) -> SocialPost:
social = SocialPost()
social.time = str(post.created)
social.title = post.title
social.description = post.selftext
for i, top_comment in enumerate(post.comments):
if i >= MAX_COMMENTS:
break
if isinstance(top_comment, MoreComments): #skip MoreComments objects
continue
comment = SocialComment()
comment.time = str(top_comment.created)
comment.description = top_comment.body
social.comments.append(comment)
return social
class RedditWrapper(SocialWrapper):
"""
A wrapper for the Reddit API using PRAW (Python Reddit API Wrapper).
Requires the following environment variables to be set:
- REDDIT_API_CLIENT_ID
- REDDIT_API_CLIENT_SECRET
You can get them by creating an app at https://www.reddit.com/prefs/apps
"""
def __init__(self):
self.client_id = os.getenv("REDDIT_API_CLIENT_ID")
assert self.client_id is not None, "REDDIT_API_CLIENT_ID environment variable is not set"
self.client_secret = os.getenv("REDDIT_API_CLIENT_SECRET")
assert self.client_secret is not None, "REDDIT_API_CLIENT_SECRET environment variable is not set"
self.tool = Reddit(
client_id=self.client_id,
client_secret=self.client_secret,
user_agent="upo-appAI",
)
def get_top_crypto_posts(self, limit=5) -> list[SocialPost]:
subreddit = self.tool.subreddit("CryptoCurrency")
top_posts = subreddit.top(limit=limit, time_filter="week")
return [create_social_post(post) for post in top_posts]

View File

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

View File

View File

@@ -1,5 +1,6 @@
from agno.tools import Toolkit
from app.markets import MarketAPIs
from app.markets import MarketAPIsTool
# TODO (?) in futuro fare in modo che la LLM faccia da sé per il mercato
# Non so se può essere utile, per ora lo lascio qui
@@ -8,20 +9,20 @@ from app.markets import MarketAPIs
# in base alle sue proprie chiamate API
class MarketToolkit(Toolkit):
def __init__(self):
self.market_api = MarketAPIs("USD") # change currency if needed
self.market_api = MarketAPIsTool("USD") # change currency if needed
super().__init__(
name="Market Toolkit",
tools=[
self.get_historical_data,
self.get_current_price,
self.get_current_prices,
],
)
def get_historical_data(self, symbol: str):
return self.market_api.get_historical_prices(symbol)
def get_current_price(self, symbol: str):
def get_current_prices(self, symbol: list):
return self.market_api.get_products(symbol)
def prepare_inputs():

View File

@@ -0,0 +1,184 @@
import statistics
from typing import Dict, List, Optional, Set
from pydantic import BaseModel, Field, PrivateAttr
from app.markets.base import ProductInfo
class AggregationMetadata(BaseModel):
"""Metadati nascosti per debugging e audit trail"""
sources_used: Set[str] = Field(default_factory=set, description="Exchange usati nell'aggregazione")
sources_ignored: Set[str] = Field(default_factory=set, description="Exchange ignorati (errori)")
aggregation_timestamp: str = Field(default="", description="Timestamp dell'aggregazione")
confidence_score: float = Field(default=0.0, description="Score 0-1 sulla qualità dei dati")
class Config:
# Nasconde questi campi dalla serializzazione di default
extra = "forbid"
class AggregatedProductInfo(ProductInfo):
"""
Versione aggregata di ProductInfo che mantiene la trasparenza per l'utente finale
mentre fornisce metadati di debugging opzionali.
"""
# Override dei campi con logica di aggregazione
id: str = Field(description="ID aggregato basato sul simbolo standardizzato")
status: str = Field(description="Status aggregato (majority vote o conservative)")
# Campi privati per debugging (non visibili di default)
_metadata: Optional[AggregationMetadata] = PrivateAttr(default=None)
_source_data: Optional[Dict[str, ProductInfo]] = PrivateAttr(default=None)
@classmethod
def from_multiple_sources(cls, products: List[ProductInfo]) -> 'AggregatedProductInfo':
"""
Crea un AggregatedProductInfo da una lista di ProductInfo.
Usa strategie intelligenti per gestire ID e status.
"""
if not products:
raise ValueError("Nessun prodotto da aggregare")
# Raggruppa per symbol (la chiave vera per l'aggregazione)
symbol_groups = {}
for product in products:
if product.symbol not in symbol_groups:
symbol_groups[product.symbol] = []
symbol_groups[product.symbol].append(product)
# Per ora gestiamo un symbol alla volta
if len(symbol_groups) > 1:
raise ValueError(f"Simboli multipli non supportati: {list(symbol_groups.keys())}")
symbol_products = list(symbol_groups.values())[0]
# Estrai tutte le fonti
sources = []
for product in symbol_products:
# Determina la fonte dall'ID o da altri metadati se disponibili
source = cls._detect_source(product)
sources.append(source)
# Aggrega i dati
aggregated_data = cls._aggregate_products(symbol_products, sources)
# Crea l'istanza e assegna gli attributi privati
instance = cls(**aggregated_data)
instance._metadata = aggregated_data.get("_metadata")
instance._source_data = aggregated_data.get("_source_data")
return instance
@staticmethod
def _detect_source(product: ProductInfo) -> str:
"""Rileva la fonte da un ProductInfo"""
# Strategia semplice: usa pattern negli ID
if "coinbase" in product.id.lower() or "cb" in product.id.lower():
return "coinbase"
elif "binance" in product.id.lower() or "bn" in product.id.lower():
return "binance"
elif "crypto" in product.id.lower() or "cc" in product.id.lower():
return "cryptocompare"
else:
return "unknown"
@classmethod
def _aggregate_products(cls, products: List[ProductInfo], sources: List[str]) -> dict:
"""
Logica di aggregazione principale.
Gestisce ID, status e altri campi numerici.
"""
import statistics
from datetime import datetime
# ID: usa il symbol come chiave standardizzata
symbol = products[0].symbol
aggregated_id = f"{symbol}_AGG"
# Status: strategia "conservativa" - il più restrittivo vince
# Ordine: trading_only < limit_only < auction < maintenance < offline
status_priority = {
"trading": 1,
"limit_only": 2,
"auction": 3,
"maintenance": 4,
"offline": 5,
"": 0 # Default se non specificato
}
statuses = [p.status for p in products if p.status]
if statuses:
# Prendi lo status con priorità più alta (più restrittivo)
aggregated_status = max(statuses, key=lambda s: status_priority.get(s, 0))
else:
aggregated_status = "trading" # Default ottimistico
# Prezzo: media semplice (uso diretto del campo price come float)
prices = [p.price for p in products if p.price > 0]
aggregated_price = statistics.mean(prices) if prices else 0.0
# Volume: somma (assumendo che i volumi siano esclusivi per exchange)
volumes = [p.volume_24h for p in products if p.volume_24h > 0]
total_volume = sum(volumes)
aggregated_volume = sum(price_i * volume_i for price_i, volume_i in zip((p.price for p in products), (volume for volume in volumes))) / total_volume
aggregated_volume = round(aggregated_volume, 5)
# aggregated_volume = sum(volumes) if volumes else 0.0 # NOTE old implementation
# Valuta: prendi la prima (dovrebbero essere tutte uguali)
quote_currency = next((p.quote_currency for p in products if p.quote_currency), "USD")
# Calcola confidence score
confidence = cls._calculate_confidence(products, sources)
# Crea metadati per debugging
metadata = AggregationMetadata(
sources_used=set(sources),
aggregation_timestamp=datetime.now().isoformat(),
confidence_score=confidence
)
# Salva dati sorgente per debugging
source_data = dict(zip(sources, products))
return {
"symbol": symbol,
"price": aggregated_price,
"volume_24h": aggregated_volume,
"quote_currency": quote_currency,
"id": aggregated_id,
"status": aggregated_status,
"_metadata": metadata,
"_source_data": source_data
}
@staticmethod
def _calculate_confidence(products: List[ProductInfo], sources: List[str]) -> float:
"""Calcola un punteggio di confidenza 0-1"""
if not products:
return 0.0
score = 1.0
# Riduci score se pochi dati
if len(products) < 2:
score *= 0.7
# Riduci score se prezzi troppo diversi
prices = [p.price for p in products if p.price > 0]
if len(prices) > 1:
price_std = (max(prices) - min(prices)) / statistics.mean(prices)
if price_std > 0.05: # >5% variazione
score *= 0.8
# Riduci score se fonti sconosciute
unknown_sources = sum(1 for s in sources if s == "unknown")
if unknown_sources > 0:
score *= (1 - unknown_sources / len(sources))
return max(0.0, min(1.0, score))
def get_debug_info(self) -> dict:
"""Metodo opzionale per ottenere informazioni di debug"""
return {
"aggregated_product": self.dict(),
"metadata": self._metadata.dict() if self._metadata else None,
"sources": list(self._source_data.keys()) if self._source_data else []
}

View File

@@ -1,5 +1,5 @@
import statistics
from typing import Dict, List, Any
from typing import Dict, Any
class MarketAggregator:
"""
@@ -65,6 +65,7 @@ class MarketAggregator:
return float(v[:-1]) * 1_000
try:
return float(v)
except Exception:
except Exception as e:
print(f"Errore nel parsing del volume: {e}")
return 0.0
return 0.0

View File

@@ -0,0 +1,184 @@
from typing import List, Optional, Dict, Any
from app.markets.base import ProductInfo, Price
from app.utils.aggregated_models import AggregatedProductInfo
class MarketDataAggregator:
"""
Aggregatore di dati di mercato che mantiene la trasparenza per l'utente.
Compone MarketAPIs per fornire gli stessi metodi, ma restituisce dati aggregati
da tutte le fonti disponibili. L'utente finale non vede la complessità.
"""
def __init__(self, currency: str = "USD"):
# Import lazy per evitare circular import
from app.markets import MarketAPIs
self._market_apis = MarketAPIs(currency)
self._aggregation_enabled = True
def get_product(self, asset_id: str) -> ProductInfo:
"""
Override che aggrega dati da tutte le fonti disponibili.
Per l'utente sembra un normale ProductInfo.
"""
if not self._aggregation_enabled:
return self._market_apis.get_product(asset_id)
# Raccogli dati da tutte le fonti
try:
raw_results = self.wrappers.try_call_all(
lambda wrapper: wrapper.get_product(asset_id)
)
# Converti in ProductInfo se necessario
products = []
for wrapper_class, result in raw_results.items():
if isinstance(result, ProductInfo):
products.append(result)
elif isinstance(result, dict):
# Converti dizionario in ProductInfo
products.append(ProductInfo(**result))
if not products:
raise Exception("Nessun dato disponibile")
# Aggrega i risultati
aggregated = AggregatedProductInfo.from_multiple_sources(products)
# Restituisci come ProductInfo normale (nascondi la complessità)
return ProductInfo(**aggregated.dict(exclude={"_metadata", "_source_data"}))
except Exception as e:
# Fallback: usa il comportamento normale se l'aggregazione fallisce
return self._market_apis.get_product(asset_id)
def get_products(self, asset_ids: List[str]) -> List[ProductInfo]:
"""
Aggrega dati per multiple asset.
"""
if not self._aggregation_enabled:
return self._market_apis.get_products(asset_ids)
aggregated_products = []
for asset_id in asset_ids:
try:
product = self.get_product(asset_id)
aggregated_products.append(product)
except Exception as e:
# Salta asset che non riescono ad aggregare
continue
return aggregated_products
def get_all_products(self) -> List[ProductInfo]:
"""
Aggrega tutti i prodotti disponibili.
"""
if not self._aggregation_enabled:
return self._market_apis.get_all_products()
# Raccogli tutti i prodotti da tutte le fonti
try:
all_products_by_source = self.wrappers.try_call_all(
lambda wrapper: wrapper.get_all_products()
)
# Raggruppa per symbol per aggregare
symbol_groups = {}
for wrapper_class, products in all_products_by_source.items():
if not isinstance(products, list):
continue
for product in products:
if isinstance(product, dict):
product = ProductInfo(**product)
if product.symbol not in symbol_groups:
symbol_groups[product.symbol] = []
symbol_groups[product.symbol].append(product)
# Aggrega ogni gruppo
aggregated_products = []
for symbol, products in symbol_groups.items():
try:
aggregated = AggregatedProductInfo.from_multiple_sources(products)
# Restituisci come ProductInfo normale
aggregated_products.append(
ProductInfo(**aggregated.dict(exclude={"_metadata", "_source_data"}))
)
except Exception:
# Se l'aggregazione fallisce, usa il primo disponibile
if products:
aggregated_products.append(products[0])
return aggregated_products
except Exception as e:
# Fallback: usa il comportamento normale
return self._market_apis.get_all_products()
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> List[Price]:
"""
Per i dati storici, usa una strategia diversa:
prendi i dati dalla fonte più affidabile o aggrega se possibile.
"""
if not self._aggregation_enabled:
return self._market_apis.get_historical_prices(asset_id, limit)
# Per dati storici, usa il primo wrapper che funziona
# (l'aggregazione di dati storici è più complessa)
try:
return self.wrappers.try_call(
lambda wrapper: wrapper.get_historical_prices(asset_id, limit)
)
except Exception as e:
# Fallback: usa il comportamento normale
return self._market_apis.get_historical_prices(asset_id, limit)
def enable_aggregation(self, enabled: bool = True):
"""Abilita o disabilita l'aggregazione"""
self._aggregation_enabled = enabled
def is_aggregation_enabled(self) -> bool:
"""Controlla se l'aggregazione è abilitata"""
return self._aggregation_enabled
# Metodi proxy per completare l'interfaccia BaseWrapper
@property
def wrappers(self):
"""Accesso al wrapper handler per compatibilità"""
return self._market_apis.wrappers
def get_aggregated_product_with_debug(self, asset_id: str) -> Dict[str, Any]:
"""
Metodo speciale per debugging: restituisce dati aggregati con metadati.
Usato solo per testing e monitoraggio.
"""
try:
raw_results = self.wrappers.try_call_all(
lambda wrapper: wrapper.get_product(asset_id)
)
products = []
for wrapper_class, result in raw_results.items():
if isinstance(result, ProductInfo):
products.append(result)
elif isinstance(result, dict):
products.append(ProductInfo(**result))
if not products:
raise Exception("Nessun dato disponibile")
aggregated = AggregatedProductInfo.from_multiple_sources(products)
return {
"product": aggregated.dict(exclude={"_metadata", "_source_data"}),
"debug": aggregated.get_debug_info()
}
except Exception as e:
return {
"error": str(e),
"debug": {"error": str(e)}
}

View File

@@ -0,0 +1,110 @@
import time
from typing import TypeVar, Callable, Generic, Iterable, Type
from agno.utils.log import log_warning
W = TypeVar("W")
T = TypeVar("T")
class WrapperHandler(Generic[W]):
"""
A handler for managing multiple wrappers with retry logic.
It attempts to call a function on the current wrapper, and if it fails,
it retries a specified number of times before switching to the next wrapper.
If all wrappers fail, it raises an exception.
Note: use `build_wrappers` to create an instance of this class for better error handling.
"""
def __init__(self, wrappers: list[W], try_per_wrapper: int = 3, retry_delay: int = 2):
"""
Initializes the WrapperHandler with a list of wrappers and retry settings.\n
Use `build_wrappers` to create an instance of this class for better error handling.
Args:
wrappers (list[W]): A list of wrapper instances to manage.
try_per_wrapper (int): Number of retries per wrapper before switching to the next.
retry_delay (int): Delay in seconds between retries.
"""
self.wrappers = wrappers
self.retry_per_wrapper = try_per_wrapper
self.retry_delay = retry_delay
self.index = 0
self.retry_count = 0
def try_call(self, func: Callable[[W], T]) -> T:
"""
Attempts to call the provided function on the current wrapper.
If it fails, it retries a specified number of times before switching to the next wrapper.
If all wrappers fail, it raises an exception.
Args:
func (Callable[[W], T]): A function that takes a wrapper and returns a result.
Returns:
T: The result of the function call.
Raises:
Exception: If all wrappers fail after retries.
"""
iterations = 0
while iterations < len(self.wrappers):
try:
wrapper = self.wrappers[self.index]
result = func(wrapper)
self.retry_count = 0
return result
except Exception as e:
self.retry_count += 1
if self.retry_count >= self.retry_per_wrapper:
self.index = (self.index + 1) % len(self.wrappers)
self.retry_count = 0
iterations += 1
else:
log_warning(f"{wrapper} failed {self.retry_count}/{self.retry_per_wrapper}: {e}")
time.sleep(self.retry_delay)
raise Exception(f"All wrappers failed after retries")
def try_call_all(self, func: Callable[[W], T]) -> dict[str, T]:
"""
Calls the provided function on all wrappers, collecting results.
If a wrapper fails, it logs a warning and continues with the next.
If all wrappers fail, it raises an exception.
Args:
func (Callable[[W], T]): A function that takes a wrapper and returns a result.
Returns:
list[T]: A list of results from the function calls.
Raises:
Exception: If all wrappers fail.
"""
results = {}
for wrapper in self.wrappers:
try:
result = func(wrapper)
results[wrapper.__class__] = result
except Exception as e:
log_warning(f"{wrapper} failed: {e}")
if not results:
raise Exception("All wrappers failed")
return results
@staticmethod
def build_wrappers(constructors: Iterable[Type[W]], try_per_wrapper: int = 3, retry_delay: int = 2) -> 'WrapperHandler[W]':
"""
Builds a WrapperHandler instance with the given wrapper constructors.
It attempts to initialize each wrapper and logs a warning if any cannot be initialized.
Only successfully initialized wrappers are included in the handler.
Args:
constructors (Iterable[Type[W]]): An iterable of wrapper classes to instantiate. e.g. [WrapperA, WrapperB]
try_per_wrapper (int): Number of retries per wrapper before switching to the next.
retry_delay (int): Delay in seconds between retries.
Returns:
WrapperHandler[W]: An instance of WrapperHandler with the initialized wrappers.
Raises:
Exception: If no wrappers could be initialized.
"""
result = []
for wrapper_class in constructors:
try:
wrapper = wrapper_class()
result.append(wrapper)
except Exception as e:
log_warning(f"{wrapper_class} cannot be initialized: {e}")
return WrapperHandler(result, try_per_wrapper, retry_delay)

View File

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

View File

@@ -1,5 +1,5 @@
import pytest
from app.agents.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle
from app.predictor import PREDICTOR_INSTRUCTIONS, PredictorInput, PredictorOutput, PredictorStyle
from app.markets.base import ProductInfo
from app.models import AppModels
@@ -16,8 +16,8 @@ def unified_checks(model: AppModels, input):
for item in content.portfolio:
assert item.asset not in (None, "", "null")
assert isinstance(item.asset, str)
assert item.percentage > 0
assert item.percentage <= 100
assert item.percentage >= 0.0
assert item.percentage <= 100.0
assert isinstance(item.percentage, (int, float))
assert item.motivation not in (None, "", "null")
assert isinstance(item.motivation, str)
@@ -41,6 +41,7 @@ class TestPredictor:
def test_gemini_model_output(self, inputs):
unified_checks(AppModels.GEMINI, inputs)
@pytest.mark.slow
def test_ollama_qwen_model_output(self, inputs):
unified_checks(AppModels.OLLAMA_QWEN, inputs)

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,24 @@
import pytest
from praw import Reddit
from app.social.reddit import MAX_COMMENTS, RedditWrapper
@pytest.mark.social
@pytest.mark.api
class TestRedditWrapper:
def test_initialization(self):
wrapper = RedditWrapper()
assert wrapper.client_id is not None
assert wrapper.client_secret is not None
assert isinstance(wrapper.tool, Reddit)
def test_get_top_crypto_posts(self):
wrapper = RedditWrapper()
posts = wrapper.get_top_crypto_posts(limit=2)
assert isinstance(posts, list)
assert len(posts) == 2
for post in posts:
assert post.title != ""
assert isinstance(post.comments, list)
assert len(post.comments) <= MAX_COMMENTS
for comment in post.comments:
assert comment.description != ""

View File

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

View File

@@ -0,0 +1,70 @@
import os
import pytest
from app.agents.market_agent import MarketToolkit
from app.markets import MarketAPIsTool
@pytest.mark.limited # usa molte api calls e non voglio esaurire le chiavi api
@pytest.mark.tools
@pytest.mark.api
class TestMarketAPIsTool:
def test_wrapper_initialization(self):
market_wrapper = MarketAPIsTool("USD")
assert market_wrapper is not None
assert hasattr(market_wrapper, 'get_product')
assert hasattr(market_wrapper, 'get_products')
assert hasattr(market_wrapper, 'get_all_products')
assert hasattr(market_wrapper, 'get_historical_prices')
def test_wrapper_capabilities(self):
market_wrapper = MarketAPIsTool("USD")
capabilities = []
if hasattr(market_wrapper, 'get_product'):
capabilities.append('single_product')
if hasattr(market_wrapper, 'get_products'):
capabilities.append('multiple_products')
if hasattr(market_wrapper, 'get_historical_prices'):
capabilities.append('historical_data')
assert len(capabilities) > 0
def test_market_data_retrieval(self):
market_wrapper = MarketAPIsTool("USD")
btc_product = market_wrapper.get_product("BTC")
assert btc_product is not None
assert hasattr(btc_product, 'symbol')
assert hasattr(btc_product, 'price')
assert btc_product.price > 0
def test_market_toolkit_integration(self):
try:
toolkit = MarketToolkit()
assert toolkit is not None
assert hasattr(toolkit, 'market_agent')
assert toolkit.market_api is not None
tools = toolkit.tools
assert len(tools) > 0
except Exception as e:
print(f"MarketToolkit test failed: {e}")
# Non fail completamente - il toolkit potrebbe avere dipendenze specifiche
def test_provider_selection_mechanism(self):
potential_providers = 0
if os.getenv('CDP_API_KEY_NAME') and os.getenv('CDP_API_PRIVATE_KEY'):
potential_providers += 1
if os.getenv('CRYPTOCOMPARE_API_KEY'):
potential_providers += 1
def test_error_handling(self):
try:
market_wrapper = MarketAPIsTool("USD")
fake_product = market_wrapper.get_product("NONEXISTENT_CRYPTO_SYMBOL_12345")
assert fake_product is None or fake_product.price == 0
except Exception as e:
pass
def test_wrapper_currency_support(self):
market_wrapper = MarketAPIsTool("USD")
assert hasattr(market_wrapper, 'currency')
assert isinstance(market_wrapper.currency, str)
assert len(market_wrapper.currency) >= 3 # USD, EUR, etc.

View File

@@ -0,0 +1,88 @@
import pytest
from app.utils.market_data_aggregator import MarketDataAggregator
from app.utils.aggregated_models import AggregatedProductInfo
from app.markets.base import ProductInfo, Price
@pytest.mark.limited
@pytest.mark.market
@pytest.mark.api
class TestMarketDataAggregator:
def test_initialization(self):
"""Test che il MarketDataAggregator si inizializzi correttamente"""
aggregator = MarketDataAggregator()
assert aggregator is not None
assert aggregator.is_aggregation_enabled() == True
def test_aggregation_toggle(self):
"""Test del toggle dell'aggregazione"""
aggregator = MarketDataAggregator()
# Disabilita aggregazione
aggregator.enable_aggregation(False)
assert aggregator.is_aggregation_enabled() == False
# Riabilita aggregazione
aggregator.enable_aggregation(True)
assert aggregator.is_aggregation_enabled() == True
def test_aggregated_product_info_creation(self):
"""Test creazione AggregatedProductInfo da fonti multiple"""
# Crea dati di esempio
product1 = ProductInfo(
id="BTC-USD",
symbol="BTC-USD",
price=50000.0,
volume_24h=1000.0,
status="active",
quote_currency="USD"
)
product2 = ProductInfo(
id="BTC-USD",
symbol="BTC-USD",
price=50100.0,
volume_24h=1100.0,
status="active",
quote_currency="USD"
)
# Aggrega i prodotti
aggregated = AggregatedProductInfo.from_multiple_sources([product1, product2])
assert aggregated.symbol == "BTC-USD"
assert aggregated.price == pytest.approx(50050.0, rel=1e-3) # media tra 50000 e 50100
assert aggregated.volume_24h == 50052.38095 # somma dei volumi
assert aggregated.status == "active" # majority vote
assert aggregated.id == "BTC-USD_AGG" # mapping_id con suffisso aggregazione
def test_confidence_calculation(self):
"""Test del calcolo della confidence"""
product1 = ProductInfo(
id="BTC-USD",
symbol="BTC-USD",
price=50000.0,
volume_24h=1000.0,
status="active",
quote_currency="USD"
)
product2 = ProductInfo(
id="BTC-USD",
symbol="BTC-USD",
price=50100.0,
volume_24h=1100.0,
status="active",
quote_currency="USD"
)
aggregated = AggregatedProductInfo.from_multiple_sources([product1, product2])
# Verifica che ci siano metadati
assert aggregated._metadata is not None
assert len(aggregated._metadata.sources_used) > 0
assert aggregated._metadata.aggregation_timestamp != ""
# La confidence può essere 0.0 se ci sono fonti "unknown"

View File

@@ -0,0 +1,90 @@
import pytest
from app.utils.wrapper_handler import WrapperHandler
class MockWrapper:
def do_something(self) -> str:
return "Success"
class MockWrapper2(MockWrapper):
def do_something(self) -> str:
return "Success 2"
class FailingWrapper(MockWrapper):
def do_something(self):
raise Exception("Intentional Failure")
@pytest.mark.wrapper
class TestWrapperHandler:
def test_all_wrappers_fail(self):
wrappers = [FailingWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0)
with pytest.raises(Exception) as exc_info:
handler.try_call(lambda w: w.do_something())
assert "All wrappers failed after retries" in str(exc_info.value)
def test_success_on_first_try(self):
wrappers = [MockWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0)
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 0 # Should still be on the first wrapper
assert handler.retry_count == 0
def test_eventual_success(self):
wrappers = [FailingWrapper, MockWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=2, retry_delay=0)
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should have switched to the second wrapper
assert handler.retry_count == 0
def test_partial_failures(self):
wrappers = [FailingWrapper, MockWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should have switched to the second wrapper
assert handler.retry_count == 0
# Next call should still succeed on the second wrapper
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should still be on the second wrapper
assert handler.retry_count == 0
handler.index = 2 # Manually switch to the third wrapper
result = handler.try_call(lambda w: w.do_something())
assert result == "Success"
assert handler.index == 1 # Should return to the second wrapper after failure
assert handler.retry_count == 0
def test_try_call_all_success(self):
wrappers = [MockWrapper, MockWrapper2]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
results = handler.try_call_all(lambda w: w.do_something())
assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"}
def test_try_call_all_partial_failures(self):
# Only the second wrapper should succeed
wrappers = [FailingWrapper, MockWrapper, FailingWrapper]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
results = handler.try_call_all(lambda w: w.do_something())
assert results == {MockWrapper: "Success"}
# Only the second and fourth wrappers should succeed
wrappers = [FailingWrapper, MockWrapper, FailingWrapper, MockWrapper2]
handler: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers(wrappers, try_per_wrapper=1, retry_delay=0)
results = handler.try_call_all(lambda w: w.do_something())
assert results == {MockWrapper: "Success", MockWrapper2: "Success 2"}
def test_try_call_all_all_fail(self):
# Test when all wrappers fail
handler_all_fail: WrapperHandler[MockWrapper] = WrapperHandler.build_wrappers([FailingWrapper, FailingWrapper], try_per_wrapper=1, retry_delay=0)
with pytest.raises(Exception) as exc_info:
handler_all_fail.try_call_all(lambda w: w.do_something())
assert "All wrappers failed" in str(exc_info.value)

257
uv.lock generated
View File

@@ -130,6 +130,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
]
[[package]]
name = "brotli"
version = "1.1.0"
@@ -156,6 +169,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255, upload-time = "2023-09-07T14:04:17.83Z" },
]
[[package]]
name = "brotlicffi"
version = "1.1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/9d/70caa61192f570fcf0352766331b735afa931b4c6bc9a348a0925cc13288/brotlicffi-1.1.0.0.tar.gz", hash = "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13", size = 465192, upload-time = "2023-09-14T14:22:40.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/11/7b96009d3dcc2c931e828ce1e157f03824a69fb728d06bfd7b2fc6f93718/brotlicffi-1.1.0.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851", size = 453786, upload-time = "2023-09-14T14:21:57.72Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e6/a8f46f4a4ee7856fbd6ac0c6fb0dc65ed181ba46cd77875b8d9bbe494d9e/brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b", size = 2911165, upload-time = "2023-09-14T14:21:59.613Z" },
{ url = "https://files.pythonhosted.org/packages/be/20/201559dff14e83ba345a5ec03335607e47467b6633c210607e693aefac40/brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9feb210d932ffe7798ee62e6145d3a757eb6233aa9a4e7db78dd3690d7755814", size = 2927895, upload-time = "2023-09-14T14:22:01.22Z" },
{ url = "https://files.pythonhosted.org/packages/cd/15/695b1409264143be3c933f708a3f81d53c4a1e1ebbc06f46331decbf6563/brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84763dbdef5dd5c24b75597a77e1b30c66604725707565188ba54bab4f114820", size = 2851834, upload-time = "2023-09-14T14:22:03.571Z" },
{ url = "https://files.pythonhosted.org/packages/b4/40/b961a702463b6005baf952794c2e9e0099bde657d0d7e007f923883b907f/brotlicffi-1.1.0.0-cp37-abi3-win32.whl", hash = "sha256:1b12b50e07c3911e1efa3a8971543e7648100713d4e0971b13631cce22c587eb", size = 341731, upload-time = "2023-09-14T14:22:05.74Z" },
{ url = "https://files.pythonhosted.org/packages/1c/fa/5408a03c041114ceab628ce21766a4ea882aa6f6f0a800e04ee3a30ec6b9/brotlicffi-1.1.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:994a4f0681bb6c6c3b0925530a1926b7a189d878e6e5e38fae8efa47c5d9c613", size = 366783, upload-time = "2023-09-14T14:22:07.096Z" },
]
[[package]]
name = "cachetools"
version = "5.5.2"
@@ -310,6 +340,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453, upload-time = "2025-06-26T09:29:21.412Z" },
]
[[package]]
name = "ddgs"
version = "9.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "httpx", extra = ["brotli", "http2", "socks"] },
{ name = "lxml" },
{ name = "primp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/45/7a408de2cd89855403ea18ed776f12c291eabe7dd54bc5b00f7cdb43f8ba/ddgs-9.6.0.tar.gz", hash = "sha256:8caf555d4282c1cf5c15969994ad55f4239bd15e97cf004a5da8f1cad37529bf", size = 35865, upload-time = "2025-09-17T13:27:10.533Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/cd/ef820662e0d87f46b829bba7e2324c7978e0153692bbd2f08f7746049708/ddgs-9.6.0-py3-none-any.whl", hash = "sha256:24120f1b672fd3a28309db029e7038eb3054381730aea7a08d51bb909dd55520", size = 41558, upload-time = "2025-09-17T13:27:08.99Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
@@ -344,6 +398,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670, upload-time = "2025-09-16T18:29:21.329Z" },
]
[[package]]
name = "feedparser"
version = "6.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sgmllib3k" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" },
]
[[package]]
name = "ffmpy"
version = "0.6.1"
@@ -421,6 +487,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" },
]
[[package]]
name = "gnews"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "dnspython" },
{ name = "feedparser" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/65/d4b19ebde3edd4d0cb63660fe61e9777de1dd35ea819cb72a5b53002bb97/gnews-0.4.2.tar.gz", hash = "sha256:5016cf5299f42ea072adb295abe5e9f093c5c422da2c12e6661d1dcdbc56d011", size = 24847, upload-time = "2025-07-27T13:46:54.717Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/77/00b21cce68b6041e78edf23efbc95eea6a4555cd474594b7360d1b9e4444/gnews-0.4.2-py3-none-any.whl", hash = "sha256:ed1fa603a7edeb3886925e756b114afb1e0c5b7b9f56fe5ebeedeeb730d2a9c4", size = 18142, upload-time = "2025-07-27T13:46:53.848Z" },
]
[[package]]
name = "google-auth"
version = "2.40.3"
@@ -528,6 +609,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hf-xet"
version = "1.1.10"
@@ -543,6 +637,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/0e/471f0a21db36e71a2f1752767ad77e92d8cde24e974e03d662931b1305ec/hf_xet-1.1.10-cp37-abi3-win_amd64.whl", hash = "sha256:5f54b19cc347c13235ae7ee98b330c26dd65ef1df47e5316ffb1e87713ca7045", size = 2804691, upload-time = "2025-09-12T20:10:28.433Z" },
]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -571,6 +674,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
brotli = [
{ name = "brotli", marker = "platform_python_implementation == 'CPython'" },
{ name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
]
http2 = [
{ name = "h2" },
]
socks = [
{ name = "socksio" },
]
[[package]]
name = "huggingface-hub"
version = "0.35.0"
@@ -590,6 +705,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/85/a18508becfa01f1e4351b5e18651b06d210dbd96debccd48a452acccb901/huggingface_hub-0.35.0-py3-none-any.whl", hash = "sha256:f2e2f693bca9a26530b1c0b9bcd4c1495644dad698e6a0060f90e22e772c31e9", size = 563436, upload-time = "2025-09-16T13:49:30.627Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.10"
@@ -620,6 +744,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -686,6 +836,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" },
]
[[package]]
name = "newsapi-python"
version = "0.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/4b/12fb9495211fc5a6d3a96968759c1a48444124a1654aaf65d0de80b46794/newsapi-python-0.2.7.tar.gz", hash = "sha256:a4b66d5dd9892198cdaa476f7542f2625cdd218e5e3121c8f880b2ace717a3c2", size = 7485, upload-time = "2023-03-02T13:15:35.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/47/e3b099102f0c826d37841d2266e19f1568dcf58ba86e4c6948e2a124f91d/newsapi_python-0.2.7-py2.py3-none-any.whl", hash = "sha256:11d34013a24d92ca7b7cbdac84ed2d504862b1e22467bc2a9a6913a70962318e", size = 7942, upload-time = "2023-03-02T13:15:34.475Z" },
]
[[package]]
name = "numpy"
version = "2.3.3"
@@ -799,6 +961,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "praw"
version = "7.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prawcore" },
{ name = "update-checker" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/52/7dd0b3d9ccb78e90236420ef6c51b6d9b2400a7229442f0cfcf2258cce21/praw-7.8.1.tar.gz", hash = "sha256:3c5767909f71e48853eb6335fef7b50a43cbe3da728cdfb16d3be92904b0a4d8", size = 154106, upload-time = "2024-10-25T21:49:33.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/ca/60ec131c3b43bff58261167045778b2509b83922ce8f935ac89d871bd3ea/praw-7.8.1-py3-none-any.whl", hash = "sha256:15917a81a06e20ff0aaaf1358481f4588449fa2421233040cb25e5c8202a3e2f", size = 189338, upload-time = "2024-10-25T21:49:31.109Z" },
]
[[package]]
name = "prawcore"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/62/d4c99cf472205f1e5da846b058435a6a7c988abf8eb6f7d632a7f32f4a77/prawcore-2.4.0.tar.gz", hash = "sha256:b7b2b5a1d04406e086ab4e79988dc794df16059862f329f4c6a43ed09986c335", size = 15862, upload-time = "2023-10-01T23:30:49.408Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/5c/8af904314e42d5401afcfaff69940dc448e974f80f7aa39b241a4fbf0cf1/prawcore-2.4.0-py3-none-any.whl", hash = "sha256:29af5da58d85704b439ad3c820873ad541f4535e00bb98c66f0fbcc8c603065a", size = 17203, upload-time = "2023-10-01T23:30:47.651Z" },
]
[[package]]
name = "primp"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/0b/a87556189da4de1fc6360ca1aa05e8335509633f836cdd06dd17f0743300/primp-0.15.0.tar.gz", hash = "sha256:1af8ea4b15f57571ff7fc5e282a82c5eb69bc695e19b8ddeeda324397965b30a", size = 113022, upload-time = "2025-04-17T11:41:05.315Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/5a/146ac964b99ea7657ad67eb66f770be6577dfe9200cb28f9a95baffd6c3f/primp-0.15.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1b281f4ca41a0c6612d4c6e68b96e28acfe786d226a427cd944baa8d7acd644f", size = 3178914, upload-time = "2025-04-17T11:40:59.558Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8a/cc2321e32db3ce64d6e32950d5bcbea01861db97bfb20b5394affc45b387/primp-0.15.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:489cbab55cd793ceb8f90bb7423c6ea64ebb53208ffcf7a044138e3c66d77299", size = 2955079, upload-time = "2025-04-17T11:40:57.398Z" },
{ url = "https://files.pythonhosted.org/packages/c3/7b/cbd5d999a07ff2a21465975d4eb477ae6f69765e8fe8c9087dab250180d8/primp-0.15.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c18b45c23f94016215f62d2334552224236217aaeb716871ce0e4dcfa08eb161", size = 3281018, upload-time = "2025-04-17T11:40:55.308Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6e/a6221c612e61303aec2bcac3f0a02e8b67aee8c0db7bdc174aeb8010f975/primp-0.15.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e985a9cba2e3f96a323722e5440aa9eccaac3178e74b884778e926b5249df080", size = 3255229, upload-time = "2025-04-17T11:40:47.811Z" },
{ url = "https://files.pythonhosted.org/packages/3b/54/bfeef5aca613dc660a69d0760a26c6b8747d8fdb5a7f20cb2cee53c9862f/primp-0.15.0-cp38-abi3-manylinux_2_34_armv7l.whl", hash = "sha256:6b84a6ffa083e34668ff0037221d399c24d939b5629cd38223af860de9e17a83", size = 3014522, upload-time = "2025-04-17T11:40:50.191Z" },
{ url = "https://files.pythonhosted.org/packages/ac/96/84078e09f16a1dad208f2fe0f8a81be2cf36e024675b0f9eec0c2f6e2182/primp-0.15.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:592f6079646bdf5abbbfc3b0a28dac8de943f8907a250ce09398cda5eaebd260", size = 3418567, upload-time = "2025-04-17T11:41:01.595Z" },
{ url = "https://files.pythonhosted.org/packages/6c/80/8a7a9587d3eb85be3d0b64319f2f690c90eb7953e3f73a9ddd9e46c8dc42/primp-0.15.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a728e5a05f37db6189eb413d22c78bd143fa59dd6a8a26dacd43332b3971fe8", size = 3606279, upload-time = "2025-04-17T11:41:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/0c/dd/f0183ed0145e58cf9d286c1b2c14f63ccee987a4ff79ac85acc31b5d86bd/primp-0.15.0-cp38-abi3-win_amd64.whl", hash = "sha256:aeb6bd20b06dfc92cfe4436939c18de88a58c640752cf7f30d9e4ae893cdec32", size = 3149967, upload-time = "2025-04-17T11:41:07.067Z" },
]
[[package]]
name = "propcache"
version = "0.3.2"
@@ -1152,6 +1356,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" },
]
[[package]]
name = "sgmllib3k"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
[[package]]
name = "shellingham"
version = "1.5.4"
@@ -1188,6 +1398,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "socksio"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
]
[[package]]
name = "soupsieve"
version = "2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]]
name = "starlette"
version = "0.48.0"
@@ -1288,6 +1516,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "update-checker"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/0b/1bec4a6cc60d33ce93d11a7bcf1aeffc7ad0aa114986073411be31395c6f/update_checker-0.18.0.tar.gz", hash = "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13", size = 6699, upload-time = "2020-08-04T07:08:50.429Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ba/8dd7fa5f0b1c6a8ac62f8f57f7e794160c1f86f31c6d0fb00f582372a3e4/update_checker-0.18.0-py3-none-any.whl", hash = "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd", size = 7008, upload-time = "2020-08-04T07:08:49.51Z" },
]
[[package]]
name = "upo-app-ai"
version = "0.1.0"
@@ -1295,10 +1535,14 @@ source = { virtual = "." }
dependencies = [
{ name = "agno" },
{ name = "coinbase-advanced-py" },
{ name = "ddgs" },
{ name = "dotenv" },
{ name = "gnews" },
{ name = "google-genai" },
{ name = "gradio" },
{ name = "newsapi-python" },
{ name = "ollama" },
{ name = "praw" },
{ name = "pytest" },
{ name = "python-binance" },
]
@@ -1307,10 +1551,14 @@ dependencies = [
requires-dist = [
{ name = "agno" },
{ name = "coinbase-advanced-py" },
{ name = "ddgs" },
{ name = "dotenv" },
{ name = "gnews" },
{ name = "google-genai" },
{ name = "gradio" },
{ name = "newsapi-python" },
{ name = "ollama" },
{ name = "praw" },
{ name = "pytest" },
{ name = "python-binance" },
]
@@ -1337,6 +1585,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]
[[package]]
name = "websocket-client"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" },
]
[[package]]
name = "websockets"
version = "13.1"