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

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ logs/
# Resultados
backtest_results/
output/

View File

@@ -9,8 +9,8 @@ from datetime import datetime, timedelta
from src.utils.logger import log
from src.data.storage import StorageManager
from src.backtest.engine import BacktestEngine
from src.backtest.metrics import print_backtest_report, calculate_all_metrics
from src.core.engine import Engine
from src.core.metrics import print_backtest_report, calculate_all_metrics
from src.strategies import MovingAverageCrossover, BuyAndHold, RSIStrategy
def setup_environment():
@@ -76,7 +76,7 @@ def run_backtest_demo():
)
# Crear motor de backtesting
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=10000,
commission=0.001, # 0.1%
@@ -172,7 +172,7 @@ def compare_strategies_demo():
for name, strategy in strategies:
log.info(f"\n🧪 Testeando: {name}")
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=10000,
commission=0.001,
@@ -187,7 +187,7 @@ def compare_strategies_demo():
log.info(f" Win Rate: {results['win_rate_pct']:.2f}%")
# Comparar resultados
from src.backtest.metrics import compare_strategies
from src.core.metrics import compare_strategies
compare_strategies(all_results)
storage.close()

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

View File

@@ -2,7 +2,7 @@
"""
Módulo de backtesting
"""
from .engine import BacktestEngine
from .engine import Engine
from .strategy import Strategy, Signal
from .trade import Trade, TradeType, TradeStatus, Position
from .optimizer import ParameterOptimizer
@@ -16,7 +16,7 @@ from .metrics import (
)
__all__ = [
'BacktestEngine',
'Engine',
'Strategy',
'Signal',
'Trade',

View File

@@ -12,7 +12,7 @@ from .trade import Trade, TradeType, TradeStatus, Position
from ..risk.sizing.base import PositionSizer
from ..risk.stops.base import StopLoss
class BacktestEngine:
class Engine:
"""
Motor de backtesting que simula la ejecución de una estrategia
"""
@@ -185,14 +185,30 @@ class BacktestEngine:
Abre una nueva posición, delegando size al PositionSizer si existe
"""
current_bar = self.data.iloc[idx]
current_price = current_bar['close']
current_price = current_bar["close"]
current_time = current_bar.name
# Aplicar slippage (en compra, pagamos más)
# Aplicar slippage (en compra pagamos más)
execution_price = current_price * (1 + self.slippage)
# --------------------------------------------------
# 1) Calcular units (size) vía sizer o fallback legacy
# 🔴 1) Calcular STOP antes del size (si existe)
# --------------------------------------------------
stop_price = None
if self.stop_loss is not None:
try:
stop_price = self.stop_loss.get_stop_price(
data=self.data,
idx=idx,
entry_price=execution_price,
trade_type=trade_type,
)
except Exception as e:
log.warning(f"[{current_time}] Error calculando stop: {e}")
return
# --------------------------------------------------
# ✅ 2) Calcular units (size)
# --------------------------------------------------
if self.position_sizer is not None:
try:
@@ -200,8 +216,9 @@ class BacktestEngine:
self.position_sizer.calculate_size(
capital=self.cash,
entry_price=float(execution_price),
stop_price=None, # stops aún no integrados
volatility=None # vol/ATR aún no integrado aquí
stop_price=stop_price,
max_capital=self.cash,
volatility=None,
)
)
except Exception as e:
@@ -209,69 +226,76 @@ class BacktestEngine:
return
if not np.isfinite(units) or units <= 0:
log.warning(f"[{current_time}] PositionSizer devolvió units inválidos: {units}")
log.warning(f"[{current_time}] Units inválidas: {units}")
return
position_value = units * execution_price
else:
# Fallback actual: usar fracción del cash
position_value = self.cash * self.position_size_fraction
units = position_value / execution_price
# Fallback legacy
units = (self.cash * self.position_size_fraction) / execution_price
# --------------------------------------------------
# ✅ 2) Comisión basada en el nominal invertido
# ✅ 3) CLIP SIZE si no hay suficiente cash
# --------------------------------------------------
position_value = units * execution_price
commission_cost = position_value * self.commission
total_cost = position_value + commission_cost
# Verificar que tenemos suficiente cash
if self.cash < position_value + commission_cost:
log.warning(
f"[{current_time}] Cash insuficiente para abrir posición "
f"(cash=${self.cash:.2f}, needed=${position_value + commission_cost:.2f})"
if total_cost > self.cash:
max_affordable_units = self.cash / (
execution_price * (1 + self.commission)
)
return
if max_affordable_units <= 0:
log.warning(
f"[{current_time}] Cash insuficiente incluso para size mínimo"
)
return
units = max_affordable_units
position_value = units * execution_price
commission_cost = position_value * self.commission
# --------------------------------------------------
# ✅ 3) Crear trade + posición
# ✅ 4) Crear trade
# --------------------------------------------------
trade = Trade(
symbol=current_bar.get('symbol', 'UNKNOWN'),
symbol=current_bar.get("symbol", "UNKNOWN"),
trade_type=trade_type,
entry_price=execution_price,
entry_time=current_time,
size=units,
entry_commission=commission_cost,
entry_reason="Strategy signal"
entry_reason="Strategy signal",
stop_price_at_entry=stop_price,
capital_at_entry=self.cash,
)
# Actualizar cash
self.cash -= (position_value + commission_cost)
# Crear posición
# --------------------------------------------------
# ✅ 5) Crear posición
# --------------------------------------------------
self.current_position = Position(
symbol=trade.symbol,
trade_type=trade_type,
average_price=execution_price,
total_size=units,
trades=[trade]
trades=[trade],
)
# 🔴 FIJAR STOP INICIAL
if self.stop_loss is not None:
stop_price = self.stop_loss.get_stop_price(
data=self.data,
idx=idx,
entry_price=execution_price,
trade_type=trade_type,
)
# Fijar stop inicial (si existe)
if stop_price is not None:
self.current_position.set_stop(stop_price)
self.trades.append(trade)
log.debug(f"[{current_time}] OPEN {trade_type.value}: "
f"Price: ${execution_price:.2f}, Units: {units:.6f}, "
f"Value: ${position_value:.2f}, Fee: ${commission_cost:.2f}")
log.debug(
f"[{current_time}] OPEN {trade_type.value}: "
f"Price=${execution_price:.2f} | Units={units:.6f} | "
f"Value=${position_value:.2f} | Fee=${commission_cost:.2f}"
)
def _close_position(self, idx: int, reason: str):
"""

View File

@@ -1,4 +1,4 @@
# src/backtest/metrics.py
# src/core/metrics.py
"""
Métricas avanzadas de performance para backtesting
"""

View File

@@ -7,7 +7,7 @@ import pandas as pd
from typing import Dict, List, Any, Type
from itertools import product
from ..utils.logger import log
from .engine import BacktestEngine
from .engine import Engine
from .strategy import Strategy
class ParameterOptimizer:
@@ -82,7 +82,7 @@ class ParameterOptimizer:
strategy = self.strategy_class(**params)
# Ejecutar backtest
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=self.initial_capital,
commission=self.commission,

View File

@@ -30,6 +30,8 @@ class Trade:
exit_price: Optional[float] = None
exit_time: Optional[datetime] = None
status: TradeStatus = field(default=TradeStatus.OPEN)
stop_price_at_entry: Optional[float] = None
capital_at_entry: Optional[float] = None
# Costes
entry_commission: float = 0.0

View File

@@ -1,8 +1,8 @@
# src/backtest/walk_forward.py
import pandas as pd
from typing import List, Dict, Optional
from src.backtest.optimizer import ParameterOptimizer
from src.backtest.engine import BacktestEngine
from src.core.optimizer import ParameterOptimizer
from src.core.engine import Engine
from ..utils.logger import log
class WalkForwardValidator:
@@ -188,7 +188,7 @@ class WalkForwardValidator:
# 2⃣ Backtest TEST (OOS)
strategy = self.strategy_class(**best_params)
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=self.initial_capital,
commission=self.commission,

0
src/metrics/__init__.py Normal file
View File

View 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),
}

View File

View 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}")

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

View 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]

View File

@@ -12,9 +12,11 @@ class PositionSizer(ABC):
@abstractmethod
def calculate_size(
self,
*,
capital: float,
entry_price: float,
stop_price: Optional[float] = None,
max_capital: float | None = None,
volatility: Optional[float] = None,
) -> float:
"""

View File

@@ -1,11 +1,11 @@
# src/risk/sizing/percent_risk.py
from .base import PositionSizer
class PercentRiskSizer(PositionSizer):
"""
Position sizing basado en % de riesgo por trade.
Position sizing basado en % de riesgo por trade,
limitado por capital disponible.
"""
def __init__(self, risk_fraction: float):
@@ -15,22 +15,30 @@ class PercentRiskSizer(PositionSizer):
def calculate_size(
self,
*,
capital: float,
entry_price: float,
stop_price: float | None = None
stop_price: float | None = None,
max_capital: float | None = None,
volatility=None,
) -> float:
if stop_price is None:
raise ValueError("PercentRiskSizer requiere stop_price")
risk_amount = capital * self.risk_fraction
distance = abs(entry_price - stop_price)
if distance < 0:
raise ValueError("Distancia entry-stop inválida")
if distance == 0:
if distance <= 0:
return 0.0
position_size = risk_amount / distance
return position_size
# 1⃣ Riesgo máximo permitido
risk_amount = capital * self.risk_fraction
units_by_risk = risk_amount / distance
# 2⃣ Límite por capital disponible
if max_capital is not None:
max_units_by_cash = max_capital / entry_price
units = min(units_by_risk, max_units_by_cash)
else:
units = units_by_risk
return max(units, 0.0)

View File

@@ -2,7 +2,7 @@
import pandas as pd
import numpy as np
from src.risk.stops.base import StopLoss
from src.backtest.trade import TradeType
from src.core.trade import TradeType
class ATRStop(StopLoss):
@@ -36,7 +36,7 @@ class ATRStop(StopLoss):
axis=1,
).max(axis=1)
atr = tr.rolling(self.atr_period).mean()
atr = tr.ewm(alpha=1/self.atr_period, adjust=False).mean()
return atr
def get_stop_price(

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
import pandas as pd
from src.backtest.trade import TradeType
from src.core.trade import TradeType
class StopLoss(ABC):
@@ -37,3 +37,16 @@ class StopLoss(ABC):
stop_price (float)
"""
pass
def update_stop(
self,
*,
data: pd.DataFrame,
idx: int,
position
):
"""
Por defecto: stop NO dinámico.
Devuelve None → no mover stop.
"""
return None

View File

@@ -1,7 +1,7 @@
# src/risk/stops/fixed_stop.py
import pandas as pd
from src.risk.stops.base import StopLoss
from src.backtest.trade import TradeType
from src.core.trade import TradeType
class FixedStop(StopLoss):
"""

View File

@@ -1,7 +1,7 @@
# src/risk/stops/trailing_stop.py
import pandas as pd
from src.risk.stops.base import StopLoss
from src.backtest.trade import TradeType, Position
from src.core.trade import TradeType, Position
class TrailingStop(StopLoss):

View File

@@ -1,5 +1,45 @@
# src/strategies/base.py
"""
Estrategias base para herencia compleja
TODO: Implementar en fases futuras
"""
from abc import ABC, abstractmethod
import pandas as pd
from src.core.strategy import Signal
class Strategy(ABC):
"""
Clase base para todas las estrategias.
Flujo:
- Engine llama a set_data(data)
- set_data → init_indicators
- Engine llama a generate_signal(idx)
"""
def __init__(self, name: str, params: dict | None = None):
self.name = name
self.params = params or {}
self.data: pd.DataFrame | None = None
def set_data(self, data: pd.DataFrame):
"""
Inyecta el DataFrame y calcula indicadores.
"""
self.data = self.init_indicators(data.copy())
@abstractmethod
def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Calcula y añade indicadores al DataFrame.
"""
pass
@abstractmethod
def generate_signal(self, idx: int) -> Signal:
"""
Devuelve BUY / SELL / HOLD para el índice idx.
"""
pass
def __repr__(self):
params = ", ".join(f"{k}={v}" for k, v in self.params.items())
return f"{self.name}({params})"

View 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

View File

@@ -3,7 +3,7 @@
Estrategia Buy and Hold
"""
import pandas as pd
from ..backtest.strategy import Strategy, Signal
from ..core.strategy import Strategy, Signal
class BuyAndHold(Strategy):
"""

View 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

View File

@@ -3,7 +3,7 @@
Estrategia de cruce de medias móviles con filtro ADX opcional
"""
import pandas as pd
from ..backtest.strategy import Strategy, Signal, calculate_sma, calculate_ema
from ..core.strategy import Strategy, Signal, calculate_sma, calculate_ema
class MovingAverageCrossover(Strategy):
@@ -21,13 +21,18 @@ class MovingAverageCrossover(Strategy):
ma_type: 'sma' o 'ema'
use_adx: Activar filtro ADX
adx_threshold: Umbral mínimo de ADX
Valores por defecto:
20/50 EMA → clásico en crypto 1h - 4h
EMA reacciona mejor que SMA
Sin ADX todavía → primero evaluamos la señal “pura”
"""
def __init__(
self,
fast_period: int = 10,
slow_period: int = 30,
ma_type: str = 'sma',
fast_period: int = 20,
slow_period: int = 50,
ma_type: str = 'ema',
use_adx: bool = False,
adx_threshold: float = 20.0
):

View File

View File

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

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

View File

@@ -3,7 +3,7 @@
Estrategia basada en RSI
"""
import pandas as pd
from ..backtest.strategy import Strategy, Signal, calculate_rsi
from ..core.strategy import Strategy, Signal, calculate_rsi
class RSIStrategy(Strategy):
"""

View 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

View 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

View File

@@ -9,8 +9,8 @@ from datetime import datetime, timedelta
# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.backtest.engine import BacktestEngine
from src.backtest.strategy import Strategy, Signal
from src.core.engine import Engine
from src.core.strategy import Strategy, Signal
from src.risk.sizing.fixed import FixedPositionSizer
class BuyOnceStrategy(Strategy):
@@ -66,7 +66,7 @@ def test_engine_uses_fixed_position_sizer():
sizer = FixedPositionSizer(capital_fraction=0.5)
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=10000,
commission=0.0,

View File

@@ -6,9 +6,9 @@ from datetime import datetime
# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.backtest.engine import BacktestEngine
from src.backtest.strategy import Strategy, Signal
from src.backtest.trade import TradeStatus
from src.core.engine import Engine
from src.core.strategy import Strategy, Signal
from src.core.trade import TradeStatus
from src.risk.stops.fixed_stop import FixedStop
@@ -57,7 +57,7 @@ def test_engine_closes_position_on_stop_hit():
data = _build_test_data()
strategy = AlwaysBuyStrategy()
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=10_000,
commission=0.0,
@@ -82,7 +82,7 @@ def test_engine_closes_position_at_end_without_stop():
data = _build_test_data()
strategy = AlwaysBuyStrategy()
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=10_000,
commission=0.0,

View File

@@ -7,9 +7,9 @@ from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.backtest.engine import BacktestEngine
from src.backtest.strategy import Strategy, Signal
from src.backtest.trade import TradeStatus
from src.core.engine import Engine
from src.core.strategy import Strategy, Signal
from src.core.trade import TradeStatus
from src.risk.stops.trailing_stop import TrailingStop
@@ -58,7 +58,7 @@ def test_trailing_stop_moves_and_closes_position():
strategy = AlwaysBuyStrategy()
engine = BacktestEngine(
engine = Engine(
strategy=strategy,
initial_capital=10000,
commission=0.0,

View File

@@ -12,7 +12,7 @@ import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.data.storage import StorageManager
from src.backtest.walk_forward import WalkForwardValidator
from src.core.walk_forward import WalkForwardValidator
from src.strategies import MovingAverageCrossover
def setup_environment():

View File

@@ -9,9 +9,9 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from src.risk.stops.base import StopLoss
from src.backtest.trade import TradeType
from src.core.trade import TradeType
from src.risk.stops.atr_stop import ATRStop
from src.backtest.trade import TradeType
from src.core.trade import TradeType
def atr_data():

View File

@@ -8,7 +8,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from src.risk.stops.fixed_stop import FixedStop
from src.backtest.trade import TradeType
from src.core.trade import TradeType
def dummy_data():

View File

@@ -14,7 +14,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from src.utils.logger import log
from src.data.storage import StorageManager
from src.strategies import MovingAverageCrossover
from src.backtest.optimizer import ParameterOptimizer
from src.core.optimizer import ParameterOptimizer
def setup_environment():
"""Carga variables de entorno"""

View File

@@ -14,8 +14,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from src.utils.logger import log
from src.data.storage import StorageManager
from src.strategies import MovingAverageCrossover
from src.backtest import BacktestEngine
from src.backtest.visualizers.visualizer import BacktestVisualizer
from src.core import BacktestEngine
from src.core.visualizers.visualizer import BacktestVisualizer
def setup_environment():
"""Carga variables de entorno"""

View File

@@ -15,7 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from src.utils.logger import log
from src.data.storage import StorageManager
from src.strategies import MovingAverageCrossover
from src.backtest.walk_forward import WalkForwardValidator
from src.core.walk_forward import WalkForwardValidator
def setup_environment():

View File

@@ -7,7 +7,7 @@ import pandas as pd
# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.backtest.visualizers.walk_forward_visualizer import WalkForwardVisualizer
from src.core.visualizers.walk_forward_visualizer import WalkForwardVisualizer
def test_wf_visualizer():