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:
DaM
2026-02-02 14:38:05 +01:00
parent c569170fcc
commit f85c522f22
53 changed files with 2389 additions and 104 deletions

View 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()

View 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()

View 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()

View 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()

View 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()

View 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="P10P90")
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()

View 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()