docs: update backtesting research, optimizer, ADX and visual analysis
This commit is contained in:
110
README.md
110
README.md
@@ -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)
|
||||||
@@ -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...")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -266,4 +267,23 @@ class DataProcessor:
|
|||||||
raise ValueError(f"Método {method} no soportado")
|
raise ValueError(f"Método {method} no soportado")
|
||||||
|
|
||||||
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
|
||||||
@@ -3,51 +3,47 @@
|
|||||||
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
|
returns = Column(Float)
|
||||||
|
log_returns = Column(Float)
|
||||||
# Índices compuestos para queries rápidas
|
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
db_host: str,
|
db_host: str,
|
||||||
@@ -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
|
# Engine SQLAlchemy (lecturas / queries)
|
||||||
db_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
self.engine = create_engine(self.db_url, echo=False)
|
||||||
|
|
||||||
try:
|
# Crear tablas si no existen
|
||||||
self.engine = create_engine(
|
Base.metadata.create_all(self.engine)
|
||||||
db_url,
|
|
||||||
pool_size=10,
|
Session = sessionmaker(bind=self.engine)
|
||||||
max_overflow=20,
|
self.session = Session()
|
||||||
echo=False
|
|
||||||
)
|
log.success("Conectado a PostgreSQL")
|
||||||
|
|
||||||
# Crear tablas si no existen
|
# Redis (opcional)
|
||||||
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()
|
|
||||||
|
|
||||||
log.success("Conectado a PostgreSQL")
|
|
||||||
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,91 +79,49 @@ 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()
|
|
||||||
|
allowed_columns = [
|
||||||
# Renombrar columna de índice a timestamp si es necesario
|
'timestamp', 'symbol', 'timeframe',
|
||||||
if df_to_save.columns[0] != 'timestamp':
|
'open', 'high', 'low', 'close', 'volume',
|
||||||
df_to_save.rename(columns={df_to_save.columns[0]: 'timestamp'}, inplace=True)
|
'returns', 'log_returns', 'adx'
|
||||||
|
]
|
||||||
# Mantener todas las columnas relevantes
|
|
||||||
allowed_columns = ['timestamp', 'open', 'high', 'low', 'close', 'volume', 'symbol', 'timeframe', 'returns', 'log_returns']
|
df_to_save = df_to_save[[c for c in allowed_columns if c in df_to_save.columns]]
|
||||||
df_to_save = df_to_save[[col for col in allowed_columns if col in df_to_save.columns]]
|
|
||||||
|
log.info(f"Guardando {len(df_to_save)} registros en base de datos")
|
||||||
# Insertar en lotes para mejor performance
|
|
||||||
records_saved = 0
|
# 🔥 CLAVE: pasar la URL como string
|
||||||
records_skipped = 0
|
df_to_save.to_sql(
|
||||||
|
'ohlcv',
|
||||||
for i in range(0, len(df_to_save), batch_size):
|
self.db_url,
|
||||||
batch = df_to_save.iloc[i:i+batch_size]
|
if_exists='append',
|
||||||
|
index=False,
|
||||||
try:
|
method='multi'
|
||||||
# Usar to_sql con if_exists='append' y method='multi'
|
)
|
||||||
batch.to_sql(
|
|
||||||
'ohlcv',
|
log.success(f"Guardados {len(df_to_save)} registros")
|
||||||
self.engine,
|
return len(df_to_save)
|
||||||
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,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -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:
|
|
||||||
symbol: Símbolo del par
|
|
||||||
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
|
|
||||||
WHERE symbol = '{symbol}' AND timeframe = '{timeframe}'
|
|
||||||
"""
|
|
||||||
|
|
||||||
if start_date:
|
|
||||||
query += f" AND timestamp >= '{start_date}'"
|
|
||||||
if end_date:
|
|
||||||
query += f" AND timestamp <= '{end_date}'"
|
|
||||||
|
|
||||||
query += " ORDER BY timestamp ASC"
|
|
||||||
|
|
||||||
try:
|
|
||||||
df = pd.read_sql(query, self.engine)
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
return df
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Error cargando datos: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[datetime]:
|
|
||||||
"""
|
|
||||||
Obtiene el último timestamp disponible para un símbolo/timeframe
|
|
||||||
|
|
||||||
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
|
|
||||||
WHERE symbol = '{symbol}' AND timeframe = '{timeframe}'
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = pd.read_sql(query, self.engine)
|
|
||||||
last_timestamp = result['last_timestamp'].iloc[0]
|
|
||||||
|
|
||||||
if pd.isna(last_timestamp):
|
|
||||||
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:
|
|
||||||
"""
|
|
||||||
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 *
|
||||||
|
FROM ohlcv
|
||||||
|
WHERE symbol = :symbol AND timeframe = :timeframe
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = {'symbol': symbol, 'timeframe': timeframe}
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query += " AND timestamp >= :start_date"
|
||||||
|
params['start_date'] = start_date
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
query += " AND timestamp <= :end_date"
|
||||||
|
params['end_date'] = end_date
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp ASC"
|
||||||
|
|
||||||
|
with self.engine.connect() as conn:
|
||||||
|
df = pd.read_sql(text(query), conn, params=params)
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
log.warning(f"No se encontraron datos para {symbol} {timeframe}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
||||||
|
df.set_index('timestamp', inplace=True)
|
||||||
|
|
||||||
|
log.info(f"Cargados {len(df)} registros de {symbol} {timeframe}")
|
||||||
|
return df
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[datetime]:
|
||||||
|
query = """
|
||||||
|
SELECT MAX(timestamp) AS last_timestamp
|
||||||
|
FROM ohlcv
|
||||||
|
WHERE symbol = :symbol AND timeframe = :timeframe
|
||||||
|
"""
|
||||||
|
|
||||||
|
with self.engine.connect() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
text(query),
|
||||||
|
{'symbol': symbol, 'timeframe': timeframe}
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
return result[0] if result and result[0] else None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_available_data(self) -> pd.DataFrame:
|
||||||
|
query = """
|
||||||
|
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}")
|
|
||||||
@@ -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:
|
||||||
# Cruce alcista: fast cruza por encima de slow
|
if 'adx' not in self.data.columns or pd.isna(row['adx']):
|
||||||
if cross_change == 2: # De -1 a 1
|
return Signal.HOLD
|
||||||
|
|
||||||
|
if row['adx'] < self.adx_threshold:
|
||||||
|
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
46
tests/dam_test.py
Normal 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()
|
||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user