200 lines
5.7 KiB
Python
200 lines
5.7 KiB
Python
# 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 = "ETH/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()
|