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:
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()
|
||||
Reference in New Issue
Block a user