Merge branch 'main' into 2-news-api

This commit is contained in:
Simo
2025-10-01 16:22:08 +02:00
committed by GitHub
19 changed files with 992 additions and 367 deletions

View File

@@ -20,9 +20,8 @@ COINBASE_API_SECRET=
# https://www.cryptocompare.com/cryptopian/api-keys
CRYPTOCOMPARE_API_KEY=
# Binance API per Market Agent
# Ottenibili da: https://www.binance.com/en/my/settings/api-management
# Supporta sia API autenticate che pubbliche (PublicBinance)
# https://www.binance.com/en/my/settings/api-management
# Non necessario per operazioni in sola lettura
BINANCE_API_KEY=
BINANCE_API_SECRET=

View File

@@ -8,6 +8,7 @@ Questo script dimostra l'utilizzo di tutti i wrapper che implementano BaseWrappe
- CryptoCompareWrapper (richiede API key)
- BinanceWrapper (richiede credenziali)
- PublicBinanceAgent (accesso pubblico)
- YFinanceWrapper (accesso gratuito a dati azionari e crypto)
Lo script effettua chiamate GET a diversi provider e visualizza i dati
in modo strutturato con informazioni dettagliate su timestamp, stato
@@ -26,11 +27,11 @@ project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
from dotenv import load_dotenv
from src.app.markets import (
from app.markets import (
CoinBaseWrapper,
CryptoCompareWrapper,
BinanceWrapper,
PublicBinanceAgent,
BinanceWrapper,
YFinanceWrapper,
BaseWrapper
)
@@ -239,13 +240,6 @@ def initialize_providers() -> Dict[str, BaseWrapper]:
providers = {}
env_vars = check_environment_variables()
# PublicBinanceAgent (sempre disponibile)
try:
providers["PublicBinance"] = PublicBinanceAgent()
print("✅ PublicBinanceAgent inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di PublicBinanceAgent: {e}")
# CryptoCompareWrapper
if env_vars["CRYPTOCOMPARE_API_KEY"]:
try:
@@ -267,15 +261,18 @@ def initialize_providers() -> Dict[str, BaseWrapper]:
print("⚠️ CoinBaseWrapper saltato: credenziali Coinbase non complete")
# BinanceWrapper
if env_vars["BINANCE_API_KEY"] and env_vars["BINANCE_API_SECRET"]:
try:
providers["Binance"] = BinanceWrapper()
print("✅ BinanceWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}")
else:
print("⚠️ BinanceWrapper saltato: credenziali Binance non complete")
try:
providers["Binance"] = BinanceWrapper()
print("✅ BinanceWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di BinanceWrapper: {e}")
# YFinanceWrapper (sempre disponibile - dati azionari e crypto gratuiti)
try:
providers["YFinance"] = YFinanceWrapper()
print("✅ YFinanceWrapper inizializzato con successo")
except Exception as e:
print(f"❌ Errore nell'inizializzazione di YFinanceWrapper: {e}")
return providers
def print_summary(results: List[Dict[str, Any]]):

View File

@@ -26,6 +26,7 @@ dependencies = [
# API di exchange di criptovalute
"coinbase-advanced-py",
"python-binance",
"yfinance",
# API di notizie
"newsapi-python",
@@ -35,7 +36,6 @@ dependencies = [
# API di social media
"praw", # Reddit
]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

View File

@@ -1,31 +1,97 @@
from .base import BaseWrapper
from .base import BaseWrapper, ProductInfo, Price
from .coinbase import CoinBaseWrapper
from .binance import BinanceWrapper
from .cryptocompare import CryptoCompareWrapper
from .yfinance import YFinanceWrapper
from .binance_public import PublicBinanceAgent
from app.utils.wrapper_handler import WrapperHandler
__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper" ]
from typing import List, Optional
from agno.tools import Toolkit
# TODO se si vuole usare un aggregatore di dati di mercato, si può aggiungere qui facendo una classe extra (simile a questa) che per ogni chiamata chiama tutti i wrapper e aggrega i risultati
class MarketAPIs(BaseWrapper):
__all__ = [ "MarketAPIs", "BinanceWrapper", "CoinBaseWrapper", "CryptoCompareWrapper", "YFinanceWrapper", "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 operazioni come ottenere informazioni su singoli prodotti, liste di prodotti e dati storici.
Usa un WrapperHandler per gestire più wrapper e tentare chiamate in modo resiliente.
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.
"""
def __init__(self, currency: str = "USD"):
def __init__(self, currency: str = "USD", enable_aggregation: bool = False):
self.currency = currency
wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper ]
wrappers = [ BinanceWrapper, CoinBaseWrapper, CryptoCompareWrapper, YFinanceWrapper ]
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
def get_product(self, 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):
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):
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 = "BTC", limit: int = 100):
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

@@ -3,16 +3,47 @@ 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", 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):

View File

@@ -23,10 +23,7 @@ class BinanceWrapper(BaseWrapper):
def __init__(self, currency: str = "USDT"):
api_key = os.getenv("BINANCE_API_KEY")
assert api_key is not None, "API key is required"
api_secret = os.getenv("BINANCE_API_SECRET")
assert api_secret is not None, "API secret is required"
self.currency = currency
self.client = Client(api_key=api_key, api_secret=api_secret)

View File

@@ -9,7 +9,6 @@ from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from binance.client import Client
from .base import BaseWrapper, ProductInfo, Price
from .error_handler import retry_on_failure, handle_api_errors, MarketAPIError
class PublicBinanceAgent(BaseWrapper):
@@ -38,8 +37,6 @@ class PublicBinanceAgent(BaseWrapper):
return asset_id
return f"{asset_id}USDT"
@retry_on_failure(max_retries=3, delay=1.0)
@handle_api_errors
def get_product(self, asset_id: str) -> ProductInfo:
"""
Ottiene informazioni su un singolo prodotto.
@@ -59,8 +56,6 @@ class PublicBinanceAgent(BaseWrapper):
print(f"Errore nel recupero del prodotto {asset_id}: {e}")
return ProductInfo(id=asset_id, symbol=asset_id)
@retry_on_failure(max_retries=3, delay=1.0)
@handle_api_errors
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
"""
Ottiene informazioni su più prodotti.
@@ -77,8 +72,6 @@ class PublicBinanceAgent(BaseWrapper):
products.append(product)
return products
@retry_on_failure(max_retries=3, delay=1.0)
@handle_api_errors
def get_all_products(self) -> list[ProductInfo]:
"""
Ottiene informazioni su tutti i prodotti disponibili.
@@ -90,8 +83,6 @@ class PublicBinanceAgent(BaseWrapper):
major_assets = ["BTC", "ETH", "BNB", "ADA", "DOT", "LINK", "LTC", "XRP"]
return self.get_products(major_assets)
@retry_on_failure(max_retries=3, delay=1.0)
@handle_api_errors
def get_historical_prices(self, asset_id: str = "BTC") -> list[Price]:
"""
Ottiene i prezzi storici per un asset.

View File

@@ -1,237 +0,0 @@
"""
Modulo per la gestione robusta degli errori nei market providers.
Fornisce decoratori e utilità per:
- Retry automatico con backoff esponenziale
- Logging standardizzato degli errori
- Gestione di timeout e rate limiting
- Fallback tra provider multipli
"""
import time
import logging
from functools import wraps
from typing import Any, Callable, Optional, Type, Union, List
from requests.exceptions import RequestException, Timeout, ConnectionError
from binance.exceptions import BinanceAPIException, BinanceRequestException
from .base import ProductInfo
# Configurazione logging
logger = logging.getLogger(__name__)
class MarketAPIError(Exception):
"""Eccezione base per errori delle API di mercato."""
pass
class RateLimitError(MarketAPIError):
"""Eccezione per errori di rate limiting."""
pass
class AuthenticationError(MarketAPIError):
"""Eccezione per errori di autenticazione."""
pass
class DataNotFoundError(MarketAPIError):
"""Eccezione quando i dati richiesti non sono disponibili."""
pass
def retry_on_failure(
max_retries: int = 3,
delay: float = 1.0,
backoff_factor: float = 2.0,
exceptions: tuple = (RequestException, BinanceAPIException, BinanceRequestException)
) -> Callable:
"""
Decoratore per retry automatico con backoff esponenziale.
Args:
max_retries: Numero massimo di tentativi
delay: Delay iniziale in secondi
backoff_factor: Fattore di moltiplicazione per il delay
exceptions: Tuple di eccezioni da catturare per il retry
Returns:
Decoratore per la funzione
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
last_exception = None
current_delay = delay
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_retries:
logger.error(
f"Function {func.__name__} failed after {max_retries + 1} attempts. "
f"Last error: {str(e)}"
)
raise MarketAPIError(f"Max retries exceeded: {str(e)}") from e
logger.warning(
f"Attempt {attempt + 1}/{max_retries + 1} failed for {func.__name__}: {str(e)}. "
f"Retrying in {current_delay:.1f}s..."
)
time.sleep(current_delay)
current_delay *= backoff_factor
except Exception as e:
# Per eccezioni non previste, non fare retry
logger.error(f"Unexpected error in {func.__name__}: {str(e)}")
raise
# Questo non dovrebbe mai essere raggiunto
if last_exception:
raise last_exception
else:
raise MarketAPIError("Unknown error occurred")
return wrapper
return decorator
def handle_api_errors(func: Callable) -> Callable:
"""
Decoratore per gestione standardizzata degli errori API.
Converte errori specifici dei provider in eccezioni standardizzate.
"""
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
try:
return func(*args, **kwargs)
except BinanceAPIException as e:
if e.code == -1021: # Timestamp error
raise MarketAPIError(f"Binance timestamp error: {e.message}")
elif e.code == -1003: # Rate limit
raise RateLimitError(f"Binance rate limit exceeded: {e.message}")
elif e.code in [-2014, -2015]: # API key errors
raise AuthenticationError(f"Binance authentication error: {e.message}")
else:
raise MarketAPIError(f"Binance API error [{e.code}]: {e.message}")
except ConnectionError as e:
raise MarketAPIError(f"Connection error: {str(e)}")
except Timeout as e:
raise MarketAPIError(f"Request timeout: {str(e)}")
except RequestException as e:
raise MarketAPIError(f"Request error: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error in {func.__name__}: {str(e)}")
raise MarketAPIError(f"Unexpected error: {str(e)}") from e
return wrapper
def safe_execute(
func: Callable,
default_value: Any = None,
log_errors: bool = True,
error_message: Optional[str] = None
) -> Any:
"""
Esegue una funzione in modo sicuro, restituendo un valore di default in caso di errore.
Args:
func: Funzione da eseguire
default_value: Valore da restituire in caso di errore
log_errors: Se loggare gli errori
error_message: Messaggio di errore personalizzato
Returns:
Risultato della funzione o valore di default
"""
try:
return func()
except Exception as e:
if log_errors:
message = error_message or f"Error executing {func.__name__}"
logger.warning(f"{message}: {str(e)}")
return default_value
class ProviderFallback:
"""
Classe per gestire il fallback tra provider multipli.
"""
def __init__(self, providers: List[Any]):
"""
Inizializza con una lista di provider ordinati per priorità.
Args:
providers: Lista di provider ordinati per priorità
"""
self.providers = providers
def execute_with_fallback(
self,
method_name: str,
*args,
**kwargs
) -> list[ProductInfo]:
"""
Esegue un metodo su tutti i provider fino a trovarne uno che funziona.
Args:
method_name: Nome del metodo da chiamare
*args: Argomenti posizionali
**kwargs: Argomenti nominali
Returns:
Risultato del primo provider che funziona
Raises:
MarketAPIError: Se tutti i provider falliscono
"""
last_error = None
for i, provider in enumerate(self.providers):
try:
if hasattr(provider, method_name):
method = getattr(provider, method_name)
result = method(*args, **kwargs)
if i > 0: # Se non è il primo provider
logger.info(f"Fallback successful: used provider {type(provider).__name__}")
return result
else:
logger.warning(f"Provider {type(provider).__name__} doesn't have method {method_name}")
continue
except Exception as e:
last_error = e
logger.warning(
f"Provider {type(provider).__name__} failed for {method_name}: {str(e)}"
)
continue
# Se arriviamo qui, tutti i provider hanno fallito
raise MarketAPIError(
f"All providers failed for method {method_name}. Last error: {str(last_error)}"
)
def validate_response_data(data: Any, required_fields: Optional[List[str]] = None) -> bool:
"""
Valida che i dati di risposta contengano i campi richiesti.
Args:
data: Dati da validare
required_fields: Lista di campi richiesti
Returns:
True se i dati sono validi, False altrimenti
"""
if data is None:
return False
if required_fields is None:
return True
if isinstance(data, dict):
return all(field in data for field in required_fields)
elif hasattr(data, '__dict__'):
return all(hasattr(data, field) for field in required_fields)
return False

214
src/app/markets/yfinance.py Normal file
View File

@@ -0,0 +1,214 @@
import json
from agno.tools.yfinance import YFinanceTools
from .base import BaseWrapper, ProductInfo, Price
def create_product_info(symbol: str, stock_data: dict) -> ProductInfo:
"""
Converte i dati di YFinanceTools in ProductInfo.
"""
product = ProductInfo()
# ID univoco per yfinance
product.id = f"yfinance_{symbol}"
product.symbol = symbol
# Estrai il prezzo corrente - gestisci diversi formati
if 'currentPrice' in stock_data:
product.price = float(stock_data['currentPrice'])
elif 'regularMarketPrice' in stock_data:
product.price = float(stock_data['regularMarketPrice'])
elif 'Current Stock Price' in stock_data:
# Formato: "254.63 USD" - estrai solo il numero
price_str = stock_data['Current Stock Price'].split()[0]
try:
product.price = float(price_str)
except ValueError:
product.price = 0.0
else:
product.price = 0.0
# Volume 24h
if 'volume' in stock_data:
product.volume_24h = float(stock_data['volume'])
elif 'regularMarketVolume' in stock_data:
product.volume_24h = float(stock_data['regularMarketVolume'])
else:
product.volume_24h = 0.0
# Status basato sulla disponibilità dei dati
product.status = "trading" if product.price > 0 else "offline"
# Valuta (default USD)
product.quote_currency = stock_data.get('currency', 'USD') or 'USD'
return product
def create_price_from_history(hist_data: dict, timestamp: str) -> Price:
"""
Converte i dati storici di YFinanceTools in Price.
"""
price = Price()
if timestamp in hist_data:
day_data = hist_data[timestamp]
price.high = float(day_data.get('High', 0.0))
price.low = float(day_data.get('Low', 0.0))
price.open = float(day_data.get('Open', 0.0))
price.close = float(day_data.get('Close', 0.0))
price.volume = float(day_data.get('Volume', 0.0))
price.time = timestamp
return price
class YFinanceWrapper(BaseWrapper):
"""
Wrapper per YFinanceTools che fornisce dati di mercato per azioni, ETF e criptovalute.
Implementa l'interfaccia BaseWrapper per compatibilità con il sistema esistente.
Usa YFinanceTools dalla libreria agno per coerenza con altri wrapper.
"""
def __init__(self, currency: str = "USD"):
self.currency = currency
# Inizializza YFinanceTools - non richiede parametri specifici
self.tool = YFinanceTools()
def _format_symbol(self, asset_id: str) -> str:
"""
Formatta il simbolo per yfinance.
Per crypto, aggiunge '-USD' se non presente.
"""
asset_id = asset_id.upper()
# Se è già nel formato corretto (es: BTC-USD), usa così
if '-' in asset_id:
return asset_id
# Per crypto singole (BTC, ETH), aggiungi -USD
if asset_id in ['BTC', 'ETH', 'ADA', 'SOL', 'DOT', 'LINK', 'UNI', 'AAVE']:
return f"{asset_id}-USD"
# Per azioni, usa il simbolo così com'è
return asset_id
def get_product(self, asset_id: str) -> ProductInfo:
"""
Recupera le informazioni di un singolo prodotto.
"""
symbol = self._format_symbol(asset_id)
# Usa YFinanceTools per ottenere i dati
try:
# Ottieni le informazioni base dello stock
stock_info = self.tool.get_company_info(symbol)
# Se il risultato è una stringa JSON, parsala
if isinstance(stock_info, str):
try:
stock_data = json.loads(stock_info)
except json.JSONDecodeError:
# Se non è JSON valido, prova a ottenere solo il prezzo
price_data_str = self.tool.get_current_stock_price(symbol)
if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit():
price = float(price_data_str)
stock_data = {'currentPrice': price, 'currency': 'USD'}
else:
raise Exception("Dati non validi")
else:
stock_data = stock_info
return create_product_info(symbol, stock_data)
except Exception as e:
# Fallback: prova a ottenere solo il prezzo
try:
price_data_str = self.tool.get_current_stock_price(symbol)
if price_data_str and price_data_str.replace('.', '').replace('-', '').isdigit():
price = float(price_data_str)
minimal_data = {
'currentPrice': price,
'currency': 'USD'
}
return create_product_info(symbol, minimal_data)
else:
raise Exception("Prezzo non disponibile")
except Exception:
# Se tutto fallisce, restituisci un prodotto vuoto
product = ProductInfo()
product.symbol = symbol
product.status = "offline"
return product
def get_products(self, asset_ids: list[str]) -> list[ProductInfo]:
"""
Recupera le informazioni di multiple assets.
"""
products = []
for asset_id in asset_ids:
try:
product = self.get_product(asset_id)
products.append(product)
except Exception as e:
# Se un asset non è disponibile, continua con gli altri
continue
return products
def get_all_products(self) -> list[ProductInfo]:
"""
Recupera tutti i prodotti disponibili.
Restituisce una lista predefinita di asset popolari.
"""
# Lista di asset popolari (azioni, ETF, crypto)
popular_assets = [
'BTC', 'ETH', 'ADA', 'SOL', 'DOT',
'AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN',
'SPY', 'QQQ', 'VTI', 'GLD', 'VIX'
]
return self.get_products(popular_assets)
def get_historical_prices(self, asset_id: str = "BTC", limit: int = 100) -> list[Price]:
"""
Recupera i dati storici di prezzo per un asset.
"""
symbol = self._format_symbol(asset_id)
try:
# Determina il periodo appropriato in base al limite
if limit <= 7:
period = "1d"
interval = "15m"
elif limit <= 30:
period = "5d"
interval = "1h"
elif limit <= 90:
period = "1mo"
interval = "1d"
else:
period = "3mo"
interval = "1d"
# Ottieni i dati storici
hist_data = self.tool.get_historical_stock_prices(symbol, period=period, interval=interval)
if isinstance(hist_data, str):
hist_data = json.loads(hist_data)
# Il formato dei dati è {timestamp: {Open: x, High: y, Low: z, Close: w, Volume: v}}
prices = []
timestamps = sorted(hist_data.keys())[-limit:] # Prendi gli ultimi 'limit' timestamp
for timestamp in timestamps:
price = create_price_from_history(hist_data, timestamp)
if price.close > 0: # Solo se ci sono dati validi
prices.append(price)
return prices
except Exception as e:
# Se fallisce, restituisci lista vuota
return []

View File

@@ -6,7 +6,6 @@ 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.markets import MarketAPIs
from app.models import AppModels
from app.predictor import PredictorStyle, PredictorInput, PredictorOutput, PREDICTOR_INSTRUCTIONS

View File

@@ -1,5 +1,5 @@
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

View File

@@ -0,0 +1,186 @@
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"
elif "yfinance" in product.id.lower() or "yf" in product.id.lower():
return "yfinance"
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,71 +0,0 @@
import statistics
from typing import Dict, Any
class MarketAggregator:
"""
Aggrega dati di mercato da più provider e genera segnali e analisi avanzate.
"""
@staticmethod
def aggregate(symbol: str, sources: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
prices = []
volumes = []
price_map = {}
for provider, data in sources.items():
price = data.get('price')
if price is not None:
prices.append(price)
price_map[provider] = price
volume = data.get('volume')
if volume is not None:
volumes.append(MarketAggregator._parse_volume(volume))
# Aggregated price (mean)
agg_price = statistics.mean(prices) if prices else None
# Spread analysis
spread = (max(prices) - min(prices)) / agg_price if prices and agg_price else 0
# Confidence
stddev = statistics.stdev(prices) if len(prices) > 1 else 0
confidence = max(0.5, 1 - (stddev / agg_price)) if agg_price else 0
if spread < 0.005:
confidence += 0.1
if len(prices) >= 3:
confidence += 0.05
confidence = min(confidence, 1.0)
# Volume trend
total_volume = sum(volumes) if volumes else None
# Price divergence
max_deviation = (max(prices) - min(prices)) / agg_price if prices and agg_price else 0
# Signals
signals = {
"spread_analysis": f"Low spread ({spread:.2%}) indicates healthy liquidity" if spread < 0.01 else f"Spread {spread:.2%} - check liquidity",
"volume_trend": f"Combined volume: {total_volume:.2f}" if total_volume else "Volume data not available",
"price_divergence": f"Max deviation: {max_deviation:.2%} - {'Normal range' if max_deviation < 0.01 else 'High divergence'}"
}
return {
"aggregated_data": {
f"{symbol}_USD": {
"price": round(agg_price, 2) if agg_price else None,
"confidence": round(confidence, 2),
"sources_count": len(prices)
}
},
"individual_sources": price_map,
"market_signals": signals
}
@staticmethod
def _parse_volume(volume: Any) -> float:
# Supporta stringhe tipo "1.2M" o numeri
if isinstance(volume, (int, float)):
return float(volume)
if isinstance(volume, str):
v = volume.upper().replace(' ', '')
if v.endswith('M'):
return float(v[:-1]) * 1_000_000
if v.endswith('K'):
return float(v[:-1]) * 1_000
try:
return float(v)
except Exception as e:
print(f"Errore nel parsing del volume: {e}")
return 0.0
return 0.0

View File

@@ -0,0 +1,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 MarketAPIsTool
self._market_apis = MarketAPIsTool(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,93 @@
import os
import pytest
from app.markets import YFinanceWrapper
@pytest.mark.market
@pytest.mark.api
class TestYFinance:
def test_yfinance_init(self):
market = YFinanceWrapper()
assert market is not None
assert hasattr(market, 'currency')
assert market.currency == "USD"
assert hasattr(market, 'tool')
assert market.tool is not None
def test_yfinance_get_product(self):
market = YFinanceWrapper()
product = market.get_product("AAPL")
assert product is not None
assert hasattr(product, 'symbol')
assert product.symbol == "AAPL"
assert hasattr(product, 'price')
assert product.price > 0
assert hasattr(product, 'status')
assert product.status == "trading"
def test_yfinance_get_crypto_product(self):
market = YFinanceWrapper()
product = market.get_product("BTC")
assert product is not None
assert hasattr(product, 'symbol')
# BTC verrà convertito in BTC-USD dal formattatore
assert product.symbol in ["BTC", "BTC-USD"]
assert hasattr(product, 'price')
assert product.price > 0
def test_yfinance_get_products(self):
market = YFinanceWrapper()
products = market.get_products(["AAPL", "GOOGL"])
assert products is not None
assert isinstance(products, list)
assert len(products) == 2
symbols = [p.symbol for p in products]
assert "AAPL" in symbols
assert "GOOGL" in symbols
for product in products:
assert hasattr(product, 'price')
assert product.price > 0
def test_yfinance_get_all_products(self):
market = YFinanceWrapper()
products = market.get_all_products()
assert products is not None
assert isinstance(products, list)
assert len(products) > 0
# Dovrebbe contenere asset popolari
symbols = [p.symbol for p in products]
assert "AAPL" in symbols # Apple dovrebbe essere nella lista
for product in products:
assert hasattr(product, 'symbol')
assert hasattr(product, 'price')
def test_yfinance_invalid_product(self):
market = YFinanceWrapper()
# Per YFinance, un prodotto invalido dovrebbe restituire un prodotto offline
product = market.get_product("INVALIDSYMBOL123")
assert product is not None
assert product.status == "offline"
def test_yfinance_history(self):
market = YFinanceWrapper()
history = market.get_historical_prices("AAPL", 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
def test_yfinance_crypto_history(self):
market = YFinanceWrapper()
history = market.get_historical_prices("BTC", limit=3)
assert history is not None
assert isinstance(history, list)
assert len(history) == 3
for entry in history:
assert hasattr(entry, 'time')
assert hasattr(entry, 'close')
assert entry.close > 0

View File

@@ -24,6 +24,7 @@ def pytest_configure(config:pytest.Config):
("limited", "marks tests that have limited execution due to API constraints"),
("wrapper", "marks tests for wrapper handler"),
("tools", "marks tests for tools"),
("aggregator", "marks tests for market data aggregator"),
]
for marker in markers:
line = f"{marker[0]}: {marker[1]}"

View File

@@ -1,12 +1,14 @@
import os
import pytest
from app.agents.market_agent import MarketToolkit
from app.markets import MarketAPIs
from app.markets import MarketAPIsTool
@pytest.mark.limited # usa molte api calls e non voglio esaurire le chiavi api
class TestMarketAPIs:
@pytest.mark.tools
@pytest.mark.api
class TestMarketAPIsTool:
def test_wrapper_initialization(self):
market_wrapper = MarketAPIs("USD")
market_wrapper = MarketAPIsTool("USD")
assert market_wrapper is not None
assert hasattr(market_wrapper, 'get_product')
assert hasattr(market_wrapper, 'get_products')
@@ -14,7 +16,7 @@ class TestMarketAPIs:
assert hasattr(market_wrapper, 'get_historical_prices')
def test_wrapper_capabilities(self):
market_wrapper = MarketAPIs("USD")
market_wrapper = MarketAPIsTool("USD")
capabilities = []
if hasattr(market_wrapper, 'get_product'):
capabilities.append('single_product')
@@ -25,7 +27,7 @@ class TestMarketAPIs:
assert len(capabilities) > 0
def test_market_data_retrieval(self):
market_wrapper = MarketAPIs("USD")
market_wrapper = MarketAPIsTool("USD")
btc_product = market_wrapper.get_product("BTC")
assert btc_product is not None
assert hasattr(btc_product, 'symbol')
@@ -55,14 +57,14 @@ class TestMarketAPIs:
def test_error_handling(self):
try:
market_wrapper = MarketAPIs("USD")
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 = MarketAPIs("USD")
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.aggregator
@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"

85
uv.lock generated
View File

@@ -325,6 +325,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" },
]
[[package]]
name = "curl-cffi"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" },
{ url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" },
{ url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" },
{ url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" },
{ url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" },
{ url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" },
]
[[package]]
name = "dateparser"
version = "1.2.2"
@@ -428,6 +449,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
]
[[package]]
name = "frozendict"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416, upload-time = "2024-10-13T12:15:32.449Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148, upload-time = "2024-10-13T12:15:26.839Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146, upload-time = "2024-10-13T12:15:28.16Z" },
]
[[package]]
name = "frozenlist"
version = "1.7.0"
@@ -933,6 +964,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" },
]
[[package]]
name = "peewee"
version = "3.18.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" }
[[package]]
name = "pillow"
version = "11.3.0"
@@ -952,6 +989,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
]
[[package]]
name = "platformdirs"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1028,6 +1074,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
]
[[package]]
name = "protobuf"
version = "6.32.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635, upload-time = "2025-09-11T21:38:42.935Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/98/645183ea03ab3995d29086b8bf4f7562ebd3d10c9a4b14ee3f20d47cfe50/protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085", size = 424411, upload-time = "2025-09-11T21:38:27.427Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f3/6f58f841f6ebafe076cebeae33fc336e900619d34b1c93e4b5c97a81fdfa/protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1", size = 435738, upload-time = "2025-09-11T21:38:30.959Z" },
{ url = "https://files.pythonhosted.org/packages/10/56/a8a3f4e7190837139e68c7002ec749190a163af3e330f65d90309145a210/protobuf-6.32.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8c7e6eb619ffdf105ee4ab76af5a68b60a9d0f66da3ea12d1640e6d8dab7281", size = 426454, upload-time = "2025-09-11T21:38:34.076Z" },
{ url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874, upload-time = "2025-09-11T21:38:35.509Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013, upload-time = "2025-09-11T21:38:37.017Z" },
{ url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289, upload-time = "2025-09-11T21:38:41.234Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
@@ -1545,6 +1605,7 @@ dependencies = [
{ name = "praw" },
{ name = "pytest" },
{ name = "python-binance" },
{ name = "yfinance" },
]
[package.metadata]
@@ -1561,6 +1622,7 @@ requires-dist = [
{ name = "praw" },
{ name = "pytest" },
{ name = "python-binance" },
{ name = "yfinance" },
]
[[package]]
@@ -1644,3 +1706,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" },
{ url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" },
]
[[package]]
name = "yfinance"
version = "0.2.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "curl-cffi" },
{ name = "frozendict" },
{ name = "multitasking" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "peewee" },
{ name = "platformdirs" },
{ name = "protobuf" },
{ name = "pytz" },
{ name = "requests" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/73/50450b9906c5137d2d02fde6f7360865366c72baea1f8d0550cc990829ce/yfinance-0.2.66.tar.gz", hash = "sha256:fae354cc1649109444b2c84194724afcc52c2a7799551ce44c739424ded6af9c", size = 132820, upload-time = "2025-09-17T11:22:35.422Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/bf/7c0c89ff8ba53592b9cb5157f70e90d8bbb04d60094fc4f10035e158b981/yfinance-0.2.66-py2.py3-none-any.whl", hash = "sha256:511a1a40a687f277aae3a02543009a8aeaa292fce5509671f58915078aebb5c7", size = 123427, upload-time = "2025-09-17T11:22:33.972Z" },
]