feat(calibration): Step 3 - Stategies preparado conceptualmente

This commit is contained in:
DaM
2026-02-14 13:47:08 +01:00
parent f4f4e8e5be
commit 4365366e7d
9 changed files with 1664 additions and 3 deletions

View File

@@ -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"<b>{r.get('strategy_id')}</b> — {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)

View File

@@ -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

View File

@@ -4,11 +4,13 @@ Optimizador de parámetros para estrategias
""" """
import os import os
import pandas as pd import pandas as pd
from typing import Dict, List, Any, Type from typing import Dict, List, Any, Type, Optional
from itertools import product from itertools import product
from ..utils.logger import log from ..utils.logger import log
from .engine import Engine from .engine import Engine
from .strategy import Strategy from .strategy import Strategy
from src.risk.sizing.base import PositionSizer
from src.risk.stops.base import StopLoss
class ParameterOptimizer: class ParameterOptimizer:
""" """
@@ -21,7 +23,10 @@ class ParameterOptimizer:
initial_capital: float = 10000, initial_capital: float = 10000,
commission: float = 0.001, commission: float = 0.001,
slippage: float = 0.0005, 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: Args:
strategy_class: Clase de estrategia (no instancia) strategy_class: Clase de estrategia (no instancia)
@@ -37,6 +42,9 @@ class ParameterOptimizer:
self.commission = commission self.commission = commission
self.slippage = slippage self.slippage = slippage
self.position_size = position_size self.position_size = position_size
self.position_sizer = position_sizer
self.stop_loss = stop_loss
self.max_combinations = max_combinations
self.results: List[Dict] = [] self.results: List[Dict] = []
@@ -63,6 +71,9 @@ class ParameterOptimizer:
param_values = list(param_grid.values()) param_values = list(param_grid.values())
combinations = list(product(*param_values)) combinations = list(product(*param_values))
if self.max_combinations is not None:
combinations = combinations[: int(self.max_combinations)]
total_tests = len(combinations) total_tests = len(combinations)
log.info(f"🔧 Iniciando optimización: {total_tests} combinaciones") log.info(f"🔧 Iniciando optimización: {total_tests} combinaciones")
@@ -87,7 +98,9 @@ class ParameterOptimizer:
initial_capital=self.initial_capital, initial_capital=self.initial_capital,
commission=self.commission, commission=self.commission,
slippage=self.slippage, 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) results = engine.run(self.data)

View File

@@ -3,6 +3,8 @@ import pandas as pd
from typing import List, Dict, Optional from typing import List, Dict, Optional
from src.core.optimizer import ParameterOptimizer from src.core.optimizer import ParameterOptimizer
from src.core.engine import Engine 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 from ..utils.logger import log
class WalkForwardValidator: class WalkForwardValidator:
@@ -28,6 +30,10 @@ class WalkForwardValidator:
position_size: float = 0.95, position_size: float = 0.95,
optimizer_metric: str = "sharpe_ratio", optimizer_metric: str = "sharpe_ratio",
verbose: bool = True, 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.strategy_class = strategy_class
self.param_grid = param_grid self.param_grid = param_grid
@@ -43,6 +49,11 @@ class WalkForwardValidator:
self.position_size = position_size self.position_size = position_size
self.optimizer_metric = optimizer_metric self.optimizer_metric = optimizer_metric
self.verbose = verbose 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 # Validaciones básicas
if not isinstance(self.data.index, pd.DatetimeIndex): if not isinstance(self.data.index, pd.DatetimeIndex):
@@ -157,6 +168,14 @@ class WalkForwardValidator:
for w in self.windows: for w in self.windows:
wid = w["window_id"] 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") log.info(f"▶️ WF #{wid} en ejecución")
train_data = self.data.loc[w["train_start"]:w["train_end"]] train_data = self.data.loc[w["train_start"]:w["train_end"]]
@@ -174,6 +193,9 @@ class WalkForwardValidator:
commission=self.commission, commission=self.commission,
slippage=self.slippage, slippage=self.slippage,
position_size=self.position_size, 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) opt_df = optimizer.optimize(self.param_grid)
@@ -194,6 +216,8 @@ class WalkForwardValidator:
commission=self.commission, commission=self.commission,
slippage=self.slippage, slippage=self.slippage,
position_size=self.position_size, position_size=self.position_size,
position_sizer=self.position_sizer,
stop_loss=self.stop_loss,
) )
test_results = engine.run(test_data) test_results = engine.run(test_data)

View File

@@ -11,6 +11,7 @@ import time
from .settings import settings 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_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_risk import router as calibration_risk_router
from src.web.api.v2.routers.calibration_strategies import router as calibration_strategies_router
# -------------------------------------------------- # --------------------------------------------------
# Logging # Logging
@@ -116,6 +117,17 @@ def create_app() -> FastAPI:
}, },
) )
@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,
},
)
# -------------------------------------------------- # --------------------------------------------------
# API routers (versionados) # API routers (versionados)
@@ -123,6 +135,7 @@ def create_app() -> FastAPI:
api_prefix = settings.api_prefix api_prefix = settings.api_prefix
app.include_router(calibration_data_router, prefix=api_prefix) app.include_router(calibration_data_router, prefix=api_prefix)
app.include_router(calibration_risk_router, prefix=api_prefix) app.include_router(calibration_risk_router, prefix=api_prefix)
app.include_router(calibration_strategies_router, prefix=api_prefix)
return app return app

View File

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

View File

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

View File

@@ -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 = `
<div class="card">
<div class="card-body">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" data-strategy-id="${s.strategy_id}">
<span class="form-check-label">
<b>${s.name}</b>
<span class="text-secondary ms-2">${(s.tags || []).join(" · ")}</span>
</span>
</label>
<div class="mt-3">
<label class="form-label">param_grid (JSON)</label>
<textarea class="form-control" rows="7" spellcheck="false">${defaultGridText}</textarea>
<div class="form-hint">Tip: usa listas. Ej: {"fast":[10,20],"slow":[50,100]}</div>
</div>
</div>
</div>
`;
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(`
<tr>
<td><b>${r.strategy_id}</b></td>
<td>${r.status}</td>
<td>${r.n_windows}</td>
<td>${Number(r.oos_total_return_pct).toFixed(2)}%</td>
<td>${Number(r.oos_max_dd_worst_pct).toFixed(2)}%</td>
<td>${Number(r.oos_final_equity).toFixed(2)}</td>
<td class="text-secondary">${r.message || ""}</td>
</tr>
`);
});
wrap.innerHTML = `
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr>
<th>Strategy</th>
<th>Status</th>
<th>Windows</th>
<th>OOS return</th>
<th>Worst DD</th>
<th>Final equity</th>
<th>Message</th>
</tr>
</thead>
<tbody>
${rows.join("")}
</tbody>
</table>
</div>
`;
}
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 = `<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Strategy</th>
<th>Status</th>
<th>OOS Return %</th>
<th>OOS Max DD %</th>
<th>Windows</th>
</tr>
</thead>
<tbody>`;
for (const r of data.results) {
html += `
<tr>
<td>${r.strategy_id}</td>
<td>${r.status}</td>
<td>${r.oos_total_return_pct?.toFixed(2)}</td>
<td>${r.oos_max_dd_worst_pct?.toFixed(2)}</td>
<td>${r.n_windows}</td>
</tr>
`;
}
html += "</tbody></table>";
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();

View File

@@ -0,0 +1,322 @@
{% extends "layout.html" %}
{% block content %}
<div class="container-xl">
<!-- ========================= -->
<!-- Wizard header -->
<!-- ========================= -->
<div class="d-flex align-items-center mb-4">
<!-- Back arrow -->
<div class="me-3">
<a href="/calibration/risk" class="btn btn-outline-secondary btn-icon">
<svg xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-left"
width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M15 6l-6 6l6 6"/>
</svg>
</a>
</div>
<div class="flex-grow-1 text-center">
<h2 class="mb-0">Calibración · Paso 3 · Strategies</h2>
<div class="text-secondary">Optimización + Walk Forward (OOS)</div>
</div>
<!-- Forward arrow (disabled until OK) -->
<div class="ms-3">
<a
id="next-step-btn"
href="#"
class="btn btn-outline-secondary btn-icon"
aria-disabled="true"
title="Next step not implemented yet"
>
<svg xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-right"
width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M9 6l6 6l-6 6"/>
</svg>
</a>
</div>
</div>
<!-- ========================= -->
<!-- Context -->
<!-- ========================= -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Context</h3>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Symbol</label>
<input id="symbol" class="form-control" placeholder="BTC/USDT">
</div>
<div class="col-md-4">
<label class="form-label">Timeframe</label>
<input id="timeframe" class="form-control" placeholder="1h">
</div>
<div class="col-md-4">
<label class="form-label">Account equity</label>
<input id="account_equity" class="form-control" type="number" step="0.01" value="10000">
</div>
</div>
<div class="mt-3 text-secondary">
Tip: Symbol y timeframe se cargan desde Step 1 (localStorage). Si no aparecen, rellénalos manualmente.
</div>
</div>
</div>
<!-- ========================= -->
<!-- Risk & Stops snapshot -->
<!-- ========================= -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Risk & Stops snapshot (Step 2)</h3>
<div class="card-actions">
<button id="load_step2_btn" class="btn btn-sm btn-outline-primary">Load from Step 2</button>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Stop type</label>
<select id="stop_type" class="form-select">
<option value="fixed">fixed</option>
<option value="trailing">trailing</option>
<option value="atr">atr</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Stop fraction (%)</label>
<input id="stop_fraction" class="form-control" type="number" step="0.01" value="1.0">
</div>
<div class="col-md-3">
<label class="form-label">ATR period</label>
<input id="atr_period" class="form-control" type="number" step="1" value="14">
</div>
<div class="col-md-3">
<label class="form-label">ATR multiplier</label>
<input id="atr_multiplier" class="form-control" type="number" step="0.1" value="3.0">
</div>
<div class="col-md-3">
<label class="form-label">Risk per trade (%)</label>
<input id="risk_fraction" class="form-control" type="number" step="0.01" value="1.0">
</div>
<div class="col-md-3">
<label class="form-label">Max position fraction (%)</label>
<input id="max_position_fraction" class="form-control" type="number" step="0.1" value="95">
</div>
<div class="col-md-3">
<label class="form-label">Max DD (%)</label>
<input id="max_drawdown_pct" class="form-control" type="number" step="0.1" value="20">
</div>
<div class="col-md-3">
<label class="form-label">Daily loss limit (%) (optional)</label>
<input id="daily_loss_limit_pct" class="form-control" type="number" step="0.1" value="">
</div>
<div class="col-md-3">
<label class="form-label">Max consecutive losses (optional)</label>
<input id="max_consecutive_losses" class="form-control" type="number" step="1" value="">
</div>
<div class="col-md-3">
<label class="form-label">Cooldown bars (optional)</label>
<input id="cooldown_bars" class="form-control" type="number" step="1" value="">
</div>
</div>
<div class="mt-3 text-secondary">
Este snapshot se envía al backend para reproducibilidad y para que WF/optimizer use el mismo sizing/stop.
</div>
</div>
</div>
<!-- ========================= -->
<!-- WF + Optimizer config -->
<!-- ========================= -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Walk-Forward & Optimization</h3>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Train days</label>
<input id="wf_train_days" class="form-control" type="number" step="1" value="120">
</div>
<div class="col-md-3">
<label class="form-label">Test days</label>
<input id="wf_test_days" class="form-control" type="number" step="1" value="30">
</div>
<div class="col-md-3">
<label class="form-label">Step days (optional)</label>
<input id="wf_step_days" class="form-control" type="number" step="1" value="">
</div>
<div class="col-md-3">
<label class="form-label">Metric</label>
<select id="opt_metric" class="form-select">
<option value="sharpe_ratio">sharpe_ratio</option>
<option value="total_return">total_return</option>
<option value="max_drawdown">max_drawdown</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Max combinations</label>
<input id="opt_max_combinations" class="form-control" type="number" step="1" value="300">
</div>
<div class="col-md-3">
<label class="form-label">Min trades (train)</label>
<input id="opt_min_trades_train" class="form-control" type="number" step="1" value="30">
</div>
<div class="col-md-3">
<label class="form-label">Min trades (test)</label>
<input id="opt_min_trades_test" class="form-control" type="number" step="1" value="10">
</div>
<div class="col-md-3">
<label class="form-label">Commission</label>
<input id="commission" class="form-control" type="number" step="0.0001" value="0.001">
</div>
<div class="col-md-3">
<label class="form-label">Slippage</label>
<input id="slippage" class="form-control" type="number" step="0.0001" value="0.0005">
</div>
</div>
</div>
</div>
<!-- ========================= -->
<!-- Strategy selection -->
<!-- ========================= -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Strategies</h3>
<div class="card-actions">
<button id="refresh_strategies_btn" class="btn btn-sm btn-outline-secondary">Refresh</button>
</div>
</div>
<div class="card-body">
<div id="strategies_list" class="row g-3"></div>
<div class="mt-3 text-secondary">
Cada estrategia incluye un <b>param_grid</b> en JSON.
</div>
</div>
</div>
<!-- ========================= -->
<!-- Actions -->
<!-- ========================= -->
<div class="d-flex gap-2 mb-4">
<button id="validate_strategies_btn" class="btn btn-primary">
Validate (WF)
</button>
<button id="report_strategies_btn" class="btn btn-outline-primary">
Generate PDF report
</button>
</div>
<!-- ========================= -->
<!-- Prograss Bar -->
<!-- ========================= -->
<div id="wf_progress_card" class="card mb-4">
<div class="card-header">
<h3 class="card-title">Walk-Forward Progress</h3>
</div>
<div class="card-body">
<div class="progress mb-2">
<div
id="wfProgressBar"
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 0%"
>
0%
</div>
</div>
<div id="wf_progress_text" class="text-secondary small">
Waiting to start...
</div>
</div>
</div>
<!-- ========================= -->
<!-- Results -->
<!-- ========================= -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Results</h3>
<div class="card-actions">
<span id="strategies_status_badge" class="badge bg-secondary"></span>
</div>
</div>
<div class="card-body">
<div id="strategies_message" class="mb-3 text-secondary">Run validation to see results.</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Strategy plot</label>
<select id="plot_strategy_select" class="form-select"></select>
</div>
</div>
<div class="mt-3">
<div id="plot_equity" style="height: 320px;"></div>
</div>
<div class="mt-3">
<div id="plot_returns" style="height: 320px;"></div>
</div>
<hr class="my-4">
<div id="strategies_table_wrap"></div>
<details class="mt-3">
<summary class="text-secondary">Debug JSON</summary>
<pre id="strategies_debug" class="mt-2" style="max-height: 300px; overflow:auto;"></pre>
</details>
</div>
</div>
<!-- ========================= -->
<!-- PDF Viewer -->
<!-- ========================= -->
<div id="pdf_viewer_section" class="card mb-4 d-none">
<div class="card-header">
<h3 class="card-title">Strategies Report (PDF)</h3>
<div class="card-actions">
<button id="close_pdf_btn" class="btn btn-sm btn-outline-secondary">Close</button>
</div>
</div>
<div class="card-body">
<iframe id="pdf_frame" style="width: 100%; height: 800px; border: none;"></iframe>
</div>
</div>
</div>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<script src="/static/js/pages/calibration_strategies.js"></script>
{% endblock %}