docs: update backtesting research, optimizer, ADX and visual analysis

This commit is contained in:
DaM
2026-01-28 16:24:34 +01:00
parent 1add69eb56
commit e15074c0a7
10 changed files with 396 additions and 408 deletions

108
README.md
View File

@@ -53,20 +53,66 @@ Bot de trading algorítmico desarrollado desde cero con Python, PostgreSQL y Mac
- ✅ Simulación de comisiones y slippage - ✅ Simulación de comisiones y slippage
- ✅ Gestión de capital y position sizing - ✅ Gestión de capital y position sizing
**Datos descargados actualmente:** ### 🔬 Research y Optimización (ACTUAL)
**Estado actual: research cuantitativo serio (in-sample).**
#### 📊 Datos históricos actuales
- 5 criptomonedas (BTC, ETH, BNB, SOL, XRP) - 5 criptomonedas (BTC, ETH, BNB, SOL, XRP)
- 3 timeframes (1h, 4h, 1d) - 3 timeframes (1h, 4h, 1d)
- 120 días de histórico - ≈ 3 años de histórico
- ~54,000 registros totales - ~26.000 velas por símbolo en 1h
- Datos limpios, validados y persistidos
#### 📈 Indicadores calculados y almacenados
- returns
- log_returns
- **ADX (Average Directional Index)**
→ calculado una sola vez en el pipeline de datos y guardado en PostgreSQL
#### 🔧 Optimización de parámetros
- Grid search exhaustivo sobre:
- fast_period
- slow_period
- tipo de media (SMA / EMA)
- uso de ADX
- umbral de ADX
- Métrica principal de selección: **Sharpe Ratio**
- Optimización **in-sample** (aún sin walk-forward)
**Mejor configuración encontrada (BTC/USDT 1h):**
- Fast MA: 15
- Slow MA: 50
- MA Type: SMA
- ADX activo
- ADX threshold: 30
- Sharpe ≈ 0.24
- Return ≈ +188%
- Max Drawdown ≈ -24%
- Total trades: 17
#### 📊 Visualización de resultados
- Equity curve
- Drawdown
- Distribución de retornos por trade
- Trades sobre el precio
- Dashboard consolidado
Conclusión preliminar:
> Menos trades + filtro de tendencia = mayor robustez.
### 🔄 En Progreso ### 🔄 En Progreso
-Optimización de parámetros (en desarrollo) -Walk-forward validation (en desarrollo)
-Visualizaciones de resultados (en desarrollo) -Position sizing dinámico (en desarrollo)
- ⏳ Stops dinámicos (en desarrollo)
- ⏳ Portfolio multi-activo (en desarrollo)
- ⏳ Machine Learning (Semanas 5-8 planificadas) - ⏳ Machine Learning (Semanas 5-8 planificadas)
### 📅 Planificado ### 📅 Planificado
- 📋 Machine Learning (Semanas 5-8 planificadas)
- 📋 Live trading con paper trading - 📋 Live trading con paper trading
- 📋 Gestión de riesgo avanzada - 📋 Gestión de riesgo avanzada
- 📋 Optimización de estrategias - 📋 Optimización de estrategias
@@ -379,40 +425,44 @@ print_backtest_report(results)
``` ```
trading-bot/ trading-bot/
├── config/ # Configuración ├── config/ # Configuración
│ ├── settings.yaml # Configuración general │ ├── settings.yaml # Configuración general
│ └── secrets.env # Credenciales (NO subir a git) │ └── secrets.env # Credenciales (NO subir a git)
├── src/ # Código fuente ├── src/ # Código fuente
│ ├── backtest/ # Motor de backtesting │ ├── backtest/ # Motor de backtesting
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── engine.py # BacktestEngine │ │ ├── engine.py # BacktestEngine
│ │ ├── strategy.py # Clase base Strategy │ │ ├── metrics.py # Métricas de performance
│ │ ├── trade.py # Trade, Position │ │ ├── optimizer.py # Optimizador de parámetros
│ │ ── metrics.py # Métricas de performance │ │ ── strategy.py # Clase base Strategy
│ │ ├── trade.py # Trade, Position
│ │ └── visualizer.py # Visualizador de estrategias
│ │ │ │
│ ├── strategies/ # Estrategias de trading │ ├── strategies/ # Estrategias de trading
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── moving_average.py # MA Crossover │ │ ├── moving_average.py # MA Crossover
│ │ ├── rsi_strategy.py # RSI Strategy │ │ ├── rsi_strategy.py # RSI Strategy
│ │ ├── buy_and_hold.py # Buy & Hold │ │ ├── buy_and_hold.py # Buy & Hold
│ │ ├── base.py # (futuro) │ │ ├── base.py # (futuro)
│ │ ├── ml_model.py # (futuro) │ │ ├── ml_model.py # (futuro)
│ │ └── signals.py # (futuro) │ │ └── signals.py # (futuro)
│ │ │ │
│ ├── data/ # Módulo de datos │ ├── data/ # Módulo de datos
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── fetcher.py # Descarga desde exchanges │ │ ├── fetcher.py # Descarga desde exchanges
│ │ ├── processor.py # Limpieza y procesamiento │ │ ├── processor.py # Limpieza y procesamiento
│ │ └── storage.py # PostgreSQL + Redis │ │ └── storage.py # PostgreSQL + Redis
│ │ │ │
│ └── utils/ # Utilidades │ └── utils/ # Utilidades
│ ├── __init__.py │ ├── __init__.py
│ ├── logger.py # Sistema de logging │ ├── logger.py # Sistema de logging
│ └── alerts.py # (futuro) │ └── alerts.py # (futuro)
├── tests/ # Tests unitarios ├── tests/ # Tests unitarios
── test_data.py ── test_data.py
│ ├── test_optimizer.py
│ └── test_visualizer.py
├── data/ # Datos locales ├── data/ # Datos locales
│ ├── historical/ # Backups (futuro) │ ├── historical/ # Backups (futuro)
@@ -427,6 +477,7 @@ trading-bot/
├── backtest.py # Backtesting runner ├── backtest.py # Backtesting runner
├── requirements.txt # Dependencias ├── requirements.txt # Dependencias
├── .gitignore ├── .gitignore
├── BACKTESTING.md
└── README.md └── README.md
``` ```
@@ -486,6 +537,7 @@ CREATE TABLE ohlcv (
volume FLOAT NOT NULL, volume FLOAT NOT NULL,
returns FLOAT, -- Retornos simples returns FLOAT, -- Retornos simples
log_returns FLOAT, -- Retornos logarítmicos log_returns FLOAT, -- Retornos logarítmicos
adx FLOAT,
CONSTRAINT unique_ohlcv UNIQUE (symbol, timeframe, timestamp) CONSTRAINT unique_ohlcv UNIQUE (symbol, timeframe, timestamp)
); );
@@ -629,7 +681,7 @@ pytest tests/test_data.py::TestDataProcessor::test_clean_data_removes_duplicates
### 🔄 Fase 3: Optimización y Visualización (EN PROGRESO) ### 🔄 Fase 3: Optimización y Visualización (EN PROGRESO)
- Optimización de parámetros (grid search) - Optimización de parámetros (grid search)
- Visualizaciones de resultados - Visualizaciones de resultados
- Gráficos de equity curve - Filtros de mercado (ADX)
- Walk-forward analysis - Walk-forward analysis
### 📅 Fase 4: Estrategias Avanzadas (Semanas 5-8) ### 📅 Fase 4: Estrategias Avanzadas (Semanas 5-8)
@@ -757,9 +809,11 @@ Para dudas sobre el código o siguientes fases de desarrollo, consulta conmigo.
--- ---
**Versión actual:** 0.3.0 (Semanas 1-4 completadas) **Versión actual:** 0.4.0 (Semanas 1-4 completadas)
**Última actualización:** Enero 2026 **Última actualización:** Enero 2026
**Python:** 3.12.3 **Python:** 3.12.3
**PostgreSQL:** 16+ **PostgreSQL:** 16+
**Datos:** 5 símbolos, 3 timeframes, 120 días (~54k registros) **Datos:** 5 símbolos, 3 timeframes, ~3 años
**Estrategias:** 3 implementadas (MA Cross, RSI, Buy&Hold) **Estrategias:** 3 implementadas (MA Cross, RSI, Buy&Hold)
**Research:** Optimización + ADX + Visualización
**Estado:** Research cuantitativo (NO trading real)

View File

@@ -4,7 +4,7 @@ Script para descargar datos históricos de múltiples símbolos y timeframes
""" """
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from src.utils.logger import log from src.utils.logger import log
from src.data.fetcher import DataFetcher from src.data.fetcher import DataFetcher
@@ -47,13 +47,16 @@ def download_multiple_symbols():
] ]
# Días históricos # Días históricos
days_back = 120 # 4 meses # days_back = 120 # 4 meses
END_DATE = datetime.utcnow()
START_DATE = END_DATE - timedelta(days=365 * 3)
log.info(f"\n📊 Configuración:") log.info(f"\n📊 Configuración:")
log.info(f" Exchange: {exchange_name}") log.info(f" Exchange: {exchange_name}")
log.info(f" Símbolos: {len(symbols)}{symbols}") log.info(f" Símbolos: {len(symbols)}{symbols}")
log.info(f" Timeframes: {timeframes}") log.info(f" Timeframes: {timeframes}")
log.info(f" Días históricos: {days_back}") # log.info(f" Días históricos: {days_back}")
log.info(f" Días históricos: from {START_DATE} until {END_DATE}")
log.info(f" Total descargas: {len(symbols) * len(timeframes)}") log.info(f" Total descargas: {len(symbols) * len(timeframes)}")
# Confirmar # Confirmar
@@ -116,7 +119,9 @@ def download_multiple_symbols():
df = fetcher.fetch_historical( df = fetcher.fetch_historical(
symbol=symbol, symbol=symbol,
timeframe=timeframe, timeframe=timeframe,
days=days_back # days=days_back
since=START_DATE,
until=END_DATE
) )
if df.empty: if df.empty:
@@ -128,6 +133,7 @@ def download_multiple_symbols():
log.info(f"🧹 Procesando datos...") log.info(f"🧹 Procesando datos...")
df_clean = processor.clean_data(df) df_clean = processor.clean_data(df)
df_clean = processor.calculate_returns(df_clean) df_clean = processor.calculate_returns(df_clean)
df_clean = processor.calculate_indicators(df_clean)
# Guardar # Guardar
log.info(f"💾 Guardando en base de datos...") log.info(f"💾 Guardando en base de datos...")

View File

@@ -16,12 +16,15 @@ greenlet==3.3.0
idna==3.11 idna==3.11
iniconfig==2.3.0 iniconfig==2.3.0
kiwisolver==1.4.9 kiwisolver==1.4.9
llvmlite==0.44.0
loguru==0.7.2 loguru==0.7.2
matplotlib==3.10.8 matplotlib==3.10.8
multidict==6.7.0 multidict==6.7.0
numpy==1.26.4 numba==0.61.2
numpy==2.2.6
packaging==26.0 packaging==26.0
pandas==2.1.4 pandas==3.0.0
pandas-ta==0.4.71b0
pillow==12.1.0 pillow==12.1.0
pluggy==1.6.0 pluggy==1.6.0
propcache==0.4.1 propcache==0.4.1
@@ -39,7 +42,8 @@ requests==2.32.5
seaborn==0.13.2 seaborn==0.13.2
setuptools==80.10.1 setuptools==80.10.1
six==1.17.0 six==1.17.0
SQLAlchemy==2.0.23 SQLAlchemy==2.0.46
tqdm==4.67.1
typing_extensions==4.15.0 typing_extensions==4.15.0
tzdata==2025.3 tzdata==2025.3
urllib3==2.6.3 urllib3==2.6.3

View File

@@ -1,5 +1,4 @@
# src/data/fetcher.py # src/data/fetcher.py
# src/data/fetcher.py
""" """
Módulo para obtener datos de exchanges usando CCXT Módulo para obtener datos de exchanges usando CCXT
""" """
@@ -110,7 +109,9 @@ class DataFetcher:
self, self,
symbol: str, symbol: str,
timeframe: str = '1h', timeframe: str = '1h',
days: int = 30, since: Optional[datetime] = None,
until: Optional[datetime] = None,
days: Optional[int] = None,
max_retries: int = 3 max_retries: int = 3
) -> pd.DataFrame: ) -> pd.DataFrame:
""" """
@@ -126,7 +127,13 @@ class DataFetcher:
DataFrame con todos los datos históricos DataFrame con todos los datos históricos
""" """
all_data = [] all_data = []
since = datetime.now() - timedelta(days=days) if since is None:
if days is None:
raise ValueError("Debes proporcionar 'since' o 'days'")
since = datetime.utcnow() - timedelta(days=days)
if until is None:
until = datetime.utcnow()
log.info(f"Iniciando descarga histórica: {symbol} desde {since.date()}") log.info(f"Iniciando descarga histórica: {symbol} desde {since.date()}")
@@ -151,7 +158,8 @@ class DataFetcher:
# Actualizar 'since' al último timestamp + 1 # Actualizar 'since' al último timestamp + 1
last_timestamp = df.index[-1] last_timestamp = df.index[-1]
since = last_timestamp + pd.Timedelta(seconds=1) timeframe_seconds = self.exchange.parse_timeframe(timeframe)
since = last_timestamp + pd.Timedelta(seconds=timeframe_seconds)
# Verificar si ya llegamos al presente # Verificar si ya llegamos al presente
if since >= datetime.now(): if since >= datetime.now():

View File

@@ -3,6 +3,7 @@
Módulo para limpiar, validar y procesar datos de mercado Módulo para limpiar, validar y procesar datos de mercado
""" """
import pandas as pd import pandas as pd
import pandas_ta as ta
import numpy as np import numpy as np
from typing import Optional from typing import Optional
from ..utils.logger import log from ..utils.logger import log
@@ -267,3 +268,22 @@ class DataProcessor:
log.debug(f"Datos normalizados usando {method}") log.debug(f"Datos normalizados usando {method}")
return df_norm return df_norm
@staticmethod
def calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""
Calcula indicadores técnicos (ADX, etc.)
"""
adx = ta.adx(
high=df['high'],
low=df['low'],
close=df['close'],
length=14
)
df = df.copy()
df['adx'] = adx['ADX_14']
log.debug("Indicadores técnicos calculados (ADX)")
return df

View File

@@ -3,46 +3,42 @@
Módulo para almacenamiento persistente de datos en PostgreSQL y caché en Redis Módulo para almacenamiento persistente de datos en PostgreSQL y caché en Redis
""" """
import pandas as pd import pandas as pd
from sqlalchemy import create_engine, Column, String, Float, DateTime, Integer, Index, text from sqlalchemy import (
create_engine, Column, String, Float,
DateTime, Integer, Index, text
)
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional
import redis import redis
import json
from ..utils.logger import log from ..utils.logger import log
Base = declarative_base() Base = declarative_base()
class OHLCV(Base): class OHLCV(Base):
"""
Modelo de tabla para datos OHLCV
"""
__tablename__ = 'ohlcv' __tablename__ = 'ohlcv'
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True)
timestamp = Column(DateTime, nullable=False) timestamp = Column(DateTime, nullable=False)
symbol = Column(String(20), nullable=False) symbol = Column(String(20), nullable=False)
timeframe = Column(String(10), nullable=False) timeframe = Column(String(10), nullable=False)
open = Column(Float, nullable=False) open = Column(Float, nullable=False)
high = Column(Float, nullable=False) high = Column(Float, nullable=False)
low = Column(Float, nullable=False) low = Column(Float, nullable=False)
close = Column(Float, nullable=False) close = Column(Float, nullable=False)
volume = 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 returns = Column(Float)
log_returns = Column(Float)
adx = Column(Float)
__table_args__ = ( __table_args__ = (
Index('idx_symbol_timeframe_timestamp', 'symbol', 'timeframe', 'timestamp'), Index('idx_symbol_tf_ts', 'symbol', 'timeframe', 'timestamp', unique=True),
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: class StorageManager:
""" """
Gestor de almacenamiento con PostgreSQL y Redis Gestor de almacenamiento con PostgreSQL y Redis
@@ -59,47 +55,21 @@ class StorageManager:
redis_port: int = 6379, redis_port: int = 6379,
redis_db: int = 0 redis_db: int = 0
): ):
""" # 🔑 Connection string (CLAVE para pandas)
Inicializa conexiones a PostgreSQL y Redis self.db_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
"""
# PostgreSQL connection
db_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
try: # Engine SQLAlchemy (lecturas / queries)
self.engine = create_engine( self.engine = create_engine(self.db_url, echo=False)
db_url,
pool_size=10,
max_overflow=20,
echo=False
)
# Crear tablas si no existen # Crear tablas si no existen
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
# Añadir constraint único si no existe (para evitar duplicados) Session = sessionmaker(bind=self.engine)
try: self.session = Session()
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 log.success("Conectado a PostgreSQL")
Session = sessionmaker(bind=self.engine)
self.session = Session()
log.success("Conectado a PostgreSQL") # Redis (opcional)
except Exception as e:
log.error(f"Error conectando a PostgreSQL: {e}")
raise
# Redis connection (para caché)
try: try:
self.redis_client = redis.Redis( self.redis_client = redis.Redis(
host=redis_host, host=redis_host,
@@ -109,90 +79,48 @@ class StorageManager:
) )
self.redis_client.ping() self.redis_client.ping()
log.success("Conectado a Redis") log.success("Conectado a Redis")
except Exception as e: except Exception:
log.warning(f"No se pudo conectar a Redis: {e}. Continuando sin caché.")
self.redis_client = None self.redis_client = None
log.warning("Redis no disponible")
def save_ohlcv(self, df: pd.DataFrame, batch_size: int = 1000) -> int: # ------------------------------------------------------------------
def save_ohlcv(self, df: pd.DataFrame) -> int:
""" """
Guarda datos OHLCV en la base de datos Guarda datos OHLCV usando pandas.to_sql (modo estable)
Args:
df: DataFrame con datos OHLCV
batch_size: Tamaño de lote para inserción
Returns:
Número de registros guardados
""" """
if df.empty: if df.empty:
log.warning("DataFrame vacío, nada que guardar") log.warning("DataFrame vacío, nada que guardar")
return 0 return 0
log.info(f"Guardando {len(df)} registros en base de datos") df_to_save = df.reset_index()
try: if df_to_save.columns[0] != 'timestamp':
# Preparar datos para inserción df_to_save.rename(columns={df_to_save.columns[0]: 'timestamp'}, inplace=True)
df_to_save = df.reset_index()
# Renombrar columna de índice a timestamp si es necesario allowed_columns = [
if df_to_save.columns[0] != 'timestamp': 'timestamp', 'symbol', 'timeframe',
df_to_save.rename(columns={df_to_save.columns[0]: 'timestamp'}, inplace=True) 'open', 'high', 'low', 'close', 'volume',
'returns', 'log_returns', 'adx'
]
# Mantener todas las columnas relevantes df_to_save = df_to_save[[c for c in allowed_columns if c in df_to_save.columns]]
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 log.info(f"Guardando {len(df_to_save)} registros en base de datos")
records_saved = 0
records_skipped = 0
for i in range(0, len(df_to_save), batch_size): # 🔥 CLAVE: pasar la URL como string
batch = df_to_save.iloc[i:i+batch_size] df_to_save.to_sql(
'ohlcv',
self.db_url,
if_exists='append',
index=False,
method='multi'
)
try: log.success(f"Guardados {len(df_to_save)} registros")
# Usar to_sql con if_exists='append' y method='multi' return len(df_to_save)
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
except Exception as e:
log.error(f"Error guardando datos: {e}")
self.session.rollback()
raise
def load_ohlcv( def load_ohlcv(
self, self,
@@ -202,189 +130,78 @@ class StorageManager:
end_date: Optional[datetime] = None, end_date: Optional[datetime] = None,
use_cache: bool = True use_cache: bool = True
) -> pd.DataFrame: ) -> pd.DataFrame:
"""
Carga datos OHLCV de la base de datos
Args: query = """
symbol: Símbolo del par SELECT *
timeframe: Timeframe
start_date: Fecha inicio (opcional)
end_date: Fecha fin (opcional)
use_cache: Si usar caché de Redis
Returns:
DataFrame con datos OHLCV
"""
# Generar cache key
cache_key = f"ohlcv:{symbol}:{timeframe}:{start_date}:{end_date}"
# Intentar obtener de caché
if use_cache and self.redis_client:
try:
cached_data = self.redis_client.get(cache_key)
if cached_data:
log.debug(f"Datos obtenidos de caché: {cache_key}")
df = pd.read_json(cached_data)
df.set_index('timestamp', inplace=True)
return df
except Exception as e:
log.warning(f"Error leyendo caché: {e}")
# Construir query
query = f"""
SELECT timestamp, open, high, low, close, volume, symbol, timeframe
FROM ohlcv FROM ohlcv
WHERE symbol = '{symbol}' AND timeframe = '{timeframe}' WHERE symbol = :symbol AND timeframe = :timeframe
""" """
params = {'symbol': symbol, 'timeframe': timeframe}
if start_date: if start_date:
query += f" AND timestamp >= '{start_date}'" query += " AND timestamp >= :start_date"
params['start_date'] = start_date
if end_date: if end_date:
query += f" AND timestamp <= '{end_date}'" query += " AND timestamp <= :end_date"
params['end_date'] = end_date
query += " ORDER BY timestamp ASC" query += " ORDER BY timestamp ASC"
try: with self.engine.connect() as conn:
df = pd.read_sql(query, self.engine) df = pd.read_sql(text(query), conn, params=params)
if df.empty:
log.warning(f"No se encontraron datos para {symbol} {timeframe}")
return pd.DataFrame()
df['timestamp'] = pd.to_datetime(df['timestamp'])
df.set_index('timestamp', inplace=True)
log.info(f"Cargados {len(df)} registros de {symbol} {timeframe}")
# Guardar en caché
if self.redis_client:
try:
df_json = df.reset_index().to_json()
self.redis_client.setex(cache_key, 3600, df_json) # TTL 1 hora
log.debug(f"Datos guardados en caché: {cache_key}")
except Exception as e:
log.warning(f"Error guardando en caché: {e}")
if df.empty:
log.warning(f"No se encontraron datos para {symbol} {timeframe}")
return df return df
except Exception as e: df['timestamp'] = pd.to_datetime(df['timestamp'])
log.error(f"Error cargando datos: {e}") df.set_index('timestamp', inplace=True)
raise
log.info(f"Cargados {len(df)} registros de {symbol} {timeframe}")
return df
# ------------------------------------------------------------------
def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[datetime]: def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[datetime]:
""" query = """
Obtiene el último timestamp disponible para un símbolo/timeframe SELECT MAX(timestamp) AS last_timestamp
Args:
symbol: Símbolo del par
timeframe: Timeframe
Returns:
Último timestamp o None si no hay datos
"""
query = f"""
SELECT MAX(timestamp) as last_timestamp
FROM ohlcv FROM ohlcv
WHERE symbol = '{symbol}' AND timeframe = '{timeframe}' WHERE symbol = :symbol AND timeframe = :timeframe
""" """
try: with self.engine.connect() as conn:
result = pd.read_sql(query, self.engine) result = conn.execute(
last_timestamp = result['last_timestamp'].iloc[0] text(query),
{'symbol': symbol, 'timeframe': timeframe}
).fetchone()
if pd.isna(last_timestamp): return result[0] if result and result[0] else None
log.debug(f"No hay datos previos para {symbol} {timeframe}")
return None
return last_timestamp # ------------------------------------------------------------------
except Exception as e:
log.error(f"Error obteniendo último timestamp: {e}")
return None
def delete_ohlcv(
self,
symbol: str,
timeframe: str,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> int:
"""
Elimina datos OHLCV
Args:
symbol: Símbolo del par
timeframe: Timeframe
start_date: Fecha inicio (opcional)
end_date: Fecha fin (opcional)
Returns:
Número de registros eliminados
"""
query = f"""
DELETE FROM ohlcv
WHERE symbol = '{symbol}' AND timeframe = '{timeframe}'
"""
if start_date:
query += f" AND timestamp >= '{start_date}'"
if end_date:
query += f" AND timestamp <= '{end_date}'"
try:
result = self.engine.execute(query)
deleted_count = result.rowcount
log.info(f"Eliminados {deleted_count} registros")
# Invalidar caché
if self.redis_client:
cache_pattern = f"ohlcv:{symbol}:{timeframe}:*"
keys = self.redis_client.keys(cache_pattern)
if keys:
self.redis_client.delete(*keys)
return deleted_count
except Exception as e:
log.error(f"Error eliminando datos: {e}")
raise
def get_available_data(self) -> pd.DataFrame: def get_available_data(self) -> pd.DataFrame:
"""
Obtiene resumen de datos disponibles en la base de datos
Returns:
DataFrame con información de símbolos y timeframes disponibles
"""
query = """ query = """
SELECT SELECT
symbol, symbol,
timeframe, timeframe,
MIN(timestamp) as first_date, MIN(timestamp) AS first_date,
MAX(timestamp) as last_date, MAX(timestamp) AS last_date,
COUNT(*) as record_count COUNT(*) AS record_count
FROM ohlcv FROM ohlcv
GROUP BY symbol, timeframe GROUP BY symbol, timeframe
ORDER BY symbol, timeframe ORDER BY symbol, timeframe
""" """
try: with self.engine.connect() as conn:
df = pd.read_sql(query, self.engine) return pd.read_sql(text(query), conn)
log.info(f"Información de {len(df)} conjuntos de datos")
return df # ------------------------------------------------------------------
except Exception as e:
log.error(f"Error obteniendo información de datos: {e}")
raise
def close(self): def close(self):
""" self.session.close()
Cierra conexiones self.engine.dispose()
""" if self.redis_client:
try: self.redis_client.close()
self.session.close() log.info("Conexiones cerradas")
self.engine.dispose()
if self.redis_client:
self.redis_client.close()
log.info("Conexiones cerradas")
except Exception as e:
log.error(f"Error cerrando conexiones: {e}")

View File

@@ -1,29 +1,42 @@
# src/strategies/moving_average.py # src/strategies/moving_average.py
""" """
Estrategia de cruce de medias móviles Estrategia de cruce de medias móviles con filtro ADX opcional
""" """
import pandas as pd import pandas as pd
from ..backtest.strategy import Strategy, Signal, calculate_sma, calculate_ema from ..backtest.strategy import Strategy, Signal, calculate_sma, calculate_ema
class MovingAverageCrossover(Strategy): class MovingAverageCrossover(Strategy):
""" """
Estrategia simple de cruce de medias móviles Estrategia de cruce de medias móviles
Señales: Señales:
- BUY: Cuando la media rápida cruza por encima de la lenta - BUY: Cruce alcista de medias + (ADX >= threshold si está activado)
- SELL: Cuando la media rápida cruza por debajo de la lenta - SELL: Cruce bajista de medias
- HOLD: En cualquier otro caso - HOLD: En cualquier otro caso
Parámetros: Parámetros:
fast_period: Periodo de la media rápida (default: 10) fast_period: Periodo MA rápida
slow_period: Periodo de la media lenta (default: 30) slow_period: Periodo MA lenta
ma_type: Tipo de media móvil 'sma' o 'ema' (default: 'sma') ma_type: 'sma' o 'ema'
use_adx: Activar filtro ADX
adx_threshold: Umbral mínimo de ADX
""" """
def __init__(self, fast_period: int = 10, slow_period: int = 30, ma_type: str = 'sma'):
def __init__(
self,
fast_period: int = 10,
slow_period: int = 30,
ma_type: str = 'sma',
use_adx: bool = False,
adx_threshold: float = 20.0
):
params = { params = {
'fast_period': fast_period, 'fast_period': fast_period,
'slow_period': slow_period, 'slow_period': slow_period,
'ma_type': ma_type 'ma_type': ma_type,
'use_adx': use_adx,
'adx_threshold': adx_threshold
} }
super().__init__(name="Moving Average Crossover", params=params) super().__init__(name="Moving Average Crossover", params=params)
@@ -31,59 +44,71 @@ class MovingAverageCrossover(Strategy):
self.fast_period = fast_period self.fast_period = fast_period
self.slow_period = slow_period self.slow_period = slow_period
self.ma_type = ma_type.lower() self.ma_type = ma_type.lower()
self.use_adx = use_adx
self.adx_threshold = adx_threshold
if self.ma_type not in ['sma', 'ema']: if self.ma_type not in ['sma', 'ema']:
raise ValueError("ma_type debe ser 'sma' o 'ema'") raise ValueError("ma_type debe ser 'sma' o 'ema'")
# ------------------------------------------------------------------
def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
""" """
Calcula las medias móviles sobre los datos Calcula indicadores necesarios (medias móviles)
""" """
# Usar precio de cierre
close_prices = data['close'] close_prices = data['close']
# Calcular medias móviles según el tipo
if self.ma_type == 'sma': if self.ma_type == 'sma':
data['ma_fast'] = calculate_sma(close_prices, self.fast_period) data['ma_fast'] = calculate_sma(close_prices, self.fast_period)
data['ma_slow'] = calculate_sma(close_prices, self.slow_period) data['ma_slow'] = calculate_sma(close_prices, self.slow_period)
else: # ema else:
data['ma_fast'] = calculate_ema(close_prices, self.fast_period) data['ma_fast'] = calculate_ema(close_prices, self.fast_period)
data['ma_slow'] = calculate_ema(close_prices, self.slow_period) data['ma_slow'] = calculate_ema(close_prices, self.slow_period)
# Calcular cruce (1 = fast > slow, -1 = fast < slow) # Estado del cruce
data['ma_cross'] = 0 data['ma_cross'] = 0
data.loc[data['ma_fast'] > data['ma_slow'], 'ma_cross'] = 1 data.loc[data['ma_fast'] > data['ma_slow'], 'ma_cross'] = 1
data.loc[data['ma_fast'] < data['ma_slow'], 'ma_cross'] = -1 data.loc[data['ma_fast'] < data['ma_slow'], 'ma_cross'] = -1
# Detectar cambios (cruces) # Cambio de estado (cruce real)
data['ma_cross_change'] = data['ma_cross'].diff() data['ma_cross_change'] = data['ma_cross'].diff()
return data return data
# ------------------------------------------------------------------
def generate_signal(self, idx: int) -> Signal: def generate_signal(self, idx: int) -> Signal:
""" """
Genera señal basada en el cruce de medias móviles Genera señal de trading
""" """
if self.data is None: if self.data is None:
raise ValueError("Data no establecida") raise ValueError("Data no establecida")
# Necesitamos al menos 2 puntos para detectar cruce
if idx < 1: if idx < 1:
return Signal.HOLD return Signal.HOLD
# Verificar que las MAs están calculadas (no son NaN) row = self.data.iloc[idx]
if pd.isna(self.data.iloc[idx]['ma_fast']) or pd.isna(self.data.iloc[idx]['ma_slow']):
# MAs válidas
if pd.isna(row['ma_fast']) or pd.isna(row['ma_slow']):
return Signal.HOLD return Signal.HOLD
cross_change = self.data.iloc[idx]['ma_cross_change'] # 🔵 FILTRO ADX (solo para entradas)
if self.use_adx:
if 'adx' not in self.data.columns or pd.isna(row['adx']):
return Signal.HOLD
# Cruce alcista: fast cruza por encima de slow if row['adx'] < self.adx_threshold:
if cross_change == 2: # De -1 a 1 return Signal.HOLD
cross_change = row['ma_cross_change']
# Cruce alcista
if cross_change == 2:
return Signal.BUY return Signal.BUY
# Cruce bajista: fast cruza por debajo de slow # Cruce bajista (salida siempre permitida)
elif cross_change == -2: # De 1 a -1 elif cross_change == -2:
return Signal.SELL return Signal.SELL
# Sin cruce
return Signal.HOLD return Signal.HOLD

46
tests/dam_test.py Normal file
View File

@@ -0,0 +1,46 @@
# dam_test.py
"""
Script para probar el optimizador de parámetros
"""
import os
import sys
from dotenv import load_dotenv
from pathlib import Path
# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.data.storage import StorageManager
def setup_environment():
"""Carga variables de entorno"""
env_path = Path(__file__).parent.parent / 'config' / 'secrets.env'
load_dotenv(dotenv_path=env_path)
def dam_test():
# Setup
setup_environment()
# Cargar datos
storage = StorageManager(
db_host=os.getenv('DB_HOST'),
db_port=int(os.getenv('DB_PORT', 5432)),
db_name=os.getenv('DB_NAME'),
db_user=os.getenv('DB_USER'),
db_password=os.getenv('DB_PASSWORD'),
)
data = storage.load_ohlcv(
symbol='BTC/USDT',
timeframe='1h',
start_date=None,
end_date=None,
use_cache=False
)
print(data.columns)
print(data[['close', 'adx']].tail(10))
if __name__ == "__main__":
dam_test()

View File

@@ -42,14 +42,14 @@ def test_optimizer():
) )
log.info("\n📥 Cargando datos...") log.info("\n📥 Cargando datos...")
end_date = datetime.now() # end_date = datetime.now()
start_date = end_date - timedelta(days=60) # start_date = end_date - timedelta(days=60)
data = storage.load_ohlcv( data = storage.load_ohlcv(
symbol='BTC/USDT', symbol='BTC/USDT',
timeframe='1h', timeframe='1h',
start_date=start_date, start_date=None,
end_date=end_date, end_date=None,
use_cache=False use_cache=False
) )
@@ -66,9 +66,11 @@ def test_optimizer():
# Definir parámetros a probar (pequeño para empezar) # Definir parámetros a probar (pequeño para empezar)
param_grid = { param_grid = {
'fast_period': [5, 10, 15], 'fast_period': [10, 15],
'slow_period': [30, 50], 'slow_period': [30, 50],
'ma_type': ['sma', 'ema'] 'ma_type': ['sma', 'ema'],
'use_adx': [True],
'adx_threshold': [20, 25, 30]
} }
log.info(f"\n📊 Grid de parámetros:") log.info(f"\n📊 Grid de parámetros:")

View File

@@ -43,14 +43,14 @@ def test_visualizer():
) )
log.info("\n📥 Cargando datos...") log.info("\n📥 Cargando datos...")
end_date = datetime.now() # end_date = datetime.now()
start_date = end_date - timedelta(days=60) # start_date = end_date - timedelta(days=60)
data = storage.load_ohlcv( data = storage.load_ohlcv(
symbol='BTC/USDT', symbol='BTC/USDT',
timeframe='1h', timeframe='1h',
start_date=start_date, start_date=None,
end_date=end_date, end_date=None,
use_cache=False use_cache=False
) )
@@ -58,7 +58,13 @@ def test_visualizer():
# Ejecutar backtest # Ejecutar backtest
log.info("\n🧪 Ejecutando backtest...") log.info("\n🧪 Ejecutando backtest...")
strategy = MovingAverageCrossover(fast_period=15, slow_period=50, ma_type='sma') strategy = MovingAverageCrossover(
fast_period=15,
slow_period=50,
ma_type='sma',
use_adx=True,
adx_threshold=30
)
engine = BacktestEngine( engine = BacktestEngine(
strategy=strategy, strategy=strategy,