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
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ logs/
|
|||||||
|
|
||||||
# Resultados
|
# Resultados
|
||||||
backtest_results/
|
backtest_results/
|
||||||
|
output/
|
||||||
10
backtest.py
10
backtest.py
@@ -9,8 +9,8 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from src.utils.logger import log
|
from src.utils.logger import log
|
||||||
from src.data.storage import StorageManager
|
from src.data.storage import StorageManager
|
||||||
from src.backtest.engine import BacktestEngine
|
from src.core.engine import Engine
|
||||||
from src.backtest.metrics import print_backtest_report, calculate_all_metrics
|
from src.core.metrics import print_backtest_report, calculate_all_metrics
|
||||||
from src.strategies import MovingAverageCrossover, BuyAndHold, RSIStrategy
|
from src.strategies import MovingAverageCrossover, BuyAndHold, RSIStrategy
|
||||||
|
|
||||||
def setup_environment():
|
def setup_environment():
|
||||||
@@ -76,7 +76,7 @@ def run_backtest_demo():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Crear motor de backtesting
|
# Crear motor de backtesting
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=10000,
|
initial_capital=10000,
|
||||||
commission=0.001, # 0.1%
|
commission=0.001, # 0.1%
|
||||||
@@ -172,7 +172,7 @@ def compare_strategies_demo():
|
|||||||
for name, strategy in strategies:
|
for name, strategy in strategies:
|
||||||
log.info(f"\n🧪 Testeando: {name}")
|
log.info(f"\n🧪 Testeando: {name}")
|
||||||
|
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=10000,
|
initial_capital=10000,
|
||||||
commission=0.001,
|
commission=0.001,
|
||||||
@@ -187,7 +187,7 @@ def compare_strategies_demo():
|
|||||||
log.info(f" Win Rate: {results['win_rate_pct']:.2f}%")
|
log.info(f" Win Rate: {results['win_rate_pct']:.2f}%")
|
||||||
|
|
||||||
# Comparar resultados
|
# Comparar resultados
|
||||||
from src.backtest.metrics import compare_strategies
|
from src.core.metrics import compare_strategies
|
||||||
compare_strategies(all_results)
|
compare_strategies(all_results)
|
||||||
|
|
||||||
storage.close()
|
storage.close()
|
||||||
|
|||||||
186
scripts/research/compare_stops.py
Normal file
186
scripts/research/compare_stops.py
Normal file
@@ -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()
|
||||||
192
scripts/research/compare_systems.py
Normal file
192
scripts/research/compare_systems.py
Normal file
@@ -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()
|
||||||
199
scripts/research/portfolio_backtest.py
Normal file
199
scripts/research/portfolio_backtest.py
Normal file
@@ -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()
|
||||||
185
scripts/research/risk_validation.py
Normal file
185
scripts/research/risk_validation.py
Normal file
@@ -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()
|
||||||
222
scripts/research/walk_forward_stops.py
Normal file
222
scripts/research/walk_forward_stops.py
Normal file
@@ -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()
|
||||||
275
scripts/research/wf_compare_strategies.py
Normal file
275
scripts/research/wf_compare_strategies.py
Normal file
@@ -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()
|
||||||
257
scripts/research/wf_optimize_strategies.py
Normal file
257
scripts/research/wf_optimize_strategies.py
Normal file
@@ -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()
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
Módulo de backtesting
|
Módulo de backtesting
|
||||||
"""
|
"""
|
||||||
from .engine import BacktestEngine
|
from .engine import Engine
|
||||||
from .strategy import Strategy, Signal
|
from .strategy import Strategy, Signal
|
||||||
from .trade import Trade, TradeType, TradeStatus, Position
|
from .trade import Trade, TradeType, TradeStatus, Position
|
||||||
from .optimizer import ParameterOptimizer
|
from .optimizer import ParameterOptimizer
|
||||||
@@ -16,7 +16,7 @@ from .metrics import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'BacktestEngine',
|
'Engine',
|
||||||
'Strategy',
|
'Strategy',
|
||||||
'Signal',
|
'Signal',
|
||||||
'Trade',
|
'Trade',
|
||||||
@@ -12,7 +12,7 @@ from .trade import Trade, TradeType, TradeStatus, Position
|
|||||||
from ..risk.sizing.base import PositionSizer
|
from ..risk.sizing.base import PositionSizer
|
||||||
from ..risk.stops.base import StopLoss
|
from ..risk.stops.base import StopLoss
|
||||||
|
|
||||||
class BacktestEngine:
|
class Engine:
|
||||||
"""
|
"""
|
||||||
Motor de backtesting que simula la ejecución de una estrategia
|
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
|
Abre una nueva posición, delegando size al PositionSizer si existe
|
||||||
"""
|
"""
|
||||||
current_bar = self.data.iloc[idx]
|
current_bar = self.data.iloc[idx]
|
||||||
current_price = current_bar['close']
|
current_price = current_bar["close"]
|
||||||
current_time = current_bar.name
|
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)
|
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:
|
if self.position_sizer is not None:
|
||||||
try:
|
try:
|
||||||
@@ -200,8 +216,9 @@ class BacktestEngine:
|
|||||||
self.position_sizer.calculate_size(
|
self.position_sizer.calculate_size(
|
||||||
capital=self.cash,
|
capital=self.cash,
|
||||||
entry_price=float(execution_price),
|
entry_price=float(execution_price),
|
||||||
stop_price=None, # stops aún no integrados
|
stop_price=stop_price,
|
||||||
volatility=None # vol/ATR aún no integrado aquí
|
max_capital=self.cash,
|
||||||
|
volatility=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -209,69 +226,76 @@ class BacktestEngine:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not np.isfinite(units) or units <= 0:
|
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
|
return
|
||||||
|
|
||||||
position_value = units * execution_price
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Fallback actual: usar fracción del cash
|
# Fallback legacy
|
||||||
position_value = self.cash * self.position_size_fraction
|
units = (self.cash * self.position_size_fraction) / execution_price
|
||||||
units = position_value / 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
|
commission_cost = position_value * self.commission
|
||||||
|
total_cost = position_value + commission_cost
|
||||||
|
|
||||||
# Verificar que tenemos suficiente cash
|
if total_cost > self.cash:
|
||||||
if self.cash < position_value + commission_cost:
|
max_affordable_units = self.cash / (
|
||||||
|
execution_price * (1 + self.commission)
|
||||||
|
)
|
||||||
|
|
||||||
|
if max_affordable_units <= 0:
|
||||||
log.warning(
|
log.warning(
|
||||||
f"[{current_time}] Cash insuficiente para abrir posición "
|
f"[{current_time}] Cash insuficiente incluso para size mínimo"
|
||||||
f"(cash=${self.cash:.2f}, needed=${position_value + commission_cost:.2f})"
|
|
||||||
)
|
)
|
||||||
return
|
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(
|
trade = Trade(
|
||||||
symbol=current_bar.get('symbol', 'UNKNOWN'),
|
symbol=current_bar.get("symbol", "UNKNOWN"),
|
||||||
trade_type=trade_type,
|
trade_type=trade_type,
|
||||||
entry_price=execution_price,
|
entry_price=execution_price,
|
||||||
entry_time=current_time,
|
entry_time=current_time,
|
||||||
size=units,
|
size=units,
|
||||||
entry_commission=commission_cost,
|
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
|
# Actualizar cash
|
||||||
self.cash -= (position_value + commission_cost)
|
self.cash -= (position_value + commission_cost)
|
||||||
|
|
||||||
# Crear posición
|
# --------------------------------------------------
|
||||||
|
# ✅ 5) Crear posición
|
||||||
|
# --------------------------------------------------
|
||||||
self.current_position = Position(
|
self.current_position = Position(
|
||||||
symbol=trade.symbol,
|
symbol=trade.symbol,
|
||||||
trade_type=trade_type,
|
trade_type=trade_type,
|
||||||
average_price=execution_price,
|
average_price=execution_price,
|
||||||
total_size=units,
|
total_size=units,
|
||||||
trades=[trade]
|
trades=[trade],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🔴 FIJAR STOP INICIAL
|
# Fijar stop inicial (si existe)
|
||||||
if self.stop_loss is not None:
|
if stop_price is not None:
|
||||||
stop_price = self.stop_loss.get_stop_price(
|
|
||||||
data=self.data,
|
|
||||||
idx=idx,
|
|
||||||
entry_price=execution_price,
|
|
||||||
trade_type=trade_type,
|
|
||||||
)
|
|
||||||
self.current_position.set_stop(stop_price)
|
self.current_position.set_stop(stop_price)
|
||||||
|
|
||||||
self.trades.append(trade)
|
self.trades.append(trade)
|
||||||
|
|
||||||
log.debug(f"[{current_time}] OPEN {trade_type.value}: "
|
log.debug(
|
||||||
f"Price: ${execution_price:.2f}, Units: {units:.6f}, "
|
f"[{current_time}] OPEN {trade_type.value}: "
|
||||||
f"Value: ${position_value:.2f}, Fee: ${commission_cost:.2f}")
|
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):
|
def _close_position(self, idx: int, reason: str):
|
||||||
"""
|
"""
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# src/backtest/metrics.py
|
# src/core/metrics.py
|
||||||
"""
|
"""
|
||||||
Métricas avanzadas de performance para backtesting
|
Métricas avanzadas de performance para backtesting
|
||||||
"""
|
"""
|
||||||
@@ -7,7 +7,7 @@ import pandas as pd
|
|||||||
from typing import Dict, List, Any, Type
|
from typing import Dict, List, Any, Type
|
||||||
from itertools import product
|
from itertools import product
|
||||||
from ..utils.logger import log
|
from ..utils.logger import log
|
||||||
from .engine import BacktestEngine
|
from .engine import Engine
|
||||||
from .strategy import Strategy
|
from .strategy import Strategy
|
||||||
|
|
||||||
class ParameterOptimizer:
|
class ParameterOptimizer:
|
||||||
@@ -82,7 +82,7 @@ class ParameterOptimizer:
|
|||||||
strategy = self.strategy_class(**params)
|
strategy = self.strategy_class(**params)
|
||||||
|
|
||||||
# Ejecutar backtest
|
# Ejecutar backtest
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=self.initial_capital,
|
initial_capital=self.initial_capital,
|
||||||
commission=self.commission,
|
commission=self.commission,
|
||||||
@@ -30,6 +30,8 @@ class Trade:
|
|||||||
exit_price: Optional[float] = None
|
exit_price: Optional[float] = None
|
||||||
exit_time: Optional[datetime] = None
|
exit_time: Optional[datetime] = None
|
||||||
status: TradeStatus = field(default=TradeStatus.OPEN)
|
status: TradeStatus = field(default=TradeStatus.OPEN)
|
||||||
|
stop_price_at_entry: Optional[float] = None
|
||||||
|
capital_at_entry: Optional[float] = None
|
||||||
|
|
||||||
# Costes
|
# Costes
|
||||||
entry_commission: float = 0.0
|
entry_commission: float = 0.0
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# src/backtest/walk_forward.py
|
# src/backtest/walk_forward.py
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from src.backtest.optimizer import ParameterOptimizer
|
from src.core.optimizer import ParameterOptimizer
|
||||||
from src.backtest.engine import BacktestEngine
|
from src.core.engine import Engine
|
||||||
from ..utils.logger import log
|
from ..utils.logger import log
|
||||||
|
|
||||||
class WalkForwardValidator:
|
class WalkForwardValidator:
|
||||||
@@ -188,7 +188,7 @@ class WalkForwardValidator:
|
|||||||
# 2️⃣ Backtest TEST (OOS)
|
# 2️⃣ Backtest TEST (OOS)
|
||||||
strategy = self.strategy_class(**best_params)
|
strategy = self.strategy_class(**best_params)
|
||||||
|
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=self.initial_capital,
|
initial_capital=self.initial_capital,
|
||||||
commission=self.commission,
|
commission=self.commission,
|
||||||
0
src/metrics/__init__.py
Normal file
0
src/metrics/__init__.py
Normal file
131
src/metrics/equity_metrics.py
Normal file
131
src/metrics/equity_metrics.py
Normal file
@@ -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),
|
||||||
|
}
|
||||||
0
src/portfolio/__init__.py
Normal file
0
src/portfolio/__init__.py
Normal file
17
src/portfolio/allocation.py
Normal file
17
src/portfolio/allocation.py
Normal file
@@ -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}")
|
||||||
67
src/portfolio/portfolio_engine.py
Normal file
67
src/portfolio/portfolio_engine.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
10
src/portfolio/portfolio_result.py
Normal file
10
src/portfolio/portfolio_result.py
Normal file
@@ -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]
|
||||||
@@ -12,9 +12,11 @@ class PositionSizer(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def calculate_size(
|
def calculate_size(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
capital: float,
|
capital: float,
|
||||||
entry_price: float,
|
entry_price: float,
|
||||||
stop_price: Optional[float] = None,
|
stop_price: Optional[float] = None,
|
||||||
|
max_capital: float | None = None,
|
||||||
volatility: Optional[float] = None,
|
volatility: Optional[float] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# src/risk/sizing/percent_risk.py
|
# src/risk/sizing/percent_risk.py
|
||||||
|
|
||||||
from .base import PositionSizer
|
from .base import PositionSizer
|
||||||
|
|
||||||
|
|
||||||
class PercentRiskSizer(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):
|
def __init__(self, risk_fraction: float):
|
||||||
@@ -15,22 +15,30 @@ class PercentRiskSizer(PositionSizer):
|
|||||||
|
|
||||||
def calculate_size(
|
def calculate_size(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
capital: float,
|
capital: float,
|
||||||
entry_price: float,
|
entry_price: float,
|
||||||
stop_price: float | None = None
|
stop_price: float | None = None,
|
||||||
|
max_capital: float | None = None,
|
||||||
|
volatility=None,
|
||||||
) -> float:
|
) -> float:
|
||||||
|
|
||||||
if stop_price is None:
|
if stop_price is None:
|
||||||
raise ValueError("PercentRiskSizer requiere stop_price")
|
raise ValueError("PercentRiskSizer requiere stop_price")
|
||||||
|
|
||||||
risk_amount = capital * self.risk_fraction
|
|
||||||
distance = abs(entry_price - stop_price)
|
distance = abs(entry_price - stop_price)
|
||||||
|
if distance <= 0:
|
||||||
if distance < 0:
|
|
||||||
raise ValueError("Distancia entry-stop inválida")
|
|
||||||
|
|
||||||
if distance == 0:
|
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
position_size = risk_amount / distance
|
# 1️⃣ Riesgo máximo permitido
|
||||||
return position_size
|
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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from src.risk.stops.base import StopLoss
|
from src.risk.stops.base import StopLoss
|
||||||
from src.backtest.trade import TradeType
|
from src.core.trade import TradeType
|
||||||
|
|
||||||
|
|
||||||
class ATRStop(StopLoss):
|
class ATRStop(StopLoss):
|
||||||
@@ -36,7 +36,7 @@ class ATRStop(StopLoss):
|
|||||||
axis=1,
|
axis=1,
|
||||||
).max(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
|
return atr
|
||||||
|
|
||||||
def get_stop_price(
|
def get_stop_price(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from src.backtest.trade import TradeType
|
from src.core.trade import TradeType
|
||||||
|
|
||||||
|
|
||||||
class StopLoss(ABC):
|
class StopLoss(ABC):
|
||||||
@@ -37,3 +37,16 @@ class StopLoss(ABC):
|
|||||||
stop_price (float)
|
stop_price (float)
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def update_stop(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
data: pd.DataFrame,
|
||||||
|
idx: int,
|
||||||
|
position
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Por defecto: stop NO dinámico.
|
||||||
|
Devuelve None → no mover stop.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# src/risk/stops/fixed_stop.py
|
# src/risk/stops/fixed_stop.py
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from src.risk.stops.base import StopLoss
|
from src.risk.stops.base import StopLoss
|
||||||
from src.backtest.trade import TradeType
|
from src.core.trade import TradeType
|
||||||
|
|
||||||
class FixedStop(StopLoss):
|
class FixedStop(StopLoss):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# src/risk/stops/trailing_stop.py
|
# src/risk/stops/trailing_stop.py
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from src.risk.stops.base import StopLoss
|
from src.risk.stops.base import StopLoss
|
||||||
from src.backtest.trade import TradeType, Position
|
from src.core.trade import TradeType, Position
|
||||||
|
|
||||||
|
|
||||||
class TrailingStop(StopLoss):
|
class TrailingStop(StopLoss):
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
# src/strategies/base.py
|
# src/strategies/base.py
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
import pandas as pd
|
||||||
|
from src.core.strategy import Signal
|
||||||
|
|
||||||
|
|
||||||
|
class Strategy(ABC):
|
||||||
"""
|
"""
|
||||||
Estrategias base para herencia compleja
|
Clase base para todas las estrategias.
|
||||||
TODO: Implementar en fases futuras
|
|
||||||
|
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})"
|
||||||
|
|||||||
64
src/strategies/breakout.py
Normal file
64
src/strategies/breakout.py
Normal file
@@ -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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Estrategia Buy and Hold
|
Estrategia Buy and Hold
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from ..backtest.strategy import Strategy, Signal
|
from ..core.strategy import Strategy, Signal
|
||||||
|
|
||||||
class BuyAndHold(Strategy):
|
class BuyAndHold(Strategy):
|
||||||
"""
|
"""
|
||||||
|
|||||||
95
src/strategies/mean_reversion.py
Normal file
95
src/strategies/mean_reversion.py
Normal file
@@ -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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Estrategia de cruce de medias móviles con filtro ADX opcional
|
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 ..core.strategy import Strategy, Signal, calculate_sma, calculate_ema
|
||||||
|
|
||||||
|
|
||||||
class MovingAverageCrossover(Strategy):
|
class MovingAverageCrossover(Strategy):
|
||||||
@@ -21,13 +21,18 @@ class MovingAverageCrossover(Strategy):
|
|||||||
ma_type: 'sma' o 'ema'
|
ma_type: 'sma' o 'ema'
|
||||||
use_adx: Activar filtro ADX
|
use_adx: Activar filtro ADX
|
||||||
adx_threshold: Umbral mínimo de 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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
fast_period: int = 10,
|
fast_period: int = 20,
|
||||||
slow_period: int = 30,
|
slow_period: int = 50,
|
||||||
ma_type: str = 'sma',
|
ma_type: str = 'ema',
|
||||||
use_adx: bool = False,
|
use_adx: bool = False,
|
||||||
adx_threshold: float = 20.0
|
adx_threshold: float = 20.0
|
||||||
):
|
):
|
||||||
|
|||||||
0
src/strategies/optimization/__init__.py
Normal file
0
src/strategies/optimization/__init__.py
Normal file
0
src/strategies/optimization/base.py
Normal file
0
src/strategies/optimization/base.py
Normal file
25
src/strategies/optimization/opt_moving_average.py
Normal file
25
src/strategies/optimization/opt_moving_average.py
Normal file
@@ -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)
|
||||||
26
src/strategies/optimization/opt_trend_filtered.py
Normal file
26
src/strategies/optimization/opt_trend_filtered.py
Normal file
@@ -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)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Estrategia basada en RSI
|
Estrategia basada en RSI
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from ..backtest.strategy import Strategy, Signal, calculate_rsi
|
from ..core.strategy import Strategy, Signal, calculate_rsi
|
||||||
|
|
||||||
class RSIStrategy(Strategy):
|
class RSIStrategy(Strategy):
|
||||||
"""
|
"""
|
||||||
|
|||||||
131
src/strategies/trend_filtered.py
Normal file
131
src/strategies/trend_filtered.py
Normal file
@@ -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
|
||||||
108
tests/backtest/test_engine_percent_risk.py
Normal file
108
tests/backtest/test_engine_percent_risk.py
Normal file
@@ -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
|
||||||
@@ -9,8 +9,8 @@ from datetime import datetime, timedelta
|
|||||||
# Añadir raíz del proyecto al path
|
# Añadir raíz del proyecto al path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
from src.backtest.engine import BacktestEngine
|
from src.core.engine import Engine
|
||||||
from src.backtest.strategy import Strategy, Signal
|
from src.core.strategy import Strategy, Signal
|
||||||
from src.risk.sizing.fixed import FixedPositionSizer
|
from src.risk.sizing.fixed import FixedPositionSizer
|
||||||
|
|
||||||
class BuyOnceStrategy(Strategy):
|
class BuyOnceStrategy(Strategy):
|
||||||
@@ -66,7 +66,7 @@ def test_engine_uses_fixed_position_sizer():
|
|||||||
|
|
||||||
sizer = FixedPositionSizer(capital_fraction=0.5)
|
sizer = FixedPositionSizer(capital_fraction=0.5)
|
||||||
|
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=10000,
|
initial_capital=10000,
|
||||||
commission=0.0,
|
commission=0.0,
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from datetime import datetime
|
|||||||
# Añadir raíz del proyecto al path
|
# Añadir raíz del proyecto al path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
from src.backtest.engine import BacktestEngine
|
from src.core.engine import Engine
|
||||||
from src.backtest.strategy import Strategy, Signal
|
from src.core.strategy import Strategy, Signal
|
||||||
from src.backtest.trade import TradeStatus
|
from src.core.trade import TradeStatus
|
||||||
from src.risk.stops.fixed_stop import FixedStop
|
from src.risk.stops.fixed_stop import FixedStop
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ def test_engine_closes_position_on_stop_hit():
|
|||||||
data = _build_test_data()
|
data = _build_test_data()
|
||||||
strategy = AlwaysBuyStrategy()
|
strategy = AlwaysBuyStrategy()
|
||||||
|
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=10_000,
|
initial_capital=10_000,
|
||||||
commission=0.0,
|
commission=0.0,
|
||||||
@@ -82,7 +82,7 @@ def test_engine_closes_position_at_end_without_stop():
|
|||||||
data = _build_test_data()
|
data = _build_test_data()
|
||||||
strategy = AlwaysBuyStrategy()
|
strategy = AlwaysBuyStrategy()
|
||||||
|
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=10_000,
|
initial_capital=10_000,
|
||||||
commission=0.0,
|
commission=0.0,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
from src.backtest.engine import BacktestEngine
|
from src.core.engine import Engine
|
||||||
from src.backtest.strategy import Strategy, Signal
|
from src.core.strategy import Strategy, Signal
|
||||||
from src.backtest.trade import TradeStatus
|
from src.core.trade import TradeStatus
|
||||||
from src.risk.stops.trailing_stop import TrailingStop
|
from src.risk.stops.trailing_stop import TrailingStop
|
||||||
|
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ def test_trailing_stop_moves_and_closes_position():
|
|||||||
|
|
||||||
strategy = AlwaysBuyStrategy()
|
strategy = AlwaysBuyStrategy()
|
||||||
|
|
||||||
engine = BacktestEngine(
|
engine = Engine(
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
initial_capital=10000,
|
initial_capital=10000,
|
||||||
commission=0.0,
|
commission=0.0,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pandas as pd
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from src.data.storage import StorageManager
|
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
|
from src.strategies import MovingAverageCrossover
|
||||||
|
|
||||||
def setup_environment():
|
def setup_environment():
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import pytest
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
from src.risk.stops.base import StopLoss
|
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.risk.stops.atr_stop import ATRStop
|
||||||
from src.backtest.trade import TradeType
|
from src.core.trade import TradeType
|
||||||
|
|
||||||
|
|
||||||
def atr_data():
|
def atr_data():
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
from src.risk.stops.fixed_stop import FixedStop
|
from src.risk.stops.fixed_stop import FixedStop
|
||||||
from src.backtest.trade import TradeType
|
from src.core.trade import TradeType
|
||||||
|
|
||||||
|
|
||||||
def dummy_data():
|
def dummy_data():
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
from src.utils.logger import log
|
from src.utils.logger import log
|
||||||
from src.data.storage import StorageManager
|
from src.data.storage import StorageManager
|
||||||
from src.strategies import MovingAverageCrossover
|
from src.strategies import MovingAverageCrossover
|
||||||
from src.backtest.optimizer import ParameterOptimizer
|
from src.core.optimizer import ParameterOptimizer
|
||||||
|
|
||||||
def setup_environment():
|
def setup_environment():
|
||||||
"""Carga variables de entorno"""
|
"""Carga variables de entorno"""
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
from src.utils.logger import log
|
from src.utils.logger import log
|
||||||
from src.data.storage import StorageManager
|
from src.data.storage import StorageManager
|
||||||
from src.strategies import MovingAverageCrossover
|
from src.strategies import MovingAverageCrossover
|
||||||
from src.backtest import BacktestEngine
|
from src.core import BacktestEngine
|
||||||
from src.backtest.visualizers.visualizer import BacktestVisualizer
|
from src.core.visualizers.visualizer import BacktestVisualizer
|
||||||
|
|
||||||
def setup_environment():
|
def setup_environment():
|
||||||
"""Carga variables de entorno"""
|
"""Carga variables de entorno"""
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
from src.utils.logger import log
|
from src.utils.logger import log
|
||||||
from src.data.storage import StorageManager
|
from src.data.storage import StorageManager
|
||||||
from src.strategies import MovingAverageCrossover
|
from src.strategies import MovingAverageCrossover
|
||||||
from src.backtest.walk_forward import WalkForwardValidator
|
from src.core.walk_forward import WalkForwardValidator
|
||||||
|
|
||||||
|
|
||||||
def setup_environment():
|
def setup_environment():
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pandas as pd
|
|||||||
# Añadir raíz del proyecto al path
|
# Añadir raíz del proyecto al path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
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():
|
def test_wf_visualizer():
|
||||||
|
|||||||
Reference in New Issue
Block a user