diff --git a/src/calibration/optimization_inspector.py b/src/calibration/optimization_inspector.py new file mode 100644 index 0000000..02dc4ce --- /dev/null +++ b/src/calibration/optimization_inspector.py @@ -0,0 +1,362 @@ +# 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 + + +def _build_param_values(min_v: float, max_v: float, step: float) -> List[float]: + min_v = float(min_v) + max_v = float(max_v) + step = float(step) + + # Valor único si min == max + if min_v == max_v: + return [min_v] + + # Valor único si step <= 1 + if step <= 1: + return [min_v] + + values = [] + v = min_v + while v <= max_v: + values.append(v) + v += step + + return values + +# -------------------------------------------------- +# 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"]) + range_params = set(sel.parameters.keys()) + + + # 🔒 Validación estricta de parámetros + if range_params != valid_params: + msg = f"Parameter keys {range_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 + + # -------------------------------------------------- + # Convert ranges -> param_grid real + # -------------------------------------------------- + param_grid = {} + + for pname, prange in sel.parameters.items(): + values = _build_param_values( + min_v=prange.min, + max_v=prange.max, + step=prange.step, + ) + param_grid[pname] = values + + # 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=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/calibration/reports/optimization_report.py b/src/calibration/reports/optimization_report.py new file mode 100644 index 0000000..305f9d5 --- /dev/null +++ b/src/calibration/reports/optimization_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 index 25ef05e..02dc4ce 100644 --- a/src/calibration/strategies_inspector.py +++ b/src/calibration/strategies_inspector.py @@ -94,6 +94,27 @@ def _accumulate_equity(initial: float, returns_pct: List[float]) -> List[float]: return eq +def _build_param_values(min_v: float, max_v: float, step: float) -> List[float]: + min_v = float(min_v) + max_v = float(max_v) + step = float(step) + + # Valor único si min == max + if min_v == max_v: + return [min_v] + + # Valor único si step <= 1 + if step <= 1: + return [min_v] + + values = [] + v = min_v + while v <= max_v: + values.append(v) + v += step + + return values + # -------------------------------------------------- # Main # -------------------------------------------------- @@ -166,11 +187,13 @@ def inspect_strategies_config( strategy_class = entry["class"] valid_params = set(entry["params"]) - grid_params = set(sel.param_grid.keys()) + range_params = set(sel.parameters.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}" + if range_params != valid_params: + msg = f"Parameter keys {range_params} do not match expected {valid_params}" + results.append({ "strategy_id": sid, "status": "fail", @@ -185,6 +208,19 @@ def inspect_strategies_config( overall_status = "fail" continue + # -------------------------------------------------- + # Convert ranges -> param_grid real + # -------------------------------------------------- + param_grid = {} + + for pname, prange in sel.parameters.items(): + values = _build_param_values( + min_v=prange.min, + max_v=prange.max, + step=prange.step, + ) + param_grid[pname] = values + # Wrapper sizer class _CappedSizer(type(base_sizer)): def __init__(self, inner): @@ -212,7 +248,7 @@ def inspect_strategies_config( try: wf = WalkForwardValidator( strategy_class=strategy_class, - param_grid=sel.param_grid, + param_grid=param_grid, data=df, train_window=train_td, test_window=test_td, diff --git a/src/web/api/v2/main.py b/src/web/api/v2/main.py index 74bdddd..186d48e 100644 --- a/src/web/api/v2/main.py +++ b/src/web/api/v2/main.py @@ -12,6 +12,7 @@ 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 +from src.web.api.v2.routers.calibration_optimization import router as calibration_optimization_router # -------------------------------------------------- # Logging @@ -127,6 +128,17 @@ def create_app() -> FastAPI: "step": 3, }, ) + + @app.get("/calibration/optimization", response_class=HTMLResponse) + def calibration_risk_page(request: Request): + return templates.TemplateResponse( + "pages/calibration/calibration_optimization.html", + { + "request": request, + "page": "calibration", + "step": 4, + }, + ) # -------------------------------------------------- @@ -136,6 +148,7 @@ def create_app() -> FastAPI: 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) + app.include_router(calibration_optimization_router, prefix=api_prefix) return app diff --git a/src/web/api/v2/routers/calibration_optimization.py b/src/web/api/v2/routers/calibration_optimization.py new file mode 100644 index 0000000..44a86e9 --- /dev/null +++ b/src/web/api/v2/routers/calibration_optimization.py @@ -0,0 +1,206 @@ +# 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.optimization_inspector import ( + inspect_strategies_config, + list_available_strategies, +) +from src.calibration.reports.optimization_report import generate_strategies_report_pdf + +from ..schemas.calibration_optimization import ( + CalibrationStrategiesInspectRequest, + CalibrationStrategiesInspectResponse, + CalibrationStrategiesValidateResponse, +) + +logger = logging.getLogger("tradingbot.api.v2") + +router = APIRouter( + prefix="/calibration/optimization", + tags=["calibration"], +) + +WF_JOBS: Dict[str, Dict] = {} + +def get_storage() -> StorageManager: + return StorageManager.from_env() + + +@router.get("/catalog") +def strategy_catalog(): + strategies = list_available_strategies() + + # Añadimos defaults sugeridos + for s in strategies: + s["parameters_meta"] = [ + { + "name": p, + "type": "int", + "default_min": 10, + "default_max": 50, + "default_step": 10, + } + for p in s["params"] + ] + + return {"strategies": 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/routers/calibration_strategies.py b/src/web/api/v2/routers/calibration_strategies.py index 63e52ac..d0460c6 100644 --- a/src/web/api/v2/routers/calibration_strategies.py +++ b/src/web/api/v2/routers/calibration_strategies.py @@ -35,9 +35,24 @@ def get_storage() -> StorageManager: return StorageManager.from_env() -@router.get("/available") -def available_strategies(): - return {"strategies": list_available_strategies()} +@router.get("/catalog") +def strategy_catalog(): + strategies = list_available_strategies() + + # Añadimos defaults sugeridos + for s in strategies: + s["parameters_meta"] = [ + { + "name": p, + "type": "int", + "default_min": 10, + "default_max": 50, + "default_step": 10, + } + for p in s["params"] + ] + + return {"strategies": strategies} @router.post("/inspect", response_model=CalibrationStrategiesInspectResponse) def inspect_strategies( diff --git a/src/web/api/v2/schemas/calibration_optimization.py b/src/web/api/v2/schemas/calibration_optimization.py new file mode 100644 index 0000000..d1c9ad3 --- /dev/null +++ b/src/web/api/v2/schemas/calibration_optimization.py @@ -0,0 +1,89 @@ +# 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 ParameterRangeSchema(BaseModel): + min: float + max: float + step: float + + +class StrategySelectionSchema(BaseModel): + strategy_id: str + parameters: Dict[str, ParameterRangeSchema] + + +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/api/v2/schemas/calibration_strategies.py b/src/web/api/v2/schemas/calibration_strategies.py index 7a67a50..d1c9ad3 100644 --- a/src/web/api/v2/schemas/calibration_strategies.py +++ b/src/web/api/v2/schemas/calibration_strategies.py @@ -19,9 +19,15 @@ class OptimizationConfigSchema(BaseModel): min_trades_test: int = Field(10, ge=0) +class ParameterRangeSchema(BaseModel): + min: float + max: float + step: float + + class StrategySelectionSchema(BaseModel): strategy_id: str - param_grid: Dict[str, List[Any]] + parameters: Dict[str, ParameterRangeSchema] class CalibrationStrategiesInspectRequest(BaseModel): diff --git a/src/web/ui/v2/static/js/pages/calibration_optimization.js b/src/web/ui/v2/static/js/pages/calibration_optimization.js new file mode 100644 index 0000000..2f5d5b0 --- /dev/null +++ b/src/web/ui/v2/static/js/pages/calibration_optimization.js @@ -0,0 +1,1040 @@ +// src/web/ui/v2/static/js/pages/calibration_strategies.js + +console.log("[calibration_strategies] script loaded ✅", new Date().toISOString()); + +let STRATEGY_CATALOG = []; +let strategySlots = []; +const MAX_STRATEGIES = 10; + +// ================================================= +// 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 strategies = []; + + strategySlots.forEach((slot, index) => { + + if (!slot.strategy_id) return; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === slot.strategy_id + ); + + const parameters = {}; + + strategyMeta.params.forEach(paramName => { + + const min = parseFloat( + document.getElementById(`${paramName}_min_${index}`)?.value + ); + + const max = parseFloat( + document.getElementById(`${paramName}_max_${index}`)?.value + ); + + const step = parseFloat( + document.getElementById(`${paramName}_step_${index}`)?.value + ); + + parameters[paramName] = { + min: min, + max: max, + step: step + }; + }); + + strategies.push({ + strategy_id: slot.strategy_id, + parameters: parameters + }); + }); + + return strategies; +} + +async function fetchAvailableStrategies() { + const res = await fetch("/api/v2/calibration/strategies/catalog"); + 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 ?? ""; +} + +function loadFromStep2() { + + document.getElementById("risk_fraction").value = + localStorage.getItem("calibration.risk_fraction") ?? ""; + + document.getElementById("max_position_fraction").value = + localStorage.getItem("calibration.max_position_fraction") ?? ""; + + document.getElementById("stop_type").value = + localStorage.getItem("calibration.stop_type") ?? "fixed"; + + document.getElementById("stop_fraction").value = + localStorage.getItem("calibration.stop_fraction") ?? ""; + + document.getElementById("atr_period").value = + localStorage.getItem("calibration.atr_period") ?? ""; + + document.getElementById("atr_multiplier").value = + localStorage.getItem("calibration.atr_multiplier") ?? ""; + + document.getElementById("max_drawdown_pct").value = + localStorage.getItem("calibration.max_drawdown_pct") ?? ""; + + document.getElementById("daily_loss_limit_pct").value = + localStorage.getItem("calibration.daily_loss_limit_pct") ?? ""; + + document.getElementById("max_consecutive_losses").value = + localStorage.getItem("calibration.max_consecutive_losses") ?? ""; + + document.getElementById("cooldown_bars").value = + localStorage.getItem("calibration.cooldown_bars") ?? ""; + + // Forzar actualización de UI según stop heredado + setTimeout(() => { + updateStopUI(); + }, 0); + + console.log("[calibration_strategies] Parameters loaded from Step 2 ✅"); +} + +function updateStopUI() { + + const type = document.getElementById("stop_type").value; + + const stopFraction = document.getElementById("stop_fraction_group"); + const atrPeriod = document.getElementById("atr_group"); + const atrMultiplier = document.getElementById("atr_multiplier_group"); + + if (type === "fixed" || type === "trailing") { + stopFraction.classList.remove("d-none"); + atrPeriod.classList.add("d-none"); + atrMultiplier.classList.add("d-none"); + } + + if (type === "atr") { + stopFraction.classList.add("d-none"); + atrPeriod.classList.remove("d-none"); + atrMultiplier.classList.remove("d-none"); + } +} + +async function loadStrategyCatalog() { + + const res = await fetch("/api/v2/calibration/strategies/catalog"); + const data = await res.json(); + + STRATEGY_CATALOG = data.strategies; +} + +function addStrategySlot() { + + // Si ya hay un slot vacío al final, no crear otro + if (strategySlots.length > 0 && + strategySlots[strategySlots.length - 1].strategy_id === null) { + return; + } + + if (strategySlots.length >= MAX_STRATEGIES) return; + + const index = strategySlots.length; + + strategySlots.push({ + strategy_id: null, + parameters: {} + }); + + renderStrategySlot(index); +} + +function renderStrategySlot(index) { + + const container = document.getElementById("strategies_container"); + + const slot = document.createElement("div"); + slot.className = "card p-3"; + slot.id = `strategy_slot_${index}`; + + slot.innerHTML = ` +
+ + +
+ +
+
+ + Combinations: + 0 + +
+ `; + + container.appendChild(slot); + + document + .getElementById(`strategy_select_${index}`) + .addEventListener("change", (e) => { + onStrategySelected(index, e.target.value); + }); +} + +function onStrategySelected(index, strategyId) { + + if (!strategyId) { + removeStrategySlot(index); + return; + } + + strategySlots[index].strategy_id = strategyId; + + renderParametersOnly(index, strategyId); + + // Si es el último slot activo, añadir nuevo vacío + if (index === strategySlots.length - 1 && + strategySlots.length < MAX_STRATEGIES) { + + strategySlots.push({ strategy_id: null, parameters: {} }); + renderStrategySlot(strategySlots.length - 1); + } + + updateCombinationCounter(); +} + +function validateParameterInputs() { + + let valid = true; + + document.querySelectorAll(".param-input").forEach(input => { + input.classList.remove("is-invalid"); + }); + + strategySlots.forEach((slot, index) => { + + if (!slot.strategy_id) return; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === slot.strategy_id + ); + + strategyMeta.params.forEach(paramName => { + + const minEl = document.getElementById(`${paramName}_min_${index}`); + const maxEl = document.getElementById(`${paramName}_max_${index}`); + const stepEl = document.getElementById(`${paramName}_step_${index}`); + + const min = parseFloat(minEl?.value); + const max = parseFloat(maxEl?.value); + const step = parseFloat(stepEl?.value); + + if (max < min) { + maxEl.classList.add("is-invalid"); + valid = false; + } + + if (step <= 0) { + stepEl.classList.add("is-invalid"); + valid = false; + } + + }); + }); + + updateCombinationCounter(); + + return valid; +} + +function updateCombinationCounter() { + + let globalTotal = 1; + let hasAnyStrategy = false; + + strategySlots.forEach((slot, index) => { + + if (!slot.strategy_id) return; + + hasAnyStrategy = true; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === slot.strategy_id + ); + + let strategyTotal = 1; + + strategyMeta.params.forEach(paramName => { + + const min = parseFloat( + document.getElementById(`${paramName}_min_${index}`)?.value + ); + + const max = parseFloat( + document.getElementById(`${paramName}_max_${index}`)?.value + ); + + const step = parseFloat( + document.getElementById(`${paramName}_step_${index}`)?.value + ); + + if (isNaN(min) || isNaN(max) || isNaN(step)) return; + + if (min === max || step == 0) { + strategyTotal *= 1; + } else { + const count = Math.floor((max - min) / step) + 1; + strategyTotal *= Math.max(count, 1); + } + + }); + + const perStrategyEl = document.getElementById(`strategy_combo_${index}`); + if (perStrategyEl) { + perStrategyEl.textContent = strategyTotal; + } + + globalTotal *= strategyTotal; + }); + + if (!hasAnyStrategy) globalTotal = 0; + + const globalEl = document.getElementById("combination_counter"); + if (globalEl) globalEl.textContent = globalTotal; + + applyCombinationWarnings(globalTotal); + updateTimeEstimate(globalTotal); + + return globalTotal; +} + +function applyCombinationWarnings(total) { + + const maxComb = parseInt( + document.getElementById("opt_max_combinations")?.value || 0 + ); + + const counter = document.getElementById("combination_counter"); + if (!counter) return; + + counter.classList.remove("text-warning", "text-danger"); + + if (total > 10000) { + counter.classList.add("text-danger"); + } else if (maxComb && total > maxComb) { + counter.classList.add("text-warning"); + } +} + +function updateTimeEstimate(totalComb) { + + const trainDays = parseInt( + document.getElementById("wf_train_days")?.value || 0 + ); + + const testDays = parseInt( + document.getElementById("wf_test_days")?.value || 0 + ); + + const approxWindows = Math.max( + Math.floor(365 / testDays), + 1 + ); + + const operations = totalComb * approxWindows; + + // 0.003s por combinación (estimación conservadora) + const seconds = operations * 0.003; + + let label; + + if (seconds < 60) { + label = `~ ${seconds.toFixed(1)} sec`; + } else if (seconds < 3600) { + label = `~ ${(seconds / 60).toFixed(1)} min`; + } else { + label = `~ ${(seconds / 3600).toFixed(1)} h`; + } + + const el = document.getElementById("wf_time_estimate"); + if (el) el.textContent = label; +} + +function removeStrategySlot(index) { + + strategySlots.splice(index, 1); + + rerenderStrategySlots(); +} + +function rerenderStrategySlots() { + + const container = document.getElementById("strategies_container"); + container.innerHTML = ""; + + const currentStrategies = strategySlots + .filter(s => s.strategy_id !== null); + + strategySlots = []; + + currentStrategies.forEach((slotData, index) => { + + strategySlots.push({ + strategy_id: slotData.strategy_id, + parameters: {} + }); + + renderStrategySlot(index); + + const select = document.getElementById(`strategy_select_${index}`); + select.value = slotData.strategy_id; + + renderParametersOnly(index, slotData.strategy_id); + }); + + // Siempre añadir un slot vacío al final + if (strategySlots.length < MAX_STRATEGIES) { + strategySlots.push({ strategy_id: null, parameters: {} }); + renderStrategySlot(strategySlots.length - 1); + } + + updateCombinationCounter(); +} + +function renderParametersOnly(index, strategyId) { + + const paramsContainer = document.getElementById(`strategy_params_${index}`); + paramsContainer.innerHTML = ""; + + if (!strategyId) return; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === strategyId + ); + + if (!strategyMeta) return; + + strategyMeta.params.forEach(paramName => { + + const col = document.createElement("div"); + col.className = "col-md-4"; + + col.innerHTML = ` + +
+
+ Min + +
+
+ Max + +
+
+ Step + +
+
+ `; + + paramsContainer.appendChild(col); + }); +} + +// ================================================= +// 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 = ` +
+ + + + + + + + + + + + + + ${rows.join("")} + +
StrategyStatusWindowsOOS returnWorst DDFinal equityMessage
+
+ `; +} + +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 = ` + + + + + + + + + + `; + + for (const r of data.results) { + html += ` + + + + + + + + `; + } + + html += "
StrategyStatusOOS Return %OOS Max DD %Windows
${r.strategy_id}${r.status}${r.oos_total_return_pct?.toFixed(2)}${r.oos_max_dd_worst_pct?.toFixed(2)}${r.n_windows}
"; + 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; + }; + + if (!validateParameterInputs()) { + alert("Please fix parameter errors before running WF."); + return; + } + + + 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"); + }); +} + +function applyInheritedLock() { + const locked = document.getElementById("lock_inherited").checked; + const fields = document.querySelectorAll(".inherited-field"); + + fields.forEach(f => { + f.disabled = locked; + if (locked) { + f.classList.add("bg-light"); + } else { + f.classList.remove("bg-light"); + } + }); +} + +async function init() { + await loadStrategyCatalog(); + addStrategySlot(); + + loadContextFromLocalStorage(); + loadFromStep2(); + applyInheritedLock(); + + document.getElementById("stop_type") + .addEventListener("change", updateStopUI); + + 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); +} + +document.getElementById("lock_inherited") + .addEventListener("change", applyInheritedLock); + + +init(); diff --git a/src/web/ui/v2/static/js/pages/calibration_risk.js b/src/web/ui/v2/static/js/pages/calibration_risk.js index e8aa341..7e14fa5 100644 --- a/src/web/ui/v2/static/js/pages/calibration_risk.js +++ b/src/web/ui/v2/static/js/pages/calibration_risk.js @@ -89,6 +89,10 @@ async function inspectCalibrationRisk() { const data = await res.json(); console.log("[calibration_risk] inspect response:", data); + if (data.status === "ok" || data.status === "warning") { + persistRiskParametersForStep3(); + } + renderRiskResult(payload, data); // -------------------------------------------------- @@ -189,6 +193,10 @@ async function validateCalibrationRisk() { const data = await res.json(); console.log("[calibration_risk] inspect response:", data); + if (data.status === "ok" || data.status === "warning") { + persistRiskParametersForStep3(); + } + renderRiskResult(payload, data); // -------------------------------------------------- @@ -698,6 +706,13 @@ function num(id) { 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 buildRiskPayload() { const symbol = localStorage.getItem("calibration.symbol"); const timeframe = localStorage.getItem("calibration.timeframe"); @@ -742,6 +757,34 @@ function buildRiskPayload() { return payload; } +function persistRiskParametersForStep3() { + + try { + + const dataToPersist = { + risk_fraction: num("risk_fraction"), + max_position_fraction: num("max_position_fraction"), + stop_type: str("stop_type"), + stop_fraction: num("stop_fraction"), + atr_period: num("atr_period"), + atr_multiplier: num("atr_multiplier"), + max_drawdown_pct: num("max_drawdown_pct"), + daily_loss_limit_pct: num("daily_loss_limit_pct"), + max_consecutive_losses: num("max_consecutive_losses"), + cooldown_bars: num("cooldown_bars"), + }; + + Object.entries(dataToPersist).forEach(([key, value]) => { + localStorage.setItem(`calibration.${key}`, value ?? ""); + }); + + console.log("[calibration_risk] Parameters saved for Step 3 ✅"); + + } catch (err) { + console.error("[calibration_risk] Persist failed ❌", err); + } +} + // ================================================= // INIT // ================================================= diff --git a/src/web/ui/v2/static/js/pages/calibration_strategies.js b/src/web/ui/v2/static/js/pages/calibration_strategies.js index 00334f9..46c1cfb 100644 --- a/src/web/ui/v2/static/js/pages/calibration_strategies.js +++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js @@ -2,6 +2,10 @@ console.log("[calibration_strategies] script loaded ✅", new Date().toISOString()); +let STRATEGY_CATALOG = []; +let strategySlots = []; +const MAX_STRATEGIES = 10; + // ================================================= // WIZARD NAVIGATION // ================================================= @@ -125,35 +129,51 @@ function buildPayload() { } 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 strategies = []; - 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}`); - } - } + strategySlots.forEach((slot, index) => { - out.push({ strategy_id: sid, param_grid: grid }); + if (!slot.strategy_id) return; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === slot.strategy_id + ); + + const parameters = {}; + + strategyMeta.params.forEach(paramName => { + + const min = parseFloat( + document.getElementById(`${paramName}_min_${index}`)?.value + ); + + const max = parseFloat( + document.getElementById(`${paramName}_max_${index}`)?.value + ); + + const step = parseFloat( + document.getElementById(`${paramName}_step_${index}`)?.value + ); + + parameters[paramName] = { + min: min, + max: max, + step: step + }; + }); + + strategies.push({ + strategy_id: slot.strategy_id, + parameters: parameters + }); }); - if (out.length === 0) { - throw new Error("Select at least 1 strategy"); - } - return out; + return strategies; } async function fetchAvailableStrategies() { - const res = await fetch("/api/v2/calibration/strategies/available"); + const res = await fetch("/api/v2/calibration/strategies/catalog"); const data = await res.json(); return data.strategies || []; } @@ -180,6 +200,398 @@ function setVal(id, value) { el.value = value ?? ""; } +function loadFromStep2() { + + document.getElementById("risk_fraction").value = + localStorage.getItem("calibration.risk_fraction") ?? ""; + + document.getElementById("max_position_fraction").value = + localStorage.getItem("calibration.max_position_fraction") ?? ""; + + document.getElementById("stop_type").value = + localStorage.getItem("calibration.stop_type") ?? "fixed"; + + document.getElementById("stop_fraction").value = + localStorage.getItem("calibration.stop_fraction") ?? ""; + + document.getElementById("atr_period").value = + localStorage.getItem("calibration.atr_period") ?? ""; + + document.getElementById("atr_multiplier").value = + localStorage.getItem("calibration.atr_multiplier") ?? ""; + + document.getElementById("max_drawdown_pct").value = + localStorage.getItem("calibration.max_drawdown_pct") ?? ""; + + document.getElementById("daily_loss_limit_pct").value = + localStorage.getItem("calibration.daily_loss_limit_pct") ?? ""; + + document.getElementById("max_consecutive_losses").value = + localStorage.getItem("calibration.max_consecutive_losses") ?? ""; + + document.getElementById("cooldown_bars").value = + localStorage.getItem("calibration.cooldown_bars") ?? ""; + + // Forzar actualización de UI según stop heredado + setTimeout(() => { + updateStopUI(); + }, 0); + + console.log("[calibration_strategies] Parameters loaded from Step 2 ✅"); +} + +function updateStopUI() { + + const type = document.getElementById("stop_type").value; + + const stopFraction = document.getElementById("stop_fraction_group"); + const atrPeriod = document.getElementById("atr_group"); + const atrMultiplier = document.getElementById("atr_multiplier_group"); + + if (type === "fixed" || type === "trailing") { + stopFraction.classList.remove("d-none"); + atrPeriod.classList.add("d-none"); + atrMultiplier.classList.add("d-none"); + } + + if (type === "atr") { + stopFraction.classList.add("d-none"); + atrPeriod.classList.remove("d-none"); + atrMultiplier.classList.remove("d-none"); + } +} + +async function loadStrategyCatalog() { + + const res = await fetch("/api/v2/calibration/strategies/catalog"); + const data = await res.json(); + + STRATEGY_CATALOG = data.strategies; +} + +function addStrategySlot() { + + // Si ya hay un slot vacío al final, no crear otro + if (strategySlots.length > 0 && + strategySlots[strategySlots.length - 1].strategy_id === null) { + return; + } + + if (strategySlots.length >= MAX_STRATEGIES) return; + + const index = strategySlots.length; + + strategySlots.push({ + strategy_id: null, + parameters: {} + }); + + renderStrategySlot(index); +} + +function renderStrategySlot(index) { + + const container = document.getElementById("strategies_container"); + + const slot = document.createElement("div"); + slot.className = "card p-3"; + slot.id = `strategy_slot_${index}`; + + slot.innerHTML = ` +
+ + +
+ +
+
+ + Combinations: + 0 + +
+ `; + + container.appendChild(slot); + + document + .getElementById(`strategy_select_${index}`) + .addEventListener("change", (e) => { + onStrategySelected(index, e.target.value); + }); +} + +function onStrategySelected(index, strategyId) { + + if (!strategyId) { + removeStrategySlot(index); + return; + } + + strategySlots[index].strategy_id = strategyId; + + renderParametersOnly(index, strategyId); + + // Si es el último slot activo, añadir nuevo vacío + if (index === strategySlots.length - 1 && + strategySlots.length < MAX_STRATEGIES) { + + strategySlots.push({ strategy_id: null, parameters: {} }); + renderStrategySlot(strategySlots.length - 1); + } + + updateCombinationCounter(); +} + +function validateParameterInputs() { + + let valid = true; + + document.querySelectorAll(".param-input").forEach(input => { + input.classList.remove("is-invalid"); + }); + + strategySlots.forEach((slot, index) => { + + if (!slot.strategy_id) return; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === slot.strategy_id + ); + + strategyMeta.params.forEach(paramName => { + + const minEl = document.getElementById(`${paramName}_min_${index}`); + const maxEl = document.getElementById(`${paramName}_max_${index}`); + const stepEl = document.getElementById(`${paramName}_step_${index}`); + + const min = parseFloat(minEl?.value); + const max = parseFloat(maxEl?.value); + const step = parseFloat(stepEl?.value); + + if (max < min) { + maxEl.classList.add("is-invalid"); + valid = false; + } + + if (step <= 0) { + stepEl.classList.add("is-invalid"); + valid = false; + } + + }); + }); + + updateCombinationCounter(); + + return valid; +} + +function updateCombinationCounter() { + + let globalTotal = 1; + let hasAnyStrategy = false; + + strategySlots.forEach((slot, index) => { + + if (!slot.strategy_id) return; + + hasAnyStrategy = true; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === slot.strategy_id + ); + + let strategyTotal = 1; + + strategyMeta.params.forEach(paramName => { + + const min = parseFloat( + document.getElementById(`${paramName}_min_${index}`)?.value + ); + + const max = parseFloat( + document.getElementById(`${paramName}_max_${index}`)?.value + ); + + const step = parseFloat( + document.getElementById(`${paramName}_step_${index}`)?.value + ); + + if (isNaN(min) || isNaN(max) || isNaN(step)) return; + + if (min === max || step == 0) { + strategyTotal *= 1; + } else { + const count = Math.floor((max - min) / step) + 1; + strategyTotal *= Math.max(count, 1); + } + + }); + + const perStrategyEl = document.getElementById(`strategy_combo_${index}`); + if (perStrategyEl) { + perStrategyEl.textContent = strategyTotal; + } + + globalTotal *= strategyTotal; + }); + + if (!hasAnyStrategy) globalTotal = 0; + + const globalEl = document.getElementById("combination_counter"); + if (globalEl) globalEl.textContent = globalTotal; + + applyCombinationWarnings(globalTotal); + updateTimeEstimate(globalTotal); + + return globalTotal; +} + +function applyCombinationWarnings(total) { + + const maxComb = parseInt( + document.getElementById("opt_max_combinations")?.value || 0 + ); + + const counter = document.getElementById("combination_counter"); + if (!counter) return; + + counter.classList.remove("text-warning", "text-danger"); + + if (total > 10000) { + counter.classList.add("text-danger"); + } else if (maxComb && total > maxComb) { + counter.classList.add("text-warning"); + } +} + +function updateTimeEstimate(totalComb) { + + const trainDays = parseInt( + document.getElementById("wf_train_days")?.value || 0 + ); + + const testDays = parseInt( + document.getElementById("wf_test_days")?.value || 0 + ); + + const approxWindows = Math.max( + Math.floor(365 / testDays), + 1 + ); + + const operations = totalComb * approxWindows; + + // 0.003s por combinación (estimación conservadora) + const seconds = operations * 0.003; + + let label; + + if (seconds < 60) { + label = `~ ${seconds.toFixed(1)} sec`; + } else if (seconds < 3600) { + label = `~ ${(seconds / 60).toFixed(1)} min`; + } else { + label = `~ ${(seconds / 3600).toFixed(1)} h`; + } + + const el = document.getElementById("wf_time_estimate"); + if (el) el.textContent = label; +} + +function removeStrategySlot(index) { + + strategySlots.splice(index, 1); + + rerenderStrategySlots(); +} + +function rerenderStrategySlots() { + + const container = document.getElementById("strategies_container"); + container.innerHTML = ""; + + const currentStrategies = strategySlots + .filter(s => s.strategy_id !== null); + + strategySlots = []; + + currentStrategies.forEach((slotData, index) => { + + strategySlots.push({ + strategy_id: slotData.strategy_id, + parameters: {} + }); + + renderStrategySlot(index); + + const select = document.getElementById(`strategy_select_${index}`); + select.value = slotData.strategy_id; + + renderParametersOnly(index, slotData.strategy_id); + }); + + // Siempre añadir un slot vacío al final + if (strategySlots.length < MAX_STRATEGIES) { + strategySlots.push({ strategy_id: null, parameters: {} }); + renderStrategySlot(strategySlots.length - 1); + } + + updateCombinationCounter(); +} + +function renderParametersOnly(index, strategyId) { + + const paramsContainer = document.getElementById(`strategy_params_${index}`); + paramsContainer.innerHTML = ""; + + if (!strategyId) return; + + const strategyMeta = STRATEGY_CATALOG.find( + s => s.strategy_id === strategyId + ); + + if (!strategyMeta) return; + + strategyMeta.params.forEach(paramName => { + + const col = document.createElement("div"); + col.className = "col-md-4"; + + col.innerHTML = ` + +
+
+ Min + +
+
+ Max + +
+
+ Step + +
+
+ `; + + paramsContainer.appendChild(col); + }); +} + // ================================================= // PROGRESS BAR // ================================================= @@ -451,6 +863,12 @@ async function validateStrategies() { if (text) txt.textContent = text; }; + if (!validateParameterInputs()) { + alert("Please fix parameter errors before running WF."); + return; + } + + try { // 0) Reset UI setProgress(0, "Starting..."); @@ -578,10 +996,37 @@ function wireButtons() { }); } -async function init() { - loadContextFromLocalStorage(); - wireButtons(); +function applyInheritedLock() { + const locked = document.getElementById("lock_inherited").checked; + const fields = document.querySelectorAll(".inherited-field"); + fields.forEach(f => { + f.disabled = locked; + if (locked) { + f.classList.add("bg-light"); + } else { + f.classList.remove("bg-light"); + } + }); +} + +document.getElementById("lock_inherited") + .addEventListener("change", applyInheritedLock); + + +async function init() { + await loadStrategyCatalog(); + addStrategySlot(); + + loadContextFromLocalStorage(); + loadFromStep2(); + applyInheritedLock(); + + document.getElementById("stop_type") + .addEventListener("change", updateStopUI); + + wireButtons(); + const strategies = await fetchAvailableStrategies(); renderStrategiesList(strategies); diff --git a/src/web/ui/v2/templates/pages/calibration/calibration_optimization.html b/src/web/ui/v2/templates/pages/calibration/calibration_optimization.html new file mode 100644 index 0000000..71c912c --- /dev/null +++ b/src/web/ui/v2/templates/pages/calibration/calibration_optimization.html @@ -0,0 +1,366 @@ +{% extends "layout.html" %} + +{% block content %} +
+ + + + +
+ + + + +
+

Calibración · Paso 3 · Strategies

+
Optimización + Walk Forward (OOS)
+
+ + + + +
+ + + + +
+
+

Context

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Tip: Symbol y timeframe se cargan desde Step 1 (localStorage). Si no aparecen, rellénalos manualmente. +
+
+
+ + + + +
+
+

Risk & Stops(Step 2)

+ +
+ + +
+
+ +
+ + + + +

Risk Configuration

+ +
+
+ + +
+ +
+ + +
+
+ + + + +

Stop Configuration

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +

Global Rules

+ +
+
+ + +
+
+ + + + +

Optional Parameters

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + + + +
+
+

Walk-Forward & Optimization

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+
+ + + + +
+
+

Strategies

+
+ +
+
+
+
+
+
+ Total combinations + 0 +
+
+
+ + Estimated WF time: + ~ 0 sec + +
+
+ Cada estrategia incluye un param_grid en JSON. +
+
+
+ + + + +
+ + +
+ + + + +
+
+

Walk-Forward Progress

+
+
+ +
+
+ 0% +
+
+ +
+ Waiting to start... +
+ +
+
+ + + + +
+
+

Results

+
+ +
+
+
+
Run validation to see results.
+ +
+
+ + +
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+ Debug JSON +

+      
+
+
+ + + + +
+
+

Strategies Report (PDF)

+
+ +
+
+
+ +
+
+ +
+ + + +{% endblock %} diff --git a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html index f5d251c..350dc21 100644 --- a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html +++ b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html @@ -78,77 +78,109 @@ - +
-
-

Risk & Stops snapshot (Step 2)

-
- +
+

Risk & Stops(Step 2)

+ +
+ +
-
-
-
- - -
+
-
- - -
+ + + +

Risk Configuration

-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
+
+
+ +
-
- Este snapshot se envía al backend para reproducibilidad y para que WF/optimizer use el mismo sizing/stop. + +
+ +
+ + + + +

Stop Configuration

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +

Global Rules

+ +
+
+ + +
+
+ + + + +

Optional Parameters

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
@@ -216,7 +248,19 @@
-
+
+
+
+ Total combinations + 0 +
+
+
+ + Estimated WF time: + ~ 0 sec + +
Cada estrategia incluye un param_grid en JSON.