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:
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
14
src/app.py
14
src/app.py
@@ -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}")
|
||||
|
||||
90
src/app/agents/market_agent.py
Normal file
90
src/app/agents/market_agent.py
Normal 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}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
32
src/app/news/__init__.py
Normal 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
35
src/app/news/base.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Article(BaseModel):
|
||||
source: str = ""
|
||||
time: str = ""
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
|
||||
class NewsWrapper:
|
||||
"""
|
||||
Base class for news API wrappers.
|
||||
All news API wrappers should inherit from this class and implement the methods.
|
||||
"""
|
||||
|
||||
def get_top_headlines(self, 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")
|
||||
|
||||
77
src/app/news/cryptopanic_api.py
Normal file
77
src/app/news/cryptopanic_api.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import requests
|
||||
from enum import Enum
|
||||
from .base import NewsWrapper, Article
|
||||
|
||||
class CryptoPanicFilter(Enum):
|
||||
RISING = "rising"
|
||||
HOT = "hot"
|
||||
BULLISH = "bullish"
|
||||
BEARISH = "bearish"
|
||||
IMPORTANT = "important"
|
||||
SAVED = "saved"
|
||||
LOL = "lol"
|
||||
ANY = ""
|
||||
|
||||
class CryptoPanicKind(Enum):
|
||||
NEWS = "news"
|
||||
MEDIA = "media"
|
||||
ALL = "all"
|
||||
|
||||
def get_articles(response: dict) -> list[Article]:
|
||||
articles = []
|
||||
if 'results' in response:
|
||||
for item in response['results']:
|
||||
article = Article()
|
||||
article.source = item.get('source', {}).get('title', '')
|
||||
article.time = item.get('published_at', '')
|
||||
article.title = item.get('title', '')
|
||||
article.description = item.get('description', '')
|
||||
articles.append(article)
|
||||
return articles
|
||||
|
||||
class CryptoPanicWrapper(NewsWrapper):
|
||||
"""
|
||||
A wrapper for the CryptoPanic API (Documentation: https://cryptopanic.com/developers/api/)
|
||||
Requires an API key set in the environment variable CRYPTOPANIC_API_KEY.
|
||||
It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/month).
|
||||
Supports different plan types via the CRYPTOPANIC_API_PLAN environment variable (developer, growth, enterprise).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.getenv("CRYPTOPANIC_API_KEY", "")
|
||||
assert self.api_key, "CRYPTOPANIC_API_KEY environment variable not set"
|
||||
|
||||
# Set here for the future, but currently not needed
|
||||
plan_type = os.getenv("CRYPTOPANIC_API_PLAN", "developer").lower()
|
||||
assert plan_type in ["developer", "growth", "enterprise"], "Invalid CRYPTOPANIC_API_PLAN value"
|
||||
|
||||
self.base_url = f"https://cryptopanic.com/api/{plan_type}/v2"
|
||||
self.filter = CryptoPanicFilter.ANY
|
||||
self.kind = CryptoPanicKind.NEWS
|
||||
|
||||
def get_base_params(self) -> dict[str, str]:
|
||||
params = {}
|
||||
params['public'] = 'true' # recommended for app and bots
|
||||
params['auth_token'] = self.api_key
|
||||
params['kind'] = self.kind.value
|
||||
if self.filter != CryptoPanicFilter.ANY:
|
||||
params['filter'] = self.filter.value
|
||||
return params
|
||||
|
||||
def set_filter(self, filter: CryptoPanicFilter):
|
||||
self.filter = filter
|
||||
|
||||
def get_top_headlines(self, 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]
|
||||
32
src/app/news/duckduckgo.py
Normal file
32
src/app/news/duckduckgo.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
from .base import Article, NewsWrapper
|
||||
from agno.tools.duckduckgo import DuckDuckGoTools
|
||||
|
||||
def create_article(result: dict) -> Article:
|
||||
article = Article()
|
||||
article.source = result.get("source", "")
|
||||
article.time = result.get("date", "")
|
||||
article.title = result.get("title", "")
|
||||
article.description = result.get("body", "")
|
||||
return article
|
||||
|
||||
class DuckDuckGoWrapper(NewsWrapper):
|
||||
"""
|
||||
A wrapper for DuckDuckGo News search using the Tool from agno.tools.duckduckgo.
|
||||
It can be rewritten to use direct API calls if needed in the future, but currently is easy to write and use.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tool = DuckDuckGoTools()
|
||||
self.query = "crypto"
|
||||
|
||||
def get_top_headlines(self, 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
36
src/app/news/gnews_api.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from gnews import GNews
|
||||
from .base import Article, NewsWrapper
|
||||
|
||||
def result_to_article(result: dict) -> Article:
|
||||
article = Article()
|
||||
article.source = result.get("source", "")
|
||||
article.time = result.get("publishedAt", "")
|
||||
article.title = result.get("title", "")
|
||||
article.description = result.get("description", "")
|
||||
return article
|
||||
|
||||
class GoogleNewsWrapper(NewsWrapper):
|
||||
"""
|
||||
A wrapper for the Google News RSS Feed (Documentation: https://github.com/ranahaani/GNews/?tab=readme-ov-file#about-gnews)
|
||||
It does not require an API key and is free to use.
|
||||
"""
|
||||
|
||||
def get_top_headlines(self, 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
50
src/app/news/news_api.py
Normal 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
83
src/app/pipeline.py
Normal 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]
|
||||
@@ -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
1
src/app/social/__init.py
Normal file
@@ -0,0 +1 @@
|
||||
from .base import SocialWrapper
|
||||
22
src/app/social/base.py
Normal file
22
src/app/social/base.py
Normal 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
53
src/app/social/reddit.py
Normal 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]
|
||||
|
||||
@@ -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]
|
||||
0
src/app/toolkits/__init__.py
Normal file
0
src/app/toolkits/__init__.py
Normal 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():
|
||||
184
src/app/utils/aggregated_models.py
Normal file
184
src/app/utils/aggregated_models.py
Normal 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 []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
184
src/app/utils/market_data_aggregator.py
Normal file
184
src/app/utils/market_data_aggregator.py
Normal 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)}
|
||||
}
|
||||
110
src/app/utils/wrapper_handler.py
Normal file
110
src/app/utils/wrapper_handler.py
Normal 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)
|
||||
Reference in New Issue
Block a user