Sistema de trading bot - Semanas 1-2 completadas
- Infraestructura de datos completa - Descarga desde exchanges (CCXT) - Procesamiento y limpieza de datos - Almacenamiento en PostgreSQL - Sistema anti-duplicados - Script de descarga masiva - Tests unitarios - Documentación completa
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
# src/data/fetcher.py
|
||||
# src/data/fetcher.py
|
||||
"""
|
||||
Módulo para obtener datos de exchanges usando CCXT
|
||||
"""
|
||||
@@ -13,7 +14,7 @@ class DataFetcher:
|
||||
"""
|
||||
Clase para obtener datos históricos y en tiempo real de exchanges
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, exchange_name: str, api_key: str = None, api_secret: str = None):
|
||||
"""
|
||||
Inicializa la conexión con el exchange
|
||||
@@ -24,24 +25,39 @@ class DataFetcher:
|
||||
api_secret: API secret (opcional para datos públicos)
|
||||
"""
|
||||
self.exchange_name = exchange_name
|
||||
|
||||
|
||||
try:
|
||||
exchange_class = getattr(ccxt, exchange_name)
|
||||
self.exchange = exchange_class({
|
||||
'apiKey': api_key,
|
||||
'secret': api_secret,
|
||||
|
||||
# Configuración base
|
||||
config = {
|
||||
'enableRateLimit': True, # Importante para evitar bans
|
||||
'options': {
|
||||
'defaultType': 'spot', # spot, future, etc
|
||||
}
|
||||
})
|
||||
log.info(f"Conectado al exchange: {exchange_name}")
|
||||
}
|
||||
|
||||
# Solo añadir API keys si están presentes y no vacías
|
||||
if api_key and api_secret:
|
||||
config['apiKey'] = api_key
|
||||
config['secret'] = api_secret
|
||||
log.info(f"Conectado al exchange: {exchange_name} (con API keys)")
|
||||
else:
|
||||
log.info(f"Conectado al exchange: {exchange_name} (modo público - sin API keys)")
|
||||
|
||||
self.exchange = exchange_class(config)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error conectando a {exchange_name}: {e}")
|
||||
raise
|
||||
|
||||
def fetch_ohlcv(self, symbol: str, timeframe: str = '1h', since: Optional[datetime] = None,
|
||||
limit: int = 500) -> pd.DataFrame:
|
||||
|
||||
def fetch_ohlcv(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str = '1h',
|
||||
since: Optional[datetime] = None,
|
||||
limit: int = 500
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Obtiene datos OHLCV (Open, High, Low, Close, Volume)
|
||||
|
||||
@@ -55,20 +71,20 @@ class DataFetcher:
|
||||
DataFrame con los datos OHLCV
|
||||
"""
|
||||
try:
|
||||
# Convertir datetime a timestamp en ms
|
||||
# Convertir datetime a timestamp en milisegundos
|
||||
since_ms = None
|
||||
if since:
|
||||
since_ms = int(since.timestamp() * 1000)
|
||||
|
||||
|
||||
log.info(f"Obteniendo datos OHLCV: {symbol} {timeframe}")
|
||||
|
||||
|
||||
ohlcv = self.exchange.fetch_ohlcv(
|
||||
symbol,
|
||||
timeframe=timeframe,
|
||||
since=since_ms,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
|
||||
# Convertir a DataFrame
|
||||
df = pd.DataFrame(
|
||||
ohlcv,
|
||||
@@ -85,13 +101,18 @@ class DataFetcher:
|
||||
|
||||
log.success(f"Obtenidos {len(df)} registros de {symbol}")
|
||||
return df
|
||||
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error obteniendo OHLCV para {symbol}: {e}")
|
||||
raise
|
||||
|
||||
def fetch_historical(self, symbol: str, timeframe: str = '1h', days: int = 30,
|
||||
max_retries: int = 3) -> pd.DataFrame:
|
||||
|
||||
def fetch_historical(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str = '1h',
|
||||
days: int = 30,
|
||||
max_retries: int = 3
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Obtiene datos históricos completos (puede requerir múltiples llamadas)
|
||||
|
||||
@@ -109,7 +130,11 @@ class DataFetcher:
|
||||
|
||||
log.info(f"Iniciando descarga histórica: {symbol} desde {since.date()}")
|
||||
|
||||
iteration = 0
|
||||
while True:
|
||||
iteration += 1
|
||||
log.debug(f"Iteración {iteration}: Obteniendo datos desde {since}")
|
||||
|
||||
retry_count = 0
|
||||
success = False
|
||||
|
||||
@@ -120,7 +145,7 @@ class DataFetcher:
|
||||
if df.empty:
|
||||
log.warning(f"No hay más datos disponibles para {symbol}")
|
||||
success = True
|
||||
break
|
||||
break # Salir del while interno
|
||||
|
||||
all_data.append(df)
|
||||
|
||||
@@ -131,7 +156,7 @@ class DataFetcher:
|
||||
# Verificar si ya llegamos al presente
|
||||
if since >= datetime.now():
|
||||
success = True
|
||||
break
|
||||
break # Salir del while interno
|
||||
|
||||
success = True
|
||||
time.sleep(self.exchange.rateLimit / 1000) # Respetar rate limit
|
||||
@@ -143,10 +168,10 @@ class DataFetcher:
|
||||
|
||||
if not success:
|
||||
log.error(f"Falló después de {max_retries} intentos")
|
||||
break
|
||||
break # Salir del while externo
|
||||
|
||||
if since >= datetime.now():
|
||||
break
|
||||
if since >= datetime.now() or df.empty:
|
||||
break # Salir del while externo si no hay más datos
|
||||
|
||||
if not all_data:
|
||||
log.error("No se pudo obtener ningún dato histórico")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Módulo para almacenamiento persistente de datos en PostgreSQL y caché en Redis
|
||||
"""
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine, Column, String, Float, DateTime, Integer, Index
|
||||
from sqlalchemy import create_engine, Column, String, Float, DateTime, Integer, Index, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
@@ -29,12 +29,19 @@ class OHLCV(Base):
|
||||
low = Column(Float, nullable=False)
|
||||
close = Column(Float, nullable=False)
|
||||
volume = Column(Float, nullable=False)
|
||||
returns = Column(Float, nullable=True) # Retornos simples
|
||||
log_returns = Column(Float, nullable=True) # Retornos logarítmicos
|
||||
|
||||
# Índices compuestos para queries rápidas
|
||||
__table_args__ = (
|
||||
Index('idx_symbol_timeframe_timestamp', 'symbol', 'timeframe', 'timestamp'),
|
||||
Index('idx_timestamp', 'timestamp'),
|
||||
# CONSTRAINT único: no permitir duplicados
|
||||
# Una combinación de symbol + timeframe + timestamp debe ser única
|
||||
{'sqlite_autoincrement': True}
|
||||
)
|
||||
|
||||
# Añadir constraint único manualmente en __init__ de StorageManager
|
||||
|
||||
class StorageManager:
|
||||
"""
|
||||
@@ -69,6 +76,20 @@ class StorageManager:
|
||||
# Crear tablas si no existen
|
||||
Base.metadata.create_all(self.engine)
|
||||
|
||||
# Añadir constraint único si no existe (para evitar duplicados)
|
||||
try:
|
||||
with self.engine.connect() as conn:
|
||||
conn.execute(text("""
|
||||
ALTER TABLE ohlcv
|
||||
ADD CONSTRAINT unique_ohlcv
|
||||
UNIQUE (symbol, timeframe, timestamp)
|
||||
"""))
|
||||
conn.commit()
|
||||
log.info("Constraint único añadido a la tabla ohlcv")
|
||||
except Exception as e:
|
||||
# El constraint ya existe o hubo error (no crítico)
|
||||
log.debug(f"Constraint único ya existe o no se pudo añadir: {e}")
|
||||
|
||||
# Crear sesión
|
||||
Session = sessionmaker(bind=self.engine)
|
||||
self.session = Session()
|
||||
@@ -117,22 +138,53 @@ class StorageManager:
|
||||
if df_to_save.columns[0] != 'timestamp':
|
||||
df_to_save.rename(columns={df_to_save.columns[0]: 'timestamp'}, inplace=True)
|
||||
|
||||
# Mantener todas las columnas relevantes
|
||||
allowed_columns = ['timestamp', 'open', 'high', 'low', 'close', 'volume', 'symbol', 'timeframe', 'returns', 'log_returns']
|
||||
df_to_save = df_to_save[[col for col in allowed_columns if col in df_to_save.columns]]
|
||||
|
||||
# Insertar en lotes para mejor performance
|
||||
records_saved = 0
|
||||
records_skipped = 0
|
||||
|
||||
for i in range(0, len(df_to_save), batch_size):
|
||||
batch = df_to_save.iloc[i:i+batch_size]
|
||||
|
||||
# Usar to_sql con if_exists='append' y method='multi'
|
||||
batch.to_sql(
|
||||
'ohlcv',
|
||||
self.engine,
|
||||
if_exists='append',
|
||||
index=False,
|
||||
method='multi'
|
||||
)
|
||||
|
||||
records_saved += len(batch)
|
||||
log.debug(f"Guardados {records_saved}/{len(df_to_save)} registros")
|
||||
try:
|
||||
# Usar to_sql con if_exists='append' y method='multi'
|
||||
batch.to_sql(
|
||||
'ohlcv',
|
||||
self.engine,
|
||||
if_exists='append',
|
||||
index=False,
|
||||
method='multi'
|
||||
)
|
||||
records_saved += len(batch)
|
||||
log.debug(f"Guardados {records_saved}/{len(df_to_save)} registros")
|
||||
|
||||
except Exception as e:
|
||||
# Si hay error de duplicados, intentar uno por uno
|
||||
if 'unique' in str(e).lower() or 'duplicate' in str(e).lower():
|
||||
log.warning(f"Duplicados detectados en batch, insertando uno por uno...")
|
||||
|
||||
for _, row in batch.iterrows():
|
||||
try:
|
||||
row.to_frame().T.to_sql(
|
||||
'ohlcv',
|
||||
self.engine,
|
||||
if_exists='append',
|
||||
index=False
|
||||
)
|
||||
records_saved += 1
|
||||
except Exception:
|
||||
# Este registro ya existe, saltarlo
|
||||
records_skipped += 1
|
||||
continue
|
||||
else:
|
||||
# Otro tipo de error, re-lanzar
|
||||
raise e
|
||||
|
||||
if records_skipped > 0:
|
||||
log.info(f"Saltados {records_skipped} registros duplicados")
|
||||
|
||||
log.success(f"Guardados {records_saved} registros exitosamente")
|
||||
return records_saved
|
||||
|
||||
Reference in New Issue
Block a user