diff --git a/src/calibration/reports/strategies_report.py b/src/calibration/reports/strategies_report.py
new file mode 100644
index 0000000..305f9d5
--- /dev/null
+++ b/src/calibration/reports/strategies_report.py
@@ -0,0 +1,94 @@
+# src/calibration/reports/strategies_report.py
+
+from pathlib import Path
+from typing import Any, Dict
+
+from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
+from reportlab.lib.styles import getSampleStyleSheet
+from reportlab.lib import colors
+
+
+def _simple_kv_table(title: str, dct: Dict[str, Any]):
+ rows = [["Key", "Value"]] + [[str(k), str(v)] for k, v in dct.items()]
+ t = Table(rows, hAlign="LEFT")
+ t.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ]))
+ return [Paragraph(title, getSampleStyleSheet()["Heading2"]), t, Spacer(1, 12)]
+
+
+def generate_strategies_report_pdf(
+ *,
+ output_path: Path,
+ context: Dict[str, Any],
+ config: Dict[str, Any],
+ results: Dict[str, Any],
+):
+ """Minimal v1 report. We'll enrich with charts/tables next iterations."""
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ styles = getSampleStyleSheet()
+ doc = SimpleDocTemplate(str(output_path))
+
+ story = []
+ story.append(Paragraph("Calibration · Step 3 · Strategies", styles["Title"]))
+ story.append(Spacer(1, 12))
+
+ story.append(Paragraph(f"Status: {results.get('status')}", styles["Normal"]))
+ story.append(Spacer(1, 12))
+
+ for block in _simple_kv_table("Context", context):
+ story.append(block)
+ for block in _simple_kv_table("Configuration", config):
+ story.append(block)
+
+ story.append(Paragraph("Results (per strategy)", styles["Heading2"]))
+ story.append(Spacer(1, 6))
+
+ for r in results.get("results", []):
+ story.append(Paragraph(
+ f"{r.get('strategy_id')} — {r.get('status').upper()} — {r.get('message','')}",
+ styles["Normal"],
+ ))
+ story.append(Spacer(1, 4))
+
+ summary = {
+ "n_windows": r.get("n_windows"),
+ "oos_final_equity": r.get("oos_final_equity"),
+ "oos_total_return_pct": r.get("oos_total_return_pct"),
+ "oos_max_dd_worst_pct": r.get("oos_max_dd_worst_pct"),
+ }
+ for block in _simple_kv_table("Summary", summary):
+ story.append(block)
+
+ # Window table (first 20 to keep PDF light)
+ windows = r.get("windows", [])
+ if windows:
+ rows = [["Window", "Test return %", "Sharpe", "Max DD %", "Trades", "Params"]]
+ for w in windows[:20]:
+ rows.append([
+ str(w.get("window")),
+ f"{float(w.get('return_pct',0.0)):.2f}",
+ f"{float(w.get('sharpe',0.0)):.2f}",
+ f"{float(w.get('max_dd_pct',0.0)):.2f}",
+ str(w.get("trades")),
+ str(w.get("params")),
+ ])
+ t = Table(rows, hAlign="LEFT", colWidths=[45, 75, 55, 65, 55, 240])
+ t.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
+ ("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ]))
+ story.append(Paragraph("Walk-Forward windows (first 20)", styles["Heading3"]))
+ story.append(t)
+ story.append(Spacer(1, 12))
+
+ story.append(PageBreak())
+
+ doc.build(story)
diff --git a/src/calibration/strategies_inspector.py b/src/calibration/strategies_inspector.py
new file mode 100644
index 0000000..25ef05e
--- /dev/null
+++ b/src/calibration/strategies_inspector.py
@@ -0,0 +1,326 @@
+# src/calibration/strategies_inspector.py
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+import numpy as np
+import pandas as pd
+
+from src.data.storage import StorageManager
+from src.utils.logger import log
+
+from src.core.walk_forward import WalkForwardValidator
+
+from src.risk.stops.fixed_stop import FixedStop
+from src.risk.stops.trailing_stop import TrailingStop
+from src.risk.stops.atr_stop import ATRStop
+
+from src.risk.sizing.percent_risk import PercentRiskSizer
+
+
+# --------------------------------------------------
+# Strategy registry (con metadata de parámetros)
+# --------------------------------------------------
+from src.strategies.moving_average import MovingAverageCrossover
+from src.strategies.rsi_strategy import RSIStrategy
+from src.strategies.buy_and_hold import BuyAndHold
+
+
+STRATEGY_REGISTRY = {
+ "moving_average": {
+ "class": MovingAverageCrossover,
+ "params": ["fast_period", "slow_period"],
+ },
+ "rsi": {
+ "class": RSIStrategy,
+ "params": ["rsi_period", "overbought", "oversold"],
+ },
+ "buy_and_hold": {
+ "class": BuyAndHold,
+ "params": [],
+ },
+}
+
+
+# --------------------------------------------------
+# Helpers
+# --------------------------------------------------
+
+def list_available_strategies() -> List[Dict[str, Any]]:
+ """
+ Devuelve metadata completa para UI.
+ """
+ out = []
+
+ for sid, entry in STRATEGY_REGISTRY.items():
+ out.append({
+ "strategy_id": sid,
+ "name": entry["class"].__name__,
+ "params": entry["params"],
+ "tags": [], # puedes rellenar más adelante
+ })
+
+ return out
+
+
+def _build_stop_loss(stop_schema) -> object | None:
+ if stop_schema.type == "fixed":
+ return FixedStop(stop_fraction=float(stop_schema.stop_fraction))
+ if stop_schema.type == "trailing":
+ return TrailingStop(stop_fraction=float(stop_schema.stop_fraction))
+ if stop_schema.type == "atr":
+ return ATRStop(
+ atr_period=int(stop_schema.atr_period),
+ multiplier=float(stop_schema.atr_multiplier),
+ )
+ raise ValueError(f"Unknown stop type: {stop_schema.type}")
+
+
+def _build_position_sizer(risk_schema) -> PercentRiskSizer:
+ return PercentRiskSizer(risk_fraction=float(risk_schema.risk_fraction))
+
+
+def _cap_units_by_max_position_fraction(units: float, capital: float, entry_price: float, max_position_fraction: float) -> float:
+ max_units = (capital * max_position_fraction) / entry_price
+ return float(min(units, max_units))
+
+
+def _accumulate_equity(initial: float, returns_pct: List[float]) -> List[float]:
+ eq = [float(initial)]
+ cur = float(initial)
+ for r in returns_pct:
+ cur *= (1.0 + float(r) / 100.0)
+ eq.append(float(cur))
+ return eq
+
+
+# --------------------------------------------------
+# Main
+# --------------------------------------------------
+
+def inspect_strategies_config(
+ *,
+ storage: StorageManager,
+ payload,
+ data_quality: Dict[str, Any],
+ include_series: bool,
+ progress_callback=None,
+) -> Dict[str, Any]:
+
+ df = storage.load_ohlcv(symbol=payload.symbol, timeframe=payload.timeframe)
+ if df is None or df.empty:
+ return {
+ "valid": False,
+ "status": "fail",
+ "checks": {},
+ "message": "No OHLCV data",
+ "results": [],
+ }
+
+ checks: Dict[str, Any] = {}
+ checks["data_quality"] = {
+ "status": data_quality.get("status", "unknown"),
+ "message": data_quality.get("message", ""),
+ }
+
+ if data_quality.get("status") == "fail":
+ return {
+ "valid": False,
+ "status": "fail",
+ "checks": checks,
+ "message": "Step 1 data quality is FAIL. Strategies cannot be validated.",
+ "results": [],
+ "series": {} if include_series else None,
+ }
+
+ stop_loss = _build_stop_loss(payload.stop)
+ base_sizer = _build_position_sizer(payload.risk)
+
+ train_td = pd.Timedelta(days=int(payload.wf.train_days))
+ test_td = pd.Timedelta(days=int(payload.wf.test_days))
+ step_td = pd.Timedelta(days=int(payload.wf.step_days or payload.wf.test_days))
+
+ overall_status = "ok"
+ results: List[Dict[str, Any]] = []
+ series: Dict[str, Any] = {"strategies": {}} if include_series else {}
+
+ for sel in payload.strategies:
+
+ sid = sel.strategy_id
+ entry = STRATEGY_REGISTRY.get(sid)
+
+ if entry is None:
+ results.append({
+ "strategy_id": sid,
+ "status": "fail",
+ "message": f"Unknown strategy_id: {sid}",
+ "n_windows": 0,
+ "oos_final_equity": payload.account_equity,
+ "oos_total_return_pct": 0.0,
+ "oos_max_dd_worst_pct": 0.0,
+ "degradation_sharpe": None,
+ "windows": [],
+ })
+ overall_status = "fail"
+ continue
+
+ strategy_class = entry["class"]
+ valid_params = set(entry["params"])
+ grid_params = set(sel.param_grid.keys())
+
+ # 🔒 Validación estricta de parámetros
+ if grid_params != valid_params:
+ msg = f"Param grid keys {grid_params} do not match expected {valid_params}"
+ results.append({
+ "strategy_id": sid,
+ "status": "fail",
+ "message": msg,
+ "n_windows": 0,
+ "oos_final_equity": payload.account_equity,
+ "oos_total_return_pct": 0.0,
+ "oos_max_dd_worst_pct": 0.0,
+ "degradation_sharpe": None,
+ "windows": [],
+ })
+ overall_status = "fail"
+ continue
+
+ # Wrapper sizer
+ class _CappedSizer(type(base_sizer)):
+ def __init__(self, inner):
+ self.inner = inner
+
+ def calculate_size(self, *, capital, entry_price, stop_price=None, max_capital=None, volatility=None):
+ u = self.inner.calculate_size(
+ capital=capital,
+ entry_price=entry_price,
+ stop_price=stop_price,
+ max_capital=max_capital,
+ volatility=volatility,
+ )
+ return _cap_units_by_max_position_fraction(
+ units=float(u),
+ capital=float(capital),
+ entry_price=float(entry_price),
+ max_position_fraction=float(payload.risk.max_position_fraction),
+ )
+
+ capped_sizer = _CappedSizer(base_sizer)
+
+ log.info(f"🧠 Step3 | WF run | strategy={sid}")
+
+ try:
+ wf = WalkForwardValidator(
+ strategy_class=strategy_class,
+ param_grid=sel.param_grid,
+ data=df,
+ train_window=train_td,
+ test_window=test_td,
+ step_size=step_td,
+ initial_capital=float(payload.account_equity),
+ commission=float(payload.commission),
+ slippage=float(payload.slippage),
+ optimizer_metric=str(payload.optimization.optimizer_metric),
+ position_sizer=capped_sizer,
+ stop_loss=stop_loss,
+ max_combinations=int(payload.optimization.max_combinations),
+ progress_callback=progress_callback,
+ )
+
+ wf_res = wf.run()
+ win_df: pd.DataFrame = wf_res["windows"]
+
+ if win_df is None or win_df.empty:
+ status = "fail"
+ msg = "WF produced no valid windows"
+ overall_status = "fail"
+ windows_out = []
+ oos_returns = []
+ else:
+ trades = win_df["trades"].astype(int).tolist()
+ too_few = sum(t < int(payload.optimization.min_trades_test) for t in trades)
+
+ if too_few > 0:
+ status = "warning"
+ msg = f"{too_few} windows below min_trades_test"
+ if overall_status == "ok":
+ overall_status = "warning"
+ else:
+ status = "ok"
+ msg = "WF OK"
+
+ windows_out = []
+ for _, r in win_df.iterrows():
+ windows_out.append({
+ "window": int(r["window"]),
+ "train_start": str(r["train_start"]),
+ "train_end": str(r["train_end"]),
+ "test_start": str(r["test_start"]),
+ "test_end": str(r["test_end"]),
+ "return_pct": float(r["return_pct"]),
+ "sharpe": float(r["sharpe"]),
+ "max_dd_pct": float(r["max_dd_pct"]),
+ "trades": int(r["trades"]),
+ "params": dict(r["params"]) if isinstance(r["params"], dict) else r["params"],
+ })
+
+ oos_returns = win_df["return_pct"].astype(float).tolist()
+
+ eq_curve = _accumulate_equity(float(payload.account_equity), oos_returns)
+ oos_final = float(eq_curve[-1]) if eq_curve else float(payload.account_equity)
+ oos_total_return = (oos_final / float(payload.account_equity) - 1.0) * 100.0
+ oos_max_dd = float(np.min(win_df["max_dd_pct"])) if (win_df is not None and not win_df.empty) else 0.0
+
+ results.append({
+ "strategy_id": sid,
+ "status": status,
+ "message": msg,
+ "n_windows": int(len(windows_out)),
+ "oos_final_equity": oos_final,
+ "oos_total_return_pct": float(oos_total_return),
+ "oos_max_dd_worst_pct": float(oos_max_dd),
+ "degradation_sharpe": None,
+ "windows": windows_out,
+ })
+
+ if include_series:
+ series["strategies"][sid] = {
+ "window_returns_pct": oos_returns,
+ "window_equity": eq_curve,
+ }
+
+ except Exception as e:
+ log.error(f"❌ Step3 WF error | strategy={sid} | {e}")
+ results.append({
+ "strategy_id": sid,
+ "status": "fail",
+ "message": f"Exception: {e}",
+ "n_windows": 0,
+ "oos_final_equity": float(payload.account_equity),
+ "oos_total_return_pct": 0.0,
+ "oos_max_dd_worst_pct": 0.0,
+ "degradation_sharpe": None,
+ "windows": [],
+ })
+ overall_status = "fail"
+
+ valid = overall_status != "fail"
+
+ human_msg = {
+ "ok": "Strategies validation OK",
+ "warning": "Strategies validation has warnings",
+ "fail": "Strategies validation FAILED",
+ }[overall_status]
+
+ out = {
+ "valid": valid,
+ "status": overall_status,
+ "checks": checks,
+ "message": human_msg,
+ "results": results,
+ }
+
+ if include_series:
+ out["series"] = series
+
+ return out
diff --git a/src/core/optimizer.py b/src/core/optimizer.py
index a529947..8223ee0 100644
--- a/src/core/optimizer.py
+++ b/src/core/optimizer.py
@@ -4,11 +4,13 @@ Optimizador de parámetros para estrategias
"""
import os
import pandas as pd
-from typing import Dict, List, Any, Type
+from typing import Dict, List, Any, Type, Optional
from itertools import product
from ..utils.logger import log
from .engine import Engine
from .strategy import Strategy
+from src.risk.sizing.base import PositionSizer
+from src.risk.stops.base import StopLoss
class ParameterOptimizer:
"""
@@ -21,7 +23,10 @@ class ParameterOptimizer:
initial_capital: float = 10000,
commission: float = 0.001,
slippage: float = 0.0005,
- position_size: float = 0.95):
+ position_size: float = 0.95,
+ position_sizer: Optional[PositionSizer] = None,
+ stop_loss: Optional[StopLoss] = None,
+ max_combinations: Optional[int] = None):
"""
Args:
strategy_class: Clase de estrategia (no instancia)
@@ -37,6 +42,9 @@ class ParameterOptimizer:
self.commission = commission
self.slippage = slippage
self.position_size = position_size
+ self.position_sizer = position_sizer
+ self.stop_loss = stop_loss
+ self.max_combinations = max_combinations
self.results: List[Dict] = []
@@ -62,6 +70,9 @@ class ParameterOptimizer:
param_names = list(param_grid.keys())
param_values = list(param_grid.values())
combinations = list(product(*param_values))
+
+ if self.max_combinations is not None:
+ combinations = combinations[: int(self.max_combinations)]
total_tests = len(combinations)
log.info(f"🔧 Iniciando optimización: {total_tests} combinaciones")
@@ -87,7 +98,9 @@ class ParameterOptimizer:
initial_capital=self.initial_capital,
commission=self.commission,
slippage=self.slippage,
- position_size=self.position_size
+ position_size=self.position_size,
+ position_sizer=self.position_sizer,
+ stop_loss=self.stop_loss,
)
results = engine.run(self.data)
diff --git a/src/core/walk_forward.py b/src/core/walk_forward.py
index e4b704e..b762460 100644
--- a/src/core/walk_forward.py
+++ b/src/core/walk_forward.py
@@ -3,6 +3,8 @@ import pandas as pd
from typing import List, Dict, Optional
from src.core.optimizer import ParameterOptimizer
from src.core.engine import Engine
+from src.risk.sizing.base import PositionSizer
+from src.risk.stops.base import StopLoss
from ..utils.logger import log
class WalkForwardValidator:
@@ -28,6 +30,10 @@ class WalkForwardValidator:
position_size: float = 0.95,
optimizer_metric: str = "sharpe_ratio",
verbose: bool = True,
+ position_sizer: Optional[PositionSizer] = None,
+ stop_loss: Optional[StopLoss] = None,
+ max_combinations: Optional[int] = None,
+ progress_callback: Optional[callable] = None,
):
self.strategy_class = strategy_class
self.param_grid = param_grid
@@ -43,6 +49,11 @@ class WalkForwardValidator:
self.position_size = position_size
self.optimizer_metric = optimizer_metric
self.verbose = verbose
+ self.position_sizer = position_sizer
+ self.stop_loss = stop_loss
+ self.max_combinations = max_combinations
+
+ self.progress_callback = progress_callback
# Validaciones básicas
if not isinstance(self.data.index, pd.DatetimeIndex):
@@ -157,6 +168,14 @@ class WalkForwardValidator:
for w in self.windows:
wid = w["window_id"]
+
+ if self.progress_callback:
+ self.progress_callback(
+ window_id=wid,
+ total_windows=len(self.windows)
+ )
+
+ wid = w["window_id"]
log.info(f"▶️ WF #{wid} en ejecución")
train_data = self.data.loc[w["train_start"]:w["train_end"]]
@@ -174,6 +193,9 @@ class WalkForwardValidator:
commission=self.commission,
slippage=self.slippage,
position_size=self.position_size,
+ position_sizer=self.position_sizer,
+ stop_loss=self.stop_loss,
+ max_combinations=self.max_combinations,
)
opt_df = optimizer.optimize(self.param_grid)
@@ -194,6 +216,8 @@ class WalkForwardValidator:
commission=self.commission,
slippage=self.slippage,
position_size=self.position_size,
+ position_sizer=self.position_sizer,
+ stop_loss=self.stop_loss,
)
test_results = engine.run(test_data)
diff --git a/src/web/api/v2/main.py b/src/web/api/v2/main.py
index 5f7496e..74bdddd 100644
--- a/src/web/api/v2/main.py
+++ b/src/web/api/v2/main.py
@@ -11,6 +11,7 @@ import time
from .settings import settings
from src.web.api.v2.routers.calibration_data import router as calibration_data_router
from src.web.api.v2.routers.calibration_risk import router as calibration_risk_router
+from src.web.api.v2.routers.calibration_strategies import router as calibration_strategies_router
# --------------------------------------------------
# Logging
@@ -115,6 +116,17 @@ def create_app() -> FastAPI:
"step": 2,
},
)
+
+ @app.get("/calibration/strategies", response_class=HTMLResponse)
+ def calibration_risk_page(request: Request):
+ return templates.TemplateResponse(
+ "pages/calibration/calibration_strategies.html",
+ {
+ "request": request,
+ "page": "calibration",
+ "step": 3,
+ },
+ )
# --------------------------------------------------
@@ -123,6 +135,7 @@ def create_app() -> FastAPI:
api_prefix = settings.api_prefix
app.include_router(calibration_data_router, prefix=api_prefix)
app.include_router(calibration_risk_router, prefix=api_prefix)
+ app.include_router(calibration_strategies_router, prefix=api_prefix)
return app
diff --git a/src/web/api/v2/routers/calibration_strategies.py b/src/web/api/v2/routers/calibration_strategies.py
new file mode 100644
index 0000000..63e52ac
--- /dev/null
+++ b/src/web/api/v2/routers/calibration_strategies.py
@@ -0,0 +1,191 @@
+# src/web/api/v2/routers/calibration_strategies.py
+
+import logging
+import re
+import uuid
+from pathlib import Path
+from typing import Dict
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import JSONResponse, HTMLResponse
+
+from src.data.storage import StorageManager
+from src.calibration.strategies_inspector import (
+ inspect_strategies_config,
+ list_available_strategies,
+)
+from src.calibration.reports.strategies_report import generate_strategies_report_pdf
+
+from ..schemas.calibration_strategies import (
+ CalibrationStrategiesInspectRequest,
+ CalibrationStrategiesInspectResponse,
+ CalibrationStrategiesValidateResponse,
+)
+
+logger = logging.getLogger("tradingbot.api.v2")
+
+router = APIRouter(
+ prefix="/calibration/strategies",
+ tags=["calibration"],
+)
+
+WF_JOBS: Dict[str, Dict] = {}
+
+def get_storage() -> StorageManager:
+ return StorageManager.from_env()
+
+
+@router.get("/available")
+def available_strategies():
+ return {"strategies": list_available_strategies()}
+
+@router.post("/inspect", response_model=CalibrationStrategiesInspectResponse)
+def inspect_strategies(
+ payload: CalibrationStrategiesInspectRequest,
+ storage: StorageManager = Depends(get_storage),
+):
+ df = storage.load_ohlcv(symbol=payload.symbol, timeframe=payload.timeframe)
+ if df is None or df.empty:
+ raise HTTPException(status_code=400, detail="No OHLCV data found. Run Step 1 first.")
+
+ from .calibration_data import analyze_data_quality
+ data_quality = analyze_data_quality(df, payload.timeframe)
+
+ result = inspect_strategies_config(
+ storage=storage,
+ payload=payload,
+ data_quality=data_quality,
+ include_series=False,
+ )
+ return CalibrationStrategiesInspectResponse(**result)
+
+@router.post("/validate", response_model=CalibrationStrategiesValidateResponse)
+def validate_strategies(
+ payload: CalibrationStrategiesInspectRequest,
+ storage: StorageManager = Depends(get_storage),
+):
+ df = storage.load_ohlcv(symbol=payload.symbol, timeframe=payload.timeframe)
+ if df is None or df.empty:
+ raise HTTPException(status_code=400, detail="No OHLCV data found. Run Step 1 first.")
+
+ from .calibration_data import analyze_data_quality
+ data_quality = analyze_data_quality(df, payload.timeframe)
+
+ result = inspect_strategies_config(
+ storage=storage,
+ payload=payload,
+ data_quality=data_quality,
+ include_series=True,
+ )
+ return CalibrationStrategiesValidateResponse(**result)
+
+@router.post("/report")
+def report_strategies(
+ payload: CalibrationStrategiesInspectRequest,
+ storage: StorageManager = Depends(get_storage),
+):
+ logger.info(f"🧾 Generating strategies report | {payload.symbol} {payload.timeframe}")
+
+ df = storage.load_ohlcv(symbol=payload.symbol, timeframe=payload.timeframe)
+ if df is None or df.empty:
+ raise HTTPException(status_code=400, detail="No OHLCV data found. Run Step 1 first.")
+
+ from .calibration_data import analyze_data_quality
+ data_quality = analyze_data_quality(df, payload.timeframe)
+
+ result = inspect_strategies_config(
+ storage=storage,
+ payload=payload,
+ data_quality=data_quality,
+ include_series=True,
+ )
+
+ # ---------------------------------------------
+ # Prepare PDF output path (outside src)
+ # ---------------------------------------------
+ project_root = Path(__file__).resolve().parents[4] # .../src
+ # project_root currently points to src/web/api/v2/routers -> parents[4] == src
+ project_root = project_root.parent # repo root
+
+ reports_dir = project_root / "reports" / "strategies"
+ reports_dir.mkdir(parents=True, exist_ok=True)
+
+ safe_symbol = re.sub(r"[^a-zA-Z0-9_-]", "_", payload.symbol)
+ filename = f"strategies_report_{safe_symbol}_{payload.timeframe}_{uuid.uuid4().hex}.pdf"
+
+ symbol_dir = reports_dir / safe_symbol
+ symbol_dir.mkdir(exist_ok=True)
+
+ output_path = symbol_dir / filename
+
+ generate_strategies_report_pdf(
+ output_path=output_path,
+ context={
+ "Symbol": payload.symbol,
+ "Timeframe": payload.timeframe,
+ "Account equity": payload.account_equity,
+ },
+ config={
+ "Stop type": payload.stop.type,
+ "Risk per trade (%)": payload.risk.risk_fraction * 100,
+ "Max position fraction (%)": payload.risk.max_position_fraction * 100,
+ "WF train_days": payload.wf.train_days,
+ "WF test_days": payload.wf.test_days,
+ "WF step_days": payload.wf.step_days or payload.wf.test_days,
+ "Optimizer metric": payload.optimization.optimizer_metric,
+ "Max combinations": payload.optimization.max_combinations,
+ },
+ results=result,
+ )
+
+ public_url = f"/reports/strategies/{safe_symbol}/{filename}"
+ return JSONResponse(content={"status": result.get("status", "ok"), "url": public_url})
+
+@router.post("/run")
+def run_strategies_async(
+ payload: CalibrationStrategiesInspectRequest,
+ storage: StorageManager = Depends(get_storage),
+):
+ import threading
+ import uuid
+
+ job_id = uuid.uuid4().hex
+
+ WF_JOBS[job_id] = {
+ "status": "running",
+ "progress": 0,
+ "current_window": 0,
+ "total_windows": 0,
+ "current_strategy": None,
+ "result": None,
+ }
+
+ def background_job():
+
+ def progress_cb(window_id, total_windows):
+ WF_JOBS[job_id]["current_window"] = window_id
+ WF_JOBS[job_id]["total_windows"] = total_windows
+ WF_JOBS[job_id]["progress"] = int(
+ window_id / total_windows * 100
+ )
+
+ result = inspect_strategies_config(
+ storage=storage,
+ payload=payload,
+ data_quality={"status": "ok"},
+ include_series=True,
+ progress_callback=progress_cb, # ← lo pasamos
+ )
+
+ WF_JOBS[job_id]["status"] = "done"
+ WF_JOBS[job_id]["progress"] = 100
+ WF_JOBS[job_id]["result"] = result
+
+ thread = threading.Thread(target=background_job)
+ thread.start()
+
+ return {"job_id": job_id}
+
+@router.get("/status/{job_id}")
+def get_status(job_id: str):
+ return WF_JOBS.get(job_id, {"status": "unknown"})
diff --git a/src/web/api/v2/schemas/calibration_strategies.py b/src/web/api/v2/schemas/calibration_strategies.py
new file mode 100644
index 0000000..7a67a50
--- /dev/null
+++ b/src/web/api/v2/schemas/calibration_strategies.py
@@ -0,0 +1,83 @@
+# src/web/api/v2/schemas/calibration_strategies.py
+
+from typing import Any, Dict, List, Literal, Optional
+from pydantic import BaseModel, Field
+
+from .calibration_risk import StopConfigSchema, RiskConfigSchema, GlobalRiskRulesSchema
+
+
+class WalkForwardConfigSchema(BaseModel):
+ train_days: int = Field(..., gt=0)
+ test_days: int = Field(..., gt=0)
+ step_days: Optional[int] = Field(None, gt=0) # if None => step = test_days
+
+
+class OptimizationConfigSchema(BaseModel):
+ optimizer_metric: str = Field("sharpe_ratio")
+ max_combinations: int = Field(500, gt=0)
+ min_trades_train: int = Field(30, ge=0)
+ min_trades_test: int = Field(10, ge=0)
+
+
+class StrategySelectionSchema(BaseModel):
+ strategy_id: str
+ param_grid: Dict[str, List[Any]]
+
+
+class CalibrationStrategiesInspectRequest(BaseModel):
+ symbol: str
+ timeframe: str
+
+ # snapshot from Step 2 (closed)
+ stop: StopConfigSchema
+ risk: RiskConfigSchema
+ global_rules: GlobalRiskRulesSchema
+ account_equity: float = Field(..., gt=0)
+
+ strategies: List[StrategySelectionSchema]
+ wf: WalkForwardConfigSchema
+ optimization: OptimizationConfigSchema
+
+ commission: float = Field(0.001, ge=0)
+ slippage: float = Field(0.0005, ge=0)
+
+
+class WindowRowSchema(BaseModel):
+ window: int
+ train_start: str
+ train_end: str
+ test_start: str
+ test_end: str
+
+ return_pct: float
+ sharpe: float
+ max_dd_pct: float
+ trades: int
+ params: Dict[str, Any]
+
+
+class StrategyRunResultSchema(BaseModel):
+ strategy_id: str
+ status: Literal["ok", "warning", "fail"]
+ message: str
+
+ n_windows: int
+ oos_final_equity: float
+ oos_total_return_pct: float
+ oos_max_dd_worst_pct: float
+ degradation_sharpe: Optional[float] = None
+
+ windows: List[WindowRowSchema]
+
+
+class CalibrationStrategiesInspectResponse(BaseModel):
+ valid: bool
+ status: Literal["ok", "warning", "fail"]
+ checks: Dict[str, Any]
+ message: str
+
+ results: List[StrategyRunResultSchema]
+
+
+class CalibrationStrategiesValidateResponse(CalibrationStrategiesInspectResponse):
+ series: Dict[str, Any]
diff --git a/src/web/ui/v2/static/js/pages/calibration_strategies.js b/src/web/ui/v2/static/js/pages/calibration_strategies.js
new file mode 100644
index 0000000..00334f9
--- /dev/null
+++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js
@@ -0,0 +1,595 @@
+// src/web/ui/v2/static/js/pages/calibration_strategies.js
+
+console.log("[calibration_strategies] script loaded ✅", new Date().toISOString());
+
+// =================================================
+// WIZARD NAVIGATION
+// =================================================
+
+function enableNextStep() {
+ const btn = document.getElementById("next-step-btn");
+ if (!btn) return;
+ btn.classList.remove("btn-outline-secondary");
+ btn.classList.add("btn-outline-primary");
+ btn.setAttribute("aria-disabled", "false");
+}
+
+function disableNextStep() {
+ const btn = document.getElementById("next-step-btn");
+ if (!btn) return;
+ btn.classList.remove("btn-outline-primary");
+ btn.classList.add("btn-outline-secondary");
+ btn.setAttribute("aria-disabled", "true");
+}
+
+// =================================================
+// UTILS
+// =================================================
+
+function loadContextFromLocalStorage() {
+ const symbol = localStorage.getItem("calibration.symbol");
+ const timeframe = localStorage.getItem("calibration.timeframe");
+ if (symbol) setVal("symbol", symbol);
+ if (timeframe) setVal("timeframe", timeframe);
+
+ // Step 2 snapshot (if stored)
+ const stop_type = localStorage.getItem("calibration.stop.type");
+ const stop_fraction = localStorage.getItem("calibration.stop.stop_fraction");
+ const atr_period = localStorage.getItem("calibration.stop.atr_period");
+ const atr_multiplier = localStorage.getItem("calibration.stop.atr_multiplier");
+ const risk_fraction = localStorage.getItem("calibration.risk.risk_fraction");
+ const max_position_fraction = localStorage.getItem("calibration.risk.max_position_fraction");
+ const max_drawdown_pct = localStorage.getItem("calibration.rules.max_drawdown_pct");
+ const daily_loss_limit_pct = localStorage.getItem("calibration.rules.daily_loss_limit_pct");
+ const max_consecutive_losses = localStorage.getItem("calibration.rules.max_consecutive_losses");
+ const cooldown_bars = localStorage.getItem("calibration.rules.cooldown_bars");
+ const account_equity = localStorage.getItem("calibration.account_equity");
+
+ if (account_equity) setVal("account_equity", account_equity);
+
+ if (stop_type) setVal("stop_type", stop_type);
+ if (stop_fraction) setVal("stop_fraction", stop_fraction);
+ if (atr_period) setVal("atr_period", atr_period);
+ if (atr_multiplier) setVal("atr_multiplier", atr_multiplier);
+ if (risk_fraction) setVal("risk_fraction", risk_fraction);
+ if (max_position_fraction) setVal("max_position_fraction", max_position_fraction);
+ if (max_drawdown_pct) setVal("max_drawdown_pct", max_drawdown_pct);
+ if (daily_loss_limit_pct) setVal("daily_loss_limit_pct", daily_loss_limit_pct);
+ if (max_consecutive_losses) setVal("max_consecutive_losses", max_consecutive_losses);
+ if (cooldown_bars) setVal("cooldown_bars", cooldown_bars);
+}
+
+function buildPayload() {
+ const symbol = str("symbol");
+ const timeframe = str("timeframe");
+ const stopType = str("stop_type");
+
+ if (!symbol || !timeframe) {
+ throw new Error("symbol/timeframe missing");
+ }
+
+ const stop = { type: stopType };
+
+ if (stopType === "fixed" || stopType === "trailing") {
+ stop.stop_fraction = (num("stop_fraction") ?? 1.0) / 100;
+ }
+ if (stopType === "atr") {
+ stop.atr_period = num("atr_period") ?? 14;
+ stop.atr_multiplier = num("atr_multiplier") ?? 3.0;
+ }
+
+ const risk_fraction = (num("risk_fraction") ?? 1.0) / 100;
+ const max_position_fraction = (num("max_position_fraction") ?? 95) / 100;
+
+ const global_rules = {
+ max_drawdown_pct: (num("max_drawdown_pct") ?? 20) / 100,
+ daily_loss_limit_pct: num("daily_loss_limit_pct") ? num("daily_loss_limit_pct") / 100 : null,
+ max_consecutive_losses: num("max_consecutive_losses"),
+ cooldown_bars: num("cooldown_bars"),
+ };
+
+ const wf_train_days = num("wf_train_days") ?? 120;
+ const wf_test_days = num("wf_test_days") ?? 30;
+ const wf_step_days = num("wf_step_days");
+
+ const strategies = collectSelectedStrategies();
+
+ return {
+ symbol,
+ timeframe,
+ account_equity: num("account_equity") ?? 10000,
+
+ stop,
+ risk: {
+ risk_fraction,
+ max_position_fraction,
+ },
+ global_rules,
+
+ strategies,
+ wf: {
+ train_days: wf_train_days,
+ test_days: wf_test_days,
+ step_days: wf_step_days,
+ },
+ optimization: {
+ optimizer_metric: str("opt_metric") ?? "sharpe_ratio",
+ max_combinations: num("opt_max_combinations") ?? 300,
+ min_trades_train: num("opt_min_trades_train") ?? 30,
+ min_trades_test: num("opt_min_trades_test") ?? 10,
+ },
+
+ commission: num("commission") ?? 0.001,
+ slippage: num("slippage") ?? 0.0005,
+ };
+}
+
+function collectSelectedStrategies() {
+ const items = document.querySelectorAll("[data-strategy-item]");
+ const out = [];
+
+ items.forEach((node) => {
+ const checkbox = node.querySelector("input[type=checkbox]");
+ if (!checkbox || !checkbox.checked) return;
+
+ const sid = checkbox.getAttribute("data-strategy-id");
+ const textarea = node.querySelector("textarea");
+ let grid = {};
+ if (textarea && textarea.value.trim()) {
+ try {
+ grid = JSON.parse(textarea.value);
+ } catch (e) {
+ throw new Error(`Invalid JSON param_grid for ${sid}: ${e.message}`);
+ }
+ }
+
+ out.push({ strategy_id: sid, param_grid: grid });
+ });
+
+ if (out.length === 0) {
+ throw new Error("Select at least 1 strategy");
+ }
+ return out;
+}
+
+async function fetchAvailableStrategies() {
+ const res = await fetch("/api/v2/calibration/strategies/available");
+ const data = await res.json();
+ return data.strategies || [];
+}
+
+function num(id) {
+ const el = document.getElementById(id);
+ if (!el) return null;
+ const val = el.value;
+ if (val === "" || val === null || val === undefined) return null;
+ const n = Number(val);
+ return Number.isFinite(n) ? n : null;
+}
+
+function str(id) {
+ const el = document.getElementById(id);
+ if (!el) return null;
+ const v = el.value;
+ return v === null || v === undefined ? null : String(v);
+}
+
+function setVal(id, value) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.value = value ?? "";
+}
+
+// =================================================
+// PROGRESS BAR
+// =================================================
+
+function startWF() {
+ document
+ .getElementById("wf_progress_card")
+ .classList.remove("d-none");
+
+ document.getElementById("wfProgressBar").style.width = "0%";
+ document.getElementById("wfProgressBar").innerText = "0%";
+}
+
+async function pollStatus(jobId) {
+ const interval = setInterval(async () => {
+ const res = await fetch(`/api/v2/calibration/strategies/status/${jobId}`);
+ const data = await res.json();
+
+ const bar = document.getElementById("wfProgressBar");
+ bar.style.width = data.progress + "%";
+ bar.innerText = data.progress + "%";
+
+ if (data.status === "done") {
+ clearInterval(interval);
+ bar.classList.remove("progress-bar-animated");
+ console.log("WF finished");
+ }
+ }, 1000);
+}
+
+// =================================================
+// RENDER RESULTS
+// =================================================
+
+function renderStrategiesList(strategies) {
+ const list = document.getElementById("strategies_list");
+ if (!list) return;
+
+ list.innerHTML = "";
+
+ strategies.forEach((s) => {
+ const col = document.createElement("div");
+ col.className = "col-12 col-lg-6";
+ col.setAttribute("data-strategy-item", "1");
+
+ const defaultGrid = s.default_grid || {};
+ const defaultGridText = JSON.stringify(defaultGrid, null, 2);
+
+ col.innerHTML = `
+
+
+
+
+
+
+
+
Tip: usa listas. Ej: {"fast":[10,20],"slow":[50,100]}
+
+
+
+ `;
+
+ list.appendChild(col);
+ });
+}
+
+function setBadge(status) {
+ const badge = document.getElementById("strategies_status_badge");
+ if (!badge) return;
+
+ badge.classList.remove("bg-secondary", "bg-success", "bg-warning", "bg-danger");
+ badge.classList.add(
+ status === "ok" ? "bg-success" : status === "warning" ? "bg-warning" : status === "fail" ? "bg-danger" : "bg-secondary"
+ );
+ badge.textContent = status ? status.toUpperCase() : "—";
+}
+
+function renderResultsTable(data) {
+ const wrap = document.getElementById("strategies_table_wrap");
+ if (!wrap) return;
+
+ const rows = [];
+ (data.results || []).forEach((r) => {
+ rows.push(`
+
+ | ${r.strategy_id} |
+ ${r.status} |
+ ${r.n_windows} |
+ ${Number(r.oos_total_return_pct).toFixed(2)}% |
+ ${Number(r.oos_max_dd_worst_pct).toFixed(2)}% |
+ ${Number(r.oos_final_equity).toFixed(2)} |
+ ${r.message || ""} |
+
+ `);
+ });
+
+ wrap.innerHTML = `
+
+
+
+
+ | Strategy |
+ Status |
+ Windows |
+ OOS return |
+ Worst DD |
+ Final equity |
+ Message |
+
+
+
+ ${rows.join("")}
+
+
+
+ `;
+}
+
+function populatePlotSelector(data) {
+ const sel = document.getElementById("plot_strategy_select");
+ if (!sel) return;
+
+ sel.innerHTML = "";
+ const ids = Object.keys((data.series && data.series.strategies) ? data.series.strategies : {});
+ ids.forEach((sid) => {
+ const opt = document.createElement("option");
+ opt.value = sid;
+ opt.textContent = sid;
+ sel.appendChild(opt);
+ });
+
+ sel.onchange = () => renderPlotsForSelected(data);
+
+ if (ids.length > 0) {
+ sel.value = ids[0];
+ }
+}
+
+function renderPlotsForSelected(data) {
+ const sel = document.getElementById("plot_strategy_select");
+ const sid = sel ? sel.value : null;
+ if (!sid) return;
+
+ const s = data.series?.strategies?.[sid];
+ if (!s) return;
+
+ const equity = s.window_equity || [];
+ const returns = s.window_returns_pct || [];
+ const xEq = [...Array(equity.length).keys()];
+ const xRet = [...Array(returns.length).keys()].map((i) => i + 1);
+
+ Plotly.newPlot("plot_equity", [
+ { x: xEq, y: equity, type: "scatter", mode: "lines", name: "Equity (OOS)" },
+ ], {
+ title: `WF OOS equity · ${sid}`,
+ margin: { t: 40, l: 50, r: 20, b: 40 },
+ xaxis: { title: "Window index" },
+ yaxis: { title: "Equity" },
+ }, { displayModeBar: false });
+
+ Plotly.newPlot("plot_returns", [
+ { x: xRet, y: returns, type: "bar", name: "Return % (per window)" },
+ ], {
+ title: `WF returns per window · ${sid}`,
+ margin: { t: 40, l: 50, r: 20, b: 40 },
+ xaxis: { title: "Window" },
+ yaxis: { title: "Return (%)" },
+ }, { displayModeBar: false });
+}
+
+function renderValidateResponse(data) {
+
+ // -------------------------------
+ // 1️⃣ Badge + message
+ // -------------------------------
+ const badge = document.getElementById("strategies_status_badge");
+ const msg = document.getElementById("strategies_message");
+
+ badge.textContent = data.status ?? "—";
+
+ badge.className = "badge";
+ if (data.status === "ok") badge.classList.add("bg-success");
+ else if (data.status === "warning") badge.classList.add("bg-warning");
+ else badge.classList.add("bg-danger");
+
+ msg.textContent = data.message ?? "";
+
+ // -------------------------------
+ // 2️⃣ Debug JSON
+ // -------------------------------
+ document.getElementById("strategies_debug").textContent =
+ JSON.stringify(data, null, 2);
+
+ // -------------------------------
+ // 3️⃣ Plots (primera estrategia por ahora)
+ // -------------------------------
+ if (data.series && data.series.strategies) {
+ const keys = Object.keys(data.series.strategies);
+ if (keys.length > 0) {
+ const s = data.series.strategies[keys[0]];
+
+ Plotly.newPlot("plot_equity", [{
+ y: s.window_equity,
+ type: "scatter",
+ mode: "lines",
+ name: "Equity"
+ }], { margin: { t: 20 } });
+
+ Plotly.newPlot("plot_returns", [{
+ y: s.window_returns_pct,
+ type: "bar",
+ name: "Window returns %"
+ }], { margin: { t: 20 } });
+ }
+ }
+
+ // -------------------------------
+ // 4️⃣ Table
+ // -------------------------------
+ const wrap = document.getElementById("strategies_table_wrap");
+ wrap.innerHTML = "";
+
+ if (data.results) {
+ let html = `
+
+
+ | Strategy |
+ Status |
+ OOS Return % |
+ OOS Max DD % |
+ Windows |
+
+
+ `;
+
+ for (const r of data.results) {
+ html += `
+
+ | ${r.strategy_id} |
+ ${r.status} |
+ ${r.oos_total_return_pct?.toFixed(2)} |
+ ${r.oos_max_dd_worst_pct?.toFixed(2)} |
+ ${r.n_windows} |
+
+ `;
+ }
+
+ html += "
";
+ wrap.innerHTML = html;
+ }
+}
+
+async function validateStrategies() {
+ console.log("[calibration_strategies] validateStrategies() START");
+
+ const bar = document.getElementById("wfProgressBar");
+ const txt = document.getElementById("wf_progress_text");
+
+ const setProgress = (pct, text) => {
+ const p = Math.max(0, Math.min(100, Number(pct || 0)));
+ bar.style.width = `${p}%`;
+ bar.textContent = `${p}%`;
+ if (text) txt.textContent = text;
+ };
+
+ try {
+ // 0) Reset UI
+ setProgress(0, "Starting...");
+
+ // 1) Construye payload igual que antes (usa tu función existente)
+ const payload = buildPayload(); // <-- NO CAMBIES tu builder, reutilízalo
+
+ // 2) Arranca job async
+ const runResp = await fetch("/api/v2/calibration/strategies/run", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!runResp.ok) {
+ const errText = await runResp.text();
+ throw new Error(`Run failed: ${runResp.status} ${errText}`);
+ }
+
+ const { job_id } = await runResp.json();
+ if (!job_id) throw new Error("No job_id returned from /run");
+
+ // 3) Poll status
+ const pollEveryMs = 500;
+ const maxMinutes = 30;
+ const maxPolls = Math.ceil((maxMinutes * 60 * 1000) / pollEveryMs);
+
+ for (let i = 0; i < maxPolls; i++) {
+ await new Promise((r) => setTimeout(r, pollEveryMs));
+
+ const stResp = await fetch(`/api/v2/calibration/strategies/status/${job_id}`);
+ if (!stResp.ok) continue;
+
+ const st = await stResp.json();
+
+ const pct = st.progress ?? 0;
+ const cw = st.current_window ?? 0;
+ const tw = st.total_windows ?? 0;
+
+ const label =
+ tw > 0
+ ? `WF running... window ${cw}/${tw}`
+ : "WF running...";
+
+ setProgress(pct, label);
+
+ if (st.status === "done") {
+ setProgress(100, "WF completed ✅");
+
+ // 4) Renderiza resultados usando el MISMO renderer que usabas con /validate
+ // (ojo: el resultado viene dentro de st.result)
+ if (!st.result) throw new Error("Job done but no result in status payload");
+
+ renderValidateResponse(st.result); // <-- usa tu función existente de render (plots, tablas, etc.)
+
+ console.log("[calibration_strategies] validateStrategies() DONE ok");
+ return;
+ }
+
+ if (st.status === "unknown") {
+ setProgress(0, "Unknown job (server lost state?)");
+ break;
+ }
+ }
+
+ throw new Error("Timeout waiting for WF job to finish");
+
+ } catch (err) {
+ console.error(err);
+ // deja un estado visible
+ const txt = document.getElementById("wf_progress_text");
+ if (txt) txt.textContent = `Error: ${err.message}`;
+ console.log("[calibration_strategies] validateStrategies() DONE fail");
+ }
+}
+
+async function generateReport() {
+ console.log("[calibration_strategies] generateReport() START");
+
+ let payload;
+ try {
+ payload = buildPayload();
+ } catch (e) {
+ alert(e.message);
+ return;
+ }
+
+ const res = await fetch("/api/v2/calibration/strategies/report", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await res.json();
+
+ if (data.url) {
+ const viewer = document.getElementById("pdf_viewer_section");
+ const frame = document.getElementById("pdf_frame");
+ frame.src = data.url;
+ viewer.classList.remove("d-none");
+ viewer.scrollIntoView({ behavior: "smooth" });
+ } else {
+ alert("Failed to generate report");
+ }
+}
+
+function wireButtons() {
+ document.getElementById("validate_strategies_btn")?.addEventListener("click", validateStrategies);
+ document.getElementById("report_strategies_btn")?.addEventListener("click", generateReport);
+
+ document.getElementById("refresh_strategies_btn")?.addEventListener("click", async () => {
+ const strategies = await fetchAvailableStrategies();
+ renderStrategiesList(strategies);
+ });
+
+ document.getElementById("load_step2_btn")?.addEventListener("click", () => {
+ loadContextFromLocalStorage();
+ });
+
+ document.getElementById("close_pdf_btn")?.addEventListener("click", () => {
+ const viewer = document.getElementById("pdf_viewer_section");
+ const frame = document.getElementById("pdf_frame");
+ frame.src = "";
+ viewer.classList.add("d-none");
+ });
+}
+
+async function init() {
+ loadContextFromLocalStorage();
+ wireButtons();
+
+ const strategies = await fetchAvailableStrategies();
+ renderStrategiesList(strategies);
+
+ // Pre-select 1 strategy by default (moving_average) if exists
+ setTimeout(() => {
+ const first = document.querySelector('input[type=checkbox][data-strategy-id="moving_average"]');
+ if (first) first.checked = true;
+ }, 0);
+}
+
+init();
diff --git a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html
new file mode 100644
index 0000000..f5d251c
--- /dev/null
+++ b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html
@@ -0,0 +1,322 @@
+{% extends "layout.html" %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
Calibración · Paso 3 · Strategies
+
Optimización + Walk Forward (OOS)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tip: Symbol y timeframe se cargan desde Step 1 (localStorage). Si no aparecen, rellénalos manualmente.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Este snapshot se envía al backend para reproducibilidad y para que WF/optimizer use el mismo sizing/stop.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cada estrategia incluye un param_grid en JSON.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Waiting to start...
+
+
+
+
+
+
+
+
+
+
+
+
Run validation to see results.
+
+
+
+
+
+
+
+
+
+
+
+ Debug JSON
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}