From f85c522f22afa2976d2ea0960825b2d5a8b0191d Mon Sep 17 00:00:00 2001 From: DaM Date: Mon, 2 Feb 2026 14:38:05 +0100 Subject: [PATCH] feat: finalize portfolio system and quantitative validation- Finalized MA_Crossover(30,100) and TrendFiltered_MA(30,100,ADX=15) - Implemented portfolio engine with risk-based allocation (50/50) - Added equity-based metrics for system-level evaluation - Validated portfolio against standalone strategies - Reduced max drawdown and volatility at system level - Quantitative decision closed before paper trading phase --- .gitignore | 3 +- backtest.py | 10 +- scripts/research/compare_stops.py | 186 ++++++++++++ scripts/research/compare_systems.py | 192 ++++++++++++ scripts/research/portfolio_backtest.py | 199 +++++++++++++ scripts/research/risk_validation.py | 185 ++++++++++++ scripts/research/walk_forward_stops.py | 222 ++++++++++++++ scripts/research/wf_compare_strategies.py | 275 ++++++++++++++++++ scripts/research/wf_optimize_strategies.py | 257 ++++++++++++++++ src/{backtest => core}/__init__.py | 4 +- src/{backtest => core}/engine.py | 110 ++++--- src/{backtest => core}/metrics.py | 2 +- src/{backtest => core}/optimizer.py | 4 +- src/{backtest => core}/strategy.py | 0 src/{backtest => core}/trade.py | 2 + .../visualizers/__init__.py | 0 .../visualizers/visualizer.py | 0 .../visualizers/walk_forward_visualizer.py | 0 src/{backtest => core}/walk_forward.py | 6 +- src/metrics/__init__.py | 0 src/metrics/equity_metrics.py | 131 +++++++++ src/portfolio/__init__.py | 0 src/portfolio/allocation.py | 17 ++ src/portfolio/portfolio_engine.py | 67 +++++ src/portfolio/portfolio_result.py | 10 + src/risk/sizing/base.py | 2 + src/risk/sizing/percent_risk.py | 30 +- src/risk/stops/atr_stop.py | 4 +- src/risk/stops/base.py | 15 +- src/risk/stops/fixed_stop.py | 2 +- src/risk/stops/trailing_stop.py | 2 +- src/strategies/base.py | 48 ++- src/strategies/breakout.py | 64 ++++ src/strategies/buy_and_hold.py | 2 +- src/strategies/mean_reversion.py | 95 ++++++ src/strategies/moving_average.py | 13 +- src/strategies/optimization/__init__.py | 0 src/strategies/optimization/base.py | 0 .../optimization/opt_moving_average.py | 25 ++ .../optimization/opt_trend_filtered.py | 26 ++ src/strategies/rsi_strategy.py | 2 +- src/strategies/trend_filtered.py | 131 +++++++++ tests/backtest/test_engine_percent_risk.py | 108 +++++++ tests/backtest/test_engine_sizing.py | 6 +- tests/backtest/test_engine_stop.py | 10 +- tests/backtest/test_engine_trailing_stop.py | 8 +- tests/dam_test.py | 2 +- tests/risk/stops/test_atr_stop.py | 4 +- tests/risk/stops/test_fixed_stop.py | 2 +- tests/test_optimizer.py | 2 +- tests/test_visualizer.py | 4 +- tests/test_walkforwad.py | 2 +- tests/test_wf_visualizer.py | 2 +- 53 files changed, 2389 insertions(+), 104 deletions(-) create mode 100644 scripts/research/compare_stops.py create mode 100644 scripts/research/compare_systems.py create mode 100644 scripts/research/portfolio_backtest.py create mode 100644 scripts/research/risk_validation.py create mode 100644 scripts/research/walk_forward_stops.py create mode 100644 scripts/research/wf_compare_strategies.py create mode 100644 scripts/research/wf_optimize_strategies.py rename src/{backtest => core}/__init__.py (92%) rename src/{backtest => core}/engine.py (85%) rename src/{backtest => core}/metrics.py (99%) rename src/{backtest => core}/optimizer.py (99%) rename src/{backtest => core}/strategy.py (100%) rename src/{backtest => core}/trade.py (98%) rename src/{backtest => core}/visualizers/__init__.py (100%) rename src/{backtest => core}/visualizers/visualizer.py (100%) rename src/{backtest => core}/visualizers/walk_forward_visualizer.py (100%) rename src/{backtest => core}/walk_forward.py (98%) create mode 100644 src/metrics/__init__.py create mode 100644 src/metrics/equity_metrics.py create mode 100644 src/portfolio/__init__.py create mode 100644 src/portfolio/allocation.py create mode 100644 src/portfolio/portfolio_engine.py create mode 100644 src/portfolio/portfolio_result.py create mode 100644 src/strategies/breakout.py create mode 100644 src/strategies/mean_reversion.py create mode 100644 src/strategies/optimization/__init__.py create mode 100644 src/strategies/optimization/base.py create mode 100644 src/strategies/optimization/opt_moving_average.py create mode 100644 src/strategies/optimization/opt_trend_filtered.py create mode 100644 src/strategies/trend_filtered.py create mode 100644 tests/backtest/test_engine_percent_risk.py diff --git a/.gitignore b/.gitignore index 1f4069a..301dca9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ __pycache__/ logs/ # Resultados -backtest_results/ \ No newline at end of file +backtest_results/ +output/ \ No newline at end of file diff --git a/backtest.py b/backtest.py index 182d25d..2e07c57 100644 --- a/backtest.py +++ b/backtest.py @@ -9,8 +9,8 @@ from datetime import datetime, timedelta from src.utils.logger import log from src.data.storage import StorageManager -from src.backtest.engine import BacktestEngine -from src.backtest.metrics import print_backtest_report, calculate_all_metrics +from src.core.engine import Engine +from src.core.metrics import print_backtest_report, calculate_all_metrics from src.strategies import MovingAverageCrossover, BuyAndHold, RSIStrategy def setup_environment(): @@ -76,7 +76,7 @@ def run_backtest_demo(): ) # Crear motor de backtesting - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=10000, commission=0.001, # 0.1% @@ -172,7 +172,7 @@ def compare_strategies_demo(): for name, strategy in strategies: log.info(f"\n🧪 Testeando: {name}") - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=10000, commission=0.001, @@ -187,7 +187,7 @@ def compare_strategies_demo(): log.info(f" Win Rate: {results['win_rate_pct']:.2f}%") # Comparar resultados - from src.backtest.metrics import compare_strategies + from src.core.metrics import compare_strategies compare_strategies(all_results) storage.close() diff --git a/scripts/research/compare_stops.py b/scripts/research/compare_stops.py new file mode 100644 index 0000000..208847a --- /dev/null +++ b/scripts/research/compare_stops.py @@ -0,0 +1,186 @@ +# scripts/research/compare_stops.py +import os +import sys +from pathlib import Path +from dotenv import load_dotenv +from datetime import datetime, timedelta +import pandas as pd +import matplotlib.pyplot as plt +from loguru import logger + +# Añadir raíz del proyecto al path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.utils.logger import log +from src.data.storage import StorageManager +from src.core.engine import Engine +from src.strategies import MovingAverageCrossover +from src.risk.sizing.fixed import FixedPositionSizer +from src.risk.stops.fixed_stop import FixedStop +from src.risk.stops.trailing_stop import TrailingStop +from src.risk.stops.atr_stop import ATRStop + +# -------------------------------------------------- +# Configuración común +# -------------------------------------------------- +SYMBOL = "BTC/USDT" +TIMEFRAME = "1d" +DAYS_BACK = 180 + +INITIAL_CAPITAL = 10_000 +COMISSION = 0.001 +SLIPPAGE = 0.0005 + +# Estrategia +STRATEGY = MovingAverageCrossover( + fast_period=10, + slow_period=30, + ma_type='ema', + use_adx=False, + adx_threshold=20 +) + +# Position sizing fijo (para aislar el efecto del stop) +POSITION_SIZER = FixedPositionSizer(0.95) + +# Stops a comparar +STOPS = { + "Fixed 2%": FixedStop(0.02), + "Trailing 2%": TrailingStop(0.02), + "ATR 14 x 2.0": ATRStop(atr_period=14, multiplier=2.0), +} + +def setup_environment(): + """Carga variables de entorno""" + env_path = Path(__file__).parent.parent.parent / 'config' / 'secrets.env' + load_dotenv(dotenv_path=env_path) + +def load_data(): + """ + Carga datos desde la base de datos + """ + # 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"), + ) + + end_date = datetime.now() + start_date = end_date - timedelta(DAYS_BACK) + + data = storage.load_ohlcv( + symbol=SYMBOL, + timeframe=TIMEFRAME, + start_date=None, + end_date=None, + use_cache=False, + ) + + storage.close() + + if data.empty: + raise RuntimeError("No se cargaron datos") + + return data + +def run(): + log.info("=" * 80) + log.info("🧪 COMPARACIÓN DE STOPS (Research)") + log.info("=" * 80) + + data = load_data() + + log.info(f"Símbolo: {SYMBOL}") + log.info(f"Timeframe: {TIMEFRAME}") + log.info(f"Velas: {len(data)}") + log.info(f"Periodo: {data.index[0]} → {data.index[-1]}") + print() + + results = {} + + # -------------------------------------------------- + # Ejecutar backtest por cada stop + # -------------------------------------------------- + for name, stop in STOPS.items(): + log.info(f"▶️ Ejecutando con stop: {name}") + + # Silenciar logs del engine (solo warnings o errores) + logger.disable("src.backtest.engine") + + engine = Engine( + strategy=STRATEGY, + initial_capital=INITIAL_CAPITAL, + commission=COMISSION, + slippage=SLIPPAGE, + position_sizer=POSITION_SIZER, + stop_loss=stop, + ) + + res = engine.run(data) + + results[name] = res + + logger.enable("src.backtest.engine") + + log.info( + f"Trades: {res['total_trades']:>4} | " + f"Max DD: {res['max_drawdown_pct']:>7.2f}% | " + f"Return: {res['total_return_pct']:>7.2f}%" + ) + print() + + print("\n" + "=" * 70) + print("📊 RESUMEN COMPARATIVO DE STOPS") + print("=" * 70) + + for name, res in results.items(): + print( + f"{name:<15} | " + f"Trades: {res['total_trades']:>4} | " + f"Max DD: {res['max_drawdown_pct']:>7.2f}% | " + f"Return: {res['total_return_pct']:>7.2f}%" + ) + + print("=" * 70) + + + # -------------------------------------------------- + # Plot equity curves (comparativa visual) + # -------------------------------------------------- + plt.figure(figsize=(14, 7)) + + for name, res in results.items(): + plt.plot( + res["timestamps"], + res["equity_curve"], + label=name, + ) + + plt.title(f"Equity Curve Comparison – {SYMBOL} {TIMEFRAME}") + plt.xlabel("Time") + plt.ylabel("Equity") + plt.legend() + plt.grid(alpha=0.3) + plt.tight_layout() + + # -------------------------------------------------- + # Guardar gráfico + # -------------------------------------------------- + output_dir = Path(__file__).parent / "output" + output_dir.mkdir(parents=True, exist_ok=True) + + output_path = output_dir / f"equity_comparison_{SYMBOL.replace('/', '_')}_{TIMEFRAME}.png" + + plt.savefig(output_path, dpi=150) + plt.close() + + log.success(f"📈 Equity curve guardada en: {output_path}") + +if __name__ == "__main__": + run() \ No newline at end of file diff --git a/scripts/research/compare_systems.py b/scripts/research/compare_systems.py new file mode 100644 index 0000000..2e63b93 --- /dev/null +++ b/scripts/research/compare_systems.py @@ -0,0 +1,192 @@ +# scripts/research/compare_systems.py + +""" +Comparación cuantitativa final entre: +- MA_Crossover (standalone) +- TrendFiltered_MA (standalone) +- Portfolio 50/50 + +Este script cierra la decisión cuantitativa antes de pasar a paper trading. +""" + +import os +import sys +from pathlib import Path +from dotenv import load_dotenv + +import pandas as pd + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.core.engine import Engine +from src.data.storage import StorageManager +from src.risk.sizing.percent_risk import PercentRiskSizer +from src.risk.stops.trailing_stop import TrailingStop + +from src.strategies.moving_average import MovingAverageCrossover +from src.strategies.trend_filtered import TrendFilteredMACrossover + +from src.portfolio.portfolio_engine import PortfolioEngine +from src.portfolio.allocation import Allocation + +from src.metrics.equity_metrics import compute_equity_metrics + + +# -------------------------------------------------- +# CONFIG +# -------------------------------------------------- +SYMBOL = "BTC/USDT" +TIMEFRAME = "1h" +INITIAL_CAPITAL = 10_000 + +STOP = TrailingStop(0.02) + + +# -------------------------------------------------- +def load_data(): + load_dotenv("config/secrets.env") + + 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=SYMBOL, + timeframe=TIMEFRAME, + use_cache=True, + ) + storage.close() + + if data.empty: + raise RuntimeError("No data loaded") + + return data + + +def run_engine(engine: Engine, data) -> dict: + res = engine.run(data) + equity = res["equity_curve"] + timestamps = res["timestamps"] + + metrics = compute_equity_metrics( + equity_curve=equity, + timestamps=timestamps, + ) + + return { + "final_capital": equity[-1], + **metrics, + } + + +# -------------------------------------------------- +def run(): + data = load_data() + + systems = {} + + # -------------------------------------------------- + # MA_Crossover standalone (1% risk) + # -------------------------------------------------- + systems["MA_Crossover"] = run_engine( + Engine( + strategy=MovingAverageCrossover(30, 100, "ema", False), + initial_capital=INITIAL_CAPITAL, + position_sizer=PercentRiskSizer(0.01), + stop_loss=STOP, + commission=0.001, + slippage=0.0005, + ), + data, + ) + + # -------------------------------------------------- + # TrendFiltered_MA standalone (1% risk) + # -------------------------------------------------- + systems["TrendFiltered_MA"] = run_engine( + Engine( + strategy=TrendFilteredMACrossover(30, 100, "ema", 15), + initial_capital=INITIAL_CAPITAL, + position_sizer=PercentRiskSizer(0.01), + stop_loss=STOP, + commission=0.001, + slippage=0.0005, + ), + data, + ) + + # -------------------------------------------------- + # Portfolio 50/50 (0.5% + 0.5%) + # -------------------------------------------------- + engines = { + "MA_Crossover": Engine( + strategy=MovingAverageCrossover(30, 100, "ema", False), + initial_capital=INITIAL_CAPITAL, + position_sizer=PercentRiskSizer(0.005), + stop_loss=STOP, + commission=0.001, + slippage=0.0005, + ), + "TrendFiltered_MA": Engine( + strategy=TrendFilteredMACrossover(30, 100, "ema", 15), + initial_capital=INITIAL_CAPITAL, + position_sizer=PercentRiskSizer(0.005), + stop_loss=STOP, + commission=0.001, + slippage=0.0005, + ), + } + + portfolio = PortfolioEngine( + engines=engines, + allocation=Allocation( + weights={ + "MA_Crossover": 0.5, + "TrendFiltered_MA": 0.5, + } + ), + initial_capital=INITIAL_CAPITAL, + ) + + portfolio_res = portfolio.run(data) + + systems["Portfolio_50_50"] = { + "final_capital": portfolio_res.final_capital, + **compute_equity_metrics( + equity_curve=portfolio_res.equity_curve, + timestamps=data.index[: len(portfolio_res.equity_curve)], + ), + } + + # -------------------------------------------------- + # COMPARISON TABLE + # -------------------------------------------------- + df = pd.DataFrame(systems).T + + df["final_capital"] = df["final_capital"].round(2) + df["cagr"] = (df["cagr"] * 100).round(2) + df["max_drawdown"] = (df["max_drawdown"] * 100).round(2) + df["volatility"] = (df["volatility"] * 100).round(2) + df["time_in_drawdown"] = (df["time_in_drawdown"] * 100).round(2) + df["calmar_ratio"] = df["calmar_ratio"].round(2) + df["ulcer_index"] = df["ulcer_index"].round(2) + + print("\n" + "=" * 100) + print("📊 SYSTEM COMPARISON (FINAL DECISION)") + print("=" * 100) + print(df) + print("=" * 100) + + print("\n📌 Interpretación:") + print("- El mejor sistema NO es el que tiene mayor capital final.") + print("- Prioriza menor drawdown, mejor Calmar y menor Ulcer Index.") + print("- Si el portfolio domina en riesgo/retorno → decisión cerrada.\n") + + +# -------------------------------------------------- +if __name__ == "__main__": + run() diff --git a/scripts/research/portfolio_backtest.py b/scripts/research/portfolio_backtest.py new file mode 100644 index 0000000..6ba5224 --- /dev/null +++ b/scripts/research/portfolio_backtest.py @@ -0,0 +1,199 @@ +# scripts/research/portfolio_backtest.py + +""" +Portfolio Backtest Script (50/50 Allocation) + +Este script ejecuta un backtest de un portfolio multi-estrategia compuesto por: + +- MA_Crossover (EMA 30 / EMA 100) +- TrendFiltered_MA (EMA 30 / EMA 100 + ADX 15) + +Cada estrategia opera de forma independiente, pero comparte el riesgo total +mediante una asignación fija 50/50 (0.5% de riesgo por estrategia). + +Objetivos principales del script: +- Validar el comportamiento conjunto del portfolio +- Evaluar la reducción de drawdown y volatilidad frente a estrategias individuales +- Calcular métricas de sistema basadas únicamente en la equity curve + (CAGR, Max Drawdown, Calmar Ratio, Time in Drawdown, Ulcer Index) +- Generar una visualización de la equity del portfolio y su drawdown + +Este script representa el último paso cuantitativo antes de: +- Walk-Forward del portfolio +- Paper trading en tiempo real +- Construcción de la UI de monitorización + +No modifica parámetros, estrategias ni lógica de ejecución. +Su propósito es exclusivamente de validación y análisis del sistema. +""" + +import os +import sys +from pathlib import Path +from dotenv import load_dotenv + +import matplotlib.pyplot as plt +import pandas as pd + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.core.engine import Engine +from src.data.storage import StorageManager +from src.risk.sizing.percent_risk import PercentRiskSizer +from src.risk.stops.trailing_stop import TrailingStop + +from src.strategies.moving_average import MovingAverageCrossover +from src.strategies.trend_filtered import TrendFilteredMACrossover + +from src.portfolio.portfolio_engine import PortfolioEngine +from src.portfolio.allocation import Allocation + +from src.metrics.equity_metrics import ( + compute_equity_metrics, + calculate_drawdown_series, +) + + +# -------------------------------------------------- +# CONFIG +# -------------------------------------------------- +SYMBOL = "BTC/USDT" +TIMEFRAME = "1h" +INITIAL_CAPITAL = 10_000 + +STOP = TrailingStop(0.02) + +# 50/50 risk split +RISK_A = PercentRiskSizer(0.005) +RISK_B = PercentRiskSizer(0.005) + +OUTPUT_DIR = Path(__file__).parent / "output/portfolio_backtest" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +# -------------------------------------------------- +def load_data(): + load_dotenv("config/secrets.env") + + 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=SYMBOL, + timeframe=TIMEFRAME, + use_cache=True, + ) + storage.close() + + if data.empty: + raise RuntimeError("No data loaded") + + return data + + +# -------------------------------------------------- +def run(): + data = load_data() + + # ----------------------------- + # Engines + # ----------------------------- + engines = { + "MA_Crossover": Engine( + strategy=MovingAverageCrossover(30, 100, "ema", False), + initial_capital=INITIAL_CAPITAL, + position_sizer=RISK_A, + stop_loss=STOP, + commission=0.001, + slippage=0.0005, + ), + "TrendFiltered_MA": Engine( + strategy=TrendFilteredMACrossover(30, 100, "ema", 15), + initial_capital=INITIAL_CAPITAL, + position_sizer=RISK_B, + stop_loss=STOP, + commission=0.001, + slippage=0.0005, + ), + } + + allocation = Allocation( + weights={ + "MA_Crossover": 0.5, + "TrendFiltered_MA": 0.5, + } + ) + + portfolio = PortfolioEngine( + engines=engines, + allocation=allocation, + initial_capital=INITIAL_CAPITAL, + ) + + # ----------------------------- + # Run portfolio + # ----------------------------- + result = portfolio.run(data) + + equity = pd.Series(result.equity_curve, index=pd.to_datetime(data.index[:len(result.equity_curve)])) + + # ----------------------------- + # Metrics + # ----------------------------- + metrics = compute_equity_metrics( + equity_curve=result.equity_curve, + timestamps=equity.index.tolist(), + ) + + # ----------------------------- + # Report + # ----------------------------- + print("\n" + "=" * 80) + print("📊 PORTFOLIO BACKTEST REPORT (50/50)") + print("=" * 80) + print(f"Final Capital: ${result.final_capital:,.2f}") + print(f"CAGR: {metrics['cagr'] * 100:>8.2f}%") + print(f"Max Drawdown: {metrics['max_drawdown'] * 100:>8.2f}%") + print(f"Calmar Ratio: {metrics['calmar_ratio']:>8.2f}") + print(f"Volatility (ann.): {metrics['volatility'] * 100:>8.2f}%") + print(f"Time in Drawdown: {metrics['time_in_drawdown'] * 100:>8.2f}%") + print(f"Ulcer Index: {metrics['ulcer_index']:>8.2f}") + print("=" * 80) + + # ----------------------------- + # Plots + # ----------------------------- + dd_series = calculate_drawdown_series(equity) + + fig, (ax1, ax2) = plt.subplots( + 2, 1, figsize=(13, 7), sharex=True, + gridspec_kw={"height_ratios": [3, 1]} + ) + + ax1.plot(equity.index, equity.values, label="Portfolio Equity") + ax1.set_title("Portfolio Equity Curve (50/50)") + ax1.set_ylabel("Capital") + ax1.grid(alpha=0.3) + ax1.legend() + + ax2.fill_between(dd_series.index, dd_series.values * 100, 0, color="red", alpha=0.3) + ax2.set_ylabel("Drawdown (%)") + ax2.set_xlabel("Time") + ax2.grid(alpha=0.3) + + plt.tight_layout() + path = OUTPUT_DIR / "portfolio_equity_drawdown.png" + plt.savefig(path) + plt.close() + + print(f"\n📈 Saved plot: {path}") + + +# -------------------------------------------------- +if __name__ == "__main__": + run() diff --git a/scripts/research/risk_validation.py b/scripts/research/risk_validation.py new file mode 100644 index 0000000..2634eee --- /dev/null +++ b/scripts/research/risk_validation.py @@ -0,0 +1,185 @@ +import os +import sys +from pathlib import Path +from dotenv import load_dotenv +from datetime import datetime, timedelta +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt + +# -------------------------------------------------- +# Path setup +# -------------------------------------------------- +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.utils.logger import log +from src.data.storage import StorageManager +from src.core.engine import Engine +from src.strategies import MovingAverageCrossover +from src.risk.sizing.percent_risk import PercentRiskSizer +from src.risk.stops.atr_stop import ATRStop + + +# -------------------------------------------------- +# CONFIG +# -------------------------------------------------- +SYMBOL = "BTC/USDT" +TIMEFRAME = "1h" +DAYS_BACK = 180 + +INITIAL_CAPITAL = 10_000 +RISK_PER_TRADE = 0.01 # 1% + +ATR_PERIOD = 14 +ATR_MULTIPLIER = 2.0 + +COMMISSION = 0.001 +SLIPPAGE = 0.0005 + + +def setup_environment(): + """Carga variables de entorno""" + env_path = Path(__file__).parent.parent.parent / 'config' / 'secrets.env' + load_dotenv(dotenv_path=env_path) + +# -------------------------------------------------- +# Load data +# -------------------------------------------------- +def load_data(): + # Setup + setup_environment() + + 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"), + ) + + end_date = datetime.now() + start_date = end_date - timedelta(days=DAYS_BACK) + + data = storage.load_ohlcv( + symbol=SYMBOL, + timeframe=TIMEFRAME, + start_date=start_date, + end_date=end_date, + use_cache=False, + ) + + storage.close() + + if data.empty: + raise RuntimeError("No data loaded") + + return data + + +# -------------------------------------------------- +# Main +# -------------------------------------------------- +def run(): + log.info("=" * 70) + log.info("📐 RISK VALIDATION – PercentRiskSizer + ATRStop") + log.info("=" * 70) + + data = load_data() + + strategy = MovingAverageCrossover( + fast_period=10, + slow_period=30, + ma_type="ema", + use_adx=False, + ) + + engine = Engine( + strategy=strategy, + initial_capital=INITIAL_CAPITAL, + commission=COMMISSION, + slippage=SLIPPAGE, + position_sizer=PercentRiskSizer(RISK_PER_TRADE), + stop_loss=ATRStop( + atr_period=ATR_PERIOD, + multiplier=ATR_MULTIPLIER, + ), + ) + + results = engine.run(data) + trades = results["trades"] + + # -------------------------------------------------- + # Compute real risk per trade + # -------------------------------------------------- + risks = [] + + for trade in trades: + if trade.exit_reason != "Stop Loss": + continue + + risk_amount = abs( + trade.entry_price - trade.stop_price_at_entry + ) * trade.size + + risk_pct = risk_amount / trade.capital_at_entry + risks.append(risk_pct) + + risks = np.array(risks) + + if len(risks) == 0: + log.warning("No stop-loss trades found") + return + + # -------------------------------------------------- + # Print summary + # -------------------------------------------------- + print() + print("=" * 70) + print("📊 RISK PER TRADE SUMMARY") + print("=" * 70) + print(f"Trades analysed : {len(risks)}") + print(f"Target risk : {RISK_PER_TRADE*100:.2f}%") + print(f"Mean risk : {risks.mean()*100:.2f}%") + print(f"Std deviation : {risks.std()*100:.2f}%") + print(f"Min risk : {risks.min()*100:.2f}%") + print(f"Max risk : {risks.max()*100:.2f}%") + print("=" * 70) + + output_dir = Path(__file__).parent / "output" + output_dir.mkdir(parents=True, exist_ok=True) + + # -------------------------------------------------- + # Plot risk distribution + # -------------------------------------------------- + plt.figure(figsize=(10, 5)) + plt.hist(risks * 100, bins=25, edgecolor="black", alpha=0.7) + plt.axvline(RISK_PER_TRADE * 100, color="red", linestyle="--", label="Target") + plt.title("Risk distribution per trade (%)") + plt.xlabel("Risk (%)") + plt.ylabel("Trades") + plt.legend() + plt.grid(alpha=0.3) + plt.tight_layout() + plt.savefig(output_dir / "risk_distribution.png") + plt.close() + + # -------------------------------------------------- + # Plot risk over time + # -------------------------------------------------- + plt.figure(figsize=(12, 5)) + plt.plot(risks * 100, marker="o", linewidth=1) + plt.axhline(RISK_PER_TRADE * 100, color="red", linestyle="--", label="Target") + plt.title("Risk per trade over time") + plt.xlabel("Trade #") + plt.ylabel("Risk (%)") + plt.legend() + plt.grid(alpha=0.3) + plt.tight_layout() + plt.savefig(output_dir / "risk_over_time.png") + plt.close() + + log.success("Risk validation completed ✔") + + +if __name__ == "__main__": + run() diff --git a/scripts/research/walk_forward_stops.py b/scripts/research/walk_forward_stops.py new file mode 100644 index 0000000..ba0c91a --- /dev/null +++ b/scripts/research/walk_forward_stops.py @@ -0,0 +1,222 @@ +import os +import sys +from pathlib import Path +from datetime import datetime, timedelta +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +from dotenv import load_dotenv + +# -------------------------------------------------- +# Path setup +# -------------------------------------------------- +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.utils.logger import log +from src.data.storage import StorageManager +from src.core.engine import Engine +from src.strategies import MovingAverageCrossover +from src.risk.sizing.percent_risk import PercentRiskSizer +from src.risk.stops.fixed_stop import FixedStop +from src.risk.stops.trailing_stop import TrailingStop +from src.risk.stops.atr_stop import ATRStop + +# -------------------------------------------------- +# CONFIG +# -------------------------------------------------- +SYMBOL = "BTC/USDT" +TIMEFRAME = "1h" + +TRAIN_DAYS = 120 +TEST_DAYS = 30 +STEP_DAYS = 30 + +INITIAL_CAPITAL = 10_000 +COMMISSION = 0.001 +SLIPPAGE = 0.0005 + +RISK_FRACTION = 0.01 + +OUT_DIR = Path("scripts/research/output/wf_stops") / f"{SYMBOL.replace('/', '_')}_{TIMEFRAME}" +OUT_DIR.mkdir(parents=True, exist_ok=True) + +STOPS = { + "Fixed 2%": FixedStop(0.02), + "Trailing 2%": TrailingStop(0.02), + "ATR 14 x 2.0": ATRStop(14, 2.0), +} + +# -------------------------------------------------- +# Helpers +# -------------------------------------------------- +def make_strategy(): + return MovingAverageCrossover( + fast_period=10, + slow_period=30, + ma_type="ema", + use_adx=False, + adx_threshold=20.0, + ) + + +def setup_env(): + env_path = Path(__file__).parent.parent.parent / "config" / "secrets.env" + load_dotenv(env_path) + + +def load_data(): + setup_env() + + 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=SYMBOL, + timeframe=TIMEFRAME, + use_cache=True, + ) + + storage.close() + + if data.empty: + raise RuntimeError("No data loaded") + + return data + + +# -------------------------------------------------- +# Walk Forward +# -------------------------------------------------- +def run(): + log.info("=" * 80) + log.info("🔁 WALK-FORWARD – STOP COMPARISON (120/30/30, 1h)") + log.info("=" * 80) + + data = load_data() + + wf_results = [] + equity_curves = {name: [] for name in STOPS.keys()} + + start_time = data.index[0] + end_time = data.index[-1] + + window_id = 0 + current_train_start = start_time + + while True: + train_end = current_train_start + timedelta(days=TRAIN_DAYS) + test_start = train_end + test_end = test_start + timedelta(days=TEST_DAYS) + + if test_end > end_time: + break + + train_df = data.loc[current_train_start:train_end] + test_df = data.loc[test_start:test_end] + + if len(test_df) < 50: + break + + window_id += 1 + + print() + print( + f"WF Window {window_id:02d} | " + f"TRAIN {train_df.index[0].date()} → {train_df.index[-1].date()} | " + f"TEST {test_df.index[0].date()} → {test_df.index[-1].date()} | " + f"bars_test={len(test_df)}" + ) + + for stop_name, stop in STOPS.items(): + engine = Engine( + strategy=make_strategy(), + initial_capital=INITIAL_CAPITAL, + commission=COMMISSION, + slippage=SLIPPAGE, + position_sizer=PercentRiskSizer(RISK_FRACTION), + stop_loss=stop, + ) + + res = engine.run(test_df) + + wf_results.append({ + "window": window_id, + "stop": stop_name, + "train_start": train_df.index[0], + "train_end": train_df.index[-1], + "test_start": test_df.index[0], + "test_end": test_df.index[-1], + "trades": res["total_trades"], + "max_dd_pct": res["max_drawdown_pct"], + "return_pct": res["total_return_pct"], + "final_equity": res["final_equity"], + }) + + equity_curves[stop_name].append( + pd.Series(res["equity_curve"], index=res["timestamps"]) + ) + + print( + f" {stop_name:<13} | " + f"Trades: {res['total_trades']:>3} | " + f"MaxDD: {res['max_drawdown_pct']:>7.2f}% | " + f"Return: {res['total_return_pct']:>7.2f}%" + ) + + current_train_start += timedelta(days=STEP_DAYS) + + # -------------------------------------------------- + # Save results + # -------------------------------------------------- + df = pd.DataFrame(wf_results) + df.to_csv(OUT_DIR / "wf_results.csv", index=False) + + print() + print("=" * 80) + print("📊 WF SUMMARY (aggregated)") + print("=" * 80) + + summary = ( + df.groupby("stop") + .agg( + windows=("window", "nunique"), + trades_avg=("trades", "mean"), + max_dd_worst=("max_dd_pct", "min"), + return_mean=("return_pct", "mean"), + return_median=("return_pct", "median"), + ) + .round(2) + ) + + print(summary) + print("=" * 80) + + # -------------------------------------------------- + # Plot equity curves (visual comparison) + # -------------------------------------------------- + plt.figure(figsize=(14, 7)) + + for stop_name, curves in equity_curves.items(): + if not curves: + continue + concat_curve = pd.concat(curves) + plt.plot(concat_curve.index, concat_curve.values, label=stop_name) + + plt.title(f"WF Equity Comparison – {SYMBOL} {TIMEFRAME}") + plt.xlabel("Time") + plt.ylabel("Equity (per-window)") + plt.legend() + plt.grid(alpha=0.3) + plt.tight_layout() + + plt.savefig(OUT_DIR / "wf_equity_comparison.png") + plt.close() + + +if __name__ == "__main__": + run() diff --git a/scripts/research/wf_compare_strategies.py b/scripts/research/wf_compare_strategies.py new file mode 100644 index 0000000..3b01c16 --- /dev/null +++ b/scripts/research/wf_compare_strategies.py @@ -0,0 +1,275 @@ +# scripts/research/wf_compare_strategies.py + +import os +import sys +from pathlib import Path +from dotenv import load_dotenv +from datetime import timedelta + +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.core.engine import Engine +from src.data.storage import StorageManager +from src.risk.sizing.percent_risk import PercentRiskSizer +from src.risk.stops.trailing_stop import TrailingStop + +from src.strategies.moving_average import MovingAverageCrossover +from src.strategies.trend_filtered import TrendFilteredMACrossover +from src.strategies.breakout import DonchianBreakout +from src.strategies.mean_reversion import RSIMeanReversion + +from src.utils.logger import log + + +# -------------------------------------------------- +# CONFIG +# -------------------------------------------------- +SYMBOL = "BTC/USDT" +TIMEFRAME = "1h" + +TRAIN_DAYS = 120 +TEST_DAYS = 30 +STEP_DAYS = 30 + +INITIAL_CAPITAL = 10_000 + +RISK = PercentRiskSizer(0.01) +STOP = TrailingStop(0.02) + +OUTPUT_DIR = Path(__file__).parent / "output/wf_strategies" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +# -------------------------------------------------- +# STRATEGIES +# -------------------------------------------------- +STRATEGIES = { + "MA_Crossover": lambda: MovingAverageCrossover(20, 50, "ema", False), + "TrendFiltered_MA": lambda: TrendFilteredMACrossover(20, 50, "ema", 20), + "Donchian": lambda: DonchianBreakout(20), + "RSI_Reversion": lambda: RSIMeanReversion(14, 30, 70), +} + + +# -------------------------------------------------- +def setup_env(): + env_path = Path(__file__).parent.parent.parent / "config" / "secrets.env" + load_dotenv(env_path) + + +def load_data(): + setup_env() + + 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, TIMEFRAME, use_cache=True) + storage.close() + + if data.empty: + raise RuntimeError("No data loaded") + + return data + + +# -------------------------------------------------- +def compute_percentiles(curves, n_points=100): + x_common = np.linspace(0, 1, n_points) + interpolated = [] + + for curve in curves: + x = np.linspace(0, 1, len(curve)) + interpolated.append(np.interp(x_common, x, curve.values)) + + arr = np.vstack(interpolated) + return ( + x_common, + np.percentile(arr, 10, axis=0), + np.percentile(arr, 50, axis=0), + np.percentile(arr, 90, axis=0), + ) + + +# -------------------------------------------------- +def run(): + log.info("🧪 WF MULTI-STRATEGY TEST (FULL VISUAL + STATS)") + + data = load_data() + + results_summary = [] + all_accumulated = {} + all_dispersion_percentiles = {} + + for name, strat_factory in STRATEGIES.items(): + log.info(f"▶ Strategy: {name}") + + capital = INITIAL_CAPITAL + equity_accumulated = [] + dispersion_curves = [] + window_returns = [] + + worst_dd = 0.0 + start = data.index[0] + + while True: + train_end = start + timedelta(days=TRAIN_DAYS) + test_end = train_end + timedelta(days=TEST_DAYS) + + if test_end > data.index[-1]: + break + + test_data = data.loc[train_end:test_end] + + engine = Engine( + strategy=strat_factory(), + initial_capital=capital, + position_sizer=RISK, + stop_loss=STOP, + commission=0.001, + slippage=0.0005, + ) + + res = engine.run(test_data) + + # ---- accumulated + capital *= (1 + res["total_return_pct"] / 100) + equity_accumulated.append((res["timestamps"][-1], capital)) + + # ---- dispersion (normalized reset) + eq = pd.Series(res["equity_curve"], index=res["timestamps"]) + dispersion_curves.append(eq / eq.iloc[0]) + + window_returns.append(res["total_return_pct"]) + worst_dd = min(worst_dd, res["max_drawdown_pct"]) + + start += timedelta(days=STEP_DAYS) + + # =============================== + # STORE ACCUMULATED + # =============================== + acc_df = pd.DataFrame(equity_accumulated, columns=["time", "capital"]).set_index("time") + all_accumulated[name] = acc_df + + # =============================== + # 1️⃣ DISPERSION – RAW (OLD) + # =============================== + plt.figure(figsize=(12, 5)) + for eq in dispersion_curves: + plt.plot(eq.index, eq.values, alpha=0.3) + + plt.title(f"WF Equity Dispersion (Raw) – {name}") + plt.ylabel("Normalized Equity") + plt.grid(alpha=0.3) + + path = OUTPUT_DIR / f"wf_equity_dispersion_raw_{name}.png" + plt.tight_layout() + plt.savefig(path) + plt.close() + + # =============================== + # 2️⃣ DISPERSION – PERCENTILES (NEW) + # =============================== + x, p10, p50, p90 = compute_percentiles(dispersion_curves) + all_dispersion_percentiles[name] = (x, p10, p50, p90) + + plt.figure(figsize=(12, 5)) + plt.plot(x, p50, label="P50", linewidth=2) + plt.fill_between(x, p10, p90, alpha=0.3, label="P10–P90") + + plt.title(f"WF Equity Dispersion (Percentiles) – {name}") + plt.ylabel("Normalized Equity") + plt.xlabel("Window Progress") + plt.legend() + plt.grid(alpha=0.3) + + path = OUTPUT_DIR / f"wf_equity_dispersion_percentiles_{name}.png" + plt.tight_layout() + plt.savefig(path) + plt.close() + + # =============================== + # 3️⃣ RETURN DISTRIBUTION (OLD) + # =============================== + plt.figure(figsize=(10, 5)) + plt.hist(window_returns, bins=20, density=True, alpha=0.7) + + plt.title(f"WF Return Distribution – {name}") + plt.xlabel("Return per window (%)") + plt.ylabel("Density") + plt.grid(alpha=0.3) + + path = OUTPUT_DIR / f"wf_return_distribution_{name}.png" + plt.tight_layout() + plt.savefig(path) + plt.close() + + # =============================== + # SUMMARY + # =============================== + results_summary.append({ + "strategy": name, + "windows": len(window_returns), + "return_mean": round(np.mean(window_returns), 2), + "return_median": round(np.median(window_returns), 2), + "max_dd_worst": round(worst_dd, 2), + "final_capital": round(capital, 2), + }) + + # =============================== + # 4️⃣ ACCUMULATED EQUITY – ALL + # =============================== + plt.figure(figsize=(13, 6)) + for name, acc_df in all_accumulated.items(): + plt.plot(acc_df.index, acc_df["capital"], linewidth=2, label=name) + + plt.title("WF Accumulated Equity – Strategy Comparison") + plt.ylabel("Capital") + plt.legend() + plt.grid(alpha=0.3) + + path = OUTPUT_DIR / "wf_equity_accumulated_ALL.png" + plt.tight_layout() + plt.savefig(path) + plt.close() + + # =============================== + # 5️⃣ DISPERSION COMPARISON – P50 + # =============================== + plt.figure(figsize=(12, 6)) + for name, (x, _, p50, _) in all_dispersion_percentiles.items(): + plt.plot(x, p50, linewidth=2, label=name) + + plt.title("WF Dispersion Comparison (Median – P50)") + plt.ylabel("Normalized Equity") + plt.xlabel("Window Progress") + plt.legend() + plt.grid(alpha=0.3) + + path = OUTPUT_DIR / "wf_dispersion_comparison_P50.png" + plt.tight_layout() + plt.savefig(path) + plt.close() + + # =============================== + # FINAL TABLE + # =============================== + summary_df = pd.DataFrame(results_summary).set_index("strategy") + + print("\n" + "=" * 80) + print("📊 WF SUMMARY (ACCUMULATED)") + print("=" * 80) + print(summary_df) + print("=" * 80) + + +if __name__ == "__main__": + run() diff --git a/scripts/research/wf_optimize_strategies.py b/scripts/research/wf_optimize_strategies.py new file mode 100644 index 0000000..4a9478d --- /dev/null +++ b/scripts/research/wf_optimize_strategies.py @@ -0,0 +1,257 @@ +# scripts/research/wf_optimize_strategies.py + +import os +import sys +from pathlib import Path +from dotenv import load_dotenv +from datetime import timedelta + +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import seaborn as sns + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.core.engine import Engine +from src.data.storage import StorageManager +from src.risk.sizing.percent_risk import PercentRiskSizer +from src.risk.stops.trailing_stop import TrailingStop + +from src.strategies.optimization.opt_moving_average import MACrossoverOptimization +from src.strategies.optimization.opt_trend_filtered import TrendFilteredMAOptimization + +from src.utils.logger import log + + +# -------------------------------------------------- +# CONFIG +# -------------------------------------------------- +SYMBOL = "BTC/USDT" +TIMEFRAME = "1h" + +TRAIN_DAYS = 120 +TEST_DAYS = 30 +STEP_DAYS = 30 + +INITIAL_CAPITAL = 10_000 + +RISK = PercentRiskSizer(0.01) +STOP = TrailingStop(0.02) + +COMMISSION = 0.001 +SLIPPAGE = 0.0005 + +OUTPUT_DIR = Path(__file__).parent / "output/wf_optimize" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +TOP_N_PRINT = 15 + +OPTIMIZERS = [ + MACrossoverOptimization, + TrendFilteredMAOptimization, +] + +# -------------------------------------------------- +def setup_env(): + env_path = Path(__file__).parent.parent.parent / "config" / "secrets.env" + load_dotenv(env_path) + +def load_data(): + setup_env() + + 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=SYMBOL, + timeframe=TIMEFRAME, + use_cache=True, + ) + storage.close() + + if data.empty: + raise RuntimeError("No data loaded") + + return data + +def run_wf(data: pd.DataFrame, strategy_factory) -> dict: + capital = INITIAL_CAPITAL + window_returns = [] + worst_dd = 0.0 + windows = 0 + + start = data.index[0] + + while True: + train_end = start + timedelta(days=TRAIN_DAYS) + test_end = train_end + timedelta(days=TEST_DAYS) + + if test_end > data.index[-1]: + break + + test_data = data.loc[train_end:test_end] + + engine = Engine( + strategy=strategy_factory(), + initial_capital=capital, + position_sizer=RISK, + stop_loss=STOP, + commission=COMMISSION, + slippage=SLIPPAGE, + ) + + res = engine.run(test_data) + + windows += 1 + window_returns.append(res["total_return_pct"]) + worst_dd = min(worst_dd, res["max_drawdown_pct"]) + capital *= (1 + res["total_return_pct"] / 100) + + start += timedelta(days=STEP_DAYS) + + wr = np.array(window_returns, dtype=float) + + return { + "windows": windows, + "final_capital": round(capital, 2), + "return_mean": round(wr.mean(), 4), + "return_median": round(np.median(wr), 4), + "max_dd_worst": round(worst_dd, 4), + "win_rate": round((wr > 0).mean(), 4), + } + +def score_row(row): + dd_penalty = abs(row["max_dd_worst"]) / 100.0 + return ( + (row["final_capital"] / INITIAL_CAPITAL) + + row["win_rate"] * 0.5 + - dd_penalty * 1.5 + ) + +# -------------------------------------------------- +def run(): + data = load_data() + rows = [] + + for optimizer in OPTIMIZERS: + log.info(f"🧪 Optimizing {optimizer.name}") + + for params in optimizer.parameter_grid(): + + def factory(p=params): + return optimizer.build_strategy(p) + + metrics = run_wf(data, factory) + row = { + "strategy": optimizer.name, + **params, + **metrics, + } + row["score"] = score_row(row) + rows.append(row) + + df = pd.DataFrame(rows) + df = df.sort_values("score", ascending=False) + + # -------------------------------------------------- + # SAVE CSV + # -------------------------------------------------- + csv_path = OUTPUT_DIR / "wf_optimization_results.csv" + df.to_csv(csv_path, index=False) + log.info(f"✅ Saved CSV: {csv_path}") + + # -------------------------------------------------- + # PRINT TOP CONFIGS + # -------------------------------------------------- + print("\n" + "=" * 100) + print("🏆 TOP CONFIGS (by score)") + print("=" * 100) + + for strat in df["strategy"].unique(): + top = df[df["strategy"] == strat].head(TOP_N_PRINT) + print(f"\n--- {strat} ---") + print( + top[ + [ + "fast_period", + "slow_period", + "adx_threshold", + "final_capital", + "return_mean", + "win_rate", + "max_dd_worst", + "score", + ] + ].to_string(index=False) + ) + + # -------------------------------------------------- + # 📊 VISUALIZATIONS + # -------------------------------------------------- + sns.set(style="whitegrid") + + # A) Scatter: Score vs DD + plt.figure(figsize=(10, 6)) + sns.scatterplot( + data=df, + x="max_dd_worst", + y="score", + hue="strategy", + size="final_capital", + sizes=(40, 200), + alpha=0.7, + ) + plt.title("Score vs Max Drawdown") + plt.tight_layout() + plt.savefig(OUTPUT_DIR / "scatter_score_vs_dd.png") + plt.close() + + # B) Boxplot: Score by Strategy + plt.figure(figsize=(8, 5)) + sns.boxplot(data=df, x="strategy", y="score") + plt.title("Score Distribution by Strategy") + plt.tight_layout() + plt.savefig(OUTPUT_DIR / "boxplot_score_by_strategy.png") + plt.close() + + # C) Heatmaps + for strat in df["strategy"].unique(): + sub = df[df["strategy"] == strat] + + if "adx_threshold" in sub.columns and sub["adx_threshold"].notna().any(): + for adx in sorted(sub["adx_threshold"].dropna().unique()): + pivot = sub[sub["adx_threshold"] == adx].pivot( + index="fast_period", + columns="slow_period", + values="final_capital", + ) + plt.figure(figsize=(10, 6)) + sns.heatmap(pivot, annot=False, cmap="viridis") + plt.title(f"{strat} – Final Capital (ADX={adx})") + plt.tight_layout() + plt.savefig( + OUTPUT_DIR / f"heatmap_{strat}_adx_{int(adx)}.png" + ) + plt.close() + else: + pivot = sub.pivot( + index="fast_period", + columns="slow_period", + values="final_capital", + ) + plt.figure(figsize=(10, 6)) + sns.heatmap(pivot, annot=False, cmap="viridis") + plt.title(f"{strat} – Final Capital") + plt.tight_layout() + plt.savefig(OUTPUT_DIR / f"heatmap_{strat}.png") + plt.close() + + +if __name__ == "__main__": + run() \ No newline at end of file diff --git a/src/backtest/__init__.py b/src/core/__init__.py similarity index 92% rename from src/backtest/__init__.py rename to src/core/__init__.py index 4b058da..bac2e1f 100644 --- a/src/backtest/__init__.py +++ b/src/core/__init__.py @@ -2,7 +2,7 @@ """ Módulo de backtesting """ -from .engine import BacktestEngine +from .engine import Engine from .strategy import Strategy, Signal from .trade import Trade, TradeType, TradeStatus, Position from .optimizer import ParameterOptimizer @@ -16,7 +16,7 @@ from .metrics import ( ) __all__ = [ - 'BacktestEngine', + 'Engine', 'Strategy', 'Signal', 'Trade', diff --git a/src/backtest/engine.py b/src/core/engine.py similarity index 85% rename from src/backtest/engine.py rename to src/core/engine.py index 6d1dbeb..13f28f8 100644 --- a/src/backtest/engine.py +++ b/src/core/engine.py @@ -12,7 +12,7 @@ from .trade import Trade, TradeType, TradeStatus, Position from ..risk.sizing.base import PositionSizer from ..risk.stops.base import StopLoss -class BacktestEngine: +class Engine: """ Motor de backtesting que simula la ejecución de una estrategia """ @@ -185,14 +185,30 @@ class BacktestEngine: Abre una nueva posición, delegando size al PositionSizer si existe """ current_bar = self.data.iloc[idx] - current_price = current_bar['close'] + current_price = current_bar["close"] current_time = current_bar.name - - # Aplicar slippage (en compra, pagamos más) + + # Aplicar slippage (en compra pagamos más) execution_price = current_price * (1 + self.slippage) # -------------------------------------------------- - # ✅ 1) Calcular units (size) vía sizer o fallback legacy + # 🔴 1) Calcular STOP antes del size (si existe) + # -------------------------------------------------- + stop_price = None + if self.stop_loss is not None: + try: + stop_price = self.stop_loss.get_stop_price( + data=self.data, + idx=idx, + entry_price=execution_price, + trade_type=trade_type, + ) + except Exception as e: + log.warning(f"[{current_time}] Error calculando stop: {e}") + return + + # -------------------------------------------------- + # ✅ 2) Calcular units (size) # -------------------------------------------------- if self.position_sizer is not None: try: @@ -200,79 +216,87 @@ class BacktestEngine: self.position_sizer.calculate_size( capital=self.cash, entry_price=float(execution_price), - stop_price=None, # stops aún no integrados - volatility=None # vol/ATR aún no integrado aquí + stop_price=stop_price, + max_capital=self.cash, + volatility=None, ) ) except Exception as e: log.warning(f"[{current_time}] PositionSizer rechazó la entrada: {e}") return - + if not np.isfinite(units) or units <= 0: - log.warning(f"[{current_time}] PositionSizer devolvió units inválidos: {units}") + log.warning(f"[{current_time}] Units inválidas: {units}") return - - position_value = units * execution_price else: - # Fallback actual: usar fracción del cash - position_value = self.cash * self.position_size_fraction - units = position_value / execution_price + # Fallback legacy + units = (self.cash * self.position_size_fraction) / execution_price # -------------------------------------------------- - # ✅ 2) Comisión basada en el nominal invertido + # ✅ 3) CLIP SIZE si no hay suficiente cash # -------------------------------------------------- + position_value = units * execution_price commission_cost = position_value * self.commission + total_cost = position_value + commission_cost - # Verificar que tenemos suficiente cash - if self.cash < position_value + commission_cost: - log.warning( - f"[{current_time}] Cash insuficiente para abrir posición " - f"(cash=${self.cash:.2f}, needed=${position_value + commission_cost:.2f})" + if total_cost > self.cash: + max_affordable_units = self.cash / ( + execution_price * (1 + self.commission) ) - return - + + if max_affordable_units <= 0: + log.warning( + f"[{current_time}] Cash insuficiente incluso para size mínimo" + ) + return + + units = max_affordable_units + position_value = units * execution_price + commission_cost = position_value * self.commission + # -------------------------------------------------- - # ✅ 3) Crear trade + posición + # ✅ 4) Crear trade # -------------------------------------------------- trade = Trade( - symbol=current_bar.get('symbol', 'UNKNOWN'), + symbol=current_bar.get("symbol", "UNKNOWN"), trade_type=trade_type, entry_price=execution_price, entry_time=current_time, size=units, entry_commission=commission_cost, - entry_reason="Strategy signal" + entry_reason="Strategy signal", + stop_price_at_entry=stop_price, + capital_at_entry=self.cash, ) - + # Actualizar cash self.cash -= (position_value + commission_cost) - - # Crear posición + + # -------------------------------------------------- + # ✅ 5) Crear posición + # -------------------------------------------------- self.current_position = Position( symbol=trade.symbol, trade_type=trade_type, average_price=execution_price, total_size=units, - trades=[trade] + trades=[trade], ) - # 🔴 FIJAR STOP INICIAL - if self.stop_loss is not None: - stop_price = self.stop_loss.get_stop_price( - data=self.data, - idx=idx, - entry_price=execution_price, - trade_type=trade_type, - ) + # Fijar stop inicial (si existe) + if stop_price is not None: self.current_position.set_stop(stop_price) - + self.trades.append(trade) - - log.debug(f"[{current_time}] OPEN {trade_type.value}: " - f"Price: ${execution_price:.2f}, Units: {units:.6f}, " - f"Value: ${position_value:.2f}, Fee: ${commission_cost:.2f}") - + + log.debug( + f"[{current_time}] OPEN {trade_type.value}: " + f"Price=${execution_price:.2f} | Units={units:.6f} | " + f"Value=${position_value:.2f} | Fee=${commission_cost:.2f}" + ) + + def _close_position(self, idx: int, reason: str): """ Cierra la posición actual diff --git a/src/backtest/metrics.py b/src/core/metrics.py similarity index 99% rename from src/backtest/metrics.py rename to src/core/metrics.py index 81dc2b7..18a2f10 100644 --- a/src/backtest/metrics.py +++ b/src/core/metrics.py @@ -1,4 +1,4 @@ -# src/backtest/metrics.py +# src/core/metrics.py """ Métricas avanzadas de performance para backtesting """ diff --git a/src/backtest/optimizer.py b/src/core/optimizer.py similarity index 99% rename from src/backtest/optimizer.py rename to src/core/optimizer.py index 67c60b9..a529947 100644 --- a/src/backtest/optimizer.py +++ b/src/core/optimizer.py @@ -7,7 +7,7 @@ import pandas as pd from typing import Dict, List, Any, Type from itertools import product from ..utils.logger import log -from .engine import BacktestEngine +from .engine import Engine from .strategy import Strategy class ParameterOptimizer: @@ -82,7 +82,7 @@ class ParameterOptimizer: strategy = self.strategy_class(**params) # Ejecutar backtest - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=self.initial_capital, commission=self.commission, diff --git a/src/backtest/strategy.py b/src/core/strategy.py similarity index 100% rename from src/backtest/strategy.py rename to src/core/strategy.py diff --git a/src/backtest/trade.py b/src/core/trade.py similarity index 98% rename from src/backtest/trade.py rename to src/core/trade.py index 230302d..6a1d466 100644 --- a/src/backtest/trade.py +++ b/src/core/trade.py @@ -30,6 +30,8 @@ class Trade: exit_price: Optional[float] = None exit_time: Optional[datetime] = None status: TradeStatus = field(default=TradeStatus.OPEN) + stop_price_at_entry: Optional[float] = None + capital_at_entry: Optional[float] = None # Costes entry_commission: float = 0.0 diff --git a/src/backtest/visualizers/__init__.py b/src/core/visualizers/__init__.py similarity index 100% rename from src/backtest/visualizers/__init__.py rename to src/core/visualizers/__init__.py diff --git a/src/backtest/visualizers/visualizer.py b/src/core/visualizers/visualizer.py similarity index 100% rename from src/backtest/visualizers/visualizer.py rename to src/core/visualizers/visualizer.py diff --git a/src/backtest/visualizers/walk_forward_visualizer.py b/src/core/visualizers/walk_forward_visualizer.py similarity index 100% rename from src/backtest/visualizers/walk_forward_visualizer.py rename to src/core/visualizers/walk_forward_visualizer.py diff --git a/src/backtest/walk_forward.py b/src/core/walk_forward.py similarity index 98% rename from src/backtest/walk_forward.py rename to src/core/walk_forward.py index 9020d40..e4b704e 100644 --- a/src/backtest/walk_forward.py +++ b/src/core/walk_forward.py @@ -1,8 +1,8 @@ # src/backtest/walk_forward.py import pandas as pd from typing import List, Dict, Optional -from src.backtest.optimizer import ParameterOptimizer -from src.backtest.engine import BacktestEngine +from src.core.optimizer import ParameterOptimizer +from src.core.engine import Engine from ..utils.logger import log class WalkForwardValidator: @@ -188,7 +188,7 @@ class WalkForwardValidator: # 2️⃣ Backtest TEST (OOS) strategy = self.strategy_class(**best_params) - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=self.initial_capital, commission=self.commission, diff --git a/src/metrics/__init__.py b/src/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/metrics/equity_metrics.py b/src/metrics/equity_metrics.py new file mode 100644 index 0000000..1308e68 --- /dev/null +++ b/src/metrics/equity_metrics.py @@ -0,0 +1,131 @@ +# src/metrics/equity_metrics.py +""" +Métricas basadas exclusivamente en equity curve. +Pensadas para portfolio, paper trading y UI. +""" + +import numpy as np +import pandas as pd +from typing import List, Optional, Dict + + +# -------------------------------------------------- +# Helpers +# -------------------------------------------------- +def _to_series( + equity_curve: List[float], + timestamps: Optional[List[pd.Timestamp]] = None, +) -> pd.Series: + if timestamps is None: + return pd.Series(equity_curve) + return pd.Series(equity_curve, index=pd.to_datetime(timestamps)) + + +# -------------------------------------------------- +# Core metrics +# -------------------------------------------------- +def calculate_cagr( + equity_curve: List[float], + timestamps: List[pd.Timestamp], +) -> float: + """ + CAGR real usando timestamps. + """ + equity = _to_series(equity_curve, timestamps) + + if len(equity) < 2: + return 0.0 + + start, end = equity.iloc[0], equity.iloc[-1] + years = (equity.index[-1] - equity.index[0]).days / 365.25 + + if years <= 0: + return 0.0 + + return (end / start) ** (1 / years) - 1 + + +def calculate_drawdown_series(equity: pd.Series) -> pd.Series: + running_max = equity.cummax() + return (equity - running_max) / running_max + + +def calculate_max_drawdown( + equity_curve: List[float], + timestamps: Optional[List[pd.Timestamp]] = None, +) -> float: + equity = _to_series(equity_curve, timestamps) + dd = calculate_drawdown_series(equity) + return dd.min() + + +def calculate_time_in_drawdown( + equity_curve: List[float], + timestamps: List[pd.Timestamp], +) -> float: + """ + % del tiempo que el sistema está bajo su máximo histórico. + """ + equity = _to_series(equity_curve, timestamps) + dd = calculate_drawdown_series(equity) + return (dd < 0).mean() + + +def calculate_ulcer_index( + equity_curve: List[float], + timestamps: Optional[List[pd.Timestamp]] = None, +) -> float: + """ + Ulcer Index: profundidad + duración del drawdown. + """ + equity = _to_series(equity_curve, timestamps) + dd = calculate_drawdown_series(equity) + return np.sqrt(np.mean(np.square(dd * 100))) + + +def calculate_equity_volatility( + equity_curve: List[float], + timestamps: Optional[List[pd.Timestamp]] = None, + annualize: bool = True, +) -> float: + equity = _to_series(equity_curve, timestamps) + returns = equity.pct_change().dropna() + + if returns.empty: + return 0.0 + + vol = returns.std() + return vol * np.sqrt(252) if annualize else vol + + +def calculate_calmar_ratio( + equity_curve: List[float], + timestamps: List[pd.Timestamp], +) -> float: + cagr = calculate_cagr(equity_curve, timestamps) + max_dd = abs(calculate_max_drawdown(equity_curve, timestamps)) + + if max_dd == 0: + return 0.0 + + return cagr / max_dd + + +# -------------------------------------------------- +# Aggregator +# -------------------------------------------------- +def compute_equity_metrics( + equity_curve: List[float], + timestamps: List[pd.Timestamp], +) -> Dict[str, float]: + """ + Métricas clave para comparar sistemas y portfolios. + """ + return { + "cagr": calculate_cagr(equity_curve, timestamps), + "max_drawdown": calculate_max_drawdown(equity_curve, timestamps), + "calmar_ratio": calculate_calmar_ratio(equity_curve, timestamps), + "volatility": calculate_equity_volatility(equity_curve, timestamps), + "time_in_drawdown": calculate_time_in_drawdown(equity_curve, timestamps), + "ulcer_index": calculate_ulcer_index(equity_curve, timestamps), + } diff --git a/src/portfolio/__init__.py b/src/portfolio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/portfolio/allocation.py b/src/portfolio/allocation.py new file mode 100644 index 0000000..f1a04cd --- /dev/null +++ b/src/portfolio/allocation.py @@ -0,0 +1,17 @@ +# src/portfolio/allocation.py +from dataclasses import dataclass +from typing import Dict + + +@dataclass +class Allocation: + """ + Define cómo se reparte el riesgo entre estrategias. + Los pesos deben sumar 1.0 + """ + weights: Dict[str, float] + + def validate(self): + total = sum(self.weights.values()) + if abs(total - 1.0) > 1e-6: + raise ValueError(f"Allocation weights must sum to 1. Got {total}") diff --git a/src/portfolio/portfolio_engine.py b/src/portfolio/portfolio_engine.py new file mode 100644 index 0000000..30e4246 --- /dev/null +++ b/src/portfolio/portfolio_engine.py @@ -0,0 +1,67 @@ +# src/portfolio/portfolio_engine.py + +from typing import Dict +import pandas as pd + +from src.core.engine import Engine +from src.portfolio.allocation import Allocation +from src.portfolio.portfolio_result import PortfolioResult + + +class PortfolioEngine: + """ + Ejecuta múltiples engines en paralelo y combina resultados + alineando las curvas por timestamp. + """ + + def __init__( + self, + engines: Dict[str, Engine], + allocation: Allocation, + initial_capital: float, + ): + allocation.validate() + self.engines = engines + self.allocation = allocation + self.initial_capital = initial_capital + + def run(self, data): + results = {} + equity_series = [] + + for name, engine in self.engines.items(): + res = engine.run(data) + results[name] = res + + weight = self.allocation.weights[name] + + # --- construir serie con timestamps --- + eq = pd.Series( + res["equity_curve"], + index=pd.to_datetime(res["timestamps"]), + name=name, + ) + + # --- aplicar peso --- + eq_weighted = eq * weight + equity_series.append(eq_weighted) + + # -------------------------------------------------- + # Alinear todas las curvas por timestamp + # -------------------------------------------------- + df = pd.concat(equity_series, axis=1) + + # Forward fill para periodos sin trades + df = df.ffill() + + # Si alguna empieza más tarde, asumimos capital inicial ponderado + df = df.fillna(self.initial_capital * 0.0) + + # Equity total del portfolio + portfolio_equity = df.sum(axis=1) + + return PortfolioResult( + equity_curve=portfolio_equity.tolist(), + final_capital=float(portfolio_equity.iloc[-1]), + components=results, + ) diff --git a/src/portfolio/portfolio_result.py b/src/portfolio/portfolio_result.py new file mode 100644 index 0000000..a0ed397 --- /dev/null +++ b/src/portfolio/portfolio_result.py @@ -0,0 +1,10 @@ +# src/portfolio/portfolio_result.py +from dataclasses import dataclass +from typing import Dict, List + + +@dataclass +class PortfolioResult: + equity_curve: List[float] + final_capital: float + components: Dict[str, dict] diff --git a/src/risk/sizing/base.py b/src/risk/sizing/base.py index 4fcfe44..e22c5e6 100644 --- a/src/risk/sizing/base.py +++ b/src/risk/sizing/base.py @@ -12,9 +12,11 @@ class PositionSizer(ABC): @abstractmethod def calculate_size( self, + *, capital: float, entry_price: float, stop_price: Optional[float] = None, + max_capital: float | None = None, volatility: Optional[float] = None, ) -> float: """ diff --git a/src/risk/sizing/percent_risk.py b/src/risk/sizing/percent_risk.py index 8c8a8a6..d605823 100644 --- a/src/risk/sizing/percent_risk.py +++ b/src/risk/sizing/percent_risk.py @@ -1,11 +1,11 @@ # src/risk/sizing/percent_risk.py - from .base import PositionSizer class PercentRiskSizer(PositionSizer): """ - Position sizing basado en % de riesgo por trade. + Position sizing basado en % de riesgo por trade, + limitado por capital disponible. """ def __init__(self, risk_fraction: float): @@ -15,22 +15,30 @@ class PercentRiskSizer(PositionSizer): def calculate_size( self, + *, capital: float, entry_price: float, - stop_price: float | None = None + stop_price: float | None = None, + max_capital: float | None = None, + volatility=None, ) -> float: if stop_price is None: raise ValueError("PercentRiskSizer requiere stop_price") - risk_amount = capital * self.risk_fraction distance = abs(entry_price - stop_price) - - if distance < 0: - raise ValueError("Distancia entry-stop inválida") - - if distance == 0: + if distance <= 0: return 0.0 - position_size = risk_amount / distance - return position_size + # 1️⃣ Riesgo máximo permitido + risk_amount = capital * self.risk_fraction + units_by_risk = risk_amount / distance + + # 2️⃣ Límite por capital disponible + if max_capital is not None: + max_units_by_cash = max_capital / entry_price + units = min(units_by_risk, max_units_by_cash) + else: + units = units_by_risk + + return max(units, 0.0) diff --git a/src/risk/stops/atr_stop.py b/src/risk/stops/atr_stop.py index b37d441..bef40af 100644 --- a/src/risk/stops/atr_stop.py +++ b/src/risk/stops/atr_stop.py @@ -2,7 +2,7 @@ import pandas as pd import numpy as np from src.risk.stops.base import StopLoss -from src.backtest.trade import TradeType +from src.core.trade import TradeType class ATRStop(StopLoss): @@ -36,7 +36,7 @@ class ATRStop(StopLoss): axis=1, ).max(axis=1) - atr = tr.rolling(self.atr_period).mean() + atr = tr.ewm(alpha=1/self.atr_period, adjust=False).mean() return atr def get_stop_price( diff --git a/src/risk/stops/base.py b/src/risk/stops/base.py index 1ca6704..95400bc 100644 --- a/src/risk/stops/base.py +++ b/src/risk/stops/base.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod import pandas as pd -from src.backtest.trade import TradeType +from src.core.trade import TradeType class StopLoss(ABC): @@ -37,3 +37,16 @@ class StopLoss(ABC): stop_price (float) """ pass + + def update_stop( + self, + *, + data: pd.DataFrame, + idx: int, + position + ): + """ + Por defecto: stop NO dinámico. + Devuelve None → no mover stop. + """ + return None diff --git a/src/risk/stops/fixed_stop.py b/src/risk/stops/fixed_stop.py index f383193..7cd8d1f 100644 --- a/src/risk/stops/fixed_stop.py +++ b/src/risk/stops/fixed_stop.py @@ -1,7 +1,7 @@ # src/risk/stops/fixed_stop.py import pandas as pd from src.risk.stops.base import StopLoss -from src.backtest.trade import TradeType +from src.core.trade import TradeType class FixedStop(StopLoss): """ diff --git a/src/risk/stops/trailing_stop.py b/src/risk/stops/trailing_stop.py index b0beaca..350e753 100644 --- a/src/risk/stops/trailing_stop.py +++ b/src/risk/stops/trailing_stop.py @@ -1,7 +1,7 @@ # src/risk/stops/trailing_stop.py import pandas as pd from src.risk.stops.base import StopLoss -from src.backtest.trade import TradeType, Position +from src.core.trade import TradeType, Position class TrailingStop(StopLoss): diff --git a/src/strategies/base.py b/src/strategies/base.py index d6ebc53..9fe50f6 100644 --- a/src/strategies/base.py +++ b/src/strategies/base.py @@ -1,5 +1,45 @@ # src/strategies/base.py -""" -Estrategias base para herencia compleja -TODO: Implementar en fases futuras -""" \ No newline at end of file + +from abc import ABC, abstractmethod +import pandas as pd +from src.core.strategy import Signal + + +class Strategy(ABC): + """ + Clase base para todas las estrategias. + + Flujo: + - Engine llama a set_data(data) + - set_data → init_indicators + - Engine llama a generate_signal(idx) + """ + + def __init__(self, name: str, params: dict | None = None): + self.name = name + self.params = params or {} + self.data: pd.DataFrame | None = None + + def set_data(self, data: pd.DataFrame): + """ + Inyecta el DataFrame y calcula indicadores. + """ + self.data = self.init_indicators(data.copy()) + + @abstractmethod + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """ + Calcula y añade indicadores al DataFrame. + """ + pass + + @abstractmethod + def generate_signal(self, idx: int) -> Signal: + """ + Devuelve BUY / SELL / HOLD para el índice idx. + """ + pass + + def __repr__(self): + params = ", ".join(f"{k}={v}" for k, v in self.params.items()) + return f"{self.name}({params})" diff --git a/src/strategies/breakout.py b/src/strategies/breakout.py new file mode 100644 index 0000000..426a4ff --- /dev/null +++ b/src/strategies/breakout.py @@ -0,0 +1,64 @@ +# src/strategies/breakout.py +import pandas as pd + +from src.strategies.base import Strategy +from src.core.strategy import Signal + +class DonchianBreakout(Strategy): + """ + Estrategia de ruptura de canales Donchian + + Señales: + - BUY: El precio rompe el máximo de los últimos N periodos + - SELL: El precio rompe el mínimo de los últimos N periodos + - HOLD: En cualquier otro caso + + Parámetros: + lookback: Ventana de cálculo del canal + + Valores por defecto: + lookback = 20 + ≈ 1 día en timeframe 1h + Parámetro clásico del sistema Turtle + + Notas: + - Es una estrategia de momentum puro + - No intenta comprar barato, compra fortaleza + - Filtra ruido al exigir ruptura real + """ + + def __init__(self, lookback: int = 20): + params = { + "lookback": lookback + } + + super().__init__(name="DonchianBreakout", params=params) + + self.lookback = lookback + + # ------------------------------------------------------------------ + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + data["donchian_high"] = data["high"].rolling(self.lookback).max() + data["donchian_low"] = data["low"].rolling(self.lookback).min() + return data + + def generate_signal(self, idx: int) -> Signal: + if idx < self.lookback: + return Signal.HOLD + + high = self.data["high"] + low = self.data["low"] + close = self.data["close"] + + max_high = high.iloc[idx - self.lookback : idx].max() + min_low = low.iloc[idx - self.lookback : idx].min() + + price = close.iloc[idx] + + if price > max_high: + return Signal.BUY + elif price < min_low: + return Signal.SELL + + return Signal.HOLD diff --git a/src/strategies/buy_and_hold.py b/src/strategies/buy_and_hold.py index 12ecf78..1978b41 100644 --- a/src/strategies/buy_and_hold.py +++ b/src/strategies/buy_and_hold.py @@ -3,7 +3,7 @@ Estrategia Buy and Hold """ import pandas as pd -from ..backtest.strategy import Strategy, Signal +from ..core.strategy import Strategy, Signal class BuyAndHold(Strategy): """ diff --git a/src/strategies/mean_reversion.py b/src/strategies/mean_reversion.py new file mode 100644 index 0000000..fbda5fe --- /dev/null +++ b/src/strategies/mean_reversion.py @@ -0,0 +1,95 @@ +# src/strategies/mean_reversion.py + +import pandas as pd +import numpy as np + +from src.strategies.base import Strategy +from src.core.strategy import Signal + + +class RSIMeanReversion(Strategy): + """ + Estrategia de reversión a la media basada en RSI. + + Idea: + - Compra cuando el mercado está sobrevendido + - Vende cuando el precio rebota hacia la media + + Señales: + - BUY: RSI cruza por debajo de oversold + - SELL: RSI cruza por encima de overbought + - HOLD: en cualquier otro caso + + Parámetros: + period: periodo del RSI + oversold: nivel de sobreventa + overbought: nivel de sobrecompra + + Valores típicos: + period = 14 + oversold = 30 + overbought = 70 + """ + + def __init__( + self, + period: int = 14, + oversold: float = 30.0, + overbought: float = 70.0, + ): + super().__init__( + name="RSI_MeanReversion", + params={ + "period": period, + "oversold": oversold, + "overbought": overbought, + }, + ) + + self.period = period + self.oversold = oversold + self.overbought = overbought + + # -------------------------------------------------- + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """ + Calcula el RSI clásico (Wilder). + + Añade: + - data["rsi"] + """ + delta = data["close"].diff() + + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + + avg_gain = gain.ewm(alpha=1 / self.period, adjust=False).mean() + avg_loss = loss.ewm(alpha=1 / self.period, adjust=False).mean() + + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + + data["rsi"] = rsi + + return data + + # -------------------------------------------------- + def generate_signal(self, idx: int) -> Signal: + """ + Genera señales de trading basadas en cruces del RSI. + """ + if idx == 0: + return Signal.HOLD + + rsi_prev = self.data["rsi"].iloc[idx - 1] + rsi_curr = self.data["rsi"].iloc[idx] + + # BUY → cruce hacia abajo de oversold + if rsi_prev > self.oversold and rsi_curr <= self.oversold: + return Signal.BUY + + # SELL → cruce hacia arriba de overbought + if rsi_prev < self.overbought and rsi_curr >= self.overbought: + return Signal.SELL + + return Signal.HOLD diff --git a/src/strategies/moving_average.py b/src/strategies/moving_average.py index 6bfefcd..3256e6e 100644 --- a/src/strategies/moving_average.py +++ b/src/strategies/moving_average.py @@ -3,7 +3,7 @@ Estrategia de cruce de medias móviles con filtro ADX opcional """ import pandas as pd -from ..backtest.strategy import Strategy, Signal, calculate_sma, calculate_ema +from ..core.strategy import Strategy, Signal, calculate_sma, calculate_ema class MovingAverageCrossover(Strategy): @@ -21,13 +21,18 @@ class MovingAverageCrossover(Strategy): ma_type: 'sma' o 'ema' use_adx: Activar filtro ADX adx_threshold: Umbral mínimo de ADX + + Valores por defecto: + 20/50 EMA → clásico en crypto 1h - 4h + EMA reacciona mejor que SMA + Sin ADX todavía → primero evaluamos la señal “pura” """ def __init__( self, - fast_period: int = 10, - slow_period: int = 30, - ma_type: str = 'sma', + fast_period: int = 20, + slow_period: int = 50, + ma_type: str = 'ema', use_adx: bool = False, adx_threshold: float = 20.0 ): diff --git a/src/strategies/optimization/__init__.py b/src/strategies/optimization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/strategies/optimization/base.py b/src/strategies/optimization/base.py new file mode 100644 index 0000000..e69de29 diff --git a/src/strategies/optimization/opt_moving_average.py b/src/strategies/optimization/opt_moving_average.py new file mode 100644 index 0000000..445fb16 --- /dev/null +++ b/src/strategies/optimization/opt_moving_average.py @@ -0,0 +1,25 @@ +from itertools import product +from src.strategies.moving_average import MovingAverageCrossover + +class MACrossoverOptimization: + + name = "MA_Crossover" + + @staticmethod + def parameter_grid(): + fast = [10, 15, 20, 25, 30] + slow = [40, 50, 60, 80, 100] + min_gap = 15 + + for f, s in product(fast, slow): + if s - f >= min_gap: + yield { + "fast_period": f, + "slow_period": s, + "ma_type": "ema", + "use_adx": False, + } + + @staticmethod + def build_strategy(params): + return MovingAverageCrossover(**params) diff --git a/src/strategies/optimization/opt_trend_filtered.py b/src/strategies/optimization/opt_trend_filtered.py new file mode 100644 index 0000000..d975f9d --- /dev/null +++ b/src/strategies/optimization/opt_trend_filtered.py @@ -0,0 +1,26 @@ +from itertools import product +from src.strategies.trend_filtered import TrendFilteredMACrossover + +class TrendFilteredMAOptimization: + + name = "TrendFiltered_MA" + + @staticmethod + def parameter_grid(): + fast = [10, 15, 20, 25, 30] + slow = [40, 50, 60, 80, 100] + adx = [15, 20, 25, 30] + min_gap = 15 + + for f, s, a in product(fast, slow, adx): + if s - f >= min_gap: + yield { + "fast_period": f, + "slow_period": s, + "ma_type": "ema", + "adx_threshold": a, + } + + @staticmethod + def build_strategy(params): + return TrendFilteredMACrossover(**params) diff --git a/src/strategies/rsi_strategy.py b/src/strategies/rsi_strategy.py index e91bf5b..5656667 100644 --- a/src/strategies/rsi_strategy.py +++ b/src/strategies/rsi_strategy.py @@ -3,7 +3,7 @@ Estrategia basada en RSI """ import pandas as pd -from ..backtest.strategy import Strategy, Signal, calculate_rsi +from ..core.strategy import Strategy, Signal, calculate_rsi class RSIStrategy(Strategy): """ diff --git a/src/strategies/trend_filtered.py b/src/strategies/trend_filtered.py new file mode 100644 index 0000000..a5f85e9 --- /dev/null +++ b/src/strategies/trend_filtered.py @@ -0,0 +1,131 @@ +# src/strategies/trend_filtered.py + +import pandas as pd +import numpy as np + +from src.strategies.base import Strategy +from src.core.strategy import Signal + + +class TrendFilteredMACrossover(Strategy): + """ + Estrategia de cruce de medias con filtro de tendencia. + + Señales: + - BUY: + * Cruce alcista de medias + * Precio por encima de MA lenta + * ADX >= threshold + - SELL: + * Cruce bajista de medias + - HOLD: + * En cualquier otro caso + + Objetivo: + - Evitar whipsaws en mercado lateral + - Operar solo cuando hay estructura de tendencia + + Parámetros por defecto: + fast_period=20 + slow_period=50 + ma_type='ema' + adx_period=14 + adx_threshold=20 + """ + + def __init__( + self, + fast_period: int = 20, + slow_period: int = 50, + ma_type: str = "ema", + adx_period: int = 14, + adx_threshold: float = 20.0, + ): + params = { + "fast_period": fast_period, + "slow_period": slow_period, + "ma_type": ma_type, + "adx_period": adx_period, + "adx_threshold": adx_threshold, + } + + super().__init__(name="TrendFilteredMACrossover", params=params) + + self.fast_period = fast_period + self.slow_period = slow_period + self.ma_type = ma_type + self.adx_period = adx_period + self.adx_threshold = adx_threshold + + # -------------------------------------------------- + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + # Medias móviles + if self.ma_type == "ema": + data["ma_fast"] = data["close"].ewm( + span=self.fast_period, adjust=False + ).mean() + data["ma_slow"] = data["close"].ewm( + span=self.slow_period, adjust=False + ).mean() + else: + data["ma_fast"] = data["close"].rolling(self.fast_period).mean() + data["ma_slow"] = data["close"].rolling(self.slow_period).mean() + + # ADX + high = data["high"] + low = data["low"] + close = data["close"] + + plus_dm = high.diff() + minus_dm = low.diff().abs() + + plus_dm[plus_dm < 0] = 0 + minus_dm[minus_dm < 0] = 0 + + tr = pd.concat( + [ + high - low, + (high - close.shift()).abs(), + (low - close.shift()).abs(), + ], + axis=1, + ).max(axis=1) + + atr = tr.ewm(alpha=1 / self.adx_period, adjust=False).mean() + + plus_di = 100 * ( + plus_dm.ewm(alpha=1 / self.adx_period, adjust=False).mean() / atr + ) + minus_di = 100 * ( + minus_dm.ewm(alpha=1 / self.adx_period, adjust=False).mean() / atr + ) + + dx = (abs(plus_di - minus_di) / (plus_di + minus_di)) * 100 + data["adx"] = dx.ewm(alpha=1 / self.adx_period, adjust=False).mean() + + return data + + # -------------------------------------------------- + def generate_signal(self, idx: int) -> Signal: + if idx == 0: + return Signal.HOLD + + row = self.data.iloc[idx] + prev = self.data.iloc[idx - 1] + + # Cruces + cross_up = prev.ma_fast <= prev.ma_slow and row.ma_fast > row.ma_slow + cross_down = prev.ma_fast >= prev.ma_slow and row.ma_fast < row.ma_slow + + # Filtro de tendencia + trend_ok = ( + row.close > row.ma_slow and row.adx >= self.adx_threshold + ) + + if cross_up and trend_ok: + return Signal.BUY + + if cross_down: + return Signal.SELL + + return Signal.HOLD diff --git a/tests/backtest/test_engine_percent_risk.py b/tests/backtest/test_engine_percent_risk.py new file mode 100644 index 0000000..1e6e8c7 --- /dev/null +++ b/tests/backtest/test_engine_percent_risk.py @@ -0,0 +1,108 @@ +# tests/backtest/test_engine_percent_risk.py +import sys +from pathlib import Path +import pandas as pd +from datetime import datetime + +# Añadir raíz del proyecto al path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.core.engine import Engine +from src.core.strategy import Strategy, Signal +from src.core.trade import TradeStatus +from src.risk.sizing.percent_risk import PercentRiskSizer +from src.risk.stops.fixed_stop import FixedStop + + +# -------------------------------------------------- +# Estrategia dummy +# -------------------------------------------------- +class AlwaysBuyStrategy(Strategy): + """ + Compra en la primera vela y nunca vende. + El stop debe cerrar la posición. + """ + + def __init__(self): + super().__init__(name="AlwaysBuy", params={}) + + def init_indicators(self, data): + return data + + def generate_signal(self, idx: int): + if idx == 0: + return Signal.BUY + return Signal.HOLD + + +# -------------------------------------------------- +# Test de integración real +# -------------------------------------------------- +def test_engine_percent_risk_with_fixed_stop(): + """ + Verifica que: + - El size se calcula usando el stop + - El riesgo por trade ≈ % configurado + - El stop cierra la posición + """ + + # ----------------------------- + # Datos simulados + # ----------------------------- + timestamps = pd.date_range( + start=datetime(2024, 1, 1), + periods=5, + freq="1h", + ) + + data = pd.DataFrame( + { + "open": [100, 100, 100, 100, 100], + "high": [101, 101, 101, 101, 101], + "low": [99, 97, 95, 93, 90], + "close": [100, 98, 96, 94, 91], # rompe stop + "volume": [1, 1, 1, 1, 1], + }, + index=timestamps, + ) + + # ----------------------------- + # Configuración + # ----------------------------- + initial_capital = 10_000 + risk_fraction = 0.01 # 1% por trade + stop_fraction = 0.02 # stop al 2% + + strategy = AlwaysBuyStrategy() + + engine = Engine( + strategy=strategy, + initial_capital=initial_capital, + commission=0.0, + slippage=0.0, + position_sizer=PercentRiskSizer(risk_fraction), + stop_loss=FixedStop(stop_fraction), + ) + + # ----------------------------- + # Ejecutar backtest + # ----------------------------- + engine.run(data) + + # ----------------------------- + # Assertions + # ----------------------------- + assert len(engine.trades) == 1 + + trade = engine.trades[0] + + # Trade cerrado por stop + assert trade.status == TradeStatus.CLOSED + assert trade.exit_reason == "Stop Loss" + + # Riesgo real ≈ riesgo esperado + expected_risk = initial_capital * risk_fraction + actual_loss = abs(trade.pnl) + + # Permitimos pequeño error numérico + assert abs(actual_loss - expected_risk) / expected_risk < 0.05 diff --git a/tests/backtest/test_engine_sizing.py b/tests/backtest/test_engine_sizing.py index 7aa0687..49a0df3 100644 --- a/tests/backtest/test_engine_sizing.py +++ b/tests/backtest/test_engine_sizing.py @@ -9,8 +9,8 @@ from datetime import datetime, timedelta # Añadir raíz del proyecto al path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from src.backtest.engine import BacktestEngine -from src.backtest.strategy import Strategy, Signal +from src.core.engine import Engine +from src.core.strategy import Strategy, Signal from src.risk.sizing.fixed import FixedPositionSizer class BuyOnceStrategy(Strategy): @@ -66,7 +66,7 @@ def test_engine_uses_fixed_position_sizer(): sizer = FixedPositionSizer(capital_fraction=0.5) - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=10000, commission=0.0, diff --git a/tests/backtest/test_engine_stop.py b/tests/backtest/test_engine_stop.py index a24e068..0d21745 100644 --- a/tests/backtest/test_engine_stop.py +++ b/tests/backtest/test_engine_stop.py @@ -6,9 +6,9 @@ from datetime import datetime # Añadir raíz del proyecto al path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from src.backtest.engine import BacktestEngine -from src.backtest.strategy import Strategy, Signal -from src.backtest.trade import TradeStatus +from src.core.engine import Engine +from src.core.strategy import Strategy, Signal +from src.core.trade import TradeStatus from src.risk.stops.fixed_stop import FixedStop @@ -57,7 +57,7 @@ def test_engine_closes_position_on_stop_hit(): data = _build_test_data() strategy = AlwaysBuyStrategy() - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=10_000, commission=0.0, @@ -82,7 +82,7 @@ def test_engine_closes_position_at_end_without_stop(): data = _build_test_data() strategy = AlwaysBuyStrategy() - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=10_000, commission=0.0, diff --git a/tests/backtest/test_engine_trailing_stop.py b/tests/backtest/test_engine_trailing_stop.py index d1cb009..76326f1 100644 --- a/tests/backtest/test_engine_trailing_stop.py +++ b/tests/backtest/test_engine_trailing_stop.py @@ -7,9 +7,9 @@ from datetime import datetime sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from src.backtest.engine import BacktestEngine -from src.backtest.strategy import Strategy, Signal -from src.backtest.trade import TradeStatus +from src.core.engine import Engine +from src.core.strategy import Strategy, Signal +from src.core.trade import TradeStatus from src.risk.stops.trailing_stop import TrailingStop @@ -58,7 +58,7 @@ def test_trailing_stop_moves_and_closes_position(): strategy = AlwaysBuyStrategy() - engine = BacktestEngine( + engine = Engine( strategy=strategy, initial_capital=10000, commission=0.0, diff --git a/tests/dam_test.py b/tests/dam_test.py index 1652b05..2afb62c 100644 --- a/tests/dam_test.py +++ b/tests/dam_test.py @@ -12,7 +12,7 @@ import pandas as pd sys.path.insert(0, str(Path(__file__).parent.parent)) from src.data.storage import StorageManager -from src.backtest.walk_forward import WalkForwardValidator +from src.core.walk_forward import WalkForwardValidator from src.strategies import MovingAverageCrossover def setup_environment(): diff --git a/tests/risk/stops/test_atr_stop.py b/tests/risk/stops/test_atr_stop.py index f95f7de..1ea472c 100644 --- a/tests/risk/stops/test_atr_stop.py +++ b/tests/risk/stops/test_atr_stop.py @@ -9,9 +9,9 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) from src.risk.stops.base import StopLoss -from src.backtest.trade import TradeType +from src.core.trade import TradeType from src.risk.stops.atr_stop import ATRStop -from src.backtest.trade import TradeType +from src.core.trade import TradeType def atr_data(): diff --git a/tests/risk/stops/test_fixed_stop.py b/tests/risk/stops/test_fixed_stop.py index a8adbc1..9e56892 100644 --- a/tests/risk/stops/test_fixed_stop.py +++ b/tests/risk/stops/test_fixed_stop.py @@ -8,7 +8,7 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) from src.risk.stops.fixed_stop import FixedStop -from src.backtest.trade import TradeType +from src.core.trade import TradeType def dummy_data(): diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py index ed24584..568f212 100644 --- a/tests/test_optimizer.py +++ b/tests/test_optimizer.py @@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from src.utils.logger import log from src.data.storage import StorageManager from src.strategies import MovingAverageCrossover -from src.backtest.optimizer import ParameterOptimizer +from src.core.optimizer import ParameterOptimizer def setup_environment(): """Carga variables de entorno""" diff --git a/tests/test_visualizer.py b/tests/test_visualizer.py index c8ed5e9..11674b5 100644 --- a/tests/test_visualizer.py +++ b/tests/test_visualizer.py @@ -14,8 +14,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from src.utils.logger import log from src.data.storage import StorageManager from src.strategies import MovingAverageCrossover -from src.backtest import BacktestEngine -from src.backtest.visualizers.visualizer import BacktestVisualizer +from src.core import BacktestEngine +from src.core.visualizers.visualizer import BacktestVisualizer def setup_environment(): """Carga variables de entorno""" diff --git a/tests/test_walkforwad.py b/tests/test_walkforwad.py index 08ff337..df3d5ce 100644 --- a/tests/test_walkforwad.py +++ b/tests/test_walkforwad.py @@ -15,7 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from src.utils.logger import log from src.data.storage import StorageManager from src.strategies import MovingAverageCrossover -from src.backtest.walk_forward import WalkForwardValidator +from src.core.walk_forward import WalkForwardValidator def setup_environment(): diff --git a/tests/test_wf_visualizer.py b/tests/test_wf_visualizer.py index c520436..f17d2a0 100644 --- a/tests/test_wf_visualizer.py +++ b/tests/test_wf_visualizer.py @@ -7,7 +7,7 @@ import pandas as pd # Añadir raíz del proyecto al path sys.path.insert(0, str(Path(__file__).parent.parent)) -from src.backtest.visualizers.walk_forward_visualizer import WalkForwardVisualizer +from src.core.visualizers.walk_forward_visualizer import WalkForwardVisualizer def test_wf_visualizer():